Relay: secrets compared in constant time (relay/lib/auth.mjs, unit test in CI), HSTS header, tools/relay.mjs prints /r/<token> in list and watch (round 4, X28 and X24 part)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
b15a86856a
commit
df5f144642
8 changed files with 40 additions and 9 deletions
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/<token>` in `list` and `watch` (only `url` prints the real one).
|
||||
|
||||
Answer: Correct. `relay/vercel.json:6` rewrites `/r/<token>/api/<fn>` 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.
|
||||
|
||||
|
|
|
|||
11
relay/lib/auth.mjs
Normal file
11
relay/lib/auth.mjs
Normal file
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
14
relay/test/auth.test.mjs
Normal file
14
relay/test/auth.test.mjs
Normal file
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -38,6 +38,10 @@
|
|||
{
|
||||
"key": "Cache-Control",
|
||||
"value": "no-store"
|
||||
},
|
||||
{
|
||||
"key": "Strict-Transport-Security",
|
||||
"value": "max-age=63072000; includeSubDomains"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/<token>`; // 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 } });
|
||||
|
|
|
|||
Loading…
Reference in a new issue