Skip to content

Encryption & Confidentiality

Confidentiality in Calimero is not a single switch. It is three independent layers, and a byte is only as private as the weakest layer that carries it:

  • Transport — the libp2p connection between two nodes (Noise/TLS on TCP, TLS 1.3 on QUIC). Protects everything in flight against a network eavesdropper, but terminates at each peer.
  • Payload — application-layer encryption applied to specific fields before they are gossiped, so that even a peer subscribed to the topic cannot read them without the right key.
  • At-rest — encryption of values written to local storage, gated on an optional KMS-backed wrapper.

The headline question this page answers is: for any given piece of data, which of these layers actually covers it. The short version is that payload-layer encryption is selective — it protects the state-change artifact, the post-apply root_hash, and the execution events (all sealed together in one ciphertext), plus group governance mutations, but a large amount of routing metadata rides in cleartext on the gossip topic.

This page cross-links Identities & Keys (which key is which), Key Rotation (how group keys turn over), Networking (the transport and gossip layers), and the operator-facing Security guide.

All payload-layer encryption goes through a single type, calimero_crypto::SharedKey (crates/crypto/src/lib.rs). It is always AES-256-GCM (via ring), always a 12-byte nonce, and — importantly — always empty AAD. There are two ways to construct it, and conflating them is the most common source of confusion:

Constructor Key material Used for
SharedKey::from_sk(sk) The 32 bytes are the symmetric key, used directly. Bulk data encrypted under a group key: state-delta artifacts and group governance ops.
SharedKey::new(sk, pk) ECDH on the Ed25519 identity keys (sk × pk), producing a shared secret. Key delivery only: wrapping a group key so a specific recipient can unwrap it.

So the symmetric variant moves data under a key the whole group already shares, and the ECDH variant moves the key itself to one recipient. Both call the identical encrypt/decrypt methods underneath.

This is the master map. “Encrypted?” refers to payload-layer encryption; everything additionally gets transport encryption on the wire (and may get at-rest encryption on disk, see below).

