docs: add notsecrets age-native redesign spec
This commit is contained in:
235
docs/superpowers/specs/2026-04-01-notsecrets-age-redesign.md
Normal file
235
docs/superpowers/specs/2026-04-01-notsecrets-age-redesign.md
Normal file
@@ -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<Result<FileKey, AgeError>>;
|
||||
}
|
||||
|
||||
pub trait Recipient {
|
||||
fn wrap_file_key(&self, file_key: &FileKey) -> Result<Stanza, AgeError>;
|
||||
}
|
||||
```
|
||||
|
||||
Core domain types:
|
||||
|
||||
```rust
|
||||
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`
|
||||
|
||||
```rust
|
||||
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`
|
||||
|
||||
```rust
|
||||
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:
|
||||
|
||||
```rust
|
||||
pub fn resolve_identities(sources: Vec<Box<dyn IdentitySource>>) -> Result<Vec<Box<dyn Identity>>, AgeError>;
|
||||
```
|
||||
|
||||
Where `IdentitySource` is a new thin trait:
|
||||
|
||||
```rust
|
||||
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`
|
||||
|
||||
```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
|
||||
Reference in New Issue
Block a user