diff --git a/DESIGN.md b/DESIGN.md index ab6c4a5..a644b4c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -106,7 +106,7 @@ Every relicario vault — whether on disk for the CLI or in a git remote read by └── .enc # encrypted attachment blob; aid is content-addressed SHA-256 ``` -The reference image (`reference.jpg`) lives **outside** the vault by convention — it is the second factor and the user's responsibility to safeguard. It is not in `.relicario/`, not in `items/`, and never committed to git. +The second-factor container (either `reference.jpg` for the reference-image path or a `.relkey` file for the key-file path) lives **outside** the vault by convention — it is the second factor and the user's responsibility to safeguard. It is not in `.relicario/`, not in `items/`, and never committed to git. This layout is not formally versioned — the **content** within each `.enc` file carries its own version byte (see § Versioning below). The directory layout itself is conventional and changes would be breaking. @@ -142,8 +142,9 @@ The threat model differs by codebase. This is the per-secret per-codebase reside | Secret | relicario-core | relicario-cli | extension SW | extension popup/vault/content/setup | |---|---|---|---|---| | Passphrase (UTF-8 bytes) | `Zeroizing` only during a single `derive_master_key` call | Same, in `UnlockedVault::unlock_interactive` | Same, used briefly to derive master key inside WASM | Never seen — entered into a ``, sent to SW via `unlock` message, immediately forgotten | -| Reference image bytes | Held by caller; core only reads | Held by `UnlockedVault::unlock_interactive` long enough to extract the secret | Same | Setup wizard holds the bytes briefly during create/attach modes | -| Image secret (32 B) | `Zeroizing<[u8;32]>` during KDF | Same | Same | Never sees it | +| Reference image bytes (image-path vaults) | Held by caller; core only reads | Held by `UnlockedVault::unlock_interactive` long enough to call `imgsecret::extract()` | Same (stored as `imageBase64` in `chrome.storage.local` — in the clear, same posture as the key file below) | Setup wizard holds the bytes briefly during create/attach modes | +| Key file bytes (key-file-path vaults) | Held by caller; core only reads via `keyfile_decode()` | Held by `UnlockedVault::unlock_interactive` long enough to decode the secret | Stored as `keyfileBase64` in `chrome.storage.local` — in the clear, same posture as `imageBase64` above | Setup wizard holds the bytes briefly during create mode | +| Second-factor secret (32 B) — extracted from either container | `Zeroizing<[u8;32]>` during KDF | Same | Same | Never sees it | | Master key | `Zeroizing<[u8;32]>` returned by `derive_master_key` | `UnlockedVault.master_key` for the lifetime of one CLI invocation | WASM-side memory behind an opaque `SessionHandle`; JS never sees the bytes | Never sees it | | Item secret (password, card number, etc.) | `Zeroizing` / `Zeroizing>` | Same | Briefly held in WASM during `item_decrypt`; results passed to popup as plaintext for display | Held in DOM (the user is staring at it); cleared when view changes | | Device private key | — | Filesystem under `~/.config/relicario/devices/.key` (mode 0600) | `chrome.storage.local.device_private_key` | — | @@ -200,7 +201,7 @@ Core tests use **fast Argon2id params** (m=256, t=1, p=1) so they don't take for | AEAD ciphertext starts with version byte | `core/crypto.rs` | Format identification; reject v1 blobs cleanly | | Item IDs are random 16-char hex (64 bits) | `core/ids.rs` | Stable, short, no information leak | | Attachment IDs are content-addressed (first 32 hex chars / 128 bits of SHA-256) | `core/ids.rs` | Dedup; integrity check | -| KDF input is length-prefixed | `core/crypto.rs` | Prevents `passphrase || image_secret` collisions | +| KDF input is length-prefixed | `core/crypto.rs` | Prevents `passphrase || second_factor_secret` collisions | | Git history is preserved as audit log; never squash | CLI commits; SW commits | Per-action history is a feature | | Per-action git commits with structured messages | `cli` (via `commit_paths`); SW (via vault.ts helpers) | Greppable, useful as audit log | | Hardened git invocations (`-c core.hooksPath=/dev/null` etc.) | CLI's `helpers::git_command`; SW does not shell out | Prevent hostile hooks; no GPG prompt holding key alive | diff --git a/README.md b/README.md index 8686b01..b5ee5cb 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,11 @@ # Relicario -> **Audience:** users + evaluators. This doc owns the pitch, security-model summary, quick-start commands, reference-image explanation, recovery-QR overview, and roadmap teaser. Goes no deeper — for the system tour see [DESIGN.md](DESIGN.md), for crypto see [docs/CRYPTO.md](docs/CRYPTO.md). +> **Audience:** users + evaluators. This doc owns the pitch, security-model summary, quick-start commands, second-factor explanation (reference image / key file), recovery-QR overview, and roadmap teaser. Goes no deeper — for the system tour see [DESIGN.md](DESIGN.md), for crypto see [docs/CRYPTO.md](docs/CRYPTO.md). -A git-backed, self-hostable password manager where decryption requires two independent factors: a passphrase you memorize and a reference JPEG that carries a hidden secret. Compromise of either factor alone is insufficient. +A git-backed, self-hostable password manager. The security thesis: two independent secrets feed the Argon2id KDF, the vault server holds only opaque ciphertext, and every mutation is a git commit — a built-in audit log. Compromise of either factor alone is insufficient to decrypt the vault. + +The **second factor** (something you have) is pluggable: at vault creation you choose a **reference image** (a JPEG that carries a 32-byte secret hidden in its DCT coefficients via steganography) or a **key file** (the same 32-byte secret in a plain armored text file). Either container enters the KDF identically; the choice is how you store and transport it. The server only ever sees opaque ciphertext. There is nothing else going on. This README is the security proof. @@ -15,7 +17,7 @@ The server only ever sees opaque ciphertext. There is nothing else going on. Thi ``` Your passphrase (something you know) + -Your reference photo (something you have) +Your second factor — reference image or key file (something you have) | v [ Argon2id KDF ] --> master_key --> [ XChaCha20-Poly1305 ] --> encrypted vault @@ -25,9 +27,9 @@ Your reference photo (something you have) your device (opaque ciphertext) ``` -At vault creation, Relicario embeds a random 256-bit secret into a carrier JPEG using DCT steganography. This photo becomes your **reference image** — a second factor that lives on your devices (and optionally as a "dead drop" on social media, since it survives JPEG re-encoding and mild cropping). +At vault creation you choose a **second-factor container**. The default is a **reference image**: Relicario generates a random 32-byte secret and embeds it into a carrier JPEG using DCT steganography. The resulting photo lives on your devices — and optionally as a "dead drop" on social media, since the embedding survives JPEG re-encoding and mild cropping. The alternative is a **key file**: the same 32-byte secret written to a plain armored `.relkey` file, suited to users who prefer copying a file to a USB drive rather than distributing a photo. -To unlock the vault, you provide your passphrase and point the client at the reference image. The client extracts the hidden secret, concatenates it with your passphrase, and runs Argon2id to derive the master key. Everything else follows from there. +To unlock the vault, you provide your passphrase and your second-factor container (reference image or key file). The client extracts the 32-byte secret, concatenates it with your passphrase, and runs Argon2id to derive the master key. The KDF input is byte-for-byte identical regardless of which container you chose. Everything else follows from there. ## Security model @@ -42,16 +44,16 @@ A git repository containing: - `.relicario/params.json` — Argon2id parameters (not secret) - `.relicario/devices.json` — authorized device public keys -That's it. No plaintext. No metadata about what's inside. No keys, no passphrases, no reference images. +That's it. No plaintext. No metadata about what's inside. No keys, no passphrases, no second-factor containers (no reference images, no key files). ### What an attacker needs | Scenario | Has | Needs | Result | |---|---|---|---| -| Server breach | Encrypted vault + salt | Passphrase AND image secret | 256+ bits of entropy. Infeasible. | -| Server breach + stolen image | Vault + image secret | Passphrase | Passphrase entropy through Argon2id. 4 diceware words = ~7 million years. | -| Shoulder-surfed passphrase | Passphrase | Image secret | 256 bits. Infeasible. | -| Stolen device | Image + vault | Passphrase | Argon2id brute-force. Strong passphrase = safe. | +| Server breach | Encrypted vault + salt | Passphrase AND second-factor secret | 256+ bits of entropy. Infeasible. | +| Server breach + stolen second factor | Vault + second-factor secret | Passphrase | Passphrase entropy through Argon2id. 4 diceware words = ~7 million years. | +| Shoulder-surfed passphrase | Passphrase | Second-factor secret | 256 bits. Infeasible. | +| Stolen device (image or key file + vault) | Second factor + vault | Passphrase | Argon2id brute-force. Strong passphrase = safe. | No single point of failure. The two-factor design means the passphrase alone can't decrypt the vault, and the image alone can't decrypt the vault. @@ -62,12 +64,12 @@ No single point of failure. The two-factor design means the passphrase alone can | LastPass | ~40-60 bits (master password only) | 1 | | Bitwarden | ~40-60 bits (master password only) | 1 | | 1Password | password + 128-bit Secret Key | 2 | -| **Relicario** | **password + 256-bit image secret** | **2** | +| **Relicario** | **password + 256-bit second-factor secret (image or key file)** | **2** | ### What we don't protect against - A compromised device with active malware. No software password manager can. -- Weak passphrases with a stolen reference image. Use 4+ diceware words. +- Weak passphrases with a stolen second factor (reference image or key file). Use 4+ diceware words. - Rubber-hose cryptanalysis. ## Quick start @@ -76,8 +78,10 @@ No single point of failure. The two-factor design means the passphrase alone can # Build from source cargo build --release -# Create a vault (pick any JPEG as the carrier) +# Create a vault with a reference image (default — stego second factor) relicario init --image vacation.jpg --output reference.jpg +# Or use a plain key file as the second factor instead +relicario init --key-file /path/to/secret.relkey # Add a credential relicario add @@ -94,7 +98,7 @@ relicario sync # Pack the vault into a single encrypted backup file relicario backup export -o vault.relbak -# Print a recovery QR for your image_secret (see "Recovery" below) +# Print a recovery QR for your second-factor secret (see "Recovery" below) relicario recovery-qr generate # Generate a random password @@ -103,31 +107,35 @@ relicario generate -l 32 ### Environment variable -Set `RELICARIO_IMAGE=/path/to/reference.jpg` to avoid being prompted for the image path on every command. +Set `RELICARIO_IMAGE=/path/to/reference.jpg` to avoid being prompted for the reference-image path on every command. If you use a key file instead, set `RELICARIO_KEYFILE=/path/to/secret.relkey`. -## The reference image +## The second factor: reference image and key file -The reference JPEG is generated once during `relicario init`. It looks like a normal photo — because it is one. The 256-bit secret is embedded in the DCT coefficients of the luminance channel using Quantization Index Modulation, with heavy redundancy and Reed-Solomon-style majority voting across multiple copies. +Relicario's second factor is a 32-byte secret. The **container** for that secret is your choice at vault creation. -The embedding survives: +**Reference image (default):** The JPEG carrier is generated during `relicario init`. It looks like a normal photo — because it is one. The 32-byte secret is embedded in the DCT coefficients of the luminance channel using Quantization Index Modulation, with heavy redundancy and Reed-Solomon-style majority voting across multiple copies. + +The steganographic embedding survives: - JPEG recompression (tested down to quality 85) - Up to ~10-15% cropping from any edge - Social media re-encoding (Instagram, Discord, etc.) -This means your reference image can live on your Instagram, your personal website, or anywhere else. It's useless without your passphrase. +This means your reference image can live on your Instagram, your personal website, or anywhere else — a "dead drop" approach. It is useless without your passphrase; the stego embedding is its transport mechanism. -## Recovery: what if I lose my reference image? +**Key file (alternative):** A plain armored `.relkey` file holding the same 32-byte secret in base64 — no steganography. Suited to users who prefer copying a small file to a USB drive, a hardware security module, or an offline storage medium. Wire format: see [docs/FORMATS.md](docs/FORMATS.md). -Without your reference image, the vault is undecryptable — that's the security model. But it also makes a lost or corrupted image a single point of failure. +## Recovery: what if I lose my second factor? -The mitigation is the **recovery QR**: a printable QR code that wraps your image secret behind a separate recovery passphrase you choose. If you ever lose access to the reference JPEG, scan or transcribe the QR, provide the recovery passphrase, and recover the 256-bit image secret. Combined with your normal vault passphrase, this restores access to the vault. +Without your second-factor container (reference image or key file), the vault is undecryptable — that's the security model. But it also makes a lost or corrupted container a single point of failure. + +The mitigation is the **recovery QR**: a printable QR code that wraps the 32-byte second-factor secret behind a separate recovery passphrase you choose. It applies regardless of which container you chose at vault creation. If you ever lose access to your reference image or key file, scan or transcribe the QR, provide the recovery passphrase, and recover the second-factor secret. Combined with your normal vault passphrase, this restores access to the vault. ```bash # Print a recovery QR (after the vault is unlocked). # You'll be prompted for a separate recovery passphrase. relicario recovery-qr generate -# Recover the image_secret from a stored QR payload. +# Recover the second-factor secret from a stored QR payload. relicario recovery-qr unwrap ``` @@ -145,7 +153,7 @@ A short tour of the four codebases and how they fit together lives in [DESIGN.md | Primitive | Purpose | Why this one | |---|---|---| -| Argon2id (64 MiB, 3 iter, 4 parallel) | Key derivation from passphrase + image secret | Memory-hard, GPU-resistant, OWASP recommended | +| Argon2id (64 MiB, 3 iter, 4 parallel) | Key derivation from passphrase + second-factor secret | Memory-hard, GPU-resistant, OWASP recommended | | XChaCha20-Poly1305 | Authenticated encryption of vault entries | 192-bit nonce (no collision risk), fast in WASM/ARM without AES-NI | | ed25519 | Device key signing | Per-device commit authorization, revocable without KDF rotation | @@ -182,7 +190,7 @@ Item IDs are random 16-char hex strings (64 bits of entropy). Git history is pre Each device generates its own ed25519 keypair. The public key is stored in `.relicario/devices.json` (committed to the repo). Device keys are used for commit signing — they do NOT participate in vault decryption. -Revoking a device: remove its key from `devices.json` and commit. No passphrase or reference image rotation needed. +Revoking a device: remove its key from `devices.json` and commit. No passphrase or second-factor rotation needed. ```bash relicario device add --name laptop @@ -210,7 +218,7 @@ The binary is at `target/release/relicario`. - [x] Typed items: Login, SecureNote, Identity, Card, Key, Document, TOTP - [x] Secure document storage (encrypted file attachments) - [x] Backup & restore (`.relbak` encrypted envelope) -- [x] Recovery QR (paper-printable image_secret backup with separate passphrase) +- [x] Recovery QR (paper-printable second-factor secret backup with separate passphrase) - [x] LastPass CSV import - [x] Device authentication (ed25519 commit signing + pre-receive hook) - [ ] Import from Bitwarden / 1Password diff --git a/STATUS.md b/STATUS.md index fa0f584..8e75842 100644 --- a/STATUS.md +++ b/STATUS.md @@ -14,7 +14,10 @@ Two-track multi-agent lift (5 streams, relay-coordinated; PM + Dev-A..E): - **Org track (merge order A→B→C):** Dev-A org SW+WASM foundation, Dev-B org read UI, Dev-C org write. - **Keyfile track (merge order D→E):** Dev-D keyfile core/cli/wasm, Dev-E keyfile extension + positioning pivot. Independent of the org track. -**Merged to main so far:** Dev-D keyfile core/cli/wasm — `588495f` (2026-06-26). Two-review-clean (PM diff read + independent security subagent; PASS, no Critical/Important); merged tree green (workspace `cargo test` incl. the non-tautological equivalence test, `clippy -D warnings`). 3 minor follow-ups tracked for a `fix/v0.9.0-keyfile-minors` branch before the tag: (1) `keyfile_encode` → `Zeroizing>`; (2) test the `backup --include-image` clean-error on keyfile vaults; (3) integration test for malformed-keyfile vs wrong-secret at unlock. Plus the deferred `backup --include-keyfile` sibling. Dev-E green-lit (next in keyfile track). +**Merge progress (as of 2026-06-27):** +- **Keyfile track ✅ COMPLETE** — Dev-D keyfile core/cli/wasm (`588495f`), Dev-D security-review minors (`2262272`: `keyfile_encode`→`Zeroizing` + a backup-keyfile-error test + the no-oracle malformed-vs-wrong pair), Dev-E keyfile extension + positioning docs (`2ff5e5b`). All merged + verified, each two-review-clean (PM diff read + independent security subagent for D; E's mandatory `/security-review` no-findings + opus whole-branch + PM SECURITY.md read). The **pluggable second factor ships end-to-end** (CLI `init --key-file`/unlock/recovery-qr + extension wizard/unlock/attach), documented honestly (secret in the clear but gitignored/local-only and never pushed; not weaker than stego). +- **Org track — in progress:** Dev-A org foundation REVIEW-READY, security verdict **PASS-WITH-MINOR** (all 4 crypto invariants confirmed: privkey never to JS, lock/timer zero ALL handles, org key never persisted, filter single-sourced). Merge gated on one required 3-line fix — **I1**: free the orphaned org-key handle on the `openOrg` error path (else an unwrapped org key can persist in WASM past a lock). Dev-B building org read UI (Tasks 1-5) + an **added config-write flow** (`org_add_config` SW msg + add-org form — needed because `org_list_configs` reads `orgConfigs` but nothing wrote them, so org-read was otherwise unreachable); B's final push held until A merges. Dev-C org-write transport (universal `commitSigned`, isomorphic-git `git-receive-pack`) done + security-clean; Tasks 3-5 (handlers/UI/acceptance) held until A+B merge. +- **Pre-tag follow-ups:** Dev-A M1 (one-time user notice that the device-key migration mints a new device identity) + M2 (recovery path for a corrupt `device_key_enc`); `backup --include-keyfile` sibling; the `gitea.ts putBlob` latent bug; stale-worktree cleanup (3 v0.8.1 worktrees + 2 leftover review worktrees in scratchpad). **Org-write transport decision (2026-06-25) — recorded per the org-GUI spec's spike gate:** - Spike (Dev-C, `426b82a`; doc `docs/superpowers/spikes/2026-06-20-org-signed-commit-spike.md`): **signature mechanism is GO** — a SW-built SSHSIG commit (raw `sign_for_git` + manual SSHSIG framing, no new WASM export) passes both `git verify-commit` and `relicario-server verify-org-commit`, incl. item+manifest dual-write + grant/slug authz. Hook matches the **signing-key fingerprint** → `members[].ed25519_pubkey` (committer text free-form, not checked). diff --git a/crates/relicario-cli/src/commands/init.rs b/crates/relicario-cli/src/commands/init.rs index e4ee0da..4abd1c3 100644 --- a/crates/relicario-cli/src/commands/init.rs +++ b/crates/relicario-cli/src/commands/init.rs @@ -56,7 +56,8 @@ pub fn cmd_init(image: Option, output: PathBuf, key_file: Option Command { @@ -139,6 +140,86 @@ fn recovery_qr_generate_works_for_keyfile_vault() { .stdout(predicates::str::contains("Recovery QR generated")); } +/// `backup export --include-image` on a key-file vault must fail with a clear message. +/// (Key-file vaults have no reference JPEG to bundle.) +#[test] +fn backup_include_image_fails_on_keyfile_vault() { + let dir = tempfile::tempdir().unwrap(); + relicario(&dir) + .args(["init", "--key-file", "vault.relkey"]) + .env("RELICARIO_TEST_PASSPHRASE", "correct horse") + .assert() + .success(); + let out_path = dir.path().join("vault.relbak"); + relicario(&dir) + .args(["backup", "export", out_path.to_str().unwrap(), "--include-image"]) + .env("RELICARIO_TEST_BACKUP_PASSPHRASE", "strong-backup-pass-test-2026") + .assert() + .failure() + .stderr(predicates::str::contains("not applicable to a key-file vault")); +} + +/// A malformed key file (garbage bytes) must fail with "invalid key file" in stderr. +/// This is the `keyfile_decode` → `.context("invalid key file")` path. +#[test] +fn malformed_keyfile_emits_invalid_key_file() { + let dir = tempfile::tempdir().unwrap(); + relicario(&dir) + .args(["init", "--key-file", "vault.relkey"]) + .env("RELICARIO_TEST_PASSPHRASE", "correct horse") + .assert() + .success(); + // Overwrite the key file with garbage — keyfile_decode will reject the header. + std::fs::write(dir.path().join("vault.relkey"), b"not a valid keyfile\n").unwrap(); + relicario(&dir) + .args(["list"]) + .env("RELICARIO_TEST_PASSPHRASE", "correct horse") + .env("RELICARIO_KEYFILE", dir.path().join("vault.relkey")) + .assert() + .failure() + .stderr(predicates::str::contains("invalid key file")); +} + +/// A well-formed but WRONG key file (valid armor, wrong 32-byte secret) must produce +/// a decryption failure — NOT "invalid key file". There must be no oracle about +/// which factor was wrong. +#[test] +fn wrong_secret_emits_decryption_failed_not_invalid_key_file() { + // Vault A: the target vault. + let dir_a = tempfile::tempdir().unwrap(); + relicario(&dir_a) + .args(["init", "--key-file", "vault.relkey"]) + .env("RELICARIO_TEST_PASSPHRASE", "correct horse") + .assert() + .success(); + // Add an item so there is encrypted manifest content to exercise. + relicario(&dir_a) + .args(["add", "login", "--title", "test", "--username", "u", "--password", "p"]) + .env("RELICARIO_TEST_PASSPHRASE", "correct horse") + .env("RELICARIO_KEYFILE", dir_a.path().join("vault.relkey")) + .assert() + .success(); + + // Vault B: a completely separate vault with a different random 32-byte secret. + let dir_b = tempfile::tempdir().unwrap(); + relicario(&dir_b) + .args(["init", "--key-file", "vault.relkey"]) + .env("RELICARIO_TEST_PASSPHRASE", "correct horse") + .assert() + .success(); + + // Open vault A with vault B's key file: armor parses fine, but the derived + // master key is wrong → manifest decryption fails with RelicarioError::Decrypt. + relicario(&dir_a) + .args(["list"]) + .env("RELICARIO_TEST_PASSPHRASE", "correct horse") + .env("RELICARIO_KEYFILE", dir_b.path().join("vault.relkey")) + .assert() + .failure() + .stderr(predicates::str::contains("decryption failed")) + .stderr(predicates::str::contains("invalid key file").not()); +} + /// --image and --key-file are mutually exclusive (clap rejects before the handler). #[test] fn init_with_both_factors_fails() { diff --git a/crates/relicario-core/src/keyfile.rs b/crates/relicario-core/src/keyfile.rs index c84cf73..5539d78 100644 --- a/crates/relicario-core/src/keyfile.rs +++ b/crates/relicario-core/src/keyfile.rs @@ -25,8 +25,8 @@ const HEADER: &str = "relicario-keyfile-v1"; /// /// Output is: `"relicario-keyfile-v1\n\n"` as UTF-8 bytes. /// The returned bytes are suitable for writing directly to a `.relkey` file. -pub fn keyfile_encode(secret: &[u8; 32]) -> Vec { - format!("{HEADER}\n{}\n", STANDARD.encode(secret)).into_bytes() +pub fn keyfile_encode(secret: &[u8; 32]) -> Zeroizing> { + Zeroizing::new(format!("{HEADER}\n{}\n", STANDARD.encode(secret)).into_bytes()) } /// Decode a `relicario-keyfile-v1` armored file back to the raw 32-byte secret. diff --git a/crates/relicario-wasm/src/lib.rs b/crates/relicario-wasm/src/lib.rs index 1b412c2..5ffee4e 100644 --- a/crates/relicario-wasm/src/lib.rs +++ b/crates/relicario-wasm/src/lib.rs @@ -101,7 +101,7 @@ pub fn unlock_with_secret( pub fn keyfile_encode(secret: &[u8]) -> Result, JsError> { let arr: &[u8; 32] = secret.try_into() .map_err(|_| JsError::new("secret must be exactly 32 bytes"))?; - Ok(relicario_core::keyfile::keyfile_encode(arr)) + Ok(relicario_core::keyfile::keyfile_encode(arr).to_vec()) } /// Decode a `relicario-keyfile-v1` armored file, returning the raw 32-byte secret. diff --git a/docs/CRYPTO.md b/docs/CRYPTO.md index 18e6aba..42f7020 100644 --- a/docs/CRYPTO.md +++ b/docs/CRYPTO.md @@ -63,6 +63,20 @@ └──────────────────────────────────────────────────────────────────┘ ``` +## Pluggable second-factor containers + +The second factor input to Argon2id is always **32 bytes** (the `image_secret` +field in the KDF call). The *container* that holds those 32 bytes is chosen at +vault creation and is interchangeable: + +| Container | How the 32 bytes are stored | Extract step | +|---|---|---| +| Reference image (default) | Embedded in JPEG DCT coefficients via QIM steganography | `imgsecret::extract()` | +| Key file (`.relkey`) | base64-encoded in a plain armored text file | `keyfile_decode()` (`crates/relicario-core/src/keyfile.rs:36`) | +| Recovery QR | XChaCha20-Poly1305 envelope keyed by a separate recovery passphrase | Unwrap then read the raw 32 bytes | + +The Argon2id KDF input — `u64_be(len(passphrase)) || passphrase || u64_be(32) || secret_32_bytes` — is **identical regardless of container**. Master-key derivation is unaffected by the container choice. The diagrams below show the reference-image path; substitute the appropriate extract step for other containers. + ## Vault Creation Flow ``` @@ -228,7 +242,7 @@ Unlike the personal vault, **org crypto bypasses Argon2id entirely**: | | Personal vault | Org vault | |---|---|---| | Key origin | Argon2id(passphrase ‖ image_secret, salt) | OsRng → 256-bit random | -| Key transport | Embedded in reference JPEG (stego) | X25519 ECIES wrap blob | +| Key transport | Embedded in reference JPEG (stego) or stored in a `.relkey` key file | X25519 ECIES wrap blob | | AEAD primitive | XChaCha20-Poly1305 (`crate::crypto::encrypt`) | Same primitive (delegated) | | KDF for wrap key | Argon2id | SHA-256(DH ‖ eph_pk ‖ rec_pk) | @@ -414,13 +428,13 @@ Input JPEG (possibly re-encoded or cropped) ``` Server breach only: ████████████████████████████ 256+ bits (infeasible) - passphrase + image_secret + passphrase + second-factor secret -Server + stolen image: ████░░░░░░░░░░░░░░░░░░░░░░ ~51 bits (4 diceware words) +Server + stolen 2nd factor: ████░░░░░░░░░░░░░░░░░░░░░░ ~51 bits (4 diceware words) passphrase through Argon2id ~7 million years Shoulder-surfed passphrase: ████████████████████████████ 256 bits (infeasible) - image_secret + second-factor secret Stolen device: ████░░░░░░░░░░░░░░░░░░░░░░ ~51 bits (4 diceware words) passphrase through Argon2id ~7 million years diff --git a/docs/FORMATS.md b/docs/FORMATS.md index 2890ec2..80a0d24 100644 --- a/docs/FORMATS.md +++ b/docs/FORMATS.md @@ -32,11 +32,34 @@ Every encrypted file — `manifest.enc`, `settings.enc`, `items/.enc`, `atta "argon2_m": 65536, "argon2_t": 3, "argon2_p": 4 - } + }, + "second_factor": "image" } ``` -Parsed via `ParamsFile { kdf: KdfParams }` in `session.rs`. The `kdf` nesting is intentional — `format_version`, `aead`, and `salt_path` co-exist for forward-compat probing. Do not flatten. Production defaults: `m=65536` (64 MiB), `t=3`, `p=4`. Tests use `m=256, t=1, p=1`. +Parsed via `ParamsFile { kdf: KdfParams }` in `crates/relicario-cli/src/session.rs:117` +(`KdfParams` is defined in `crates/relicario-core/src/crypto.rs:180`). `second_factor` is a +top-level sibling of `kdf` on `ParamsFile` (`ParamsFile.second_factor`, `crates/relicario-cli/src/session.rs:125`; the `SecondFactor` enum is `crates/relicario-core/src/crypto.rs:69`). +The `kdf` nesting is intentional — `format_version`, `aead`, and `salt_path` co-exist for forward-compat probing. Do not flatten. Production defaults: `m=65536` (64 MiB), `t=3`, `p=4`. Tests use `m=256, t=1, p=1`. + +### `second_factor` field + +Top-level field on the `ParamsFile` wrapper. Value: `"image"` or `"keyfile"`. **Absent means `"image"`** — all existing vaults missing this field use the steganographic reference-image path (back-compatible). Clients read this field before the unlock prompt to decide whether to request a JPEG path or a `.relkey` file path. + +## Key-file second factor (`.relkey`) + +When `second_factor` is `"keyfile"`, the user holds a plain armored file instead of a steganographic JPEG. The file format is: + +``` +relicario-keyfile-v1 + +``` + +Exactly two lines, each terminated by a newline (`\n`). The first line is the literal ASCII string `relicario-keyfile-v1`. The second line is the standard base64 encoding (RFC 4648) of the 32-byte second-factor secret with no line wrapping. + +- Suggested file extension: `.relkey` +- The 32 bytes are the second-factor secret **in the clear** — this file is the "something you have". It is not itself encrypted. See [SECURITY.md](SECURITY.md) for the threat-model implications. +- Source: `crates/relicario-core/src/keyfile.rs` — `keyfile_encode` (`:28`) / `keyfile_decode` (`:36`) ## `.relicario/salt` @@ -159,10 +182,10 @@ Full item type inventory: `crates/relicario-core/ARCHITECTURE.md` § "Module map The password fed to Argon2id is length-prefixed to prevent extension attacks: ``` -u64_be(len(passphrase)) || passphrase_bytes || u64_be(32) || image_secret +u64_be(len(passphrase)) || passphrase_bytes || u64_be(32) || second_factor_secret ``` -NFC-normalized before hashing. Covered in `crypto.rs:229-236` and tested in `tests/format_v2.rs:44-54`. +`second_factor_secret` is always 32 bytes extracted from whichever container the vault uses (reference image via `imgsecret::extract()` or key file via `keyfile_decode()`). The KDF input is byte-for-byte identical regardless of container. NFC-normalized before hashing. Covered in `crypto.rs:229-236` and tested in `tests/format_v2.rs:44-54`. --- diff --git a/docs/SECURITY.md b/docs/SECURITY.md index dca0c52..714bd7c 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -6,11 +6,26 @@ Relicario uses two-factor vault decryption: 1. **Passphrase** — user-memorized, zxcvbn score ≥3 required -2. **Reference image** — JPEG carrying 256-bit secret via DCT steganography +2. **Second factor** — a 32-byte secret carried by a pluggable container chosen at vault creation: + - **Reference image** (default): a JPEG with the secret embedded in DCT coefficients via QIM steganography + - **Key file**: the same 32-byte secret in a plain `.relkey` armored file + +The second-factor input to the KDF is always 32 bytes regardless of container. The Argon2id input and master-key derivation are identical for both container types. Key derivation: Argon2id (64 MiB memory, 3 iterations, 4 parallelism) Encryption: XChaCha20-Poly1305 (192-bit nonce, 256-bit key) +### Second-factor storage posture + +Both containers hold the second-factor secret **in the clear** — they are the "something you have", not an encrypted artifact. This is the expected and correct posture: + +- **Reference image** (`reference.jpg`): the 32-byte secret is embedded in the JPEG DCT coefficients. The file itself is not encrypted. In the browser extension the image bytes are stored as `imageBase64` in `chrome.storage.local` — also in the clear. +- **Key file** (`.relkey`): the 32-byte secret is base64-encoded in a plain text file. The file itself is not encrypted. In the browser extension the key-file bytes are stored as `keyfileBase64` in `chrome.storage.local` — also in the clear. + +Neither container is ever committed to the vault repository or pushed to the git remote: the reference JPEG / `.relkey` lives only on your devices (a local file you safeguard for the CLI, `chrome.storage.local` for the extension). The server's repository holds only the `.enc` ciphertext, the non-secret salt, and KDF params — never the second factor. + +The key file is **not weaker** than the reference image. Both containers are protected by the same invariant: the passphrase is also required, and the server holds only opaque ciphertext sealed under a key derived from both. A stolen second factor without the passphrase yields nothing actionable (256 bits of Argon2id to brute-force). A stolen passphrase without the second factor is equally unactionable. + ## Manifest Integrity The manifest (`manifest.enc`) is encrypted with AEAD, which provides: @@ -212,7 +227,8 @@ reviewers to audit the surface in one place. | Variable | Purpose | Trust | |---|---|---| -| `RELICARIO_IMAGE` | Override the reference-image JPEG path used during vault unlock. | Trusted: filesystem path under the user's control. Read-only; its bytes feed `imgsecret::extract_secret`. | +| `RELICARIO_IMAGE` | Override the reference-image JPEG path used during vault unlock (for image-path vaults). | Trusted: filesystem path under the user's control. Read-only; its bytes feed `imgsecret::extract_secret`. | +| `RELICARIO_KEYFILE` | Override the `.relkey` key-file path used during vault unlock (for key-file-path vaults). | Trusted: filesystem path under the user's control. Read-only; its bytes feed `keyfile_decode()`. The file holds the second-factor secret in the clear — same trust surface as `RELICARIO_IMAGE`. | | `RELICARIO_GITEA_URL` | Gitea API base URL for `relicario device add`. Equivalent to `--gitea-url`. | Trusted: HTTPS URL. Used only in the device-add code path. | | `RELICARIO_GITEA_TOKEN` | Gitea personal-access token. Equivalent to `--gitea-token`. | **Secret**: anyone who can read this env var can manage the user's deploy keys via the Gitea API. The CLI never logs it. | | `RELICARIO_GITEA_OWNER` | Gitea repository owner (e.g. `alee`). Equivalent to `--owner`. | Trusted: opaque string. | diff --git a/extension/src/service-worker/__tests__/keyfile-unlock.test.ts b/extension/src/service-worker/__tests__/keyfile-unlock.test.ts new file mode 100644 index 0000000..0349b7c --- /dev/null +++ b/extension/src/service-worker/__tests__/keyfile-unlock.test.ts @@ -0,0 +1,444 @@ +// Keyfile-mode SW coverage: handleCreateVault keyfile branch (Task 2), handleUnlock +// second-factor resolution (Task 4), and handleAttachVault keyfile branch (Task 5). +// All green now that Tasks 2/4 have landed and Task 5 wires the attach path. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as vault from '../vault'; +import * as session from '../session'; +import type { PopupState } from '../router/popup-only'; +import type { GitHost } from '../git-host'; +import * as gitHostMod from '../git-host'; + +// ---- Mirror the makeHostMock / vi.mock / mockChromeStorage harness from vault.test.ts ---- + +function makeHostMock(): GitHost & { _calls: Record } { + const calls: Record = { + writeFileCreateOnly: [], + writeFile: [], + readFile: [], + }; + return { + _calls: calls, + readFile: vi.fn().mockImplementation(async (path: string) => { + if (path === '.relicario/salt') return new Uint8Array(32); + if (path === '.relicario/params.json') { + return new TextEncoder().encode('{"argon2_m":65536,"argon2_t":3,"argon2_p":4}'); + } + if (path === 'manifest.enc') return new Uint8Array([0xab, 0xcd]); + throw new Error(`404: ${path}`); + }), + writeFile: vi.fn().mockImplementation(async (...args: unknown[]) => { + calls.writeFile.push(args); + }), + writeFileCreateOnly: vi.fn().mockImplementation(async (...args: unknown[]) => { + calls.writeFileCreateOnly.push(args); + }), + deleteFile: vi.fn(), + listDir: vi.fn().mockResolvedValue([]), + lastCommit: vi.fn().mockResolvedValue(null), + putBlob: vi.fn(), + getBlob: vi.fn(), + deleteBlob: vi.fn(), + }; +} + +vi.mock('../git-host', async () => { + const actual = await vi.importActual('../git-host'); + (globalThis as { __lastFakeGitHost?: ReturnType | null }).__lastFakeGitHost = null; + return { + ...actual, + createGitHost: vi.fn().mockImplementation(() => { + const h = makeHostMock(); + (globalThis as { __lastFakeGitHost?: ReturnType | null }).__lastFakeGitHost = h; + return h; + }), + }; +}); + +function mockChromeStorage(initial: Record = {}) { + const store: Record = { ...initial }; + (global as { chrome: unknown }).chrome = { + storage: { + local: { + get: vi.fn((keys: string | string[]) => { + const arr = Array.isArray(keys) ? keys : [keys]; + const out: Record = {}; + for (const k of arr) if (k in store) out[k] = store[k]; + return Promise.resolve(out); + }), + set: vi.fn((kv: Record) => { + Object.assign(store, kv); + return Promise.resolve(); + }), + }, + }, + } as never; + return store; +} + +function makeFakeHandle() { + return { free: vi.fn() }; +} + +function makeWasm(overrides: Record = {}) { + const fakeHandle = makeFakeHandle(); + return { + _handle: fakeHandle, + embed_image_secret: vi.fn(() => new Uint8Array([1, 2, 3])), + unlock: vi.fn(() => fakeHandle), + manifest_encrypt: vi.fn(() => new Uint8Array([9])), + manifest_decrypt: vi.fn(() => ({ schema_version: 2, items: {} })), + default_vault_settings_json: vi.fn(() => '{}'), + settings_encrypt: vi.fn(() => new Uint8Array([8])), + register_device: vi.fn(() => ({ signing_public_key: 'pk', deploy_public_key: 'dk' })), + // Task 4.5: create/attach persist the device key after register_device; unlock restores it. + persist_device_key: vi.fn(() => new Uint8Array([0xde, 0xad])), + restore_device_key: vi.fn(), + lock: vi.fn(), + ...overrides, + }; +} + +function makeState(wasm: ReturnType): PopupState { + return { + manifest: null, + gitHost: null, + wasm, + }; +} + +const BASE_MSG = { + config: { hostType: 'gitea' as const, hostUrl: 'https://g', repoPath: 'u/v', apiToken: 't' }, + passphrase: 'pw', + // Must begin with the JPEG SOI magic (0xFF 0xD8) to pass handleCreateVault's + // pre-embed format guard; the WASM embed itself is mocked. + carrierImageBytes: new Uint8Array([0xff, 0xd8, 0x00]).buffer, + deviceName: 'Dev', +}; + +// ---- Task 2: create_vault keyfile mode ---- +// ASSERTION-red: handleCreateVault currently ignores secondFactor. +// Will go green when Task 2 adds the keyfile branch. + +describe('handleCreateVault — keyfile mode (Task 2)', () => { + let setCurrent: ReturnType; + + beforeEach(() => { + mockChromeStorage(); + vi.mocked(gitHostMod.createGitHost).mockImplementation(() => { + const h = makeHostMock(); + (globalThis as { __lastFakeGitHost?: ReturnType | null }).__lastFakeGitHost = h; + return h; + }); + setCurrent = vi.spyOn(session, 'setCurrent').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('routes to keyfile path when secondFactor is keyfile', async () => { + const keyfileHandle = makeFakeHandle(); + const wasm = makeWasm({ + keyfile_encode: vi.fn(() => new Uint8Array([10, 11, 12])), + unlock_with_secret: vi.fn(() => keyfileHandle), + }); + const state = makeState(wasm); + + const resp = await vault.handleCreateVault( + // secondFactor is already in the messages.ts type but handleCreateVault + // does not yet read it — cast to pass it. + { ...BASE_MSG, secondFactor: 'keyfile' } as Parameters[0], + state, + ); + + expect(resp.ok).toBe(true); + if (!resp.ok) throw new Error('expected ok:true'); + + // resp.data.relkeyBytes must be present (the armored .relkey for the wizard to download) + expect((resp.data as { relkeyBytes?: Uint8Array }).relkeyBytes).toBeInstanceOf(Uint8Array); + + // Correct WASM path + expect(wasm.keyfile_encode).toHaveBeenCalled(); + expect(wasm.unlock_with_secret).toHaveBeenCalled(); + + // Image path must NOT have been taken + expect(wasm.unlock).not.toHaveBeenCalled(); + expect(wasm.embed_image_secret).not.toHaveBeenCalled(); + + // chrome.storage.local.set: keyfileBase64 present, imageBase64 absent + const chromeSets = (global as { chrome: { storage: { local: { set: ReturnType } } } }) + .chrome.storage.local.set.mock.calls; + const merged: Record = {}; + for (const [kv] of chromeSets) Object.assign(merged, kv); + expect(merged).toHaveProperty('keyfileBase64'); + expect(merged).not.toHaveProperty('imageBase64'); + + // params.json writeFileCreateOnly call must embed "second_factor":"keyfile" + const fakeHost = (globalThis as { + __lastFakeGitHost?: { writeFileCreateOnly: ReturnType } | null; + }).__lastFakeGitHost; + expect(fakeHost).not.toBeNull(); + const wfco = fakeHost!.writeFileCreateOnly as ReturnType; + const paramsCall = (wfco.mock.calls as unknown[][]).find( + (c: unknown[]) => c[0] === '.relicario/params.json', + ); + expect(paramsCall).toBeDefined(); + const paramsBytes = paramsCall![1] as Uint8Array; + const paramsObj = JSON.parse(new TextDecoder().decode(paramsBytes)) as Record; + expect(paramsObj).toHaveProperty('second_factor', 'keyfile'); + + // Void session.setCurrent — avoid lint unused-variable + void setCurrent; + }); +}); + +// ---- Task 5: attach_vault keyfile mode ---- +// handleAttachVault must branch on secondFactor:'keyfile' — decode the uploaded +// .relkey, derive via unlock_with_secret (NOT unlock), and persist keyfileBase64. + +describe('handleAttachVault — keyfile mode (Task 5)', () => { + let setCurrent: ReturnType; + + beforeEach(() => { + mockChromeStorage(); + vi.mocked(gitHostMod.createGitHost).mockImplementation(() => { + const h = makeHostMock(); + (globalThis as { __lastFakeGitHost?: ReturnType | null }).__lastFakeGitHost = h; + return h; + }); + setCurrent = vi.spyOn(session, 'setCurrent').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const KF_ATTACH_MSG = { + config: { hostType: 'gitea' as const, hostUrl: 'https://g', repoPath: 'u/v', apiToken: 't' }, + passphrase: 'pw', + secondFactor: 'keyfile' as const, + keyfileBytes: new Uint8Array([10, 11, 12]).buffer, + deviceName: 'Dev2', + }; + + it('decodes the .relkey and unlocks with the secret (not unlock), storing keyfileBase64', async () => { + const keyfileHandle = makeFakeHandle(); + const wasm = makeWasm({ + keyfile_decode: vi.fn(() => new Uint8Array(32)), + unlock_with_secret: vi.fn(() => keyfileHandle), + }); + const state = makeState(wasm); + + const resp = await vault.handleAttachVault( + KF_ATTACH_MSG as Parameters[0], + state, + ); + + expect(resp.ok).toBe(true); + if (!resp.ok) throw new Error('expected ok:true'); + expect(resp.data.deviceName).toBe('Dev2'); + + // Keyfile path: keyfile_decode + unlock_with_secret; the image unlock must NOT run. + expect(wasm.keyfile_decode).toHaveBeenCalled(); + expect(wasm.unlock_with_secret).toHaveBeenCalled(); + expect(wasm.unlock).not.toHaveBeenCalled(); + + // The existing manifest_decrypt verification still runs; device registered. + expect(wasm.manifest_decrypt).toHaveBeenCalled(); + expect(wasm.register_device).toHaveBeenCalledWith('Dev2'); + + // chrome.storage.local.set: keyfileBase64 present, imageBase64 absent. + const chromeSets = (global as { chrome: { storage: { local: { set: ReturnType } } } }) + .chrome.storage.local.set.mock.calls; + const merged: Record = {}; + for (const [kv] of chromeSets) Object.assign(merged, kv); + expect(merged).toHaveProperty('keyfileBase64'); + expect(merged).not.toHaveProperty('imageBase64'); + + expect(setCurrent).toHaveBeenCalled(); + }); + + it('returns invalid_key_file when keyfile_decode throws (and never unlocks)', async () => { + const wasm = makeWasm({ + keyfile_decode: vi.fn(() => { throw new Error('bad armor'); }), + unlock_with_secret: vi.fn(), + }); + const state = makeState(wasm); + + const resp = await vault.handleAttachVault( + KF_ATTACH_MSG as Parameters[0], + state, + ); + + expect(resp).toEqual({ ok: false, error: 'invalid_key_file' }); + expect(wasm.unlock_with_secret).not.toHaveBeenCalled(); + expect(wasm.register_device).not.toHaveBeenCalled(); + expect(setCurrent).not.toHaveBeenCalled(); + }); +}); + +// ---- Task 4: unlock resolves second factor from params hint ---- +// HELD: Task 4 extracts handleUnlock + adds the keyfile branch; green after Dev-D merges. +// IMPORT-red: handleUnlock does not exist yet. + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const { handleUnlock } = await import('../router/popup-only') as any; + +describe('handleUnlock — keyfile branch (Task 4)', () => { + beforeEach(() => { + // chrome.storage.local returns vaultConfig + keyfileBase64 (no imageBase64) + const fakeVaultConfig = { + hostType: 'gitea', + hostUrl: 'https://g', + repoPath: 'u/v', + apiToken: 't', + }; + // A minimal base64 string for keyfileBase64 + const keyfileBase64 = btoa(String.fromCharCode(...new Uint8Array(32))); + mockChromeStorage({ + vaultConfig: fakeVaultConfig, + keyfileBase64, + }); + + // The gitHost readFile for params.json must return second_factor:keyfile + vi.mocked(gitHostMod.createGitHost).mockImplementation(() => { + const h = makeHostMock(); + // Override readFile for params.json to include second_factor + (h.readFile as ReturnType).mockImplementation(async (path: string) => { + if (path === '.relicario/salt') return new Uint8Array(32); + if (path === '.relicario/params.json') { + return new TextEncoder().encode( + '{"argon2_m":65536,"argon2_t":3,"argon2_p":4,"second_factor":"keyfile"}', + ); + } + if (path === 'manifest.enc') return new Uint8Array([0xab, 0xcd]); + throw new Error(`404: ${path}`); + }); + (globalThis as { __lastFakeGitHost?: ReturnType | null }).__lastFakeGitHost = h; + return h; + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('calls unlock_with_secret (not unlock) when params hint is keyfile', async () => { + const keyfileHandle = makeFakeHandle(); + const wasm = makeWasm({ + keyfile_decode: vi.fn(() => new Uint8Array(32)), + unlock_with_secret: vi.fn(() => keyfileHandle), + manifest_decrypt: vi.fn(() => ({ schema_version: 2, items: {} })), + }); + const state = makeState(wasm); + + // Wire the module-level wasm in vault.ts so fetchAndDecryptManifest's + // requireWasm() resolves to the same mock (mirrors production SW init). + vault.setWasm(wasm); + await handleUnlock({ type: 'unlock', passphrase: 'pw' }, state); + + expect(wasm.unlock_with_secret).toHaveBeenCalled(); + expect(wasm.unlock).not.toHaveBeenCalled(); + }); +}); + +// ---- Task 4 error paths: forward-compat guard, invalid_key_file, key-file-not-set ---- + +describe('handleUnlock — keyfile error paths', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('forward-compat guard: rejects unknown second_factor without calling unlock or unlock_with_secret', async () => { + const fakeVaultConfig = { hostType: 'gitea' as const, hostUrl: 'https://g', repoPath: 'u/v', apiToken: 't' }; + const keyfileBase64 = btoa(String.fromCharCode(...new Uint8Array(32))); + mockChromeStorage({ vaultConfig: fakeVaultConfig, keyfileBase64 }); + vi.mocked(gitHostMod.createGitHost).mockImplementation(() => { + const h = makeHostMock(); + (h.readFile as ReturnType).mockImplementation(async (path: string) => { + if (path === '.relicario/salt') return new Uint8Array(32); + if (path === '.relicario/params.json') { + return new TextEncoder().encode( + '{"argon2_m":65536,"argon2_t":3,"argon2_p":4,"second_factor":"totp"}', + ); + } + if (path === 'manifest.enc') return new Uint8Array([0xab, 0xcd]); + throw new Error(`404: ${path}`); + }); + (globalThis as { __lastFakeGitHost?: ReturnType | null }).__lastFakeGitHost = h; + return h; + }); + const wasm = makeWasm({ keyfile_decode: vi.fn(), unlock_with_secret: vi.fn() }); + const state = makeState(wasm); + vault.setWasm(wasm); + + const resp = await handleUnlock({ type: 'unlock', passphrase: 'pw' }, state); + + expect(resp).toEqual({ ok: false, error: 'unsupported second factor in vault params' }); + expect(wasm.unlock).not.toHaveBeenCalled(); + expect(wasm.unlock_with_secret).not.toHaveBeenCalled(); + }); + + it('returns invalid_key_file when keyfile_decode throws (bad armor)', async () => { + const fakeVaultConfig = { hostType: 'gitea' as const, hostUrl: 'https://g', repoPath: 'u/v', apiToken: 't' }; + const keyfileBase64 = btoa(String.fromCharCode(...new Uint8Array(32))); + mockChromeStorage({ vaultConfig: fakeVaultConfig, keyfileBase64 }); + vi.mocked(gitHostMod.createGitHost).mockImplementation(() => { + const h = makeHostMock(); + (h.readFile as ReturnType).mockImplementation(async (path: string) => { + if (path === '.relicario/salt') return new Uint8Array(32); + if (path === '.relicario/params.json') { + return new TextEncoder().encode( + '{"argon2_m":65536,"argon2_t":3,"argon2_p":4,"second_factor":"keyfile"}', + ); + } + if (path === 'manifest.enc') return new Uint8Array([0xab, 0xcd]); + throw new Error(`404: ${path}`); + }); + (globalThis as { __lastFakeGitHost?: ReturnType | null }).__lastFakeGitHost = h; + return h; + }); + const wasm = makeWasm({ + keyfile_decode: vi.fn(() => { throw new Error('bad armor'); }), + unlock_with_secret: vi.fn(), + }); + const state = makeState(wasm); + vault.setWasm(wasm); + + const resp = await handleUnlock({ type: 'unlock', passphrase: 'pw' }, state); + + expect(resp).toEqual({ ok: false, error: 'invalid_key_file' }); + expect(wasm.unlock_with_secret).not.toHaveBeenCalled(); + }); + + it('returns key-file-not-set error when keyfileBase64 absent from storage', async () => { + const fakeVaultConfig = { hostType: 'gitea' as const, hostUrl: 'https://g', repoPath: 'u/v', apiToken: 't' }; + // Intentionally no keyfileBase64 in storage — only vaultConfig. + mockChromeStorage({ vaultConfig: fakeVaultConfig }); + vi.mocked(gitHostMod.createGitHost).mockImplementation(() => { + const h = makeHostMock(); + (h.readFile as ReturnType).mockImplementation(async (path: string) => { + if (path === '.relicario/salt') return new Uint8Array(32); + if (path === '.relicario/params.json') { + return new TextEncoder().encode( + '{"argon2_m":65536,"argon2_t":3,"argon2_p":4,"second_factor":"keyfile"}', + ); + } + if (path === 'manifest.enc') return new Uint8Array([0xab, 0xcd]); + throw new Error(`404: ${path}`); + }); + (globalThis as { __lastFakeGitHost?: ReturnType | null }).__lastFakeGitHost = h; + return h; + }); + const wasm = makeWasm({ keyfile_decode: vi.fn(), unlock_with_secret: vi.fn() }); + const state = makeState(wasm); + vault.setWasm(wasm); + + const resp = await handleUnlock({ type: 'unlock', passphrase: 'pw' }, state); + + expect(resp).toEqual({ ok: false, error: 'Key file not set. Run setup first.' }); + expect(wasm.keyfile_decode).not.toHaveBeenCalled(); + expect(wasm.unlock_with_secret).not.toHaveBeenCalled(); + }); +}); diff --git a/extension/src/service-worker/router/popup-only.ts b/extension/src/service-worker/router/popup-only.ts index c7e3917..7dc293e 100644 --- a/extension/src/service-worker/router/popup-only.ts +++ b/extension/src/service-worker/router/popup-only.ts @@ -45,39 +45,7 @@ export async function handle( case 'is_unlocked': return { ok: true, data: { unlocked: session.getCurrent() !== null } }; - case 'unlock': { - const w = state.wasm; - const config = await loadConfig(); - if (!config) return { ok: false, error: 'Extension not configured. Run setup first.' }; - const imageB64 = await loadImageBase64(); - if (!imageB64) return { ok: false, error: 'Reference image not set. Run setup first.' }; - - const imageBytes = base64ToUint8Array(imageB64); - if (!state.gitHost) state.gitHost = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken); - const meta = await vault.fetchVaultMeta(state.gitHost); - - const handle = w.unlock(msg.passphrase, imageBytes, meta.salt, meta.paramsJson); - session.setCurrent(handle); - (msg as { passphrase: string }).passphrase = ''; - - // Restore device key so org_unwrap_key can access DEVICE_STATE this session. - const storedKey = await chrome.storage.local.get('device_key_enc'); - if (storedKey.device_key_enc) { - try { - w.restore_device_key(handle, base64ToUint8Array(storedKey.device_key_enc as string)); - } catch (e) { - // Must NOT block unlock — log and continue; org features will be unavailable. - console.warn('[relicario sw] device key restore failed', e); - } - } else { - // MIGRATION: pre-4.5b install with no persisted device key. Best-effort; - // must NOT block unlock even if migration itself errors. - await migrateDeviceKey(w, handle, state); - } - - state.manifest = await vault.fetchAndDecryptManifest(state.gitHost, handle); - return { ok: true }; - } + case 'unlock': return handleUnlock(msg, state); case 'lock': clearOrgCache(); // prevent use-after-free: clear org cache BEFORE freeing handles @@ -688,6 +656,71 @@ export async function handle( } } +// --- handleUnlock — second-factor-aware unlock --- + +export async function handleUnlock( + msg: Extract, + state: PopupState, +): Promise { + const w = state.wasm; + const config = await loadConfig(); + if (!config) return { ok: false, error: 'Extension not configured. Run setup first.' }; + + if (!state.gitHost) state.gitHost = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken); + const meta = await vault.fetchVaultMeta(state.gitHost); + + // Parse second_factor hint from vault params. Forward-compat guard: an unknown + // value is rejected immediately rather than silently falling back to image. + let secondFactor: 'image' | 'keyfile' = 'image'; + try { + const sf = (JSON.parse(meta.paramsJson) as Record).second_factor; + if (sf === 'keyfile') secondFactor = 'keyfile'; + else if (sf !== undefined && sf !== 'image') { + return { ok: false, error: 'unsupported second factor in vault params' }; + } + } catch { /* malformed params.json — default to image */ } + + let handle; + if (secondFactor === 'keyfile') { + const kfB64 = await loadKeyfileBase64(); + if (!kfB64) return { ok: false, error: 'Key file not set. Run setup first.' }; + let secret: Uint8Array; + try { + secret = w.keyfile_decode(base64ToUint8Array(kfB64)); + } catch { + return { ok: false, error: 'invalid_key_file' }; + } + handle = w.unlock_with_secret(msg.passphrase, secret, meta.salt, meta.paramsJson); + } else { + // Image path (default). + const imageB64 = await loadImageBase64(); + if (!imageB64) return { ok: false, error: 'Reference image not set. Run setup first.' }; + handle = w.unlock(msg.passphrase, base64ToUint8Array(imageB64), meta.salt, meta.paramsJson); + } + + // Shared tail for both paths. + session.setCurrent(handle); + (msg as { passphrase: string }).passphrase = ''; + + // Restore the device key so org_unwrap_key can read DEVICE_STATE this session (Task 4.5). + const storedKey = await chrome.storage.local.get('device_key_enc'); + if (storedKey.device_key_enc) { + try { + w.restore_device_key(handle, base64ToUint8Array(storedKey.device_key_enc as string)); + } catch (e) { + // Must NOT block unlock — log and continue; org features will be unavailable. + console.warn('[relicario sw] device key restore failed', e); + } + } else { + // MIGRATION: pre-4.5b install with no persisted device key. Best-effort; + // must NOT block unlock even if migration itself errors. + await migrateDeviceKey(w, handle, state); + } + + state.manifest = await vault.fetchAndDecryptManifest(state.gitHost, handle); + return { ok: true }; +} + // --- fill_credentials with captured-tab verification (audit M5) --- async function handleFillCredentials( @@ -768,10 +801,21 @@ async function loadImageBase64(): Promise { return (r.imageBase64 as string) ?? null; } +async function loadKeyfileBase64(): Promise { + const r = await chrome.storage.local.get('keyfileBase64'); + return (r.keyfileBase64 as string) ?? null; +} + async function loadSetupState(): Promise { const config = await loadConfig(); const imageBase64 = await loadImageBase64(); - return { config, imageBase64, isConfigured: config !== null && imageBase64 !== null }; + const keyfileBase64 = await loadKeyfileBase64(); + // A keyfile vault has no imageBase64 but does have keyfileBase64; either factor counts as configured. + return { + config, + imageBase64, + isConfigured: config !== null && (imageBase64 !== null || keyfileBase64 !== null), + }; } function safeHostname(url: string): string | undefined { diff --git a/extension/src/service-worker/vault.ts b/extension/src/service-worker/vault.ts index 4454207..3ef6b94 100644 --- a/extension/src/service-worker/vault.ts +++ b/extension/src/service-worker/vault.ts @@ -35,16 +35,20 @@ export async function persistDeviceKey( } const DEFAULT_PARAMS_JSON = '{"argon2_m":65536,"argon2_t":3,"argon2_p":4}'; +const KEYFILE_PARAMS_JSON = '{"argon2_m":65536,"argon2_t":3,"argon2_p":4,"second_factor":"keyfile"}'; /// Register this device on the remote (devices.json), persist the encrypted -/// device key to chrome.storage.local, and store the vault config + reference -/// image locally so future unlocks work. Shared by the create and attach flows. +/// device key to chrome.storage.local (Task 4.5), and store the vault config + +/// factor storage locally so future unlocks work. Shared by the create and +/// attach flows. `factorStorage` is either `{ imageBase64 }` (image mode) or +/// `{ keyfileBase64 }` (key-file mode); it is spread directly into the +/// chrome.storage.local.set call so exactly one key is present. async function registerDeviceAndPersistConfig( // eslint-disable-next-line @typescript-eslint/no-explicit-any w: any, git: GitHost, config: VaultConfig, - referenceImageBytes: Uint8Array, + factorStorage: { imageBase64: string } | { keyfileBase64: string }, deviceName: string, handle: SessionHandle, ): Promise { @@ -57,51 +61,79 @@ async function registerDeviceAndPersistConfig( }); await chrome.storage.local.set({ vaultConfig: config, - imageBase64: uint8ArrayToBase64(referenceImageBytes), + ...factorStorage, device_name: deviceName, }); } export async function handleCreateVault( - msg: { config: VaultConfig; passphrase: string; carrierImageBytes: ArrayBuffer; deviceName: string }, + msg: { config: VaultConfig; passphrase: string; secondFactor?: 'image' | 'keyfile'; + carrierImageBytes?: ArrayBuffer; deviceName: string }, state: PopupState, ): Promise { const w = state.wasm; let handle: SessionHandle | null = null; try { - const carrierBytes = new Uint8Array(msg.carrierImageBytes); - // Actionable guard: the file is only a hint, - // and a broken ArrayBuffer transport would arrive empty. Both surface as - // the opaque WASM error "no SOF marker found in JPEG", so classify here - // before handing bytes to the steganography embed. - if (carrierBytes.length === 0) { - return { ok: false, error: 'carrier_image_empty' }; - } - if (carrierBytes[0] !== 0xff || carrierBytes[1] !== 0xd8) { - return { ok: false, error: 'carrier_image_not_jpeg' }; - } - const imageSecret = new Uint8Array(32); - crypto.getRandomValues(imageSecret); - const referenceImageBytes = new Uint8Array(w.embed_image_secret(carrierBytes, imageSecret)); - const salt = new Uint8Array(32); crypto.getRandomValues(salt); - // Capture the unlock result in a non-null binding for the in-scope ops; - // `handle` stays the ownership tracker the finally block cleans up. - const h: SessionHandle = w.unlock(msg.passphrase, referenceImageBytes, salt, DEFAULT_PARAMS_JSON); - handle = h; + // --- Branch on second factor --- + let h: SessionHandle; + let paramsJson: string; + let factorStorage: { imageBase64: string } | { keyfileBase64: string }; + let responseData: { referenceImageBytes?: Uint8Array; relkeyBytes?: Uint8Array; + deviceName: string; recoveryQrAvailable: true }; + + if (msg.secondFactor === 'keyfile') { + // KEY-FILE branch: skip carrier-image guards and embed_image_secret. + // Generate a fresh 32-byte secret, derive via unlock_with_secret, and + // armor it with keyfile_encode for the wizard download. + const secret = crypto.getRandomValues(new Uint8Array(32)); + // Capture the unlock result in a non-null binding for the in-scope ops; + // `handle` stays the ownership tracker the finally block cleans up. + h = w.unlock_with_secret(msg.passphrase, secret, salt, KEYFILE_PARAMS_JSON) as SessionHandle; + handle = h; + const relkeyBytes = new Uint8Array(w.keyfile_encode(secret)); + paramsJson = KEYFILE_PARAMS_JSON; + factorStorage = { keyfileBase64: uint8ArrayToBase64(relkeyBytes) }; + responseData = { relkeyBytes, deviceName: msg.deviceName, recoveryQrAvailable: true }; + } else { + // IMAGE branch (default): existing behavior — carrier guards, stego embed, unlock. + const carrierBytes = new Uint8Array(msg.carrierImageBytes!); + // Actionable guard: the file is only a hint, + // and a broken ArrayBuffer transport would arrive empty. Both surface as + // the opaque WASM error "no SOF marker found in JPEG", so classify here + // before handing bytes to the steganography embed. + if (carrierBytes.length === 0) { + return { ok: false, error: 'carrier_image_empty' }; + } + if (carrierBytes[0] !== 0xff || carrierBytes[1] !== 0xd8) { + return { ok: false, error: 'carrier_image_not_jpeg' }; + } + const imageSecret = new Uint8Array(32); + crypto.getRandomValues(imageSecret); + const referenceImageBytes = new Uint8Array(w.embed_image_secret(carrierBytes, imageSecret)); + // Capture the unlock result in a non-null binding for the in-scope ops; + // `handle` stays the ownership tracker the finally block cleans up. + h = w.unlock(msg.passphrase, referenceImageBytes, salt, DEFAULT_PARAMS_JSON) as SessionHandle; + handle = h; + paramsJson = DEFAULT_PARAMS_JSON; + factorStorage = { imageBase64: uint8ArrayToBase64(referenceImageBytes) }; + responseData = { referenceImageBytes, deviceName: msg.deviceName, recoveryQrAvailable: true }; + } + + // --- Shared tail (both branches): encrypt init files, device registration, session --- const encryptedManifest = new Uint8Array(w.manifest_encrypt(h, '{"schema_version":2,"items":{}}')); const encryptedSettings = new Uint8Array(w.settings_encrypt(h, w.default_vault_settings_json())); const { config } = msg; const git = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken); await git.writeFileCreateOnly('.relicario/salt', salt, 'init: vault salt'); - await git.writeFileCreateOnly('.relicario/params.json', new TextEncoder().encode(DEFAULT_PARAMS_JSON), 'init: KDF parameters'); + await git.writeFileCreateOnly('.relicario/params.json', new TextEncoder().encode(paramsJson), 'init: KDF parameters'); await git.writeFileCreateOnly('manifest.enc', encryptedManifest, 'init: encrypted manifest'); await git.writeFileCreateOnly('settings.enc', encryptedSettings, 'init: encrypted settings'); - await registerDeviceAndPersistConfig(w, git, config, referenceImageBytes, msg.deviceName, h); + await registerDeviceAndPersistConfig(w, git, config, factorStorage, msg.deviceName, h); // SW now owns the unlocked session — keeps the handle alive (enables recoveryQrAvailable). session.setCurrent(h); @@ -109,7 +141,7 @@ export async function handleCreateVault( state.manifest = { schema_version: 2, items: {} } as Manifest; handle = null; // ownership transferred — do NOT lock-and-free in finally - return { ok: true, data: { referenceImageBytes, deviceName: msg.deviceName, recoveryQrAvailable: true } }; + return { ok: true, data: responseData }; } catch (err) { return { ok: false, error: err instanceof Error ? err.message : String(err) }; } finally { @@ -123,13 +155,13 @@ export async function handleCreateVault( } export async function handleAttachVault( - msg: { config: VaultConfig; passphrase: string; referenceImageBytes: ArrayBuffer; deviceName: string }, + msg: { config: VaultConfig; passphrase: string; secondFactor?: 'image' | 'keyfile'; + referenceImageBytes?: ArrayBuffer; keyfileBytes?: ArrayBuffer; deviceName: string }, state: PopupState, ): Promise { const w = state.wasm; let handle: SessionHandle | null = null; try { - const referenceImageBytes = new Uint8Array(msg.referenceImageBytes); const { config } = msg; const git = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken); @@ -139,12 +171,39 @@ export async function handleAttachVault( git.readFile('manifest.enc'), ]); - const h: SessionHandle = w.unlock(msg.passphrase, referenceImageBytes, meta.salt, meta.paramsJson); - handle = h; - // manifest_decrypt verifies the passphrase + reference image — throws on AEAD failure. + // --- Branch on second factor: derive the unlock handle + factor storage --- + // Only the secret derivation and the stored artifact differ; the verify + // (manifest_decrypt), device registration, and session wiring are shared. + let h: SessionHandle; + let factorStorage: { imageBase64: string } | { keyfileBase64: string }; + + if (msg.secondFactor === 'keyfile') { + // KEY-FILE branch: decode the uploaded .relkey to its 32-byte secret and + // derive via unlock_with_secret. We persist the armored .relkey verbatim + // (same artifact create stores) so future unlocks can re-derive. + const relkeyBytes = new Uint8Array(msg.keyfileBytes!); + let secret: Uint8Array; + try { + secret = w.keyfile_decode(relkeyBytes); + } catch { + return { ok: false, error: 'invalid_key_file' }; + } + h = w.unlock_with_secret(msg.passphrase, secret, meta.salt, meta.paramsJson) as SessionHandle; + handle = h; + factorStorage = { keyfileBase64: uint8ArrayToBase64(relkeyBytes) }; + } else { + // IMAGE branch (default): unlock with the reference JPEG (the secret is + // recovered from the embedded steganography inside the WASM unlock). + const referenceImageBytes = new Uint8Array(msg.referenceImageBytes!); + h = w.unlock(msg.passphrase, referenceImageBytes, meta.salt, meta.paramsJson) as SessionHandle; + handle = h; + factorStorage = { imageBase64: uint8ArrayToBase64(referenceImageBytes) }; + } + + // manifest_decrypt verifies the passphrase + second factor — throws on AEAD failure. const manifest = w.manifest_decrypt(h, encryptedManifest) as Manifest; - await registerDeviceAndPersistConfig(w, git, config, referenceImageBytes, msg.deviceName, h); + await registerDeviceAndPersistConfig(w, git, config, factorStorage, msg.deviceName, h); // SW now owns the unlocked session — transfer ownership to the session. session.setCurrent(h); diff --git a/extension/src/setup/__tests__/probe.test.ts b/extension/src/setup/__tests__/probe.test.ts index 2901cd8..d1028b4 100644 --- a/extension/src/setup/__tests__/probe.test.ts +++ b/extension/src/setup/__tests__/probe.test.ts @@ -6,6 +6,7 @@ function fakeHost(opts: { relicarioFiles?: string[]; rootFiles?: string[]; commit?: { sha: string; author: string; date: string } | null; + paramsJson?: string; } = {}): GitHost { return { listDir: vi.fn().mockImplementation(async (p: string) => { @@ -14,7 +15,14 @@ function fakeHost(opts: { return []; }), lastCommit: vi.fn().mockResolvedValue(opts.commit ?? null), - readFile: vi.fn(), writeFile: vi.fn(), writeFileCreateOnly: vi.fn(), + readFile: vi.fn().mockImplementation(async (p: string) => { + if (p === '.relicario/params.json') { + if (opts.paramsJson === undefined) throw new Error(`404: ${p}`); + return new TextEncoder().encode(opts.paramsJson); + } + throw new Error(`404: ${p}`); + }), + writeFile: vi.fn(), writeFileCreateOnly: vi.fn(), deleteFile: vi.fn(), putBlob: vi.fn(), getBlob: vi.fn(), deleteBlob: vi.fn(), }; } @@ -46,4 +54,24 @@ describe('probeVault', () => { expect(result.exists).toBe(true); expect(result.lastCommit).toBeUndefined(); }); + + it('reports secondFactor=keyfile when params.json declares it', async () => { + const host = fakeHost({ + relicarioFiles: ['salt', 'params.json'], + paramsJson: '{"argon2_m":65536,"argon2_t":3,"argon2_p":4,"second_factor":"keyfile"}', + }); + const result = await probeVault(host); + expect(result.exists).toBe(true); + expect(result.secondFactor).toBe('keyfile'); + }); + + it('defaults secondFactor=image when params.json omits second_factor (back-compat)', async () => { + const host = fakeHost({ + relicarioFiles: ['salt', 'params.json'], + paramsJson: '{"argon2_m":65536,"argon2_t":3,"argon2_p":4}', + }); + const result = await probeVault(host); + expect(result.exists).toBe(true); + expect(result.secondFactor).toBe('image'); + }); }); diff --git a/extension/src/setup/__tests__/setup-steps.test.ts b/extension/src/setup/__tests__/setup-steps.test.ts new file mode 100644 index 0000000..0c74796 --- /dev/null +++ b/extension/src/setup/__tests__/setup-steps.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { state, clearWizardState, renderVaultNew, renderVaultAttach, triggerRelkeyDownload } from '../setup-steps'; + +describe('triggerRelkeyDownload', () => { + afterEach(() => clearWizardState()); + + it('triggers a vault.relkey download from the relkey bytes', () => { + const dl = vi.fn(); + triggerRelkeyDownload(new Uint8Array([1, 2, 3]), dl); + expect(dl).toHaveBeenCalledWith('vault.relkey', expect.any(Blob)); + }); +}); + +describe('renderVaultNew — second-factor container choice', () => { + afterEach(() => clearWizardState()); + + it('default (image) mode shows the stego note', () => { + const html = renderVaultNew(); + expect(html).toContain('A 256-bit secret will be steganographically embedded'); + }); + + it('keyfile mode shows "Key File" and hides the carrier drop / stego note', () => { + state.secondFactor = 'keyfile'; + const html = renderVaultNew(); + expect(html).toContain('Key File'); + expect(html).not.toContain('A 256-bit secret will be steganographically embedded'); + }); + + it('keyfile mode shows the key-file note copy', () => { + state.secondFactor = 'keyfile'; + const html = renderVaultNew(); + expect(html).toContain('A 32-byte key file will be generated'); + }); +}); + +describe('renderVaultAttach — second-factor picker follows the probed vault', () => { + afterEach(() => clearWizardState()); + + it('shows the .relkey picker (and hides the JPEG picker) for a keyfile vault', () => { + state.vaultProbe = { exists: true, secondFactor: 'keyfile' }; + const html = renderVaultAttach(); + expect(html).toContain('key file (.relkey)'); + expect(html).not.toContain('reference image (JPEG)'); + }); + + it('shows the reference-image picker for an image vault (default)', () => { + state.vaultProbe = { exists: true, secondFactor: 'image' }; + const html = renderVaultAttach(); + expect(html).toContain('reference image (JPEG)'); + expect(html).not.toContain('key file (.relkey)'); + }); +}); diff --git a/extension/src/setup/probe.ts b/extension/src/setup/probe.ts index 6f13928..160bd0f 100644 --- a/extension/src/setup/probe.ts +++ b/extension/src/setup/probe.ts @@ -3,11 +3,17 @@ import type { GitHost } from '../service-worker/git-host'; export interface VaultProbe { exists: boolean; lastCommit?: { sha: string; author: string; date: string }; + // The vault's second factor, read from params.json's top-level `second_factor` + // (absent ⇒ image). Only populated when `exists` is true. Drives the attach + // wizard's picker (reference JPEG vs .relkey). + secondFactor?: 'image' | 'keyfile'; } /// Detect whether the configured remote already contains a relicario vault. /// Considered present if any of: .relicario/salt, .relicario/params.json, -/// manifest.enc exists. Best-effort metadata fetch via lastCommit(). +/// manifest.enc exists. Best-effort metadata fetch via lastCommit(). When a +/// vault is present, also resolves its second factor from params.json so the +/// attach flow can prompt for the right artifact. export async function probeVault(host: GitHost): Promise { const [relicarioFiles, rootFiles] = await Promise.all([ host.listDir('.relicario'), @@ -18,6 +24,17 @@ export async function probeVault(host: GitHost): Promise { relicarioFiles.includes('params.json') || rootFiles.includes('manifest.enc'); if (!exists) return { exists: false }; + + // Resolve the second factor from params.json (top-level `second_factor`). + // Tolerate absence or malformed JSON — older vaults predate the field and an + // unreadable params.json should not break the probe; default to image. + let secondFactor: 'image' | 'keyfile' = 'image'; + try { + const raw = await host.readFile('.relicario/params.json'); + const sf = (JSON.parse(new TextDecoder().decode(raw)) as { second_factor?: unknown }).second_factor; + secondFactor = sf === 'keyfile' ? 'keyfile' : 'image'; + } catch { /* params.json absent or unparsable — default to image */ } + const lastCommit = await host.lastCommit('manifest.enc'); - return lastCommit ? { exists, lastCommit } : { exists }; + return lastCommit ? { exists, lastCommit, secondFactor } : { exists, secondFactor }; } diff --git a/extension/src/setup/setup-steps.ts b/extension/src/setup/setup-steps.ts index 8382072..426494b 100644 --- a/extension/src/setup/setup-steps.ts +++ b/extension/src/setup/setup-steps.ts @@ -45,7 +45,9 @@ export interface WizardState { connectionTested: boolean; vaultProbe: VaultProbe | null; carrierImageBytes: Uint8Array | null; + secondFactor: 'image' | 'keyfile'; referenceImageBytesAttach: Uint8Array | null; + keyfileBytesAttach: Uint8Array | null; passphrase: string; passphraseConfirm: string; passphraseScore: number; @@ -53,6 +55,7 @@ export interface WizardState { passphraseVisible: boolean; confirmVisible: boolean; referenceImageBytes: Uint8Array | null; + relkeyBytes: Uint8Array | null; creating: boolean; attaching: boolean; error: string | null; @@ -61,9 +64,10 @@ export interface WizardState { export const state: WizardState = { stepId: 'mode', mode: null, hostType: 'gitea', hostUrl: '', repoPath: '', apiToken: '', - connectionTested: false, vaultProbe: null, carrierImageBytes: null, referenceImageBytesAttach: null, + connectionTested: false, vaultProbe: null, carrierImageBytes: null, secondFactor: 'image', + referenceImageBytesAttach: null, keyfileBytesAttach: null, passphrase: '', passphraseConfirm: '', passphraseScore: -1, passphraseGuessesLog10: -1, - passphraseVisible: false, confirmVisible: false, referenceImageBytes: null, + passphraseVisible: false, confirmVisible: false, referenceImageBytes: null, relkeyBytes: null, creating: false, attaching: false, error: null, deviceName: '', }; @@ -343,24 +347,40 @@ const connectionStep: SetupStep = { // --- vault --- -function renderVaultAttach(): string { +export function renderVaultAttach(): string { const p = state.passphrase; const pType = state.passphraseVisible ? 'text' : 'password'; const pToggle = state.passphraseVisible ? 'hide' : 'show'; - const hasImage = !!state.referenceImageBytesAttach; - const gateDisabled = !p || !hasImage; + // Mirror the probed vault's second factor — a keyfile vault needs the .relkey, + // an image vault needs the reference JPEG. Default to image (back-compat). + const isKeyfile = state.vaultProbe?.secondFactor === 'keyfile'; + const hasFactor = isKeyfile ? !!state.keyfileBytesAttach : !!state.referenceImageBytesAttach; + const gateDisabled = !p || !hasFactor; + const intro = isKeyfile + ? 'Use your existing passphrase and key file (.relkey) to attach this browser to your vault. We\'ll verify both when you register this device.' + : 'Use your existing passphrase and reference image to attach this browser to your vault. We\'ll verify both when you register this device.'; + const factorGroup = isKeyfile ? ` +
+ +
+ + ${hasFactor ? '

key file loaded

' : '

click to select your .relkey file

'} +
+

The key file is the .relkey you saved when you first created this vault. It holds the 256-bit secret.

+
` : ` +
+ +
+ + ${hasFactor ? '

reference image loaded

' : '

click to select your reference JPEG

'} +
+

The reference image is the JPEG you saved when you first created this vault — not the original photo. It has the 256-bit secret embedded.

+
`; return `

attach this device

-

Use your existing passphrase and reference image to attach this browser to your vault. We'll verify both when you register this device.

-
- -
- - ${hasImage ? '

reference image loaded

' : '

click to select your reference JPEG

'} -
-

The reference image is the JPEG you saved when you first created this vault — not the original photo. It has the 256-bit secret embedded.

-
+

${intro}

+ ${factorGroup}
@@ -375,7 +395,7 @@ function renderVaultAttach(): string {
`; } -function renderVaultNew(): string { +export function renderVaultNew(): string { const score = state.passphraseScore; const hasScore = score >= 0; const meterClass = hasScore ? `s${score}` : ''; @@ -397,6 +417,14 @@ function renderVaultNew(): string { return `

create vault

+
+ +
+ + +
+
+ ${state.secondFactor === 'image' ? `
@@ -404,7 +432,10 @@ function renderVaultNew(): string { ${state.carrierImageBytes ? '

image loaded

' : '

click to select a JPEG photo

'}

A 256-bit secret will be steganographically embedded in this image.

-
+
` : ` +
+

A 32-byte key file will be generated for you to save — it is your second factor.

+
`}
A long phrase of unrelated words is stronger than a short complex password. Your vault needs good (score ≥ 3) to continue.
@@ -447,25 +478,47 @@ const vaultStep: SetupStep = { }; function attachVaultAttach(ctx: StepContext): () => void { - const refDrop = document.getElementById('ref-drop')!; - const refInput = document.getElementById('ref-input') as HTMLInputElement; - refDrop.addEventListener('click', () => refInput.click()); - refInput.addEventListener('change', () => { - const file = refInput.files?.[0]; - if (!file) return; - const reader = new FileReader(); - reader.onload = () => { - state.referenceImageBytesAttach = new Uint8Array(reader.result as ArrayBuffer); - state.error = null; - ctx.rerender(); - }; - reader.readAsArrayBuffer(file); - }); + // Only one picker is rendered (renderVaultAttach branches on the probed second + // factor); wire whichever exists. Mirrors attachVaultNew's guard pattern — the + // inactive factor's elements are simply not in the DOM. + const isKeyfile = state.vaultProbe?.secondFactor === 'keyfile'; + const hasFactor = () => (isKeyfile ? !!state.keyfileBytesAttach : !!state.referenceImageBytesAttach); + if (isKeyfile) { + const kfDrop = document.getElementById('kf-drop')!; + const kfInput = document.getElementById('kf-input') as HTMLInputElement; + kfDrop.addEventListener('click', () => kfInput.click()); + kfInput.addEventListener('change', () => { + const file = kfInput.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + state.keyfileBytesAttach = new Uint8Array(reader.result as ArrayBuffer); + state.error = null; + ctx.rerender(); + }; + reader.readAsArrayBuffer(file); + }); + } else { + const refDrop = document.getElementById('ref-drop')!; + const refInput = document.getElementById('ref-input') as HTMLInputElement; + refDrop.addEventListener('click', () => refInput.click()); + refInput.addEventListener('change', () => { + const file = refInput.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + state.referenceImageBytesAttach = new Uint8Array(reader.result as ArrayBuffer); + state.error = null; + ctx.rerender(); + }; + reader.readAsArrayBuffer(file); + }); + } const passInput = document.getElementById('passphrase') as HTMLInputElement | null; passInput?.addEventListener('input', (e) => { state.passphrase = (e.target as HTMLInputElement).value; const btn = document.getElementById('attach-btn') as HTMLButtonElement | null; - if (btn) btn.disabled = !state.passphrase || !state.referenceImageBytesAttach; + if (btn) btn.disabled = !state.passphrase || !hasFactor(); }); document.getElementById('eye-btn')?.addEventListener('click', () => { state.passphraseVisible = !state.passphraseVisible; @@ -476,8 +529,8 @@ function attachVaultAttach(ctx: StepContext): () => void { }); document.getElementById('back-btn')?.addEventListener('click', () => ctx.goto('connection')); document.getElementById('attach-btn')?.addEventListener('click', () => { - if (!state.referenceImageBytesAttach) { - state.error = 'Please select your reference JPEG image'; + if (!hasFactor()) { + state.error = isKeyfile ? 'Please select your .relkey key file' : 'Please select your reference JPEG image'; ctx.rerender(); return; } @@ -492,20 +545,28 @@ function attachVaultAttach(ctx: StepContext): () => void { } function attachVaultNew(ctx: StepContext): () => void { - const fileDrop = document.getElementById('file-drop')!; - const fileInput = document.getElementById('file-input') as HTMLInputElement; - fileDrop.addEventListener('click', () => fileInput.click()); - fileInput.addEventListener('change', () => { - const file = fileInput.files?.[0]; - if (!file) return; - const reader = new FileReader(); - reader.onload = () => { - state.carrierImageBytes = new Uint8Array(reader.result as ArrayBuffer); - state.error = null; + document.querySelectorAll('[data-factor]').forEach((btn) => { + btn.addEventListener('click', () => { + state.secondFactor = (btn as HTMLElement).dataset.factor as 'image' | 'keyfile'; ctx.rerender(); - }; - reader.readAsArrayBuffer(file); + }); }); + if (state.secondFactor === 'image') { + const fileDrop = document.getElementById('file-drop')!; + const fileInput = document.getElementById('file-input') as HTMLInputElement; + fileDrop.addEventListener('click', () => fileInput.click()); + fileInput.addEventListener('change', () => { + const file = fileInput.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + state.carrierImageBytes = new Uint8Array(reader.result as ArrayBuffer); + state.error = null; + ctx.rerender(); + }; + reader.readAsArrayBuffer(file); + }); + } // Track passphrase changes inline (no full re-render) so the input keeps focus. // zxcvbn score is computed via the SW on a 150ms debounce — see scheduleRate. const passInput = document.getElementById('passphrase') as HTMLInputElement | null; @@ -543,7 +604,7 @@ function attachVaultNew(ctx: StepContext): () => void { document.getElementById('create-btn')?.addEventListener('click', async () => { state.passphrase = (document.getElementById('passphrase') as HTMLInputElement).value; state.passphraseConfirm = (document.getElementById('passphrase-confirm') as HTMLInputElement).value; - if (!state.carrierImageBytes) { + if (state.secondFactor === 'image' && !state.carrierImageBytes) { state.error = 'Please select a carrier JPEG image'; ctx.rerender(); return; @@ -617,32 +678,60 @@ const deviceStep: SetupStep = { if (state.mode === 'attach') { state.attaching = true; ctx.rerender(); - const resp = await swSend({ - type: 'attach_vault', - config: vaultConfig(), - passphrase: state.passphrase, - referenceImageBytes: state.referenceImageBytesAttach!.buffer as ArrayBuffer, - deviceName: state.deviceName, - }); + // Send whichever factor the probed vault uses. keyfileBytes/referenceImageBytes + // are ArrayBuffers; message-binary.ts base64-envelopes them across the channel. + const resp = state.vaultProbe?.secondFactor === 'keyfile' + ? await swSend({ + type: 'attach_vault', + config: vaultConfig(), + passphrase: state.passphrase, + secondFactor: 'keyfile', + keyfileBytes: state.keyfileBytesAttach!.buffer as ArrayBuffer, + deviceName: state.deviceName, + }) + : await swSend({ + type: 'attach_vault', + config: vaultConfig(), + passphrase: state.passphrase, + referenceImageBytes: state.referenceImageBytesAttach!.buffer as ArrayBuffer, + deviceName: state.deviceName, + }); state.attaching = false; if (resp.ok) ctx.goto('done'); else { state.error = lookupErrorCopy(resp.error).body; ctx.rerender(); } } else { state.creating = true; ctx.rerender(); - const resp = await swSend({ - type: 'create_vault', - config: vaultConfig(), - passphrase: state.passphrase, - carrierImageBytes: state.carrierImageBytes!.buffer as ArrayBuffer, - deviceName: state.deviceName, - }); - state.creating = false; - if (resp.ok) { - const data = resp.data as { referenceImageBytes: Uint8Array }; - state.referenceImageBytes = new Uint8Array(data.referenceImageBytes); - ctx.goto('done'); - } else { state.error = lookupErrorCopy(resp.error).body; ctx.rerender(); } + let resp; + if (state.secondFactor === 'keyfile') { + resp = await swSend({ + type: 'create_vault', + config: vaultConfig(), + passphrase: state.passphrase, + secondFactor: 'keyfile', + deviceName: state.deviceName, + }); + state.creating = false; + if (resp.ok) { + const data = resp.data as { relkeyBytes: Uint8Array }; + state.relkeyBytes = new Uint8Array(data.relkeyBytes); + ctx.goto('done'); + } else { state.error = lookupErrorCopy(resp.error).body; ctx.rerender(); } + } else { + resp = await swSend({ + type: 'create_vault', + config: vaultConfig(), + passphrase: state.passphrase, + carrierImageBytes: state.carrierImageBytes!.buffer as ArrayBuffer, + deviceName: state.deviceName, + }); + state.creating = false; + if (resp.ok) { + const data = resp.data as { referenceImageBytes: Uint8Array }; + state.referenceImageBytes = new Uint8Array(data.referenceImageBytes); + ctx.goto('done'); + } else { state.error = lookupErrorCopy(resp.error).body; ctx.rerender(); } + } } }); return () => {}; @@ -655,19 +744,25 @@ const doneStep: SetupStep = { id: 'done', render() { const isAttach = state.mode === 'attach'; + const isKeyfile = !isAttach && state.secondFactor === 'keyfile'; const qrBannerHtml = isAttach ? '' : `
Generate a recovery QR before you go
-

If you lose your reference image, this QR lets you recover your vault. Print it and store it safely.

+

If you lose your second factor, this QR lets you recover your vault. Print it and store it safely.

`; - const refSection = isAttach ? '' : ` + const refSection = isAttach ? '' : isKeyfile ? ` +
+ +

Save this key file — it is your second factor. Without it you cannot unlock your vault.

+ +
` : `

Download and store this image securely. It is your second factor for decryption. Without it, you cannot unlock the vault.

@@ -736,6 +831,10 @@ const doneStep: SetupStep = { document.body.removeChild(a); URL.revokeObjectURL(url); }); + document.getElementById('download-relkey-btn')?.addEventListener('click', () => { + if (!state.relkeyBytes) return; + triggerRelkeyDownload(state.relkeyBytes); + }); document.getElementById('copy-config-btn')?.addEventListener('click', async () => { const blob = document.getElementById('config-blob'); if (!blob) return; @@ -763,6 +862,27 @@ export const STEPS: ReadonlyArray = [ modeStep, hostStep, connectionStep, vaultStep, deviceStep, doneStep, ]; +// --- Relkey download helper --- + +function defaultBlobDownload(name: string, blob: Blob): void { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = name; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +export function triggerRelkeyDownload( + relkeyBytes: Uint8Array, + trigger: (name: string, blob: Blob) => void = defaultBlobDownload +): void { + const blob = new Blob([relkeyBytes.buffer as ArrayBuffer], { type: 'application/octet-stream' }); + trigger('vault.relkey', blob); +} + // --- Sensitive-state cleanup --- export function clearWizardState(): void { @@ -770,6 +890,8 @@ export function clearWizardState(): void { state.carrierImageBytes?.fill(0); state.referenceImageBytes?.fill(0); state.referenceImageBytesAttach?.fill(0); + state.keyfileBytesAttach?.fill(0); + state.relkeyBytes?.fill(0); state.mode = null; state.hostType = 'gitea'; state.hostUrl = ''; @@ -778,7 +900,9 @@ export function clearWizardState(): void { state.connectionTested = false; state.vaultProbe = null; state.carrierImageBytes = null; + state.secondFactor = 'image'; state.referenceImageBytesAttach = null; + state.keyfileBytesAttach = null; state.passphrase = ''; state.passphraseConfirm = ''; state.passphraseScore = -1; @@ -786,6 +910,7 @@ export function clearWizardState(): void { state.passphraseVisible = false; state.confirmVisible = false; state.referenceImageBytes = null; + state.relkeyBytes = null; state.creating = false; state.attaching = false; state.error = null; diff --git a/extension/src/shared/error-copy.ts b/extension/src/shared/error-copy.ts index 256a794..2d62e92 100644 --- a/extension/src/shared/error-copy.ts +++ b/extension/src/shared/error-copy.ts @@ -100,6 +100,19 @@ export const ERROR_COPY: Record = { body: 'Run setup to save your reference image.', cta: { label: 'Open setup', action: 'open_setup' }, }, + 'Key file not set. Run setup first.': { + title: 'Key file missing', + body: 'Run setup to save your key file on this device.', + cta: { label: 'Open setup', action: 'open_setup' }, + }, + invalid_key_file: { + title: 'Invalid key file', + body: 'The key file is corrupted or the wrong format — re-download it from your backup and try again.', + }, + 'unsupported second factor in vault params': { + title: 'Unsupported vault type', + body: 'This vault uses a second-factor type not supported by this version of Relicario — update the extension.', + }, }; export function lookupErrorCopy(code: string): ErrorCopy { diff --git a/extension/src/shared/messages.ts b/extension/src/shared/messages.ts index 23a51ed..7f3f57f 100644 --- a/extension/src/shared/messages.ts +++ b/extension/src/shared/messages.ts @@ -65,9 +65,10 @@ export type PopupMessage = | { type: 'generate_recovery_qr'; passphrase: string } | { type: 'unwrap_recovery_qr'; payload_b64: string; passphrase: string } | { type: 'create_vault'; config: VaultConfig; passphrase: string; - carrierImageBytes: ArrayBuffer; deviceName: string } + secondFactor?: 'image' | 'keyfile'; carrierImageBytes?: ArrayBuffer; deviceName: string } | { type: 'attach_vault'; config: VaultConfig; passphrase: string; - referenceImageBytes: ArrayBuffer; deviceName: string } + secondFactor?: 'image' | 'keyfile'; referenceImageBytes?: ArrayBuffer; + keyfileBytes?: ArrayBuffer; deviceName: string } | { type: 'get_vault_status' } | { type: 'org_list_configs' } | { type: 'org_switch'; context: string } @@ -215,7 +216,10 @@ export interface ImportLastPassCommitResponse extends Extract { - data: { referenceImageBytes: Uint8Array; deviceName: string; + // Image-mode vaults return referenceImageBytes (the stego JPEG); key-file-mode + // vaults return relkeyBytes (the armored .relkey) for the wizard to download. + // Exactly one is present per the chosen second factor. + data: { referenceImageBytes?: Uint8Array; relkeyBytes?: Uint8Array; deviceName: string; recoveryQrAvailable: true }; }