Citrine audit: verify Nostr handshake on probe + stop probe churn (1.3.3)

Self-audit of the 1.3.0–1.3.2 Citrine feature found two real issues:

1. False-positive detection (the important one). probeCitrine resolved true on
   bare socket OPEN, so ANY process squatting on 127.0.0.1:4869 would be treated
   as Citrine — and we'd then route reads AND published events at a non-Nostr
   peer. The probe now completes a minimal Nostr handshake: open → send a bounded
   REQ with a random sub id → require a protocol-shaped reply (EVENT/EOSE/CLOSED
   echoing the sub id, or a NOTICE) before declaring the relay live. Non-JSON /
   non-Nostr responses fall through to a clean timeout=false. A real relay
   answers with EOSE in ~ms on localhost, so the happy path is unchanged.

2. Probe churn. The reachability query re-opened a fresh ws:// probe every 5 min
   and on every window focus for the whole session — needless battery/wakelock,
   and a localhost poke on every Amber round-trip. Now it only polls (30s) and
   refetches-on-focus WHILE still waiting for Citrine to appear; once connected
   it stops the background churn (a per-session local relay won't vanish
   silently, and a failed read re-probes naturally).

Reviewed and left as-is (deliberate, safe): writing every kind to Citrine incl.
gift-wrapped DMs / Stash is intended on-device encrypted backup — all sensitive
kinds are NIP-44 encrypted before reaching the publish layer, so nothing
plaintext-sensitive is mirrored. Preview flag remains render-only.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-08 19:21:32 +07:00
parent 335fb40f72
commit 913786c7c9
3 changed files with 63 additions and 21 deletions

View file

@ -1,7 +1,7 @@
{ {
"name": "skate-spots", "name": "skate-spots",
"private": true, "private": true,
"version": "1.3.2", "version": "1.3.3",
"type": "module", "type": "module",
"homepage": "https://skate-spots.com", "homepage": "https://skate-spots.com",
"scripts": { "scripts": {

View file

@ -36,12 +36,16 @@ export function useCitrine() {
// opted in — no point connecting to localhost otherwise. // opted in — no point connecting to localhost otherwise.
enabled: possible && enabled, enabled: possible && enabled,
queryFn: () => probeCitrine(CITRINE_RELAY_URL), queryFn: () => probeCitrine(CITRINE_RELAY_URL),
// Reachability changes rarely within a session (app installed/running or
// not); keep it warm but re-verify periodically so a freshly-launched
// Citrine is picked up without a manual refresh.
staleTime: 60_000, staleTime: 60_000,
refetchInterval: 5 * 60_000, // Poll only while we're still WAITING for Citrine (not yet reachable), so a
refetchOnWindowFocus: true, // freshly-started relay is picked up automatically. Once connected, stop the
// background churn — a per-session localhost relay doesn't vanish silently,
// and any failed read will naturally re-probe. This avoids opening a fresh
// ws:// probe every few minutes for the whole session.
refetchInterval: (query) => (query.state.data ? false : 30_000),
// Re-verify on focus only until connected (e.g. user returns after starting
// Citrine). No need to re-poke localhost on every Amber round-trip once up.
refetchOnWindowFocus: (query) => !query.state.data,
retry: false, retry: false,
}); });

View file

@ -88,25 +88,34 @@ export function isCitrinePossible(): boolean {
} }
/** /**
* Probe whether a Citrine relay is actually running and reachable at `url` by * Probe whether a Citrine relay is actually running AND speaking Nostr at `url`.
* opening a short-lived WebSocket. Resolves true only on a clean open; resolves
* false on error or timeout. Never rejects, never throws — safe to call from a
* query or effect.
* *
* We only probe on Android (isCitrinePossible) to avoid pointless localhost * A bare "socket opened" check is not enough: any process squatting on the port
* connection attempts (and console noise) in the browser/desktop. * would open cleanly and we'd then wrongly route reads — and, worse, published
* events — at a non-Nostr peer. So we complete a minimal Nostr handshake:
* open → send a tiny bounded REQ → require a protocol-shaped reply
* (EVENT / EOSE / NOTICE / CLOSED) referencing our subscription id.
*
* Resolves true only on a verified Nostr reply; false on error, timeout, or a
* non-Nostr response. Never rejects, never throws — safe from a query/effect.
* Short-circuits to false off-Android (and outside preview) to avoid pointless
* localhost attempts and console noise in the browser/desktop.
*/ */
export function probeCitrine( export function probeCitrine(
url: string = CITRINE_RELAY_URL, url: string = CITRINE_RELAY_URL,
timeoutMs = 2500, timeoutMs = 3000,
): Promise<boolean> { ): Promise<boolean> {
if (!isCitrinePossible()) return Promise.resolve(false); if (!isCitrinePossible()) return Promise.resolve(false);
return new Promise<boolean>((resolve) => { return new Promise<boolean>((resolve) => {
let settled = false; let settled = false;
const done = (result: boolean, ws?: WebSocket) => { let ws: WebSocket | undefined;
let timer: ReturnType<typeof setTimeout> | undefined;
const done = (result: boolean) => {
if (settled) return; if (settled) return;
settled = true; settled = true;
if (timer) clearTimeout(timer);
try { try {
ws?.close(); ws?.close();
} catch { } catch {
@ -115,7 +124,6 @@ export function probeCitrine(
resolve(result); resolve(result);
}; };
let ws: WebSocket;
try { try {
ws = new WebSocket(url); ws = new WebSocket(url);
} catch { } catch {
@ -123,15 +131,45 @@ export function probeCitrine(
return; return;
} }
const timer = setTimeout(() => done(false, ws), timeoutMs); // Unique, throwaway subscription id we can match in the reply.
const subId = `citrine-probe-${Math.random().toString(36).slice(2, 10)}`;
timer = setTimeout(() => done(false), timeoutMs);
ws.onopen = () => { ws.onopen = () => {
clearTimeout(timer); try {
done(true, ws); // Ask for a single event of any kind — bounded and cheap. We don't care
// about the payload, only that the peer answers in the Nostr protocol.
ws!.send(JSON.stringify(['REQ', subId, { limit: 1 }]));
} catch {
done(false);
}
}; };
ws.onerror = () => {
clearTimeout(timer); ws.onmessage = (ev) => {
done(false, ws); try {
const data = JSON.parse(typeof ev.data === 'string' ? ev.data : '');
// A Nostr relay replies with a JSON array whose first element is a known
// verb. EVENT/EOSE/CLOSED echo our subId; NOTICE is a generic message.
if (Array.isArray(data) && typeof data[0] === 'string') {
const verb = data[0];
const matchesSub = data[1] === subId;
if (
((verb === 'EVENT' || verb === 'EOSE' || verb === 'CLOSED') && matchesSub) ||
verb === 'NOTICE'
) {
done(true);
return;
}
}
// Any other shape: not a Nostr relay we recognise — keep waiting until
// timeout (a valid relay will still send EOSE).
} catch {
// Non-JSON frame → not a Nostr relay; wait for timeout.
}
}; };
ws.onerror = () => done(false);
ws.onclose = () => done(false);
}); });
} }