131 lines
4.4 KiB
TypeScript
131 lines
4.4 KiB
TypeScript
|
|
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<ShadeEventListener>();
|
||
|
|
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<K extends ShadeEventName>(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<string> {
|
||
|
|
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('');
|
||
|
|
}
|