Skip to content

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.

curl http://localhost:3002/health
{
  "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"}'
{
  "index": 42,
  "leaf_hash": "a1b2c3...",
  "root": "d4e5f6...",
  "size": 43
}

GET /root

Get the current Merkle root of the log.

curl http://localhost:3002/root
{
  "root": "d4e5f6...",
  "size": 43
}

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 }.

curl http://localhost:3002/root/signed
{
  "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.

curl http://localhost:3002/proof/5
{
  "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.

curl http://localhost:3002/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):

  1. Append-only: Entries are never deleted or modified
  2. Merkle tree: RFC 6962 §2.1. leaf_hash = SHA-256(0x00 || UTF-8(entry.hash)) and interior nodes are SHA-256(0x01 || left || right), so a leaf can never pass for a subtree. A tree of n leaves splits at the largest power of two below n; the last node is never duplicated.
  3. Inclusion proofs: Anyone can verify that an entry exists in the log using the proof
  4. Signed root: Ed25519-signed tree heads (GET /root/signed)

Verifying an Inclusion Proof

To verify that an entry is in the log:

  1. Get the proof with GET /proof/:index
  2. Compute leaf_hash = SHA-256(0x00 || UTF-8(entry.hash))
  3. Recombine the proof nodes in order, as raw bytes (the hex strings decoded):
  4. If direction == "left": hash = SHA-256(0x01 || proof_hash || current)
  5. If direction == "right": hash = SHA-256(0x01 || current || proof_hash)
  6. The final result must equal the root of 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). The root in 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