feat(notsecrets): scaffold age-native domain types and trait ports

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Joseph O'Brien
2026-04-01 20:44:05 -04:00
parent 3760b59f31
commit 34d9e3d677
10 changed files with 276 additions and 147 deletions

View File

@@ -5,12 +5,24 @@ edition = "2024"
license.workspace = true license.workspace = true
[dependencies] [dependencies]
notcore = { workspace = true } notcore = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
which = { workspace = true } rpassword = { workspace = true }
rpassword = { workspace = true } dirs = { 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] [dev-dependencies]
tempfile = { workspace = true } tempfile = { workspace = true }

View File

@@ -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<String>;
}
/// try each source in order; return the first success.
pub fn resolve_age_key(sources: Vec<Box<dyn AgeKeySource>>) -> Result<String> {
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<PathBuf> {
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 -- <path>`.
/// Extracted for testability: callers can verify `--` is present without spawning sops.
pub fn sops_args(sops_file: &Path) -> Result<Vec<String>> {
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 -- <sops_file>` and return the decrypted content.
pub fn decrypt_sops(sops_file: &Path) -> Result<String> {
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);
}
}

View File

@@ -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 },
}

View File

@@ -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<Self, AgeError> {
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<String>,
pub body: Vec<u8>,
}
/// The full age header (all stanzas + MAC).
#[derive(Debug, Clone)]
pub struct Header {
pub recipients: Vec<Stanza>,
pub mac: Vec<u8>,
}
/// 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<Result<FileKey, AgeError>>;
}

View File

@@ -1,126 +1,21 @@
pub mod error;
pub mod identities;
pub mod recipients;
pub mod sources; 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}; pub use sources::{BitwardenSource, FileSource, PromptSource};
use anyhow::{bail, Context, Result}; // Legacy API — kept for notstrap compatibility until Task 12.
use std::path::{Path, PathBuf}; #[allow(deprecated)]
use std::process::Command; pub use _legacy::{
AgeKeySource,
/// Trait for retrieving an age private key from some source. install_age_key,
pub trait AgeKeySource { install_age_key_at,
fn name(&self) -> &str; resolve_age_key,
fn retrieve(&self) -> Result<String>; decrypt_sops,
} sops_args,
};
/// Try each source in order; return the first success.
pub fn resolve_age_key(sources: Vec<Box<dyn AgeKeySource>>) -> Result<String> {
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<PathBuf> {
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 -- <path>`.
/// Extracted for testability: callers can verify `--` is present without spawning sops.
pub fn sops_args(sops_file: &Path) -> Result<Vec<String>> {
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 -- <sops_file>` and return the decrypted content.
pub fn decrypt_sops(sops_file: &Path) -> Result<String> {
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);
}
}

View File

@@ -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<Stanza, AgeError>;
}

View File

@@ -1,7 +1,6 @@
use anyhow::{bail, Result}; use crate::_legacy::AgeKeySource;
use anyhow::{Result, bail};
use std::process::Command; use std::process::Command;
use which::which;
use crate::AgeKeySource;
pub struct BitwardenSource { pub struct BitwardenSource {
pub item_name: String, pub item_name: String,
@@ -9,15 +8,24 @@ pub struct BitwardenSource {
impl BitwardenSource { impl BitwardenSource {
pub fn new(item_name: impl Into<String>) -> Self { pub fn new(item_name: impl Into<String>) -> Self {
Self { item_name: item_name.into() } Self {
item_name: item_name.into(),
}
} }
} }
impl AgeKeySource for BitwardenSource { impl AgeKeySource for BitwardenSource {
fn name(&self) -> &str { "bitwarden" } fn name(&self) -> &str {
"bitwarden"
}
fn retrieve(&self) -> Result<String> { fn retrieve(&self) -> Result<String> {
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"); bail!("bw CLI not found in PATH");
} }
@@ -29,7 +37,10 @@ impl AgeKeySource for BitwardenSource {
.args(["unlock", "--raw", &password]) .args(["unlock", "--raw", &password])
.output()?; .output()?;
if !output.status.success() { 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() String::from_utf8(output.stdout)?.trim().to_string()
} else { } else {

View File

@@ -1,6 +1,6 @@
use std::path::PathBuf; use crate::_legacy::AgeKeySource;
use anyhow::Result; use anyhow::Result;
use crate::AgeKeySource; use std::path::PathBuf;
pub struct FileSource { pub struct FileSource {
path: PathBuf, path: PathBuf,
@@ -13,7 +13,9 @@ impl FileSource {
} }
impl AgeKeySource for FileSource { impl AgeKeySource for FileSource {
fn name(&self) -> &str { "file" } fn name(&self) -> &str {
"file"
}
fn retrieve(&self) -> Result<String> { fn retrieve(&self) -> Result<String> {
std::fs::read_to_string(&self.path) std::fs::read_to_string(&self.path)

View File

@@ -1,10 +1,12 @@
use crate::_legacy::AgeKeySource;
use anyhow::Result; use anyhow::Result;
use crate::AgeKeySource;
pub struct PromptSource; pub struct PromptSource;
impl AgeKeySource for PromptSource { impl AgeKeySource for PromptSource {
fn name(&self) -> &str { "prompt" } fn name(&self) -> &str {
"prompt"
}
fn retrieve(&self) -> Result<String> { fn retrieve(&self) -> Result<String> {
let key = rpassword::prompt_password("Paste your age private key: ") let key = rpassword::prompt_password("Paste your age private key: ")

View File

@@ -1,6 +1,6 @@
use notsecrets::{AgeKeySource, FileSource, resolve_age_key};
use std::fs; use std::fs;
use tempfile::TempDir; use tempfile::TempDir;
use notsecrets::{resolve_age_key, AgeKeySource, FileSource};
#[test] #[test]
fn test_file_source_reads_key() { 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"); let key_file = dir.path().join("age.key");
fs::write(&key_file, "AGE-SECRET-KEY-1TEST\n").unwrap(); fs::write(&key_file, "AGE-SECRET-KEY-1TEST\n").unwrap();
let sources: Vec<Box<dyn AgeKeySource>> = vec![ let sources: Vec<Box<dyn AgeKeySource>> = vec![Box::new(FileSource::new(key_file))];
Box::new(FileSource::new(key_file)),
];
let key = resolve_age_key(sources).unwrap(); let key = resolve_age_key(sources).unwrap();
assert_eq!(key.trim(), "AGE-SECRET-KEY-1TEST"); assert_eq!(key.trim(), "AGE-SECRET-KEY-1TEST");
} }
#[test] #[test]
fn test_resolve_age_key_all_fail_returns_err() { fn test_resolve_age_key_all_fail_returns_err() {
let sources: Vec<Box<dyn AgeKeySource>> = vec![ let sources: Vec<Box<dyn AgeKeySource>> =
Box::new(FileSource::new("/nonexistent".into())), vec![Box::new(FileSource::new("/nonexistent".into()))];
];
assert!(resolve_age_key(sources).is_err()); assert!(resolve_age_key(sources).is_err());
} }