feat(notfiles): hexagonal architecture refactor with adapters, integration tests, and notsecrets cleanup
Some checks failed
Public Repo Readiness / Check Public Repo Readiness (push) Has been cancelled
Some checks failed
Public Repo Readiness / Check Public Repo Readiness (push) Has been cancelled
- Extract FileStore port and InMemoryFileStore/FileStoreImpl/Reporter adapters - Add Reporter trait to notcore with TerminalReporter and JsonReporter - Refactor linker, package, status modules to use FileStore port - Add integration test suite with dotfiles fixtures - Add cross-crate integration tests - Remove ssh_rsa identity/recipient (unsupported) - Add rustqual.toml, .sccignore, notfiles.toml config files - Update CLAUDE.md, README.md, AGENTS.md with current architecture
This commit is contained in:
BIN
crates/notsecrets/.DS_Store
vendored
Normal file
BIN
crates/notsecrets/.DS_Store
vendored
Normal file
Binary file not shown.
@@ -14,7 +14,6 @@ serde = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
x25519-dalek = { version = "2", features = ["static_secrets"] }
|
||||
ed25519-dalek = "2"
|
||||
rsa = { version = "0.9", features = ["sha2"] }
|
||||
sha2 = "0.10"
|
||||
hkdf = "0.12"
|
||||
chacha20poly1305 = "0.10"
|
||||
|
||||
@@ -9,9 +9,6 @@ pub use ssh_ed25519::SshEd25519Identity;
|
||||
pub mod scrypt;
|
||||
pub use scrypt::ScryptIdentity;
|
||||
|
||||
pub mod ssh_rsa;
|
||||
pub use ssh_rsa::SshRsaIdentity;
|
||||
|
||||
pub mod encrypted;
|
||||
pub use encrypted::EncryptedIdentity;
|
||||
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
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<Result<FileKey, AgeError>> {
|
||||
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<FileKey, AgeError> {
|
||||
let padding = Oaep::new::<Sha256>();
|
||||
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::Recipient;
|
||||
use crate::recipients::ssh_rsa::SshRsaRecipient;
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -15,13 +15,11 @@ pub use encrypt::Encryptor;
|
||||
pub use error::AgeError;
|
||||
pub use error::SecretsError;
|
||||
pub use identities::{
|
||||
EncryptedIdentity, FileKey, Header, Identity, ScryptIdentity, SshEd25519Identity,
|
||||
SshRsaIdentity, Stanza, X25519Identity,
|
||||
EncryptedIdentity, FileKey, Header, Identity, ScryptIdentity, SshEd25519Identity, Stanza,
|
||||
X25519Identity,
|
||||
};
|
||||
pub use ports::{EnumerableSecretSource, IdentitySource, SecretSource};
|
||||
pub use recipients::{
|
||||
Recipient, ScryptRecipient, SshEd25519Recipient, SshRsaRecipient, X25519Recipient,
|
||||
};
|
||||
pub use recipients::{Recipient, ScryptRecipient, SshEd25519Recipient, X25519Recipient};
|
||||
pub use resolver::SecretResolver;
|
||||
pub use sources::{
|
||||
BitwardenSource, DirenvSource, DotenvxSource, DotenvySource, EnvSource, FileSource, GsmSource,
|
||||
|
||||
@@ -10,9 +10,6 @@ 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<Stanza, AgeError>;
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
use crate::error::AgeError;
|
||||
use crate::identities::ssh_rsa::rsa_pubkey_fingerprint;
|
||||
use crate::identities::{FileKey, Stanza};
|
||||
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<Stanza, AgeError> {
|
||||
use rand::rngs::OsRng;
|
||||
let padding = Oaep::new::<Sha256>();
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user