mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-11 23:00:58 +00:00
f85b9dc3ff
A field named passwordSha256 next to a hex literal reads as a hard-coded password to static analysis.
507 lines
18 KiB
JavaScript
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);
|
|
});
|
|
}
|