diff --git a/.github/workflows/build_test.yml b/.github/workflows/build_test.yml index f2faccb..d2520f5 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 --features toolhelper --verbose + run: cargo build --features accesssecret --features nostr --features toolhelper --verbose - name: Run tests - run: cargo test --features accesssecret --features toolhelper --verbose + run: cargo test --features accesssecret --features nostr --features toolhelper --verbose diff --git a/secretstore/src/secretstore.rs b/secretstore/src/secretstore.rs index 839d8cb..dad06c6 100644 --- a/secretstore/src/secretstore.rs +++ b/secretstore/src/secretstore.rs @@ -162,8 +162,7 @@ impl SecretStore { })?; // Create contents - let encrypted_payload = - self.assemble_encrypted_payload(encryption_password, options)?; + let encrypted_payload = self.assemble_encrypted_payload(encryption_password, options)?; // Set restricted permissions #[cfg(feature = "unixfilepermissions")] @@ -312,11 +311,7 @@ impl SecretStoreCreator { encryption_password: &str, options: Option, ) -> Result<(), String> { - secretstore.write_to_file( - path_for_secret_file, - encryption_password, - options, - ) + secretstore.write_to_file(path_for_secret_file, encryption_password, options) } } diff --git a/seedstore/Cargo.toml b/seedstore/Cargo.toml index 109ac64..5a8f4b7 100644 --- a/seedstore/Cargo.toml +++ b/seedstore/Cargo.toml @@ -9,10 +9,13 @@ edition = "2021" default = [] # Allow direct access to secret material accesssecret = [] +# Nostr/nsec store +nostr = ["bech32"] # Helpers for seedstore-tool toolhelper = ["rpassword"] [dependencies] +bech32 = { version = "0.9", optional = true } bip39 = { version = "2.1.0", features = ["zeroize"] } bitcoin = "0.32.5" rpassword = { version = "7.4.0", optional = true } diff --git a/seedstore/src/keystore.rs b/seedstore/src/keystore.rs index ee2d833..d6c1b87 100644 --- a/seedstore/src/keystore.rs +++ b/seedstore/src/keystore.rs @@ -7,7 +7,7 @@ //! KeyStore is a solution for storing a single bitcoin-style ECDSA private key (32 bytes) //! in a password-protected encrypted file. -//! SeedStore is built on [`SecretStore`]. +//! KeyStore is built on [`SecretStore`]. //! A typical example is a wallet storing the secret seed. //! See also [`SeedStore`] for storing a master key (as opposed to a single key) @@ -202,15 +202,11 @@ impl KeyStoreCreator { /// ['encryption_password']: The passowrd to be used for encryption, should be strong. /// Minimal length of password is checked. pub fn write_to_file( - seedstore: &KeyStore, + keystore: &KeyStore, path_for_secret_file: &str, encryption_password: &str, options: Option, ) -> Result<(), String> { - seedstore.write_to_file( - path_for_secret_file, - encryption_password, - options, - ) + keystore.write_to_file(path_for_secret_file, encryption_password, options) } } diff --git a/seedstore/src/lib.rs b/seedstore/src/lib.rs index bb8a669..1e9c45b 100644 --- a/seedstore/src/lib.rs +++ b/seedstore/src/lib.rs @@ -13,6 +13,8 @@ //! If only a single key is needed, it it possible to use a single child key, or use [`KeyStore`] for a single key. mod keystore; +#[cfg(feature = "nostr")] +mod nsecstore; mod seedstore; #[cfg(feature = "toolhelper")] @@ -22,11 +24,17 @@ mod tool; mod compat_backtest; #[cfg(test)] mod test_keystore; +// #[cfg(and(test, feature = "nostr"))] +#[cfg(test)] +#[cfg(feature = "nostr")] +mod test_nsecstore; #[cfg(test)] mod test_seedstore; // re-exports pub use crate::keystore::{KeyStore, KeyStoreCreator}; +#[cfg(feature = "nostr")] +pub use crate::nsecstore::{NsecStore, NsecStoreCreator}; pub use crate::seedstore::{ChildSpecifier, SeedStore, SeedStoreCreator}; #[cfg(feature = "toolhelper")] pub use crate::tool::SeedStoreTool; diff --git a/seedstore/src/nsecstore.rs b/seedstore/src/nsecstore.rs new file mode 100644 index 0000000..4ccf539 --- /dev/null +++ b/seedstore/src/nsecstore.rs @@ -0,0 +1,228 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the MIT license +// . +// You may not use this file except in accordance with the license. + +//! NsecStore is a solution for storing a single Nostr private key (nsec, 32 bytes) +//! in a password-protected encrypted file. +//! NsecStore is built on [`SecretStore`]. + +use bech32::{decode, encode, FromBase32, ToBase32}; +use bitcoin::key::Secp256k1; +use bitcoin::secp256k1::ecdsa::Signature; +use bitcoin::secp256k1::{All, Message, PublicKey, SecretKey, Signing}; +use secretstore::{Options, SecretStore, SecretStoreCreator}; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +const NPUB_BECH_HRP: &str = "npub"; + +/// Store a single Nostr nsec 32-byte private key in an encrypted file. +/// The secret can be loaded from an encrypted file. +/// +/// The secret is stored in memory scrambled (using an ephemeral scrambling key). +/// See also [`NsecStoreCreator`], [`super::SeedStore`]. +pub struct NsecStore { + secretstore: SecretStore, + npub: String, + secp: Secp256k1, +} + +/// Helper class for creating the store from given data. +/// Should be used only by the utility that creates the encrypted file. +/// See also [`NsecStore`]. +pub struct NsecStoreCreator {} + +impl NsecStore { + /// Load the secret from a password-protected secret file. + pub fn new_from_encrypted_file( + path_for_secret_file: &str, + encryption_password: &str, + ) -> Result { + let secretstore = + SecretStore::new_from_encrypted_file(path_for_secret_file, encryption_password)?; + Self::new_from_secretstore(secretstore) + } + + /// Load the secret store from encrypted data. + /// Typically the data is stored in a file, but this method takes the contents directly. + pub fn new_from_payload( + secret_payload: &Vec, + encryption_password: &str, + ) -> Result { + let secretstore = SecretStore::new_from_payload(secret_payload, encryption_password)?; + Self::new_from_secretstore(secretstore) + } + + fn new_from_secretstore(secretstore: SecretStore) -> Result { + let secp = Secp256k1::new(); + let npub = Self::get_npub_intern(&secretstore, &secp)?; + + Ok(NsecStore { + secretstore, + npub, + secp, + }) + } + + fn get_npub_intern( + secret_store: &SecretStore, + secp: &Secp256k1, + ) -> Result { + let npub = secret_store + .processed_secret_data(|secret| Self::get_npub_from_secret_intern(secret, &secp))?; + Ok(npub) + } + + /// Caution: secret data is processed internally. + fn get_npub_from_secret_intern( + secret: &Vec, + secp: &Secp256k1, + ) -> Result { + let private_key = SecretKey::from_slice(secret) + .map_err(|e| format!("Secret key conversion error {}", e))?; + let public_key = private_key.x_only_public_key(&secp).0.serialize(); + + let npub = encode( + NPUB_BECH_HRP, + public_key.to_base32(), + bech32::Variant::Bech32, + ) + .map_err(|e| e.to_string())?; + + Ok(npub) + } + + /// Caution: secret data is returned in copy + fn get_secret_private_key_from_secret_intern(secret: &Vec) -> Result { + let private_key = SecretKey::from_slice(secret) + .map_err(|e| format!("Secret key conversion error {}", e))?; + Ok(private_key) + } + + /// Write out secret content to a file. + /// Use it through [`SeedStoreCreator`] + pub(crate) fn write_to_file( + &self, + path_for_secret_file: &str, + encryption_password: &str, + options: Option, + ) -> Result<(), String> { + SecretStoreCreator::write_to_file( + &self.secretstore, + path_for_secret_file, + encryption_password, + options, + ) + } + + /// Return the corresponding npub, generated from the secret nsec. + pub fn get_npub(&self) -> Result<&str, String> { + Ok(&self.npub) + } + + /// Return the PRIVATE key. + /// CAUTION: unencrypted secret is returned in copy! + #[cfg(feature = "accesssecret")] + pub fn get_secret_private_key(&self) -> Result { + self.get_secret_private_key_nonpub() + } + + /// Return the PRIVATE key. + /// CAUTION: unencrypted secret is returned in copy! + fn get_secret_private_key_nonpub(&self) -> Result { + let private_key = self.secretstore.processed_secret_data(|secret| { + Self::get_secret_private_key_from_secret_intern(secret) + })?; + Ok(private_key) + } + + // TODO Change to schnorr! + + /// Sign using the private key. Use ECDSA signature as it is used in bitcoin. + /// A 32-byte digest (hash) is signed. + /// The signer public key has to be provided as well, to be able to check the signer key. + /// Caution: secret material is processed internally + pub fn sign_hash_with_private_key_ecdsa( + &self, + hash: &[u8; 32], + signer_public_key: &PublicKey, + ) -> Result { + let private_key = self.get_secret_private_key_nonpub()?; + let public_key = private_key.public_key(&self.secp); + // verify public key + if *signer_public_key != public_key { + return Err(format!( + "Public key mismatch, {} vs {}", + signer_public_key.to_string(), + public_key.to_string() + )); + } + let msg = Message::from_digest_slice(hash) + .map_err(|e| format!("Hash digest processing error {}", e.to_string()))?; + let signature = self.secp.sign_ecdsa(&msg, &private_key); + + Ok(signature) + } +} + +impl Zeroize for NsecStore { + fn zeroize(&mut self) { + self.secretstore.zeroize(); + let _ = self.npub; + self.secp = Secp256k1::new(); + } +} + +impl ZeroizeOnDrop for NsecStore {} + +impl NsecStoreCreator { + /// Create a new store instance from given secret private key bytes. + /// The store can be written out to file using [`write_to_file`] + /// Caution: unencrypted secret data is taken. + pub fn new_from_data(secret_private_key_bytes: &[u8; 32]) -> Result { + let nonsecret_data = Vec::new(); + + let secretstore = + SecretStoreCreator::new_from_data(nonsecret_data, &secret_private_key_bytes.to_vec())?; + NsecStore::new_from_secretstore(secretstore) + } + + /// Create a new store instance from given secret nsec. + /// The store can be written out to file using [`write_to_file`] + /// Caution: unencrypted secret data is taken. + pub fn new_from_nsec(secret_nsec: &str) -> Result { + let nsec = Self::decode_nsec(secret_nsec)?; + let nonsecret_data = Vec::new(); + + let secretstore = SecretStoreCreator::new_from_data(nonsecret_data, &nsec.to_vec())?; + NsecStore::new_from_secretstore(secretstore) + } + + /// Write out the encrypted contents to a file. + /// ['encryption_password']: The passowrd to be used for encryption, should be strong. + /// Minimal length of password is checked. + pub fn write_to_file( + nsecstore: &NsecStore, + path_for_secret_file: &str, + encryption_password: &str, + options: Option, + ) -> Result<(), String> { + nsecstore.write_to_file(path_for_secret_file, encryption_password, options) + } + + fn decode_nsec(nsec_str: &str) -> Result<[u8; 32], String> { + let nsec_decoded = decode(&nsec_str) + .map_err(|e| format!("Invalid nsec {} {}", nsec_str, e.to_string()))?; + if nsec_decoded.0 != "nsec" { + return Err(format!("Unexcpeted HRP {}", nsec_decoded.0).into()); + } + let nsec = Vec::::from_base32(&nsec_decoded.1) + .map_err(|e| format!("Invalid bech32 {}", e.to_string()))?; + let nsec: [u8; 32] = nsec + .try_into() + .map_err(|_e| format!("Invalid bech32 length"))?; + Ok(nsec) + } +} diff --git a/seedstore/src/seedstore.rs b/seedstore/src/seedstore.rs index 9f36451..4806363 100644 --- a/seedstore/src/seedstore.rs +++ b/seedstore/src/seedstore.rs @@ -425,11 +425,7 @@ impl SeedStoreCreator { encryption_password: &str, options: Option, ) -> Result<(), String> { - seedstore.write_to_file( - path_for_secret_file, - encryption_password, - options, - ) + seedstore.write_to_file(path_for_secret_file, encryption_password, options) } } diff --git a/seedstore/src/test_nsecstore.rs b/seedstore/src/test_nsecstore.rs new file mode 100644 index 0000000..92f4111 --- /dev/null +++ b/seedstore/src/test_nsecstore.rs @@ -0,0 +1,153 @@ +use crate::{NsecStore, NsecStoreCreator}; +use hex_conservative::{DisplayHex, FromHex}; +use rand::Rng; +use std::env::temp_dir; +use std::fs; +use zeroize::Zeroize; + +const PASSWORD1: &str = "password"; +const PASSWORD2: &str = "This is a different password, ain't it?"; +const PASSWORD3: &str = "aA1+bB2+cC3"; +const PAYLOAD_V1_EV2_SCRYPT: &str = "535301042a2b2c2d020ef2b6285346559b45dd51fa64ecb5d4e82000adb162a64389dee81bbf1a788b0626961153c6e6edefe6aa03b07e44d2d3d727bb318d87"; +const NSEC1: &str = "nsec1qqqsyqcyq5rqwzqfpg9scrgwpuqqzqsrqszsvpcgpy9qkrqdpc8ssqr7uq"; +const SECRETKEY1: &str = "000102030405060708090a0b0c0d0e0f000102030405060708090a0b0c0d0e0f"; +const NPUB1: &str = "npub1aylr3kgxnl4fnpexavj6t6da4tdwj9s7lrnr2zxm4qrnxnwwmz9sjam98u"; + +#[test] +fn create_from_nsec() { + let mut store = NsecStoreCreator::new_from_nsec(NSEC1).unwrap(); + + assert_eq!(store.get_npub().unwrap().to_string(), NPUB1); + + // uncomment for obtaining actual output + // let payload = store.secretstore.assemble_encrypted_payload(&PASSWORD1).unwrap(); + // assert_eq!(payload.to_lower_hex_string(), "_placeholder_"); + + store.zeroize(); +} + +#[test] +fn create_from_data() { + let secret_key = <[u8; 32]>::from_hex(SECRETKEY1).unwrap(); + let mut store = NsecStoreCreator::new_from_data(&secret_key).unwrap(); + + assert_eq!(store.get_npub().unwrap().to_string(), NPUB1); + + // uncomment for obtaining actual output + // let payload = store.secretstore.assemble_encrypted_payload(&PASSWORD1).unwrap(); + // assert_eq!(payload.to_lower_hex_string(), "_placeholder_"); + + store.zeroize(); +} + +#[cfg(feature = "accesssecret")] +#[test] +fn create_get_secret() { + let secret_key = <[u8; 32]>::from_hex(SECRETKEY1).unwrap(); + let mut store = NsecStoreCreator::new_from_data(&secret_key).unwrap(); + + assert_eq!( + store + .get_secret_private_key() + .unwrap() + .as_ref() + .to_lower_hex_string(), + SECRETKEY1 + ); + + store.zeroize(); +} + +#[test] +fn create_from_payload_const() { + let payload = Vec::from_hex(PAYLOAD_V1_EV2_SCRYPT).unwrap(); + let password = PASSWORD1.to_owned(); + + let mut store = NsecStore::new_from_payload(&payload, &password).unwrap(); + + assert_eq!(store.get_npub().unwrap().to_string(), NPUB1); + + store.zeroize(); +} + +#[test] +fn neg_create_from_payload_wrong_pw_wrong_result() { + let payload = Vec::from_hex(PAYLOAD_V1_EV2_SCRYPT).unwrap(); + let password = PASSWORD2.to_owned(); + + let mut store = NsecStore::new_from_payload(&payload, &password).unwrap(); + + assert_eq!( + store.get_npub().unwrap().to_string(), + "npub1yr6gz5k6smgufqs83mh67ugjrjnp99wdl0xkxzlekpakx4nv88fqp75qdx" + ); + + store.zeroize(); +} + +fn get_temp_file_name() -> String { + format!( + "{}/_seedstore_tempfile_{}_.tmp", + temp_dir().to_str().unwrap(), + rand::rng().random::() + ) +} + +#[test] +fn write_to_file() { + let secret_key = <[u8; 32]>::from_hex(SECRETKEY1).unwrap(); + let store = NsecStoreCreator::new_from_data(&secret_key).unwrap(); + + let temp_file = get_temp_file_name(); + let password = PASSWORD3.to_owned(); + let _res = store.write_to_file(&temp_file, &password, None).unwrap(); + + // check the file + let contents = fs::read(&temp_file).unwrap(); + // Note: cannot assert full contents, it contains dynamic fields + assert_eq!(contents.len(), 60); + assert_eq!(contents[0..6].to_lower_hex_string(), "53530100020e"); + + let _res = fs::remove_file(&temp_file); +} + +#[test] +fn read_from_file() { + let temp_file = get_temp_file_name(); + + // write constant payload to file + let payload = Vec::from_hex(PAYLOAD_V1_EV2_SCRYPT).unwrap(); + let _res = fs::write(&temp_file, &payload).unwrap(); + + let password = PASSWORD1.to_owned(); + let store = NsecStore::new_from_encrypted_file(&temp_file, &password).unwrap(); + + assert_eq!(store.get_npub().unwrap().to_string(), NPUB1); + + let _res = fs::remove_file(&temp_file); +} + +// #[test] +// fn test_signature() { +// let secret_key = <[u8; 32]>::from_hex(SECRETKEY1).unwrap(); +// let store = NsecStoreCreator::new_from_data(&secret_key).unwrap(); + +// assert_eq!(store.get_npub().unwrap().to_string(), NPUB1); + +// let pubkey = store.get_npub().unwrap(); + +// let hash_to_be_signed = [42; 32]; + +// let signature = store +// .sign_hash_with_private_key_ecdsa(&hash_to_be_signed, &pubkey) +// .unwrap(); + +// // Signature can change, do not assert, +// // but verify the signature +// { +// let secp = bitcoin::secp256k1::Secp256k1::new(); +// let msg = bitcoin::secp256k1::Message::from_digest_slice(&hash_to_be_signed).unwrap(); +// let verify_result = secp.verify_ecdsa(&msg, &signature, &pubkey); +// assert!(verify_result.is_ok()); +// } +// }