Data Encrypted? Key Algorithm / notes
State-delta artifact (the CRDT actions) Yes Group/scope key (from_sk) AES-256-GCM. Encrypted at crates/node/primitives/src/client.rs:557; decrypted at crates/node/src/handlers/state_delta/crypto.rs:113.
State-delta events (execution events) Yes Group/scope key (from_sk) Sealed with the artifact inside SealedDeltaPayload, then AES-256-GCM (client.rs). Readable only by group-key holders.
State-delta root_hash (post-apply state fingerprint) Yes Group/scope key (from_sk) Also sealed inside SealedDeltaPayload — never a cleartext wire field.
Delta envelope: parent_ids, hlc, author_id, delta_id, key_id, governance_position, delta_signature, nonce No Plaintext routing/causality metadata.
key_id n/a (it is a tag) SHA-256(group_key); cleartext so receivers can resolve the key.
Governance Root ops, sealed set (GroupCreated, GroupReparented, GroupDeleted, AdminChanged, PolicyUpdated, KeyDelivery, MemberJoinedOpen, MemberJoinedViaTeeAttestation) Yes Namespace key AES-256-GCM over borsh(RootOp), carried as NamespaceOp::RootSealed { key_id, encrypted }. Published by a principal that already holds the namespace key, so nothing is lost by sealing. root_op_is_sealable (crates/governance-types/src/lib.rs) is the single place the split is decided.
Governance Root ops, cleartext set (MemberJoined, MemberJoinedAt, NamespaceCreated) No Unsealable rather than deliberately public. The two invitation joins are published by the JOINER, which holds no namespace key and obtains one because the op is published — sealing them under that key is circular. Worse, the signature covers op verbatim, so nobody else can seal one either: the joiner cannot sign the sealed form, and an admitter may not re-sign (every peer enforces signer == credential.statement.sign_pk). NamespaceCreated is genesis, before any key exists.
A relayed invitation join (NamespaceOp::RootRelaySealed) Yes Namespace key The way an invitation join can be sealed. When an admitter relays a join for a keyholder with no node of its own (POST /admin-api/namespaces/:id/admit), it seals the joiner’s whole signed opAES-256-GCM(borsh(SignedNamespaceOp)) — and signs the envelope itself. Receivers decrypt, verify the JOINER’S inner signature, and apply, so join_op_proves_ownership still decides who joined and a hostile admitter still cannot substitute an account. Nesting the signed op rather than moving the signature inside the seal is what preserves keyless verification: the outer envelope is admitter-signed, so a peer with no namespace key authenticates what it stores exactly as for RootSealed. A joiner with a node of its own seals its own join whenever it already holds the namespace key, which the join bundle normally delivers before the publish; only a joiner still without the key falls back to cleartext, because its key arrives in answer to that very op.
Governance Group ops (EncryptedGroupOp.ciphertext) Yes Group key (from_sk) AES-256-GCM over borsh(GroupOp). group_id / key_id / signer stay cleartext as routing tags; non-members store an OpaqueSkeleton (crates/governance-types/src/lib.rs).
Key delivery (KeyEnvelope.ciphertext) Yes ECDH from a per-envelope ephemeral: new(ephemeral_sk, identity_pk) to a member, from_x25519(ephemeral_sk, device_kem_pk) to a device AES-256-GCM over the 32-byte group key, plus an Ed25519 signature by sender bound to the group_id. On member removal the new key is re-wrapped once per remaining recipient; the removed member — and every device speaking for them — gets no envelope and is cryptographically locked out (KeyRotation).
Store values at rest Only if EncryptedDatabase is enabled DEK from HKDF-SHA256(KMS master, "calimero-dek-v{n}") AES-256-GCM, 12-byte nonce, versioned DEKs for rotation (crates/store/encryption/). Off by default.
Store keys at rest No Plaintext even with the wrapper on, so range scans / prefix seeks still work.
Blobs No Not encrypted at rest or at the app layer. BlobId = SHA-256(plaintext); only the transport protects them in flight (crates/node/.../blobs).
libp2p transport Yes Noise / TLS session keys Noise or TLS on TCP, TLS 1.3 on QUIC (crates/network/src/behaviour.rs).
Gossipsub messages Signed, not encrypted Ed25519 node key MessageAuthenticity::Signed (behaviour.rs:113) — authenticity, not confidentiality.

The pattern: bulk payloads that should be group-private are encrypted under the group key; the metadata that makes routing, causal ordering, and authorization work is cleartext. Confidentiality protects what changed, not that something changed, by whom, and in what shape.

“The group key” above is not always the key of the group whose data it is. One predicate decides, CapabilitiesRepository::is_open_chain_to_namespace (crates/governance-store/src/capabilities.rs), and calimero_governance_store::key_covering_group is the single helper that applies it:

  • Every ancestor up to the namespace is Open → the namespace key. An Open subgroup’s contents are namespace-scoped by construction: parent-group members holding CAN_JOIN_OPEN_SUBGROUPS are inherited into it, so there is no boundary for a separate key to enforce. Both its governance ops and its state deltas are encrypted under the namespace key.
  • Anything else → the group’s own key. That includes a Restricted subgroup, an Open subgroup sitting behind a Restricted ancestor (the wall breaks the chain — a one-hop visibility check gets this case wrong), and the namespace root, which the predicate answers false for against itself. A namespace is, in this respect, a Restricted group in its own right.

Two consequences worth stating, because both have bitten:

A subgroup’s key row exists even when nothing uses it. create_group mints a key for every subgroup at birth regardless of visibility, because a later SubgroupVisibilitySet -> Restricted establishes no key of its own — that row is what the flip turns into the group’s real key, and an apply handler could not mint one consistently across peers. So for an Open chain the row is a key nothing is encrypted under. Serving or adopting it is worse than serving nothing: the recipient records “key present” and then decrypts nothing, with no error on either side. Every site that reads a keyring for a group must go through key_covering_group, not the group’s own id.

