Transparency Log Service¶
HTTP transparency log service inspired by Certificate Transparency (CT). Stores bundle hashes in an append-only RFC 6962 Merkle tree with verifiable inclusion proofs and Ed25519-signed tree heads. State is in memory: a restart starts a new, empty tree (see Persistence).
Quickstart¶
# Start the service, from the root of a checkout after `npm install` there
node services/transparency-log/index.js
# Or with Docker, from the repo root: the Compose stack runs two logs that gossip with each other
docker compose -f docker/docker-compose.yml up transparency-log-a transparency-log-b
The service listens on http://localhost:3002 by default.
Endpoints¶
GET /health¶
Service health check.
{
"ok": true,
"service": "dcp-transparency-log",
"log_id": "log-alpha",
"public_key": "base64 Ed25519 public key that signs STHs",
"size": 42,
"gossip_peers": 1,
"alerts": 0
}
POST /add¶
Add a bundle hash to the log: sha256: followed by 64 lowercase hex characters. Anything else,
or a body over 1 MB, is a 400.
curl -X POST http://localhost:3002/add \
-H "Content-Type: application/json" \
-d '{"bundle_hash": "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"}'
GET /root¶
Get the current Merkle root of the log.
GET /root/signed¶
Get the Signed Tree Head (STH): an Ed25519 signature (base64) over the canonical JSON (sorted
keys, signObject in tools/crypto.js) of { log_id, root, size, timestamp }.
{
"log_id": "log-alpha",
"root": "d4e5f6...",
"size": 43,
"timestamp": "2025-01-01T00:00:00.000Z",
"signature": "base64...",
"public_key": "base64..."
}
Verify it against a public key you pinned out of band (printed at startup and in /health),
never only against the public_key the STH carries:
import { verifyObject } from "./tools/crypto.js";
const { log_id, root, size, timestamp, signature } = sth;
verifyObject({ log_id, root, size, timestamp }, signature, pinnedPublicKey); // true
OPERATOR_KEY is the key's 32-byte seed, so the signing key survives restarts; the tree does not, since it stays in
memory. Without it the log signs with a new ephemeral key after each restart: earlier STHs still verify under the old
key, if you archived or pinned it, but not under the new one.
GET /proof/:index¶
Get the Merkle inclusion proof for an entry by index.
{
"index": 5,
"leaf_hash": "a1b2c3...",
"entry": {
"hash": "sha256:abc123...",
"leaf_hash": "a1b2c3...",
"timestamp": "2025-01-01T00:00:00.000Z",
"index": 5
},
"root": "d4e5f6...",
"proof": [
{ "hash": "x1y2z3...", "direction": "left" },
{ "hash": "m4n5o6...", "direction": "right" }
]
}
The proof allows verifying that an entry is included in the Merkle tree without downloading the entire log.
GET /entries¶
List all log entries.
{
"entries": [
{ "hash": "sha256:...", "leaf_hash": "...", "timestamp": "...", "index": 0 },
{ "hash": "sha256:...", "leaf_hash": "...", "timestamp": "...", "index": 1 }
],
"size": 2
}
Gossip¶
| Method | Path | Description |
|---|---|---|
| GET | /gossip/peers |
Known peers and the last 20 alerts |
| POST | /gossip/peers |
Add a peer { peer_id, endpoint, public_key } (http/https endpoint, base64 Ed25519 key). Requires Authorization: Bearer <OPERATOR_TOKEN>: the log fetches peers itself, so an open list would allow SSRF |
| POST | /gossip/exchange |
Exchange STHs with a pinned peer { sth }; returns ours and any split-view alert |
| GET | /gossip/alerts |
Split-view alerts (the last 100) |
A peer's tree heads only count when they verify under its pinned public key: give it as
<endpoint>#<base64 Ed25519 public key> in GOSSIP_PEERS, or public_key in
POST /gossip/peers. Polled tree heads from a peer without a pinned key, malformed ones or ones
with a bad signature are ignored. A poll reads at most 16 KB, gives up after one interval
(10 s at most), and never starts while the previous one is still running. /gossip/exchange is open, so it only accepts a well-formed
STH (64-hex or null root, integer size) signed by a pinned peer, in a body of at most 16 KB
(otherwise 400 or 403). Alerts are deduplicated by peer, tree size and root, so replaying a
signed tree head adds nothing, and only the last 100 are kept.
Configuration¶
Environment Variables¶
| Variable | Default | Description |
|---|---|---|
PORT |
3002 |
HTTP port |
LOG_ID |
random | Identifier of this log instance |
OPERATOR_KEY |
— | Ed25519 seed of the STH signing key: 64 hex chars (openssl rand -hex 32). Any other value stops the service; unset means an ephemeral key |
OPERATOR_TOKEN |
— | Bearer token for POST /gossip/peers. Unset: locked |
GOSSIP_PEERS |
— | Comma-separated peers, each <endpoint>#<base64 Ed25519 public key> (the peer's /health public_key, fixed by its OPERATOR_KEY), polled every GOSSIP_INTERVAL_MS |
GOSSIP_INTERVAL_MS |
30000 |
Gossip polling interval |
CT-style Format¶
The log follows a format inspired by Certificate Transparency (RFC 6962):
- Append-only: Entries are never deleted or modified
- Merkle tree: RFC 6962 §2.1.
leaf_hash = SHA-256(0x00 || UTF-8(entry.hash))and interior nodes areSHA-256(0x01 || left || right), so a leaf can never pass for a subtree. A tree ofnleaves splits at the largest power of two belown; the last node is never duplicated. - Inclusion proofs: Anyone can verify that an entry exists in the log using the proof
- Signed root: Ed25519-signed tree heads (
GET /root/signed)
Verifying an Inclusion Proof¶
To verify that an entry is in the log:
- Get the proof with
GET /proof/:index - Compute
leaf_hash = SHA-256(0x00 || UTF-8(entry.hash)) - Recombine the proof nodes in order, as raw bytes (the hex strings decoded):
- If
direction == "left":hash = SHA-256(0x01 || proof_hash || current) - If
direction == "right":hash = SHA-256(0x01 || current || proof_hash) - The final result must equal the
rootof a signed tree head that verifies under the log's pinned public key and covers the tree the proof was built from (the same size). Therootin the proof response alone is self-declared, and a newer root stops matching once entries are added
Development¶
# Start in development mode, from the root of a checkout
PORT=3002 node services/transparency-log/index.js
# Test
curl http://localhost:3002/health
curl -X POST http://localhost:3002/add \
-H "Content-Type: application/json" \
-d '{"bundle_hash": "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"}'
curl http://localhost:3002/root
curl http://localhost:3002/proof/0
License¶
Apache-2.0