diff --git a/docs/superpowers/specs/2026-04-27-relicario-import-export-design.md b/docs/superpowers/specs/2026-04-27-relicario-import-export-design.md new file mode 100644 index 0000000..d87d00e --- /dev/null +++ b/docs/superpowers/specs/2026-04-27-relicario-import-export-design.md @@ -0,0 +1,370 @@ +# relicario import / export — design + +Date: 2026-04-27 +Status: design (not yet implemented) +Scope: backup / restore (round-trippable to relicario itself) + LastPass CSV import. Migration **out** to other tools is explicitly out of scope. + +## Motivation + +Self-hosting a password vault without a backup story is unacceptable for production use. Today, a relicario user has no way to: +1. **Snapshot** their vault for disaster recovery (git remote going away, repo corruption, account loss). +2. **Onboard** from an existing manager — there's no migration path for a user with credentials in another tool. + +This design adds both, with parity across CLI and the fullscreen vault tab in the browser extension. The popup UI is unchanged (these are heavyweight workflows that don't fit the popup). + +## Decisions + +The following choices were brainstormed and approved before this spec was written. They are stated as decisions, not options. + +| # | Decision | +|---|---| +| D1 | Two features, one spec: backup/restore round-trippable to relicario, plus a LastPass CSV importer. Migration out is out of scope. | +| D2 | Backup file format: single-file `.relbak` container. Magic header + version + salt + nonce + AEAD-encrypted, zstd-compressed JSON envelope with base64'd binary blobs. | +| D3 | AEAD: XChaCha20-Poly1305 (same primitive used for vault items, but the backup format uses its own envelope with magic header + version byte; it does **not** reuse the `crypto.rs` `encrypt`/`decrypt` helpers, which assume the vault-master-key format). KDF: Argon2id with the same parameters as v1 of the live vault (m=64MiB, t=3, p=4) — but the params are tied to **backup format version**, not read from the vault's `params.json`. | +| D4 | Backup passphrase is independent of the vault passphrase. User picks one at export; user types it at restore. Reusing the vault passphrase is allowed but not auto-filled. | +| D5 | Reference image inclusion is optional. `--include-image` flag (CLI) / checkbox (UI). When included, the image is base64'd into the encrypted envelope — never in the clear inside the file. | +| D6 | Git history (`.git/`) is included **by default**. `--no-history` opt-out for users who want a smaller file at the cost of audit trail and remote URL. | +| D7 | Restore semantics: refuse if the target directory already contains a relicario vault. Restore is a fresh round-trip operation, not a merge. | +| D8 | Backup passphrase strength: zxcvbn score ≥ 3, same gate as `init`. Backup is single-factor (one passphrase decrypts the container), so it must be at least as strong as a vault factor. | +| D9 | The user is responsible for deleting the backup file after restore is verified. The encryption protects it in transit / at rest while it exists; it is not a defense against forensic recovery of deleted copies. Documented in CLI help text and the extension UI. | +| D10 | LastPass import: parse the standard LastPass CSV (`url,username,password,totp,extra,name,grouping,fav`). Logins → `Login` items (with embedded TOTP if present); rows with `url == http://sn` → `SecureNote`; structured LastPass notes (cards, SSH keys, addresses) are **not** auto-parsed — they fall through as `SecureNote` with `extra` as the body. | +| D11 | Failed CSV rows are skipped with a warning; the import continues. CLI exits 0 if at least one item was imported. | +| D12 | Imported items always create new IDs, even if the `name` collides with an existing item. Relicario does not enforce title uniqueness; collisions are harmless. | +| D13 | An import is committed in **one** git commit covering all newly written items + the manifest. Mid-import crashes leave orphan item files (no manifest reference); safe to retry. | +| D14 | UI placement: CLI commands + fullscreen vault tab UI (`vault.html`) only. Popup is not touched. | + +## Architecture + +Three new modules. The bulk of the logic lives in `relicario-core` so CLI and extension share it. + +### `relicario-core` (new code, ~250 LOC + tests) + +- **`backup.rs`** — `pack_backup(...)` and `unpack_backup(...)`. Pure, bytes-in / bytes-out (no filesystem). Owns the JSON envelope schema, zstd compression, AEAD encryption, magic header, format-version handling. +- **`import_lastpass.rs`** — `parse_lastpass_csv(bytes) -> Result<(Vec, Vec)>`. Pure: takes CSV bytes, returns relicario `Item`s with freshly-minted IDs. Failed rows → `ImportWarning` entries alongside the items. + +### `relicario-cli` (new commands) + +- `relicario export [--include-image] [--image ] [--no-history]` + - Reads vault root → packs → encrypts (prompts for backup passphrase, with confirmation + zxcvbn gate) → writes file with `atomic_write`. + - Does **not** require vault unlock. The backup container key is independent. +- `relicario restore []` + - `target_dir` defaults to current directory. + - Refuses if `target_dir/.relicario` exists. + - Prompts for backup passphrase → decrypts → unpacks → writes vault layout into target → if `.git/` was bundled, untar; otherwise `git init` + initial commit `"restore from backup "`. + - User then unlocks normally with vault passphrase + reference image. +- `relicario import lastpass ` + - Requires unlock. + - Parses CSV → encrypts each `Item` under master key → writes `items/.enc` files → updates manifest in-memory → saves manifest last (the single commit point) → one git commit. + - Prints summary; exits 0 on partial success. + +### `relicario-wasm` (new exports) + +- `pack_backup_json(vault_state_json: &str, passphrase: &str) -> Vec` — thin wrapper around `core::pack_backup`. Takes a JSON description of vault state (the SW assembles it from chrome.storage / git fetches), returns the `.relbak` bytes. +- `unpack_backup_json(bytes: &[u8], passphrase: &str) -> String` — JSON-encoded inverse. +- `parse_lastpass_csv_json(csv_bytes: &[u8]) -> String` — JSON-encoded `(items, warnings)` tuple, ready for the SW to iterate via existing `add_item` calls. + +### Extension (vault tab `vault.html` only — popup unchanged) + +- New "Backup & restore" panel under settings (vault tab): + - **Export backup** — passphrase modal (with zxcvbn meter) → `chrome.downloads.download(blobUrl, "relicario-backup.relbak")`. + - **Restore from backup** — file picker → passphrase modal → confirms target is empty → restores via SW. +- New "Import" panel: + - File picker for LastPass CSV → SW parses → preview ("142 logins, 17 notes, 3 skipped — proceed?") → bulk-add via SW. + - Progress bar + inline warnings list. + +## File format: `.relbak` v1 + +``` +Offset Length Field +─────── ──────── ──────────────────────────────────────────────────────── +0 4 Magic: ASCII "RBAK" +4 1 Format version: 0x01 +5 32 Argon2id salt (random per export, 32 bytes) +37 24 XChaCha20-Poly1305 nonce (random per export, 24 bytes) +61 ... AEAD ciphertext + 16-byte Poly1305 tag + + ┌── after AEAD decryption ──┐ + ▼ ▼ + zstd-compressed bytes + │ + ▼ + JSON document (UTF-8): + { + "schema_version": 1, + "created_at": , + "vault": { + "salt": "", + "params": { ... contents of .relicario/params.json verbatim ... }, + "devices": [ ... contents of .relicario/devices.json verbatim ... ], + "manifest": "", + "settings": "", + "items": { "": ".enc>", ... }, + "attachments": { "/": "", ... }, + "reference_jpg": "", // present iff --include-image + "git_archive": "" // present iff !--no-history + } + } +``` + +KDF parameters for v1 (hard-coded, NOT read from `params.json`): +- Algorithm: Argon2id +- Memory: 64 MiB +- Iterations: 3 +- Parallelism: 4 +- Output length: 32 bytes + +Future format v2 may change these; v1 readers will see `version != 0x01` and produce a clear "newer version" error. + +## Data flow + +### Export + +``` +1. Read from disk (no vault unlock needed): + .relicario/salt, params.json, devices.json + manifest.enc, settings.enc + items/*.enc + attachments//*.enc + (optional) reference image -- via --include-image: from RELICARIO_IMAGE env, or --image + (optional) tarred .git/ -- default-on; --no-history to skip + +2. Build JSON envelope per the schema above. Binary fields → base64 (using + `data_encoding::BASE64` which already lives in the workspace). + +3. zstd-compress the JSON document (level 3 — the speed/size sweet spot). + +4. Prompt for backup passphrase (twice to confirm). Run zxcvbn gate; reject score < 3. + +5. Generate fresh salt (32B) + nonce (24B) from `OsRng`. + +6. Argon2id(passphrase, salt, v1-fixed params) → 32-byte key. + +7. XChaCha20-Poly1305(key, nonce, compressed_bytes) → ciphertext. + +8. atomic_write the file: + [magic "RBAK"][version 0x01][salt 32B][nonce 24B][ciphertext] + +9. Print: "Wrote backup.relbak (N MiB). Delete after restore is verified." +``` + +### Restore + +``` +1. target_dir = arg or current dir. Refuse if target_dir/.relicario exists. + +2. Read file. Verify magic (4 bytes "RBAK") and version (must be 0x01). + Read salt (32B), nonce (24B), ciphertext (rest). + +3. Prompt for backup passphrase. + +4. Argon2id(passphrase, salt, v1-fixed params) → 32B key. + +5. XChaCha20-Poly1305 decrypt → zstd decompress → parse JSON. + Bad passphrase / tampered file → AEAD authentication failure; + surface as "wrong backup passphrase, or the file is corrupt" + (deliberately ambiguous, like vault unlock). + +6. Validate envelope.schema_version == 1. + +7. Write into target_dir: + .relicario/salt + .relicario/params.json + .relicario/devices.json + manifest.enc + settings.enc + items/.enc for each + attachments//.enc for each + (if present) reference.jpg in target_dir root + (if present) untar git_archive into target_dir/.git + +8. If git_archive was NOT in the envelope: + git init + git add . + git -c hooks=disabled commit -m "restore from backup " + +9. Print: "Restored vault to . Unlock with your passphrase + reference image." +``` + +### Import LastPass + +``` +1. Vault.unlock_interactive() — need master key to encrypt new items. + +2. Read CSV bytes from filesystem (CLI) or File API (extension). + +3. core::parse_lastpass_csv(bytes) → (Vec, Vec) + Each Item already has: + - fresh ItemId (random 8-char hex per existing convention) + - title from `name` + - group from `grouping` (None if empty) + - favorite from `fav == "1"` + - core mapped per the table below + +4. Encrypt each item under master key. Write items/.enc. + Update manifest in-memory: manifest.upsert(&item). + +5. Save manifest.enc (atomic_write — this is the single commit point). + +6. ONE git commit covering all new items/*.enc + manifest.enc: + "import: items from LastPass ()" + +7. Print summary: + "Imported , skipped (see warnings above)" + Exit 0 if N > 0, else 1. +``` + +## LastPass field mapping + +| LastPass column | relicario destination | Notes | +|---|---|---| +| `name` | `Item.title` | Required; row skipped with warning if missing | +| `grouping` | `Item.group` | `None` if empty | +| `fav` | `Item.favorite` | `"1"` → `true`, anything else → `false` | +| `url` | `LoginCore.url` (parsed) | The literal value `"http://sn"` is LastPass's secure-note marker — when seen, the **row** is mapped to `SecureNote` (not Login) and the URL field is not stored. For ordinary login rows, an invalid URL is imported as `url = None` with a warning. | +| `username` | `LoginCore.username` | `None` if empty | +| `password` | `LoginCore.password` | Required for `Login` rows; missing → row skipped | +| `totp` | `LoginCore.totp` | If non-empty: base32-decode; build `TotpConfig { secret, algorithm: Sha1, digits: 6, period_seconds: 30, kind: Totp }`. Bad base32 → warning, login imported without TOTP. | +| `extra` (when `url != http://sn`) | `Item.notes` | Multi-line preserved | +| `extra` (when `url == http://sn`) | `SecureNoteCore.body` | Verbatim, even when LastPass packed structured data into it | + +Items where every required field for a `Login` is present and `url != http://sn` map to `ItemCore::Login`. Otherwise, if `url == http://sn`, map to `ItemCore::SecureNote`. Otherwise, the row is skipped with a warning explaining why. + +## Error handling + +### Export + +| Error | Detection | User-facing message | Recovery | +|---|---|---|---| +| Not in a vault | `vault_dir()` fails | `"no .relicario/ found"` | `cd` to vault root | +| Missing reference image | `fs::read` of `--image` path fails | `"cannot read reference image: "` | Fix path or drop `--include-image` | +| Backup passphrase too weak | zxcvbn score < 3 | `"backup passphrase too weak (score N): "` | Choose a longer/more-entropic phrase | +| Disk full / permission denied | `atomic_write` returns `io::Error` | propagated `io::Error` with file path | Free space / fix permissions | + +Atomicity: output uses the existing `atomic_write` helper (write `.tmp` → rename). Partial output files are never visible. + +### Restore + +| Error | Detection | User-facing message | Recovery | +|---|---|---|---| +| Bad magic | First 4 bytes ≠ `"RBAK"` | `"not a relicario backup file"` | Verify file | +| Unsupported version | Version byte > current (1) | `"backup created by a newer relicario; upgrade required"` | Update binary | +| Wrong backup passphrase | AEAD authentication fails | `"wrong backup passphrase, or the file is corrupt"` (deliberately ambiguous) | Retry | +| Target dir already has a vault | `target/.relicario/` exists | `"target dir already contains a relicario vault; restore refuses to overwrite — use an empty directory"` | Choose empty dir | +| Schema mismatch | envelope.schema_version != current | `"backup is schema v; this relicario reads v"` | Use matching binary | +| Mid-restore crash | (no detection) | — | User deletes target dir, retries | + +Atomicity: best-effort. If interrupted mid-write, target dir has partial files — user cleans up and retries. Documented limitation. Restore is rare enough that engineering atomic-rename of multiple files is not worth the complexity. + +### Import LastPass + +| Error | Detection | User-facing message | Recovery | +|---|---|---|---| +| CSV header missing/malformed | First-line parse fails | `"unrecognized CSV header — expected LastPass export format"` | Re-export from LastPass | +| Row missing required field | Per-row validation | Logged warning: `"row N: missing 'name' — skipped"` | Row skipped; no manual recovery | +| Bad base32 TOTP | base32 decode fails | Logged warning: `"row N (): invalid TOTP secret — login imported without TOTP"` | Login imported sans TOTP | +| Vault locked | Pre-flight unlock | `"unlock failed"` | Retry passphrase | +| Mid-import crash | (no detection) | — | Items written before crash are orphan files (no manifest reference); safe to retry — will create new IDs, possibly duplicating | + +Atomicity: manifest is the single source of truth and is written **last**, with `atomic_write`. Item files written before the manifest are referenced only after the manifest commits. Orphans don't pollute the vault — they're invisible until the user runs a future "vault gc" sweep (out of scope here). + +### Progress feedback + +- **CLI**: stderr line every 50 items: `"[150/1247] importing..."`. Final summary on success: `"Imported 1244, skipped 3 (see warnings above)"`. Non-zero exit only if zero items imported. +- **Extension (vault tab)**: progress bar with same denominator. Inline warnings list. Final toast. + +## Testing strategy + +### Core tests (`crates/relicario-core/tests/`) + +Pure logic, no IO. New files: + +- `backup.rs` + - Pack → unpack round-trip preserves bytes for empty vault, vault-with-attachments, vault-with-git-history. + - Wrong passphrase → AEAD auth error (use `RelicarioError::AuthenticationFailed` or equivalent). + - Tampered ciphertext / magic / version → format error variants. + - `--include-image` round-trips the JPEG; absence honored. + - `--no-history` produces a strict subset (no `git_archive` in envelope). +- `import_lastpass.rs` + - Standard login row → `Login` with all fields populated. + - `url == http://sn` → `SecureNote`. + - TOTP base32 → embedded `TotpConfig`. + - Bad base32 → warning, login imported without TOTP. + - Missing `name` / `password` → row skipped + warning. + - Quoted-comma, multi-line `extra`, unicode all parse cleanly. + - `grouping`, `fav`, `name` pass through to `Item`. + +Tests use fast Argon2id params (m=256, t=1, p=1) per the existing convention. + +### CLI integration tests (`crates/relicario-cli/tests/`) + +End-to-end with the existing `TestVault` harness. New files: + +- `backup.rs` + - `init` → add 3 items → `export` → fresh-dir `restore` → `unlock` → `list` shows the same 3 items. + - Restore refuses non-empty target with the documented error. + - Wrong backup passphrase fails on restore. + - `--include-image` carries the reference image; restored vault unlocks without separate `--image` arg. + - `--no-history` produces a smaller file; restored vault has only the `"restore from backup"` commit. +- `import_lastpass.rs` + - Fixture CSV → `import lastpass` → `list` shows the imported items. + - Single git commit covers all imports (verify via `git log --oneline`). + - Skipped rows produce warnings on stderr; CLI exits 0 if any item imported. + - Title collision with existing item → both kept (decision D12). + +### Extension tests (vitest, mocked WASM/SW) + +- `extension/src/vault/__tests__/backup-panel.test.ts` — renders Export / Restore / Import buttons; click → right SW message. +- Extend `extension/src/service-worker/router/__tests__/router.test.ts` with `export_backup`, `restore_backup`, `import_lastpass` cases — sender = vault tab, popup is rejected. +- `extension/src/service-worker/__tests__/backup.test.ts` — SW handler calls `pack_backup_json`, returns Blob bytes for download. +- Mocked WASM returns deterministic envelopes; assertions on payload structure. + +### Fixtures + +- `crates/relicario-cli/tests/fixtures/lastpass-sample.csv` — ~15 synthesized rows, no real credentials. Coverage: + - Standard login + - Login with TOTP + - Login with embedded URL TOTP that decodes correctly + - Login with bad base32 TOTP (warning case) + - SecureNote (`url == http://sn`) + - Grouped item + - Favorite item + - Malformed row (missing `name`) + - Unicode title (covers UTF-8 handling) + - Multi-line `extra` (quoted, embedded newlines) +- Backup fixtures are generated per-test via `setup()`; not committed. + +## Out of scope / future work + +- **Migration out** to other tools' formats (1Password 1pux, Bitwarden JSON, KeePass kdbx, generic CSV). Could be added later if users ask. +- **Other importers**: 1Password, Bitwarden, Chrome, Firefox. LastPass-only for now; plan is to add one importer per concrete user need rather than speculating. +- **Vault GC sweep**: orphan-file detection (items on disk without a manifest entry, attachments without an item). Useful after interrupted imports, but a separate feature. +- **Merge restore**: restoring a backup INTO an existing vault (rather than refusing). Conceptually overlaps with the future "sharing" feature; deferring decision. +- **Backup encryption with the vault factor**: requiring passphrase + reference image to unlock the backup, mirroring the live vault's 2FA. Conceptually possible but adds complexity, was rejected in brainstorming in favor of the standalone backup-passphrase model. +- **Cloud-backed automatic backups**: scheduled backups to Dropbox/S3/etc. Out of scope; users can wrap `relicario export` in cron. + +## Appendix A: estimated effort + +| Component | LOC est. | Days | +|---|---|---| +| `core::backup` (pack/unpack + format) | ~150 | 1 | +| `core::import_lastpass` (parser + mapping) | ~120 | 0.5 | +| Core tests | ~250 | 0.5 | +| CLI commands (export, restore, import lastpass) | ~200 | 0.5 | +| CLI integration tests + fixtures | ~200 | 0.5 | +| WASM bindings (3 new exports) | ~50 | 0.25 | +| SW handlers (export, restore, import) | ~150 | 0.5 | +| Vault tab UI (Backup & restore panel + Import panel) | ~400 | 1 | +| Vitest tests | ~200 | 0.5 | +| Documentation (CHANGELOG, CLI help, UI copy) | — | 0.25 | + +**Total: ~5.5 dev-days end-to-end** for full CLI + extension parity. The estimate is a guideline, not a commitment. + +## Appendix B: risks + +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| LastPass changes their CSV format mid-stream | low | medium | Pin to today's column order; document expected header; surface a clear error on header mismatch so users don't silently get garbage | +| Backup files end up large (with `.git/`) | medium | low | `--no-history` opt-out; document trade-off in CLI help | +| User loses backup passphrase | medium | catastrophic | Document explicitly in CLI help and UI: "the backup passphrase cannot be recovered. If you lose it, the backup is unreadable." | +| zstd / Argon2id WASM bundle size | low | low | Both are already in our dep tree (Argon2id) or small (zstd ~100KB). Verify total wasm bundle stays under 4 MiB. | +| Cross-platform path / line-ending issues in `.git/` tar | low | medium | Use `tar` crate's portable defaults; test round-trip on linux + mac in CI if available |