The namespace key is not a subgroup member’s to hold. It reads all namespace-level governance and every Open-chain subgroup’s application state, and it is retained after leaving unless rotated. So a member admitted to a subgroup alone may not be given it — which means an invitation targeting an Open-chain subgroup cannot be satisfied at all, and is refused rather than answered with a key. Such a member is invited into the namespace root instead, after which inheritance carries them into the Open subgroup (MemberJoinedOpen).

A subgroup join seals under the subgroup key

Section titled “A subgroup join seals under the subgroup key”

A join whose invitation targets a subgroup is published sealed under the key covering that subgroup, as NamespaceOp::RootSealedForGroup { group_id, key_id, encrypted }.

This is the one root op a joiner can seal for itself, and the reason is which key it ends up holding. A subgroup-targeted invitation’s join bundle delivers that group’s key — join_group stores it before publishing — so on a Restricted chain the joiner holds the subgroup key at publish time. It never holds the namespace key, which is why RootSealed cannot carry this op and why root_op_is_sealable answers false for the MemberJoined / MemberJoinedAt variants.

group_id travels in the clear, and it is load-bearing rather than a leak: the receiver has to know which keyring to resolve key_id in. RootSealed can omit it because the namespace is implied by the topic; here the encrypting group is one of many beneath that namespace, and trying each keyring in turn is the guessing the key_id fields exist to prevent.

The encrypting group is chosen by key_covering_group, so a Restricted chain seals under the subgroup and an Open chain under the namespace — never under a key row nothing encrypts to.

Who may read it, and why that set suffices. The readers are the subgroup’s own members: its admins and any admitted TEE node, both of which already hold its key (TEE admission writes a normal member row, so current_key_recipients reaches it). No namespace admin outside the subgroup reads the join, and none needs to, because every invariant a subgroup join touches is already written by ops sealed to that same set:

  • the deny-list row a join clears is only ever written by MemberRemoved / MemberLeft, which are GroupOps — so only subgroup members ever hold one;
  • re-entry blocks and invitation-consumption records are per-(group_id, identity) and written by those same exits; both columns are hash-neutral, so a non-member not deriving them cannot surface as divergence;
  • count_admins is per-group and consulted only from those ops, and a join only ever adds a member.

State deltas are unaffected for an independent reason: they travel a per-context topic, so a peer outside the subgroup is not subscribed to its contexts and never resolves an at-cut membership verdict for one. Governance ops travel the namespace topic, which is where the sealed join lands and where it is opaque by design — the same standing an encrypted GroupOp for a foreign group already has.

The envelope carries a join and nothing else. Every path that opens it checks that, because the variant otherwise becomes a general-purpose envelope for any root op that opens with a subgroup key — a privilege widening rather than a looser type. RootOp::KeyDelivery was the concrete case: its side effect stores the delivered key without binding it to any signed op, so carried here it let a plain member of one Restricted subgroup write a key of its own choosing into a co-member’s keyring for an unrelated group, and then read that group’s traffic. Carried only by RootSealed it required the namespace key. That particular escalation is now blocked independently, by the trusted-anchor gate on delivery described below — but the join-only restriction is not therefore redundant: it is a property of this carrier, which should not be a general-purpose envelope for root ops, while the anchor gate is a property of one op. Either alone would leave the next sealable root op to be argued about individually. The same check pins the envelope’s group_id to one of the two values key_covering_group can emit for that invitation — the target subgroup, or the namespace root that covers an Open chain — since the sealing group decides which peers authorize the join and is therefore not the joiner’s to pick. It is compared against those two values rather than re-derived at the receiver: re-deriving reads local visibility rows, and two peers mid-flip would then disagree about whether an op is admissible.

A cleartext subgroup-targeted join is refused at apply, so sealing is a rule rather than a convention. The decision lives there and not in root_op_is_sealable because that function is const and sees only the op, which cannot tell whether the invitation’s group_id is the namespace root. A namespace-root joiner genuinely holds no key — its key arrives only in answer to the join it is publishing — so that join is still published, and accepted, in the clear.

A key delivery is accepted only from a trusted anchor

Section titled “A key delivery is accepted only from a trusted anchor”

