Files
Shade/packages/shade-server/src/kt-integration.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

217 lines
7.3 KiB
TypeScript

/**
* 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<unknown> = 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<KeyTransparencyService> {
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<T>(fn: () => Promise<T>): Promise<T> {
const prev = this.mutex;
let resolveNext: () => void;
const next = new Promise<void>((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<SignedTreeHead> {
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<SignedTreeHead> {
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<KTProof | null> {
const sth = await this.maybeHeartbeat();
return this.mgr.buildBundleInclusionProof(address, sth);
}
async buildAbsence(address: string): Promise<KTProof | null> {
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<SignedTreeHead> {
return this.maybeHeartbeat();
}
/** Historical STH at a specific tree size. */
async getSTHByTreeSize(treeSize: number): Promise<SignedTreeHead | null> {
return this.store.getSTHByTreeSize(treeSize);
}
/** All persisted STHs in a time window — used by witness backfill. */
async listSTHs(fromTimestampMs?: number, toTimestampMs?: number): Promise<SignedTreeHead[]> {
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<SignedTreeHead> {
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);
}