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:
@@ -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