Files
lettabot/src/channels/matrix/verification.ts
Ani Tunturi 18010eb14f feat: Matrix adapter with E2EE, TTS/STT, reactions, and heartbeat routing
Full Matrix channel integration for LettaBot:

- E2EE via rust crypto (ephemeral mode, cross-signing bootstrap)
- Proactive SAS verification with Element clients
- TTS (VibeVoice) and STT (Faster-Whisper) voice pipeline
- Streaming message edits with 800ms throttle
- Collapsible reasoning blocks via <details> htmlPrefix
- Per-tool emoji reactions (brain, eyes, tool-specific, max 6)
- Heartbeat room conversation routing (heartbeatTargetChatId)
- Custom heartbeat prompt with first-person voice
- Per-room conversation isolation (per-chat mode)
- !pause, !resume, !status, !new, !timeout, !turns commands
- Audio/image/file upload handlers with E2EE media
- SDK 0.1.11 (approval recovery), CLI 0.18.2

Tested against Synapse homeserver with E2EE enabled for 2+ weeks,
handles key backup/restore and device verification.
2026-03-14 21:27:32 -04:00

399 lines
15 KiB
TypeScript

/**
* Matrix E2EE Device Verification Handler
*
* Handles SAS (emoji) device verification for matrix-js-sdk v28 with rust crypto.
*
* KEY FIXES:
* - Event handlers MUST be set up BEFORE startClient()
* - Use literal string event names: "show_sas", "cancel", "change"
* - Call verifier.verify() to actually start the verification flow
* - Accept when NOT in accepting state (!request.accepting)
*/
import { createLogger } from "../../logger.js";
import * as sdk from "matrix-js-sdk";
const log = createLogger('MatrixVerification');
interface VerificationCallbacks {
onShowSas?: (emojis: string[]) => void;
onComplete?: () => void;
onCancel?: (reason: string) => void;
onError?: (error: Error) => void;
}
interface ActiveVerification {
userId: string;
deviceId: string;
verifier: sdk.Crypto.Verifier | null;
request: sdk.Crypto.VerificationRequest;
sasCallbacks?: sdk.Crypto.ShowSasCallbacks | null;
}
/**
* Matrix Verification Handler for rust crypto backend
*
* Event flow (Matrix spec-compliant):
* 1. m.key.verification.request (incoming)
* 2. m.key.verification.ready (we accept)
* 3. m.key.verification.start (SAS method)
* 4. m.key.verification.key (exchange keys)
* 5. SAS computed - we call confirm()
* 6. m.key.verification.mac (send MAC)
* 7. m.key.verification.done
*
* CRITICAL: setupEventHandlers() MUST be called BEFORE client.startClient()
*/
export class MatrixVerificationHandler {
private client: sdk.MatrixClient;
private activeVerifications = new Map<string, ActiveVerification>();
private callbacks: VerificationCallbacks;
constructor(client: sdk.MatrixClient, callbacks: VerificationCallbacks = {}) {
this.client = client;
this.callbacks = callbacks;
}
/**
* CRITICAL: Call this BEFORE client.startClient()
*/
setupEventHandlers(): void {
// Log all verification to-device messages for debugging
this.client.on(sdk.ClientEvent.ToDeviceEvent, (event: sdk.MatrixEvent) => {
const type = event.getType();
if (type.startsWith("m.key.verification")) {
log.info(`[MatrixVerification] To-device: ${type} from ${event.getSender()}`, event.getContent());
}
});
// Listen for verification requests from rust crypto
// This is the PRIMARY event for incoming verification requests
this.client.on(sdk.CryptoEvent.VerificationRequestReceived, (request: sdk.Crypto.VerificationRequest) => {
log.info(`[MatrixVerification] VerificationRequestReceived: ${request.otherUserId}:${request.otherDeviceId}, phase=${this.phaseName(request.phase)}`);
this.handleVerificationRequest(request);
});
// Listen for device verification status changes
this.client.on(sdk.CryptoEvent.DevicesUpdated, (userIds: string[]) => {
log.info(`[MatrixVerification] Devices updated: ${userIds.join(", ")}`);
});
log.info("[MatrixVerification] Event handlers configured (ready BEFORE startClient())");
}
private phaseName(phase: sdk.Crypto.VerificationPhase): string {
const phases = ["Unsent", "Requested", "Ready", "Started", "Cancelled", "Done"];
return phases[phase - 1] || `Unknown(${phase})`;
}
private handleVerificationRequest(request: sdk.Crypto.VerificationRequest): void {
const otherUserId = request.otherUserId;
const otherDeviceId = request.otherDeviceId || "unknown";
const key = `${otherUserId}|${otherDeviceId}`;
// Check if already handling - but allow new requests if the old one is cancelled/timed out
const existing = this.activeVerifications.get(key);
if (existing) {
// If existing request is in a terminal state, clear it and proceed
if (existing.request.phase === sdk.Crypto.VerificationPhase.Cancelled ||
existing.request.phase === sdk.Crypto.VerificationPhase.Done) {
log.info(`[MatrixVerification] Clearing stale verification: ${otherUserId}:${otherDeviceId}`);
this.activeVerifications.delete(key);
} else if (request.phase === sdk.Crypto.VerificationPhase.Requested) {
// New request coming in while old one pending - replace it
log.info(`[MatrixVerification] Replacing stale verification: ${otherUserId}:${otherDeviceId}`);
this.activeVerifications.delete(key);
} else {
log.info(`[MatrixVerification] Already handling: ${otherUserId}:${otherDeviceId}`);
return;
}
}
log.info(`[MatrixVerification] *** REQUEST from ${otherUserId}:${otherDeviceId} ***`);
log.info(`[MatrixVerification] Phase: ${this.phaseName(request.phase)}`);
// NOTE: request.methods throws "not implemented" for RustVerificationRequest
// Rust crypto with SAS uses m.sas.v1 method by default
// Store the request immediately
this.activeVerifications.set(key, {
userId: otherUserId,
deviceId: otherDeviceId,
verifier: null,
request,
sasCallbacks: null,
});
// Handle based on phase
if (request.phase === sdk.Crypto.VerificationPhase.Requested) {
// Automatically accept incoming requests
this.acceptAndStartSAS(request, key);
} else if (request.phase === sdk.Crypto.VerificationPhase.Ready) {
// Already ready, start SAS
this.startSASVerification(request, key);
} else if (request.phase === sdk.Crypto.VerificationPhase.Started && request.verifier) {
// Verification already started, attach listeners
this.attachVerifierListeners(request.verifier, request, key);
}
}
private async acceptAndStartSAS(request: sdk.Crypto.VerificationRequest, key: string): Promise<void> {
try {
log.info("[MatrixVerification] Accepting verification request...");
await request.accept();
log.info(`[MatrixVerification] Accepted, phase is now: ${this.phaseName(request.phase)}`);
// Check if already Ready (phase might change immediately)
if (request.phase === sdk.Crypto.VerificationPhase.Ready) {
log.info("[MatrixVerification] Already Ready, starting SAS immediately...");
this.startSASVerification(request, key);
return;
}
// The SDK will emit a 'change' event when phase changes to Ready
// Listen for that and then start SAS
const onChange = () => {
log.info(`[MatrixVerification] Phase changed to: ${this.phaseName(request.phase)}`);
if (request.phase === sdk.Crypto.VerificationPhase.Ready) {
log.info("[MatrixVerification] Now in Ready phase, starting SAS...");
request.off("change" as any, onChange);
this.startSASVerification(request, key);
} else if (request.phase === sdk.Crypto.VerificationPhase.Done) {
request.off("change" as any, onChange);
}
};
request.on("change" as any, onChange);
// Also check after a short delay in case event doesn't fire
setTimeout(() => {
if (request.phase === sdk.Crypto.VerificationPhase.Ready) {
log.info("[MatrixVerification] Ready detected via timeout, starting SAS...");
request.off("change" as any, onChange);
this.startSASVerification(request, key);
}
}, 1000);
} catch (err) {
log.error("[MatrixVerification] Failed to accept:", err);
this.callbacks.onError?.(err as Error);
}
}
private async startSASVerification(request: sdk.Crypto.VerificationRequest, key: string): Promise<void> {
try {
log.info("[MatrixVerification] Starting SAS verification with m.sas.v1...");
// CRITICAL: Fetch device keys for the other user BEFORE starting SAS
// Without this, rust crypto says "device doesn't exist"
const crypto = this.client.getCrypto();
if (crypto && request.otherUserId) {
log.info(`[MatrixVerification] Fetching device keys for ${request.otherUserId}...`);
await crypto.getUserDeviceInfo([request.otherUserId], true);
log.info("[MatrixVerification] Device keys fetched");
// Small delay to let the crypto module process the keys
await new Promise(resolve => setTimeout(resolve, 500));
}
// Check if verifier already exists
const existingVerifier = request.verifier;
log.info(`[MatrixVerification] Verifier exists: ${!!existingVerifier}`);
if (existingVerifier) {
log.info("[MatrixVerification] Verifier already exists, attaching listeners...");
this.attachVerifierListeners(existingVerifier, request, key);
return;
}
log.info("[MatrixVerification] Calling request.startVerification()...");
// Start the SAS verification
const verifier = await request.startVerification("m.sas.v1");
log.info(`[MatrixVerification] startVerification() returned: ${!!verifier}`);
if (!verifier) {
throw new Error("startVerification returned undefined");
}
log.info("[MatrixVerification] SAS verifier created");
// Update stored verification
const stored = this.activeVerifications.get(key);
if (stored) {
stored.verifier = verifier;
}
// Attach listeners
log.info("[MatrixVerification] Attaching verifier listeners...");
this.attachVerifierListeners(verifier, request, key);
log.info("[MatrixVerification] Calling verifier.verify()...");
// Start the verification flow - this sends the accept message
await verifier.verify();
log.info("[MatrixVerification] verifier.verify() completed successfully");
} catch (err) {
log.error("[MatrixVerification] Error starting SAS:", err);
this.callbacks.onError?.(err as Error);
}
}
private attachVerifierListeners(verifier: sdk.Crypto.Verifier, request: sdk.Crypto.VerificationRequest, key: string): void {
// CRITICAL: Use the literal string "show_sas", not an enum property
verifier.on("show_sas" as any, (sas: sdk.Crypto.ShowSasCallbacks) => {
log.info("[MatrixVerification] *** SHOW SAS (EMOJI) ***");
if (!sas) {
log.error("[MatrixVerification] No SAS data received!");
return;
}
const sasData = verifier.getShowSasCallbacks();
if (!sasData?.sas?.emoji) {
log.error("[MatrixVerification] No emoji data in SAS!");
return;
}
const emojis = sasData.sas.emoji.map((e: [string, string]) => `${e[0]} ${e[1]}`);
log.info("[MatrixVerification] Emojis:", emojis.join(" | "));
log.info("[MatrixVerification] *** COMPARE THESE EMOJIS IN ELEMENT ***");
// Store callbacks and notify user
const stored = this.activeVerifications.get(key);
if (stored) {
stored.sasCallbacks = sasData;
}
this.callbacks.onShowSas?.(emojis);
// Auto-confirm after delay for bot
setTimeout(() => {
this.confirmVerification(key);
}, 5000); // 5 seconds for emoji comparison
});
// CRITICAL: Use the literal string "cancel"
verifier.on("cancel" as any, (err: Error | sdk.MatrixEvent) => {
log.error("[MatrixVerification] Verification cancelled:", err);
this.activeVerifications.delete(key);
const reason = err instanceof Error ? err.message : "Verification cancelled";
this.callbacks.onCancel?.(reason);
});
// Listen for verification request phase changes
request.on("change" as any, () => {
const phase = request.phase;
log.info(`[MatrixVerification] Request phase changed: ${this.phaseName(phase)}`);
if (phase === sdk.Crypto.VerificationPhase.Done) {
log.info("[MatrixVerification] *** VERIFICATION DONE ***");
this.activeVerifications.delete(key);
this.callbacks.onComplete?.();
} else if (phase === sdk.Crypto.VerificationPhase.Cancelled) {
log.info("[MatrixVerification] *** VERIFICATION CANCELLED ***");
this.activeVerifications.delete(key);
this.callbacks.onCancel?.(request.cancellationCode || "Unknown");
}
});
}
async confirmVerification(key: string): Promise<void> {
const stored = this.activeVerifications.get(key);
if (!stored?.sasCallbacks) {
log.info("[MatrixVerification] No pending verification to confirm");
return;
}
log.info("[MatrixVerification] Confirming verification (sending MAC)...");
try {
await stored.sasCallbacks.confirm();
log.info("[MatrixVerification] Verification confirmed (MAC sent). Waiting for Done...");
} catch (err) {
log.error("[MatrixVerification] Failed to confirm:", err);
this.callbacks.onError?.(err as Error);
}
}
/**
* Request verification with a specific device (initiated by us)
*/
async requestVerification(userId: string, deviceId: string): Promise<sdk.Crypto.VerificationRequest> {
const crypto = this.client.getCrypto();
if (!crypto) {
throw new Error("Crypto not initialized");
}
log.info(`[MatrixVerification] Requesting verification with ${userId}:${deviceId}`);
const request = await crypto.requestDeviceVerification(userId, deviceId);
const key = `${userId}|${deviceId}`;
this.activeVerifications.set(key, {
userId,
deviceId,
verifier: null,
request,
});
// Listen for the request to be ready, then start SAS
const onReadyOrStarted = () => {
const phase = request.phase;
if (phase === sdk.Crypto.VerificationPhase.Ready) {
log.info("[MatrixVerification] Outgoing request ready, starting SAS...");
this.startSASVerification(request, key);
request.off("change" as any, onReadyOrStarted);
} else if (phase === sdk.Crypto.VerificationPhase.Started && request.verifier) {
log.info("[MatrixVerification] Outgoing request already started, attaching listeners...");
this.attachVerifierListeners(request.verifier, request, key);
request.off("change" as any, onReadyOrStarted);
}
};
request.on("change" as any, onReadyOrStarted);
return request;
}
/**
* Get all pending verification requests for a user
*/
getVerificationRequests(userId: string): sdk.Crypto.VerificationRequest[] {
const requests: sdk.Crypto.VerificationRequest[] = [];
for (const [key, value] of Array.from(this.activeVerifications.entries())) {
if (key.startsWith(`${userId}|`)) {
requests.push(value.request);
}
}
return requests;
}
dispose(): void {
this.activeVerifications.forEach((v) => {
try {
// Note: EventEmitter.off() requires the specific handler reference
// Since we used anonymous functions, we can't easily remove them
// The map clear below will allow garbage collection anyway
} catch (e) {
// Ignore cleanup errors
}
});
this.activeVerifications.clear();
}
}
/**
* Format emojis for display
*/
export function formatEmojis(emojis: unknown[]): string {
if (!Array.isArray(emojis)) return "";
return emojis
.map((e) => {
if (Array.isArray(e) && e.length >= 2) {
return `${e[0]} ${e[1]}`;
}
return "";
})
.filter(Boolean)
.join(" | ");
}