Skip to content

Identity & Key Rotation

A scope (a group or a namespace; see Identities and Governance) protects its replicated data with a single symmetric scope key. Every governance op and state delta written into the scope is encrypted under that key, so holding the key is what lets a node read the scope at all. This chapter is about what forces that key to change — a member’s departure, whether an involuntary removal or a voluntary self-leave — and the self-healing path a member uses to acquire a key it is entitled to but does not yet hold.

Calimero keeps two cryptographic objects per member, and only one of them rotates:

Member identity Scope key
What it is The member’s per-root-group Ed25519 keypair (its namespace identity) A 32-byte symmetric AES-256-GCM key for one scope
Who holds it Only that member (the private half never leaves the node) Every current member of the scope
What it signs / does Signs the member’s governance ops and state deltas; is the ECDH wrap recipient Encrypts/decrypts every op and delta in the scope
Rotates on removal? No — the member’s identity is stable Yes — a fresh key is minted, locking the removed member out

The member key is never rotated by this mechanism. Removal does not (and cannot) change another node’s keypair; it changes the symmetric key everyone shares, and simply declines to hand the new one to the removed member. The member identity is what the new key is ECDH-wrapped to.

A removed member still has, on its disk, the old scope key and every byte it ever decrypted. Rotation cannot retract that. What it can guarantee is forward secrecy on new writes: a member removed at epoch N must not be able to read anything authored at epoch N+1 or later.

The mechanism is to make the old key a dead end. On an involuntary removal the scope mints a brand-new symmetric key, hands it to every remaining member, and from that moment encrypts all new ops under the new key. The removed member, holding only the old key, can decrypt the history it already had but nothing written after its eviction.

This is the cryptographic half of removal. The governance half — dropping the membership row and deny-listing the member so its state deltas are refused at the receive entry point — happens in the same MemberRemoved apply (crates/governance-store/src/ops/group/member_removed.rs). Rotation is what makes the eviction cryptographic rather than merely authorizational.

A scope accumulates more than one key over its lifetime (the genesis key plus one per rotation), so every encrypted op must say which key it is under. The identifier is the SHA-256 of the key itself:

pub fn key_id_for(group_key: &[u8; 32]) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(group_key);
hasher.finalize().into()
}

Every encrypted group op travels as NamespaceOp::Group { group_id, key_id, encrypted, key_rotation }. A receiver resolves key_id against its keyring (load_key_by_id) and decrypts with whatever key hashes to it — so an op encrypted under an old key still decodes for a node that retained that key, and a node that has only the new key transparently fails to read superseded epochs it was never meant to. The KeyRotation bundle carries the next epoch’s id as new_key_id = sha256(new_group_key).

How the scope key is wrapped: ECDH per recipient

Section titled “How the scope key is wrapped: ECDH per recipient”

The new key is never broadcast in the clear. It is wrapped once per recipient using an ECDH-derived shared secret, so only the intended recipient can unwrap it, and each envelope is signed by whoever wrapped it:

pub struct KeyEnvelope {
pub recipient: EnvelopeRecipient, // who it is for, and the key that opens it
pub sender: PublicKey, // authenticated by `signature`
pub nonce: [u8; 12],
pub ciphertext: Vec<u8>, // AES-256-GCM(scope_key) under the ECDH secret
pub signature: [u8; 64], // Ed25519 by `sender` over the canonical payload
}

A recipient is either a member or a single device, and the variant carries its own ECDH public key, because the addressing mode is the agreement mode:

pub enum EnvelopeRecipient {
// Bootstrap: ECDH over the Curve25519 form of the member's Ed25519 identity.
Member { identity: PublicKey, ephemeral_pk: PublicKey },
// Post-enrollment: native X25519 to the key in the device's certificate.
Device { device: DeviceId, ephemeral_pk: KemPublicKey },
}

