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:
parent
335fb40f72
commit
913786c7c9
3 changed files with 63 additions and 21 deletions
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue