Accounts & Devices
Two words this page leans on
Section titled “Two words this page leans on”Scope. A group — a namespace, or a subgroup of one. A namespace is itself a group, the root one. (The word is overloaded in the code: ScopeId in crates/op is a namespace id, the partition key for the causal log. On this page “scope” means the group whose key is under discussion.)
Scope key. One symmetric AES-256-GCM key per group, called group_key in the store. It encrypts that group’s governance operations and its contexts’ state deltas. Membership says you may participate; the scope key is what makes the bytes intelligible. That distinction is the whole reason revocation is not finished until the key rotates — strip someone’s authority and they still read everything until the key changes.
The problem: one key doing four jobs
Section titled “The problem: one key doing four jobs”In the model described in Identities & Keys, a namespace member identity is a single Ed25519 keypair, and it does everything at once: it is the identity, it signs operations, it receives wrapped scope keys, and it is the key membership rows are stored under.
That conflation has consequences you cannot design around:
| You want to | And you get |
|---|---|
| Add a second device | A second, unrelated member. It is not “you”, it is a stranger who must be granted separately. |
| Rotate a compromised key | A different member. The old identity dies and every grant must be re-issued. |
| Lose your only device | Nothing can vouch for a replacement. The identity is unrecoverable. |
| Revoke one device | Not expressible. You can only remove a member. |
The account plane separates those jobs.
Two ids, one strict boundary
Section titled “Two ids, one strict boundary”AccountId |
DeviceId |
|
|---|---|---|
| what it is | a person or agent | one installation |
| authorizes | yes — the only authz subject | never |
| signs | no (only certificates) | yes, every operation |
| CRDT replica id | never | yes |
| KEM recipient | no | yes |
Neither is a public key. Both are content addresses:
AccountId = H( genesis(root_pk) )DeviceId = H( account ‖ nonce )Because the id is a hash rather than a key, the keys underneath can rotate without the identity moving.
The non-goal is as important as the goal. Accounts never reach below authorization. Nothing that needs per-writer uniqueness (counter slots, HLC seeds), per-key revocability (KEM recipients), or cryptographic attribution (operation signatures) may be re-keyed onto AccountId. The test for any future change: if two devices of the same account did this concurrently, is the result still correct? If correctness depends on them being distinct, it stays device-keyed, and per-user aggregation happens above storage.
Three keys, one job each
Section titled “Three keys, one job each” ACCOUNT ROOT ──── signs device certificates and root-key handoffs. (offline) Nothing else. Never signs an operation, never receives data. One per node. Survives losing every device.
SIGN KEY ──────── signs operations. This is the node's namespace identity — the "member identity" of the previous model, now demoted to one job.
KEM KEY ───────── receives wrapped scope keys (X25519).Separating the sign key from the KEM key is what lets a device rotate one without invalidating deliveries made under the other. Crossing the account root with the sign key is silent — a certificate signed by the wrong one still serializes and is refused only by peers — so the two have a dedicated invariant test beside the repository that owns them.
What the KEM key is
Section titled “What the KEM key is”KEM is key encapsulation mechanism: the X25519 public key a device publishes in its certificate, whose only job is to receive the scope key. It never signs anything.
If the scope key is the key to a room, the KEM key is a mailbox slot on your door that only you can open. To let you in, someone already inside posts a copy of the room key through your slot — no round trip, no handshake. That is why delivery can ride on an ordinary operation and be picked up whenever the recipient next syncs.
Two properties matter beyond the mechanics:
- Your KEM public key never travels on the envelope. Only a fresh per-envelope ephemeral public key does. The sender reads your KEM key from the folded device binding — the same row that records whether you are still authorized. That is structurally why a revoked device cannot be wrapped for: the address and the authorization come from one source.
- The ephemeral is discarded after wrapping, so compromising a sender’s long-term key later does not decrypt past envelopes.
The envelope format, both ECDH variants, and the AEAD details are specified in Encryption; this page only covers who gets addressed and why.
What is derived, and what is not
Section titled “What is derived, and what is not”This split is deliberate in both directions.
FROM THE ROOT KEY (deterministic, recomputable) AccountId = H( genesis(root_pk) )
RANDOM AT CREATION (generated once, stored, unrecoverable) DeviceId = H(account ‖ random 16 bytes) KEM secret = X25519, random device signing keypair = Ed25519, randomThe account must be recomputable — that is the whole recovery story. Hold the root secret and you can name your account, with no stored state and nothing to keep beside it.
The device must not be. A DeviceId is a CRDT replica id. Two installations that derived the same id would share counter slots and an HLC seed and silently lose each other’s writes. Determinism there would be a bug, not a feature.
One root key is one account, in every namespace. The account id carries no
per-namespace salt, and deliberately so. A salted id would only be worth its cost if
it made a person uncorrelatable across namespaces, and it does not: the genesis names
the same root_sign_pk wherever it is published, so a member of two namespaces links
the two accounts by comparing two numbers — and every node keeps a member → PeerId
map besides, under one PeerId per node. Unlinkability needs distinct network
identities, not distinct account ids; until that exists, a per-namespace account is
cost without the property. See the reversibility notes in the account-model issue for
how it would be reintroduced if that changes.
The bridge: endorsements
Section titled “The bridge: endorsements”Membership rows are still keyed by member key, but the account root is offline and is a member nowhere. So how does a group learn that an account belongs to a member?
A granted member key signs a statement binding the account id to itself:
account root ──signs──▶ DeviceCert "this device speaks for this account" only the root holder can produce it
member key ──signs──▶ Endorsement "this account is mine" only a member can produce itA link is admitted only if both hold and the endorser is a member at the operation’s causal cut. Neither alone is enough: anyone may endorse an account they do not own (ids are public data) and gains nothing, because they cannot certify a device into it; anyone may certify devices into their own account and gains nothing, because they are not a member.
Endorsers are stored as a grow-only set, never a single field. Two links for one account may legitimately name different endorsers, and collapsing them to one value would make the stored state depend on which link folded last — order-dependent state on the authorization path. Set union is a join, and “is any endorser a member at this cut” is order-independent.
Enrolling at join — the default path
Section titled “Enrolling at join — the default path”A member and the device it joined with become known together, in one operation, because every cleartext join operation carries the joiner’s credential as a required field — MemberJoined and MemberJoinedAt (invitation), MemberJoinedOpen (open-subgroup self-join), and MemberJoinedViaTeeAttestation (a hardware-attested fleet replica):
JoinAccountCredential { genesis, chain, cert }Everything a peer needs to verify the account and the device, from the operation alone — no prior operation about this account has to have been folded first.
Why this is possible at all
Section titled “Why this is possible at all”Nothing in building the credential publishes or encrypts. ensure_enrolled mints the device row locally and returns the genesis derived from the node’s account root; the certificate is signed by that root. The whole credential therefore exists before the node holds any scope key — which is exactly what lets it ride a cleartext join operation, and why enrolment no longer waits on key delivery.
The one join somebody else publishes
Section titled “The one join somebody else publishes”A fleet replica cannot admit itself — an existing member has to check its attestation quote against the namespace policy first — so MemberJoinedViaTeeAttestation is signed by that verifier, not by the joiner. The credential still belongs to the replica, so it travels to the verifier on the attestation announcement, and the verifier checks it against the key the quote binds to before admitting.
That is why the apply path does not assert op.signer == member for this one op, the way it does for the other three. The verifier-membership gate stands in its place.
Why no endorsement travels on the wire
Section titled “Why no endorsement travels on the wire”AccountDeviceLinked needs an AccountMemberEndorsement because an account root is deliberately a member nowhere, so its gate has to ask whether some member vouched. A join operation already carries both halves: it is signed by the joining member’s own namespace identity, and it carries the admin-signed invitation authorising that member. The endorsement collapses into the operation.
What does not collapse is the endorser row. Every reader that turns a key into an account consults those rows, so the join still records the joining member as an endorser of its own account. A binding without one is recorded and inert: no per-device authorization, no scope keys.
What the join verifies
Section titled “What the join verifies”Two questions, both answerable from the operation’s own bytes:
| Check | Guards against |
|---|---|
cert.sign_pk == member |
Join operations are cleartext, so anyone can lift a credential out of somebody else’s join and present it as their own. op.signer == member proves the envelope, never the payload. |
verify_device_cert(...) |
The genesis hashes to the account the certificate claims, and the certificate is signed by the root key its chain reaches. |
Both live in one predicate that the apply path and the projection encoder share, because a credential admitted by one plane and refused by the other is the split this whole design exists to prevent.
Revocation and epoch supersession are deliberately not in that predicate: they are stateful, so they cannot be decided from the operation alone. Each plane answers them from its own view, and both refuse the same credentials.
A refused credential never orphans the membership
Section titled “A refused credential never orphans the membership”If the credential cannot be admitted, the member is still recorded — without a binding — and the refusal is logged. A member with no binding is the state that held before joins carried one at all, and it is survivable. A membership operation the DAG cannot apply is not: it would stall every operation behind it on that replica alone.
A store failure is the opposite case and does propagate, because it is not a verdict on the credential. Swallowing it would leave one replica holding the membership with no binding while its peers hold both — a permanent, silent disagreement about which principal that member writes as.
One principal, two resolvers — and they must agree
Section titled “One principal, two resolvers — and they must agree”This is the invariant most easily broken by accident, so it is worth stating on its own.
“Which account does this key write as” is answered in two places, and they have to give the same answer:
| Plane | Path | Reads |
|---|---|---|
| Writer — what the node itself writes as | env::account_id() → account_for_group → binding_for_sign_pk |
materialized binding rows |
| Peer — what everyone else resolves that key to | device_account_at_cut → AclView::devices |
the folded projection |
A writer set the node seeds names whatever the first returns. Every peer matches incoming signatures against whatever the second returns. If a fact reaches one plane and not the other, the node writes as somebody its peers do not recognise, and its writes match no grant — with no permission error anywhere, because from each side’s own point of view nothing is wrong. It presents as data that silently never converges.
AccountDeviceLinked feeds both: the apply path writes the rows, and the operation folds into the projection as DeviceLinked. A join has to do the same, so it folds too — as MemberJoinedWithDevice for the invitation joins (membership and device as one indivisible fact) and as a bare DeviceLinked for an open self-join, whose membership stays inheritance-derived because a direct row would outlive the anchor that grants it.
Enrolling a device into an existing membership
Section titled “Enrolling a device into an existing membership”AccountDeviceLinked is the standalone form, for a device that is added to a membership that already exists — a paired second device, or a replacement after a revocation. A joining node does not use it; see Enrolling at join.
Note the ordering constraint on this path: the link is an encrypted group operation, so the node must already hold the scope key. That is what makes it unusable at join time, and why the join carries its credential in the clear instead.
It is also why key delivery can still address a member rather than a device. A joiner is now bound the moment it is a member, so the device-addressed fan-out covers it — but the member-addressed envelope remains the bootstrap form for the cases that still need one (a re-admission, or an explicit delivery by an admin to a member whose devices are gone). It is permanent, not legacy.
Pairing a second device
Section titled “Pairing a second device”Pairing is a two-way exchange, and the ordering is forced rather than stylistic:
the new device cannot mint its DeviceId without knowing the account the holder cannot sign the certificate without knowing the DeviceIdTwo things gate the certificate, and they answer different questions. The new device signs a statement over the account, its own id and both keys, so the values arrive as a statement by the device that minted them rather than as claims by whoever relayed them. But a signature only proves the payload agrees with itself, and an attacker holding both keys can arrange that — so pair-complete also requires the confirmation code, and refuses if it does not describe the key material that arrived. The account holder gets that code from whoever is at the pairing device; it is the one value an attacker rewriting the payload cannot produce.
Requiring it rather than printing it for comparison is the point: an advisory step is one an operator in a hurry skips. What the requirement cannot do is force the code to travel separately from the keys — pasted along with them it proves nothing, since whoever rewrote the payload rewrote the code too. That part is the operator’s channel, and it is the whole security assumption of pairing. See Known limits for why the code is 64 bits rather than a friendlier six digits.
The key delivery must be a cleartext root operation. The pairing device holds no scope key, so a device-addressed envelope carried inside an encrypted group operation would be unreadable by its only recipient — the same bootstrap deadlock again. KeyDelivery being a root operation is what breaks the cycle.
The new device is never a member. It records participation in the namespace and takes a gossip subscription — both membership-free operations — and its entire right to participate comes from being a device of the account. Because a node holds exactly one device, “two devices” means two nodes.
Only the current scope key is delivered. Peers retain rotated-out keys, so history could be handed back, but that would make every newly paired device a full-history reader — a capability decision that deserves its own change. The paired device converges on forward state and cannot decrypt operations sealed under retired epochs.
Reading your account
Section titled “Reading your account”An AccountId appears on no wire and in no operation, so a node has to be asked
which one it writes as:
meroctl account show # GET /admin-api/identityThe answer does not change at enrollment, and that is deliberate. The id is the content address of the account root, so it exists as soon as the node has a root — before it has joined anything, and identically in every namespace. What enrollment adds is the device binding: the row that lets a peer map this node’s signing key to that account, and that lets a second device of the same person write under the same principal.
There used to be a second answer here. A node that had not enrolled wrote as a stand-in account derived from its signing key, because a root-derived id is derived from a secret and no peer could compute it. That meant a writer set seeded before its writer enrolled held a principal that stopped matching the moment it did, and the advice was to re-grant afterwards. The stand-in is gone: a join now carries the device credential, so a node is bound by the operation that admits it and there is no window in which it writes as something else.
Both ends still resolve through one rule — the node deciding what to put in a writer set and the peer resolving an incoming signature — because a disagreement means a node’s own writes are refused everywhere else.
Everything that names a person needs this first: granting a writer, revoking a
device, or an app method that takes an AccountId. Naming them by signing key
instead would look friendlier and be wrong — a grant to a key excludes that
person’s other devices, and stops meaning anything the moment they re-key.
Key delivery is device-first
Section titled “Key delivery is device-first” for each member of the group: │ ├─ does the group know an account for them? │ ├── NO ──▶ wrap for their IDENTITY key │ (bootstrap: they have never linked a device) │ └── YES ──▶ wrap once per LIVE DEVICE of that account ── and give them NO identity envelope ──The absence of an identity envelope is the security property, not an optimization. A revoked device runs on a node that still holds the member key; an identity-addressed envelope would hand the key straight back to it.
Each entry is paired with the member whose entitlement it rests on, which is what makes excluding a removed member take every device of theirs with them. Filtering by recipient alone could only drop the identity entry.
The sync pull path obeys the same rule. A key request names the device it asks as, and the responder resolves the reply with the same device-first logic narrowed to one member. The requester’s claimed device is deliberately unauthenticated: the reply is sealed to that device’s certified X25519 key, so a false claim yields an envelope the caller cannot open. The wrap is the authentication.
Revocation
Section titled “Revocation”Revocation is terminal: the DeviceId is spent for good, so re-enrolling that machine mints a fresh one. That permanence is what stops a replica id from ever being reused.
The tombstone therefore has to release the node’s own device slot as well as the group’s binding. A node holds one device, so a spent id left in place would leave enrolment minting certificates every peer refuses — locked out by its own revocation, and locally indistinguishable from success.
Two authorities, either of which suffices:
Admin at the cut is the path that ejects a device whose account holder is unreachable. Revocation is terminal, so an ungated one would be a permanent denial of service any member could inflict on any other.
A root-signed proof is the self-service path — the lost-laptop case, where the owner may be the only person who knows. It is available to whoever holds the root, which is the account: a paired device holds no root, so disowning it is the holder’s act or an admin’s, never its own.
Which account a revocation names is read from the group’s own device binding, never derived from the revoker’s root. Those two answer different questions — “whose device is this” versus “which account do I own here” — and they coincide only when you revoke your own. It deliberately is not a gate on folded state: “is the signer this account’s current root key” depends on which rotations a replica has folded, so two replicas would decide one operation differently and disagree permanently about who may author. The proof travels with the operation and verifies from the account id alone, exactly as a certificate does.
The proof accepts any epoch its carried chain resolves, not merely the newest. That asymmetry with certificate verification is deliberate: filtering superseded epochs would mean rotating an account’s root silently un-revokes every device it had withdrawn. The cost is that a compromised old root can still revoke — accepted, because whoever holds any root key of an account can already sign a handoff and take it over.
Cutting off authorship is not enough on its own. A revoked device already holds the current scope key, so without a rotation it stops writing and goes on reading — a silent reader. The rotation therefore rides on the same operation. Only an admin may rotate (peers accept a rotation sidecar only from an admin at the cut), so a self-service revocation locks the device out of writing immediately and leaves the rotation owed.
Revoking with only the account root
Section titled “Revoking with only the account root”The lost-device case, and the one the account plane exists for. Revoking is the account’s authority, and someone who has merely lost a device still holds it — but the code path that mints a proof reads the root from the acting node’s own store, and that node is the one that is gone.
What makes the offline route possible is that the proof is self-certifying: it carries the genesis and the root-key chain, so a verifier checks it from the account id alone. Nothing about it depends on where it was made, so the root never has to reach a node.
# Wherever the root is. --from reads the recovery phrase, so this needs no node,# no home and no init: a fresh machine, the merod binary, and the words.merod account revoke-proof --namespace <NAMESPACE_ID> --device <DEVICE_ID> \ --from phrase.txt
# Then, from any node that is a member and has folded the device's link:meroctl account revoke <NAMESPACE_ID> --device-id <DEVICE_ID> --proof @proof.hexThe publishing node needs no authority of its own — not admin, not the account. It is a relay. Which is also why the node holding the revoked device being offline is irrelevant: nothing asks it anything.
Two things the proof deliberately cannot do:
- Name a device that is not the account’s. A proof only attests that its
signer holds the root of the account it names; choosing a
DeviceIdto write beside it is free. Tying the two together is the stored binding’s job, checked before publishing and again by every replica that applies the operation. Without that check, anyone holding any account root could spend any replica id for good. - Rotate the scope key. Only an admin may, so — exactly as with the on-node self-service path — the device stops writing immediately and can read until an admin rotates.
A proof names an account, and one root owns one account everywhere — so a proof minted while pointed at one namespace verifies against the same account reached from any other. Publication is still per-DAG: it takes effect in a group once it has been published there.
Per-device authorization
Section titled “Per-device authorization”A paired device signs with its own namespace identity, which is a member of nothing. Its right to author comes entirely from the account its certificate binds it to:
author key │ ├─ is it a member at the cut? ──▶ yes: authorized (ordinary member) │ └─ no ─▶ is it the sign_pk of a LIVE device binding? │ └─ yes ─▶ is any endorser of that account a member AT THE CUT? ──▶ authorizedThe two halves are different in kind on purpose. Which account a key speaks for is read from materialized rows, because account operations do not reach the fold on the governance bridge. Whether that account may write is resolved against the folded view at the operation’s cut, like every other authority question — so a cut that removed the endorser refuses the device’s operations too.
Because the lookup reads live bindings, which exclude revoked and superseded devices, revocation withdraws authorship and not merely delivery.
What an app sees
Section titled “What an app sees”The two ids reach app code as two host functions, and the choice between them is the whole model in one line:
env::account_id() // WHO is calling — a person; same on all their devicesenv::device_id() // WHICH machine — one installation; the replica idThere is deliberately no executor_id() any more. It returned a device-shaped value while reading like a user, so Map<executor_id, Vote> was one vote per device — and adding a second device silently split the person in two. Deleting it rather than redefining it makes every existing call site a compile error, which is the only way that choice gets made deliberately instead of inherited.
| If the state is… | Key it by | Because |
|---|---|---|
| per person — a vote, a profile, a balance, a role | account_id() |
two devices of one person must agree |
| per replica — a counter slot, an HLC seed, “which machine wrote this” | device_id() |
two devices of one person must differ |
| an owner stamp read back from a storage collection | device_id() |
UserStorage / AuthoredMap / SharedStorage stamp the device, so a lookup has to match |
The third row is the one that bites: those collections record the writer at write time, and that writer is the device. Handing them an account id produces a gate that never matches. Per-person aggregation over them belongs above storage.
Recovery, and what it does not cover
Section titled “Recovery, and what it does not cover”Two separable halves; conflating them is what makes recovery look impossible.
| Provided by | |
|---|---|
| Proving you are you | the root key — a self-certifying certificate, verified from the account id alone |
| Getting scope keys | a peer — you hold none, and forward secrecy means they cannot be re-derived |
A peer’s role is therefore transport, not judgement: it cannot impersonate the recovering account and does not have to decide out of band who somebody is.
root key + [namespace_ids] ├──▶ recompute the AccountId in each namespace ├──▶ certify a brand new device into that account │ ├──✗ the old DeviceId — gone, and not wanted back ├──✗ the old KEM secret — gone └──✗ the old namespace key — gone; provision a fresh oneRecovery means prove who you are, then enrol fresh — not restore the old machine. The replacement is a new replica with a new lineage; the old device’s history stays in the DAG, authored by an id nobody will hold again.
Where this lives in the code
Section titled “Where this lives in the code”| Crate / module | What it owns |
|---|---|
crates/account |
The primitives: AccountId, DeviceId, AccountGenesis, DeviceCert, RootKeyHandoff, DeviceRevocation, and their sign/verify functions. No storage, no policy. |
crates/governance-store/src/node_device.rs |
This node’s own secrets: the NodeAccountRoot and NodeDeviceIdentity singletons. Node-local, never gossiped. |
crates/governance-store/src/account_bindings.rs |
The replicated view: device bindings, revocation tombstones, per-account root keys, and the endorser set. |
crates/governance-store/src/ops/group/account_ops.rs |
Apply handlers for link, revoke, and rotate — the authorization gates. |
crates/governance-store/src/group_keys.rs |
Both wrap modes, the device-first recipient resolution, and the pull-path equivalent. |
crates/context/src/handlers/ |
The operations a user drives: create_account, pair_device_init, pair_device_complete, revoke_device. |
crates/context/src/scope_projection.rs |
member_at_cut, including the per-device fallthrough. |
crates/projection, crates/authz, crates/op |
The unified-log account plane. Correct and tested, and the specification the governance path was built against — but not yet fed on the live path. |
Account operations travel as GroupOp variants because that is the transport that exists: broadcast carries namespace governance deltas, not unified operations. The unified-log work becomes live at cutover.
Backing up and recovering an account
Section titled “Backing up and recovering an account”One root, one account, everywhere
Section titled “One root, one account, everywhere”Worth being precise about the cardinalities, because “enrolling in a namespace” reads as if it mints something new each time, and it does not:
| scope | |
|---|---|
| account root — the secret you back up | one per node. Node-level, no namespace in its key |
| account id | one per root, derived: H(genesis(root_pk)). The same in every namespace |
| device | one per node. NodeDeviceIdentity is a singleton |
| namespace participation | one row per namespace, carrying no key material — the index of where this node takes part |
None of the identity layer varies by namespace. It used to: the account carried a per-namespace salt so a member of two namespaces could not tell two accounts were one person. That property did not hold — the genesis names the same root key wherever it is published, and one PeerId serves every namespace — so it was cost without benefit. What is genuinely many-to-one is devices per account, via pairing: several machines, one account, one grant.
Enrolment is therefore an ensure, not a create — and there is no command for it.
It derives the account from the root that already exists and mints a device only if
this node holds none, so it is idempotent by construction. It happens inside the
join: MemberJoined, MemberJoinedAt, MemberJoinedOpen and
MemberJoinedViaTeeAttestation are cleartext RootOps that carry the
JoinAccountCredential, so the binding lands in the same apply as the membership
and needs no scope key. That is also why the old “join first, then enrol” ordering
rule is gone: there is nothing left to sequence.
What a backup is
Section titled “What a backup is”One secret. The account root, printed as a 24-word BIP-39 phrase.
That is the whole backup. The account is H(version ‖ root_sign_pk) — the content
address of the root key and nothing else — so the phrase recomputes it directly,
and it is the same account in every namespace the root speaks in. There is no
per-namespace salt to record and no list of namespaces to keep beside the phrase.
Losing the phrase is terminal. The AccountId is H(genesis) over a root key that
no longer exists, so nobody can ever certify a device into that account again — the
account is dead, and the member grants attached to it with it.
Exporting
Section titled “Exporting”merod --node node1 account exportlegal winner thank year wave sausage worth useful legal winner thank year wavesausage worth useful legal winner thank year wave sausage worth title
Account root public key: D2RGSD4paRbDANmHeUC2L9Ljaon7vXy6HzaNeHnoQ7Tg
Keep the phrase AND the list of namespaces you use it in. …Three things, and only the first is secret:
| output | what it is for |
|---|---|
| the 24 words, on the first line | the backup. This is the account — anyone holding it can certify a device into every account this root owns |
| the account root public key | identifies which root you are holding, so you can tell two backups apart without revealing either |
| the advisory | a reminder that the namespace list matters too |
--namespace is optional and repeatable. Pass it to have export derive and
print the account ids for those namespaces:
merod --node node1 account export \ --namespace abab…abab \ --namespace cdcd…cdcd namespace abab…abab -> account 03944a72253996cf87a2ac0680ff52b75143333ea21605e0dd1877dc1b16ecaa namespace cdcd…cdcd -> account b7ba2620ea2b69e81a9b6ffe03e2432f22b9358a94836501e1500240298e37c7Those ids are not secrets — they are what writer sets and membership rows already name — and they are the half of the backup that stops recovery being a guessing game about which accounts you had.
By default the phrase goes to stdout only, so it need never touch a disk.
--out PATH writes it to a file and is refused without --allow-plaintext-file,
because pairing “write my recovery key down” with a default-permission file is the
wrong combination. When it does write, the file is created mode 0600 from the
start rather than chmod-ed afterwards — the gap between the two is long enough for
another local user to read it.
Two constraints on export:
- The node must be stopped. It opens the datastore directly and RocksDB’s lock is exclusive.
- A KMS-encrypted datastore is refused, not silently misread. The key is fetched
during
runwith the node’s TEE identity, which a recovery CLI on a replacement machine generally cannot reproduce.
Recovering onto a replacement machine
Section titled “Recovering onto a replacement machine”merod --node node1 init --server-port 2428 --swarm-port 2528 # fresh nodemerod --node node1 account import # paste the phrasemerod --node node1 runThen rejoin each namespace you used. The join mints and links a device for the restored account, and a peer delivers the current scope key to it, exactly as it would to any newly linked device — there is no separate enrol step to run.
What comes back, and what does not:
| after recovery | |
|---|---|
| the account id | identical — it is the content address of the root, and the root is what the phrase restores |
| writer grants and membership naming that account | still apply, because they name the account, not the machine |
| devices | gone. They died with the disk; enrol new ones. The old device ids stay revocable but never return |
| the node’s own identity keypair | new, so the old member row is dead and the node needs a fresh invitation to the namespace |
| local history | not restored from the phrase — the node re-syncs current state like any cold joiner |
The ordering constraint that used to catch people is gone: a device link once travelled as an encrypted group operation, so enrolling needed the scope key and had to follow the join. The credential now rides the cleartext join op, so the join is the enrolment and there is no order to get wrong.
Import’s guards
Section titled “Import’s guards”- It refuses to replace an existing root unless
--force. A root that has already certified devices has no second copy, so overwriting it strands every account it owned. The check lives in the repository under the same lock as the write — not in the CLI — so no other caller can skip it, and it hands back the root it replaced so nothing can destroy one silently. - On an empty store no flag is needed. Recovery onto a fresh machine is the normal path and must not require the flag that exists to protect an existing key.
--outrefuses an existing file.O_CREAT | O_EXCL, which is what makes the owner-only mode meaningful: the mode applies only on creation, so reusing a path would write the phrase into whatever permissions that file already had, and a pre-planted symlink could redirect it somewhere readable. Refusing removes the window instead of narrowing it — and declining to truncate whatever is already there is the right instinct for the one key that cannot be regenerated. On a platform with no owner-only mode to set, the command says so rather than printing a permission it did not apply.- A mistyped phrase is rejected, not silently accepted. This is why the backup is BIP-39 words and not hex: every 32-byte string is a valid key, so a hex backup with one wrong character recovers a root — a different one — and you would discover it when the account you restored turns out to be one nobody has heard of. A wrong word fails the checksum at import.
- Whitespace is normalised, so a phrase retyped across several lines works.
- A forced replacement releases the discarded root’s device slots. A device row
is keyed by namespace alone, and enrolment refuses to replace a linked row that
names a different account — correctly, because that row holds the namespace’s
replica state. But after a forced import every such row names an account derived
from the key just discarded, so without this the node was refused enrolment under
the root it had just recovered, and told to “revoke the existing device first” —
advice needing the key it replaced. Rows belonging to the replaced root are
therefore dropped, and each namespace needs a fresh enrolment. Rows naming an
account this root never owned — a device paired into
somebody else’s account — are kept, because replacing this node’s root says
nothing about them. Import reports both lists. Re-importing the root that is
already installed releases nothing — it changed nothing, so a defensive
--forceon a re-run cannot cost you your enrolments. The new root and the removal of the rows it invalidates land as one atomic write, because the half-applied state (new root beside old rows) is exactly the one that makes enrolment refuse.
Rotated-out keys are not handed back
Section titled “Rotated-out keys are not handed back”A recovering account receives the current scope key only. Key delivery wraps for
current_key_recipients; old keys are retained locally (load_key_by_id) so a node
can still read deltas it already holds, and are never sent to a device.
This is policy, not an omission. The root is exactly what an attacker holding a stolen backup also has, so replaying the key history to anyone who proves possession of it would turn one leaked phrase into retroactive decryption of everything ever written. Rotations also frequently happen because something was compromised, and handing the old keys back to a “recovered” device would undo the rotation. The practical cost is nil: a recovering node has no local history to decrypt anyway.
Verified end to end
Section titled “Verified end to end”The account-root-backup-restore scenario deletes the node’s data directory and
recovers from the phrase alone — re-init, import, rejoin with a new identity, enrol a
new device — then asserts two things: the account id is unchanged, and a value
written before the wipe reads back. The second is only possible if a peer delivered
the current scope key to the new device, so it covers the peer-assisted half rather
than just the key round trip.
Known limits
Section titled “Known limits”-
There is no longer an un-enrolled window, and that was a real class of bug. A member that could exist before its account was known wrote as a key-derived stand-in until it enrolled, then switched to its real
AccountId— so any account-keyed grant made on either side of that switch silently stopped matching, and the mismatch surfaced far from the join that caused it. Joins now carry the credential precisely to remove the window rather than narrow it. -
Root-key compromise is unrecoverable. A stolen root signs its own handoff. Whoever holds the root is the account.
-
The root is exportable; the rest of recovery is manual.
merod account export|importback it up and restore it, but a recovered node still has to rejoin its namespaces before a peer will deliver the scope key — and its old devices are gone for good. Enrolment is no longer a separate step: rejoining carries the credential, so the replacement device is bound in the same operation that makes the node a member again. Both commands need the node stopped and cannot read a KMS-encrypted store. See Backing up and recovering an account. -
Revocation latency is per scope. A scope that has not folded the revocation still honours the old binding. Inherent to causal revocation.
-
Self-service revocation cannot rotate. The device stops writing at once but can read until an admin rotates.
-
Pairing is only as good as the channel the confirmation code travels on.
pair-initsigns a statement over the account, the device id and both keys, andpair-completerefuses to certify key material that does not carry it — so nobody can swap the KEM key under a captured device id, which is what previously made the exchange only as safe as the channel carrying it. What a signature cannot settle is a wholesale substitution: an attacker that replaces both keys and re-signs with its own produces a statement that verifies, because nothing on the certifying side commits to the genuine keys in advance. Binding the keys into theDeviceIdwould fix it and is deliberately unavailable — the id excludes them so a device survives key rotation. What closes it instead is the confirmation code: a 64-bit value over exactly what gets certified, whichpair-completerequires and checks against the keys that arrived, so the comparison cannot be skipped. It is 64 bits and not the six digits that would be pleasant to read aloud because the attacker knows the genuine code and can grind its own keypairs offline until one matches — the width is the work factor. What remains is not a gap in the check but in the channel: a code pasted alongside the keys it describes was rewritten by the same hand, so the code has to reach the account holder some other way — read out, or over a channel the payload did not take. -
A device cannot revoke itself. If it could, compromising one would hand the attacker a revocation primitive instead of taking one away. Revoking is the account’s authority, exercised from elsewhere — see Revoking with only the account root.
-
Key-delivery fan-out is quadratic in group size.
current_key_recipientsresolves devices per member, and each resolution scans the group’s bindings, so a rotation costs on the order of members × devices. It runs at rotation and join time — never per operation — and each recipient already needs a KEM wrap, which dominates. Hoisting one binding scan out of the loop collapses it when group sizes justify the change. -
A member with no live device cannot self-recover. Enrolment-at-join does not help here, because they are already a member — there is no join operation left to carry a credential. Linking a replacement means
AccountDeviceLinked, which is encrypted and so needs the key they no longer have, leaving them dependent on an admin to re-deliver it or to publish the replacement link. If they could re-key themselves, revocation would mean nothing. -
Member-addressed delivery remains reachable by admin action. Re-admission and an explicit key delivery both wrap to a member identity, so an admin can hand a key to a node still running a revoked device. An explicit privileged act, not an automatic path.
-
Storage gates take accounts; storage stamps keep devices.
AccessControl’s admins are the backingSharedStorage’s writer set, andPermissionedStorage’s capability map is keyed the same way — both now name accounts, so one grant covers every device a person holds. Owner stamps inUserStorage,AuthoredMapandAuthoredVectorstill name the device, and must: they are per-writer state, and two devices sharing a counter slot or an HLC seed lose each other’s writes. The rule for any change here is the one this page opens with — if correctness depends on two devices of one account being distinct, it stays device-keyed.A writer set names accounts while a signature can only name a key, so the two are bridged by
ApplyContext.signer_account: the account the signing key speaks for, resolved by the applying node at the governance cut the delta cites, through the same at-cut fold the membership check uses.Nonemeans “cannot resolve yet” and the write is deferred, not refused — a not-yet-folded device binding is a timing gap, and collapsing it into “that key speaks for nobody” would turn it into permanent data loss.Two limits remain, both closed by the unified-log cutover. The writer set half still resolves from the per-object rotation log at the data-DAG cut, because rotation operations bridged into the ACL plane carry no causal parents and are therefore unreachable from any cut. And a historical rotation’s author is checked against the bindings at the cut the current write cites, not the cut the rotation happened at — every replica resolves the cited cut identically, so it converges, but it is not causal honour for rotations.
The networked half of both planes is pinned by a scenario each, and they are deliberately separate:
account-device-revoke-lockoutcovers membership (one grant, two devices, and revocation that sticks for reads as well as writes), whileshared-storage-account-writers-two-devicescovers writer sets (a granted account’s second device writes aSharedStorageobject it was never named in, and still cannot update the first device’s authored entry). A paired device can be a perfectly good context member and have every guarded write refused — that was a real bug, from the two account spaces above — so passing one scenario says nothing about the other. -
A snapshot leaf is verified by signature alone. It is state, not an operation: it has no causal parents, so “was this account a writer then” has nothing to resolve against. Asking today’s bindings instead would refuse a leaf written by a since-rotated-out writer even though the sender’s own root hash includes it, leaving the receiver unable to match the root it just accepted. What still holds a snapshot together: the sender is a member, the delivered contents hash to the root it claims, and every subsequent operation is authorized at its own cut.