Both forms exist permanently. A join carries its device credential in the clear, so an ordinary joiner is already bound when the key is delivered and the device-addressed form reaches it. A standalone AccountDeviceLinked is different: it travels as an encrypted group op, so a node must already hold the scope key to publish one. Member addressing is therefore the bootstrap path for everyone who has no device the scope knows and no join left to carry one — a re-admitted member, or one whose devices have all been revoked.

The wrap generates a fresh ephemeral keypair per envelope and derives the secret from that ephemeral against the recipient’s key, which is what gives the wrap forward secrecy: compromising the sender’s long-term key later does not decrypt past envelopes. The sender then signs the canonical envelope bytes, bound to the group_id, so a recipient can reject a forged or cross-group-replayed envelope before doing any ECDH work. The variant tag is inside those signed bytes, so the two addressing modes cannot be swapped by rewriting the discriminant.

The scope-key-to-data step is plain symmetric AES-256-GCM: encrypt_op builds SharedKey::from_sk(group_key), which uses the 32-byte scope key directly as the AES key, then seals the borsh-encoded op. ECDH is used only to wrap the scope key for a recipient; the scope key itself encrypts the bulk data symmetrically.

Rotation is built and attached at publish time, not in the generic apply handler. When the admin publishes the removal (sign_apply_and_publish_removal), the group-governance publisher mints a fresh key and builds a KeyRotation bundle for the remaining members (crates/governance-store/src/group_governance_publisher.rs):

let new_group_key: [u8; 32] = OsRng.gen();
let _ = GroupKeyring::new(self.store, self.group_id).store_key_with_epoch(&new_group_key, epoch)?;
// Who is entitled is decided here, where the reason for the exclusion is visible.
let recipients: Vec<KeyRecipient> = keyring
.current_key_recipients()?
.into_iter()
.filter(|entitled| entitled.member != *removed)
.map(|entitled| entitled.recipient)
.collect();
Some(keyring.build_rotation(&new_group_key, &rotation_sender_sk, &recipients)?)

build_rotation takes its recipient list as an input and only wraps — there is no excluded_member parameter, because removing someone is now simply leaving them out, which cannot be silently forgotten the way an unpassed exclusion could.

current_key_recipients is what decides the list, per member, device-first:

  • a member the scope knows an account for is addressed only through that account’s live devices — no identity fallback, because the identity key lives on the same node as a revoked device and would hand the key straight back;
  • a member with no account is addressed by identity.

Each entry is paired with the member it rests on, which is why filtering by member above drops every device of the removed member rather than just their identity entry.

The bundle rides on the same NamespaceOp::Group { .. , key_rotation: Some(rotation) } that carries the encrypted MemberRemoved op. A single bundle may mix both addressing modes, so on apply each receiver checks for an envelope addressed to its namespace identity or to its own enrolled device, then unwraps with whichever credential the envelope names (crates/governance-store/src/namespace/governance.rs):

let node_device = NodeDeviceRepository::new(self.store).get(&ns_id)?;
for envelope in &rotation.envelopes {
let for_us = match envelope.recipient {
EnvelopeRecipient::Member { identity, .. } => identity == recipient_pk,
EnvelopeRecipient::Device { device, .. } => {
node_device.as_ref().is_some_and(|own| own.device == device)
}
};
if !for_us { continue; }
// `expected_sender = op.signer`: the identity that signed the outer op MUST be
// the one that wrapped the envelopes, so a mismatched wrapper fails closed.
let new_key = GroupKeyring::unwrap_any(
&recipient_sk, node_device.as_ref(), &group_id, Some(&op.signer), envelope,
)?;
let _ = GroupKeyring::new(self.store, group_id_typed).store_key_with_epoch(&new_key, epoch)?;
break;
}

Rotation is forward-only. The removed member keeps the old key and the plaintext history it already decrypted — Calimero does not and cannot claw that back. The guarantee is strictly that data authored after the rotation is unreadable to it, because that data is encrypted under a key it was never handed.

Self-leave rotates too — but not by the leaver

