diff --git a/crates/notsecrets/src/_legacy.rs b/crates/notsecrets/src/_legacy.rs deleted file mode 100644 index 22184cf..0000000 --- a/crates/notsecrets/src/_legacy.rs +++ /dev/null @@ -1,126 +0,0 @@ -use anyhow::{Context, Result, bail}; -use std::path::{Path, PathBuf}; -use std::process::Command; - -pub trait AgeKeySource { - fn name(&self) -> &str; - fn retrieve(&self) -> Result; -} - -/// try each source in order; return the first success. -pub fn resolve_age_key(sources: Vec>) -> Result { - let mut last_err = String::new(); - for source in sources { - match source.retrieve() { - Ok(key) => return Ok(key), - Err(e) => { - eprintln!(" [{}] {e}", source.name()); - last_err = format!("{e}"); - } - } - } - bail!("all age key sources failed; last error: {last_err}") -} - -/// write the age key permission:0600 -pub fn install_age_key(key: &str) -> Result { - let path = dirs::home_dir() - .context("cannot find home directory")? - .join(".config/sops/age/keys.txt"); - install_age_key_at(key, &path)?; - Ok(path) -} - -/// Write the age key to `path` with mode 0600, never transiently world-readable. -/// The file is created with the correct permissions atomically via `OpenOptions`. -pub fn install_age_key_at(key: &str, path: &Path) -> Result<()> { - std::fs::create_dir_all( - path.parent() - .context("age keys.txt path has no parent directory")?, - )?; - #[cfg(unix)] - { - use std::io::Write; - use std::os::unix::fs::OpenOptionsExt; - let mut f = std::fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .mode(0o600) - .open(path)?; - f.write_all(key.as_bytes())?; - } - #[cfg(not(unix))] - { - std::fs::write(path, key)?; - } - Ok(()) -} - -/// Build the argument list for `sops --decrypt -- `. -/// Extracted for testability: callers can verify `--` is present without spawning sops. -pub fn sops_args(sops_file: &Path) -> Result> { - let path_str = sops_file - .to_str() - .ok_or_else(|| anyhow::anyhow!("sops path is not valid UTF-8"))? - .to_string(); - Ok(vec!["--decrypt".to_string(), "--".to_string(), path_str]) -} - -/// Run `sops --decrypt -- ` and return the decrypted content. -pub fn decrypt_sops(sops_file: &Path) -> Result { - let args = sops_args(sops_file)?; - let output = Command::new("sops") - .args(&args) - .output() - .context("failed to run sops")?; - - if !output.status.success() { - bail!( - "sops decrypt failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - } - - Ok(String::from_utf8(output.stdout)?) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::os::unix::fs::PermissionsExt; - - /// Issue #3: age key file must never be world-readable, even transiently. - #[test] - fn install_age_key_never_world_readable() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join(".config/sops/age/keys.txt"); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - - install_age_key_at("AGE-SECRET-KEY-TEST", &path).unwrap(); - - let mode = std::fs::metadata(&path).unwrap().permissions().mode(); - assert_eq!( - mode & 0o777, - 0o600, - "key file must be mode 0600, got {:o}", - mode & 0o777 - ); - } - - /// Issue #2: sops invocation must include -- before the path. - #[test] - fn decrypt_sops_args_include_separator() { - let path = std::path::Path::new("/some/file.sops.env"); - let args = sops_args(path).unwrap(); - assert_eq!(args[0], "--decrypt"); - assert_eq!(args[1], "--"); - assert_eq!(args[2], "/some/file.sops.env"); - } - - /// Issue #7: .context() should be used instead of .with_context(closure) for string literals. - #[test] - fn install_age_key_uses_context_not_with_context() { - assert!(true); - } -} diff --git a/crates/notsecrets/src/lib.rs b/crates/notsecrets/src/lib.rs index b593ae7..253cb79 100644 --- a/crates/notsecrets/src/lib.rs +++ b/crates/notsecrets/src/lib.rs @@ -5,22 +5,96 @@ pub mod format; pub mod identities; pub mod recipients; pub mod sources; -pub mod _legacy; -pub use error::AgeError; -pub use identities::{FileKey, Header, Identity, Stanza, X25519Identity, ScryptIdentity, SshEd25519Identity, SshRsaIdentity, EncryptedIdentity}; pub use decrypt::Decryptor; pub use encrypt::Encryptor; -pub use recipients::{Recipient, X25519Recipient, ScryptRecipient, SshEd25519Recipient, SshRsaRecipient}; -pub use sources::{BitwardenSource, FileSource, PromptSource}; - -// Legacy API — kept for notstrap compatibility until Task 12. -#[allow(deprecated)] -pub use _legacy::{ - AgeKeySource, - install_age_key, - install_age_key_at, - resolve_age_key, - decrypt_sops, - sops_args, +pub use error::AgeError; +pub use identities::{ + EncryptedIdentity, FileKey, Header, Identity, ScryptIdentity, SshEd25519Identity, + SshRsaIdentity, Stanza, X25519Identity, }; +pub use recipients::{ + Recipient, ScryptRecipient, SshEd25519Recipient, SshRsaRecipient, X25519Recipient, +}; +pub use sources::{BitwardenSource, FileSource, IdentitySource, PromptSource}; + +/// Try each `IdentitySource` in order; collect all identities that load successfully. +/// +/// Returns an error only if all sources fail. Partial success is accepted because +/// not every source needs to hold the key for the target file. +pub fn resolve_identities( + sources: Vec>, +) -> Result>, AgeError> { + let mut identities: Vec> = Vec::new(); + let mut last_err: Option = None; + + for source in sources { + match source.load() { + Ok(identity) => identities.push(identity), + Err(e) => { + eprintln!(" [{}] {e}", source.name()); + last_err = Some(e); + } + } + } + + if identities.is_empty() { + Err(last_err.unwrap_or(AgeError::SourceError { + name: "resolve_identities".to_string(), + source: anyhow::anyhow!("no sources provided"), + })) + } else { + Ok(identities) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sources::IdentitySource; + + struct AlwaysFailSource; + impl IdentitySource for AlwaysFailSource { + fn name(&self) -> &str { + "always-fail" + } + fn load(&self) -> Result, AgeError> { + Err(AgeError::SourceError { + name: "always-fail".to_string(), + source: anyhow::anyhow!("intentional failure"), + }) + } + } + + struct StaticX25519Source; + impl IdentitySource for StaticX25519Source { + fn name(&self) -> &str { + "static-x25519" + } + fn load(&self) -> Result, AgeError> { + use rand::rngs::OsRng; + use x25519_dalek::StaticSecret; + Ok(Box::new( + crate::identities::x25519::X25519Identity::from_static_secret( + StaticSecret::random_from_rng(OsRng), + ), + )) + } + } + + #[test] + fn resolve_identities_all_fail_returns_error() { + let sources: Vec> = vec![Box::new(AlwaysFailSource)]; + assert!(resolve_identities(sources).is_err()); + } + + #[test] + fn resolve_identities_partial_success_returns_loaded() { + let sources: Vec> = vec![ + Box::new(AlwaysFailSource), + Box::new(StaticX25519Source), + ]; + let ids = resolve_identities(sources).expect("at least one source succeeded"); + assert_eq!(ids.len(), 1); + } +} diff --git a/crates/notsecrets/src/sources/bitwarden.rs b/crates/notsecrets/src/sources/bitwarden.rs index f402d4c..9c23977 100644 --- a/crates/notsecrets/src/sources/bitwarden.rs +++ b/crates/notsecrets/src/sources/bitwarden.rs @@ -1,5 +1,7 @@ -use crate::_legacy::AgeKeySource; -use anyhow::{Result, bail}; +use crate::error::AgeError; +use crate::identities::Identity; +use crate::identities::x25519::X25519Identity; +use crate::sources::IdentitySource; use std::process::Command; pub struct BitwardenSource { @@ -8,61 +10,101 @@ pub struct BitwardenSource { impl BitwardenSource { pub fn new(item_name: impl Into) -> Self { - Self { - item_name: item_name.into(), - } - } -} - -impl AgeKeySource for BitwardenSource { - fn name(&self) -> &str { - "bitwarden" + Self { item_name: item_name.into() } } - fn retrieve(&self) -> Result { - let bw_available = Command::new("sh") + fn retrieve_key(&self) -> Result { + let bw_ok = Command::new("sh") .args(["-c", "command -v bw"]) .status() .map(|s| s.success()) .unwrap_or(false); - if !bw_available { - bail!("bw CLI not found in PATH"); + if !bw_ok { + return Err(AgeError::SourceError { + name: self.name().to_string(), + source: anyhow::anyhow!("bw CLI not found in PATH"), + }); } let session = std::env::var("BW_SESSION").unwrap_or_default(); let session = if session.is_empty() { let password = rpassword::prompt_password("Bitwarden master password: ") - .map_err(|e| anyhow::anyhow!("could not read password: {e}"))?; + .map_err(|e| AgeError::SourceError { + name: self.name().to_string(), + source: anyhow::anyhow!("could not read password: {e}"), + })?; let output = Command::new("bw") .args(["unlock", "--raw", &password]) - .output()?; + .output() + .map_err(|e| AgeError::SourceError { + name: self.name().to_string(), + source: anyhow::anyhow!("bw unlock spawn: {e}"), + })?; if !output.status.success() { - bail!( - "bw unlock failed: {}", - String::from_utf8_lossy(&output.stderr) - ); + return Err(AgeError::SourceError { + name: self.name().to_string(), + source: anyhow::anyhow!( + "bw unlock failed: {}", + String::from_utf8_lossy(&output.stderr) + ), + }); } - String::from_utf8(output.stdout)?.trim().to_string() + String::from_utf8(output.stdout) + .map_err(|e| AgeError::SourceError { + name: self.name().to_string(), + source: anyhow::anyhow!("bw unlock output UTF-8: {e}"), + })? + .trim() + .to_string() } else { session }; let output = Command::new("bw") .args(["get", "notes", &self.item_name, "--session", &session]) - .output()?; - + .output() + .map_err(|e| AgeError::SourceError { + name: self.name().to_string(), + source: anyhow::anyhow!("bw get spawn: {e}"), + })?; if !output.status.success() { - bail!( - "bw get notes '{}' failed: {}", - self.item_name, - String::from_utf8_lossy(&output.stderr) - ); + return Err(AgeError::SourceError { + name: self.name().to_string(), + source: anyhow::anyhow!( + "bw get notes '{}' failed: {}", + self.item_name, + String::from_utf8_lossy(&output.stderr) + ), + }); } - - let key = String::from_utf8(output.stdout)?.trim().to_string(); + let key = String::from_utf8(output.stdout) + .map_err(|e| AgeError::SourceError { + name: self.name().to_string(), + source: anyhow::anyhow!("bw output UTF-8: {e}"), + })? + .trim() + .to_string(); if key.is_empty() { - bail!("Bitwarden item '{}' has empty notes", self.item_name); + return Err(AgeError::SourceError { + name: self.name().to_string(), + source: anyhow::anyhow!("Bitwarden item '{}' has empty notes", self.item_name), + }); } Ok(key) } } + +impl IdentitySource for BitwardenSource { + fn name(&self) -> &str { + "bitwarden" + } + + fn load(&self) -> Result, AgeError> { + let key = self.retrieve_key()?; + let identity = X25519Identity::from_bech32(key.trim()).map_err(|e| AgeError::SourceError { + name: self.name().to_string(), + source: anyhow::anyhow!("key is not a valid AGE-SECRET-KEY-1: {e}"), + })?; + Ok(Box::new(identity)) + } +} diff --git a/crates/notsecrets/src/sources/file.rs b/crates/notsecrets/src/sources/file.rs index e49593b..7c19f96 100644 --- a/crates/notsecrets/src/sources/file.rs +++ b/crates/notsecrets/src/sources/file.rs @@ -1,5 +1,8 @@ -use crate::_legacy::AgeKeySource; -use anyhow::Result; +use crate::error::AgeError; +use crate::identities::encrypted::EncryptedIdentity; +use crate::identities::x25519::X25519Identity; +use crate::identities::Identity; +use crate::sources::IdentitySource; use std::path::PathBuf; pub struct FileSource { @@ -12,13 +15,43 @@ impl FileSource { } } -impl AgeKeySource for FileSource { +impl IdentitySource for FileSource { fn name(&self) -> &str { "file" } - fn retrieve(&self) -> Result { - std::fs::read_to_string(&self.path) - .map_err(|e| anyhow::anyhow!("cannot read key file {}: {e}", self.path.display())) + fn load(&self) -> Result, AgeError> { + let content = std::fs::read(&self.path).map_err(|e| AgeError::SourceError { + name: self.name().to_string(), + source: anyhow::anyhow!("cannot read key file {}: {e}", self.path.display()), + })?; + + if content.starts_with(b"AGE-SECRET-KEY-1") { + let key_str = std::str::from_utf8(&content) + .map_err(|e| AgeError::SourceError { + name: self.name().to_string(), + source: anyhow::anyhow!("key file UTF-8: {e}"), + })? + .trim(); + let identity = X25519Identity::from_bech32(key_str).map_err(|e| AgeError::SourceError { + name: self.name().to_string(), + source: anyhow::anyhow!("invalid AGE-SECRET-KEY-1: {e}"), + })?; + Ok(Box::new(identity)) + } else if content.starts_with(b"age-encryption.org/v1") { + let passphrase = rpassword::prompt_password( + "Enter passphrase for encrypted identity file: ", + ) + .map_err(|e| AgeError::SourceError { + name: self.name().to_string(), + source: anyhow::anyhow!("could not read passphrase: {e}"), + })?; + Ok(Box::new(EncryptedIdentity::new(content, passphrase))) + } else { + Err(AgeError::UnsupportedKeyType(format!( + "file {} does not contain a recognized age key format", + self.path.display() + ))) + } } } diff --git a/crates/notsecrets/src/sources/mod.rs b/crates/notsecrets/src/sources/mod.rs index 59fc105..cb8b444 100644 --- a/crates/notsecrets/src/sources/mod.rs +++ b/crates/notsecrets/src/sources/mod.rs @@ -1,3 +1,6 @@ +use crate::error::AgeError; +use crate::identities::Identity; + pub mod bitwarden; pub mod file; pub mod prompt; @@ -5,3 +8,10 @@ pub mod prompt; pub use bitwarden::BitwardenSource; pub use file::FileSource; pub use prompt::PromptSource; + +/// Infra boundary trait: an identity resolver that loads key material from an +/// external source and returns a concrete Identity. +pub trait IdentitySource { + fn name(&self) -> &str; + fn load(&self) -> Result, AgeError>; +} diff --git a/crates/notsecrets/src/sources/prompt.rs b/crates/notsecrets/src/sources/prompt.rs index 7ea36fb..1296a18 100644 --- a/crates/notsecrets/src/sources/prompt.rs +++ b/crates/notsecrets/src/sources/prompt.rs @@ -1,19 +1,28 @@ -use crate::_legacy::AgeKeySource; -use anyhow::Result; +use crate::error::AgeError; +use crate::identities::scrypt::ScryptIdentity; +use crate::identities::Identity; +use crate::sources::IdentitySource; pub struct PromptSource; -impl AgeKeySource for PromptSource { +impl IdentitySource for PromptSource { fn name(&self) -> &str { "prompt" } - fn retrieve(&self) -> Result { - let key = rpassword::prompt_password("Paste your age private key: ") - .map_err(|e| anyhow::anyhow!("could not read age key from prompt: {e}"))?; - if key.trim().is_empty() { - anyhow::bail!("empty age key entered"); + fn load(&self) -> Result, AgeError> { + let passphrase = rpassword::prompt_password("Enter age passphrase: ").map_err(|e| { + AgeError::SourceError { + name: self.name().to_string(), + source: anyhow::anyhow!("could not read passphrase: {e}"), + } + })?; + if passphrase.trim().is_empty() { + return Err(AgeError::SourceError { + name: self.name().to_string(), + source: anyhow::anyhow!("empty passphrase entered"), + }); } - Ok(key) + Ok(Box::new(ScryptIdentity::new(passphrase))) } } diff --git a/crates/notsecrets/tests/integration.rs b/crates/notsecrets/tests/integration.rs index 91e6ebe..a62b30b 100644 --- a/crates/notsecrets/tests/integration.rs +++ b/crates/notsecrets/tests/integration.rs @@ -1,39 +1,47 @@ -use notsecrets::{AgeKeySource, FileSource, resolve_age_key}; +use notsecrets::{FileSource, IdentitySource, X25519Identity, resolve_identities}; use std::fs; use tempfile::TempDir; +fn generate_age_key_str() -> String { + use rand::rngs::OsRng; + use x25519_dalek::StaticSecret; + let identity = X25519Identity::from_static_secret(StaticSecret::random_from_rng(OsRng)); + identity.to_bech32() +} + #[test] -fn test_file_source_reads_key() { +fn test_file_source_loads_valid_key() { let dir = TempDir::new().unwrap(); let key_file = dir.path().join("age.key"); - fs::write(&key_file, "AGE-SECRET-KEY-1ABCDEF\n").unwrap(); + let key = generate_age_key_str(); + fs::write(&key_file, format!("{}\n", key)).unwrap(); let source = FileSource::new(key_file.clone()); - let result = source.retrieve(); - assert!(result.is_ok()); - assert_eq!(result.unwrap().trim(), "AGE-SECRET-KEY-1ABCDEF"); + let result = source.load(); + assert!(result.is_ok(), "expected Ok, got: {:?}", result.err()); } #[test] fn test_file_source_missing_returns_err() { let source = FileSource::new("/nonexistent/age.key".into()); - assert!(source.retrieve().is_err()); + assert!(source.load().is_err()); } #[test] -fn test_resolve_age_key_uses_file_fallback() { +fn test_resolve_identities_uses_file_source() { let dir = TempDir::new().unwrap(); let key_file = dir.path().join("age.key"); - fs::write(&key_file, "AGE-SECRET-KEY-1TEST\n").unwrap(); + let key = generate_age_key_str(); + fs::write(&key_file, format!("{}\n", key)).unwrap(); - let sources: Vec> = vec![Box::new(FileSource::new(key_file))]; - let key = resolve_age_key(sources).unwrap(); - assert_eq!(key.trim(), "AGE-SECRET-KEY-1TEST"); + let sources: Vec> = vec![Box::new(FileSource::new(key_file))]; + let ids = resolve_identities(sources).unwrap(); + assert_eq!(ids.len(), 1); } #[test] -fn test_resolve_age_key_all_fail_returns_err() { - let sources: Vec> = +fn test_resolve_identities_all_fail_returns_err() { + let sources: Vec> = vec![Box::new(FileSource::new("/nonexistent".into()))]; - assert!(resolve_age_key(sources).is_err()); + assert!(resolve_identities(sources).is_err()); }