From 0a8ead0f8511da38cf6f72a995b09521fdc99f7d Mon Sep 17 00:00:00 2001 From: schmop Date: Thu, 23 Apr 2026 01:33:16 +0200 Subject: [PATCH] docs: add README and project notes --- CLAUDE.md | 60 +++++++++++++++++++++++++++++ README.md | 113 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 CLAUDE.md create mode 100644 README.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..532779c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,60 @@ +# 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). diff --git a/README.md b/README.md new file mode 100644 index 0000000..c135df0 --- /dev/null +++ b/README.md @@ -0,0 +1,113 @@ +# Passchmop + +A Firefox password manager whose vault is a plain git repository. Every entry is a client-side-encrypted blob; the remote only ever sees ciphertext. Multiple devices sync by pushing to the same repo — conflicts resolve deterministically on-device. + +No server. No cloud account. You bring the repo. + +## Features + +- **Your repo is the backend.** Any HTTPS-accessible git remote works — GitHub, GitLab, Gitea, Forgejo, self-hosted. One encrypted file per entry under `entries/.enc`. +- **Your device holds the key.** Master password → PBKDF2-SHA256 (600 k iterations, 16-byte random salt) → AES-GCM-256. The remote sees only ciphertext; no verifier for the password is ever transmitted in the clear. +- **Cross-device sync.** Sync on popup open, on every add/edit/delete, and on a 2-minute background timer. The vault stays unlocked for 5 minutes of inactivity (timer resets on use). +- **Deterministic conflict resolution.** Edit/edit produces a `.conflict-.enc` sidecar the user resolves in the UI. Edit/delete always keeps the edit (no silent data loss). Add/add is impossible because entry IDs are random UUIDs. +- **Origin-aware list.** Entries matching the active tab's origin surface to the top; URL prefill on "+ Add" uses just the origin. +- **Autofill button.** Click ✎ Autofill on an expanded entry — the popup injects a small form-heuristic script into the active tab via `browser.scripting.executeScript`, finds the first visible `input[type=password]`, walks back for the nearest username-ish input in the same form, and fills both using the prototype setter so React/Vue/Angular controlled inputs observe the change. +- **Clipboard auto-clear.** Copied credentials blank out of the clipboard after 30 s. + +## Install from source + +``` +git clone …/passchmop.git +cd passchmop +npm install +npm run build +npx web-ext run +``` + +`web-ext run` launches a temporary Firefox instance with the extension loaded. For permanent install, package with `npm run package` and install the resulting `.xpi` via `about:debugging → This Firefox → Load Temporary Add-on` (or sign it for proper installation). + +**Firefox 115+ required** — we rely on Manifest V3 features including `browser.storage.session`. + +## First-run setup + +1. Click the Passchmop toolbar icon → "Open setup". +2. Enter your git repo URL (HTTPS), a username, and a personal access token with write access to the repo. +3. Pick **Create new vault** (empty repo, or a repo that only contains an auto-README) or **Join existing vault** (a repo that was set up by another Passchmop device). +4. Choose a master password. It never leaves this device. +5. The extension commits `vault-meta.json` and an empty `entries/` directory, then pushes. On a repo with an existing non-Passchmop commit (e.g. an auto-created README) it rebases the init commit on top; on a repo that already contains a vault it refuses and directs you to "Join existing". + +**Your master password is unrecoverable.** There is no reset flow on the server side. Lose it and the vault is ciphertext bricks. + +## Dev workflow + +``` +npm run build # one-shot bundle into build/ +npm run watch # rebuild on file change +npm run typecheck # tsc --noEmit (strict + noUncheckedIndexedAccess) +npx web-ext run # launch Firefox dev instance +npx web-ext lint # sanity check the manifest + bundle +``` + +- `src/background/` — the event page (vault state, git sync, crypto, message router). +- `src/popup/` — toolbar popup UI. +- `src/options/` — first-run setup and settings. +- `src/common/` — shared types + typed message API. +- Build output lands in `build/` (gitignored). + +Architecture and non-obvious invariants are documented in `CLAUDE.md`. + +## Vault layout on disk + +``` +your-repo/ + vault-meta.json # KDF params + verifier. Not secret. + entries/ + .enc # iv(12B) || ciphertext || gcm-tag(16B) + .conflict-.enc # conflict sidecar, if any +``` + +`vault-meta.json`: + +```json +{ + "version": 1, + "kdf": { + "name": "PBKDF2", + "hash": "SHA-256", + "iterations": 600000, + "salt": "" + }, + "verifier": "" +} +``` + +Each entry's plaintext is UTF-8 JSON: `{ id, title, url, username, password, notes, created_at, modified_at, device_id }`. + +## Security model + +- The master password derives the AES-GCM key. The derived key is held in memory while the vault is unlocked; its raw bytes are also mirrored into `browser.storage.session` so the key survives Firefox MV3's event-page suspensions. `storage.session` is in-memory only and is wiped on browser restart. +- The idle-lock alarm clears both the in-memory key and the session copy after 5 minutes of inactivity, or immediately when you click Lock. +- Repo credentials (username + PAT) are encrypted with the master key before hitting `storage.local`. Without the master password, a compromised extension profile yields only ciphertext + KDF parameters. +- The remote is assumed hostile-adjacent: it sees the number of entries, their sizes, and commit timing, but not their contents. Commit author is `passchmop-@passchmop.local`, not your real identity. +- `host_permissions: ["https://*/*"]` is broad. It's needed because the background script's `fetch` must bypass CORS against arbitrary git hosts (none of them set CORS headers on smart-HTTP endpoints). Narrow it after first-run configuration if you prefer — requires an extension reload. + +## Conflict resolution, concretely + +- Each PUT op carries a `baseModifiedAt` — the `modified_at` of the version you started editing from. On sync, if `remote.modified_at === baseModifiedAt`, it's a linear update; the remote is overwritten with no sidecar. Anything else is genuine divergence. +- Divergence → the newer version becomes `.enc`, the loser is written as `.conflict-.enc`. Both show up in the list (the sidecar is flagged with a `conflict` badge). +- Resolve a conflict by picking which entry to keep and deleting the other. +- Edit vs delete → edit wins. Delete vs delete → clean no-op. + +## Known limitations + +- No SSH transport. Browser fetch can't speak ssh; HTTPS + PAT is the only option. +- No content-script autofill on page load. Autofill is explicit (you click the button), by design. +- No password generator UI beyond the `↻ gen` button on the edit form. +- No TOTP / secure notes / attachments in v1. The free-text `notes` field can hold whatever. +- Background sync is best-effort: Firefox MV3 event pages can be suspended. Sync always runs when you open the popup, so incoming changes from other devices appear then. +- Salt is fixed at vault creation; rotation would require re-encrypting every entry (not in v1). +- Chrome / other-browser support is untested. The manifest is MV3 and mostly portable, but `browser.*` globals and some Firefox specifics would need work. + +## License + +TBD.