Files
notfiles/docs/superpowers/specs/2026-04-01-notsecrets-age-redesign.md
2026-04-01 19:01:24 -04:00

7.3 KiB

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:

pub trait Identity {
    fn unwrap_file_key(&self, stanza: &Stanza) -> Option<Result<FileKey, AgeError>>;
}

pub trait Recipient {
    fn wrap_file_key(&self, file_key: &FileKey) -> Result<Stanza, AgeError>;
}

Core domain types:

pub struct FileKey([u8; 16]);
pub struct Stanza { pub tag: String, pub args: Vec<String>, pub body: Vec<u8> }
pub struct Header { pub recipients: Vec<Stanza>, pub mac: Vec<u8> }

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

pub struct Encryptor { recipients: Vec<Box<dyn Recipient>> }

impl Encryptor {
    pub fn with_recipients(recipients: Vec<Box<dyn Recipient>>) -> Result<Self, AgeError>;
    pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, 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

pub struct Decryptor { identities: Vec<Box<dyn Identity>> }

impl Decryptor {
    pub fn with_identities(identities: Vec<Box<dyn Identity>>) -> Self;
    pub fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>, 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: -> <tag> <args...>\n<base64-body>\n
  • Footer: --- <base64-mac>\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<dyn Identity> instead of a raw key string. resolve_age_key is replaced by:

pub fn resolve_identities(sources: Vec<Box<dyn IdentitySource>>) -> Result<Vec<Box<dyn Identity>>, AgeError>;

Where IdentitySource is a new thin trait:

pub trait IdentitySource {
    fn name(&self) -> &str;
    fn load(&self) -> Result<Box<dyn Identity>, 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

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

#[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