RootOp::KeyDelivery is how an admin hands a group key to a member that cannot yet decrypt the group (add_group_members, admit_tee_node). Its apply handler is a deliberate no-op: the whole effect runs in root_op_side_effects, which unwraps the envelope and stores the key. That side effect is the op, and it is where the op is authorized.

Who delivered it, and what was delivered, are separate powers. Being an admin of a group is authority to hand out its key; it is not authority to choose which key the group uses. So the gate has two halves.

The envelope apply rejects a key whose SHA256 matches none of the ids it is given, which is what makes a deliverer untrusted for content rather than merely less-preferred. A KeyDelivery names no key_id of its own — and one added to it would be signed by the deliverer alongside the key it chose, matching by construction and checking nothing, which is why closing this needed no wire change. The id has to come from a different signed op, and one is usually to hand: a buffered NamespaceOp::Group envelope, authored by a group member, names the key_id its ciphertext was encrypted under. awaited_key_ids_for collects those, the same source apply_received_group_key is handed on the pull path, and any of them is accepted — a group stranded across a rotation awaits two, and both are legitimately deliverable.

Where nothing signed names a key yet — the ordinary first delivery — the bytes genuinely cannot be checked, and then the anchor half below is the only check.

The direct-pull path had already decided what may be accepted on that basis. key_servers_allowed (crates/node/src/sync/peers.rs) takes an unverifiable key from a trusted anchor and from nobody else, and refuses outright when no anchor can be identified for the group — trading a liveness risk for a confidentiality one, deliberately. The DAG path applied that rule to nobody: its only check was check_sender, which pins envelope.sender to op.signer and so is satisfied by construction for a self-signed op. Provenance stood in for authority.

So the gate is now the same on both paths: the signer must be a device key of a trusted anchor of the group being delivered for — MembershipRepository::anchor_device_keys, which expands trusted_anchors (the group’s owner, its legacy admin marker, and every Admin or ReadOnlyTee member) to the live device bindings that speak for those accounts. ReadOnlyTee is included because an admitted TEE node legitimately holds and hands out group keys.

Without it, any holder of the namespace key could name any group in the namespace and have a key of its own choosing adopted by a co-member. Minting the wrap needs no secret — wrap_for_member takes the attacker’s own key and the victim’s public identity key — and the injected key does not merely sit unused: store_key writes at epoch 0 and key_rank breaks epoch-0 ties on local insertion_seq, so it outranks the key already held and becomes the one the victim encrypts under. A poisoned node then serves it onward as that group’s covering key, so the poison outlives the node first attacked. A group that has ever rotated carries a non-zero epoch and was immune; the namespace root, which every node holds a key for, was the most valuable target rather than the least.

One group’s anchors, or its namespace’s while that group is still unfolded. A delivery legitimately arrives before the GroupCreated that establishes its group — add_group_members publishes the key alongside the creation, and the key can land first — and until that op folds there is no meta, no parent edge and no member row, so the group’s own anchor set is empty. Refusing on an empty set would refuse precisely the deliveries the op exists for, so authority falls back to the namespace the op arrived on: known even when the named group is not, since the op came in on that namespace’s topic sealed under its key. The relaxation is narrow in the way that matters — a plain member is not an anchor of the namespace either, so it buys an attacker nothing, and an attacker always picks the group id and could otherwise simply name one the receiver has not folded. Once GroupCreated folds, the group’s own anchors decide and a namespace admin no longer qualifies on that basis alone.

A delivery may seed a key, never replace one — whether or not an op names the key. If this node already holds a key for the group and a delivery carries a different one, it is refused. Re-delivering the key already held is not a replacement and stays allowed, so a retry is idempotent.

That is unconditional on purpose, and the reason matters more than the rule. expected_key_ids is read from the cleartext key_id of buffered NamespaceOp::Group envelopes, and nothing checks that field: the receive path verifies only the topic and the signature, an unresolvable key_id decrypts nothing and raises nothing, and the op is written to the log regardless. So the id is chosen by whoever signs the op, and the “different signed op” the content binding is described on can be minted by the same principal that then satisfies it. An earlier form of this rule exempted a bound delivery, which made being bound strictly more powerful than being unbound: it turned “may seed” into “may replace”. Treat a key_id on a buffered envelope as a decryption hint, never as authority over what a group’s key is.

