Files
relicario/docs/superpowers/specs/2026-04-11-relicario-design.md
adlee-was-taken 39ae2ecbf3 style: capitalize "Relicario" in prose / UI / CLI help
Brand name uses capital R in user-facing text — extension UI strings,
CLI clap help / descriptions / error prose, markdown docs. Lowercase
preserved for the binary command, crate names, npm package, file
paths, env vars, and code identifiers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 17:29:10 -04:00

370 lines
18 KiB
Markdown

# Relicario — Design Specification
A git-backed, self-hostable password manager with a Rust core, CLI, and Chrome browser extension. The reference image as a DCT-embedded secret carrier is the core differentiator.
## Overview
Relicario is a password manager where vault decryption requires two independent factors: a passphrase the user memorizes and a reference JPEG that carries a 256-bit secret embedded via DCT steganography. The vault lives in a git repository (self-hosted on the user's own Gitea instance), and the server only ever sees opaque ciphertext. Compromise of either factor alone is insufficient to decrypt the vault.
Primary goals: portfolio project for adlee.work, architectural elegance, legibility-as-security (the README should read as the security proof), learning Rust, and fun to tinker with.
## Threat Model
### What we protect
A collection of credentials (usernames, passwords, URLs, TOTP seeds, notes) belonging to a single user or family.
### Adversaries
| Adversary | Access | Goal | Defense |
|---|---|---|---|
| Gitea server compromise | Full repo contents | Decrypt vault entries | Server-zero-knowledge: only ciphertext stored. KDF inputs never touch server. |
| Network observer | Git traffic | Read vault in transit | Git over HTTPS/SSH. Vault double-encrypted (TLS + AEAD). |
| Stolen device | Filesystem: reference image, device key, cached vault | Decrypt vault | Attacker has image_secret but not passphrase. Argon2id makes brute-force expensive. |
| Stolen device + weak passphrase | Same + feasible brute-force | Decrypt vault | Enforce minimum passphrase strength at vault creation. Universal worst case. |
| Shoulder surfer | Observed passphrase | Decrypt vault (if they also get image) | Passphrase alone insufficient — still need image_secret. |
| Credential stuffing | Leaked email/password from other breaches | Access user's accounts | Relicario generates unique passwords per site. Breach of site A doesn't compromise site B. |
### Out of scope
- Compromised device with active malware (keylogger + screen capture). No software password manager survives this.
- Rubber-hose cryptanalysis.
- Quantum computing (XChaCha20 is symmetric/believed quantum-resistant, not a design goal).
### Security invariants
1. **Server-zero-knowledge.** The server never receives, stores, or can derive the passphrase, image_secret, or any vault key.
2. **Two-factor vault key.** Decryption requires both passphrase AND reference image. Compromise of either alone is insufficient.
3. **Forward secrecy on rotation.** When passphrase or reference image is rotated, old vault snapshots in git history remain encrypted with the old key.
4. **Device revocation without KDF rotation.** Removing a device ed25519 key from the manifest prevents commits. Does not require changing passphrase or reference image.
## Crypto Pipeline
### Key derivation
```
passphrase (user types, UTF-8 encoded)
+ image_secret (256 bits, extracted from reference JPEG)
+ vault_salt (32 bytes, stored plaintext in repo)
Argon2id(
password = passphrase_bytes || image_secret_bytes, // concatenated, 32-byte secret appended
salt = vault_salt, // 32 bytes, from .relicario/salt
memory = 64 MiB,
iterations = 3,
parallelism = 4,
output = 32 bytes
)
master_key (32 bytes, held in memory only, never persisted)
```
The Argon2id `password` parameter receives the concatenation of the passphrase (UTF-8 bytes) and the image_secret (32 raw bytes). The `salt` parameter receives the vault_salt. This is the canonical Argon2id API — no custom construction.
Single master_key encrypts all vault entries and the manifest. No per-entry subkey derivation — unnecessary for the expected vault size (family use, thousands of entries at most).
### Entropy analysis
| Scenario | Attacker has | Must crack | Effective entropy |
|---|---|---|---|
| Server breach only | Encrypted vault, salt, params | Passphrase + image_secret | passphrase_bits + 256 (infeasible) |
| Server breach + stolen image | Vault + image_secret | Passphrase only | passphrase_bits through Argon2id |
| Passphrase shoulder-surfed + server breach | Passphrase + vault | image_secret | 256 bits (infeasible) |
| Stolen device | Image + device key + vault | Passphrase only | passphrase_bits through Argon2id |
With a 4-word diceware passphrase (~51 bits) and Argon2id at 64 MiB, brute-force of the passphrase alone takes ~7 million years on specialized hardware.
Compared to competitors:
- LastPass/Bitwarden: server breach exposes ~40-60 bits (master password only)
- 1Password: server breach exposes password + 128-bit Secret Key
- Relicario: server breach exposes password + 256-bit image_secret
### Authenticated encryption
**XChaCha20-Poly1305** (192-bit nonce, 256-bit key, 128-bit auth tag).
Chosen over AES-256-GCM because:
- 192-bit nonce eliminates birthday-bound collision concerns (96-bit AES-GCM nonce has a ~2^48 bound; XChaCha20's 192-bit nonce has ~2^96)
- Fast in software — critical for WASM (browser extension) and ARM (future mobile) where AES-NI hardware acceleration is unavailable
- Used by age, libsodium, WireGuard — well-studied, modern standard
Per-file binary format:
```
┌─────────┬───────┬────────────┬─────┐
│ version │ nonce │ ciphertext │ tag │
│ 1 byte │ 24 B │ variable │ 16B │
└─────────┴───────┴────────────┴─────┘
```
Nonce is generated fresh (CSPRNG) on every write. Version byte allows future format changes.
### KDF parameters
Stored in `.relicario/params.json` (plaintext, committed). Configurable per-vault:
- Default: `argon2_m=65536` (64 MiB), `argon2_t=3`, `argon2_p=4`
- Users can increase for CLI-only use on powerful hardware
- Enables future parameter upgrades without format changes
## imgsecret Module
The DCT-based secret embedding primitive. This is the technically novel component.
### Public API
```rust
/// Embed a 256-bit secret into a carrier JPEG.
/// Returns the modified JPEG bytes.
fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result<Vec<u8>>
/// Extract a 256-bit secret from a (possibly re-encoded/cropped) JPEG.
fn extract(jpeg: &[u8]) -> Result<[u8; 32]>
```
No passphrase, no KDF, no vault awareness. Pure bytes-in, bytes-out.
### Embedding process
1. **Decode JPEG to pixels.** Convert to YCbCr, extract Y (luminance) channel only. Luminance has the most DCT energy and survives re-encoding best. Cb/Cr are often subsampled 4:2:0 by social media.
2. **Define embedding region.** Central 70% of image (15% margin on all sides). For a 4000x3000 photo: inner 2800x2100 pixels. The margin is a "crumple zone" — 15% crop from any edge doesn't touch embedded data.
3. **Block-DCT.** Divide the embedding region into 8x8 blocks, apply 2D DCT to each.
4. **Select embedding blocks.** Fixed geometric pattern — evenly spaced blocks across the central region. The pattern is public (part of the spec). Security comes from the extracted secret being meaningless without the passphrase, not from hiding which blocks carry data.
5. **Encode with heavy redundancy.**
- 32 bytes secret → Reed-Solomon RS(255, 223) → 64 bytes (can correct 16 byte errors per block)
- Repeat entire RS-encoded payload N times across different block groups (20+ copies for a typical phone photo)
- Extraction uses majority voting across copies + RS correction within each copy — two layers of error correction
6. **Embed via QIM (Quantization Index Modulation).** Round each selected mid-frequency DCT coefficient (positions 4-15 in zig-zag order) to a quantization grid and nudge to encode 0 or 1. Mid-frequency: low enough to survive re-quantization, high enough to be visually invisible.
7. **Reconstruct and save.** Inverse DCT, reconstruct image, save as JPEG at quality 90-92.
### Extraction process (including crop recovery)
1. Decode JPEG, extract Y channel, block-DCT (computed once for the whole image).
2. **Try canonical alignment (no crop).** Read fixed geometric pattern from central region, extract bits, RS-decode each redundant copy, majority-vote. If RS succeeds → done.
3. **If canonical fails, try crop offsets.** Iterate `(dx, dy)` from -15% to +15% of image dimensions, stepping by 8 pixels. For each offset, recompute block positions, attempt extraction + RS decode. First successful RS decode + majority vote = correct alignment.
4. **Performance.** Worst case ~16,800 offset candidates for a 4000x3000 image. Each candidate reads ~20 blocks (microseconds since DCT is pre-computed). Total worst case: ~50-100ms. Average case (no crop): first try succeeds.
### EXIF handling
Caller must normalize EXIF orientation before passing JPEG to embed/extract. EXIF rotation changes the pixel grid, which breaks block alignment.
### Constraints
- Carrier must be a JPEG image (the format social media uses)
- Minimum image size: to be determined empirically during implementation, documented in spec
- The module does NOT encrypt anything — it embeds plaintext bits. The secret is random, so it's indistinguishable from noise.
### Test battery
- Round-trip: embed → extract, no transformation (must always pass)
- JPEG recompression: embed at Q92, recompress at Q75, Q60, Q50 — find the threshold
- Social media simulation: embed → resize to 1080px wide → recompress at Q80 → extract
- Crop: 5%, 10%, 15% from each edge independently
- Combined: crop 10% + recompress at Q75
- Negative: extract from an un-embedded JPEG (must fail cleanly, not return garbage)
- Minimum image size: find smallest viable carrier
## Vault Format & Repo Layout
```
relicario-vault/
├── manifest.enc # encrypted JSON: entry index, vault metadata
├── entries/
│ ├── a1b2c3d4.enc # one encrypted entry per file, random hex ID
│ ├── e5f6a7b8.enc
│ └── ...
└── .relicario/
├── salt # 32 bytes, plaintext (prevents precomputation)
├── params.json # Argon2id parameters, plaintext
└── devices.json # authorized device ed25519 public keys, plaintext
```
### manifest.enc
Encrypted JSON containing the entry index:
```json
{
"entries": {
"a1b2c3d4": {"name": "GitHub", "url": "https://github.com/login", "username": "alee", "updated_at": "2026-04-11T22:30:00Z"},
"e5f6a7b8": {"name": "Netflix", "url": "https://netflix.com", "username": "family@email.com", "updated_at": "2026-04-10T10:00:00Z"}
},
"version": 1
}
```
Decrypting only the manifest is sufficient for listing/searching entries without decrypting every entry file.
### Entry files (entries/<id>.enc)
Each encrypted entry contains:
```json
{
"name": "GitHub",
"url": "https://github.com/login",
"username": "alee",
"password": "generated-random-password",
"notes": "2FA enabled, backup codes in safe",
"totp_secret": "JBSWY3DPEHPK3PXP",
"created_at": "2026-04-11T22:30:00Z",
"updated_at": "2026-04-11T22:30:00Z"
}
```
Flat schema. No nested objects, no folders, no tags for V1. Entry IDs are random 8-character hex strings (32 bits — sufficient for family vault sizes).
### Plaintext metadata
Stored in `.relicario/` and committed to the repo:
- `salt`: 32 random bytes, generated once at vault creation
- `params.json`: Argon2id tuning knobs (memory, iterations, parallelism, format version)
- `devices.json`: list of authorized device ed25519 public keys, used to verify commit signatures
### Git history
Preserved as-is. Every add/edit/rm is a commit. Provides "when was this password last rotated" for free. No history rewriting or squashing.
### What never leaves the client
- Passphrase
- Reference image / image_secret
- master_key (derived in memory, never persisted to disk)
## Crate Layout
```
relicario/
├── Cargo.toml # workspace root
├── crates/
│ ├── relicario-core/ # library: imgsecret, KDF, vault format
│ │ └── src/
│ │ ├── lib.rs
│ │ ├── imgsecret.rs
│ │ ├── kdf.rs
│ │ ├── vault.rs
│ │ └── entry.rs
│ ├── relicario-cli/ # binary: the `relicario` CLI
│ │ └── src/
│ │ └── main.rs
│ └── relicario-wasm/ # wasm-bindgen wrapper around core
│ └── src/
│ └── lib.rs
├── extension/ # TypeScript Chrome MV3 extension
│ ├── src/
│ ├── manifest.json
│ └── package.json
├── docs/
│ └── spec/ # vault format spec + test vectors
└── README.md
```
### Design principles
- **`relicario-core` is platform-agnostic.** No filesystem, no git, no network. Takes bytes, returns bytes. This makes it trivially portable to WASM, Android (via JNI), iOS (via Swift bridge).
- **`relicario-cli`** is the platform layer. Handles filesystem, git operations (shells out to `git`), clipboard, terminal I/O.
- **`relicario-wasm`** is a thin wasm-bindgen wrapper exposing core functions to JavaScript.
- **`extension/`** is TypeScript/MV3. Loads the WASM module, runs crypto inline (no native messaging bridge).
### Rust crate dependencies (expected)
**relicario-core:**
- `argon2` — Argon2id KDF
- `chacha20poly1305` — XChaCha20-Poly1305 AEAD
- `sha2` — SHA-256 for hashing
- `rand` — CSPRNG for nonces, salts, entry IDs, image_secret generation
- `image` — JPEG decode/encode
- `rustdct` — DCT computation (or inline 8x8 implementation)
- `reed-solomon-erasure` — RS error correction for imgsecret
- `serde`, `serde_json` — entry/manifest serialization
- `ed25519-dalek` — device key signing (used by CLI, exposed via core)
- `thiserror` — error types
**relicario-cli:**
- `clap` (derive) — argument parsing
- `anyhow` — CLI error handling
- `rpassword` — passphrase prompt without echo
- `arboard` or `cli-clipboard` — clipboard access
- `dirs` — platform config/data directories
**relicario-wasm:**
- `wasm-bindgen` — JS interop
- `js-sys`, `web-sys` — browser APIs
## CLI Commands
```
relicario init # Create vault: generate salt, prompt for passphrase,
# prompt for carrier image, embed image_secret,
# output reference JPEG, git init + first commit
relicario add # Prompt for entry fields, encrypt, commit
relicario get <name> # Case-insensitive substring match on name/URL, decrypt, copy password to clipboard (30s TTL)
relicario list # Decrypt manifest, print entry names/URLs
relicario edit <name> # Decrypt entry, prompt for changes, re-encrypt, commit
relicario rm <name> # Remove entry file, update manifest, commit
relicario sync # git pull --rebase && git push
relicario generate # Generate a random password (utility, no vault interaction)
relicario device add # Generate ed25519 keypair, add pubkey to devices.json, commit
relicario device list # List authorized devices
relicario device revoke <name> # Remove device from devices.json, commit
```
Unlock flow: on any command that needs the vault, the CLI prompts for the passphrase and the reference image path (or uses a configured default path). Derives master_key, holds it in memory for the duration of the command, then drops it. No persistent daemon for V1 — each invocation re-derives.
Future: `relicario unlock` could spawn a background agent (ssh-agent-style) that holds the key for a configurable TTL, so subsequent commands don't re-prompt.
## Chrome Extension Architecture
The Chrome MV3 extension loads `relicario-wasm` directly — no native messaging bridge.
- **Service worker:** initializes the WASM module, holds the master_key in memory after unlock, handles vault operations
- **Popup:** passphrase prompt, entry list/search, entry detail view
- **Content script:** detects login forms, communicates with service worker for autofill
The master_key lives in the service worker's memory. Chrome may terminate idle service workers (MV3 behavior), which would require re-unlock. This is acceptable — it's a natural session timeout.
Reference image: stored in extension local storage (chrome.storage.local) after first setup. The image bytes never leave the device.
Extension design details (popup UI, content script heuristics, autofill flow) are deferred to implementation planning.
## Recovery (Post-V1)
Not in V1 scope. Planned approach:
- `relicario export-recovery` generates a small encrypted file containing only the `image_secret` (32 bytes + metadata), locked with the passphrase alone (separate Argon2id derivation)
- User stores this file offline (USB drive, printed QR, safe deposit box)
- Recovery: `relicario recover --file recovery.enc` + passphrase → recovers image_secret → can decrypt vault from git
- This is a second backup path alongside the "dead drop" reference JPEG (which can live on social media, personal website, etc.)
## Post-V1 Ideas
- **Secure notes:** free-form encrypted text entries (no URL/username/password schema, just a title + body). Same encryption, same repo layout — just a different entry type field.
- **Secure document storage:** encrypted file attachments up to 5-10 MB per entry. Stored as separate `.enc` blobs in an `attachments/` directory, referenced by entry ID. Git handles large binary blobs tolerably at this scale; git-lfs is an option if vaults grow beyond ~100 MB total.
- **`relicario unlock` daemon:** ssh-agent-style background process that holds master_key for a configurable TTL, so repeated CLI commands don't re-prompt for passphrase.
- **Mobile clients (Android/iOS):** Rust core compiles to ARM. Thin native wrappers (Kotlin/Swift) deferred.
- **Import from LastPass/Bitwarden/1Password**
- **Firefox/Safari extensions**
- **TOTP code generation in the extension** (codes stored in V1, auto-generation deferred)
- **Password strength auditing / breach checking**
- **Shared vaults / multi-user access control** (V1: single vault, shared passphrase + image for family)
## Non-Goals for V1
- Mobile clients
- Multi-user access control
- Browser extensions beyond Chrome
- Import/export from other password managers (except the recovery export)
- Password strength auditing / breach checking