210 lines
7.5 KiB
Markdown
210 lines
7.5 KiB
Markdown
|
|
# 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.
|