import type { CryptoProvider } from './crypto.js'; /** * Shade event bus. * * Emits structural events for observability — NEVER plaintext, private keys, * nonces, or other secret material. Identity references are SHA-256 truncated * to 8 bytes (16 hex chars) for display only. * * Optional: pass a ShadeEventEmitter to ShadeSessionManager to enable. * If not passed, all emits are no-ops with zero overhead. */ // ─── Event payload types ────────────────────────────────────── export interface ShadeEventBase { /** Monotonic sequence number assigned at emit time */ seq: number; /** Wall-clock timestamp in milliseconds */ timestamp: number; } /** Map of event names to their payload shape (without seq/timestamp) */ export interface ShadeEventMap { 'identity.initialized': { fingerprint: string; registrationId: number }; 'identity.rotated': { newFingerprint: string }; 'session.created': { address: string; remoteIdentityKeyHash: string }; 'session.removed': { address: string }; 'message.encrypted': { address: string; counter: number; ciphertextSize: number }; 'message.decrypted': { address: string; counter: number; plaintextSize: number }; 'ratchet.dh_step': { address: string }; 'prekey.generated': { count: number; totalAfter: number }; 'prekey.consumed': { keyId: number }; 'signed_prekey.rotated': { oldKeyId: number; newKeyId: number }; 'trust.pinned': { address: string; identityKeyHash: string }; 'trust.changed': { address: string; oldKeyHash: string; newKeyHash: string }; } export type ShadeEventName = keyof ShadeEventMap; export type ShadeEvent = { [K in ShadeEventName]: ShadeEventBase & { name: K; data: ShadeEventMap[K] }; }[ShadeEventName]; export type ShadeEventListener = (event: ShadeEvent) => void; // ─── EventEmitter implementation ───────────────────────────── /** * Minimal typed event emitter for Shade observability. * * Supports subscribe (`on`), unsubscribe (`off`), and replay buffer * for late subscribers. */ export class ShadeEventEmitter { private listeners = new Set(); private nextSeq = 1; private buffer: ShadeEvent[] = []; private readonly maxBuffer: number; constructor(options: { bufferSize?: number } = {}) { this.maxBuffer = options.bufferSize ?? 1000; } /** Subscribe to all events. Returns an unsubscribe function. */ on(listener: ShadeEventListener): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); } off(listener: ShadeEventListener): void { this.listeners.delete(listener); } /** Emit a typed event. Adds seq + timestamp automatically. */ emit(name: K, data: ShadeEventMap[K]): void { const event = { seq: this.nextSeq++, timestamp: Date.now(), name, data, } as ShadeEvent; // Add to ring buffer this.buffer.push(event); if (this.buffer.length > this.maxBuffer) { this.buffer.shift(); } // Notify listeners (catching throws so one bad listener doesn't break others) for (const listener of this.listeners) { try { listener(event); } catch (err) { console.error('[Shade] Event listener threw:', err); } } } /** Get all buffered events with seq > since (for SSE replay/reconnect) */ getBufferedSince(since: number): ShadeEvent[] { return this.buffer.filter((e) => e.seq > since); } /** Get the most recent N events */ getRecent(n: number): ShadeEvent[] { return this.buffer.slice(-n); } /** Current sequence number (next event will use this + 1) */ get currentSeq(): number { return this.nextSeq - 1; } } // ─── Hash helper for safe display ──────────────────────────── /** * Compute a short, display-safe hash of a public key. * Uses HKDF-SHA256 (since CryptoProvider has it) to produce 8 bytes, * then formats as 16 hex characters. * * NEVER use this for security decisions — it's lossy and only for UI display. */ export async function shortHash(crypto: CryptoProvider, key: Uint8Array): Promise { const salt = new Uint8Array(32); const info = new TextEncoder().encode('ShadeShortHash'); const hash = await crypto.hkdf(key, salt, info, 8); return Array.from(hash, (b) => b.toString(16).padStart(2, '0')).join(''); }