theseus/DESIGN-wallet-multi-account-amendment.md

437 lines
21 KiB
Markdown
Raw Permalink Normal View History

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
# 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/<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:
```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<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`](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.