diff --git a/crates/notsecrets/src/identities/mod.rs b/crates/notsecrets/src/identities/mod.rs index 7d93268..5fa4728 100644 --- a/crates/notsecrets/src/identities/mod.rs +++ b/crates/notsecrets/src/identities/mod.rs @@ -9,6 +9,9 @@ pub use ssh_ed25519::SshEd25519Identity; pub mod scrypt; pub use scrypt::ScryptIdentity; +pub mod ssh_rsa; +pub use ssh_rsa::SshRsaIdentity; + /// The symmetric file encryption key — 16 random bytes. #[derive(Clone, Debug)] pub struct FileKey([u8; 16]); diff --git a/crates/notsecrets/src/identities/ssh_rsa.rs b/crates/notsecrets/src/identities/ssh_rsa.rs new file mode 100644 index 0000000..00527b6 --- /dev/null +++ b/crates/notsecrets/src/identities/ssh_rsa.rs @@ -0,0 +1,104 @@ +use crate::error::AgeError; +use crate::identities::{FileKey, Identity, Stanza}; +use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD}; +use rsa::{Oaep, RsaPrivateKey, RsaPublicKey, traits::PublicKeyParts}; +use sha2::{Digest, Sha256}; + +const TAG: &str = "ssh-rsa"; + +pub struct SshRsaIdentity { + private_key: RsaPrivateKey, +} + +impl SshRsaIdentity { + pub fn from_private_key(private_key: RsaPrivateKey) -> Self { + Self { private_key } + } + + fn fingerprint(&self) -> [u8; 4] { + rsa_pubkey_fingerprint(&RsaPublicKey::from(&self.private_key)) + } +} + +impl Identity for SshRsaIdentity { + fn unwrap_file_key(&self, stanza: &Stanza) -> Option> { + if stanza.tag != TAG { + return None; + } + if stanza.args.len() != 1 { + return Some(Err(AgeError::ParseError( + "ssh-rsa stanza must have 1 arg".to_string(), + ))); + } + let fp = match STANDARD_NO_PAD.decode(&stanza.args[0]) { + Ok(b) => b, + Err(e) => return Some(Err(AgeError::ParseError(format!("fingerprint base64: {e}")))), + }; + if fp.as_slice() != self.fingerprint() { + return None; + } + Some(unwrap(stanza, &self.private_key)) + } +} + +fn unwrap(stanza: &Stanza, private_key: &RsaPrivateKey) -> Result { + let padding = Oaep::new::(); + let file_key_bytes = private_key + .decrypt(padding, &stanza.body) + .map_err(|_| AgeError::CryptoError("RSA-OAEP decrypt failed".to_string()))?; + FileKey::try_from(file_key_bytes.as_slice()) +} + +pub(crate) fn rsa_pubkey_fingerprint(public_key: &RsaPublicKey) -> [u8; 4] { + let n_bytes = public_key.n().to_bytes_be(); + let e_bytes = public_key.e().to_bytes_be(); + let mut hasher = Sha256::new(); + hasher.update(&n_bytes); + hasher.update(&e_bytes); + let hash = hasher.finalize(); + hash[..4].try_into().expect("SHA-256 is 32 bytes") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::recipients::ssh_rsa::SshRsaRecipient; + use crate::recipients::Recipient; + use rand::rngs::OsRng; + + fn test_rsa_keypair() -> (RsaPrivateKey, RsaPublicKey) { + let private_key = RsaPrivateKey::new(&mut OsRng, 2048).expect("RSA keygen failed"); + let public_key = RsaPublicKey::from(&private_key); + (private_key, public_key) + } + + #[test] + fn ssh_rsa_wrap_unwrap_roundtrip() { + let (private_key, public_key) = test_rsa_keypair(); + let recipient = SshRsaRecipient::from_public_key(public_key); + let identity = SshRsaIdentity::from_private_key(private_key); + + let file_key = FileKey::new([0x55u8; 16]); + let stanza = recipient.wrap_file_key(&file_key).expect("wrap should succeed"); + assert_eq!(stanza.tag, "ssh-rsa"); + 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 ssh_rsa_wrong_fingerprint_returns_none() { + let (private_key1, _) = test_rsa_keypair(); + let (_, public_key2) = test_rsa_keypair(); + let recipient = SshRsaRecipient::from_public_key(public_key2); + let identity = SshRsaIdentity::from_private_key(private_key1); + + let file_key = FileKey::new([0x55u8; 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 8f95233..1d80cd4 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, SshEd25519Identity}; -pub use recipients::{Recipient, X25519Recipient, ScryptRecipient, SshEd25519Recipient}; +pub use identities::{FileKey, Header, Identity, Stanza, X25519Identity, ScryptIdentity, SshEd25519Identity, SshRsaIdentity}; +pub use recipients::{Recipient, X25519Recipient, ScryptRecipient, SshEd25519Recipient, SshRsaRecipient}; 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 8d0389b..1c1ed1d 100644 --- a/crates/notsecrets/src/recipients/mod.rs +++ b/crates/notsecrets/src/recipients/mod.rs @@ -10,6 +10,9 @@ pub use ssh_ed25519::SshEd25519Recipient; pub mod scrypt; pub use scrypt::ScryptRecipient; +pub mod ssh_rsa; +pub use ssh_rsa::SshRsaRecipient; + /// 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/ssh_rsa.rs b/crates/notsecrets/src/recipients/ssh_rsa.rs new file mode 100644 index 0000000..fe4b6ea --- /dev/null +++ b/crates/notsecrets/src/recipients/ssh_rsa.rs @@ -0,0 +1,38 @@ +use crate::error::AgeError; +use crate::identities::{FileKey, Stanza}; +use crate::identities::ssh_rsa::rsa_pubkey_fingerprint; +use crate::recipients::Recipient; +use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD}; +use rsa::{Oaep, RsaPublicKey}; +use sha2::Sha256; + +const TAG: &str = "ssh-rsa"; + +pub struct SshRsaRecipient { + public_key: RsaPublicKey, +} + +impl SshRsaRecipient { + pub fn from_public_key(public_key: RsaPublicKey) -> Self { + Self { public_key } + } +} + +impl Recipient for SshRsaRecipient { + fn wrap_file_key(&self, file_key: &FileKey) -> Result { + use rand::rngs::OsRng; + let padding = Oaep::new::(); + let wrapped = self + .public_key + .encrypt(&mut OsRng, padding, file_key.as_bytes()) + .map_err(|_| AgeError::CryptoError("RSA-OAEP encrypt failed".to_string()))?; + + let fingerprint = rsa_pubkey_fingerprint(&self.public_key); + + Ok(Stanza { + tag: TAG.to_string(), + args: vec![STANDARD_NO_PAD.encode(fingerprint)], + body: wrapped, + }) + } +}