From 566501cb42764a460efe1e1151c00ceabdd3124f Mon Sep 17 00:00:00 2001 From: Joseph O'Brien <98370624+89jobrien@users.noreply.github.com> Date: Wed, 1 Apr 2026 20:53:59 -0400 Subject: [PATCH] feat(notsecrets): SSH Ed25519 identity and recipient --- crates/notsecrets/src/identities/mod.rs | 3 + .../notsecrets/src/identities/ssh_ed25519.rs | 149 ++++++++++++++++++ crates/notsecrets/src/lib.rs | 4 +- crates/notsecrets/src/recipients/mod.rs | 3 + .../notsecrets/src/recipients/ssh_ed25519.rs | 64 ++++++++ 5 files changed, 221 insertions(+), 2 deletions(-) create mode 100644 crates/notsecrets/src/identities/ssh_ed25519.rs create mode 100644 crates/notsecrets/src/recipients/ssh_ed25519.rs diff --git a/crates/notsecrets/src/identities/mod.rs b/crates/notsecrets/src/identities/mod.rs index 7ec0851..7d93268 100644 --- a/crates/notsecrets/src/identities/mod.rs +++ b/crates/notsecrets/src/identities/mod.rs @@ -3,6 +3,9 @@ use crate::error::AgeError; pub mod x25519; pub use x25519::X25519Identity; +pub mod ssh_ed25519; +pub use ssh_ed25519::SshEd25519Identity; + pub mod scrypt; pub use scrypt::ScryptIdentity; diff --git a/crates/notsecrets/src/identities/ssh_ed25519.rs b/crates/notsecrets/src/identities/ssh_ed25519.rs new file mode 100644 index 0000000..dee3c8a --- /dev/null +++ b/crates/notsecrets/src/identities/ssh_ed25519.rs @@ -0,0 +1,149 @@ +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}; +use ed25519_dalek::SigningKey; +use hkdf::Hkdf; +use sha2::{Digest, Sha256, Sha512}; +use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret as X25519StaticSecret}; + +const TAG: &str = "ssh-ed25519"; +const HKDF_INFO: &[u8] = b"age-encryption.org/v1/ssh-ed25519"; + +pub struct SshEd25519Identity { + signing_key: SigningKey, +} + +impl SshEd25519Identity { + pub fn from_signing_key(signing_key: SigningKey) -> Self { + Self { signing_key } + } + + fn x25519_secret(&self) -> X25519StaticSecret { + let hash = Sha512::digest(self.signing_key.as_bytes()); + let mut scalar = [0u8; 32]; + scalar.copy_from_slice(&hash[..32]); + scalar[0] &= 248; + scalar[31] &= 127; + scalar[31] |= 64; + X25519StaticSecret::from(scalar) + } + + fn fingerprint(&self) -> [u8; 4] { + ssh_key_fingerprint(self.signing_key.verifying_key().as_bytes()) + } +} + +impl Identity for SshEd25519Identity { + fn unwrap_file_key(&self, stanza: &Stanza) -> Option> { + if stanza.tag != TAG { + return None; + } + if stanza.args.len() != 2 { + return Some(Err(AgeError::ParseError( + "ssh-ed25519 stanza must have 2 args".to_string(), + ))); + } + let fp_bytes = match STANDARD_NO_PAD.decode(&stanza.args[0]) { + Ok(b) => b, + Err(e) => return Some(Err(AgeError::ParseError(format!("fingerprint base64: {e}")))), + }; + if fp_bytes.as_slice() != self.fingerprint() { + return None; + } + Some(unwrap(stanza, self)) + } +} + +fn unwrap(stanza: &Stanza, identity: &SshEd25519Identity) -> Result { + let ephemeral_pub_bytes = STANDARD_NO_PAD + .decode(&stanza.args[1]) + .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 = X25519PublicKey::from(ephemeral_pub_bytes); + + let x25519_secret = identity.x25519_secret(); + let shared = x25519_secret.diffie_hellman(&ephemeral_pub); + let x25519_pub = X25519PublicKey::from(&x25519_secret); + + let wrap_key = + derive_wrap_key(shared.as_bytes(), ephemeral_pub.as_bytes(), x25519_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("ssh-ed25519 file key decryption failed".to_string()))?; + FileKey::try_from(file_key_bytes.as_slice()) +} + +pub(crate) fn ssh_key_fingerprint(pub_key_bytes: &[u8]) -> [u8; 4] { + let hash = Sha256::digest(pub_key_bytes); + hash[..4].try_into().expect("SHA-256 output is 32 bytes") +} + +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::ssh_ed25519::SshEd25519Recipient; + use crate::recipients::Recipient; + + fn test_signing_key() -> SigningKey { + use rand::rngs::OsRng; + let mut rng = OsRng; + let mut seed = [0u8; 32]; + rand::RngCore::fill_bytes(&mut rng, &mut seed); + SigningKey::from_bytes(&seed) + } + + #[test] + fn ssh_ed25519_wrap_unwrap_roundtrip() { + let signing_key = test_signing_key(); + let verifying_key = signing_key.verifying_key(); + let recipient = SshEd25519Recipient::from_verifying_key(verifying_key); + let identity = SshEd25519Identity::from_signing_key(signing_key); + + let file_key = FileKey::new([0x33u8; 16]); + let stanza = recipient.wrap_file_key(&file_key).expect("wrap should succeed"); + assert_eq!(stanza.tag, "ssh-ed25519"); + 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 ssh_ed25519_wrong_fingerprint_returns_none() { + let signing_key1 = test_signing_key(); + let signing_key2 = test_signing_key(); + let verifying_key1 = signing_key1.verifying_key(); + let recipient = SshEd25519Recipient::from_verifying_key(verifying_key1); + let identity = SshEd25519Identity::from_signing_key(signing_key2); + + let file_key = FileKey::new([0x33u8; 16]); + let stanza = recipient.wrap_file_key(&file_key).unwrap(); + assert!(identity.unwrap_file_key(&stanza).is_none()); + } +} diff --git a/crates/notsecrets/src/lib.rs b/crates/notsecrets/src/lib.rs index 769c1ac..8f95233 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, ScryptIdentity}; -pub use recipients::{Recipient, X25519Recipient, ScryptRecipient}; +pub use identities::{FileKey, Header, Identity, Stanza, X25519Identity, ScryptIdentity, SshEd25519Identity}; +pub use recipients::{Recipient, X25519Recipient, ScryptRecipient, SshEd25519Recipient}; 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 7890e31..8d0389b 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 ssh_ed25519; +pub use ssh_ed25519::SshEd25519Recipient; + pub mod scrypt; pub use scrypt::ScryptRecipient; diff --git a/crates/notsecrets/src/recipients/ssh_ed25519.rs b/crates/notsecrets/src/recipients/ssh_ed25519.rs new file mode 100644 index 0000000..dc5333a --- /dev/null +++ b/crates/notsecrets/src/recipients/ssh_ed25519.rs @@ -0,0 +1,64 @@ +use crate::error::AgeError; +use crate::identities::ssh_ed25519::{derive_wrap_key, ssh_key_fingerprint}; +use crate::identities::{FileKey, Stanza}; +use crate::recipients::Recipient; +use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD}; +use chacha20poly1305::{ChaCha20Poly1305, Key, KeyInit, Nonce, aead::Aead}; +use ed25519_dalek::VerifyingKey; +use rand::rngs::OsRng; +use x25519_dalek::{EphemeralSecret, PublicKey as X25519PublicKey}; + +const TAG: &str = "ssh-ed25519"; + +pub struct SshEd25519Recipient { + verifying_key: VerifyingKey, +} + +impl SshEd25519Recipient { + pub fn from_verifying_key(verifying_key: VerifyingKey) -> Self { + Self { verifying_key } + } + + fn x25519_public(&self) -> X25519PublicKey { + use curve25519_dalek::edwards::CompressedEdwardsY; + let compressed = CompressedEdwardsY(self.verifying_key.to_bytes()); + let point = compressed + .decompress() + .expect("valid ed25519 pubkey decompresses"); + X25519PublicKey::from(point.to_montgomery().to_bytes()) + } +} + +impl Recipient for SshEd25519Recipient { + fn wrap_file_key(&self, file_key: &FileKey) -> Result { + let ephemeral_secret = EphemeralSecret::random_from_rng(OsRng); + let ephemeral_pub = X25519PublicKey::from(&ephemeral_secret); + let recipient_x25519 = self.x25519_public(); + let shared = ephemeral_secret.diffie_hellman(&recipient_x25519); + + let wrap_key = derive_wrap_key( + shared.as_bytes(), + ephemeral_pub.as_bytes(), + recipient_x25519.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("ssh-ed25519 file key encryption failed".to_string()) + })?; + + let fingerprint = ssh_key_fingerprint(self.verifying_key.as_bytes()); + + Ok(Stanza { + tag: TAG.to_string(), + args: vec![ + STANDARD_NO_PAD.encode(fingerprint), + STANDARD_NO_PAD.encode(ephemeral_pub.as_bytes()), + ], + body: wrapped, + }) + } +}