passchmop/CLAUDE.md

61 lines
5.2 KiB
Markdown

# 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: <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).