Files
TREK/scripts/ci/check-registration-dialect.mjs
T
Maurice 12e22c258a ci(release): stop on a stale dialect, check it like CI would, give prereleases their own
A stable release without a dialect of its own now ships the one on main only while that one has 90 days left; dialect 0 or less stops the run before anything is committed, unless allow_stale_dialect is ticked. Fetching and checking moved into registration-dialect.yml, which never fails its caller and also type-checks the module under the strict config and lints it with --max-warnings 0, since the bump commit skips CI. The bump and everything after it run whatever became of the dialect job. Prereleases ask the issuer for a dialect of their -pre.N version and build it into the image without committing it. The release check reads the module token by token against an allowlist of keywords, globals and member names, refuses any assignment to a property, computed access and regular expressions, and names Object, JSON and defineProperty among the forbidden. The issuer's kitchen-sink sample is gone; the tests use a hand-written dialect instead.
2026-10-29 20:04:40 +01:00

507 lines
18 KiB
JavaScript

#!/usr/bin/env node
// Checks a registration dialect the issuer handed out, before a release ships it.
//
// docker.yml (through registration-dialect.yml) asks the TREK services issuer
// for the dialect of the version it releases and commits the answer as
// server/src/nest/service-identity/registration-dialect.ts, without a person
// reading it first. That file then runs inside every TREK server, so it is
// held to what a dialect is: a few hashes over the inputs it is given, and
// nothing that reaches past them.
//
// 1. Statically, before anything of it runs. The size, the header comment,
// no backslash (so no escape can spell a name), imports from node:crypto,
// node:zlib and the types module only, and the version and id it must
// carry. Then the code is read token by token against what it may hold,
// not against what it may not: the few keywords the generator writes,
// names it declares itself or imports, a handful of globals (Buffer,
// Math, String, BigInt, Array, parseInt), and after a dot only the Buffer,
// array, string, hash and Math members the generator uses. It may assign
// to its own names only, never to a property (no Math.random = ...,
// Buffer.from = ..., no defineProperty at all), read no property by a
// computed name and hold no regular expression. A list of names that reach
// the process, the network, timers or code from strings is checked on top.
// 2. Then it is imported (Node strips the types) and run on the fixed inputs
// in its SELF_CHECK: the password, the salt, scrypt over them and the
// request must be what the issuer computed, and the request must pass
// the rules the issuer client applies (issuer.client.ts holds the same).
//
// A new construct in the issuer's generator needs its name added here first,
// in a release before the first dialect that uses it.
//
// Usage:
// node scripts/ci/check-registration-dialect.mjs <file.ts> <version>
//
// Prints what it found and exits 0 only when the dialect may ship. The release
// job runs it without any secret in its environment.
import { createHash, scryptSync } from 'node:crypto';
import { copyFileSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const MAX_BYTES = 64 * 1024;
const MAX_LINES = 1000;
const TYPES_MODULE = './registration-dialect.types';
const RUNTIME_MODULES = new Set(['node:crypto', 'node:zlib']);
const IMPORT_LINE = /^import (type )?\{[\w\s,]+\} from '([^']+)';$/;
// Checked on every line, on top of the allowlist below: each of these reaches
// past the hashes, into the process and its environment, modules, the
// network, timers, the globals' definitions, or a way to run a string as code.
const FORBIDDEN = [
/\bprocess\b/,
/\brequire\b/,
/\bimport\s*\(/,
/\bimport\s*\.\s*meta\b/,
/\beval\b/,
/\bFunction\b/,
/\bglobalThis\b/,
/\bglobal\b/,
/\bmodule\s*\./,
/\bexports\s*\./,
/\bfetch\b/,
/\bXMLHttpRequest\b/,
/\bWebSocket\b/,
/\bEventSource\b/,
/\bWorker\b/,
/\bWebAssembly\b/,
/\bchild_process\b/,
/node:(?!crypto'|zlib')/,
/\bset(Timeout|Interval|Immediate)\b/,
/\bqueueMicrotask\b/,
/\bconstructor\b/,
/\bprototype\b/,
/\b__proto__\b/,
/\bReflect\b/,
/\bProxy\b/,
/\bObject\b/,
/\bJSON\b/,
/\bSymbol\b/,
/\bdefineProperty\b/,
/\bsetPrototypeOf\b/,
/\bexport\s+default\b/,
];
// The keywords a dialect is written with.
const KEYWORDS = new Set([
'const',
'let',
'function',
'return',
'for',
'do',
'while',
'export',
'import',
'type',
'from',
]);
// Globals and type names it may name without declaring them.
const GLOBALS = new Set(['Buffer', 'Math', 'String', 'BigInt', 'Array', 'parseInt', 'number', 'string']);
// What it may read after a dot: the challenge's c, and the members of Buffer,
// arrays, strings, hashes and Math the generator writes.
const PROPERTIES = new Set([
'c',
'length',
'from',
'alloc',
'concat',
'subarray',
'slice',
'copy',
'reverse',
'map',
'forEach',
'push',
'join',
'padStart',
'padEnd',
'charAt',
'toString',
'toUpperCase',
'update',
'digest',
'readUInt8',
'writeUInt8',
'readUInt32BE',
'readUInt32LE',
'writeUInt16BE',
'writeUInt16LE',
'writeUInt32BE',
'writeUInt32LE',
'writeBigUInt64BE',
'writeBigUInt64LE',
'floor',
'sign',
'max',
'min',
]);
// Every punctuator the generator writes, longest first. `?`, `?.`, `??`, `#`
// and `@` are not among them.
const PUNCTUATORS = [
'>>>=',
'...',
'===',
'!==',
'**=',
'<<=',
'>>=',
'>>>',
'=>',
'==',
'!=',
'<=',
'>=',
'&&',
'||',
'++',
'--',
'+=',
'-=',
'*=',
'/=',
'%=',
'&=',
'|=',
'^=',
'**',
'<<',
'>>',
'{',
'}',
'(',
')',
'[',
']',
';',
',',
'.',
'<',
'>',
'+',
'-',
'*',
'/',
'%',
'&',
'|',
'^',
'!',
'~',
'=',
':',
];
const ASSIGNMENTS = new Set(['=', '+=', '-=', '*=', '/=', '%=', '**=', '<<=', '>>=', '>>>=', '&=', '|=', '^=']);
// After one of these (or at the start) a `/` would open a regular expression.
const BEFORE_EXPRESSION = new Set([...PUNCTUATORS.filter((p) => p !== ')' && p !== ']'), 'return']);
const CHALLENGE_PATH = /^\/v1\/register(\/[A-Za-z0-9_-]{12})?\/challenge$/;
const REGISTER_PATH = /^\/v1\/register(\/[A-Za-z0-9_-]{12})?$/;
const DIALECT_HEADER = /^X-TREK-[A-Za-z0-9-]{1,40}$/;
const RESERVED_HEADER = /^X-TREK-(Install|Tier|Legacy|Quota|Instance|Notice)(-|$)/i;
const HEADER_VALUE = /^[\x20-\x7e]{1,256}$/;
const MAX_REGISTRATION_BYTES = 2048;
const LARGEST_NONCE = 2 ** 40;
const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// The source as tokens: { kind: 'name' | 'number' | 'string' | 'punct', value, line }.
// Comments go, a template literal becomes a string token around the tokens of
// each ${...} in it. Throws on anything else, a regular expression included.
export function tokenize(source) {
const tokens = [];
const braces = [];
let i = 0;
let line = 1;
const push = (kind, value) => tokens.push({ kind, value, line });
const fail = (what) => {
throw new Error(`line ${line} holds ${what}`);
};
const template = () => {
// From just after a ` or a }, up to the closing ` or the next ${.
while (i < source.length && source[i] !== '`' && !source.startsWith('${', i)) {
if (source[i] === '\n') line += 1;
i += 1;
}
if (i >= source.length) fail('a template literal that does not end');
push('string', '`');
if (source[i] === '`') {
i += 1;
} else {
i += 2;
braces.push('template');
}
};
while (i < source.length) {
const ch = source[i];
if (ch === '\n') {
line += 1;
i += 1;
} else if (ch === ' ') {
i += 1;
} else if (source.startsWith('//', i)) {
while (i < source.length && source[i] !== '\n') i += 1;
} else if (source.startsWith('/*', i)) {
const end = source.indexOf('*/', i + 2);
if (end < 0) fail('a comment that does not end');
line += source.slice(i, end).split('\n').length - 1;
i = end + 2;
} else if (ch === "'" || ch === '"') {
const end = source.indexOf(ch, i + 1);
if (end < 0 || source.slice(i, end).includes('\n')) fail('a string that does not end');
push('string', source.slice(i + 1, end));
i = end + 1;
} else if (ch === '`') {
i += 1;
template();
} else if (/[A-Za-z_$]/.test(ch)) {
const name = /^[A-Za-z_$][\w$]*/.exec(source.slice(i))[0];
push('name', name);
i += name.length;
} else if (/[0-9]/.test(ch)) {
const number = /^(0x[0-9a-fA-F]+|[0-9]+)/.exec(source.slice(i))[0];
push('number', number);
i += number.length;
} else if (ch === '}' && braces.at(-1) === 'template') {
braces.pop();
i += 1;
template();
} else {
const punct = PUNCTUATORS.find((p) => source.startsWith(p, i));
if (!punct) fail(`the character ${JSON.stringify(ch)}`);
if (punct === '/') {
const before = tokens.at(-1);
if (!before || BEFORE_EXPRESSION.has(before.value)) fail('a regular expression');
}
if (punct === '{') braces.push('brace');
if (punct === '}') braces.pop();
push('punct', punct);
i += punct.length;
}
}
return tokens;
}
// The index of the ) that closes the ( at `open`.
function closing(tokens, open) {
let depth = 0;
for (let i = open; i < tokens.length; i += 1) {
if (tokens[i].value === '(') depth += 1;
if (tokens[i].value === ')') depth -= 1;
if (depth === 0) return i;
}
return tokens.length;
}
// The names the module declares: consts, lets, functions, imports and the
// parameters of its functions, arrows and methods.
function declaredNames(tokens) {
const names = new Set();
tokens.forEach((token, i) => {
const before = tokens[i - 1];
if (token.kind !== 'name') return;
if (before && ['const', 'let', 'function'].includes(before.value)) names.add(token.value);
});
let inImport = false;
for (const token of tokens) {
if (token.value === 'import') inImport = true;
else if (token.value === 'from') inImport = false;
else if (inImport && token.kind === 'name' && token.value !== 'type') names.add(token.value);
}
tokens.forEach((token, open) => {
if (token.value !== '(') return;
const end = closing(tokens, open);
if (!['=>', '{', ':'].includes(tokens[end + 1]?.value)) return;
for (let i = open + 1; i < end; i += 1) {
const isParameter =
tokens[i].kind === 'name' &&
['(', ',', '{'].includes(tokens[i - 1].value) &&
[',', ')', ':', '}', '='].includes(tokens[i + 1].value);
if (isParameter) names.add(tokens[i].value);
}
});
return names;
}
// What the tokens hold that a dialect may not; empty when nothing.
export function tokenProblems(tokens) {
const problems = [];
const declared = declaredNames(tokens);
const isLocal = (i) => tokens[i]?.kind === 'name' && tokens[i - 1]?.value !== '.';
tokens.forEach((token, i) => {
const before = tokens[i - 1];
const after = tokens[i + 1];
const at = `line ${token.line}`;
if (token.kind === 'name') {
if (before?.value === '.') {
if (!PROPERTIES.has(token.value)) problems.push(`${at} reads .${token.value}, which a dialect does not`);
return;
}
// A key of an object literal or a parameter with its type.
if (after?.value === ':') return;
// A method of an object literal: name(params) { ... }.
if (
after?.value === '(' &&
['{', ','].includes(before?.value) &&
tokens[closing(tokens, i + 1) + 1]?.value === '{'
) {
return;
}
if (!KEYWORDS.has(token.value) && !GLOBALS.has(token.value) && !declared.has(token.value)) {
problems.push(`${at} names ${token.value}, which it neither declares nor may use`);
}
return;
}
if (token.kind !== 'punct') return;
if (ASSIGNMENTS.has(token.value)) {
// const out: number[] = [] has a type before the =, not a property.
const typed = before?.value === ']' && tokens[i - 2]?.value === '[';
if (!typed && !isLocal(i - 1)) problems.push(`${at} assigns to something other than a name of its own`);
} else if (token.value === '++' || token.value === '--') {
const postfix = before && (before.kind === 'name' || [')', ']'].includes(before.value));
const target = postfix ? isLocal(i - 1) : isLocal(i + 1) && tokens[i + 2]?.value !== '.';
if (!target) problems.push(`${at} counts something other than a name of its own`);
} else if (token.value === '[') {
const reads = before && (before.kind !== 'punct' || [')', ']'].includes(before.value));
if (reads && after?.value !== ']') problems.push(`${at} reads a property by a computed name`);
}
});
return [...new Set(problems)];
}
// What is wrong with the source before it runs; empty when nothing is.
export function staticProblems(source, version) {
const problems = [];
if (Buffer.byteLength(source, 'utf8') > MAX_BYTES) problems.push(`it is larger than ${MAX_BYTES} bytes`);
const lines = source.split('\n');
if (lines.length > MAX_LINES) problems.push(`it is longer than ${MAX_LINES} lines`);
if (!source.startsWith('/**\n')) problems.push('it does not start with its header comment');
if (source.includes('\\')) problems.push('it holds a backslash');
if (source.includes('\r')) problems.push('it holds a carriage return');
if (source.includes('\t')) problems.push('it holds a tab');
for (const [index, line] of lines.entries()) {
if (!/^\s*(import|export\s+\*|export\s*\{)/.test(line) && !/\bfrom\s+'/.test(line)) continue;
const match = IMPORT_LINE.exec(line);
const allowed = match && (match[1] ? match[2] === TYPES_MODULE : RUNTIME_MODULES.has(match[2]));
if (!allowed) problems.push(`line ${index + 1} imports or re-exports what a dialect does not: ${line.trim()}`);
}
for (const pattern of FORBIDDEN) {
const line = lines.findIndex((text) => pattern.test(text));
if (line >= 0) problems.push(`line ${line + 1} names what a dialect does not need (${pattern.source})`);
}
try {
problems.push(...tokenProblems(tokenize(source)));
} catch (err) {
problems.push(err instanceof Error ? err.message : String(err));
}
if (!new RegExp(`^ releasedFor: '${escapeRegExp(version)}',$`, 'm').test(source)) {
problems.push(`it is not written for ${version}`);
}
if (!/^const DIALECT = '[A-Za-z0-9_-]{12}';$/m.test(source)) problems.push('it does not name a dialect id');
if (!/^export const registrationDialect: RegistrationDialect = \{$/m.test(source)) {
problems.push('it does not export registrationDialect');
}
if (!/^export const SELF_CHECK: DialectSelfCheck = \{$/m.test(source)) problems.push('it does not export SELF_CHECK');
return problems;
}
const sha256 = (data) => createHash('sha256').update(data).digest('hex');
function requestProblems(path, request) {
const problems = [];
if (!REGISTER_PATH.test(path)) problems.push(`it registers at ${path}`);
for (const [name, value] of Object.entries(request.headers ?? {})) {
if (
!DIALECT_HEADER.test(name) ||
RESERVED_HEADER.test(name) ||
typeof value !== 'string' ||
!HEADER_VALUE.test(value)
) {
problems.push(`it sends the header ${name}, which TREK does not send`);
}
}
const body = JSON.stringify(request.body);
if (typeof body !== 'string' || Buffer.byteLength(body, 'utf8') > MAX_REGISTRATION_BYTES) {
problems.push('its body is no registration');
}
return problems;
}
// What is wrong with the imported module; empty when nothing is.
export function runtimeProblems(mod, version) {
const dialect = mod.registrationDialect;
const check = mod.SELF_CHECK;
if (!dialect || !check) return ['it exports no registrationDialect or no SELF_CHECK'];
const problems = [];
if (dialect.releasedFor !== version) problems.push(`it is released for ${dialect.releasedFor}, not ${version}`);
if (!/^[A-Za-z0-9_-]{12}$/.test(dialect.id)) problems.push(`its id ${dialect.id} is not an issuer's`);
if (!/^\d{4}-\d{2}-\d{2}$/.test(dialect.createdAt) || Number.isNaN(Date.parse(dialect.createdAt))) {
problems.push(`its date ${dialect.createdAt} is no date`);
}
if (!CHALLENGE_PATH.test(dialect.challengePath) || dialect.challengePath !== `/v1/register/${dialect.id}/challenge`) {
problems.push(`it asks for a challenge at ${dialect.challengePath}`);
}
if (dialect.registerPath !== `/v1/register/${dialect.id}`) problems.push(`it registers at ${dialect.registerPath}`);
const inputs = {
challenge: check.challenge,
version: check.version,
random: Buffer.from(check.random, 'hex'),
nowSeconds: check.nowSeconds,
};
const attempt = dialect.begin(inputs);
const { password, salt } = attempt.work(check.nonce);
if (sha256(password) !== check.passwordSha256) problems.push('its password is not the one the issuer computed');
if (sha256(salt) !== check.saltSha256) problems.push('its salt is not the one the issuer computed');
const { N, r, p } = check.challenge;
if (scryptSync(password, salt, 32, { N, r, p }).toString('hex') !== check.scrypt) {
problems.push('its proof of work is not the one the issuer computed');
}
const request = attempt.request(check.nonce);
if (sha256(JSON.stringify({ body: request.body, headers: request.headers })) !== check.requestSha256) {
problems.push('its request is not the one the issuer computed');
}
problems.push(...requestProblems(dialect.registerPath, request));
problems.push(...requestProblems(dialect.registerPath, attempt.request(LARGEST_NONCE)));
return [...new Set(problems)];
}
// Runs a checked source: Node strips the types of an .mts file and imports it.
async function importDialect(file) {
const dir = mkdtempSync(join(tmpdir(), 'registration-dialect-'));
try {
const copy = join(dir, 'registration-dialect.mts');
copyFileSync(file, copy);
return await import(pathToFileURL(copy).href);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
export async function checkDialect(file, version) {
const source = readFileSync(file, 'utf8');
const found = staticProblems(source, version);
if (found.length > 0) return found;
try {
return runtimeProblems(await importDialect(file), version);
} catch (err) {
return [`it fails when run: ${err instanceof Error ? err.message : err}`];
}
}
async function main() {
const [file, version] = process.argv.slice(2);
if (!file || !version) throw new Error('usage: check-registration-dialect.mjs <file.ts> <version>');
const problems = await checkDialect(file, version);
for (const problem of problems) console.log(`The dialect for ${version} is refused: ${problem}.`);
if (problems.length > 0) process.exit(1);
console.log(`The dialect for ${version} computes what the issuer computed.`);
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
main().catch((err) => {
console.error(`::error::${err instanceof Error ? err.message : err}`);
process.exit(1);
});
}