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:
2026-04-10 18:49:51 +02:00
parent 75008b623a
commit b014f9b44c
17 changed files with 1364 additions and 5 deletions

View 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('');
}

View File

@@ -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';

View File

@@ -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;
}

View File

@@ -0,0 +1,190 @@
import { describe, test, expect } from 'bun:test';
import { SubtleCryptoProvider, MemoryStorage } from '@shade/crypto-web';
import {
ShadeSessionManager,
ShadeEventEmitter,
shortHash,
} from '../src/index.js';
import type { ShadeEvent } from '../src/index.js';
const crypto = new SubtleCryptoProvider();
describe('ShadeEventEmitter', () => {
test('subscribes and emits events', () => {
const emitter = new ShadeEventEmitter();
const received: ShadeEvent[] = [];
emitter.on((e) => received.push(e));
emitter.emit('identity.initialized', { fingerprint: 'abc', registrationId: 1 });
expect(received.length).toBe(1);
expect(received[0]!.name).toBe('identity.initialized');
expect(received[0]!.seq).toBe(1);
expect(received[0]!.timestamp).toBeGreaterThan(0);
expect((received[0]!.data as any).fingerprint).toBe('abc');
});
test('seq is monotonically increasing', () => {
const emitter = new ShadeEventEmitter();
const seqs: number[] = [];
emitter.on((e) => seqs.push(e.seq));
emitter.emit('prekey.generated', { count: 5, totalAfter: 5 });
emitter.emit('prekey.consumed', { keyId: 1 });
emitter.emit('prekey.consumed', { keyId: 2 });
expect(seqs).toEqual([1, 2, 3]);
});
test('unsubscribe stops receiving events', () => {
const emitter = new ShadeEventEmitter();
let count = 0;
const unsub = emitter.on(() => count++);
emitter.emit('prekey.generated', { count: 1, totalAfter: 1 });
unsub();
emitter.emit('prekey.generated', { count: 1, totalAfter: 2 });
expect(count).toBe(1);
});
test('listener throw does not break other listeners', () => {
const emitter = new ShadeEventEmitter();
let goodCount = 0;
emitter.on(() => { throw new Error('boom'); });
emitter.on(() => goodCount++);
emitter.emit('prekey.generated', { count: 1, totalAfter: 1 });
expect(goodCount).toBe(1);
});
test('getBufferedSince returns events after seq', () => {
const emitter = new ShadeEventEmitter();
emitter.emit('prekey.generated', { count: 1, totalAfter: 1 });
emitter.emit('prekey.generated', { count: 1, totalAfter: 2 });
emitter.emit('prekey.generated', { count: 1, totalAfter: 3 });
const events = emitter.getBufferedSince(1);
expect(events.length).toBe(2);
expect(events[0]!.seq).toBe(2);
expect(events[1]!.seq).toBe(3);
});
test('ring buffer evicts oldest', () => {
const emitter = new ShadeEventEmitter({ bufferSize: 3 });
for (let i = 0; i < 5; i++) {
emitter.emit('prekey.generated', { count: 1, totalAfter: i });
}
const recent = emitter.getRecent(10);
expect(recent.length).toBe(3);
expect(recent[0]!.seq).toBe(3);
expect(recent[2]!.seq).toBe(5);
});
});
describe('shortHash helper', () => {
test('produces 16-hex-char string', async () => {
const hash = await shortHash(crypto, crypto.randomBytes(32));
expect(hash).toMatch(/^[0-9a-f]{16}$/);
});
test('deterministic for same input', async () => {
const key = new Uint8Array(32).fill(0xab);
const a = await shortHash(crypto, key);
const b = await shortHash(crypto, key);
expect(a).toBe(b);
});
test('different inputs produce different hashes', async () => {
const a = await shortHash(crypto, crypto.randomBytes(32));
const b = await shortHash(crypto, crypto.randomBytes(32));
expect(a).not.toBe(b);
});
});
describe('ShadeSessionManager event integration', () => {
test('initialize emits identity.initialized', async () => {
const events = new ShadeEventEmitter();
const received: ShadeEvent[] = [];
events.on((e) => received.push(e));
const mgr = new ShadeSessionManager(crypto, new MemoryStorage(), { events });
await mgr.initialize();
const init = received.find((e) => e.name === 'identity.initialized');
expect(init).toBeDefined();
const data = init!.data as any;
expect(data.fingerprint).toMatch(/^\d{5}( \d{5}){11}$/);
expect(data.registrationId).toBeGreaterThan(0);
});
test('full conversation emits expected event sequence', async () => {
const events = new ShadeEventEmitter();
const received: ShadeEvent[] = [];
events.on((e) => received.push(e));
const alice = new ShadeSessionManager(crypto, new MemoryStorage(), { events });
const bob = new ShadeSessionManager(crypto, new MemoryStorage(), { events });
await alice.initialize();
await bob.initialize();
const otpks = await bob.generateOneTimePreKeys(5);
const bundle = await bob.createPreKeyBundle();
bundle.oneTimePreKey = { keyId: otpks[0].keyId, publicKey: otpks[0].keyPair.publicKey };
await alice.initSessionFromBundle('bob', bundle);
const env1 = await alice.encrypt('bob', 'hello');
await bob.decrypt('alice', env1);
const env2 = await bob.encrypt('alice', 'hi');
await alice.decrypt('bob', env2);
const names = received.map((e) => e.name);
expect(names).toContain('identity.initialized');
expect(names).toContain('prekey.generated');
expect(names).toContain('session.created');
expect(names).toContain('trust.pinned');
expect(names).toContain('message.encrypted');
expect(names).toContain('message.decrypted');
expect(names).toContain('ratchet.dh_step'); // Bob's reply triggers a DH step
});
test('no events emitted when emitter not provided', async () => {
const mgr = new ShadeSessionManager(crypto, new MemoryStorage());
await mgr.initialize();
// No assertion needed — should not throw or error
});
test('SECURITY: no key material in event payloads', async () => {
const events = new ShadeEventEmitter();
const received: ShadeEvent[] = [];
events.on((e) => received.push(e));
const alice = new ShadeSessionManager(crypto, new MemoryStorage(), { events });
const bob = new ShadeSessionManager(crypto, new MemoryStorage(), { events });
await alice.initialize();
await bob.initialize();
const otpks = await bob.generateOneTimePreKeys(5);
const bundle = await bob.createPreKeyBundle();
bundle.oneTimePreKey = { keyId: otpks[0].keyId, publicKey: otpks[0].keyPair.publicKey };
await alice.initSessionFromBundle('bob', bundle);
const env = await alice.encrypt('bob', 'secret message');
await bob.decrypt('alice', env);
await alice.rotateSignedPreKey();
// Serialize all events and check for any 32-byte base64 patterns
// (which would indicate raw key material)
const json = JSON.stringify(received);
// 32-byte base64 = 44 chars (with padding) or 43 (without)
// We allow short 16-hex-char hashes, but no 44-char base64 or 64-char hex
const longBase64 = /[A-Za-z0-9+/]{43,}={0,2}/g;
const longHex = /[0-9a-f]{32,}/gi;
const base64Matches = json.match(longBase64) ?? [];
const hexMatches = json.match(longHex) ?? [];
// Filter out any matches that are inside hash fields (which are 16 hex chars,
// so the regex above wouldn't match anyway, but be explicit)
expect(base64Matches.length).toBe(0);
expect(hexMatches.length).toBe(0);
// Also no plaintext leakage
expect(json).not.toContain('secret message');
});
});