/** * Signed Tree Head (STH) — server's commitment to a tree state. * * canonical layout for signing: * 0x02 (DOMAIN_STH) || * uint64_be tree_size || * uint64_be timestamp_ms || * root_hash (32 bytes) || * index_root (32 bytes) || * log_id (32 bytes) * * `log_id` is `SHA-256(log_public_key)` — a stable identifier that * doesn't change unless the operator rotates the signing key. */ import type { CryptoProvider } from '@shade/core'; import { DOMAIN_STH } from './hashes.js'; import { sha256Sync } from './sha256.js'; import { constantTimeEqual } from './util.js'; export interface SignedTreeHead { treeSize: number; timestampMs: number; rootHash: Uint8Array; indexRoot: Uint8Array; logId: Uint8Array; signature: Uint8Array; } /** Compute log_id = SHA-256(public_key). */ export function computeLogId(logPublicKey: Uint8Array): Uint8Array { return sha256Sync(logPublicKey); } /** Canonical bytes covered by the STH signature. */ export function canonicalSthBytes(sth: Omit): Uint8Array { if (sth.rootHash.length !== 32) throw new Error('rootHash must be 32 bytes'); if (sth.indexRoot.length !== 32) throw new Error('indexRoot must be 32 bytes'); if (sth.logId.length !== 32) throw new Error('logId must be 32 bytes'); if (sth.treeSize < 0 || !Number.isFinite(sth.treeSize)) { throw new Error('treeSize must be a non-negative integer'); } const buf = new Uint8Array(1 + 8 + 8 + 32 + 32 + 32); const view = new DataView(buf.buffer); let off = 0; buf[off++] = DOMAIN_STH; view.setUint32(off, Math.floor(sth.treeSize / 0x100000000)); view.setUint32(off + 4, sth.treeSize >>> 0); off += 8; view.setUint32(off, Math.floor(sth.timestampMs / 0x100000000)); view.setUint32(off + 4, sth.timestampMs >>> 0); off += 8; buf.set(sth.rootHash, off); off += 32; buf.set(sth.indexRoot, off); off += 32; buf.set(sth.logId, off); return buf; } /** Sign an STH with the operator's Ed25519 signing key. */ export async function signSth( crypto: CryptoProvider, signingPrivateKey: Uint8Array, sth: Omit, ): Promise { const message = canonicalSthBytes(sth); const signature = await crypto.sign(signingPrivateKey, message); return { ...sth, signature }; } /** * Verify the STH signature against a pinned `logPublicKey`. * * Also checks `logId === SHA-256(logPublicKey)` so a forged STH that * claims a different log_id is rejected. */ export async function verifySthSignature( crypto: CryptoProvider, sth: SignedTreeHead, logPublicKey: Uint8Array, ): Promise { const expectedLogId = computeLogId(logPublicKey); if (!constantTimeEqual(expectedLogId, sth.logId)) return false; const message = canonicalSthBytes(sth); return crypto.verify(logPublicKey, message, sth.signature); } /** JSON-friendly STH for the wire (base64-encoded byte fields). */ export interface STHWire { treeSize: number; timestampMs: number; rootHash: string; indexRoot: string; logId: string; signature: string; } export function sthToWire(sth: SignedTreeHead, b64: (b: Uint8Array) => string): STHWire { return { treeSize: sth.treeSize, timestampMs: sth.timestampMs, rootHash: b64(sth.rootHash), indexRoot: b64(sth.indexRoot), logId: b64(sth.logId), signature: b64(sth.signature), }; } export function sthFromWire(wire: STHWire, fromB64: (s: string) => Uint8Array): SignedTreeHead { return { treeSize: wire.treeSize, timestampMs: wire.timestampMs, rootHash: fromB64(wire.rootHash), indexRoot: fromB64(wire.indexRoot), logId: fromB64(wire.logId), signature: fromB64(wire.signature), }; }