diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..6241305 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,17 @@ + + +## What this changes + + + +## How it was verified + + + +## Contributor License Agreement + +- [ ] I have read and agree to the [Contributor License Agreement](https://docs.xchain.io/legal/cla). + +The CLA Assistant bot checks this automatically and records your signature +against your GitHub account, once, covering all XChain Platform repositories. +Its `license/cla` check is the record that counts; this box is a reminder. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 245f842..1c2436b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ on: jobs: ci: - uses: XChain-Platform/.github/.github/workflows/ci-reusable.yml@e2d578827928e79ec71c9b6afc4595025dc025fe # pin: XChain-Platform/.github @ master 2026-08-13; bump deliberately + uses: XChain-Platform/.github/.github/workflows/ci-reusable.yml@6f4d39ae85787fc31e90a31588d87610a2c33103 # pin: XChain-Platform/.github @ master 2026-08-14; bump deliberately # Override the Node version for a repo if ever needed: # with: # node-version: "20" diff --git a/.github/workflows/nightly-e2e.yml b/.github/workflows/nightly-e2e.yml index 956e4e8..586d52d 100644 --- a/.github/workflows/nightly-e2e.yml +++ b/.github/workflows/nightly-e2e.yml @@ -71,7 +71,7 @@ on: # covering the changed set only. description: 'Ref to install the stack at (branch, release/vX.Y.Z, or a published vX.Y.Z)' type: string - default: master + default: develop permissions: contents: read @@ -86,7 +86,7 @@ jobs: # argument, so the CLI running the install is the same version as the # stack it installs. Splitting those two was how "we tested the release" # could mean "we tested master's installer against the release". - STACK_REF: ${{ github.event.inputs.ref || 'master' }} + STACK_REF: ${{ github.event.inputs.ref || 'develop' }} # Headless DB: point xchain-node at an external MariaDB instead of its # bundled DB container, so the first install does NOT stop on the # interactive root-password prompt (see DatabaseService.getExternalDbConfig @@ -109,7 +109,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: ${{ github.event.inputs.ref || 'master' }} + ref: ${{ github.event.inputs.ref || 'develop' }} - uses: actions/setup-node@v4 with: @@ -170,7 +170,7 @@ jobs: git config --global url."https://github.com/".insteadOf "git@github.com:" fi - - name: Boot the regtest stack (clones every service at ${{ github.event.inputs.ref || 'master' }}) + - name: Boot the regtest stack (clones every service at ${{ github.event.inputs.ref || 'develop' }}) # First heavy step - repo clones + docker image builds + coin daemon. # Watch disk and image-build time here (the 120-min job timeout covers it). # Note: the external-DB connection for the install process itself is diff --git a/.github/workflows/verify-tag.yml b/.github/workflows/verify-tag.yml new file mode 100644 index 0000000..db99919 --- /dev/null +++ b/.github/workflows/verify-tag.yml @@ -0,0 +1,104 @@ +# Train tag gate: every vX.Y.Z tag in this repo must be signed by the XChain +# Platform release key, and must name the version the commit actually carries. +# +# WHY THIS EXISTS. The release-manifest chain starts at the tag: the tag +# signature proves who cut the release, SHA256SUMS.asc proves the asset set is +# theirs, the manifest pins every component, and clone verification proves the +# installed tree is that commit. An unsigned train tag is not a style lapse, it +# is the root of that chain missing, and it cannot be fixed after the fact: +# re-signing means deleting and re-pushing the tag, which branch protection +# refuses and which breaks the sparse-tag invariant. A tag cut unsigned stays +# unsigned, so this gate has to exist before a train is cut, not after. +# +# THIS FILE IS A TWIN. It is byte-identical in every train repo (nothing in it +# is repo-specific) and a platform-side test enforces that. Edit it in one place +# and re-copy; a per-repo edit is how nine gates stop being one gate. +# +# NOT the wallet's keys. The wallet signs its tags with K14 and its release +# manifests with K1, and confusing the three is a named hazard. This gate pins +# the PLATFORM key by fingerprint, from a file in this repo. +name: Verify tag + +on: + push: + tags: + - 'v*' + +# Never cancel a tag verification in flight: a cancelled run reads as "nothing +# went wrong" and this is the one check that must have said yes out loud. +concurrency: + group: verify-tag-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + verify-tag: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # actions/checkout recreates the TRIGGERING tag as a lightweight ref + # pointing straight at the commit, which discards the annotated tag object + # and with it the signature. Every check below would then fail as "cannot + # verify a non-tag object of type commit": not because the tag is + # unsigned, but because the runner no longer has a tag to verify. Re-fetch + # by force so what gets verified is the object the maintainer signed. + # (Learned the hard way in xchain-wallet's first release run.) + - name: Restore the annotated tag object (checkout flattens it) + run: git fetch --force origin "refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}" + + - name: Tag must be signed by the XChain Platform release key + run: | + set -euo pipefail + KEY="tools/release/release-signing-key.asc" + FPR_FILE="tools/release/release-signing-fingerprint.txt" + + EXPECTED="$(tr -d ' \n' < "$FPR_FILE" || true)" + + # A real fingerprint or nothing: a placeholder, an empty file or any + # other malformed value is refused rather than read as "unpinned, so + # allow". A gate that defaults to allow when unconfigured is not a gate. + if ! printf '%s' "$EXPECTED" | grep -qiE '^[0-9A-F]{40}$'; then + echo "::error::the release key is not pinned" + echo " $FPR_FILE reads '${EXPECTED}', not a 40-hex fingerprint." + exit 1 + fi + + gpg --batch --import "$KEY" + # Trust the pinned key ultimately so verification fails on the + # SIGNATURE rather than on the web of trust. + echo "${EXPECTED}:6:" | gpg --batch --import-ownertrust + + if ! git verify-tag --raw "${GITHUB_REF_NAME}" 2>verify.txt; then + echo "::error::tag ${GITHUB_REF_NAME} is not signed by a key we trust" + sed 's/^/ /' verify.txt + exit 1 + fi + + # `git verify-tag` succeeding is not the verdict: it passes for ANY + # key in the keyring. Bind it to the pinned fingerprint explicitly. + if ! grep -q "VALIDSIG ${EXPECTED}" verify.txt; then + echo "::error::tag ${GITHUB_REF_NAME} is signed, but not by the pinned release key" + echo " expected fingerprint: ${EXPECTED}" + sed 's/^/ /' verify.txt + exit 1 + fi + + echo "tag ${GITHUB_REF_NAME} verified against ${EXPECTED}" + + - name: Tag must match the committed version + run: | + set -euo pipefail + TAG="${GITHUB_REF_NAME}" + VERSION="v$(node -p "require('./package.json').version")" + if [ "$TAG" != "$VERSION" ]; then + echo "::error::tag $TAG does not match package.json version $VERSION" + echo " A train tag is cut on the master merge commit that carries the" + echo " version bump; a mismatch means the tag was cut from the wrong SHA." + exit 1 + fi + echo "$TAG matches package.json at $(git rev-parse HEAD)" diff --git a/bin/coverage-thresholds.json b/bin/coverage-thresholds.json index 08f2d2e..48ffaf0 100644 --- a/bin/coverage-thresholds.json +++ b/bin/coverage-thresholds.json @@ -1,7 +1,7 @@ { - "comment": "Coverage floors for the CI coverage job (regression floors, ~1-1.5 points below measured on 2026-08-12, not tier targets; raise as coverage climbs). Mirrored into the coverage:check npm script in package.json: keep both in sync.", - "lines": 87.5, - "statements": 87.5, + "comment": "Coverage floors for the CI coverage job (regression floors, ~1-1.5 points below measured, not tier targets; raise as coverage climbs). Mirrored into the coverage:check npm script in package.json and guarded by test/unit/coverage-thresholds-sync.test.js. Re-measured 2026-08-15 against a full sibling checkout: 92.44 lines/statements, 87.61 branches, 90.77 functions over 1477 unit tests. The previous floors came from a 2026-08-12 run and sat nearly 5 points under the lines figure, wide enough for a real regression to pass unseen. This repo declares no .ci-siblings; its one cross-repo guard is the xchain-hub coins byte-identity check, which reads files rather than executing src, so the sibling-less CI run measures the same surface.", + "lines": 91, + "statements": 91, "branches": 86.5, - "functions": 88.5 + "functions": 89.5 } diff --git a/package.json b/package.json index 7b056c5..e3302b9 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "scripts": { "test": "mocha 'test/unit/**/*.test.js' --timeout 2000 --recursive", "coverage": "c8 --reporter=text --reporter=html --include 'src/**/*.js' mocha 'test/unit/**/*.test.js' --timeout 2000 --recursive --exit", - "coverage:check": "c8 --check-coverage --lines 87.5 --statements 87.5 --branches 86.5 --functions 88.5 --reporter=text-summary --include 'src/**/*.js' mocha 'test/unit/**/*.test.js' --timeout 2000 --recursive --exit", + "coverage:check": "c8 --check-coverage --lines 91 --statements 91 --branches 86.5 --functions 89.5 --reporter=text-summary --include 'src/**/*.js' mocha 'test/unit/**/*.test.js' --timeout 2000 --recursive --exit", "ci": "mocha 'test/unit/**/*.test.js' --timeout 2000 --recursive --exit && npm run ci:security && npm run ci:regression", "ci:security": "mocha 'test/security/**/*.test.js' --timeout 10000 --recursive --exit", "ci:regression": "mocha 'test/regression/**/*.test.js' --timeout 30000 --recursive --exit", diff --git a/src/operations/moduleOperations.js b/src/operations/moduleOperations.js index 1880d6d..dd8347d 100644 --- a/src/operations/moduleOperations.js +++ b/src/operations/moduleOperations.js @@ -29,6 +29,7 @@ const { createDockerNetwork, killContainer, removeContainer, forceRemoveContaine const { buildDatabaseModule, resetDatabases, clearHubPriceIngestWatermark, getDatabaseContainerId } = require('../services/DatabaseService') const { getModuleBranch, installModule, uninstallModule } = require('../services/ModuleService') const { assertHubNotBehind } = require('../services/SkewGuardService') +const { assertRequiredMigrationsApplied } = require('../services/MigrationPreconditionService') const { statusChanged } = require('../services/StatusService') // Resolve the operator's single ref slot into an install target and publish it @@ -204,6 +205,16 @@ async function updateModulesOnBranch(servicesList, branch = null) { const { resolveComponentRef } = require('../services/ReleaseManifestService') const pin = resolveComponentRef(nextModule, moduleBranch) await assertHubNotBehind(nextModule, pin.ref) + // Migration-precondition guard: a service whose new source asserts a + // GATED (mode=manual) migration at startup is REFUSED when the database + // it will use has not applied that migration, before anything is torn + // down. Without it the only thing that discovers the requirement is the + // recreated container crash-looping - which is exactly how a routine + // indexer deploy took all three mainnet indexers down on 2026-08-09. + // Reads the same PINNED ref as the skew guard above, for the same + // reason: a precondition read from a different ref than the one being + // installed is a check that blessed a version it never saw. + await assertRequiredMigrationsApplied(nextModule, nextCoin, nextNetwork, pin.ref) // moduleBranch MUST be threaded through: installModule re-clones the // module on the remoteUpdate path (cloneGit with this `branch`), so a // null branch here re-clones the default branch and clobbers the branch diff --git a/src/services/DatabaseService.js b/src/services/DatabaseService.js index 9a3438e..4ec21e0 100644 --- a/src/services/DatabaseService.js +++ b/src/services/DatabaseService.js @@ -978,6 +978,18 @@ async function buildDatabaseModule(coin, network) { }) const containerId = stdout.trim() if (/^[a-f0-9]{64}$/.test(containerId)) { + // No db.insertModuleContainer(DB_MODULE_NAME, ...) here, unlike + // ModuleService.buildAndUp / NodeService, and that is a property of + // the ordering, not an oversight (XC-1473). The `modules` registry + // table lives inside the container we just created: there is no + // xchain_node database, no open pool and no table to insert into + // until later in the install, so the DB module cannot register + // itself in its own registry. Nothing needs it to: every lookup of + // this container goes through getDatabaseContainerId(), which reads + // the id from `docker inspect` on the container NAME for exactly + // that reason, and DiscoveryService.discoverContainers() writes the + // row once a registry exists (DB_MODULE_NAME is in its + // SHARED_MODULES list), which is what puts the database line in `ps`. await statusChanged() return containerId } diff --git a/src/services/MigrationPreconditionService.js b/src/services/MigrationPreconditionService.js new file mode 100644 index 0000000..9b5568a --- /dev/null +++ b/src/services/MigrationPreconditionService.js @@ -0,0 +1,295 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * XChain Node - Migration Precondition Guard + * + * Deploy-time precondition check for services that assert a GATED schema + * migration at startup. Refuses the update BEFORE the container is recreated, + * naming the migration, instead of letting the service discover the requirement + * by crash-looping on boot. + * + * WHY IT EXISTS + * ------------- + * 2026-08-09: a routine indexer deploy put all three mainnet indexers (BTC on + * nodes01, DOGE and LTC on nodes02) into Restarting(1) crash-loops with + * `Fatal indexer error: pubkeys.pubkey holds 66 chars but VARCHAR(130) is + * required`. The new code asserts that column width at startup; the migration + * that widens it is mode=manual (a COPY table rebuild under a metadata lock, so + * it wants the writer quiesced) and had never been applied on mainnet. Both + * halves were correct in isolation. The defect was that nothing asked the + * question at deploy time, so a production outage was the discovery mechanism. + * + * SOURCE OF TRUTH FOR THE CONSTRAINT + * ---------------------------------- + * A header tag on the migration file itself, in the source tree about to be + * deployed: + * + * -- xchain:migration mode=manual deploy-precondition=required + * + * Same shape as the SkewGuardService contract (`xchainRequiresHub` in the + * module's own package.json): the constraint travels with the code that carries + * the assertion, so a new assertion is covered the moment it lands and no + * xchain-node release is needed to track it. The service-side half of the + * contract is xchain-indexer's Database.STARTUP_ASSERTED_MIGRATIONS, whose unit + * suite fails if a registered assertion's migration is missing this tag. + * + * WHAT IT CHECKS + * -------------- + * Every tagged migration in the target tree must have a `schema_migrations` row + * in the database that service will use. Missing row -> refuse. Cannot tell -> + * refuse (an unknown migration state is exactly the situation that produced the + * outage). Genuinely empty database -> proceed; a fresh install builds its + * schema from src/sql at the current widths and can never be behind. + ********************************************************************/ + +const fs = require('fs') +const path = require('path') + +const { XChainService, EXTERNAL_DB } = require('../config/constants') +const { getModuleTmpDir, getModuleDatabaseName, getDockerContainerImageName } = require('./ConfigService') + +// Only these modules ship a migrations directory, so everything else skips the +// guard entirely and costs the update path nothing. +const MIGRATION_BEARING_MODULES = [ + XChainService.XCHAIN_INDEXER, + XChainService.XCHAIN_DECODER +] + +const SKIP_ENV = 'XCHAIN_NODE_SKIP_MIGRATION_PRECONDITION' +const LEDGER_TABLE = 'schema_migrations' + +function guardSkipped() { + // Read BY NAME, not through SKIP_ENV, even though the constant is right + // there: the documentation coverage gate scans for literal `process.env.X` + // and a computed read is invisible to it, so a bracket read here is + // undocumentable configuration by construction. SKIP_ENV stays as the name + // used in messages. + const v = process.env.XCHAIN_NODE_SKIP_MIGRATION_PRECONDITION + return v === '1' || v === 'true' || v === 'yes' +} + +/** + * Does this migration file's header declare itself a deploy precondition? + * + * Prologue-anchored: the scan stops at the first non-blank, non-comment line, so + * the token can only arm the flag from the leading comment block and never from + * body prose or a data literal. Widening that to the whole file is how a + * migration that merely DISCUSSES the convention would start refusing deploys. + * + * Twin of xchain-indexer's Database.migrationDeclaresDeployPrecondition. It is + * duplicated rather than shared because this tool reads these files out of a + * source tree it has only cloned, with that tree's dependencies uninstalled, so + * requiring the module is not available to it. Keep the two in step. + */ +function migrationDeclaresDeployPrecondition(raw) { + const prologue = [] + for (const line of String(raw).split('\n')) { + const trimmed = line.trim() + if (trimmed === '' || trimmed.startsWith('--')) { prologue.push(line); continue } + break + } + return /^\s*--\s*xchain:migration\b[^\n]*\bdeploy-precondition\s*=\s*required\b/im.test(prologue.join('\n')) +} + +/** + * Every migration filename in `dir` whose header declares a deploy precondition, + * sorted. A missing directory yields [] - a module (or a ref) with no migrations + * declares no preconditions, which is not an error. + */ +function listDeployPreconditionMigrations(dir) { + let files + try { + files = fs.readdirSync(dir).filter(f => f.endsWith('.sql')).sort() + } catch { + return [] + } + return files.filter(f => { + try { + return migrationDeclaresDeployPrecondition(fs.readFileSync(path.join(dir, f), 'utf8')) + } catch { + return false + } + }) +} + +/** + * Read the applied-migration ledger of one module database. + * + * Returns { state: 'ledger', applied: Set } when the ledger was read, + * { state: 'empty-database' } when the schema holds no tables at all (or does + * not exist yet), and { state: 'unreadable', reason } for everything else. The + * three are deliberately distinct: only the middle one is safe to proceed on. + * + * WHY ROOT AND NOT THE MODULE'S OWN ACCOUNT + * ----------------------------------------- + * The first cut of this guard connected with the module's generated credentials + * (INDEXER_DB_USER/PASS) over the published port. Run against the live regtest + * stack it produced `Access denied for user 'xchain_indexer_bitcoin_regtest'`, + * i.e. an unknown-state REFUSAL of a perfectly deployable update - the sidecar + * password and the live account had drifted, which is a documented recurring + * condition here and has nothing to do with migrations. A guard that fails + * closed on a routine credential drift blocks every deploy and gets switched + * off. So this uses the same root-credential runner every other DB read in + * xchain-node uses (see clearHubPriceIngestWatermark), which the update path + * already resolves non-interactively for its own credential parity pass. + */ +async function defaultReadAppliedMigrations({ database, coin, network }, deps = {}) { + // The name comes from getModuleDatabaseName, but it reaches SQL as text (an + // identifier cannot be bound), so gate it on the same allowlist the + // provisioning DDL uses rather than trusting its provenance. + if (!/^[A-Za-z0-9_]+$/.test(String(database))) { + return { state: 'unreadable', reason: 'refusing to query a database name that is not a plain identifier' } + } + const literal = "'" + database + "'" + + let runner = deps.runner + try { + if (!runner) { + const { + getExternalDbConfig, executeNativeMariaDbCommand, + executeDockerMariaDbCommand, askMariadbRootPassword, getDatabaseContainerId + } = require('./DatabaseService') + if (EXTERNAL_DB) { + const cfg = await getExternalDbConfig() + runner = (sql) => executeNativeMariaDbCommand(cfg, sql, '-B -N') + } else { + const containerId = await getDatabaseContainerId() + if (!containerId) return { state: 'unreadable', reason: 'no MariaDB container found on this host' } + const rootPassword = await askMariadbRootPassword(coin, network) + runner = (sql) => executeDockerMariaDbCommand(containerId, rootPassword, sql, '-B -N') + } + } + + const tableCount = parseInt(String(await runner( + 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = ' + literal)).trim(), 10) + // No tables at all: either the database does not exist yet or it is + // untouched. A fresh install builds its schema from src/sql, which already + // carries the post-migration widths, so it cannot be behind. + if (!tableCount) return { state: 'empty-database' } + + const hasLedger = parseInt(String(await runner( + 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = ' + literal + + " AND TABLE_NAME = '" + LEDGER_TABLE + "'")).trim(), 10) + // Tables but no ledger: this database predates the migration runner, or is + // not the database we think it is. Either way its migration state is + // unknowable, which is the case this guard must not wave through. + if (!hasLedger) { + return { state: 'unreadable', reason: database + ' holds ' + tableCount + ' table(s) but no ' + LEDGER_TABLE + ' ledger' } + } + + const out = String(await runner('SELECT name FROM `' + database + '`.' + LEDGER_TABLE)) + const applied = new Set(out.split('\n').map(s => s.trim()).filter(Boolean)) + return { state: 'ledger', applied } + } catch (err) { + return { state: 'unreadable', reason: (err && err.message) ? err.message : String(err) } + } +} + +function refusalMessage(module, coin, network, dbName, missing) { + const container = getDockerContainerImageName(module, coin, network) + const files = missing.join(', ') + return 'update refused: the ' + module + ' source about to be deployed asserts migration' + + (missing.length > 1 ? 's' : '') + ' ' + files + ' at startup, but ' + dbName + + ' has not applied ' + (missing.length > 1 ? 'them' : 'it') + '. Deploying now replaces a working ' + + 'container with one that crash-loops on boot (the 2026-08-09 mainnet halt: all three indexers went to ' + + 'Restarting(1) on exactly this). These migrations are operator-gated on purpose - apply ' + + (missing.length > 1 ? 'them' : 'it') + ' deliberately, with the writer quiesced, then re-run the update:\n' + + missing.map(f => ' docker exec -i ' + container + ' node src/migrate.js --file ' + f).join('\n') + '\n' + + ' Take a fresh backup first: DEPLOY-ORDER.md says so for every migration-bearing deploy, ' + + 'and the coin boxes back up only WEEKLY. ' + + 'Set ' + SKIP_ENV + '=1 to override.' +} + +/** + * Refuses (throws) when the source tree about to be deployed declares a migration + * as a startup precondition that the target database has not applied. + * + * Fail-closed rules: + * - precondition declared + ledger says missing -> refuse + * - precondition declared + ledger unreadable -> refuse (unknown state) + * - precondition declared + database empty / absent -> proceed (fresh install) + * - no precondition declared / not migration-bearing -> proceed + * - the target source cannot be read at all -> proceed with a warning + * (the update is about to fail the same way; do not add a second failure mode) + * + * `deps` is injectable for tests; production callers pass nothing. + */ +async function assertRequiredMigrationsApplied(module, coin, network, branch = null, deps = {}) { + if (!MIGRATION_BEARING_MODULES.includes(module)) return { checked: false, reason: 'no-migrations' } + if (guardSkipped()) { + console.warn(`WARNING: ${SKIP_ENV} is set; skipping the migration precondition check for ${module}. ` + + 'A service whose startup assertion needs an unapplied migration crash-loops as soon as the container is recreated.') + return { checked: false, reason: 'skipped-by-env' } + } + + const cloneGitDep = deps.cloneGit || require('./ModuleService').cloneGit + const listRequired = deps.listDeployPreconditionMigrations || listDeployPreconditionMigrations + const readApplied = deps.readAppliedMigrations || defaultReadAppliedMigrations + + // Clone the target source and read ITS migrations: the constraint must come + // from the code that is about to run. The tmp tree is NOT reused from the skew + // guard even when that just cloned the same ref - that guard is conditional + // (module set, skip env, early returns), so a leftover tree can be from another + // branch or another run, and reading the wrong tree is how a precondition check + // blesses a version it never saw. + let required + try { + await cloneGitDep(module, false, true, branch) + required = listRequired(path.join(getModuleTmpDir(module), 'src', 'sql', 'migrations')) + } catch (err) { + console.warn(`Migration precondition guard: could not read ${module}'s migrations ` + + `(${err && err.message ? err.message : err}); guard not applied.`) + return { checked: false, reason: 'source-unreadable' } + } + if (!required.length) return { checked: false, reason: 'no-preconditions' } + + const dbName = getModuleDatabaseName(module, coin, network) + const result = await readApplied({ database: dbName, coin, network }) + + if (result.state === 'empty-database') { + console.warn(`Migration precondition guard: ${dbName} holds no tables yet, so ${module}'s gated ` + + `migrations (${required.join(', ')}) cannot be outstanding on it; proceeding.`) + return { checked: true, required, ok: true, reason: 'empty-database' } + } + + if (result.state !== 'ledger') { + // Say which situation this is. "Apply the migration" would be advice this + // branch cannot justify: what failed is reading the ledger, not the ledger + // reporting a gap. + throw new Error( + `update refused: ${module} asserts migration(s) ${required.join(', ')} at startup, and whether ` + + `${dbName} has applied ${required.length > 1 ? 'them' : 'it'} could NOT be determined ` + + `(${result.reason}). This is an unknown-state refusal, not a known-missing migration. Check the ` + + `database is up and that this host can reach it, then re-run; set ${SKIP_ENV}=1 to override once ` + + `you know the schema is current.` + ) + } + + const missing = required.filter(f => !result.applied.has(f)) + if (missing.length) throw new Error(refusalMessage(module, coin, network, dbName, missing)) + + return { checked: true, required, missing: [], ok: true } +} + +module.exports = { + MIGRATION_BEARING_MODULES, + SKIP_ENV, + LEDGER_TABLE, + migrationDeclaresDeployPrecondition, + listDeployPreconditionMigrations, + // Exported for the unit suite: the refusal path hinges on an unreachable + // database returning `unreadable` rather than throwing past the guard, and + // that is a property of the real driver call, not of a stub. + readAppliedMigrations: defaultReadAppliedMigrations, + assertRequiredMigrationsApplied +} diff --git a/src/services/ReleaseManifestService.js b/src/services/ReleaseManifestService.js index 93b28f6..1003ab9 100644 --- a/src/services/ReleaseManifestService.js +++ b/src/services/ReleaseManifestService.js @@ -31,6 +31,7 @@ const path = require('path') const axios = require('axios') const { githubApiHeaders, githubRateLimitError } = require('../GitHubDownloader') +const { verifyManifestForTag } = require('./ReleaseSignatureService') // The repo that carries the manifest. Pinned installs resolve their manifest // from a tag on THIS repo, never from a sibling. @@ -98,6 +99,10 @@ async function fetchManifestAtTag(tag) { // The manifest is fetched from the tag itself rather than from the running // checkout: `install v0.9.0` is routinely driven by a node already running // some other version, and that node's own manifest describes ITS train. + // The one path that does not run the signature gate below, deliberately: this + // file came out of the running checkout, whose own provenance was decided + // when the operator installed it (signed tag, verified clone). Re-verifying + // it here would prove only that the checkout agrees with itself. const local = readLocalManifest() if (local && local.platform_version && `v${local.platform_version}` === tag) { return local @@ -124,13 +129,74 @@ async function fetchManifestAtTag(tag) { throw new Error(`Release manifest for ${tag} came back empty`) } + const bytes = Buffer.from(body.content, body.encoding || 'base64') + + // PROVENANCE GATE (spec section 12). Everything downstream of here treats + // the manifest as authoritative - it decides which commit every component + // is cloned at - and up to this line it is just a file the same server + // served. Verify the release key covered these exact bytes BEFORE parsing + // them, so a tampered manifest is refused rather than acted on. + await verifyManifestBytes(tag, bytes) + try { - return JSON.parse(Buffer.from(body.content, body.encoding || 'base64').toString('utf8')) + return JSON.parse(bytes.toString('utf8')) } catch (err) { throw new Error(`Release manifest for ${tag} is not valid JSON: ${err.message}`) } } +/** + * Fetch one published asset from a release, or null when the release does not + * carry it (which the caller reads as "this release publishes no signature"). + */ +async function fetchReleaseAsset(tag, assetName) { + const url = `https://api.github.com/repos/${MANIFEST_OWNER}/${MANIFEST_REPO}/releases/tags/${tag}` + let release + try { + release = await axios.get(url, { headers: githubApiHeaders() }) + } catch (error) { + const rateLimited = githubRateLimitError(error) + if (rateLimited) throw rateLimited + if (error && error.response && error.response.status === 404) return null + throw error + } + + const assets = (release.data && release.data.assets) || [] + const asset = assets.find(entry => entry && entry.name === assetName) + if (!asset || !asset.url) return null + + // The asset endpoint answers a 302 to signed object storage, and that + // storage REJECTS a request still carrying our Authorization header (the + // same trap githubApiHeaders documents for coin-node downloads). So follow + // the redirect by hand and drop the credentials on the second hop. + const first = await axios.get(asset.url, { + headers: { ...githubApiHeaders(), Accept: 'application/octet-stream' }, + responseType: 'arraybuffer', + maxRedirects: 0, + validateStatus: status => (status >= 200 && status < 300) || [301, 302, 307, 308].includes(status) + }) + + if (first.status >= 300) { + const location = first.headers && first.headers.location + if (!location) throw new Error(`Release asset ${assetName} redirected without a location header`) + const followed = await axios.get(location, { + responseType: 'arraybuffer', + headers: { 'User-Agent': 'GitHubDownloader' } + }) + return Buffer.from(followed.data) + } + + return Buffer.from(first.data) +} + +async function verifyManifestBytes(tag, bytes) { + return verifyManifestForTag({ + tag, + manifestBytes: bytes, + fetchAsset: assetName => fetchReleaseAsset(tag, assetName) + }) +} + // The ONE latest-release semantic for the platform (spec section 5). // // Two paths existed and disagreed: VersionService hits `releases/latest`, which @@ -254,6 +320,7 @@ module.exports = { manifestHasPins, getComponentPin, fetchManifestAtTag, + fetchReleaseAsset, resolveLatestReleaseTag, resolveInstallTarget, resolveComponentRef, diff --git a/src/services/ReleaseSignatureService.js b/src/services/ReleaseSignatureService.js new file mode 100644 index 0000000..76e90fc --- /dev/null +++ b/src/services/ReleaseSignatureService.js @@ -0,0 +1,382 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * XChain Node - Release Signature Service + * + * Turns clone integrity into a PROVENANCE check. + * + * A pinned install verifies every clone against `release-manifest.json` + * (ReleaseManifestService + cloneIntegrity). That check is only as trustworthy + * as the manifest, and the manifest is fetched from the same repository the + * clones come from: unsigned, it proves the install is self-consistent, which + * anyone who can write to the org can also arrange. The signature is what makes + * it evidence of who published the release. + * + * The chain a verifier walks (release-management spec section 12): + * + * 1. the TAG signature proves who cut the release (git tag -v) + * 2. SHA256SUMS.asc proves the asset set is theirs (this file) + * 3. release-manifest.json pins every component to a commit + * 4. clone verification proves the tree IS that commit (cloneIntegrity) + * + * This service is step 2, plus the digest comparison that binds step 3 to it. + * + * TRUST ANCHOR: `tools/release/release-signing-key.asc`, shipped in this repo, + * pinned to PLATFORM_KEY_FINGERPRINT below. The canonical copy lives in + * xchain-documentation (operations/RELEASE-SIGNING-KEY.asc) and the fingerprint + * is published independently at https://xchain.io/security, so an operator can + * compare two channels rather than trust one. The copy here is what the code + * uses: a key fetched at verification time from the same place as the artifact + * verifies nothing. + * + * Verification runs in an EPHEMERAL gpg homedir. Using the operator's keyring + * would let any key they happen to trust satisfy the gate, and `gpg --verify` + * succeeding says only "some key in this keyring signed it", never "the release + * key signed it". Every check below is bound to the pinned fingerprint. + * + * OPT-OUT: XCHAIN_NODE_REQUIRE_SIGNED_RELEASE=0 (or false/no) for airgapped and + * development installs. It announces itself on every run; a gate that goes quiet + * when disabled is a gate nobody notices is off. + ********************************************************************/ + +const fs = require('fs') +const os = require('os') +const path = require('path') +const crypto = require('crypto') +const { execFileSync } = require('child_process') + +// The XChain Platform release key: RSA 4096, created 2026-07-23, expires +// 2036-07-20. NOT the wallet's keys - the wallet signs its tags and its release +// manifests with two different keys of its own, and confusing the three is a +// named hazard in wallet-release-rails.md. If a document or a check says "the +// release key" without a fingerprint, it is not saying which key. +const PLATFORM_KEY_FINGERPRINT = '1DA7C4896F56EA22CF491EDF4361611A82F90B70' + +const KEY_PATH = path.join(__dirname, '..', '..', 'tools', 'release', 'release-signing-key.asc') +const SUMS_ASSET = 'SHA256SUMS' +const SIG_ASSET = 'SHA256SUMS.asc' +const MANIFEST_ASSET = 'release-manifest.json' + +// Same shape as BootstrapIntegrityError: a refusal here is the gate working, +// and left as a bare Error it reaches the operator as a stack trace, which reads +// as "the installer is broken, retry it" when it means "this release is not +// what it claims to be, do not install it". +class ReleaseIntegrityError extends Error { + constructor(message) { + super(message) + this.name = 'ReleaseIntegrityError' + } +} + +function signatureCheckDisabled() { + return /^(0|false|no)$/i.test(process.env.XCHAIN_NODE_REQUIRE_SIGNED_RELEASE || '') +} + +function gpgBinary() { + return process.env.XCHAIN_NODE_GPG_BIN || 'gpg' +} + +function normalizeFingerprint(value) { + return String(value || '').replace(/\s+/g, '').toUpperCase() +} + +/** + * Parse a coreutils-format SHA256SUMS body. + * + * Strict on purpose. A line this parser cannot read is not skipped, because a + * skipped line is an artifact whose digest silently stops being checked, and a + * duplicate name is an ambiguity a verifier must never resolve by "last wins". + * + * @param {string} text + * @returns {Map} filename -> lowercase hex digest + */ +function parseSha256sums(text) { + const entries = new Map() + + String(text).split('\n').forEach((rawLine, index) => { + const line = rawLine.replace(/\r$/, '') + if (line.trim() === '') return + + // `<64 hex>`: two spaces is text mode, + // ` *` is coreutils binary mode. Both are emitted in the wild, so both + // are accepted; anything else is a malformed digest file. + const match = /^([0-9a-fA-F]{64}) [ *](.+)$/.exec(line) + if (!match) { + throw new ReleaseIntegrityError( + `SHA256SUMS line ${index + 1} is malformed: ${JSON.stringify(line)}.` + + ' Refusing to verify against a digest file that cannot be read exactly.' + ) + } + + const name = match[2].trim() + if (entries.has(name)) { + throw new ReleaseIntegrityError( + `SHA256SUMS lists '${name}' more than once. Refusing an ambiguous digest file.` + ) + } + entries.set(name, match[1].toLowerCase()) + }) + + if (entries.size === 0) { + throw new ReleaseIntegrityError('SHA256SUMS is empty; there is nothing to verify against.') + } + + return entries +} + +function sha256(buffer) { + return crypto.createHash('sha256').update(buffer).digest('hex') +} + +/** + * Verify a detached armored signature against the pinned release key. + * + * @param {object} args + * @param {Buffer} args.data the signed bytes (SHA256SUMS) + * @param {Buffer} args.signature the detached armored signature + * @param {string} [args.keyPath] trust anchor; defaults to the repo-shipped key + * @param {string} [args.fingerprint] expected primary key fingerprint + * @returns {{fingerprint: string}} + * @throws {ReleaseIntegrityError} + */ +function verifyDetachedSignature({ data, signature, keyPath = KEY_PATH, fingerprint = PLATFORM_KEY_FINGERPRINT }) { + const expected = normalizeFingerprint(fingerprint) + if (!/^[0-9A-F]{40}$/.test(expected)) { + throw new ReleaseIntegrityError( + `Release signing key is not pinned to a 40-hex fingerprint (got ${JSON.stringify(fingerprint)}).` + ) + } + + if (!fs.existsSync(keyPath)) { + throw new ReleaseIntegrityError( + `No release signing key is shipped at ${keyPath}. The trust anchor must travel with the` + + ' code; fetching it at verification time proves nothing.' + ) + } + + const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'xchain-relsig-')) + const homeDir = path.join(workDir, 'gnupg') + fs.mkdirSync(homeDir, { mode: 0o700 }) + + const dataFile = path.join(workDir, SUMS_ASSET) + const sigFile = path.join(workDir, SIG_ASSET) + fs.writeFileSync(dataFile, data) + fs.writeFileSync(sigFile, signature) + + const gpg = (args, opts = {}) => execFileSync(gpgBinary(), [ + '--batch', '--no-tty', '--quiet', '--homedir', homeDir, ...args + ], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...opts }) + + try { + try { + gpg(['--import', keyPath]) + } catch (err) { + if (err && err.code === 'ENOENT') { + throw new ReleaseIntegrityError( + 'gpg is not installed, so this release cannot be verified. Install gnupg, or set' + + ' XCHAIN_NODE_REQUIRE_SIGNED_RELEASE=0 to install without provenance checks.' + ) + } + throw new ReleaseIntegrityError(`Could not import the pinned release key: ${describeGpgError(err)}`) + } + + let status = '' + try { + // --status-fd 1 puts the machine-readable verdict on stdout. The + // human-readable text on stderr is advisory; every decision below + // reads the status lines, because the prose has changed shape + // between gpg versions and the status protocol has not. + status = gpg(['--status-fd', '1', '--verify', sigFile, dataFile]) + } catch (err) { + throw new ReleaseIntegrityError( + `${SIG_ASSET} does not verify against the pinned release key` + + ` (${expected}): ${describeGpgError(err)}` + ) + } + + assertStatusIsGood(status, expected) + return { fingerprint: expected } + } finally { + fs.rmSync(workDir, { recursive: true, force: true }) + } +} + +function describeGpgError(err) { + const detail = [err && err.stderr, err && err.stdout, err && err.message] + .map(part => (part ? String(part).trim() : '')) + .filter(Boolean) + .join(' | ') + return detail || 'gpg failed with no output' +} + +// gpg exiting zero is not the verdict. An expired or revoked key still produces +// a "good" signature line and exit status 0, and a signature made by any other +// key in the keyring would pass a naive check. Bind the result to the pin. +function assertStatusIsGood(status, expected) { + const lines = String(status).split('\n').map(line => line.trim()) + const flag = name => lines.some(line => line.startsWith(`[GNUPG:] ${name}`)) + + if (flag('REVKEYSIG')) { + throw new ReleaseIntegrityError(`${SIG_ASSET} was signed with a REVOKED key. Refusing this release.`) + } + if (flag('EXPKEYSIG')) { + throw new ReleaseIntegrityError(`${SIG_ASSET} was signed with an EXPIRED key. Refusing this release.`) + } + if (flag('BADSIG') || !flag('GOODSIG')) { + throw new ReleaseIntegrityError(`${SIG_ASSET} is not a good signature over ${SUMS_ASSET}.`) + } + + // VALIDSIG's first field is the fingerprint of the key that made the + // signature (a subkey, when a subkey signed) and its tenth is the primary + // key's. Accepting either is what lets the pin stay on the primary key + // through a future signing-subkey rotation without loosening it. + const validsig = lines.find(line => line.startsWith('[GNUPG:] VALIDSIG ')) + if (!validsig) { + throw new ReleaseIntegrityError(`${SIG_ASSET} produced no VALIDSIG line; refusing an unverified release.`) + } + + const fields = validsig.replace('[GNUPG:] VALIDSIG ', '').split(/\s+/) + const signing = normalizeFingerprint(fields[0]) + const primary = normalizeFingerprint(fields[9]) + + if (signing !== expected && primary !== expected) { + throw new ReleaseIntegrityError( + `${SIG_ASSET} is signed, but not by the pinned release key.` + + ` Expected ${expected}, got ${signing || 'nothing'}.` + + ' A valid signature by the wrong key is not an official release.' + ) + } +} + +/** + * Bind one artifact to a verified digest file. + * + * @param {object} args + * @param {string} args.sumsText verified SHA256SUMS body + * @param {Buffer} args.bytes the artifact as fetched + * @param {string} args.name its name in SHA256SUMS + */ +function assertDigestMatches({ sumsText, bytes, name }) { + const entries = parseSha256sums(sumsText) + const expected = entries.get(name) + + if (!expected) { + throw new ReleaseIntegrityError( + `${SUMS_ASSET} does not list '${name}', so nothing signed covers it.` + + ` Listed: ${[...entries.keys()].join(', ')}` + ) + } + + const actual = sha256(bytes) + if (actual !== expected) { + throw new ReleaseIntegrityError( + `'${name}' does not match the signed digest (expected ${expected}, got ${actual}).` + + ' Refusing to install from an artifact the release key did not cover.' + ) + } +} + +/** + * The install-time gate: verify a release's manifest bytes against the signed + * digest file published with that release. + * + * `fetchAsset` is injected rather than imported so the network path stays with + * the caller that already owns GitHub access (and so this is testable without + * one). It resolves to a Buffer, or null when the release carries no such asset. + * + * `keyPath` and `fingerprint` default to the pins and exist so the gate itself + * can be exercised against a scratch key. No install path passes them: an + * install that could choose its own trust anchor would not have one. + * + * @param {object} args + * @param {string} args.tag + * @param {Buffer} args.manifestBytes + * @param {function} args.fetchAsset (assetName) => Promise + * @param {object} [args.logger] + * @param {string} [args.keyPath] + * @param {string} [args.fingerprint] + * @returns {Promise<{verified: boolean, fingerprint?: string, reason?: string}>} + */ +async function verifyManifestForTag({ + tag, manifestBytes, fetchAsset, logger = console, + keyPath = KEY_PATH, fingerprint = PLATFORM_KEY_FINGERPRINT +}) { + const disabled = signatureCheckDisabled() + + const refuse = (message) => { + if (!disabled) { + throw new ReleaseIntegrityError( + `${message} Signed releases are required by default;` + + ' set XCHAIN_NODE_REQUIRE_SIGNED_RELEASE=0 to install anyway (airgapped/dev use).' + ) + } + // The opt-out is loud on every run, and says what it gave up rather + // than just that it is on: an operator who sees this line is being told + // the install is pinned but not attributed. + logger.warn( + `WARNING: installing ${tag} WITHOUT release signature verification (${message.trim()})` + + ' XCHAIN_NODE_REQUIRE_SIGNED_RELEASE=0 is set, so this install is pinned but its' + + ' provenance is unproven: the manifest is trusted because it was served, not because' + + ' the release key covered it.' + ) + return { verified: false, reason: message.trim() } + } + + let sums, sig + try { + [sums, sig] = await Promise.all([fetchAsset(SUMS_ASSET), fetchAsset(SIG_ASSET)]) + } catch (err) { + return refuse(`Could not fetch the signed digest files for ${tag} (${err.message}).`) + } + + if (!sums || !sig) { + const missing = [!sums && SUMS_ASSET, !sig && SIG_ASSET].filter(Boolean).join(' and ') + return refuse(`Release ${tag} publishes no ${missing}.`) + } + + // Signature first, then digests. Reading the digest file before proving who + // wrote it would mean acting on unverified input, and the failure message + // would name a mismatch when the real finding is an unsigned release. + let verified + try { + verified = verifyDetachedSignature({ data: sums, signature: sig, keyPath, fingerprint }) + } catch (err) { + if (disabled) return refuse(`${err.message}`) + throw err + } + + try { + assertDigestMatches({ sumsText: sums.toString('utf8'), bytes: manifestBytes, name: MANIFEST_ASSET }) + } catch (err) { + if (disabled) return refuse(`${err.message}`) + throw err + } + + logger.log(`Release ${tag}: ${SIG_ASSET} verified against ${verified.fingerprint}, ${MANIFEST_ASSET} digest matches.`) + return { verified: true, fingerprint: verified.fingerprint } +} + +module.exports = { + PLATFORM_KEY_FINGERPRINT, + KEY_PATH, + SUMS_ASSET, + SIG_ASSET, + MANIFEST_ASSET, + ReleaseIntegrityError, + signatureCheckDisabled, + parseSha256sums, + sha256, + verifyDetachedSignature, + assertDigestMatches, + verifyManifestForTag +} diff --git a/test/integration/database-setup.test.js b/test/integration/database-setup.test.js index c3be6be..86a5bc7 100644 --- a/test/integration/database-setup.test.js +++ b/test/integration/database-setup.test.js @@ -197,9 +197,32 @@ describe('Integration: Database Service Chain', function () { expect(runEnv.MYSQL_ROOT_PASSWORD).to.equal('testrootpw') expect(runCmd).to.include('xchain-node-database') + // What the install branch owes its caller is the new container id; + // every downstream provisioning step keys off that return value. + expect(result).to.equal(dbContainerId) + + // It does NOT write a `modules` registry row for the database + // module, and that absence is deliberate rather than a missing + // insert (XC-1473). This branch is what CREATES the MariaDB + // container, and the registry table lives INSIDE that very + // container: at this point there is no xchain_node database, no + // open pool, and no `modules` table to insert into, so registering + // the DB module in its own registry is circular. Nothing in src/ + // reads such a row to find the DB either - every lookup goes + // through DatabaseService.getDatabaseContainerId(), which resolves + // the id with `docker inspect` on the container NAME, precisely + // because it has to work before the registry exists. The row does + // show up later, written by DiscoveryService.discoverContainers() + // (DB_MODULE_NAME is in its SHARED_MODULES list), which runs once + // there is a registry to write into and is what puts the database + // line into `ps`. + // + // Asserting the absence rather than dropping the check keeps the + // decision visible: a future change that starts writing here has to + // be deliberate about the ordering problem above. const state = require('../../src/state') const storedId = await state.db.getModuleContainer(DB_MODULE_NAME, '', '') - expect(storedId).to.equal(dbContainerId) + expect(storedId).to.equal(null) }) it('includes network flag when coin/network are provided', async function () { diff --git a/test/unit/MigrationPreconditionService.test.js b/test/unit/MigrationPreconditionService.test.js new file mode 100644 index 0000000..19e3c12 --- /dev/null +++ b/test/unit/MigrationPreconditionService.test.js @@ -0,0 +1,257 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// Deploy-time migration precondition guard (XC-1335). A service whose new +// source asserts a gated migration at startup must be REFUSED when the target +// database has not applied it, before the container is recreated - and must +// stay inert everywhere else, or every routine deploy starts failing. + +const fs = require('fs') +const os = require('os') +const path = require('path') +const sinon = require('sinon') +const { expect } = require('chai') + +const { XChainService } = require('../../src/config/constants') +const { + MIGRATION_BEARING_MODULES, + SKIP_ENV, + migrationDeclaresDeployPrecondition, + listDeployPreconditionMigrations, + readAppliedMigrations, + assertRequiredMigrationsApplied +} = require('../../src/services/MigrationPreconditionService') + +const GATED = '2026-07-24-pubkeys-widen-uncompressed.sql' + +const TAGGED = '-- xchain:migration mode=manual deploy-precondition=required\nALTER TABLE pubkeys MODIFY pubkey VARCHAR(130) NOT NULL;\n' +const UNTAGGED = '-- xchain:migration mode=manual\nALTER TABLE pubkeys MODIFY pubkey VARCHAR(130) NOT NULL;\n' + +function makeDeps({ required = [GATED], applied = [GATED], state = 'ledger', reason = 'connection refused', cloneErr = null } = {}) { + return { + cloneGit: cloneErr ? sinon.stub().rejects(cloneErr) : sinon.stub().resolves(), + listDeployPreconditionMigrations: sinon.stub().returns(required), + readAppliedMigrations: sinon.stub().resolves( + state === 'ledger' ? { state: 'ledger', applied: new Set(applied) } : { state, reason }) + } +} + +describe('MigrationPreconditionService', () => { + + let warnStub + beforeEach(() => { warnStub = sinon.stub(console, 'warn') }) + afterEach(() => { + warnStub.restore() + delete process.env[SKIP_ENV] + }) + + describe('migrationDeclaresDeployPrecondition', () => { + it('reads the tag off the xchain:migration directive line', () => { + expect(migrationDeclaresDeployPrecondition(TAGGED)).to.equal(true) + }) + it('tolerates spacing around the token', () => { + expect(migrationDeclaresDeployPrecondition('-- xchain:migration mode = manual deploy-precondition = required\nALTER TABLE t;')).to.equal(true) + }) + it('is false for an ordinary migration and for an empty file', () => { + expect(migrationDeclaresDeployPrecondition(UNTAGGED)).to.equal(false) + expect(migrationDeclaresDeployPrecondition('')).to.equal(false) + }) + it('ignores the token once the SQL body has started', () => { + // Prologue anchoring: a migration that merely DISCUSSES the convention in a + // trailing comment must not start refusing every deploy. + expect(migrationDeclaresDeployPrecondition('ALTER TABLE t;\n-- xchain:migration mode=manual deploy-precondition=required\n')).to.equal(false) + }) + it('ignores the token on a comment line that is not the directive', () => { + expect(migrationDeclaresDeployPrecondition('-- deploy-precondition=required, see the other file\nALTER TABLE t;')).to.equal(false) + }) + it('sees the tag through a long license banner', () => { + const banner = Array(30).fill('-- license line').join('\n') + expect(migrationDeclaresDeployPrecondition(banner + '\n\n' + TAGGED)).to.equal(true) + }) + }) + + describe('listDeployPreconditionMigrations', () => { + let dir + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'xcn-mig-')) + }) + afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }) }) + + it('returns only the tagged .sql files, sorted', () => { + fs.writeFileSync(path.join(dir, '2026-07-24-b.sql'), TAGGED) + fs.writeFileSync(path.join(dir, '2026-07-01-a.sql'), TAGGED) + fs.writeFileSync(path.join(dir, '2026-07-30-c.sql'), UNTAGGED) + fs.writeFileSync(path.join(dir, 'notes.txt'), TAGGED) + expect(listDeployPreconditionMigrations(dir)).to.deep.equal(['2026-07-01-a.sql', '2026-07-24-b.sql']) + }) + + it('returns [] for a missing directory (a ref with no migrations declares nothing)', () => { + expect(listDeployPreconditionMigrations(path.join(dir, 'nope'))).to.deep.equal([]) + }) + + it('reads the REAL indexer tree and finds the migration behind the 2026-08-09 halt', function () { + // Guards the whole contract end to end: if the tag is ever dropped from the + // committed file, or the migrations path moves, this guard silently stops + // protecting the deploy that caused the outage. Skipped when xchain-node is + // checked out on its own, without the sibling indexer tree beside it. + const indexerMigrations = path.join(__dirname, '..', '..', '..', 'xchain-indexer', 'src', 'sql', 'migrations') + if (!fs.existsSync(indexerMigrations)) return this.skip() + expect(listDeployPreconditionMigrations(indexerMigrations)).to.include(GATED) + }) + }) + + describe('readAppliedMigrations', () => { + + const target = { database: 'XChain_BTC_Mainnet_Indexer', coin: 'bitcoin', network: 'mainnet' } + + // Fake mariadb batch-mode output: one value per COUNT query, newline-joined + // names for the ledger read - the exact shapes `-B -N` produces. + function runnerFor({ tables = 40, ledger = 1, names = [GATED] }) { + return async (sql) => { + if (/TABLE_NAME = 'schema_migrations'/.test(sql)) return String(ledger) + if (/COUNT\(\*\)/.test(sql)) return String(tables) + return names.join('\n') + } + } + + it('reads the ledger into a set of applied names', async () => { + const res = await readAppliedMigrations(target, { runner: runnerFor({ names: [GATED, '2026-08-11-attests-relay-identity-index.sql'] }) }) + expect(res.state).to.equal('ledger') + expect([...res.applied]).to.have.members([GATED, '2026-08-11-attests-relay-identity-index.sql']) + }) + + it('tolerates a ledger read that comes back empty', async () => { + const res = await readAppliedMigrations(target, { runner: runnerFor({ names: [] }) }) + expect(res.state).to.equal('ledger') + expect(res.applied.size).to.equal(0) + }) + + it('calls a database with no tables empty, not unreadable', async () => { + const res = await readAppliedMigrations(target, { runner: runnerFor({ tables: 0 }) }) + expect(res.state).to.equal('empty-database') + }) + + it('calls a populated database with no ledger table UNREADABLE, never empty', async () => { + // Waving this through would be the whole outage again: a real schema whose + // migration state nobody can see. + const res = await readAppliedMigrations(target, { runner: runnerFor({ tables: 40, ledger: 0 }) }) + expect(res.state).to.equal('unreadable') + expect(res.reason).to.contain('schema_migrations') + }) + + it('turns a driver failure into unreadable instead of throwing past the guard', async () => { + // A throw here would escape assertRequiredMigrationsApplied as an opaque + // driver error, and the operator would read ECONNREFUSED with no idea a + // migration was at stake. + const res = await readAppliedMigrations(target, { + runner: async () => { throw new Error('ECONNREFUSED 127.0.0.1:13306') } + }) + expect(res.state).to.equal('unreadable') + expect(res.reason).to.contain('ECONNREFUSED') + }) + + it('refuses a database name that is not a plain identifier, before any query runs', async () => { + let called = false + const res = await readAppliedMigrations( + { database: 'x`; DROP DATABASE y; -- ', coin: 'bitcoin', network: 'mainnet' }, + { runner: async () => { called = true; return '0' } }) + expect(res.state).to.equal('unreadable') + expect(called, 'nothing may reach SQL').to.equal(false) + }) + }) + + describe('assertRequiredMigrationsApplied', () => { + + it('is inert for a module that ships no migrations', async () => { + const deps = makeDeps() + const res = await assertRequiredMigrationsApplied(XChainService.XCHAIN_ENCODER, 'bitcoin', 'mainnet', 'master', deps) + expect(res).to.deep.equal({ checked: false, reason: 'no-migrations' }) + expect(deps.cloneGit.called).to.equal(false) + }) + + it('covers the indexer and the decoder, the two migration-bearing modules', () => { + expect(MIGRATION_BEARING_MODULES).to.have.members([XChainService.XCHAIN_INDEXER, XChainService.XCHAIN_DECODER]) + }) + + it('proceeds, loudly, when the skip env is set', async () => { + process.env[SKIP_ENV] = '1' + const deps = makeDeps({ applied: [] }) + const res = await assertRequiredMigrationsApplied(XChainService.XCHAIN_INDEXER, 'bitcoin', 'mainnet', 'master', deps) + expect(res.reason).to.equal('skipped-by-env') + expect(warnStub.called).to.equal(true) + }) + + it('refuses when the target DB has not applied a declared precondition', async () => { + const deps = makeDeps({ applied: ['2026-07-21-anchor-reward-attestations-table.sql'] }) + let err = null + try { + await assertRequiredMigrationsApplied(XChainService.XCHAIN_INDEXER, 'bitcoin', 'mainnet', 'master', deps) + } catch (e) { err = e } + expect(err, 'the deploy must be refused').to.not.equal(null) + expect(err.message).to.contain(GATED) + expect(err.message).to.contain('XChain_BTC_Mainnet_Indexer') + expect(err.message).to.contain('--file ' + GATED) + }) + + it('reads the source tree about to be deployed, at the pinned ref', async () => { + const deps = makeDeps() + await assertRequiredMigrationsApplied(XChainService.XCHAIN_INDEXER, 'bitcoin', 'mainnet', 'release-1.2.3', deps) + expect(deps.cloneGit.calledWith(XChainService.XCHAIN_INDEXER, false, true, 'release-1.2.3')).to.equal(true) + }) + + it('passes when every declared precondition is in the ledger', async () => { + const deps = makeDeps() + const res = await assertRequiredMigrationsApplied(XChainService.XCHAIN_INDEXER, 'bitcoin', 'mainnet', 'master', deps) + expect(res.ok).to.equal(true) + expect(res.missing).to.deep.equal([]) + }) + + it('does not touch the database when the target source declares no preconditions', async () => { + const deps = makeDeps({ required: [] }) + const res = await assertRequiredMigrationsApplied(XChainService.XCHAIN_INDEXER, 'bitcoin', 'mainnet', 'master', deps) + expect(res).to.deep.equal({ checked: false, reason: 'no-preconditions' }) + expect(deps.readAppliedMigrations.called).to.equal(false) + }) + + it('proceeds on a genuinely empty database (a fresh install cannot be behind)', async () => { + const deps = makeDeps({ state: 'empty-database' }) + const res = await assertRequiredMigrationsApplied(XChainService.XCHAIN_INDEXER, 'bitcoin', 'mainnet', 'master', deps) + expect(res.ok).to.equal(true) + expect(res.reason).to.equal('empty-database') + }) + + it('refuses when the migration state cannot be read, and says so is not the same as missing', async () => { + const deps = makeDeps({ state: 'unreadable', reason: 'ECONNREFUSED 127.0.0.1:13306' }) + let err = null + try { + await assertRequiredMigrationsApplied(XChainService.XCHAIN_INDEXER, 'bitcoin', 'mainnet', 'master', deps) + } catch (e) { err = e } + expect(err, 'an unknown migration state must fail closed').to.not.equal(null) + expect(err.message).to.contain('could NOT be determined') + expect(err.message).to.contain('ECONNREFUSED') + expect(err.message).to.contain(SKIP_ENV) + }) + + it('proceeds with a warning when the source itself cannot be cloned', async () => { + // The update is about to fail on the same clone; adding a second failure + // mode here would only obscure the real one. + const deps = makeDeps({ cloneErr: new Error('network down') }) + const res = await assertRequiredMigrationsApplied(XChainService.XCHAIN_INDEXER, 'bitcoin', 'mainnet', 'master', deps) + expect(res).to.deep.equal({ checked: false, reason: 'source-unreadable' }) + expect(warnStub.called).to.equal(true) + }) + + it('checks the database belonging to the module, coin and network being updated', async () => { + const deps = makeDeps() + await assertRequiredMigrationsApplied(XChainService.XCHAIN_DECODER, 'litecoin', 'testnet', 'master', deps) + const arg = deps.readAppliedMigrations.firstCall.args[0] + expect(arg.database).to.equal('XChain_LTC_Testnet_Decoder') + expect(arg.coin).to.equal('litecoin') + expect(arg.network).to.equal('testnet') + }) + }) +}) diff --git a/test/unit/ReleaseManifestService.test.js b/test/unit/ReleaseManifestService.test.js index 4438b1d..53a8be9 100644 --- a/test/unit/ReleaseManifestService.test.js +++ b/test/unit/ReleaseManifestService.test.js @@ -20,7 +20,13 @@ const OTHER_SHA = 'b'.repeat(40) function makeStubs() { return { axiosGet: sinon.stub(), - readFileSync: sinon.stub().throws(new Error('ENOENT')) + readFileSync: sinon.stub().throws(new Error('ENOENT')), + // The provenance gate is exercised for real (gpg and all) in + // ReleaseSignatureService.test.js. Here it is stubbed so these cases stay + // about manifest RESOLUTION, and asserted on so the wiring cannot be + // removed silently: an unverified manifest is the defect this whole + // service was rebuilt to prevent. + verifyManifestForTag: sinon.stub().resolves({ verified: true, fingerprint: 'F'.repeat(40) }) } } @@ -31,6 +37,9 @@ function load(stubs) { '../GitHubDownloader': { githubApiHeaders: () => ({}), githubRateLimitError: () => null + }, + './ReleaseSignatureService': { + verifyManifestForTag: stubs.verifyManifestForTag } }) } @@ -271,6 +280,72 @@ describe('ReleaseManifestService', () => { const t = await local.resolveInstallTarget('v0.9.0') expect(t.manifest.components['xchain-vm'].commit).to.equal(OTHER_SHA) expect(stubs.axiosGet.called).to.equal(false) + // No signature check on this path, deliberately: the bytes came out + // of the running checkout, and verifying them here would only prove + // the checkout agrees with itself. + expect(stubs.verifyManifestForTag.called).to.equal(false) + }) + }) + + describe('the provenance gate', () => { + // Section 12: clone integrity checks a clone against a manifest fetched + // from the same place the clone came from. Signing the manifest is what + // turns that from a consistency check into a provenance check, so a + // FETCHED manifest must never reach a caller unverified. + it('verifies the fetched manifest BEFORE parsing it', async () => { + const body = manifest({ 'xchain-vm': { tag: 'v0.9.0', commit: PIN_SHA } }) + stubs.axiosGet.resolves(contentsResponse(body)) + await svc.resolveInstallTarget('v0.9.0') + + expect(stubs.verifyManifestForTag.calledOnce).to.equal(true) + const args = stubs.verifyManifestForTag.firstCall.args[0] + expect(args.tag).to.equal('v0.9.0') + // The EXACT bytes that were fetched, not a re-serialization of them: + // a digest is a statement about bytes. + expect(args.manifestBytes.toString('utf8')).to.equal(JSON.stringify(body)) + expect(args.fetchAsset).to.be.a('function') + }) + + it('a refusal from the gate aborts the install', async () => { + stubs.axiosGet.resolves(contentsResponse(manifest())) + stubs.verifyManifestForTag.rejects(new Error('Release v0.9.0 publishes no SHA256SUMS.asc.')) + await svc.resolveInstallTarget('v0.9.0').then( + () => { throw new Error('should have rejected') }, + e => expect(e.message).to.match(/publishes no SHA256SUMS\.asc/)) + }) + + it('fetchReleaseAsset returns null when the release carries no such asset', async () => { + stubs.axiosGet.resolves({ data: { assets: [{ name: 'SHA256SUMS', url: 'https://api/asset/1' }] } }) + expect(await svc.fetchReleaseAsset('v0.9.0', 'SHA256SUMS.asc')).to.equal(null) + }) + + it('fetchReleaseAsset returns null when the release itself is absent', async () => { + const err = new Error('Not Found'); err.response = { status: 404 } + stubs.axiosGet.rejects(err) + expect(await svc.fetchReleaseAsset('v9.9.9', 'SHA256SUMS')).to.equal(null) + }) + + it('fetchReleaseAsset downloads the asset bytes', async () => { + stubs.axiosGet.onFirstCall().resolves({ data: { assets: [{ name: 'SHA256SUMS', url: 'https://api/asset/1' }] } }) + stubs.axiosGet.onSecondCall().resolves({ status: 200, data: Buffer.from('digests\n') }) + const bytes = await svc.fetchReleaseAsset('v0.9.0', 'SHA256SUMS') + expect(bytes.toString('utf8')).to.equal('digests\n') + expect(stubs.axiosGet.secondCall.args[1].headers.Accept).to.equal('application/octet-stream') + }) + + it('fetchReleaseAsset follows the storage redirect WITHOUT the credentials', async () => { + // Signed object storage rejects a request that still carries our + // Authorization header, which is why the redirect is followed by + // hand rather than by axios. + stubs.axiosGet.onCall(0).resolves({ data: { assets: [{ name: 'SHA256SUMS', url: 'https://api/asset/1' }] } }) + stubs.axiosGet.onCall(1).resolves({ status: 302, headers: { location: 'https://objects/blob' }, data: null }) + stubs.axiosGet.onCall(2).resolves({ status: 200, data: Buffer.from('digests\n') }) + + const bytes = await svc.fetchReleaseAsset('v0.9.0', 'SHA256SUMS') + expect(bytes.toString('utf8')).to.equal('digests\n') + expect(stubs.axiosGet.thirdCall.args[0]).to.equal('https://objects/blob') + expect(stubs.axiosGet.thirdCall.args[1].headers).to.not.have.property('Authorization') + expect(stubs.axiosGet.thirdCall.args[1].headers).to.not.have.property('Accept') }) }) diff --git a/test/unit/ReleaseSignatureService.test.js b/test/unit/ReleaseSignatureService.test.js new file mode 100644 index 0000000..d936391 --- /dev/null +++ b/test/unit/ReleaseSignatureService.test.js @@ -0,0 +1,372 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const fs = require('fs') +const os = require('os') +const path = require('path') +const crypto = require('crypto') +const { execFileSync } = require('child_process') +const { expect } = require('chai') + +const svc = require('../../src/services/ReleaseSignatureService') + +const digestOf = buf => crypto.createHash('sha256').update(buf).digest('hex') + +// A real gpg key, generated once per run into a scratch homedir. The gate is +// "does a signature by the pinned key pass and a signature by any other key +// fail", and that question cannot be answered with a stubbed verifier: every +// bug this file is here to catch (accepting any key in the keyring, reading the +// prose instead of the status protocol, treating exit 0 as the verdict) lives +// inside the gpg call itself. +function makeKeyring(name) { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'xchain-testkey-')) + fs.chmodSync(home, 0o700) + // Unprotected key, generated non-interactively: loopback pinentry with an + // empty passphrase is what stops gpg reaching for a tty it does not have. + execFileSync('gpg', [ + '--batch', '--no-tty', '--quiet', '--homedir', home, + '--pinentry-mode', 'loopback', '--passphrase', '', + '--quick-generate-key', `${name} <${name}@example.invalid>`, 'ed25519', 'sign', 'never' + ], { stdio: ['ignore', 'pipe', 'pipe'] }) + + const colons = execFileSync('gpg', [ + '--batch', '--no-tty', '--homedir', home, '--with-colons', '--fingerprint', '--list-keys' + ], { encoding: 'utf8' }) + const fingerprint = colons.split('\n').find(line => line.startsWith('fpr:')).split(':')[9] + + const keyPath = path.join(home, 'public.asc') + fs.writeFileSync(keyPath, execFileSync('gpg', [ + '--batch', '--no-tty', '--homedir', home, '--armor', '--export', fingerprint + ], { encoding: 'utf8' })) + + return { + home, + fingerprint, + keyPath, + sign(data) { + const dataPath = path.join(home, `data-${crypto.randomBytes(4).toString('hex')}`) + fs.writeFileSync(dataPath, data) + return execFileSync('gpg', [ + '--batch', '--yes', '--no-tty', '--homedir', home, + '--local-user', fingerprint, '--armor', '--detach-sign', '--output', '-', dataPath + ]) + }, + cleanup() { fs.rmSync(home, { recursive: true, force: true }) } + } +} + +function gpgAvailable() { + try { + execFileSync('gpg', ['--version'], { stdio: 'ignore' }) + return true + } catch { + return false + } +} + +describe('ReleaseSignatureService', () => { + afterEach(() => { + delete process.env.XCHAIN_NODE_REQUIRE_SIGNED_RELEASE + }) + + describe('the pinned trust anchor', () => { + it('ships the release key inside this repo', () => { + // Fetching the key at verification time would prove nothing: the + // anchor has to travel with the code. + expect(fs.existsSync(svc.KEY_PATH)).to.equal(true) + expect(fs.readFileSync(svc.KEY_PATH, 'utf8')).to.match(/^-----BEGIN PGP PUBLIC KEY BLOCK-----/) + }) + + it('pins the PLATFORM key, and it is the key the shipped file contains', function () { + if (!gpgAvailable()) return this.skip() + + // Two channels compared by a test rather than one generated from the + // other: the constant is written out by hand from the published + // fingerprint, the file is the key itself. + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'xchain-anchor-')) + fs.chmodSync(home, 0o700) + try { + execFileSync('gpg', ['--batch', '--no-tty', '--quiet', '--homedir', home, '--import', svc.KEY_PATH], + { stdio: ['ignore', 'pipe', 'pipe'] }) + const colons = execFileSync('gpg', [ + '--batch', '--no-tty', '--homedir', home, '--with-colons', '--fingerprint', '--list-keys' + ], { encoding: 'utf8' }) + const fingerprints = colons.split('\n').filter(l => l.startsWith('fpr:')).map(l => l.split(':')[9]) + expect(fingerprints).to.include(svc.PLATFORM_KEY_FINGERPRINT) + } finally { + fs.rmSync(home, { recursive: true, force: true }) + } + }) + + it('is NOT the wallet key (the named confusion hazard)', () => { + // wallet-release-rails.md names three keys and requires anything + // saying "the release key" to say which one. K1 and K14 sign the + // wallet; this constant must never drift onto either. + expect(svc.PLATFORM_KEY_FINGERPRINT).to.equal('1DA7C4896F56EA22CF491EDF4361611A82F90B70') + }) + }) + + describe('parseSha256sums()', () => { + it('reads coreutils text and binary mode lines', () => { + const entries = svc.parseSha256sums( + `${'a'.repeat(64)} release-manifest.json\n${'b'.repeat(64)} *xchain-vm-0.9.0.tar.gz\n` + ) + expect(entries.get('release-manifest.json')).to.equal('a'.repeat(64)) + expect(entries.get('xchain-vm-0.9.0.tar.gz')).to.equal('b'.repeat(64)) + }) + + it('lowercases digests so comparison never depends on case', () => { + const entries = svc.parseSha256sums(`${'A'.repeat(64)} file\n`) + expect(entries.get('file')).to.equal('a'.repeat(64)) + }) + + it('REFUSES a malformed line instead of skipping it', () => { + // A skipped line is an artifact that silently stops being checked. + expect(() => svc.parseSha256sums(`${'a'.repeat(64)} ok\nnot a digest line\n`)) + .to.throw(svc.ReleaseIntegrityError, /line 2 is malformed/) + }) + + it('REFUSES a duplicated filename rather than letting the last win', () => { + expect(() => svc.parseSha256sums(`${'a'.repeat(64)} f\n${'b'.repeat(64)} f\n`)) + .to.throw(svc.ReleaseIntegrityError, /more than once/) + }) + + it('REFUSES an empty digest file', () => { + expect(() => svc.parseSha256sums('\n\n')).to.throw(svc.ReleaseIntegrityError, /empty/) + }) + + it('tolerates CRLF, which a digest file round-tripped through Windows carries', () => { + const entries = svc.parseSha256sums(`${'a'.repeat(64)} file\r\n`) + expect(entries.get('file')).to.equal('a'.repeat(64)) + }) + }) + + describe('assertDigestMatches()', () => { + const bytes = Buffer.from('{"platform_version":"0.9.0"}') + + it('passes when the artifact matches its signed digest', () => { + const sums = `${digestOf(bytes)} release-manifest.json\n` + expect(() => svc.assertDigestMatches({ sumsText: sums, bytes, name: 'release-manifest.json' })) + .to.not.throw() + }) + + it('REFUSES an artifact the digest file does not list', () => { + const sums = `${digestOf(bytes)} something-else\n` + expect(() => svc.assertDigestMatches({ sumsText: sums, bytes, name: 'release-manifest.json' })) + .to.throw(svc.ReleaseIntegrityError, /does not list 'release-manifest.json'/) + }) + + it('REFUSES a tampered artifact', () => { + const sums = `${digestOf(bytes)} release-manifest.json\n` + const tampered = Buffer.from('{"platform_version":"0.9.0"} ') + expect(() => svc.assertDigestMatches({ sumsText: sums, bytes: tampered, name: 'release-manifest.json' })) + .to.throw(svc.ReleaseIntegrityError, /does not match the signed digest/) + }) + }) + + describe('verifyDetachedSignature()', () => { + let key, other + + before(function () { + if (!gpgAvailable()) return this.skip() + this.timeout(30000) + key = makeKeyring('xchain-test-release') + other = makeKeyring('xchain-test-impostor') + }) + + after(() => { + if (key) key.cleanup() + if (other) other.cleanup() + }) + + it('accepts a signature by the pinned key', () => { + const data = Buffer.from('digest file\n') + const result = svc.verifyDetachedSignature({ + data, signature: key.sign(data), keyPath: key.keyPath, fingerprint: key.fingerprint + }) + expect(result.fingerprint).to.equal(key.fingerprint) + }) + + it('accepts a fingerprint written with the spaced grouping people copy from docs', () => { + const data = Buffer.from('digest file\n') + const spaced = key.fingerprint.replace(/(.{4})/g, '$1 ').trim() + expect(svc.verifyDetachedSignature({ + data, signature: key.sign(data), keyPath: key.keyPath, fingerprint: spaced + }).fingerprint).to.equal(key.fingerprint) + }) + + it('REFUSES a valid signature made by a DIFFERENT key', () => { + // The bug this exists for: `gpg --verify` succeeding only says some + // key in the keyring signed it. Here the impostor's key is the one + // imported, so gpg is perfectly happy and the pin is the only thing + // that refuses. + const data = Buffer.from('digest file\n') + expect(() => svc.verifyDetachedSignature({ + data, signature: other.sign(data), keyPath: other.keyPath, fingerprint: key.fingerprint + })).to.throw(svc.ReleaseIntegrityError, /not by the pinned release key/) + }) + + it('REFUSES a signature over different bytes', () => { + const signature = key.sign(Buffer.from('the real digest file\n')) + expect(() => svc.verifyDetachedSignature({ + data: Buffer.from('a swapped digest file\n'), + signature, keyPath: key.keyPath, fingerprint: key.fingerprint + })).to.throw(svc.ReleaseIntegrityError, /does not verify against the pinned release key/) + }) + + it('REFUSES a signature whose key is not in the shipped anchor at all', () => { + const data = Buffer.from('digest file\n') + expect(() => svc.verifyDetachedSignature({ + data, signature: other.sign(data), keyPath: key.keyPath, fingerprint: key.fingerprint + })).to.throw(svc.ReleaseIntegrityError, /does not verify against the pinned release key/) + }) + + it('REFUSES when the trust anchor is missing', () => { + expect(() => svc.verifyDetachedSignature({ + data: Buffer.from('x'), signature: Buffer.from('x'), + keyPath: path.join(os.tmpdir(), 'no-such-key.asc'), fingerprint: key.fingerprint + })).to.throw(svc.ReleaseIntegrityError, /No release signing key is shipped/) + }) + + it('REFUSES a fingerprint pin that is not 40 hex', () => { + // Guards against a placeholder ("UNPINNED", an empty file) reading + // as configured. Checked before anything is executed. + expect(() => svc.verifyDetachedSignature({ + data: Buffer.from('x'), signature: Buffer.from('x'), fingerprint: 'UNPINNED' + })).to.throw(svc.ReleaseIntegrityError, /not pinned to a 40-hex fingerprint/) + }) + + it('REFUSES when gpg itself is unavailable rather than passing', () => { + const data = Buffer.from('digest file\n') + process.env.XCHAIN_NODE_GPG_BIN = path.join(os.tmpdir(), 'definitely-not-gpg') + try { + expect(() => svc.verifyDetachedSignature({ + data, signature: Buffer.from('x'), keyPath: key.keyPath, fingerprint: key.fingerprint + })).to.throw(svc.ReleaseIntegrityError, /gpg is not installed/) + } finally { + delete process.env.XCHAIN_NODE_GPG_BIN + } + }) + }) + + describe('verifyManifestForTag()', () => { + let key + const manifestBytes = Buffer.from('{"platform_version":"0.9.0","components":{}}') + + before(function () { + if (!gpgAvailable()) return this.skip() + this.timeout(30000) + key = makeKeyring('xchain-test-train') + }) + + after(() => { if (key) key.cleanup() }) + + function assets({ sums, sig }) { + return name => Promise.resolve(name === 'SHA256SUMS' ? sums : name === 'SHA256SUMS.asc' ? sig : null) + } + + function signedSet(bytes, signer = key) { + const sums = Buffer.from(`${digestOf(bytes)} release-manifest.json\n`) + return { sums, sig: signer.sign(sums) } + } + + const silent = { log() {}, warn() {} } + + // The scratch key stands in for the platform key. Everything else about + // the gate is the real path: real gpg, real status parsing, real digest + // comparison. No install passes these, which is why they default. + function verify(fetchAsset, bytes = manifestBytes) { + return svc.verifyManifestForTag({ + tag: 'v0.9.0', manifestBytes: bytes, fetchAsset, logger: silent, + keyPath: key.keyPath, fingerprint: key.fingerprint + }) + } + + it('verifies signature THEN digest, and reports the key', async () => { + const set = signedSet(manifestBytes) + const result = await verify(assets(set)) + expect(result.verified).to.equal(true) + expect(result.fingerprint).to.equal(key.fingerprint) + }).timeout(10000) + + it('REFUSES a release that publishes no signature', async () => { + await verify(name => Promise.resolve(name === 'SHA256SUMS' ? Buffer.from('x') : null)) + .then(() => { throw new Error('should have refused') }, + err => expect(err.message).to.match(/publishes no SHA256SUMS\.asc/)) + }) + + it('REFUSES a release that publishes no digest file', async () => { + await verify(() => Promise.resolve(null)) + .then(() => { throw new Error('should have refused') }, + err => expect(err.message).to.match(/publishes no SHA256SUMS and SHA256SUMS\.asc/)) + }) + + it('REFUSES when the asset fetch itself fails', async () => { + await verify(() => Promise.reject(new Error('network down'))) + .then(() => { throw new Error('should have refused') }, + err => expect(err.message).to.match(/Could not fetch the signed digest files.*network down/)) + }) + + it('names the opt-out in every refusal, so the airgapped path is discoverable', async () => { + await verify(() => Promise.resolve(null)) + .then(() => { throw new Error('should have refused') }, + err => expect(err.message).to.match(/XCHAIN_NODE_REQUIRE_SIGNED_RELEASE=0/)) + }) + + it('the opt-out downgrades a refusal to a LOUD warning', async () => { + process.env.XCHAIN_NODE_REQUIRE_SIGNED_RELEASE = '0' + const warnings = [] + const result = await svc.verifyManifestForTag({ + tag: 'v0.9.0', + manifestBytes, + fetchAsset: () => Promise.resolve(null), + logger: { log() {}, warn: msg => warnings.push(msg) } + }) + expect(result.verified).to.equal(false) + expect(warnings).to.have.length(1) + expect(warnings[0]).to.match(/WITHOUT release signature verification/) + expect(warnings[0]).to.match(/provenance is unproven/) + }) + + it('the opt-out is only the explicit falsy values, never any set value', async () => { + // `XCHAIN_NODE_REQUIRE_SIGNED_RELEASE=1` must not read as "an + // override is present, so skip the check". + process.env.XCHAIN_NODE_REQUIRE_SIGNED_RELEASE = '1' + expect(svc.signatureCheckDisabled()).to.equal(false) + await verify(() => Promise.resolve(null)) + .then(() => { throw new Error('should have refused') }, + err => expect(err).to.be.instanceOf(svc.ReleaseIntegrityError)) + }) + + it('REFUSES a manifest whose digest is not the signed one', async function () { + this.timeout(10000) + // Correctly signed digest file, swapped manifest: exactly the shape + // of an attack that reuses a real release's signature. + const set = signedSet(Buffer.from('{"platform_version":"0.9.0","components":{"evil":1}}')) + await verify(assets(set)) + .then(() => { throw new Error('should have refused') }, + err => expect(err.message).to.match(/does not match the signed digest/)) + }) + + it('REFUSES a digest file signed by an impostor key', async function () { + this.timeout(30000) + const impostor = makeKeyring('xchain-test-impostor2') + try { + await verify(assets(signedSet(manifestBytes, impostor))) + .then(() => { throw new Error('should have refused') }, + err => expect(err).to.be.instanceOf(svc.ReleaseIntegrityError)) + } finally { + impostor.cleanup() + } + }) + }) +}) diff --git a/test/unit/coverage-thresholds-sync.test.js b/test/unit/coverage-thresholds-sync.test.js new file mode 100644 index 0000000..9fab91c --- /dev/null +++ b/test/unit/coverage-thresholds-sync.test.js @@ -0,0 +1,43 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +// The coverage ratchet keeps its floors in two places: bin/coverage-thresholds.json, +// which is what a human reads, and the c8 flags inside the coverage:check npm script, +// which is what CI obeys. Every one of those files says "keep both in sync" and +// nothing enforced it, so a floor could describe a ratchet the job was not running. +// The failure mode is not hypothetical: xchain-dashboard's ci.yml called a +// coverage:check script that did not exist in that repo at all, a job that could only +// ever exit 1, and the missing-script case is asserted here for that reason. +describe('coverage ratchet floors', () => { + const repoRoot = path.join(__dirname, '..', '..'); + const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); + const declared = JSON.parse( + fs.readFileSync(path.join(repoRoot, 'bin', 'coverage-thresholds.json'), 'utf8'), + ); + + it('ships the coverage:check script the CI coverage job invokes', () => { + assert.equal( + typeof (pkg.scripts || {})['coverage:check'], + 'string', + 'ci.yml runs `npm run coverage:check`; without the script the job can only exit 1', + ); + }); + + it('enforces every declared floor, at the declared value', () => { + const script = pkg.scripts['coverage:check']; + for (const metric of ['lines', 'statements', 'branches', 'functions']) { + const flag = script.match(new RegExp('--' + metric + '\\s+([0-9.]+)')); + assert.ok(flag, `coverage:check does not enforce --${metric}, so that floor is decorative`); + assert.equal( + Number(flag[1]), + declared[metric], + `${metric} floor drifted: thresholds.json says ${declared[metric]}, coverage:check enforces ${flag[1]}`, + ); + } + }); + + it('fails the job on a shortfall rather than only reporting it', () => { + assert.match(pkg.scripts['coverage:check'], /--check-coverage/); + }); +}); diff --git a/test/unit/moduleOperations.test.js b/test/unit/moduleOperations.test.js index de25ce9..6bbea66 100644 --- a/test/unit/moduleOperations.test.js +++ b/test/unit/moduleOperations.test.js @@ -47,6 +47,8 @@ function makeStubs() { setDatabaseParameters: sinon.stub().resolves(true), installModule: sinon.stub().resolves('new-container-id'), uninstallModule: sinon.stub().resolves(true), + assertHubNotBehind: sinon.stub().resolves({ checked: false, reason: 'not-hub-dependent' }), + assertRequiredMigrationsApplied: sinon.stub().resolves({ checked: false, reason: 'no-migrations' }), statusChanged: sinon.stub().resolves(), execFile: sinon.stub(), fs: { @@ -94,6 +96,12 @@ function loadOperations(stubs) { installModule: stubs.installModule, uninstallModule: stubs.uninstallModule }, + '../services/SkewGuardService': { + assertHubNotBehind: stubs.assertHubNotBehind + }, + '../services/MigrationPreconditionService': { + assertRequiredMigrationsApplied: stubs.assertRequiredMigrationsApplied + }, '../services/StatusService': { statusChanged: stubs.statusChanged }, @@ -196,6 +204,32 @@ describe('moduleOperations', function () { describe('updateModules()', function () { + // XC-1335. A gated migration the target DB never applied is a startup + // crash-loop, and on 2026-08-09 the only thing that discovered it was three + // mainnet indexers going to Restarting(1). The refusal is worth nothing + // unless it lands BEFORE the working container is torn down. + it('checks the migration precondition BEFORE the container is rebuilt', async function () { + const stubs = makeStubs() + const ops = loadOperations(stubs) + await ops.updateModules({ bitcoin: { mainnet: ['xchain-indexer'] } }) + expect(stubs.assertRequiredMigrationsApplied.calledBefore(stubs.installModule)).to.be.true + expect(stubs.assertRequiredMigrationsApplied.calledWith('xchain-indexer', 'bitcoin', 'mainnet')).to.be.true + }) + + it('aborts the update, leaving the running container untouched, when the guard refuses', async function () { + const stubs = makeStubs() + stubs.assertRequiredMigrationsApplied.rejects( + new Error('update refused: 2026-07-24-pubkeys-widen-uncompressed.sql has not been applied')) + const ops = loadOperations(stubs) + let err = null + try { + await ops.updateModules({ bitcoin: { mainnet: ['xchain-indexer'] } }) + } catch (e) { err = e } + expect(err, 'the refusal must propagate out of updateModules').to.not.equal(null) + expect(err.message).to.contain('2026-07-24-pubkeys-widen-uncompressed.sql') + expect(stubs.installModule.called, 'nothing may be rebuilt after a refusal').to.be.false + }) + it('fetches existing container ID before updating', async function () { const stubs = makeStubs() const ops = loadOperations(stubs) diff --git a/tools/release/release-signing-fingerprint.txt b/tools/release/release-signing-fingerprint.txt new file mode 100644 index 0000000..475ee41 --- /dev/null +++ b/tools/release/release-signing-fingerprint.txt @@ -0,0 +1 @@ +1DA7C4896F56EA22CF491EDF4361611A82F90B70 diff --git a/tools/release/release-signing-key.asc b/tools/release/release-signing-key.asc new file mode 100644 index 0000000..8c98bab --- /dev/null +++ b/tools/release/release-signing-key.asc @@ -0,0 +1,53 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGpinzcBEACrk6OdbxTbCX4VeZ3HxDUvQBMHrJ2CGwdZa0jUs6GdxqYiDFvZ +mL05IcwgN/pbBCuTmRvywOufflU+DfVfnUqClF8gyjNoX+cvY2Tqjt3jWYup76pe +JUFQEE1+jlCiq87RlPic2YutYRPNCRhm4TKzcy3Wh2F2joSYUjiRfIq/e4b5Hxcb +Jeg1xVU/pE6NwaTZuE0SzwVSRx64ZkyI9i5XBSncsMvfiPA8DTTR0uzkHEh1xFaH +u3l3sOpVfHCJ3LEaCX7s42mEUWJY1aFSEQW5kvn9fAS72JN+YXfCdaADtT/+J00h +3Dk/0Gy15FC0Tp9c5JpPgM3wzY6SLCLgHRQk32JHmvu9/iA9fJDGrqkMJxu7kDxq +kEnZWRyqpNoqvGtzlY8RwCpj+hpKiCWnLa+v2PyOMxWoQYL+KDWxlgr5w4jBQz7S +9CDYHYiR1SVv5SMt4+Z4W/3M7dBO/Iqs7K4OnF0A+LmRN729/xNz75tP4uDa+igu +sNvQx8sxrqmjm536fscSBq+3mH6rEADSKzKbDF6QsCslfMoT1sqt64b62YPJd55T +WBVqDd2J4zDwrODjY3BcND1jozFQyjsJQIYZHBj76LI7YHrBDVKqxz3z0X+h2bRr +0mlehRCCfRfLbW4aWppT+Gf8T9bpUCm0GOJ/vKTndq3QYSzMXVizxGvGQwARAQAB +tDRYQ2hhaW4gUGxhdGZvcm0gUmVsZWFzZSBTaWduaW5nIDxyZWxlYXNlc0B4Y2hh +aW4uaW8+iQJzBBMBCABdFiEEHafEiW9W6iLPSR7fQ2FhGoL5C3AFAmpinzcbFIAA +AAAABAAObWFudTIsMi41KzEuMTIsMCwzAhsDBQkSzAMABQsJCAcCAiICBhUKCQgL +AgQWAgMBAh4HAheAAAoJEENhYRqC+QtwH88P/iVKdS15rbBWIKYaE56wJdBXcTS4 +8yL895Md12oUn8BDv8jToYiGVWJTzwRXanAOeBGM3isTMXOzjOQYYFsbo17CQ40q +woZC6IHXTRmQ4uGtKlmGiFwhrrBDbUut7LbuYCUXy/34f2HxlHbJeWQbRKPiAI5r +R0vzvz0QOdagCe5MzaItA5wp29C0i5UVOp1K502hxPIw04aEsULCtOk1X+kDFjjt +wIk8QMt6cJ/pFX6qJje05Nem4uSLFqkjcRMqjXfGmdNv+XohILl9QEAOVuuBevJ/ +kQJEXeGpBeahOFVHVRVd/zXo2YCbaINiCAvN5g/OGlobNxfUCfAB4sVW+7OtgboZ +/PT1mepkRUdurbp84iC8ZQJLpUhlb3s7JRByxvv0Xy1+Hw9SO8+XsQnVJsxrzqjc +trgJz3OCvPgdUwCMALETSvpvnSqFYQSmQleURQ5I/bIYq7hSNaIGpyKjrMCSYPtC +ZPOrxji4alplbcJZPlbfpRJjZM7YAdHsGKyVOoi8BrD9eCngJMv7CWTh2/+HgT8w +EV5sm4vpLV0VykJbmobS+8+rPPgOHypj8itFiwROqZ8yIPgQMoZJkj8jsX59bm4P +vMCkv3lo6yOFVZoZwjRoR8HZl4mgal5g08rzDhyd9qRCBUShsB+nFDvxmbc5n77+ +6jZLKNSrP6f0hsbpuQINBGpinzcBEADaUO264nv/LPoep+xTKvM3naE0EkHhkoiG +cB8xte8+CAIhoeYKA7XuX6BTZue/KGWlFsTOVvz8mverulVhoGXSP/aRXAwaNfr1 +7EtNzZyEW5bm17Efsw4GVL6ko7Aj7VdO8cVpJCoYJI471ktAzffWcbnqpxyL5iRO +pMs2bvkDLJEIz35JQQ4QD5y2Cjw5WEGan4P3eAOjZZDILdKgmAE298o8WQBegGUG +aRTjt7GOMl6ZE0fdypIowU8LHrSJJMfCoQ6zE8VypPBE+pj3zgVzO5OqSPwPK0SG +2DkWxxalkzPNpLSSBY1G0xAx+crfCK4Mwc7tAljujJe2DW+GvWwT/+4K/ZH8ozJf +n7lNFwFwLrNkdBB+4Ryx312QxyWUC18yDmA+TsyY2UL0fAos8MB+rZwTraq9xipC +VhJ5Y3Zm6m7UUPTWdnTrjJAXqhYgVZe2M92TJL/0k3mLs/lA7Qjmoo6dE3jy2iAs +fPYnFOQilmDCR67AvTUSaXylBxZI41TF8Qi63T53htIa4EW3VEJNVirFR7BDmt7K +Je3/ct4c+5S1xWbsE3wGG+MEmhhA8dHGJuZYPwJ4RH1UuH+CbE6vEDmuD7Fu9VvH +nGL/rGwYkVCDLH/oI9KxRRA3iGuI92hFNnlxS27jYY/DqJKEEQbEIMV3fNCmd7j/ +aF+wKtQCwQARAQABiQJYBBgBCABCFiEEHafEiW9W6iLPSR7fQ2FhGoL5C3AFAmpi +nzcbFIAAAAAABAAObWFudTIsMi41KzEuMTIsMCwzAhsMBQkSzAMAAAoJEENhYRqC ++QtwYNgQAJYw1sskrb9Ptyu2b75Ry7JeH26KEgzp4VFC7aQsFfQlAjxcU9vjQ+TU +CLH55xQy9meEc9UrBSD1hodxRqiJ2vUy0a38sB3DNIpfACqbmOqdJ1FjyMv0hNXL +ndNE0nhGXh26nu5UUudnhziWwhj1XUhwMaa0y2mloSOBIxHQ6V7HeDOkZCgKloY5 +bBnuYcTmTGB0BxsGLb9uKi3dQcUlWaLziAv0e/pGsi36q11MEXsPfhtS77Ngs8Cz +YKIrh/eQXlqYO/ByCjkuQwhgjRGjDOd8yB6cZ++YBy6yx7a/E2UrgVcjSf4ovGNy +pEW2KLqRtGUdnjHGONvHzmmndp/P5DyFRrn4gOODJMiZqTbjUJV8f9MV8VDfnq5D +DuG2KOWp0fZRLySSNMupQ5qlIKSSfTsm5ZJPImRfb90Lr+hK2mVqmxRy3C0dakYy +3LJscLVK3ayeo58Pd1s1uwmVeFfz9L/FDEh3scfBAkg9uYakosuRsfJhXGosw4+U +z9WcZNLLnSW3vrXmZ6QTRNJQSCX7Ncn/fp7FPJefZXVPtfQSAppFPYpifoaWcDe6 +emX0xsw8/kAa1z7MA1f67zHiWGdtVZdiuVNlkULafUd3WdUWKmZi15pGheG5SEob +98rod/yeUUUw9WKdqPK0T2BZpVyFdiHFwr/bBkiVWmm9pipxz9Wt +=rTEm +-----END PGP PUBLIC KEY BLOCK-----