theseus/DESIGN-password-manager.md
Local Dev 0888048ace Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
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
2026-08-14 23:17:18 +02:00

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.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.