From fe8f29d5054bad75a71ba2676c18eb904ce54f03 Mon Sep 17 00:00:00 2001 From: Joseph O'Brien <98370624+89jobrien@users.noreply.github.com> Date: Wed, 1 Apr 2026 19:01:24 -0400 Subject: [PATCH] docs: add notsecrets age-native redesign spec --- .../2026-04-01-notsecrets-age-redesign.md | 235 ++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-01-notsecrets-age-redesign.md diff --git a/docs/superpowers/specs/2026-04-01-notsecrets-age-redesign.md b/docs/superpowers/specs/2026-04-01-notsecrets-age-redesign.md new file mode 100644 index 0000000..2ea93e4 --- /dev/null +++ b/docs/superpowers/specs/2026-04-01-notsecrets-age-redesign.md @@ -0,0 +1,235 @@ +# notsecrets: Age-Native Redesign + +**Date:** 2026-04-01 +**Status:** Approved + +## Overview + +Redesign `notsecrets` from a thin wrapper around external tools (sops, Bitwarden CLI) into a self-contained age encryption/decryption library implemented in pure Rust. Modeled architecturally after `rage` but with no dependency on it. Supports multiple recipients, passphrases, passphrase-protected identity files, and SSH keys. + +Replaces: `sops --decrypt` shell-out, `resolve_age_key`, `install_age_key`. +Keeps: existing `sources/` resolvers (Bitwarden, file, prompt) — repurposed as identity resolvers. + +--- + +## Domain Layer (Ports) + +Two core traits define the age conceptual split: + +```rust +pub trait Identity { + fn unwrap_file_key(&self, stanza: &Stanza) -> Option>; +} + +pub trait Recipient { + fn wrap_file_key(&self, file_key: &FileKey) -> Result; +} +``` + +Core domain types: + +```rust +pub struct FileKey([u8; 16]); +pub struct Stanza { pub tag: String, pub args: Vec, pub body: Vec } +pub struct Header { pub recipients: Vec, pub mac: Vec } +``` + +`Stanza` is the age wire-format recipient block. `FileKey` is the symmetric payload key. No infrastructure details appear in domain types. + +--- + +## Identity Implementations (`src/identities/`) + +| File | Type | Key material | Crypto | +|------|------|-------------|--------| +| `x25519.rs` | `X25519Identity` | `AGE-SECRET-KEY-1...` bech32 | X25519 + HKDF + ChaCha20-Poly1305 | +| `scrypt.rs` | `ScryptIdentity` | passphrase string | scrypt + ChaCha20-Poly1305 | +| `ssh_ed25519.rs` | `SshEd25519Identity` | OpenSSH ed25519 private key | Ed25519→X25519 twist + HKDF + ChaCha20-Poly1305 | +| `ssh_rsa.rs` | `SshRsaIdentity` | OpenSSH RSA private key (incl. passphrase-protected PEM) | OAEP-SHA256 | +| `encrypted.rs` | `EncryptedIdentity` | age-encrypted identity file | Wraps inner `Identity`; decrypts file first via `ScryptIdentity` | + +Each implements `Identity`, maps its errors to `AgeError`, and has no knowledge of sources (Bitwarden, file, prompt). + +--- + +## Recipient Implementations (`src/recipients/`) + +| File | Type | Key material | Crypto | +|------|------|-------------|--------| +| `x25519.rs` | `X25519Recipient` | `age1...` bech32 public key | ephemeral X25519 + HKDF + ChaCha20-Poly1305 | +| `scrypt.rs` | `ScryptRecipient` | passphrase + work factor | scrypt + ChaCha20-Poly1305 | +| `ssh_ed25519.rs` | `SshEd25519Recipient` | OpenSSH ed25519 public key | Ed25519→X25519 + HKDF + ChaCha20-Poly1305 | +| `ssh_rsa.rs` | `SshRsaRecipient` | OpenSSH RSA public key | OAEP-SHA256 | + +Multiple recipients: `Encryptor` calls `wrap_file_key` on each, writing all stanzas to the header. The single-scrypt-recipient invariant is enforced at `Encryptor` construction, not in the `ScryptRecipient` itself. + +--- + +## Encryptor and Decryptor + +### `Encryptor` + +```rust +pub struct Encryptor { recipients: Vec> } + +impl Encryptor { + pub fn with_recipients(recipients: Vec>) -> Result; + pub fn encrypt(&self, plaintext: &[u8]) -> Result, AgeError>; +} +``` + +Encrypt steps: +1. Generate random `FileKey` +2. `wrap_file_key` on each recipient → collect stanzas +3. Serialize `Header` (base64 stanza bodies + HMAC-SHA256 MAC) +4. Derive payload key from `FileKey` via HKDF +5. Encrypt payload with ChaCha20-Poly1305 (random nonce) +6. Write age binary format: version line + header stanzas + `---` separator + nonce + ciphertext + +### `Decryptor` + +```rust +pub struct Decryptor { identities: Vec> } + +impl Decryptor { + pub fn with_identities(identities: Vec>) -> Self; + pub fn decrypt(&self, ciphertext: &[u8]) -> Result, AgeError>; +} +``` + +Decrypt steps: +1. Parse header (format module) +2. Verify header MAC +3. Try each identity against each stanza until `FileKey` recovered +4. Derive payload key, decrypt with ChaCha20-Poly1305 + +--- + +## Format Module (`src/format.rs`) + +Pure serialization/deserialization of the age binary wire format: +- Version line: `age-encryption.org/v1` +- Recipient stanzas: `-> \n\n` +- Footer: `--- \n` +- Payload: nonce + ciphertext + +No crypto here. Gets thorough unit tests covering round-trip, malformed input, truncated files. + +--- + +## Sources (Kept, Repurposed) + +Existing `sources/` (Bitwarden, file, prompt) are kept but now produce `Box` instead of a raw key string. `resolve_age_key` is replaced by: + +```rust +pub fn resolve_identities(sources: Vec>) -> Result>, AgeError>; +``` + +Where `IdentitySource` is a new thin trait: + +```rust +pub trait IdentitySource { + fn name(&self) -> &str; + fn load(&self) -> Result, AgeError>; +} +``` + +`FileSource` loads a key file and returns `X25519Identity` if the content starts with `AGE-SECRET-KEY-1`, or `EncryptedIdentity` if the content starts with the age version line (`age-encryption.org/v1`). `PromptSource` returns `ScryptIdentity`. `BitwardenSource` retrieves the raw key and returns `X25519Identity`. + +--- + +## Module Structure + +``` +crates/notsecrets/src/ + lib.rs # public API + format.rs # age wire format parser/serializer + error.rs # AgeError + identities/ + mod.rs # Identity trait, FileKey, Stanza, Header + x25519.rs + scrypt.rs + ssh_ed25519.rs + ssh_rsa.rs + encrypted.rs + recipients/ + mod.rs # Recipient trait + x25519.rs + scrypt.rs + ssh_ed25519.rs + ssh_rsa.rs + sources/ + mod.rs # IdentitySource trait + bitwarden.rs + file.rs + prompt.rs +``` + +--- + +## Dependencies Added to `Cargo.toml` + +```toml +x25519-dalek = { version = "2", features = ["static_secrets"] } +ed25519-dalek = "2" +rsa = "0.9" +sha2 = "1" +hkdf = "0.12" +chacha20poly1305 = "0.10" +scrypt = "0.11" +bech32 = "0.11" +base64 = "0.22" +hmac = "0.12" +``` + +**Removed:** `which` (was only used for sops check). +**Kept:** `rpassword` (used by `PromptSource`), `dirs`, `anyhow`, `thiserror`. + +--- + +## Removed from Public API + +- `resolve_age_key()` +- `install_age_key()` / `install_age_key_at()` +- `decrypt_sops()` / `sops_args()` +- `AgeKeySource` trait + +--- + +## notstrap Impact + +Two call sites to update: +- `resolve_age_key(sources)` → `resolve_identities(sources)` +- `decrypt_sops(path)` → `Decryptor::with_identities(identities).decrypt(&bytes)` + +--- + +## Error Handling + +```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 }, +} +``` + +--- + +## Testing Strategy + +- **Unit tests** on `format.rs`: round-trip parse/serialize, malformed input, truncated files +- **Unit tests** on each identity/recipient pair: wrap then unwrap with matching identity +- **Integration tests**: encrypt with multiple recipients, verify each identity can independently decrypt +- **Negative tests**: wrong identity returns `NoMatch`, corrupted MAC returns `MacMismatch` +- No external process spawning in tests