// The relay's guards (review round 4, ledger X23 to X28; fixed 5 October 2026, night). No dependencies, so // `node --test relay/test/guard.test.mjs` covers every rule here. // // Three secrets, three tiers: // token the console token: the URL path (the phone's page only) or the x-relay-token header. Everything. // key the relay's own key (RELAY_KEY, ~/.config/igneum/relay-key) in x-igneum-key. Reports and reads; // never task, run, name, role, delete, secret. // intake the log-intake key (LOG_INTAKE_KEY, LOG_INTAKE_KEY_NEXT) in x-igneum-key: the key inside every shipped // package. Only `upload` and a `drop` of kind file or text (the PC apps' build-job outputs). Nothing else, // no reads. Closed by RELAY_INTAKE_COMPAT=0 once the apps carry a relay key of their own. // // A `run` task (a script the agent executes, often as administrator) needs more than the token: // sig an Ed25519 signature by the Mac's run key (~/.config/igneum/relay-run-key) over runCanon(); the API // verifies it with RELAY_RUN_PUB and refuses the task with 401 when it is missing or wrong. // mac an HMAC-SHA256 tag with the target machine's own secret over the same canonical text; the agent verifies // it before it executes anything (Windows PowerShell 5.1 has no Ed25519, so the agent's check is the HMAC). // nonce 32 hex, unique per run task; the agent remembers executed nonces. // The canonical text names the machine, the nonce, the body's sha256 and the three flags that change what the agent // does, so none of them can be altered by a holder of the token or the database alone. // // Machines: a per-machine secret (32 hex, made on the Mac, `node tools/relay.mjs secret PC1`) whose sha256 the relay // stores; `register` and every `result` present it in x-machine-secret and `from` must be that machine. import { createHash, createHmac, createPrivateKey, createPublicKey, generateKeyPairSync, randomBytes, sign as edSign, verify as edVerify, timingSafeEqual } from 'node:crypto'; import { sameSecret } from './auth.mjs'; export const FEED_LIMIT_DEFAULT = 50; export const FEED_LIMIT_MAX = 100; // was 500: one secret no longer pages the whole history in five calls export const RETENTION_DAYS = 30; export const RATE_PER_MIN = 120; // authenticated calls per IP per minute (an agent polls 3 a minute) export const AUTH_FAIL_PER_MIN = 10; // failed authentications per IP per minute export const REBOOT_MARKER = 'RELAY-REBOOT'; const HEX = n => new RegExp(`^[0-9a-f]{${n}}$`); export const isNonce = s => typeof s === 'string' && HEX(32).test(s); export const isSig = s => typeof s === 'string' && HEX(128).test(s); export const isMac = s => typeof s === 'string' && HEX(64).test(s); export const isSecret = s => typeof s === 'string' && HEX(64).test(s); export const isPub = s => typeof s === 'string' && HEX(64).test(s); /** Which tier a request authenticates as, from its query (the rewrite's ?token=) and headers; null when none. */ export function authVia({ query = {}, headers = {} }, env = process.env) { const given = query.token || headers['x-relay-token']; if (sameSecret(given, env.RELAY_TOKEN)) return 'token'; const k = headers['x-igneum-key']; if (sameSecret(k, env.RELAY_KEY)) return 'key'; if (env.RELAY_INTAKE_COMPAT !== '0') { for (const name of ['LOG_INTAKE_KEY', 'LOG_INTAKE_KEY_NEXT']) if (env[name] && sameSecret(k, env[name])) return 'intake'; } return null; } /** What each tier may call. POST fn names; GET reads are allowed for token and key only. */ export const POST_ALLOWED = { token: new Set(['drop', 'task', 'upload', 'ack', 'done', 'delete', 'register', 'name', 'role', 'secret', 'inbox']), key: new Set(['drop', 'upload', 'ack', 'done', 'register', 'inbox']), intake: new Set(['drop', 'upload']), }; export const DROP_KINDS = { token: null, key: new Set(['text', 'file', 'result']), intake: new Set(['text', 'file']) }; // null: any kind export const mayRead = via => via === 'token' || via === 'key'; export const sha256hex = s => createHash('sha256').update(Buffer.isBuffer(s) ? s : Buffer.from(String(s), 'utf8')).digest('hex'); export const secretHash = secret => sha256hex(secret); const flag = v => (v === true || v === 1 || v === '1' || v === 'true' ? '1' : '0'); /** The canonical text a run task is signed over. Deterministic; the agent builds the same string. */ export function runCanon({ to, nonce, body, flags = {} }) { return ['igneum-relay-run/1', `to=${String(to || '')}`, `nonce=${String(nonce || '')}`, `elevated=${flag(flags.elevated)}`, `reboot_continue=${flag(flags.reboot_continue)}`, `reboot=${flag(flags.reboot)}`, `body_sha256=${sha256hex(String(body || ''))}`, ''].join('\n'); } // Ed25519 raw keys as hex (32-byte seed, 32-byte public), the OTA key's shape, through PKCS8 and SPKI DER prefixes. const PKCS8 = Buffer.from('302e020100300506032b657004220420', 'hex'); const SPKI = Buffer.from('302a300506032b6570032100', 'hex'); export const privFromSeed = seedHex => createPrivateKey({ key: Buffer.concat([PKCS8, Buffer.from(seedHex, 'hex')]), format: 'der', type: 'pkcs8' }); export const pubFromHex = pubHex => createPublicKey({ key: Buffer.concat([SPKI, Buffer.from(pubHex, 'hex')]), format: 'der', type: 'spki' }); export function keygen() { const { privateKey, publicKey } = generateKeyPairSync('ed25519'); const seed = privateKey.export({ format: 'der', type: 'pkcs8' }).subarray(PKCS8.length).toString('hex'); const pub = publicKey.export({ format: 'der', type: 'spki' }).subarray(SPKI.length).toString('hex'); return { seed, pub }; } export const signRun = (canon, seedHex) => edSign(null, Buffer.from(canon, 'utf8'), privFromSeed(seedHex)).toString('hex'); export function verifyRun(canon, sigHex, pubHex) { if (!isSig(sigHex) || !isPub(pubHex)) return false; try { return edVerify(null, Buffer.from(canon, 'utf8'), pubFromHex(pubHex), Buffer.from(sigHex, 'hex')); } catch { return false; } } export const machineTag = (secret, canon) => createHmac('sha256', Buffer.from(String(secret), 'utf8')).update(Buffer.from(canon, 'utf8')).digest('hex'); export function sameTag(a, b) { if (!isMac(a) || !isMac(b)) return false; return timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex')); } export const newNonce = () => randomBytes(16).toString('hex'); export const newSecret = () => randomBytes(32).toString('hex'); /** * Checks everything a run task must carry before the API stores it. Returns null when good, else the refusal text. * pubHex: RELAY_RUN_PUB; without it every run is refused (the safe failure at a deploy that forgot the key). */ export function checkRun(o, pubHex) { const f = o.flags && typeof o.flags === 'object' ? o.flags : {}; if (!o.to || o.to === 'all') return 'a run task needs one named machine'; if (!isPub(pubHex)) return 'run tasks are refused: RELAY_RUN_PUB is not set on the relay'; if (!isNonce(f.nonce)) return 'a run task needs flags.nonce (32 hex)'; if (!isMac(f.mac)) return 'a run task needs flags.mac, the HMAC tag with the machine secret'; if (!isSig(f.sig)) return 'a run task needs flags.sig, the Ed25519 signature by the relay run key'; if (!verifyRun(runCanon({ to: o.to, nonce: f.nonce, body: o.body, flags: f }), f.sig, pubHex)) return 'the run signature does not verify against RELAY_RUN_PUB'; return null; } /** The reboot request: the marker on a line of its own, never inside other output (X28). */ export const wantsReboot = text => /(^|\r?\n)RELAY-REBOOT\r?(\n|$)/.test(String(text || '')); export const feedLimit = q => Math.min(FEED_LIMIT_MAX, Math.max(1, Number(q && q.limit) || FEED_LIMIT_DEFAULT)); /** The oldest timestamp the relay keeps, as an ISO string, for `ts < $1`. */ export const retentionCutoff = (now = Date.now()) => new Date(now - RETENTION_DAYS * 86400_000).toISOString(); /** The machine a presented secret names: {name} from the rows, or an error text. rows: [{name, secret_hash}]. */ export function machineForSecret(secret, rows) { if (!isSecret(secret)) return { error: 'x-machine-secret must be 64 hex' }; const h = secretHash(secret); const hit = rows.find(r => r.secret_hash && sameSecret(h, r.secret_hash)); return hit ? { name: hit.name } : { error: 'unknown machine secret' }; }