240 lines
8.2 KiB
TypeScript
240 lines
8.2 KiB
TypeScript
|
|
/**
|
||
|
|
* 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<STHWire>;
|
||
|
|
/** 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<number, SignedTreeHead>();
|
||
|
|
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<SignedTreeHead> {
|
||
|
|
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<void> {
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|