change RFC wallet architecture to BIP-85 per profile and hardened paths for names

This commit is contained in:
Alain Brenzikofer
2026-09-21 08:05:05 +02:00
parent 4255b46c04
commit 805f68ca94
+290 -78
View File
@@ -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=<phrase> take the entropy from a mnemonic
/_wallet export the seed mnemonic
/_wallet export name <k> 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=<p> from=<n> count=<c>
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 <n> 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.