diff --git a/.github/workflows/build_test.yml b/.github/workflows/build_test.yml index 2982b83..f2faccb 100644 --- a/.github/workflows/build_test.yml +++ b/.github/workflows/build_test.yml @@ -21,6 +21,6 @@ jobs: - name: Build with default features run: cargo build --verbose - name: Build - run: cargo build --features accesssecret --verbose + run: cargo build --features accesssecret --features toolhelper --verbose - name: Run tests - run: cargo test --features accesssecret --verbose + run: cargo test --features accesssecret --features toolhelper --verbose diff --git a/Cargo.toml b/Cargo.toml index 9ebbe0a..aa69ea7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ resolver = "2" members = [ "secretstore", "seedstore", + "seedstore-tool", ] exclude = [] diff --git a/README.md b/README.md index d48777b..800c64a 100644 --- a/README.md +++ b/README.md @@ -28,12 +28,11 @@ Steps to mitigate the risks: - enforce restricted permissions on the file - encorce strong password -- seedstore tool (for prompting for and saving secret) - version 1.0, with format guarantee - (later) breaking challenge bounty -## Usage Example +## Usage -- Example usage of the code Reading secret from file: @@ -58,7 +57,7 @@ use seedstore::SeedStoreCreator; See the [example programs](seedstore/examples). -## Usage Guide +## Usage -- Building `SeedStore` is a simple Rust library. To compile it, use the usual Rust commands. @@ -75,6 +74,26 @@ cargo run --example create_seedstore _MSRV_: Rust 1.81 (due to `fs::exists`) +## Usage -- Tool + +`seedstore-tool` is a command-line utility to create or check secret files. +Here are some sample calls to get started: + +``` +cargo r -p seedstore-tool -- --help +``` + +Check existing secret file; type 'password' for encryption password, twice: +``` +cargo r -p seedstore-tool -- --file seedstore/sample_secret.sec +``` + +Create a new secret, enter secret information to store: +``` +cargo r -p seedstore-tool -- --set --file /tmp/newfile +``` + + ## Data Format The data format of the secret file is documented here: [Data_Format.md](Data_Format.md) diff --git a/secretstore/Cargo.toml b/secretstore/Cargo.toml index 40df981..47db8f2 100644 --- a/secretstore/Cargo.toml +++ b/secretstore/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "secretstore" -version = "0.3.3" +version = "0.3.4" description = "Store a secret (such as a private key) in an encrypted file" license = "MIT" edition = "2021" diff --git a/seedstore-tool/Cargo.toml b/seedstore-tool/Cargo.toml new file mode 100644 index 0000000..4ba3a44 --- /dev/null +++ b/seedstore-tool/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "seedstore-tool" +version = "0.3.4" +edition = "2021" + +[dependencies] +bip39 = "2.1.0" +seedstore = { version = "0.3.4", path = "../seedstore", features = ["toolhelper"] } diff --git a/seedstore-tool/src/main.rs b/seedstore-tool/src/main.rs new file mode 100644 index 0000000..1aa7fb6 --- /dev/null +++ b/seedstore-tool/src/main.rs @@ -0,0 +1,10 @@ +//! `seedstore-tool` is a command-line utility to create or check secret files. + +use seedstore::SeedStoreTool; +use std::env; + +/// Top-level executable implementation for seedstore-tool. +fn main() { + let args: Vec = env::args().collect(); + SeedStoreTool::run(&args); +} diff --git a/seedstore/Cargo.toml b/seedstore/Cargo.toml index 18caec1..3e3d1f9 100644 --- a/seedstore/Cargo.toml +++ b/seedstore/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "seedstore" -version = "0.3.3" +version = "0.3.4" description = "Store bitcoin secret material (BIP39 mnemonic entropy, or similar) in an encrypted file" license = "MIT" edition = "2021" @@ -9,11 +9,14 @@ edition = "2021" default = [] # Allow direct access to secret material accesssecret = [] +# Helpers for seedstore-tool +toolhelper = ["rpassword"] [dependencies] bip39 = { version = "2.1.0", features = ["zeroize"] } bitcoin = "0.32.5" -secretstore = { version = "0.3.3", path = "../secretstore" } +rpassword = { version = "7.4.0", optional = true } +secretstore = { version = "0.3.4", path = "../secretstore" } zeroize = "1.8.1" [dev-dependencies] diff --git a/seedstore/src/lib.rs b/seedstore/src/lib.rs index 410da5c..91d2f56 100644 --- a/seedstore/src/lib.rs +++ b/seedstore/src/lib.rs @@ -14,6 +14,9 @@ mod keystore; mod seedstore; +#[cfg(feature = "toolhelper")] +mod tool; + #[cfg(test)] mod compat_backtest; #[cfg(test)] @@ -24,3 +27,5 @@ mod test_seedstore; // re-exports pub use crate::keystore::{KeyStore, KeyStoreCreator}; pub use crate::seedstore::{ChildSpecifier, SeedStore, SeedStoreCreator}; +#[cfg(feature = "toolhelper")] +pub use crate::tool::SeedStoreTool; diff --git a/seedstore/src/tool.rs b/seedstore/src/tool.rs new file mode 100644 index 0000000..2ce2c76 --- /dev/null +++ b/seedstore/src/tool.rs @@ -0,0 +1,456 @@ +///! Utility tool implementation: tool to create or check an encrypted secret seed file. +use crate::{SeedStore, SeedStoreCreator}; +use bip39::Mnemonic; +use std::io::{self, stdout, BufRead, Write}; +use std::{fs, str::FromStr}; + +const DEFAULT_FILE_NAME: &str = "secret.sec"; +const DEFAULT_NETWORK: u8 = 0; + +#[derive(PartialEq)] +enum Mode { + /// Create new file + Set, + /// Check existing file + Check, + /// Help only + Help, +} + +struct Config { + mode: Mode, + filename: String, + network: Option, + program_name: String, + test_canned_input: Vec, +} + +/// Utility tool implementation: tool to create or check an encrypted secret seed file. +pub struct SeedStoreTool { + config: Config, +} + +impl Config { + fn default() -> Self { + Self { + mode: Mode::Check, + filename: DEFAULT_FILE_NAME.to_owned(), + network: None, + program_name: "tool".to_owned(), + test_canned_input: Vec::new(), + } + } +} + +impl ToString for Config { + fn to_string(&self) -> std::string::String { + let mut s = String::with_capacity(200); + s += &format!("[{}]: ", self.program_name); + s += "Mode: "; + s += match self.mode { + Mode::Check => "Check only", + Mode::Set => "Set", + Mode::Help => "Help only", + }; + s += &format!(" File: {}", self.filename); + if let Some(n) = self.network { + s += &format!(" Network: {}", n); + } + s + } +} + +impl SeedStoreTool { + pub fn new(args: &Vec) -> Result { + // Process cmd line arguments + let config = Self::process_args(args)?; + + Ok(Self::new_from_config(config)) + } + + fn new_from_config(config: Config) -> Self { + Self { config } + } + + pub fn print_usage(progname: &Option<&String>) { + let default_progname = "tool".to_owned(); + let progname = progname.unwrap_or(&default_progname); + println!("{}: Set or check secret seed file", progname); + println!(""); + println!( + "{} [--help] [--set] [--file ] [--signet] [--net ]", + progname + ); + println!(" --set: If specified, mnemominc is prompted for, and secret is saved. Secret file must not exist."); + println!(" Default is to only check secret file, and print the xpub"); + println!( + " --file Secret file to use, default is {}", + DEFAULT_FILE_NAME + ); + println!( + " --signet If specified, assume Signet network. Default is mainnet ({})", + DEFAULT_NETWORK + ); + println!( + " --net Network byte. Default is mainnet ({})", + DEFAULT_NETWORK + ); + println!(" --help Print usage (this)"); + println!(""); + } + + fn process_args(args: &Vec) -> Result { + let mut config = Config::default(); + let len = args.len(); + if len < 1 { + return Err("Internal arg error, progname missing".to_owned()); + } + debug_assert!(len >= 1); + config.program_name = args[0].clone(); + let mut i = 1; + while i < len { + let a = &args[i]; + if *a == "--set" { + config.mode = Mode::Set; + } else if *a == "--file" { + if i + 1 < len { + config.filename = args[i + 1].clone(); + i += 1; + } else { + return Err("--file requires a argument".to_owned()); + } + } else if *a == "--signet" { + config.network = Some(3); + } else if *a == "--net" { + if i + 1 < len { + match args[i + 1].parse::() { + Err(e) => { + return Err(format!("--net requires a numerical argument, {}", e) + .to_string()) + } + Ok(n) => config.network = Some(n), + } + i += 1; + } else { + return Err("--net requires an argument".to_owned()); + } + } else if *a == "--help" { + config.mode = Mode::Help; + } else { + return Err(format!("Unknown argument {}", a)); + } + i += 1; + } + + if config.network.is_some() && config.mode != Mode::Set { + return Err("Network should be specified only in Set mode".to_owned()); + } + + Ok(config) + } + + pub fn run(args: &Vec) { + match Self::new(&args) { + Err(err) => { + println!("Error processing arguments! {}", err); + Self::print_usage(&args.get(0)); + } + Ok(mut tool) => match tool.execute() { + Err(err) => println!("ERROR: {}", err), + Ok(_) => { + println!("Done."); + } + }, + } + } + + /// Return XPub (for testing) + pub fn execute(&mut self) -> Result { + println!("{}", self.config.to_string()); + + match self.config.mode { + Mode::Set => self.do_set(), + Mode::Check => self.do_check(), + Mode::Help => { + Self::print_usage(&Some(&self.config.program_name)); + Ok("".to_owned()) + } + } + } + + /// Perform Set file operation + /// Return XPub (for testing) + fn do_set(&mut self) -> Result { + let exists = fs::exists(&self.config.filename).unwrap_or(true); + if exists { + return Err(format!( + "File already exists, won't overwrite, aborting {}", + self.config.filename + )); + } + + let mnemonic_str = self.read_mnemonic()?; + let mnemonic = Mnemonic::from_str(&mnemonic_str) + .map_err(|e| format!("Invalid mnemonic {}", e.to_string()))?; + let entropy = mnemonic.to_entropy(); + println!("Seedphrase entered, seems OK"); + + let password = self.read_password()?; + + let passphrase = self.read_passphrase_if_needed(&password)?; + + let seedstore = SeedStoreCreator::new_from_data( + &entropy, + self.config.network.unwrap_or_default(), + Some(&passphrase), + ) + .map_err(|e| format!("Could not encrypt secret, {}", e))?; + + let xpub = self.print_info(&seedstore)?; + + let _res = SeedStoreCreator::write_to_file(&seedstore, &self.config.filename, &password) + .map_err(|e| format!("Could not write secret file, {}", e))?; + + println!("Seed written to encrypted file: {}", self.config.filename); + + Ok(xpub) + } + + fn next_canned_input(&mut self) -> Option { + if self.config.test_canned_input.is_empty() { + return None; + } + debug_assert!(self.config.test_canned_input.len() >= 1); + let next = self.config.test_canned_input.remove(0); + println!("Using canned input '{}'", next); + Some(next) + } + + fn read_no_echo(&mut self, item_name: &str, prompt: &str) -> Result { + if let Some(canned_input) = self.next_canned_input() { + return Ok(canned_input); + } + let result = rpassword::prompt_password(prompt) + .map_err(|e| format!("Error reading {}, {}", item_name, e.to_string()))?; + Ok(result) + } + + fn read_password(&mut self) -> Result { + let password1 = self.read_no_echo("password", "Enter the encryption password: ")?; + let password2 = self.read_no_echo("password", "Repeat the encryption password: ")?; + if password1 != password2 { + return Err("The two passwords don't match".to_owned()); + } + println!("Passwords entered, match OK"); + debug_assert_eq!(password1, password2); + Ok(password1) + } + + fn read_mnemonic(&mut self) -> Result { + let mnemonic = self.read_no_echo( + "mnemonic", + "Enter the seedphrase (mnemonic) words (input is hidden): ", + )?; + Ok(mnemonic) + } + + fn read_line(&mut self) -> String { + if let Some(canned_input) = self.next_canned_input() { + return canned_input; + } + let stdin = io::stdin(); + let mut iterator = stdin.lock().lines(); + let line1 = iterator.next().unwrap().unwrap_or_default(); + line1 + } + + fn read_passphrase(&mut self) -> Result { + let passphrase1 = self.read_no_echo("passphrase", "Enter the seed passphrase: ")?; + let passphrase2 = self.read_no_echo("passphrase", "Repeat the seed passphrase: ")?; + if passphrase1 != passphrase2 { + return Err("The two passphrase don't match".to_owned()); + } + println!("Passphrases entered, match OK"); + debug_assert_eq!(passphrase1, passphrase2); + Ok(passphrase1) + } + + fn read_passphrase_if_needed( + &mut self, + encryption_password: &String, + ) -> Result { + println!("Optionally, a seed passphrase can be used. Please choose:"); + println!(" Enter or 0 : no passphrase"); + println!(" 1 : enter a passphrase"); + println!(" 2 : use the encryption password as passphrase too"); + loop { + print!("Enter your choice: "); + let _res = stdout().flush(); + let resp = self.read_line(); + match resp.as_str() { + "" | "0" => return Ok("".to_string()), + "1" => { + let passphrase = self.read_passphrase()?; + return Ok(passphrase); + } + "2" => return Ok(encryption_password.clone()), + _ => {} + } + } + } + + /// Print out info from the seedstore + /// Return XPub (for testing) + fn print_info(&self, seedstore: &SeedStore) -> Result { + let xpub = seedstore.get_xpub()?.to_string(); + let child_spec0 = crate::ChildSpecifier::Index4(0); + let address0 = seedstore.get_child_address(&child_spec0)?; + let pubkey0 = seedstore.get_child_public_key(&child_spec0)?.to_string(); + + let network = seedstore.network(); + let derivation = child_spec0.derivation_path(network)?.to_string(); + println!( + "XPUB, first address, and public key (network {}, derivation {}):", + network, derivation + ); + println!(" {}", xpub); + println!(" {}", address0); + println!(" {}", pubkey0); + println!(""); + + Ok(xpub) + } + + /// Return XPub (for testing) + fn do_check(&mut self) -> Result { + let exists = fs::exists(&self.config.filename).unwrap_or(false); + if !exists { + return Err(format!( + "Could not find secret file {}", + self.config.filename + )); + } + + let password = self.read_password()?; + + let passphrase = self.read_passphrase_if_needed(&password)?; + + let seedstore = + SeedStore::new_from_encrypted_file(&self.config.filename, &password, Some(&passphrase)) + .map_err(|e| format!("Could not read secret file, {}", e))?; + + println!(""); + println!( + "Seed has been read from secret file {}", + self.config.filename + ); + + let xpub = self.print_info(&seedstore)?; + + Ok(xpub) + } +} + +#[cfg(test)] +mod tests { + use super::{Config, Mode, SeedStore, SeedStoreTool}; + use hex_conservative::FromHex; + use rand::Rng; + use std::env::temp_dir; + use std::fs; + + const PAYLOAD_V1_EV2_SCRYPT: &str = "53530104002a2b2c020ee3d3970706fbe9f680eb68763af3c849100010907fc4f2740c7613422df300488137c1e6af59"; + const PASSWORD1: &str = "password"; + const XPUB1: &str = "xpub6CDDB17Xj7pDDWedpLsED1JbPPQmyuapHmAzQEEs2P57hciCjwQ3ov7TfGsTZftAM2gVdPzE55L6gUvHguwWjY82518zw1Z3VbDeWgx3Jqs"; + const MNEMO1: &str = "oil oil oil oil oil oil oil oil oil oil oil oil"; + + fn get_temp_file_name() -> String { + format!( + "{}/_seedstore_tempfile_{}_.tmp", + temp_dir().to_str().unwrap(), + rand::rng().random::() + ) + } + + #[test] + fn check() { + let temp_file = get_temp_file_name(); + let payload = Vec::from_hex(PAYLOAD_V1_EV2_SCRYPT).unwrap(); + let _res = fs::write(&temp_file, &payload).unwrap(); + + let config = Config { + mode: Mode::Check, + filename: temp_file.clone(), + network: None, + program_name: "tool".to_owned(), + test_canned_input: vec![ + PASSWORD1.to_owned(), // encryption password + PASSWORD1.to_owned(), // repeat encryption password + "".to_owned(), // passphrase + ], + }; + let mut tool = SeedStoreTool::new_from_config(config); + + let xpub = tool.execute().unwrap(); + + assert_eq!(xpub.to_string(), XPUB1); + + let _res = fs::remove_file(&temp_file); + } + + #[test] + fn set() { + let temp_file = get_temp_file_name(); + + let network = 3; + let config = Config { + mode: Mode::Set, + filename: temp_file.clone(), + network: Some(network), + program_name: "tool".to_owned(), + test_canned_input: vec![ + MNEMO1.to_owned(), // mnemonic + PASSWORD1.to_owned(), // encryption password + PASSWORD1.to_owned(), // repeat encryption password + "".to_owned(), // passphrase + ], + }; + let mut tool = SeedStoreTool::new_from_config(config); + + let xpub = tool.execute().unwrap(); + + assert_eq!(xpub.to_string(), "tpubDCRo9GmRAvEWANJ5iSfMEqPoq3uYvjBPAAjrDj5iQMxAq7DCs5orw7m9xJes8hWYAwKuH3T63WrKfzzw7g9ucbjq4LUu5cgCLUPMN7gUkrL"); + + // Read back the file + { + let store = SeedStore::new_from_encrypted_file(&temp_file, PASSWORD1, None).unwrap(); + + assert_eq!(store.network(), network); + assert_eq!(store.get_xpub().unwrap().to_string(), "tpubDCRo9GmRAvEWANJ5iSfMEqPoq3uYvjBPAAjrDj5iQMxAq7DCs5orw7m9xJes8hWYAwKuH3T63WrKfzzw7g9ucbjq4LUu5cgCLUPMN7gUkrL"); + drop(store); + } + + let _res = fs::remove_file(&temp_file); + } + + #[test] + fn neg_set_pw_dont_match() { + let temp_file = get_temp_file_name(); + + let config = Config { + mode: Mode::Set, + filename: temp_file, + network: Some(0), + program_name: "tool".to_owned(), + test_canned_input: vec![ + MNEMO1.to_owned(), // mnemonic + "password".to_owned(), // encryption password + "passsword".to_owned(), // repeat encryption password + ], + }; + let mut tool = SeedStoreTool::new_from_config(config); + + let res = tool.execute(); + assert_eq!(res.err().unwrap(), "The two passwords don't match"); + } +}