From b899198e9826f32b8e9e39ba3ffdc1f232be0742 Mon Sep 17 00:00:00 2001 From: Joseph O'Brien <98370624+89jobrien@users.noreply.github.com> Date: Wed, 1 Apr 2026 20:51:40 -0400 Subject: [PATCH] feat(notsecrets): scrypt identity and recipient (passphrase-based) --- crates/notsecrets/src/identities/mod.rs | 5 +- crates/notsecrets/src/identities/scrypt.rs | 112 +++++++++++++++++++++ crates/notsecrets/src/lib.rs | 4 +- crates/notsecrets/src/recipients/mod.rs | 3 + crates/notsecrets/src/recipients/scrypt.rs | 45 +++++++++ 5 files changed, 166 insertions(+), 3 deletions(-) create mode 100644 crates/notsecrets/src/identities/scrypt.rs create mode 100644 crates/notsecrets/src/recipients/scrypt.rs diff --git a/crates/notsecrets/src/identities/mod.rs b/crates/notsecrets/src/identities/mod.rs index 00397f4..7ec0851 100644 --- a/crates/notsecrets/src/identities/mod.rs +++ b/crates/notsecrets/src/identities/mod.rs @@ -3,8 +3,11 @@ use crate::error::AgeError; pub mod x25519; pub use x25519::X25519Identity; +pub mod scrypt; +pub use scrypt::ScryptIdentity; + /// The symmetric file encryption key — 16 random bytes. -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct FileKey([u8; 16]); impl FileKey { diff --git a/crates/notsecrets/src/identities/scrypt.rs b/crates/notsecrets/src/identities/scrypt.rs new file mode 100644 index 0000000..99ce8cf --- /dev/null +++ b/crates/notsecrets/src/identities/scrypt.rs @@ -0,0 +1,112 @@ +use crate::error::AgeError; +use crate::identities::{FileKey, Identity, Stanza}; +use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD}; +use chacha20poly1305::{ChaCha20Poly1305, Key, KeyInit, Nonce, aead::Aead}; + +const TAG: &str = "scrypt"; + +pub struct ScryptIdentity { + passphrase: String, +} + +impl ScryptIdentity { + pub fn new(passphrase: String) -> Self { + Self { passphrase } + } +} + +impl Identity for ScryptIdentity { + fn unwrap_file_key(&self, stanza: &Stanza) -> Option> { + if stanza.tag != TAG { + return None; + } + Some(unwrap(stanza, &self.passphrase)) + } +} + +fn unwrap(stanza: &Stanza, passphrase: &str) -> Result { + if stanza.args.len() != 2 { + return Err(AgeError::ParseError( + "scrypt stanza must have 2 args: salt work_factor".to_string(), + )); + } + let salt = STANDARD_NO_PAD + .decode(&stanza.args[0]) + .map_err(|e| AgeError::ParseError(format!("scrypt salt base64: {e}")))?; + let work_factor: u8 = stanza.args[1] + .parse() + .map_err(|e| AgeError::ParseError(format!("scrypt work factor parse: {e}")))?; + + let wrap_key = derive_scrypt_key(passphrase.as_bytes(), &salt, work_factor)?; + let cipher = ChaCha20Poly1305::new(Key::from_slice(&wrap_key)); + let nonce = Nonce::default(); + let file_key_bytes = cipher + .decrypt(&nonce, stanza.body.as_slice()) + .map_err(|_| AgeError::CryptoError("scrypt file key decryption failed".to_string()))?; + FileKey::try_from(file_key_bytes.as_slice()) +} + +pub(crate) fn derive_scrypt_key( + passphrase: &[u8], + salt: &[u8], + work_factor: u8, +) -> Result<[u8; 32], AgeError> { + let params = scrypt::Params::new(work_factor, 8, 1, 32) + .map_err(|e| AgeError::CryptoError(format!("scrypt params: {e}")))?; + let mut wrap_key = [0u8; 32]; + scrypt::scrypt(passphrase, salt, ¶ms, &mut wrap_key) + .map_err(|e| AgeError::CryptoError(format!("scrypt: {e}")))?; + Ok(wrap_key) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::identities::FileKey; + use crate::recipients::scrypt::ScryptRecipient; + use crate::recipients::Recipient; + + #[test] + fn scrypt_wrap_unwrap_roundtrip() { + let passphrase = "hunter2".to_string(); + let recipient = ScryptRecipient::new(passphrase.clone(), 14); + let identity = ScryptIdentity::new(passphrase); + + let file_key = FileKey::new([0x11u8; 16]); + let stanza = recipient.wrap_file_key(&file_key).expect("wrap should succeed"); + assert_eq!(stanza.tag, "scrypt"); + assert_eq!(stanza.args.len(), 2); + + let unwrapped = identity + .unwrap_file_key(&stanza) + .expect("identity should match") + .expect("unwrap should succeed"); + assert_eq!(unwrapped.as_bytes(), file_key.as_bytes()); + } + + #[test] + fn scrypt_wrong_passphrase_returns_crypto_error() { + let recipient = ScryptRecipient::new("correct".to_string(), 14); + let identity = ScryptIdentity::new("wrong".to_string()); + + let file_key = FileKey::new([0x11u8; 16]); + let stanza = recipient.wrap_file_key(&file_key).unwrap(); + + let result = identity + .unwrap_file_key(&stanza) + .expect("tag matches") + .expect_err("wrong passphrase should fail"); + assert!(matches!(result, crate::error::AgeError::CryptoError(_))); + } + + #[test] + fn scrypt_wrong_tag_returns_none() { + let identity = ScryptIdentity::new("pass".to_string()); + let stanza = Stanza { + tag: "X25519".to_string(), + args: vec!["arg".to_string()], + body: vec![], + }; + assert!(identity.unwrap_file_key(&stanza).is_none()); + } +} diff --git a/crates/notsecrets/src/lib.rs b/crates/notsecrets/src/lib.rs index 5131a6e..769c1ac 100644 --- a/crates/notsecrets/src/lib.rs +++ b/crates/notsecrets/src/lib.rs @@ -6,8 +6,8 @@ pub mod sources; pub mod _legacy; pub use error::AgeError; -pub use identities::{FileKey, Header, Identity, Stanza, X25519Identity}; -pub use recipients::{Recipient, X25519Recipient}; +pub use identities::{FileKey, Header, Identity, Stanza, X25519Identity, ScryptIdentity}; +pub use recipients::{Recipient, X25519Recipient, ScryptRecipient}; pub use sources::{BitwardenSource, FileSource, PromptSource}; // Legacy API — kept for notstrap compatibility until Task 12. diff --git a/crates/notsecrets/src/recipients/mod.rs b/crates/notsecrets/src/recipients/mod.rs index 8079a02..7890e31 100644 --- a/crates/notsecrets/src/recipients/mod.rs +++ b/crates/notsecrets/src/recipients/mod.rs @@ -4,6 +4,9 @@ use crate::identities::{FileKey, Stanza}; pub mod x25519; pub use x25519::X25519Recipient; +pub mod scrypt; +pub use scrypt::ScryptRecipient; + /// Domain port: a recipient that can wrap a file key into a stanza. pub trait Recipient { fn wrap_file_key(&self, file_key: &FileKey) -> Result; diff --git a/crates/notsecrets/src/recipients/scrypt.rs b/crates/notsecrets/src/recipients/scrypt.rs new file mode 100644 index 0000000..43ce26f --- /dev/null +++ b/crates/notsecrets/src/recipients/scrypt.rs @@ -0,0 +1,45 @@ +use crate::error::AgeError; +use crate::identities::{FileKey, Stanza}; +use crate::identities::scrypt::derive_scrypt_key; +use crate::recipients::Recipient; +use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD}; +use chacha20poly1305::{ChaCha20Poly1305, Key, KeyInit, Nonce, aead::Aead}; +use rand::RngCore; + +const TAG: &str = "scrypt"; +const SALT_LEN: usize = 16; + +pub struct ScryptRecipient { + passphrase: String, + work_factor: u8, +} + +impl ScryptRecipient { + /// `work_factor` is log2(N). Use 18 in production, 14 in tests. + pub fn new(passphrase: String, work_factor: u8) -> Self { + Self { passphrase, work_factor } + } +} + +impl Recipient for ScryptRecipient { + fn wrap_file_key(&self, file_key: &FileKey) -> Result { + let mut salt = [0u8; SALT_LEN]; + rand::thread_rng().fill_bytes(&mut salt); + + let wrap_key = derive_scrypt_key(self.passphrase.as_bytes(), &salt, self.work_factor)?; + let cipher = ChaCha20Poly1305::new(Key::from_slice(&wrap_key)); + let nonce = Nonce::default(); + let wrapped = cipher + .encrypt(&nonce, file_key.as_bytes().as_slice()) + .map_err(|_| AgeError::CryptoError("scrypt file key encryption failed".to_string()))?; + + Ok(Stanza { + tag: TAG.to_string(), + args: vec![ + STANDARD_NO_PAD.encode(salt), + self.work_factor.to_string(), + ], + body: wrapped, + }) + } +}