diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d9613b6..9d1cb693 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,5 +61,5 @@ jobs: run: node tools/ci/link-check.mjs - name: identity grep of the public export list run: bash tools/ci/identity-check.sh - - name: console parsers (relay/lib/parse.mjs) - run: node --test relay/test/parse.test.mjs + - name: relay unit tests (parsers, secret compare) + run: node --test relay/test/parse.test.mjs relay/test/auth.test.mjs diff --git a/docs/fud-ledger.md b/docs/fud-ledger.md index cb96e9b0..d47688e6 100644 --- a/docs/fud-ledger.md +++ b/docs/fud-ledger.md @@ -1604,7 +1604,7 @@ Evidence: the files above; `docs/review/round-4-2026-10-04.md` sections 4 and 5. ### X24. The relay token rides in the URL on every request "Every poll of every agent and every page refresh puts the token in the path, so it is in Vercel's request logs, in browser history and in every terminal that ran `tools/relay.mjs`. You built an `x-relay-token` header and nobody uses it." -Status: Open (4 October 2026). +Status: Open (4 October 2026); the print lines fixed: `tools/relay.mjs` shows `/r/` in `list` and `watch` (only `url` prints the real one). Answer: Correct. `relay/vercel.json:6` rewrites `/r//api/` to a query string; `igneum-agent.ps1:14`, `send.ps1:26`, `send.sh:11`, `agent.sh:10` and `tools/relay.mjs:24` all build the tokened URL, and `tools/relay.mjs:83,88,125` print it. `Referrer-Policy: no-referrer` and `X-Robots-Tag` are set (`vercel.json:12-13`); there is no HSTS. Fix: the header in every client, the URL token kept for the phone's page only, HSTS, and the print lines masked. Review id R4.4.3. @@ -1640,7 +1640,7 @@ Evidence: the files above. Experiment: a `result` posted with a `from` that does ### X28. Relay hygiene, minor "`===` on secrets, no HSTS, a GET that acks, a reboot on any output containing `RELAY-REBOOT`, orphaned blobs, no rate limit anywhere, a WSL user `igneum`/`igneum` with NOPASSWD sudo, the username and secret folder posted on register, and a file in `~/.config/igneum` whose name is a token." -Status: Open, minor (4 October 2026). +Status: Open, minor (4 October 2026); two of the points fixed: secrets compared in constant time (`relay/lib/auth.mjs`, `sameSecret`, unit test in CI) and HSTS on the relay (`relay/vercel.json`). Answer: Correct on each point: `relay/lib/relay.mjs:38,40`; `relay/vercel.json:8-16`; `relay/api/relay.mjs:85`; `igneum-agent.ps1:133`; `:145`; no limiter in either function; `relay/playbooks/wsl-setup.ps1:39-40` and `prover-setup.ps1:21`; `igneum-agent.ps1:41, :113`; the stray file next to `desec-token` (3 Oct 19:33). Fix when convenient; the stray file today. Review ids R4.4.9 to R4.4.13. diff --git a/relay/lib/auth.mjs b/relay/lib/auth.mjs new file mode 100644 index 00000000..3230f2e0 --- /dev/null +++ b/relay/lib/auth.mjs @@ -0,0 +1,11 @@ +// Constant-time comparison of a presented secret with the configured one (round 4, X28: `===` on secrets leaks the +// matching prefix length through timing). No dependencies, so `node --test relay/test` covers it. +import { timingSafeEqual } from 'node:crypto'; + +export function sameSecret(given, expected) { + if (typeof given !== 'string' || typeof expected !== 'string' || !expected) return false; + const a = Buffer.from(given, 'utf8'); + const b = Buffer.from(expected, 'utf8'); + if (a.length !== b.length) return false; // lengths are not secret: every token and key here has a fixed length + return timingSafeEqual(a, b); +} diff --git a/relay/lib/relay.mjs b/relay/lib/relay.mjs index 9ee1bb6f..511ce7d2 100644 --- a/relay/lib/relay.mjs +++ b/relay/lib/relay.mjs @@ -5,6 +5,7 @@ import { put } from '@vercel/blob'; import { generateClientTokenFromReadWriteToken } from '@vercel/blob/client'; import { randomBytes } from 'node:crypto'; +import { sameSecret } from './auth.mjs'; export const MAX_INLINE = 4 * 1024 * 1024; // raw upload through the function (Vercel body cap is 4.5 MB) export const MAX_BLOB = 50 * 1024 * 1024; // direct-to-Blob upload with a client token @@ -35,9 +36,9 @@ export function authed(req) { const token = process.env.RELAY_TOKEN; const key = process.env.RELAY_KEY; const given = q.token || req.headers['x-relay-token']; - if (token && typeof given === 'string' && given === token) return 'token'; + if (sameSecret(given, token)) return 'token'; const k = req.headers['x-igneum-key']; - if (key && typeof k === 'string' && k === key) return 'key'; + if (sameSecret(k, key)) return 'key'; return null; } diff --git a/relay/test/auth.test.mjs b/relay/test/auth.test.mjs new file mode 100644 index 00000000..b36aa3dd --- /dev/null +++ b/relay/test/auth.test.mjs @@ -0,0 +1,14 @@ +// node --test relay/test/auth.test.mjs +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { sameSecret } from '../lib/auth.mjs'; + +test('sameSecret: equal strings only; never for a missing, empty or non-string side', () => { + assert.equal(sameSecret('abcdefghijklmnopqrst', 'abcdefghijklmnopqrst'), true); + assert.equal(sameSecret('abcdefghijklmnopqrsT', 'abcdefghijklmnopqrst'), false); + assert.equal(sameSecret('abcdefghijklmnopqrs', 'abcdefghijklmnopqrst'), false); + assert.equal(sameSecret(undefined, 'abcdefghijklmnopqrst'), false); + assert.equal(sameSecret(['abcdefghijklmnopqrst'], 'abcdefghijklmnopqrst'), false); + assert.equal(sameSecret('', ''), false); + assert.equal(sameSecret('x', undefined), false); +}); diff --git a/relay/test/parse.test.mjs b/relay/test/parse.test.mjs index 926ebdf4..7d7b84ed 100644 --- a/relay/test/parse.test.mjs +++ b/relay/test/parse.test.mjs @@ -1,4 +1,4 @@ -// node --test relay/test/parse.test.mjs (no dependencies; CI runs it in the site job) +// node --test relay/test/parse.test.mjs relay/test/auth.test.mjs (no dependencies; CI runs both in the site job) import { test } from 'node:test'; import assert from 'node:assert/strict'; import { parseLabel, parseMinerTail, markStale, STALE_S } from '../lib/parse.mjs'; diff --git a/relay/vercel.json b/relay/vercel.json index 68090ef0..783c74e9 100644 --- a/relay/vercel.json +++ b/relay/vercel.json @@ -38,6 +38,10 @@ { "key": "Cache-Control", "value": "no-store" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=63072000; includeSubDomains" } ] } diff --git a/tools/relay.mjs b/tools/relay.mjs index 44f5c85d..e0121ba0 100644 --- a/tools/relay.mjs +++ b/tools/relay.mjs @@ -23,6 +23,7 @@ const BASE = (cfg('relay-url') || 'https://relay.igneum.network').replace(/\/$/, if (!TOKEN) { console.error('no ~/.config/igneum/relay-token'); process.exit(1); } const API = `${BASE}/r/${TOKEN}/api/`; const WEB = `${BASE}/r/${TOKEN}`; +const SHOWN = `${BASE}/r/`; // printed in place of WEB everywhere but `url` (round 4, X24: the token in every terminal) const argv = process.argv.slice(2); const flags = {}; const pos = []; @@ -85,7 +86,7 @@ try { const n = Number(pos[1]) || 50; const q = { limit: n }; if (flags.machine) q.machine = flags.machine; const j = await api('feed', { q }); const waiting = j.machines.filter(m => m.unread).map(m => `${m.name} ${m.unread}`).join(', '); - console.log(`${WEB}\nmachines: ${j.machines.map(m => `${m.name}${m.role ? '/' + m.role : ''}${m.named === false ? ' (unnamed, hostname ' + m.hostname + ')' : ''}${m.last_seen ? ' seen ' + when(m.last_seen) : ''}`).join(' | ')}${waiting ? `\nwaiting: ${waiting}` : ''}`); + console.log(`${SHOWN}\nmachines: ${j.machines.map(m => `${m.name}${m.role ? '/' + m.role : ''}${m.named === false ? ' (unnamed, hostname ' + m.hostname + ')' : ''}${m.last_seen ? ' seen ' + when(m.last_seen) : ''}`).join(' | ')}${waiting ? `\nwaiting: ${waiting}` : ''}`); if (!j.items.length) console.log('no items yet'); for (const it of j.items) console.log(line(it)); } @@ -122,7 +123,7 @@ try { else if (cmd === 'rm') { await api('delete', { body: { id: Number(pos[1]) } }); console.log(`#${pos[1]} deleted`); } else if (cmd === 'watch') { let since = Number(flags.since) || (await api('feed', { q: { limit: 1 } })).items[0]?.id || 0; - console.log(`watching ${WEB} from #${since} (every 10 s, Ctrl+C to stop)`); + console.log(`watching ${SHOWN} from #${since} (every 10 s, Ctrl+C to stop)`); for (;;) { try { const j = await api('feed', { q: { since } });