release(v4.0.0): Shade GA — V3.x consolidation + audit prep
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
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
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>
This commit is contained in:
216
packages/shade-server/src/kt-integration.ts
Normal file
216
packages/shade-server/src/kt-integration.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
Reference in New Issue
Block a user