theseus/DESIGN-wallet-multi-account-amendment.md
Local Dev 8649bb97ea docs(theseus): draft wallet multi-account amendment
Amends DESIGN-integrated-wallet.md §0 ("one account per profile in v1") with
a formal path to supporting external key imports (BIP39 seeds and raw WIFs)
alongside the primary HD purpose-subtree. Motivated by the user's 15-wallet
Deviant chipnet keystore now encrypted under the same master-password model
that Theseus is designed around.

Key discipline:
- SEPARATE storage file (wallet-imports.enc), same PBKDF2/AES-256-GCM crypto,
  same master password, distinct KDF salt. Imports cannot compromise the
  primary seed and vice versa; corruption of one file does not damage the
  other.
- The imports schema mirrors Deviant's chipnet-keystore/1 shape (cashaddr /
  label / category / source / path / seed / wif) so import is a 1:1 field
  copy, not a translation.
- Every import stores the raw seed + BIP44 path (or the raw WIF) and derives
  addresses DIRECTLY via HDKey.fromMasterSeed, bypassing the vault's
  purposes/wallet HKDF subtree. This is essential: without the bypass,
  pasting a mnemonic re-hashes the seed through HKDF and produces DIFFERENT
  addresses than the source wallet (see memory
  bchwallet-vault-root-derivation).
- No bcnr.importWallet() ever; imports are settings-page only.
- Sign modal grows an account picker with category chips; per-origin
  "recently used" pre-selection with red-flag on category change.

Also introduces:
- bcnr.requestAccount({ account?, category? }) — backwards compatible
- bcnr.getAccounts({ category? })
- Stale-import detection (source-file cashaddr comparison on unlock)
- Sequencing: M.1 (generic import) → M.2 (import from Deviant keystore) →
  M.3 (API extensions) → M.4 (stale detection). M.1 is load-bearing;
  everything else composes.

Companion to Option A which shipped separately as the cross-repo fall-through
in Argus/src/lib/wallet.js.
2026-09-09 02:17:01 +02:00

21 KiB
Raw Blame History

Theseus wallet — multi-account amendment

Amendment to 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:


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/<index> 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

<userData>/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: <userData>/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:

{
  "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:

{ …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:

bcnr.requestAccount({ account?: string, category?: string }): Promise<string | null>
// 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<Array<{
  cashaddr: string;
  label: string;
  category: string;
}>>
// 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".
  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.2B.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.