commit 76c94b76cc85c826113f26e021af0b38e3cdd2c2 Author: BJ Dweck Date: Tue Sep 1 14:18:32 2020 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..28ae4ee --- /dev/null +++ b/.gitignore @@ -0,0 +1,132 @@ +# Created by https://www.gitignore.io/api/java,gradle,intellij +# Edit at https://www.gitignore.io/?templates=java,gradle,intellij + +### Intellij ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +.idea/ + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +.idea/modules.xml +.idea/*.iml +.idea/modules + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +# JetBrains templates +**___jb_tmp___ + +### Intellij Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +.idea/sonarlint + +### Java ### +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* + +### Gradle ### +.gradle +build/ + +# Ignore Gradle GUI config +gradle-app.setting + +# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) +!gradle-wrapper.jar + +# Cache of project +.gradletasknamecache + +# # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 +# gradle/wrapper/gradle-wrapper.properties + +### Gradle Patch ### +**/build/ + +# End of https://www.gitignore.io/api/java,gradle,intellij \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e1eae32 --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +# Rudefox Cold + +Rudefox is a pair of command line tools for the creation and management of Bitcoin wallets. + ++ Use `rudefox-cold` on an air-gapped single-board computer, like a Raspberry Pi, to generate new wallets, obtain addresses and extended public keys from existing wallets and sign transactions offline. ++ Use `rudefox-hot` on a connected device to view your balance and utxo's and to create new, unsigned transactions. + +## Mnemonic Generation + +```bash +alice@cold.machine:~$ rudefox-cold mnemonic --interactive --dice-entropy --bits 128 +Input 50 dice rolls [1-6]: 234322343242422344161254151 +Input 23 more dice rolls [1-6]: 33116265515343114314456 +Mnemonic Sentence: mountain tilt wing silk rude fox almost volume wine media verify card +``` + +### Mnemonic Options + +```bash +usage: rudefox-cold mnemonic + -b,--bits <128|160|192|224|256> bits of entropy (default: 256) + -d,--dice-entropy use dice entropy source + -e,--events string representing events from entropy source + -h,--help display help message + -i,--interactive use interactive command line mode +``` + +## Xpub Export + +```bash +alice@cold.machine:~$ rudefox-cold wallet --sentence "stove prefer lunch collect small orphan wasp size beyond auction +guilt great" --passphrase apple +xpub6CKy5SECeJipZid8dF3bopoMGdRzd7hMJuPzMGesZCobrMSssZyASexzXuzRTPVLcqqdyAEZJKPMGvDthgZW2Z3mPHLohxEAVbkvGKAXjqx +``` +### QR Code Generation + +```bash-qrcode +alice@cold.machine:~$ rudefox-cold wallet --sentence "stove prefer lunch collect small orphan wasp size beyond auction +guilt great" --passphrase apple -q + +█▀▀▀▀▀█ ▄█ █▄█ ▀▄▀ ▀▄▄▄██▄ █▀▄ █▀▀▀▀▀█ +█ ███ █ ▀ ▀▄█▄▄▄▄██▄▄▄█ █ █▄ █ ███ █ +█ ▀▀▀ █ █▄▀ █▄█▄▄▄▀█▀▄ ▄▀▀█▀ ▄▄▄ █ ▀▀▀ █ +▀▀▀▀▀▀▀ █▄▀▄█ ▀▄█▄▀ ▀▄█ █ █▄▀▄▀ ▀ ▀▀▀▀▀▀▀ +██ █ ▀█ ███ ██ ▄█▄▀▄▀▀██ █▀▄▄▀▀▀ ▀▀▄ +██ ▀ █▀█▄▀█▄██▀ ▀ █▀ ▄▄▄▀██ █▀▀▀█ █▀ █▀ +█▀█▄ ▀█▀▀ ▄█▄█▄▀ █▀█ ██▀▄█▀ ▄██▀█ ▀ ▀ +▀██ ▀▄▄▀▄ ██ ▀██▄▀▄█▀▀█▄▀▄▀█▀▄▄▀▀█▄▀ █▀ +▀█▄▄ ▀▀▄ ▀▀▀▄▀ ██▄▄ ▀ ▀ █▀ ▀▄▀ █ █ █▀█▄ ▄ +███ █ ▀██ ▀▄▀█ ▄▄▀ ▄ ▄█▄▄ ▀▄██▄ ▀▄█▀▀ ▀▀ +▀█▄█▄ ▀ ▀▄▄█ ▀▄ █ ▄▀▀ ▄▄▀▄█▄█ ██▀▀▀▀▀ █ +▀ ▀▀▄ ▀▄█ ▄ ██▄▀▀██▄▄█▄ ▄█▄ █▀ ▄ ▄▀██ +█ ▀▄▄▄▀█▀██▄▄▀▄▄▀█▄ █ ▄██▀▄ ▄ ▄▄█▀█ █ ▄ +▀ ██ ▀▀▀▄ ▀██▄▄▄ ██ █▀█▀▄█ ▄▄▀▄▀▀ ▄█ ▀█▀ +▀▄▄█ █▀ █▄▀ ██▀█▄ █▄▀████▀▄██ ▄▄▀█ █▀█▀▀█ +▀ ▄▄ ▀▄▄▄▄▀█ ▄▀▀█████ ▀▀▀▄ █ ▄█▀█▀▀█▀▀▀ +▀ ▀▀ ▀▀▄▀▀▄▄▀█▀▄ ▄███ ▀██▀▀█ █▄█▀▀▀█▄ █ +█▀▀▀▀▀█ ▀▄█▄▀ ▄ █ ▀▄▄▄▄▄█▀ ▀▀ █ ▀ █ ▄█▄ +█ ███ █ ▄ █▄ ▄ █▄▄ ▀█ █ ▄ ▀██ █▀█▀█ ▀█▀ +█ ▀▀▀ █ ▄▄▀█ █ ▄ ▄█▄ ▄▄▀█▄▀ ▄█ █ ▀ █▀ █▀ +▀▀▀▀▀▀▀ ▀▀ ▀ ▀ ▀▀ ▀ ▀ ▀▀▀ ▀▀▀ +alice@cold.machine:~$ +``` + +### Wallet Options + +```bash +usage: rudefox-cold wallet + -h,--help display help message + -p,--passphrase optional seed passphrase + -s,--sentence mnemonic sentence + -q,--qrcode optional seed passphrase +``` + +# Pending Use Cases + +- verify seed in interactive mode \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..567f247 --- /dev/null +++ b/build.gradle @@ -0,0 +1,88 @@ +plugins { + id 'java' + id 'application' + id 'maven-publish' + id 'com.palantir.git-version' version '0.12.3' +} + +repositories { + + maven { + url "https://repo.rudefox.io/maven-public" + } + + mavenCentral() +} + +sourceCompatibility = 1.8 +targetCompatibility = 1.8 + +def isRelease = versionDetails().commitDistance == 0 + +group 'io.rudefox' +version isRelease ? gitVersion.call() : gitVersion.call() + "-SNAPSHOT" + +application { + applicationName 'rudefox-cold' + mainClassName = 'io.rudefox.cold.RudefoxCold' +} + +run { + standardInput = System.in +} + +def junitVersion = "5.6.2" + +tasks.withType(Test) { + useJUnitPlatform() +} + +dependencies { + compile 'io.rudefox:vixen:0.0.2' + compile 'info.picocli:picocli:4.0.4' + compile 'com.google.zxing:core:3.4.0' + testCompile 'com.bjdweck.test:commons-test:0.0.1' + testCompile "org.junit.jupiter:junit-jupiter-params:$junitVersion" + testImplementation "org.junit.jupiter:junit-jupiter-api:$junitVersion" + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junitVersion" +} + +publishing { + + publications { + jar(MavenPublication) { + from components.java + pom { + name = 'Rudefox Burrow' + description = 'Bitcoin offline seed generation and tools' + url = 'https://rudefox.io' + developers { + developer { + name = 'B.J. Dweck' + } + } + scm { + developerConnection = 'scm:git:https://git.rudefox.io/rudefox/burrow.git' + url = 'https://git.rudefox.io/rudefox/burrow/' + } + } + } + } + + repositories { + maven { + name = "rudefox" + + def repoBaseUrl = 'https://repo.rudefox.io/repository' + url = isRelease ? "${repoBaseUrl}/maven-releases/" : "${repoBaseUrl}/maven-snapshots/" + + credentials { + username System.getenv("RUDEFOX_MAVEN_USR") ?: + (project.hasProperty('rudefox_maven_username') ? project.rudefox_maven_username : "defualt") + + password System.getenv("RUDEFOX_MAVEN_PSW") ?: + (project.hasProperty('rudefox_maven_password') ? project.rudefox_maven_password : "defualt") + } + } + } +} diff --git a/gradle.properties.sample b/gradle.properties.sample new file mode 100644 index 0000000..d03d4cd --- /dev/null +++ b/gradle.properties.sample @@ -0,0 +1,15 @@ +# bitcoind connection info for integration testing +bitcoind_host=backend.sample.com +bitcoind_port=8332 +bitcoind_username=admin +bitcoind_password=l6RYSFfGHosjvHDfK_f7GSBkdGHk26fjHchZrJ87dSH= + +# public esplora URL for integration testing +esplora_url=http://backend.sample.com:8080 +esplora_testnet_url=http://backend.sample.com:8081/testnet/ + +# web server credentials for website deployment (use in the ':deployWebsite' task) +webserver_host=web.sample.com +webserver_user=http_admin +webserver_password=mycrazypassword +webserver_target_dir=/path/to/webroot/without-ending-slash \ No newline at end of file diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..0853944 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'burrow' \ No newline at end of file diff --git a/src/main/java/io/rudefox/cold/Dice8EntropyGenerator.java b/src/main/java/io/rudefox/cold/Dice8EntropyGenerator.java new file mode 100644 index 0000000..aa16b56 --- /dev/null +++ b/src/main/java/io/rudefox/cold/Dice8EntropyGenerator.java @@ -0,0 +1,115 @@ +package io.rudefox.cold; + +import com.bjdweck.bitcoin.mnemonic.Entropy; + +import java.util.Scanner; +import java.util.regex.Pattern; + +public class Dice8EntropyGenerator { + + public static final int DICE_PER_ROLL = 11; + + private final int targetBitsOfEntropy; + private final Scanner inputScanner; + + private Entropy entropy = new Entropy(); + + public Dice8EntropyGenerator(int targetBitsOfEntropy, Scanner inputScanner) { + + this.targetBitsOfEntropy = targetBitsOfEntropy; + this.inputScanner = inputScanner; + } + + Entropy generate() { + + entropy = new Entropy(); + + for (int rollSetCount = 0; entropy.getBitLength() < targetBitsOfEntropy; rollSetCount++) + doDiceRoll(rollSetCount); + + return entropy.truncate(targetBitsOfEntropy).appendChecksum(); + } + + private void doDiceRoll(int currentRollSet) { + + String eventString = scanNextRollSetString(); + + StringBuilder rollValuesLine = new StringBuilder("|"); + + for (int rollNumber = 0; rollNumber < DICE_PER_ROLL; rollNumber++) { + + int rollValue = Integer.parseInt(eventString.charAt(rollNumber) + ""); + + String formatString = + rollNumber == 3 || rollNumber == 7 ? " %d |" : " %d |"; + + rollValuesLine.append(String.format(formatString, rollValue)); + + entropy = entropy.appendBits(rollValue - 1, 3); + } + + System.out.printf("%n%s%n%s%n%s%n%n", + rollValuesLine, + getBitsLine(), + getSeedWordsLine(currentRollSet) + ); + } + + private String scanNextRollSetString() { + + String rollSetString = ""; + Pattern rollSetPattern = Pattern.compile(String.format("[1-8]{%d}", DICE_PER_ROLL)); + + while (!rollSetPattern.matcher(rollSetString).matches()) { + System.out.print("Input 11 x 8-sided dice rolls [1-8]: "); + rollSetString = inputScanner.next(); + } + + return rollSetString; + } + + private StringBuilder getBitsLine() { + + String entropyBitString = entropy.toString(); + + String currentRollSetBitString = + entropyBitString.substring(entropyBitString.length() - (DICE_PER_ROLL * 3)); + + StringBuilder rollSetBitsLine = new StringBuilder("|"); + for (int rollSetBitIndex = 0; rollSetBitIndex < currentRollSetBitString.length(); rollSetBitIndex++) { + + int entropyBitIndex = entropyBitString.length() - currentRollSetBitString.length() + rollSetBitIndex; + + if (entropyBitIndex < targetBitsOfEntropy) + rollSetBitsLine.append(currentRollSetBitString.charAt(rollSetBitIndex)); + else + rollSetBitsLine.append("-"); + + if (rollSetBitIndex == 32) rollSetBitsLine.append("|"); + else if (rollSetBitIndex % 3 == 2) rollSetBitsLine.append(" "); + else if (rollSetBitIndex % 11 == 10) rollSetBitsLine.append(" | "); + } + + return rollSetBitsLine; + } + + private String getSeedWordsLine(int rollSet) { + + int baseIndex = rollSet * 3; + + return String.format( + "|%-15s|%-17s|%-15s|", + getFormattedSeedWord(baseIndex), + getFormattedSeedWord(baseIndex + 1), + getFormattedSeedWord(baseIndex + 2)); + } + + private String getFormattedSeedWord(int index) { + + int lastWordIndex = (3 * targetBitsOfEntropy / 32) - 1; + + String seedWord = index != lastWordIndex ? entropy.getWord(index) : "CHECKWORD"; + + return String.format(" %2d. %s", index + 1, seedWord); + } +} \ No newline at end of file diff --git a/src/main/java/io/rudefox/cold/DiceEventBuffer.java b/src/main/java/io/rudefox/cold/DiceEventBuffer.java new file mode 100644 index 0000000..d388c79 --- /dev/null +++ b/src/main/java/io/rudefox/cold/DiceEventBuffer.java @@ -0,0 +1,47 @@ +package io.rudefox.cold; + +import com.bjdweck.bitcoin.mnemonic.Entropy; +import com.bjdweck.math.UnsignedInt; + +class DiceEventBuffer { + + private final int diceBase; + private final StringBuilder buffer; + private final int targetBitsOfEntropy; + + DiceEventBuffer(int targetBitsOfEntropy, int diceBase) { + + this.diceBase = diceBase; + this.targetBitsOfEntropy = targetBitsOfEntropy; + this.buffer = new StringBuilder(getRequiredEvents()); + } + + int getRequiredEvents() { + return (int) Math.ceil(this.targetBitsOfEntropy * Math.log(2) / Math.log(diceBase)); + } + + void appendEvents(String eventString) { + for (char inChar : eventString.toCharArray()) + if (inChar >= '1' && inChar <= '6' && events() < getRequiredEvents()) + append((char) (inChar - 1)); + } + + Entropy toEntropy() { + UnsignedInt entropy = new UnsignedInt(toString(), diceBase); + byte[] entropyBytes = entropy.getLowestOrderBits(this.targetBitsOfEntropy).toBigEndianByteArray(); + return Entropy.fromRawEntropy(entropyBytes); + } + + int events() { + return buffer.length(); + } + + private void append(char c) { + buffer.append(c); + } + + @Override + public String toString() { + return buffer.toString(); + } +} \ No newline at end of file diff --git a/src/main/java/io/rudefox/cold/MnemonicCommand.java b/src/main/java/io/rudefox/cold/MnemonicCommand.java new file mode 100644 index 0000000..98f8f7d --- /dev/null +++ b/src/main/java/io/rudefox/cold/MnemonicCommand.java @@ -0,0 +1,125 @@ +package io.rudefox.cold; + +import com.bjdweck.bitcoin.mnemonic.Entropy; +import picocli.CommandLine; + +import java.security.SecureRandom; +import java.util.Scanner; + +@CommandLine.Command(name = "mnemonic", description = "generate mnemonic sentence") +public class MnemonicCommand implements Runnable { + + private static final int DEFAULT_BITS_OF_ENTROPY = 256; + + private boolean isDiceEntropy() { + return entropyOptions != null && (entropyOptions.isDice6Entropy || entropyOptions.isDice8Entropy); + } + + private boolean isInteractiveMode() { + return entropyOptions != null && entropyOptions.eventMethod.isInteractiveMode; + } + + private boolean isDice6InteractiveMode() { + return isInteractiveMode() && entropyOptions.isDice6Entropy; + } + + private boolean isDice8InteractiveMode() { + return isInteractiveMode() && entropyOptions.isDice8Entropy; + } + + @CommandLine.ArgGroup(exclusive = false) + EntropyOptions entropyOptions; + + static class EntropyOptions { + + @CommandLine.Option(names = {"-6", "--dice6-entropy"}, + description = "use 6-sided dice entropy source") + boolean isDice6Entropy; + + @CommandLine.Option(names = {"-8", "--dice8-entropy"}, + description = "use 8-sided dice entropy source") + boolean isDice8Entropy; + + @CommandLine.ArgGroup(multiplicity = "1") + EventMethod eventMethod; + + static class EventMethod { + + @CommandLine.Option(names = {"-e", "--events"}, paramLabel = "[1-6]{100}|[1-8]{86}", + description = "string representing events from entropy source", + required = true) + String getEventString; + + @CommandLine.Option(names = {"-i", "--interactive"}, + description = "use interactive command line mode", + required = true) + boolean isInteractiveMode; + } + } + + @CommandLine.Option(names = {"-b", "--bits"}, defaultValue = DEFAULT_BITS_OF_ENTROPY+"", + description = "bits of entropy (default: 256)", + paramLabel = "128|160|192|224|256") + int targetBitsOfEntropy; + + public void run() { + + Entropy entropyBytes = getEntropy(); + + System.out.println(entropyBytes.toMnemonic().getSentence()); + } + + Entropy getEntropy() { + + if (!isDiceEntropy()) + return getGeneratedEntropy(); + + DiceEventBuffer diceEventBuffer; + + if (entropyOptions.isDice6Entropy) + diceEventBuffer = new DiceEventBuffer(this.targetBitsOfEntropy, 6); + else + diceEventBuffer = new DiceEventBuffer(this.targetBitsOfEntropy, 8); + + if (isDice6InteractiveMode()) + return getDice6EntropyInteractive(diceEventBuffer); + + if (isDice8InteractiveMode()) + return new Dice8EntropyGenerator(targetBitsOfEntropy, new Scanner(System.in)).generate(); + + diceEventBuffer.appendEvents(entropyOptions.eventMethod.getEventString); + return diceEventBuffer.toEntropy(); + } + + private Entropy getDice6EntropyInteractive(DiceEventBuffer diceEventBuffer) { + + int requiredEvents = diceEventBuffer.getRequiredEvents(); + + Scanner scanner = new Scanner(System.in); + + boolean firstIteration = true; + + while (diceEventBuffer.events() < requiredEvents) { + + int remainingEvents = requiredEvents - diceEventBuffer.events(); + System.out.print(String.format("Input %d %sdice rolls [1-6]: ", remainingEvents, (firstIteration ? "" : "more "))); + + String inputString = scanner.next(); + + diceEventBuffer.appendEvents(inputString); + + firstIteration = false; + } + + return diceEventBuffer.toEntropy(); + } + + private Entropy getGeneratedEntropy() { + + SecureRandom random = new SecureRandom(); + int byteLength = targetBitsOfEntropy / 8; + byte[] randomBytes = new byte[byteLength]; + random.nextBytes(randomBytes); + return Entropy.fromRawEntropy(randomBytes); + } +} \ No newline at end of file diff --git a/src/main/java/io/rudefox/cold/QRCode.java b/src/main/java/io/rudefox/cold/QRCode.java new file mode 100644 index 0000000..9d17ba3 --- /dev/null +++ b/src/main/java/io/rudefox/cold/QRCode.java @@ -0,0 +1,111 @@ +package io.rudefox.cold; + +import com.google.zxing.BarcodeFormat; +import com.google.zxing.EncodeHintType; +import com.google.zxing.WriterException; +import com.google.zxing.common.BitMatrix; +import com.google.zxing.qrcode.QRCodeWriter; +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +class QRCode { + + private static final String FALLBACK_SET_STRING = "##"; + + static String toQRCode(String data) { + + QRCodeWriter qrCodeWriter = new QRCodeWriter(); + Map hints = new HashMap<>(); + hints.put(EncodeHintType.MARGIN, 0); + hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.L); + + BitMatrix bitMatrix = null; + try { + bitMatrix = qrCodeWriter.encode(data, BarcodeFormat.QR_CODE, 0, 0, hints); + } catch (WriterException e) { + e.printStackTrace(); + } + + if (bitMatrix == null) + return "[Error Generating QR Code]"; + + if (charsetDoesNotSupportFullBlockChar()) + return bitMatrix.toString(FALLBACK_SET_STRING, " "); + + return toUTF8(bitMatrix); + } + + private static String toUTF8(BitMatrix bitMatrix) { + + char SPACE = '\u0020'; + char LOWER_BLOCK = '\u2584'; + char UPPER_BLOCK = '\u2580'; + char FULL_BLOCK = '\u2588'; + + int[] blockCodepoints = new int[] {SPACE, LOWER_BLOCK, UPPER_BLOCK, FULL_BLOCK}; + + StringBuilder qrCode = new StringBuilder(); + + for (int y = 0; y < bitMatrix.getHeight(); y += 2) { + + for (int x = 0; x < bitMatrix.getWidth(); x++) { + + int upper_bit = bitMatrix.get(x, y) ? 1 : 0; + int lower_bit = y+1 < bitMatrix.getHeight() && bitMatrix.get(x, y+1) ? 1 : 0; + + int index = upper_bit << 1 | lower_bit; + int blockCodepoint = blockCodepoints[index]; + + qrCode.appendCodePoint(blockCodepoint); + } + + if (y < bitMatrix.getHeight() - 1) + qrCode.append("\n"); + } + + return qrCode.toString(); + } + + private static boolean charsetDoesNotSupportFullBlockChar() { + + Charset charset = ServiceLocator.DEFAULT_CHARSET; + String charsetString = charset.toString(); + + return charset.equals(StandardCharsets.ISO_8859_1) || + charset.equals(StandardCharsets.US_ASCII) || + charsetString.equalsIgnoreCase("euc-jp") || + charsetString.equalsIgnoreCase("euc-kr") || + charsetString.equalsIgnoreCase("iso-2022-jp") || + charsetString.equalsIgnoreCase("iso-8859-10") || + charsetString.equalsIgnoreCase("iso-8859-13") || + charsetString.equalsIgnoreCase("iso-8859-14") || + charsetString.equalsIgnoreCase("iso-8859-15") || + charsetString.equalsIgnoreCase("iso-8859-16") || + charsetString.equalsIgnoreCase("iso-8859-2") || + charsetString.equalsIgnoreCase("iso-8859-3") || + charsetString.equalsIgnoreCase("iso-8859-4") || + charsetString.equalsIgnoreCase("iso-8859-5") || + charsetString.equalsIgnoreCase("iso-8859-6") || + charsetString.equalsIgnoreCase("iso-8859-7") || + charsetString.equalsIgnoreCase("iso-8859-8") || + charsetString.equalsIgnoreCase("iso-8859-8-i") || + charsetString.equalsIgnoreCase("macintosh") || + charsetString.equalsIgnoreCase("shift_jis") || + charsetString.equalsIgnoreCase("windows-1250") || + charsetString.equalsIgnoreCase("windows-1251") || + charsetString.equalsIgnoreCase("windows-1252") || + charsetString.equalsIgnoreCase("windows-1253") || + charsetString.equalsIgnoreCase("windows-1254") || + charsetString.equalsIgnoreCase("windows-1255") || + charsetString.equalsIgnoreCase("windows-1256") || + charsetString.equalsIgnoreCase("windows-1257") || + charsetString.equalsIgnoreCase("windows-1258") || + charsetString.equalsIgnoreCase("windows-874") || + charsetString.equalsIgnoreCase("x-mac-cyrillic") || + charsetString.equalsIgnoreCase("x-user-defined"); + } +} \ No newline at end of file diff --git a/src/main/java/io/rudefox/cold/RudefoxCold.java b/src/main/java/io/rudefox/cold/RudefoxCold.java new file mode 100644 index 0000000..402e9fa --- /dev/null +++ b/src/main/java/io/rudefox/cold/RudefoxCold.java @@ -0,0 +1,32 @@ +package io.rudefox.cold; + +import com.bjdweck.bitcoin.params.INetworkParameters; +import com.bjdweck.bitcoin.params.NetworkParameters; +import picocli.CommandLine; + +@CommandLine.Command( + name = "rudefox-cold", + synopsisSubcommandLabel = "COMMAND", + description = "Offline wallet tool", + subcommands = {MnemonicCommand.class, WalletCommand.class} +) +public class RudefoxCold implements Runnable { + + @CommandLine.Option(names = "--testnet", description = "run on Bitcoin Testnet (default: Mainnet)") + boolean testnet = false; + + public INetworkParameters getNetworkParameters() { + return testnet ? NetworkParameters.Testnet() : NetworkParameters.Mainnet(); + } + + public static void main(String[] args) { + new CommandLine(new RudefoxCold()).execute(args); + } + + @CommandLine.Spec + CommandLine.Model.CommandSpec commandSpec; + + public void run() { + throw new CommandLine.ParameterException(commandSpec.commandLine(), "Missing required subcommand"); + } +} \ No newline at end of file diff --git a/src/main/java/io/rudefox/cold/ServiceLocator.java b/src/main/java/io/rudefox/cold/ServiceLocator.java new file mode 100644 index 0000000..7167f24 --- /dev/null +++ b/src/main/java/io/rudefox/cold/ServiceLocator.java @@ -0,0 +1,8 @@ +package io.rudefox.cold; + +import java.nio.charset.Charset; + +public class ServiceLocator { + + public static Charset DEFAULT_CHARSET = Charset.defaultCharset(); +} diff --git a/src/main/java/io/rudefox/cold/WalletCommand.java b/src/main/java/io/rudefox/cold/WalletCommand.java new file mode 100644 index 0000000..447fb6b --- /dev/null +++ b/src/main/java/io/rudefox/cold/WalletCommand.java @@ -0,0 +1,137 @@ +package io.rudefox.cold; + +import com.bjdweck.bitcoin.address.BitcoinAddress; +import com.bjdweck.bitcoin.extendedkey.AddressChain; +import com.bjdweck.bitcoin.extendedkey.DerivationPath; +import com.bjdweck.bitcoin.extendedkey.KeyOrigin; +import com.bjdweck.bitcoin.extendedkey.PrivateExtendedKey; +import com.bjdweck.bitcoin.hdwallet.Bip44Purpose; +import com.bjdweck.bitcoin.hdwallet.Bip44SigningAccount; +import com.bjdweck.bitcoin.hdwallet.Bip44Wallet; +import com.bjdweck.bitcoin.mnemonic.Mnemonic; +import com.bjdweck.bitcoin.mnemonic.Seed; +import com.bjdweck.bitcoin.psbt.Psbt; +import com.bjdweck.bitcoin.transaction.scriptpubkey.ScriptPubKey; +import picocli.CommandLine; + +@SuppressWarnings("ALL") +@CommandLine.Command(name = "wallet", description = "BIP44 wallet operations") +public class WalletCommand implements Runnable { + + @CommandLine.ParentCommand + private RudefoxCold globalOptions; + + @CommandLine.Option(names = {"-s", "--sentence"}, description = "mnemonic sentence", required = true) + String sentence; + + @CommandLine.Option(names = {"-p", "--passphrase"}, description = "optional mnemonic passphrase") + String passphrase; + + Bip44Wallet getWallet() { + Seed seed = Mnemonic.fromSentence(sentence).generateSeed(passphrase); + return new Bip44Wallet(PrivateExtendedKey.fromSeed(seed), globalOptions.getNetworkParameters()); + } + + Bip44SigningAccount getAccount(Bip44Purpose purpose, int accountSeq) { + return getWallet().deriveAccount(purpose, accountSeq); + } + + @CommandLine.Command(name = "listxpub", description = "list xpub (default: SLIP-0132)") + public int listXpub( + + @CommandLine.Option(names = {"-q", "--qrcode"}, description = "output QR code") + boolean isQRCode, + + @CommandLine.Option(names = {"-p", "--purpose"}, description = "purpose", + paramLabel = "[P2PKH, P2SH_P2WPKH, P2WPKH, P2SH_MultiSigP2WSH, MultiSigP2WSH]", + defaultValue = "P2PKH") + Bip44Purpose purpose, + + @CommandLine.Option(names = {"-a", "--account"}, description = "account sequence number", + paramLabel = "n", defaultValue = "0") + int accountSeq) { + + Bip44SigningAccount bip44SigningAccount = getAccount(purpose, accountSeq); + String slip0132 = bip44SigningAccount.toXpubSlip0132Base58(); + String keyOrigin = bip44SigningAccount.getKeyOrigin().toString(); + + if (isQRCode) { + System.out.println(QRCode.toQRCode(slip0132)); + return 0; + } + + System.out.printf("[%s]%s%n", keyOrigin, slip0132); + return 0; + } + + @CommandLine.Command(name = "listaddress", description = "list account addresses") + public int listAddress( + + @CommandLine.Option(names = {"-p", "--purpose"}, description = "purpose", + paramLabel = "[P2PKH, P2SH_P2WPKH, P2WPKH, P2SH_MultiSigP2WSH, MultiSigP2WSH]", + defaultValue = "P2PKH") + Bip44Purpose purpose, + + @CommandLine.Option(names = {"-a", "--account"}, description = "account sequence number", + paramLabel = "", defaultValue = "0") + int accountSeq, + + @CommandLine.Option(names = {"-c", "--address-chain"}, description = "address chain", + paramLabel = "[INTERNAL|EXTERNAL]", defaultValue = "EXTERNAL") + AddressChain addressChain, + + @CommandLine.Option(names = {"-s", "--address-seq"}, description = "starting address sequence number", + paramLabel = "", defaultValue = "0") + int addressSeq, + + @CommandLine.Option(names = {"-n", "--count"}, description = "number of addresses to list", + paramLabel = "", defaultValue = "10") + int count) { + + Bip44SigningAccount account = getAccount(purpose, accountSeq); + + for (int i = 0; i < count; i++) { + + int seq = addressSeq + i; + + DerivationPath addressPath = DerivationPath.m().slash(addressChain.getSequence()).slash(seq); + KeyOrigin keyOrigin = account.getKeyOrigin().combineRelativePath(addressPath); + + ScriptPubKey scriptPubKey = account.deriveScriptPubKey(addressChain, seq); + BitcoinAddress address = scriptPubKey.getAddress(globalOptions.getNetworkParameters()); + + System.out.printf("%s: %s%n", keyOrigin, address); + } + + return 0; + } + + @CommandLine.Command(name = "signpsbt", description = "sign a PSBT") + public int signPsbt( + @CommandLine.Parameters() + String psbtBase64) { + + Psbt psbt = Psbt.fromBase64(psbtBase64); + + psbt.getValidationViolationMap(); + /* + Satoshi fee = psbt.getFee(); + Satoshi spendAmount = psbt.getSpendAmount(getWallet()); + BitcoinAddress spendAddress = psbt.getSpendAddress(getWallet()); + + System.out.printf("Confirm spend of %s to %s with a fee of %s", spendAmount.toBitcoinString(), spendAddress.getAddressString(), fee.toBitcoinString()); +*/ + psbt.signPsbt(getWallet()); + + System.out.println(psbt.toBase64()); + + return 0; + } + + @CommandLine.Spec + CommandLine.Model.CommandSpec commandSpec; + + public void run() { + throw new CommandLine.ParameterException(commandSpec.commandLine(), "Missing required wallet subcommand"); + } +} \ No newline at end of file diff --git a/src/test/java/io/rudefox/cold/mnemonic_8_sided_dice_tests.java b/src/test/java/io/rudefox/cold/mnemonic_8_sided_dice_tests.java new file mode 100644 index 0000000..a252b36 --- /dev/null +++ b/src/test/java/io/rudefox/cold/mnemonic_8_sided_dice_tests.java @@ -0,0 +1,64 @@ +package io.rudefox.cold; + +import com.bjdweck.test.CliTestFixture; +import org.junit.jupiter.api.Test; + +import java.io.UnsupportedEncodingException; + +class mnemonic_8_sided_dice_tests extends CliTestFixture { + + @Test + void with_arguments_interactive_8_sided_dice_should_generate_mnemonic_sentence() throws UnsupportedEncodingException { + + withArgs("mnemonic -i8 --bits 128"); + + expectedOutput("Input 11 x 8-sided dice rolls [1-8]: "); + provideInput("12345678123" + EOL); + + expectedOutput(EOL); + expectedOutput("| 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 1 | 2 | 3 |" + EOL); + expectedOutput("|000 001 010 01 | 1 100 101 110 1 | 11 000 001 010|" + EOL); + expectedOutput("| 1. ahead | 2. slight | 3. scout |" + EOL); + expectedOutput(EOL); + + expectedOutput("Input 11 x 8-sided dice rolls [1-8]: "); + provideInput("12345678123" + EOL); + + expectedOutput(EOL); + expectedOutput("| 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 1 | 2 | 3 |" + EOL); + expectedOutput("|000 001 010 01 | 1 100 101 110 1 | 11 000 001 010|" + EOL); + expectedOutput("| 4. ahead | 5. slight | 6. scout |" + EOL); + expectedOutput(EOL); + + expectedOutput("Input 11 x 8-sided dice rolls [1-8]: "); + provideInput("12345678123" + EOL); + + expectedOutput(EOL); + expectedOutput("| 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 1 | 2 | 3 |" + EOL); + expectedOutput("|000 001 010 01 | 1 100 101 110 1 | 11 000 001 010|" + EOL); + expectedOutput("| 7. ahead | 8. slight | 9. scout |" + EOL); + expectedOutput(EOL); + + expectedOutput("Input 11 x 8-sided dice rolls [1-8]: "); + provideInput("12345678123" + EOL); + + expectedOutput(EOL); + expectedOutput("| 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 1 | 2 | 3 |" + EOL); + expectedOutput("|000 001 010 01 | 1 100 101 110 1 | 11 000 00- ---|" + EOL); + expectedOutput("| 10. ahead | 11. slight | 12. CHECKWORD |" + EOL); + expectedOutput(EOL); + + expectedOutput("ahead slight scout ahead slight scout ahead slight scout ahead slight scan" + EOL); + + doMain(); + + verifyOutput(); + } + + private void doMain() { + + setInput(); + + RudefoxCold.main(getArgs()); + } +} \ No newline at end of file diff --git a/src/test/java/io/rudefox/cold/mnemonic_command_tests.java b/src/test/java/io/rudefox/cold/mnemonic_command_tests.java new file mode 100644 index 0000000..dd10a47 --- /dev/null +++ b/src/test/java/io/rudefox/cold/mnemonic_command_tests.java @@ -0,0 +1,71 @@ +package io.rudefox.cold; + +import com.bjdweck.test.CliTestFixture; +import org.junit.jupiter.api.Test; + +import java.io.UnsupportedEncodingException; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class mnemonic_command_tests extends CliTestFixture { + + @Test + void with_arguments_interactive_dice_should_generate_mnemonic_sentence() throws UnsupportedEncodingException { + + withArgs("mnemonic --interactive --dice6-entropy --bits 128"); + + expectedOutput("Input 50 dice rolls [1-6]: "); + provideInput("234322343242422344161254151\r\n"); + + expectedOutput("Input 23 more dice rolls [1-6]: "); + provideInput("33116265515343114314456\r\n"); + + expectedOutput("mountain tilt wing silk rude fox almost volume wine media verify card" + EOL); + + doMain(); + + verifyOutput(); + } + + @Test + void with_no_arguments_should_output_mnemonic() throws UnsupportedEncodingException { + + withArgs("mnemonic"); + + doMain(); + + assertEquals(24, getOutput().split(" ").length); + } + + @Test + void with_arguments_non_interactive_dice_bits_should_generate_mnemonic_sentence() throws UnsupportedEncodingException { + + withArgs("mnemonic --dice6-entropy --events 23432234324242234416125415133116265515343114314456 --bits 128"); + + expectedOutput("mountain tilt wing silk rude fox almost volume wine media verify card" + EOL); + + doMain(); + + verifyOutput(); + } + + @Test + void with_arguments_without_interactive_dice_should_generate_mnemonic_sentence() throws UnsupportedEncodingException { + + withArgs("mnemonic --dice6-entropy --events 2343223432424223441612541513311626551534311431445623432234324242234416125415133116265515343114314456"); + + expectedOutput("first welcome social broccoli nasty rather weird uncle spirit horn update pencil help rescue " + + "grape enough fork wave eight fuel ribbon pony clean couple" + EOL); + + doMain(); + + verifyOutput(); + } + + private void doMain() { + + setInput(); + + RudefoxCold.main(getArgs()); + } +} \ No newline at end of file diff --git a/src/test/java/io/rudefox/cold/wallet_command_tests.java b/src/test/java/io/rudefox/cold/wallet_command_tests.java new file mode 100644 index 0000000..5ccfdf0 --- /dev/null +++ b/src/test/java/io/rudefox/cold/wallet_command_tests.java @@ -0,0 +1,145 @@ +package io.rudefox.cold; + +import com.bjdweck.test.CliTestFixture; +import org.junit.jupiter.api.Test; + +import java.io.UnsupportedEncodingException; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class wallet_command_tests extends CliTestFixture { + + static { + OUTPUT_CHARSET = "UTF8"; + } + + @Test + void with_sentence_and_passphrase_should_output_xpub() throws UnsupportedEncodingException { + + withArgs(new String[] {"wallet", + "--sentence","stove prefer lunch collect small orphan wasp size beyond auction guilt great", + "--passphrase","apple", + "listxpub"}); + + doMain(); + + assertEquals("[39F8B071:m/44'/0'/0']xpub6CKy5SECeJipZid8dF3bopoMGdRzd7hMJuPzMGesZCobrMSssZyASexzXuzRTPVLcqqdyAEZJKPMGvDthgZW2Z3mPHLohxEAVbkvGKAXjqx" + EOL, getOutput()); + } + + @Test + void with_testnet_sentence_and_passphrase_and_witness_should_output_vpub() throws UnsupportedEncodingException { + + withArgs(new String[] {"--testnet", + "wallet", + "--sentence","icon issue absorb apology price atom bread toward worry final dune dial swing armor donkey", + "--passphrase","apple", + "listxpub", + "--purpose","P2WPKH", + "--account","6"}); + + doMain(); + + assertEquals("[CBACA81C:m/84'/1'/6']vpub5Z1KP7PJ8f85AqbvPMKeFHxmDfrgimiAar3KqwCzfsw65tmFLevm4MJX9cRcoiERYkjeG5Z3VwhSf1E3fVBFF86J33CHSED45Hk6kLZumqV" + EOL, getOutput()); + } + + @Test + void with_testnet_sentence_and_passphrase_and_witness_should_output_addresses() throws UnsupportedEncodingException { + + withArgs(new String[] {"--testnet", + "wallet", + "--sentence","icon issue absorb apology price atom bread toward worry final dune dial swing armor donkey", + "--passphrase","apple", + "listaddress", + "--purpose","P2WPKH", + "--account","6", + "--address-chain","INTERNAL", + "--address-seq","3", + "--count","5"}); + + doMain(); + + assertEquals("CBACA81C:m/84'/1'/6'/1/3: tb1qs7cnr9sql9nef5e95jk5t7pr2qwpjsqtffnuxj" + EOL + + "CBACA81C:m/84'/1'/6'/1/4: tb1qgl947tylg3tazgdy2rm3rs4gxheue707dkg3q2" + EOL + + "CBACA81C:m/84'/1'/6'/1/5: tb1q03n6q2jjeks9zaneglqz5g37xkcrzy8nh8hn6s" + EOL + + "CBACA81C:m/84'/1'/6'/1/6: tb1q50gylhzz2mrf76ffn7dc5zvymsd9lghj24t7ns" + EOL + + "CBACA81C:m/84'/1'/6'/1/7: tb1qvzkez0zg6xm9z780n0358xp7nx96fh508awktw" + EOL, getOutput()); + } + + @Test + void with_testnet_sentence_and_passphrase_and_witness_can_sign_psbt() throws UnsupportedEncodingException { + + withArgs(new String[] {"--testnet", + "wallet", + "--sentence","vibrant delay limit tenant prepare reflect lonely pepper dragon calm dolphin prize slide inch purse term raw eternal twin kidney scan power magnet humble", + "signpsbt", + "cHNidP8BAHcCAAAAAS/zE49gxbTp0n4mTv0wPV7KuLtQlbzTL/E9s4b4JKl/AQAAAAD/////AkAfAAAAAAAAGXapFE9U1GY" + + "KflieNl1PUIpdTTzcXnUkiKxiyR4AAAAAABl2qRR6uJziLUo4i7ooJyzxIctMZfxb5IisAAAAAE8BBDWHzwOP9tWogAAAAN/zgjOY" + + "H39q085n7GG+5+/Jtovkvc8WQftwMSsBV7svAhPpi88y7pGhXzOdH0iJbXJsMq3F9GNRnRyqkQ7hVEtjEI2C0R8sAACAAQAAgAAAA" + + "IAAAQDeAQAAAAGVcHJZzeDV1JsQv869yoS/f8eSufNKON0o56ChVEnxCwEAAABqRzBEAiAD95mxgC+CHJNt1Ui+Y/raIymIiK1fyV" + + "c9wx0EALk/uQIgdQS27tjyujeItDg96m5GR7UD5tIz+95A4RQX4YOXXZoBIQNsvOVlbTqbZoKJbx8z94F6C/xS4faQ2XsHINcd50q" + + "T5P////8CQB8AAAAAAAAWABRzMqPZdHy0JMwPKY/YRhrODmyijpzpHgAAAAAAGXapFKmSAlpRxRJXr8ywpN500rXVsxIHiKwAAAAA" + + "IgYCUEaaspCm8AVu5WJ9Z7RG53iW66Btq6QODz7xfMlRogoYjYLRHywAAIABAACAAAAAgAEAAAADAAAAAAAA"}); + + doMain(); + + assertEquals("cHNidP8BAHcCAAAAAS/zE49gxbTp0n4mTv0wPV7KuLtQlbzTL/E9s4b4JKl/AQAAAAD/////AkAfAAAAAAAAGXapFE9U1GYKflieNl1PUIpdTTzcXnUkiKxiyR4AAAAAABl2qRR6uJziLUo4i7ooJyzxIctMZfxb5IisAAAAAE8BBDWHzwOP9tWogAAAAN/zgjOYH39q085n7GG+5+/Jtovkvc8WQftwMSsBV7svAhPpi88y7pGhXzOdH0iJbXJsMq3F9GNRnRyqkQ7hVEtjEI2C0R8sAACAAQAAgAAAAIAAAQDeAQAAAAGVcHJZzeDV1JsQv869yoS/f8eSufNKON0o56ChVEnxCwEAAABqRzBEAiAD95mxgC+CHJNt1Ui+Y/raIymIiK1fyVc9wx0EALk/uQIgdQS27tjyujeItDg96m5GR7UD5tIz+95A4RQX4YOXXZoBIQNsvOVlbTqbZoKJbx8z94F6C/xS4faQ2XsHINcd50qT5P////8CQB8AAAAAAAAWABRzMqPZdHy0JMwPKY/YRhrODmyijpzpHgAAAAAAGXapFKmSAlpRxRJXr8ywpN500rXVsxIHiKwAAAAAIgYCUEaaspCm8AVu5WJ9Z7RG53iW66Btq6QODz7xfMlRogoYjYLRHywAAIABAACAAAAAgAEAAAADAAAAIgICUEaaspCm8AVu5WJ9Z7RG53iW66Btq6QODz7xfMlRogpIMEUCIQCrgKRLUmzYL8edfZ7cltQWnLdVdU2Ta3BYbu5NR8JYwgIgDeOEdkOmOkPp7HpC4dkniXvXsvfzagmJ6pkBDd2DcgwBAAAA" + EOL, getOutput()); + } + + @Test + void with_sentence_without_passphrase_should_output_xpub() throws UnsupportedEncodingException { + + withArgs(new String[] {"wallet", + "--sentence","ordinary debate stomach mix poverty upset amateur small sadness female general fabric", + "listxpub"}); + + doMain(); + + assertEquals("[A6932DE1:m/44'/0'/0']xpub6BjcsZDafUukJNMhD1Porw4SZB1RkiCE7EsQWJGVaMcZd9qXyawcjeVa28SJt5WNNAKJGGUbebfgzyrsWPTsLRkWmMNwrThaq8umcp7Yzn8" + EOL, getOutput()); + } + + @Test + void with_sentence_without_passphrase_should_output_xpub_qrcode() throws UnsupportedEncodingException { + + withArgs(new String[] {"wallet", + "--sentence","ordinary debate stomach mix poverty upset amateur small sadness female general fabric", + "listxpub", + "--qrcode"}); + + String ExpectedQRCode = + "█▀▀▀▀▀█ █ ▄ ██▀▄▄ ▀▀ ▀ █ ██▄ █▀▀▀▀▀█\n" + + "█ ███ █ █▄ ▄▀▄█ ▄▄█▄▀███▄█▄▀█▄██ █ ███ █\n" + + "█ ▀▀▀ █ ▄▄▄█ ▀▀ ▄ ▀█▀▄▀▀ ▄▀▀▀ █ █ ▀▀▀ █\n" + + "▀▀▀▀▀▀▀ ▀ ▀ ▀ ▀▄█▄▀ ▀▄▀ ▀ ▀ ▀▄█▄█ ▀▀▀▀▀▀▀\n" + + "▀▀▄ ▀▀▀▄ ▀ ▀█▄▄▄▄▀▀█ █ ▀█▄▀█▀▄█▀ █▄▀█▀▀\n" + + "▀▄ ▀█ ▀▄▄▀ █ ▄▀██▄▄▀▄ ▀█▀ █▀█▀▄ █▀███ █\n" + + "███▄▀▀▀▄█ █▀▀█▀▀▄▄▀█▀ █▄▀█ ▄█ ▄ ▀▄▄▄█ ▄▄\n" + + " ▀ ▄▀▄█▄▄ ▄ ▄ ▀█▄█▄█▀██▀ █ █ ▄▀▄█ █▀▄█\n" + + " ▀▄ ▄█▀█▀ ▀▀ ▀█▄█ ▀▄██▄▄▀█▀ ▄█▀▀▄████ \n" + + "▄ ▄▀█▄▀ █████▀▀▄ ▀ ██▀██ ▀▀ ▄▄▄▀▄▀▄▀ ▀▄▀\n" + + " █▄▄ ▀▀█▄█ ▄█▄ ▀ ▄▄▄▀█▄▀▄▄ ▄ ▄ ▀▄▄▄▄ ██\n" + + "▀▀█▀▀▄▀ ▄█▀▀ █▄█▄ ▀▄ ██ ▄█ █▀▄ ▀██▀▄▀▄█\n" + + "▀▄█ ▄▀ ▄ █▄▄▄▀▄▄ █▀▄▀ ▀▀ ███▀▀▀▀▄█▄▀█ ▀\n" + + "▀ ▄█ ▀▀ ▄▀ █▄ ▄▀ █▄ ▀▄█▀▀ ▀██▀ █ ▀▄▀▄▀▄▀\n" + + "█▀█▄▀▀▀ ██▄ ▀▀▀ ▄▄▀█▀█▀▄ ▄▄▄▄ ▀ █▄▄▄▀███\n" + + " ▀██▀▀▀█▀ ▄ ▀█ █▄▄██ ▀ ▀ █▄ ▀▄▀ █▀ ▀\n" + + "▀▀ ▀▀▄▀▀█ ▀ ███ ▀▄▀▀▀▄▄▄█ █▀▀▀███▀▀\n" + + "█▀▀▀▀▀█ ▄█▀▀ █▀█▄ ▀▄▄▄▄█▀ ▄▀ ▄ ██ ▀ █▄▀ ▀\n" + + "█ ███ █ ▀▀█▀▄█▄ ▀▄▄██▀▄█▄ ▀█▀▄▀██▀▀▀▀▄▀▄\n" + + "█ ▀▀▀ █ ▄█ █▀▀▄█▄█▄ ▄ █▀▀▄▀▄█ █▄▄▀▄▄█▀▄█\n" + + "▀▀▀▀▀▀▀ ▀ ▀ ▀▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀▀ " + EOL; + + doMain(); + + assertEquals(ExpectedQRCode, getOutput()); + } + + private void doMain() { + + setInput(); + + ServiceLocator.DEFAULT_CHARSET = StandardCharsets.UTF_8; + + RudefoxCold.main(getArgs()); + } +} \ No newline at end of file