Certificate Transparency Proofs
This document specifies the Certificate Transparency proof formats clients use to verify the ENC enclave event log without trusting the node: CT inclusion and consistency proofs for the log itself, bundle membership proofs that tie individual events to a CT leaf, and the Signed Tree Head (STH) the sequencer publishes as the trust root. SMT membership / non-membership proofs (for current enclave state) are specified separately in smt.md.
Overview
The ENC protocol uses two types of Merkle proofs:
| Proof Type | Purpose | Tree |
|---|---|---|
| CT Inclusion | Prove event exists at position N in log | Certificate Transparency |
| CT Consistency | Prove earlier log is prefix of current log | Certificate Transparency |
These Merkle proofs prove inclusion against a root the client already trusts. To instead prove that a root transition old_root → new_root is itself valid under the RBAC rules — letting a client trust a fresh state_hash in O(1) without replaying the log — see the zero-knowledge validity-proof layer in zk.md. For proofs of current enclave state against a known state_hash, see smt.md.
CT Proofs
Bundle Membership Proof
Proves that an event is part of a specific bundle.
Structure:
{
event_id: <32 bytes>,
bundle_index: <number>,
bundle_size: <number>, // total event count in the bundle; needed for odd-size promotion
siblings: [<hash>, ...]
}
Where:
- event_id — the event being proven
- bundle_index — position of event within the bundle (0-indexed)
- bundle_size — total number of events in the bundle (
>= 1); used by the verifier to detect odd-leaf promotion at each layer - siblings — Merkle proof siblings from event_id to events_root, in leaf-to-root order (
siblings[0]is the deepest sibling). Carried (odd-promoted) leaves contribute NO sibling at the layer where they were carried up.
Verification:
hash = event_id
index = bundle_index
n = bundle_size
sib_i = 0
while n > 1:
if index == n - 1 and (n % 2 == 1):
# Odd promotion — last leaf at an odd-sized layer is carried up
# unchanged. No sibling consumed; no hash step at this layer.
pass
else:
s = siblings[sib_i]; sib_i += 1
if index % 2 == 0:
hash = H(0x01, hash, s) # current is left child
else:
hash = H(0x01, s, hash) # current is right child
index = index >> 1
n = (n + 1) >> 1 # ceil(n / 2) — size of next layer
require sib_i == siblings.length # no excess siblings
require hash == events_root
Algorithm Notes:
bundle_indexis the event's 0-indexed position within the bundle.- The LSB of
indexdetermines left (0) or right (1) at each level. - After each layer, shift
indexright and recomputen = ceil(n/2)for the next layer's size. - The odd-promotion check (
index == n - 1 and n % 2 == 1) reflects the RFC 9162-style root construction: when a layer has an odd number of nodes, the rightmost node is carried up to the next layer unchanged (nonode_hashapplied).Reference:computeEventsRootand the parity-matching Rust at. - Siblings are ordered leaf-to-root; the verifier consumes
siblings[0]at the deepest non-promoted layer.
Example (even bundle):
Bundle with 4 events, verifying event at bundle_index = 2, bundle_size = 4:
events_root
/ \
h01 h23
/ \ / \
e0 e1 e2 e3 ← bundle_index: 0, 1, 2, 3
- Start:
hash = e2,index = 2,n = 4,sib_i = 0 - Layer 0 (
n=4):index=2, n-1=3, not last. LSB(2)=0 →hash = H(0x01, hash, siblings[0])wheresiblings[0] = e3.sib_i=1, index=1, n=2. - Layer 1 (
n=2):index=1, n-1=1, IS last butn=2is even — not odd promotion. LSB(1)=1 →hash = H(0x01, siblings[1], hash)wheresiblings[1] = h01.sib_i=2, index=0, n=1. - Loop ends.
hash == events_root.siblings = [e3, h01](length 2).
Example (odd bundle, carried leaf):
Bundle with 3 events, verifying event at bundle_index = 2, bundle_size = 3:
events_root = H(0x01, h01, e2)
/ \
h01 e2 ← layer 1: e2 carried up
/ \ /
e0 e1 e2 ← bundle_index: 0, 1, 2
- Start:
hash = e2,index = 2,n = 3,sib_i = 0 - Layer 0 (
n=3):index=2 == n-1=2ANDnis odd → ODD PROMOTION, no sibling consumed.index=1, n=2. - Layer 1 (
n=2):index=1, n-1=1, last butn=2even — normal. LSB(1)=1 →hash = H(0x01, siblings[0], hash)wheresiblings[0] = h01.sib_i=1, index=0, n=1. - Loop ends.
hash == events_root.siblings = [h01](length 1).
With bundle_size = 1, the bundle contains one event, the loop body never runs, siblings == [], and events_root == event_id.
CT Inclusion Proof
Proves that a bundle exists at a specific position in the log.
Structure:
{
tree_size: <uint64>,
leaf_index: <uint64>,
path: [<hash>, ...]
}
Where:
- tree_size — number of bundles in the tree when proof was generated
- leaf_index — 0-based position of the bundle in the log
- path — sibling hashes from leaf to root
Leaf Hash:
leaf_hash = H(0x00, events_root, state_hash)
Where events_root is the Merkle root of event IDs in the bundle, and state_hash is the SMT root after the bundle.
Verification (RFC 9162 Section 2.1.3.2):
- Set
fn = leaf_index,sn = tree_size - 1,r = leaf_hash - For each
pin path:- a. If
sn == 0: FAIL (proof too long) - b. If
LSB(fn) == 1orfn == sn:r = H(0x01, p, r)- While
LSB(fn) == 0andfn != 0:fn >>= 1; sn >>= 1
- c. Else:
r = H(0x01, r, p)
- d.
fn >>= 1; sn >>= 1
- a. If
- Verify
sn == 0andr == expected_root
Test Vector:
Tree size: 7, Leaf index: 5
Initial: fn=5, sn=6
Step 1 (p[0]): LSB(5)=1 → r=H(0x01,p[0],r), shift → fn=2, sn=3
Step 2 (p[1]): LSB(2)=0, fn≠sn → r=H(0x01,r,p[1]), shift → fn=1, sn=1
Step 3 (p[2]): fn==sn → r=H(0x01,p[2],r), shift → fn=0, sn=0
Final: sn=0 ✓, compare r to expected_root
Edge Cases:
-
Single-element tree (tree_size = 1, leaf_index = 0):
- Initial:
fn = 0,sn = 0 pathis empty (no siblings)- Skip the loop and verify
r == expected_rootdirectly
- Initial:
-
Leaf at last position (leaf_index = tree_size - 1):
- Valid case; algorithm handles via
fn == sncondition
- Valid case; algorithm handles via
CT Consistency Proof
Proves that an earlier log state is a prefix of the current state.
Structure:
{
tree_size_1: <uint64>,
tree_size_2: <uint64>,
path: [<hash>, ...]
}
Where:
- tree_size_1 — size of the older (smaller) tree
- tree_size_2 — size of the newer (larger) tree
- path — sibling hashes proving consistency
Precondition: tree_size_1 <= tree_size_2. If tree_size_1 > tree_size_2, reject with INVALID_RANGE error immediately.
Verification:
- If
tree_size_1 == tree_size_2: verify path has 1 element equal to both roots - If
tree_size_1is a power of 2: prependfirst_hashto path - Set
fn = tree_size_1 - 1,sn = tree_size_2 - 1 - While
LSB(fn) == 0: shift bothfnandsnright by 1 - Set
fr = path[0],sr = path[0] - For each
cin path[1:]:- If
sn == 0: FAIL - If
LSB(fn) == 1orfn == sn: setfr = H(0x01, c, fr),sr = H(0x01, c, sr), then whileLSB(fn) == 0andfn != 0: shift both right by 1 - Else: set
sr = H(0x01, sr, c) - Shift both
fnandsnright by 1
- If
- Verify
fr == first_hash,sr == second_hash, andsn == 0
Based on RFC 9162 Section 2.1.4.
Signed Tree Head (STH)
The sequencer signs the CT root periodically to create a checkpoint.
Structure:
{
t: <timestamp>,
ts: <tree_size>,
r: <root_hash>,
sig: <signature>
}
Where:
- t — Unix milliseconds when STH was generated
- ts — number of bundles in the tree
- r — CT root hash (32 bytes)
- sig — Schnorr signature over the STH
Signature:
message = "enc:sth:" || be64(t) || be64(ts) || r
sig = schnorr_sign(sha256(message), seq_priv)
Where r is the raw 32-byte root hash (NOT hex-encoded). The message is binary concatenation:
"enc:sth:"= 8 bytes UTF-8be64(t)= 8 bytes big-endianbe64(ts)= 8 bytes big-endianr= 32 bytes raw
Total: 56 bytes before SHA-256.
Wire Format (JSON):
{
"t": 1706000000000,
"ts": 1000,
"r": "<hex64>",
"sig": "<hex128>"
}Verification:
- Reconstruct message:
"enc:sth:" || be64(t) || be64(ts) || hex_decode(r) - Verify:
schnorr_verify(sha256(message), sig, seq_pub)
Full Event Proof
To fully prove an event exists and verify its state:
- Bundle membership proof — proves event_id is in bundle's events_root
- CT inclusion proof — proves bundle is in CT tree
- SMT proof — proves state claim against bundle's state_hash
This two-level structure allows efficient bundling while maintaining per-event verifiability.
Wire Format (JSON)
The normative wire format is JSON.
CT Inclusion Proof
{
"ts": 1000,
"li": 42,
"p": ["<hex>", ...]
}| Field | Encoding |
|---|---|
| ts | Integer (tree_size) |
| li | Integer (leaf_index) |
| p | Array of hex strings, 64 chars each (32 bytes) |
CT Consistency Proof
{
"ts1": 500,
"ts2": 1000,
"p": ["<hex>", ...]
}| Field | Encoding |
|---|---|
| ts1 | Integer (tree_size_1) |
| ts2 | Integer (tree_size_2) |
| p | Array of hex strings, 64 chars each (32 bytes) |
Bundle Membership Proof
{
"ei": 2,
"s": ["<hex>", ...]
}| Field | Encoding |
|---|---|
| ei | Integer (event index within bundle, 0-indexed) |
| s | Array of hex strings, 64 chars each (32 bytes) |
Note: With bundle.size = 1, the bundle contains one event, so s is empty and ei is 0.
Note: Wire format omits event_id as the verifier already knows the event being proven from request context. For self-contained proofs (e.g., archival), include event_id separately.
Bundle Construction (RFC 9162)
How the sequencer assembles events into CT leaves and grows the append-only Merkle tree. Follows RFC 9162.
Properties:
- CT root: Single hash representing the entire event history AND state
- Inclusion proof: Proves a specific event exists at a given position in the log
- Consistency proof: Proves an earlier log state is a prefix of the current state
Bundle Structure:
Events are grouped into bundles (see spec.md §Bundle Configuration). Each bundle produces one CT leaf.
bundle = {
events: [event_id_0, event_id_1, ..., event_id_N],
state_hash: <SMT root after last event in bundle>
}
Initial State:
Before any events (including Manifest), the SMT is empty:
empty_state_hash = sha256("")
= 0xe3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
The Manifest event is always in bundle 0. The bundle's state_hash is the SMT root AFTER Manifest's init entries have been applied.
Leaf hash:
events_root = merkle_root(events) // binary Merkle tree of event IDs
leaf_hash = H(0x00, events_root, state_hash)
Where:
events_root— Merkle root of event IDs in this bundlestate_hash— SMT root AFTER the last event in this bundle is applied
events_root Construction:
For N events in a bundle:
- If N = 1:
events_root = event_ids[0](no tree needed) - If N > 1: Build a binary Merkle tree over event IDs:
- Leaf:
event_id(raw 32-byte hash, no prefix) - Internal node:
H(0x01, left, right) - Built bottom-up by pairing adjacent nodes; if a level has an odd number of nodes, the final unpaired node is carried up unchanged to the next level (it is NOT duplicated or padded).
- Example: 3 events →
H(0x01, H(0x01, e0, e1), e2)(e2 carried up, never duplicated)
- Leaf:
Bundle Event Ordering:
Events within a bundle are ordered by their sequence number (seq). The first event has the lowest seq, the last has the highest. This ordering is deterministic and verifiable.
Bundle Index Assignment:
Bundles are numbered sequentially starting from 0. An event's bundle membership is determined by bundle boundaries:
bundle_0contains events from seq=0 until size or timeout reachedbundle_Ncontains events fromboundary[N-1] + 1until size or timeout reachedboundary[N]= seq of last event in bundle N
Determining bundle from seq:
event.seq belongs to bundle_N where:
boundary[N-1] < event.seq <= boundary[N]
(boundary[-1] = -1 for the first bundle)
Deterministic reconstruction:
During log replay or migration, bundle boundaries are reconstructed by applying the same closing rules (size/timeout). If CT root matches after reconstruction, bundle assignment is correct.
Example:
Config: bundle.size = 3, timeout = 5000ms
seq=0,1,2 (ts: 1000ms) → bundle_0, boundary[0]=2
seq=3,4,5 (ts: 3000ms) → bundle_1, boundary[1]=5
seq=6 (ts: 9000ms) → bundle_2, boundary[2]=6 (timeout hit)
Query: which bundle is seq=4?
Answer: bundle_1 (because 2 < 4 <= 5)
Internal node:
node_hash = H(0x01, left_child, right_child)
The 0x00 and 0x01 prefixes prevent second-preimage attacks by distinguishing leaf nodes from internal nodes.
CT Tree Construction:
The CT tree follows RFC 9162 Section 2.1 (Merkle Tree algorithm):
- Bundles are CT leaves, numbered sequentially (
bundle_0,bundle_1, ...). - Tree grows as bundles are appended
- The Merkle Tree Hash (MTH) is computed recursively (RFC 9162 §2.1), with NO padding or leaf duplication. For
nleavesD:MTH([]) = 0x00…00(32 zero bytes) — the sentinel root for an empty CT. NOTE: This is[0u8; 32], NOTsha256("")— the empty-tree sentinel deliberately differs from the SMT's empty-subtree hashsha256("")(see smt.md).MTH([d0]) = leaf_hash(d0)— a single leaf is its own hash (it is NOT re-hashed).MTH(D[0:n]) = H(0x01 || MTH(D[0:k]) || MTH(D[k:n]))forn > 1, wherekis the largest power of two strictly less thann.
- For non-power-of-two
nthis yields an unbalanced tree; the trailing leaves are promoted up the right spine, never padded/duplicated. - Example: 5 bundles split at
k = 4→H(0x01 || MTH([b0..b3]) || MTH([b4]))
CT root (5 bundles, RFC 9162 §2.1 — split at largest power of two < n):
root
/ \
h0123 b4 ← b4 is a lone leaf promoted up; no padding
/ \
h01 h23
/ \ / \
b0 b1 b2 b3
This matches RFC 9162 exactly and is required for working inclusion AND consistency proofs. Pad-with-last (duplicating the final leaf to the next power of two) MUST NOT be used — it cannot support RFC 9162 consistency proofs.
Proof Structure:
To prove an event exists and verify state:
- Bundle membership proof — proves event_id is in the bundle's events_root
- CT inclusion proof — proves bundle is in the CT tree
With bundle.size = 1, the events_root equals the single event_id, and bundle membership proof is trivial.
State Binding:
The CT leaf binds each bundle to the enclave state after that bundle:
state_hash[bundle_0] = SMT root after all events in bundle 0
state_hash[bundle_N] = SMT root after all events in bundle N
Within a bundle, state changes are applied sequentially:
For events [e_0, e_1, ..., e_k] in bundle:
state = apply(state, e_0)
state = apply(state, e_1)
...
state = apply(state, e_k)
bundle.state_hash = state
State-changing events (modify SMT): Manifest, Move, Grant, Revoke, Transfer, Gate, AC_Bundle, Shared, Own, Update, Delete, Pause, Resume, Terminate, Migrate.
Non-state-changing events: Content Events (app-defined customs).
Since state_hash is deterministic from the log, anyone can recompute and verify it. If a node provides incorrect state_hash values, the CT root will not match.
Note: State changes take effect immediately for authorization (in-memory). The
state_hashin CT reflects the state at bundle boundaries for proof purposes.
State Query Semantics:
When clients query enclave state (RBAC or event status) mid-bundle, two modes are available:
| Mode | Returns | Verifiable | Fresh |
|---|---|---|---|
verified | state_hash from last finalized bundle | ✅ Yes (CT proof) | May be stale |
current | SMT root including pending events | ❌ No proof | ✅ Fresh |
- Verified queries: Use for audits, disputes, high-stakes operations
- Current queries: Use for real-time apps (chat, collaboration)
Nodes SHOULD support both modes.
::: extension-point id=ct-default-query-mode class=impl_defined_default
reason: choice between verified (paid SMT-proof round-trip) and current (no proof) is a per-deployment tradeoff between latency and verification strength
A node's default query mode (when a caller omits the mode parameter) is chosen by the implementation. Nodes SHOULD document their choice.
:::
Staleness Guidance:
With default bundle.timeout = 5000 ms, verified queries MAY be up to 5 seconds stale. Nodes SHOULD document their bundle configuration and expected staleness.
Use cases:
- Client verifies an event is part of the canonical log
- Client verifies the enclave state at any point in history
- Client verifies the log they cached is still valid (consistency with current log)
- Detect if a node is presenting different log histories to different clients
- Checkpoint for migration (CT root proves both log and state)
Proof serialization formats are specified in §Wire Format (JSON) above.