Files
notfiles/docs/superpowers/plans/2026-04-01-notsecrets-age-redesign.md
2026-04-01 19:31:09 -04:00

2830 lines
90 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<Self, AgeError> {
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<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>>;
}
```
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<Stanza, AgeError>;
}
```
### 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<u8> {
// 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::<Vec<_>>()[..]);
}
#[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<Stanza> = Vec::new();
loop {
if pos >= input.len() {
return Err(AgeError::ParseError("unexpected end of header".to_string()));
}
// Check for footer line: "--- <base64-mac>\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: "-> <tag> [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<String> = 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 `--- <base64-mac>` footer. Does NOT write the payload.
pub fn serialize_header(header: &Header) -> Vec<u8> {
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<Vec<u8>, 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<Self, AgeError> {
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::<Bech32>(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<Result<FileKey, AgeError>> {
if stanza.tag != TAG {
return None;
}
Some(unwrap(stanza, &self.secret))
}
}
fn unwrap(stanza: &Stanza, secret: &StaticSecret) -> Result<FileKey, AgeError> {
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::<Sha256>::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<Self, AgeError> {
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::<Bech32>(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<Stanza, AgeError> {
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<Result<FileKey, AgeError>> {
if stanza.tag != TAG {
return None;
}
Some(unwrap(stanza, &self.passphrase))
}
}
fn unwrap(stanza: &Stanza, passphrase: &str) -> Result<FileKey, AgeError> {
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, &params, &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<Stanza, AgeError> {
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<Result<FileKey, AgeError>> {
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<FileKey, AgeError> {
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::<Sha256>::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<Stanza, AgeError> {
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<Result<FileKey, AgeError>> {
if stanza.tag != TAG {
return None;
}
if stanza.args.len() != 1 {
return Some(Err(AgeError::ParseError(
"ssh-rsa stanza must have 1 arg".to_string(),
)));
}
let fp = match STANDARD_NO_PAD.decode(&stanza.args[0]) {
Ok(b) => b,
Err(e) => return Some(Err(AgeError::ParseError(format!("fingerprint base64: {e}")))),
};
if fp != self.fingerprint() {
return None;
}
Some(unwrap(stanza, &self.private_key))
}
}
fn unwrap(stanza: &Stanza, private_key: &RsaPrivateKey) -> Result<FileKey, AgeError> {
use rand::rngs::OsRng;
let padding = Oaep::new::<Sha256>();
let file_key_bytes = private_key
.decrypt(padding, &stanza.body)
.map_err(|_| AgeError::CryptoError("RSA-OAEP decrypt failed".to_string()))?;
FileKey::try_from(file_key_bytes.as_slice())
}
pub(crate) fn rsa_pubkey_fingerprint(public_key: &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<Stanza, AgeError> {
use rand::rngs::OsRng;
let padding = Oaep::new::<Sha256>();
let wrapped = self
.public_key
.encrypt(&mut OsRng, padding, file_key.as_bytes())
.map_err(|_| AgeError::CryptoError("RSA-OAEP encrypt failed".to_string()))?;
let fingerprint = rsa_pubkey_fingerprint(&self.public_key);
Ok(Stanza {
tag: TAG.to_string(),
args: vec![STANDARD_NO_PAD.encode(fingerprint)],
body: wrapped,
})
}
}
```
### 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<u8> {
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<u8>,
/// Identity used to decrypt the outer age file (typically `ScryptIdentity`).
passphrase_identity: Box<dyn Identity>,
}
impl EncryptedIdentity {
pub fn new(encrypted_data: Vec<u8>, passphrase_identity: Box<dyn Identity>) -> Self {
Self { encrypted_data, passphrase_identity }
}
}
impl Identity for EncryptedIdentity {
fn unwrap_file_key(&self, stanza: &Stanza) -> Option<Result<FileKey, AgeError>> {
// 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<dyn Identity> = 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<Box<dyn Identity>>` 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<dyn Identity + Send>` and rely on the fact that `ScryptIdentity` is `Clone`. Adjust `Decryptor` API in Task 9 to accept `&[Box<dyn Identity>]` 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<dyn crate::recipients::Recipient> =
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<dyn crate::recipients::Recipient> =
Box::new(ScryptRecipient::new("pass1".to_string(), 14));
let r2: Box<dyn crate::recipients::Recipient> =
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<Sha256>;
pub struct Encryptor {
recipients: Vec<Box<dyn Recipient>>,
}
impl Encryptor {
/// Construct an `Encryptor`. Returns `AgeError` if:
/// - `recipients` is empty
/// - more than one scrypt recipient is present
pub fn with_recipients(recipients: Vec<Box<dyn Recipient>>) -> Result<Self, AgeError> {
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<Vec<u8>, 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::<Result<_, _>>()?;
// 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::<Sha256>::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::<Sha256>::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<Sha256>;
pub struct Decryptor {
identities: Vec<Box<dyn Identity>>,
}
impl Decryptor {
pub fn with_identities(identities: Vec<Box<dyn Identity>>) -> Self {
Self { identities }
}
pub fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>, 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<FileKey> = 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::<Sha256>::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::<Sha256>::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<Box<dyn Identity>, 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<String>) -> Self {
Self { item_name: item_name.into() }
}
fn retrieve_key(&self) -> Result<String, AgeError> {
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<Box<dyn Identity>, 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<Box<dyn Identity>, 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<Box<dyn Identity>, 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<Box<dyn sources::IdentitySource>>,
) -> Result<Vec<Box<dyn Identity>>, AgeError> {
let mut identities: Vec<Box<dyn Identity>> = Vec::new();
let mut last_err: Option<AgeError> = 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<Box<dyn Identity>, 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<Box<dyn Identity>, 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<Box<dyn IdentitySource>> = vec![Box::new(AlwaysFailSource)];
assert!(resolve_identities(sources).is_err());
}
#[test]
fn resolve_identities_partial_success_returns_loaded() {
let sources: Vec<Box<dyn IdentitySource>> = 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 92110), replace:
```rust
// 4. Retrieve age key and install
let sources: Vec<Box<dyn notsecrets::AgeKeySource>> = 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<Box<dyn IdentitySource>> = 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 112142):
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<dyn Fn(&Path) -> Result<String>>`) — 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<notstrap::EnvInjector> = 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<dyn Identity>` to be cloneable across the closure boundary, wrap identities in `Arc`:
Change `identities` type from `Vec<Box<dyn Identity>>` to `Vec<Arc<dyn Identity>>` in the closure. Adjust `Decryptor::with_identities` to accept `Vec<Arc<dyn Identity>>` as an alternative, or convert: `identities.into_iter().map(|i| i as Box<dyn Identity>).collect()`.
The simplest approach: keep `Decryptor::with_identities(Vec<Box<dyn Identity>>)` and pass ownership into the closure — the closure is `FnOnce`, and `EnvInjector` is `Box<dyn Fn>` which requires `Fn`. Resolve by storing identities in `Arc<Vec<Box<dyn Identity>>>`:
```rust
// In lib.rs: expose a Decryptor constructor from Arc
impl Decryptor {
pub fn with_identities_arc(identities: std::sync::Arc<Vec<Box<dyn Identity>>>) -> Self {
// store Arc internally
}
}
```
Or simplest of all: change `EnvInjector` from `Box<dyn Fn>` to `Box<dyn FnOnce>` 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<dyn FnOnce(&Path) -> Result<String>>;
```
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.