This document is background for the cactus codebase. It walks through the parts of draft-ietf-plants-merkle-tree-certs that you need to keep in your head while reading the code.
It's deliberately thinner than the IETF draft. For anything you can't
find here, the draft is the source of truth — specs/draft-ietf-plants-merkle-tree-certs-05.txt
is the version cactus targets, and section numbers in this doc match
that file.
In a CT-style PKI, every certificate carries:
- The CA's signature on the cert itself.
- Two or more Signed Certificate Timestamps (SCTs), each a signature from a CT log saying "I have logged this certificate."
With ECDSA-P256 that's tolerable: each SCT is ~70 bytes. With ML-DSA-44, each SCT signature is 2,420 bytes and each public key is 1,312 bytes. Two SCTs plus the cert signature plus the cert's public key starts to crowd out the rest of the TLS handshake. Worse, every CT log has to store the same data over and over.
MTC inverts the design. Instead of "the CA signs the cert, then logs it", the CA logs the cert (a small structure with a hash where the public key would be) and then signs the log. Each issued certificate becomes a Merkle inclusion proof against a logged tree, plus one or more cosigner signatures over a subtree of that tree — never over the cert directly.
The win:
- Log entries don't carry public keys or signatures, only
(name, validity, hash-of-pubkey). Storage scales with issuance rate, not key/sig size. - The CA signs O(checkpoints) per unit time, not O(certs). Fewer signature operations.
- An optional second mode — landmark-relative certificates — ships the inclusion proof without any signatures at all, by pre-distributing a small set of trusted subtree hashes to clients.
+---------+ 1. ACME order +---------+
| client | --------------------> | CA |
+---------+ +---------+
|
| 2. add to issuance log
v
+---------+ 4. inclusion proof +---------+
| client | <-------------------- | CA |
+---------+ + cosignatures +---------+
^
| 3. sign subtree(s)
|
+---------+
| mirrors |
+---------+
The cert the client receives is an X.509 with:
- Standard fields: subject, validity, SubjectPublicKeyInfo, extensions (e.g. SAN). Same as a regular leaf cert.
signatureAlgorithm = id-alg-mtcProof. New OID, no parameters.signatureValue(a BIT STRING) whose body is not a signature but a TLS-presentation-language struct calledMTCProofthat contains:- The subtree the cert was logged into:
(start, end). - An inclusion proof from the cert's leaf to that subtree.
- One or more cosigner signatures over that subtree.
- The subtree the cert was logged into:
A relying party verifies the cert by:
- Re-deriving the leaf hash from the cert's TBS (with the public key replaced by its hash).
- Walking the inclusion proof up to the subtree hash.
- Checking that some sufficient set of trusted cosigners signed that subtree.
No signature on the cert itself. No SCTs.
RFC 9162 (CT v2) defines a Merkle tree, but only ever talks about the
root. MTC introduces subtrees: a subtree is any range [start, end)
of leaves where:
0 ≤ start < end ≤ tree_size.startis a multiple ofbit_ceil(end - start). (So[4, 8)is valid;[1, 4)is not.)
A subtree of size 2^k is full and lives at a fixed position in the
larger Merkle tree. A non-power-of-two subtree is partial; it's only
directly contained in a tree of size exactly end, but it can still
be efficiently shown consistent with bigger trees (§4.4).
§4.5 is the algorithmic core of MTC: given any range [start, end),
return the one or two subtrees that efficiently cover it. After a
checkpoint flush, the CA uses §4.5 to find the covering subtree(s)
for "everything I added since the last checkpoint" and signs each
one. That's why a single MTCProof has one subtree even though
multiple subtrees may be signed at each checkpoint — the proof picks
the one that contains the cert's index.
In cactus, §4 lives in the tlogx/ package:
tlogx.FindSubtrees(start, end)— the §4.5 procedure.tlogx.IsValid(start, end)— the §4.1 validity predicate.tlogx.HashLeaf/tlogx.HashChildren— the RFC 9162 prefixes.tlogx.GenerateInclusionProof/EvaluateInclusionProof— §4.3.tlogx.GenerateConsistencyProof/VerifyConsistencyProof— §4.4.
A CA has a single CA ID (§5.1), a trust anchor ID. Each issuance
log it operates has a log number (1–65535); the log's ID is derived
as CA-ID.0.logNumber (§5.2). cactus runs one log, configured by its
log.number.
The log is an append-only tree of MerkleTreeCertEntry structures.
Each entry begins with an extensions<0..2^16-1> vector (empty in
cactus) followed by a type. Any index MAY be a null_entry. Serial
numbers are kept non-zero by requiring a non-zero log number (see §6).
Each non-null entry is a tbs_cert_entry: the TBS-style fields of
the cert with the public key replaced by HASH(SubjectPublicKeyInfo).
The cert's "issuer" is a special DN containing the CA ID (§5.1).
A CA's parameters are published in its CA certificate via a critical
id-pe-mtcCertificationAuthority extension (§5.5) carrying the log hash
algorithm, the CA cosigner's signature algorithm, and a minSerial;
cactus encodes/decodes this in cert/cacert.go.
The log is published as tiles (c2sp.org/tlog-tiles). For cactus, that means the read-path (HTTP) serves files at:
/checkpoint— a c2sp signed-note with the latest size + root./tile/<L>/<NNN>[.p/<W>]— Merkle hash tiles./tile/entries/<NNN>[.p/<W>]— entry blobs (the "data" tiles). A single entry is read by fetching its data tile and splitting out the entry at its position within the tile; there is no per-entry endpoint.
Cactus's log/ and tile/ packages own this. The log is a
single-writer affair: one goroutine ticks every checkpoint_period_ms,
appends pooled entries to the tilewriter, signs the new checkpoint
- covering subtrees, and writes the checkpoint and tiles to disk via
temp-file + rename. The covering-subtree cosigner signatures are kept
only in memory — they travel inside issued certs (the MTCProof), so
they are not separately published. The "lock" against multiple writers
is documentation, not fcntl — see
docs/threat-model.md.
Anyone who attests to the log's append-only property is a cosigner. There are two kinds:
- CA cosigner (§5.5): the CA itself, attesting that "I issued every entry in this subtree." The CA's signature is the bedrock — without it the cert isn't authentic at all.
- Mirror cosigner (c2sp tlog-mirror): a third party that follows the log, verifies consistency proofs, and signs subtrees too. Mirrors prove transparency: a misbehaving CA can't issue a cert that mirrors haven't seen.
A cosigner's signature is over a structure called CosignedMessage
(§5.3.1):
struct {
uint8 label[12] = "subtree/v1\n\0";
opaque cosigner_name<1..2^8-1>; // "oid/" + cosigner ID
uint64 timestamp; // 0 for MTC proofs
opaque log_origin<1..2^8-1>; // "oid/" + log ID
uint64 start;
uint64 end;
HashValue subtree_hash;
} CosignedMessage;
The 12-byte label is domain separation (§12.8): it does not begin with
the DER SEQUENCE tag 0x30, so a subtree signature can never collide
with a TBSCertificate / TBSCertList / OCSP signing input. The structure
is compatible with the c2sp tlog-cosignature ML-DSA-44 construction.
MTC proofs always use timestamp = 0.
In cactus, the cosigner abstraction is in signer/:
signer.Signer— a small interface whose core method isSign(rand, msg) → sig(plusAlgorithm()andPublicKey()).signer.FromSeed(alg, seed)— derives a key from a 32-byte seed via HKDF. cactus is ML-DSA-only: ML-DSA-44/65/87, using Go's built-incrypto/mldsa(FIPS 204). That package needs Go 1.27+, so the whole module isgo 1.27in go.mod — no per-file build tags. Until Go 1.27 is released, agotip1.27-devel toolchain builds it (gotip build ./...).
§6.2 specifies how the X.509 cert is laid out:
signatureAlgorithmandtbsCertificate.signatureboth useid-alg-mtcProofwith absent parameters.signatureValueis a BIT STRING whose body — with no further ASN.1 wrapping — is the TLS-presentation encoding of:
struct {
MerkleTreeCertEntryExtension extensions<0..2^16-1>;
uint48 start;
uint48 end;
HashValue inclusion_proof<0..2^16-1>;
MTCSignature signatures<0..2^16-1>;
} MTCProof;
extensionsmirrors the log entry's extensions (empty in cactus).start/endare 48-bit, leaving room in the serial for the log number.signaturesMUST be sorted bycosigner_id(shorter first, then lexicographically) with no duplicates.serialNumberis(log_number << 48) | index(§6.2). The non-zero log number keeps the serial non-zero (RFC 5280 §4.1.2.2 forbids a zero serial), and lets a relying party revoke whole logs by serial range (§7.5).
This MTCProof can carry two flavors of cert:
- Standalone certificate (§6.3): the proof's subtree is one of
the §4.5 covering subtrees from a recent checkpoint, and the
signatures<>slice has at least one cosigner. Issuable immediately after the next checkpoint. - Landmark-relative certificate (§6.4): the proof's subtree is
a special pre-distributed landmark subtree, and the
signatures<>slice is empty. Issuable only after the entry has been included in a landmark, but smaller and signature-free.
The cert assembly code is in cert/ and ca/:
cert.MTCProof/cert.MTCSubtree/cert.MTCSignature— TLS-presentation encoders.cert.BuildCAName— the §5.2 issuer DN.ca.Validator/ca.Issuer— turn an ACME order + CSR into a TBSCertificateLogEntry, submit to the log, await the inclusion proof, assemble the X.509 cert.cert.BuildLandmarkRelativeCert— clones a standalone cert with a different MTCProof (signature-less, points at a landmark subtree).
Suppose:
- The log has 100 entries.
- The previous checkpoint was at size 95.
- A flush runs and produces checkpoint 100.
The CA:
-
Calls
tlogx.FindSubtrees(95, 100)→ it returns[(start: 88, end: 96), (start: 96, end: 100)].Note that
(88, 96)covers indices 88–95, seven of which (88, 89, 90, 91, 92, 93, 94) are from before the previous checkpoint — that's fine; "efficiently cover" doesn't mean "exactly cover". -
Computes the Merkle hashes of those two subtrees from the tiles.
-
Signs each subtree's
MTCSubtreeSignatureInputwith its CA cosigner key. -
Optionally fans the request out to mirrors. Each mirror that has caught up returns its own signature.
-
Persists the signed checkpoint to disk. The per-subtree cosigner signatures are kept only in memory — they travel inside issued certs (the MTCProof), so they are not separately published (see the §5 note above).
When a client wants the cert at index 97:
- Index 97 is in subtree
(96, 100). The CA computes the §4.3 inclusion proof from leaf 97 up to the subtree's root. - The X.509 cert is assembled with serial=97, the inclusion proof,
and however many cosigner signatures are attached to subtree
(96, 100).
This is the subtle part. The promise: a cert with no signatures that an up-to-date relying party can verify in ~constant time.
A landmark is a designated tree size. Landmarks are allocated by the CA (or some coordinating party) at a regular cadence — say, once per hour — and they're append-only and strictly increasing.
For each landmark N with tree size T_N, define its landmark
subtrees as the §4.5 covering subtrees of [T_{N-1}, T_N). So if
a CA runs hourly landmarks and issues 4M certs/hour, each landmark
has one or two subtrees, each ~22 levels deep (~2M leaves).
Relying parties periodically download the active landmarks (the
most recent max_active_landmarks of them) and store just the
subtree hashes — about 10 KiB per CA at typical settings. When an
authenticating party presents a landmark-relative cert:
- The cert's MTCProof has a subtree
[s, e)and an inclusion proof. - The relying party looks up that subtree in its trusted set.
- If found and the inclusion proof matches the trusted hash, the cert is valid. No cosigner signature is consulted.
That's the size optimization: the cert ships an inclusion proof (~32 bytes × log₂(N)) and zero signatures.
In cactus:
landmark/sequence.gois the CA-side allocator (§6.4.2). Append-only on disk; the in-memory state replays from JSONL on restart.landmark.Sequence.Handler()serves the §6.4.1 text-format URL that relying parties poll.cert.BuildLandmarkRelativeCertre-uses the existing standalone cert's TBS (so subject, validity, SPKI all match) and replaces only the signature value with a landmark MTCProof.acme.Serveradvertises the landmark-relative cert as arel="acme-optional-alternate"URL on the standalone cert response, served byhandleCertLandmarkRelativeat/cert/{id}/landmark-relative/{number}(see "Doing it over ACME" below).cmd/cactus-cli cert landmark-relativederives the same cert from a standalone cert plus the log's tiles, for use outside ACME.
The relying-party side is exercised by
integration/TestEnhancementURLSwitchover (over ACME) and
integration/TestCLICertLandmarkRelative (via cactus-cli): each obtains a
landmark-relative cert and verifies it against the live log (signature-free,
the inclusion proof reconstructs the log's subtree hash) without consulting
any cosigner key.
Step-by-step, the relying party's job:
- Check the cert's
signatureAlgorithmisid-alg-mtcProof. - Decode the BIT STRING body as
MTCProof. - The cert's
serialNumberis the entry index. (Reject if it's in the relying party's revocation-by-index list, §7.5 — cactus doesn't fully implement this, but the data model carries it.) - Reconstruct
TBSCertificateLogEntryfrom the cert's TBS by replacingsubjectPublicKeyInfowithHASH(SubjectPublicKeyInfo). - Wrap that in a
MerkleTreeCertEntry{type=tbs_cert_entry,data=...}and compute the leaf hash:HASH(0x00 || entry). - Evaluate the inclusion proof from the leaf hash up to the
MTCProof.subtreehash. - Either match the subtree against a trusted-subtree set (landmark fast path) or verify enough cosigner signatures over the subtree to satisfy local policy.
Cactus's helpers:
cert.SplitCertificate— split a cert DER into TBS, alg-id, sig BIT STRING.cert.RebuildLogEntryFromTBS— step 4.cert.EntryHash— step 5 (single-pass per §7.2's inline algorithm).tlogx.EvaluateInclusionProof— step 6.cert.VerifyMTCSignature— step 7's cosigner check.
The shape of all these helpers — small, composable, zero hidden
state — is intentional. A relying-party library outside cactus
should be able to import just cert/ and tlogx/ and verify
certs with a few function calls.
ACME is what the authenticating party (the cert holder) uses to request certs. The MTC draft layers two changes onto RFC 8555:
-
The order moves to
validonce the entry is sequenced — i.e. when its log index is assigned and the next checkpoint is signed — not when the cosignatures arrive. This decouples ACME state from cosignature collection latency. -
Cert download negotiation. The client may send
Accept: application/pem-certificate-chain-with-properties. The server then includes aCertificatePropertyListalongside the PEM (cactus uses an adjacentMTC PROPERTIESPEM block; the trust-anchor-ids draft hasn't pinned the wire format yet). The property list carries a singletrust_anchor_id— the CA ID (§8.1) for a standalone cert, or the specific landmark's IDCA-ID.1.logNumber.L(§8.2) for a landmark-relative cert. A relying party advertises a landmark groupCA-ID.2.logNumber.L(§8.2.1) in itstrust_anchorsto accept the CA's standalone certs and all active landmarks at once. -
Enhancement URL. The standalone cert response carries a
Link: <…>; rel="acme-optional-alternate"header (draft §9.1) pointing at the landmark-relative variant, at/cert/{id}/landmark-relative/{number}. The number pins the single landmark the entry is relative to (ContainingIndex), so the URL is an immutable resource: it returnsHTTP 202 (Accepted)+Retry-Afteruntil that landmark is allocated, then the cert. An optional alternate is an optional, non-blocking substitute — a client retries the 202 later but must never let it hold up deploying the standalone cert.This replaced an earlier
rel="alternate"+503 + Retry-Afterdesign.alternateis load-bearing: clients (e.g. lego, viago-retryablehttp) fetch it eagerly during issuance and honour theRetry-Afteron the 503, so a not-yet-available landmark stalled issuance for the full interval.acme-optional-alternate+ 202 is non-blocking, and pinning the landmark number keeps the URL stable.
Cactus implements the above in acme/ plus the property-list builder
in cert/properties.go.
If you're reading the code, this is the order I'd recommend:
cert/entry.go— TBSCertificateLogEntry encoder + the single-pass §7.2 entry hash. Fundamental; everything else downstream of "what's in the log".tlogx/subtree.goandtlogx/inclusion.go— §4 primitives.log/log.go— the issuance log: how new entries get into a checkpoint, how covering subtrees get signed, how Wait blocks for a committed entry.cert/proof.go— MTCProof, MTCSubtree, MTCSignature on the wire.ca/issuer.go— assemble the X.509 cert from a CSR + alog.Issued.acme/handler.go— the ACME state machine.landmark/sequence.go— §6.4.1/2 allocator.mirrorpush/client.go— the push client that replicates the log to external c2sp.org/tlog-mirror mirrors and retains their cosignatures. (cactus itself is not a mirror; there is nomirror/package.)
The integration test integration/TestParallelIssuance exercises
1–6 end-to-end: 100 certs in parallel, each one re-parsed and
re-verified using the §7.2 procedure on the live log.
- draft-ietf-plants-merkle-tree-certs-05 — the spec.
- c2sp tlog-tiles — the read-path layout cactus uses.
- c2sp tlog-cosignature — the cosigner signed-note format.
- c2sp tlog-mirror — the mirror role.
- c2sp signed-note — the underlying note-signing format.
- RFC 9162 — Certificate Transparency v2; the Merkle-tree conventions MTC builds on.