Files
relicario/docs/superpowers/specs/2026-04-27-relicario-import-export-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

21 KiB

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://snSecureNote; 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.rspack_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.rsparse_lastpass_csv(bytes) -> Result<(Vec<Item>, Vec<ImportWarning>)>. Pure: takes CSV bytes, returns relicario Items with freshly-minted IDs. Failed rows → ImportWarning entries alongside the items.

relicario-cli (new commands)

  • relicario export <out.relbak> [--include-image] [--image <path>] [--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 <in.relbak> [<target_dir>]
    • 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 <utc-timestamp>".
    • User then unlocks normally with vault passphrase + reference image.
  • relicario import lastpass <csv>
    • Requires unlock.
    • Parses CSV → encrypts each Item under master key → writes items/<id>.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<u8> — 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": <unix-seconds>,
           "vault": {
             "salt":          "<base64 of .relicario/salt>",
             "params":        { ... contents of .relicario/params.json verbatim ... },
             "devices":       [ ... contents of .relicario/devices.json verbatim ... ],
             "manifest":      "<base64 of manifest.enc>",
             "settings":      "<base64 of settings.enc>",
             "items":         { "<item-id-hex>": "<base64 of items/<id>.enc>", ... },
             "attachments":   { "<item-id>/<aid>": "<base64 of attachment blob>", ... },
             "reference_jpg": "<base64>",        // present iff --include-image
             "git_archive":   "<base64 of tarred .git/>"   // 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/<item>/*.enc
     (optional) reference image  -- via --include-image: from RELICARIO_IMAGE env, or --image <path>
     (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/<id>.enc for each
     attachments/<item>/<aid>.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 <iso8601-utc>"

9. Print: "Restored vault to <target>. 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<Item>, Vec<ImportWarning>)
   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/<id>.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: <N> items from LastPass (<csv-filename>)"

7. Print summary:
     "Imported <N>, skipped <K> (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: <path>" Fix path or drop --include-image
Backup passphrase too weak zxcvbn score < 3 "backup passphrase too weak (score N): <feedback>" 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<N>; this Relicario reads v<M>" 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 (<title>): 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://snSecureNote.
    • 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 restoreunlocklist 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 lastpasslist 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