├── .vscode └── settings.json ├── docs ├── images │ ├── whynotboth.gif │ ├── xkcd_security.png │ ├── sign_psbt_interactive.gif │ ├── show_qr_code_interactive.gif │ ├── show_secrets_interactive.gif │ └── generate_single_interactive.gif ├── keyfiles.md ├── technical.md ├── security.md ├── duress.md └── hw.md ├── .cargo └── config.toml ├── .gitignore ├── frozenkrill-core ├── src │ ├── compression.rs │ ├── utils.rs │ ├── custom_logger.rs │ ├── encoding │ │ └── mod.rs │ ├── encryption.rs │ ├── random_generation_utils.rs │ ├── key_derivation.rs │ └── wallet_export.rs └── Cargo.toml ├── keys └── dr.asc ├── .github ├── dependabot.yml └── workflows │ ├── security.yml │ ├── main.yml │ └── tag.yml ├── LICENSE-MIT ├── Cargo.toml ├── deny.toml ├── Release.md ├── flake.nix ├── src ├── progress_bar.rs └── commands │ ├── interactive │ ├── open │ │ ├── show_secrets.rs │ │ ├── show_receiving_qr_code.rs │ │ ├── export_public_info.rs │ │ ├── reencode.rs │ │ ├── sign_psbt.rs │ │ └── mod.rs │ ├── generate_batch.rs │ ├── generate_one.rs │ └── mod.rs │ ├── export_public_info.rs │ ├── show_secrets.rs │ ├── common │ ├── singlesig.rs │ └── mod.rs │ ├── show_receiving_qr_code.rs │ ├── psbt.rs │ ├── reencode.rs │ └── generate │ └── mod.rs ├── flake.lock ├── resources └── tests │ └── 73C5DA0A_coldcard-generic-export.json └── LICENSE-APACHE /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "ciphertext", 4 | "secp" 5 | ] 6 | } -------------------------------------------------------------------------------- /docs/images/whynotboth.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/planktonlabs/frozenkrill/HEAD/docs/images/whynotboth.gif -------------------------------------------------------------------------------- /docs/images/xkcd_security.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/planktonlabs/frozenkrill/HEAD/docs/images/xkcd_security.png -------------------------------------------------------------------------------- /docs/images/sign_psbt_interactive.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/planktonlabs/frozenkrill/HEAD/docs/images/sign_psbt_interactive.gif -------------------------------------------------------------------------------- /docs/images/show_qr_code_interactive.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/planktonlabs/frozenkrill/HEAD/docs/images/show_qr_code_interactive.gif -------------------------------------------------------------------------------- /docs/images/show_secrets_interactive.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/planktonlabs/frozenkrill/HEAD/docs/images/show_secrets_interactive.gif -------------------------------------------------------------------------------- /docs/images/generate_single_interactive.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/planktonlabs/frozenkrill/HEAD/docs/images/generate_single_interactive.gif -------------------------------------------------------------------------------- /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | target = "x86_64-unknown-linux-musl" 3 | 4 | [target.x86_64-unknown-linux-musl] 5 | linker = "x86_64-unknown-linux-musl-gcc" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # ---> Rust 2 | # Generated by Cargo 3 | # will have compiled files and executables 4 | debug/ 5 | target/ 6 | 7 | # These are backup files generated by rustfmt 8 | **/*.rs.bk 9 | 10 | # MSVC Windows builds of rustc generate these, which store debugging information 11 | *.pdb 12 | 13 | -------------------------------------------------------------------------------- /frozenkrill-core/src/compression.rs: -------------------------------------------------------------------------------- 1 | use std::io::Write; 2 | 3 | pub fn uncompress(data: &[u8]) -> anyhow::Result> { 4 | let mut e = flate2::write::ZlibDecoder::new(Vec::with_capacity(data.len())); 5 | e.write_all(data)?; 6 | Ok(e.finish()?) 7 | } 8 | 9 | pub(crate) fn compress(data: &[u8]) -> anyhow::Result> { 10 | let mut e = flate2::write::ZlibEncoder::new( 11 | Vec::with_capacity(data.len()), 12 | flate2::Compression::best(), 13 | ); 14 | e.write_all(data)?; 15 | Ok(e.finish()?) 16 | } 17 | -------------------------------------------------------------------------------- /keys/dr.asc: -------------------------------------------------------------------------------- 1 | -----BEGIN PGP PUBLIC KEY BLOCK----- 2 | 3 | mDMEaH7kvxYJKwYBBAHaRw8BAQdA9L+F4bl8QWLrbrgrd4EFv0rU77v2OAKHL/c9 4 | Oryw0rq0JERhdGEgUmV0cmlldmVyIDxkckBnYWx0bGFuZC5uZXR3b3JrPoiTBBMW 5 | CgA7FiEEarzvaGCqo8jxZ19v4dIHKR0QQeUFAmh+5L8CGwMFCwkIBwICIgIGFQoJ 6 | CAsCBBYCAwECHgcCF4AACgkQ4dIHKR0QQeXxcwD/Vwe4w5b3P39zrih2cA8qtZF/ 7 | c1eEVQOZn5/fEWeT5toA/3yoxqTBqjKn/yAuhRTyCOf5q+1lWkc1XOhZSXVT3NcI 8 | tB9EYXRhIFJldHJpZXZlciA8ZHJAZ2FsdGxhbmQuaW8+iJMEExYKADsWIQRqvO9o 9 | YKqjyPFnX2/h0gcpHRBB5QUCaH7lJgIbAwULCQgHAgIiAgYVCgkICwIEFgIDAQIe 10 | BwIXgAAKCRDh0gcpHRBB5W64AQDbLBJaflXOt/BFa9gf3V623RqaOoPjBISqmGWU 11 | E7EwigD+JBgf8oxmlAZcDFqR6h75SJKr8KijoP3yYgP37xCrMg64OARofuS/Egor 12 | BgEEAZdVAQUBAQdAaYxGteNfWDiliCoe9trZKSGUCJuTNM5q527gPb+joGIDAQgH 13 | iHgEGBYKACAWIQRqvO9oYKqjyPFnX2/h0gcpHRBB5QUCaH7kvwIbDAAKCRDh0gcp 14 | HRBB5QQzAP0aYySRfd08osy+wZwT428/xIsVfzfwuYUarWdhj5/sBgEAzWnxpC+k 15 | a97bPffFNviBSfZZEMtNmZknyAto0dL5ggo= 16 | =Y5s3 17 | -----END PGP PUBLIC KEY BLOCK----- 18 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | # Cargo dependencies 5 | - package-ecosystem: "cargo" 6 | directory: "/" 7 | schedule: 8 | interval: "weekly" 9 | day: "monday" 10 | time: "06:00" 11 | open-pull-requests-limit: 10 12 | reviewers: 13 | - "planktonlabs" 14 | assignees: 15 | - "planktonlabs" 16 | commit-message: 17 | prefix: "cargo" 18 | include: "scope" 19 | labels: 20 | - "dependencies" 21 | - "rust" 22 | 23 | # GitHub Actions 24 | - package-ecosystem: "github-actions" 25 | directory: "/" 26 | schedule: 27 | interval: "weekly" 28 | day: "monday" 29 | time: "06:00" 30 | open-pull-requests-limit: 5 31 | reviewers: 32 | - "planktonlabs" 33 | assignees: 34 | - "planktonlabs" 35 | commit-message: 36 | prefix: "ci" 37 | include: "scope" 38 | labels: 39 | - "dependencies" 40 | - "github-actions" -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 frozenkrill developers 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /frozenkrill-core/src/utils.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fs::{File, OpenOptions}, 3 | io::{BufReader, BufWriter, Write}, 4 | path::Path, 5 | }; 6 | 7 | use anyhow::{self, Context}; 8 | 9 | pub fn open_create_file(output_file_path: &Path) -> anyhow::Result> { 10 | Ok(BufWriter::new( 11 | OpenOptions::new() 12 | .write(true) 13 | .create_new(true) 14 | .open(output_file_path) 15 | .context("failure opening output file for write")?, 16 | )) 17 | } 18 | 19 | pub fn create_file<'a>(data: &[u8], output_file_path: &'a Path) -> anyhow::Result<&'a Path> { 20 | let mut f = open_create_file(output_file_path)?; 21 | f.write_all(data).context("failure writing final data")?; 22 | f.flush().context("failure flushing final data")?; 23 | Ok(output_file_path) 24 | } 25 | 26 | pub fn open_file(path: &Path) -> anyhow::Result { 27 | Ok(OpenOptions::new().read(true).open(path)?) 28 | } 29 | 30 | pub fn buf_open_file(path: &Path) -> anyhow::Result> { 31 | Ok(BufReader::new(open_file(path)?)) 32 | } 33 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "frozenkrill" 3 | version = "0.0.0" 4 | edition = "2024" 5 | license = "MIT" 6 | 7 | [workspace] 8 | members = ["frozenkrill-core"] 9 | 10 | [dependencies] 11 | base32 = "0.5" 12 | clap = { version = "4", features = ["derive", "env"] } 13 | const_format = "0.2" 14 | current_platform = "0.2" 15 | dialoguer = { git = "https://github.com/galtland/dialoguer", branch = "master", features = [ 16 | "fuzzy-select", 17 | ] } 18 | frozenkrill-core = { path = "./frozenkrill-core" } 19 | indicatif = "0.18" 20 | mockall = "0.13" 21 | path-absolutize = "3" 22 | qr2term = "0.3" 23 | serde = { version = "1", features = ["derive"] } 24 | termimad = "0.34" 25 | zxcvbn = "3" 26 | expectrl = { version = "0.7", optional = true } 27 | 28 | [features] 29 | default = [] 30 | cli_tests = ["expectrl"] 31 | 32 | [dev-dependencies] 33 | pretty_assertions = "1" 34 | tempfile = "3.21" 35 | 36 | [profile.release] 37 | strip = true 38 | opt-level = 3 39 | 40 | [profile.release.package."*"] 41 | opt-level = 3 42 | 43 | [profile.dev] 44 | opt-level = 1 45 | 46 | [profile.dev.package."*"] 47 | opt-level = 3 48 | -------------------------------------------------------------------------------- /docs/keyfiles.md: -------------------------------------------------------------------------------- 1 | # What are keyfiles? 2 | 3 | The keyfile can be __any file__ that you are always able to retrieve on demand. For instance, it can be a publicly available picture, song or book. 4 | 5 | It can be your favorite bible verse or your favorite meme. If you are mathematically inclined, it may one or more of your favorite irrational numbers. 6 | 7 | It may also be a "personal" file, like your favorite photo of your favorite trip. 8 | 9 | You just need enough backups of this file. Public archives like https://archive.org or https://commons.wikimedia.org/ may be useful on this context. 10 | 11 | It may also be something very different from the examples above. Creativity is important here. 12 | 13 | But no matter what you pick, pick something. 14 | 15 | ## Even a simple keyfile is better than no keyfile 16 | 17 | It's **highly recommended** to use at least one keyfile when creating a wallet. 18 | 19 | If the password is really strong, it's easy to be forgotten. If it's weak, it can be brute forced (even with the builtin brute force protections) 20 | 21 | A keyfile provides a __complementary strong password__ (entropy) that is __easy to remember__. 22 | 23 | -------------------------------------------------------------------------------- /deny.toml: -------------------------------------------------------------------------------- 1 | # cargo-deny configuration 2 | # https://embarkstudios.github.io/cargo-deny/ 3 | 4 | [graph] 5 | targets = [ 6 | { triple = "x86_64-unknown-linux-gnu" }, 7 | { triple = "x86_64-unknown-linux-musl" }, 8 | { triple = "x86_64-apple-darwin" }, 9 | { triple = "aarch64-apple-darwin" }, 10 | { triple = "x86_64-pc-windows-msvc" }, 11 | ] 12 | 13 | [advisories] 14 | ignore = [] 15 | 16 | [licenses] 17 | allow = [ 18 | "MIT", 19 | "Apache-2.0", 20 | "Apache-2.0 WITH LLVM-exception", 21 | "BSD-2-Clause", 22 | "BSD-3-Clause", 23 | "ISC", 24 | "MPL-2.0", 25 | "Unicode-3.0", 26 | "Zlib", 27 | "CC0-1.0", 28 | "MITNFA", 29 | ] 30 | 31 | [bans] 32 | multiple-versions = "warn" 33 | wildcards = "allow" 34 | highlight = "all" 35 | workspace-default-features = "allow" 36 | external-default-features = "allow" 37 | allow = [] 38 | deny = [ 39 | # Deny known problematic crates 40 | { name = "openssl", version = "*" }, # Prefer rustls 41 | { name = "cmake", version = "*" }, # Avoid C dependencies 42 | ] 43 | 44 | [sources] 45 | unknown-registry = "warn" 46 | unknown-git = "warn" 47 | allow-registry = ["https://github.com/rust-lang/crates.io-index"] 48 | allow-git = [] -------------------------------------------------------------------------------- /frozenkrill-core/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "frozenkrill-core" 3 | version = "0.0.0" 4 | edition = "2024" 5 | license = "MIT OR Apache-2.0" 6 | 7 | [dependencies] 8 | alkali = "0.3" 9 | libsodium-sys-stable = "1.22.3" 10 | anyhow = { version = "1", features = ["backtrace"] } 11 | bip39 = { git = "https://github.com/rust-bitcoin/rust-bip39", features = [ 12 | "rand", 13 | "zeroize", 14 | ] } 15 | bitcoin = "0.32" 16 | blake3 = { version = "1", features = ["rayon"] } 17 | env_logger = "0.11" 18 | flate2 = "1" 19 | hex = "0.4" 20 | itertools = "0.14" 21 | log = "0.4" 22 | miniscript = "12" 23 | once_cell = "1" 24 | rand = "0.9" 25 | rand_core = "0.9" 26 | rayon = "1.11" 27 | regex = "1" 28 | secp256k1 = { version = "0.31", features = ["rand", "std"] } 29 | secrecy = "0.10" 30 | serde = { version = "1", features = ["derive"] } 31 | serde_json = "1" 32 | sha2 = "0.10" 33 | thiserror = "2" 34 | walkdir = "2" 35 | zeroize = { version = "1", features = ["zeroize_derive"] } 36 | 37 | [dev-dependencies] 38 | argon2 = { version = "0.5", features = ["zeroize"] } 39 | chacha20poly1305 = "0.10" 40 | mockall = "0.13" 41 | orion = "0.17" 42 | pretty_assertions = "1" 43 | rargon2 = { version = "2", package = "rust-argon2" } 44 | tempfile = "3.21" 45 | -------------------------------------------------------------------------------- /Release.md: -------------------------------------------------------------------------------- 1 | # Release 2 | 3 | In order to verify the release, you'll need to have gpg installed on your system. Once you've obtained a copy (and hopefully verified that as well), you'll first need to import the keys that have signed this release if you haven't done so already: 4 | 5 | ```bash 6 | curl https://raw.githubusercontent.com/planktonlabs/frozenkrill/master/keys/dr.asc | gpg --import 7 | ``` 8 | 9 | Once you have the required PGP keys, you can verify the files. For instance after downloading a file and its signature `.sig`, run on the same directory: 10 | 11 | ```bash 12 | gpg --verify frozenkrill-x86_64-linux.tar.gz.sig 13 | ``` 14 | 15 | You should see something like: 16 | 17 | ``` 18 | gpg: assuming signed data in 'frozenkrill-x86_64-linux.tar.gz' 19 | gpg: Signature made Mon 13 Feb 2023 01:52:18 AM UTC 20 | gpg: using RSA key FA26B85752DC29DA32EE71403DD4DF7C8F3A3C60 21 | gpg: Good signature from "Data Retriever " [ultimate] 22 | gpg: aka "Data Retriever " [ultimate] 23 | ``` 24 | 25 | The above procedure should be enough to verify integrity and authenticity, but the checksums may be checked separately by downloading `sha512-manifest-checksums.txt` and then running: 26 | 27 | ```bash 28 | sha512sum --ignore-missing -c sha512-manifest-checksums.txt 29 | ``` -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Rust project with musl target"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 6 | flake-utils.url = "github:numtide/flake-utils"; 7 | rust-overlay.url = "github:oxalica/rust-overlay"; 8 | }; 9 | 10 | outputs = { self, nixpkgs, flake-utils, rust-overlay }: 11 | flake-utils.lib.eachDefaultSystem (system: 12 | let 13 | pkgs = import nixpkgs { 14 | inherit system; 15 | overlays = [ (import rust-overlay) ]; 16 | }; 17 | 18 | rustToolchain = pkgs.rust-bin.stable.latest.default.override { 19 | extensions = [ "rust-src" "rust-analyzer" ]; 20 | targets = [ "x86_64-unknown-linux-musl" ]; 21 | }; 22 | in 23 | { 24 | devShells.default = pkgs.mkShell { 25 | packages = with pkgs; [ 26 | bashInteractive 27 | rustToolchain 28 | pkg-config 29 | pkgsStatic.stdenv.cc 30 | cargo-edit 31 | ]; 32 | 33 | CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER = "${pkgs.pkgsStatic.stdenv.cc}/bin/${pkgs.pkgsStatic.stdenv.cc.targetPrefix}cc"; 34 | CC_x86_64_unknown_linux_musl = "${pkgs.pkgsStatic.stdenv.cc}/bin/${pkgs.pkgsStatic.stdenv.cc.targetPrefix}cc"; 35 | }; 36 | } 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/progress_bar.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use indicatif::ProgressBar; 4 | 5 | pub fn get_prefixed_progress_bar(len: usize, prefix: &str, message: &str) -> ProgressBar { 6 | let pb = ProgressBar::new(len.try_into().expect("to be able to convert")); 7 | pb.set_style( 8 | indicatif::ProgressStyle::with_template( 9 | "{spinner:.dim.bold}{prefix:>19.cyan.bold} [{bar:50}] {pos}/{len} {wide_msg}", 10 | ) 11 | .expect("to be a good template") 12 | .progress_chars("=> ") 13 | .tick_chars("/|\\- "), 14 | ); 15 | pb.enable_steady_tick(Duration::from_millis(150)); 16 | pb.set_prefix(prefix.to_owned()); 17 | pb.set_message(message.to_owned()); 18 | pb 19 | } 20 | 21 | pub fn get_spinner(message: &str) -> ProgressBar { 22 | let pb = ProgressBar::new_spinner(); 23 | pb.set_style( 24 | indicatif::ProgressStyle::with_template("{spinner:.cyan} {wide_msg}") 25 | .expect("to be a good template") 26 | .tick_strings(&[ 27 | "● ", 28 | "●● ", 29 | "●●● ", 30 | "●●●● ", 31 | "●●●●● ", 32 | "●●●●●●", 33 | "●●●●●●", 34 | ]), 35 | ); 36 | pb.enable_steady_tick(Duration::from_millis(150)); 37 | pb.set_message(message.to_owned()); 38 | pb 39 | } 40 | -------------------------------------------------------------------------------- /src/commands/interactive/open/show_secrets.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use dialoguer::{console::Term, theme::Theme}; 4 | use frozenkrill_core::{ 5 | anyhow, 6 | bitcoin::secp256k1::{All, Secp256k1}, 7 | log, 8 | secrecy::SecretString, 9 | wallet_description::{MultiSigWalletDescriptionV0, SingleSigWalletDescriptionV0}, 10 | }; 11 | 12 | pub(super) fn singlesig_show( 13 | theme: &dyn Theme, 14 | term: &Term, 15 | secp: &Secp256k1, 16 | wallet: &SingleSigWalletDescriptionV0, 17 | non_duress_password: &Option>, 18 | ) -> anyhow::Result<()> { 19 | if ask_acknowledge_dangerous_show_secrets(theme, term)? { 20 | crate::commands::show_secrets::singlesig_show_secrets( 21 | theme, 22 | term, 23 | secp, 24 | wallet, 25 | non_duress_password, 26 | )?; 27 | } 28 | Ok(()) 29 | } 30 | 31 | pub(super) fn multisig_show( 32 | secp: &Secp256k1, 33 | wallet: &MultiSigWalletDescriptionV0, 34 | ) -> anyhow::Result<()> { 35 | crate::commands::show_secrets::multisig_show_secrets(secp, wallet) 36 | } 37 | 38 | fn ask_acknowledge_dangerous_show_secrets(theme: &dyn Theme, term: &Term) -> anyhow::Result { 39 | log::warn!( 40 | "{}", 41 | termimad::inline("This will expose the secrets in **plaintext**") 42 | ); 43 | log::warn!( 44 | "{}", 45 | termimad::inline("This is a **very dangerous operation**") 46 | ); 47 | log::warn!( 48 | "{}", 49 | termimad::inline("**Funds may be lost** if it leaks to third parties") 50 | ); 51 | Ok(dialoguer::Confirm::with_theme(theme) 52 | .with_prompt("Do you really want do this?") 53 | .interact_on(term)?) 54 | } 55 | -------------------------------------------------------------------------------- /docs/technical.md: -------------------------------------------------------------------------------- 1 | # Technical details 2 | 3 | The encrypted wallet file layout is: 4 | - nonce: 24 bytes, 5 | - salt: 16 bytes, 6 | - encrypted_header: 80 bytes, 7 | - ciphertext: variable length, 8 | 9 | The header key is derived by `argon2id` according to the `difficulty` parameters using as input the `user password` concatenated with the `salt` field. If keyfiles are used, a set of hashes will be deterministically calculated and concatenated to the other inputs. For details see the [key_derivation.rs](./src/key_derivation.rs) file. 10 | 11 | Using [XChaCha20-Poly1305](https://en.wikipedia.org/wiki/ChaCha20-Poly1305#XChaCha20-Poly1305_%E2%80%93_extended_nonce_variant) and the `nonce` field the `encrypted_header` header is encrypted/decrypted using above derived header key. 12 | 13 | Once decrypted, the header layout is: 14 | - key: 32 bytes, 15 | - nonce: 24 bytes, 16 | - version: 4 bytes, 17 | - length: 4 bytes, 18 | 19 | The `key` encrypts/decrypts the first `length` bytes of `ciphertext` using the header `nonce` and `XChaCha20-Poly1305`. 20 | 21 | Once decrypted we have two options depending on `version`: 22 | - for version = 0, a gzipped json containing exactly what's in show in the `show-secrets` command. 23 | - for version = 1 or 2, the seed entropy 24 | 25 | By default we use [libsodium](https://doc.libsodium.org/)'s implementation of `argon2id` and `XChaCha20-Poly1305`, but there are tests cross-checking alternative libraries. So they can easily be used in future releases if necessary. 26 | 27 | ## About padding 28 | 29 | The ciphertext is padded to a minimum value to avoid exposing the json size and thus avoiding exposing some information about the secrets. 30 | 31 | Also by default random bytes are added (see `--min-additional-padding-bytes` and `--max-additional-padding-bytes` on `singlesig-generate` and `multisig-generate`) to make the file size unpredictable. 32 | 33 | Note that this layout makes it perfectly possible to write the encrypted wallet directly to a block device (like a SD card). -------------------------------------------------------------------------------- /docs/security.md: -------------------------------------------------------------------------------- 1 | # Using frozenkrill safely 2 | 3 | In future, `frozenkrill` (or software designed with similar goals) will run on hardware wallets (fingers crossed🤞), but as today it requires a "normal" computer and a tech-savvy user. 4 | 5 | It requires that: 6 | 7 | 1) the user is able to check the hardware for potential malware, in particular 8 | 1) There are no keyloggers on main computer or keyboard 9 | 2) The BIOS can be trusted 10 | 3) Components like [Intel Management Engine](https://en.wikipedia.org/wiki/Intel_Management_Engine#Security_vulnerabilities) are not doing funny stuff 11 | 2) the user will boot a minimal and safe (preferably open-source) system in a known state (e.g use a secure Linux/BSD distribution that has been safely downloaded and minimally audited) 12 | - In particular it's imperative that the _swap memory_ is disable so no RAM contents ever get persisted to disk 13 | - And, of course, that `frozenkrill` is the official version and no tampering has happened 14 | 3) the system will never get connected to internet during key generation and it will be reset before connecting again 15 | 4) the password is reasonably strong and has not been reused (and will not be reused) outside of `frozenrkrill` 16 | 5) no one is watching you 17 | 18 | # A note about computer entropy and randomness 19 | 20 | Many hardware wallet users are very concerned about the capabilities of their devices of being random enough while generating a seed. 21 | 22 | Many will prefer to throw dice (or some similar method) and generate the seed using non digital methods of gathering entropy. 23 | 24 | While `frozenkrill` is perfectly capable of accepting an external seed generated by the user (see the `--user-generated-seed` flag of the `singlesig-generate` command), in practice if the user don't trust the operating system's entropy pool, then others cryptographic processes like `salt` and `nonce` generation shouldn't also be trusted. 25 | 26 | So we recommended everyone to do their own due diligence and check if they can trust the entropy of their setup. 27 | 28 | One tip is to install the package `rng-tools`, present in most Linux distributions, in particular run the `rngtest` binary like `rngtest -c 1000 < /dev/random`. 29 | 30 | If it doesn't block and yield a low failure rate (let's say, less than 1%), then it _may_ be safe enough for our purposes. -------------------------------------------------------------------------------- /.github/workflows/security.yml: -------------------------------------------------------------------------------- 1 | name: Security 2 | 3 | on: 4 | push: 5 | branches: [ main, master, develop ] 6 | pull_request: 7 | branches: [ main, master, develop ] 8 | schedule: 9 | # Run security audit weekly on Sundays at 6 AM UTC 10 | - cron: '0 6 * * 0' 11 | 12 | env: 13 | CARGO_TERM_COLOR: always 14 | 15 | jobs: 16 | security-audit: 17 | name: Security Audit 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - name: Checkout code 22 | uses: actions/checkout@v5 23 | 24 | - name: Install Rust toolchain 25 | uses: dtolnay/rust-toolchain@stable 26 | with: 27 | targets: x86_64-unknown-linux-musl 28 | 29 | - name: Install musl tools 30 | run: sudo apt-get update && sudo apt-get install -y musl-tools 31 | 32 | - name: Cache cargo dependencies 33 | uses: actions/cache@v4 34 | with: 35 | path: | 36 | ~/.cargo/registry 37 | ~/.cargo/git 38 | key: audit-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} 39 | 40 | - name: Install cargo-audit 41 | run: cargo install cargo-audit 42 | 43 | - name: Run cargo audit 44 | run: cargo audit 45 | 46 | - name: Run cargo audit (JSON output) 47 | run: cargo audit --json > audit-results.json 48 | continue-on-error: true 49 | 50 | - name: Upload audit results 51 | uses: actions/upload-artifact@v4 52 | with: 53 | name: security-audit-results 54 | path: audit-results.json 55 | retention-days: 30 56 | 57 | supply-chain-security: 58 | name: Supply Chain Security 59 | runs-on: ubuntu-latest 60 | 61 | steps: 62 | - name: Checkout code 63 | uses: actions/checkout@v5 64 | 65 | - name: Install Rust toolchain 66 | uses: dtolnay/rust-toolchain@stable 67 | with: 68 | targets: x86_64-unknown-linux-musl 69 | 70 | - name: Install musl tools 71 | run: sudo apt-get update && sudo apt-get install -y musl-tools 72 | 73 | - name: Cache cargo dependencies 74 | uses: actions/cache@v4 75 | with: 76 | path: | 77 | ~/.cargo/registry 78 | ~/.cargo/git 79 | key: supply-chain-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} 80 | 81 | - name: Install cargo-deny 82 | run: cargo install cargo-deny 83 | 84 | - name: Run cargo deny 85 | run: cargo deny check 86 | 87 | -------------------------------------------------------------------------------- /src/commands/export_public_info.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | 3 | use dialoguer::{console::Term, theme::Theme}; 4 | use frozenkrill_core::{ 5 | anyhow::{self}, 6 | bitcoin::secp256k1::{All, Secp256k1}, 7 | wallet_description::MultiSigWalletDescriptionV0, 8 | }; 9 | 10 | use frozenkrill_core::wallet_description::SingleSigWalletDescriptionV0; 11 | 12 | use crate::{ 13 | CommonExportPublicInfoArgs, CommonOpenArgs, commands::generate::export_singlesig_public_infos, 14 | handle_input_path, handle_output_path, 15 | }; 16 | 17 | use super::{ 18 | common::{AddressGenerationParams, from_wallet_to_public_info_json_path}, 19 | generate::{DuressPublicInfoParams, export_multisig_public_infos}, 20 | }; 21 | 22 | pub(crate) fn export_public_info_parse_args( 23 | common_open_args: &CommonOpenArgs, 24 | common_args: &CommonExportPublicInfoArgs, 25 | ) -> anyhow::Result<(PathBuf, AddressGenerationParams)> { 26 | let input_file_path = handle_input_path(&common_open_args.wallet_input_file)?; 27 | let output_file_path = match &common_args.output_file_json { 28 | Some(p) => handle_output_path(p)?.into_owned(), 29 | None => handle_output_path( 30 | from_wallet_to_public_info_json_path(&input_file_path)? 31 | .display() 32 | .to_string() 33 | .as_str(), 34 | )? 35 | .into_owned(), 36 | }; 37 | let address_generation_params = AddressGenerationParams { 38 | first_index: common_args.first_index, 39 | quantity: common_args.quantity, 40 | }; 41 | Ok((output_file_path, address_generation_params)) 42 | } 43 | 44 | pub(crate) fn export_singlesig( 45 | theme: &dyn Theme, 46 | term: &Term, 47 | secp: &Secp256k1, 48 | wallet_description: &SingleSigWalletDescriptionV0, 49 | public_info_json_output: &Path, 50 | duress_params: &Option, 51 | address_generation_params: &AddressGenerationParams, 52 | ) -> anyhow::Result<()> { 53 | export_singlesig_public_infos( 54 | theme, 55 | term, 56 | secp, 57 | wallet_description, 58 | public_info_json_output, 59 | duress_params, 60 | address_generation_params, 61 | )?; 62 | Ok(()) 63 | } 64 | 65 | pub(crate) fn export_multisig( 66 | secp: &Secp256k1, 67 | wallet_description: &MultiSigWalletDescriptionV0, 68 | public_info_json_output: &Path, 69 | address_generation_params: &AddressGenerationParams, 70 | ) -> anyhow::Result<()> { 71 | export_multisig_public_infos( 72 | secp, 73 | wallet_description, 74 | public_info_json_output, 75 | address_generation_params, 76 | )?; 77 | Ok(()) 78 | } 79 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1731533236, 9 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1756266583, 24 | "narHash": "sha256-cr748nSmpfvnhqSXPiCfUPxRz2FJnvf/RjJGvFfaCsM=", 25 | "owner": "NixOS", 26 | "repo": "nixpkgs", 27 | "rev": "8a6d5427d99ec71c64f0b93d45778c889005d9c2", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "NixOS", 32 | "ref": "nixos-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "nixpkgs_2": { 38 | "locked": { 39 | "lastModified": 1744536153, 40 | "narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=", 41 | "owner": "NixOS", 42 | "repo": "nixpkgs", 43 | "rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11", 44 | "type": "github" 45 | }, 46 | "original": { 47 | "owner": "NixOS", 48 | "ref": "nixpkgs-unstable", 49 | "repo": "nixpkgs", 50 | "type": "github" 51 | } 52 | }, 53 | "root": { 54 | "inputs": { 55 | "flake-utils": "flake-utils", 56 | "nixpkgs": "nixpkgs", 57 | "rust-overlay": "rust-overlay" 58 | } 59 | }, 60 | "rust-overlay": { 61 | "inputs": { 62 | "nixpkgs": "nixpkgs_2" 63 | }, 64 | "locked": { 65 | "lastModified": 1756348497, 66 | "narHash": "sha256-xJp3VnoYh4kpsaKFO/7SsGbwOz7pI1ZmjbqpXEuR2cw=", 67 | "owner": "oxalica", 68 | "repo": "rust-overlay", 69 | "rev": "0adf92c70d23fb4f703aea5d3ebb51ac65994f7f", 70 | "type": "github" 71 | }, 72 | "original": { 73 | "owner": "oxalica", 74 | "repo": "rust-overlay", 75 | "type": "github" 76 | } 77 | }, 78 | "systems": { 79 | "locked": { 80 | "lastModified": 1681028828, 81 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 82 | "owner": "nix-systems", 83 | "repo": "default", 84 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 85 | "type": "github" 86 | }, 87 | "original": { 88 | "owner": "nix-systems", 89 | "repo": "default", 90 | "type": "github" 91 | } 92 | } 93 | }, 94 | "root": "root", 95 | "version": 7 96 | } 97 | -------------------------------------------------------------------------------- /src/commands/show_secrets.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use dialoguer::{console::Term, theme::Theme}; 4 | use frozenkrill_core::{ 5 | anyhow::{self}, 6 | bitcoin::secp256k1::{All, Secp256k1}, 7 | secrecy::SecretString, 8 | wallet_description::{MultiSigWalletDescriptionV0, MultisigJsonWalletDescriptionV0}, 9 | }; 10 | 11 | use frozenkrill_core::secrecy::ExposeSecret; 12 | 13 | use frozenkrill_core::wallet_description::{ 14 | SingleSigWalletDescriptionV0, SinglesigJsonWalletDescriptionV0, 15 | }; 16 | 17 | use crate::{SinglesigShowSecretsArgs, commands::common::double_check_non_duress_password}; 18 | 19 | pub(crate) fn singlesig_show_secrets_parse_args( 20 | args: &SinglesigShowSecretsArgs, 21 | ) -> anyhow::Result<()> { 22 | anyhow::ensure!( 23 | args.acknowledge_dangerous_operation, 24 | "This operation will expose secrets in plaintext, run the command again adding the --acknowledge-dangerous-operation flag if you know what you're doing" 25 | ); 26 | Ok(()) 27 | } 28 | 29 | fn ask_show_non_duress(theme: &dyn Theme, term: &Term) -> anyhow::Result { 30 | let items = [ 31 | "Show default duress (decoy) wallet", 32 | "Show non duress (real) wallet", 33 | ]; 34 | let item = dialoguer::Select::with_theme(theme) 35 | .items(items) 36 | .default(1) 37 | .with_prompt("Pick an option") 38 | .interact_on(term)?; 39 | Ok(item == 1) 40 | } 41 | 42 | pub(crate) fn singlesig_show_secrets( 43 | theme: &dyn Theme, 44 | term: &Term, 45 | secp: &Secp256k1, 46 | wallet: &SingleSigWalletDescriptionV0, 47 | non_duress_password: &Option>, 48 | ) -> anyhow::Result<()> { 49 | let non_duress_wallet = match non_duress_password { 50 | Some(non_duress_password) => { 51 | if ask_show_non_duress(theme, term)? { 52 | double_check_non_duress_password(theme, term, non_duress_password)?; 53 | Some(wallet.change_seed_password(&Some(Arc::clone(non_duress_password)), secp)?) 54 | } else { 55 | None 56 | } 57 | } 58 | None => None, 59 | }; 60 | let wallet = non_duress_wallet.as_ref().unwrap_or(wallet); 61 | let json_wallet_description = 62 | SinglesigJsonWalletDescriptionV0::from_wallet_description(wallet, secp)?; 63 | println!( 64 | "{}", 65 | json_wallet_description 66 | .expose_secret() 67 | .to_string_pretty()? 68 | .expose_secret() 69 | ); 70 | Ok(()) 71 | } 72 | 73 | pub(crate) fn multisig_show_secrets( 74 | secp: &Secp256k1, 75 | wallet: &MultiSigWalletDescriptionV0, 76 | ) -> anyhow::Result<()> { 77 | let json_wallet_description = 78 | MultisigJsonWalletDescriptionV0::from_wallet_description(wallet, secp)?; 79 | println!( 80 | "{}", 81 | json_wallet_description 82 | .expose_secret() 83 | .to_string_pretty()? 84 | .expose_secret() 85 | ); 86 | Ok(()) 87 | } 88 | -------------------------------------------------------------------------------- /src/commands/interactive/open/show_receiving_qr_code.rs: -------------------------------------------------------------------------------- 1 | use std::{fmt::Display, str::FromStr, sync::Arc}; 2 | 3 | use dialoguer::{console::Term, theme::Theme}; 4 | use frozenkrill_core::{ 5 | anyhow, 6 | bitcoin::{ 7 | Amount, 8 | secp256k1::{All, Secp256k1}, 9 | }, 10 | secrecy::SecretString, 11 | wallet_description::{MultiSigWalletDescriptionV0, SingleSigWalletDescriptionV0}, 12 | }; 13 | 14 | pub(super) fn singlesig_show( 15 | theme: &dyn Theme, 16 | term: &Term, 17 | secp: &Secp256k1, 18 | wallet: &SingleSigWalletDescriptionV0, 19 | non_duress_password: &Option>, 20 | ) -> anyhow::Result<()> { 21 | let amount_receive = ask_amount_receive(theme, term)?; 22 | let address_index = ask_address_index_receive(theme, term)?; 23 | crate::commands::show_receiving_qr_code::singlesig_show_receiving_qr_code( 24 | theme, 25 | term, 26 | secp, 27 | wallet, 28 | amount_receive, 29 | address_index, 30 | non_duress_password, 31 | ) 32 | } 33 | 34 | pub(super) fn multisig_show( 35 | theme: &dyn Theme, 36 | term: &Term, 37 | secp: &Secp256k1, 38 | wallet: &MultiSigWalletDescriptionV0, 39 | ) -> anyhow::Result<()> { 40 | let amount_receive = ask_amount_receive(theme, term)?; 41 | let address_index = ask_address_index_receive(theme, term)?; 42 | crate::commands::show_receiving_qr_code::multisig_show_receiving_qr_code( 43 | secp, 44 | wallet, 45 | amount_receive, 46 | address_index, 47 | ) 48 | } 49 | 50 | #[derive(Clone)] 51 | struct ReceiveAmountInput(Option); 52 | 53 | impl Display for ReceiveAmountInput { 54 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 55 | if let Some(a) = &self.0 { 56 | f.write_str(&a.to_string()) 57 | } else { 58 | f.write_str("") 59 | } 60 | } 61 | } 62 | 63 | impl FromStr for ReceiveAmountInput { 64 | type Err = anyhow::Error; 65 | 66 | fn from_str(s: &str) -> Result { 67 | if s.trim().is_empty() { 68 | Ok(Self(None)) 69 | } else { 70 | Ok(Self(Some( 71 | crate::commands::show_receiving_qr_code::parse_amount(s)?, 72 | ))) 73 | } 74 | } 75 | } 76 | 77 | fn ask_amount_receive(theme: &dyn Theme, term: &Term) -> anyhow::Result> { 78 | eprintln!("Amount can be like 2.3 btc or 1500 sats"); 79 | let amount: ReceiveAmountInput = dialoguer::Input::with_theme(theme) 80 | .with_prompt("How much to receive? (empty to leave unspecified)") 81 | .allow_empty(true) 82 | .interact_text_on(term)?; 83 | Ok(amount.0) 84 | } 85 | 86 | fn ask_address_index_receive(theme: &dyn Theme, term: &Term) -> anyhow::Result { 87 | let n = dialoguer::Input::with_theme(theme) 88 | .allow_empty(false) 89 | .with_initial_text("0") 90 | .with_prompt("Which address index to receive on (first is zero)") 91 | .interact_text_on(term)?; 92 | Ok(n) 93 | } 94 | -------------------------------------------------------------------------------- /src/commands/interactive/open/export_public_info.rs: -------------------------------------------------------------------------------- 1 | use std::{path::Path, sync::Arc}; 2 | 3 | use dialoguer::{console::Term, theme::Theme}; 4 | use frozenkrill_core::{ 5 | anyhow, 6 | bitcoin::secp256k1::{All, Secp256k1}, 7 | secrecy::SecretString, 8 | wallet_description::{MultiSigWalletDescriptionV0, SingleSigWalletDescriptionV0}, 9 | }; 10 | 11 | use crate::commands::{ 12 | common::AddressGenerationParams, 13 | generate::DuressPublicInfoParams, 14 | interactive::{ 15 | ask_addresses_quantity, ask_non_duress_public_info_json_output, 16 | ask_public_info_json_output_required, 17 | }, 18 | }; 19 | 20 | pub(super) fn singlesig_export( 21 | theme: &dyn Theme, 22 | term: &Term, 23 | secp: &Secp256k1, 24 | wallet: &SingleSigWalletDescriptionV0, 25 | wallet_input_file: &Path, 26 | non_duress_password: &Option>, 27 | ) -> anyhow::Result<()> { 28 | let output_file_path = ask_public_info_json_output_required(theme, term, wallet_input_file)?; 29 | let quantity = ask_addresses_quantity(theme, term)?; 30 | let first_index = if quantity > 0 { 31 | ask_address_index_export(theme, term)? 32 | } else { 33 | 0 34 | }; 35 | let params = AddressGenerationParams { 36 | first_index, 37 | quantity, 38 | }; 39 | let duress_params = match non_duress_password { 40 | Some(non_duress_password) => Some(DuressPublicInfoParams { 41 | non_duress_password: non_duress_password.to_owned(), 42 | non_duress_public_info_json_output: ask_non_duress_public_info_json_output( 43 | theme, 44 | term, 45 | &output_file_path, 46 | )?, 47 | }), 48 | None => None, 49 | }; 50 | crate::commands::export_public_info::export_singlesig( 51 | theme, 52 | term, 53 | secp, 54 | wallet, 55 | &output_file_path, 56 | &duress_params, 57 | ¶ms, 58 | ) 59 | } 60 | 61 | pub(super) fn multisig_export( 62 | theme: &dyn Theme, 63 | term: &Term, 64 | secp: &Secp256k1, 65 | wallet: &MultiSigWalletDescriptionV0, 66 | wallet_input_file: &Path, 67 | ) -> anyhow::Result<()> { 68 | let output_file_path = ask_public_info_json_output_required(theme, term, wallet_input_file)?; 69 | let quantity = ask_addresses_quantity(theme, term)?; 70 | let first_index = if quantity > 0 { 71 | ask_address_index_export(theme, term)? 72 | } else { 73 | 0 74 | }; 75 | let params = AddressGenerationParams { 76 | first_index, 77 | quantity, 78 | }; 79 | crate::commands::export_public_info::export_multisig(secp, wallet, &output_file_path, ¶ms) 80 | } 81 | 82 | fn ask_address_index_export(theme: &dyn Theme, term: &Term) -> anyhow::Result { 83 | let n = dialoguer::Input::with_theme(theme) 84 | .allow_empty(false) 85 | .with_initial_text("0") 86 | .with_prompt("Start exporting at what receiving address index? (first is zero)") 87 | .interact_text_on(term)?; 88 | Ok(n) 89 | } 90 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: ["master"] 6 | pull_request: 7 | branches: ["master"] 8 | merge_group: 9 | 10 | # Allows you to run this workflow manually from the Actions tab 11 | workflow_dispatch: 12 | 13 | jobs: 14 | test: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Install Nix 20 | uses: cachix/install-nix-action@v24 21 | with: 22 | extra_nix_config: | 23 | experimental-features = nix-command flakes 24 | - name: Setup Magic Nix Cache 25 | uses: DeterminateSystems/magic-nix-cache-action@main 26 | - name: rustup update 27 | run: rustup update 28 | - name: Show cargo version 29 | run: cargo --version 30 | - name: Show rustc version 31 | run: rustc --version 32 | - name: Install cross 33 | run: | 34 | cargo install cross --git https://github.com/cross-rs/cross 35 | - name: Cargo clean 36 | run: cargo clean 37 | - name: Show Cargo.lock hash 38 | run: sha256sum Cargo.lock 39 | - name: Fetch 40 | run: | 41 | cargo fetch --locked 42 | - name: Build x86_64-unknown-linux-gnu 43 | run: | 44 | cargo build --target=x86_64-unknown-linux-gnu --release --frozen 45 | - name: Test x86_64-unknown-linux-gnu including cli 46 | run: | 47 | cargo test --target=x86_64-unknown-linux-gnu --release --frozen --workspace --features cli_tests -- --nocapture --test-threads 1 48 | - name: Build x86_64-unknown-linux-musl using Nix 49 | run: | 50 | cargo clean && nix develop -c cargo build --target=x86_64-unknown-linux-musl --release --frozen 51 | - name: Test x86_64-unknown-linux-musl using Nix 52 | run: | 53 | nix develop -c cargo test --target=x86_64-unknown-linux-musl --release --frozen --workspace --features cli_tests -- --nocapture --test-threads 1 54 | - name: Build armv7-unknown-linux-musleabihf 55 | if: ${{ github.event.pull_request }} # only required to detect big issues, not required on merge queue etc 56 | run: | 57 | cargo clean && cross build --target=armv7-unknown-linux-musleabihf --release --frozen 58 | - name: Test armv7-unknown-linux-musleabihf 59 | if: ${{ github.event.pull_request }} # only required to detect big issues, not required on merge queue etc 60 | run: | 61 | cross test --target=armv7-unknown-linux-musleabihf --release --frozen --workspace -- --nocapture --test-threads 1 62 | - name: Build aarch64-unknown-linux-musl 63 | if: ${{ github.event.pull_request }} # only required to detect big issues, not required on merge queue etc 64 | run: | 65 | cargo clean && cross build --target=aarch64-unknown-linux-musl --release --frozen 66 | - name: Test aarch64-unknown-linux-musl 67 | if: ${{ github.event.pull_request }} # only required to detect big issues, not required on merge queue etc 68 | run: | 69 | cross test --target=aarch64-unknown-linux-musl --release --frozen --workspace -- --nocapture --test-threads 1 70 | -------------------------------------------------------------------------------- /resources/tests/73C5DA0A_coldcard-generic-export.json: -------------------------------------------------------------------------------- 1 | { 2 | "chain": "XTN", 3 | "xfp": "73C5DA0A", 4 | "account": 0, 5 | "xpub": "tpubD6NzVbkrYhZ4XYa9MoLt4BiMZ4gkt2faZ4BcmKu2a9te4LDpQmvEz2L2yDERivHxFPnxXXhqDRkUNnQCpZggCyEZLBktV7VaSmwayqMJy1s", 6 | "bip44": { 7 | "name": "p2pkh", 8 | "xfp": "4334C988", 9 | "deriv": "m/44'/1'/0'", 10 | "xpub": "tpubDC5FSnBiZDMmhiuCmWAYsLwgLYrrT9rAqvTySfuCCrgsWz8wxMXUS9Tb9iVMvcRbvFcAHGkMD5Kx8koh4GquNGNTfohfk7pgjhaPCdXpoba", 11 | "desc": "pkh([73c5da0a/44h/1h/0h]tpubDC5FSnBiZDMmhiuCmWAYsLwgLYrrT9rAqvTySfuCCrgsWz8wxMXUS9Tb9iVMvcRbvFcAHGkMD5Kx8koh4GquNGNTfohfk7pgjhaPCdXpoba/<0;1>/*)#0x5u8d5c", 12 | "first": "mkpZhYtJu2r87Js3pDiWJDmPte2NRZ8bJV" 13 | }, 14 | "bip49": { 15 | "name": "p2sh-p2wpkh", 16 | "xfp": "0A55DB61", 17 | "deriv": "m/49'/1'/0'", 18 | "xpub": "tpubDD7tXK8KeQ3YY83yWq755fHY2JW8Ha8Q765tknUM5rSvjPcGWfUppDFMpQ1ScziKfW3ZNtZvAD7M3u7bSs7HofjTD3KP3YxPK7X6hwV8Rk2", 19 | "desc": "sh(wpkh([73c5da0a/49h/1h/0h]tpubDD7tXK8KeQ3YY83yWq755fHY2JW8Ha8Q765tknUM5rSvjPcGWfUppDFMpQ1ScziKfW3ZNtZvAD7M3u7bSs7HofjTD3KP3YxPK7X6hwV8Rk2/<0;1>/*))#ea0dwls3", 20 | "_pub": "upub5EFU65HtV5TeiSHmZZm7FUffBGy8UKeqp7vw43jYbvZPpoVsgU93oac7Wk3u6moKegAEWtGNF8DehrnHtv21XXEMYRUocHqguyjknFHYfgY", 21 | "first": "2Mww8dCYPUpKHofjgcXcBCEGmniw9CoaiD2" 22 | }, 23 | "bip84": { 24 | "name": "p2wpkh", 25 | "xfp": "E99B8628", 26 | "deriv": "m/84'/1'/0'", 27 | "xpub": "tpubDC8msFGeGuwnKG9Upg7DM2b4DaRqg3CUZa5g8v2SRQ6K4NSkxUgd7HsL2XVWbVm39yBA4LAxysQAm397zwQSQoQgewGiYZqrA9DsP4zbQ1M", 28 | "desc": "wpkh([73c5da0a/84h/1h/0h]tpubDC8msFGeGuwnKG9Upg7DM2b4DaRqg3CUZa5g8v2SRQ6K4NSkxUgd7HsL2XVWbVm39yBA4LAxysQAm397zwQSQoQgewGiYZqrA9DsP4zbQ1M/<0;1>/*)#lxek0ef2", 29 | "_pub": "vpub5Y6cjg78GGuNLsaPhmYsiw4gYX3HoQiRBiSwDaBXKUafCt9bNwWQiitDk5VZ5BVxYnQdwoTyXSs2JHRPAgjAvtbBrf8ZhDYe2jWAqvZVnsc", 30 | "first": "tb1q6rz28mcfaxtmd6v789l9rrlrusdprr9pqcpvkl" 31 | }, 32 | "bip48_1": { 33 | "name": "p2sh-p2wsh", 34 | "xfp": "86965BEB", 35 | "deriv": "m/48'/1'/0'/1'", 36 | "xpub": "tpubDFH9dgzveyD8yHQb8VrpG8FYAuwcLMHMje2CCcbBo1FpaGzYVtJeYYxcYgRqSTta5utUFts8nPPHs9C2bqoxrey5jia6Dwf9mpwrPq7YvcJ", 37 | "desc": "sh(wsh(sortedmulti(M,[73c5da0a/48'/1'/0'/1']tpubDFH9dgzveyD8yHQb8VrpG8FYAuwcLMHMje2CCcbBo1FpaGzYVtJeYYxcYgRqSTta5utUFts8nPPHs9C2bqoxrey5jia6Dwf9mpwrPq7YvcJ/0/*,...)))", 38 | "_pub": "Upub5TJpKgtw4cBcaAom7tyqG1yU3gSsjTVPkwWuR97vgrChHsT4S6M9d3BJ3jRmUgCUJZ58GUZhkWt6eGUVM7sdizaeuZqvC61TGRSP43VHvGm" 39 | }, 40 | "bip48_2": { 41 | "name": "p2wsh", 42 | "xfp": "3FEF83EE", 43 | "deriv": "m/48'/1'/0'/2'", 44 | "xpub": "tpubDFH9dgzveyD8zTbPUFuLrGmCydNvxehyNdUXKJAQN8x4aZ4j6UZqGfnqFrD4NqyaTVGKbvEW54tsvPTK2UoSbCC1PJY8iCNiwTL3RWZEheQ", 45 | "desc": "wsh(sortedmulti(M,[73c5da0a/48'/1'/0'/2']tpubDFH9dgzveyD8zTbPUFuLrGmCydNvxehyNdUXKJAQN8x4aZ4j6UZqGfnqFrD4NqyaTVGKbvEW54tsvPTK2UoSbCC1PJY8iCNiwTL3RWZEheQ/0/*,...))", 46 | "_pub": "Vpub5n95dMZrDHj6SeBgJ1oz4Fae2N2eJNuWK3VTKDb2dzGpMFLUHLmtyDfen7AaQxwQ5mZnMyXdVrkEaoMLVTH8FmVBRVWPGFYWhmtDUGehGmq" 47 | }, 48 | "bip45": { 49 | "name": "p2sh", 50 | "xfp": "A0ECFF4D", 51 | "deriv": "m/45'", 52 | "xpub": "tpubD97UxEEVXiRtzRBmHvR38R7QXNz6Dx3A7gKtoe9UgxepdJXExmJCd5Nxsv8YYLgHd3MEBKPzRwgVaJ62kvBSvMtntbkPnv6Pf8Zkny5rC89", 53 | "desc": "sh(sortedmulti(M,[73c5da0a/45']tpubD97UxEEVXiRtzRBmHvR38R7QXNz6Dx3A7gKtoe9UgxepdJXExmJCd5Nxsv8YYLgHd3MEBKPzRwgVaJ62kvBSvMtntbkPnv6Pf8Zkny5rC89/0/*,...))" 54 | } 55 | } -------------------------------------------------------------------------------- /src/commands/common/singlesig.rs: -------------------------------------------------------------------------------- 1 | use std::{path::PathBuf, sync::Arc}; 2 | 3 | use dialoguer::{console::Term, theme::Theme}; 4 | use frozenkrill_core::{ 5 | anyhow::{self, Context}, 6 | bitcoin::secp256k1::{All, Secp256k1}, 7 | key_derivation::KeyDerivationDifficulty, 8 | parse_keyfiles_paths, 9 | secrecy::SecretString, 10 | wallet_description::{ 11 | EncryptedWalletDescription, SingleSigWalletDescriptionV0, read_decode_wallet, 12 | }, 13 | wallet_export::SinglesigJsonWalletPublicExportV0, 14 | }; 15 | 16 | use crate::{ 17 | InternetChecker, SinglesigOpenArgs, ask_non_duress_password, ask_password, handle_input_path, 18 | ui_derive_key, ui_get_singlesig_wallet_description, 19 | }; 20 | 21 | pub(crate) fn open_singlesig_wallet_non_interactive( 22 | theme: &dyn Theme, 23 | term: &Term, 24 | secp: &Secp256k1, 25 | ic: impl InternetChecker, 26 | args: &SinglesigOpenArgs, 27 | ) -> anyhow::Result<(SingleSigWalletDescriptionV0, Option>)> { 28 | let input_file_path = handle_input_path(&args.common.wallet_input_file)?; 29 | let encrypted_wallet = read_decode_wallet(&input_file_path)?; 30 | let keyfiles = parse_keyfiles_paths(&args.common.keyfile)?; 31 | let password = args 32 | .common 33 | .password 34 | .clone() 35 | .map(|s| SecretString::new(s.into())) 36 | .map(Arc::new); 37 | singlesig_core_open( 38 | theme, 39 | term, 40 | secp, 41 | Some(ic), 42 | &encrypted_wallet, 43 | &keyfiles, 44 | &args.common.difficulty, 45 | args.enable_duress_wallet, 46 | password, 47 | ) 48 | } 49 | 50 | #[allow(clippy::too_many_arguments)] 51 | pub(crate) fn singlesig_core_open( 52 | theme: &dyn Theme, 53 | term: &Term, 54 | secp: &Secp256k1, 55 | ic: Option, 56 | encrypted_wallet: &EncryptedWalletDescription, 57 | keyfiles: &[PathBuf], 58 | difficulty: &KeyDerivationDifficulty, 59 | enable_duress_wallet: bool, 60 | password: Option>, 61 | ) -> anyhow::Result<(SingleSigWalletDescriptionV0, Option>)> { 62 | ic.map(|mut i| i.check()).transpose()?; 63 | let password = password 64 | .map(Result::Ok) 65 | .unwrap_or_else(|| ask_password(theme, term).map(Arc::new))?; 66 | let non_duress_password = if enable_duress_wallet { 67 | Some(ask_non_duress_password(theme, term)?) 68 | } else { 69 | None 70 | }; 71 | let key = ui_derive_key(&password, keyfiles, &encrypted_wallet.salt, difficulty)?; 72 | let seed_password = &None; 73 | let json_wallet = encrypted_wallet.decrypt_singlesig(&key, seed_password, secp)?; 74 | let wallet = ui_get_singlesig_wallet_description(&json_wallet, seed_password, secp)?; 75 | Ok((wallet, non_duress_password)) 76 | } 77 | 78 | pub(crate) fn generate_singlesig_public_info( 79 | secp: &Secp256k1, 80 | wallet: &SingleSigWalletDescriptionV0, 81 | first_index: u32, 82 | quantity: u32, 83 | ) -> anyhow::Result> { 84 | let addresses = wallet 85 | .derive_receiving_addresses(first_index, quantity, secp) 86 | .context("failure deriving receive addresses")?; 87 | let change_addresses = wallet 88 | .derive_change_addresses(first_index, quantity, secp) 89 | .context("failure deriving change addresses")?; 90 | let public_export = 91 | SinglesigJsonWalletPublicExportV0::generate(wallet, addresses, change_addresses)?; 92 | public_export.to_vec_pretty() 93 | } 94 | -------------------------------------------------------------------------------- /src/commands/show_receiving_qr_code.rs: -------------------------------------------------------------------------------- 1 | use std::{str::FromStr, sync::Arc}; 2 | 3 | use dialoguer::{console::Term, theme::Theme}; 4 | use frozenkrill_core::{ 5 | anyhow::{self, Context}, 6 | bitcoin::{ 7 | Amount, 8 | secp256k1::{All, Secp256k1}, 9 | }, 10 | secrecy::SecretString, 11 | wallet_description::MultiSigWalletDescriptionV0, 12 | }; 13 | 14 | use frozenkrill_core::wallet_description::SingleSigWalletDescriptionV0; 15 | 16 | use crate::ShowReceivingQrCodeArgs; 17 | 18 | use super::common::double_check_non_duress_password; 19 | 20 | pub(super) fn parse_amount(s: &str) -> anyhow::Result { 21 | let s = s.trim(); 22 | match s.parse::() { 23 | Ok(v) => Ok(Amount::from_btc(v)?), 24 | Err(_) => Ok(Amount::from_str(s)?), 25 | } 26 | } 27 | 28 | pub(crate) fn show_receiving_qr_code_parse_args( 29 | args: &ShowReceivingQrCodeArgs, 30 | ) -> anyhow::Result<(Option, u32)> { 31 | let amount = args 32 | .amount 33 | .as_ref() 34 | .map(|s| parse_amount(s)) 35 | .transpose() 36 | .context("failure parsing amount")?; 37 | Ok((amount, args.address_index)) 38 | } 39 | 40 | fn ask_received_qr_code_non_duress(theme: &dyn Theme, term: &Term) -> anyhow::Result { 41 | let items = [ 42 | "Fake receiving addresses for default duress wallet", 43 | "Real receiving addresses for non duress wallet", 44 | ]; 45 | let item = dialoguer::Select::with_theme(theme) 46 | .items(items) 47 | .default(1) 48 | .with_prompt("Pick an option") 49 | .interact_on(term)?; 50 | Ok(item == 1) 51 | } 52 | 53 | pub(crate) fn singlesig_show_receiving_qr_code( 54 | theme: &dyn Theme, 55 | term: &Term, 56 | secp: &Secp256k1, 57 | wallet: &SingleSigWalletDescriptionV0, 58 | amount: Option, 59 | address_index: u32, 60 | non_duress_password: &Option>, 61 | ) -> anyhow::Result<()> { 62 | let non_duress_wallet = match non_duress_password { 63 | Some(non_duress_password) => { 64 | if ask_received_qr_code_non_duress(theme, term)? { 65 | double_check_non_duress_password(theme, term, non_duress_password)?; 66 | Some(wallet.change_seed_password(&Some(Arc::clone(non_duress_password)), secp)?) 67 | } else { 68 | None 69 | } 70 | } 71 | None => None, 72 | }; 73 | let wallet = non_duress_wallet.as_ref().unwrap_or(wallet); 74 | let address = wallet.derive_receiving_address(address_index, secp)?; 75 | print_qr_info(amount, address)?; 76 | Ok(()) 77 | } 78 | 79 | pub(crate) fn multisig_show_receiving_qr_code( 80 | secp: &Secp256k1, 81 | wallet: &MultiSigWalletDescriptionV0, 82 | amount: Option, 83 | address_index: u32, 84 | ) -> anyhow::Result<()> { 85 | let address = wallet.derive_receiving_address(address_index, secp)?; 86 | print_qr_info(amount, address)?; 87 | Ok(()) 88 | } 89 | 90 | fn print_qr_info( 91 | amount: Option, 92 | address: frozenkrill_core::bitcoin::Address, 93 | ) -> Result<(), anyhow::Error> { 94 | let suffix = amount 95 | .map(|a| format!("?amount={}", a.to_btc())) 96 | .unwrap_or_default(); 97 | let qrcode = format!("{}{suffix}", address.to_qr_uri()); 98 | qr2term::print_qr(&qrcode)?; 99 | println!("{qrcode}"); 100 | println!("Address: {address}"); 101 | if let Some(amount) = amount { 102 | println!("Amount: {amount}") 103 | }; 104 | Ok(()) 105 | } 106 | -------------------------------------------------------------------------------- /src/commands/interactive/generate_batch.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | path::{Path, PathBuf}, 3 | sync::Arc, 4 | }; 5 | 6 | use dialoguer::{console::Term, theme::Theme}; 7 | use frozenkrill_core::{ 8 | PaddingParams, anyhow, 9 | bitcoin::secp256k1::{All, Secp256k1}, 10 | key_derivation::KeyDerivationDifficulty, 11 | rand_core::CryptoRng, 12 | secrecy::SecretString, 13 | wallet_description::ScriptType, 14 | }; 15 | use path_absolutize::Absolutize; 16 | 17 | use crate::{InternetChecker, commands::batch_generate_export::CoreBatchGenerateExportArgs}; 18 | 19 | use super::{ 20 | ask_addresses_quantity, ask_for_keyfiles_generate, ask_network, ask_non_duress_wallet_generate, 21 | ask_wallet_file_type, ask_word_count, get_ask_difficulty, 22 | }; 23 | 24 | #[allow(clippy::too_many_arguments)] 25 | pub(super) fn interactive_generate_batch( 26 | theme: &dyn Theme, 27 | term: &Term, 28 | secp: &mut Secp256k1, 29 | rng: &mut impl CryptoRng, 30 | ic: impl InternetChecker, 31 | keyfiles: Vec, 32 | difficulty: Option, 33 | enable_duress_wallet: bool, 34 | password: Option>, 35 | ) -> anyhow::Result<()> { 36 | let wallets_quantity = ask_batch_wallets_quantity(theme, term)?; 37 | let output_prefix = ask_batch_generate_wallet_prefix(theme, term)?; 38 | let keyfiles = if keyfiles.is_empty() { 39 | ask_for_keyfiles_generate(theme, term)? 40 | } else { 41 | keyfiles 42 | }; 43 | let disable_public_info_export = !ask_batch_public_info_export(theme, term)?; 44 | let addresses_quantity = if disable_public_info_export { 45 | 0 46 | } else { 47 | ask_addresses_quantity(theme, term)? 48 | }; 49 | let word_count = ask_word_count(theme, term)?; 50 | let wallet_file_type = ask_wallet_file_type(theme, term)?; 51 | let difficulty = get_ask_difficulty(theme, term, difficulty)?; 52 | let network = ask_network(theme, term)?; 53 | let script_type = ScriptType::SegwitNative; 54 | let args = CoreBatchGenerateExportArgs { 55 | password, 56 | keyfiles: &keyfiles, 57 | word_count, 58 | script_type, 59 | network, 60 | wallets_quantity, 61 | output_prefix: &output_prefix, 62 | enable_duress_wallet: enable_duress_wallet || ask_non_duress_wallet_generate(theme, term)?, 63 | difficulty: &difficulty, 64 | disable_public_info_export, 65 | addresses_quantity, 66 | padding_params: PaddingParams::default(), 67 | encrypted_wallet_version: wallet_file_type 68 | .to_encrypted_wallet_version(network, script_type)?, 69 | }; 70 | crate::commands::batch_generate_export::core_batch_generate_export( 71 | theme, term, secp, rng, ic, args, 72 | ) 73 | } 74 | 75 | fn ask_batch_public_info_export(theme: &dyn Theme, term: &Term) -> anyhow::Result { 76 | Ok(dialoguer::Confirm::with_theme(theme) 77 | .with_prompt("Export public info json?") 78 | .default(true) 79 | .interact_on(term)?) 80 | } 81 | 82 | fn ask_batch_wallets_quantity(theme: &dyn Theme, term: &Term) -> anyhow::Result { 83 | let n = dialoguer::Input::with_theme(theme) 84 | .allow_empty(false) 85 | .with_prompt("How many wallets to create?") 86 | .with_initial_text("10") 87 | .interact_text_on(term)?; 88 | Ok(n) 89 | } 90 | 91 | fn ask_batch_generate_wallet_prefix(theme: &dyn Theme, term: &Term) -> anyhow::Result { 92 | let suggested = Path::new("wallet").absolutize()?.display().to_string(); 93 | let prefix = dialoguer::Input::with_theme(theme) 94 | .with_prompt("Enter the path prefix for the multiple wallets") 95 | .with_initial_text(suggested) 96 | .interact_text_on(term)?; 97 | Ok(prefix) 98 | } 99 | -------------------------------------------------------------------------------- /src/commands/interactive/open/reencode.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | 3 | use dialoguer::{console::Term, theme::Theme}; 4 | use frozenkrill_core::{ 5 | PaddingParams, anyhow, 6 | bitcoin::secp256k1::{All, Secp256k1}, 7 | rand_core::CryptoRng, 8 | wallet_description::{MultiSigWalletDescriptionV0, SingleSigWalletDescriptionV0}, 9 | }; 10 | use path_absolutize::Absolutize; 11 | 12 | use crate::{ 13 | InternetChecker, 14 | commands::{ 15 | common::from_input_to_reencoded, 16 | interactive::{ 17 | ValidateOutputFile, ask_for_keyfiles_generate, ask_network, ask_wallet_file_type, 18 | get_ask_difficulty, 19 | }, 20 | reencode::{MultisigCoreReencodeArgs, SinglesigCoreReencodeArgs}, 21 | }, 22 | handle_output_path, 23 | }; 24 | 25 | pub(super) fn singlesig_reencode( 26 | theme: &dyn Theme, 27 | term: &Term, 28 | secp: &mut Secp256k1, 29 | rng: &mut impl CryptoRng, 30 | ic: impl InternetChecker, 31 | wallet: &SingleSigWalletDescriptionV0, 32 | input_path: &Path, 33 | ) -> anyhow::Result<()> { 34 | let keyfiles = ask_for_keyfiles_generate(theme, term)?; 35 | let wallet_file_type = ask_wallet_file_type(theme, term)?; 36 | let suggested_output_path = from_input_to_reencoded(input_path)?; 37 | let output_file_path = ask_output_file(theme, term, &suggested_output_path)?; 38 | let difficulty = get_ask_difficulty(theme, term, None)?; 39 | let network = ask_network(theme, term)?; 40 | let script_type = frozenkrill_core::wallet_description::ScriptType::SegwitNative; 41 | let args = SinglesigCoreReencodeArgs { 42 | password: None, 43 | output_file_path, 44 | keyfiles, 45 | script_type, 46 | network, 47 | difficulty, 48 | padding_params: PaddingParams::default(), 49 | encrypted_wallet_version: wallet_file_type 50 | .to_encrypted_wallet_version(network, script_type)?, 51 | }; 52 | 53 | crate::commands::reencode::singlesig_core_reencode(theme, term, secp, rng, ic, wallet, args) 54 | } 55 | 56 | pub(super) fn multisig_reencode( 57 | theme: &dyn Theme, 58 | term: &Term, 59 | secp: &mut Secp256k1, 60 | rng: &mut impl CryptoRng, 61 | ic: impl InternetChecker, 62 | wallet: &MultiSigWalletDescriptionV0, 63 | input_path: &Path, 64 | ) -> anyhow::Result<()> { 65 | let keyfiles = ask_for_keyfiles_generate(theme, term)?; 66 | let wallet_file_type = ask_wallet_file_type(theme, term)?; 67 | let suggested_output_path = from_input_to_reencoded(input_path)?; 68 | let output_file_path = ask_output_file(theme, term, &suggested_output_path)?; 69 | let difficulty = get_ask_difficulty(theme, term, None)?; 70 | let network = ask_network(theme, term)?; 71 | let script_type = frozenkrill_core::wallet_description::ScriptType::SegwitNative; 72 | let args = MultisigCoreReencodeArgs { 73 | password: None, 74 | output_file_path, 75 | keyfiles, 76 | script_type, 77 | network, 78 | difficulty, 79 | padding_params: PaddingParams::default(), 80 | encrypted_wallet_version: wallet_file_type 81 | .to_encrypted_wallet_version(network, script_type)?, 82 | }; 83 | 84 | crate::commands::reencode::multisig_core_reencode(theme, term, secp, rng, ic, wallet, args) 85 | } 86 | 87 | fn ask_output_file(theme: &dyn Theme, term: &Term, suggested: &PathBuf) -> anyhow::Result { 88 | let suggested = suggested.absolutize()?.display().to_string(); 89 | let name = dialoguer::Input::with_theme(theme) 90 | .with_prompt("Where to save the reencoded wallet?") 91 | .with_initial_text(suggested) 92 | .validate_with(ValidateOutputFile) 93 | .interact_text_on(term)?; 94 | Ok(handle_output_path(&name)?.into_owned()) 95 | } 96 | -------------------------------------------------------------------------------- /.github/workflows/tag.yml: -------------------------------------------------------------------------------- 1 | name: Releases 2 | on: 3 | push: 4 | tags: 5 | - "*" 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | contents: write 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Install Nix 14 | uses: cachix/install-nix-action@v24 15 | with: 16 | extra_nix_config: | 17 | experimental-features = nix-command flakes 18 | - name: Setup Magic Nix Cache 19 | uses: DeterminateSystems/magic-nix-cache-action@main 20 | - name: rustup update 21 | run: rustup update 22 | - name: Show cargo version 23 | run: cargo --version 24 | - name: Show rustc version 25 | run: rustc --version 26 | - name: Update versions 27 | run: | 28 | export VERSION_SHA="${{ github.ref_name }}:${{ github.sha }}" 29 | sed -i "s/\"0.0.0\"/\"${VERSION_SHA}\"/g" src/main.rs 30 | - name: Install cross 31 | run: | 32 | cargo install cross --git https://github.com/cross-rs/cross 33 | - name: Fetch 34 | run: | 35 | cargo fetch --locked 36 | - name: Build x86_64-unknown-linux-gnu 37 | run: | 38 | cargo build --target=x86_64-unknown-linux-gnu --release --frozen 39 | - name: Test x86_64-unknown-linux-gnu 40 | run: | 41 | cargo test --target=x86_64-unknown-linux-gnu --release --frozen --workspace --features cli_tests -- --nocapture --test-threads 1 42 | - name: Build x86_64-unknown-linux-musl using Nix 43 | run: | 44 | cargo clean && nix develop -c cargo build --target=x86_64-unknown-linux-musl --release --frozen 45 | - name: Test x86_64-unknown-linux-musl using Nix 46 | run: | 47 | nix develop -c cargo test --target=x86_64-unknown-linux-musl --release --frozen --workspace --features cli_tests -- --nocapture --test-threads 1 48 | - name: Package x86_64-unknown-linux-musl 49 | run: | 50 | mkdir -p frozenkrill-x86_64-linux && 51 | mv target/x86_64-unknown-linux-musl/release/frozenkrill frozenkrill-x86_64-linux 52 | - name: Build armv7-unknown-linux-musleabihf 53 | run: | 54 | cargo clean && cross build --target=armv7-unknown-linux-musleabihf --release --frozen 55 | - name: Test armv7-unknown-linux-musleabihf 56 | run: | 57 | cross test --target=armv7-unknown-linux-musleabihf --release --frozen --workspace -- --nocapture --test-threads 1 58 | - name: Package armv7-unknown-linux-musleabihf 59 | run: | 60 | mkdir -p frozenkrill-armv7-linux && 61 | mv target/armv7-unknown-linux-musleabihf/release/frozenkrill frozenkrill-armv7-linux 62 | - name: Build aarch64-unknown-linux-musl 63 | run: | 64 | cargo clean && cross build --target=aarch64-unknown-linux-musl --release --frozen 65 | - name: Test aarch64-unknown-linux-musl 66 | run: | 67 | cross test --target=aarch64-unknown-linux-musl --release --frozen --workspace -- --nocapture --test-threads 1 68 | - name: Package aarch64-unknown-linux-musl 69 | run: | 70 | mkdir -p frozenkrill-aarch64-linux && 71 | mv target/aarch64-unknown-linux-musl/release/frozenkrill frozenkrill-aarch64-linux 72 | - name: Finalize packages and create manifest 73 | run: | 74 | for i in frozenkrill-x86_64-linux frozenkrill-armv7-linux frozenkrill-aarch64-linux; do 75 | tar cvzf ${i}.tar.gz $i 76 | sha512sum ${i}.tar.gz $i/* >> sha512-manifest-checksums.txt 77 | done 78 | - uses: ncipollo/release-action@v1 79 | with: 80 | artifacts: "frozenkrill-x86_64-linux.tar.gz,frozenkrill-armv7-linux.tar.gz,frozenkrill-aarch64-linux.tar.gz,sha512-manifest-checksums.txt" 81 | bodyFile: "Release.md" 82 | prerelease: true 83 | draft: true 84 | -------------------------------------------------------------------------------- /frozenkrill-core/src/custom_logger.rs: -------------------------------------------------------------------------------- 1 | use env_logger::Builder; 2 | use log::Level; 3 | use std::io::Write; 4 | 5 | // Custom pretty env logger 6 | 7 | /// Initializes the global logger with a pretty env logger. 8 | /// 9 | /// This should be called early in the execution of a Rust program, and the 10 | /// global logger may only be initialized once. Future initialization attempts 11 | /// will return an error. 12 | /// 13 | /// # Panics 14 | /// 15 | /// This function fails to set the global logger if one has already been set. 16 | pub fn init() { 17 | try_init().unwrap(); 18 | } 19 | 20 | /// Initializes the global logger with a specific log level if RUST_LOG is not set. 21 | /// 22 | /// This should be called early in the execution of a Rust program, and the 23 | /// global logger may only be initialized once. Future initialization attempts 24 | /// will return an error. 25 | /// 26 | /// # Panics 27 | /// 28 | /// This function fails to set the global logger if one has already been set. 29 | pub fn init_with_level(level: &str) { 30 | try_init_with_level(level).unwrap(); 31 | } 32 | 33 | /// Initializes the global logger with a pretty env logger. 34 | /// 35 | /// This should be called early in the execution of a Rust program, and the 36 | /// global logger may only be initialized once. Future initialization attempts 37 | /// will return an error. 38 | /// 39 | /// # Errors 40 | /// 41 | /// This function fails to set the global logger if one has already been set. 42 | pub fn try_init() -> Result<(), log::SetLoggerError> { 43 | try_init_custom_env("RUST_LOG") 44 | } 45 | 46 | /// Initializes the global logger with a specific log level if RUST_LOG is not set. 47 | /// 48 | /// This should be called early in the execution of a Rust program, and the 49 | /// global logger may only be initialized once. Future initialization attempts 50 | /// will return an error. 51 | /// 52 | /// # Errors 53 | /// 54 | /// This function fails to set the global logger if one has already been set. 55 | pub fn try_init_with_level(level: &str) -> Result<(), log::SetLoggerError> { 56 | let mut builder = formatted_builder(); 57 | 58 | // Check if RUST_LOG is set, use it if it is, otherwise use the provided level 59 | if let Ok(s) = ::std::env::var("RUST_LOG") { 60 | builder.parse_filters(&s); 61 | } else { 62 | builder.parse_filters(level); 63 | } 64 | 65 | builder.try_init() 66 | } 67 | 68 | /// Initialized the global logger with a pretty env logger, with a custom variable name. 69 | /// 70 | /// This should be called early in the execution of a Rust program, and the 71 | /// global logger may only be initialized once. Future initialization attempts 72 | /// will return an error. 73 | /// 74 | /// # Errors 75 | /// 76 | /// This function fails to set the global logger if one has already been set. 77 | pub fn try_init_custom_env(environment_variable_name: &str) -> Result<(), log::SetLoggerError> { 78 | let mut builder = formatted_builder(); 79 | 80 | if let Ok(s) = ::std::env::var(environment_variable_name) { 81 | builder.parse_filters(&s); 82 | } 83 | 84 | builder.try_init() 85 | } 86 | 87 | // INFO levels will be be just normal text 88 | pub fn formatted_builder() -> Builder { 89 | let mut builder = Builder::new(); 90 | 91 | builder.format(|f, record| { 92 | if record.level() == Level::Info { 93 | writeln!(f, " > {}", record.args()) 94 | } else { 95 | let label = match record.level() { 96 | Level::Trace => "TRACE", 97 | Level::Debug => "DEBUG", 98 | Level::Info => "INFO ", 99 | Level::Warn => "WARN ", 100 | Level::Error => "ERROR", 101 | }; 102 | writeln!(f, " {} > {}", label, record.args()) 103 | } 104 | }); 105 | 106 | builder 107 | } 108 | -------------------------------------------------------------------------------- /docs/duress.md: -------------------------------------------------------------------------------- 1 | # Duress wallet (aka plausible deniability aka decoy wallet) 2 | The BIP-39 seed password is weak against a brute force attack, but there is useful use case for it: plausible deniability under coercion (duress) 3 | 4 | ## The $5 Wrench Attack 5 | ![XKCD about the wrench attack](./images/xkcd_security.png) 6 | 7 | [https://xkcd.com/538/](https://xkcd.com/538/) 8 | 9 | If someone gets physical access to your _persona_ then there is no cryptography, hardware wallet or vault than can completely save you (even multisigs may be not enough, see below). 10 | 11 | Most people will eventually yield under violence and hand over keys and passwords to the aggressor. But there is still an option to limit _how much_ these keys hold. You don't need to put all your eggs in one basket. 12 | 13 | ## Decoy wallets (or simply, multiple wallets) 14 | 15 | So one idea is leave the default wallet with an empty seed password and avoiding putting the _bulk_ of one's money here, but instead put higher values on non-empty seed password wallets. In `frozenkrill`'s nomenclature these are called respectively _the duress wallet (a decoy)_ and the _non duress wallet (a "real" one)_. 16 | 17 | But these are just conventions. In fact you can create many non duress wallets for the same seed and even a wallet with a password may not contain most of the funds. 18 | 19 | Ideally there are multiple passwords creating multiple wallets each one with a fraction of the funds but in practice remembering too many uncorrelated good passwords is unfeasible. 20 | 21 | (there are also other practical difficulties here, for instance the aggressor may find out looking at the blockchain or tax returns how much money you controls and then proceed to extract everything, therefore techniques like coinjoin and avoiding kyc procedures may help, but this is out of scope of this document) 22 | 23 | But even with its flaws, we suggest the users to at least consider creating a decoy wallet if physical coercion is a real threat and they have good memory. 24 | 25 | Also note that this concept isn't restricted to seed passwords, for instance a hardware wallet in a vault may be the decoy wallet, while one hidden in a computer the non duress one. Creativity is useful here. 26 | 27 | ## Decoy wallets or multisigs? 28 | ![Why not both?](./images/whynotboth.gif) 29 | 30 | If one's is afraid of physical coercion, then the common wisdom is to just use multisig and geographically distribute the keys to slow down the attack and make it completely fail. 31 | 32 | This is good and recommended but it's not a guarantee that everyone will be able to perfectly distribute and secure everything, specially given `frozenkrill`'s assumptions and uses cases. This wallet has been built [for people that have limited access to secure and stable physical locations](./hw.md#when-physical-security-and-stability-isn-t-a-given). 33 | 34 | For instance, even if there are friends or family holding on keys for a multisig and being oriented to never give up the key or signature if you are under coercion, the plan may completely fail when they actually see the violence taking action. 35 | 36 | So even in these cases it's possible to have different multisig wallets, each one being controlled by different combinations of seed passwords. 37 | 38 | (not necessarily all wallets offer this feature, but `frozenkrill` does) 39 | 40 | ## So let's do it? 41 | 42 | While we suggest everyone to consider creating a duress wallet, as it is a much more complex setup with higher cognitive load we can't just recommend it as a "best-practice" because the risks may not offset the gains. 43 | 44 | There is a real danger of losing funds if one forgets or mistypes a complex and long seed password. At least it will require a lot of computational resources to brute force it. 45 | 46 | As with any advice, take it with a grain of salt and consider your own context, capabilities and threat models. 47 | 48 | If in doubt, don't do it. And if you are going to do, double check everything and consider employing shorter passwords than usual. -------------------------------------------------------------------------------- /docs/hw.md: -------------------------------------------------------------------------------- 1 | # frozenkrill vs hardware wallets 2 | 3 | The recommended way to use bitcoin nowadays is: 4 | 5 | - for average amounts: get a hardware wallet, create a BIP-39 seed phrase with a password and backup the seed to paper or (preferably) a metal plate in multiple places (for instance, to multiple geodistributed vaults). Everything should be done offline and all transactions should be air-gapped (through PSBT files exchanged by SD cards or QR codes) 6 | - for high amounts: same as above, but the wallet should be configured to require multiple signatures ("multisig"), like 2-of-3, 3-of-5 etc. For each signature a different hardware wallet is used and they must be geodistributed and preferably controlled by multiple entities. If a high number of signatures and hardware wallets is used, a paper/metal seed backup _can_ be optional 7 | 8 | This setup works very fine for many people in many circumstances. But it requires physical security and physical stability. It's ideal for sedentary people in safe countries. But consider that most of world's population may be living in non ideal situations. 9 | 10 | ## When physical security and stability isn't a given 11 | Consider the following: 12 | - People that travel frequently through national borders (like digital nomads) 13 | - Zones under war or violent conflict 14 | - Places where kidnapping or home invasion is common, like ghettos or slums (or "developing" countries in general) 15 | - People that live under authoritarian regimes or are political dissidents 16 | - Places where natural disasters are common, like fire, landslides or flooding 17 | - People that are just bad with physical things and will break or lose anything they can touch 18 | 19 | For these people and circumstances, a digital backup (complementing or replacing physical ones), may be safer than just physical ones, and this is where `frozenkrill` may be useful. 20 | 21 | But you may ask: 22 | > In this particular cases, why not just save the seed phrase digitally? It's protected by the seed password, right? RIGHT? 23 | 24 | ## Why the seed password isn't good enough 25 | 26 | There are basically two reasons that makes BIP-39 seed passwords unsecure: 27 | 28 | 1) Passwords in general are weak because humans are bad at generating entropy 29 | 2) The cryptography of BIP-39 seed passwords hasn't been designed to secure against brute-force attacks 30 | 31 | `frozenkrill` addresses both points, respectively, by: 32 | 33 | 1) _Strongly encouraging_ and facilitating the use of [keyfiles](./keyfiles.md) to provide additional entropy 34 | 2) Correctly employing cryptography designed to resist against brute-force attacks 35 | 36 | So, with `frozenkrill` an encrypted wallet may be securely stored in a digital medium because it designed to survive attacks by malicious parties. It can be stored along other files in one or more storage providers, including free or cheap email and password managers accounts or really anywhere on internet. 37 | 38 | **But it can never be generated on a non-trusted computer, specially a non-trusted computer connected to internet** 39 | 40 | ## The problem of generating the keys/seeds 41 | 42 | Hardware wallets and paper/metal for backup seeds are recommended because: 43 | 1) They are simple enough to be safely handled even by people that are not "good with computers" 44 | 2) And in case of hardware wallets, they are simple enough to be audited by those who are "tech-savvy" (at least in theory) 45 | 46 | It's a known fact that most digital devices are infested by malware due to ignorance and/or recklessness of users and even well maintained devices may contain backdoors or exploitable bugs due the opacity of most software and hardware (i.e most software and hardware are not open-source and have a big attack surface). 47 | 48 | So using a computer to handle keys and passwords is hopeless because they will be leaked? 49 | 50 | For non tech-savvy or extremely paranoid people, yes, a computer is too risky compared to a hardware wallet plus plain paper/metal. 51 | 52 | But for many tech-savvy users it's a reasonable choice supposing that: 53 | - Open-source software is safe enough 54 | - Hardware vendors like AMD/Intel (or even Apple) can be trusted 55 | - The user will spend time to reduce the surface attack and configure a clean system 56 | 57 | For specific security considerations on how to safely use `frozenkrill`, see [this document](./security.md) -------------------------------------------------------------------------------- /src/commands/interactive/open/sign_psbt.rs: -------------------------------------------------------------------------------- 1 | use std::{fs, path::PathBuf, sync::Arc}; 2 | 3 | use dialoguer::{console::Term, theme::Theme}; 4 | use frozenkrill_core::{ 5 | anyhow::{self, bail}, 6 | bitcoin::{ 7 | psbt::Psbt, 8 | secp256k1::{All, Secp256k1}, 9 | }, 10 | psbt::open_psbt_file, 11 | secrecy::SecretString, 12 | wallet_description::{MultiSigWalletDescriptionV0, SingleSigWalletDescriptionV0}, 13 | }; 14 | use path_absolutize::Absolutize; 15 | 16 | use crate::{ 17 | commands::{ 18 | common::{double_check_non_duress_password, from_input_to_signed_psbt}, 19 | interactive::ValidateOutputFile, 20 | psbt::{ask_sign_non_duress, validated_input_psbt_sign}, 21 | }, 22 | handle_input_path, handle_output_path, 23 | }; 24 | 25 | pub(super) fn singlesig_sign( 26 | theme: &dyn Theme, 27 | term: &Term, 28 | secp: &Secp256k1, 29 | wallet: &SingleSigWalletDescriptionV0, 30 | non_duress_password: &Option>, 31 | ) -> anyhow::Result<()> { 32 | let (input_path, psbt) = ask_psbt_input_file(theme, term)?; 33 | let suggested_output_psbt_path = from_input_to_signed_psbt(&input_path)?; 34 | let output_psbt_path = ask_psbt_output_file(theme, term, &suggested_output_psbt_path)?; 35 | let non_duress_wallet = match non_duress_password { 36 | Some(non_duress_password) => { 37 | if ask_sign_non_duress(theme, term)? { 38 | double_check_non_duress_password(theme, term, non_duress_password)?; 39 | Some(wallet.change_seed_password(&Some(Arc::clone(non_duress_password)), secp)?) 40 | } else { 41 | None 42 | } 43 | } 44 | None => None, 45 | }; 46 | let wallet = non_duress_wallet.as_ref().unwrap_or(wallet); 47 | crate::commands::psbt::sign_core( 48 | theme, 49 | term, 50 | secp, 51 | wallet, 52 | psbt, 53 | &output_psbt_path, 54 | wallet.network, 55 | ) 56 | } 57 | 58 | pub(super) fn multisig_sign( 59 | theme: &dyn Theme, 60 | term: &Term, 61 | secp: &Secp256k1, 62 | wallet: &MultiSigWalletDescriptionV0, 63 | ) -> anyhow::Result<()> { 64 | let (input_path, psbt) = ask_psbt_input_file(theme, term)?; 65 | let suggested_output_psbt_path = from_input_to_signed_psbt(&input_path)?; 66 | let output_psbt_path = ask_psbt_output_file(theme, term, &suggested_output_psbt_path)?; 67 | crate::commands::psbt::sign_core( 68 | theme, 69 | term, 70 | secp, 71 | wallet, 72 | psbt, 73 | &output_psbt_path, 74 | wallet.network, 75 | ) 76 | } 77 | 78 | fn ask_psbt_input_file(theme: &dyn Theme, term: &Term) -> anyhow::Result<(PathBuf, Psbt)> { 79 | let mut files = fs::read_dir(".")? 80 | .collect::, _>>()? 81 | .iter() 82 | .filter(|i| i.file_type().is_ok() && i.file_type().unwrap().is_file()) 83 | .map(|i| i.path().display().to_string()) 84 | .filter(|i| i.to_lowercase().ends_with(".psbt")) 85 | .collect::>(); 86 | files.sort(); 87 | if files.is_empty() { 88 | eprintln!( 89 | "You can't pick a PSBT file because there are no .psbt files in current directory" 90 | ); 91 | bail!( 92 | "Copy some .psbt file to current directory or change the current directory so you can load the PSBT" 93 | ); 94 | } 95 | loop { 96 | let file = dialoguer::Select::with_theme(theme) 97 | .with_prompt("Select a .psbt file") 98 | .items(&files) 99 | .interact_on(term)?; 100 | let file = handle_input_path(&files[file])?.into_owned(); 101 | match open_psbt_file(&file).and_then(|psbt| validated_input_psbt_sign(&psbt).map(|_| psbt)) 102 | { 103 | Ok(w) => return Ok((file, w)), 104 | Err(e) => { 105 | eprintln!("Error reading PSBT: {e:?}"); 106 | if !dialoguer::Confirm::with_theme(theme) 107 | .with_prompt("The selected file is invalid, do you want to pick another file?") 108 | .default(true) 109 | .interact_on(term)? 110 | { 111 | bail!("No valid PSBT file selected"); 112 | } 113 | } 114 | } 115 | } 116 | } 117 | 118 | fn ask_psbt_output_file( 119 | theme: &dyn Theme, 120 | term: &Term, 121 | suggested: &PathBuf, 122 | ) -> anyhow::Result { 123 | let suggested = suggested.absolutize()?.display().to_string(); 124 | let name = dialoguer::Input::with_theme(theme) 125 | .with_prompt("Where to save the signed PSBT?") 126 | .with_initial_text(suggested) 127 | .validate_with(ValidateOutputFile) 128 | .interact_text_on(term)?; 129 | Ok(handle_output_path(&name)?.into_owned()) 130 | } 131 | -------------------------------------------------------------------------------- /frozenkrill-core/src/encoding/mod.rs: -------------------------------------------------------------------------------- 1 | // Adapted from rust-bitcoin 2 | 3 | use std::{ 4 | io::{BufRead, BufReader, BufWriter, Read, Write}, 5 | mem::size_of, 6 | }; 7 | 8 | pub mod wallet; 9 | 10 | /// A variable-length unsigned integer. 11 | #[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Debug)] 12 | pub struct VarInt(pub u64); 13 | 14 | pub fn encode_u8(n: u8) -> [u8; size_of::()] { 15 | n.to_le_bytes() 16 | } 17 | 18 | pub fn decode_u8(bytes: [u8; size_of::()]) -> u8 { 19 | u8::from_le_bytes(bytes) 20 | } 21 | 22 | pub fn encode_u16(n: u16) -> [u8; size_of::()] { 23 | n.to_le_bytes() 24 | } 25 | 26 | pub fn decode_u16(bytes: [u8; size_of::()]) -> u16 { 27 | u16::from_le_bytes(bytes) 28 | } 29 | 30 | pub fn encode_u32(n: u32) -> [u8; size_of::()] { 31 | n.to_le_bytes() 32 | } 33 | 34 | pub fn decode_u32(bytes: [u8; size_of::()]) -> u32 { 35 | u32::from_le_bytes(bytes) 36 | } 37 | 38 | pub fn encode_u64(n: u64) -> [u8; size_of::()] { 39 | n.to_le_bytes() 40 | } 41 | 42 | pub fn decode_u64(bytes: [u8; size_of::()]) -> u64 { 43 | u64::from_le_bytes(bytes) 44 | } 45 | 46 | impl VarInt { 47 | pub const ZERO: VarInt = VarInt(0); 48 | pub const ONE: VarInt = VarInt(1); 49 | 50 | pub fn serialize(&self, w: &mut W) -> anyhow::Result { 51 | match self.0 { 52 | 0..=0xFC => { 53 | w.write_all(&encode_u8(self.0.try_into()?))?; 54 | Ok(1) 55 | } 56 | 0xFD..=0xFFFF => { 57 | w.write_all(&encode_u8(0xFD))?; 58 | w.write_all(&encode_u16(self.0.try_into()?))?; 59 | Ok(3) 60 | } 61 | 0x10000..=0xFFFFFFFF => { 62 | w.write_all(&encode_u8(0xFE))?; 63 | w.write_all(&encode_u32(self.0.try_into()?))?; 64 | Ok(5) 65 | } 66 | _ => { 67 | w.write_all(&encode_u8(0xFF))?; 68 | w.write_all(&encode_u64(self.0))?; 69 | Ok(9) 70 | } 71 | } 72 | } 73 | 74 | pub fn deserialize(r: &mut R) -> anyhow::Result { 75 | let mut buffer = [0u8; 1]; 76 | r.read_exact(&mut buffer)?; 77 | match buffer[0] { 78 | 0xFF => { 79 | let mut buffer = [0u8; size_of::()]; 80 | r.read_exact(&mut buffer)?; 81 | let x = decode_u64(buffer); 82 | if x < 0x100000000 { 83 | Err(anyhow::anyhow!("NonMinimalVarInt")) 84 | } else { 85 | Ok(VarInt::from(x)) 86 | } 87 | } 88 | 0xFE => { 89 | let mut buffer = [0u8; size_of::()]; 90 | r.read_exact(&mut buffer)?; 91 | let x = decode_u32(buffer); 92 | if x < 0x10000 { 93 | Err(anyhow::anyhow!("NonMinimalVarInt")) 94 | } else { 95 | Ok(VarInt::from(x)) 96 | } 97 | } 98 | 0xFD => { 99 | let mut buffer = [0u8; size_of::()]; 100 | r.read_exact(&mut buffer)?; 101 | let x = decode_u16(buffer); 102 | if x < 0xFD { 103 | Err(anyhow::anyhow!("NonMinimalVarInt")) 104 | } else { 105 | Ok(VarInt::from(x)) 106 | } 107 | } 108 | n => Ok(VarInt::from(n)), 109 | } 110 | } 111 | } 112 | 113 | impl From for VarInt { 114 | fn from(value: u8) -> Self { 115 | Self(value.into()) 116 | } 117 | } 118 | 119 | impl From for VarInt { 120 | fn from(value: u16) -> Self { 121 | Self(value.into()) 122 | } 123 | } 124 | 125 | impl From for VarInt { 126 | fn from(value: u32) -> Self { 127 | Self(value.into()) 128 | } 129 | } 130 | 131 | impl From for VarInt { 132 | fn from(value: u64) -> Self { 133 | Self(value) 134 | } 135 | } 136 | 137 | impl TryFrom for VarInt { 138 | type Error = anyhow::Error; 139 | 140 | fn try_from(value: usize) -> Result { 141 | Ok(Self(value.try_into()?)) 142 | } 143 | } 144 | 145 | pub fn serialize_string(s: &str, w: &mut BufWriter) -> anyhow::Result<()> { 146 | VarInt::try_from(s.len())?.serialize(w)?; 147 | w.write_all(s.as_bytes())?; 148 | Ok(()) 149 | } 150 | 151 | pub fn deserialize_string(r: &mut BufReader) -> anyhow::Result { 152 | let len: usize = VarInt::deserialize(r)?.0.try_into()?; 153 | let mut buffer = vec![0u8; len]; 154 | r.read_exact(&mut buffer)?; 155 | let s = String::from_utf8(buffer)?; 156 | Ok(s) 157 | } 158 | 159 | pub fn serialize_varint_vector( 160 | vector: &[VarInt], 161 | w: &mut BufWriter, 162 | ) -> anyhow::Result<()> { 163 | VarInt::try_from(vector.len())?.serialize(w)?; 164 | for v in vector { 165 | v.serialize(w)?; 166 | } 167 | Ok(()) 168 | } 169 | 170 | pub fn deserialize_varint_vector(r: &mut BufReader) -> anyhow::Result> { 171 | let len: usize = VarInt::deserialize(r)?.0.try_into()?; 172 | let mut buffer: Vec = Vec::with_capacity(len); 173 | for _ in 0..len { 174 | buffer.push(VarInt::deserialize(r)?); 175 | } 176 | Ok(buffer) 177 | } 178 | 179 | pub fn serialize_byte_vector(vector: &[u8], w: &mut BufWriter) -> anyhow::Result<()> { 180 | VarInt::try_from(vector.len())?.serialize(w)?; 181 | w.write_all(vector)?; 182 | Ok(()) 183 | } 184 | 185 | pub fn deserialize_byte_vector(r: &mut BufReader) -> anyhow::Result> { 186 | let len: usize = VarInt::deserialize(r)?.0.try_into()?; 187 | let mut buffer = vec![0u8; len]; 188 | r.read_exact(&mut buffer)?; 189 | Ok(buffer) 190 | } 191 | -------------------------------------------------------------------------------- /frozenkrill-core/src/encryption.rs: -------------------------------------------------------------------------------- 1 | use secrecy::{ExposeSecret, SecretBox}; 2 | 3 | use crate::wallet_description::{KEY_SIZE, NONCE_SIZE}; 4 | 5 | pub const MAC_LENGTH: usize = alkali::symmetric::aead::xchacha20poly1305_ietf::MAC_LENGTH; 6 | 7 | pub(crate) fn default_encrypt( 8 | key: &SecretBox<[u8; KEY_SIZE]>, 9 | nonce: &[u8; NONCE_SIZE], 10 | message: &SecretBox>, 11 | ) -> anyhow::Result> { 12 | let ciphertext = 13 | libsodium_encrypt_xchacha20poly1305(key.expose_secret(), nonce, message.expose_secret())?; 14 | Ok(ciphertext) 15 | } 16 | 17 | pub(crate) fn default_decrypt( 18 | key: &SecretBox<[u8; KEY_SIZE]>, 19 | nonce: &[u8; NONCE_SIZE], 20 | ciphertext: &[u8], 21 | ) -> anyhow::Result>> { 22 | let plaintext = libsodium_decrypt_xchacha20poly1305(key.expose_secret(), nonce, ciphertext)?; 23 | let cleartext = SecretBox::from(Box::new(plaintext)); 24 | Ok(cleartext) 25 | } 26 | 27 | fn libsodium_encrypt_xchacha20poly1305( 28 | key: &[u8; KEY_SIZE], 29 | nonce: &[u8; NONCE_SIZE], 30 | message: &[u8], 31 | ) -> anyhow::Result> { 32 | let mut k = alkali::symmetric::aead::xchacha20poly1305_ietf::Key::new_empty()?; 33 | k.copy_from_slice(key); 34 | let mut ciphertext = 35 | vec![0u8; message.len() + alkali::symmetric::aead::xchacha20poly1305_ietf::MAC_LENGTH]; 36 | alkali::symmetric::aead::xchacha20poly1305_ietf::encrypt( 37 | message, 38 | None, 39 | &k, 40 | Some(nonce), 41 | &mut ciphertext, 42 | )?; 43 | Ok(ciphertext) 44 | } 45 | 46 | fn libsodium_decrypt_xchacha20poly1305( 47 | key: &[u8; KEY_SIZE], 48 | nonce: &[u8; NONCE_SIZE], 49 | ciphertext: &[u8], 50 | ) -> anyhow::Result> { 51 | let mut k = alkali::symmetric::aead::xchacha20poly1305_ietf::Key::new_empty()?; 52 | k.copy_from_slice(key); 53 | let mut plaintext = 54 | vec![0u8; ciphertext.len() - alkali::symmetric::aead::xchacha20poly1305_ietf::MAC_LENGTH]; 55 | alkali::symmetric::aead::xchacha20poly1305_ietf::decrypt( 56 | ciphertext, 57 | None, 58 | &k, 59 | nonce, 60 | &mut plaintext, 61 | )?; 62 | Ok(plaintext) 63 | } 64 | 65 | #[cfg(test)] 66 | mod tests { 67 | use chacha20poly1305::{KeyInit, aead::Aead}; 68 | use rand_core::RngCore; 69 | 70 | use crate::random_generation_utils::get_random_nonce; 71 | 72 | use super::*; 73 | fn rust_encrypt_xchacha20poly1305( 74 | key: &[u8; KEY_SIZE], 75 | nonce: &[u8; NONCE_SIZE], 76 | message: &[u8], 77 | ) -> anyhow::Result> { 78 | let cipher = chacha20poly1305::XChaCha20Poly1305::new(key.into()); 79 | let ciphertext = cipher 80 | .encrypt(nonce.into(), message) 81 | .map_err(|e| anyhow::anyhow!("Got on cipher.encrypt: {e:?}"))?; 82 | Ok(ciphertext) 83 | } 84 | 85 | fn rust_decrypt_xchacha20poly1305( 86 | key: &[u8; KEY_SIZE], 87 | nonce: &[u8; NONCE_SIZE], 88 | ciphertext: &[u8], 89 | ) -> anyhow::Result> { 90 | let cipher = chacha20poly1305::XChaCha20Poly1305::new(key.into()); 91 | let cleartext = cipher 92 | .decrypt(nonce.into(), ciphertext) 93 | .map_err(|e| anyhow::anyhow!("Got on cipher.decrypt: {e:?}"))?; 94 | Ok(cleartext) 95 | } 96 | 97 | fn orion_encrypt_xchacha20poly1305( 98 | key: &[u8; KEY_SIZE], 99 | nonce: &[u8; NONCE_SIZE], 100 | message: &[u8], 101 | ) -> anyhow::Result> { 102 | let key = orion::hazardous::aead::xchacha20poly1305::SecretKey::from_slice(key)?; 103 | let nonce = orion::aead::streaming::Nonce::from_slice(nonce)?; 104 | let mut ciphertext = 105 | vec![0u8; message.len() + alkali::symmetric::aead::xchacha20poly1305_ietf::MAC_LENGTH]; 106 | orion::hazardous::aead::xchacha20poly1305::seal( 107 | &key, 108 | &nonce, 109 | message, 110 | None, 111 | &mut ciphertext, 112 | )?; 113 | Ok(ciphertext) 114 | } 115 | 116 | fn orion_decrypt_xchacha20poly1305( 117 | key: &[u8; KEY_SIZE], 118 | nonce: &[u8; NONCE_SIZE], 119 | ciphertext: &[u8], 120 | ) -> anyhow::Result> { 121 | let key = orion::hazardous::aead::xchacha20poly1305::SecretKey::from_slice(key)?; 122 | let nonce = orion::aead::streaming::Nonce::from_slice(nonce)?; 123 | let mut cleartext = vec![ 124 | 0u8; 125 | ciphertext.len() 126 | - alkali::symmetric::aead::xchacha20poly1305_ietf::MAC_LENGTH 127 | ]; 128 | orion::hazardous::aead::xchacha20poly1305::open( 129 | &key, 130 | &nonce, 131 | ciphertext, 132 | None, 133 | &mut cleartext, 134 | )?; 135 | Ok(cleartext) 136 | } 137 | 138 | #[test] 139 | fn test_encryption() -> anyhow::Result<()> { 140 | use pretty_assertions::assert_eq; 141 | let mut rng = rand::thread_rng(); 142 | let mut key = [0u8; KEY_SIZE]; 143 | rng.fill_bytes(&mut key); 144 | let nonce = get_random_nonce(&mut rng)?; 145 | let mut message = [0u8; 1024]; 146 | rng.fill_bytes(&mut message); 147 | 148 | let sodium_encrypted_xchacha20poly1305 = 149 | libsodium_encrypt_xchacha20poly1305(&key, &nonce, &message)?; 150 | let rust_encrypted_xchacha20poly1305 = 151 | rust_encrypt_xchacha20poly1305(&key, &nonce, &message)?; 152 | let orion_encrypted_xchacha20poly1305 = 153 | orion_encrypt_xchacha20poly1305(&key, &nonce, &message)?; 154 | let sodium_decrypted_xchacha20poly1305 = 155 | libsodium_decrypt_xchacha20poly1305(&key, &nonce, &sodium_encrypted_xchacha20poly1305)?; 156 | let rust_decrypted_xchacha20poly1305 = 157 | rust_decrypt_xchacha20poly1305(&key, &nonce, &rust_encrypted_xchacha20poly1305)?; 158 | let orion_decrypted_xchacha20poly1305 = 159 | orion_decrypt_xchacha20poly1305(&key, &nonce, &orion_encrypted_xchacha20poly1305)?; 160 | assert_eq!(message.to_vec(), sodium_decrypted_xchacha20poly1305); 161 | assert_eq!(message.to_vec(), rust_decrypted_xchacha20poly1305); 162 | assert_eq!(message.to_vec(), orion_decrypted_xchacha20poly1305); 163 | assert_eq!( 164 | orion_encrypted_xchacha20poly1305, 165 | rust_encrypted_xchacha20poly1305, 166 | ); 167 | assert_eq!( 168 | rust_encrypted_xchacha20poly1305, 169 | sodium_encrypted_xchacha20poly1305, 170 | ); 171 | Ok(()) 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/commands/psbt.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use dialoguer::{console::Term, theme::Theme}; 4 | use frozenkrill_core::{ 5 | anyhow::{self, bail}, 6 | bitcoin::{ 7 | Address, Network, Psbt, 8 | secp256k1::{All, Secp256k1}, 9 | }, 10 | itertools::Itertools, 11 | log::{self, debug}, 12 | psbt::{open_psbt_file, save_psbt_file}, 13 | wallet_description::PsbtWallet, 14 | }; 15 | 16 | use crate::open_multisig_wallet_non_interactive; 17 | use crate::open_singlesig_wallet_non_interactive; 18 | 19 | use crate::{ 20 | InternetChecker, MultisigOpenArgs, SignPsbtArgs, SinglesigOpenArgs, handle_input_path, 21 | handle_output_path, 22 | }; 23 | 24 | use super::common::{double_check_non_duress_password, from_input_to_signed_psbt}; 25 | 26 | pub(super) fn validated_input_psbt_sign(psbt: &Psbt) -> anyhow::Result<()> { 27 | anyhow::ensure!( 28 | !psbt.outputs.is_empty(), 29 | "The PSBT has no outputs, better avoid signing that" 30 | ); 31 | Ok(()) 32 | } 33 | 34 | pub(super) fn ask_sign_non_duress(theme: &dyn Theme, term: &Term) -> anyhow::Result { 35 | let items = [ 36 | "Sign using default duress (decoy) wallet", 37 | "Sign using non duress (real) wallet", 38 | ]; 39 | let item = dialoguer::Select::with_theme(theme) 40 | .items(items) 41 | .default(1) 42 | .with_prompt("Pick an option") 43 | .interact_on(term)?; 44 | Ok(item == 1) 45 | } 46 | 47 | pub(super) fn sign_core( 48 | theme: &dyn Theme, 49 | term: &Term, 50 | secp: &Secp256k1, 51 | wallet: &impl PsbtWallet, 52 | mut psbt: Psbt, 53 | signed_psbt_output_file_path: &PathBuf, 54 | network: Network, 55 | ) -> anyhow::Result<()> { 56 | validated_input_psbt_sign(&psbt)?; 57 | let partial_sigs_count_before: usize = psbt.inputs.iter().map(|i| i.partial_sigs.len()).sum(); 58 | if wallet.sign_psbt(&mut psbt, secp)? == 0 { 59 | bail!( 60 | "This PSBT file has not input matching our fingerprints {}", 61 | wallet.get_pub_fingerprints().iter().join(" ") 62 | ) 63 | } 64 | let partial_sigs_count_after: usize = psbt.inputs.iter().map(|i| i.partial_sigs.len()).sum(); 65 | if partial_sigs_count_before == partial_sigs_count_after { 66 | log::warn!("The PSBT file has been already signed!"); 67 | if !dialoguer::Confirm::with_theme(theme) 68 | .with_prompt("Sign again?") 69 | .default(false) 70 | .interact_on(term)? 71 | { 72 | return Ok(()); 73 | } 74 | } 75 | let new_sigs = partial_sigs_count_after - partial_sigs_count_before; 76 | 77 | let fee = psbt.fee()?; 78 | let change_addresses = wallet.derive_change_addresses(0, 100, secp)?; 79 | for (output_index, (output, psbt_output)) in psbt 80 | .unsigned_tx 81 | .output 82 | .iter() 83 | .zip(&psbt.outputs) 84 | .enumerate() 85 | { 86 | let address = Address::from_script(output.script_pubkey.as_script(), network); 87 | let s = address 88 | .as_ref() 89 | .map(ToString::to_string) 90 | .unwrap_or_else(|_| output.script_pubkey.as_script().to_string()); 91 | let is_change = address.is_ok() && change_addresses.contains(address.as_ref().unwrap()); 92 | let maybe_change = address.is_ok() 93 | && !(psbt_output.bip32_derivation.is_empty() && psbt_output.tap_key_origins.is_empty()); 94 | let output_string = if is_change { 95 | "Change" 96 | } else if maybe_change { 97 | "Change?" 98 | } else { 99 | "Output" 100 | }; 101 | let address_type = match address.as_ref().map(|a| a.address_type()) { 102 | Ok(Some(address_type)) => address_type.to_string(), 103 | other => { 104 | debug!("Got {other:?} while trying to figure address type"); 105 | "unknown".to_string() 106 | } 107 | }; 108 | eprintln!( 109 | "- {output_index}: {output_string} {s} ({address_type}): {:.8} BTC", 110 | output.value.to_btc() 111 | ) 112 | } 113 | eprintln!("Fee: {fee} sats"); 114 | if dialoguer::Confirm::with_theme(theme) 115 | .with_prompt("Sign the transaction with the above outputs?") 116 | .interact_on(term)? 117 | { 118 | save_psbt_file(&psbt, signed_psbt_output_file_path)?; 119 | log::info!("Saved signed PSBT to {signed_psbt_output_file_path:?}"); 120 | log::info!("Added {new_sigs} new signatures"); 121 | Ok(()) 122 | } else { 123 | Ok(()) 124 | } 125 | } 126 | 127 | pub(crate) fn singlesig_sign_non_interactive( 128 | theme: &dyn Theme, 129 | term: &Term, 130 | secp: &Secp256k1, 131 | ic: impl InternetChecker, 132 | open_args: &SinglesigOpenArgs, 133 | args: &SignPsbtArgs, 134 | ) -> anyhow::Result<()> { 135 | let input_psbt_file_path = handle_input_path(&args.input_psbt_file)?; 136 | let psbt = open_psbt_file(&input_psbt_file_path)?; 137 | validated_input_psbt_sign(&psbt)?; 138 | let signed_psbt_output_file_path = match &args.signed_output_psbt_file { 139 | Some(s) => s.to_owned(), 140 | None => from_input_to_signed_psbt(&input_psbt_file_path)? 141 | .display() 142 | .to_string(), 143 | }; 144 | let signed_psbt_output_file_path = 145 | handle_output_path(&signed_psbt_output_file_path)?.to_path_buf(); 146 | let (wallet, non_duress_password) = 147 | open_singlesig_wallet_non_interactive(theme, term, secp, ic, open_args)?; 148 | let wallet = match non_duress_password { 149 | Some(non_duress_password) => { 150 | if ask_sign_non_duress(theme, term)? { 151 | double_check_non_duress_password(theme, term, &non_duress_password)?; 152 | wallet.change_seed_password(&Some(non_duress_password), secp)? 153 | } else { 154 | wallet 155 | } 156 | } 157 | None => wallet, 158 | }; 159 | sign_core( 160 | theme, 161 | term, 162 | secp, 163 | &wallet, 164 | psbt, 165 | &signed_psbt_output_file_path, 166 | wallet.network, 167 | ) 168 | } 169 | 170 | pub(crate) fn multisig_sign_non_interactive( 171 | theme: &dyn Theme, 172 | term: &Term, 173 | secp: &Secp256k1, 174 | ic: impl InternetChecker, 175 | open_args: &MultisigOpenArgs, 176 | args: &SignPsbtArgs, 177 | ) -> anyhow::Result<()> { 178 | let input_psbt_file_path = handle_input_path(&args.input_psbt_file)?; 179 | let psbt = open_psbt_file(&input_psbt_file_path)?; 180 | validated_input_psbt_sign(&psbt)?; 181 | let signed_psbt_output_file_path = match &args.signed_output_psbt_file { 182 | Some(s) => s.to_owned(), 183 | None => from_input_to_signed_psbt(&input_psbt_file_path)? 184 | .display() 185 | .to_string(), 186 | }; 187 | let signed_psbt_output_file_path = 188 | handle_output_path(&signed_psbt_output_file_path)?.to_path_buf(); 189 | let wallet = open_multisig_wallet_non_interactive(theme, term, secp, ic, open_args)?; 190 | sign_core( 191 | theme, 192 | term, 193 | secp, 194 | &wallet, 195 | psbt, 196 | &signed_psbt_output_file_path, 197 | wallet.network, 198 | ) 199 | } 200 | -------------------------------------------------------------------------------- /src/commands/common/mod.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | 3 | use base32::Alphabet; 4 | use dialoguer::{console::Term, theme::Theme}; 5 | use frozenkrill_core::{ 6 | anyhow::{self, Context, bail}, 7 | bitcoin::secp256k1::{All, Secp256k1}, 8 | blake3, 9 | log::{self, debug}, 10 | rand_core::CryptoRng, 11 | utils, 12 | wallet_description::{self, EncryptedWalletDescription, KEY_SIZE, SigType}, 13 | wallet_export::{ 14 | GenericOutputExportJson, MultisigJsonWalletPublicExportV0, 15 | SinglesigJsonWalletPublicExportV0, 16 | }, 17 | }; 18 | 19 | use frozenkrill_core::secrecy::{ExposeSecret, SecretString}; 20 | use frozenkrill_core::wallet_description::SingleSigWalletDescriptionV0; 21 | 22 | use crate::{ask_password, handle_output_path, ui_derive_key}; 23 | 24 | pub(crate) mod multisig; 25 | pub(crate) mod singlesig; 26 | 27 | pub(crate) const CONTEXT_CORRUPTION_WARNING: &str = "failure decrypting wallet, some data has been corrupted on disk or memory, change the destination disk or check the ram memory"; 28 | 29 | const PUB_FILE_SUFFIX: &str = "_pub.json"; 30 | const PUB_NON_DURESS_FILE_SUFFIX: &str = "_non_duress.json"; 31 | const SIGNED_PSBT_FILE_SUFFIX: &str = "_signed.psbt"; 32 | const REENCODED_FILE_SUFFIX: &str = "_reencoded"; 33 | 34 | pub(crate) fn ask_try_open_again_multisig_parse_multisig_input( 35 | theme: &dyn Theme, 36 | term: &Term, 37 | ) -> anyhow::Result { 38 | Ok(dialoguer::Confirm::with_theme(theme) 39 | .with_prompt("Got an error, try to open another file?") 40 | .default(true) 41 | .interact_on_opt(term)? 42 | .unwrap_or_default()) 43 | } 44 | 45 | pub(crate) fn keyfiles_elevator_pitch() { 46 | eprintln!("It's highly recommended to use at least one keyfile"); 47 | eprintln!("If the password is really strong, it's easy to be forgotten"); 48 | eprintln!("If it's weak, it can be brute forced"); 49 | eprintln!("A keyfile provides a complementary strong password that is easy to remember"); 50 | eprintln!("The keyfile can be any file that you are always able to retrieve on demand"); 51 | eprintln!("For instance, it can be a publicly available picture, song or book. Be creative!"); 52 | } 53 | 54 | pub(crate) struct AddressGenerationParams { 55 | pub first_index: u32, 56 | pub quantity: u32, 57 | } 58 | 59 | pub(crate) fn generate_random_name( 60 | prefix: &str, 61 | suffix: &str, 62 | rng: &mut impl CryptoRng, 63 | ) -> anyhow::Result { 64 | let mut name_salt = [0u8; KEY_SIZE]; 65 | rng.fill_bytes(&mut name_salt); 66 | Ok(generate_name(prefix, &name_salt, 0, suffix)) 67 | } 68 | 69 | pub(crate) fn generate_name( 70 | prefix: &str, 71 | name_salt: &[u8; KEY_SIZE], 72 | index: usize, 73 | suffix: &str, 74 | ) -> String { 75 | let mut hasher = blake3::Hasher::new_keyed(name_salt); 76 | hasher.update(prefix.as_bytes()); 77 | hasher.update(&index.to_ne_bytes()); 78 | let mut output = [0; 10]; 79 | let mut output_reader = hasher.finalize_xof(); 80 | output_reader.fill(&mut output); 81 | let hash = base32::encode(Alphabet::Crockford, &output).to_lowercase(); 82 | format!("{prefix}{hash}{suffix}") 83 | } 84 | 85 | pub(crate) fn from_wallet_to_public_info_json_path( 86 | wallet_output_path: &Path, 87 | ) -> anyhow::Result { 88 | extend_base_name_with_suffix(wallet_output_path, PUB_FILE_SUFFIX) 89 | } 90 | 91 | pub(crate) fn from_input_to_signed_psbt(file: &Path) -> anyhow::Result { 92 | extend_base_name_with_suffix(file, SIGNED_PSBT_FILE_SUFFIX) 93 | } 94 | 95 | pub(crate) fn from_input_to_reencoded(file: &Path) -> anyhow::Result { 96 | extend_base_name_with_suffix(file, REENCODED_FILE_SUFFIX) 97 | } 98 | 99 | pub(crate) fn from_public_info_json_path_to_non_duress(file: &Path) -> anyhow::Result { 100 | extend_base_name_with_suffix(file, PUB_NON_DURESS_FILE_SUFFIX) 101 | } 102 | 103 | pub(crate) fn double_check_non_duress_password( 104 | theme: &dyn Theme, 105 | term: &Term, 106 | non_duress_password: &SecretString, 107 | ) -> anyhow::Result<()> { 108 | eprintln!("We will ask again for the non duress password"); 109 | eprintln!("If you forget or misstype it your funds will be lost"); 110 | eprintln!("So let's double check it"); 111 | loop { 112 | let again = dialoguer::Password::with_theme(theme) 113 | .with_prompt("Enter the non duress seed password again") 114 | .interact_on(term) 115 | .context("failure reading password")?; 116 | if non_duress_password.expose_secret() == again { 117 | return Ok(()); 118 | } else { 119 | log::error!("Passwords don't match. Type the same password as used before") 120 | } 121 | } 122 | } 123 | 124 | fn extend_base_name_with_suffix(base_name: &Path, suffix: &str) -> anyhow::Result { 125 | let mut output_name = base_name 126 | .file_stem() 127 | .ok_or_else(|| anyhow::anyhow!("Output path {base_name:?} isn't a file"))? 128 | .to_str() 129 | .ok_or_else(|| anyhow::anyhow!("Output path {base_name:?} has a invalid name"))? 130 | .to_string(); 131 | output_name.push_str(suffix); 132 | Ok(base_name.with_file_name(output_name.as_str())) 133 | } 134 | 135 | fn ask_try_decrypt( 136 | path: &Path, 137 | encrypted_wallet: &EncryptedWalletDescription, 138 | theme: &dyn Theme, 139 | term: &Term, 140 | secp: &Secp256k1, 141 | ) -> anyhow::Result { 142 | eprintln!("Trying to open singlesig wallet {path:?}"); 143 | let keyfiles = crate::commands::interactive::open::ask_for_keyfiles_open(theme, term)?; 144 | let difficulty = crate::commands::interactive::get_ask_difficulty(theme, term, None)?; 145 | let password = ask_password(theme, term)?; 146 | let non_duress_password = 147 | if crate::commands::interactive::open::ask_to_open_duress(theme, term)? { 148 | Some(crate::ask_non_duress_password(theme, term)?) 149 | } else { 150 | None 151 | }; 152 | let key = ui_derive_key(&password, &keyfiles, &encrypted_wallet.salt, &difficulty)?; 153 | let seed_password = &None; 154 | let json_wallet = encrypted_wallet.decrypt_singlesig(&key, seed_password, secp)?; 155 | let wallet = crate::ui_get_singlesig_wallet_description(&json_wallet, seed_password, secp)?; 156 | wallet.change_seed_password(&non_duress_password, secp) 157 | } 158 | 159 | pub(crate) fn calculate_non_duress_output( 160 | enable_duress_wallet: bool, 161 | non_duress_output_file_json: &Option, 162 | public_json_file_path: &Option, 163 | ) -> anyhow::Result> { 164 | let non_duress_output_file_json = match ( 165 | enable_duress_wallet, 166 | non_duress_output_file_json, 167 | public_json_file_path, 168 | ) { 169 | (_, Some(p), _) => Some(handle_output_path(p)?.into_owned()), 170 | (true, None, Some(public_json_file_path)) => Some( 171 | handle_output_path( 172 | from_public_info_json_path_to_non_duress(public_json_file_path)? 173 | .display() 174 | .to_string() 175 | .as_str(), 176 | )? 177 | .into_owned(), 178 | ), 179 | (true, None, None) => { 180 | bail!("If duress wallet is enabled a public json must be specified") 181 | } 182 | (false, None, _) => None, 183 | }; 184 | Ok(non_duress_output_file_json) 185 | } 186 | 187 | #[derive(Debug)] 188 | pub(crate) enum ParsedWalletInputFile { 189 | Encrypted(EncryptedWalletDescription), 190 | PublicInfo(PublicInfoInput), 191 | } 192 | 193 | #[derive(Debug)] 194 | pub(crate) enum PublicInfoInput { 195 | // TODO: add single sig pub support 196 | // TODO: add plain output descriptors (using something like try_parse_input_as_simple_descriptor) 197 | MultisigJson(MultisigJsonWalletPublicExportV0), 198 | } 199 | 200 | pub fn try_open_as_json_input(file: &Path) -> anyhow::Result { 201 | match GenericOutputExportJson::deserialize(utils::buf_open_file(file)?) { 202 | Ok(g) => match g.version_sigtype()? { 203 | (Some(wallet_description::ZERO_SINGLESIG_WALLET_VERSION), Some(SigType::Singlesig)) => { 204 | let _v = SinglesigJsonWalletPublicExportV0::from_path(file)?; 205 | let message = "Using a singlesig json pub export isn't supported right now"; 206 | debug!("{message}"); 207 | bail!("{message}") 208 | } 209 | ( 210 | Some(wallet_description::ZERO_MULTISIG_WALLET_VERSION), 211 | Some(SigType::Multisig(_)), 212 | ) => { 213 | let v = MultisigJsonWalletPublicExportV0::from_path(file)?; 214 | debug!("Will open {file:?} as multisig pub json"); 215 | Ok(PublicInfoInput::MultisigJson(v)) 216 | } 217 | _ => { 218 | let message = format!("Unrecognized json file {file:?}"); 219 | debug!("{message}"); 220 | bail!("{message}") 221 | } 222 | }, 223 | Err(e) => Err(e), 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /frozenkrill-core/src/random_generation_utils.rs: -------------------------------------------------------------------------------- 1 | use alkali::random; 2 | use anyhow::Context; 3 | use bitcoin::bip32::{ChildNumber, DerivationPath, Fingerprint, Xpub}; 4 | use bitcoin::secp256k1::{All, Secp256k1}; 5 | use miniscript::{ 6 | Descriptor, DescriptorPublicKey, 7 | descriptor::{DerivPaths, Wildcard}, 8 | }; 9 | use rand_core::CryptoRng; 10 | 11 | use crate::{ 12 | MAX_BASE_PADDING_BYTES, OptOrigin, PaddingParams, 13 | wallet_description::{KEY_SIZE, NONCE_SIZE, SALT_SIZE}, 14 | }; 15 | 16 | pub fn get_random_salt(rng: &mut impl CryptoRng) -> anyhow::Result<[u8; SALT_SIZE]> { 17 | let mut salt = [0u8; SALT_SIZE]; 18 | rng.fill_bytes(&mut salt); 19 | Ok(salt) 20 | } 21 | 22 | pub fn get_random_nonce(rng: &mut impl CryptoRng) -> anyhow::Result<[u8; NONCE_SIZE]> { 23 | let mut nonce = [0u8; NONCE_SIZE]; 24 | rng.fill_bytes(&mut nonce); 25 | Ok(nonce) 26 | } 27 | 28 | pub fn get_random_key(rng: &mut impl CryptoRng) -> anyhow::Result<[u8; KEY_SIZE]> { 29 | let mut key = [0u8; KEY_SIZE]; 30 | rng.fill_bytes(&mut key); 31 | Ok(key) 32 | } 33 | 34 | pub fn get_additional_random_padding_bytes( 35 | rng: &mut impl CryptoRng, 36 | params: &PaddingParams, 37 | ) -> anyhow::Result> { 38 | let padding_size = Ord::max(rng.next_u32() % (params.max + 1), params.min); 39 | let padding_size = padding_size.try_into().expect("to be within usize"); 40 | let mut padding = vec![0; padding_size]; 41 | rng.fill_bytes(&mut padding); 42 | Ok(padding) 43 | } 44 | 45 | pub fn get_base_random_padding_bytes( 46 | rng: &mut impl CryptoRng, 47 | ) -> anyhow::Result<[u8; MAX_BASE_PADDING_BYTES]> { 48 | let mut padding = [0u8; MAX_BASE_PADDING_BYTES]; 49 | rng.fill_bytes(&mut padding); 50 | Ok(padding) 51 | } 52 | 53 | pub fn get_secp(rng: &mut Rng) -> Secp256k1 { 54 | let mut seed = [0u8; 32]; 55 | rng.fill_bytes(&mut seed); 56 | let mut s = Secp256k1::new(); 57 | s.seeded_randomize(&seed); 58 | s 59 | } 60 | 61 | pub fn random_pkh_descriptor( 62 | secp: &Secp256k1, 63 | rng: &mut impl CryptoRng, 64 | ) -> anyhow::Result> { 65 | let pk = random_descriptor_pk(secp, rng)?; 66 | Ok(Descriptor::Pkh(miniscript::descriptor::Pkh::new(pk)?)) 67 | } 68 | 69 | pub fn random_wpkh_descriptor( 70 | secp: &Secp256k1, 71 | rng: &mut impl CryptoRng, 72 | ) -> anyhow::Result> { 73 | let pk = random_descriptor_pk(secp, rng)?; 74 | Ok(Descriptor::Wpkh(miniscript::descriptor::Wpkh::new(pk)?)) 75 | } 76 | 77 | pub fn random_sh_descriptors( 78 | n: usize, 79 | secp: &Secp256k1, 80 | rng: &mut impl CryptoRng, 81 | ) -> anyhow::Result>> { 82 | let mut v = Vec::with_capacity(n); 83 | for _ in 0..n { 84 | v.push(random_sh_descriptor(secp, rng)?) 85 | } 86 | Ok(v) 87 | } 88 | 89 | pub fn random_sh_descriptor( 90 | secp: &Secp256k1, 91 | rng: &mut impl CryptoRng, 92 | ) -> anyhow::Result> { 93 | match random::random_u32_in_range(0, 2)? { 94 | 0 => random_sh_sortedmulti_descriptor(secp, rng), 95 | 1 => random_sh_wpkh_descriptor(secp, rng), 96 | // TODO: add others sh 97 | _others => unreachable!(), 98 | } 99 | } 100 | 101 | pub fn random_sh_sortedmulti_descriptor( 102 | secp: &Secp256k1, 103 | rng: &mut impl CryptoRng, 104 | ) -> anyhow::Result> { 105 | let npks = random::random_u32_in_range(1, 16)?; 106 | let k: usize = random::random_u32_in_range(1, npks)?.try_into()?; 107 | 108 | let mut dpks = Vec::with_capacity(npks.try_into()?); 109 | for _ in 0..npks { 110 | let dpk = random_descriptor_pk(secp, rng)?; 111 | dpks.push(dpk); 112 | } 113 | 114 | Ok(Descriptor::Sh(miniscript::descriptor::Sh::new_sortedmulti( 115 | k, dpks, 116 | )?)) 117 | } 118 | 119 | pub fn random_sh_wpkh_descriptor( 120 | secp: &Secp256k1, 121 | rng: &mut impl CryptoRng, 122 | ) -> anyhow::Result> { 123 | Ok(Descriptor::Sh(miniscript::descriptor::Sh::new_with_wpkh( 124 | miniscript::descriptor::Wpkh::new(random_descriptor_pk(secp, rng)?)?, 125 | ))) 126 | } 127 | 128 | pub fn random_wsh_descriptors( 129 | n: usize, 130 | secp: &Secp256k1, 131 | rng: &mut impl CryptoRng, 132 | ) -> anyhow::Result>> { 133 | let mut v = Vec::with_capacity(n); 134 | for _ in 0..n { 135 | v.push(random_wsh_descriptor(secp, rng)?) 136 | } 137 | Ok(v) 138 | } 139 | 140 | pub fn random_wsh_descriptor( 141 | secp: &Secp256k1, 142 | rng: &mut impl CryptoRng, 143 | ) -> anyhow::Result> { 144 | let npks = random::random_u32_in_range(1, 16)?; 145 | let k: usize = random::random_u32_in_range(1, npks)?.try_into()?; 146 | 147 | let mut dpks = Vec::with_capacity(npks.try_into()?); 148 | for _ in 0..npks { 149 | let dpk = random_descriptor_pk(secp, rng)?; 150 | dpks.push(dpk); 151 | } 152 | 153 | // TODO: add others Wsh types 154 | Ok(Descriptor::Wsh( 155 | miniscript::descriptor::Wsh::new_sortedmulti(k, dpks)?, 156 | )) 157 | } 158 | 159 | pub fn random_single_full_pk( 160 | secp: &Secp256k1, 161 | rng: &mut impl CryptoRng, 162 | ) -> miniscript::descriptor::SinglePubKey { 163 | let pk = random_pk(secp, rng); 164 | miniscript::descriptor::SinglePubKey::FullKey(bitcoin::PublicKey::from(pk)) 165 | } 166 | 167 | pub fn _random_single_pk( 168 | secp: &Secp256k1, 169 | rng: &mut impl CryptoRng, 170 | ) -> miniscript::descriptor::SinglePubKey { 171 | let pk = random_pk(secp, rng); 172 | let full: bool = rand::random(); 173 | if full { 174 | miniscript::descriptor::SinglePubKey::FullKey(bitcoin::PublicKey::from(pk)) 175 | } else { 176 | let (xonly, _) = pk.x_only_public_key(); 177 | miniscript::descriptor::SinglePubKey::XOnly(xonly) 178 | } 179 | } 180 | 181 | pub fn random_descriptor_pk( 182 | secp: &Secp256k1, 183 | rng: &mut impl CryptoRng, 184 | ) -> anyhow::Result { 185 | match random::random_u32_in_range(0, 3)? { 186 | 0 => { 187 | let key = random_single_full_pk(secp, rng); 188 | let k = miniscript::descriptor::SinglePub { 189 | origin: random_opt_origin()?, 190 | key, 191 | }; 192 | Ok(DescriptorPublicKey::Single(k)) 193 | } 194 | 1 => { 195 | let d = miniscript::descriptor::DescriptorXKey { 196 | origin: random_opt_origin()?, 197 | xkey: random_xpub(secp, rng)?, 198 | derivation_path: random_derivation_path()?, 199 | wildcard: random_wildcard()?, 200 | }; 201 | Ok(DescriptorPublicKey::XPub(d)) 202 | } 203 | 2 => { 204 | let d = miniscript::descriptor::DescriptorMultiXKey { 205 | origin: random_opt_origin()?, 206 | xkey: random_xpub(secp, rng)?, 207 | derivation_paths: random_deriv_paths()?, 208 | wildcard: random_wildcard()?, 209 | }; 210 | Ok(DescriptorPublicKey::MultiXPub(d)) 211 | } 212 | _other => unreachable!(), 213 | } 214 | } 215 | 216 | pub fn random_pk( 217 | secp: &Secp256k1, 218 | rng: &mut impl CryptoRng, 219 | ) -> bitcoin::secp256k1::PublicKey { 220 | let mut secret_bytes = [0u8; 32]; 221 | rng.fill_bytes(&mut secret_bytes); 222 | let secret = 223 | bitcoin::secp256k1::SecretKey::from_slice(&secret_bytes).expect("32 bytes always valid"); 224 | let kp = bitcoin::secp256k1::Keypair::from_secret_key(secp, &secret); 225 | kp.public_key() 226 | } 227 | 228 | pub fn random_xpub( 229 | secp: &Secp256k1, 230 | rng: &mut impl CryptoRng, 231 | ) -> anyhow::Result { 232 | let chain_code: [u8; 32] = rand::random(); 233 | let xkey = Xpub { 234 | network: bitcoin::NetworkKind::Test, 235 | depth: rand::random(), 236 | parent_fingerprint: random_fingerprint(), 237 | child_number: random_child_number()?, 238 | public_key: random_pk(secp, rng), 239 | chain_code: chain_code.into(), 240 | }; 241 | Ok(xkey) 242 | } 243 | 244 | pub fn random_fingerprint() -> Fingerprint { 245 | let fingerprint: [u8; 4] = rand::random(); 246 | fingerprint.into() 247 | } 248 | 249 | pub fn random_opt_origin() -> anyhow::Result { 250 | let none: bool = rand::random(); 251 | if none { 252 | Ok(None) 253 | } else { 254 | let fingerprint = random_fingerprint(); 255 | let derivation_path = random_derivation_path()?; 256 | Ok(Some((fingerprint, derivation_path))) 257 | } 258 | } 259 | 260 | pub fn random_wildcard() -> anyhow::Result { 261 | Ok(match random::random_u32_in_range(0, 3)? { 262 | 0 => Wildcard::None, 263 | 1 => Wildcard::Unhardened, 264 | 2 => Wildcard::Hardened, 265 | _other => unreachable!(), 266 | }) 267 | } 268 | 269 | pub fn random_deriv_paths() -> anyhow::Result { 270 | let n: usize = random::random_u32_in_range(1, 9)?.try_into()?; 271 | let mut v = Vec::with_capacity(n); 272 | for _ in 0..n { 273 | v.push(random_derivation_path()?) 274 | } 275 | 276 | DerivPaths::new(v).context("empty paths") 277 | } 278 | 279 | pub fn random_derivation_path() -> anyhow::Result { 280 | let n: usize = random::random_u32_in_range(2, 9)?.try_into()?; 281 | let mut v = Vec::with_capacity(n); 282 | for _ in 0..n { 283 | v.push(random_child_number()?); 284 | } 285 | let derivation_path = DerivationPath::from(v); 286 | Ok(derivation_path) 287 | } 288 | 289 | pub fn random_normal_child_number() -> anyhow::Result { 290 | let index = random::random_u32_in_range(0, 1 << 31)?; 291 | Ok(ChildNumber::from_normal_idx(index)?) 292 | } 293 | 294 | pub fn random_child_number() -> anyhow::Result { 295 | let normal: bool = rand::random(); 296 | let index = random::random_u32_in_range(0, 1 << 31)?; 297 | if normal { 298 | Ok(ChildNumber::from_normal_idx(index)?) 299 | } else { 300 | Ok(ChildNumber::from_hardened_idx(index)?) 301 | } 302 | } 303 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS -------------------------------------------------------------------------------- /src/commands/interactive/generate_one.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fs, 3 | path::{Path, PathBuf}, 4 | str::FromStr, 5 | sync::Arc, 6 | }; 7 | 8 | use dialoguer::{console::Term, theme::Theme}; 9 | use frozenkrill_core::{ 10 | MultisigInputs, PaddingParams, 11 | anyhow::{self, bail}, 12 | bitcoin::secp256k1::{All, Secp256k1}, 13 | key_derivation::KeyDerivationDifficulty, 14 | rand_core::CryptoRng, 15 | secrecy::SecretString, 16 | wallet_description::{MAX_TOTAL_SIGS_MULTISIG, MultisigType, ScriptType}, 17 | }; 18 | use path_absolutize::Absolutize; 19 | 20 | use crate::commands::common::{generate_random_name, multisig::parse_multisig_input}; 21 | 22 | use crate::{ 23 | InternetChecker, 24 | commands::{ 25 | common::ask_try_open_again_multisig_parse_multisig_input, 26 | generate::core::{DuressInputArgs, MultisigCoreGenerateArgs, SinglesigCoreGenerateArgs}, 27 | }, 28 | handle_input_path, handle_output_path, ui_ask_manually_seed_input, 29 | }; 30 | 31 | use super::{ 32 | ValidateOutputFile, ask_addresses_quantity, ask_for_keyfiles_generate, ask_network, 33 | ask_non_duress_wallet_generate, ask_public_info_json_output, ask_user_generated_seed, 34 | ask_wallet_file_type, ask_word_count, get_ask_difficulty, 35 | }; 36 | 37 | #[allow(clippy::too_many_arguments)] 38 | pub(super) fn singlesig_interactive_generate_one( 39 | theme: &dyn Theme, 40 | term: &Term, 41 | secp: &mut Secp256k1, 42 | mut rng: &mut impl CryptoRng, 43 | ic: impl InternetChecker, 44 | keyfiles: Vec, 45 | difficulty: Option, 46 | enable_duress_wallet: bool, 47 | password: Option>, 48 | ) -> anyhow::Result<()> { 49 | let output_file_path = ask_generate_wallet_output_file(theme, term, rng)?; 50 | let keyfiles = if keyfiles.is_empty() { 51 | ask_for_keyfiles_generate(theme, term)? 52 | } else { 53 | keyfiles 54 | }; 55 | let public_info_json_output = ask_public_info_json_output(theme, term, &output_file_path)?; 56 | let addresses_quantity = match public_info_json_output { 57 | Some(_) => ask_addresses_quantity(theme, term)?, 58 | None => 0, 59 | }; 60 | let word_count = ask_word_count(theme, term)?; 61 | let wallet_file_type = ask_wallet_file_type(theme, term)?; 62 | let user_mnemonic = if ask_user_generated_seed(theme, term)? { 63 | Some(Arc::new(ui_ask_manually_seed_input( 64 | &mut rng, 65 | theme, 66 | term, 67 | &word_count, 68 | false, 69 | )?)) 70 | } else { 71 | None 72 | }; 73 | let difficulty = get_ask_difficulty(theme, term, difficulty)?; 74 | let network = ask_network(theme, term)?; 75 | let duress_input_args = DuressInputArgs { 76 | enable_duress_wallet: enable_duress_wallet || ask_non_duress_wallet_generate(theme, term)?, 77 | non_duress_output_file_json: None, 78 | public_json_file_path: public_info_json_output.clone(), 79 | }; 80 | let script_type = ScriptType::SegwitNative; 81 | let args = SinglesigCoreGenerateArgs { 82 | password, 83 | output_file_path, 84 | public_info_json_output, 85 | keyfiles: &keyfiles, 86 | user_mnemonic, 87 | duress_input_args, 88 | word_count, 89 | script_type, 90 | network, 91 | difficulty: &difficulty, 92 | addresses_quantity, 93 | padding_params: PaddingParams::default(), 94 | encrypted_wallet_version: wallet_file_type 95 | .to_encrypted_wallet_version(network, script_type)?, 96 | }; 97 | crate::commands::generate::core::singlesig_core_generate(theme, term, secp, rng, ic, args)?; 98 | Ok(()) 99 | } 100 | 101 | enum MultisigConfigurationOptions { 102 | Some(MultisigType), 103 | Other, 104 | } 105 | 106 | impl FromStr for MultisigConfigurationOptions { 107 | type Err = anyhow::Error; 108 | 109 | fn from_str(s: &str) -> Result { 110 | match s.to_ascii_lowercase().as_str() { 111 | "other" => Ok(Self::Other), 112 | other => MultisigType::from_str(other).map(Self::Some), 113 | } 114 | } 115 | } 116 | 117 | impl std::fmt::Display for MultisigConfigurationOptions { 118 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 119 | match self { 120 | MultisigConfigurationOptions::Some(o) => f.write_str(&o.to_string()), 121 | MultisigConfigurationOptions::Other => f.write_str("Other"), 122 | } 123 | } 124 | } 125 | 126 | fn ask_multisig_configuration(theme: &dyn Theme, term: &Term) -> anyhow::Result { 127 | let options = [ 128 | MultisigConfigurationOptions::Some(MultisigType::new(2, 3)?), 129 | MultisigConfigurationOptions::Some(MultisigType::new(3, 5)?), 130 | MultisigConfigurationOptions::Other, 131 | ]; 132 | let option = dialoguer::Select::with_theme(theme) 133 | .items(&options) 134 | .default(0) 135 | .interact_on(term)?; 136 | match options[option] { 137 | MultisigConfigurationOptions::Some(conf) => Ok(conf), 138 | MultisigConfigurationOptions::Other => { 139 | let total: u32 = dialoguer::Input::with_theme(theme).with_prompt(format!("How many total signatures in the multisig? (max {MAX_TOTAL_SIGS_MULTISIG})")).default(3).validate_with(|n: &u32| { 140 | if *n > 0 && *n <= MAX_TOTAL_SIGS_MULTISIG { 141 | Ok(()) 142 | } else { 143 | Err(format!("At least one signature is required and a maximum of {MAX_TOTAL_SIGS_MULTISIG} is allowed")) 144 | } 145 | } 146 | ).interact_on(term)?; 147 | let required: u32 = dialoguer::Input::with_theme(theme).with_prompt(format!("How many required signatures in the multisig? (max {total})")).default(total).validate_with(|n: &u32| { 148 | if *n > 0 && *n <= total { 149 | Ok(()) 150 | } else { 151 | Err(format!("At least one signature is required and a maximum of total = {total} is allowed")) 152 | } 153 | } 154 | ).interact_on(term)?; 155 | MultisigType::new(required, total) 156 | } 157 | } 158 | } 159 | 160 | pub(crate) fn ask_create_multisig_inputs( 161 | theme: &dyn Theme, 162 | term: &Term, 163 | secp: &mut Secp256k1, 164 | configuration: &MultisigType, 165 | ) -> anyhow::Result { 166 | let mut files = fs::read_dir(".")? 167 | .map(|i| i.map(|i| i.path().display().to_string())) 168 | .collect::, _>>()?; 169 | files.sort(); 170 | anyhow::ensure!( 171 | !files.is_empty(), 172 | "You can't pick a wallet or pub key from a json file because there are no files in current directory. Copy some files or change the current directory and try again" 173 | ); 174 | let total = configuration.total; 175 | eprintln!( 176 | "We are going to open {total} files to get the public keys for the {configuration} multisig" 177 | ); 178 | let mut result = MultisigInputs::default(); 179 | for i in 1..=total { 180 | loop { 181 | let file_index = dialoguer::Select::with_theme(theme) 182 | .with_prompt(format!( 183 | "Select a encrypted singlesig wallet or a json with the public keys ({i}/{total})" 184 | )) 185 | .items(&files) 186 | .interact_on(term)?; 187 | let parsed = 188 | parse_multisig_input(theme, term, secp, &handle_input_path(&files[file_index])?); 189 | let parsed = parsed.and_then(|input| { 190 | let descriptors_added = result.merge(input)?; 191 | if descriptors_added != 1 { 192 | bail!("Expected to read one new descriptor but got {descriptors_added} new descriptors, perhaps a duplicated file was selected?") 193 | } 194 | Ok(()) 195 | }); 196 | match parsed { 197 | Ok(()) => break, 198 | Err(e) => { 199 | eprintln!("{e:?}"); 200 | if !ask_try_open_again_multisig_parse_multisig_input(theme, term)? { 201 | bail!( 202 | "You need valid encrypted singlesig wallets or a json with the public keys to create a multisig" 203 | ) 204 | } 205 | } 206 | } 207 | } 208 | } 209 | Ok(result) 210 | } 211 | 212 | #[allow(clippy::too_many_arguments)] 213 | pub(super) fn multisig_interactive_generate_single( 214 | theme: &dyn Theme, 215 | term: &Term, 216 | secp: &mut Secp256k1, 217 | rng: &mut impl CryptoRng, 218 | mut ic: impl InternetChecker, 219 | keyfiles: Vec, 220 | difficulty: Option, 221 | password: Option>, 222 | ) -> anyhow::Result<()> { 223 | let configuration = ask_multisig_configuration(theme, term)?; 224 | ic.check()?; 225 | let inputs = ask_create_multisig_inputs(theme, term, secp, &configuration)?; 226 | let output_file_path_encrypted = ask_generate_wallet_output_file(theme, term, rng)?; 227 | let keyfiles = if keyfiles.is_empty() { 228 | ask_for_keyfiles_generate(theme, term)? 229 | } else { 230 | keyfiles 231 | }; 232 | let output_file_path_json = 233 | ask_public_info_json_output(theme, term, &output_file_path_encrypted)?; 234 | let addresses_quantity = match output_file_path_json { 235 | Some(_) => ask_addresses_quantity(theme, term)?, 236 | None => 0, 237 | }; 238 | let difficulty = get_ask_difficulty(theme, term, difficulty)?; 239 | let network = ask_network(theme, term)?; 240 | let wallet_file_type = ask_wallet_file_type(theme, term)?; 241 | let script_type = ScriptType::SegwitNative; 242 | let args = MultisigCoreGenerateArgs { 243 | password, 244 | keyfiles: &keyfiles, 245 | network, 246 | script_type, 247 | difficulty: &difficulty, 248 | addresses_quantity, 249 | padding_params: PaddingParams::default(), 250 | configuration, 251 | inputs, 252 | output_file_path_encrypted, 253 | output_file_path_json, 254 | encrypted_wallet_version: wallet_file_type 255 | .to_encrypted_wallet_version(network, script_type)?, 256 | }; 257 | crate::commands::generate::core::multisig_core_generate(theme, term, secp, rng, args)?; 258 | Ok(()) 259 | } 260 | 261 | fn ask_generate_wallet_output_file( 262 | theme: &dyn Theme, 263 | term: &Term, 264 | rng: &mut impl CryptoRng, 265 | ) -> anyhow::Result { 266 | let suggested = Path::new(&generate_random_name("wallet_", "", rng)?) 267 | .absolutize()? 268 | .display() 269 | .to_string(); 270 | let name = dialoguer::Input::with_theme(theme) 271 | .with_prompt("Where to save the encrypted output file?") 272 | .with_initial_text(suggested) 273 | .validate_with(ValidateOutputFile) 274 | .interact_text_on(term)?; 275 | Ok(handle_output_path(&name)?.into_owned()) 276 | } 277 | -------------------------------------------------------------------------------- /frozenkrill-core/src/key_derivation.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fmt::Display, 3 | fs::OpenOptions, 4 | io::{self, Read}, 5 | path::PathBuf, 6 | str::FromStr, 7 | }; 8 | 9 | use anyhow::{Context, bail}; 10 | use secrecy::{ExposeSecret, SecretBox, SecretString}; 11 | type Secret = SecretBox; 12 | 13 | use crate::wallet_description::{KEY_SIZE, SALT_SIZE}; 14 | 15 | pub struct Argon2DifficultyParams { 16 | pub ops_limit: u32, 17 | pub mem_limit_kbytes: u32, 18 | } 19 | 20 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] 21 | pub enum KeyDerivationDifficulty { 22 | Easy, 23 | Normal, 24 | Hard, 25 | VeryHard, 26 | } 27 | 28 | pub const DEFAULT_DIFFICULTY_LEVEL: KeyDerivationDifficulty = KeyDerivationDifficulty::Normal; 29 | 30 | pub const DIFFICULTY_LEVELS: [KeyDerivationDifficulty; 4] = [ 31 | KeyDerivationDifficulty::Easy, 32 | KeyDerivationDifficulty::Normal, 33 | KeyDerivationDifficulty::Hard, 34 | KeyDerivationDifficulty::VeryHard, 35 | ]; 36 | 37 | impl FromStr for KeyDerivationDifficulty { 38 | type Err = anyhow::Error; 39 | 40 | fn from_str(s: &str) -> Result { 41 | match s.to_lowercase().as_str() { 42 | "easy" => Ok(Self::Easy), 43 | "normal" => Ok(Self::Normal), 44 | "hard" => Ok(Self::Hard), 45 | "veryhard" => Ok(Self::VeryHard), 46 | other => bail!( 47 | "Invalid difficulty: {other}, valid options are: easy, normal, hard, veryhard" 48 | ), 49 | } 50 | } 51 | } 52 | 53 | impl Display for KeyDerivationDifficulty { 54 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 55 | f.write_str(self.as_str()) 56 | } 57 | } 58 | 59 | impl KeyDerivationDifficulty { 60 | pub const fn as_str(&self) -> &str { 61 | match self { 62 | KeyDerivationDifficulty::Easy => "Easy", 63 | KeyDerivationDifficulty::Normal => "Normal", 64 | KeyDerivationDifficulty::Hard => "Hard", 65 | KeyDerivationDifficulty::VeryHard => "VeryHard", 66 | } 67 | } 68 | 69 | const fn argon2_difficulty_params(&self) -> Argon2DifficultyParams { 70 | match self { 71 | KeyDerivationDifficulty::Easy => Argon2DifficultyParams { 72 | ops_limit: 42, 73 | mem_limit_kbytes: 256 * 1024, 74 | }, 75 | KeyDerivationDifficulty::Normal => Argon2DifficultyParams { 76 | ops_limit: 210, 77 | mem_limit_kbytes: 512 * 1024, 78 | }, 79 | KeyDerivationDifficulty::Hard => Argon2DifficultyParams { 80 | ops_limit: 930, 81 | mem_limit_kbytes: 1024 * 1024, 82 | }, 83 | KeyDerivationDifficulty::VeryHard => Argon2DifficultyParams { 84 | ops_limit: 5500, 85 | mem_limit_kbytes: 2 * 1024 * 1024, 86 | }, 87 | } 88 | } 89 | 90 | pub const fn estimate_time(&self) -> &str { 91 | match self { 92 | KeyDerivationDifficulty::Easy => "min 2-4s", 93 | KeyDerivationDifficulty::Normal => "min 20-40s", 94 | KeyDerivationDifficulty::Hard => "min 3-6m", 95 | KeyDerivationDifficulty::VeryHard => "min 40-80m", 96 | } 97 | } 98 | pub const fn estimate_memory(&self) -> &str { 99 | match self { 100 | KeyDerivationDifficulty::Easy => "256MB of RAM", 101 | KeyDerivationDifficulty::Normal => "512MB of RAM", 102 | KeyDerivationDifficulty::Hard => "1GB of RAM", 103 | KeyDerivationDifficulty::VeryHard => "2GB of RAM", 104 | } 105 | } 106 | } 107 | 108 | const KEYFILES_CONTEXT: &str = "frozenkrill keyfiles derivation"; 109 | 110 | fn libsodium_argon2id_derive_key( 111 | password: &[u8], 112 | salt: &[u8; SALT_SIZE], 113 | ops_limit: usize, 114 | mem_limit_kbytes: usize, 115 | ) -> anyhow::Result<[u8; KEY_SIZE]> { 116 | let mut key = [0u8; KEY_SIZE]; 117 | let mem_limit_bytes = mem_limit_kbytes * 1024; 118 | alkali::hash::pbkdf::argon2id::derive_key( 119 | password, 120 | salt, 121 | ops_limit, 122 | mem_limit_bytes, 123 | &mut key[..], 124 | )?; 125 | Ok(key) 126 | } 127 | 128 | fn _default_derive_key( 129 | password: &SecretString, 130 | keyfiles: &[PathBuf], 131 | salt: &[u8; SALT_SIZE], 132 | difficulty: &KeyDerivationDifficulty, 133 | ) -> anyhow::Result> { 134 | // note that if keyfiles is empty the resulting password will be the original password 135 | let password = generate_password_with_keyfiles(password, salt, keyfiles)?; 136 | let argon2_difficulty_params = difficulty.argon2_difficulty_params(); 137 | let key_array = libsodium_argon2id_derive_key( 138 | password.expose_secret(), 139 | salt, 140 | argon2_difficulty_params.ops_limit.try_into()?, 141 | argon2_difficulty_params.mem_limit_kbytes.try_into()?, 142 | )?; 143 | let key = Secret::from(Box::new(key_array)); 144 | Ok(key) 145 | } 146 | 147 | #[must_use = "expensive to calculate"] 148 | pub fn default_derive_key( 149 | password: &SecretString, 150 | keyfiles: &[PathBuf], 151 | salt: &[u8; SALT_SIZE], 152 | difficulty: &KeyDerivationDifficulty, 153 | ) -> anyhow::Result> { 154 | _default_derive_key(password, keyfiles, salt, difficulty) 155 | .context("failure deriving key, check if you have enough memory, perhaps try with a easier difficulty param") 156 | } 157 | 158 | fn copy_wide(mut reader: impl Read, hasher: &mut blake3::Hasher) -> io::Result { 159 | let mut buffer = vec![0; 10 * 1024 * 1024]; 160 | let mut total = 0; 161 | loop { 162 | match reader.read(&mut buffer) { 163 | Ok(0) => return Ok(total), 164 | Ok(n) => { 165 | hasher.update_rayon(&buffer[..n]); 166 | total += n as u64; 167 | } 168 | Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue, 169 | Err(e) => return Err(e), 170 | } 171 | } 172 | } 173 | 174 | fn generate_password_with_keyfiles( 175 | password: &SecretString, 176 | salt: &[u8], 177 | keyfiles: &[PathBuf], 178 | ) -> anyhow::Result>> { 179 | let mut hashes = Vec::with_capacity(keyfiles.len()); 180 | for keyfile in keyfiles { 181 | let mut hasher = blake3::Hasher::new_derive_key(KEYFILES_CONTEXT); 182 | let f = OpenOptions::new() 183 | .read(true) 184 | .open(keyfile) 185 | .with_context(|| format!("failure opening keyfile {}", keyfile.display()))?; 186 | copy_wide(f, &mut hasher) 187 | .with_context(|| format!("failure reading keyfile {}", keyfile.display()))?; 188 | hasher.update(password.expose_secret().as_bytes()); 189 | hasher.update(salt); 190 | hashes.push(hasher.finalize().as_bytes().to_vec()); 191 | } 192 | hashes.sort(); 193 | let mut concatenated_hashes = hashes.concat(); 194 | concatenated_hashes.extend(password.expose_secret().as_bytes()); 195 | Ok(SecretBox::from(Box::new(concatenated_hashes))) 196 | } 197 | 198 | #[cfg(test)] 199 | mod tests { 200 | use std::time::Instant; 201 | 202 | fn rust_argon2id_derive_key( 203 | password: &[u8], 204 | salt: &[u8; SALT_SIZE], 205 | ops_limit: u32, 206 | mem_limit_kbytes: u32, 207 | ) -> anyhow::Result<[u8; KEY_SIZE]> { 208 | let mut key = [0u8; KEY_SIZE]; 209 | let params = argon2::Params::new( 210 | mem_limit_kbytes, 211 | ops_limit, 212 | argon2::Params::DEFAULT_P_COST, 213 | None, 214 | ) 215 | .map_err(|e| anyhow::anyhow!("Error creating argon2 params: {e:?}"))?; 216 | let algorithm = 217 | argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params); 218 | algorithm 219 | .hash_password_into(password, salt, &mut key) 220 | .map_err(|e| anyhow::anyhow!("Error hashing password with argon2: {e:?}"))?; 221 | Ok(key) 222 | } 223 | 224 | fn rargon2_derive_key( 225 | password: &[u8], 226 | salt: &[u8; SALT_SIZE], 227 | ops_limit: u32, 228 | mem_limit_kbytes: u32, 229 | ) -> anyhow::Result<[u8; KEY_SIZE]> { 230 | let mut key = [0u8; KEY_SIZE]; 231 | let config = rargon2::Config { 232 | mem_cost: mem_limit_kbytes, 233 | time_cost: ops_limit, 234 | variant: rargon2::Variant::Argon2id, 235 | ..Default::default() 236 | }; 237 | let raw = rargon2::hash_raw(password, salt, &config)?; 238 | key.copy_from_slice(&raw); 239 | Ok(key) 240 | } 241 | 242 | use alkali::random; 243 | use rand_core::RngCore; 244 | 245 | use crate::{random_generation_utils::get_random_salt, utils::create_file}; 246 | 247 | use super::*; 248 | 249 | #[test] 250 | fn test_key_derivation() -> anyhow::Result<()> { 251 | use pretty_assertions::assert_eq; 252 | let mut rng = rand::thread_rng(); 253 | let mut password = [1u8; 1024 * 1024]; 254 | rng.fill_bytes(&mut password); 255 | let salt = get_random_salt(&mut rng)?; 256 | let difficulty = KeyDerivationDifficulty::Easy.argon2_difficulty_params(); 257 | let ops_limit = difficulty.ops_limit; 258 | let mem_limit = difficulty.mem_limit_kbytes; 259 | 260 | let libsodium_time = Instant::now(); 261 | let sodium_argon2_key = libsodium_argon2id_derive_key( 262 | &password, 263 | &salt, 264 | ops_limit.try_into()?, 265 | mem_limit.try_into()?, 266 | )?; 267 | dbg!(libsodium_time.elapsed()); 268 | let rust_argon2_time = Instant::now(); 269 | let rust_argon2_key = rust_argon2id_derive_key(&password, &salt, ops_limit, mem_limit)?; 270 | dbg!(rust_argon2_time.elapsed()); 271 | let rargon2_time = Instant::now(); 272 | let rargon2_key = rargon2_derive_key(&password, &salt, ops_limit, mem_limit)?; 273 | dbg!(rargon2_time.elapsed()); 274 | assert_eq!(sodium_argon2_key, rust_argon2_key); 275 | assert_eq!(rust_argon2_key, rargon2_key); 276 | 277 | Ok(()) 278 | } 279 | 280 | #[test] 281 | fn generate_password_with_keyfiles_test() -> anyhow::Result<()> { 282 | // very simple test for quick regression discover. more tests are present as integration tests 283 | let tempdir = tempfile::tempdir()?; 284 | let password = "abc123"; 285 | let keyfile = tempdir 286 | .path() 287 | .join(format!("whatever{i}", i = random::random_u32()?)); 288 | create_file("somecontent".as_bytes(), keyfile.as_path())?; 289 | let salt = vec![123u8; 32]; 290 | 291 | let secret = generate_password_with_keyfiles( 292 | &SecretString::new(password.into()), 293 | &salt, 294 | &[keyfile], 295 | )?; 296 | 297 | assert_eq!( 298 | hex::encode(secret.expose_secret().as_slice()), 299 | "a4cca66b4fa11239814965941b2d49f59543654cfd26ac547d8bf6de93d7546f616263313233" 300 | ); 301 | 302 | Ok(()) 303 | } 304 | 305 | #[test] 306 | fn generate_password_with_empty_keyfiles_test() -> anyhow::Result<()> { 307 | // if not keyfiles given, this function just returns the password 308 | let password = "abc123"; 309 | let salt = vec![123u8; 32]; 310 | let secret = 311 | generate_password_with_keyfiles(&SecretString::new(password.into()), &salt, &[])?; 312 | assert_eq!(secret.expose_secret().as_slice(), password.as_bytes()); 313 | Ok(()) 314 | } 315 | } 316 | -------------------------------------------------------------------------------- /src/commands/reencode.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | path::{Path, PathBuf}, 3 | sync::Arc, 4 | }; 5 | 6 | use dialoguer::{console::Term, theme::Theme}; 7 | use frozenkrill_core::{ 8 | PaddingParams, 9 | anyhow::{self, Context}, 10 | bitcoin::{ 11 | Network, 12 | secp256k1::{All, Secp256k1}, 13 | }, 14 | generate_encrypted_encoded_singlesig_wallet, get_padder, 15 | key_derivation::KeyDerivationDifficulty, 16 | log, parse_keyfiles_paths, 17 | rand_core::CryptoRng, 18 | secrecy::{ExposeSecret, SecretBox, SecretString}, 19 | utils::create_file, 20 | wallet_description::{ 21 | EncryptedWalletVersion, MultiSigWalletDescriptionV0, MultisigJsonWalletDescriptionV0, 22 | ScriptType, SingleSigWalletDescriptionV0, SinglesigJsonWalletDescriptionV0, 23 | read_decode_wallet, 24 | }, 25 | }; 26 | use frozenkrill_core::{key_derivation::default_derive_key, random_generation_utils::*}; 27 | 28 | use crate::{ 29 | InternetChecker, MultisigOpenArgs, MultisigReencodeArgs, SinglesigOpenArgs, 30 | SinglesigReencodeArgs, 31 | commands::{ 32 | common::CONTEXT_CORRUPTION_WARNING, 33 | generate::{core::generate_ask_password, inform_custom_generate_params}, 34 | }, 35 | get_derivation_key_spinner, handle_output_path, ui_derive_key, warn_difficulty_level, 36 | }; 37 | 38 | use super::common::from_input_to_reencoded; 39 | 40 | type Secret = SecretBox; 41 | 42 | pub(crate) fn singlesig_reencode_parse_args( 43 | open_args: &SinglesigOpenArgs, 44 | args: &SinglesigReencodeArgs, 45 | ) -> anyhow::Result { 46 | let output_file_path = match &args.wallet_output_file { 47 | Some(p) => handle_output_path(p)?.into_owned(), 48 | None => from_input_to_reencoded(Path::new(&open_args.common.wallet_input_file))?, 49 | }; 50 | let output_file_path = handle_output_path(&output_file_path)?.into_owned(); 51 | let keyfiles = parse_keyfiles_paths(&args.common.keyfile)?; 52 | let network = if args.common.use_testnet { 53 | Network::Testnet 54 | } else { 55 | Network::Bitcoin 56 | }; 57 | let script_type = ScriptType::SegwitNative; 58 | let password = args 59 | .common 60 | .password 61 | .clone() 62 | .map(|s| SecretString::new(s.into())) 63 | .map(Arc::new); 64 | Ok(SinglesigCoreReencodeArgs { 65 | password, 66 | output_file_path, 67 | keyfiles, 68 | script_type, 69 | network, 70 | difficulty: args.common.difficulty, 71 | padding_params: PaddingParams::new( 72 | args.common.disable_all_padding, 73 | Some(args.common.min_additional_padding_bytes), 74 | Some(args.common.max_additional_padding_bytes), 75 | )?, 76 | encrypted_wallet_version: args 77 | .common 78 | .wallet_file_type 79 | .to_encrypted_wallet_version(network, script_type)?, 80 | }) 81 | } 82 | 83 | pub(crate) struct SinglesigCoreReencodeArgs { 84 | pub(crate) password: Option>, 85 | pub(crate) output_file_path: PathBuf, 86 | pub(crate) keyfiles: Vec, 87 | pub(crate) script_type: ScriptType, 88 | pub(crate) network: Network, 89 | pub(crate) difficulty: KeyDerivationDifficulty, 90 | pub(crate) padding_params: PaddingParams, 91 | pub(crate) encrypted_wallet_version: EncryptedWalletVersion, 92 | } 93 | 94 | pub(crate) fn singlesig_core_reencode( 95 | theme: &dyn Theme, 96 | term: &Term, 97 | secp: &mut Secp256k1, 98 | mut rng: &mut impl CryptoRng, 99 | ic: impl InternetChecker, 100 | wallet: &SingleSigWalletDescriptionV0, 101 | args: SinglesigCoreReencodeArgs, 102 | ) -> anyhow::Result<()> { 103 | warn_difficulty_level(&args.difficulty); 104 | let output_file_path = &args.output_file_path; 105 | log::info!("Will generate a new wallet saving to {output_file_path:?}"); 106 | let password = args 107 | .password 108 | .map(Result::Ok) 109 | .unwrap_or_else(|| generate_ask_password(theme, term, Some(ic)))?; 110 | let salt = get_random_salt(&mut rng)?; 111 | let key = ui_derive_key(&password, &args.keyfiles, &salt, &args.difficulty)?; 112 | let nonce = get_random_nonce(&mut rng)?; 113 | let header_nonce = get_random_nonce(&mut rng)?; 114 | let header_key = Secret::from(Box::new(get_random_key(&mut rng)?)); 115 | let padder = get_padder(&mut rng, &args.padding_params)?; 116 | let mnemonic = Arc::clone(&wallet.mnemonic); 117 | let seed_password = &None; 118 | let encrypted_wallet = generate_encrypted_encoded_singlesig_wallet( 119 | &key, 120 | header_key, 121 | mnemonic, 122 | seed_password, 123 | salt, 124 | nonce, 125 | header_nonce, 126 | padder, 127 | args.script_type, 128 | args.network, 129 | args.encrypted_wallet_version, 130 | secp, 131 | )?; 132 | // Write file 133 | create_file(&encrypted_wallet, output_file_path) 134 | .with_context(|| format!("failure saving encrypted wallet to {output_file_path:?}"))?; 135 | log::info!("Wallet saved to {output_file_path:?}"); 136 | let mut seed = [0u8; 32]; 137 | rng.fill_bytes(&mut seed); 138 | secp.seeded_randomize(&seed); 139 | let encrypted_wallet = read_decode_wallet(output_file_path)?; 140 | log::info!("Will derive the key again to double check against bit flips..."); 141 | let pb = get_derivation_key_spinner(); 142 | let key = default_derive_key( 143 | &password, 144 | &args.keyfiles, 145 | &encrypted_wallet.salt, 146 | &args.difficulty, 147 | ) 148 | .context("failure trying to derive the same key again")?; 149 | pb.finish_using_style(); 150 | let read_json_wallet_description = encrypted_wallet 151 | .decrypt_singlesig(&key, seed_password, secp) 152 | .context(CONTEXT_CORRUPTION_WARNING)?; 153 | let wallet_description = read_json_wallet_description 154 | .expose_secret() 155 | .to(seed_password, secp) 156 | .context("failure parsing generated wallet")?; 157 | // Sanity check 158 | SinglesigJsonWalletDescriptionV0::validate_same( 159 | &read_json_wallet_description, 160 | &wallet_description, 161 | secp, 162 | )? 163 | .context("failure checking generated wallet")?; 164 | inform_custom_generate_params(&args.keyfiles, &args.difficulty, false); 165 | log::info!("Finished successfully!"); 166 | Ok(()) 167 | } 168 | 169 | pub(crate) struct MultisigCoreReencodeArgs { 170 | pub(crate) password: Option>, 171 | pub(crate) output_file_path: PathBuf, 172 | pub(crate) keyfiles: Vec, 173 | pub(crate) script_type: ScriptType, 174 | pub(crate) network: Network, 175 | pub(crate) difficulty: KeyDerivationDifficulty, 176 | pub(crate) padding_params: PaddingParams, 177 | pub(crate) encrypted_wallet_version: EncryptedWalletVersion, 178 | } 179 | 180 | pub(crate) fn multisig_reencode_parse_args( 181 | open_args: &MultisigOpenArgs, 182 | args: &MultisigReencodeArgs, 183 | ) -> anyhow::Result { 184 | let output_file_path = match &args.wallet_output_file { 185 | Some(p) => handle_output_path(p)?.into_owned(), 186 | None => from_input_to_reencoded(Path::new(&open_args.common.wallet_input_file))?, 187 | }; 188 | let output_file_path = handle_output_path(&output_file_path)?.into_owned(); 189 | let keyfiles = parse_keyfiles_paths(&args.common.keyfile)?; 190 | let network = if args.common.use_testnet { 191 | Network::Testnet 192 | } else { 193 | Network::Bitcoin 194 | }; 195 | let script_type = ScriptType::SegwitNative; 196 | let password = args 197 | .common 198 | .password 199 | .clone() 200 | .map(|s| SecretString::new(s.into())) 201 | .map(Arc::new); 202 | Ok(MultisigCoreReencodeArgs { 203 | password, 204 | output_file_path, 205 | keyfiles, 206 | script_type, 207 | network, 208 | difficulty: args.common.difficulty, 209 | padding_params: PaddingParams::new( 210 | args.common.disable_all_padding, 211 | Some(args.common.min_additional_padding_bytes), 212 | Some(args.common.max_additional_padding_bytes), 213 | )?, 214 | encrypted_wallet_version: args 215 | .common 216 | .wallet_file_type 217 | .to_encrypted_wallet_version(network, script_type)?, 218 | }) 219 | } 220 | 221 | pub(crate) fn multisig_core_reencode( 222 | theme: &dyn Theme, 223 | term: &Term, 224 | secp: &mut Secp256k1, 225 | mut rng: &mut impl CryptoRng, 226 | ic: impl InternetChecker, 227 | wallet: &MultiSigWalletDescriptionV0, 228 | args: MultisigCoreReencodeArgs, 229 | ) -> anyhow::Result<()> { 230 | warn_difficulty_level(&args.difficulty); 231 | let output_file_path_encrypted = &args.output_file_path; 232 | log::info!("Will generate a multisig wallet saving to {output_file_path_encrypted:?}"); 233 | let password = args 234 | .password 235 | .map(Result::Ok) 236 | .unwrap_or_else(|| generate_ask_password(theme, term, Some(ic)))?; 237 | let salt = get_random_salt(&mut rng)?; 238 | let key = ui_derive_key(&password, &args.keyfiles, &salt, &args.difficulty)?; 239 | let nonce = get_random_nonce(&mut rng)?; 240 | let header_nonce = get_random_nonce(&mut rng)?; 241 | let header_key = Secret::from(Box::new(get_random_key(&mut rng)?)); 242 | let padder = get_padder(&mut rng, &args.padding_params)?; 243 | let encrypted_wallet = frozenkrill_core::generate_encrypted_encoded_multisig_wallet( 244 | wallet.configuration, 245 | wallet.inputs.clone(), 246 | &key, 247 | header_key, 248 | salt, 249 | nonce, 250 | header_nonce, 251 | padder, 252 | args.script_type, 253 | args.network, 254 | args.encrypted_wallet_version, 255 | secp, 256 | )?; 257 | 258 | // Write file 259 | create_file(&encrypted_wallet, output_file_path_encrypted).with_context(|| { 260 | format!("failure saving encrypted wallet to {output_file_path_encrypted:?}") 261 | })?; 262 | log::info!("Wallet saved to {output_file_path_encrypted:?}"); 263 | let mut seed = [0u8; 32]; 264 | rng.fill_bytes(&mut seed); 265 | secp.seeded_randomize(&seed); 266 | 267 | let read_encrypted_wallet = read_decode_wallet(output_file_path_encrypted)?; 268 | log::info!("Will derive the key again to double check against bit flips..."); 269 | let pb = get_derivation_key_spinner(); 270 | let key = default_derive_key( 271 | &password, 272 | &args.keyfiles, 273 | &read_encrypted_wallet.salt, 274 | &args.difficulty, 275 | ) 276 | .context("failure trying to derive the same key again")?; 277 | pb.finish_using_style(); 278 | let read_json_wallet_description = read_encrypted_wallet 279 | .decrypt_multisig(&key, secp) 280 | .context(CONTEXT_CORRUPTION_WARNING)?; 281 | let read_wallet_description = MultiSigWalletDescriptionV0::generate_from_ddpks( 282 | vec![], 283 | read_json_wallet_description 284 | .expose_secret() 285 | .receiving_output_descriptor()?, 286 | read_json_wallet_description 287 | .expose_secret() 288 | .change_output_descriptor()?, 289 | read_json_wallet_description 290 | .expose_secret() 291 | .configuration()?, 292 | read_json_wallet_description.expose_secret().network()?, 293 | read_json_wallet_description.expose_secret().script_type()?, 294 | ) 295 | .context("failure parsing generated wallet")?; 296 | // Sanity check 297 | MultisigJsonWalletDescriptionV0::validate_same( 298 | &read_json_wallet_description, 299 | &read_wallet_description, 300 | secp, 301 | )? 302 | .context("failure checking generated wallet")?; 303 | inform_custom_generate_params(&args.keyfiles, &args.difficulty, false); 304 | log::info!("Finished successfully!"); 305 | Ok(()) 306 | } 307 | -------------------------------------------------------------------------------- /src/commands/generate/mod.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | path::{Path, PathBuf}, 3 | sync::Arc, 4 | }; 5 | 6 | use dialoguer::{console::Term, theme::Theme}; 7 | use frozenkrill_core::{ 8 | PaddingParams, 9 | anyhow::{self, Context, bail}, 10 | bitcoin::{ 11 | Network, 12 | secp256k1::{All, Secp256k1}, 13 | }, 14 | key_derivation::{self, KeyDerivationDifficulty}, 15 | log, parse_keyfiles_paths, 16 | rand_core::CryptoRng, 17 | secrecy::SecretString, 18 | utils::create_file, 19 | wallet_description::{MultiSigWalletDescriptionV0, SingleSigWalletDescriptionV0}, 20 | wallet_export::MultisigJsonWalletPublicExportV0, 21 | }; 22 | 23 | use crate::commands::common::{ 24 | multisig::parse_multisig_inputs, singlesig::generate_singlesig_public_info, 25 | }; 26 | 27 | use frozenkrill_core::wallet_description::WordCount; 28 | 29 | use crate::commands::common::generate_random_name; 30 | 31 | // type Secret = SecretBox; // Currently unused 32 | 33 | use crate::{ 34 | InternetChecker, SinglesigGenerateArgs, commands::common::double_check_non_duress_password, 35 | handle_output_path, ui_ask_manually_seed_input, 36 | }; 37 | 38 | use self::core::{ 39 | DuressInputArgs, MultisigCoreGenerateArgs, SinglesigCoreGenerateArgs, multisig_core_generate, 40 | singlesig_core_generate, 41 | }; 42 | 43 | use super::{ 44 | common::{AddressGenerationParams, from_wallet_to_public_info_json_path}, 45 | interactive::ask_for_keyfiles_generate, 46 | }; 47 | 48 | pub mod core; 49 | 50 | pub(crate) struct DuressPublicInfoParams { 51 | pub(crate) non_duress_password: Arc, 52 | pub(crate) non_duress_public_info_json_output: PathBuf, 53 | } 54 | 55 | pub(super) fn export_singlesig_public_infos( 56 | theme: &dyn Theme, 57 | term: &Term, 58 | secp: &Secp256k1, 59 | wallet_description: &SingleSigWalletDescriptionV0, 60 | public_info_json_output: &Path, 61 | duress_params: &Option, 62 | params: &AddressGenerationParams, 63 | ) -> Result<(), anyhow::Error> { 64 | let json = generate_singlesig_public_info( 65 | secp, 66 | wallet_description, 67 | params.first_index, 68 | params.quantity, 69 | ) 70 | .context("failure exporting public info")?; 71 | if let Some(duress_params) = duress_params { 72 | double_check_non_duress_password(theme, term, &duress_params.non_duress_password)?; 73 | } 74 | create_file(&json, public_info_json_output).with_context(|| { 75 | anyhow::anyhow!("failure exporting public info to {public_info_json_output:?}") 76 | })?; 77 | log::info!("Exported public info to {public_info_json_output:?}"); 78 | if let Some(duress_params) = duress_params { 79 | let non_duress_wallet_description = wallet_description 80 | .change_seed_password(&Some(Arc::clone(&duress_params.non_duress_password)), secp) 81 | .context("failure generating non duress wallet")?; 82 | let json = generate_singlesig_public_info( 83 | secp, 84 | &non_duress_wallet_description, 85 | params.first_index, 86 | params.quantity, 87 | ) 88 | .context("failure exporting public info")?; 89 | create_file(&json, &duress_params.non_duress_public_info_json_output).with_context( 90 | || { 91 | anyhow::anyhow!( 92 | "failure exporting non duress public info to {:?}", 93 | duress_params.non_duress_public_info_json_output 94 | ) 95 | }, 96 | )?; 97 | log::info!( 98 | "Exported non duress public info to {:?}", 99 | duress_params.non_duress_public_info_json_output 100 | ); 101 | }; 102 | Ok(()) 103 | } 104 | 105 | fn generate_multisig_public_info( 106 | secp: &Secp256k1, 107 | wallet: &MultiSigWalletDescriptionV0, 108 | first_index: u32, 109 | quantity: u32, 110 | ) -> anyhow::Result> { 111 | let addresses = wallet 112 | .derive_receiving_addresses(first_index, quantity, secp) 113 | .context("failure deriving receive addresses")?; 114 | let change_addresses = wallet 115 | .derive_change_addresses(first_index, quantity, secp) 116 | .context("failure deriving change addresses")?; 117 | let public_export = 118 | MultisigJsonWalletPublicExportV0::generate(wallet, addresses, change_addresses); 119 | public_export.to_vec_pretty() 120 | } 121 | 122 | pub(super) fn export_multisig_public_infos( 123 | secp: &Secp256k1, 124 | wallet_description: &MultiSigWalletDescriptionV0, 125 | public_info_json_output: &Path, 126 | params: &AddressGenerationParams, 127 | ) -> Result<(), anyhow::Error> { 128 | let json = generate_multisig_public_info( 129 | secp, 130 | wallet_description, 131 | params.first_index, 132 | params.quantity, 133 | ) 134 | .context("failure exporting public info")?; 135 | create_file(&json, public_info_json_output).with_context(|| { 136 | anyhow::anyhow!("failure exporting public info to {public_info_json_output:?}") 137 | })?; 138 | log::info!("Exported public info to {public_info_json_output:?}"); 139 | Ok(()) 140 | } 141 | 142 | pub(super) fn inform_custom_generate_params( 143 | keyfiles: &[PathBuf], 144 | difficulty: &KeyDerivationDifficulty, 145 | enable_duress_wallet: bool, 146 | ) { 147 | let custom_keyfiles = !keyfiles.is_empty(); 148 | let custom_difficulty = *difficulty != key_derivation::DEFAULT_DIFFICULTY_LEVEL; 149 | if custom_keyfiles || custom_difficulty || enable_duress_wallet { 150 | eprintln!( 151 | "{}", 152 | termimad::inline( 153 | "**Note:** non default parameters **required** to open the wallet in future:" 154 | ) 155 | ); 156 | for k in keyfiles { 157 | eprintln!("\t--keyfile {k:?}"); 158 | } 159 | if custom_difficulty { 160 | eprintln!("\t--difficulty {}", difficulty.as_str().to_lowercase()); 161 | } 162 | if enable_duress_wallet { 163 | eprintln!("\t--enable-duress-wallet"); 164 | // eprintln!("Your wallet now have two set of addresses:"); 165 | // eprintln!("- Default duress addresses using the empty seed password"); 166 | // eprintln!("- And non duress addresses using the non duress seed password"); 167 | // eprintln!( 168 | // "Note that a non empty seed password isn't supported by wallets like Electrum" 169 | // ); 170 | // eprintln!("Now you have plausible deniability under coercion (duress)"); 171 | // eprintln!("You should transfer low amounts to the default duress addresses"); 172 | // eprintln!("And keep most of your bitcoin under the non duress adresses"); 173 | // eprintln!("But if you forget or wrongly type you non duress password you may easily loose funds"); 174 | // eprintln!("There is no way to check if the password is correct"); 175 | // eprintln!("It'll just generate different addresses for different passwords"); 176 | } 177 | } 178 | } 179 | 180 | pub(crate) fn generate_check_keyfiles( 181 | theme: &dyn Theme, 182 | term: &Term, 183 | keyfiles: Vec, 184 | ) -> anyhow::Result> { 185 | if keyfiles.is_empty() { 186 | ask_for_keyfiles_generate(theme, term) 187 | } else { 188 | Ok(keyfiles) 189 | } 190 | } 191 | 192 | pub(crate) fn singlesig_generate( 193 | theme: &dyn Theme, 194 | term: &Term, 195 | secp: &mut Secp256k1, 196 | mut rng: &mut impl CryptoRng, 197 | ic: impl InternetChecker, 198 | args: SinglesigGenerateArgs, 199 | ) -> anyhow::Result<()> { 200 | let output_file_path = match args.wallet_output_file { 201 | Some(p) => p, 202 | None => generate_random_name("wallet_", "", rng)?, 203 | }; 204 | let output_file_path = handle_output_path(&output_file_path)?.into_owned(); 205 | let public_info_json_output = match args.public_info_json_output { 206 | Some(p) if p.trim().is_empty() => None, 207 | Some(p) => Some(handle_output_path(&p)?.into_owned()), 208 | None => Some(from_wallet_to_public_info_json_path(&output_file_path)?), 209 | }; 210 | 211 | let keyfiles = parse_keyfiles_paths(&args.common.keyfile)?; 212 | let keyfiles = generate_check_keyfiles(theme, term, keyfiles)?; 213 | let word_count = if args.use_12_words { 214 | WordCount::W12 215 | } else { 216 | WordCount::W24 217 | }; 218 | let network = if args.common.use_testnet { 219 | Network::Testnet 220 | } else { 221 | Network::Bitcoin 222 | }; 223 | let user_mnemonic = if args.user_generated_seed { 224 | Some(Arc::new(ui_ask_manually_seed_input( 225 | &mut rng, 226 | theme, 227 | term, 228 | &word_count, 229 | args.always_hide_typed_seed, 230 | )?)) 231 | } else if args.always_hide_typed_seed { 232 | bail!( 233 | "The --always-hide-typed-seed flag only makes sense when used with --user-generated-seed" 234 | ); 235 | } else { 236 | None 237 | }; 238 | let duress_input_args = DuressInputArgs { 239 | enable_duress_wallet: args.enable_duress_wallet, 240 | non_duress_output_file_json: None, 241 | public_json_file_path: public_info_json_output.clone(), 242 | }; 243 | let script_type = frozenkrill_core::wallet_description::ScriptType::SegwitNative; 244 | let password = args 245 | .common 246 | .password 247 | .map(|s| SecretString::new(s.into())) 248 | .map(Arc::new); 249 | let args = SinglesigCoreGenerateArgs { 250 | password, 251 | output_file_path, 252 | public_info_json_output, 253 | keyfiles: &keyfiles, 254 | user_mnemonic, 255 | duress_input_args, 256 | word_count, 257 | script_type, 258 | network, 259 | difficulty: &args.common.difficulty, 260 | addresses_quantity: args.common.addresses_quantity, 261 | padding_params: PaddingParams::new( 262 | args.common.disable_all_padding, 263 | Some(args.common.min_additional_padding_bytes), 264 | Some(args.common.max_additional_padding_bytes), 265 | )?, 266 | encrypted_wallet_version: args 267 | .common 268 | .wallet_file_type 269 | .to_encrypted_wallet_version(network, script_type)?, 270 | }; 271 | singlesig_core_generate(theme, term, secp, rng, ic, args)?; 272 | Ok(()) 273 | } 274 | 275 | pub(crate) fn multisig_generate( 276 | theme: &dyn Theme, 277 | term: &Term, 278 | secp: &mut Secp256k1, 279 | rng: &mut impl CryptoRng, 280 | ic: impl InternetChecker, 281 | args: crate::MultisigGenerateArgs, 282 | ) -> anyhow::Result<()> { 283 | let output_file_path = match args.encrypted_wallet_output_file { 284 | Some(p) => p, 285 | None => generate_random_name("wallet_", "", rng)?, 286 | }; 287 | let output_file_path_encrypted = handle_output_path(&output_file_path)?.into_owned(); 288 | let output_file_path_json = match args.json_output_file { 289 | Some(p) if p.trim().is_empty() => None, 290 | Some(p) => Some(handle_output_path(&p)?.into_owned()), 291 | None => Some(from_wallet_to_public_info_json_path( 292 | &output_file_path_encrypted, 293 | )?), 294 | }; 295 | let keyfiles = parse_keyfiles_paths(&args.common.keyfile)?; 296 | let keyfiles = generate_check_keyfiles(theme, term, keyfiles)?; 297 | let network = if args.common.use_testnet { 298 | Network::Testnet 299 | } else { 300 | Network::Bitcoin 301 | }; 302 | let inputs = parse_multisig_inputs(theme, term, secp, ic, &args.input_files)?; 303 | let script_type = frozenkrill_core::wallet_description::ScriptType::SegwitNative; 304 | let password = args 305 | .common 306 | .password 307 | .clone() 308 | .map(|s| SecretString::new(s.into())) 309 | .map(Arc::new); 310 | let args = MultisigCoreGenerateArgs { 311 | password, 312 | keyfiles: &keyfiles, 313 | network, 314 | script_type, 315 | difficulty: &args.common.difficulty, 316 | addresses_quantity: args.common.addresses_quantity, 317 | padding_params: PaddingParams::new( 318 | args.common.disable_all_padding, 319 | Some(args.common.min_additional_padding_bytes), 320 | Some(args.common.max_additional_padding_bytes), 321 | )?, 322 | configuration: args.configuration, 323 | inputs, 324 | output_file_path_encrypted, 325 | output_file_path_json, 326 | encrypted_wallet_version: args 327 | .common 328 | .wallet_file_type 329 | .to_encrypted_wallet_version(network, script_type)?, 330 | }; 331 | multisig_core_generate(theme, term, secp, rng, args)?; 332 | Ok(()) 333 | } 334 | -------------------------------------------------------------------------------- /src/commands/interactive/open/mod.rs: -------------------------------------------------------------------------------- 1 | use std::{fs, path::PathBuf, sync::Arc}; 2 | 3 | use dialoguer::{console::Term, theme::Theme}; 4 | use frozenkrill_core::{ 5 | anyhow::{self, bail}, 6 | bitcoin::secp256k1::{All, Secp256k1}, 7 | key_derivation::KeyDerivationDifficulty, 8 | log::{self, debug}, 9 | parse_keyfiles_paths, 10 | rand_core::CryptoRng, 11 | secrecy::{SecretBox, SecretString}, 12 | wallet_description::read_decode_wallet, 13 | }; 14 | 15 | use crate::{ 16 | InternetChecker, 17 | commands::{ 18 | common::{ 19 | ParsedWalletInputFile, PublicInfoInput, multisig::MultisigCoreOpenWalletParam, 20 | try_open_as_json_input, 21 | }, 22 | interactive::get_ask_difficulty, 23 | }, 24 | handle_input_path, 25 | }; 26 | 27 | use super::{choose_keyfiles, duress_wallet_explanation}; 28 | 29 | type Secret = SecretBox; 30 | 31 | mod export_public_info; 32 | mod reencode; 33 | mod show_receiving_qr_code; 34 | mod show_secrets; 35 | mod sign_psbt; 36 | 37 | #[derive(Copy, Clone)] 38 | enum InteractiveOpenActions { 39 | ShowReceivingQrCode, 40 | ExportPublicInfo, 41 | ShowSecrets, 42 | SignPsbt, 43 | Reencode, 44 | } 45 | 46 | impl std::fmt::Display for InteractiveOpenActions { 47 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 48 | let s = match self { 49 | InteractiveOpenActions::ShowReceivingQrCode => "Show receiving QR code", 50 | InteractiveOpenActions::ExportPublicInfo => "Export public info json", 51 | InteractiveOpenActions::ShowSecrets => "Show secrets", 52 | InteractiveOpenActions::SignPsbt => "Sign a PSBT", 53 | InteractiveOpenActions::Reencode => "Reencode the wallet", 54 | }; 55 | f.write_str(s) 56 | } 57 | } 58 | 59 | const INTERACTIVE_OPEN_ACTIONS: [InteractiveOpenActions; 5] = [ 60 | InteractiveOpenActions::ShowReceivingQrCode, 61 | InteractiveOpenActions::ExportPublicInfo, 62 | InteractiveOpenActions::ShowSecrets, 63 | InteractiveOpenActions::SignPsbt, 64 | InteractiveOpenActions::Reencode, 65 | ]; 66 | 67 | fn ask_interactive_open_action( 68 | theme: &dyn Theme, 69 | term: &Term, 70 | ) -> anyhow::Result> { 71 | let action = dialoguer::Select::with_theme(theme) 72 | .with_prompt("Pick an option") 73 | .items(INTERACTIVE_OPEN_ACTIONS) 74 | .default(0) 75 | .interact_on_opt(term)?; 76 | let action = match action { 77 | Some(v) => v, 78 | None => return Ok(None), 79 | }; 80 | Ok(Some(INTERACTIVE_OPEN_ACTIONS[action])) 81 | } 82 | 83 | fn ask_select_another_action(theme: &dyn Theme, term: &Term) -> anyhow::Result { 84 | Ok(dialoguer::Confirm::with_theme(theme) 85 | .with_prompt("Select another action?") 86 | .default(true) 87 | .interact_on(term)?) 88 | } 89 | 90 | #[allow(clippy::too_many_arguments)] 91 | pub(super) fn singlesig_interactive_open( 92 | theme: &dyn Theme, 93 | term: &Term, 94 | secp: &mut Secp256k1, 95 | rng: &mut impl CryptoRng, 96 | ic: impl InternetChecker, 97 | keyfiles: Vec, 98 | difficulty: Option, 99 | enable_duress_wallet: bool, 100 | password: Option>, 101 | ) -> anyhow::Result<()> { 102 | let (input_file_path, wallet_input) = ask_wallet_input_file(theme, term)?; 103 | let ParsedWalletInputFile::Encrypted(encrypted_wallet) = wallet_input else { 104 | bail!("Expected an encrypted wallet but got a different format: {wallet_input:?}") 105 | }; 106 | let keyfiles = if keyfiles.is_empty() { 107 | ask_for_keyfiles_open(theme, term)? 108 | } else { 109 | keyfiles 110 | }; 111 | let difficulty = get_ask_difficulty(theme, term, difficulty)?; 112 | let (wallet, non_duress_password) = crate::commands::common::singlesig::singlesig_core_open( 113 | theme, 114 | term, 115 | secp, 116 | Some(ic.clone()), 117 | &encrypted_wallet, 118 | &keyfiles, 119 | &difficulty, 120 | enable_duress_wallet || ask_to_open_duress(theme, term)?, 121 | password, 122 | )?; 123 | loop { 124 | match ask_interactive_open_action(theme, term)? { 125 | Some(InteractiveOpenActions::ShowReceivingQrCode) => { 126 | show_receiving_qr_code::singlesig_show( 127 | theme, 128 | term, 129 | secp, 130 | &wallet, 131 | &non_duress_password, 132 | )?; 133 | } 134 | Some(InteractiveOpenActions::ExportPublicInfo) => { 135 | export_public_info::singlesig_export( 136 | theme, 137 | term, 138 | secp, 139 | &wallet, 140 | &input_file_path, 141 | &non_duress_password, 142 | )?; 143 | } 144 | Some(InteractiveOpenActions::ShowSecrets) => { 145 | show_secrets::singlesig_show(theme, term, secp, &wallet, &non_duress_password)?; 146 | } 147 | Some(InteractiveOpenActions::SignPsbt) => { 148 | sign_psbt::singlesig_sign(theme, term, secp, &wallet, &non_duress_password)?; 149 | } 150 | Some(InteractiveOpenActions::Reencode) => { 151 | reencode::singlesig_reencode( 152 | theme, 153 | term, 154 | secp, 155 | rng, 156 | ic.clone(), 157 | &wallet, 158 | &input_file_path, 159 | )?; 160 | } 161 | None => break, 162 | }; 163 | if !ask_select_another_action(theme, term)? { 164 | break; 165 | } 166 | } 167 | log::info!("Done!"); 168 | Ok(()) 169 | } 170 | 171 | pub(super) fn multisig_interactive_open( 172 | theme: &dyn Theme, 173 | term: &Term, 174 | secp: &mut Secp256k1, 175 | rng: &mut impl CryptoRng, 176 | mut ic: impl InternetChecker, 177 | keyfiles: Vec, 178 | difficulty: Option, 179 | ) -> anyhow::Result<()> { 180 | ic.check()?; 181 | let (input_file_path, wallet_input) = ask_wallet_input_file(theme, term)?; 182 | let input_wallet = match wallet_input { 183 | ParsedWalletInputFile::Encrypted(encrypted_wallet) => { 184 | let keyfiles = if keyfiles.is_empty() { 185 | ask_for_keyfiles_open(theme, term)? 186 | } else { 187 | keyfiles 188 | }; 189 | let difficulty = get_ask_difficulty(theme, term, difficulty)?; 190 | MultisigCoreOpenWalletParam::Encrypted { 191 | input_wallet: encrypted_wallet, 192 | password: None, 193 | keyfiles, 194 | difficulty, 195 | } 196 | } 197 | ParsedWalletInputFile::PublicInfo(public_info) => match public_info { 198 | PublicInfoInput::MultisigJson(json) => { 199 | MultisigCoreOpenWalletParam::Json(Secret::from(Box::new(json))) 200 | } 201 | }, 202 | }; 203 | let wallet = crate::commands::common::multisig::multisig_core_open( 204 | theme, 205 | term, 206 | secp, 207 | input_wallet, 208 | None, 209 | )?; 210 | loop { 211 | match ask_interactive_open_action(theme, term)? { 212 | Some(InteractiveOpenActions::ShowReceivingQrCode) => { 213 | show_receiving_qr_code::multisig_show(theme, term, secp, &wallet)?; 214 | } 215 | Some(InteractiveOpenActions::ExportPublicInfo) => { 216 | export_public_info::multisig_export(theme, term, secp, &wallet, &input_file_path)?; 217 | } 218 | Some(InteractiveOpenActions::ShowSecrets) => { 219 | show_secrets::multisig_show(secp, &wallet)?; 220 | } 221 | Some(InteractiveOpenActions::SignPsbt) => { 222 | if wallet.has_signers() { 223 | sign_psbt::multisig_sign(theme, term, secp, &wallet)?; 224 | } else { 225 | log::error!("No signers added, impossible to sign a PSBT") 226 | } 227 | } 228 | Some(InteractiveOpenActions::Reencode) => { 229 | reencode::multisig_reencode( 230 | theme, 231 | term, 232 | secp, 233 | rng, 234 | ic.clone(), 235 | &wallet, 236 | &input_file_path, 237 | )?; 238 | } 239 | None => break, 240 | }; 241 | if !ask_select_another_action(theme, term)? { 242 | break; 243 | } 244 | } 245 | log::info!("Done!"); 246 | Ok(()) 247 | } 248 | 249 | pub(crate) fn ask_to_open_duress(theme: &dyn Theme, term: &Term) -> anyhow::Result { 250 | duress_wallet_explanation(); 251 | Ok(dialoguer::Confirm::with_theme(theme) 252 | .default(false) 253 | .with_prompt("Enable duress feature for this wallet? (advanced usage, be careful)") 254 | .interact_on(term)?) 255 | } 256 | 257 | pub(crate) fn ask_for_keyfiles_open( 258 | theme: &dyn Theme, 259 | term: &Term, 260 | ) -> anyhow::Result> { 261 | if dialoguer::Confirm::with_theme(theme) 262 | .with_prompt("Have you used a keyfile when generating this wallet?") 263 | .interact_on(term)? 264 | { 265 | let mut files = fs::read_dir(".")? 266 | .map(|i| i.map(|i| i.path().display().to_string())) 267 | .collect::, _>>()?; 268 | files.sort(); 269 | if files.is_empty() { 270 | eprintln!( 271 | "You can't pick a keyfile because there are no files or directories in the current directory" 272 | ); 273 | bail!( 274 | "Copy the keyfiles or directories to current directory or change the current directory or use the --keyfile argument on command line to load keyfiles from other places" 275 | ); 276 | } 277 | loop { 278 | let chosen_files = choose_keyfiles(theme, term, &files)?; 279 | let chosen_files: Vec<_> = chosen_files 280 | .into_iter() 281 | .map(|i| files[i].to_owned()) 282 | .collect(); 283 | if chosen_files.is_empty() { 284 | eprintln!( 285 | "No keyfile selected, you won't be able to open the wallet if it was created with a keyfile" 286 | ); 287 | if dialoguer::Confirm::with_theme(theme) 288 | .with_prompt("Proceed without a keyfile?") 289 | .interact_on(term)? 290 | { 291 | return Ok(Vec::new()); 292 | } 293 | } else { 294 | let keyfiles = parse_keyfiles_paths(&chosen_files)?; 295 | return Ok(keyfiles); 296 | } 297 | } 298 | } else { 299 | Ok(Vec::new()) 300 | } 301 | } 302 | 303 | pub(crate) fn ask_wallet_input_file( 304 | theme: &dyn Theme, 305 | term: &Term, 306 | ) -> anyhow::Result<(PathBuf, ParsedWalletInputFile)> { 307 | let mut files = fs::read_dir(".")? 308 | .collect::, _>>()? 309 | .iter() 310 | .filter(|i| i.file_type().is_ok() && i.file_type().unwrap().is_file()) 311 | .map(|i| i.path().display().to_string()) 312 | .collect::>(); 313 | files.sort(); 314 | if files.is_empty() { 315 | eprintln!("You can't pick a wallet file because there are no files in current directory"); 316 | bail!( 317 | "Copy some files or directories to current directory or change the current directory so you can load a wallet" 318 | ); 319 | } 320 | loop { 321 | let file = dialoguer::Select::with_theme(theme) 322 | .with_prompt("Select") 323 | .items(&files) 324 | .interact_on(term)?; 325 | let file = handle_input_path(&files[file])?.into_owned(); 326 | debug!("Will try to open {file:?}"); 327 | let json_result = try_open_as_json_input(&file); 328 | match json_result { 329 | Ok(result) => return Ok((file, ParsedWalletInputFile::PublicInfo(result))), 330 | Err(json_error) => match read_decode_wallet(&file) { 331 | Ok(w) => return Ok((file, ParsedWalletInputFile::Encrypted(w))), 332 | Err(e) => { 333 | eprintln!("Error reading wallet as encrypted: {e} and as json: {json_error}"); 334 | if !dialoguer::Confirm::with_theme(theme) 335 | .with_prompt( 336 | "The selected file is invalid, do you want to pick another file?", 337 | ) 338 | .default(true) 339 | .interact_on(term)? 340 | { 341 | bail!("No valid wallet file selected"); 342 | } 343 | } 344 | }, 345 | } 346 | } 347 | } 348 | -------------------------------------------------------------------------------- /src/commands/interactive/mod.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fs, 3 | path::{Path, PathBuf}, 4 | sync::Arc, 5 | }; 6 | 7 | use dialoguer::{console::Term, theme::Theme}; 8 | use frozenkrill_core::{ 9 | anyhow::{self, bail}, 10 | bitcoin::{ 11 | Network, 12 | secp256k1::{All, Secp256k1}, 13 | }, 14 | key_derivation::{self, KeyDerivationDifficulty}, 15 | parse_keyfiles_paths, 16 | rand_core::CryptoRng, 17 | secrecy::SecretString, 18 | wallet_description::WordCount, 19 | }; 20 | 21 | use crate::{InteractiveArgs, InternetChecker, WalletFileType, handle_output_path}; 22 | 23 | use self::{ 24 | generate_batch::interactive_generate_batch, 25 | generate_one::{multisig_interactive_generate_single, singlesig_interactive_generate_one}, 26 | open::{multisig_interactive_open, singlesig_interactive_open}, 27 | }; 28 | 29 | use super::common::{ 30 | from_public_info_json_path_to_non_duress, from_wallet_to_public_info_json_path, 31 | keyfiles_elevator_pitch, 32 | }; 33 | 34 | mod generate_batch; 35 | mod generate_one; 36 | pub(crate) mod open; 37 | 38 | enum MainActions { 39 | SinglesigCreateNewSingle, 40 | MultisigCreateNewSingle, 41 | CreateNewBatch, 42 | OpenSinglesig, 43 | OpenMultisig, 44 | } 45 | 46 | impl std::fmt::Display for MainActions { 47 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 48 | let s = match self { 49 | MainActions::SinglesigCreateNewSingle => "singlesig: create wallet", 50 | MainActions::MultisigCreateNewSingle => " multisig: create wallet", 51 | MainActions::OpenSinglesig => "singlesig: open wallet", 52 | MainActions::OpenMultisig => " multisig: open wallet", 53 | MainActions::CreateNewBatch => "singlesig: create multiple wallets (batch mode)", 54 | }; 55 | f.write_str(s) 56 | } 57 | } 58 | 59 | pub(crate) fn interactive( 60 | theme: &dyn Theme, 61 | term: &Term, 62 | secp: &mut Secp256k1, 63 | rng: &mut impl CryptoRng, 64 | ic: impl InternetChecker, 65 | args: &InteractiveArgs, 66 | ) -> anyhow::Result<()> { 67 | let keyfiles = parse_keyfiles_paths(&args.keyfile)?; 68 | let actions = [ 69 | MainActions::SinglesigCreateNewSingle, 70 | MainActions::MultisigCreateNewSingle, 71 | MainActions::OpenSinglesig, 72 | MainActions::OpenMultisig, 73 | MainActions::CreateNewBatch, 74 | ]; 75 | let action = dialoguer::Select::with_theme(theme) 76 | .with_prompt("Pick an option") 77 | .items(&actions) 78 | .default(0) 79 | .interact_on_opt(term)?; 80 | let action = match action { 81 | Some(v) => v, 82 | None => { 83 | return Ok(()); 84 | } 85 | }; 86 | let password = args 87 | .password 88 | .clone() 89 | .map(|s| SecretString::new(s.into())) 90 | .map(Arc::new); 91 | match &actions[action] { 92 | MainActions::SinglesigCreateNewSingle => singlesig_interactive_generate_one( 93 | theme, 94 | term, 95 | secp, 96 | rng, 97 | ic, 98 | keyfiles, 99 | args.difficulty.to_owned(), 100 | args.enable_duress_wallet, 101 | password, 102 | )?, 103 | MainActions::MultisigCreateNewSingle => multisig_interactive_generate_single( 104 | theme, 105 | term, 106 | secp, 107 | rng, 108 | ic, 109 | keyfiles, 110 | args.difficulty.to_owned(), 111 | password, 112 | )?, 113 | MainActions::CreateNewBatch => { 114 | interactive_generate_batch( 115 | theme, 116 | term, 117 | secp, 118 | rng, 119 | ic, 120 | keyfiles, 121 | args.difficulty.to_owned(), 122 | args.enable_duress_wallet, 123 | password, 124 | )?; 125 | } 126 | MainActions::OpenSinglesig => singlesig_interactive_open( 127 | theme, 128 | term, 129 | secp, 130 | rng, 131 | ic, 132 | keyfiles, 133 | args.difficulty.to_owned(), 134 | args.enable_duress_wallet, 135 | password, 136 | )?, 137 | MainActions::OpenMultisig => multisig_interactive_open( 138 | theme, 139 | term, 140 | secp, 141 | rng, 142 | ic, 143 | keyfiles, 144 | args.difficulty.to_owned(), 145 | )?, 146 | }; 147 | Ok(()) 148 | } 149 | 150 | #[derive(PartialEq, Eq, PartialOrd, Ord)] 151 | struct KeyDerivationDifficultyDisplay(KeyDerivationDifficulty); 152 | 153 | impl std::fmt::Display for KeyDerivationDifficultyDisplay { 154 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 155 | write!( 156 | f, 157 | "{} ({}, {})", 158 | self.0, 159 | self.0.estimate_time(), 160 | self.0.estimate_memory() 161 | ) 162 | } 163 | } 164 | 165 | pub(crate) fn get_ask_difficulty( 166 | theme: &dyn Theme, 167 | term: &Term, 168 | v: Option, 169 | ) -> anyhow::Result { 170 | if let Some(v) = v { 171 | Ok(v) 172 | } else { 173 | let levels: Vec<_> = key_derivation::DIFFICULTY_LEVELS 174 | .into_iter() 175 | .map(KeyDerivationDifficultyDisplay) 176 | .collect(); 177 | let default_level = levels 178 | .iter() 179 | .position(|l| l.0 == key_derivation::DEFAULT_DIFFICULTY_LEVEL) 180 | .expect("code to be correct"); 181 | eprintln!("The difficulty level controls how hard will be to break the derived key"); 182 | eprintln!("It should be the same when generating and when opening the wallet"); 183 | let level = dialoguer::Select::with_theme(theme) 184 | .with_prompt("Select one (leave the default if unsure)") 185 | .items(&levels) 186 | .default(default_level) 187 | .interact_on(term)?; 188 | Ok(levels[level].0.to_owned()) 189 | } 190 | } 191 | 192 | fn ask_continue_without_a_keyfile(theme: &dyn Theme, term: &Term) -> anyhow::Result { 193 | keyfiles_elevator_pitch(); 194 | Ok(dialoguer::Confirm::with_theme(theme) 195 | .with_prompt("Continue without a keyfile? (strongly discouraged)") 196 | .interact_on(term)?) 197 | } 198 | 199 | pub(crate) fn ask_for_keyfiles_generate( 200 | theme: &dyn Theme, 201 | term: &Term, 202 | ) -> anyhow::Result> { 203 | let mut files = fs::read_dir(".")? 204 | .map(|i| i.map(|i| i.path().display().to_string())) 205 | .collect::, _>>()?; 206 | files.sort(); 207 | if files.is_empty() { 208 | keyfiles_elevator_pitch(); 209 | if dialoguer::Confirm::with_theme(theme) 210 | .with_prompt("You can't pick a keyfile because there are no files in current directory, continue without keyfiles?").interact_on(term)? { 211 | return Ok(Vec::new()) 212 | } else { 213 | bail!("Copy some files or directories to current directory or change the current directory so you can load a keyfile or give a --keyfile as argument on the command line"); 214 | } 215 | } 216 | if !dialoguer::Confirm::with_theme(theme) 217 | .with_prompt("Do you want to pick one or more keyfiles?") 218 | .interact_on(term)? 219 | && ask_continue_without_a_keyfile(theme, term)? 220 | { 221 | Ok(Vec::new()) 222 | } else { 223 | loop { 224 | let chosen_files = choose_keyfiles(theme, term, &files)?; 225 | let chosen_files: Vec<_> = chosen_files 226 | .into_iter() 227 | .map(|i| files[i].to_owned()) 228 | .collect(); 229 | if chosen_files.is_empty() { 230 | if ask_continue_without_a_keyfile(theme, term)? { 231 | return Ok(Vec::new()); 232 | } 233 | } else { 234 | let keyfiles = parse_keyfiles_paths(&chosen_files)?; 235 | return Ok(keyfiles); 236 | } 237 | } 238 | } 239 | } 240 | 241 | fn choose_keyfiles(theme: &dyn Theme, term: &Term, files: &[String]) -> anyhow::Result> { 242 | let chosen_files = dialoguer::MultiSelect::with_theme(theme) 243 | .with_prompt( 244 | "Select the keyfiles by pressing \"space\" on each item, press \"enter\" to finish", 245 | ) 246 | .items(files) 247 | .interact_on(term)?; 248 | Ok(chosen_files) 249 | } 250 | 251 | struct ValidateOutputFile; 252 | 253 | impl dialoguer::InputValidator for ValidateOutputFile { 254 | type Err = anyhow::Error; 255 | fn validate(&mut self, input: &String) -> Result<(), Self::Err> { 256 | if !input.is_empty() { 257 | handle_output_path(input)?; 258 | } 259 | Ok(()) 260 | } 261 | } 262 | 263 | fn ask_public_info_json_output( 264 | theme: &dyn Theme, 265 | term: &Term, 266 | wallet_output_file: &Path, 267 | ) -> anyhow::Result> { 268 | let suggested_path = from_wallet_to_public_info_json_path(wallet_output_file)?; 269 | let name = dialoguer::Input::with_theme(theme) 270 | .with_prompt("Where to save the public info json?") 271 | .with_initial_text(suggested_path.display().to_string()) 272 | .validate_with(ValidateOutputFile) 273 | .allow_empty(true) 274 | .interact_text_on(term)?; 275 | if name.is_empty() { 276 | Ok(None) 277 | } else { 278 | Ok(Some(handle_output_path(&name)?.into_owned())) 279 | } 280 | } 281 | 282 | fn duress_wallet_explanation() { 283 | eprintln!("A duress wallet makes plausible deniability possible under coercion (duress)"); 284 | eprintln!("It works as follows:"); 285 | eprintln!( 286 | "- The main wallet will become a duress (decoy/fake) one\n\t-> so it should receive small bitcoin amounts" 287 | ); 288 | eprintln!( 289 | "- A custom seed password will be asked to create the non duress (real)\n\t-> this wallet will receive most of the funds" 290 | ); 291 | eprintln!( 292 | "- So two public infos will be available:\n\t1) the default duress with fake receiving addresses" 293 | ); 294 | eprintln!( 295 | "\t2) the non default (using the non duress password)\n\twith real receiving addresses" 296 | ); 297 | eprintln!("- This is an advanced feature, enable only if know what you're doing"); 298 | eprintln!("- The risk of losing funds is very high, only use it after careful testing"); 299 | } 300 | 301 | fn ask_non_duress_wallet_generate(theme: &dyn Theme, term: &Term) -> anyhow::Result { 302 | duress_wallet_explanation(); 303 | let items = [ 304 | "Standard Wallet (recommended)", 305 | "Duress Wallet (standard duress + non duress public info)", 306 | ]; 307 | let item = dialoguer::Select::with_theme(theme) 308 | .items(items) 309 | .default(0) 310 | .with_prompt("Pick an option") 311 | .interact_on(term)?; 312 | Ok(item == 1) 313 | } 314 | 315 | fn ask_non_duress_public_info_json_output( 316 | theme: &dyn Theme, 317 | term: &Term, 318 | wallet_output_file: &Path, 319 | ) -> anyhow::Result { 320 | let suggested_path = from_public_info_json_path_to_non_duress(wallet_output_file)?; 321 | let name = dialoguer::Input::with_theme(theme) 322 | .with_prompt("Where to save the non duress (i.e real) public info json?") 323 | .with_initial_text(suggested_path.display().to_string()) 324 | .validate_with(ValidateOutputFile) 325 | .interact_text_on(term)?; 326 | Ok(handle_output_path(&name)?.into_owned()) 327 | } 328 | 329 | fn ask_public_info_json_output_required( 330 | theme: &dyn Theme, 331 | term: &Term, 332 | wallet_output_file: &Path, 333 | ) -> anyhow::Result { 334 | let suggested_path = from_wallet_to_public_info_json_path(wallet_output_file)?; 335 | let name = dialoguer::Input::with_theme(theme) 336 | .with_prompt("Where to save the public info json?") 337 | .with_initial_text(suggested_path.display().to_string()) 338 | .validate_with(ValidateOutputFile) 339 | .interact_text_on(term)?; 340 | Ok(handle_output_path(&name)?.into_owned()) 341 | } 342 | 343 | fn ask_word_count(theme: &dyn Theme, term: &Term) -> anyhow::Result { 344 | let items = ["12 words", "24 words (recommended)"]; 345 | let item = dialoguer::Select::with_theme(theme) 346 | .items(items) 347 | .default(1) 348 | .with_prompt("How many words for the seed?") 349 | .interact_on(term)?; 350 | if item == 0 { 351 | Ok(WordCount::W12) 352 | } else { 353 | Ok(WordCount::W24) 354 | } 355 | } 356 | 357 | fn ask_wallet_file_type(theme: &dyn Theme, term: &Term) -> anyhow::Result { 358 | let items = ["Standard (recommended)", "Compact"]; 359 | let item = dialoguer::Select::with_theme(theme) 360 | .items(items) 361 | .default(0) 362 | .with_prompt( 363 | r#"What's the type of the encrypted file? 364 | Only pick compact if you really need it (e.g if you want to store it on a QR code)"#, 365 | ) 366 | .interact_on(term)?; 367 | if item == 0 { 368 | Ok(WalletFileType::Standard) 369 | } else { 370 | Ok(WalletFileType::Compact) 371 | } 372 | } 373 | 374 | fn ask_user_generated_seed(theme: &dyn Theme, term: &Term) -> anyhow::Result { 375 | let items = [ 376 | "Randomly generate the seed phrase (recommended)", 377 | "Manually input the seed phrase", 378 | ]; 379 | Ok(dialoguer::Select::with_theme(theme) 380 | .with_prompt("Pick an option") 381 | .items(items) 382 | .default(0) 383 | .interact_on(term)? 384 | == 1) 385 | } 386 | 387 | fn ask_network(theme: &dyn Theme, term: &Term) -> anyhow::Result { 388 | let items = ["Bitcoin mainnet (recommended)", "Bitcoin testnet"]; 389 | if dialoguer::Select::with_theme(theme) 390 | .with_prompt("Pick a network") 391 | .items(items) 392 | .default(0) 393 | .interact_on(term)? 394 | == 0 395 | { 396 | Ok(Network::Bitcoin) 397 | } else { 398 | Ok(Network::Testnet) 399 | } 400 | } 401 | 402 | fn ask_addresses_quantity(theme: &dyn Theme, term: &Term) -> anyhow::Result { 403 | let n = dialoguer::Input::::with_theme(theme) 404 | .allow_empty(false) 405 | .with_initial_text("100") 406 | .with_prompt("How many receiving addresses to include in the public info json?") 407 | .interact_text_on(term)?; 408 | Ok(n) 409 | } 410 | -------------------------------------------------------------------------------- /frozenkrill-core/src/wallet_export.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | io::{BufReader, Read}, 3 | path::Path, 4 | str::FromStr, 5 | }; 6 | 7 | use anyhow::{Context, bail}; 8 | use bitcoin::bip32::{ChildNumber, DerivationPath, Fingerprint, Xpub}; 9 | use miniscript::DescriptorPublicKey; 10 | use serde::{Deserialize, Serialize}; 11 | use zeroize::Zeroize; 12 | use zeroize::ZeroizeOnDrop; 13 | 14 | use crate::wallet_description::{ 15 | self, AddressInfo, DerivedAddress, MultiSigWalletDescriptionV0, ScriptType, SigType, 16 | SingleSigWalletDescriptionV0, WalletVersionType, ZERO_MULTISIG_WALLET_VERSION, 17 | ZERO_SINGLESIG_WALLET_VERSION, slip132_decode_pub, 18 | }; 19 | 20 | pub const FROZENKRILL_WALLET: &str = "frozenkrill"; 21 | 22 | #[derive(serde::Deserialize)] 23 | pub struct GenericOutputExportJson { 24 | wallet: Option, 25 | version: Option, 26 | sigtype: Option, 27 | } 28 | 29 | impl GenericOutputExportJson { 30 | pub fn deserialize(data: BufReader) -> anyhow::Result { 31 | let d = serde_json::from_reader::<_, Self>(data)?; 32 | 33 | match d.wallet.as_ref() { 34 | Some(w) if w.as_str() != FROZENKRILL_WALLET => { 35 | bail!( 36 | "Not a json generated by frozenkrill because wallet {w} != {FROZENKRILL_WALLET}" 37 | ) 38 | } 39 | Some(_) => {} 40 | None => bail!("Not a json generated by frozenkrill because there is no wallet field"), 41 | } 42 | Ok(d) 43 | } 44 | 45 | pub fn version_sigtype( 46 | &self, 47 | ) -> anyhow::Result<( 48 | Option, 49 | Option, 50 | )> { 51 | Ok(( 52 | self.version, 53 | self.sigtype 54 | .as_ref() 55 | .map(|s| wallet_description::SigType::from_str(s)) 56 | .transpose()?, 57 | )) 58 | } 59 | } 60 | 61 | #[derive(Debug, Default, Zeroize, ZeroizeOnDrop, Serialize, Deserialize, PartialEq, Eq)] 62 | pub struct JsonDerivedAddressInfo { 63 | address: String, 64 | derivation_path: String, 65 | } 66 | 67 | impl From for JsonDerivedAddressInfo { 68 | fn from(v: DerivedAddress) -> Self { 69 | Self { 70 | address: v.address.to_string(), 71 | derivation_path: v.derivation_path.to_string(), 72 | } 73 | } 74 | } 75 | 76 | #[derive(Debug, Default, Zeroize, ZeroizeOnDrop, Serialize, Deserialize, PartialEq, Eq)] 77 | pub struct JsonAddressInfo { 78 | address: String, 79 | index: u32, 80 | } 81 | 82 | impl From for JsonAddressInfo { 83 | fn from(v: AddressInfo) -> Self { 84 | Self { 85 | address: v.address.to_string(), 86 | index: v.index, 87 | } 88 | } 89 | } 90 | 91 | #[derive(Debug, Default, Zeroize, ZeroizeOnDrop, Serialize, Deserialize, PartialEq, Eq)] 92 | pub struct SinglesigJsonWalletPublicExportV0 { 93 | wallet: String, 94 | version: WalletVersionType, 95 | sigtype: String, 96 | master_fingerprint: String, 97 | singlesig_xpub: String, 98 | singlesig_derivation_path: String, 99 | multisig_xpub: String, 100 | multisig_derivation_path: String, 101 | singlesig_receiving_output_descriptor: String, 102 | singlesig_change_output_descriptor: String, 103 | multisig_receiving_output_descriptor_key: String, 104 | multisig_change_output_descriptor_key: String, 105 | script_type: String, 106 | network: String, 107 | receiving_addresses: Vec, 108 | change_addresses: Vec, 109 | } 110 | 111 | impl SinglesigJsonWalletPublicExportV0 { 112 | pub fn generate( 113 | w: &SingleSigWalletDescriptionV0, 114 | receiving_addresses: Vec, 115 | change_addresses: Vec, 116 | ) -> anyhow::Result { 117 | Ok(Self { 118 | wallet: FROZENKRILL_WALLET.to_owned(), 119 | version: ZERO_SINGLESIG_WALLET_VERSION, 120 | sigtype: SigType::Singlesig.to_string(), 121 | master_fingerprint: w.master_fingerprint.to_string(), 122 | singlesig_xpub: w.encoded_singlesig_xpub(), 123 | singlesig_derivation_path: w.singlesig_derivation_path.to_string(), 124 | multisig_xpub: w.encoded_multisig_xpub(), 125 | multisig_derivation_path: w.multisig_derivation_path.to_string(), 126 | singlesig_receiving_output_descriptor: w 127 | .receiving_singlesig_output_descriptor()? 128 | .to_string(), 129 | singlesig_change_output_descriptor: w.change_singlesig_output_descriptor()?.to_string(), 130 | multisig_receiving_output_descriptor_key: w 131 | .receiving_multisig_public_descriptor() 132 | .to_string(), 133 | multisig_change_output_descriptor_key: w 134 | .change_multisig_public_descriptor() 135 | .to_string(), 136 | script_type: w.script_type.to_string(), 137 | network: w.network.to_string(), 138 | receiving_addresses: receiving_addresses.into_iter().map(Into::into).collect(), 139 | change_addresses: change_addresses.into_iter().map(Into::into).collect(), 140 | }) 141 | } 142 | 143 | fn script_type(&self) -> anyhow::Result { 144 | ScriptType::from_str(&self.script_type) 145 | } 146 | 147 | pub fn deserialize(reader: BufReader) -> anyhow::Result { 148 | let d: Self = serde_json::from_reader(reader).context("failure parsing json")?; 149 | anyhow::ensure!( 150 | d.wallet.as_str() == FROZENKRILL_WALLET, 151 | "Trying to deserialize singlesig export, got wallet {} != {FROZENKRILL_WALLET}", 152 | d.wallet 153 | ); 154 | anyhow::ensure!( 155 | d.version == ZERO_SINGLESIG_WALLET_VERSION, 156 | "Trying to deserialize singlesig export, got version {} != {ZERO_SINGLESIG_WALLET_VERSION}", 157 | d.version 158 | ); 159 | let sigtype = SigType::from_str(&d.sigtype)?; 160 | anyhow::ensure!( 161 | sigtype == SigType::Singlesig, 162 | "Trying to deserialize singlesig export, got sigtype {} != {}", 163 | d.sigtype, 164 | SigType::Singlesig 165 | ); 166 | Ok(d) 167 | } 168 | 169 | pub fn to_vec_pretty(&self) -> anyhow::Result> { 170 | serde_json::to_vec_pretty(self).context("failure serializing json") 171 | } 172 | 173 | pub fn to_string_pretty(&self) -> anyhow::Result { 174 | serde_json::to_string_pretty(self).context("failure serializing json") 175 | } 176 | 177 | pub fn from_path(path: &Path) -> anyhow::Result { 178 | let data = crate::utils::buf_open_file(path) 179 | .with_context(|| format!("failure opening output file {path:?}"))?; 180 | Self::deserialize(data).with_context(|| format!("failure decoding output file {path:?}")) 181 | } 182 | 183 | fn multisig_xpub(&self) -> anyhow::Result { 184 | match self.script_type()? { 185 | ScriptType::SegwitNative => slip132_decode_pub(&self.multisig_xpub), 186 | } 187 | } 188 | 189 | fn master_fingerprint(&self) -> anyhow::Result { 190 | Ok(Fingerprint::from_str(&self.master_fingerprint)?) 191 | } 192 | 193 | fn multisig_derivation_path(&self) -> anyhow::Result { 194 | Ok(DerivationPath::from_str(&self.multisig_derivation_path)?) 195 | } 196 | 197 | fn multisig_public_descriptor( 198 | &self, 199 | child: ChildNumber, 200 | ) -> anyhow::Result { 201 | match self.script_type()? { 202 | ScriptType::SegwitNative => Ok(DescriptorPublicKey::XPub( 203 | miniscript::descriptor::DescriptorXKey { 204 | origin: Some((self.master_fingerprint()?, self.multisig_derivation_path()?)), 205 | xkey: self.multisig_xpub()?, 206 | derivation_path: vec![child].into(), 207 | wildcard: miniscript::descriptor::Wildcard::Unhardened, 208 | }, 209 | )), 210 | } 211 | } 212 | 213 | pub fn receiving_multisig_public_descriptor(&self) -> anyhow::Result { 214 | match self.script_type()? { 215 | ScriptType::SegwitNative => { 216 | self.multisig_public_descriptor(ChildNumber::Normal { index: 0 }) 217 | } 218 | } 219 | } 220 | 221 | pub fn change_multisig_public_descriptor(&self) -> anyhow::Result { 222 | match self.script_type()? { 223 | ScriptType::SegwitNative => { 224 | self.multisig_public_descriptor(ChildNumber::Normal { index: 1 }) 225 | } 226 | } 227 | } 228 | } 229 | 230 | #[derive(Debug, Default, Zeroize, ZeroizeOnDrop, Serialize, Deserialize, PartialEq, Eq)] 231 | pub struct MultisigJsonWalletPublicExportV0 { 232 | pub wallet: String, 233 | pub version: WalletVersionType, 234 | pub sigtype: String, 235 | pub script_type: String, 236 | pub network: String, 237 | pub receiving_output_descriptor: String, 238 | pub change_output_descriptor: String, 239 | pub receiving_addresses: Vec, 240 | pub change_addresses: Vec, 241 | } 242 | 243 | impl MultisigJsonWalletPublicExportV0 { 244 | pub fn generate( 245 | w: &MultiSigWalletDescriptionV0, 246 | receiving_addresses: Vec, 247 | change_addresses: Vec, 248 | ) -> Self { 249 | Self { 250 | wallet: FROZENKRILL_WALLET.to_owned(), 251 | version: ZERO_MULTISIG_WALLET_VERSION, 252 | sigtype: w.configuration.to_string(), 253 | script_type: w.script_type.to_string(), 254 | network: w.network.to_string(), 255 | receiving_output_descriptor: w.receiving_descriptor.to_string(), 256 | change_output_descriptor: w.change_descriptor.to_string(), 257 | receiving_addresses: receiving_addresses.into_iter().map(Into::into).collect(), 258 | change_addresses: change_addresses.into_iter().map(Into::into).collect(), 259 | } 260 | } 261 | 262 | pub fn deserialize(reader: BufReader) -> anyhow::Result { 263 | let d: Self = serde_json::from_reader(reader).context("failure parsing json")?; 264 | anyhow::ensure!( 265 | d.wallet.as_str() == FROZENKRILL_WALLET, 266 | "Trying to deserialize singlesig export, got wallet {} != {FROZENKRILL_WALLET}", 267 | d.wallet 268 | ); 269 | anyhow::ensure!( 270 | d.version == ZERO_MULTISIG_WALLET_VERSION, 271 | "Trying to deserialize multisig export, got version {} != {ZERO_MULTISIG_WALLET_VERSION}", 272 | d.version 273 | ); 274 | let sigtype = SigType::from_str(&d.sigtype)?; 275 | anyhow::ensure!( 276 | matches!(sigtype, SigType::Multisig(_)), 277 | "Trying to deserialize multisig export, got sigtype {sigtype} not multisig" 278 | ); 279 | Ok(d) 280 | } 281 | 282 | pub fn to_vec_pretty(&self) -> anyhow::Result> { 283 | serde_json::to_vec_pretty(self).context("failure serializing json") 284 | } 285 | 286 | pub fn to_string_pretty(&self) -> anyhow::Result { 287 | serde_json::to_string_pretty(self).context("failure serializing json") 288 | } 289 | 290 | pub fn from_path(path: &Path) -> anyhow::Result { 291 | let data = crate::utils::buf_open_file(path) 292 | .with_context(|| format!("failure opening output file {path:?}"))?; 293 | Self::deserialize(data).with_context(|| format!("failure decoding output file {path:?}")) 294 | } 295 | } 296 | 297 | #[cfg(test)] 298 | mod tests { 299 | use std::{str::FromStr, sync::Arc}; 300 | 301 | use secrecy::SecretBox; 302 | 303 | use crate::random_generation_utils::get_secp; 304 | 305 | use super::*; 306 | 307 | type Secret = SecretBox; 308 | 309 | #[test] 310 | fn test_json_public_export() -> anyhow::Result<()> { 311 | use pretty_assertions::assert_eq; 312 | 313 | // TODO: also test using https://github.com/satoshilabs/slips/blob/master/slip-0014.md 314 | let seed_phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; 315 | let mnemonic = Secret::from(Box::new(bip39::Mnemonic::from_str(seed_phrase)?)); 316 | let mut rng = rand::thread_rng(); 317 | let secp = get_secp(&mut rng); 318 | let w = SingleSigWalletDescriptionV0::generate( 319 | Arc::new(mnemonic), 320 | &None, 321 | bitcoin::Network::Bitcoin, 322 | ScriptType::SegwitNative, 323 | &secp, 324 | )?; 325 | let receiving = w.derive_receiving_addresses(0, 2, &secp)?; 326 | let change = w.derive_change_addresses(0, 2, &secp)?; 327 | let generated = SinglesigJsonWalletPublicExportV0::generate(&w, receiving, change)?; 328 | let expected = SinglesigJsonWalletPublicExportV0 { 329 | wallet: FROZENKRILL_WALLET.to_owned(), 330 | version: ZERO_SINGLESIG_WALLET_VERSION, 331 | sigtype: "singlesig".into(), 332 | script_type: "segwit-native".into(), 333 | singlesig_xpub: "zpub6rFR7y4Q2AijBEqTUquhVz398htDFrtymD9xYYfG1m4wAcvPhXNfE3EfH1r1ADqtfSdVCToUG868RvUUkgDKf31mGDtKsAYz2oz2AGutZYs".into(), 334 | singlesig_derivation_path: "84'/0'/0'".into(), 335 | singlesig_receiving_output_descriptor: "wpkh([73c5da0a/84'/0'/0']xpub6CatWdiZiodmUeTDp8LT5or8nmbKNcuyvz7WyksVFkKB4RHwCD3XyuvPEbvqAQY3rAPshWcMLoP2fMFMKHPJ4ZeZXYVUhLv1VMrjPC7PW6V/0/*)#wc3n3van".into(), 336 | singlesig_change_output_descriptor: "wpkh([73c5da0a/84'/0'/0']xpub6CatWdiZiodmUeTDp8LT5or8nmbKNcuyvz7WyksVFkKB4RHwCD3XyuvPEbvqAQY3rAPshWcMLoP2fMFMKHPJ4ZeZXYVUhLv1VMrjPC7PW6V/1/*)#lv5jvedt".into(), 337 | multisig_receiving_output_descriptor_key: "[73c5da0a/48'/0'/0'/2']xpub6DkFAXWQ2dHxq2vatrt9qyA3bXYU4ToWQwCHbf5XB2mSTexcHZCeKS1VZYcPoBd5X8yVcbXFHJR9R8UCVpt82VX1VhR28mCyxUFL4r6KFrf/0/*".into(), 338 | multisig_change_output_descriptor_key: "[73c5da0a/48'/0'/0'/2']xpub6DkFAXWQ2dHxq2vatrt9qyA3bXYU4ToWQwCHbf5XB2mSTexcHZCeKS1VZYcPoBd5X8yVcbXFHJR9R8UCVpt82VX1VhR28mCyxUFL4r6KFrf/1/*".into(), 339 | network: "bitcoin".into(), 340 | receiving_addresses: vec![ 341 | JsonDerivedAddressInfo { 342 | address: "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu".into(), 343 | derivation_path: "84'/0'/0'/0/0".into(), 344 | }, 345 | JsonDerivedAddressInfo { 346 | address: "bc1qnjg0jd8228aq7egyzacy8cys3knf9xvrerkf9g".into(), 347 | derivation_path: "84'/0'/0'/0/1".into(), 348 | }, 349 | ], 350 | change_addresses: vec![ 351 | JsonDerivedAddressInfo { 352 | address: "bc1q8c6fshw2dlwun7ekn9qwf37cu2rn755upcp6el".into(), 353 | derivation_path: "84'/0'/0'/1/0".into(), 354 | }, 355 | JsonDerivedAddressInfo { 356 | address: "bc1qggnasd834t54yulsep6fta8lpjekv4zj6gv5rf".into(), 357 | derivation_path: "84'/0'/0'/1/1".into(), 358 | } 359 | ], 360 | master_fingerprint: "73c5da0a".into(), 361 | multisig_xpub: "Zpub74Jru6aftwwHxCUCWEvP6DgrfFsdA4U6ZRtQ5i8qJpMcC39yZGv3egBhQfV3MS9pZtH5z8iV5qWkJsK6ESs6mSzt4qvGhzJxPeeVS2e1zUG".into(), 362 | multisig_derivation_path: "48'/0'/0'/2'".into(), 363 | }; 364 | assert_eq!(generated, expected); 365 | Ok(()) 366 | } 367 | } 368 | --------------------------------------------------------------------------------