Go to file
2026-04-23 01:33:16 +02:00
icons feat: padlock toolbar icon with spiral keyhole 2026-04-23 01:33:13 +02:00
src feat: vault popup with search, autofill and clipboard copy 2026-04-23 01:33:04 +02:00
.gitignore chore: scaffold MV3 extension with TypeScript + esbuild toolchain 2026-04-23 01:24:59 +02:00
CLAUDE.md docs: add README and project notes 2026-04-23 01:33:16 +02:00
esbuild.config.mjs chore: scaffold MV3 extension with TypeScript + esbuild toolchain 2026-04-23 01:24:59 +02:00
manifest.json feat: padlock toolbar icon with spiral keyhole 2026-04-23 01:33:13 +02:00
package-lock.json chore: scaffold MV3 extension with TypeScript + esbuild toolchain 2026-04-23 01:24:59 +02:00
package.json chore: scaffold MV3 extension with TypeScript + esbuild toolchain 2026-04-23 01:24:59 +02:00
README.md docs: add README and project notes 2026-04-23 01:33:16 +02:00
tsconfig.json chore: scaffold MV3 extension with TypeScript + esbuild toolchain 2026-04-23 01:24:59 +02:00

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/<uuid>.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 <uuid>.conflict-<ts>.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/
    <uuid>.enc             # iv(12B) || ciphertext || gcm-tag(16B)
    <uuid>.conflict-<ts>.enc   # conflict sidecar, if any

vault-meta.json:

{
  "version": 1,
  "kdf": {
    "name": "PBKDF2",
    "hash": "SHA-256",
    "iterations": 600000,
    "salt": "<base64 16B>"
  },
  "verifier": "<base64: AES-GCM-encrypted known plaintext>"
}

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-<deviceIdPrefix>@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 <uuid>.enc, the loser is written as <uuid>.conflict-<ts>.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.