Files
TREK/scripts/ci/check-registration-dialect.mjs
T
Maurice f85b9dc3ff fix(service-identity): call the dialect self-check's scrypt input inputSha256
A field named passwordSha256 next to a hex literal reads as a hard-coded
password to static analysis.
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.inputSha256) 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);
});
}