diff --git a/.github/workflows/docker-dev.yml b/.github/workflows/docker-dev.yml index 49ea4dbf0..4ebd1d55e 100644 --- a/.github/workflows/docker-dev.yml +++ b/.github/workflows/docker-dev.yml @@ -6,9 +6,12 @@ name: Build & Push Docker Image (Prerelease) # release it refuses a commit whose ci-ok is not green. docker.yml with # bump=auto later finalizes the line once main carries it. # -# A prerelease gets no registration dialect of its own; it carries the one of -# the last stable release, which dev gets by merging main into dev after that -# release. The build warns when that dialect is the original one or old. +# A prerelease asks the TREK services issuer for a registration dialect of its +# own -pre.N version (registration-dialect.yml, as the stable release does) +# and builds it into the image; nothing of it is committed, dev keeps the +# module of the last stable release, which it gets when main is merged back +# into dev. Without one the image carries dev's module, with a warning when +# that is the original one or old. on: workflow_dispatch: @@ -93,31 +96,23 @@ jobs: echo "VERSION=$NEW_VERSION" >> "$GITHUB_OUTPUT" echo "$STABLE to $NEW_VERSION" - # A prerelease registers new installations in the dialect of the last - # stable release, which reaches dev when main is merged into dev after - # the release. The issuer takes a dialect for 180 days, so this warns - # (without stopping the build) while dev still speaks the original - # registration or a dialect older than 150 days. - - name: Check the registration dialect's age - run: | - set -eu - file=server/src/nest/service-identity/registration-dialect.ts - id="$(sed -n "s/^const DIALECT = '\(.*\)';$/\1/p" "$file")" - created="$(sed -n "s/^ createdAt: '\(.*\)',$/\1/p" "$file")" - if [ "$id" = 0 ]; then - message="This build registers in the original dialect, which the issuer accepts only for a transition period. Merge main into dev once a stable release carries a dialect of its own." - else - age=$(( ($(date -u +%s) - $(date -u -d "$created" +%s)) / 86400 )) - [ "$age" -gt 150 ] || { echo "This build registers in dialect $id from $created ($age days old)."; exit 0; } - message="This build registers in dialect $id from $created, $age days old; new installations of it stop registering on $(date -u -d "$created + 180 days" +%Y-%m-%d). Merge main into dev after the next stable release." - fi - echo "::warning::$message" - printf '### Registration dialect\n%s\n' "$message" >> "$GITHUB_STEP_SUMMARY" + # The registration dialect of this prerelease, fetched and checked in a job + # without write access. It never fails the build. + dialect: + needs: version-bump + uses: ./.github/workflows/registration-dialect.yml + with: + version: ${{ needs.version-bump.outputs.version }} + ref: ${{ needs.version-bump.outputs.sha }} + secrets: + TREK_DIALECT_TOKEN: ${{ secrets.TREK_DIALECT_TOKEN }} build: runs-on: ${{ matrix.runner }} timeout-minutes: 120 - needs: version-bump + needs: [version-bump, dialect] + # Whatever became of the dialect job: without its artifact the image carries dev's module. + if: ${{ !cancelled() && needs.version-bump.result == 'success' }} strategy: fail-fast: false matrix: @@ -136,6 +131,48 @@ jobs: with: ref: ${{ needs.version-bump.outputs.sha }} + - name: Take over the prerelease's dialect + id: take + uses: actions/download-artifact@v7 + continue-on-error: true + with: + name: registration-dialect + path: ${{ runner.temp }}/dialect + + # Into the build context only; the commit stays as it is. Without a + # dialect of its own the image speaks dev's module, which the issuer + # takes for new installations for 180 days after it was made (longer + # while it is the newest stable release's). That is warned about, never + # stopped on, and a date that cannot be read only warns as well. + - name: Place the dialect + env: + VERSION: ${{ needs.version-bump.outputs.version }} + TAKEN: ${{ steps.take.outcome }} + REPORT: ${{ matrix.platform == 'linux/amd64' }} + run: | + set -u + target=server/src/nest/service-identity/registration-dialect.ts + fetched="$RUNNER_TEMP/dialect/registration-dialect.ts" + if [ "$TAKEN" = success ] && [ -f "$fetched" ]; then + cp "$fetched" "$target" + echo "$VERSION registers in dialect $(sed -n "s/^const DIALECT = '\(.*\)';$/\1/p" "$target") of its own." + exit 0 + fi + [ "$REPORT" = true ] || exit 0 + id="$(sed -n "s/^const DIALECT = '\(.*\)';$/\1/p" "$target")" + created="$(sed -n "s/^ createdAt: '\(.*\)',$/\1/p" "$target")" + if [ "$id" = 0 ]; then + message="$VERSION got no dialect of its own and registers in the original one, which the issuer accepts only for a transition period." + elif made="$(date -u -d "$created" +%s 2>/dev/null)"; then + age=$(( ($(date -u +%s) - made) / 86400 )) + message="$VERSION got no dialect of its own and registers in dev's dialect $id from $created, $age days old." + [ "$age" -gt 150 ] || { echo "$message"; exit 0; } + else + message="$VERSION got no dialect of its own, and the date of dev's dialect cannot be read from $target." + fi + echo "::warning::$message" + printf '### Registration dialect\n%s\n' "$message" >> "$GITHUB_STEP_SUMMARY" + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 @@ -181,6 +218,7 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 120 needs: [version-bump, build] + if: ${{ !cancelled() && needs.build.result == 'success' }} steps: - uses: actions/checkout@v6 with: diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index af2a69f45..ffde6cc1e 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -4,8 +4,9 @@ name: Build & Push Docker Image # 1. refuses to run unless ci-ok passed on the main commit it releases, # 2. works out the version from the tags and the bump input, # 3. asks the TREK services issuer for the registration dialect of that -# version and checks it, in a job without write access (a release without -# one ships the dialect already on main, with a warning), +# version and checks it, in a job without write access +# (registration-dialect.yml); without one the release ships the dialect +# already on main, but only while that one still has 90 days to run, # 4. commits the bump to package.json, the lockfile, Chart.yaml and the # dialect, tags it and pushes, # 5. builds both platforms from exactly that bump commit (never from @@ -19,10 +20,11 @@ name: Build & Push Docker Image # someone picks patch, minor or major. # # Each stable release registers new installations in a dialect of its own, -# which the issuer accepts for 180 days; renewing a key needs none. dev and -# the prereleases built from it carry the dialect of the last stable release, -# so after a release, merge main into dev. docker-dev.yml warns when dev's -# dialect is the original one or getting old. +# which the issuer accepts for 180 days, and for as long as it is the newest +# release's; renewing a key needs none. dev gets the module when main is merged +# back into dev after the release; a prerelease asks the issuer for one of its +# own (docker-dev.yml). The issuer's side, and what to set up before the first +# release with a dialect, is in the operations guide of the TREK services. on: workflow_dispatch: @@ -36,6 +38,10 @@ on: description: "Type MAJOR (all caps) to confirm a major release" type: string default: '' + allow_stale_dialect: + description: 'Ship without a dialect of its own even if the one on main is the original one or has under 90 days left' + type: boolean + default: false permissions: contents: write @@ -155,90 +161,23 @@ jobs: echo "VERSION=$NEW_VERSION" >> "$GITHUB_OUTPUT" echo "$STABLE to $NEW_VERSION ($BUMP)" - # Fetches the registration dialect of this version from the TREK services - # issuer and checks it (scripts/ci/check-registration-dialect.mjs). The - # issuer's answer runs here, in a job that holds no write token and keeps no - # git credentials; only the checked file goes on, as an artifact. The job - # never fails the release: without a dialect, the release ships the one on - # main, which the issuer accepts for 180 days after it was made. + # The registration dialect of this version, fetched and checked in a job + # without write access. It never fails; without an artifact from it the + # bump decides below whether the dialect on main may ship. dialect: needs: version - runs-on: ubuntu-24.04 - timeout-minutes: 10 - permissions: - contents: read - steps: - # Every step either cannot fail or is allowed to, so this job always - # succeeds and never holds up the bump. - - uses: actions/checkout@v6 - continue-on-error: true - with: - ref: ${{ needs.version.outputs.base }} - persist-credentials: false - - - uses: actions/setup-node@v6 - continue-on-error: true - with: - node-version-file: .nvmrc - - # The only step that sees the token. The URL is fixed; the answer is - # bounded in time and size and goes to a file nothing runs yet. - - name: Fetch the dialect from the issuer - id: fetch - env: - TREK_DIALECT_TOKEN: ${{ secrets.TREK_DIALECT_TOKEN }} - VERSION: ${{ needs.version.outputs.version }} - run: | - set -uo pipefail - out="$RUNNER_TEMP/registration-dialect.ts" - if [ -z "${TREK_DIALECT_TOKEN:-}" ]; then - echo "::warning::TREK_DIALECT_TOKEN is not set; $VERSION ships the registration dialect already on main." - exit 0 - fi - status=$(curl --silent --show-error --proto '=https' --tlsv1.2 --max-time 30 --retry 2 --retry-delay 5 \ - --max-filesize 262144 -H "Authorization: Bearer $TREK_DIALECT_TOKEN" -H 'Content-Type: application/json' \ - --data "{\"version\":\"$VERSION\"}" -o "$out" -w '%{http_code}' https://places.liketrek.com/v1/dialects) \ - || status=000 - if [ "$status" != 200 ] && [ "$status" != 201 ]; then - echo "::warning::The issuer handed out no registration dialect for $VERSION (HTTP $status); it ships the one on main." - rm -f "$out" - exit 0 - fi - if [ "$(wc -c < "$out")" -gt 262144 ]; then - echo "::warning::The issuer's registration dialect for $VERSION is larger than a dialect; it ships the one on main." - rm -f "$out" - exit 0 - fi - echo "file=$out" >> "$GITHUB_OUTPUT" - - - name: Check the dialect - id: check - if: steps.fetch.outputs.file != '' - env: - FILE: ${{ steps.fetch.outputs.file }} - VERSION: ${{ needs.version.outputs.version }} - run: | - set -uo pipefail - if ! node scripts/ci/check-registration-dialect.mjs "$FILE" "$VERSION"; then - echo "::warning::The issuer's registration dialect for $VERSION failed its check; $VERSION ships the one on main." - exit 0 - fi - echo "What changes against the dialect on main:" - git diff --no-index -- server/src/nest/service-identity/registration-dialect.ts "$FILE" || true - echo "ok=true" >> "$GITHUB_OUTPUT" - - - name: Hand the dialect to the bump - if: steps.check.outputs.ok == 'true' - uses: actions/upload-artifact@v6 - continue-on-error: true - with: - name: registration-dialect - path: ${{ steps.fetch.outputs.file }} - if-no-files-found: error - retention-days: 1 + uses: ./.github/workflows/registration-dialect.yml + with: + version: ${{ needs.version.outputs.version }} + ref: ${{ needs.version.outputs.base }} + secrets: + TREK_DIALECT_TOKEN: ${{ secrets.TREK_DIALECT_TOKEN }} + # Runs whatever became of the dialect job, a timeout or a lost runner + # included: no artifact means the dialect on main, if it may still ship. version-bump: needs: [version, dialect] + if: ${{ !cancelled() && needs.version.result == 'success' }} runs-on: ubuntu-24.04 timeout-minutes: 120 outputs: @@ -265,31 +204,56 @@ jobs: name: registration-dialect path: ${{ runner.temp }}/dialect + # Without a dialect of its own the release ships the one on main, but + # only while the issuer takes new registrations in it for 90 more days: + # dialect 0 closes on the issuer's transition date, and a dialect with + # little left would shut out new installations of this release soon after + # it is out. Then this stops before anything is committed, unless + # allow_stale_dialect says to ship anyway. The issuer keeps the newest + # release's dialect open past its date, so the date here is the least it + # has, not the most. - name: Place the dialect env: VERSION: ${{ needs.version.outputs.version }} TAKEN: ${{ steps.take.outcome }} + ALLOW_STALE: ${{ inputs.allow_stale_dialect }} run: | - set -eu + set -u target=server/src/nest/service-identity/registration-dialect.ts fetched="$RUNNER_TEMP/dialect/registration-dialect.ts" + dialect_id() { sed -n "s/^const DIALECT = '\(.*\)';$/\1/p" "$1"; } + report() { + echo "$2$1" + printf '### Registration dialect\n%s\n' "$1" >> "$GITHUB_STEP_SUMMARY" + } if [ "$TAKEN" = success ] && [ -f "$fetched" ]; then cp "$fetched" "$target" - message="$VERSION registers in dialect $(sed -n "s/^const DIALECT = '\(.*\)';$/\1/p" "$target"), written by the issuer for it." - echo "$message" - printf '### Registration dialect\n%s\n' "$message" >> "$GITHUB_STEP_SUMMARY" + report "$VERSION registers in dialect $(dialect_id "$target"), written by the issuer for it." "" exit 0 fi - id="$(sed -n "s/^const DIALECT = '\(.*\)';$/\1/p" "$target")" + id="$(dialect_id "$target")" created="$(sed -n "s/^ createdAt: '\(.*\)',$/\1/p" "$target")" - if [ "$id" = 0 ]; then - until="the end of the transition period the issuer allows the original registration" - else - until="$(date -u -d "$created + 180 days" +%Y-%m-%d)" + left="" + if [ -n "$id" ] && [ "$id" != 0 ] && made="$(date -u -d "$created" +%s 2>/dev/null)"; then + left=$(( (made + 180 * 86400 - $(date -u +%s)) / 86400 )) fi - message="$VERSION ships without a dialect of its own and registers in dialect $id from $created, which the issuer accepts for new installations until $until. The issuer's operator can write the dialect by hand; it then ships with the next patch." - echo "::warning::$message" - printf '### Registration dialect\n%s\n' "$message" >> "$GITHUB_STEP_SUMMARY" + if [ -n "$left" ] && [ "$left" -ge 90 ]; then + report "$VERSION ships without a dialect of its own and registers in dialect $id from $created, which the issuer takes for new installations for $left more days at least. Its operator can write one by hand for the next patch." "::warning::" + exit 0 + fi + if [ "$id" = 0 ]; then + what="the original registration, which the issuer accepts only until the end of its transition period" + elif [ -n "$left" ]; then + what="dialect $id from $created, which has $left days left" + else + what="a dialect whose id or date cannot be read from $target" + fi + if [ "$ALLOW_STALE" = true ]; then + report "$VERSION ships without a dialect of its own and registers in $what, as allow_stale_dialect asked." "::warning::" + exit 0 + fi + report "$VERSION got no dialect of its own and would register in $what. Nothing was committed or pushed. Make sure the issuer runs with its dialect seed and release token and that TREK_DIALECT_TOKEN is set, then run the release again, or tick allow_stale_dialect to ship anyway." "::error::" + exit 1 - name: Update the version, commit, tag and push id: commit @@ -321,6 +285,8 @@ jobs: runs-on: ${{ matrix.runner }} timeout-minutes: 120 needs: [version, version-bump] + # version-bump may run after a failed dialect job; that alone must not skip what follows it. + if: ${{ !cancelled() && needs.version-bump.result == 'success' }} strategy: fail-fast: false matrix: @@ -384,6 +350,7 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 120 needs: [version, version-bump, build] + if: ${{ !cancelled() && needs.build.result == 'success' }} steps: - uses: actions/checkout@v6 with: @@ -448,6 +415,7 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 120 needs: version-bump + if: ${{ !cancelled() && needs.version-bump.result == 'success' }} steps: - name: Checkout uses: actions/checkout@v6 diff --git a/.github/workflows/registration-dialect.yml b/.github/workflows/registration-dialect.yml new file mode 100644 index 000000000..0b9f91f94 --- /dev/null +++ b/.github/workflows/registration-dialect.yml @@ -0,0 +1,142 @@ +name: Registration dialect + +# Asks the TREK services issuer for the registration dialect of one version and +# checks it, for docker.yml (a stable release commits it with the version bump) +# and docker-dev.yml (a prerelease only builds it into its image). +# +# The issuer's answer runs here, in a job that holds no write token and keeps +# no git credentials; the token for the issuer is in the environment of the +# one step that fetches. The answer is held to what a dialect is +# (scripts/ci/check-registration-dialect.mjs), then type-checked and linted +# the way TREK's CI would see it once it is on main and dev. Only a module that +# passes all of it goes on, as the artifact `registration-dialect`; without +# one the caller decides what ships. This workflow never fails its caller. + +on: + workflow_call: + inputs: + version: + description: 'The version the dialect is for, X.Y.Z or X.Y.Z-pre.N' + type: string + required: true + ref: + description: 'The commit whose checker, types and lint config the dialect is checked with' + type: string + required: true + secrets: + TREK_DIALECT_TOKEN: + required: false + +permissions: + contents: read + +jobs: + dialect: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + # A failed or timed-out run leaves no artifact, which the caller already + # handles; it must not fail the release around it. + continue-on-error: true + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref }} + persist-credentials: false + + - uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: npm + cache-dependency-path: package-lock.json + + # The only step that sees the token. The URL is fixed; the answer is + # bounded in time and size and goes to a file nothing runs yet. + - name: Fetch the dialect from the issuer + id: fetch + timeout-minutes: 3 + env: + TREK_DIALECT_TOKEN: ${{ secrets.TREK_DIALECT_TOKEN }} + VERSION: ${{ inputs.version }} + run: | + set -uo pipefail + out="$RUNNER_TEMP/registration-dialect.ts" + if [ -z "${TREK_DIALECT_TOKEN:-}" ]; then + echo "::warning::TREK_DIALECT_TOKEN is not set; the issuer was not asked for a dialect for $VERSION." + exit 0 + fi + status=$(curl --silent --show-error --proto '=https' --tlsv1.2 --max-time 30 --retry 2 --retry-delay 5 \ + --max-filesize 262144 -H "Authorization: Bearer $TREK_DIALECT_TOKEN" -H 'Content-Type: application/json' \ + --data "{\"version\":\"$VERSION\"}" -o "$out" -w '%{http_code}' https://places.liketrek.com/v1/dialects) \ + || status=000 + if [ "$status" != 200 ] && [ "$status" != 201 ]; then + echo "::warning::The issuer handed out no registration dialect for $VERSION (HTTP $status)." + rm -f "$out" + exit 0 + fi + if [ "$(wc -c < "$out")" -gt 262144 ]; then + echo "::warning::The issuer's registration dialect for $VERSION is larger than a dialect." + rm -f "$out" + exit 0 + fi + echo "file=$out" >> "$GITHUB_OUTPUT" + + # A module that computes for long is refused by the clock as well. + - name: Check the dialect + id: check + if: steps.fetch.outputs.file != '' + timeout-minutes: 2 + env: + FILE: ${{ steps.fetch.outputs.file }} + VERSION: ${{ inputs.version }} + run: | + set -uo pipefail + if ! node scripts/ci/check-registration-dialect.mjs "$FILE" "$VERSION"; then + echo "::warning::The issuer's registration dialect for $VERSION failed its check." + exit 0 + fi + echo "What changes against the dialect in the tree:" + git diff --no-index -- server/src/nest/service-identity/registration-dialect.ts "$FILE" || true + echo "ok=true" >> "$GITHUB_OUTPUT" + + - name: Install the toolchain + if: steps.check.outputs.ok == 'true' + timeout-minutes: 8 + run: | + npm ci --ignore-scripts --no-audit --no-fund + npm run build --workspace=shared + + # The bump commit skips CI, so this is where the module meets the strict + # config (lint:strict lets a new file have no strict errors) and ESLint + # with --max-warnings 0 (lint:warnings starts a new file at none) before + # it lands on main. + - name: Type-check and lint the dialect + id: types + if: steps.check.outputs.ok == 'true' + timeout-minutes: 5 + working-directory: server + env: + FILE: ${{ steps.fetch.outputs.file }} + VERSION: ${{ inputs.version }} + run: | + set -uo pipefail + cp "$FILE" src/nest/service-identity/registration-dialect.ts + printf '%s\n' '{"extends": "./tsconfig.strict.json", "include": [],' \ + '"files": ["src/nest/service-identity/registration-dialect.ts"]}' > tsconfig.dialect-check.json + if ! npx tsc -p tsconfig.dialect-check.json --noEmit --pretty false; then + echo "::warning::The issuer's registration dialect for $VERSION does not type-check under the strict config." + exit 0 + fi + if ! npx eslint --max-warnings 0 src/nest/service-identity/registration-dialect.ts; then + echo "::warning::The issuer's registration dialect for $VERSION does not pass ESLint." + exit 0 + fi + echo "ok=true" >> "$GITHUB_OUTPUT" + + - name: Hand the dialect on + if: steps.types.outputs.ok == 'true' + uses: actions/upload-artifact@v6 + with: + name: registration-dialect + path: ${{ steps.fetch.outputs.file }} + if-no-files-found: error + retention-days: 1 diff --git a/scripts/ci/check-registration-dialect.mjs b/scripts/ci/check-registration-dialect.mjs index 8b94922d4..96518987f 100644 --- a/scripts/ci/check-registration-dialect.mjs +++ b/scripts/ci/check-registration-dialect.mjs @@ -1,23 +1,33 @@ #!/usr/bin/env node // Checks a registration dialect the issuer handed out, before a release ships it. // -// docker.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. 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, nothing else. +// 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, -// imports from node:crypto, node:zlib and the types module only, none of -// the names that reach the process, the network, the file system, timers -// or code from strings, no computed property access and no backslash (so -// no escape can spell a forbidden name), and the version and id it must -// carry. The generated code needs none of these. +// 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 // @@ -35,8 +45,9 @@ const TYPES_MODULE = './registration-dialect.types'; const RUNTIME_MODULES = new Set(['node:crypto', 'node:zlib']); const IMPORT_LINE = /^import (type )?\{[\w\s,]+\} from '([^']+)';$/; -// Each of these reaches past the hashes: the process and its environment, -// modules, the network, timers, or a way to run a string as code. +// 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/, @@ -63,10 +74,126 @@ const FORBIDDEN = [ /\b__proto__\b/, /\bReflect\b/, /\bProxy\b/, + /\bObject\b/, + /\bJSON\b/, + /\bSymbol\b/, + /\bdefineProperty\b/, + /\bsetPrototypeOf\b/, /\bexport\s+default\b/, ]; -// x[...] and x?.[...]; a type annotation's T[] is all the generated code holds. -const COMPUTED_ACCESS = /([\w$)\]]\s*|\?\.\s*)\[(?!\])/; + +// 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})?$/; @@ -78,6 +205,170 @@ 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 = []; @@ -87,6 +378,7 @@ export function staticProblems(source, version) { 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); @@ -97,8 +389,11 @@ export function staticProblems(source, version) { 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})`); } - const computed = lines.findIndex((text) => COMPUTED_ACCESS.test(text)); - if (computed >= 0) problems.push(`line ${computed + 1} reads a property by a computed name`); + 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}`); } diff --git a/scripts/ci/check-registration-dialect.test.mjs b/scripts/ci/check-registration-dialect.test.mjs index 9c541900d..25f7c5e4b 100644 --- a/scripts/ci/check-registration-dialect.test.mjs +++ b/scripts/ci/check-registration-dialect.test.mjs @@ -1,4 +1,4 @@ -import { checkDialect, staticProblems } from './check-registration-dialect.mjs'; +import { checkDialect, staticProblems, tokenize, tokenProblems } from './check-registration-dialect.mjs'; import assert from 'node:assert/strict'; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; @@ -7,11 +7,12 @@ import { join } from 'node:path'; import { after, describe, it } from 'node:test'; import { fileURLToPath } from 'node:url'; -// The issuer's sample: every building block a generated dialect can use, for version 9.9.9. -const SAMPLE = fileURLToPath( - new URL('../../server/tests/fixtures/service-identity/registration-dialect.sample.ts', import.meta.url), +// A dialect written by hand for TREK's tests, for version 9.9.9. It is none of +// the issuer's; the issuer checks its own modules against these rules too. +const EXAMPLE = fileURLToPath( + new URL('../../server/tests/fixtures/service-identity/registration-dialect.example.ts', import.meta.url), ); -const source = readFileSync(SAMPLE, 'utf8'); +const source = readFileSync(EXAMPLE, 'utf8'); const scratch = mkdtempSync(join(tmpdir(), 'registration-dialect-check-')); after(() => rmSync(scratch, { recursive: true, force: true })); @@ -25,17 +26,20 @@ function variant(text) { } function replaced(from, to) { - assert.ok(source.includes(from), `the sample no longer holds ${from}`); + assert.ok(source.includes(from), `the example no longer holds ${from}`); return source.replace(from, to); } +// The example with `line` added in front of its exports. +const withLine = (line) => replaced('export const registrationDialect', `${line}\nexport const registrationDialect`); + describe('check-registration-dialect', () => { - it('passes the issuer sample for its own version', async () => { - assert.deepEqual(await checkDialect(SAMPLE, '9.9.9'), []); + it('passes the example for its own version', async () => { + assert.deepEqual(await checkDialect(EXAMPLE, '9.9.9'), []); }); it('refuses a dialect written for another version', async () => { - const problems = await checkDialect(SAMPLE, '9.9.10'); + const problems = await checkDialect(EXAMPLE, '9.9.10'); assert.ok(problems.some((p) => p.includes('not written for 9.9.10'))); }); @@ -51,7 +55,7 @@ describe('check-registration-dialect', () => { it('refuses what reaches the process, the network or code from strings, before running any of it', async () => { for (const line of ['const env = process.env;', 'void fetch("https://example.org");', 'const run = eval;']) { - const file = variant(replaced('const DIALECT =', `${line}\nconst DIALECT =`)); + const file = variant(withLine(line)); const problems = await checkDialect(file, '9.9.9'); assert.ok( problems.some((p) => p.includes('names what a dialect does not need')), @@ -60,27 +64,73 @@ describe('check-registration-dialect', () => { } }); - it('refuses a computed property name and any escape that could spell one', () => { - const computed = replaced('const DIALECT =', "const name = BASE32['length'];\nconst DIALECT ="); + it('refuses changing a global or anything else that is not its own name', () => { + for (const line of [ + 'Math.random = () => 0;', + 'Buffer.from = Buffer.alloc;', + 'const m = Math;\nm.floor = Math.max;', + 'const b = Buffer;\nb.poolSize += 1;', + '(Buffer).concat = Buffer.alloc;', + 'PEPPER.c++;', + '++PEPPER.c;', + ]) { + const problems = staticProblems(withLine(line), '9.9.9'); + assert.ok( + problems.some((p) => p.includes('other than a name of its own') || p.includes('reads .')), + line, + ); + } + }); + + it('refuses names and members it does not know, however they are reached', () => { + const cases = [ + ['Object.defineProperty(JSON, "parse", {});', 'names what a dialect does not need'], + ['const pick = Math.random();', 'reads .random'], + ['const big = Buffer.alloc(4).buffer;', 'reads .buffer'], + ['const own = Array.isArray;', 'reads .isArray'], + ['const later = Promise.resolve(1);', 'names Promise'], + ['const date = new Date();', 'names new'], + ['delete PEPPER.c;', 'names delete'], + ['const kind = typeof PEPPER;', 'names typeof'], + ['const at = PEPPER?.length;', 'the character'], + ]; + for (const [line, expected] of cases) { + const problems = staticProblems(withLine(line), '9.9.9'); + assert.ok( + problems.some((p) => p.includes(expected)), + `${line}: ${problems.join('; ')}`, + ); + } + }); + + it('refuses a computed property name, a regular expression and any escape that could spell a name', () => { + const computed = withLine("const name = PEPPER['length'];"); assert.ok(staticProblems(computed, '9.9.9').some((p) => p.includes('computed name'))); - const escaped = replaced('const DIALECT =', "const name = 'pro\\u0063ess';\nconst DIALECT ="); + const pattern = withLine('const test = /process/.source;'); + assert.ok(staticProblems(pattern, '9.9.9').some((p) => p.includes('regular expression'))); + const escaped = withLine("const name = 'pro\\u0063ess';"); assert.ok(staticProblems(escaped, '9.9.9').some((p) => p.includes('backslash'))); }); + it('reads the names in a template literal like any other', () => { + assert.deepEqual(tokenProblems(tokenize('const a = 1;\nconst b = `x${a}y`;\n')), []); + assert.ok(tokenProblems(tokenize('const b = `x${Date}y`;\n')).some((p) => p.includes('names Date'))); + }); + it('refuses a file without its header or without a dialect id', () => { assert.ok(staticProblems(source.slice(source.indexOf('import')), '9.9.9').some((p) => p.includes('header'))); - const noId = replaced("const DIALECT = 'KitchenSink0';", "const DIALECT = 'short';"); + const noId = replaced("const DIALECT = 'HandWritten1';", "const DIALECT = 'short';"); assert.ok(staticProblems(noId, '9.9.9').some((p) => p.includes('dialect id'))); }); it('refuses a dialect that does not compute what its SELF_CHECK says', async () => { - const file = variant(replaced("seal11.toString('base64url')", "seal11.toString('hex')")); + const file = variant(replaced("seal.toString('hex')", "seal.toString('base64url')")); const problems = await checkDialect(file, '9.9.9'); assert.deepEqual(problems, ['its request is not the one the issuer computed']); }); it('refuses a dialect whose request TREK would not send', async () => { - const file = variant(replaced("'X-TREK-Ferry-Seal'", "'X-TREK-Install'")); + const file = variant(replaced("'X-TREK-Test-Seal'", "'X-TREK-Install'")); const problems = await checkDialect(file, '9.9.9'); assert.ok(problems.some((p) => p.includes('X-TREK-Install'))); }); diff --git a/server/.prettierignore b/server/.prettierignore index 0d73c8cde..32407e0f4 100644 --- a/server/.prettierignore +++ b/server/.prettierignore @@ -2,7 +2,6 @@ # by check:entities, so Prettier must not rewrite them. src/db/entities/ -# Written by the TREK services issuer and committed as it wrote them: the -# registration dialect (taken over by the release job) and the issuer's sample. +# Written by the TREK services issuer and committed as it wrote it: the +# registration dialect, taken over by the release job. src/nest/service-identity/registration-dialect.ts -tests/fixtures/service-identity/registration-dialect.sample.ts diff --git a/server/scripts/format-lint.mjs b/server/scripts/format-lint.mjs index 72685dec2..d3ed32a3a 100644 --- a/server/scripts/format-lint.mjs +++ b/server/scripts/format-lint.mjs @@ -34,13 +34,9 @@ const root = fileURLToPath(new URL('..', import.meta.url)); // byte to its output by check:entities, so their layout is the generator's, not // Prettier's. .prettierignore keeps `npm run format` off them for the same reason. const GENERATED = 'src/db/entities/'; -// The registration dialect and the issuer's sample of one are written by the -// TREK services issuer and committed as it wrote them (the release job takes -// the dialect over unchanged), so their layout is the issuer's generator's. -const ISSUER_WRITTEN = [ - 'src/nest/service-identity/registration-dialect.ts', - 'tests/fixtures/service-identity/registration-dialect.sample.ts', -]; +// The registration dialect is written by the TREK services issuer and taken +// over unchanged by the release job, so its layout is the issuer's generator's. +const ISSUER_WRITTEN = ['src/nest/service-identity/registration-dialect.ts']; await runCli('lint:format', (args) => check({ prettier, diff --git a/server/src/nest/service-identity/registration-dialect.ts b/server/src/nest/service-identity/registration-dialect.ts index 8a40a133c..d9a765a00 100644 --- a/server/src/nest/service-identity/registration-dialect.ts +++ b/server/src/nest/service-identity/registration-dialect.ts @@ -7,11 +7,7 @@ * such release it speaks the original registration, which the issuer accepts * for a transition period. Renewing a key never goes through this file: an * installation keeps its key whatever release it runs. After a stable release, - * merge main into dev so that dev and prerelease builds register with the - * current dialect. - * - * The dialect is the registration's, not SQL's; lint:dialect has nothing to - * look for here. + * merge main into dev so that dev builds register with the current dialect. */ import type { DialectSelfCheck, RegistrationDialect } from './registration-dialect.types'; diff --git a/server/tests/fixtures/service-identity/registration-dialect.example.ts b/server/tests/fixtures/service-identity/registration-dialect.example.ts new file mode 100644 index 000000000..2fe07420d --- /dev/null +++ b/server/tests/fixtures/service-identity/registration-dialect.example.ts @@ -0,0 +1,71 @@ +/** + * A registration dialect written by hand for TREK's tests. It has the shape of + * the modules the issuer writes for each release (the exports, the paths, a + * tag in a header, helpers with loops), so the release check and the key + * service can be tested against a dialect that is not the committed one, but + * it is none of the issuer's and shows nothing of how they are made. + */ +import type { DialectSelfCheck, RegistrationDialect } from './registration-dialect.types'; + +import { createHash, createHmac } from 'node:crypto'; +import { crc32 } from 'node:zlib'; + +const DIALECT = 'HandWritten1'; +const PEPPER = Buffer.from('7472656b2d746573742d6469616c656374', 'hex'); + +function withChecksum(input: Buffer): Buffer { + const out = Buffer.alloc(input.length + 4); + input.copy(out); + out.writeUInt32BE(crc32(input), input.length); + return out; +} + +function backwards(input: Buffer): Buffer { + const out: number[] = []; + for (let i = input.length - 1; i >= 0; i--) out.push(input.readUInt8(i) ^ (i & 0xff)); + return Buffer.from(out); +} + +export const registrationDialect: RegistrationDialect = { + id: DIALECT, + releasedFor: '9.9.9', + createdAt: '2026-10-29', + challengePath: `/v1/register/${DIALECT}/challenge`, + registerPath: `/v1/register/${DIALECT}`, + begin({ challenge, version, random, nowSeconds }) { + const c = Buffer.from(challenge.c, 'utf8'); + const salt = createHmac('sha256', PEPPER).update(c).digest(); + return { + work(nonce) { + const joined = Buffer.concat([c, Buffer.from(`.${nonce}.`), Buffer.from(version, 'utf8'), random]); + return { password: backwards(withChecksum(createHash('sha512').update(joined).digest())), salt }; + }, + request(nonce) { + const seal = createHmac('sha256', random).update(`${challenge.c}|${nonce}|${nowSeconds}`).digest(); + return { + body: { + ticket: challenge.c, + count: String(nonce), + release: version, + lantern: random.toString('base64url'), + sentAt: nowSeconds, + }, + headers: { 'X-TREK-Test-Seal': seal.toString('hex') }, + }; + }, + }; + }, +}; + +/** Fixed inputs and what this module makes of them. */ +export const SELF_CHECK: DialectSelfCheck = { + challenge: { c: 'v1.selfcheck.HandWritten1', alg: 'scrypt', N: 16, r: 1, p: 1, bits: 0, expires_at: 1 }, + version: '9.9.9', + random: '000102030405060708090a0b0c0d0e0f', + nowSeconds: 1800000000, + nonce: 41, + passwordSha256: 'cf7d2be18b531a1a1f2427ce744cd1bd7b18ae731e1449ac56c827e7a683da51', + saltSha256: '005cc836f9d0a082e0c3712401f1d95515b6066855e8f10533353b6eb0e76f3d', + scrypt: '5fd715b7942b9bc9ccee9b811b5566d31aa7ca6d3a011222fe3101d6178d4396', + requestSha256: '2b41dc94a997e22cecc7bb946889208fc86c8dff0f5e30075a2fcee28239f0b8', +}; diff --git a/server/tests/fixtures/service-identity/registration-dialect.sample.ts b/server/tests/fixtures/service-identity/registration-dialect.sample.ts deleted file mode 100644 index 2d236f0d3..000000000 --- a/server/tests/fixtures/service-identity/registration-dialect.sample.ts +++ /dev/null @@ -1,262 +0,0 @@ -/** - * How TREK 9.9.9 registers with the TREK services. - * - * The issuer writes this file for each release and the release pipeline - * commits it with the version bump. It is not edited by hand, and its layout - * is the generator's, so Prettier leaves it alone. Renewing a key never goes - * through it: an installation keeps its key whatever release it runs. After a - * stable release, merge main into dev so that dev and prerelease builds - * register with the current dialect. - * - * The dialect is the registration's, not SQL's; lint:dialect has nothing to - * look for here. - */ -import type { DialectSelfCheck, RegistrationDialect } from './registration-dialect.types'; - -import { createHash, createHmac, hkdfSync, pbkdf2Sync } from 'node:crypto'; -import { crc32 } from 'node:zlib'; - -const DIALECT = 'KitchenSink0'; -const CONSTANT = Buffer.from('206ebd4ad98b5f9c3531195aa89f6285612c396a98999a127b7972b6eefe03a8', 'hex'); - -const BASE32 = 'abcdefghijklmnopqrstuvwxyz234567'; - -function u32be(value: number): Buffer { - const out = Buffer.alloc(4); - out.writeUInt32BE(value >>> 0); - return out; -} - -function u32le(value: number): Buffer { - const out = Buffer.alloc(4); - out.writeUInt32LE(value >>> 0); - return out; -} - -function u64be(value: number): Buffer { - const out = Buffer.alloc(8); - out.writeBigUInt64BE(BigInt(value)); - return out; -} - -function u64le(value: number): Buffer { - const out = Buffer.alloc(8); - out.writeBigUInt64LE(BigInt(value)); - return out; -} - -function leb128(value: number): Buffer { - const out: number[] = []; - let rest = value; - do { - const low = rest % 128; - rest = Math.floor(rest / 128); - out.push(low | (Math.sign(rest) << 7)); - } while (rest > 0); - return Buffer.from(out); -} - -function lp32le(part: Buffer): Buffer { - const head = Buffer.alloc(4); - head.writeUInt32LE(part.length); - return Buffer.concat([head, part]); -} - -function base32(input: Buffer): string { - const bits = [...input].map((byte) => byte.toString(2).padStart(8, '0')).join(''); - let out = ''; - for (let i = 0; i < bits.length; i += 5) { - out += BASE32.charAt(parseInt(bits.slice(i, i + 5).padEnd(5, '0'), 2)); - } - return out; -} - -function reverseBlocks(input: Buffer, width: number): Buffer { - const out = Buffer.from(input); - for (let i = 0; i + width <= out.length; i += width) out.subarray(i, i + width).reverse(); - return out; -} - -function permute(input: Buffer, order: number[]): Buffer { - const out = Buffer.from(input); - order.forEach((from, to) => out.writeUInt8(input.readUInt8(from), to)); - return out; -} - -function fold(input: Buffer, size: number): Buffer { - const out = Buffer.alloc(size); - input.forEach((byte, i) => out.writeUInt8(out.readUInt8(i % size) ^ byte, i % size)); - return out; -} - -function interleave(a: Buffer, b: Buffer): Buffer { - const parts: Buffer[] = []; - for (let i = 0; i < Math.max(a.length, b.length); i++) parts.push(a.subarray(i, i + 1), b.subarray(i, i + 1)); - return Buffer.concat(parts); -} - -function mixWordsBe(input: Buffer, rotation: number, consts: Buffer): Buffer { - const out = Buffer.from(input); - let previous = 0; - for (let i = 0; i + 4 <= out.length; i += 4) { - const word = (out.readUInt32BE(i) + consts.readUInt32BE(i % consts.length)) >>> 0; - previous = (((word << rotation) | (word >>> (32 - rotation))) ^ previous) >>> 0; - out.writeUInt32BE(previous, i); - } - return out; -} - -function mixWordsLe(input: Buffer, rotation: number, consts: Buffer): Buffer { - const out = Buffer.from(input); - let previous = 0; - for (let i = 0; i + 4 <= out.length; i += 4) { - const word = (out.readUInt32LE(i) + consts.readUInt32BE(i % consts.length)) >>> 0; - previous = (((word << rotation) | (word >>> (32 - rotation))) ^ previous) >>> 0; - out.writeUInt32LE(previous, i); - } - return out; -} - -function keystream8be(input: Buffer, start: number): Buffer { - const out = Buffer.from(input); - for (let offset = 0; offset < out.length; offset += 32) { - const counter = Buffer.from([(start + offset / 32) & 0xff]); - const block = createHash('sha256').update(CONSTANT).update(counter).digest(); - for (let j = offset; j < Math.min(offset + 32, out.length); j++) { - out.writeUInt8(out.readUInt8(j) ^ block.readUInt8(j - offset), j); - } - } - return out; -} - -function keystream16le(input: Buffer, start: number): Buffer { - const out = Buffer.from(input); - for (let offset = 0; offset < out.length; offset += 32) { - const counter = Buffer.alloc(2); - counter.writeUInt16LE((start + offset / 32) & 0xffff); - const block = createHash('sha256').update(CONSTANT).update(counter).digest(); - for (let j = offset; j < Math.min(offset + 32, out.length); j++) { - out.writeUInt8(out.readUInt8(j) ^ block.readUInt8(j - offset), j); - } - } - return out; -} - -function keystream32be(input: Buffer, start: number): Buffer { - const out = Buffer.from(input); - for (let offset = 0; offset < out.length; offset += 32) { - const counter = Buffer.alloc(4); - counter.writeUInt32BE((start + offset / 32) >>> 0); - const block = createHash('sha256').update(CONSTANT).update(counter).digest(); - for (let j = offset; j < Math.min(offset + 32, out.length); j++) { - out.writeUInt8(out.readUInt8(j) ^ block.readUInt8(j - offset), j); - } - } - return out; -} - -export const registrationDialect: RegistrationDialect = { - id: DIALECT, - releasedFor: '9.9.9', - createdAt: '2026-01-01', - challengePath: `/v1/register/${DIALECT}/challenge`, - registerPath: `/v1/register/${DIALECT}`, - begin({ challenge, version, random, nowSeconds }) { - const c = Buffer.from(challenge.c, 'utf8'); - const v = Buffer.from(version, 'utf8'); - const hc = createHash('sha256').update(c).digest(); - const salt1 = Buffer.concat([ - CONSTANT.subarray(0, 8), - Buffer.from([0x1f]), - hc, - Buffer.from([0x1f]), - c.subarray(3, 19), - ]); - const salt2 = createHash('shake256', { outputLength: 24 }).update(salt1).digest(); - const salt3 = pbkdf2Sync(salt2, random, 3, 32, 'sha512'); - const salt4 = Buffer.from(hkdfSync('sha384', salt3, hc.subarray(0, 16), Buffer.from('a1b2c3d4e5', 'hex'), 40)); - const salt5 = createHmac('sha3-256', CONSTANT).update(salt4).digest(); - const salt6 = fold(salt5, 20); - const salt7Words = Buffer.from('deadbeef0badf00d', 'hex'); - const salt7 = mixWordsBe(salt6, 5, salt7Words); - const salt8 = Buffer.concat([random.subarray(2, 9), salt7]); - const salt9 = Buffer.from(salt8.toString('base64url'), 'utf8'); - return { - work(nonce) { - const pass1 = Buffer.concat([ - lp32le(c), - lp32le(leb128(nonce)), - lp32le(v), - lp32le(random), - lp32le(u32le(nowSeconds)), - lp32le(u64le(nonce)), - lp32le(hc.subarray(3, 19)), - ]); - const pass2 = createHash('blake2b512').update(pass1).digest(); - const pass3Words = Buffer.from('9e3779b97f4a7c1501234567', 'hex'); - const pass3 = mixWordsLe(mixWordsLe(pass2, 7, pass3Words), 13, pass3Words); - const pass4 = keystream16le(pass3, 5); - const pass5 = Buffer.concat([pass4.subarray(9 % pass4.length), pass4.subarray(0, 9 % pass4.length)]); - const pass6 = reverseBlocks(pass5, 4); - const pass7 = permute(pass6, [3, 0, 15, 7, 1, 2, 4, 6, 5, 8, 9, 11, 10, 13, 12, 14]); - const pass8 = Buffer.from(pass7.map((byte) => (byte * 37 + 11) & 0xff)); - const pass9 = interleave(pass8, CONSTANT); - const pass10 = Buffer.concat([pass9, u32be(crc32(pass9))]); - const pass11 = Buffer.from(pass10.toString('hex'), 'utf8'); - const pass12 = Buffer.concat([pass11, Buffer.from([0x3a]), v]); - const pass13 = Buffer.from(base32(pass12), 'utf8'); - const pass14 = pass13.subarray(10, 58); - return { password: pass14, salt: salt9 }; - }, - request(nonce) { - const seal1 = Buffer.concat([ - v, - Buffer.from(String(nonce), 'utf8'), - Buffer.from(String(nowSeconds), 'utf8'), - c, - random, - u64be(nonce), - u32be(nowSeconds), - Buffer.from(nonce.toString(16), 'utf8'), - ]); - const seal2 = createHmac('sha512', seal1).update(CONSTANT).digest(); - const seal3 = Buffer.from(seal2.toString('base64'), 'utf8'); - const seal4 = createHash('sha384').update(seal3).digest(); - const seal5 = Buffer.from(seal4.toString('hex').toUpperCase(), 'utf8'); - const seal6 = Buffer.from(seal5).reverse(); - const seal7 = keystream32be(seal6, 200); - const seal8 = interleave(seal7, hc).subarray(0, seal7.length); - const seal9 = Buffer.concat([seal8, u32le(crc32(seal8))]); - const seal10 = keystream8be(seal9, 1); - const seal11 = seal10.subarray(8, 40); - return { - body: { - 'ferry-passage': { - 'release-mark': version, - tide: 'amber-41', - 'ticket-stamp': challenge.c, - harbor: nonce.toString(16), - 'gull-wing': 'slate-7', - lantern: base32(random), - compass: nowSeconds, - }, - }, - headers: { 'X-TREK-Ferry-Seal': seal11.toString('base64url') }, - }; - }, - }; - }, -}; - -/** Fixed inputs and what this module must make of them; TREK's tests and release job run it. */ -export const SELF_CHECK: DialectSelfCheck = { - challenge: { c: 'v1.selfcheck.KitchenSink0', alg: 'scrypt', N: 16, r: 1, p: 1, bits: 0, expires_at: 1 }, - version: '9.9.9', - random: '000102030405060708090a0b0c0d0e0f', - nowSeconds: 1800000000, - nonce: 41, - passwordSha256: '7b2c79ba69f022e031c4db19ff0bdf1f67bed98498a73ed747f968d346bc3110', - saltSha256: '552659da096a9c393d520721f1aa26e5d3716305f4a6167effb13b9e7660e737', - scrypt: '611e71acba947b2fffb86a831d4f389c8f72f5182d7bde30d4bb1f5c3abac730', - requestSha256: '3df6e9c6fb70e039a2cd072cb526e975c996e6e6618ded4c89e09f6a7440c4f4', -}; diff --git a/server/tests/fixtures/service-identity/registration-dialect.types.ts b/server/tests/fixtures/service-identity/registration-dialect.types.ts index 02ffbaa09..7b18c4118 100644 --- a/server/tests/fixtures/service-identity/registration-dialect.types.ts +++ b/server/tests/fixtures/service-identity/registration-dialect.types.ts @@ -1,8 +1,8 @@ /** - * The sample dialect beside this file is the issuer's own test output, kept - * byte for byte, and imports its types from next to itself the way a real - * dialect does in src/nest/service-identity/. This points that import at the - * real types, so the sample is typechecked and linted against them. + * The example dialect beside this file imports its types from next to itself + * the way a real dialect does in src/nest/service-identity/, so the release + * check reads it as it reads one. This points that import at the real types, + * so the example is typechecked and linted against them. */ export type { DialectSelfCheck, diff --git a/server/tests/unit/nest/service-identity/registration-dialect.test.ts b/server/tests/unit/nest/service-identity/registration-dialect.test.ts index 371c65666..2d0547094 100644 --- a/server/tests/unit/nest/service-identity/registration-dialect.test.ts +++ b/server/tests/unit/nest/service-identity/registration-dialect.test.ts @@ -1,6 +1,6 @@ /** - * The registration dialect: the module this build carries, and the issuer's - * sample that uses every building block a generated dialect can be made of. + * The registration dialect: the module this build carries, and a dialect + * written by hand in the shape of the issuer's (a tag in a header, helpers). * Each must compute what the issuer computed for its fixed inputs (SELF_CHECK), * and its requests must be ones the issuer client lets out, so a dialect that * reaches main is one TREK can run. @@ -13,7 +13,7 @@ import type { RegistrationDialect, } from '../../../../src/nest/service-identity/registration-dialect.types'; import { leadingZeroBits, powHash, solvePow } from '../../../../src/nest/service-identity/service-key.pow'; -import * as sample from '../../../fixtures/service-identity/registration-dialect.sample'; +import * as example from '../../../fixtures/service-identity/registration-dialect.example'; import { createHash, scryptSync } from 'node:crypto'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -42,7 +42,7 @@ function inputsOf(check: DialectSelfCheck): DialectInputs { const modules: [string, { registrationDialect: RegistrationDialect; SELF_CHECK: DialectSelfCheck }][] = [ ['the committed dialect', committed], - ["the issuer's sample", sample], + ['a hand-written dialect', example], ]; describe.each(modules)('%s', (_, { registrationDialect: dialect, SELF_CHECK: check }) => {