theseus/DESIGN-password-manager.md

210 lines
7.5 KiB
Markdown
Raw Normal View History

# 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: <hex> },
iv: <12-byte hex>,
ciphertext: <hex>, // 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: <ciphertext>, // 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=<zero>, 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: `<userData>/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: <plaintext> } (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.