Files
cinny/src/app/utils/cryptoDiagLog.ts
T
jaredandClaude Opus 5 c5082a78ef
CI / Build & Quality Checks (push) Successful in 1m28s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 6s
CI / Playwright smoke (e2e) (push) Successful in 2m7s
docs: move the LOTUS_TODO / LOTUS_TESTING backlogs into Gitea issues; keep a reference-only LOTUS_REFERENCE.md
LOTUS_TODO.md → LOTUS_REFERENCE.md (design laws, decided deferrals,
server-blocked features, operational reference only). Every open task was
filed: cinny #195–#210, cinny-desktop #15–#18, matrix #8–#10.

LOTUS_TESTING.md keeps the automated-coverage map, the Playwright notes and
the deploy tip; every manual checklist is now a `qa` issue under the
'Manual QA backlog' (cinny #170–#194, #198) and 'Desktop QA backlog'
(cinny-desktop #11–#14, #18) milestones.

Repointed the README, LOTUS_FEATURES, CI and source comments that
referenced LOTUS_TODO.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-17 23:26:21 -04:00

152 lines
4.8 KiB
TypeScript

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
// (E2EE KE-1..4 capture; see cinny #201). 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);
};