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 and group governance mutations, but a large amount of routing metadata (and, today, execution events) 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:

ConstructorKey materialUsed 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).

DataEncrypted?KeyAlgorithm / notes
State-delta artifact (the CRDT actions)YesGroup/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)NoPlaintext serde_json shipped alongside the artifact (client.rs:570). See the caution below.
Delta envelope: root_hash, parent_ids, hlc, author_id, key_id, governance_position, delta_signatureNoPlaintext routing/causality metadata.
key_idn/a (it is a tag)SHA-256(group_key); cleartext so receivers can resolve the key.
Governance Root ops (GroupCreated, AdminChanged, MemberJoined, KeyDelivery, …)NoCleartext, visible to all namespace members by design.
Governance Group ops (EncryptedGroupOp.ciphertext)YesGroup 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)YesECDH new(sender_sk, recipient_pk)AES-256-GCM over the 32-byte group key. On member removal the new key is re-wrapped once per remaining member; the removed member gets no envelope and is cryptographically locked out (KeyRotation).
Store values at restOnly if EncryptedDatabase is enabledDEK 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 restNoPlaintext even with the wrapper on, so range scans / prefix seeks still work.
BlobsNoNot encrypted at rest or at the app layer. BlobId = SHA-256(plaintext); only the transport protects them in flight (crates/node/.../blobs).
libp2p transportYesNoise / TLS session keysNoise or TLS on TCP, TLS 1.3 on QUIC (crates/network/src/behaviour.rs).
Gossipsub messagesSigned, not encryptedEd25519 node keyMessageAuthenticity::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.

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, hlc, and root_hash reveal the DAG shape, write cadence, and convergence state, even though the payloads are opaque.
  • Group structure and membership churngroup_id and key_id tags on governance ops, plus KeyDelivery / MemberJoined / MemberRemoved Root ops in cleartext, expose who joined or left which group and when.
  • Execution events — today, the full event stream (kinds, data, handler names) is in cleartext. See below.

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)
root_hash: 0x6e72…ff // author's expected scope_root after apply
key_id: 0x4d90…7c // = SHA-256(group_key) — which key decrypts
nonce: 0x1b…(12 bytes) // AES-GCM nonce
artifact: 0xa3f1…(opaque) // CIPHERTEXT — borsh(actions) under group key
events: [ { kind: "MessageSent", data: { len: 2 } } ] // PLAINTEXT
delta_signature: Some(0x82c5…) // author's envelope signature
}
Field groupNon-member observer (no key)Member peer (holds group_key where SHA-256 == key_id)
artifactOpaque ciphertext — no key for key_id, stays unreadableSharedKey::from_sk(group_key).decrypt(artifact, nonce) → the CRDT action(s): a Put to the messages collection entry with value borsh("hi")
Envelope (author_id, delta_id, parent_ids, hlc, root_hash, key_id)Fully readable — reconstructs who wrote, when, on top of what, and the convergence stateSame cleartext, plus it now means something: the peer folds the decrypted action and confirms its recomputed root matches root_hash
eventsReadable: sees a MessageSent event fired (kind + data) — even though it never learns the text was "hi"Same — events are plaintext for everyone

So the observer learns that member 0x9f4b…21 sent a chat message of some length at a known time, building on a known parent — it just never learns the content "hi". 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, group governance ops, key envelopes; optionally store values (KMS); always the transport.
  • Not encrypted: execution events, the entire delta/governance envelope metadata, store keys, blobs. Gossip is signed, not encrypted.
  • Confidentiality covers content, not metadata or membership. Plan your threat model around the cleartext envelope, the plaintext events, 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.