diff --git a/crates/relicario-cli/ARCHITECTURE.md b/crates/relicario-cli/ARCHITECTURE.md index 4d4ac29..c3662a6 100644 --- a/crates/relicario-cli/ARCHITECTURE.md +++ b/crates/relicario-cli/ARCHITECTURE.md @@ -16,22 +16,46 @@ locally, and lets recovery debugging happen with familiar tooling. ## Module map -The crate is three files of source and a `tests/` directory. Each source file -has one job. +`src/main.rs` is now a thin clap-surface + dispatcher; per-command logic lives +under `src/commands/`. Each source file has one job. -- **`src/main.rs`** (`main.rs:1-1719`) — clap surface plus every command - handler. Internal structure: a top-level `Cli` / `Commands` enum - (`main.rs:13-275`), a flat dispatcher `match` in `main()` - (`main.rs:277-303`), per-command handlers named `cmd_`, and a layer of - per-type item helpers (`build__item` for `cmd_add`, `edit_` for - `cmd_edit`). The per-type split is recent: commit `3f0f5b1` extracted - ~217-line `match` arms in `cmd_add` and `cmd_edit` into focused functions, - one per `ItemCore` variant, so each builder/editor reads top-to-bottom and - can be tested through the same integration paths. Owns all clap argument - parsing, all interactive prompts (`prompt`, `prompt_optional`, `prompt_keep`, - `prompt_keep_opt`, `prompt_yesno`, `prompt_secret`), and the shared - `commit_paths` helper that is the single chokepoint for git commits during - vault mutations. +- **`src/main.rs`** (`main.rs:1-492`) — clap surface and the flat dispatcher. + Owns the top-level `Cli` / `Commands` enum and every subcommand enum + (`AddKind`, `TrashAction`, `SettingsAction`, `BackupAction`, `ImportAction`, + `DeviceAction`, `RecoveryQrCmd`). `main()` is a single `match` that + delegates each variant to `commands::::cmd_(...)`. Also owns the + three test-only env-var hooks (`test_passphrase_override`, + `test_item_secret_override`, `test_backup_passphrase_override`) — each is + stripped from release builds via `#[cfg(debug_assertions)]`. + +- **`src/commands/`** — one module per top-level command. `mod.rs` re-exports + the public surface and hosts the shared `commit_paths` helper (the single + chokepoint for git commits during vault mutations) plus other cross-command + glue. Per-command modules: `init`, `add`, `get`, `list` (also hosts + `cmd_history`), `edit`, `trash` (rm / restore / purge / trash empty), + `backup` (export / restore), `import` (lastpass), `attach` (attach / + attachments / extract / detach), `generate`, `settings`, `sync`, `status`, + `rate`, `device`, `recovery_qr`. `add` and `edit` each fan out internally to + per-`ItemCore` helpers (`build__item`, `edit_`) so each + builder/editor reads top-to-bottom and can be tested through the same + integration paths. + +- **`src/prompt.rs`** — interactive prompt primitives shared across commands: + `prompt`, `prompt_optional`, `prompt_keep`, `prompt_keep_opt`, + `prompt_yesno`, `prompt_secret`. `prompt_secret` honours + `RELICARIO_TEST_ITEM_SECRET` before falling back to `rpassword`. + +- **`src/parse.rs`** — pure parsers for CLI-typed inputs (e.g. MonthYear + expiries, TOTP `otpauth://` URIs, comma-separated tag lists). No I/O. + +- **`src/device.rs`** — device-management plumbing called by + `commands::device`: ed25519 keypair generation via `relicario-core::device`, + on-disk layout under `/relicario/devices//`, and the + read/write of `.relicario/devices.json` / `revoked.json`. + +- **`src/gitea.rs`** — minimal Gitea REST client used by `commands::device add` + / `revoke` to register and remove deploy keys. Reads + `RELICARIO_GITEA_{URL,TOKEN,OWNER,REPO}` env vars (overridable via CLI flags). - **`src/session.rs`** (`session.rs:1-152`) — `UnlockedVault` lifecycle. Holds the derived master key in `Zeroizing<[u8; 32]>` for one CLI invocation; the @@ -306,13 +330,65 @@ rewrite `devices.json`, commit `device: revoke `. Note that device keys are kept entirely separate from the KDF (passphrase × image stays unchanged across device add/revoke), as per the design spec. -### Backup-passphrase-style commands (none yet) +### Backup (`commands::backup`, `commands/backup.rs`) -The import / export / `import-lastpass` commands described in -`docs/superpowers/specs/2026-04-27-relicario-import-export-design.md` are -not yet implemented. When they land they'll fit in the dispatcher -(`main.rs:279-302`) alongside `Sync` and `Status`. Don't add stubs here -until that work begins. +Two subcommands, both keyed by a *backup* passphrase that is independent of +the vault master passphrase. + +- **`backup export [--include-image] [--image PATH] [--no-history]`** — + reads the entire on-disk vault layout (`.relicario/{salt,params.json, + devices.json}`, `manifest.enc`, `settings.enc`, every `items/*.enc`, every + `attachments//.enc`), optionally bundles the reference JPEG and + the `.git/` directory (as an in-memory tar), and hands the lot to + `relicario_core::backup::pack_backup` with a zxcvbn-gated backup + passphrase prompted twice. The resulting `.relbak` is written via + `tmp` + rename. A `.relicario/last_backup` marker file (ISO-8601 line) is + also written so `cmd_status` can show "last backup at …". +- **`backup restore []`** — refuses to overwrite an existing + vault (`target/.relicario/` must not exist). Unpacks the `.relbak` via + `unpack_backup`, then materialises every byte into the target layout. The + bundled `.git/` tar is extracted via the hardened + `relicario_core::safe_unpack_git_archive` (path-traversal / symlink / + size-cap guards) with a cap of `min(100 × tar_size, 1 GiB)`; if no + history was bundled, the target gets a fresh `git init` + initial commit. + +### Import (`commands::import`, `commands/import.rs`) + +- **`import lastpass `** — reads the CSV, calls + `relicario_core::import_lastpass::parse_lastpass_csv`, then unlocks the + vault and writes every produced `Item` through `vault.save_item` + manifest + upsert. Failed rows surface as `ImportWarning`s on stderr and never abort + the import; only a missing or malformed header is fatal. Commit message: + `import: items from LastPass ()`. The dispatch shape + (`ImportAction` subcommand enum) is in place for future importers + (Bitwarden, 1Password, etc.) — each would add one `ImportAction` variant + and one helper. + +### Rate (`commands::rate`, `commands/rate.rs`) + +`rate ` runs `relicario_core::generators::rate_passphrase` +(zxcvbn-backed) and prints the 0–4 score, a human-readable label, and the +estimated guess count as `~10^N`. Reads one line from stdin when the +argument is `-`, which keeps the passphrase out of shell history. Purely +informational — does not unlock or mutate anything; the `init` command +calls `validate_passphrase_strength` directly and does not consult `rate`. + +### RecoveryQr (`commands::recovery_qr`, `commands/recovery_qr.rs`) + +Two subcommands wrapping `relicario_core::recovery_qr::{generate_recovery_qr, +unwrap_recovery_qr}`. + +- **`recovery-qr generate`** — re-extracts the 32-byte image_secret from the + reference JPEG (via `get_image_path` + `imgsecret::extract`), prompts for + the recovery passphrase (which may be the same as the vault passphrase or + different — domain-separated by core), produces the 109-byte sealed + payload, and renders it as a Unicode-block QR (EcLevel::M) directly to + stdout. The payload is **never written to disk** — the user is expected to + print or photograph it. +- **`recovery-qr unwrap`** — reads a base64-encoded payload from stdin, + prompts for the recovery passphrase, runs `unwrap_recovery_qr`, and prints + the recovered `image_secret` as hex. Useful for recovery dry-runs and for + reconstructing a lost reference image. ## Cross-cutting concerns diff --git a/crates/relicario-core/ARCHITECTURE.md b/crates/relicario-core/ARCHITECTURE.md index faef4bb..193c3de 100644 --- a/crates/relicario-core/ARCHITECTURE.md +++ b/crates/relicario-core/ARCHITECTURE.md @@ -101,6 +101,38 @@ Pipeline" and "Crate Layout"). auth factor. Owns its own `YChannel`, `EmbedRegion`, 8×8 DCT/IDCT, Quantization Index Modulation, and crop-recovery extractor. No other module imports it; it is consumed only via the public re-export from `lib.rs`. +- **`backup.rs`** — `.relbak` v1 container format: `pack_backup` / + `unpack_backup` plus the `BackupInput` / `BackupOutput` / `BackupItem` / + `BackupAttachment` shapes. Wraps a zstd-compressed JSON envelope of vault + bytes (salt, params.json, devices.json, manifest, settings, items, + attachments, optional reference JPEG, optional `.git/` tar) in an + XChaCha20-Poly1305 envelope keyed by Argon2id over a user-chosen *backup* + passphrase. The backup key is independent of any vault master key, and + Argon2id parameters are pinned to the v1 values (m=64MiB, t=3, p=4) so a v1 + reader doesn't need to negotiate them. +- **`import_lastpass.rs`** — `parse_lastpass_csv` plus `ImportWarning`. Pure + bytes-in / `Vec`-out LastPass CSV importer: validates the fixed + 8-column header, mints fresh IDs and timestamps for each row, downgrades or + skips malformed rows into `ImportWarning`s instead of aborting the import. + Only fatal error is a missing/malformed header. +- **`device.rs`** — Device-identity surface: `DeviceEntry`, `RevokedEntry`, + `generate_keypair`, `sign`, `verify`, `fingerprint`. ed25519 in OpenSSH + format (so private keys are interchangeable with `ssh-keygen`-produced + keys); the same module backs both `.relicario/devices.json` entries and the + server's pre-receive commit-verification hook. +- **`tar_safe.rs`** — `safe_unpack_git_archive` + `DEFAULT_MAX_UNCOMPRESSED` + (1 GiB). Hardened tar reader used by `backup::unpack_backup` for the + bundled `.git/` directory: rejects `..` components, absolute paths, Windows + drive prefixes, symlinks, hardlinks, and any entry whose declared size + (or running total across all entries) exceeds the supplied cap. +- **`recovery_qr.rs`** — `generate_recovery_qr` / `unwrap_recovery_qr` plus + `recovery_qr_to_svg`. Produces a 109-byte XChaCha20-Poly1305 envelope + around the 32-byte image_secret, keyed by Argon2id over a user-chosen + recovery passphrase with the domain-separation prefix + `b"relicario-recovery-v1\0"`. Parameters are pinned at module scope — + changing them invalidates every printed QR — and both salt and nonce are + freshly randomized per call so two QRs printed from the same inputs are + different bytes. ## Invariants & contracts @@ -386,11 +418,11 @@ when subsequent `decrypt_*` returns `RelicarioError::Decrypt`. `generators::bip39_passphrase`. A single `rand::thread_rng()` call exists inside an `imgsecret` test (`imgsecret.rs:1033`) to generate a random test secret; production code is `OsRng` only. -- **`ed25519-dalek` is a dependency placeholder.** Listed in - `Cargo.toml:17` but unused in `src/`. It exists for the future - device-key surface (`RelicarioError::DeviceKey` is the reserved variant, - `error.rs:84-88`); device-key signing currently happens in - `relicario-cli` instead. +- **`ed25519-dalek` is consumed by `device.rs`.** Together with `ssh-key` (for + OpenSSH wire encoding) it backs `generate_keypair`, `sign`, and `verify` — + the same primitives the CLI uses to populate `.relicario/devices.json` and + the server uses to verify pre-receive commit signatures. The corresponding + error variant is `RelicarioError::DeviceKey`. ## Test architecture