feat(observer): M-Obs 1-3 — event bus, server hooks, observer backend
M-Obs 1: Event bus in @shade/core - ShadeEventEmitter with typed event union, ring buffer for replay - 12 event types covering session lifecycle, ratchet operations, prekey changes, identity rotation, trust changes - Wired into ShadeSessionManager (zero overhead when not enabled) - shortHash helper for safe display of public keys - Security test: regex-checks event payloads contain no key material M-Obs 2: Prekey server event hooks - PrekeyServerEvents emitter mirroring core's pattern - 5 server event types: registered, fetched, replenished, deleted, rate_limited - Wired into all routes including the rate-limit error handler - shortHash helper using crypto.subtle directly (no provider dep) M-Obs 3: @shade/observer package - StateAggregator subscribes to client + server events, builds rolling snapshot - Hono routes: GET /api/state (snapshot), GET /api/events (SSE stream) - Bearer token auth via SHADE_OBSERVER_TOKEN, query string for SSE - Refuses to start with token < 16 chars (ConfigurationError) - Static file serving for bundled dashboard at /dashboard/ - Placeholder dashboard renders when no built SPA present 220 tests passing, 0 failures. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
130
packages/shade-core/src/events.ts
Normal file
130
packages/shade-core/src/events.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
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('');
|
||||
}
|
||||
@@ -8,3 +8,4 @@ export * from './ratchet.js';
|
||||
export { ShadeSessionManager, GRACE_PERIOD_MS } from './session.js';
|
||||
export * from './serialization.js';
|
||||
export * from './fingerprint.js';
|
||||
export * from './events.js';
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import { NoSessionError, UntrustedIdentityError } from './errors.js';
|
||||
import { computeFingerprint, shortFingerprint } from './fingerprint.js';
|
||||
import { constantTimeEqual } from './crypto.js';
|
||||
import { ShadeEventEmitter, shortHash } from './events.js';
|
||||
|
||||
const enc = new TextEncoder();
|
||||
const dec = new TextDecoder();
|
||||
@@ -59,11 +60,20 @@ export class ShadeSessionManager {
|
||||
private identity: IdentityKeyPair | null = null;
|
||||
private registrationId: number = 0;
|
||||
private currentSignedPreKeyId: number = 0;
|
||||
private readonly events?: ShadeEventEmitter;
|
||||
|
||||
constructor(
|
||||
private readonly crypto: CryptoProvider,
|
||||
private readonly storage: StorageProvider,
|
||||
) {}
|
||||
options: { events?: ShadeEventEmitter } = {},
|
||||
) {
|
||||
this.events = options.events;
|
||||
}
|
||||
|
||||
/** Get the event emitter (if observability is enabled) */
|
||||
getEvents(): ShadeEventEmitter | undefined {
|
||||
return this.events;
|
||||
}
|
||||
|
||||
// ─── Initialization ────────────────────────────────────────
|
||||
|
||||
@@ -95,6 +105,15 @@ export class ShadeSessionManager {
|
||||
} else {
|
||||
this.currentSignedPreKeyId = spk.keyId;
|
||||
}
|
||||
|
||||
// Emit identity initialization event
|
||||
if (this.events) {
|
||||
const fingerprint = await this.getIdentityFingerprint();
|
||||
this.events.emit('identity.initialized', {
|
||||
fingerprint,
|
||||
registrationId: this.registrationId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Get our identity's DH public key (for addressing) */
|
||||
@@ -168,6 +187,7 @@ export class ShadeSessionManager {
|
||||
*/
|
||||
async resetSession(address: string): Promise<void> {
|
||||
await this.storage.removeSession(address);
|
||||
this.events?.emit('session.removed', { address });
|
||||
// Note: we keep the trusted identity; new session will verify against it.
|
||||
}
|
||||
|
||||
@@ -177,9 +197,16 @@ export class ShadeSessionManager {
|
||||
* After this, any pinned trust for this address is replaced.
|
||||
*/
|
||||
async acceptIdentityChange(address: string, newIdentityKey: Uint8Array): Promise<void> {
|
||||
// Capture old hash for the trust.changed event (TOFU semantics make this messy
|
||||
// because isTrustedIdentity() compares not retrieves; we just emit the new hash)
|
||||
await this.storage.saveTrustedIdentity(address, newIdentityKey);
|
||||
// Also reset the session so the next message triggers a fresh X3DH
|
||||
await this.storage.removeSession(address);
|
||||
|
||||
if (this.events) {
|
||||
const newHash = await shortHash(this.crypto, newIdentityKey);
|
||||
this.events.emit('trust.changed', { address, oldKeyHash: '?', newKeyHash: newHash });
|
||||
this.events.emit('session.removed', { address });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -211,17 +238,23 @@ export class ShadeSessionManager {
|
||||
for (const key of keys) {
|
||||
await this.storage.saveOneTimePreKey(key);
|
||||
}
|
||||
this.events?.emit('prekey.generated', {
|
||||
count,
|
||||
totalAfter: existingCount + count,
|
||||
});
|
||||
return keys;
|
||||
}
|
||||
|
||||
/** Rotate the signed prekey (recommended: every 1-7 days) */
|
||||
async rotateSignedPreKey(): Promise<SignedPreKey> {
|
||||
if (!this.identity) throw new Error('Not initialized');
|
||||
const newId = this.currentSignedPreKeyId + 1;
|
||||
const oldId = this.currentSignedPreKeyId;
|
||||
const newId = oldId + 1;
|
||||
const spk = await generateSignedPreKey(this.crypto, this.identity, newId);
|
||||
await this.storage.saveSignedPreKey(spk);
|
||||
// Keep old one for a grace period (sessions may still reference it)
|
||||
this.currentSignedPreKeyId = newId;
|
||||
this.events?.emit('signed_prekey.rotated', { oldKeyId: oldId, newKeyId: newId });
|
||||
return spk;
|
||||
}
|
||||
|
||||
@@ -261,6 +294,11 @@ export class ShadeSessionManager {
|
||||
await this.storage.saveSignedPreKey(spk);
|
||||
this.currentSignedPreKeyId = newSpkId;
|
||||
|
||||
if (this.events) {
|
||||
const newFingerprint = await this.getIdentityFingerprint();
|
||||
this.events.emit('identity.rotated', { newFingerprint });
|
||||
}
|
||||
|
||||
// Return a fresh bundle for re-publication
|
||||
return createPreKeyBundle(this.registrationId, this.identity, spk);
|
||||
}
|
||||
@@ -313,6 +351,12 @@ export class ShadeSessionManager {
|
||||
registrationId: this.registrationId,
|
||||
};
|
||||
await this.storage.saveSession(address, session);
|
||||
|
||||
if (this.events) {
|
||||
const remoteHash = await shortHash(this.crypto, x3dhResult.remoteIdentityKey);
|
||||
this.events.emit('session.created', { address, remoteIdentityKeyHash: remoteHash });
|
||||
this.events.emit('trust.pinned', { address, identityKeyHash: remoteHash });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Encrypt / Decrypt ─────────────────────────────────────
|
||||
@@ -329,6 +373,12 @@ export class ShadeSessionManager {
|
||||
|
||||
const ratchetMsg = await ratchetEncrypt(this.crypto, session, enc.encode(plaintext));
|
||||
|
||||
this.events?.emit('message.encrypted', {
|
||||
address,
|
||||
counter: ratchetMsg.counter,
|
||||
ciphertextSize: ratchetMsg.ciphertext.length,
|
||||
});
|
||||
|
||||
// Check if this is the first message (X3DH metadata attached)
|
||||
const x3dh = (session as any).__x3dh;
|
||||
if (x3dh) {
|
||||
@@ -390,6 +440,20 @@ export class ShadeSessionManager {
|
||||
await this.storage.saveSession(address, session);
|
||||
await this.storage.saveTrustedIdentity(address, x3dhResult.remoteIdentityKey);
|
||||
|
||||
if (this.events) {
|
||||
const remoteHash = await shortHash(this.crypto, x3dhResult.remoteIdentityKey);
|
||||
this.events.emit('session.created', { address, remoteIdentityKeyHash: remoteHash });
|
||||
this.events.emit('trust.pinned', { address, identityKeyHash: remoteHash });
|
||||
if (message.preKeyId != null) {
|
||||
this.events.emit('prekey.consumed', { keyId: message.preKeyId });
|
||||
}
|
||||
this.events.emit('message.decrypted', {
|
||||
address,
|
||||
counter: x3dhResult.initialMessage.counter,
|
||||
plaintextSize: plaintext.length,
|
||||
});
|
||||
}
|
||||
|
||||
return dec.decode(plaintext);
|
||||
}
|
||||
|
||||
@@ -397,9 +461,32 @@ export class ShadeSessionManager {
|
||||
const session = await this.storage.getSession(address);
|
||||
if (!session) throw new NoSessionError(address);
|
||||
|
||||
// Detect DH ratchet step (new remote DH key)
|
||||
const willRatchet = !session.dhReceive ||
|
||||
!arraysEqual(message.dhPublicKey, session.dhReceive);
|
||||
|
||||
const plaintext = await ratchetDecrypt(this.crypto, session, message);
|
||||
await this.storage.saveSession(address, session);
|
||||
|
||||
if (this.events) {
|
||||
if (willRatchet) {
|
||||
this.events.emit('ratchet.dh_step', { address });
|
||||
}
|
||||
this.events.emit('message.decrypted', {
|
||||
address,
|
||||
counter: message.counter,
|
||||
plaintextSize: plaintext.length,
|
||||
});
|
||||
}
|
||||
|
||||
return dec.decode(plaintext);
|
||||
}
|
||||
}
|
||||
|
||||
function arraysEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user