diff --git a/DESIGN-wallet-multi-account-amendment.md b/DESIGN-wallet-multi-account-amendment.md new file mode 100644 index 0000000..426247f --- /dev/null +++ b/DESIGN-wallet-multi-account-amendment.md @@ -0,0 +1,436 @@ +# Theseus wallet — multi-account amendment + +Amendment to [`DESIGN-integrated-wallet.md`](DESIGN-integrated-wallet.md) §0 +("Multi-account UX. One account per Theseus profile in v1."). Proposes lifting +that restriction under a specific discipline that keeps the single-seed HKDF +purpose-subtree model intact. + +Status: **draft** — not accepted, not scheduled. Written 2026-09-09 as the +formal path to "Theseus recognises the master password and shows all of my +existing wallets, including externally-generated ones", raised while migrating +the Deviant `Keys/chipnet-keystore.json` to master-password encryption. + +Companion documents: +- [`DESIGN-integrated-wallet.md`](DESIGN-integrated-wallet.md) — the v1 design this amends +- [`DESIGN-password-manager.md`](DESIGN-password-manager.md) — the crypto discipline (PBKDF2, HKDF, purpose subtrees) +- [`../Argus/src/lib/password-vault.js`](../Argus/src/lib/password-vault.js) — the implemented vault primitives +- [`../Argus/src/lib/wallet.js`](../Argus/src/lib/wallet.js) — the just-landed cross-repo loader (Option A) that resolves `loadWallet('sirius.x')` from either SilentMode's own `wallets.json` or Deviant's encrypted `Keys/chipnet-keystore.json` + +--- + +## 0. Why amend now + +Three facts changed since §0 was written: + +1. **The user has 15 chipnet wallets already** — 5 HD seeds plus 10 single-key + WIFs — spread across Deviant (`Keys/chipnet-keystore.json`, encrypted with + PBKDF2/AES-256-GCM and the master password) and SilentMode + (`Argus/wallets.json`, plaintext). Consolidating them under Theseus is the + natural next step now that both projects use the same master password. +2. **`password-vault.js` already ships** the primitives that make this cheap: + `unlockVault(path, pw)` returns a state object; `seedToPurposeRoot(seed, + purpose)` derives an HKDF subtree deterministically; `bytesToHex/hexToBytes` + are the wire format. There is no new crypto to design — only new state to + serialise. +3. **The Deviant wallet dApp (`shells/app/`) already has multi-account UX**, + including `MultiKeyStore.WalletSummary { id, label, network, cashaddr, kind: + 'independent' | 'hd-account' }`. The alternative to amending Theseus is to + host that dApp inside Theseus. This document argues the amendment is + *additionally* worth doing, not a replacement for that path. + +The core question §0 punted on was not "is multi-account complicated?" — it was +"does supporting external key material contaminate the single-seed model?" +This document answers: **no, if we keep external material in a distinct storage +lane with a separate origin tier in every UI surface.** + +--- + +## 1. What "multi-account" means here — three distinct notions + +We need to keep these apart because they have different threat models: + +| Kind | Storage | Origin | Recovery | Example | +|---|---|---|---|---| +| **Primary account (unchanged)** | HKDF subtree under `silentmode/wallet/0/` | Derived from the profile's single seed | Recover the seed → recover every primary account | The wallet a fresh Theseus profile creates for you | +| **Primary sub-accounts (new)** | HKDF subtree at index N under the same purpose root | Same seed | Same seed | "Trading", "Savings", "GMX game bets" — different indexes off the same profile seed | +| **External accounts (new)** | Separately encrypted blob keyed by the same master password | Independent (imported seed OR raw WIF) | Recover the *external material* — the profile seed does NOT recover them | Deviant's `sirius.x`, `bns-beacon`, the a-g throughput test WIFs | + +The confusion §0 avoided was mixing categories 1 and 3 in one bag. The +amendment keeps them **in separate files** on disk. The user sees one unified +UI, the crypto keeps them completely isolated. + +--- + +## 2. Threat-model deltas from §1 of the base doc + +### 2.1 New surface: importing external key material + +Every import path is a potential exfiltration event. Three attack shapes: + +- **Shoulder-surf on paste** — someone reads the seed / WIF as it's typed or + pasted. Mitigation: never render the imported string; the modal shows only a + masked derived cashaddr after the import completes ("Import a wallet ending + in …abc123?"). No "show me the WIF I just imported" affordance. +- **Import from clipboard by malicious page** — a page holding + `navigator.clipboard.readText()` permission could feed material INTO the + import flow. Mitigation: **import is a settings-page action only**, never + reachable from `window.bcnr` or WizardConnect. There is no + `bcnr.importWallet()` method. Ever. +- **Import from disk by malicious extension** — Theseus itself can't fully + defend against this (§1.4 base doc), but the imported material lives in a + separately-encrypted file so a compromised in-memory purpose-root leak does + not leak the imports. + +### 2.2 New surface: rendering many wallets in one UI + +The user's clicks now cost more, because a wrong-account signature is +irrecoverable. Mitigations: + +- **Explicit signer selection per sign-request.** The sign modal shows + `Signing as: [Sirius.x · BNS name buyer ▾]` with the account picker OPEN by + default (not collapsed). If a page requested a specific account via + `bcnr.requestAccount({ account: 'sirius.x' })`, the modal still shows every + account the user could have chosen — the requested one is pre-selected and + the picker collapsed, but expanding it is one click. +- **Category badges everywhere the account appears.** The wallet metadata + already carries a `category` field (`bns`, `bns-infra`, `chipnet-test`, + `operational`, …). The modal chip is coloured by category so `sirius.x` + (BNS) is visually distinct from `gmx` (operational) even if the user only + glanced at the modal. +- **"Recently used" is per-origin.** `deviant.bch` remembers which account you + last signed with — but it does not silently reuse it; it pre-selects it in + the modal, and a category change ("last time you used a `bns` account here, + now the page wants an `operational` one") gets a red warning banner. + +### 2.3 What we still do NOT defend against (unchanged from base doc §1.4) + +Imports of a seed the user already leaked, RCE on Theseus itself, keylogger. +These remain out-of-scope; the amendment does not widen the scope, only the +attack surface within it. + +--- + +## 3. Storage model + +### 3.1 The primary vault stays as designed + +`/wallet.enc` (from `DESIGN-integrated-wallet.md` §6) holds the +profile's single seed. Every primary sub-account is derived on demand via +`seedToPurposeRoot(seed, "wallet/0")` → BIP32-style index. Nothing about the +existing design changes. + +### 3.2 New file: `/wallet-imports.enc` + +Same crypto envelope as `wallet.enc` (PBKDF2-SHA256 → AES-256-GCM), same +master password, separate KDF salt and IV. This is the ONLY place external key +material lives. Format after decryption: + +```json +{ + "version": "silentmode/wallet-imports/1", + "generated": "2026-09-09T12:00:00Z", + "accounts": { + "sirius-x": { + "kind": "seed", + "seed": "…", + "path": "m/44'/145'/0'/0/0", + "cashaddr": "bchtest:qzy92wznza359wzqz5r732wylfyrgl4djsl2agdp0p", + "label": "Sirius.x · BNS name buyer", + "category": "bns", + "source": "imported-from-D:/Dev/Deviant/Keys/chipnet-keystore.json#sirius-x", + "createdAt": 1725840000000 + }, + "gmx": { + "kind": "wif", + "wif": "…", + "cashaddr": "bchtest:qz040z8hza8n96llrk5kewp5dmyr5rd7csj8rasu9d", + "label": "GMX platform operator", + "category": "operational", + "source": "imported-from-D:/Dev/Deviant/Keys/chipnet-keystore.json#gmx", + "createdAt": 1725840000001 + } + } +} +``` + +The `accounts` shape deliberately mirrors the fields already used by Deviant's +`chipnet-keystore/1` format (`cashaddr`, `label`, `category`, `source`, `path`, +`seed`, `wif`) so a Deviant → Theseus import is a one-to-one field mapping, +not a schema translation. + +**Why a separate file, not one file with two sections?** Blast radius. A bug +that corrupts `wallet-imports.enc` cannot destroy the primary seed. A revoke +"remove all imports" clears one file — the primary vault is untouched. This +is the same discipline `DESIGN-password-manager.md` uses to keep the password +vault distinct from the seed vault: distinct files, same master password. + +### 3.3 One-way pointer: primary → imports, never the other way + +The primary vault gets a new small field: + +```json +{ …existing wallet.enc payload…, "importsExist": true } +``` + +...updated on every import/revoke. Loading the primary vault tells Theseus +whether it should also try to decrypt `wallet-imports.enc`. The **imports +file NEVER references the primary** — an attacker who exfiltrates +`wallet-imports.enc` learns nothing about the profile seed, not even its +existence. + +### 3.4 What the master password unlocks in one session + +On `unlockVault(masterPw)`: + +1. Derive `vaultKey` (PBKDF2 with `wallet.enc`'s salt). +2. Decrypt `wallet.enc` → get profile seed → derive `purposes/wallet/0`, + `purposes/passwords/0`, `purposes/messenger/0`. +3. If `importsExist`: derive `vaultKey'` (PBKDF2 with + `wallet-imports.enc`'s DIFFERENT salt — deliberately not the same key + material) and decrypt. + +Both keys are held in RAM for the session; both are wiped on auto-lock. A +malicious page that gets an in-memory read of the wallet purpose root learns +nothing about imports. + +--- + +## 4. UX + +### 4.1 Settings > Wallet — the accounts panel + +Replaces the base doc's "Account row: address, balance, backup phrase". + +``` +── Accounts ───────────────────────────────────────────────────── + [+ Create sub-account] [+ Import external wallet] + + ● Primary [rename ▎revoke] + bchtest:qxxx…yyy · category "primary" + + ● Trading (primary sub-account #1) [rename ▎revoke] + bchtest:qaaa…bbb · category "primary" + + ── Imported ───────────────────────────────────────────────── + ● Sirius.x · BNS name buyer [rename ▎revoke] + bchtest:qzy…dp0p · category "bns" + source: Deviant/Keys/chipnet-keystore.json#sirius-x + + ● BNS TLD Beacon (.asm/.x) [rename ▎revoke] + bchtest:qzc…pmerh · category "bns-infra" + source: Deviant/Keys/chipnet-keystore.json#bns-tld-beacon + + ● GMX platform operator [rename ▎revoke] + bchtest:qz0…rasu9d · category "operational" + source: Deviant/Keys/chipnet-keystore.json#gmx +───────────────────────────────────────────────────────────────── +``` + +- Primary and sub-accounts always appear above imports. This is not just + cosmetic — it reflects that the profile seed is the wallet's identity. +- Imports are grouped by `category` when there are more than 5. `bns`, + `bns-infra`, `operational`, `chipnet-test` collapse into collapsible groups. +- Revoke removes an import (from `wallet-imports.enc`); it does NOT touch the + on-chain material and warns clearly: "The on-chain wallet still exists. Its + seed/WIF is only removed from this Theseus profile." +- **No affordance to view seed / WIF material after import.** Recovery lives + in the SOURCE (Deviant's keystore, or wherever the user got it from). §2.1 + shoulder-surf mitigation. + +### 4.2 Sign-request modal — the account picker + +Same modal as base doc §5, extended: + +- Account row becomes `Signing as: [dropdown ▾]`. +- Dropdown lists every unlocked account, grouped: Primary → sub-accounts → imports. +- Each row shows label + truncated cashaddr + category chip. +- Origin's "recently used" account is pre-selected (§2.2 above). +- If the calling page passed `bcnr.requestAccount({ account: 'sirius.x' })` + and the account exists, it's pre-selected. If it doesn't exist, the modal + says "This site is asking for an account you don't have. Sign as:" and the + user picks from what they do have. + +### 4.3 Import flow + +Two entry points, both in Settings > Wallet: + +1. **"Import external wallet" → paste seed OR WIF.** + The pasted string never renders back; the modal shows only what will be + stored (`kind`, derived `cashaddr` truncated, `label` input, `category` + picker). + +2. **"Import from Deviant keystore" (if `D:/Dev/Deviant/Keys/chipnet-keystore.json` + is reachable).** Detects the file, prompts for its master password (which + must equal Theseus's — refuses otherwise with a clear message: "The Deviant + keystore uses a different master password. Change one of them first."), + decrypts, shows a preview list of every entry (label + cashaddr + category + only), user picks which to import. Bulk-select shortcuts by category + ("all `bns`", "all `operational`"). + +The "different master password" rejection is a deliberate ergonomic +constraint: we choose the pain of "unify your passwords once" over the pain +of "which password unlocks what in this profile". + +--- + +## 5. API surface for pages (deltas from §3 of base doc) + +Backwards-compatible additions only: + +```ts +bcnr.requestAccount({ account?: string, category?: string }): Promise +// If neither `account` nor `category` given: current behaviour (returns cashaddr +// of the default account, or null if not granted). +// If `account`: requests a specific labelled account. Modal pre-selects it; if +// it doesn't exist, the modal falls back to a picker over what the user has. +// If `category`: requests any account in that category (e.g. "bns"). Useful +// for the SilentMode registrar UI which knows it wants a BNS-holding wallet +// but not which one. + +bcnr.getAccounts({ category?: string }): Promise> +// Returns granted accounts. Optionally filtered by category. Note: the user +// grants per-account, not per-category — this method never leaks accounts the +// origin was not granted access to. +``` + +The `bcnr.signMessage` / `bcnr.sendPayment` / `bcnr.registerName` signatures +gain an optional `account?: string` parameter that pre-selects an account in +the modal. Non-passing preserves current behaviour. + +**Not added:** no method to import, revoke, or rename. All state-modifying +account operations are settings-page only (§2.1). + +--- + +## 6. Compatibility with Option A (the cross-repo loader that shipped 2026-09-09) + +Option A lets `Argus/src/lib/wallet.js:loadWallet('sirius.x')` transparently +read Deviant's encrypted keystore. That path is authoritative today and stays +authoritative after this amendment ships: + +- Theseus imports are **copies**, not sources of truth. Revoking an import in + Theseus does not affect Deviant's keystore or SilentMode's `wallets.json`. +- If the user rotates a key in Deviant's keystore, the corresponding Theseus + import goes stale. Theseus should detect this by comparing derived + `cashaddr` on unlock — a mismatch surfaces a banner: "The Deviant source of + Sirius.x has changed since this import. [Re-import] or [Remove]." +- The two systems remain independent by design. This is not a bug; it is the + same discipline that keeps password-vault seeds separate from wallet seeds + (§3.4 base doc). + +--- + +## 7. Sequencing — where this slots into the base roadmap + +Adds to the base doc's §7: + +| Phase | What ships | Depends on | +|---|---|---| +| B.3d | Sub-account creation (primary purpose subtree, indexes 1..N) — `Trading` / `Savings` labels | B.3a shipped | +| **M.1** | Import external seed / WIF, `wallet-imports.enc` file, account picker in sign modal | B.3b + `password-vault.js` shipped ✓ | +| **M.2** | Import from Deviant keystore (file detection + bulk-select UX) | M.1 + `chipnet-keystore/2-encrypted` format stable ✓ | +| **M.3** | `bcnr.requestAccount({ account, category })` + `getAccounts({ category })` API extensions | M.1 | +| **M.4** | Stale-import detection (source-file cashaddr comparison) | M.2 | + +M.1 is the load-bearing phase — everything else composes on top of the +`wallet-imports.enc` file. M.2 through M.4 are refinements the user's specific +setup makes worthwhile. + +Do NOT ship M.2 (Deviant integration) before M.1 (generic import). The +generic-first path keeps the design honest — if the amendment cannot be used +by a user who never touches Deviant, we've over-fitted. + +--- + +## 8. Open questions — decide before writing M.1 code + +1. **What's the primary account's "category"?** Options: `primary` (mirrors + the model), leave the field null, or use the user-picked purpose ("Personal + spending"). *Recommend `primary` — it's the one category name that never + collides with an import.* + +2. **How does WizardConnect surface multi-account?** External pairings via + `wiz://` — the `@wizardconnect/wallet@0.2.2` SDK's WalletAdapter has one + `getXpub(path)` and one `signTransaction(request)`. Neither takes an + account parameter. Options: pair-per-account (each account has its own + `wiz://` URI); pair-per-profile with account selection at sign time (like + in-page dApps); or accept that WizardConnect only exposes the primary + account. *Recommend option 3 for M.1 (only-primary), revisit when a user + asks. Multi-account WizardConnect is a real cross-cutting design that + deserves its own doc.* + +3. **Do we let the profile primary seed be *replaced* by an import?** The + proposal above says no — imports are always in the imports file, and the + primary purpose subtrees stay tied to the profile seed. A user who wants + "my Deviant seed as the profile seed" would create a fresh profile and + restore that seed at profile-creation. *Recommend keeping the restriction + — it makes the sub-account / import distinction fundamental rather than + optional.* + +4. **Auto-lock behaviour with imports.** Auto-lock re-encrypts the primary + purpose root under a session key (§6 base doc). Do we do the same for the + imports? Or wipe the imports outright and force a re-decrypt on unlock? + *Recommend the same treatment as primary — session-key re-encryption. The + cost is symmetric.* + +5. **Export.** Can the user export an imported wallet's key back out of + Theseus? *Recommend no by default (§4.1 no-view-material rule), but a + "Show recovery WIF for this import" behind master-password reprompt AND + "why is this dangerous" panel (like the base doc §8.6 for the profile + seed) is a reasonable escape hatch for users who need to move a wallet + between machines.* + +6. **Sub-account discovery.** If a user restores their profile seed on a new + Theseus install, do we auto-scan chipnet for used sub-account addresses? + *Recommend a "Scan for used sub-accounts" button in settings, not + automatic. Scanning is a chain-load event; make the user ask for it.* + +7. **Category taxonomy.** The Deviant keystore uses `bns`, `bns-infra`, + `chipnet-test`, `operational`, `primary`, `hd-general`. Should Theseus + enforce a fixed set or allow free-form strings? *Recommend free-form + strings with the six above as autocomplete suggestions. Constraining now + would force a v2 migration when a user coins a new category.* + +--- + +## 9. What this amendment does NOT decide + +- The wallet-imports file format after v1. Version-tag it now; iterate as + needed. The `version: "silentmode/wallet-imports/1"` field exists precisely + so a v2 migration is a well-defined event, not a guess. +- The Deviant-side story. Deviant's `chipnet-keystore.json` continues to be + read by Deviant scripts (via `scripts/lib/chipnet-keystore.mjs`) and by + SilentMode's `Argus/src/lib/wallet.js` (via the Option A fall-through). This + amendment adds a THIRD reader (Theseus) — it does not consolidate the two + existing readers. +- The mobile story. Ariadne (mobile companion, base doc §9) is out of scope. + When it lands, the same `wallet-imports.enc` format ports over — that's the + point of designing it version-tagged and portable. + +--- + +## 10. Suggested acceptance path + +If the reader agrees with this amendment: + +1. Merge M.1 into the roadmap between B.3b and B.4 in + `ROADMAP-identity-wallet.md`. +2. Move the base doc's §0 "Multi-account UX. One account per Theseus profile + in v1." to §9 "What this design does NOT decide, in v1 — this is now + addressed by [`DESIGN-wallet-multi-account-amendment.md`](DESIGN-wallet-multi-account-amendment.md)". +3. Address the open questions in §8 above in the roadmap discussion, not by + editing this file — the design is stable, only the answers are open. + +If the reader disagrees, the fallback path is what the base doc already +implies: **host the Deviant wallet dApp inside Theseus, once `window.bcnr` +ships** (base doc §4.4 — "External dApp using WizardConnect = Nostr relay", +minus the Nostr relay because the dApp is loaded IN Theseus, not paired +externally). That path requires no Theseus code changes beyond what B.2–B.4 +already deliver, and it inherits Deviant's own multi-account UX for free. The +trade-off is that "my wallet" and "the Deviant wallet loaded in my wallet +browser" become two things instead of one; this amendment is the case for +one.