# 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 typecheck` — `tsc --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: })`.** 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/.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 `.conflict-.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).