igneum/relay/lib/guard.mjs

124 lines
8.1 KiB
JavaScript

// 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' };
}