Section titled “Self-leave rotates too — but not by the leaver”

A voluntary MemberLeft cannot carry its own rotation, for the reason the apply handler gives (crates/governance-store/src/ops/group/member_left.rs):

this op deliberately does NOT trigger the key-rotation pipeline that MemberRemoved does, because the publisher (the leaver) cannot generate the new key without also retaining it — which would defeat forward secrecy.

The leaver is the one publishing the op. If the leaver minted the new key it would by construction know that key, so wrapping it for the remaining members would buy nothing. Peers reject a rotation signed by a non-admin anyway.

So the two halves are split across nodes: the apply records the debt rather than paying it, and the members who remain settle it.

  1. The leaver publishes MemberLeft. Its apply removes the membership row, deny-lists the leaver, and marks the rotation owed — writing a replicated GroupPendingKeyRotation row on every node. The apply is deterministic, so the worklist replicates rather than needing its own gossip.

  2. rotation_listener (crates/context/src/rotation_listener.rs), running on each remaining admin, picks the debt up and issues RotateGroupKeyRequest, which mints a fresh key and publishes GroupKeyRotated with it wrapped for the members who are still there.

  3. The first GroupKeyRotated to apply clears the pending row. Later attempts find it cleared and become no-ops.

There is deliberately no election and no quorum. Every remaining admin reacts, and two admins racing mint different keys — which is safe, because the keyring already converges on one (highest epoch, ties broken by the larger key id: a total order over a hash, so every node makes the same choice), and because every competing key excludes the leaver. Whichever wins, the leaver holds none of them. The only cost is redundant envelopes on the wire.

Liveness has two triggers, because the live op-event alone is not enough — an admin that was offline when the leave applied never saw it. The listener therefore also runs a startup sweep of the persisted worklist. Both triggers funnel into the same idempotent request, so they can overlap harmlessly. The worklist drains; it does not evaporate.

Rotation on self-leave is deferred, not immediate, and that difference is the whole of what a self-leave gives up next to an admin-published MemberRemoved, which rotates in the same op. Until a remaining admin discharges the debt, new writes are still encrypted under the key the leaver holds. The deny-list stops the leaver writing and it unsubscribes from the topic, but a leaver that keeps watching gossip can still read that window.

The window is bounded by how quickly a remaining admin rotates, and it is observable — the pending row is the standing record of what is owed. It does not close on its own if no admin ever returns: a group whose remaining admins are all permanently offline keeps the debt outstanding indefinitely.

Not every departure rotates. For a removal the publisher decides by which key encrypted the op; for a self-leave the same question is asked by group_rotates_on_departure (crates/governance-store/src/pending_rotation.rs), which records a debt only for a scope that encrypts under its own key. Either way the answer turns on the same distinction:

  • Restricted subgroup (encrypted under the subgroup’s own key), or any subgroup behind a Restricted ancestor: rotate. This is the standard forward-secrecy path described above.
  • Namespace root: rotate. The namespace key also decrypts every Open subgroup beneath it, so a member who leaves the namespace entirely would otherwise go on reading the root and all of those.
  • Open subgroup beneath a fully-Open chain (encrypted under the namespace key, which it inherits): skip rotation. The departing member’s namespace membership is unaffected by a subgroup departure, so it still holds the namespace key — a per-subgroup rotation would mint a key that nothing uses while the subgroup stays Open. Leaving such a subgroup revokes authorization (the membership row is gone, so the identity can no longer pass the membership walk) but not cryptographic read access; revoking that means rotating the namespace key (a broad blast radius) or flipping the subgroup to Restricted.

This is the documented trade-off in group_governance_publisher.rs; the choice keys off whether the encrypting scope is the subgroup itself or the namespace root.

Key delivery to a newly-admitted member is a one-shot push (RootOp::KeyDelivery, or the rotation envelopes above): an existing key-holder wraps the key once when it first applies the join. A single gossip publish into an intermittent mesh can be missed, and gossipsub has no replay. So the durable path is the opposite direction — a member that is online and syncing pulls any key it is entitled to but lacks, every sync round, until it has it.

