217 lines
7.3 KiB
TypeScript
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);
|
||
|
|
}
|