From fc3e872b2e2ab9e1d6fd554f276501f0ae503371 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Sat, 31 Oct 2026 06:26:56 +0000 Subject: [PATCH 1/2] fix: pin and verify standalone OpenSSL LTS dependencies --- .github/workflows/build.yml | 2 + .github/workflows/release.yml | 53 +++++++++- README.md | 6 ++ scripts/ci/probe-standalone-runtime.php | 6 ++ scripts/ci/standalone-dependencies.json | 13 +++ scripts/ci/test-standalone-dependencies.js | 104 +++++++++++++++++++ scripts/ci/test-standalone-runtime.js | 36 ++++++- scripts/ci/verify-standalone-dependencies.js | 94 +++++++++++++++++ scripts/ci/verify-standalone-runtime.js | 43 ++++++++ 9 files changed, 353 insertions(+), 4 deletions(-) create mode 100644 scripts/ci/standalone-dependencies.json create mode 100644 scripts/ci/test-standalone-dependencies.js create mode 100644 scripts/ci/verify-standalone-dependencies.js diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ca1f92c..22948ee 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -83,6 +83,7 @@ jobs: node --check scripts/ci/verify-cli-release-channel.js node --check scripts/ci/verify-cli-upgrade-result.js node --check scripts/ci/verify-standalone-runtime.js + node --check scripts/ci/verify-standalone-dependencies.js php -l scripts/ci/probe-standalone-runtime.php bash -n scripts/ci/verify-release-tag-source.sh bash -n scripts/ci/establish-native-prerequisites.sh @@ -95,6 +96,7 @@ jobs: python scripts/ci/test-native-prerequisites.py node --test scripts/ci/test-release-workflow-metadata.js node --test scripts/ci/test-standalone-runtime.js + node --test scripts/ci/test-standalone-dependencies.js node --test scripts/ci/test-cli-release-channel.js node --test scripts/ci/test-cli-upgrade-result.js node --test scripts/ci/test-release-metadata.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 52cf841..94ef49c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -481,6 +481,8 @@ jobs: scripts/ci/establish-native-prerequisites.sh scripts/ci/probe-standalone-runtime.php scripts/ci/verify-standalone-runtime.js + scripts/ci/verify-standalone-dependencies.js + scripts/ci/standalone-dependencies.json sparse-checkout-cone-mode: false - name: Download PHAR artifact @@ -648,6 +650,8 @@ jobs: sha256_file release-toolchain-control/scripts/ci/establish-native-prerequisites.sh )" extensions_sha="$(printf '%s' "$SPC_EXTENSIONS" | sha256_stdin)" + dependency_identity="$(node release-toolchain-control/scripts/ci/verify-standalone-dependencies.js identity)" + printf '%s\n' "$dependency_identity" > build/.tools/dependency-policy.json runner_image="${ImageOS}-${ImageVersion}" toolchain_id="$( printf '%s\n' \ @@ -656,6 +660,7 @@ jobs: "spc_revision=$SPC_REVISION" \ "php_version=$PHP_VERSION" \ "extensions=$SPC_EXTENSIONS" \ + "dependency_policy=$dependency_identity" \ "native_toolchain_id=${{ steps.native-prerequisites.outputs.toolchain_id }}" \ "native_prerequisite_identity=${{ steps.native-prerequisites.outputs.identity }}" \ "native_prerequisite_script_sha256=$native_prerequisite_script_sha" \ @@ -755,6 +760,8 @@ jobs: # Keep the SHA-256-verified musl archive and lock established above. # This remains the trusted input to spc download on a cold build. rm -rf source buildroot + dependency_policy="$GITHUB_WORKSPACE/release-toolchain-control/scripts/ci/verify-standalone-dependencies.js" + openssl_url="$(node "$dependency_policy" url)" case "$SPC_DOWNLOAD_OUTER_ATTEMPTS" in ''|*[!0-9]*) echo "::error::SPC_DOWNLOAD_OUTER_ATTEMPTS must be a positive integer" @@ -770,7 +777,9 @@ jobs: while [ "$attempt" -le "$SPC_DOWNLOAD_OUTER_ATTEMPTS" ]; do if ./spc download --with-php=${{ env.PHP_VERSION }} \ --for-extensions="$SPC_EXTENSIONS" --prefer-pre-built \ + --custom-url="openssl:$openssl_url" \ --without-suggestions --retry="${SPC_DOWNLOAD_RETRY}"; then + node "$dependency_policy" verify-download downloads > openssl-source-verification.json exit 0 fi @@ -926,6 +935,8 @@ jobs: build/.tools/phpmicro-timing.json build/.tools/standalone-runtime.json build/.tools/standalone-runtime-verification.json + build/.tools/dependency-policy.json + build/.tools/openssl-source-verification.json if-no-files-found: warn - name: Combine PHAR into standalone binary @@ -991,11 +1002,17 @@ jobs: run: | php scripts/ci/test-start-dev-signals.php "$GITHUB_WORKSPACE/build/dw-${{ matrix.name }}" + - name: Retain the verified standalone dependency inventory + if: needs.resolve-release.outputs.cache_warm != 'true' + run: cp build/.tools/standalone-runtime-verification.json build/dw-${{ matrix.name }}.dependencies.json + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 if: needs.resolve-release.outputs.cache_warm != 'true' with: name: dw-${{ matrix.name }} - path: build/dw-${{ matrix.name }} + path: | + build/dw-${{ matrix.name }} + build/dw-${{ matrix.name }}.dependencies.json if-no-files-found: error build-binary-windows: @@ -1038,6 +1055,8 @@ jobs: .github/workflows/release.yml scripts/ci/probe-standalone-runtime.php scripts/ci/verify-standalone-runtime.js + scripts/ci/verify-standalone-dependencies.js + scripts/ci/standalone-dependencies.json sparse-checkout-cone-mode: false - name: Download PHAR artifact @@ -1171,6 +1190,12 @@ jobs: $extensionsSha = [Convert]::ToHexString( [Security.Cryptography.SHA256]::HashData($extensionsBytes) ).ToLowerInvariant() + $dependencyPolicy = Join-Path $env:GITHUB_WORKSPACE "release-toolchain-control\scripts\ci\verify-standalone-dependencies.js" + $dependencyIdentity = & node $dependencyPolicy identity + if ($LASTEXITCODE -ne 0) { + throw "Could not resolve the pinned standalone dependency policy" + } + $dependencyIdentity | Set-Content -Path build\.tools\dependency-policy.json -Encoding utf8 $runnerImage = "$env:ImageOS-$env:ImageVersion" $identity = @( "schema=$env:PHPMICRO_CACHE_SCHEMA" @@ -1178,6 +1203,7 @@ jobs: "spc_revision=$env:SPC_REVISION" "php_version=$env:PHP_VERSION_WINDOWS" "extensions=$env:SPC_EXTENSIONS_WINDOWS" + "dependency_policy=$dependencyIdentity" "native_toolchain_id=${{ steps.native-prerequisites.outputs.toolchain_id }}" "workflow_sha256=$workflowSha" "runner_label=windows-2022" @@ -1262,6 +1288,11 @@ jobs: working-directory: build\.tools run: | Remove-Item -Recurse -Force downloads,source,buildroot -ErrorAction SilentlyContinue + $dependencyPolicy = Join-Path $env:GITHUB_WORKSPACE "release-toolchain-control\scripts\ci\verify-standalone-dependencies.js" + $opensslUrl = & node $dependencyPolicy url + if ($LASTEXITCODE -ne 0) { + throw "Could not resolve the pinned OpenSSL source" + } $outerAttempts = [int]$env:SPC_DOWNLOAD_OUTER_ATTEMPTS if ($outerAttempts -lt 1) { Write-Error "SPC_DOWNLOAD_OUTER_ATTEMPTS must be at least 1" @@ -1271,8 +1302,13 @@ jobs: for ($attempt = 1; $attempt -le $outerAttempts; $attempt++) { .\spc.exe download --with-php=${{ env.PHP_VERSION_WINDOWS }} ` --for-extensions="$env:SPC_EXTENSIONS_WINDOWS" --prefer-pre-built ` + --custom-url="openssl:$opensslUrl" ` --without-suggestions --retry="$env:SPC_DOWNLOAD_RETRY" if ($LASTEXITCODE -eq 0) { + node $dependencyPolicy verify-download downloads > openssl-source-verification.json + if ($LASTEXITCODE -ne 0) { + throw "The OpenSSL source failed its pinned SHA-256 policy" + } break } @@ -1444,6 +1480,8 @@ jobs: build/.tools/phpmicro-timing.json build/.tools/standalone-runtime.json build/.tools/standalone-runtime-verification.json + build/.tools/dependency-policy.json + build/.tools/openssl-source-verification.json if-no-files-found: warn - name: Combine PHAR into standalone binary @@ -1462,11 +1500,18 @@ jobs: .\build\dw-windows-x86_64.exe runtime:check .\build\dw-windows-x86_64.exe list + - name: Retain the verified standalone dependency inventory + if: needs.resolve-release.outputs.cache_warm != 'true' + shell: pwsh + run: Copy-Item build\.tools\standalone-runtime-verification.json build\dw-windows-x86_64.dependencies.json + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 if: needs.resolve-release.outputs.cache_warm != 'true' with: name: dw-windows-x86_64 - path: build/dw-windows-x86_64.exe + path: | + build/dw-windows-x86_64.exe + build/dw-windows-x86_64.dependencies.json if-no-files-found: error bundle-release: @@ -1643,6 +1688,10 @@ jobs: dw-linux-aarch64 dw-macos-aarch64 dw-windows-x86_64.exe + dw-linux-x86_64.dependencies.json + dw-linux-aarch64.dependencies.json + dw-macos-aarch64.dependencies.json + dw-windows-x86_64.dependencies.json install.sh install.ps1 verify-release.sh diff --git a/README.md b/README.md index ccf5525..faef408 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,12 @@ make phar Run `make help` for the complete local development surface. +Standalone release builds use the reviewed [dependency source policy](scripts/ci/standalone-dependencies.json). +The workflow verifies the OpenSSL archive before compilation and checks the +produced runtime after both cold builds and cache restores. Each release includes +per-platform `.dependencies.json` inventories in the normal checksum and +attestation set, recording PHP, OpenSSL, libcurl and available library versions. + ## License MIT diff --git a/scripts/ci/probe-standalone-runtime.php b/scripts/ci/probe-standalone-runtime.php index 58f16de..ec005ee 100644 --- a/scripts/ci/probe-standalone-runtime.php +++ b/scripts/ci/probe-standalone-runtime.php @@ -6,4 +6,10 @@ 'php_version' => PHP_VERSION, 'php_version_id' => PHP_VERSION_ID, 'sapi' => PHP_SAPI, + 'os_family' => PHP_OS_FAMILY, + 'openssl_version' => defined('OPENSSL_VERSION_TEXT') ? OPENSSL_VERSION_TEXT : null, + 'curl' => extension_loaded('curl') ? curl_version() : null, + 'pcre_version' => defined('PCRE_VERSION') ? PCRE_VERSION : null, + 'oniguruma_version' => defined('MB_ONIGURUMA_VERSION') ? MB_ONIGURUMA_VERSION : null, + 'iconv_version' => defined('ICONV_VERSION') ? ICONV_VERSION : null, ], JSON_THROW_ON_ERROR).PHP_EOL; diff --git a/scripts/ci/standalone-dependencies.json b/scripts/ci/standalone-dependencies.json new file mode 100644 index 0000000..106f5a1 --- /dev/null +++ b/scripts/ci/standalone-dependencies.json @@ -0,0 +1,13 @@ +{ + "schema": "durable-workflow.cli.standalone-dependencies/v1", + "openssl": { + "version": "3.5.9", + "support_ends": "2030-04-08", + "filename": "openssl-3.5.9.tar.gz", + "url": "https://github.com/openssl/openssl/releases/download/openssl-3.5.9/openssl-3.5.9.tar.gz", + "sha256": "603f5602e2eef00d77fbd429d34dcd5822bb301757a1bc9cdb24c670f1eb859a" + }, + "curl": { + "minimum_version": "8.23.0" + } +} diff --git a/scripts/ci/test-standalone-dependencies.js b/scripts/ci/test-standalone-dependencies.js new file mode 100644 index 0000000..1165c85 --- /dev/null +++ b/scripts/ci/test-standalone-dependencies.js @@ -0,0 +1,104 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const {execFileSync} = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); +const {loadDependencyPolicy, dependencyPolicyIdentity, verifyOpenSslDownload} = require('./verify-standalone-dependencies'); + +function fixture(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dw-openssl-source-')); + t.after(() => fs.rmSync(root, {recursive: true, force: true})); + const policy = structuredClone(loadDependencyPolicy()); + const bytes = Buffer.from('disposable OpenSSL archive fixture\n'); + policy.openssl.sha256 = crypto.createHash('sha256').update(bytes).digest('hex'); + const archive = path.join(root, policy.openssl.filename); + fs.writeFileSync(archive, bytes); + const lock = {openssl: {source_type: 'archive', filename: policy.openssl.filename, hash: 'legacy-sha1-is-not-authority'}}; + const save = () => fs.writeFileSync(path.join(root, '.lock.json'), JSON.stringify(lock)); + save(); + return {root, policy, archive, lock, save}; +} + +test('accepts only the selected source bytes and records their pinned identity', (t) => { + const f = fixture(t); + const result = verifyOpenSslDownload(f.root, f.policy); + assert.equal(result.verified, true); + assert.equal(result.sha256, f.policy.openssl.sha256); + assert.equal(result.version, '3.5.9'); +}); + +test('rejects tampered source even when the SPC lock keeps its accepted archive name', (t) => { + const f = fixture(t); + fs.appendFileSync(f.archive, 'changed'); + assert.throws(() => verifyOpenSslDownload(f.root, f.policy), /SHA-256/); +}); + +test('refuses stale, prebuilt, missing and redirected archive lock entries', (t) => { + const f = fixture(t); + for (const entry of [null, {...f.lock.openssl, filename: 'openssl-3.6.2.tar.gz'}, {...f.lock.openssl, source_type: 'pre-built'}, {...f.lock.openssl, filename: '../openssl-3.5.9.tar.gz'}]) { + f.lock.openssl = entry; + f.save(); + assert.throws(() => verifyOpenSslDownload(f.root, f.policy), /pinned OpenSSL source archive/); + } +}); + +test('refuses a symlink in place of the verified source archive', (t) => { + const f = fixture(t); + const target = path.join(f.root, 'original'); + fs.renameSync(f.archive, target); + fs.symlinkSync(target, f.archive); + assert.throws(() => verifyOpenSslDownload(f.root, f.policy), /regular archive/); +}); + +test('source policy and verifier digests bind the cache identity to the selected library', (t) => { + const identity = dependencyPolicyIdentity(); + assert.equal(identity.openssl_version, '3.5.9'); + for (const key of ['openssl_sha256', 'policy_sha256', 'verifier_sha256']) { + assert.match(identity[key], /^[a-f0-9]{64}$/); + } + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dw-openssl-policy-')); + t.after(() => fs.rmSync(root, {recursive: true, force: true})); + const helper = path.join(root, 'verify-standalone-dependencies.js'); + fs.copyFileSync(path.join(__dirname, 'verify-standalone-dependencies.js'), helper); + const policy = structuredClone(loadDependencyPolicy()); + const policyFile = path.join(root, 'standalone-dependencies.json'); + fs.copyFileSync(path.join(__dirname, 'standalone-dependencies.json'), policyFile); + const resolve = () => JSON.parse(execFileSync(process.execPath, [helper, 'identity'], {encoding: 'utf8'})); + assert.deepEqual(resolve(), identity); + policy.openssl.sha256 = 'f'.repeat(64); + fs.writeFileSync(policyFile, JSON.stringify(policy)); + const changed = resolve(); + assert.notEqual(changed.policy_sha256, identity.policy_sha256); + assert.notEqual(changed.openssl_sha256, identity.openssl_sha256); + assert.equal(changed.verifier_sha256, identity.verifier_sha256); + fs.appendFileSync(helper, '\n// Changed verifier policy.\n'); + assert.notEqual(resolve().verifier_sha256, changed.verifier_sha256); +}); + +test('both native build paths verify the exact OpenSSL source before compilation and bind its cache policy', () => { + const workflow = fs.readFileSync(path.join(__dirname, '../../.github/workflows/release.yml'), 'utf8'); + const unix = workflow.split('\n build-binary:')[1].split('\n build-binary-windows:')[0]; + const windows = workflow.split('\n build-binary-windows:')[1].split('\n bundle-release:')[0]; + for (const job of [unix, windows]) { + assert.match(job, /scripts\/ci\/standalone-dependencies\.json/); + assert.match(job, /--custom-url="openssl:/); + assert.match(job, /verify-download downloads > openssl-source-verification\.json/); + assert.ok(job.indexOf('verify-download downloads') < job.indexOf('Build and bind phpmicro on cold build')); + assert.match(job, /dependency_policy=\$/); + assert.match(job, /standalone-runtime-verification\.json/); + assert.match(job, /\.dependencies\.json/); + } +}); + +test('all four public dependency inventories use the ordinary required-asset and attestation path', () => { + const workflow = fs.readFileSync(path.join(__dirname, '../../.github/workflows/release.yml'), 'utf8'); + const publication = workflow.split('\n release:')[1]; + for (const target of ['linux-x86_64', 'linux-aarch64', 'macos-aarch64', 'windows-x86_64']) { + assert.ok(publication.includes(`dw-${target}.dependencies.json`), target); + } + assert.match(publication, /subject-path: dist\/\*/); +}); diff --git a/scripts/ci/test-standalone-runtime.js b/scripts/ci/test-standalone-runtime.js index 06116a1..4ce3733 100644 --- a/scripts/ci/test-standalone-runtime.js +++ b/scripts/ci/test-standalone-runtime.js @@ -7,12 +7,16 @@ const test = require('node:test'); const {verifyStandaloneRuntime} = require('./verify-standalone-runtime'); const verifier = path.join(__dirname, 'verify-standalone-runtime.js'); -const fixed = {php_version: '8.4.27', php_version_id: 80427, sapi: 'micro'}; +const fixed = { + php_version: '8.4.27', php_version_id: 80427, sapi: 'micro', + os_family: 'Linux', openssl_version: 'OpenSSL 3.5.9 29 Sep 2026', + curl: {version: '8.23.0', ssl_version: 'OpenSSL/3.5.9'}, +}; test('accepts the configured fixed micro runtime and later supported patch pins', () => { for (const patch of [27, 28]) { const version = `8.4.${patch}`; - const result = verifyStandaloneRuntime({php_version: version, php_version_id: 80400 + patch, sapi: 'micro'}, version); + const result = verifyStandaloneRuntime({...fixed, php_version: version, php_version_id: 80400 + patch}, version); assert.equal(result.verified, true); assert.equal(result.php_version, version); assert.equal(result.minimum_php_version, '8.4.27'); @@ -54,4 +58,32 @@ test('the command emits a bounded verification receipt only for valid runtime ev const receipt = JSON.parse(result.stdout); assert.equal(receipt.verified, true); assert.equal(receipt.php_version_id, 80427); + assert.equal(receipt.openssl.version, '3.5.9'); + assert.equal(receipt.curl.version, '8.23.0'); +}); + +test('refuses absent, unsupported or mismatched OpenSSL runtimes from stale caches', () => { + for (const value of [null, undefined, 'OpenSSL 3.6.2 2026', 'OpenSSL 3.6.5 2026', 'OpenSSL 3.5.8 2026', 'OpenSSL 3.5.90 2026', 'LibreSSL 3.5.9']) { + assert.throws(() => verifyStandaloneRuntime({...fixed, openssl_version: value}, '8.4.27'), /produced OpenSSL/); + } +}); + +test('requires libcurl on Unix with its version floor and the same OpenSSL backend', () => { + for (const curl of [null, {}, {version: '8.22.0', ssl_version: 'OpenSSL/3.5.9'}, {version: '8.23.0-dev', ssl_version: 'OpenSSL/3.5.9'}]) { + assert.throws(() => verifyStandaloneRuntime({...fixed, curl}, '8.4.27'), /libcurl/); + } + assert.throws(() => verifyStandaloneRuntime({...fixed, curl: {...fixed.curl, ssl_version: 'OpenSSL/3.6.2'}}, '8.4.27'), /same pinned/); + for (const os_family of ['Linux', 'Darwin']) { + const result = verifyStandaloneRuntime({...fixed, os_family, curl: {...fixed.curl, version: '8.24.0'}}, '8.4.27'); + assert.equal(result.verified, true); + } +}); + +test('retains Windows streams policy while checking its OpenSSL and dependency inventory', () => { + const result = verifyStandaloneRuntime({...fixed, os_family: 'Windows', curl: null}, '8.4.27'); + assert.equal(result.verified, true); + assert.equal(result.curl, null); + assert.match(result.openssl.source.sha256, /^[a-f0-9]{64}$/); + assert.throws(() => verifyStandaloneRuntime({...fixed, os_family: 'Windows'}, '8.4.27'), /streams transport/); + assert.throws(() => verifyStandaloneRuntime({...fixed, os_family: 'unknown'}, '8.4.27'), /supported operating system/); }); diff --git a/scripts/ci/verify-standalone-dependencies.js b/scripts/ci/verify-standalone-dependencies.js new file mode 100644 index 0000000..0f645d9 --- /dev/null +++ b/scripts/ci/verify-standalone-dependencies.js @@ -0,0 +1,94 @@ +'use strict'; + +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); + +const policyPath = path.join(__dirname, 'standalone-dependencies.json'); +const hash = (value) => crypto.createHash('sha256').update(value).digest('hex'); + +function loadDependencyPolicy() { + const policy = JSON.parse(fs.readFileSync(policyPath, 'utf8')); + const openssl = policy.openssl; + if (policy.schema !== 'durable-workflow.cli.standalone-dependencies/v1' || !openssl) { + throw new Error('standalone dependency policy is invalid'); + } + const version = /^3\.5\.(0|[1-9]\d*)$/.exec(openssl.version); + if (!version || Number(version[1]) < 9) { + throw new Error('standalone OpenSSL must use 3.5 LTS at 3.5.9 or later'); + } + const filename = `openssl-${openssl.version}.tar.gz`; + if (openssl.filename !== filename || + openssl.url !== `https://github.com/openssl/openssl/releases/download/openssl-${openssl.version}/${filename}` || + !/^[a-f0-9]{64}$/.test(openssl.sha256)) { + throw new Error('OpenSSL source identity must name an exact official archive and SHA-256'); + } + if (openssl.support_ends !== '2030-04-08') { + throw new Error('OpenSSL 3.5 must retain its documented LTS support boundary'); + } + if (Date.now() >= Date.parse(`${openssl.support_ends}T00:00:00Z`)) { + throw new Error('the selected OpenSSL line has reached its support end'); + } + if (!/^\d+\.\d+\.\d+$/.test(policy.curl?.minimum_version)) { + throw new Error('standalone libcurl must have a stable minimum version'); + } + return policy; +} + +function dependencyPolicyIdentity() { + const policy = loadDependencyPolicy(); + return { + openssl_version: policy.openssl.version, + openssl_sha256: policy.openssl.sha256, + policy_sha256: hash(fs.readFileSync(policyPath)), + verifier_sha256: hash(fs.readFileSync(__filename)), + }; +} + +function verifyOpenSslDownload(directory, policy = loadDependencyPolicy()) { + const lock = JSON.parse(fs.readFileSync(path.join(directory, '.lock.json'), 'utf8')); + const entry = lock.openssl; + if (!entry || entry.source_type !== 'archive' || entry.filename !== policy.openssl.filename) { + throw new Error('SPC must use the pinned OpenSSL source archive, not a prebuilt library'); + } + const archive = path.join(directory, entry.filename); + const stat = fs.lstatSync(archive); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size === 0) { + throw new Error('OpenSSL source must be a nonempty regular archive'); + } + const digest = hash(fs.readFileSync(archive)); + if (digest !== policy.openssl.sha256) { + throw new Error('OpenSSL source SHA-256 does not match the pinned official archive'); + } + return { + schema: 'durable-workflow.cli.openssl-source/v1', + version: policy.openssl.version, + filename: entry.filename, + url: policy.openssl.url, + sha256: digest, + verified: true, + }; +} + +module.exports = {loadDependencyPolicy, dependencyPolicyIdentity, verifyOpenSslDownload}; + +if (require.main === module) { + try { + const args = process.argv.slice(2); + let result; + if (args.length === 1 && args[0] === 'identity') { + result = dependencyPolicyIdentity(); + } else if (args.length === 1 && args[0] === 'url') { + process.stdout.write(`${loadDependencyPolicy().openssl.url}\n`); + return; + } else if (args.length === 2 && args[0] === 'verify-download') { + result = verifyOpenSslDownload(args[1]); + } else { + throw new Error('usage: verify-standalone-dependencies.js identity | url | verify-download '); + } + process.stdout.write(`${JSON.stringify(result)}\n`); + } catch (error) { + process.stderr.write(`Standalone dependency verification failed: ${error.message}\n`); + process.exitCode = 1; + } +} diff --git a/scripts/ci/verify-standalone-runtime.js b/scripts/ci/verify-standalone-runtime.js index 7c1d0ad..42cc72f 100644 --- a/scripts/ci/verify-standalone-runtime.js +++ b/scripts/ci/verify-standalone-runtime.js @@ -1,6 +1,7 @@ 'use strict'; const fs = require('node:fs'); +const {loadDependencyPolicy, dependencyPolicyIdentity} = require('./verify-standalone-dependencies'); const MINIMUM_PHP_VERSION = '8.4.27'; const MINIMUM_PHP_VERSION_ID = 80427; @@ -27,6 +28,37 @@ function verifyStandaloneRuntime(evidence, expectedVersion) { throw new Error('produced PHP runtime version ID does not match the configured pin'); } + const policy = loadDependencyPolicy(); + const opensslVersion = /^OpenSSL (\d+\.\d+\.\d+)(?:\s|$)/.exec(evidence.openssl_version); + if (!opensslVersion || opensslVersion[1] !== policy.openssl.version) { + throw new Error('produced OpenSSL runtime does not match the supported exact source pin'); + } + if (!['Linux', 'Darwin', 'Windows'].includes(evidence.os_family)) { + throw new Error('standalone runtime must identify a supported operating system'); + } + if (evidence.os_family === 'Windows') { + if (evidence.curl !== null) { + throw new Error('Windows standalone must retain its streams transport policy'); + } + } else { + const curl = evidence.curl; + const version = typeof curl?.version === 'string' && /^\d+\.\d+\.\d+$/.test(curl.version); + const compareVersions = (a, b) => { + const left = a.split('.').map(Number); + const right = b.split('.').map(Number); + for (let i = 0; i < left.length; i++) { + if (left[i] !== right[i]) return left[i] - right[i]; + } + return 0; + }; + if (!version || compareVersions(curl.version, policy.curl.minimum_version) < 0) { + throw new Error('produced libcurl does not meet the supported minimum version'); + } + if (curl.ssl_version !== `OpenSSL/${policy.openssl.version}`) { + throw new Error('libcurl must use the same pinned OpenSSL runtime'); + } + } + return Object.freeze({ schema: 'durable-workflow.cli.standalone-runtime/v1', minimum_php_version: MINIMUM_PHP_VERSION, @@ -34,6 +66,17 @@ function verifyStandaloneRuntime(evidence, expectedVersion) { php_version: evidence.php_version, php_version_id: evidence.php_version_id, sapi: evidence.sapi, + os_family: evidence.os_family, + openssl: { + version: opensslVersion[1], + support_ends: policy.openssl.support_ends, + source: policy.openssl, + }, + curl: evidence.curl, + pcre_version: evidence.pcre_version ?? null, + oniguruma_version: evidence.oniguruma_version ?? null, + iconv_version: evidence.iconv_version ?? null, + dependency_policy: dependencyPolicyIdentity(), verified: true, }); } From 0a5bbef3e4c3ee60a3805b1707ac54d315138a27 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Sat, 31 Oct 2026 06:44:55 +0000 Subject: [PATCH 2/2] fix: verify loaded TLS libraries and clarify release guards --- .github/workflows/release.yml | 20 ++++-- scripts/ci/probe-standalone-runtime.php | 17 ++++- scripts/ci/test-standalone-runtime.js | 9 +++ scripts/ci/verify-standalone-dependencies.js | 2 +- scripts/ci/verify-standalone-runtime.js | 69 ++++++++++++-------- 5 files changed, 79 insertions(+), 38 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 94ef49c..cc3468a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -119,11 +119,9 @@ jobs: fi release_metadata="$(node scripts/ci/release-version.js metadata "$tag")" read -r prerelease make_latest <<< "$release_metadata" - if [ "$tag" = "2.0.0" ]; then - if [ "$GITHUB_EVENT_NAME" != "workflow_dispatch" ]; then - echo "::error::Stable 2.0.0 publication requires a protected main-branch dispatch" - exit 1 - fi + if [ "$tag" = "2.0.0" ] && [ "$GITHUB_EVENT_NAME" != "workflow_dispatch" ]; then + echo "::error::Stable 2.0.0 publication requires a protected main-branch dispatch" + exit 1 fi fi if ! printf '%s\n' "$commit" | grep -Eq '^[0-9a-f]{40}$'; then @@ -608,6 +606,10 @@ jobs: macOS) package_identity="$(brew list --versions | sort)" ;; + *) + echo "::error::Unsupported native build platform" + exit 1 + ;; esac native_toolchain_id="$( { @@ -626,10 +628,11 @@ jobs: run: | set -euo pipefail sha256_file() { + local file="$1" if command -v sha256sum >/dev/null 2>&1; then - sha256sum "$1" | awk '{print $1}' + sha256sum "$file" | awk '{print $1}' else - shasum -a 256 "$1" | awk '{print $1}' + shasum -a 256 "$file" | awk '{print $1}' fi } sha256_stdin() { @@ -767,6 +770,7 @@ jobs: echo "::error::SPC_DOWNLOAD_OUTER_ATTEMPTS must be a positive integer" exit 1 ;; + *) ;; esac if [ "$SPC_DOWNLOAD_OUTER_ATTEMPTS" -lt 1 ]; then echo "::error::SPC_DOWNLOAD_OUTER_ATTEMPTS must be at least 1" @@ -1774,12 +1778,14 @@ jobs: echo "::error::DURABLE_WORKFLOW_RELEASE_ASSET_ATTEMPTS must be a positive integer" exit 1 ;; + *) ;; esac case "$sleep_seconds" in ''|*[!0-9]*) echo "::error::DURABLE_WORKFLOW_RELEASE_ASSET_RETRY_SLEEP must be a non-negative integer" exit 1 ;; + *) ;; esac if [ "$attempts" -lt 1 ]; then echo "::error::DURABLE_WORKFLOW_RELEASE_ASSET_ATTEMPTS must be at least 1" diff --git a/scripts/ci/probe-standalone-runtime.php b/scripts/ci/probe-standalone-runtime.php index ec005ee..c65a9a6 100644 --- a/scripts/ci/probe-standalone-runtime.php +++ b/scripts/ci/probe-standalone-runtime.php @@ -2,12 +2,27 @@ declare(strict_types=1); +$opensslLibraryVersion = null; +if (extension_loaded('openssl')) { + ob_start(); + phpinfo(INFO_MODULES); + $moduleInfo = ob_get_clean(); + if (is_string($moduleInfo) && preg_match( + '/OpenSSL Library Version(?:\s*=>)?\s*(OpenSSL \d+\.\d+\.\d+)(?:\s|$)/', + strip_tags($moduleInfo), + $matches, + ) === 1) { + $opensslLibraryVersion = $matches[1]; + } +} + echo json_encode([ 'php_version' => PHP_VERSION, 'php_version_id' => PHP_VERSION_ID, 'sapi' => PHP_SAPI, 'os_family' => PHP_OS_FAMILY, - 'openssl_version' => defined('OPENSSL_VERSION_TEXT') ? OPENSSL_VERSION_TEXT : null, + 'openssl_version' => $opensslLibraryVersion, + 'openssl_header_version' => defined('OPENSSL_VERSION_TEXT') ? OPENSSL_VERSION_TEXT : null, 'curl' => extension_loaded('curl') ? curl_version() : null, 'pcre_version' => defined('PCRE_VERSION') ? PCRE_VERSION : null, 'oniguruma_version' => defined('MB_ONIGURUMA_VERSION') ? MB_ONIGURUMA_VERSION : null, diff --git a/scripts/ci/test-standalone-runtime.js b/scripts/ci/test-standalone-runtime.js index 4ce3733..71453ff 100644 --- a/scripts/ci/test-standalone-runtime.js +++ b/scripts/ci/test-standalone-runtime.js @@ -10,6 +10,7 @@ const verifier = path.join(__dirname, 'verify-standalone-runtime.js'); const fixed = { php_version: '8.4.27', php_version_id: 80427, sapi: 'micro', os_family: 'Linux', openssl_version: 'OpenSSL 3.5.9 29 Sep 2026', + openssl_header_version: 'OpenSSL 3.5.9 29 Sep 2026', curl: {version: '8.23.0', ssl_version: 'OpenSSL/3.5.9'}, }; @@ -87,3 +88,11 @@ test('retains Windows streams policy while checking its OpenSSL and dependency i assert.throws(() => verifyStandaloneRuntime({...fixed, os_family: 'Windows'}, '8.4.27'), /streams transport/); assert.throws(() => verifyStandaloneRuntime({...fixed, os_family: 'unknown'}, '8.4.27'), /supported operating system/); }); + +test('checks the loaded OpenSSL library separately from its compilation headers on every platform', () => { + for (const os_family of ['Linux', 'Darwin', 'Windows']) { + const evidence = {...fixed, os_family, curl: os_family === 'Windows' ? null : fixed.curl}; + assert.throws(() => verifyStandaloneRuntime({...evidence, openssl_version: 'OpenSSL 3.6.5'}, '8.4.27'), /produced OpenSSL runtime/); + assert.throws(() => verifyStandaloneRuntime({...evidence, openssl_header_version: 'OpenSSL 3.6.5'}, '8.4.27'), /OpenSSL headers/); + } +}); diff --git a/scripts/ci/verify-standalone-dependencies.js b/scripts/ci/verify-standalone-dependencies.js index 0f645d9..ad6f15b 100644 --- a/scripts/ci/verify-standalone-dependencies.js +++ b/scripts/ci/verify-standalone-dependencies.js @@ -48,7 +48,7 @@ function dependencyPolicyIdentity() { function verifyOpenSslDownload(directory, policy = loadDependencyPolicy()) { const lock = JSON.parse(fs.readFileSync(path.join(directory, '.lock.json'), 'utf8')); const entry = lock.openssl; - if (!entry || entry.source_type !== 'archive' || entry.filename !== policy.openssl.filename) { + if (entry?.source_type !== 'archive' || entry.filename !== policy.openssl.filename) { throw new Error('SPC must use the pinned OpenSSL source archive, not a prebuilt library'); } const archive = path.join(directory, entry.filename); diff --git a/scripts/ci/verify-standalone-runtime.js b/scripts/ci/verify-standalone-runtime.js index 42cc72f..08aa981 100644 --- a/scripts/ci/verify-standalone-runtime.js +++ b/scripts/ci/verify-standalone-runtime.js @@ -6,33 +6,16 @@ const {loadDependencyPolicy, dependencyPolicyIdentity} = require('./verify-stand const MINIMUM_PHP_VERSION = '8.4.27'; const MINIMUM_PHP_VERSION_ID = 80427; -function verifyStandaloneRuntime(evidence, expectedVersion) { - const match = typeof expectedVersion === 'string' && /^8\.4\.(0|[1-9]\d*)$/.exec(expectedVersion); - if (!match) { - throw new Error('standalone PHP pin must be a stable 8.4 patch version'); - } - const expectedVersionId = 80400 + Number(match[1]); - if (!Number.isSafeInteger(expectedVersionId) || expectedVersionId < MINIMUM_PHP_VERSION_ID) { - throw new Error(`standalone PHP pin must be at least ${MINIMUM_PHP_VERSION}`); - } - if (!evidence || typeof evidence !== 'object' || Array.isArray(evidence)) { - throw new Error('standalone runtime probe must return a JSON object'); - } - if (evidence.sapi !== 'micro') { - throw new Error('standalone runtime probe must execute in the produced micro SAPI'); - } - if (evidence.php_version !== expectedVersion) { - throw new Error('produced PHP runtime does not match the configured stable pin'); - } - if (!Number.isInteger(evidence.php_version_id) || evidence.php_version_id !== expectedVersionId) { - throw new Error('produced PHP runtime version ID does not match the configured pin'); - } - +function verifyRuntimeDependencies(evidence) { const policy = loadDependencyPolicy(); const opensslVersion = /^OpenSSL (\d+\.\d+\.\d+)(?:\s|$)/.exec(evidence.openssl_version); if (!opensslVersion || opensslVersion[1] !== policy.openssl.version) { throw new Error('produced OpenSSL runtime does not match the supported exact source pin'); } + const headers = /^OpenSSL (\d+\.\d+\.\d+)(?:\s|$)/.exec(evidence.openssl_header_version); + if (!headers || headers[1] !== policy.openssl.version) { + throw new Error('produced OpenSSL headers do not match the selected runtime'); + } if (!['Linux', 'Darwin', 'Windows'].includes(evidence.os_family)) { throw new Error('standalone runtime must identify a supported operating system'); } @@ -59,16 +42,11 @@ function verifyStandaloneRuntime(evidence, expectedVersion) { } } - return Object.freeze({ - schema: 'durable-workflow.cli.standalone-runtime/v1', - minimum_php_version: MINIMUM_PHP_VERSION, - expected_php_version: expectedVersion, - php_version: evidence.php_version, - php_version_id: evidence.php_version_id, - sapi: evidence.sapi, + return { os_family: evidence.os_family, openssl: { version: opensslVersion[1], + header_version: headers[1], support_ends: policy.openssl.support_ends, source: policy.openssl, }, @@ -77,6 +55,39 @@ function verifyStandaloneRuntime(evidence, expectedVersion) { oniguruma_version: evidence.oniguruma_version ?? null, iconv_version: evidence.iconv_version ?? null, dependency_policy: dependencyPolicyIdentity(), + }; +} + +function verifyStandaloneRuntime(evidence, expectedVersion) { + const match = typeof expectedVersion === 'string' && /^8\.4\.(0|[1-9]\d*)$/.exec(expectedVersion); + if (!match) { + throw new Error('standalone PHP pin must be a stable 8.4 patch version'); + } + const expectedVersionId = 80400 + Number(match[1]); + if (!Number.isSafeInteger(expectedVersionId) || expectedVersionId < MINIMUM_PHP_VERSION_ID) { + throw new Error(`standalone PHP pin must be at least ${MINIMUM_PHP_VERSION}`); + } + if (!evidence || typeof evidence !== 'object' || Array.isArray(evidence)) { + throw new Error('standalone runtime probe must return a JSON object'); + } + if (evidence.sapi !== 'micro') { + throw new Error('standalone runtime probe must execute in the produced micro SAPI'); + } + if (evidence.php_version !== expectedVersion) { + throw new Error('produced PHP runtime does not match the configured stable pin'); + } + if (!Number.isInteger(evidence.php_version_id) || evidence.php_version_id !== expectedVersionId) { + throw new Error('produced PHP runtime version ID does not match the configured pin'); + } + + return Object.freeze({ + schema: 'durable-workflow.cli.standalone-runtime/v1', + minimum_php_version: MINIMUM_PHP_VERSION, + expected_php_version: expectedVersion, + php_version: evidence.php_version, + php_version_id: evidence.php_version_id, + sapi: evidence.sapi, + ...verifyRuntimeDependencies(evidence), verified: true, }); }