That matters because of how key_rank orders keys. Equal non-zero epochs fall through to a key_id tie-break, so concurrent rotations converge across nodes — but epoch-0 keys carry no DAG ordering at all and are ranked by insertion_seq, meaning when this node happened to learn them. store_key writes at epoch 0, so a second epoch-0 key is simply newer here and becomes current, and two nodes that learned the same pair in opposite orders disagree about which key the group uses. That is a convergence bug as much as a disclosure one.

Fixing it in key_rank was the tempting shape and the wrong one: that ordering also decides which key is current for rows already on disk, so changing it would change what an upgraded node encrypts under while its peers still hold the old answer — a mixed-version break of exactly the kind merobox cannot see. Every legitimate delivery seeds rather than replaces, by construction: the pull path asks with no expected id only for groups from groups_member_but_keyless, add_group_members and admit_tee_node deliver to a member that cannot yet decrypt the group, and device_link’s op is sealed under a key the new device does not hold. A real switch does not come through here at all. apply_key_rotation requires admin authority at the op’s causal cut, binds content to rotation.new_key_id, and stores at a real DAG epoch that outranks every epoch-0 key monotonically — so a node holding the old key decrypts the rotation and takes that path, while a node holding none is seeding. The rotation op is also causally ahead of every op encrypted under the new key, so a node that has those has the rotation too.

A refusal is a skipped effect, never a DAG failure. The delivery is best-effort by design — an error here must not orphan every op causally after it — and recover_missing_group_keys is its durable retry, enforcing this same anchor rule through peer selection. The lookup itself has three outcomes rather than two — authorized, refused, and “could not tell” — because a store error is not a verdict about the signer the way “not an anchor” is. It is logged as itself and left to the pull: neither propagated (which would orphan every op causally after this one) nor reported as a denial (which would send an operator hunting a permissions problem that does not exist).

device_link also publishes a KeyDelivery, for the namespace root, and is unaffected for a reason worth stating: that op is sealed under the namespace key, which the newly linked device does not hold, so it is a causal record rather than the delivery mechanism — the device pulls the key instead. On every other node the envelope is addressed to a device that is not theirs, so the side effect was already inert.

The asymmetry that made this a defect rather than a trade-off: apply_key_rotation, in the same file and doing the same job, gates on is_admin at the op’s causal cut and binds the content to the advertised new_key_id. Delivery did neither.

What an observer on the gossip topic can see

Section titled “What an observer on the gossip topic can see”

The gossip topic for a context is TopicHash::from_raw(context_id), and gossipsub messages are signed but not encrypted. So any node subscribed to the topic — including one that holds no group key and can decrypt nothing — can still read the full delta envelope and reconstruct a surprising amount:

  • The context itself — the topic hash is the context_id.
  • Who is activeauthor_id on every delta, signer on every governance op.
  • The causal graphparent_ids and hlc reveal the DAG shape and write cadence, even though the payloads are opaque. (The root_hash convergence fingerprint is not exposed — it is sealed inside the ciphertext.)
  • Group structure and membership churngroup_id and key_id tags on governance ops, plus the cleartext invitation joins (MemberJoined, MemberJoinedAt) when the joiner publishes its own, expose that a namespace gained a member and when. A join an admitter relays is sealed (RootRelaySealed) and exposes neither. The rest no longer adds to this: KeyDelivery, MemberJoinedOpen and MemberJoinedViaTeeAttestation are sealed, so which subgroup an inherited member reached, which fleet replica was admitted with what attestation measurements, and who was handed a key at which causal position all stay inside the namespace. Removals are GroupOps and were never cleartext.

In other words, payload encryption gives you content confidentiality, but not metadata privacy or membership privacy. An observer who cannot read a single byte of your application state can still map your group’s membership, activity, and topology.

