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>
205 lines
6.5 KiB
TypeScript
205 lines
6.5 KiB
TypeScript
import type { StorageProvider } from '@shade/core';
|
|
import { ConfigurationError } from '@shade/core';
|
|
import type { ObservabilityHook } from '@shade/observability';
|
|
|
|
/**
|
|
* Shade SDK configuration.
|
|
*
|
|
* All fields have sane defaults except `prekeyServer` which is required
|
|
* for any networked usage.
|
|
*/
|
|
export interface ShadeConfig {
|
|
/** Required: base URL of the prekey server */
|
|
prekeyServer: string;
|
|
|
|
/**
|
|
* Storage backend. Accepts:
|
|
* - "memory" — in-memory only (lost on restart, good for tests)
|
|
* - "sqlite:/path/to/file.db" — SQLite backend
|
|
* - { type: 'postgres', url: 'postgres://...' } — PostgreSQL backend
|
|
* - An explicit StorageProvider instance
|
|
*
|
|
* Default: "memory"
|
|
*/
|
|
storage?: string | StorageProvider | { type: 'postgres'; url: string };
|
|
|
|
/**
|
|
* Your address on the prekey server (e.g. "alice@example.com" or "device:abc123").
|
|
* If omitted (undefined), a random UUID is generated and persisted.
|
|
*/
|
|
address?: string | undefined;
|
|
|
|
/**
|
|
* Auto-replenish configuration. When the one-time prekey stock drops
|
|
* below `min`, the SDK will generate enough to reach `target` and
|
|
* upload them to the prekey server.
|
|
*
|
|
* Default: { min: 5, target: 20, intervalMs: 60_000 }
|
|
* Pass `false` to disable.
|
|
*/
|
|
autoReplenish?: { min: number; target: number; intervalMs: number } | false;
|
|
|
|
/**
|
|
* Auto identity rotation. Default OFF.
|
|
* Pass an interval string like '7d' or '30d' to enable periodic rotation.
|
|
*/
|
|
autoRotate?: false | '1d' | '7d' | '30d' | '90d';
|
|
|
|
/**
|
|
* Optional observer configuration. If provided, starts a Shade Observer
|
|
* endpoint for the dashboard.
|
|
*/
|
|
observer?: {
|
|
/** Bearer token for the observer. Must be at least 16 chars. */
|
|
token: string;
|
|
/** Port to listen on. If not set, observer is created but not served. */
|
|
port?: number;
|
|
/** Path prefix to mount under (default: "/shade-observer") */
|
|
basePath?: string;
|
|
};
|
|
|
|
/**
|
|
* Optional OpenTelemetry observability hook. Build with
|
|
* `withTracer(otelTracer)` from `@shade/observability`. Default: off.
|
|
*
|
|
* The hook propagates to the session manager (encrypt/decrypt spans),
|
|
* transfer engine (upload/download), and `@shade/files` (per-op).
|
|
* `withTracer()` is itself a no-op unless `SHADE_OTEL_ENABLED` is set,
|
|
* so leaving this configured in code stays free in production until
|
|
* the env-var flips it on.
|
|
*/
|
|
observability?: ObservabilityHook;
|
|
|
|
/**
|
|
* Optional Key-Transparency verifier (V3.12). When set, every
|
|
* `fetchBundle` validates the server's inclusion proof against the
|
|
* pinned `logPublicKey` and feeds the STH into a `LightWitness` for
|
|
* split-view detection.
|
|
*
|
|
* Modes:
|
|
* - `'observe'` — verify proofs when present, do not fail when missing.
|
|
* - `'observe-strict'` — require a proof on every successful and 404 response.
|
|
*
|
|
* Default: KT verification disabled. Set this to enable.
|
|
*/
|
|
keyTransparency?: ShadeKTConfig;
|
|
}
|
|
|
|
/** Public configuration shape for `Shade.keyTransparency`. */
|
|
export interface ShadeKTConfig {
|
|
mode: 'observe' | 'observe-strict';
|
|
/** Operator's pinned signing public key (32-byte Ed25519) — base64 or raw bytes. */
|
|
logPublicKey: Uint8Array | string;
|
|
/** Reject STHs older than this many ms. Default 24h. */
|
|
maxStaleMs?: number;
|
|
/** Cap on observed-STH cache. Default 1024. */
|
|
witnessMaxStored?: number;
|
|
}
|
|
|
|
export interface ResolvedConfig {
|
|
prekeyServer: string;
|
|
storage: string | StorageProvider | { type: 'postgres'; url: string };
|
|
address?: string | undefined;
|
|
autoReplenish: { min: number; target: number; intervalMs: number } | false;
|
|
autoRotate: false | '1d' | '7d' | '30d' | '90d';
|
|
observer?: {
|
|
token: string;
|
|
port?: number | undefined;
|
|
basePath: string;
|
|
};
|
|
observability?: ObservabilityHook;
|
|
keyTransparency?: ResolvedKTConfig;
|
|
}
|
|
|
|
export interface ResolvedKTConfig {
|
|
mode: 'observe' | 'observe-strict';
|
|
logPublicKey: Uint8Array;
|
|
maxStaleMs: number;
|
|
witnessMaxStored: number;
|
|
}
|
|
|
|
/** Parse and validate a ShadeConfig, resolving defaults and env var overrides */
|
|
export function resolveConfig(input: ShadeConfig): ResolvedConfig {
|
|
if (!input.prekeyServer) {
|
|
throw new ConfigurationError('prekeyServer is required');
|
|
}
|
|
|
|
const autoReplenish = input.autoReplenish === false
|
|
? false
|
|
: {
|
|
min: input.autoReplenish?.min ?? 5,
|
|
target: input.autoReplenish?.target ?? 20,
|
|
intervalMs: input.autoReplenish?.intervalMs ?? 60_000,
|
|
};
|
|
|
|
const resolved: ResolvedConfig = {
|
|
prekeyServer: input.prekeyServer.replace(/\/$/, ''),
|
|
storage: input.storage ?? 'memory',
|
|
address: input.address,
|
|
autoReplenish,
|
|
autoRotate: input.autoRotate ?? false,
|
|
};
|
|
|
|
if (input.observer) {
|
|
if (!input.observer.token || input.observer.token.length < 16) {
|
|
throw new ConfigurationError('observer.token must be at least 16 characters');
|
|
}
|
|
resolved.observer = {
|
|
token: input.observer.token,
|
|
port: input.observer.port,
|
|
basePath: input.observer.basePath ?? '/shade-observer',
|
|
};
|
|
}
|
|
|
|
if (input.observability !== undefined) {
|
|
resolved.observability = input.observability;
|
|
}
|
|
|
|
if (input.keyTransparency !== undefined) {
|
|
const kt = input.keyTransparency;
|
|
if (kt.mode !== 'observe' && kt.mode !== 'observe-strict') {
|
|
throw new ConfigurationError(
|
|
`keyTransparency.mode must be 'observe' or 'observe-strict'`,
|
|
);
|
|
}
|
|
let logKey: Uint8Array;
|
|
if (typeof kt.logPublicKey === 'string') {
|
|
try {
|
|
logKey = new Uint8Array(Buffer.from(kt.logPublicKey, 'base64'));
|
|
} catch {
|
|
throw new ConfigurationError(
|
|
'keyTransparency.logPublicKey must be base64 or Uint8Array',
|
|
);
|
|
}
|
|
} else {
|
|
logKey = kt.logPublicKey;
|
|
}
|
|
if (logKey.length !== 32) {
|
|
throw new ConfigurationError(
|
|
`keyTransparency.logPublicKey must be 32 bytes (got ${logKey.length})`,
|
|
);
|
|
}
|
|
resolved.keyTransparency = {
|
|
mode: kt.mode,
|
|
logPublicKey: logKey,
|
|
maxStaleMs: kt.maxStaleMs ?? 24 * 60 * 60 * 1000,
|
|
witnessMaxStored: kt.witnessMaxStored ?? 1024,
|
|
};
|
|
}
|
|
|
|
return resolved;
|
|
}
|
|
|
|
/** Parse a rotation interval string ('7d', '30d', etc.) into milliseconds */
|
|
export function parseRotationInterval(
|
|
interval: '1d' | '7d' | '30d' | '90d',
|
|
): number {
|
|
const map: Record<string, number> = {
|
|
'1d': 24 * 60 * 60 * 1000,
|
|
'7d': 7 * 24 * 60 * 60 * 1000,
|
|
'30d': 30 * 24 * 60 * 60 * 1000,
|
|
'90d': 90 * 24 * 60 * 60 * 1000,
|
|
};
|
|
return map[interval]!;
|
|
}
|