Several concurrent workstreams committed together as a checkpoint: - Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic, AndroidManifest + build.ps1 - Theseus password manager — settings.html/chrome.html/settings-preload.js UI + main.js wiring + package.json resource; Argus password-vault.js, record-picker.js (+ tests) and resolver-web.d.ts - Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored) - Argus public-gateway.mjs updates - Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant), site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup, Decentralized Storage map, coordination notes - .gitignore — exclude /.keys/ and Hephaestus/.env
7.5 KiB
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.passwordsAPI.
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.storageor Electron'ssafeStorageas 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 soevil-google.comcan't fillgoogle.comentries. 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.bcnrprovider 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.