Files
bitvid/js/nostr/signatureVerifyWorkerClient.js
T
thePR0M3TH3ANandClaude Opus 4.8 7e3cdb4174 fix(nostr): make feed signature verification fail-open and non-hanging
The UX harness (fake nip-07 extension) revealed that the off-thread verification
could blank the feed: if nostr-tools is slow/unable to load inside the verify
worker, the worker hangs, each batch waits the full 10s timeout, and the
main-thread fallback could return an empty set (fail-closed) — dropping every
event so no videos ever render, silently (devLogger is suppressed in prod).

Harden it so verification can never stall or blank the read feed:
- Worker timeout 10s -> 2.5s, and disable the worker for the session after one
  timeout/error so we never pay the timeout on every batch.
- Bound the main-thread nostr-tools wait (Promise.race ~2s) so a slow/failed
  load can't hang the fallback.
- Fail OPEN when the verifier is genuinely unavailable: pass events through
  unverified rather than dropping them (the pool already does not verify, so this
  does not regress the prior posture). Real verification still runs whenever
  nostr-tools is available.

Adds a regression test (hung worker -> resolves via fallback, worker disabled).
Also updates the harness to seed mode:"live" videos + a NIP-65 relay list for
mock-relay isolation.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-12 15:52:40 -04:00

166 lines
5.7 KiB
JavaScript

// Client for signatureVerifyWorker. Verifies event batches off the main thread
// and resolves with the Set of valid event ids. Falls back to main-thread
// verification only when Web Workers are unavailable, so signatures are still
// checked (never silently trusted) in that degraded case.
import { devLogger } from "../utils/logger.js";
import { ensureNostrTools, getCachedNostrTools } from "./toolkit.js";
// Short timeout: if the worker can't answer quickly (e.g. nostr-tools is slow to
// load inside the worker), we must NOT stall the feed — fall back to the
// already-loaded main-thread tools instead. After one failure we stop using the
// worker entirely for the session so we never pay the timeout twice.
const DEFAULT_TIMEOUT_MS = 2500;
let workerInstance = null;
let workerReady = false;
let workerDisabled = false;
let requestId = 0;
const pending = new Map();
function attachWorkerListeners(worker) {
if (!worker || workerReady) return;
worker.addEventListener("message", (event) => {
const data = event?.data || {};
const entry = pending.get(data.id);
if (!entry) return;
pending.delete(data.id);
clearTimeout(entry.timeoutId);
if (data.ok && Array.isArray(data.validIds)) {
entry.resolve(new Set(data.validIds));
} else {
entry.reject(new Error(data?.error?.message || "verify-worker-error"));
}
});
worker.addEventListener("error", (error) => {
devLogger.warn("[signatureVerifyWorkerClient] Worker error", error);
disableWorker();
for (const entry of pending.values()) {
clearTimeout(entry.timeoutId);
entry.reject(error instanceof Error ? error : new Error("verify-worker-error"));
}
pending.clear();
});
workerReady = true;
}
// Stop using the worker for the rest of the session (it hung or errored). All
// subsequent verification goes straight to the main-thread tools, which are
// already loaded by the app.
function disableWorker() {
workerDisabled = true;
if (workerInstance) {
try {
workerInstance.terminate();
} catch (_) {
// ignore
}
}
workerInstance = null;
}
function ensureWorker() {
if (workerDisabled) return null;
if (workerInstance) return workerInstance;
if (typeof Worker === "undefined") return null;
try {
workerInstance = new Worker(new URL("./signatureVerifyWorker.js", import.meta.url), {
type: "module",
});
attachWorkerListeners(workerInstance);
} catch (error) {
devLogger.warn("[signatureVerifyWorkerClient] Failed to create worker", error);
workerInstance = null;
}
return workerInstance;
}
// Main-thread fallback used only when Workers are unavailable. Preserves the
// security guarantee (still verifies) at the cost of main-thread time.
async function verifyOnMainThread(events) {
let tools = getCachedNostrTools();
if (!tools || typeof tools.verifyEvent !== "function") {
try {
// Bound the wait: if nostr-tools is slow/unable to load, we must not stall
// the feed waiting for it — fail open below instead of hanging.
tools = await Promise.race([
ensureNostrTools(),
new Promise((resolve) => setTimeout(() => resolve(null), 2000)),
]);
} catch (_) {
tools = null;
}
}
const valid = new Set();
if (!tools || typeof tools.verifyEvent !== "function") {
// The verification infrastructure itself is unavailable (e.g. nostr-tools
// failed to load in this context). Fail OPEN for the read feed: dropping
// every event would blank the feed entirely, which is far worse than briefly
// showing unverified events when the verifier is broken. The pool already
// does not verify, so this does not regress the prior security posture.
devLogger.warn(
"[signatureVerifyWorkerClient] verifier unavailable; passing events through unverified",
);
for (const event of events) {
if (event?.id) valid.add(event.id);
}
return valid;
}
for (const event of events) {
try {
if (!event || !event.id) continue;
if (typeof tools.validateEvent === "function" && !tools.validateEvent(event)) continue;
if (tools.verifyEvent(event) === true) valid.add(event.id);
} catch (_) {
// skip
}
}
return valid;
}
/**
* Verify a batch of events. Resolves with a Set of valid event ids.
* @param {Array<object>} events
* @param {{ timeoutMs?: number }} [options]
* @returns {Promise<Set<string>>}
*/
export function verifyEventsInWorker(events, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
const list = Array.isArray(events) ? events.filter((e) => e && e.id) : [];
if (!list.length) return Promise.resolve(new Set());
const worker = ensureWorker();
if (!worker) {
return verifyOnMainThread(list);
}
const id = ++requestId;
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
pending.delete(id);
// The worker hung (e.g. couldn't load nostr-tools). Stop using it so we
// don't pay this timeout on every subsequent batch, then fall back.
disableWorker();
reject(new Error("verify-worker-timeout"));
}, timeoutMs);
pending.set(id, { resolve, reject, timeoutId });
try {
worker.postMessage({ id, events: list });
} catch (error) {
clearTimeout(timeoutId);
pending.delete(id);
reject(error instanceof Error ? error : new Error("verify-worker-post-failed"));
}
}).catch(async (error) => {
// On worker failure/timeout, fall back to main-thread verification rather
// than dropping events or trusting them blindly.
devLogger.warn("[signatureVerifyWorkerClient] Falling back to main-thread verify:", error?.message || error);
return verifyOnMainThread(list);
});
}
// Test/diagnostic helpers.
export const __signatureVerifyInternals = { verifyOnMainThread };