diff --git a/docs/rfcs/2026-09-10-wallet-keys.md b/docs/rfcs/2026-09-10-wallet-keys.md index 28352ef5e8..35e5a6a8e7 100644 --- a/docs/rfcs/2026-09-10-wallet-keys.md +++ b/docs/rfcs/2026-09-10-wallet-keys.md @@ -11,75 +11,171 @@ of these addresses. Buying is not implemented yet. The keys land first, so the key material can be reviewed on its own, before the names protocol, the registrar and signing. +A device holds several chat profiles, and they are meant to stay apart. The +layout below gives each profile a seed of its own that the one phrase still +recovers. + ## Design -One BIP-39 seed per device, and one key per name. +One BIP-39 master seed per device. It signs nothing. Every key sits under a +per-profile seed that BIP-85 derives from it. ``` -m/44'/60'/0'/0/0 unused -m/44'/60'/0'/0/k names, k >= 1, counted per device -m/44'/60'/i'/0/j a profile's own addresses, i >= 1 (later) -m/5564'/60'/i'/... a profile's stealth meta keys (later) +master seed, 24 words the only thing to back up +└── profile p m/83696968'/39'/0'/24'/p' BIP-85, its own 24-word mnemonic + ├── m/44'/60'/0'/0/0 the profile's own address (later) + ├── m/44'/60'/n'/0/0 names, n >= 1 + ├── m/5564'/60'/0'/0'/0 stealth spend (later) + └── m/5564'/60'/0'/1'/0 stealth view (later) ``` -This is plain BIP-44, so the addresses match wallets people already use: the -device's names are MetaMask's account list from its second entry on, and a -profile's first address is the matching Ledger Live account. The tests pin that -against the standard `abandon ... about` mnemonic: +BIP-85 derives entropy from a BIP-32 node: take the node's private key `k`, the +letter its specification uses, and read the leading bytes of +`HMAC-SHA512("bip-entropy-from-k", k)`. Application `39'` reads those bytes as +BIP-39 entropy, with the language, the word count and the index in the path, so +`0'` is English, `24'` is the word count and `p'` is the profile. Every index is +hardened, so the derivation never needs the public-key half of BIP-32, which is +the half simplexmq deliberately does not implement. + +BIP-85 lands in simplexmq as `Simplex.Messaging.Crypto.BIP85`, on top of the +BIP-32 and BIP-39 modules already there. It needs no new dependency: the +HMAC-SHA512 it uses is the one BIP-32 already uses. + +Below the profile seed this is plain BIP-44, with a name at the account level. +An exported profile mnemonic therefore reaches the same addresses in another +wallet: that profile's names are Ledger Live's account list from its second +entry on, which Ledger Live, Rabby and others enumerate. The tests pin BIP-85 +against the vectors in its own specification, and BIP-44 against the standard +`abandon ... about` mnemonic: ``` -m/44'/60'/0'/0/1 0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0 MetaMask account 2 -m/44'/60'/0'/0/2 0xb6716976A3ebe8D39aCEB04372f22Ff8e6802D7A MetaMask account 3 +m/44'/60'/0'/0/0 0x9858EfFD232B4033E47d90003D41EC34EcaEda94 Ledger Live account 1 +m/44'/60'/1'/0/0 0x78839F6054d7ed13918bAe0473BA31b1Ca9D7265 Ledger Live account 2 ``` -The mnemonic imports there as a recovery phrase, a single secret as a private -key. `m/44'/60'/0'/0/0` is left unused so that neither names nor profiles claim -the point where both dimensions start. +**Why a seed per profile.** What a user can hand over is otherwise all or +nothing: the device phrase hands over every profile, a name's raw secret hands +over one name, and there is nothing in between. A profile seed is the thing in +between, and it is a mnemonic, so it imports anywhere. -**Why a key per name.** A name's owner is public, so an address that owns -several names links them: whoever knows one of them can read its owner and find -the rest. A key per name leaves no such link. It also keeps an export narrow, as -a name's secret is a leaf, with no chain code, so handing it over hands over -that name only. +**Why one key per name.** A name's owner is public, so an address that owns +several names links them, and that is as true inside one profile as across two. +A key per name leaves no such link. It also keeps an export narrow: a name's +secret is a leaf with no chain code, and its account index is hardened, so +handing it over hands over that name only, whatever else the holder has. -**Why names are not derived per profile.** Which profile a name belongs to is -in the record the name resolves to, which the owner signs, not in the key. A -profile dimension in the path would carry a mapping nothing reads, and it would -have to be reconstructed after a restore, since a phrase does not say which -profile held which account. Counting names per device removes the question. +**Why hardened throughout.** BIP-85 uses hardened indexes only, so no extended +public key exists that could link two profiles, and a leaked profile mnemonic +derives no sibling. A name sits at the account level for the same reason, since +that is the level BIP-44 hardens: on a non-hardened index, an extended public +key above a name plus that name's private key yields the parent key and so every +other name in the profile, and a mnemonic exported to another wallet is exactly +how such an extended public key comes to exist. A profile is a compartment with +one way out, its own mnemonic, and a name inside it is another. -The cost is compartmentalisation that is not in use. Per-profile accounts would -confine an exposed account-level extended key to one profile's names. Nothing -derives or exports an extended key, and nothing is planned to, so the compartment -has no exit. +**Why 24-word children.** The same reason the master is 24: entropy cannot be +added to a seed afterwards, and what a seed is used for can grow. One length to +recognise rather than two. The word count is part of the path, so it is not a +setting and cannot change later. -Profiles get their own dimension for what is per profile: their own addresses at -account `i >= 1`, and stealth meta keys at purpose `5564'`. The plan there is one -meta address per profile, a spend key and a viewing key whose public halves are -published with the chat profile, opt-in. A sender derives a fresh destination -from it without a handshake, so one meta address serves any number of incoming -destinations, and those keys are not at a derivation path. That is why it belongs -at the profile level, while what a profile buys sits at the address level. +**Why names start at 1.** The account level under a profile seed counts that +profile's names and nothing else, so account 0 is free, and it is kept for the +profile's own address at `m/44'/60'/0'/0/0`. + +**What this does not buy.** A profile seed is derived from the master, so +whoever holds the master phrase derives every profile under it. This separates +what can be handed over, not what can be found. + +## Profiles + +A profile holds its seeds by index. Indexes come from a high-water mark and are +never reused: a profile the device no longer tracks still owns whatever its +names are registered to. + +A profile holds no index, one, or several. Several is what a recovery leaves: a +device restored from the phrase alone has one profile and no idea which of the +recovered indexes belonged to which profile before, so they all land on the +first one and are moved out from there. At most one of a profile's indexes is +the one it buys new names under, recorded rather than inferred, so taking on a +recovered index never silently moves where the next name goes. A recovered index +arrives with no such mark, because a scan hands a profile many at once and only +one of them can carry it. + +**A hidden profile cannot hold a name.** This is a check, not an omission. The +master derives every index under it, so unlocking any profile derives a hidden +profile's name keys as well, and the name is written into the profile's own row +and listed device-wide, while hidden profiles are a view filter rather than +encryption. Neither is closed by this layout, and closing them is work in the +profiles and in the name record rather than here. Until then a hidden profile is +given no index, by `bind` or by anything that follows it. + +An incognito profile holds no seed and cannot hold a name. ## Commands Internal API. The names commands will call these; users will not. ``` -/_wallet the next name addresses -/_wallet create new generate the seed, 24 words +/_wallet whether the device has a seed, and the + indexes the active profile holds +/_wallet create new generate the master seed, 24 words /_wallet create mnemonic= take the entropy from a mnemonic -/_wallet export the seed mnemonic -/_wallet export name one name key's secret, 0x and 64 hex digits -/_wallet delete delete the seed +/_wallet bind give the active profile the next free index to derive from master seed +/_wallet addresses profile=

