/** * Light-witness — a passive observer of one or more KT logs. * * Responsibilities (V1): * 1. Pin a log_public_key. * 2. Periodically poll the server's `/v1/kt/sth` endpoint. * 3. Verify each new STH's signature. * 4. Maintain a chain of observed STHs and verify consistency proofs * between successive observations. * 5. Expose a "compare" API: given an STH another party has seen * (e.g. delivered with a bundle-fetch), return whether *we* have * seen the same `tree_size → root_hash → index_root` triple. A * mismatch is a *split-view alarm*. * * V1 is library-only; deployments embed it in long-running processes * (CLI tools, security-research auditors, server-to-server). V2 will * add an HTTP `GET /witness/sth` endpoint for peer-to-peer gossip. */ import type { CryptoProvider } from '@shade/core'; import { type SignedTreeHead, type STHWire, computeLogId, sthFromWire, verifySthSignature, } from './sth.js'; import { verifyConsistencyProof } from './log.js'; import { fromBase64 } from './util.js'; import { constantTimeEqual } from './util.js'; import { KTLogIdMismatchError, KTSplitViewError, KTStaleSTHError, KTVerificationError, } from './errors.js'; export interface WitnessFetcher { /** GET latest STH. */ fetchLatestSTH(): Promise; /** GET consistency proof from `fromTreeSize` to `toTreeSize`. */ fetchConsistencyProof(fromTreeSize: number, toTreeSize: number): Promise<{ proof: string[] }>; } export interface LightWitnessOptions { crypto: CryptoProvider; /** Pinned log signing public key (Ed25519, 32 bytes). */ logPublicKey: Uint8Array; /** Source of STHs and consistency proofs. */ fetcher: WitnessFetcher; /** Reject STH older than this many ms. Default 24h. */ maxStaleMs?: number; /** Allowed clock-skew for STH future timestamps. Default 60 s. */ futureSkewMs?: number; /** Time source. Defaults to `Date.now()`. */ now?: () => number; /** Cap on stored STHs (LRU on tree_size). Default 1024. */ maxStored?: number; } const DEFAULT_MAX_STALE_MS = 24 * 60 * 60 * 1000; const DEFAULT_FUTURE_SKEW_MS = 60_000; const DEFAULT_MAX_STORED = 1024; /** A piece of evidence that a particular STH was observed. */ export interface WitnessObservation { sth: SignedTreeHead; observedAtMs: number; } export class LightWitness { private readonly crypto: CryptoProvider; private readonly logPublicKey: Uint8Array; private readonly fetcher: WitnessFetcher; private readonly logId: Uint8Array; private readonly maxStaleMs: number; private readonly futureSkewMs: number; private readonly now: () => number; private readonly maxStored: number; /** STHs we've observed, indexed by tree_size. */ private observed = new Map(); private observedOrder: number[] = []; // insertion order for LRU eviction constructor(opts: LightWitnessOptions) { this.crypto = opts.crypto; this.logPublicKey = opts.logPublicKey; this.fetcher = opts.fetcher; this.logId = computeLogId(opts.logPublicKey); this.maxStaleMs = opts.maxStaleMs ?? DEFAULT_MAX_STALE_MS; this.futureSkewMs = opts.futureSkewMs ?? DEFAULT_FUTURE_SKEW_MS; this.now = opts.now ?? (() => Date.now()); this.maxStored = opts.maxStored ?? DEFAULT_MAX_STORED; } /** Pinned log_id for callers that want to verify message routing. */ getLogId(): Uint8Array { return new Uint8Array(this.logId); } /** * Poll the server for the latest STH. Verifies signature, freshness, and * consistency with the most recent observation we hold. Adds the STH to * the local set on success. */ async pollOnce(): Promise { const wire = await this.fetcher.fetchLatestSTH(); const sth = sthFromWire(wire, fromBase64); await this.observe(sth); return sth; } /** * Ingest an STH supplied externally (e.g. embedded in a bundle-fetch * response). Re-uses all the same verification & comparison logic so * proofs returned by the bundle-fetch path also feed into split-view * detection. */ async observe(sth: SignedTreeHead): Promise { if (!constantTimeEqual(sth.logId, this.logId)) { throw new KTLogIdMismatchError(`STH log_id does not match pinned key`); } const ok = await verifySthSignature(this.crypto, sth, this.logPublicKey); if (!ok) throw new KTVerificationError('STH signature did not verify'); const now = this.now(); if (sth.timestampMs > now + this.futureSkewMs) { throw new KTStaleSTHError(`STH timestamp in the future`); } if (sth.timestampMs + this.maxStaleMs < now) { throw new KTStaleSTHError(`STH older than maxStale`); } // Split-view check — same tree_size, different root or index_root → fork. const prior = this.observed.get(sth.treeSize); if (prior) { if ( !constantTimeEqual(prior.rootHash, sth.rootHash) || !constantTimeEqual(prior.indexRoot, sth.indexRoot) ) { throw new KTSplitViewError( `Split view: two STHs at tree_size=${sth.treeSize} disagree`, ); } // Same STH content; nothing to insert (might just be a refreshed timestamp). // Keep the freshest one for staleness checks. if (sth.timestampMs > prior.timestampMs) { this.observed.set(sth.treeSize, sth); } return; } // Consistency check against our most recent prior observation. const latest = this.latestObserved(); if (latest && latest.treeSize !== sth.treeSize) { const [oldSth, newSth] = latest.treeSize < sth.treeSize ? [latest, sth] : [sth, latest]; if (oldSth.treeSize > 0 && oldSth.treeSize < newSth.treeSize) { const { proof } = await this.fetcher.fetchConsistencyProof( oldSth.treeSize, newSth.treeSize, ); const proofBytes = proof.map(fromBase64); const consistent = verifyConsistencyProof( oldSth.treeSize, newSth.treeSize, oldSth.rootHash, newSth.rootHash, proofBytes, ); if (!consistent) { throw new KTVerificationError( `Consistency proof failed: ${oldSth.treeSize} → ${newSth.treeSize}`, ); } } } this.insertObservation(sth); } /** * Compare an external STH (e.g. one another client claims to have seen) * against our stored set. Returns: * - 'agree' if we've observed the same tree_size with the same roots, * - 'unknown' if we have no STH at that tree_size (caller may want to * poll once more before deciding), * - 'split-view' if our roots differ. */ compare(sth: SignedTreeHead): 'agree' | 'unknown' | 'split-view' { const ours = this.observed.get(sth.treeSize); if (!ours) return 'unknown'; const sameRoot = constantTimeEqual(ours.rootHash, sth.rootHash); const sameIndex = constantTimeEqual(ours.indexRoot, sth.indexRoot); if (sameRoot && sameIndex) return 'agree'; return 'split-view'; } /** Latest observed STH (highest tree_size). */ latestObserved(): SignedTreeHead | null { let best: SignedTreeHead | null = null; for (const sth of this.observed.values()) { if (!best || sth.treeSize > best.treeSize) best = sth; } return best; } /** Snapshot all observed STHs (defensive copy). */ observations(): WitnessObservation[] { return Array.from(this.observed.values()).map((sth) => ({ sth: { treeSize: sth.treeSize, timestampMs: sth.timestampMs, rootHash: new Uint8Array(sth.rootHash), indexRoot: new Uint8Array(sth.indexRoot), logId: new Uint8Array(sth.logId), signature: new Uint8Array(sth.signature), }, observedAtMs: this.now(), })); } private insertObservation(sth: SignedTreeHead): void { if (!this.observed.has(sth.treeSize)) { this.observedOrder.push(sth.treeSize); } this.observed.set(sth.treeSize, sth); while (this.observedOrder.length > this.maxStored) { const evict = this.observedOrder.shift(); if (evict !== undefined && evict !== sth.treeSize) { // Always keep the latest STH even under aggressive eviction. if (this.latestObserved()?.treeSize !== evict) { this.observed.delete(evict); } } } } }