/** * Key-Transparency integration for the Shade prekey server. * * The prekey server is the *source of truth* for which prekey bundle is * currently published for each address. Without KT a malicious server * could swap a bundle without anyone noticing. With KT enabled: * * - Every register / delete operation appends a leaf to an append-only * Merkle log via `KTLogManager.recordRegister` / `recordDelete`. * - After each mutation the manager re-signs and publishes a fresh STH. * - GET /v1/keys/bundle/:address attaches a `ktProof` to its response, * so the client can verify inclusion + freshness. * - GET /v1/kt/sth and friends expose the log to witnesses. * * KT is **opt-in**: pass `keyTransparency` to `createPrekeyServer`. When * absent, the server behaves exactly as before — proof fields are simply * not added to the bundle response. */ import type { CryptoProvider } from '@shade/core'; import { KTLogManager, type KTLogStore, computeBundleHash, ktProofToWire, sthToWire, type KTProof, type KTProofWire, type STHWire, type SignedTreeHead, } from '@shade/key-transparency'; export interface KeyTransparencyConfig { /** Persistent store for the log + index + STH set. */ store: KTLogStore; /** Operator's STH signing key (32-byte Ed25519 seed). */ signingPrivateKey: Uint8Array; /** Operator's STH signing public key (32-byte Ed25519). */ signingPublicKey: Uint8Array; /** * Heartbeat interval — minimum gap between fresh STHs even when no * mutations occur. Default 10 minutes; set to 0 to disable. */ heartbeatIntervalMs?: number; /** Time source override (testing). */ now?: () => number; } /** * Wraps a `KTLogManager` with the bookkeeping the server cares about: * - Serializes mutations (single-writer guarantee). * - Caches the latest STH so bundle-fetch is hot-path-fast. * - Schedules / surfaces heartbeats. * - Lazily backfills index entries from the prekey-server's existing * state when KT is first turned on. */ export class KeyTransparencyService { private readonly mgr: KTLogManager; private readonly store: KTLogStore; private readonly heartbeatIntervalMs: number; private readonly now: () => number; private latest: SignedTreeHead | null = null; private mutex: Promise = Promise.resolve(); private constructor( mgr: KTLogManager, store: KTLogStore, opts: { heartbeatIntervalMs?: number; now?: () => number }, ) { this.mgr = mgr; this.store = store; this.heartbeatIntervalMs = opts.heartbeatIntervalMs ?? 10 * 60 * 1000; this.now = opts.now ?? (() => Date.now()); } static async create(crypto: CryptoProvider, cfg: KeyTransparencyConfig): Promise { const mgr = await KTLogManager.create({ crypto, store: cfg.store, signingPrivateKey: cfg.signingPrivateKey, signingPublicKey: cfg.signingPublicKey, ...(cfg.now ? { now: cfg.now } : {}), }); const svc = new KeyTransparencyService(mgr, cfg.store, { ...(cfg.heartbeatIntervalMs !== undefined ? { heartbeatIntervalMs: cfg.heartbeatIntervalMs } : {}), ...(cfg.now ? { now: cfg.now } : {}), }); // Cache or generate the initial STH so bundle responses always have one. const existing = await cfg.store.getLatestSTH(); if (existing) { svc.latest = existing; } else { svc.latest = await mgr.publishSTH(); } return svc; } /** * Run a mutation under the manager's serial lock and refresh the STH. */ private async withLock(fn: () => Promise): Promise { const prev = this.mutex; let resolveNext: () => void; const next = new Promise((res) => { resolveNext = res; }); this.mutex = next; try { await prev.catch(() => {}); return await fn(); } finally { resolveNext!(); } } async recordRegister(address: string, bundle: { identitySigningKey: Uint8Array; identityDHKey: Uint8Array; signedPreKey: { keyId: number; publicKey: Uint8Array; signature: Uint8Array }; }): Promise { return this.withLock(async () => { await this.mgr.recordRegister(address, computeBundleHash(bundle)); this.latest = await this.mgr.publishSTH(); return this.latest!; }); } async recordDelete(address: string): Promise { return this.withLock(async () => { await this.mgr.recordDelete(address); this.latest = await this.mgr.publishSTH(); return this.latest!; }); } /** * Build a proof for a freshly-fetched bundle. Returns null if the * address has no live entry (caller can request an absence proof * via `buildAbsenceProof` instead). */ async buildBundleInclusion(address: string): Promise { const sth = await this.maybeHeartbeat(); return this.mgr.buildBundleInclusionProof(address, sth); } async buildAbsence(address: string): Promise { const sth = await this.maybeHeartbeat(); return this.mgr.buildBundleAbsenceProof(address, sth); } /** Latest STH — issuing a heartbeat first if the cached one is stale. */ async getLatestSTH(): Promise { return this.maybeHeartbeat(); } /** Historical STH at a specific tree size. */ async getSTHByTreeSize(treeSize: number): Promise { return this.store.getSTHByTreeSize(treeSize); } /** All persisted STHs in a time window — used by witness backfill. */ async listSTHs(fromTimestampMs?: number, toTimestampMs?: number): Promise { return this.store.listSTHs(fromTimestampMs, toTimestampMs); } /** Build a consistency proof for `from → to`. */ async buildConsistencyProof(fromTreeSize: number, toTreeSize?: number): Promise<{ fromTreeSize: number; toTreeSize: number; proof: Uint8Array[]; }> { return this.withLock(async () => { const targetSize = toTreeSize ?? this.mgr.getTreeSize(); const proof = await this.mgr.buildHistoricalConsistencyProof(fromTreeSize, targetSize); return { fromTreeSize, toTreeSize: targetSize, proof }; }); } /** STH signing public key — operators expose this to clients OOB. */ getSigningPublicKey(): Uint8Array { return this.mgr.getSigningPublicKey(); } getLogId(): Uint8Array { return this.mgr.getLogId(); } private async maybeHeartbeat(): Promise { if (!this.latest) { return this.withLock(async () => { this.latest = await this.mgr.publishSTH(); return this.latest!; }); } if (this.heartbeatIntervalMs <= 0) return this.latest; const age = this.now() - this.latest.timestampMs; if (age < this.heartbeatIntervalMs) return this.latest; return this.withLock(async () => { // Re-check age inside the lock — another caller may have published. const ageNow = this.now() - (this.latest?.timestampMs ?? 0); if (ageNow < this.heartbeatIntervalMs) return this.latest!; this.latest = await this.mgr.publishSTH(); return this.latest!; }); } } /** Helpers to encode an STH for the wire (base64). */ export function encodeSthForWire(sth: SignedTreeHead): STHWire { return sthToWire(sth, (b) => Buffer.from(b).toString('base64')); } export function encodeProofForWire(proof: KTProof): KTProofWire { return ktProofToWire(proof); }