The requester enumerates the scopes it is a member of but holds no key for (namespace_groups_awaiting_key) and asks a candidate peer — the peer it just synced with first, then namespace-mesh subscribers (recover_missing_group_keys, crates/node/src/sync/manager/namespace_sync.rs):

InitPayload::GroupKeyRequest {
namespace_id,
group_id,
requester_public_key, // membership-checked; the wrap recipient only while
// the responder knows no account for it
requester_device, // the device it asks as, when it has enrolled one
}

requester_device is deliberately unauthenticated. The reply is sealed to that device’s certified X25519 key, so naming someone else’s device yields an envelope the caller cannot open — the wrap is the authentication, which is why this request needs no signed proof of its own.

The responder authorizes the request before serving anything (build_group_key_delivery). Two gates must both pass:

  1. Cross-namespace pin. The requested group_id must resolve to the namespace_id the requester named — otherwise an attacker in namespace A could elicit a key for a group in namespace B.

  2. Membership. requester_public_key must be a current member of group_id (is_member).

Only then does the responder load the current key, ECDH-wrap it for the requester, and reply with GroupKeyResponse { key_envelope_bytes, responder_identity }. Every non-deliverable case replies with an empty envelope — not an error and not a rejection — so the exchange leaks no membership oracle, and the requester simply tries another peer next round.

When a key finally lands, apply_received_group_key stores it and replays any encrypted ops that were buffered awaiting it — governance ops that had been frozen as undecodable now decode to their real payload, so the membership projection catches up.

A keyless bootstrap joiner does not trust the key deliverer as the namespace admin. Instead it seeds the namespace root with an all-zeros placeholder admin that grants authority to nobody; the replayable NamespaceCreated genesis op later overwrites the placeholder with the real founder, and the two converge regardless of arrival order (seed-first or genesis-first). The all-zeros key is a safe sentinel because it decodes outside the Ed25519 prime-order subgroup, so no real key can collide. This replaced an earlier scheme that trusted the responder_identity to seed the admin — which could pin the wrong admin and wedge backfill.

Why the pull is device-addressed once accounts exist

Section titled “Why the pull is device-addressed once accounts exist”

The pull is the one path that can undo device-granular revocation, and it did: a request named only requester_public_key, so a node whose device had just been revoked was still that member and was served the current key on its next sync round — routing straight around the exclusion the rotation had enforced.

So the responder resolves the reply by the same rule the rotation fan-out uses:

  • The requester’s member key has no account in the scope → wrap to its identity. This is the bootstrap case and cannot be retired. It no longer covers the ordinary joiner, whose join op carries a credential and so binds a device before any key is pulled; what remains are the members with no join left to carry one — a re-admission, or a member whose every device is revoked — for whom enrolling still needs an encrypted group op, hence the key they are asking for.
  • It has an account → serve only a live device of that account, and only the one the request named. Identity addressing is no longer available, which is what closes the leak: a revoked device cannot simply omit its id and be served as its member.

A member whose every device is revoked or superseded therefore receives nothing from the pull. That is deliberate, not a dead end. Re-enrolling needs an encrypted group op and so needs the key, so recovery is admin-mediated: an admin re-delivers the key with RootOp::KeyDelivery, or publishes the replacement link on the member’s behalf (a link op need not be signed by the device it enrolls). If a member in that state could re-key itself, revocation would mean nothing.

  • Identities — the namespace-identity keypair model that rotation wraps to.
  • Governance — the MemberRemoved / MemberLeft ops and the membership projection.
  • TEE Attestation & Fleet Admission — fleet eviction routes through the owner-side rotating removal; self-purge deletes the evicted node’s now-orphaned old key but rotates nothing.

Next: Security & Threat Model — how signatures, membership, and keys combine into the full trust model.