124 lines
8.1 KiB
JavaScript
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' };
|
|
}
|