from= count= + name addresses n onwards under profile p +/_wallet export master the master mnemonic +/_wallet export profile the active profile's mnemonic, 24 words +/_wallet export name one name key's secret, 0x and 64 hex digits +/_wallet delete delete the master seed and its indexes ``` Creating and importing are one command, because they differ only in where the -entropy comes from, and either is refused if the device already has a seed. The -source is always named, so no seed is generated by typing a prefix. A seed is -created only when asked for, never at startup and never as a side effect of +entropy comes from, and either is refused if the device already has a master. +The source is always named, so no seed is generated by typing a prefix. A seed +is created only when asked for, never at startup and never as a side effect of reading. +`bind` is how a profile gets the index it buys names under, and only a profile +that has none, so no profile is ever put on an index it did not ask for. A +profile can hold indexes and still have none to buy under, which is what a +recovery leaves, and `bind` serves it like any other. It is not done at startup +and not as a side effect of reading either. + +`bind` allocates from the high-water mark, and after a phrase is imported that +mark is 0 again, so a restored device can bind an index that already owns names. +Nothing is spent, because buying is not here, but the binding is written, and +the scan that raises the mark is what reconciles it. + +`/_wallet` answers what its name asks, whether the device has a seed and which +indexes the active profile holds, with the one it buys names under marked. It +derives no address, because an address is not what the bare command name +promises. + +`addresses` is that derivation, and it is what a name scan is built on. It +answers with a path and an address per row, and `from=` with `count=` windows +the walk the way `count=` already windows a list of chat items. It does not need +the index to exist in the database: a scan after a lost database is looking for +indexes no row was ever written for, so the command derives from the seed and +the numbers alone. + +There is one form and not a second one over profile indexes, because no single +address answers for a profile. Names are dense from 1, but a name can expire or +be transferred away, so the address at 1 can own nothing while 2 and 3 still do, +and a probe of 1 alone would skip a profile that holds names. The walk over +profile indexes is the walk over name indexes repeated, with the caller nesting +the same command. + +`export` names what it exports, for the same reason `create` names its source: +the three subjects hand over very different amounts, and none of them should be +printed by typing a prefix. The master mnemonic is every profile, a profile +mnemonic is that profile's names and nothing else, a name secret is one name, +and the responses say which is which. `export` on its own is refused. + +`export profile` and `export name` act on the index the active profile buys +under, and are refused where there is none, which is a profile that holds only +recovered indexes. Naming one of those is part of the command that moves a +recovered index to a profile, and lands with it. + The generated mnemonic is 24 words, 256 bits of entropy, rather than 12. That buys nothing at the security level secp256k1 works at, and it is twice as much to write down, so it is a deliberate choice rather than a default: entropy @@ -91,59 +187,175 @@ cannot be added to a seed afterwards, and what a seed is used for can grow. CREATE TABLE wallet_seeds ( wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT, entropy BLOB NOT NULL, - next_name_index INTEGER NOT NULL DEFAULT 1, - single_seed INTEGER NOT NULL DEFAULT 1 + seed_kind TEXT NOT NULL, -- 'master' or 'profile' + next_profile_index INTEGER NOT NULL DEFAULT 0 -- on the master row ); + +CREATE TABLE wallet_profile_indexes ( + wallet_profile_index_id INTEGER PRIMARY KEY AUTOINCREMENT, + wallet_seed_id INTEGER NOT NULL REFERENCES wallet_seeds ON DELETE CASCADE, + profile_index INTEGER, -- null on a seed of the profile's own + user_id INTEGER REFERENCES users ON DELETE SET NULL, + buys_names INTEGER NOT NULL DEFAULT 0, + next_name_index INTEGER NOT NULL DEFAULT 1 +); + +CREATE UNIQUE INDEX idx_wallet_seeds_master ON wallet_seeds(seed_kind) WHERE seed_kind = 'master'; +CREATE UNIQUE INDEX idx_wallet_profile_indexes_index ON wallet_profile_indexes(wallet_seed_id, profile_index); +CREATE UNIQUE INDEX idx_wallet_profile_indexes_buys ON wallet_profile_indexes(user_id) WHERE buys_names = 1; ``` -No other table is touched. +A seed is stored only when nothing derives it. The master is one such row, 16 to +32 bytes of BIP-39 entropy, and a profile seed derived from it is not stored at +all: it is computed when a key is needed, as the master seed itself already is. +`wallet_profile_indexes` holds what derivation cannot produce, which index a +profile was given, which seed it is under, which profile holds it, which of a +profile's indexes it buys names under, and how far each name counter has run. +The table is named for that, so that nothing in the schema reads as if a derived +seed were written down. -What is stored is the BIP-39 entropy, 16 to 32 bytes. The mnemonic comes back -from it exactly, as the entropy and its checksum determine the words, and that -is what export returns. The seed itself, the 64 bytes PBKDF2 derives from the -mnemonic, is computed when a key is needed and never stored. +**A seed the master does not cover.** A profile can be put on entropy of its +own, imported rather than derived, and then the master phrase does not recover +that profile and must not appear to. `seed_kind` is what says so: a `master` row +is the device seed, and the only row `next_profile_index` means anything on; a +`profile` row is entropy that arrived on its own and serves exactly one profile. +The partial unique index keeps one master and leaves the number of profile rows +open. On a profile row `profile_index` is null, because there is no BIP-85 step +to record, the seed being the profile's root already. Importing one is not in +this change. The column is here so that adding it later reads rows written now +correctly, rather than having to guess what an unflagged seed was. -`next_name_index` is a high-water mark, not a count of names held. A name a -device no longer tracks still owns its address, so an index is never reused. +`users` is not touched. Which profile holds an index is on the index row, +because a recovery gives a profile several and a column on `users` could only +say one. `buys_names` picks the one new names go under, and the partial unique +index makes at most one per profile the database's rule rather than the +caller's. `bind` sets it, and nothing else does: a recovered index arrives +unmarked, so a profile that holds only those buys under none until it binds one +or is given one. -The table models several seeds, which a later change needs. One per device is -`single_seed` and a unique index on it, lifted later by a `DROP INDEX` and a -`DROP COLUMN`. It is a named index rather than an inline `UNIQUE` because SQLite -cannot drop a `UNIQUE` column or its automatic index, which would force a table -rebuild. +Deleting a profile leaves its indexes behind, unassigned and with the flag +meaningless until one of them is given to a profile again, because the names +under them are still registered to their addresses. Deleting a seed takes the +index rows under that seed with it, because an index without the entropy it +counts against derives nothing. + +`next_name_index` counts what one seed holds, `next_profile_index` counts the +indexes handed out under the master, and both are high-water marks rather than +counts: an index is never reused. Migrations are sorted by name, and the runner requires the ones a database has applied to be a prefix of that list. A later wallet migration has to sort after `20260908_wallet_seeds`, or every existing database fails to start. +## Recovery + +Three cases, by how much of the database came back. + +**The database is current.** It carries `wallet_profile_indexes`, so every +profile is back on its own index and nothing is asked of the user. + +**The database is older than the seed.** It carries the profiles and the +bindings as of the backup, and nothing written after it: indexes bound since, +names bought since, and marks that are behind. What it is missing is the +phrase-only case over a smaller range, and the same scan answers it. + +**Only the phrase is left.** Nothing in it says which indexes were used, and +nothing says which profile held which one. A scan of owned names answers the +first question, and it has two levels, a profile index and a name index under +it, each with a gap limit, so the number of probes is their product. That is a +real cost on a scan that already hands one registrar a view of the derivation +tree at the moment a user is restoring a lost device. + +The scan is a nested walk over `addresses`, and the wallet supplies only the +addresses. For each profile index in turn the caller takes a window of that +profile's name addresses and asks the chain what they own, widening while names +keep appearing and moving on when a whole window owns nothing; the outer walk +ends after a gap of profile indexes that turned up nothing at all. Asking the +chain, counting the gap and raising the high-water marks are the names layer's, +which is why the wallet hands out addresses and counts nothing: a gap limit +needs the chain answers, and the wallet never sees them. + +What the scan finds lands on the first profile, unmarked, which then holds +several indexes and buys under none of them until it binds one. The user moves +them out one at a time, or keeps one where it is and makes it the one that +profile buys under. That is a command, and it lands with the scan rather than +here, because a scan is what puts a profile in that state and nothing else can. +What it will do is move one index, with every name under it, to the profile that +should have it: nothing signed and nothing +moved on chain, because ownership does not change, only which profile the app +shows the names under. Pointing a name at the new profile's address stays a +separate signed record edit, the same edit that points a name anywhere else. + +An index moves whole, because every name under it derives from one seed and an +index belongs to one profile at a time. Splitting one index's names between two +profiles would put a name under another profile's seed, which is an on-chain +transfer, and a name transfer is not a recovery step. + ## What the phrase does not carry -A phrase carries entropy and nothing else. Which indexes are already taken is -not in it, not derivable, and not on chain in a form the client can ask for -directly. `next_name_index` starts at 1 after an import, so until a scan of -owned names raises it, the next name would be bought at a path that already owns -one. Buying is not implemented here, so nothing can act on the stale mark yet, -and the scan lands with the registrar that makes buying possible. +A phrase carries entropy and nothing else. -Which profile a name belongs to is not lost with the device, because it was -never in the key. It is in the record, and re-pointing a name at a profile is a -signed record edit, not a rebinding of keys. +**Which indexes are taken.** `next_profile_index` starts at 0 after an import +and `next_name_index` at 1, so until a scan raises them the next profile or the +next name would be taken at a path that already owns one. `bind` allocates from +the first of those and ships here, so a restored device can write a binding on +an index that already owns names. Nothing is spent, because buying is not here, +and the scan that raises the marks is what reconciles the binding; until it +lands the marks are a known stale value rather than a count of what the seed +holds. -## Hidden profiles +**Which profile held which index.** Saying so by hand is the only answer, and +the command for it lands with the scan that finds the indexes in the first +place. Neither is here. -A hidden profile should not register a name yet. The seed is the device's, so -whoever unlocks any profile can derive every name key. Beyond that, a name is -written into the profile's own row and listed device-wide, and hidden profiles -are a view filter rather than encryption, so the binding is readable beside it. -Nothing in the wallet prevents it, and closing it is work in the profiles and in -the name record rather than here. +**A profile on a seed of its own.** Entropy that was imported rather than +derived is not in the phrase and cannot be, which is why `seed_kind` records it: +what the phrase recovers has to be answerable without guessing. ## Scope Not here: buying a name, the names protocol, the registrar, signing, the -recovery scan, several seeds per device, a profile's own addresses, stealth -addresses. +recovery scan and the command that moves a recovered index to a profile, +importing a seed for one profile and naming one in `addresses`, several master +seeds, the encrypted backup that would carry the index to profile map, a +profile's own address, stealth addresses. -`/_wallet` shows the next two name addresses, which is enough to check the -derivation against another wallet. It will show the names actually held once -those are recorded. +`delete` takes the master and the indexes under it. A seed of a profile's own is +a row that nothing derives, so it is not under the master and is not taken with +it, which is one more thing importing has to settle when it lands. + +Also not here: deriving at any layout but this one. A name may have been +registered from this phrase in another wallet, at the bare root or at a path +another tool picked, and finding those needs the caller to say which path rather +than which index. `addresses` takes indexes, so the candidate set of foreign +layouts arrives with the scan that needs it, as a widening of this command or +beside it. + +## What is verified + +Each of these is a test, not a claim. + +- The BIP-85 vectors for application `39'` reproduce from the specification's + master key, at 12, 18 and 24 words, entropy and mnemonic. +- A profile mnemonic, imported on its own as a master, reaches that profile's + name addresses, so a profile can be handed to another wallet. +- Two profiles' addresses do not intersect, and neither mnemonic derives the + other's. +- The BIP-44 addresses above reproduce, which is what an exported profile + mnemonic gives in a wallet that enumerates Ledger Live accounts. +- A name path hardens its account component, so the path a name renders at ends + in no index an extended public key could walk. +- `addresses` derives for an index the database has no row for, which is the + case a scan exists for. +- `/_wallet` derives nothing. +- A secret whose first byte is zero keeps its 64 hex digits. +- Nothing is created until asked for, and a second create or import is refused. +- `bind` gives an index only to a profile with none to buy under, and no two + profiles end up on one index. +- A profile buys names under at most one of its indexes, and the database + refuses a second. +- `export profile` is refused for a profile that has no index to buy under. +- `export` on its own is refused, and each subject exports only itself. +- An index at or above 2^31 is refused rather than folded onto a low one. +- Binding a hidden profile is refused. +- The keys and the addresses survive a restart.