# Theseus password manager — design Built-in password manager local to Theseus, derived from a Bitcoin Cash-style seed. The seed is the single root of trust; passwords are one purpose among many (BCH wallet, messenger, future identity uses) all held under distinct hardened derivation subtrees so a leak in one purpose can't compromise another. **Status**: design + Phase 1 (local vault, settings UI, no autofill, no sync). ## Threat model **In scope:** - Local process compromise reads plaintext passwords ONLY while the vault is unlocked. Locked-vault-on-disk is opaque. - Disk exfiltration (stolen laptop, forensic image) yields only the encrypted vault. No plaintext, no seed material, no metadata about which sites the user has passwords for. - A malicious web page CANNOT ask the password manager for anything. Autofill (phase 2) will happen via a Theseus-controlled contentScript bound to the origin; there is no `window.passwords` API. **Out of scope (phase 1):** - Malware running with keyboard-input capability. (No password manager survives a keylogger.) - Physical shoulder-surfing when the vault is unlocked and shown. - Backup / cloud sync — phase 2 (opt-in, Sia). **Explicitly rejected:** - Reusing Chromium's `chrome.storage` or Electron's `safeStorage` as the sole cryptographic layer. Both are DPAPI-backed on Windows (encrypted by the OS user's DPAPI key). Fine as a *belt* alongside our AES-GCM *suspenders*, not as a standalone. ## Crypto ### Root The user provides ONE of: - Their BCH wallet seed (BIP39 mnemonic) — unified identity, one backup - A fresh seed generated in Phase 1 setup — isolated from any BCH funds Seed is never persisted by the password vault. What is persisted is a per-purpose derived root, encrypted under the master password. ### Hardened derivation discipline Every purpose gets its own subtree, using a purpose byte that is distinct from BIP44's coin-type space: ``` BIP32-style: m / purpose' / subpurpose' Passwords: m / 1381' / 0' (1381 = 0x555 = arbitrary picked; documented) Messenger: m / 1414' / 0' (reserved for future) BCH wallet: m / 44' / 145' (SLIP-44 coin 145 — untouched) ``` Hardened (`'`) means the parent public key alone cannot derive child keys — you need the parent private key. So even if a password-purpose child key leaks, an attacker cannot walk backward to the BCH wallet subtree. ### Vault key ``` master_key_material = PBKDF2( masterPassword, salt = 16 random bytes stored in vault header, iterations = 200_000, hash = SHA-256, keylen = 32 ) vault_key = AES-256-GCM key(master_key_material) ``` 200k PBKDF2 iterations balances phone-CPU login latency (~200ms) against brute-force cost. Bumped to 600k on desktop-detected CPUs in Phase 2. ### Vault encryption ``` vault_on_disk = { version: 1, kdf: { name: "PBKDF2", iters: 200_000, salt: }, iv: <12-byte hex>, ciphertext: , // AES-GCM(vault_key, iv, JSON.stringify(plaintext)) tag: <16-byte hex, appended>, } plaintext = { purposeRoot: <32-byte hex>, // the m/1381'/0' node — derived once at setup entries: [ { id, domain, username, addedAt, // one of: literal: , // legacy pasted password (encrypted with vault_key) generated: { version, rules } // deterministic — re-derived from purposeRoot on demand }, ... ] } ``` Every entry carries a stable `id` (UUIDv4) so autofill (phase 2) can bind by id, not by domain+username (which can change). ### Deterministic derivation recipe (the "Generate" button) For a `generated` entry, the password is not stored — it's computed: ``` info = "silentmode-passwords-v1|" + domain + "|" + username + "|v" + version bits = HKDF(hash=SHA-256, key=purposeRoot, salt=, info) → 32 bytes password = mapBytesToRules(bits, rules) ``` `rules` default: ``` { length: 20, upper: true, lower: true, digits: true, symbols: true } ``` `mapBytesToRules` is a template scheme: take the first 4 bytes to seed a DRBG, produce N chars from the requested character classes with guaranteed inclusion of at least one from each enabled class. Same input → same password on every device holding the seed. Version-bumping (`v2` etc.) is how a user "rotates" a deterministic password without ever losing the old one — old services rejecting a rotation can still be logged into by looking up v1. ## Vault file Location: `/passwords.vault` (single file). Never written unencrypted. On save: build the new plaintext, encrypt with a fresh IV, write atomically (`.tmp` + rename). The file's presence is not itself sensitive — it just says "this user has opted into the password manager". Contents are opaque. ## Runtime - **Unlock state** lives in the main process only. Never sent to renderers in plaintext except in response to explicit `password-get(id)` calls. - Vault stays unlocked for the current session. Auto-locks on: - Explicit lock button - App quit (before the storage-clear ran) - N minutes of settings-page inactivity — phase 2 knob - No BROWSER autofill in phase 1. Users copy from Settings → Passwords. ## UI (phase 1) New sidebar entry in Settings between Search and Naming: **Passwords**. Two states: **Locked / not-yet-set-up:** - "Set up password vault" — one-time form: - Master password (with confirm) - Source of derivation seed: "Use my Ariadne wallet seed" (default) OR "Generate a new seed for passwords" - "Create vault" — writes the encrypted vault file - "Unlock vault" (when the file exists) — master password only **Unlocked:** - List of entries: favicon + domain + username + reveal / copy / delete - "Add new entry": domain, username, password (paste) OR "Generate" button - "Lock now" at the top-right ## IPC surface (through settings-preload) ``` password-status() → { setup: bool, unlocked: bool } password-setup(masterPw, seedSource) → { ok: true } | { err } password-unlock(masterPw) → { ok: true, entries: [...] } | { err: "bad password" } password-lock() → true password-list() → array of entries (metadata only) password-get(id) → { password: } (only while unlocked) password-add(entry) → id password-update(id, patch) → true password-remove(id) → true password-generate({ domain, username, version, rules }) → <plaintext string> ``` Renderers NEVER see the seed / purposeRoot / vault key / master password past the unlock call. ## Phase 2 — autofill + Sia backup - **Autofill**: contentScript watches `input[type=password]` on load, binds by (public-suffix-list-derived) eTLD+1 origin so `evil-google.com` can't fill `google.com` entries. Toolbar key icon + right-click "Fill password" menu. - **Sia backup**: user provides a Sia S3 endpoint + credentials (or reuses the operator relay). Vault encrypted-blob is uploaded on save; restored on new device by pointing at the same endpoint with the master password. ## Phase 3 — unified identity - Same seed → Nostr messaging keys under `m/1414'/0'`. Compatible with all Nostr clients (secp256k1 keys, npub/nsec encoding). - `window.bcnr` provider spec (Web3-style) exposing signed BCNR name operations to Silent Mode pages — decision to be made per SECURITY.md. ## Explicitly not doing - Cloud sync via anything but Sia. No opinionated third-party. - Chromium's password autofill UI. Its ergonomics are Google-Sync-shaped and don't fit our threat model. - Silent-Mode-only browser extension. The whole thing is built-in — no install / no separate origin / no extension permissions to grant.