Take a chat app where a member runs send_message("hi"). The node encrypts the artifact under the group key (SharedKey::from_sk) and publishes one BroadcastMessage::StateDelta on TopicHash(context_id) (crates/node/primitives/src/client.rs). Identical bytes reach every subscriber; only the key differs. (Hashes below are abbreviated and illustrative.)

BroadcastMessage::StateDelta {
context_id: 0x7a1c…e0 // = the gossip topic itself
author_id: 0x9f4b…21 // the sending member's public key
delta_id: 0x3c08…9d // hash(parents ‖ actions)
parent_ids: [ 0x11aa…02 ] // previous head(s)
hlc: 1750.000123:#0 // (seconds.fraction, logical counter)
key_id: 0x4d90…7c // = SHA-256(group_key) — which key decrypts
nonce: 0x1b…(12 bytes) // AES-GCM nonce
artifact: 0xa3f1…(opaque) // CIPHERTEXT — encrypt(borsh(SealedDeltaPayload))
// sealed inside: { root_hash, actions, events }
delta_signature: Some(0x82c5…) // author's envelope signature
}
Field group Non-member observer (no key) Member peer (holds group_key where SHA-256 == key_id)
artifact (sealed payload) Opaque ciphertext — no key for key_id, stays unreadable SharedKey::from_sk(group_key).decrypt(artifact, nonce) → the SealedDeltaPayload: the CRDT action(s) (a Put with value borsh("hi")), the expected root_hash, and the events
root_hash & events Not visible — sealed inside the ciphertext Recovered on decrypt: confirms the recomputed root matches root_hash; replays the MessageSent event
Envelope (author_id, delta_id, parent_ids, hlc, key_id) Fully readable — reconstructs who wrote, when, and on top of what Same cleartext, plus the decrypted payload now means something

So the observer learns that member 0x9f4b…21 wrote some delta at a known time, building on a known parent — but never the content "hi", the event it fired, or the resulting convergence state. The member learns everything.

When the EncryptedDatabase wrapper is enabled, its mechanics are worth understanding because they make key rotation a non-event (crates/store/encryption/src/lib.rs, key_manager.rs):

  • A key hierarchy. The KMS hands the wrapper a single master key. That master never encrypts values directly — it feeds HKDF-SHA256 (salt calimero-dek-v{n}) to derive a versioned data-encryption key (DEK). Values are encrypted under the current DEK with AES-256-GCM and a fresh 12-byte nonce.
  • A 1-byte version prefix. Each ciphertext is laid out as version ‖ nonce ‖ ciphertext+tag. On read, the wrapper reads byte zero and auto-selects the matching DEK, deriving it from the master if it isn’t cached. Rotating the key just bumps the current version for new writes; old values keep decrypting under their own prefix, so rotation needs no migration pass over existing data.
  • Transparent reads. Iteration returns a DecryptingIter that decrypts each value as it is yielded, so callers above the store never see ciphertext — the wrapper is a drop-in around any Database.
  • Keys stay plaintext. Only values are encrypted; record keys are written in the clear so range scans and prefix seeks still work (the column-family layout in Anatomy of state depends on this).

These are the sharp edges. Read them before reasoning about what is and isn’t private.

  • Two crypto modes, one API: from_sk (symmetric, group key, bulk data) and new (ECDH, key delivery). Both AES-256-GCM, 12-byte nonce, empty AAD.
  • Encrypted: state-delta artifacts, the post-apply root_hash, and execution events (all sealed together in SealedDeltaPayload), group governance ops, key envelopes; optionally store values (KMS); always the transport.
  • Not encrypted: the delta/governance envelope routing metadata (parent_ids, hlc, author_id, key_id, governance_position, …), store keys, blobs. Gossip is signed, not encrypted.
  • Confidentiality covers content, not metadata or membership. Plan your threat model around the cleartext envelope and the off-by-default at-rest layer.

For how group keys are created and turned over, see Key Rotation; for the keys themselves, Identities & Keys.


Next: Key Rotation — how a scope’s symmetric key turns over when a member is removed.