Files
Shade/packages/shade-key-transparency/src/witness.ts
Sterister e6fdf31b49
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled
release(v4.0.0): Shade GA — V3.x consolidation + audit prep
V3.1 → V3.12 consolidated and tagged for the first GA release. Wire
format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers
byte-for-byte. The version bump is semantic: audit-cycle complete,
opt-in surface fully exposed, threat model refreshed for every new
surface.

Highlights:
- All 24 @shade/* packages bumped to 4.0.0 in lockstep.
- CHANGELOG 4.0.0 section is the canonical manifest of what landed.
- THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12
  Web-Worker boundary) + residual-risks table refreshed.
- OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox,
  bridge, observer, /metrics, /healthz, /ready.
- MIGRATION 0.3.x → 4.0 documented + smoke-tested against
  shade migrate-storage on a real SQLite DB.
- docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer.
- scripts/soak.ts harness for the GA-stable 2-week soak window.
- All V*.md plans archived under docs/archive/ with Status: Done.
- Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen
  non-realtime stack.

Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green.
Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports
  version 4.0.0 on /health.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:35:35 +02:00

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);
}
}
}
}
}