passchmop/CLAUDE.md

5.2 KiB

Passchmop — working notes

Firefox MV3 extension. Git-backed, client-side-encrypted password vault. TypeScript, esbuild, isomorphic-git. No server — the remote is a plain git repo.

Layout

  • src/background/ — event page. vault.ts owns state and key material; sync.ts owns git + conflict resolution; crypto.ts is Web Crypto wrappers; fs.ts wraps lightning-fs; index.ts is the message router + alarms.
  • src/popup/ — main UI. popup.ts is the view; autofill.ts is injected into tabs via browser.scripting.executeScript and must stay self-contained (serialized through func).
  • src/options/ — setup and settings page.
  • src/common/ — shared types, the typed api object for background↔UI messages, and polyfills.ts (Buffer re-export, injected by esbuild into the background bundle only).

Build & check

  • npm run build — esbuild bundles the three entry points into build/.
  • npm run watch — same, in watch mode.
  • npm run typechecktsc --noEmit. Run before committing non-trivial changes; esbuild does not type-check.
  • npx web-ext run — launches Firefox with the extension loaded.
  • npx web-ext lint --source-dir=. — 0 errors expected. Two DANGEROUS_EVAL warnings come from bundled deps (pako new Function) and are acceptable for local loading.

Non-obvious invariants — do not regress these

Git / isomorphic-git

  • Never git.checkout({ ref: <SHA> }). Checkout by SHA leaves HEAD detached; commit() then advances HEAD but not refs/heads/main, and push('main') silently sends the stale ref. Always writeRef main first, then git.checkout({ ref: 'main', force: true }).
  • applyPut / applyDelete must read the remote blob via git.readBlob(remoteOid, filepath), not from the working tree. vault.put() pre-writes blobs to the workdir for immediate UI feedback, and checkout -f does not remove untracked files. Reading the workdir would treat our own fresh-IV encryption as a divergent remote and spawn phantom conflict sidecars.
  • createVault handles three remote states: empty (init + push), has main without a vault (base our init commit on it — e.g. auto-created README), has a vault (refuse, point user at "Join existing").
  • esbuild → isomorphic-git ESM: the . subpath export only points at the CJS entry, which does require('crypto') and breaks browser bundling. The config has a tiny plugin (isogitEsmPlugin) that redirects the bare specifier isomorphic-git to node_modules/isomorphic-git/index.js. Don't drop it.
  • Buffer polyfill is injected only into the background bundle via inject: [src/common/polyfills.ts]. isomorphic-git's index-file code uses Buffer.from / Buffer.alloc / Buffer.concat at module scope.

Crypto / session

  • deriveKey returns an extractable CryptoKey (extractable: true). We export raw bytes into browser.storage.session so the vault stays unlocked across Firefox MV3 event-page suspensions. storage.session is in-memory only; the trade-off is a live memory artifact, not disk persistence.
  • Idle lock: idleCheck fires on a 30 s alarm. Any background message handler calls vault.touchActivity() on success — except lock and reset. Do not bump activity from those.
  • Master password → PBKDF2-SHA256, 600 000 iterations, 16-byte random salt. Salt is in vault-meta.json (not secret). A verifier blob in the same file lets unlock detect the wrong password before touching entries.

Conflict resolution

  • Entries live at entries/<uuid>.enc; UUIDs are random, so add/add is impossible.
  • A PUT op carries baseModifiedAt — the modified_at of the version we edited from. If the remote's current modified_at === baseModifiedAt, it's a linear edit → overwrite, no sidecar. Only genuine divergence produces a <uuid>.conflict-<ts>.enc sidecar.
  • Edit vs delete: edit wins (no silent data loss). Delete vs delete: no-op. Sidecar delete is always permitted (it's how the user resolves a conflict from the UI).

Transport

  • Extension background has host_permissions: ["https://*/*"] and bypasses CORS. No git host sets CORS headers on smart-HTTP endpoints; this is the workaround. A user-configurable CORS proxy URL exists as a fallback.
  • Firefox browser.scripting.executeScript func is typed as returning void in @types/firefox-webext-browser. The real API serialises return values into InjectionResult.result. The wrapper execInTab in popup.ts keeps a typed return path.

Style

  • Keep popups type-checked under noUncheckedIndexedAccess. When iterating Uint8Array via index, use ! or restructure.
  • Don't add content scripts unless there's a reason — v1 autofill is explicitly popup-driven (user must click Autofill button), not passive.
  • The api object in src/common/messages.ts is the only way the UI talks to background; keep message shapes in src/common/types.ts.

Things that are out of scope (v1)

  • Content-script autofill on page load, context-menu fill, form heuristics beyond the simple one in autofill.ts.
  • Password generator UI beyond the ↻ gen button in the edit form.
  • TOTP, secure notes, attachments.
  • Salt rotation / re-encryption.
  • Chrome port (should mostly work; untested).
  • SSH transport (impossible from a browser).