Transparency-log envelope: signed notes, checkpoints, tiles (C2SP)
pki.merkle already verifies: a signed note (c2sp.org/signed-note), the checkpoint body a log signs into one (c2sp.org/tlog-checkpoint), and the tile addressing a static log serves them beside (c2sp.org/tlog-tiles). Static-CT, Merkle Tree Certificates and Rekor v2 differ in what they put in the origin line and what they do with the root, not in this layer.
A note is text, a blank line, then one signature line per signer. The signature is over the text INCLUDING its trailing newline, so parseNote surfaces signedBytes as a view of the input, never a re-serialization: a verifier that rebuilt the text from its parsed lines would accept a note altered in the delimiter. A key ID is four bytes and the specification says it identifies rather than proves, so verifyNote tries every supplied key whose ID matches and reports a verdict only after all of them. An unknown signature is ignored; a note carrying no signature from a supplied key is rejected.
A checkpoint is that note with a body of origin, tree size and root hash, plus opaque extension lines. The tree size is carried as a BigInt, the root as 32 raw bytes ready for pki.merkle.verifyInclusion. Decoding is fail-closed: a leading zero in the tree size, an empty origin, an empty extension line, a root that is not 32 bytes, or non-canonical base64 anywhere is a typed tlog/* refusal.
tilePath / entryBundlePath build the paths a log serves, and parseTilePath reads one back. Verifying only: nothing here signs, and nothing fetches.
pki.tlog.keyId
pki.tlog.keyId(keyName, publicKey) -> Buffer
The four-byte key ID a signed note carries in front of its signature. The derivation is fixed per algorithm and they are not variations on one form, so it is read from the key material rather than from anything the caller declares:
- **Ed25519:** SHA-256(keyName || 0x0A || 0x01 || raw32)[:4]. Pass the raw 32 bytes or an Ed25519 SubjectPublicKeyInfo; both give the same ID, because one key has one ID. - **ECDSA** over P-256, P-384 or P-521: SHA-256(spkiDer)[:4], with neither the key name nor a type byte in the preimage. The same key therefore has one ID under any name. - **RSA:** SHA-256(keyName || 0x0A || 0xFF || "PKIX-RSA-PKCS#1v1.5" || spkiDer)[:4].
An algorithm with no stated derivation is tlog/bad-input rather than hashed under an assumed one, which would produce an identifier no log would ever state.
The specification calls these identifiers rather than cryptographically strong hashes, and four bytes is short enough that two keys can share one. A match therefore selects a candidate and never decides a verdict; pki.tlog.verifyNote checks every key whose ID matches.
Example
async function example() {
var pair = await pki.key.generate("Ed25519");
var spki = await pki.key.export(pair.publicKey);
var raw = pki.asn1.decode(spki).children[1].content.subarray(1); // the 32 key bytes
pki.tlog.keyId("example.com/log", raw).length; // -> 4
}
example();
References
- spec C2SP signed-note
pki.tlog.parseNote
pki.tlog.parseNote(input) -> { text, signedBytes, signatures }
Read a signed note: the text, the exact bytes a signature covers, and one entry per signature line carrying keyName, the four-byte keyId and the signature after it.
signedBytes is a view of the input, not a re-serialization. The signature covers the text including its trailing newline, so a parser that rebuilt that text from its parsed lines would verify a note against bytes the signer never saw. Everything a verifier hashes comes from here.
A note carrying an ASCII control byte other than newline, no blank line, a signature line that is not an em dash, a space, a key name, a space and canonical base64, a signature shorter than its key ID, or more signatures than C.LIMITS.TLOG_MAX_SIGNATURES is refused with tlog/bad-note.
Example
var DASH = String.fromCharCode(0x2014); // the em dash a signature line opens with
var noteText = "example.com/log\n5\n" + Buffer.alloc(32).toString("base64") + "\n" +
"\n" + DASH + " example.com/log " + Buffer.alloc(68).toString("base64") + "\n";
var note = pki.tlog.parseNote(noteText);
note.signatures[0].keyName; // -> "example.com/log"
note.signedBytes.length === Buffer.byteLength(note.text); // -> true
References
- spec C2SP signed-note
- defends
signature-bypass (CWE-347)
pki.tlog.verifyNote
pki.tlog.verifyNote(input, keys) -> Promise<{ verified, signers, note }>
Verify a signed note against a list of { name, publicKey } keys, where publicKey is the raw 32 Ed25519 bytes or a SubjectPublicKeyInfo for any algorithm pki.tlog.keyId derives an ID for. Resolves { verified, signers, note }: signers is one entry per signature that verified under a supplied key, and verified is whether there was at least one.
The specification states two separate rules about failure and this draws the line between them:
- A signature from a key not in the list is **ignored**, which is what a verifier must do with a co-signature it does not know. It carries no claim to check. - A note carrying no signature from a supplied key resolves **verified: false** without throwing, because that is a verdict about the note and not a fault in it. - A signature line naming a supplied key by BOTH name and ID, which no such key verifies, **rejects the whole note** with tlog/bad-signature. Reporting another line's success instead would let a forged line from a known signer pass unnoticed behind a valid one.
Every supplied key whose ID matches is tried before that refusal. A key ID is four bytes and identifies rather than proves, so stopping at the first candidate would reject a note a later key with the same ID verifies.
Example
async function example() {
var pair = await pki.key.generate("Ed25519");
var raw = pki.asn1.decode(await pki.key.export(pair.publicKey)).children[1].content.subarray(1);
var text = "example.com/log\n0\n" + pki.merkle.emptyRootHash().toString("base64") + "\n";
var sig = Buffer.from(await pki.webcrypto.subtle.sign({ name: "Ed25519" }, pair.privateKey,
Buffer.from(text, "utf8")));
var line = String.fromCharCode(0x2014) + " example.com/log " +
Buffer.concat([pki.tlog.keyId("example.com/log", raw), sig]).toString("base64");
var v = await pki.tlog.verifyNote(text + "\n" + line + "\n",
[{ name: "example.com/log", publicKey: raw }]);
v.verified; // -> true
}
example();
References
- spec C2SP signed-note
- spec RFC 8032
- defends
signature-bypass (CWE-347)
pki.tlog.parseCheckpoint
pki.tlog.parseCheckpoint(input) -> { origin, treeSize, rootHash, extensions, note }
Read the checkpoint a log signed into a note: the origin that names the log, the treeSize as a BigInt, the 32-byte rootHash ready for pki.merkle, any opaque extensions, and the note beneath, whose signedBytes remain reachable for verification.
The origin is not validated as a URL. The specification recommends a schema-less one and then says outright that a client must not assume the origin follows that shape or names a reachable endpoint, so only an empty origin is refused.
A tree size with a leading zero, a root hash that is not 32 bytes, an empty extension line, or a body of fewer than three lines is refused with tlog/bad-checkpoint.
Example
var DASH = String.fromCharCode(0x2014);
var checkpointText = "example.com/log\n5\n" + Buffer.alloc(32, 1).toString("base64") + "\n" +
"\n" + DASH + " example.com/log " + Buffer.alloc(68).toString("base64") + "\n";
var cp = pki.tlog.parseCheckpoint(checkpointText);
cp.treeSize; // -> 5n
cp.rootHash.length; // -> 32, ready for pki.merkle.verifyInclusion
References
- spec C2SP tlog-checkpoint
- spec RFC 6962
pki.tlog.verifyCheckpoint
pki.tlog.verifyCheckpoint(input, keys, opts?) -> Promise<{ verified, signers, checkpoint }>
Verify a checkpoint's signatures and read its body in one call: pki.tlog.verifyNote over the note, and pki.tlog.parseCheckpoint over the text it signed. A verified: false result still carries the parsed checkpoint, since a caller auditing a log needs to see what was claimed.
The root hash it returns is what pki.merkle.verifyInclusion checks a proof against, and that is the whole point of the pairing: a proof against an unverified root proves nothing.
Example
async function example() {
var pair = await pki.key.generate("Ed25519");
var raw = pki.asn1.decode(await pki.key.export(pair.publicKey)).children[1].content.subarray(1);
var leaves = [pki.merkle.leafHash(Buffer.from("a")), pki.merkle.leafHash(Buffer.from("b"))];
var body = "example.com/log\n2\n" +
pki.merkle.nodeHash(leaves[0], leaves[1]).toString("base64") + "\n";
var sig = Buffer.from(await pki.webcrypto.subtle.sign({ name: "Ed25519" }, pair.privateKey,
Buffer.from(body, "utf8")));
var note = body + "\n" + String.fromCharCode(0x2014) + " example.com/log " +
Buffer.concat([pki.tlog.keyId("example.com/log", raw), sig]).toString("base64") + "\n";
var v = await pki.tlog.verifyCheckpoint(note, [{ name: "example.com/log", publicKey: raw }]);
v.verified && pki.merkle.verifyInclusion({ leafHash: leaves[0], leafIndex: 0n,
treeSize: v.checkpoint.treeSize, rootHash: v.checkpoint.rootHash, proof: [leaves[1]] });
// -> true
}
example();
References
- spec C2SP tlog-checkpoint
- defends
signature-bypass (CWE-347)
pki.tlog.parseVkey
pki.tlog.parseVkey(text) -> { name, keyId, signatureType, publicKey }
Read the verifier-key text form a log publishes to identify itself, <name>+<hex key ID>+<base64 of signature type || public key>, as in example.com/foo+530d903a+Aeky.... Returns the name, the four-byte keyId, the signatureType byte, and the publicKey: the raw 32 bytes for Ed25519, or the SubjectPublicKeyInfo for ECDSA and RSA. The publicKey is what verifyNote takes for that key.
The stated key ID is checked against the one the key material derives, and the stated signature type against the algorithm that material actually is, so a vkey cannot name a key it does not carry or claim an algorithm its key is not. A key name may not contain a plus, so the name ends at the first separator; a text with three of them is refused rather than read as a name containing one.
Example
var v = pki.tlog.parseVkey("example.com/foo+530d903a+AekyeRrm56hApGFkyQR4ZCbV54Id2LKaANYcrnKv3U2k");
v.name; // "example.com/foo"
v.keyId.toString("hex"); // "530d903a"
v.signatureType; // 1
References
- spec C2SP signed-note
pki.tlog.tilePath
pki.tlog.tilePath(level, index, width?) -> string
The path a tiled log serves one Merkle tile at, relative to the log's prefix: tile/<level>/<index> with the index in zero-padded three-digit segments, every one but the last carrying an x. A width of 1 to 255 appends the .p/<width> suffix a partial tile has.
The segmented form is what lets a log hold its tiles in a filesystem without a directory and a file colliding on one name.
Example
pki.tlog.tilePath(0, 1234067n); // "tile/0/x001/x234/067"
pki.tlog.tilePath(0, 1234067n, 42); // "tile/0/x001/x234/067.p/42"
References
- spec C2SP tlog-tiles
pki.tlog.entryBundlePath
pki.tlog.entryBundlePath(index, width?) -> string
The path a tiled log serves one entry bundle at: tile/entries/<index>, with the same segmented index and .p/<width> suffix a tile path uses. The bundle holds the entries whose leaf hashes are the level-0 tile at the same index.
Example
pki.tlog.entryBundlePath(1234067n); // "tile/entries/x001/x234/067"
References
- spec C2SP tlog-tiles
pki.tlog.checkpointPath
pki.tlog.checkpointPath() -> string
The path a tiled log serves its checkpoint at, relative to the log's prefix. Fixed by the specification, and given as a verb so a caller composes a URL without writing the literal.
Example
pki.tlog.checkpointPath(); // "checkpoint"
References
- spec C2SP tlog-tiles
pki.tlog.parseTilePath
pki.tlog.parseTilePath(path) -> { level, index, width }
Read a tile path back into its level, its index as a BigInt, and its width, which is null for a full tile. The inverse of pki.tlog.tilePath.
Fail-closed on every shape the specification forbids: a level with a leading zero or above 63, a segment that is not three digits, a leading segment without its x, a final segment with one, or a partial width outside 1 to 255.
Example
pki.tlog.parseTilePath("tile/0/x001/x234/067.p/42");
// { level: 0, index: 1234067n, width: 42 }
References
- spec C2SP tlog-tiles
pki.tlog.parseTile
pki.tlog.parseTile(bytes, opts?) -> Buffer[]
Split a tile into its 32-byte hashes. A full tile is exactly 256 of them, 8192 bytes; a partial tile carries 1 to 255. Bytes that are not a whole number of hashes, or more than a full tile, are refused with tlog/bad-tile.
A tile is binary. A string is refused with tlog/bad-input rather than decoded, because reading a response as text rewrites every byte above 0x7f.
Pass opts.full when the caller asked the log for a full tile: a short answer is then refused and is not read as a partial one, which is the difference between a truncated response and a tile the log meant to serve.
Example
var tileBytes = Buffer.concat([pki.merkle.leafHash(Buffer.from("a")),
pki.merkle.leafHash(Buffer.from("b"))]);
var hashes = pki.tlog.parseTile(tileBytes);
hashes.length; // -> 2, a partial tile; a full one holds 256
hashes[0].length; // -> 32
References
- spec C2SP tlog-tiles
pki.tlog.parseEntryBundle
pki.tlog.parseEntryBundle(bytes) -> Buffer[]
Split an entry bundle into its entries. The wire form is big-endian uint16 length-prefixed entries, and each entry's pki.merkle.leafHash is the corresponding hash in the level-0 tile at the same index, which is what makes a bundle checkable against the tree.
A length prefix that overruns the buffer, or a trailing byte that is not a whole prefix, is refused with tlog/bad-bundle rather than read as a short final entry. An empty bundle is an empty list.
Example
var first = Buffer.from("the first entry");
var len = Buffer.alloc(2);
len.writeUInt16BE(first.length, 0);
var entries = pki.tlog.parseEntryBundle(Buffer.concat([len, first]));
entries[0].toString(); // -> "the first entry"
pki.merkle.leafHash(entries[0]).equals(pki.merkle.leafHash(first)); // -> true
References
- spec C2SP tlog-tiles
- defends
parser-DoS (CWE-400)
pki.tlog.tileWidth
pki.tlog.tileWidth(treeSize, level, index) -> number | null
How wide the tile at level and index is in a tree of treeSize leaves: null when it is full, which is the form tilePath takes for a full tile, or 1 to 255 when it is partial. The two compose directly, so tilePath(l, i, tileWidth(size, l, i)) is the path the log serves.
A client has to know this before it asks. A tiled log serves a full tile and a partial tile at different paths, so requesting the full path for a tile that is partial is a request for a resource the log does not have.
A tile the tree does not reach is tlog/bad-tile rather than a width of zero: the specification says empty tiles must not be served, so there is no path to build.
Example
pki.tlog.tileWidth(300n, 0, 0n); // null, a full tile
pki.tlog.tileWidth(300n, 0, 1n); // 44
References
- spec C2SP tlog-tiles
pki.tlog.inclusionProof
pki.tlog.inclusionProof(opts) -> Promise<Buffer[]>
Assemble the RFC 6962 audit path for one leaf of a tiled log. A tiled log serves no proof endpoint, so a client fetches tiles and computes the path itself; this is that computation, with the fetching left to read. The returned array is what pki.merkle.verifyInclusion folds against the root a verified checkpoint carries.
read(level, index, width) takes the same three values tilePath takes and returns that tile's bytes, or a promise of them, so a caller wires it straight to a fetch of tilePath(level, index, width). Tiles are cached for the life of one call.
Take size from a checkpoint you have verified. The specification says a client must not fetch arbitrary partial tiles without a checkpoint whose size requires them, and the width of every tile this reads follows from that size.
A tile served narrower than the tree size requires is tlog/bad-tile rather than a path folded from short data; an index at or above size is tlog/index-out-of-range, refused before any tile is read.
Options
index: number | bigint, // 0-based leaf position to prove
size: number | bigint, // tree size from a VERIFIED checkpoint
read: function, // (level, index, width) -> Buffer | Promise<Buffer>
Example
async function example() {
var leaves = [];
for (var i = 0; i < 5; i++) leaves.push(pki.merkle.leafHash(Buffer.from([i])));
var tile = Buffer.concat(leaves);
var proof = await pki.tlog.inclusionProof({ index: 2n, size: 5n,
read: function () { return tile; } });
pki.merkle.verifyInclusion({ leafIndex: 2, treeSize: 5, leafHash: leaves[2],
proof: proof, rootHash: pki.merkle.root(leaves) }); // -> true
}
example();
References
- spec C2SP tlog-tiles
- spec RFC 6962