diff --git a/crates/notsecrets/Cargo.toml b/crates/notsecrets/Cargo.toml index 4ab9c50..a9a1adc 100644 --- a/crates/notsecrets/Cargo.toml +++ b/crates/notsecrets/Cargo.toml @@ -5,12 +5,24 @@ edition = "2024" license.workspace = true [dependencies] -notcore = { workspace = true } -anyhow = { workspace = true } -thiserror = { workspace = true } -which = { workspace = true } -rpassword = { workspace = true } -dirs = { workspace = true } +notcore = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } +rpassword = { workspace = true } +dirs = { 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" +scrypt = "0.11" +bech32 = "0.11" +base64 = "0.22" +hmac = "0.12" +rand = "0.8" +zeroize = "1" +curve25519-dalek = "4" [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/notsecrets/src/_legacy.rs b/crates/notsecrets/src/_legacy.rs new file mode 100644 index 0000000..22184cf --- /dev/null +++ b/crates/notsecrets/src/_legacy.rs @@ -0,0 +1,126 @@ +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/error.rs b/crates/notsecrets/src/error.rs new file mode 100644 index 0000000..d52a604 --- /dev/null +++ b/crates/notsecrets/src/error.rs @@ -0,0 +1,15 @@ +#[derive(Debug, thiserror::Error)] +pub enum AgeError { + #[error("no identity could decrypt any recipient stanza")] + NoMatch, + #[error("header MAC verification failed")] + MacMismatch, + #[error("malformed age file: {0}")] + ParseError(String), + #[error("crypto error: {0}")] + CryptoError(String), + #[error("unsupported key type: {0}")] + UnsupportedKeyType(String), + #[error("identity source failed ({name}): {source}")] + SourceError { name: String, source: anyhow::Error }, +} diff --git a/crates/notsecrets/src/identities/mod.rs b/crates/notsecrets/src/identities/mod.rs new file mode 100644 index 0000000..30def4d --- /dev/null +++ b/crates/notsecrets/src/identities/mod.rs @@ -0,0 +1,62 @@ +use crate::error::AgeError; + +/// The symmetric file encryption key — 16 random bytes. +#[derive(Clone)] +pub struct FileKey([u8; 16]); + +impl FileKey { + pub fn new(bytes: [u8; 16]) -> Self { + Self(bytes) + } + + pub fn as_bytes(&self) -> &[u8; 16] { + &self.0 + } + + pub fn generate() -> Self { + use rand::RngCore; + let mut bytes = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut bytes); + Self(bytes) + } +} + +impl TryFrom<&[u8]> for FileKey { + type Error = AgeError; + fn try_from(b: &[u8]) -> Result { + b.try_into() + .map(Self) + .map_err(|_| AgeError::ParseError(format!("file key must be 16 bytes, got {}", b.len()))) + } +} + +impl Drop for FileKey { + fn drop(&mut self) { + // Zero out key material on drop + self.0.iter_mut().for_each(|b| *b = 0); + } +} + +/// A single recipient stanza in the age header. +#[derive(Debug, Clone)] +pub struct Stanza { + pub tag: String, + pub args: Vec, + pub body: Vec, +} + +/// The full age header (all stanzas + MAC). +#[derive(Debug, Clone)] +pub struct Header { + pub recipients: Vec, + pub mac: Vec, +} + +/// Domain port: an identity that can attempt to unwrap a recipient stanza. +/// +/// Returns `None` if the stanza tag/args do not match this identity type. +/// Returns `Some(Err(...))` if the stanza matches but decryption fails. +/// Returns `Some(Ok(file_key))` on success. +pub trait Identity { + fn unwrap_file_key(&self, stanza: &Stanza) -> Option>; +} diff --git a/crates/notsecrets/src/lib.rs b/crates/notsecrets/src/lib.rs index a5a5303..79fd423 100644 --- a/crates/notsecrets/src/lib.rs +++ b/crates/notsecrets/src/lib.rs @@ -1,126 +1,21 @@ +pub mod error; +pub mod identities; +pub mod recipients; pub mod sources; +pub mod _legacy; +pub use error::AgeError; +pub use identities::{FileKey, Header, Identity, Stanza}; +pub use recipients::Recipient; pub use sources::{BitwardenSource, FileSource, PromptSource}; -use anyhow::{bail, Context, Result}; -use std::path::{Path, PathBuf}; -use std::process::Command; - -/// Trait for retrieving an age private key from some source. -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 to `~/.config/sops/age/keys.txt` (mode 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() { - // We verify the args list directly without spawning sops. - let path = std::path::Path::new("/some/file.sops.env"); - let args = sops_args(path).unwrap(); - // args should be ["--decrypt", "--", "/some/file.sops.env"] - 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. - /// This is a compile-time style check — verified by reading the source; tested via successful - /// compilation only. Placeholder test to document the fix was applied. - #[test] - fn install_age_key_uses_context_not_with_context() { - // Verified by code review: path.parent().context("...") not .with_context(|| "...") - // This test documents the fix; actual enforcement is in review. - assert!(true); - } -} +// 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, +}; diff --git a/crates/notsecrets/src/recipients/mod.rs b/crates/notsecrets/src/recipients/mod.rs new file mode 100644 index 0000000..39e8dd7 --- /dev/null +++ b/crates/notsecrets/src/recipients/mod.rs @@ -0,0 +1,7 @@ +use crate::error::AgeError; +use crate::identities::{FileKey, Stanza}; + +/// 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/sources/bitwarden.rs b/crates/notsecrets/src/sources/bitwarden.rs index 23b3a02..f402d4c 100644 --- a/crates/notsecrets/src/sources/bitwarden.rs +++ b/crates/notsecrets/src/sources/bitwarden.rs @@ -1,7 +1,6 @@ -use anyhow::{bail, Result}; +use crate::_legacy::AgeKeySource; +use anyhow::{Result, bail}; use std::process::Command; -use which::which; -use crate::AgeKeySource; pub struct BitwardenSource { pub item_name: String, @@ -9,15 +8,24 @@ pub struct BitwardenSource { impl BitwardenSource { pub fn new(item_name: impl Into) -> Self { - Self { item_name: item_name.into() } + Self { + item_name: item_name.into(), + } } } impl AgeKeySource for BitwardenSource { - fn name(&self) -> &str { "bitwarden" } + fn name(&self) -> &str { + "bitwarden" + } fn retrieve(&self) -> Result { - if which("bw").is_err() { + let bw_available = 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"); } @@ -29,7 +37,10 @@ impl AgeKeySource for BitwardenSource { .args(["unlock", "--raw", &password]) .output()?; if !output.status.success() { - bail!("bw unlock failed: {}", String::from_utf8_lossy(&output.stderr)); + bail!( + "bw unlock failed: {}", + String::from_utf8_lossy(&output.stderr) + ); } String::from_utf8(output.stdout)?.trim().to_string() } else { diff --git a/crates/notsecrets/src/sources/file.rs b/crates/notsecrets/src/sources/file.rs index e4fe166..e49593b 100644 --- a/crates/notsecrets/src/sources/file.rs +++ b/crates/notsecrets/src/sources/file.rs @@ -1,6 +1,6 @@ -use std::path::PathBuf; +use crate::_legacy::AgeKeySource; use anyhow::Result; -use crate::AgeKeySource; +use std::path::PathBuf; pub struct FileSource { path: PathBuf, @@ -13,7 +13,9 @@ impl FileSource { } impl AgeKeySource for FileSource { - fn name(&self) -> &str { "file" } + fn name(&self) -> &str { + "file" + } fn retrieve(&self) -> Result { std::fs::read_to_string(&self.path) diff --git a/crates/notsecrets/src/sources/prompt.rs b/crates/notsecrets/src/sources/prompt.rs index 05e85bd..7ea36fb 100644 --- a/crates/notsecrets/src/sources/prompt.rs +++ b/crates/notsecrets/src/sources/prompt.rs @@ -1,10 +1,12 @@ +use crate::_legacy::AgeKeySource; use anyhow::Result; -use crate::AgeKeySource; pub struct PromptSource; impl AgeKeySource for PromptSource { - fn name(&self) -> &str { "prompt" } + fn name(&self) -> &str { + "prompt" + } fn retrieve(&self) -> Result { let key = rpassword::prompt_password("Paste your age private key: ") diff --git a/crates/notsecrets/tests/integration.rs b/crates/notsecrets/tests/integration.rs index 1ac8fdc..91e6ebe 100644 --- a/crates/notsecrets/tests/integration.rs +++ b/crates/notsecrets/tests/integration.rs @@ -1,6 +1,6 @@ +use notsecrets::{AgeKeySource, FileSource, resolve_age_key}; use std::fs; use tempfile::TempDir; -use notsecrets::{resolve_age_key, AgeKeySource, FileSource}; #[test] fn test_file_source_reads_key() { @@ -26,17 +26,14 @@ fn test_resolve_age_key_uses_file_fallback() { let key_file = dir.path().join("age.key"); fs::write(&key_file, "AGE-SECRET-KEY-1TEST\n").unwrap(); - let sources: Vec> = vec![ - Box::new(FileSource::new(key_file)), - ]; + 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"); } #[test] fn test_resolve_age_key_all_fail_returns_err() { - let sources: Vec> = vec![ - Box::new(FileSource::new("/nonexistent".into())), - ]; + let sources: Vec> = + vec![Box::new(FileSource::new("/nonexistent".into()))]; assert!(resolve_age_key(sources).is_err()); }