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:
@@ -4,11 +4,18 @@ import { createPrekeyRoutes } from './routes.js';
|
||||
import { MemoryPrekeyStore } from './memory-store.js';
|
||||
import type { PrekeyStore } from './store.js';
|
||||
import type { PrekeyServerEvents } from './events.js';
|
||||
import {
|
||||
KeyTransparencyService,
|
||||
type KeyTransparencyConfig,
|
||||
} from './kt-integration.js';
|
||||
|
||||
export { createPrekeyRoutes } from './routes.js';
|
||||
export { MemoryPrekeyStore } from './memory-store.js';
|
||||
export type { PrekeyStore } from './store.js';
|
||||
export { verifyPayload, signPayload, canonicalizePayload, validateAddress } from './auth.js';
|
||||
export { KeyTransparencyService, encodeSthForWire, encodeProofForWire } from './kt-integration.js';
|
||||
export type { KeyTransparencyConfig } from './kt-integration.js';
|
||||
export { createKTRoutes } from './kt-routes.js';
|
||||
|
||||
/**
|
||||
* Create a standalone Shade Prekey Server.
|
||||
@@ -29,6 +36,8 @@ export function createPrekeyServer(options: {
|
||||
store?: PrekeyStore;
|
||||
disableRateLimit?: boolean;
|
||||
events?: PrekeyServerEvents;
|
||||
/** Existing KT service (already initialized via `KeyTransparencyService.create`). */
|
||||
keyTransparency?: KeyTransparencyService;
|
||||
}): Hono {
|
||||
const store = options.store ?? new MemoryPrekeyStore();
|
||||
const routesOptions: Parameters<typeof createPrekeyRoutes>[2] = {};
|
||||
@@ -38,9 +47,35 @@ export function createPrekeyServer(options: {
|
||||
if (options.events !== undefined) {
|
||||
routesOptions.events = options.events;
|
||||
}
|
||||
if (options.keyTransparency !== undefined) {
|
||||
routesOptions.keyTransparency = options.keyTransparency;
|
||||
}
|
||||
return createPrekeyRoutes(store, options.crypto, routesOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: create both the KT service and the prekey server in one call.
|
||||
* Async because `KeyTransparencyService.create` reads existing state.
|
||||
*/
|
||||
export async function createPrekeyServerWithKT(options: {
|
||||
crypto: CryptoProvider;
|
||||
store?: PrekeyStore;
|
||||
disableRateLimit?: boolean;
|
||||
events?: PrekeyServerEvents;
|
||||
keyTransparency: KeyTransparencyConfig;
|
||||
}): Promise<{ app: Hono; kt: KeyTransparencyService }> {
|
||||
const kt = await KeyTransparencyService.create(options.crypto, options.keyTransparency);
|
||||
const passOpts: Parameters<typeof createPrekeyServer>[0] = {
|
||||
crypto: options.crypto,
|
||||
keyTransparency: kt,
|
||||
};
|
||||
if (options.store !== undefined) passOpts.store = options.store;
|
||||
if (options.disableRateLimit !== undefined) passOpts.disableRateLimit = options.disableRateLimit;
|
||||
if (options.events !== undefined) passOpts.events = options.events;
|
||||
const app = createPrekeyServer(passOpts);
|
||||
return { app, kt };
|
||||
}
|
||||
|
||||
export { RateLimiter, MemoryRateLimitStore } from './rate-limit.js';
|
||||
export type { RateLimitStore, RateLimitConfig } from './rate-limit.js';
|
||||
export { PrekeyServerEvents, shortHash as serverShortHash } from './events.js';
|
||||
|
||||
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);
|
||||
}
|
||||
74
packages/shade-server/src/kt-routes.ts
Normal file
74
packages/shade-server/src/kt-routes.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Hono } from 'hono';
|
||||
import { ValidationError } from '@shade/core';
|
||||
import type { KeyTransparencyService } from './kt-integration.js';
|
||||
import { encodeSthForWire } from './kt-integration.js';
|
||||
|
||||
/**
|
||||
* Mountable routes that expose the KT log to clients and witnesses.
|
||||
*
|
||||
* GET /v1/kt/log_id — public-key + log_id (operator pinning)
|
||||
* GET /v1/kt/sth — latest signed tree head
|
||||
* GET /v1/kt/sth/:treeSize — historical STH at a specific tree_size
|
||||
* GET /v1/kt/consistency — consistency proof between two tree_sizes
|
||||
*
|
||||
* These are intentionally **anonymous & read-only** so witnesses can
|
||||
* poll without sharing identity. Rate-limiting is the prekey-server's
|
||||
* existing fetch RL bucket.
|
||||
*/
|
||||
export function createKTRoutes(svc: KeyTransparencyService): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
app.get('/log_id', (c) => {
|
||||
const logId = svc.getLogId();
|
||||
const pub = svc.getSigningPublicKey();
|
||||
return c.json({
|
||||
logId: Buffer.from(logId).toString('base64'),
|
||||
publicKey: Buffer.from(pub).toString('base64'),
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/sth', async (c) => {
|
||||
const sth = await svc.getLatestSTH();
|
||||
return c.json(encodeSthForWire(sth));
|
||||
});
|
||||
|
||||
app.get('/sth/:treeSize', async (c) => {
|
||||
const sizeRaw = c.req.param('treeSize');
|
||||
const size = Number(sizeRaw);
|
||||
if (!Number.isFinite(size) || size < 0 || size !== Math.floor(size)) {
|
||||
throw new ValidationError('treeSize must be a non-negative integer');
|
||||
}
|
||||
const sth = await svc.getSTHByTreeSize(size);
|
||||
if (!sth) {
|
||||
return c.json({ error: 'STH not found at that tree_size', code: 'SHADE_NOT_FOUND' }, 404);
|
||||
}
|
||||
return c.json(encodeSthForWire(sth));
|
||||
});
|
||||
|
||||
app.get('/consistency', async (c) => {
|
||||
const fromRaw = c.req.query('from');
|
||||
const toRaw = c.req.query('to');
|
||||
if (fromRaw === undefined) {
|
||||
throw new ValidationError('from query param required');
|
||||
}
|
||||
const from = Number(fromRaw);
|
||||
if (!Number.isFinite(from) || from < 0 || from !== Math.floor(from)) {
|
||||
throw new ValidationError('from must be a non-negative integer');
|
||||
}
|
||||
let to: number | undefined;
|
||||
if (toRaw !== undefined) {
|
||||
to = Number(toRaw);
|
||||
if (!Number.isFinite(to) || to < from || to !== Math.floor(to)) {
|
||||
throw new ValidationError('to must be an integer >= from');
|
||||
}
|
||||
}
|
||||
const result = await svc.buildConsistencyProof(from, to);
|
||||
return c.json({
|
||||
fromTreeSize: result.fromTreeSize,
|
||||
toTreeSize: result.toTreeSize,
|
||||
proof: result.proof.map((p) => Buffer.from(p).toString('base64')),
|
||||
});
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -5,6 +5,16 @@ import type { PrekeyStore } from './store.js';
|
||||
import { verifyPayload, validateAddress } from './auth.js';
|
||||
import { RateLimiter, MemoryRateLimitStore, REGISTER_LIMIT, FETCH_LIMIT, REPLENISH_LIMIT, DELETE_LIMIT } from './rate-limit.js';
|
||||
import { PrekeyServerEvents, shortHash } from './events.js';
|
||||
import {
|
||||
ATTR_ERROR_CODE,
|
||||
ATTR_HTTP_STATUS,
|
||||
ATTR_ROUTE,
|
||||
NOOP_HOOK,
|
||||
type ObservabilityHook,
|
||||
} from '@shade/observability';
|
||||
import type { KeyTransparencyService } from './kt-integration.js';
|
||||
import { encodeProofForWire, encodeSthForWire } from './kt-integration.js';
|
||||
import { createKTRoutes } from './kt-routes.js';
|
||||
|
||||
/** Max POST body size in bytes (64KB) */
|
||||
const MAX_BODY_SIZE = 64 * 1024;
|
||||
@@ -26,6 +36,22 @@ export interface PrekeyRoutesOptions {
|
||||
disableRateLimit?: boolean;
|
||||
/** Optional event emitter for observability. */
|
||||
events?: PrekeyServerEvents;
|
||||
/**
|
||||
* Optional OTel observability hook. When supplied (and the runtime gate
|
||||
* is on), each request gets a `shade.prekey.<route>` span with route
|
||||
* + HTTP-status attributes. PII-safe: never logs the address path
|
||||
* parameter or client IP.
|
||||
*/
|
||||
observability?: ObservabilityHook;
|
||||
/**
|
||||
* Optional Key-Transparency service (V3.12). When provided:
|
||||
* - Every `register` and `delete` mutation is committed to the log.
|
||||
* - `GET /v1/keys/bundle/:address` includes a `ktProof` field.
|
||||
* - `/v1/kt/*` routes are mounted (latest STH, historical STHs,
|
||||
* consistency proofs, log_id pinning info).
|
||||
* When absent, the server is byte-compatible with pre-V3.12 clients.
|
||||
*/
|
||||
keyTransparency?: KeyTransparencyService;
|
||||
}
|
||||
|
||||
export function createPrekeyRoutes(
|
||||
@@ -35,6 +61,35 @@ export function createPrekeyRoutes(
|
||||
): Hono {
|
||||
const app = new Hono();
|
||||
const events = options.events;
|
||||
const observability = options.observability ?? NOOP_HOOK;
|
||||
const kt = options.keyTransparency;
|
||||
|
||||
// Per-request span middleware — runs first so it covers handlers AND
|
||||
// the global error handler. Span name is the route template (e.g.
|
||||
// `/v1/keys/bundle/:address`), so cardinality stays bounded and the
|
||||
// address itself never enters span data.
|
||||
app.use('*', async (c, next) => {
|
||||
const route = c.req.routePath ?? c.req.path ?? '<unknown>';
|
||||
const span = observability.startSpan('shade.prekey.request', {
|
||||
[ATTR_ROUTE]: route,
|
||||
});
|
||||
try {
|
||||
await next();
|
||||
span.setAttribute(ATTR_HTTP_STATUS, c.res.status);
|
||||
span.setStatus(c.res.status >= 500 ? 'error' : 'ok');
|
||||
} catch (err) {
|
||||
const code =
|
||||
err instanceof ShadeError
|
||||
? err.code ?? 'SHADE_ERROR'
|
||||
: 'SHADE_INTERNAL';
|
||||
span.setAttribute(ATTR_ERROR_CODE, code);
|
||||
span.recordException(err);
|
||||
span.setStatus('error', code);
|
||||
throw err;
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
});
|
||||
|
||||
// Rate limiters (one per route, per IP or per identity)
|
||||
const rlStore = new MemoryRateLimitStore();
|
||||
@@ -115,6 +170,21 @@ export function createPrekeyRoutes(
|
||||
await store.saveOneTimePreKeys(addr, keys);
|
||||
}
|
||||
|
||||
// Commit to KT log (if enabled). The bundle covered by the commitment
|
||||
// is { signing_key, dh_key, signed_prekey } — one-time prekeys are
|
||||
// intentionally excluded so OTP rotation doesn't churn the log.
|
||||
if (kt) {
|
||||
await kt.recordRegister(addr, {
|
||||
identitySigningKey: signingKey,
|
||||
identityDHKey: dhKey,
|
||||
signedPreKey: {
|
||||
keyId: signedPreKey.keyId,
|
||||
publicKey: b64ToBytes(signedPreKey.publicKey),
|
||||
signature: b64ToBytes(signedPreKey.signature),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (events) {
|
||||
const hash = await shortHash(signingKey);
|
||||
events.emit('server.identity_registered', { address: addr, identityKeyHash: hash });
|
||||
@@ -130,6 +200,33 @@ export function createPrekeyRoutes(
|
||||
|
||||
const identity = await store.getIdentity(address);
|
||||
if (!identity) {
|
||||
// KT-enabled: pin the negative answer to a tree state. If the
|
||||
// address has been tombstoned we serve the tombstone (inclusion)
|
||||
// proof; otherwise an absence proof.
|
||||
if (kt) {
|
||||
const inclusion = await kt.buildBundleInclusion(address);
|
||||
if (inclusion) {
|
||||
return c.json(
|
||||
{
|
||||
error: 'Address not found',
|
||||
code: 'SHADE_NOT_FOUND',
|
||||
ktProof: encodeProofForWire(inclusion),
|
||||
},
|
||||
404,
|
||||
);
|
||||
}
|
||||
const absence = await kt.buildAbsence(address);
|
||||
if (absence) {
|
||||
return c.json(
|
||||
{
|
||||
error: 'Address not found',
|
||||
code: 'SHADE_NOT_FOUND',
|
||||
ktProof: encodeProofForWire(absence),
|
||||
},
|
||||
404,
|
||||
);
|
||||
}
|
||||
}
|
||||
return c.json({ error: 'Address not found', code: 'SHADE_NOT_FOUND' }, 404);
|
||||
}
|
||||
|
||||
@@ -157,6 +254,19 @@ export function createPrekeyRoutes(
|
||||
};
|
||||
}
|
||||
|
||||
if (kt) {
|
||||
const proof = await kt.buildBundleInclusion(address);
|
||||
if (proof) {
|
||||
bundle.ktProof = encodeProofForWire(proof);
|
||||
} else {
|
||||
// No live entry in the index — fall back to STH so client at
|
||||
// least sees a fresh tree-head, then surfaces "no proof available"
|
||||
// as a soft warning.
|
||||
const sth = await kt.getLatestSTH();
|
||||
bundle.ktSth = encodeSthForWire(sth);
|
||||
}
|
||||
}
|
||||
|
||||
// Update activity so stale cleanup doesn't purge active addresses
|
||||
await store.touchIdentity(address);
|
||||
|
||||
@@ -228,10 +338,17 @@ export function createPrekeyRoutes(
|
||||
await verifyPayload(crypto, identity.identitySigningKey, { ...body, address });
|
||||
|
||||
await store.deleteAll(address);
|
||||
if (kt) {
|
||||
await kt.recordDelete(address);
|
||||
}
|
||||
events?.emit('server.identity_deleted', { address });
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
if (kt) {
|
||||
app.route('/v1/kt', createKTRoutes(kt));
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { Hono } from 'hono';
|
||||
import { SubtleCryptoProvider } from '@shade/crypto-web';
|
||||
import {
|
||||
createInboxRoutes,
|
||||
createBridgeRoutes,
|
||||
InboxServerEvents,
|
||||
InboxPruneTask,
|
||||
MemoryInboxStore,
|
||||
type InboxStore,
|
||||
} from '@shade/inbox-server';
|
||||
import { createPrekeyRoutes } from './routes.js';
|
||||
import { createHealthRoutes } from './health.js';
|
||||
import { createMetricsRoutes, metricsMiddleware } from './metrics.js';
|
||||
@@ -8,8 +16,13 @@ import { PrekeyServerEvents } from './events.js';
|
||||
import { StaleCleanupTask } from './cleanup.js';
|
||||
import { logger } from './logger.js';
|
||||
import type { PrekeyStore } from './store.js';
|
||||
import { KeyTransparencyService } from './kt-integration.js';
|
||||
import {
|
||||
MemoryKTLogStore,
|
||||
type KTLogStore,
|
||||
} from '@shade/key-transparency';
|
||||
|
||||
const VERSION = '1.0.0';
|
||||
const VERSION = '4.0.0';
|
||||
|
||||
async function createStore(): Promise<PrekeyStore & { close?: () => void | Promise<void> }> {
|
||||
const sqlitePath = process.env.SHADE_PREKEY_DB_PATH;
|
||||
@@ -32,6 +45,89 @@ async function createStore(): Promise<PrekeyStore & { close?: () => void | Promi
|
||||
return new MemoryPrekeyStore();
|
||||
}
|
||||
|
||||
async function createInboxStore(): Promise<InboxStore & { close?: () => void | Promise<void> }> {
|
||||
const sqlitePath = process.env.SHADE_INBOX_DB_PATH;
|
||||
const pgUrl = process.env.SHADE_INBOX_PG_URL ?? process.env.SHADE_PREKEY_PG_URL;
|
||||
|
||||
if (pgUrl && process.env.SHADE_INBOX_PG_URL) {
|
||||
const { PostgresInboxStore } = await import('@shade/storage-postgres');
|
||||
logger.info('Using PostgreSQL inbox store', { url: maskUrl(pgUrl) });
|
||||
return PostgresInboxStore.create(pgUrl);
|
||||
}
|
||||
|
||||
if (sqlitePath) {
|
||||
const { SqliteInboxStore } = await import('@shade/storage-sqlite');
|
||||
logger.info('Using SQLite inbox store', { path: sqlitePath });
|
||||
return new SqliteInboxStore(sqlitePath);
|
||||
}
|
||||
|
||||
if (pgUrl) {
|
||||
const { PostgresInboxStore } = await import('@shade/storage-postgres');
|
||||
logger.info('Using PostgreSQL inbox store (sharing prekey URL)', { url: maskUrl(pgUrl) });
|
||||
return PostgresInboxStore.create(pgUrl);
|
||||
}
|
||||
|
||||
logger.warn('Using in-memory inbox store — data will not persist across restarts');
|
||||
return new MemoryInboxStore();
|
||||
}
|
||||
|
||||
async function maybeCreateKT(): Promise<KeyTransparencyService | undefined> {
|
||||
const skPriv = process.env.SHADE_KT_SIGNING_PRIVATE_KEY;
|
||||
const skPub = process.env.SHADE_KT_SIGNING_PUBLIC_KEY;
|
||||
if (!skPriv || !skPub) {
|
||||
if (skPriv || skPub) {
|
||||
logger.warn(
|
||||
'Key Transparency requires BOTH SHADE_KT_SIGNING_PRIVATE_KEY and SHADE_KT_SIGNING_PUBLIC_KEY — KT disabled',
|
||||
);
|
||||
} else {
|
||||
logger.info('Key Transparency disabled (signing keys not configured)');
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let signingPrivateKey: Uint8Array;
|
||||
let signingPublicKey: Uint8Array;
|
||||
try {
|
||||
signingPrivateKey = new Uint8Array(Buffer.from(skPriv, 'base64'));
|
||||
signingPublicKey = new Uint8Array(Buffer.from(skPub, 'base64'));
|
||||
} catch {
|
||||
logger.error('SHADE_KT_SIGNING_*_KEY must be base64 — KT disabled');
|
||||
return undefined;
|
||||
}
|
||||
if (signingPrivateKey.length !== 32 || signingPublicKey.length !== 32) {
|
||||
logger.error(
|
||||
`SHADE_KT_SIGNING_*_KEY must decode to 32 bytes (priv=${signingPrivateKey.length}, pub=${signingPublicKey.length}) — KT disabled`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let ktStore: KTLogStore;
|
||||
const ktPg = process.env.SHADE_KT_PG_URL ?? process.env.SHADE_PREKEY_PG_URL;
|
||||
if (ktPg) {
|
||||
const { PostgresKTLogStore } = await import('@shade/storage-postgres');
|
||||
logger.info('Using PostgreSQL KT log store', { url: maskUrl(ktPg) });
|
||||
ktStore = await PostgresKTLogStore.create(ktPg);
|
||||
} else {
|
||||
logger.warn('Using in-memory KT log store — KT data will not persist across restarts');
|
||||
ktStore = new MemoryKTLogStore();
|
||||
}
|
||||
|
||||
const heartbeatRaw = process.env.SHADE_KT_HEARTBEAT_MS;
|
||||
const heartbeatIntervalMs = heartbeatRaw ? Number(heartbeatRaw) : 10 * 60 * 1000;
|
||||
|
||||
const svc = await KeyTransparencyService.create(crypto, {
|
||||
store: ktStore,
|
||||
signingPrivateKey,
|
||||
signingPublicKey,
|
||||
heartbeatIntervalMs,
|
||||
});
|
||||
logger.info('Key Transparency enabled', {
|
||||
logId: Buffer.from(svc.getLogId()).toString('hex').slice(0, 16) + '…',
|
||||
heartbeatIntervalMs,
|
||||
});
|
||||
return svc;
|
||||
}
|
||||
|
||||
function maskUrl(url: string): string {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
@@ -46,13 +142,42 @@ const crypto = new SubtleCryptoProvider();
|
||||
const store = await createStore();
|
||||
const events = new PrekeyServerEvents();
|
||||
|
||||
// Inbox store + events (V3.6 store-and-forward relay)
|
||||
const inboxStore = await createInboxStore();
|
||||
const inboxEvents = new InboxServerEvents();
|
||||
|
||||
// ─── Optional: Key Transparency (V3.12) ──────────────────────
|
||||
//
|
||||
// Enabled when both SHADE_KT_SIGNING_PRIVATE_KEY and
|
||||
// SHADE_KT_SIGNING_PUBLIC_KEY are set (base64-encoded 32-byte
|
||||
// Ed25519 seeds). Storage: PostgresKTLogStore when
|
||||
// SHADE_KT_PG_URL (or SHADE_PREKEY_PG_URL) is set, else memory.
|
||||
const kt = await maybeCreateKT();
|
||||
|
||||
// Compose the full app: metrics middleware + health + metrics + prekey routes
|
||||
const app = new Hono();
|
||||
app.use('*', metricsMiddleware());
|
||||
app.route('/', createHealthRoutes(store, VERSION));
|
||||
app.route('/', createMetricsRoutes());
|
||||
app.route('/', createOpenApiRoutes());
|
||||
app.route('/', createPrekeyRoutes(store, crypto, { events }));
|
||||
app.route(
|
||||
'/',
|
||||
createPrekeyRoutes(store, crypto, {
|
||||
events,
|
||||
...(kt ? { keyTransparency: kt } : {}),
|
||||
}),
|
||||
);
|
||||
app.route('/', createInboxRoutes(inboxStore, crypto, { events: inboxEvents }));
|
||||
|
||||
// V3.7 transport-bridge — SSE / long-poll / WS fallbacks for the inbox.
|
||||
// Held as a top-level reference so the WebSocket handler can be passed to
|
||||
// Bun.serve below.
|
||||
const bridgeRoutes = createBridgeRoutes({
|
||||
store: inboxStore,
|
||||
crypto,
|
||||
events: inboxEvents,
|
||||
});
|
||||
app.route('/', bridgeRoutes.app);
|
||||
|
||||
// ─── Optional: Observer + Dashboard ──────────────────────────
|
||||
|
||||
@@ -84,13 +209,31 @@ logger.info('Stale cleanup task started', {
|
||||
intervalHours: Number(process.env.SHADE_CLEANUP_INTERVAL_HOURS ?? 24),
|
||||
});
|
||||
|
||||
// ─── Inbox prune task ────────────────────────────────────────
|
||||
|
||||
const inboxPrune = new InboxPruneTask(inboxStore, {
|
||||
events: inboxEvents,
|
||||
logger: {
|
||||
info: (m, d) => logger.info(m, d as Record<string, unknown> | undefined),
|
||||
error: (m, d) => logger.error(m, d as Record<string, unknown> | undefined),
|
||||
},
|
||||
});
|
||||
inboxPrune.start();
|
||||
logger.info('Inbox prune task started', {
|
||||
intervalMinutes: Number(process.env.SHADE_INBOX_PRUNE_INTERVAL_MINUTES ?? 5),
|
||||
});
|
||||
|
||||
// ─── Start HTTP server ───────────────────────────────────────
|
||||
|
||||
const port = Number(process.env.PORT ?? 3900);
|
||||
|
||||
logger.info('Shade Prekey Server starting', { port, version: VERSION });
|
||||
|
||||
const server = Bun.serve({ port, fetch: app.fetch });
|
||||
const server = Bun.serve({
|
||||
port,
|
||||
fetch: (req, srv) => app.fetch(req, srv),
|
||||
websocket: bridgeRoutes.websocket as any,
|
||||
});
|
||||
|
||||
// ─── Graceful shutdown ───────────────────────────────────────
|
||||
|
||||
@@ -102,10 +245,14 @@ async function shutdown(signal: string) {
|
||||
|
||||
try {
|
||||
cleanupTask.stop();
|
||||
inboxPrune.stop();
|
||||
server.stop();
|
||||
if ('close' in store && typeof store.close === 'function') {
|
||||
await store.close();
|
||||
}
|
||||
if ('close' in inboxStore && typeof inboxStore.close === 'function') {
|
||||
await inboxStore.close();
|
||||
}
|
||||
logger.info('Shutdown complete');
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user