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) and opens payloads sealed to it (see below). Never signs an operation. 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.
The one thing addressed to the account root
Section titled “The one thing addressed to the account root”Everything above addresses a device, which is right for anything a running node needs. It is exactly wrong for one case: recovery after every device is gone. There the only thing the holder still has is the account root, restored from its mnemonic — so recovery data has to be addressed to the root itself.
POST /admin-api/groups/{group_id}/accounts/{account}/seal (and
meroctl account seal-to) does that, over a fresh ephemeral sender key whose
public half rides with the ciphertext. That last part is not decoration: the
node that sealed it may have rotated, been reprovisioned, or left the fleet long
before anyone opens the envelope, and a recovering holder knows nothing except
their own root key.
Two properties to keep in mind:
- It proves confidentiality, not authorship. The ephemeral sender is unauthenticated, so anyone knowing a root public key can produce an envelope for it. Whatever decides an envelope is genuine belongs in the service that accepted the write, not in the envelope.
- The root is doing double duty here, as a signing key and as an ECDH recipient. That is a deliberate trade, not an oversight: this page separates the sign key from the KEM key precisely to avoid such dual use, and the clean version of this would be a recovery KEM key of its own. It cannot be that today because the sealer holds only the root public key, and no X25519 key derivable from the private root is derivable from the public one — publishing a separate recovery KEM key means changing the account genesis, which every existing account already carries. Revisit this if the genesis format ever moves.
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 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 the join operation itself, and why enrolment no longer waits on key delivery. It is also why the two invitation joins a keyless joiner publishes — MemberJoined and MemberJoinedAt — are the joins that cannot be sealed: sealing them would need the very key the join exists to obtain.
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 |
A join operation is readable by everyone who can apply it — every namespace member, and for the cleartext invitation joins every peer on the topic — so a credential can be lifted out of somebody else’s join and presented as one’s 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 is a root operation, and a sealed one. It has to be a root operation for the reason the bootstrap deadlock always gives: a device-addressed envelope carried inside an encrypted group operation would be unreadable by its only recipient, since the recipient is exactly who has no scope key.
But being a root operation is no longer what hands the device its key — the operation is sealed under the namespace key, which the device also does not hold. The device gets the key by pulling it from a peer over the direct-stream path (recover_missing_group_keys), the same way any participant holding no governance state does, and that pull verifies the served key against the key_id a signed operation names and requires a trusted anchor where it cannot. Sealing keeps the delivery metadata — which account, at which causal position — off the namespace topic.
What sealing gives up is ordering, and that is paid at the join. Cleartext, the delivery handed the device its key at a causal position, and the re-drive folded the AccountDeviceLinked published just before it; the device’s authority was therefore always present by the time anything asked. The pull is ordered against nothing, so join_context waits for that binding instead of asking once.
The new device is never a member. It records participation in each namespace it was told about and takes a gossip subscription there - 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.
The new device is told its namespaces; the holder chooses by application. A device that is a member of nothing can neither read its account’s namespace set off a DAG nor derive it, so pair-init takes the set from whoever is driving the pairing and enrolls into all of them under one DeviceId - one certificate and one confirmation code, however many namespaces it covers. The holder’s side asks a different question, because a namespace is an implementation unit nobody outside the node named: pair-complete scopes the fan-out by application, and naming none means every namespace the holder takes part in. The two sets need not agree. A binding published where the device is not listening still lands on that namespace’s DAG and is picked up whenever it does subscribe, and a subscription the holder never reaches costs the device nothing; only prompt delivery depends on the overlap. The namespace’s target application replicates the same way: the creator publishes TargetApplicationSet right after genesis, so a device that never saw the original invitation still learns which application the namespace serves.
A device that was unreachable when the holder published misses both ops: the link and the key delivery never arrive. Because it holds no governance state for the namespace, it cannot verify the members’ readiness beacons either - verification needs the member bindings that only the DAG it never pulled would have given it. The readiness-beacon handler uses the beacon’s arrival as the cue instead, and pulls the namespace DAG from the beacon’s sender through the same authenticated sync path every governance op still has to validate against. The device converges within a few beacon intervals - beacons fire roughly every 5 seconds - with no restart.
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.
A paired device keeps the certificate its own link carried, so it can found or join a namespace as the account without an import step.
A device paired before this change holds no such certificate, so the holder relinks it, or a certificate signed offline is imported with merod account import-cert.
The account namespace
Section titled “The account namespace”Every account has one namespace of its own, and every device of the account follows it. It is what makes an account a principal with replicated state rather than a set of facts that live only on one machine: what the account needs its devices to know is written there once and read by all of them.
Its id is derived, not assigned.
It is derived like every namespace id, from its founder and a salt (governance): the founder is the account, and the holder hashes the salt from the account root secret under a fixed tag.
So it is deterministic, a restored root recomputes it with no change to the backup phrase, its genesis is checked like any other, and nobody can find or squat the topic from an account id they merely know - the salt rides only the account namespace’s own genesis.
GET /admin-api/identity reports it as accountNamespaceId on the holder from the first run, before anything has created it, so a pairing invite can carry it.
The holder creates it lazily, at its first pair-complete, as a root group that targets no application, and binds every device it certifies into it.
pair-init takes the id as accountNamespace, records it and follows it like any namespace, so the namespaces list may be left empty.
It is not a project: GET /admin-api/namespaces and GET /admin-api/account/applications never show it, while GET /admin-api/account/devices lists it among a device’s bindings, because the device is genuinely bound there.
What it records. Two things, both encrypted under the account key that only the account’s devices hold.
The device registry: one row per device the root has certified, holding the full certificate proof and the applications that device may speak for. The replicated binding row a link writes drops the root signature, so a link cannot be rebuilt from it - which is why the registry carries the whole proof, and why every device of the account, not only the holder, can bind a sibling anywhere. Scope lives here and nowhere else, so a project namespace’s other members never learn which applications a device of the account is scoped to. A holder upgraded from before the registry publishes its cached certificates into it once, on first start, and then drops them.
The namespace set: one row per namespace the account is a member of, with the application it targets. Whichever device gains a namespace writes the line, and it is written after the links it publishes there, so no device follows a namespace before the authority it needs there exists. A gain announced before the account namespace itself exists publishes nothing at all - the holder names the id before creating it, and there is no DAG to write to yet - which is why the holder backfills instead. When it first creates its account namespace it backfills one line per namespace it already takes part in, so a device paired later reads the account’s whole history off one DAG. A gain that reaches the line before its namespace’s target application has folded does not write it yet: it waits, re-reading the namespace’s own metadata for up to thirty seconds, because no event announces a first target and nothing would re-drive the gain. Once the window closes it writes the line anyway, with no application - an app-less root is legitimate, and an unscoped device follows either way. Either way it writes nothing if the account has left the namespace in the meantime: a waiting gain must never overtake a leave, because re-naming a namespace in the set is something no later sweep can undo. Recording a line never un-knows an application the set already holds.
What a device does with it. On a gain whose application its own registry scope covers, it follows the namespace: it records participation, subscribes, and pulls the namespace once. The pull is part of following, not a rescue: gossip only carries what is published from that moment on, so without it the namespace’s existing DAG would arrive only on the next beacon. From there the existing machinery converges it - the key pull succeeds because the gainer already bound this device, the target folds, the bytecode is acquired, and contexts join through the account’s auto-follow flags. A device whose own scope has not arrived yet follows nothing and catches up when it does: on learning its own certificate and scope it walks the whole set and follows every namespace that scope now covers. That is also how a device widened after the fact reaches the projects it may now see.
On a leave it pulls the namespace once more and then unsubscribes from its topic, keeping the local rows exactly as the holder’s own leave_namespace does, so a later gain re-follows into state that is already there.
The pull comes first because the device’s own view of the namespace turns on folding that namespace’s MemberLeft, which travels on the topic about to be dropped.
What a device writes back.
It does not only read the account namespace, it acts on what it reads.
Folding a certificate for a sibling, it binds that device into every namespace it takes part in whose target the sibling’s scope covers - which is how a device certified after a namespace was gained reaches it at all, since the holder that signed the certificate may not be there.
Never a device revoked anywhere this node takes part, though: the DeviceId is spent everywhere at once while a tombstone is per namespace, so one tombstone refuses the whole projection.
It waits a random moment first, up to two seconds, so that k online devices do not all publish the same links at once, and it re-reads that refusal after the wait, because a revocation can fold while it is waiting.
The holder’s own fan-out at pair-complete is this same rule, run eagerly by the node that signed.
Folding a proof-bearing revocation, it publishes the same withdrawal into every namespace it takes part in where that device is still bound.
An admin revocation carries no proof and is not carried: being an admin of one group is not authority over another.
Every start sweeps, both ways. Nothing re-drives an op applied while no listener was up, so a device begins by walking the set itself: it follows every namespace the set names that its scope covers, and unfollows every namespace it takes part in that the set no longer names. The unfollow half acts only once this device has folded the namespace and the account’s member row there is gone. Both extra reads matter, because a namespace this device has never synced answers “absent” to each of them just as a left one does; requiring the namespace’s own governance to be present locally is what tells silence from a verdict.
Repeating a bind is cheap, and that is why any of this works. A certificate carries no namespace and no expiry. Binding a device somewhere new needs the stored certificate, a fresh endorsement any member of that namespace can sign, and one key wrap - no handshake, no confirmation code, and the device need not be online. A node that gains a namespace, by creating one or by joining one, extends every in-scope device of its account into it once it holds that namespace’s scope key: after the key arrives on a join, and after the genesis applies on a creation, because a link’s endorser is resolved through the binding rows that genesis writes. Any device of the account does this, not only the holder: the phone that founds a project carries the laptop into it, with the scope only the account root could have signed. The carry-over is best effort - a device it cannot extend is logged and skipped, never a reason to fail the join or the creation.
An operator can ask for the same repeat directly, which is also how a device’s scope is widened after the fact:
POST /admin-api/account/devices/<DEVICE_ID>/relink{ "applications": ["<APP_ID>"] } # optional; absent repairs the stored scopeIt reports, per namespace, where the link landed and where it did not - alreadyBound, noScopeKey, outOfScope - for the same reason a revocation does: publication is per-DAG, so a partially reached device is a state an operator has to be able to see.
Naming applications widens the device’s scope and publishes the widened statement into the account namespace at the next scope epoch, since that scope is what every later namespace gain - on any device of the account - is judged against.
Relink only ever adds, which is deliberate: a request meant to widen must never narrow by accident. Taking an application away is a separate operation, and it names the whole replacement rather than a delta:
PUT /admin-api/account/devices/<DEVICE_ID>/scope{ "scope": { "only": ["<APP_ID>"] } } # or { "scope": "all" }It records the replacement in the account namespace first, so every other device judges the change against it, then publishes AccountDeviceDescoped into every namespace this node takes part in that the new scope no longer reaches - whether or not the device was bound there, since that op is also what writes the scope floor - then binds the device into the ones it now does.
The narrowing is not a revocation: it deletes the binding row and leaves no tombstone, so the DeviceId survives and a later widening re-binds the same device.
A revocation would spend that id in every namespace at once, which is the difference between taking one application away and destroying the device.
The rotation rides along where this node is an admin of the namespace and the device actually held a binding there, and is left owed otherwise; the response reports it per namespace, as descoped, linkedIn and skipped lists shaped like relink’s.
linkedIn names a namespace whose binding row is actually there after the publish, and a namespace whose link was published but not recorded - or whose narrowing did not publish at all - is reported under skipped with reason failed, since nothing re-drives it and repeating the same request is the repair.
Three requests are refused before anything is signed.
Naming the device that holds the account root is a 403: that device signs every scope statement, so it always acts for every application and there is nothing to replace.
Naming an application no namespace of this account targets is a 400, because such a scope reaches nothing and would descope the device everywhere - an empty scope under another name.
A list longer than a scope statement may carry is a 400 too, and repeats in it are dropped rather than signed.
A narrowing is ordered, because a root-signed statement is not a fresh op. Every member of a namespace decrypts the ops published there, so any of them holds the statement a past narrowing carried and can re-publish it under their own signature and nonce - which is a genuinely new op, past both the signer check and the replay window. So every link carries the root-signed scope it was made under, and the binding records that scope’s epoch; a narrowing applies only where the binding was made under a strictly older scope. Re-presenting yesterday’s narrowing after today’s widening therefore changes nothing, on every replica, in whatever order the two arrive. The old link is just as replayable as the old narrowing, so a narrowing also records its scope epoch as a floor for that account’s device in that namespace, whether or not anything is bound there yet. A link made under a scope at or below the floor is refused, which is what keeps a stale link from re-binding a device the account narrowed out, and what lets a narrowing win when it arrives before the link it outranks. The floor is keyed by account and device together, so a statement signed by another account’s root cannot raise it, and only the account’s own key may publish the narrowing at all - the apply refuses one signed by any other member of the namespace. The same statement on the link is what moves scope enforcement off the publisher: a link into a namespace the scope does not reach is refused when it applies, not merely declined by a well-behaved sender. The projection’s account plane folds the narrowing under the same ordering, because the at-cut gates resolve an author through that plane before they read a single live row: a plane that folded every link and no narrowing would keep authorising a narrowed device on every other replica, leaving only its own node refusing. It records the highest scope epoch a link was made under and the highest epoch a narrowing raised, and withdraws the binding when the view is read - both sides a maximum over the folded set, so the answer is a function of the ops rather than of the order they arrived in.
A revoked device is refused outright rather than repaired namespace by namespace.
The tombstone is per namespace but the DeviceId is spent everywhere, and enrolling the machine again mints a fresh one - so there is no relink that brings the old id back, and the refusal says so.
A device carries a name, and the name replicates. A node-local alias names a device on one machine only, so a phone named on the laptop still shows as an id on the phone. The replicated name is a separate field, set through its own route:
PUT /admin-api/account/devices/<DEVICE_ID>/label{ "label": "Work laptop" }It publishes AccountDeviceLabelled into the account namespace alone, and GET /admin-api/account/devices carries the result as each entry’s label.
The name is display only - it gates nothing, which is why it is the one account op the unified plane does not fold at all.
Who may set it follows from who can sign: the node holding the account root attaches a root-signed statement and may name any device of the account, while a paired device holds no root and so may name only itself, where its own binding in the account namespace is the authority.
Asking a paired node to name another device is a 403 rather than a publish nobody would accept.
A device the account revoked has no live binding left, so it cannot rename itself on its way out, though the name it already carries stays in the listing beside its revoked: true.
Names converge the way scopes do: the higher label epoch wins, and two minted at one epoch tie on the name itself, so replicas folding a race in opposite orders keep the same row.
The name is bounded at the trust boundary rather than at the route alone - trimmed, non-empty, at most 64 bytes and free of control characters - because it is rendered verbatim beside other devices, where a newline forges the rows around it.
Renames are also spaced out per device by a node-local cooldown, answered as 429; it is deliberately not part of the apply, so two nodes with different clocks still fold the same ops to the same row.
A revoked device can say why it is unpaired.
Folding its own withdrawal is what releases a device’s row, after which the node simply stops being paired and has nothing to point at.
So the release records the account and device it lost, and GET /admin-api/identity reports it as revokedFrom.
The field is absent on a node no revocation has reached, and it is dropped the moment the node pairs again or gets an account root of its own, since the question it answers has stopped being asked.
A device paired before all this has to learn the id once.
The desktop shows a link code carrying the root key and the account namespace id; running the idempotent pair-init on it mints nothing and starts the device following the account namespace.
A relink on the holder then binds it there and publishes its certificate, and from that point the device converges like any other.
A cloud device is nothing special here. A node paired as a device of the account with full scope follows the account namespace and every project namespace in its scope, beacons on their topics, serves pulls, and binds new siblings. A phone then converges while the laptop is offline, which is otherwise the one thing the design still needs: without such a device, convergence waits until some other device of the account is reachable. The DAG makes the wait harmless, but it is still a wait.
What it costs, and what it leaks.
One more namespace per account - a topic, beacons and a DAG - created lazily at the first pairing, so a single-device account pays nothing.
The id is not computable from the public account id, but the namespace’s genesis is cleartext and names the founder account, so anyone who learns the id can link it to the account and watch beacon timing for device liveness.
Project topics leak the same class of metadata today, and the same mitigation applies to both: the id is only as private as the invite that carried it.
Per-namespace revocation latency is unchanged - a namespace that has not folded the unlink still honours the old binding - and the application a gain records is only what the gainer had folded at that moment, which decides who follows, never what the namespace actually runs.
Signing for a verifier that is not a node
Section titled “Signing for a verifier that is not a node”A service outside Calimero — the cloud’s account linking, for one — needs the account root to sign something before it will file an account under a login. It already specifies what: a domain-separated signature over a challenge it issued and sealed itself.
The node does not design that format. It produces it.
signature = ed25519( root_secret, DOMAIN ‖ payload )where DOMAIN is one of a closed set the node knows and payload is
whatever the verifier asked for — for the cloud, the UTF-8 of its challenge.
Why the domain is a name and not bytes
Section titled “Why the domain is a name and not bytes”“Sign these bytes with the account root” is a signing oracle, and the root is the one key that can certify a device — which is to say, take over the account. So the caller names a domain and the node supplies its bytes:
POST /admin-api/account/sign-with-root{ "domain": "mdma.account-login", // a name from the allowlist "payload": "65794a6c…" } // hex; opaque to the node→ { "data": { "rootPublicKey": "<64 hex>", "signature": "<base64>", "accountId": "<64 hex>" } }A length check would be the tempting shortcut — every credential Calimero itself
designs signs a 32-byte domain_hash digest, so refusing 32-byte payloads looks
like it separates the two worlds. It does not: admitter_endorsement_payload
signs a raw concatenation of variable length, and nothing stops the next signing
site from doing the same. A guard that depends on auditing every signing site
forever is the wrong shape. Naming the reachable domains inverts it — a new
signing site cannot become a target, because its domain is not on the list.
no_external_domain_shares_a_prefix_with_a_core_domain keeps the two sets
prefix-disjoint in both directions, because a caller-chosen payload can extend a
message rightward into whatever follows.
Offline, when the root is in cold storage and no node exists:
merod account sign-with-root --domain mdma.account-login \ --payload <HEX> --from ./phrase.txtWhat the signature establishes
Section titled “What the signature establishes”Possession of the root, and nothing else. Whether the payload is a challenge anyone issued, whether it is fresh, and whether the holder may link this account anywhere are all the verifier’s — it issued the challenge and knows who to.
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/identityTwo flags on the answer separate a half-finished pairing from a delegate device.
holdsAccountRoot says whether this node can certify another device into the account, and deviceCertified says whether the account holder has certified this node’s device.
Pair-init makes the first false and leaves the second false; only pair-complete - or an imported certificate - flips the second.
The 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.
A revoked device is not a device here either. The device row is read first,
because for a paired node it is the only place the adopted account is written
down - but one row serves every namespace while a tombstone is per namespace, so a
device revoked anywhere is one whose id is spent: enrolling the machine again
mints a fresh one, which is why the enrolment slot is already released on that
basis. Once the tombstone lands, GET /admin-api/identity reports no device and
falls back to this node’s own account root. A paired node that held no root of
its own has nothing left to report and answers 404 - which is the truth, rather
than a locally derived id no row in any group is keyed by.
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.
It does not stop at the namespaces the publishing node can reach, either. Every device of the account follows the account namespace, so a proof-bearing revocation published there is folded by all of them, and each republishes it into every namespace it takes part in where the device is still bound. The proof names no namespace, so the same bytes verify wherever they land. That is the lost-laptop case in full: the holder is gone, the phone revokes it with a proof minted from the phrase, and the tablet carries the tombstone into the projects the phone was never in. The revoker publishes into its own account namespace last, after every project namespace it reaches: that apply is what drives its own carry, and by then there is nothing left for the carry to publish.
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.
A state delta is the exception, and the receive filter covers it. The cross-DAG check on a state delta resolves the author at the governance heads the author cites, so a device that was offline when it was revoked, and writes before it folds its own revocation, cites heads from before it and passes that check. So apply_revocation also records the signing key the deleted binding named, and the state-delta receive filter drops any delta signed by such a key while no live binding speaks for it. The “while” matters: a device’s signing key is its node’s namespace identity, which a re-paired node keeps under its fresh device, so a key bound again is not refused — whichever of the two operations arrives first. The DAG catch-up of a peer’s heads refuses such a delta too (#4070), and so does the fine-sync that follows a snapshot, which fetches the source’s current heads (#4089).
A revoked device’s delta is valid until you learn of the revocation, and an honest descendant proves it. A delta fetched as the parent of one already accepted is not checked for revocation. The authorized author who built on it signed that it was accepted, which could only happen before the revocation reached them. So a write accepted by any node before it heard of the revocation reaches everyone with that node’s next write, and nodes converge without rolling anything back. A post-revocation write never gets that far: every node refuses it on arrival, so no honest delta ever builds on it. Refusals are not remembered, so a delta refused as a head is still accepted if it later arrives as a parent.
The rule’s accepted costs:
- A member who modifies their own node can accept a revoked device’s write and build on it, getting it accepted everywhere. They could write anything under their own key already; what they gain is attribution to the revoked device’s account.
- A node that has not yet heard of the revocation, for example one coming back online, accepts the write, and its next write carries it everywhere.
- Until that node writes again, it and the others differ, and HashComparison does not close the gap: its leaf gate refuses the revoked author on every node.
- During a rolling upgrade, nodes without the head-pull check accept a revoked device’s head, and their next write vouches for it.
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 or writer-set entry read back from a storage collection | account_id() |
UserStorage, AuthoredMap, AuthoredVector, SharedStorage and AccessControl all gate on the account, so a lookup has to match it |
The third row used to bite the other way: those collections once stamped the writer’s device, so handing them an account id produced a gate that never matched. They moved onto AccountId for the reason this page opens with — a person’s second device is not a stranger to their own data. What is still device-keyed on the same entry is the signer recorded alongside the owner (signature_data.signer, which device actually wrote the bytes) and the CRDT mechanics underneath it — an LWW tiebreak, a counter slot, an HLC seed. Neither of those is what a lookup matches against.
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, plus the NodeAccountDeviceCert rows that keep the certificates this account signed. Node-local, never gossiped. |
crates/governance-store/src/account_bindings.rs |
The replicated view: device bindings, revocation tombstones and the signing keys they withdrew, 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/governance-store/src/device_link.rs |
The one publish path that links a device - the link plus its key delivery - and the decision about whether a namespace needs one. |
crates/context/src/handlers/ |
The operations a user drives: pair_device_init, pair_device_complete, relink_device, 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 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: b2a942ff4c98718bed76e255987f6d59b1a72d3b2cd2510003e6170ac63a9ffbAccount: 03944a72253996cf87a2ac0680ff52b75143333ea21605e0dd1877dc1b16ecaa
The phrase is the whole backup. The account is the content address of thisroot, so recovering the phrase recovers the account — the same one in everynamespace, with nothing to keep beside the words.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 account id | the same content address in every namespace — nothing to derive, and nothing extra to keep beside the words |
There is no --namespace flag. It existed when the account id was
per-namespace-derived; now that one root is one account everywhere, there is
nothing left for it to select. export takes only --out (write the phrase to
a file instead of stdout) and --allow-plaintext-file (required to acknowledge
that).
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 join op itself, 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, and so do storage owner stamps.
AccessControl’s admins are the backingSharedStorage’s writer set, andPermissionedStorage’s capability map is keyed the same way — both name accounts, so one grant covers every device a person holds. The owner stamp inUserStorage,AuthoredMapandAuthoredVectornames an account too, for the same reason: a person’s second device has to be able to edit their own slot or entry, not be refused as a stranger to it. What stays device-keyed on those same entries is the signer recorded alongside the owner (signature_data.signer) and the CRDT mechanics underneath — an LWW tiebreak, a counter slot, an HLC seed — because those are per-writer state and two devices sharing one would 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 checked by authorship, not by current authority. It is state, not an operation: it has no causal parents, so “was this account a writer then” has nothing to resolve against. Asking whether the author may write now, as tree comparison does, would drop from every cold joiner the state of anyone who has since left or lost a device, and nothing would repair it, because the root check reads the shipped root. So the receiver asks only whose key signed the leaf: the signature must verify, and the key must have been certified for the entry’s owner (
User) or for an account in its writer set (Shared, or the anchor’s set for aSharedMember). The key is resolved through every device certificate the node has ever folded (GroupSignerAccount), not the live binding, which a revocation, descope or device-key rotation removes. A leaf signed by some other account’s key is dropped, except aSharedleaf whose signer removed themselves from its writer set: its last write was that rotation, and the anchor’s rotation log (delivered by the same snapshot) shows the signer was a writer before it. Likewise aSharedMemberwhose signer a later rotation removed from its anchor’s writers: rotations do not re-sign members, so it is kept when the signer was in the set in effect at the member’s own timestamp. A leaf whose key no certificate names fails the snapshot for retry, since that usually means the joiner has not folded that far yet. What else holds a snapshot together: the sender proves it is an admitted member, the delivered contents hash to the root it claims, and every subsequent operation is authorized at its own cut.