feat(diag): E2EE investigation kit for the KE-1→4 cluster

LOTUS_E2EE_INVESTIGATION.md: per-KE capture runbook (console signatures, synapse
log greps + SQL against the documented LXC deployment, the KE-1⇒KE-2 causality
decision tree, ranked remediations incl. what a crypto-store reset wipes; SDK
finding: stable 41.6.0 has no OTK fix over our RC pin). Client: capture-only
console ring buffer (cryptoDiagLog, KE-signature-matched, max 200) + a Crypto
Diagnostics card in Developer Tools with a download-report button. ClientRoot
installs the capture hook at module load and mounts useSessionSync (cross-tab
sessions, prior commit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 21:19:02 -04:00
parent 91bd360125
commit 7a8cadc6ec
5 changed files with 636 additions and 0 deletions
@@ -13,6 +13,7 @@ import {
} from '../../../components/AccountDataEditor';
import { copyToClipboard } from '../../../utils/dom';
import { AccountData } from './AccountData';
import { CryptoDiagnostics } from '../developer/CryptoDiagnostics';
type DeveloperToolsProps = {
requestClose: () => void;
@@ -109,6 +110,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
/>
</SequenceCard>
)}
{developerTools && <CryptoDiagnostics />}
</Box>
{developerTools && (
<AccountData
@@ -0,0 +1,71 @@
import React, { useCallback } from 'react';
import { Badge, Box, Button, Text } from 'folds';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useForceUpdate } from '../../../hooks/useForceUpdate';
import { useInterval } from '../../../hooks/useInterval';
import { buildCryptoDiagReport, getCryptoDiagEntries } from '../../../utils/cryptoDiagLog';
// Lotus E2EE investigation kit — Crypto Diagnostics settings card.
// Mirrors the surrounding Developer Tools cards (see DevelopTools.tsx).
const REFRESH_MS = 1000;
export function CryptoDiagnostics() {
const mx = useMatrixClient();
// Re-render on a light interval so the live matched-entry count stays fresh
// while the settings pane is open.
const [, forceUpdate] = useForceUpdate();
useInterval(forceUpdate, REFRESH_MS);
const count = getCryptoDiagEntries().length;
const handleDownload = useCallback(() => {
const report = buildCryptoDiagReport(mx);
const blob = new Blob([report], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `lotus-crypto-diag-${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
a.click();
URL.revokeObjectURL(url);
}, [mx]);
return (
<Box direction="Column" gap="100">
<Text size="L400">Crypto Diagnostics</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Crypto Diagnostics — captures E2EE error signatures this session"
description="Ring-buffers up to 200 matched console warnings/errors for the KE-1..KE-4 bug cluster. Local only — no network calls. The downloaded report includes the matched log lines as evidence."
after={
<Box alignItems="Center" gap="200" shrink="No">
<Badge variant={count > 0 ? 'Critical' : 'Secondary'} fill="Solid" radii="Pill">
<Text as="span" size="L400">
{count}
</Text>
</Badge>
<Button
onClick={handleDownload}
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
>
<Text size="B300">Download report</Text>
</Button>
</Box>
}
/>
</SequenceCard>
</Box>
);
}
+10
View File
@@ -43,8 +43,15 @@ import { stopPropagation } from '../../utils/keyboard';
import { SyncStatus } from './SyncStatus';
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
import { getFallbackSession, removeFallbackSession } from '../../state/sessions';
import { useSessionSync } from '../../hooks/useSessionSync';
import { installCryptoDiagLog } from '../../utils/cryptoDiagLog';
import { AutoDiscovery } from './AutoDiscovery';
// Capture-only E2EE diagnostics ring buffer (KE-1→4 signatures) — installed at
// module load so it sees crypto warnings from the very first sync. Idempotent;
// report download lives in Settings → Developer Tools → Crypto Diagnostics.
installCryptoDiagLog();
function ClientRootLoading() {
return (
<SplashScreen>
@@ -178,6 +185,9 @@ export function ClientRoot({ children }: ClientRootProps) {
);
useLogoutListener(mx);
// Cross-tab session sync: another tab logging out / in (access token changed
// in localStorage) reloads this tab so it never runs with stale credentials.
useSessionSync();
useEffect(() => {
if (loadState.status === AsyncStatus.Idle) {
+151
View File
@@ -0,0 +1,151 @@
import type { MatrixClient } from 'matrix-js-sdk';
import pkg from '../../../package.json';
// Lotus E2EE investigation kit — capture-only console diagnostics.
//
// Installs pass-through wrappers around `console.warn` / `console.error` that
// ring-buffer any log line matching the KE-1..KE-4 bug-cluster signatures
// (see LOTUS_E2EE_INVESTIGATION.md). It NEVER swallows a log call — the
// original console method is always invoked — and it performs NO network I/O.
// The report metadata is limited to SDK version / device id / user id / sync
// state; the captured log lines themselves are intentional evidence and may
// contain event ids or matrix ids exactly as the SDK logged them.
export type CryptoDiagLevel = 'warn' | 'error';
export type CryptoDiagEntry = {
/** ISO-8601 UTC timestamp of when the line was captured. */
ts: string;
level: CryptoDiagLevel;
/** Which KE bucket the signature belongs to, e.g. `KE-1`. */
ke: string;
/** Human-readable label of the matched signature. */
signature: string;
/** The serialized console line (best-effort). */
message: string;
};
type Signature = {
ke: string;
label: string;
re: RegExp;
};
// Ordered most-specific-first so the recorded label is the tightest match.
const SIGNATURES: Signature[] = [
{ ke: 'KE-1', label: 'already exists', re: /already exists/i },
{ ke: 'KE-2', label: 'missing key at index', re: /missing key at index/i },
{
ke: 'KE-2',
label: 'io.element.call.encryption_keys',
re: /io\.element\.call\.encryption_keys/,
},
{ ke: 'KE-2', label: 'MissingKey', re: /MissingKey/ },
{ ke: 'KE-3', label: 'DecryptionError', re: /DecryptionError/ },
{ ke: 'KE-4', label: 'update_delayed_event', re: /update_delayed_event/ },
{ ke: 'KE-4', label: 'delayed event', re: /delayed event/i },
];
const MAX_ENTRIES = 200;
const entries: CryptoDiagEntry[] = [];
let installed = false;
let originalWarn: ((...data: unknown[]) => void) | undefined;
let originalError: ((...data: unknown[]) => void) | undefined;
const stringifyArg = (arg: unknown): string => {
if (typeof arg === 'string') return arg;
if (arg instanceof Error) return `${arg.name}: ${arg.message}`;
try {
return JSON.stringify(arg);
} catch {
return String(arg);
}
};
const capture = (level: CryptoDiagLevel, args: unknown[]): void => {
const message = args.map(stringifyArg).join(' ');
const sig = SIGNATURES.find((s) => s.re.test(message));
if (!sig) return;
entries.push({
ts: new Date().toISOString(),
level,
ke: sig.ke,
signature: sig.label,
message,
});
// Ring-buffer: keep only the most recent MAX_ENTRIES.
while (entries.length > MAX_ENTRIES) {
entries.shift();
}
};
/**
* Install the capture-only console wrappers. Idempotent — calling it more than
* once is a no-op. Safe to call as early as possible during app boot.
*/
export const installCryptoDiagLog = (): void => {
if (installed) return;
installed = true;
originalWarn = console.warn.bind(console);
originalError = console.error.bind(console);
console.warn = (...args: unknown[]): void => {
capture('warn', args);
originalWarn?.(...args);
};
console.error = (...args: unknown[]): void => {
capture('error', args);
originalError?.(...args);
};
};
/** A snapshot copy of the current capture buffer (most-recent-last). */
export const getCryptoDiagEntries = (): CryptoDiagEntry[] => entries.slice();
const readSdkVersion = (mx?: MatrixClient): string => {
// Prefer the value the running client reports; fall back to the declared pin.
const declared = (pkg.dependencies as Record<string, string> | undefined)?.['matrix-js-sdk'];
const clientVersion = (mx as unknown as { getSdkVersion?: () => string } | undefined)
?.getSdkVersion;
if (typeof clientVersion === 'function') {
try {
return clientVersion.call(mx) || declared || 'unknown';
} catch {
// fall through to the declared pin
}
}
return declared ?? 'unknown';
};
/**
* Build a self-contained JSON diagnostic report string. Contains only the SDK
* version, device id, user id, sync state, crypto readiness, and the captured
* KE signature buffer — no message content, tokens, or other PII.
*/
export const buildCryptoDiagReport = (mx?: MatrixClient): string => {
const buffer = getCryptoDiagEntries();
const countsByKe: Record<string, number> = {};
buffer.forEach((entry) => {
countsByKe[entry.ke] = (countsByKe[entry.ke] ?? 0) + 1;
});
const report = {
kind: 'lotus-crypto-diag',
generatedAt: new Date().toISOString(),
sdkVersion: readSdkVersion(mx),
deviceId: mx?.getDeviceId() ?? null,
userId: mx?.getUserId() ?? null,
syncState: mx?.getSyncState() ?? null,
cryptoReady: Boolean(mx?.getCrypto()),
entryCount: buffer.length,
maxEntries: MAX_ENTRIES,
countsByKe,
entries: buffer,
};
return JSON.stringify(report, null, 2);
};