diff --git a/crates/notsecrets/src/identities/mod.rs b/crates/notsecrets/src/identities/mod.rs index 30def4d..00397f4 100644 --- a/crates/notsecrets/src/identities/mod.rs +++ b/crates/notsecrets/src/identities/mod.rs @@ -1,5 +1,8 @@ use crate::error::AgeError; +pub mod x25519; +pub use x25519::X25519Identity; + /// The symmetric file encryption key — 16 random bytes. #[derive(Clone)] pub struct FileKey([u8; 16]); diff --git a/crates/notsecrets/src/identities/x25519.rs b/crates/notsecrets/src/identities/x25519.rs new file mode 100644 index 0000000..08f0b2a --- /dev/null +++ b/crates/notsecrets/src/identities/x25519.rs @@ -0,0 +1,163 @@ +use crate::error::AgeError; +use crate::identities::{FileKey, Identity, Stanza}; +use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD}; +use bech32::{Bech32, Hrp}; +use chacha20poly1305::{ChaCha20Poly1305, Key, KeyInit, Nonce, aead::Aead}; +use hkdf::Hkdf; +use sha2::Sha256; +use x25519_dalek::{PublicKey, StaticSecret}; + +const TAG: &str = "X25519"; +const HKDF_INFO: &[u8] = b"age-encryption.org/v1/X25519"; +const IDENTITY_HRP: &str = "age-secret-key-"; + +pub struct X25519Identity { + secret: StaticSecret, +} + +impl X25519Identity { + pub fn from_static_secret(secret: StaticSecret) -> Self { + Self { secret } + } + + pub fn from_bech32(s: &str) -> Result { + let s_lower = s.to_lowercase(); + let (hrp, data) = bech32::decode(&s_lower) + .map_err(|e| AgeError::ParseError(format!("bech32 decode identity: {e}")))?; + if hrp.as_str() != IDENTITY_HRP { + return Err(AgeError::ParseError(format!( + "expected hrp '{IDENTITY_HRP}', got '{}'", + hrp.as_str() + ))); + } + let bytes: [u8; 32] = data + .try_into() + .map_err(|_| AgeError::ParseError("identity key must be 32 bytes".to_string()))?; + Ok(Self { + secret: StaticSecret::from(bytes), + }) + } + + pub fn to_bech32(&self) -> String { + let hrp = Hrp::parse(IDENTITY_HRP).expect("static hrp is valid"); + bech32::encode::(hrp, self.secret.as_bytes()) + .expect("bech32 encode cannot fail for valid input") + .to_uppercase() + } +} + +impl Identity for X25519Identity { + fn unwrap_file_key(&self, stanza: &Stanza) -> Option> { + if stanza.tag != TAG { + return None; + } + Some(unwrap_stanza(stanza, &self.secret)) + } +} + +fn unwrap_stanza(stanza: &Stanza, secret: &StaticSecret) -> Result { + if stanza.args.len() != 1 { + return Err(AgeError::ParseError( + "X25519 stanza must have exactly 1 arg".to_string(), + )); + } + let ephemeral_pub_bytes = STANDARD_NO_PAD + .decode(&stanza.args[0]) + .map_err(|e| AgeError::ParseError(format!("ephemeral pubkey base64: {e}")))?; + let ephemeral_pub_bytes: [u8; 32] = ephemeral_pub_bytes + .try_into() + .map_err(|_| AgeError::ParseError("ephemeral pubkey must be 32 bytes".to_string()))?; + let ephemeral_pub = PublicKey::from(ephemeral_pub_bytes); + + let shared = secret.diffie_hellman(&ephemeral_pub); + let recipient_pub = PublicKey::from(secret); + + let wrap_key = derive_wrap_key( + shared.as_bytes(), + ephemeral_pub.as_bytes(), + recipient_pub.as_bytes(), + )?; + + 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("X25519 file key decryption failed".to_string()))?; + + FileKey::try_from(file_key_bytes.as_slice()) +} + +pub(crate) fn derive_wrap_key( + shared: &[u8], + ephemeral_pub: &[u8], + recipient_pub: &[u8], +) -> Result<[u8; 32], AgeError> { + let mut ikm = Vec::with_capacity(shared.len() + ephemeral_pub.len() + recipient_pub.len()); + ikm.extend_from_slice(shared); + ikm.extend_from_slice(ephemeral_pub); + ikm.extend_from_slice(recipient_pub); + + let hk = Hkdf::::new(None, &ikm); + let mut wrap_key = [0u8; 32]; + hk.expand(HKDF_INFO, &mut wrap_key) + .map_err(|e| AgeError::CryptoError(format!("HKDF expand: {e}")))?; + Ok(wrap_key) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::identities::FileKey; + use crate::recipients::Recipient; + use crate::recipients::x25519::X25519Recipient; + + #[test] + fn x25519_wrap_unwrap_roundtrip() { + let secret_bytes = [1u8; 32]; + let secret = StaticSecret::from(secret_bytes); + let public = PublicKey::from(&secret); + let recipient = X25519Recipient::from_public_key(public); + let identity = X25519Identity::from_static_secret(StaticSecret::from(secret_bytes)); + let file_key = FileKey::new([0x42u8; 16]); + let stanza = recipient.wrap_file_key(&file_key).expect("wrap should succeed"); + assert_eq!(stanza.tag, "X25519"); + assert_eq!(stanza.args.len(), 1); + 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 x25519_wrong_tag_returns_none() { + let secret = StaticSecret::from([2u8; 32]); + let identity = X25519Identity::from_static_secret(secret); + let stanza = Stanza { + tag: "scrypt".to_string(), + args: vec!["salt".to_string(), "18".to_string()], + body: vec![0u8; 32], + }; + assert!(identity.unwrap_file_key(&stanza).is_none()); + } + + #[test] + fn x25519_bech32_roundtrip() { + let secret_bytes = [3u8; 32]; + let secret = StaticSecret::from(secret_bytes); + let public = PublicKey::from(&secret); + let recipient_str = X25519Recipient::from_public_key(public).to_bech32(); + assert!(recipient_str.starts_with("age1")); + let identity_str = X25519Identity::from_static_secret(StaticSecret::from(secret_bytes)).to_bech32(); + assert!(identity_str.starts_with("AGE-SECRET-KEY-1")); + } + + #[test] + fn x25519_bech32_identity_roundtrip() { + let secret_bytes = [4u8; 32]; + let identity = X25519Identity::from_static_secret(StaticSecret::from(secret_bytes)); + let encoded = identity.to_bech32(); + let decoded = X25519Identity::from_bech32(&encoded).expect("decode should succeed"); + assert_eq!(decoded.secret.as_bytes(), &secret_bytes); + } +} diff --git a/crates/notsecrets/src/lib.rs b/crates/notsecrets/src/lib.rs index aa46b6f..5131a6e 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}; -pub use recipients::Recipient; +pub use identities::{FileKey, Header, Identity, Stanza, X25519Identity}; +pub use recipients::{Recipient, X25519Recipient}; 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 39e8dd7..8079a02 100644 --- a/crates/notsecrets/src/recipients/mod.rs +++ b/crates/notsecrets/src/recipients/mod.rs @@ -1,6 +1,9 @@ use crate::error::AgeError; use crate::identities::{FileKey, Stanza}; +pub mod x25519; +pub use x25519::X25519Recipient; + /// 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/x25519.rs b/crates/notsecrets/src/recipients/x25519.rs new file mode 100644 index 0000000..1f5b3bb --- /dev/null +++ b/crates/notsecrets/src/recipients/x25519.rs @@ -0,0 +1,71 @@ +use crate::error::AgeError; +use crate::identities::{FileKey, Stanza}; +use crate::identities::x25519::derive_wrap_key; +use crate::recipients::Recipient; +use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD}; +use bech32::{Bech32, Hrp}; +use chacha20poly1305::{ChaCha20Poly1305, Key, KeyInit, Nonce, aead::Aead}; +use rand::rngs::OsRng; +use x25519_dalek::{EphemeralSecret, PublicKey}; + +const TAG: &str = "X25519"; +const RECIPIENT_HRP: &str = "age"; + +pub struct X25519Recipient { + public_key: PublicKey, +} + +impl X25519Recipient { + pub fn from_public_key(public_key: PublicKey) -> Self { + Self { public_key } + } + + pub fn from_bech32(s: &str) -> Result { + let (hrp, data) = bech32::decode(s) + .map_err(|e| AgeError::ParseError(format!("bech32 decode recipient: {e}")))?; + if hrp.as_str() != RECIPIENT_HRP { + return Err(AgeError::ParseError(format!( + "expected hrp '{RECIPIENT_HRP}', got '{}'", + hrp.as_str() + ))); + } + let bytes: [u8; 32] = data + .try_into() + .map_err(|_| AgeError::ParseError("recipient key must be 32 bytes".to_string()))?; + Ok(Self { + public_key: PublicKey::from(bytes), + }) + } + + pub fn to_bech32(&self) -> String { + let hrp = Hrp::parse(RECIPIENT_HRP).expect("static hrp is valid"); + bech32::encode::(hrp, self.public_key.as_bytes()) + .expect("bech32 encode cannot fail for valid input") + } +} + +impl Recipient for X25519Recipient { + fn wrap_file_key(&self, file_key: &FileKey) -> Result { + let ephemeral_secret = EphemeralSecret::random_from_rng(OsRng); + let ephemeral_pub = PublicKey::from(&ephemeral_secret); + let shared = ephemeral_secret.diffie_hellman(&self.public_key); + + let wrap_key = derive_wrap_key( + shared.as_bytes(), + ephemeral_pub.as_bytes(), + self.public_key.as_bytes(), + )?; + + 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("X25519 file key encryption failed".to_string()))?; + + Ok(Stanza { + tag: TAG.to_string(), + args: vec![STANDARD_NO_PAD.encode(ephemeral_pub.as_bytes())], + body: wrapped, + }) + } +}