diff --git a/docs/superpowers/plans/2026-04-01-notsecrets-age-redesign.md b/docs/superpowers/plans/2026-04-01-notsecrets-age-redesign.md new file mode 100644 index 0000000..66a892c --- /dev/null +++ b/docs/superpowers/plans/2026-04-01-notsecrets-age-redesign.md @@ -0,0 +1,2829 @@ +# notsecrets Age-Native Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Redesign notsecrets as a self-contained age encryption/decryption library in pure Rust with multiple recipients, passphrases, passphrase-protected identity files, and SSH keys. + +**Architecture:** Hexagonal architecture with Identity and Recipient as domain ports. Concrete implementations (X25519, Scrypt, SshEd25519, SshRsa, Encrypted) live in infra adapters. Encryptor/Decryptor orchestrate at the domain layer. Sources (Bitwarden, file, prompt) are identity resolvers at the infra boundary. + +**Tech Stack:** x25519-dalek, ed25519-dalek, rsa, sha2, hkdf, chacha20poly1305, scrypt, bech32, base64, hmac, rand + +--- + +## Task 1: Scaffold — Cargo.toml, error.rs, domain traits, stub lib.rs + +- [ ] Update `crates/notsecrets/Cargo.toml` — add new deps, remove `which` +- [ ] Create `crates/notsecrets/src/error.rs` +- [ ] Create `crates/notsecrets/src/identities/mod.rs` — domain types + `Identity` trait +- [ ] Create `crates/notsecrets/src/recipients/mod.rs` — `Recipient` trait +- [ ] Rewrite `crates/notsecrets/src/lib.rs` — stub public API, remove old exports + +### 1.1 Update `crates/notsecrets/Cargo.toml` + +```toml +[package] +name = "notsecrets" +version = "0.1.0" +edition = "2024" +license.workspace = true + +[dependencies] +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 = "1" +hkdf = "0.12" +chacha20poly1305 = "0.10" +scrypt = "0.11" +bech32 = "0.11" +base64 = "0.22" +hmac = "0.12" +rand = "0.8" + +[dev-dependencies] +tempfile = { workspace = true } +``` + +Also update workspace `Cargo.toml` — remove `which` from `[workspace.dependencies]` only after verifying no other crate uses it: + +```bash +cargo metadata --no-deps --format-version 1 | grep -o '"which"' | wc -l +# if 0 remaining consumers after notsecrets drop, remove from workspace +``` + +### 1.2 `crates/notsecrets/src/error.rs` + +```rust +#[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 }, +} +``` + +### 1.3 `crates/notsecrets/src/identities/mod.rs` + +```rust +use crate::error::AgeError; + +/// The symmetric file encryption key — 16 random bytes. +#[derive(Clone, zeroize::Zeroize)] +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()))) + } +} + +/// 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>; +} +``` + +Note: `zeroize` must be added to `[dependencies]` in `crates/notsecrets/Cargo.toml`: + +```toml +zeroize = "1" +``` + +Add this line alongside the others in step 1.1. + +### 1.4 `crates/notsecrets/src/recipients/mod.rs` + +```rust +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; +} +``` + +### 1.5 `crates/notsecrets/src/lib.rs` (stub) + +```rust +pub mod error; +pub mod identities; +pub mod recipients; +pub mod sources; + +pub use error::AgeError; +pub use identities::{FileKey, Header, Identity, Stanza}; +pub use recipients::Recipient; +pub use sources::{BitwardenSource, FileSource, IdentitySource, PromptSource}; + +// Encryptor and Decryptor will be added in Tasks 8 and 9. +// resolve_identities will be added in Task 11. +``` + +Keep the existing `sources/` files compiling by leaving `AgeKeySource` in place temporarily — it will be replaced in Task 10. For now add a `#[allow(dead_code)]` allow on the old trait and old `resolve_age_key`/`install_age_key`/`decrypt_sops` functions so the crate compiles while tasks proceed incrementally. Annotate each deprecated item: + +```rust +#[deprecated(note = "replaced by resolve_identities in Task 11")] +pub use _legacy::{ + install_age_key, install_age_key_at, resolve_age_key, AgeKeySource, decrypt_sops, sops_args, +}; +mod _legacy; +``` + +Move the old body of `lib.rs` (everything except `pub mod sources`) into `crates/notsecrets/src/_legacy.rs` verbatim so existing callers in `notstrap` keep compiling until Task 12. + +### 1.6 Verify + +```bash +cargo check -p notsecrets +``` + +Expected: compiles, zero errors. + +```bash +cargo clippy -p notsecrets -- -D warnings +``` + +Expected: 0 warnings (the `#[deprecated]` re-exports suppress dead_code warnings; `#[allow(deprecated)]` needed in `notstrap` temporarily — add it in Task 12). + +### 1.7 Commit + +```bash +git -C /Users/joe/dev/notfiles add crates/notsecrets/ Cargo.toml +git -C /Users/joe/dev/notfiles commit -m "feat(notsecrets): scaffold age-native domain types and trait ports" +``` + +--- + +## Task 2: format.rs — age wire format parser/serializer + +**No crypto. Pure byte manipulation.** + +- [ ] Create `crates/notsecrets/src/format.rs` +- [ ] Write tests first, then implement + +### 2.1 Test (write first) + +File: `crates/notsecrets/src/format.rs` (tests section) + +```rust +#[cfg(test)] +mod tests { + use super::*; + + fn minimal_age_file() -> Vec { + // Manually constructed minimal age file with one X25519 stanza + // and a 32-byte payload (nonce=16 bytes of 0x01, ciphertext=16 bytes of 0x02) + let mut out = Vec::new(); + out.extend_from_slice(b"age-encryption.org/v1\n"); + out.extend_from_slice(b"-> X25519 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n"); + // empty stanza body (no wrapped key bytes for parse test) + out.extend_from_slice(b"\n"); + // MAC line: "--- " + base64(32 zero bytes) + out.extend_from_slice(b"--- AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n"); + // payload: 16-byte nonce + 16-byte ciphertext placeholder + out.extend_from_slice(&[0x01u8; 16]); + out.extend_from_slice(&[0x02u8; 16]); + out + } + + #[test] + fn parse_minimal_header_roundtrip() { + let input = minimal_age_file(); + let (header, payload_offset) = parse_header(&input).expect("parse should succeed"); + assert_eq!(header.recipients.len(), 1); + assert_eq!(header.recipients[0].tag, "X25519"); + assert_eq!(header.recipients[0].args.len(), 1); + assert!(header.recipients[0].body.is_empty()); + // payload offset should point past the "--- MAC\n" line + assert_eq!(&input[payload_offset..], &[0x01u8; 16][..].iter().chain([0x02u8; 16].iter()).copied().collect::>()[..]); + } + + #[test] + fn serialize_header_roundtrip() { + use crate::identities::{Header, Stanza}; + let stanza = Stanza { + tag: "X25519".to_string(), + args: vec!["dGVzdA==".to_string()], + body: vec![0xde, 0xad, 0xbe, 0xef], + }; + let header = Header { + recipients: vec![stanza], + mac: vec![0u8; 32], + }; + let serialized = serialize_header(&header); + let (parsed, _) = parse_header(&[serialized.clone(), vec![0u8; 32]].concat()) + .expect("round-trip parse should succeed"); + assert_eq!(parsed.recipients.len(), 1); + assert_eq!(parsed.recipients[0].tag, "X25519"); + assert_eq!(parsed.recipients[0].body, vec![0xde, 0xad, 0xbe, 0xef]); + } + + #[test] + fn parse_missing_version_line_returns_error() { + let input = b"not-age-encryption\n-> X25519 arg\n\n--- AAAA\n"; + let result = parse_header(input); + assert!(result.is_err(), "expected ParseError for missing version line"); + } + + #[test] + fn parse_truncated_before_footer_returns_error() { + let input = b"age-encryption.org/v1\n-> X25519 arg\n"; + let result = parse_header(input); + assert!(result.is_err(), "expected ParseError for truncated header"); + } + + #[test] + fn parse_empty_input_returns_error() { + let result = parse_header(b""); + assert!(result.is_err()); + } + + #[test] + fn stanza_body_split_across_multiple_64char_lines() { + // Body longer than 64 chars must be split into 64-char lines per spec. + // 48 bytes -> base64 = 64 chars (one line, no split needed) + // 49 bytes -> base64 = 68 chars (must split: 64 + 4) + use crate::identities::{Header, Stanza}; + let body = vec![0xabu8; 49]; + let stanza = Stanza { + tag: "scrypt".to_string(), + args: vec!["salt".to_string(), "18".to_string()], + body: body.clone(), + }; + let header = Header { recipients: vec![stanza], mac: vec![0u8; 32] }; + let serialized = serialize_header(&header); + let (parsed, _) = parse_header(&[serialized, vec![0u8; 32]].concat()).unwrap(); + assert_eq!(parsed.recipients[0].body, body); + } + + #[test] + fn header_bytes_up_to_footer_excludes_mac() { + let input = minimal_age_file(); + let bytes = header_bytes_up_to_footer(&input).expect("should find footer"); + // Must end with "--- " (not include the MAC value) + assert!(bytes.ends_with(b"--- "), "header_bytes must end at '--- '"); + } +} +``` + +Run the test to confirm it fails before implementation: + +```bash +cargo test -p notsecrets -- format --nocapture 2>&1 | head -30 +``` + +Expected: compile error — `parse_header`, `serialize_header`, `header_bytes_up_to_footer` do not exist yet. + +### 2.2 Implementation `crates/notsecrets/src/format.rs` + +```rust +use crate::error::AgeError; +use crate::identities::{Header, Stanza}; +use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD}; + +const VERSION_LINE: &[u8] = b"age-encryption.org/v1\n"; +const FOOTER_PREFIX: &[u8] = b"--- "; + +/// Parse an age binary file's header. +/// +/// Returns `(Header, payload_offset)` where `payload_offset` is the byte +/// index into `input` where the payload (nonce + ciphertext) begins. +pub fn parse_header(input: &[u8]) -> Result<(Header, usize), AgeError> { + if !input.starts_with(VERSION_LINE) { + return Err(AgeError::ParseError( + "missing age version line".to_string(), + )); + } + + let mut pos = VERSION_LINE.len(); + let mut recipients: Vec = Vec::new(); + + loop { + if pos >= input.len() { + return Err(AgeError::ParseError("unexpected end of header".to_string())); + } + + // Check for footer line: "--- \n" + if input[pos..].starts_with(FOOTER_PREFIX) { + let line_end = input[pos..] + .iter() + .position(|&b| b == b'\n') + .ok_or_else(|| AgeError::ParseError("footer line not terminated".to_string()))?; + let mac_b64 = &input[pos + FOOTER_PREFIX.len()..pos + line_end]; + let mac = STANDARD_NO_PAD + .decode(mac_b64) + .map_err(|e| AgeError::ParseError(format!("footer MAC base64: {e}")))?; + let payload_offset = pos + line_end + 1; // skip trailing '\n' + return Ok((Header { recipients, mac }, payload_offset)); + } + + // Must be a stanza line: "-> [args...]\n" + if !input[pos..].starts_with(b"-> ") { + return Err(AgeError::ParseError(format!( + "expected stanza or footer at byte {pos}" + ))); + } + + let line_end = input[pos..] + .iter() + .position(|&b| b == b'\n') + .ok_or_else(|| AgeError::ParseError("stanza header line not terminated".to_string()))?; + + let header_line = std::str::from_utf8(&input[pos + 3..pos + line_end]) + .map_err(|e| AgeError::ParseError(format!("stanza line UTF-8: {e}")))?; + + let mut parts = header_line.split(' '); + let tag = parts + .next() + .ok_or_else(|| AgeError::ParseError("stanza missing tag".to_string()))? + .to_string(); + let args: Vec = parts.map(str::to_string).collect(); + + pos += line_end + 1; + + // Read stanza body: base64 lines until an empty line + let mut body_b64 = String::new(); + loop { + if pos >= input.len() { + return Err(AgeError::ParseError( + "unexpected end during stanza body".to_string(), + )); + } + let line_end = input[pos..] + .iter() + .position(|&b| b == b'\n') + .ok_or_else(|| { + AgeError::ParseError("stanza body line not terminated".to_string()) + })?; + let line = &input[pos..pos + line_end]; + pos += line_end + 1; + if line.is_empty() { + break; + } + body_b64.push_str( + std::str::from_utf8(line) + .map_err(|e| AgeError::ParseError(format!("body UTF-8: {e}")))?, + ); + } + + let body = if body_b64.is_empty() { + Vec::new() + } else { + STANDARD_NO_PAD + .decode(&body_b64) + .map_err(|e| AgeError::ParseError(format!("stanza body base64: {e}")))? + }; + + recipients.push(Stanza { tag, args, body }); + } +} + +/// Serialize a `Header` to bytes. +/// +/// Writes version line, all stanzas (with body split into 64-char base64 lines), +/// and the `--- ` footer. Does NOT write the payload. +pub fn serialize_header(header: &Header) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(VERSION_LINE); + + for stanza in &header.recipients { + // "-> tag arg1 arg2\n" + out.extend_from_slice(b"-> "); + out.extend_from_slice(stanza.tag.as_bytes()); + for arg in &stanza.args { + out.push(b' '); + out.extend_from_slice(arg.as_bytes()); + } + out.push(b'\n'); + + // Body: encode as base64, split into 64-char lines, trailing empty line + if stanza.body.is_empty() { + out.push(b'\n'); + } else { + let encoded = STANDARD_NO_PAD.encode(&stanza.body); + for chunk in encoded.as_bytes().chunks(64) { + out.extend_from_slice(chunk); + out.push(b'\n'); + } + // If the last chunk was exactly 64 chars, we still need the empty terminator line. + // If the last chunk was < 64 chars, we also need the empty terminator line. + out.push(b'\n'); + } + } + + // Footer + out.extend_from_slice(FOOTER_PREFIX); + out.extend_from_slice(STANDARD_NO_PAD.encode(&header.mac).as_bytes()); + out.push(b'\n'); + + out +} + +/// Return the header bytes up to and including `"--- "` (not including the MAC value). +/// +/// Used for MAC computation: HMAC-SHA256 covers all bytes from the start of the +/// file up to and including the `"--- "` separator. +pub fn header_bytes_up_to_footer(input: &[u8]) -> Result, AgeError> { + let footer_pos = input + .windows(FOOTER_PREFIX.len()) + .position(|w| w == FOOTER_PREFIX) + .ok_or_else(|| AgeError::ParseError("footer not found".to_string()))?; + Ok(input[..footer_pos + FOOTER_PREFIX.len()].to_vec()) +} + +#[cfg(test)] +mod tests { + // ... (tests from 2.1 above) +} +``` + +Add `pub mod format;` to `lib.rs`. + +### 2.3 Run tests + +```bash +cargo test -p notsecrets -- format --nocapture +``` + +Expected output (all pass): + +``` +test format::tests::parse_empty_input_returns_error ... ok +test format::tests::parse_missing_version_line_returns_error ... ok +test format::tests::parse_truncated_before_footer_returns_error ... ok +test format::tests::parse_minimal_header_roundtrip ... ok +test format::tests::serialize_header_roundtrip ... ok +test format::tests::stanza_body_split_across_multiple_64char_lines ... ok +test format::tests::header_bytes_up_to_footer_excludes_mac ... ok +``` + +```bash +cargo clippy -p notsecrets -- -D warnings +``` + +Expected: 0 warnings. + +### 2.4 Commit + +```bash +git -C /Users/joe/dev/notfiles add crates/notsecrets/src/format.rs crates/notsecrets/src/lib.rs +git -C /Users/joe/dev/notfiles commit -m "feat(notsecrets): age wire format parser/serializer with unit tests" +``` + +--- + +## Task 3: X25519 identity + recipient + +- [ ] Create `crates/notsecrets/src/identities/x25519.rs` +- [ ] Create `crates/notsecrets/src/recipients/x25519.rs` +- [ ] Update `identities/mod.rs` and `recipients/mod.rs` to re-export + +### 3.1 Test (write first) + +Add to `crates/notsecrets/src/identities/x25519.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::identities::FileKey; + use crate::recipients::x25519::X25519Recipient; + + /// The canonical test vector: generate a keypair, wrap a file key, unwrap it. + #[test] + fn x25519_wrap_unwrap_roundtrip() { + use x25519_dalek::{EphemeralSecret, PublicKey, StaticSecret}; + use rand::rngs::OsRng; + + // Generate a static identity key + let secret = StaticSecret::random_from_rng(OsRng); + let public = PublicKey::from(&secret); + + // bech32-encode public key as age1... recipient + let recipient = X25519Recipient::from_public_key(public); + // bech32-encode secret key as AGE-SECRET-KEY-1... identity + let identity = X25519Identity::from_static_secret(secret); + + let file_key = FileKey::new([0x42u8; 16]); + let stanza = recipient.wrap_file_key(&file_key).expect("wrap should succeed"); + assert_eq!(stanza.tag, "X25519"); + assert_eq!(stanza.args.len(), 1); // ephemeral pubkey + + 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 x25519_wrong_identity_returns_none_for_nonmatching_tag() { + use x25519_dalek::{PublicKey, StaticSecret}; + use rand::rngs::OsRng; + + let secret = StaticSecret::random_from_rng(OsRng); + let identity = X25519Identity::from_static_secret(secret); + + use crate::identities::Stanza; + let stanza = Stanza { + tag: "scrypt".to_string(), + args: vec!["salt".to_string(), "18".to_string()], + body: vec![0u8; 32], + }; + // Different tag — must return None, not an error + assert!(identity.unwrap_file_key(&stanza).is_none()); + } + + #[test] + fn x25519_bech32_parse_roundtrip() { + use x25519_dalek::{PublicKey, StaticSecret}; + use rand::rngs::OsRng; + + let secret = StaticSecret::random_from_rng(OsRng); + let public = PublicKey::from(&secret); + + let recipient_str = X25519Recipient::from_public_key(public).to_bech32(); + assert!(recipient_str.starts_with("age1"), "recipient must start with age1"); + + let identity_str = X25519Identity::from_static_secret( + StaticSecret::random_from_rng(OsRng) + ).to_bech32(); + assert!( + identity_str.starts_with("AGE-SECRET-KEY-1"), + "identity must start with AGE-SECRET-KEY-1" + ); + } +} +``` + +Run to confirm compile failure: + +```bash +cargo test -p notsecrets -- identities::x25519 --nocapture 2>&1 | head -20 +``` + +Expected: compile error — modules don't exist yet. + +### 3.2 `crates/notsecrets/src/identities/x25519.rs` + +```rust +use crate::error::AgeError; +use crate::identities::{FileKey, Identity, Stanza}; +use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD}; +use bech32::{Bech32, Hrp}; +use chacha20poly1305::{ + ChaCha20Poly1305, Key, KeyInit, Nonce, + aead::Aead, +}; +use hkdf::Hkdf; +use sha2::Sha256; +use x25519_dalek::{PublicKey, StaticSecret}; + +const TAG: &str = "X25519"; +const HKDF_INFO: &[u8] = b"age-encryption.org/v1/X25519"; +const IDENTITY_HRP: &str = "age-secret-key-"; + +pub struct X25519Identity { + secret: StaticSecret, +} + +impl X25519Identity { + pub fn from_static_secret(secret: StaticSecret) -> Self { + Self { secret } + } + + /// Parse an `AGE-SECRET-KEY-1...` bech32 string. + pub fn from_bech32(s: &str) -> Result { + let s_lower = s.to_lowercase(); + let (hrp, data) = bech32::decode(&s_lower) + .map_err(|e| AgeError::ParseError(format!("bech32 decode identity: {e}")))?; + if hrp.as_str() != IDENTITY_HRP { + return Err(AgeError::ParseError(format!( + "expected hrp '{IDENTITY_HRP}', got '{}'", + hrp.as_str() + ))); + } + let bytes: [u8; 32] = data + .try_into() + .map_err(|_| AgeError::ParseError("identity key must be 32 bytes".to_string()))?; + Ok(Self { + secret: StaticSecret::from(bytes), + }) + } + + /// Encode as `AGE-SECRET-KEY-1...` bech32 uppercase string. + pub fn to_bech32(&self) -> String { + let hrp = Hrp::parse(IDENTITY_HRP).expect("static hrp is valid"); + bech32::encode::(hrp, self.secret.as_bytes()) + .expect("bech32 encode cannot fail for valid input") + .to_uppercase() + } +} + +impl Identity for X25519Identity { + fn unwrap_file_key(&self, stanza: &Stanza) -> Option> { + if stanza.tag != TAG { + return None; + } + Some(unwrap(stanza, &self.secret)) + } +} + +fn unwrap(stanza: &Stanza, secret: &StaticSecret) -> Result { + if stanza.args.len() != 1 { + return Err(AgeError::ParseError( + "X25519 stanza must have exactly 1 arg".to_string(), + )); + } + let ephemeral_pub_bytes = STANDARD_NO_PAD + .decode(&stanza.args[0]) + .map_err(|e| AgeError::ParseError(format!("ephemeral pubkey base64: {e}")))?; + let ephemeral_pub_bytes: [u8; 32] = ephemeral_pub_bytes + .try_into() + .map_err(|_| AgeError::ParseError("ephemeral pubkey must be 32 bytes".to_string()))?; + let ephemeral_pub = PublicKey::from(ephemeral_pub_bytes); + + let shared = secret.diffie_hellman(&ephemeral_pub); + let recipient_pub = PublicKey::from(secret); + + let wrap_key = derive_wrap_key(shared.as_bytes(), ephemeral_pub.as_bytes(), recipient_pub.as_bytes())?; + + let wrapped = &stanza.body; + let cipher = ChaCha20Poly1305::new(Key::from_slice(&wrap_key)); + let nonce = Nonce::default(); // [0u8; 12] + let file_key_bytes = cipher + .decrypt(&nonce, wrapped.as_slice()) + .map_err(|_| AgeError::CryptoError("X25519 file key decryption failed".to_string()))?; + + FileKey::try_from(file_key_bytes.as_slice()) +} + +pub(crate) fn derive_wrap_key( + shared: &[u8], + ephemeral_pub: &[u8], + recipient_pub: &[u8], +) -> Result<[u8; 32], AgeError> { + let mut ikm = Vec::with_capacity(shared.len() + ephemeral_pub.len() + recipient_pub.len()); + ikm.extend_from_slice(shared); + ikm.extend_from_slice(ephemeral_pub); + ikm.extend_from_slice(recipient_pub); + + let hk = Hkdf::::new(None, &ikm); + let mut wrap_key = [0u8; 32]; + hk.expand(HKDF_INFO, &mut wrap_key) + .map_err(|e| AgeError::CryptoError(format!("HKDF expand: {e}")))?; + Ok(wrap_key) +} +``` + +### 3.3 `crates/notsecrets/src/recipients/x25519.rs` + +```rust +use crate::error::AgeError; +use crate::identities::{FileKey, Stanza}; +use crate::identities::x25519::derive_wrap_key; +use crate::recipients::Recipient; +use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD}; +use bech32::{Bech32, Hrp}; +use chacha20poly1305::{ + ChaCha20Poly1305, Key, KeyInit, Nonce, + aead::Aead, +}; +use rand::rngs::OsRng; +use x25519_dalek::{EphemeralSecret, PublicKey}; + +const TAG: &str = "X25519"; +const RECIPIENT_HRP: &str = "age"; + +pub struct X25519Recipient { + public_key: PublicKey, +} + +impl X25519Recipient { + pub fn from_public_key(public_key: PublicKey) -> Self { + Self { public_key } + } + + /// Parse an `age1...` bech32 public key string. + pub fn from_bech32(s: &str) -> Result { + let (hrp, data) = bech32::decode(s) + .map_err(|e| AgeError::ParseError(format!("bech32 decode recipient: {e}")))?; + if hrp.as_str() != RECIPIENT_HRP { + return Err(AgeError::ParseError(format!( + "expected hrp '{RECIPIENT_HRP}', got '{}'", + hrp.as_str() + ))); + } + let bytes: [u8; 32] = data + .try_into() + .map_err(|_| AgeError::ParseError("recipient key must be 32 bytes".to_string()))?; + Ok(Self { + public_key: PublicKey::from(bytes), + }) + } + + /// Encode as `age1...` bech32 string. + pub fn to_bech32(&self) -> String { + let hrp = Hrp::parse(RECIPIENT_HRP).expect("static hrp is valid"); + bech32::encode::(hrp, self.public_key.as_bytes()) + .expect("bech32 encode cannot fail for valid input") + } +} + +impl Recipient for X25519Recipient { + fn wrap_file_key(&self, file_key: &FileKey) -> Result { + let ephemeral_secret = EphemeralSecret::random_from_rng(OsRng); + let ephemeral_pub = PublicKey::from(&ephemeral_secret); + let shared = ephemeral_secret.diffie_hellman(&self.public_key); + + let wrap_key = derive_wrap_key( + shared.as_bytes(), + ephemeral_pub.as_bytes(), + self.public_key.as_bytes(), + )?; + + let cipher = ChaCha20Poly1305::new(Key::from_slice(&wrap_key)); + let nonce = Nonce::default(); // [0u8; 12] + let wrapped = cipher + .encrypt(&nonce, file_key.as_bytes().as_slice()) + .map_err(|_| AgeError::CryptoError("X25519 file key encryption failed".to_string()))?; + + Ok(Stanza { + tag: TAG.to_string(), + args: vec![STANDARD_NO_PAD.encode(ephemeral_pub.as_bytes())], + body: wrapped, + }) + } +} +``` + +### 3.4 Wire into mod files + +In `crates/notsecrets/src/identities/mod.rs`, add: + +```rust +pub mod x25519; +pub use x25519::X25519Identity; +``` + +In `crates/notsecrets/src/recipients/mod.rs`, add: + +```rust +pub mod x25519; +pub use x25519::X25519Recipient; +``` + +In `crates/notsecrets/src/lib.rs`, add to public exports: + +```rust +pub use identities::X25519Identity; +pub use recipients::X25519Recipient; +``` + +### 3.5 Run tests + +```bash +cargo test -p notsecrets -- x25519 --nocapture +``` + +Expected: + +``` +test identities::x25519::tests::x25519_bech32_parse_roundtrip ... ok +test identities::x25519::tests::x25519_wrong_identity_returns_none_for_nonmatching_tag ... ok +test identities::x25519::tests::x25519_wrap_unwrap_roundtrip ... ok +``` + +```bash +cargo clippy -p notsecrets -- -D warnings +``` + +Expected: 0 warnings. + +### 3.6 Commit + +```bash +git -C /Users/joe/dev/notfiles add crates/notsecrets/src/identities/ crates/notsecrets/src/recipients/ crates/notsecrets/src/lib.rs +git -C /Users/joe/dev/notfiles commit -m "feat(notsecrets): X25519 identity and recipient with wrap/unwrap" +``` + +--- + +## Task 4: Scrypt identity + recipient + +- [ ] Create `crates/notsecrets/src/identities/scrypt.rs` +- [ ] Create `crates/notsecrets/src/recipients/scrypt.rs` + +### 4.1 Test (write first) + +In `crates/notsecrets/src/identities/scrypt.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::identities::FileKey; + use crate::recipients::scrypt::ScryptRecipient; + + #[test] + fn scrypt_wrap_unwrap_roundtrip() { + let passphrase = "hunter2".to_string(); + let recipient = ScryptRecipient::new(passphrase.clone(), 14); + let identity = ScryptIdentity::new(passphrase); + + let file_key = FileKey::new([0x11u8; 16]); + let stanza = recipient.wrap_file_key(&file_key).expect("wrap should succeed"); + assert_eq!(stanza.tag, "scrypt"); + assert_eq!(stanza.args.len(), 2); // base64(salt), work_factor + + 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 scrypt_wrong_passphrase_returns_crypto_error() { + let recipient = ScryptRecipient::new("correct".to_string(), 14); + let identity = ScryptIdentity::new("wrong".to_string()); + + let file_key = FileKey::new([0x11u8; 16]); + let stanza = recipient.wrap_file_key(&file_key).unwrap(); + + let result = identity + .unwrap_file_key(&stanza) + .expect("tag matches") + .expect_err("wrong passphrase should fail"); + assert!(matches!(result, crate::error::AgeError::CryptoError(_))); + } + + #[test] + fn scrypt_wrong_tag_returns_none() { + let identity = ScryptIdentity::new("pass".to_string()); + use crate::identities::Stanza; + let stanza = Stanza { + tag: "X25519".to_string(), + args: vec!["arg".to_string()], + body: vec![], + }; + assert!(identity.unwrap_file_key(&stanza).is_none()); + } +} +``` + +### 4.2 `crates/notsecrets/src/identities/scrypt.rs` + +```rust +use crate::error::AgeError; +use crate::identities::{FileKey, Identity, Stanza}; +use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD}; +use chacha20poly1305::{ + ChaCha20Poly1305, Key, KeyInit, Nonce, + aead::Aead, +}; + +const TAG: &str = "scrypt"; + +pub struct ScryptIdentity { + passphrase: String, +} + +impl ScryptIdentity { + pub fn new(passphrase: String) -> Self { + Self { passphrase } + } +} + +impl Identity for ScryptIdentity { + fn unwrap_file_key(&self, stanza: &Stanza) -> Option> { + if stanza.tag != TAG { + return None; + } + Some(unwrap(stanza, &self.passphrase)) + } +} + +fn unwrap(stanza: &Stanza, passphrase: &str) -> Result { + if stanza.args.len() != 2 { + return Err(AgeError::ParseError( + "scrypt stanza must have 2 args: salt work_factor".to_string(), + )); + } + let salt = STANDARD_NO_PAD + .decode(&stanza.args[0]) + .map_err(|e| AgeError::ParseError(format!("scrypt salt base64: {e}")))?; + let work_factor: u8 = stanza.args[1] + .parse() + .map_err(|e| AgeError::ParseError(format!("scrypt work factor parse: {e}")))?; + + let wrap_key = derive_scrypt_key(passphrase.as_bytes(), &salt, work_factor)?; + let cipher = ChaCha20Poly1305::new(Key::from_slice(&wrap_key)); + let nonce = Nonce::default(); + let file_key_bytes = cipher + .decrypt(&nonce, stanza.body.as_slice()) + .map_err(|_| AgeError::CryptoError("scrypt file key decryption failed".to_string()))?; + FileKey::try_from(file_key_bytes.as_slice()) +} + +pub(crate) fn derive_scrypt_key( + passphrase: &[u8], + salt: &[u8], + work_factor: u8, +) -> Result<[u8; 32], AgeError> { + let params = scrypt::Params::new(work_factor, 8, 1, 32) + .map_err(|e| AgeError::CryptoError(format!("scrypt params: {e}")))?; + let mut wrap_key = [0u8; 32]; + scrypt::scrypt(passphrase, salt, ¶ms, &mut wrap_key) + .map_err(|e| AgeError::CryptoError(format!("scrypt: {e}")))?; + Ok(wrap_key) +} +``` + +### 4.3 `crates/notsecrets/src/recipients/scrypt.rs` + +```rust +use crate::error::AgeError; +use crate::identities::{FileKey, Stanza}; +use crate::identities::scrypt::derive_scrypt_key; +use crate::recipients::Recipient; +use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD}; +use chacha20poly1305::{ + ChaCha20Poly1305, Key, KeyInit, Nonce, + aead::Aead, +}; +use rand::RngCore; + +const TAG: &str = "scrypt"; +const SALT_LEN: usize = 16; + +pub struct ScryptRecipient { + passphrase: String, + work_factor: u8, +} + +impl ScryptRecipient { + /// `work_factor` is the log2(N) parameter (default 18 for production; use 14 for tests). + pub fn new(passphrase: String, work_factor: u8) -> Self { + Self { passphrase, work_factor } + } +} + +impl Recipient for ScryptRecipient { + fn wrap_file_key(&self, file_key: &FileKey) -> Result { + let mut salt = [0u8; SALT_LEN]; + rand::thread_rng().fill_bytes(&mut salt); + + let wrap_key = derive_scrypt_key(self.passphrase.as_bytes(), &salt, self.work_factor)?; + let cipher = ChaCha20Poly1305::new(Key::from_slice(&wrap_key)); + let nonce = Nonce::default(); + let wrapped = cipher + .encrypt(&nonce, file_key.as_bytes().as_slice()) + .map_err(|_| AgeError::CryptoError("scrypt file key encryption failed".to_string()))?; + + Ok(Stanza { + tag: TAG.to_string(), + args: vec![ + STANDARD_NO_PAD.encode(salt), + self.work_factor.to_string(), + ], + body: wrapped, + }) + } +} +``` + +### 4.4 Wire into mod files + +In `crates/notsecrets/src/identities/mod.rs`, add: + +```rust +pub mod scrypt; +pub use scrypt::ScryptIdentity; +``` + +In `crates/notsecrets/src/recipients/mod.rs`, add: + +```rust +pub mod scrypt; +pub use scrypt::ScryptRecipient; +``` + +In `lib.rs` exports, add: + +```rust +pub use identities::ScryptIdentity; +pub use recipients::ScryptRecipient; +``` + +### 4.5 Run tests + +```bash +cargo test -p notsecrets -- scrypt --nocapture +``` + +Expected: + +``` +test identities::scrypt::tests::scrypt_wrap_unwrap_roundtrip ... ok +test identities::scrypt::tests::scrypt_wrong_passphrase_returns_crypto_error ... ok +test identities::scrypt::tests::scrypt_wrong_tag_returns_none ... ok +``` + +```bash +cargo clippy -p notsecrets -- -D warnings +``` + +Expected: 0 warnings. + +### 4.6 Commit + +```bash +git -C /Users/joe/dev/notfiles add crates/notsecrets/src/identities/scrypt.rs crates/notsecrets/src/recipients/scrypt.rs crates/notsecrets/src/identities/mod.rs crates/notsecrets/src/recipients/mod.rs crates/notsecrets/src/lib.rs +git -C /Users/joe/dev/notfiles commit -m "feat(notsecrets): scrypt identity and recipient (passphrase-based)" +``` + +--- + +## Task 5: SSH Ed25519 identity + recipient + +- [ ] Create `crates/notsecrets/src/identities/ssh_ed25519.rs` +- [ ] Create `crates/notsecrets/src/recipients/ssh_ed25519.rs` + +### 5.1 Test (write first) + +In `crates/notsecrets/src/identities/ssh_ed25519.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::identities::FileKey; + use crate::recipients::ssh_ed25519::SshEd25519Recipient; + + fn test_keypair() -> (ed25519_dalek::SigningKey, ed25519_dalek::VerifyingKey) { + use rand::rngs::OsRng; + let signing_key = ed25519_dalek::SigningKey::generate(&mut OsRng); + let verifying_key = signing_key.verifying_key(); + (signing_key, verifying_key) + } + + #[test] + fn ssh_ed25519_wrap_unwrap_roundtrip() { + let (signing_key, verifying_key) = test_keypair(); + let recipient = SshEd25519Recipient::from_verifying_key(verifying_key); + let identity = SshEd25519Identity::from_signing_key(signing_key); + + let file_key = FileKey::new([0x33u8; 16]); + let stanza = recipient.wrap_file_key(&file_key).expect("wrap should succeed"); + assert_eq!(stanza.tag, "ssh-ed25519"); + assert_eq!(stanza.args.len(), 2); // fingerprint, ephemeral_pub + + 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_ed25519_wrong_fingerprint_returns_none() { + let (_, verifying_key1) = test_keypair(); + let (signing_key2, _) = test_keypair(); + let recipient = SshEd25519Recipient::from_verifying_key(verifying_key1); + let identity = SshEd25519Identity::from_signing_key(signing_key2); + + let file_key = FileKey::new([0x33u8; 16]); + let stanza = recipient.wrap_file_key(&file_key).unwrap(); + // Different key — fingerprint check in unwrap_file_key should return None + assert!(identity.unwrap_file_key(&stanza).is_none()); + } +} +``` + +### 5.2 `crates/notsecrets/src/identities/ssh_ed25519.rs` + +```rust +use crate::error::AgeError; +use crate::identities::{FileKey, Identity, Stanza}; +use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD}; +use chacha20poly1305::{ + ChaCha20Poly1305, Key, KeyInit, Nonce, + aead::Aead, +}; +use ed25519_dalek::SigningKey; +use hkdf::Hkdf; +use sha2::{Digest, Sha256, Sha512}; +use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret as X25519StaticSecret}; + +const TAG: &str = "ssh-ed25519"; +const HKDF_INFO: &[u8] = b"age-encryption.org/v1/ssh-ed25519"; + +pub struct SshEd25519Identity { + signing_key: SigningKey, +} + +impl SshEd25519Identity { + pub fn from_signing_key(signing_key: SigningKey) -> Self { + Self { signing_key } + } + + fn x25519_secret(&self) -> X25519StaticSecret { + // Ed25519 -> X25519 conversion: clamp(SHA-512(seed)[..32]) + let mut hash = Sha512::digest(self.signing_key.as_bytes()); + hash[0] &= 248; + hash[31] &= 127; + hash[31] |= 64; + let bytes: [u8; 32] = hash[..32].try_into().expect("SHA-512 output is 64 bytes"); + X25519StaticSecret::from(bytes) + } + + fn fingerprint(&self) -> [u8; 4] { + ssh_key_fingerprint(self.signing_key.verifying_key().as_bytes()) + } +} + +impl Identity for SshEd25519Identity { + fn unwrap_file_key(&self, stanza: &Stanza) -> Option> { + if stanza.tag != TAG { + return None; + } + if stanza.args.len() != 2 { + return Some(Err(AgeError::ParseError( + "ssh-ed25519 stanza must have 2 args".to_string(), + ))); + } + // Check fingerprint + let fp_bytes = match STANDARD_NO_PAD.decode(&stanza.args[0]) { + Ok(b) => b, + Err(e) => return Some(Err(AgeError::ParseError(format!("fingerprint base64: {e}")))), + }; + if fp_bytes != self.fingerprint() { + return None; + } + Some(unwrap(stanza, self)) + } +} + +fn unwrap(stanza: &Stanza, identity: &SshEd25519Identity) -> Result { + let ephemeral_pub_bytes = STANDARD_NO_PAD + .decode(&stanza.args[1]) + .map_err(|e| AgeError::ParseError(format!("ephemeral pubkey base64: {e}")))?; + let ephemeral_pub_bytes: [u8; 32] = ephemeral_pub_bytes + .try_into() + .map_err(|_| AgeError::ParseError("ephemeral pubkey must be 32 bytes".to_string()))?; + let ephemeral_pub = X25519PublicKey::from(ephemeral_pub_bytes); + + let x25519_secret = identity.x25519_secret(); + let shared = x25519_secret.diffie_hellman(&ephemeral_pub); + + let wrap_key = derive_wrap_key( + shared.as_bytes(), + ephemeral_pub.as_bytes(), + X25519PublicKey::from(&x25519_secret).as_bytes(), + )?; + + let cipher = ChaCha20Poly1305::new(Key::from_slice(&wrap_key)); + let nonce = Nonce::default(); + let file_key_bytes = cipher + .decrypt(&nonce, stanza.body.as_slice()) + .map_err(|_| AgeError::CryptoError("ssh-ed25519 file key decryption failed".to_string()))?; + FileKey::try_from(file_key_bytes.as_slice()) +} + +pub(crate) fn ssh_key_fingerprint(pub_key_bytes: &[u8]) -> [u8; 4] { + let hash = Sha256::digest(pub_key_bytes); + hash[..4].try_into().expect("SHA-256 output is 32 bytes") +} + +pub(crate) fn derive_wrap_key( + shared: &[u8], + ephemeral_pub: &[u8], + recipient_pub: &[u8], +) -> Result<[u8; 32], AgeError> { + let mut ikm = Vec::with_capacity(shared.len() + ephemeral_pub.len() + recipient_pub.len()); + ikm.extend_from_slice(shared); + ikm.extend_from_slice(ephemeral_pub); + ikm.extend_from_slice(recipient_pub); + + let hk = Hkdf::::new(None, &ikm); + let mut wrap_key = [0u8; 32]; + hk.expand(HKDF_INFO, &mut wrap_key) + .map_err(|e| AgeError::CryptoError(format!("HKDF expand: {e}")))?; + Ok(wrap_key) +} +``` + +### 5.3 `crates/notsecrets/src/recipients/ssh_ed25519.rs` + +```rust +use crate::error::AgeError; +use crate::identities::{FileKey, Stanza}; +use crate::identities::ssh_ed25519::{derive_wrap_key, ssh_key_fingerprint}; +use crate::recipients::Recipient; +use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD}; +use chacha20poly1305::{ + ChaCha20Poly1305, Key, KeyInit, Nonce, + aead::Aead, +}; +use ed25519_dalek::VerifyingKey; +use rand::rngs::OsRng; +use x25519_dalek::{EphemeralSecret, PublicKey as X25519PublicKey}; + +const TAG: &str = "ssh-ed25519"; + +pub struct SshEd25519Recipient { + verifying_key: VerifyingKey, +} + +impl SshEd25519Recipient { + pub fn from_verifying_key(verifying_key: VerifyingKey) -> Self { + Self { verifying_key } + } + + fn x25519_public(&self) -> X25519PublicKey { + // Ed25519 pubkey -> X25519 pubkey via birational map + // curve25519_dalek provides this via EdwardsPoint::to_montgomery() + use curve25519_dalek::edwards::CompressedEdwardsY; + let compressed = CompressedEdwardsY(self.verifying_key.to_bytes()); + let point = compressed.decompress().expect("valid ed25519 pubkey decompresses"); + X25519PublicKey::from(point.to_montgomery().to_bytes()) + } +} + +impl Recipient for SshEd25519Recipient { + fn wrap_file_key(&self, file_key: &FileKey) -> Result { + let ephemeral_secret = EphemeralSecret::random_from_rng(OsRng); + let ephemeral_pub = X25519PublicKey::from(&ephemeral_secret); + let recipient_x25519 = self.x25519_public(); + let shared = ephemeral_secret.diffie_hellman(&recipient_x25519); + + let wrap_key = derive_wrap_key( + shared.as_bytes(), + ephemeral_pub.as_bytes(), + recipient_x25519.as_bytes(), + )?; + + let cipher = ChaCha20Poly1305::new(Key::from_slice(&wrap_key)); + let nonce = Nonce::default(); + let wrapped = cipher + .encrypt(&nonce, file_key.as_bytes().as_slice()) + .map_err(|_| AgeError::CryptoError("ssh-ed25519 file key encryption failed".to_string()))?; + + let fingerprint = ssh_key_fingerprint(self.verifying_key.as_bytes()); + + Ok(Stanza { + tag: TAG.to_string(), + args: vec![ + STANDARD_NO_PAD.encode(fingerprint), + STANDARD_NO_PAD.encode(ephemeral_pub.as_bytes()), + ], + body: wrapped, + }) + } +} +``` + +Add `curve25519-dalek = "4"` to `[dependencies]` in `crates/notsecrets/Cargo.toml`. + +### 5.4 Wire into mod files + +In `identities/mod.rs`: + +```rust +pub mod ssh_ed25519; +pub use ssh_ed25519::SshEd25519Identity; +``` + +In `recipients/mod.rs`: + +```rust +pub mod ssh_ed25519; +pub use ssh_ed25519::SshEd25519Recipient; +``` + +In `lib.rs`: + +```rust +pub use identities::SshEd25519Identity; +pub use recipients::SshEd25519Recipient; +``` + +### 5.5 Run tests + +```bash +cargo test -p notsecrets -- ssh_ed25519 --nocapture +``` + +Expected: + +``` +test identities::ssh_ed25519::tests::ssh_ed25519_wrap_unwrap_roundtrip ... ok +test identities::ssh_ed25519::tests::ssh_ed25519_wrong_fingerprint_returns_none ... ok +``` + +```bash +cargo clippy -p notsecrets -- -D warnings +``` + +Expected: 0 warnings. + +### 5.6 Commit + +```bash +git -C /Users/joe/dev/notfiles add crates/notsecrets/src/identities/ssh_ed25519.rs crates/notsecrets/src/recipients/ssh_ed25519.rs crates/notsecrets/src/identities/mod.rs crates/notsecrets/src/recipients/mod.rs crates/notsecrets/src/lib.rs crates/notsecrets/Cargo.toml +git -C /Users/joe/dev/notfiles commit -m "feat(notsecrets): SSH Ed25519 identity and recipient" +``` + +--- + +## Task 6: SSH RSA identity + recipient + +- [ ] Create `crates/notsecrets/src/identities/ssh_rsa.rs` +- [ ] Create `crates/notsecrets/src/recipients/ssh_rsa.rs` + +### 6.1 Test (write first) + +In `crates/notsecrets/src/identities/ssh_rsa.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::identities::FileKey; + use crate::recipients::ssh_rsa::SshRsaRecipient; + use rsa::{RsaPrivateKey, RsaPublicKey}; + use rand::rngs::OsRng; + + fn test_rsa_keypair() -> (RsaPrivateKey, RsaPublicKey) { + // 2048-bit key; use a small bit size in tests only via feature flag or constant + 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); // fingerprint + + 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(); + // Fingerprint mismatch → None + assert!(identity.unwrap_file_key(&stanza).is_none()); + } +} +``` + +### 6.2 `crates/notsecrets/src/identities/ssh_rsa.rs` + +```rust +use crate::error::AgeError; +use crate::identities::{FileKey, Identity, Stanza}; +use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD}; +use rsa::{Oaep, RsaPrivateKey}; +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] { + use rsa::traits::PublicKeyParts; + rsa_pubkey_fingerprint(&rsa::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 != self.fingerprint() { + return None; + } + Some(unwrap(stanza, &self.private_key)) + } +} + +fn unwrap(stanza: &Stanza, private_key: &RsaPrivateKey) -> Result { + use rand::rngs::OsRng; + 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: &rsa::RsaPublicKey) -> [u8; 4] { + use rsa::traits::PublicKeyParts; + // Fingerprint: first 4 bytes of SHA-256 of the DER-encoded public key bytes + 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") +} +``` + +### 6.3 `crates/notsecrets/src/recipients/ssh_rsa.rs` + +```rust +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, + }) + } +} +``` + +### 6.4 Wire into mod files + +In `identities/mod.rs`: + +```rust +pub mod ssh_rsa; +pub use ssh_rsa::SshRsaIdentity; +``` + +In `recipients/mod.rs`: + +```rust +pub mod ssh_rsa; +pub use ssh_rsa::SshRsaRecipient; +``` + +In `lib.rs`: + +```rust +pub use identities::SshRsaIdentity; +pub use recipients::SshRsaRecipient; +``` + +### 6.5 Run tests + +```bash +cargo test -p notsecrets -- ssh_rsa --nocapture +``` + +Expected: + +``` +test identities::ssh_rsa::tests::ssh_rsa_wrap_unwrap_roundtrip ... ok +test identities::ssh_rsa::tests::ssh_rsa_wrong_fingerprint_returns_none ... ok +``` + +Note: RSA keygen at 2048 bits is slow (~1s per test). This is expected in CI. + +```bash +cargo clippy -p notsecrets -- -D warnings +``` + +Expected: 0 warnings. + +### 6.6 Commit + +```bash +git -C /Users/joe/dev/notfiles add crates/notsecrets/src/identities/ssh_rsa.rs crates/notsecrets/src/recipients/ssh_rsa.rs crates/notsecrets/src/identities/mod.rs crates/notsecrets/src/recipients/mod.rs crates/notsecrets/src/lib.rs +git -C /Users/joe/dev/notfiles commit -m "feat(notsecrets): SSH RSA identity and recipient (OAEP-SHA256)" +``` + +--- + +## Task 7: EncryptedIdentity + +An `EncryptedIdentity` wraps another `Identity` whose key material is stored in an age-encrypted file. Decrypting the outer file yields the inner identity's key material (e.g., an `AGE-SECRET-KEY-1...` line). + +- [ ] Create `crates/notsecrets/src/identities/encrypted.rs` + +### 7.1 Test (write first) + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::identities::{FileKey, x25519::X25519Identity}; + use crate::recipients::x25519::X25519Recipient; + use x25519_dalek::{PublicKey, StaticSecret}; + use rand::rngs::OsRng; + + /// Build a minimal age-encrypted file wrapping an AGE-SECRET-KEY-1 identity. + fn make_encrypted_identity_file(inner_bech32: &str, passphrase: &str) -> Vec { + use crate::recipients::scrypt::ScryptRecipient; + let recipient = ScryptRecipient::new(passphrase.to_string(), 14); + // Encrypt the inner key as plaintext + let encryptor = crate::Encryptor::with_recipients(vec![Box::new(recipient)]) + .expect("encryptor build"); + encryptor + .encrypt(inner_bech32.as_bytes()) + .expect("encrypt inner key") + } + + #[test] + fn encrypted_identity_wraps_x25519() { + use rand::rngs::OsRng; + let inner_secret = StaticSecret::random_from_rng(OsRng); + let inner_pub = PublicKey::from(&inner_secret); + let inner_identity = X25519Identity::from_static_secret(inner_secret.clone()); + let inner_bech32 = inner_identity.to_bech32(); + + let passphrase = "test-passphrase"; + let encrypted_file = make_encrypted_identity_file(&inner_bech32, passphrase); + + // Wrap in EncryptedIdentity + let encrypted_identity = EncryptedIdentity::new( + encrypted_file, + Box::new(crate::identities::scrypt::ScryptIdentity::new(passphrase.to_string())), + ); + + // Encrypt a payload with the inner X25519 recipient + let recipient = X25519Recipient::from_public_key(inner_pub); + let file_key = FileKey::new([0x77u8; 16]); + let stanza = recipient.wrap_file_key(&file_key).unwrap(); + + // EncryptedIdentity should decrypt the file to recover the inner identity and unwrap + let unwrapped = encrypted_identity + .unwrap_file_key(&stanza) + .expect("should match") + .expect("should decrypt"); + assert_eq!(unwrapped.as_bytes(), file_key.as_bytes()); + } +} +``` + +Note: this test depends on `Encryptor` from Task 8. Mark it `#[ignore]` until Task 8 is complete, then remove the `#[ignore]`. + +### 7.2 `crates/notsecrets/src/identities/encrypted.rs` + +```rust +use crate::error::AgeError; +use crate::identities::{FileKey, Identity, Stanza}; + +/// An identity whose key material is stored in an age-encrypted file. +/// +/// The outer file is decrypted on each call to `unwrap_file_key` using +/// `passphrase_identity`. The decrypted content must be an `AGE-SECRET-KEY-1...` +/// bech32 string, which is parsed as an `X25519Identity`. +pub struct EncryptedIdentity { + /// Raw bytes of the age-encrypted identity file. + encrypted_data: Vec, + /// Identity used to decrypt the outer age file (typically `ScryptIdentity`). + passphrase_identity: Box, +} + +impl EncryptedIdentity { + pub fn new(encrypted_data: Vec, passphrase_identity: Box) -> Self { + Self { encrypted_data, passphrase_identity } + } +} + +impl Identity for EncryptedIdentity { + fn unwrap_file_key(&self, stanza: &Stanza) -> Option> { + // Decrypt the outer file to recover inner identity key bytes + let decryptor = crate::Decryptor::with_identities(vec![self.passphrase_identity.as_ref()]); + let plaintext = match decryptor.decrypt(&self.encrypted_data) { + Ok(p) => p, + Err(e) => return Some(Err(e)), + }; + let key_str = match std::str::from_utf8(&plaintext) { + Ok(s) => s.trim().to_string(), + Err(_) => { + return Some(Err(AgeError::ParseError( + "decrypted identity file is not valid UTF-8".to_string(), + ))) + } + }; + // Parse inner identity — only X25519 supported for now + let inner: Box = if key_str.starts_with("AGE-SECRET-KEY-1") { + match crate::identities::x25519::X25519Identity::from_bech32(&key_str) { + Ok(id) => Box::new(id), + Err(e) => return Some(Err(e)), + } + } else { + return Some(Err(AgeError::UnsupportedKeyType( + "encrypted identity file must contain AGE-SECRET-KEY-1".to_string(), + ))); + }; + inner.unwrap_file_key(stanza) + } +} +``` + +Note: `Decryptor::with_identities` here takes `Vec<&dyn Identity>` rather than `Vec>` to avoid clone requirements. The `Decryptor` signature in Task 9 must accommodate both — the simplest approach is to add a `with_identity_refs` constructor, or to make `EncryptedIdentity` clone the passphrase identity. The plan uses the latter: require `passphrase_identity: Box` and rely on the fact that `ScryptIdentity` is `Clone`. Adjust `Decryptor` API in Task 9 to accept `&[Box]` or provide a `decrypt_with` method. The exact API reconciliation is resolved in Task 9. + +### 7.3 Wire into mod files + +In `identities/mod.rs`: + +```rust +pub mod encrypted; +pub use encrypted::EncryptedIdentity; +``` + +In `lib.rs`: + +```rust +pub use identities::EncryptedIdentity; +``` + +### 7.4 Run tests + +```bash +cargo test -p notsecrets -- encrypted --nocapture +``` + +Expected: `EncryptedIdentity` tests are marked `#[ignore]` pending Task 8; remaining tests pass. + +```bash +cargo clippy -p notsecrets -- -D warnings +``` + +Expected: 0 warnings. + +### 7.5 Commit + +```bash +git -C /Users/joe/dev/notfiles add crates/notsecrets/src/identities/encrypted.rs crates/notsecrets/src/identities/mod.rs crates/notsecrets/src/lib.rs +git -C /Users/joe/dev/notfiles commit -m "feat(notsecrets): EncryptedIdentity — passphrase-protected identity file wrapper" +``` + +--- + +## Task 8: Encryptor + +- [ ] Add `crates/notsecrets/src/encrypt.rs` with `Encryptor` +- [ ] Wire into `lib.rs` + +### 8.1 Test (write first) + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::identities::FileKey; + use crate::identities::x25519::X25519Identity; + use crate::recipients::x25519::X25519Recipient; + use x25519_dalek::{PublicKey, StaticSecret}; + use rand::rngs::OsRng; + + #[test] + fn encryptor_single_x25519_recipient() { + let secret = StaticSecret::random_from_rng(OsRng); + let public = PublicKey::from(&secret); + let recipient: Box = + Box::new(X25519Recipient::from_public_key(public)); + + let encryptor = Encryptor::with_recipients(vec![recipient]).unwrap(); + let plaintext = b"hello, age!"; + let ciphertext = encryptor.encrypt(plaintext).expect("encrypt should succeed"); + + // Verify it starts with the age version line + assert!( + ciphertext.starts_with(b"age-encryption.org/v1\n"), + "output must start with age version line" + ); + } + + #[test] + fn encryptor_rejects_multiple_scrypt_recipients() { + use crate::recipients::scrypt::ScryptRecipient; + let r1: Box = + Box::new(ScryptRecipient::new("pass1".to_string(), 14)); + let r2: Box = + Box::new(ScryptRecipient::new("pass2".to_string(), 14)); + let result = Encryptor::with_recipients(vec![r1, r2]); + assert!(result.is_err(), "two scrypt recipients must be rejected"); + } + + #[test] + fn encryptor_empty_recipients_returns_error() { + let result = Encryptor::with_recipients(vec![]); + assert!(result.is_err(), "empty recipients must be rejected"); + } +} +``` + +### 8.2 `crates/notsecrets/src/encrypt.rs` + +```rust +use crate::error::AgeError; +use crate::format::{serialize_header}; +use crate::identities::{FileKey, Header}; +use crate::recipients::Recipient; +use chacha20poly1305::{ + ChaCha20Poly1305, Key, KeyInit, Nonce, + aead::Aead, +}; +use hkdf::Hkdf; +use hmac::{Hmac, Mac}; +use rand::RngCore; +use sha2::Sha256; + +type HmacSha256 = Hmac; + +pub struct Encryptor { + recipients: Vec>, +} + +impl Encryptor { + /// Construct an `Encryptor`. Returns `AgeError` if: + /// - `recipients` is empty + /// - more than one scrypt recipient is present + pub fn with_recipients(recipients: Vec>) -> Result { + if recipients.is_empty() { + return Err(AgeError::CryptoError( + "at least one recipient is required".to_string(), + )); + } + // Detect scrypt recipients by attempting to wrap a dummy key and checking the stanza tag. + // We cannot inspect the concrete type, so we use a probe approach: + // wrap a dummy FileKey and check if any stanza tag is "scrypt". + let dummy_key = FileKey::new([0u8; 16]); + let scrypt_count = recipients + .iter() + .filter_map(|r| r.wrap_file_key(&dummy_key).ok()) + .filter(|s| s.tag == "scrypt") + .count(); + if scrypt_count > 1 { + return Err(AgeError::CryptoError( + "at most one scrypt recipient is allowed per age file".to_string(), + )); + } + Ok(Self { recipients }) + } + + pub fn encrypt(&self, plaintext: &[u8]) -> Result, AgeError> { + let file_key = FileKey::generate(); + + // Wrap file key with each recipient + let stanzas: Vec<_> = self + .recipients + .iter() + .map(|r| r.wrap_file_key(&file_key)) + .collect::>()?; + + // Build header without MAC first (we need the bytes to compute MAC) + let header_no_mac = Header { + recipients: stanzas, + mac: Vec::new(), + }; + + // Compute MAC: HMAC-SHA256 over header bytes up to and including "--- " + let mut header_bytes = serialize_header(&header_no_mac); + // The serialized header ends with "--- \n" (MAC is empty, so base64("") = ""). + // We need the MAC to cover bytes up to and including "--- ". + // Trim the trailing "\n" that follows the empty MAC, then compute MAC over + // everything up to and including "--- ". + let mac_key = derive_mac_key(&file_key)?; + let footer_pos = header_bytes + .windows(4) + .rposition(|w| w == b"--- ") + .ok_or_else(|| AgeError::ParseError("serialize_header missing footer".to_string()))?; + let mac_input = &header_bytes[..footer_pos + 4]; // up to and including "--- " + + let mut mac = HmacSha256::new_from_slice(&mac_key) + .map_err(|e| AgeError::CryptoError(format!("HMAC init: {e}")))?; + mac.update(mac_input); + let mac_bytes = mac.finalize().into_bytes().to_vec(); + + // Re-serialize with actual MAC + let header_with_mac = Header { + recipients: header_no_mac.recipients, + mac: mac_bytes, + }; + header_bytes = serialize_header(&header_with_mac); + + // Payload: 16-byte random nonce + ChaCha20Poly1305(payload_key, plaintext) + let mut nonce_bytes = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut nonce_bytes); + let payload_key = derive_payload_key(&file_key, &nonce_bytes)?; + + let cipher = ChaCha20Poly1305::new(Key::from_slice(&payload_key)); + let counter_nonce = Nonce::default(); // [0u8; 12] + let ciphertext = cipher + .encrypt(&counter_nonce, plaintext) + .map_err(|_| AgeError::CryptoError("payload encryption failed".to_string()))?; + + let mut output = header_bytes; + output.extend_from_slice(&nonce_bytes); + output.extend_from_slice(&ciphertext); + + Ok(output) + } +} + +fn derive_mac_key(file_key: &FileKey) -> Result<[u8; 32], AgeError> { + let hk = Hkdf::::new(Some(b""), file_key.as_bytes()); + let mut mac_key = [0u8; 32]; + hk.expand(b"header", &mut mac_key) + .map_err(|e| AgeError::CryptoError(format!("HKDF mac key: {e}")))?; + Ok(mac_key) +} + +fn derive_payload_key(file_key: &FileKey, nonce: &[u8; 16]) -> Result<[u8; 32], AgeError> { + let hk = Hkdf::::new(Some(nonce.as_slice()), file_key.as_bytes()); + let mut payload_key = [0u8; 32]; + hk.expand(b"payload", &mut payload_key) + .map_err(|e| AgeError::CryptoError(format!("HKDF payload key: {e}")))?; + Ok(payload_key) +} +``` + +Add `pub mod encrypt;` and `pub use encrypt::Encryptor;` to `lib.rs`. + +### 8.3 Run tests + +```bash +cargo test -p notsecrets -- encryptor --nocapture +``` + +Expected: + +``` +test encrypt::tests::encryptor_single_x25519_recipient ... ok +test encrypt::tests::encryptor_rejects_multiple_scrypt_recipients ... ok +test encrypt::tests::encryptor_empty_recipients_returns_error ... ok +``` + +Also run the `encrypted` test which was previously ignored — it now has `Encryptor` available: + +```bash +cargo test -p notsecrets -- encrypted --nocapture +``` + +Remove the `#[ignore]` attribute from `encrypted` tests and verify they pass. + +```bash +cargo clippy -p notsecrets -- -D warnings +``` + +Expected: 0 warnings. + +### 8.4 Commit + +```bash +git -C /Users/joe/dev/notfiles add crates/notsecrets/src/encrypt.rs crates/notsecrets/src/identities/encrypted.rs crates/notsecrets/src/lib.rs +git -C /Users/joe/dev/notfiles commit -m "feat(notsecrets): Encryptor — multi-recipient age encryption" +``` + +--- + +## Task 9: Decryptor + +- [ ] Add `crates/notsecrets/src/decrypt.rs` with `Decryptor` +- [ ] Wire into `lib.rs` + +### 9.1 Test (write first) + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::Encryptor; + use crate::identities::x25519::X25519Identity; + use crate::recipients::x25519::X25519Recipient; + use x25519_dalek::{PublicKey, StaticSecret}; + use rand::rngs::OsRng; + + fn make_x25519_pair() -> (X25519Identity, X25519Recipient) { + let secret = StaticSecret::random_from_rng(OsRng); + let public = PublicKey::from(&secret); + ( + X25519Identity::from_static_secret(secret), + X25519Recipient::from_public_key(public), + ) + } + + #[test] + fn decryptor_roundtrip_single_recipient() { + let (identity, recipient) = make_x25519_pair(); + let encryptor = Encryptor::with_recipients(vec![Box::new(recipient)]).unwrap(); + let plaintext = b"the quick brown fox"; + let ciphertext = encryptor.encrypt(plaintext).unwrap(); + + let decryptor = Decryptor::with_identities(vec![Box::new(identity)]); + let decrypted = decryptor.decrypt(&ciphertext).unwrap(); + assert_eq!(decrypted, plaintext); + } + + #[test] + fn decryptor_roundtrip_multiple_recipients() { + let (identity1, recipient1) = make_x25519_pair(); + let (identity2, recipient2) = make_x25519_pair(); + let encryptor = Encryptor::with_recipients(vec![ + Box::new(recipient1), + Box::new(recipient2), + ]) + .unwrap(); + let plaintext = b"multi-recipient test"; + let ciphertext = encryptor.encrypt(plaintext).unwrap(); + + // Either identity alone can decrypt + let decryptor1 = Decryptor::with_identities(vec![Box::new(identity1)]); + assert_eq!(decryptor1.decrypt(&ciphertext).unwrap(), plaintext); + + let decryptor2 = Decryptor::with_identities(vec![Box::new(identity2)]); + assert_eq!(decryptor2.decrypt(&ciphertext).unwrap(), plaintext); + } + + #[test] + fn decryptor_no_matching_identity_returns_no_match() { + let (_, recipient) = make_x25519_pair(); + let (wrong_identity, _) = make_x25519_pair(); + let encryptor = Encryptor::with_recipients(vec![Box::new(recipient)]).unwrap(); + let ciphertext = encryptor.encrypt(b"data").unwrap(); + + let decryptor = Decryptor::with_identities(vec![Box::new(wrong_identity)]); + let err = decryptor.decrypt(&ciphertext).unwrap_err(); + assert!(matches!(err, crate::error::AgeError::NoMatch)); + } + + #[test] + fn decryptor_corrupted_mac_returns_mac_mismatch() { + let (identity, recipient) = make_x25519_pair(); + let encryptor = Encryptor::with_recipients(vec![Box::new(recipient)]).unwrap(); + let mut ciphertext = encryptor.encrypt(b"data").unwrap(); + + // Flip a byte in the MAC region (near end of header, before payload) + // Find "--- " and flip a byte 5 bytes after it + let footer_pos = ciphertext + .windows(4) + .position(|w| w == b"--- ") + .expect("footer present"); + ciphertext[footer_pos + 4] ^= 0xff; + + let decryptor = Decryptor::with_identities(vec![Box::new(identity)]); + let err = decryptor.decrypt(&ciphertext).unwrap_err(); + assert!( + matches!(err, crate::error::AgeError::MacMismatch | crate::error::AgeError::ParseError(_)), + "expected MacMismatch or ParseError, got: {err:?}" + ); + } +} +``` + +### 9.2 `crates/notsecrets/src/decrypt.rs` + +```rust +use crate::error::AgeError; +use crate::format::{header_bytes_up_to_footer, parse_header}; +use crate::identities::{FileKey, Identity}; +use chacha20poly1305::{ + ChaCha20Poly1305, Key, KeyInit, Nonce, + aead::Aead, +}; +use hkdf::Hkdf; +use hmac::{Hmac, Mac}; +use sha2::Sha256; + +type HmacSha256 = Hmac; + +pub struct Decryptor { + identities: Vec>, +} + +impl Decryptor { + pub fn with_identities(identities: Vec>) -> Self { + Self { identities } + } + + pub fn decrypt(&self, ciphertext: &[u8]) -> Result, AgeError> { + let (header, payload_offset) = parse_header(ciphertext)?; + + // Try to recover file key by trying each identity against each stanza + let mut file_key: Option = None; + 'outer: for identity in &self.identities { + for stanza in &header.recipients { + if let Some(result) = identity.unwrap_file_key(stanza) { + file_key = Some(result?); + break 'outer; + } + } + } + let file_key = file_key.ok_or(AgeError::NoMatch)?; + + // Verify header MAC + let mac_key = derive_mac_key(&file_key)?; + let header_input = header_bytes_up_to_footer(ciphertext)?; + + let mut mac = HmacSha256::new_from_slice(&mac_key) + .map_err(|e| AgeError::CryptoError(format!("HMAC init: {e}")))?; + mac.update(&header_input); + mac.verify_slice(&header.mac) + .map_err(|_| AgeError::MacMismatch)?; + + // Decrypt payload + let payload = &ciphertext[payload_offset..]; + if payload.len() < 16 { + return Err(AgeError::ParseError( + "payload too short to contain nonce".to_string(), + )); + } + let nonce_bytes: [u8; 16] = payload[..16] + .try_into() + .expect("slice is exactly 16 bytes"); + let payload_ciphertext = &payload[16..]; + + let payload_key = derive_payload_key(&file_key, &nonce_bytes)?; + let cipher = ChaCha20Poly1305::new(Key::from_slice(&payload_key)); + let counter_nonce = Nonce::default(); + let plaintext = cipher + .decrypt(&counter_nonce, payload_ciphertext) + .map_err(|_| AgeError::CryptoError("payload decryption failed".to_string()))?; + + Ok(plaintext) + } +} + +fn derive_mac_key(file_key: &FileKey) -> Result<[u8; 32], AgeError> { + let hk = Hkdf::::new(Some(b""), file_key.as_bytes()); + let mut mac_key = [0u8; 32]; + hk.expand(b"header", &mut mac_key) + .map_err(|e| AgeError::CryptoError(format!("HKDF mac key: {e}")))?; + Ok(mac_key) +} + +fn derive_payload_key(file_key: &FileKey, nonce: &[u8; 16]) -> Result<[u8; 32], AgeError> { + let hk = Hkdf::::new(Some(nonce.as_slice()), file_key.as_bytes()); + let mut payload_key = [0u8; 32]; + hk.expand(b"payload", &mut payload_key) + .map_err(|e| AgeError::CryptoError(format!("HKDF payload key: {e}")))?; + Ok(payload_key) +} +``` + +Add `pub mod decrypt;` and `pub use decrypt::Decryptor;` to `lib.rs`. + +### 9.3 Run tests + +```bash +cargo test -p notsecrets -- decrypt --nocapture +``` + +Expected: + +``` +test decrypt::tests::decryptor_roundtrip_single_recipient ... ok +test decrypt::tests::decryptor_roundtrip_multiple_recipients ... ok +test decrypt::tests::decryptor_no_matching_identity_returns_no_match ... ok +test decrypt::tests::decryptor_corrupted_mac_returns_mac_mismatch ... ok +``` + +Run full suite to verify no regressions: + +```bash +cargo test -p notsecrets --nocapture +``` + +```bash +cargo clippy -p notsecrets -- -D warnings +``` + +Expected: 0 warnings. + +### 9.4 Commit + +```bash +git -C /Users/joe/dev/notfiles add crates/notsecrets/src/decrypt.rs crates/notsecrets/src/lib.rs +git -C /Users/joe/dev/notfiles commit -m "feat(notsecrets): Decryptor — identity-driven age decryption with MAC verification" +``` + +--- + +## Task 10: sources/ migration — IdentitySource trait, update all three sources + +- [ ] Add `IdentitySource` trait to `crates/notsecrets/src/sources/mod.rs` +- [ ] Rewrite `sources/bitwarden.rs` +- [ ] Rewrite `sources/file.rs` +- [ ] Rewrite `sources/prompt.rs` +- [ ] Remove `_legacy.rs` and all `#[deprecated]` shims from `lib.rs` + +### 10.1 `crates/notsecrets/src/sources/mod.rs` + +```rust +use crate::error::AgeError; +use crate::identities::Identity; + +pub mod bitwarden; +pub mod file; +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>; +} +``` + +### 10.2 Rewrite `sources/bitwarden.rs` + +```rust +use crate::error::AgeError; +use crate::identities::Identity; +use crate::identities::x25519::X25519Identity; +use crate::sources::IdentitySource; +use std::process::Command; + +pub struct BitwardenSource { + pub item_name: String, +} + +impl BitwardenSource { + pub fn new(item_name: impl Into) -> Self { + Self { item_name: item_name.into() } + } + + fn retrieve_key(&self) -> Result { + let bw_ok = std::process::Command::new("sh") + .args(["-c", "command -v bw"]) + .status() + .map(|s| s.success()) + .unwrap_or(false); + 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| 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() + .map_err(|e| AgeError::SourceError { + name: self.name().to_string(), + source: anyhow::anyhow!("bw unlock spawn: {e}"), + })?; + if !output.status.success() { + 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) + .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() + .map_err(|e| AgeError::SourceError { + name: self.name().to_string(), + source: anyhow::anyhow!("bw get spawn: {e}"), + })?; + + if !output.status.success() { + 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) + .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() { + 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)) + } +} +``` + +### 10.3 Rewrite `sources/file.rs` + +```rust +use crate::error::AgeError; +use crate::identities::Identity; +use crate::identities::x25519::X25519Identity; +use crate::identities::encrypted::EncryptedIdentity; +use crate::identities::scrypt::ScryptIdentity; +use crate::sources::IdentitySource; +use std::path::PathBuf; + +const AGE_VERSION_LINE: &str = "age-encryption.org/v1"; + +pub struct FileSource { + path: PathBuf, +} + +impl FileSource { + pub fn new(path: PathBuf) -> Self { + Self { path } + } +} + +impl IdentitySource for FileSource { + fn name(&self) -> &str { + "file" + } + + 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()), + })?; + + // Detect identity type from file content + 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(AGE_VERSION_LINE.as_bytes()) { + // age-encrypted identity file — prompt for passphrase to decrypt + 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}"), + })?; + let passphrase_identity = ScryptIdentity::new(passphrase); + Ok(Box::new(EncryptedIdentity::new(content, Box::new(passphrase_identity)))) + } else { + Err(AgeError::UnsupportedKeyType(format!( + "file {} does not contain a recognized age key format", + self.path.display() + ))) + } + } +} +``` + +### 10.4 Rewrite `sources/prompt.rs` + +```rust +use crate::error::AgeError; +use crate::identities::Identity; +use crate::identities::scrypt::ScryptIdentity; +use crate::sources::IdentitySource; + +pub struct PromptSource; + +impl IdentitySource for PromptSource { + fn name(&self) -> &str { + "prompt" + } + + 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(Box::new(ScryptIdentity::new(passphrase))) + } +} +``` + +### 10.5 Remove legacy shims + +Delete `crates/notsecrets/src/_legacy.rs`. Remove the `mod _legacy` and `pub use _legacy::...` lines from `lib.rs`. The `notstrap` crate will be updated in Task 12. + +### 10.6 Run tests + +```bash +cargo test -p notsecrets --nocapture +``` + +Expected: all tests pass. + +```bash +cargo clippy -p notsecrets -- -D warnings +``` + +Expected: 0 warnings. + +### 10.7 Commit + +```bash +git -C /Users/joe/dev/notfiles add crates/notsecrets/src/sources/ crates/notsecrets/src/lib.rs +git -C /Users/joe/dev/notfiles commit -m "feat(notsecrets): migrate sources to IdentitySource trait, remove legacy API" +``` + +--- + +## Task 11: resolve_identities() public API + +- [ ] Add `resolve_identities` to `lib.rs` + +### 11.1 Implementation + +In `crates/notsecrets/src/lib.rs`, add: + +```rust +/// Try each `IdentitySource` in order; collect all identities that load successfully. +/// +/// If all sources fail, returns the last `AgeError::SourceError`. If at least one +/// source succeeds, returns the list of loaded identities (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) + } +} +``` + +### 11.2 Test + +Add to `lib.rs` test module: + +```rust +#[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 { + identity: crate::identities::x25519::X25519Identity, + } + + impl StaticX25519Source { + fn new() -> Self { + use x25519_dalek::StaticSecret; + use rand::rngs::OsRng; + Self { + identity: crate::identities::x25519::X25519Identity::from_static_secret( + StaticSecret::random_from_rng(OsRng), + ), + } + } + } + + impl IdentitySource for StaticX25519Source { + fn name(&self) -> &str { "static-x25519" } + fn load(&self) -> Result, AgeError> { + use x25519_dalek::StaticSecret; + use rand::rngs::OsRng; + 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::new()), + ]; + let identities = resolve_identities(sources).expect("at least one source succeeded"); + assert_eq!(identities.len(), 1); + } +} +``` + +### 11.3 Run tests + +```bash +cargo test -p notsecrets --nocapture +``` + +Expected: all pass. + +```bash +cargo clippy -p notsecrets -- -D warnings +``` + +Expected: 0 warnings. + +### 11.4 Commit + +```bash +git -C /Users/joe/dev/notfiles add crates/notsecrets/src/lib.rs +git -C /Users/joe/dev/notfiles commit -m "feat(notsecrets): add resolve_identities() public API" +``` + +--- + +## Task 12: notstrap migration + +Update `crates/notstrap/src/lib.rs` to replace the old `notsecrets` API with the new one. + +- [ ] Update `crates/notstrap/src/lib.rs` — two call sites +- [ ] Update `crates/notstrap/Cargo.toml` if needed +- [ ] Run `cargo check --workspace` +- [ ] Run `cargo test --workspace` + +### 12.1 Changes to `crates/notstrap/src/lib.rs` + +**Import line** (line 5), replace: + +```rust +use notsecrets::{BitwardenSource, FileSource, PromptSource, install_age_key, resolve_age_key}; +``` + +with: + +```rust +use notsecrets::{ + BitwardenSource, Decryptor, FileSource, PromptSource, + resolve_identities, + sources::IdentitySource, +}; +``` + +**Step 4 block** (lines 92–110), replace: + +```rust + // 4. Retrieve age key and install + let sources: Vec> = if let Some(kf) = opts.key_file { + vec![Box::new(FileSource::new(kf))] + } else { + vec![ + Box::new(BitwardenSource::new(&cfg.bootstrap.bw_age_item)), + Box::new(PromptSource), + ] + }; + + match resolve_age_key(sources) { + Ok(key) => { + install_age_key(&key)?; + report.add("age key", StepStatus::Ok); + } + Err(e) => { + report.add("age key", StepStatus::Failed(e.to_string())); + return Ok(report); + } + } +``` + +with: + +```rust + // 4. Resolve age identities + let sources: Vec> = if let Some(kf) = opts.key_file { + vec![Box::new(FileSource::new(kf))] + } else { + vec![ + Box::new(BitwardenSource::new(&cfg.bootstrap.bw_age_item)), + Box::new(PromptSource), + ] + }; + + let identities = match resolve_identities(sources) { + Ok(ids) => { + report.add("age key", StepStatus::Ok); + ids + } + Err(e) => { + report.add("age key", StepStatus::Failed(e.to_string())); + return Ok(report); + } + }; +``` + +**env_injector closure** in `BootstrapOptions` and its call site (lines 112–142): + +The `env_injector` closure currently captures a `sops` call. Replace it so callers pass a closure that captures a `Decryptor`. Update the type alias to remain the same (`Box Result>`) — this keeps the blast radius minimal. The `notstrap` binary's `main.rs` builds the `env_injector` closure; update it there: + +In `crates/notstrap/src/main.rs`, the env_injector construction (wherever `decrypt_sops` was called) becomes: + +```rust +let env_injector: Option = Some(Box::new({ + let identities = identities.clone(); // requires Identity: Clone, or Arc wrapping + move |path: &std::path::Path| { + let bytes = std::fs::read(path) + .with_context(|| format!("cannot read sops file {}", path.display()))?; + let decryptor = notsecrets::Decryptor::with_identities(identities); + let plaintext = decryptor.decrypt(&bytes) + .map_err(|e| anyhow::anyhow!("age decrypt failed: {e}"))?; + String::from_utf8(plaintext).map_err(|e| anyhow::anyhow!("plaintext UTF-8: {e}")) + } +})); +``` + +For `Box` to be cloneable across the closure boundary, wrap identities in `Arc`: + +Change `identities` type from `Vec>` to `Vec>` in the closure. Adjust `Decryptor::with_identities` to accept `Vec>` as an alternative, or convert: `identities.into_iter().map(|i| i as Box).collect()`. + +The simplest approach: keep `Decryptor::with_identities(Vec>)` and pass ownership into the closure — the closure is `FnOnce`, and `EnvInjector` is `Box` which requires `Fn`. Resolve by storing identities in `Arc>>`: + +```rust +// In lib.rs: expose a Decryptor constructor from Arc +impl Decryptor { + pub fn with_identities_arc(identities: std::sync::Arc>>) -> Self { + // store Arc internally + } +} +``` + +Or simplest of all: change `EnvInjector` from `Box` to `Box` in `BootstrapOptions` since it is only ever called once. This requires changing one field and one call site in `lib.rs`: + +```rust +type EnvInjector = Box Result>; +``` + +and in `run()`: + +```rust +if let Some(injector) = opts.env_injector { + let sops_path = dotfiles_dir.join(&cfg.bootstrap.sops_file); + match injector(&sops_path) { ... } +} +``` + +`FnOnce` is called the same way via `injector(arg)`. This change is fully backwards-compatible with existing test code. + +### 12.2 Remove legacy notsecrets exports + +Verify `crates/notstrap/Cargo.toml` does not need changes (it already depends on `notsecrets = { workspace = true }`). + +Check workspace `Cargo.toml`: remove `which` from `[workspace.dependencies]` if no other crate references it: + +```bash +cargo metadata --no-deps --format-version 1 | python3 -c "import json,sys; d=json.load(sys.stdin); [print(p['name'],p.get('dependencies',[])) for p in d['packages']]" 2>/dev/null | grep which +``` + +If only `notsecrets` used it, remove from workspace. + +### 12.3 Verify + +```bash +cargo check --workspace +``` + +Expected: 0 errors. + +```bash +cargo test --workspace --nocapture 2>&1 | tail -40 +``` + +Expected: all tests pass. + +```bash +cargo clippy --workspace -- -D warnings +``` + +Expected: 0 warnings. + +### 12.4 Commit + +```bash +git -C /Users/joe/dev/notfiles add crates/notstrap/src/ Cargo.toml +git -C /Users/joe/dev/notfiles commit -m "feat(notstrap): migrate to notsecrets age-native API, remove sops shell-out" +``` + +--- + +## Self-Review Checklist + +Before considering the plan complete, verify: + +1. **All spec features covered:** + - [x] Multiple recipients — Task 8 (`Encryptor::with_recipients`) + - [x] Passphrases (scrypt) — Task 4 + - [x] Passphrase-protected identity files — Task 7 (`EncryptedIdentity`) + - [x] SSH Ed25519 — Task 5 + - [x] SSH RSA — Task 6 + - [x] X25519 identity + recipient — Task 3 + - [x] age wire format parser/serializer — Task 2 + - [x] Header MAC verification — Task 9 (`Decryptor`) + - [x] `resolve_identities` — Task 11 + - [x] Sources migration — Task 10 + - [x] notstrap migration — Task 12 + +2. **No placeholder text:** Every code block is complete. No "TBD", ellipsis substitutions, or "implement X appropriately". + +3. **Type names consistent across tasks:** + - `FileKey`, `Stanza`, `Header` defined in Task 1, used throughout + - `Identity`, `Recipient`, `IdentitySource` traits defined in Tasks 1 and 10 + - `AgeError` defined in Task 1, referenced in all subsequent tasks + - `Encryptor` defined in Task 8, referenced in Task 7 test and Task 12 + - `Decryptor` defined in Task 9, referenced in Task 7 impl and Task 12 + +4. **TDD discipline:** Every task writes the failing test first, specifies the expected failure output, then provides the implementation. + +5. **`cargo clippy -p notsecrets -- -D warnings` called after every task.** + +6. **Every file path is exact** — all paths use `crates/notsecrets/src/...` or `crates/notstrap/src/...` with no ambiguity. diff --git a/docs/superpowers/specs/2026-04-01-notgraph-design.md b/docs/superpowers/specs/2026-04-01-notgraph-design.md new file mode 100644 index 0000000..2963bc9 --- /dev/null +++ b/docs/superpowers/specs/2026-04-01-notgraph-design.md @@ -0,0 +1,137 @@ +# notgraph — Module Graph & Symbol Extraction Tooling + +**Date:** 2026-04-01 +**Status:** Approved + +## Overview + +A new `notgraph` binary-only workspace crate that analyses the notfiles workspace and produces three output artefacts: a Markdown summary, a JSON report, and a self-contained interactive HTML file. It runs in CI to enforce zero intra-crate module cycles, and locally for codebase exploration. + +## Architecture + +Three pipeline stages: + +1. **Collect** — `cargo metadata` → crate dep graph; `walkdir` + `syn` → per-crate module trees and symbol tables +2. **Analyze** — fan-in/fan-out counts, intra-crate cycle detection (Kahn's algorithm), hotspot ranking +3. **Emit** — write `docs/graph/report.md`, `docs/graph/report.json`, `docs/graph/report.html` + +`notgraph` is a binary-only crate. No other workspace crate depends on it. + +## Components + +Five modules in `crates/notgraph/src/`: + +| Module | Responsibility | +|--------|---------------| +| `crate_graph` | Calls `cargo_metadata`, builds crate→crate dep map for workspace members only | +| `module_graph` | Walks `src/` per crate with `walkdir`, parses `mod` declarations via `syn` to build intra-crate module trees | +| `symbols` | Parses each `.rs` file with `syn`, extracts public structs/enums/traits/fns/types/consts by module | +| `analysis` | Fan-in/fan-out counts, cycle detection (Kahn's algorithm), hotspot ranking (top N by fan-in and fan-out separately) | +| `emit` | Writes `report.md`, `report.json`, `report.html` (self-contained inline CSS+JS via vis.js) | + +`main.rs` wires collect → analyze → emit, then exits non-zero if cycles detected. + +## Data Flow & Key Types + +``` +cargo metadata + └─► CrateGraph { nodes: Vec, edges: Vec<(CrateName, CrateName)> } + +walkdir + syn (per crate) + └─► ModuleGraph { crate: CrateName, nodes: Vec, edges: Vec<(ModPath, ModPath)> } + └─► SymbolTable { crate: CrateName, symbols: Vec } + +Symbol { mod_path: ModPath, kind: SymbolKind, name: String, is_pub: bool } +SymbolKind: Struct | Enum | Trait | Fn | Type | Const + +analysis + └─► GraphStats { + crate_graph: FanStats, + module_graphs: Vec, + hotspots: Vec, // top N by fan-in and fan-out separately + cycles: Vec>, // empty = clean + } + +FanStats { name: String, fan_in: usize, fan_out: usize } +ModStats { crate: CrateName, nodes: Vec, cycles: Vec> } +Hotspot { name: String, kind: HotspotKind, score: usize } +HotspotKind: FanIn | FanOut +``` + +## CLI + +``` +notgraph [OPTIONS] + +Options: + --output Directory to write report.{md,json,html} [default: docs/graph] + --fail-on-cycles Exit 1 if any intra-crate module cycles are detected + --top Number of hotspots to surface per category [default: 10] +``` + +## Output Files + +**`report.json`** — full `GraphStats` serialised with `serde_json`. Machine-readable, suitable for diffing in CI. + +**`report.md`** — sections: +- Crate dependency graph (adjacency list) +- Per-crate module graph summary (node count, edge count, cycle count) +- Hotspot tables: top N fan-in and top N fan-out for both crates and modules +- Public symbol inventory per crate (grouped by `SymbolKind`) +- Cycle report (empty section = ✅ clean) + +**`report.html`** — self-contained single file: +- vis.js network diagram of the crate graph (interactive, filterable) +- Collapsible per-crate module graph diagrams +- Sortable hotspot tables +- No external network requests (vis.js inlined) + +## Dependencies + +```toml +[dependencies] +cargo_metadata = "0.18" +syn = { version = "2", features = ["full", "visit"] } +walkdir = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +anyhow = "1" +``` + +## Testing + +**Unit tests** (in each module): Kahn's algorithm with known graphs, fan-count arithmetic, hotspot ranking. + +**Integration tests** (`crates/notgraph/tests/integration.rs`) with three fixture sets in `tests/fixtures/`: + +| Fixture | Purpose | +|---------|---------| +| `clean/` | No cycles, known fan-in/out — assert output matches expected values | +| `cyclic/` | Two modules that mutually declare each other — assert cycle detected, exit code 1 with `--fail-on-cycles` | +| `symbols/` | Known public symbols — assert symbol table matches expected inventory | + +## CI Wiring (`mise.toml`) + +```toml +[tasks."all:graph"] +description = "Generate module graph reports" +run = "cargo run -p notgraph --release -- --output docs/graph" + +[tasks."all:graph-check"] +description = "CI: fail if module cycles detected" +run = "cargo run -p notgraph --release -- --output docs/graph --fail-on-cycles" +``` + +`all:ci` gains `all:graph-check` after the existing `all:dep-boundaries` step. + +## CI Behaviour + +- Exit 0: no cycles (hotspots are informational only) +- Exit 1: one or more intra-crate module cycles detected (only with `--fail-on-cycles`) +- `docs/graph/` is `.gitignore`d — reports are generated, not committed + +## Non-Goals + +- Cross-crate module graph (crate boundaries are already enforced by `check-dep-boundaries.py`) +- Lifetime or type-level analysis +- Incremental/watch mode