From a24df2eb5074c641ea343e90e74a81f17124021a Mon Sep 17 00:00:00 2001 From: k9ert <117085+k9ert@users.noreply.github.com> Date: Fri, 17 Apr 2026 20:50:12 +0200 Subject: [PATCH] ci: add GHA test workflow + GPG-verify bitcoind/elementsd downloads (#2606) Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/test.yml | 215 ++++++++++ .gitignore | 2 + cypress.json | 6 +- docker/cypress-python-jammy/Readme.md | 2 + docs/cirrus-replacement-spec.md | 561 ++++++++++++++++++++++++++ tests/bitcoin_SHA256SUMS | 35 ++ tests/elements_SHA256SUMS | 22 + tests/install_noded.sh | 233 ++++++++++- 8 files changed, 1070 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 docs/cirrus-replacement-spec.md create mode 100644 tests/bitcoin_SHA256SUMS create mode 100644 tests/elements_SHA256SUMS diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..58f6a44c5 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,215 @@ +name: Tests + +on: + pull_request: + push: + branches: [master] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-22.04 + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + cache: pip + cache-dependency-path: requirements.txt + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libusb-1.0-0-dev libudev-dev python3-virtualenv + + - name: Cache bitcoind and elementsd + uses: actions/cache@v4 + with: + path: | + ./tests/bitcoin + ./tests/bitcoin-* + ./tests/elements + ./tests/elements-* + key: ${{ runner.os }}-${{ runner.arch }}-noded-${{ hashFiles('pyproject.toml', 'tests/elements_gitrev_pinned', 'tests/install_noded.sh', 'tests/bitcoin_SHA256SUMS', 'tests/elements_SHA256SUMS') }}-binary + save-always: true + + - name: Install bitcoind + run: ./tests/install_noded.sh --debug --bitcoin binary + + - name: Install elementsd + run: ./tests/install_noded.sh --debug --elements binary + + - name: Install Python dependencies + run: | + pip install -r requirements.txt --require-hashes + pip install -e ".[test]" + + - name: Fetch version tag + run: git fetch origin refs/tags/v1.0.0 + + - name: Run pytest + run: > + pytest + --cov=cryptoadvance + --cov-report=term + --junitxml=./testresults.xml + -p no:cacheprovider + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: testresults + path: ./testresults.xml + + cypress: + runs-on: ubuntu-22.04 + timeout-minutes: 30 + container: + image: ghcr.io/cryptoadvance/specter-desktop/cypress-python-jammy@sha256:149f19f9534a3932972151d9f0084b386f8beef1b897ff739ad64a962af891dc + options: --shm-size=2g + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Fix virtualenv shim + shell: bash + run: | + echo -e '#!/bin/bash\npython3 -m virtualenv "$@"' > /usr/local/bin/virtualenv + chmod +x /usr/local/bin/virtualenv + virtualenv --version + + - name: Cache bitcoind and elementsd + uses: actions/cache@v4 + with: + path: | + ./tests/bitcoin + ./tests/bitcoin-* + ./tests/elements + ./tests/elements-* + key: ${{ runner.os }}-${{ runner.arch }}-noded-${{ hashFiles('pyproject.toml', 'tests/elements_gitrev_pinned', 'tests/install_noded.sh', 'tests/bitcoin_SHA256SUMS', 'tests/elements_SHA256SUMS') }}-binary + save-always: true + + - name: Install bitcoind + run: ./tests/install_noded.sh --debug --bitcoin binary + + - name: Install elementsd + run: ./tests/install_noded.sh --debug --elements binary + + - name: Install Python dependencies + shell: bash + run: | + # Container uid differs from checkout owner — trust the repo dir so + # setuptools-scm/vcs_versioning can run `git describe` during pip -e. + git config --global --add safe.directory "$GITHUB_WORKSPACE" + virtualenv --python=python .env + source ./.env/bin/activate + pip3 install -r requirements.txt --require-hashes + pip3 install -e ".[test]" + + - name: Cache npm modules + uses: actions/cache@v4 + with: + path: ./node_modules + key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }} + + - name: Install npm deps + run: npm ci + + - name: Run Cypress + shell: bash + run: | + source ./.env/bin/activate + ./utils/test-cypress.sh --debug run + + - name: Upload Cypress test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: cypress-results + path: cypresstest-output.xml + + - name: Upload Cypress screenshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: cypress-screenshots + path: cypress/screenshots/** + if-no-files-found: ignore + + - name: Upload Cypress videos + if: always() + uses: actions/upload-artifact@v4 + with: + name: cypress-videos + path: cypress/videos/** + if-no-files-found: ignore + + extension-smoketest: + runs-on: ubuntu-22.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + cache: pip + cache-dependency-path: requirements.txt + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libusb-1.0-0-dev libudev-dev python3-virtualenv + + - name: Cache bitcoind and elementsd + uses: actions/cache@v4 + with: + path: | + ./tests/bitcoin + ./tests/bitcoin-* + ./tests/elements + ./tests/elements-* + key: ${{ runner.os }}-${{ runner.arch }}-noded-${{ hashFiles('pyproject.toml', 'tests/elements_gitrev_pinned', 'tests/install_noded.sh', 'tests/bitcoin_SHA256SUMS', 'tests/elements_SHA256SUMS') }}-binary + save-always: true + + - name: Install bitcoind + run: ./tests/install_noded.sh --debug --bitcoin binary + + - name: Install elementsd + run: ./tests/install_noded.sh --debug --elements binary + + - name: Install Python dependencies + run: | + virtualenv --python=python .env + source ./.env/bin/activate + pip3 install -r requirements.txt --require-hashes + pip3 install -e ".[test]" + + - name: Extension smoketest + run: | + git config --global user.name "CI CD" + git config --global user.email "cicd@example.com" + source ./.env/bin/activate + echo $PATH + mkdir tmp && cd tmp + mkdir testextension && cd testextension + pwd + python3 -m cryptoadvance.specter ext gen --ext-id cicdtest --org cryptoadvance --no-isolated-client --devicename cicddevice + pip3 install -e . + python3 -m cryptoadvance.specter server --config DevelopmentConfig --debug 2> specter.log & + sleep 15 + cat specter.log | grep "Found CicdtestService" # Discovery failed + cat specter.log | grep "Extension CicdtestService activated (alpha)" + sudo apt-get update && sudo apt-get -y install curl + curl http://127.0.0.1:25441/svc/cicdtest/ | grep "CicdtestService 4thewin." diff --git a/.gitignore b/.gitignore index ac6c04fb8..ba0a6ec05 100644 --- a/.gitignore +++ b/.gitignore @@ -38,7 +38,9 @@ node_modules btcd-conn.json elmd-conn.json tests/bitcoin* +!tests/bitcoin_SHA256SUMS tests/elements* +!tests/elements_SHA256SUMS token.sh src/cryptoadvance/specter/translations/**/messages.mo src/cryptoadvance/specter/_version.py diff --git a/cypress.json b/cypress.json index 31c0282a6..da91d58f1 100644 --- a/cypress.json +++ b/cypress.json @@ -23,5 +23,9 @@ "mochaFile": "cypresstest-output.xml", "toConsole": true }, - "experimentalSessionAndOrigin": true + "experimentalSessionAndOrigin": true, + "retries": { + "runMode": 1, + "openMode": 0 + } } diff --git a/docker/cypress-python-jammy/Readme.md b/docker/cypress-python-jammy/Readme.md index 623899ecc..30e1a54db 100644 --- a/docker/cypress-python-jammy/Readme.md +++ b/docker/cypress-python-jammy/Readme.md @@ -8,3 +8,5 @@ docker push ghcr.io/cryptoadvance/specter-desktop/cypress-python-jammy:20260411 ``` Search for `cypress-python` on where this is used in the project. + +When editing this Dockerfile, rebuild and push to `ghcr.io/cryptoadvance/specter-desktop/cypress-python-jammy` with a new tag, then update the digest pin in `.github/workflows/test.yml`. diff --git a/docs/cirrus-replacement-spec.md b/docs/cirrus-replacement-spec.md new file mode 100644 index 000000000..2df008f66 --- /dev/null +++ b/docs/cirrus-replacement-spec.md @@ -0,0 +1,561 @@ +# Cirrus CI Replacement Spec + +> Incorporates review findings from Winston (architecture), Amelia (impl), +> Murat (test arch), Mary (requirements), and Ravi (red team) across two +> party-mode rounds plus a security pass. Key decisions: GHCR image +> migration is already complete (landed in `kn/update-node-ci`); cutover +> split across two PRs; acceptance gate is flake-rate based with 30-day +> steady-state budget; branch-protection rename has a runbook; committed +> evidence artifact required at gate-close; Codecov is out of scope. +> **CRITICAL security regression identified and fixed as PR #1 blocker:** +> unverified bitcoind downloads plus `save-always` cache turn a +> supply-chain attack into a persistent backdoor vector — PR #1 must +> update `tests/install_noded.sh` to GPG-verify releases. + +## Context + +Cirrus CI's free OSS tier shuts down **end of June 2026**. Specter-Desktop +depends on Cirrus for PR gating via `.cirrus.yml` (pytest + Cypress + +extension smoketest). We must migrate before the shutdown or lose PR gating. + +Today's CI split (see `AGENTS.md` §CI/CD): + +| System | Purpose | Status | +|----------------|-----------------------------------------------------|-----------------| +| GitHub Actions | Lint, smoke-build, Docker, **full release pipeline** | Active | +| Cirrus CI | pytest + Cypress + extension smoketest on PRs | **Sunsetting** | +| GitLab CI | `check` job only; release flow already migrated off | Vestigial, dead | + +**Reframe:** the real root cause is **3-system CI sprawl with no owner**. +Cirrus shutdown is the forcing function to consolidate on GH Actions. + +## Goals + +1. Preserve PR gating equivalent to Cirrus (pytest, Cypress, extension + smoketest) before Cirrus goes dark. +2. Zero new private-hardware requirements — stay on public runners. +3. Preserve cache hit rate so PR turnaround doesn't regress beyond SLO + (see §SLOs). +4. Leave the repo in a state where `.cirrus.yml`, `docker/cirrus-jammy/`, + `.gitlab-ci.yml`, and `pyinstaller/build-win-ci.bat` can be removed + cleanly in a follow-up PR. +5. Remove the cross-provider GitLab registry dependency entirely. + +## Non-goals + +- Re-architecting the test suite. +- Rebalancing the test pyramid (Cypress → pytest migration). Tracked + separately in §Deferred. +- Moving jobs back to GitLab CI (no PR model). +- Adding Cypress Dashboard or other paid observability SaaS. +- Coverage upload to Codecov or similar. Coverage stays terminal-only, + matching current Cirrus behavior. +- Introducing new test matrices (OS/python versions) beyond Cirrus parity. + +## Target: GitHub Actions + +GH Actions already hosts lint, Docker, and the release pipeline, all on +public runners. Consolidating reduces CI systems from three to one. + +## Image hosting — already on GHCR + +**Already done** by PR #2602 (merged 2026-04-12, commit `3e277689`). That +PR upgraded Node 12→18 and migrated **all three** CI images from GitLab +registry to GHCR: + +- `ghcr.io/cryptoadvance/specter-desktop/cirrus-jammy:20260412` +- `ghcr.io/cryptoadvance/specter-desktop/cypress-python-jammy:20260411` +- `ghcr.io/cryptoadvance/specter-desktop/cypress-base-ubuntu-jammy:20260411` + +The cross-provider GitLab registry dependency is gone. No mirror workflow +needed. `.cirrus.yml` already references the GHCR paths, so Cirrus and the +new GHA workflow will pull from the same registry during the side-by-side +period. + +**Residual ownership gap (small):** the Dockerfiles are built by hand per +`docker/cypress-python-jammy/Readme.md`. When they change (historically +every ~18 months), whoever edits must remember to `docker build && docker push` +to GHCR and bump the tag in `test.yml`. Fix: add one sentence to +`docker/cypress-python-jammy/Readme.md` stating that Dockerfile edits +require re-push to GHCR and a matching tag/digest bump in +`.github/workflows/test.yml`. That's the full remediation — no scheduled +liveness check, no CODEOWNERS fight. The image is too static to justify more. + +Deeper hardening (cosign signing, automated build workflow) is tracked in +§Security HIGH as a 30-day follow-up. + +## What to port + +Three Cirrus tasks → three GH Actions jobs in a new +`.github/workflows/test.yml`. Trigger: `pull_request` + `push` to master. + +### Global workflow-level settings + +```yaml +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +``` + +Every job must set: +- `timeout-minutes:` (budget below) +- `actions/checkout@v4` with **`fetch-depth: 0`** (NOT default `1`) — + `tests/test_util_version.py` needs tag history and `git describe` needs + annotated tags. No separate `git fetch refs/tags/v1.0.0` dance. +- `fail-fast: false` semantics where matrices are used. + +### 1. `test` (pytest) + +Cirrus today: `test_task` on +`ghcr.io/cryptoadvance/specter-desktop/cirrus-jammy:20260412` +(post-PR-#2602 baseline), runs +`pytest --cov=cryptoadvance --junitxml=./testresults.xml` with cached +bitcoind and elementsd. + +GH Actions mapping: +- `runs-on: ubuntu-22.04` +- `timeout-minutes: 45` +- System deps installed inline: `libusb-1.0-0-dev libudev-dev + python3-virtualenv`. No custom image. +- `actions/setup-python@v5` pinned to `3.10`, `cache: pip`, `cache-dependency-path: requirements.txt`. +- **bitcoind/elementsd cache** (see §Caching). +- Install: + ``` + pip install -r requirements.txt --require-hashes + pip install -e ".[test]" # intentionally bypasses hashes, documented + ``` +- Run: `pytest --cov=cryptoadvance --cov-report=term --junitxml=./testresults.xml -p no:cacheprovider --reruns 0` + - `--reruns 0`: fail fast. Flakiness is debt, not a coping mechanism. + - Coverage is terminal-only, matching current Cirrus behavior. No upload. +- Artifacts: `testresults.xml`. + +### 2. `cypress` + +Cirrus today: `cypress_test_task` on +`ghcr.io/cryptoadvance/specter-desktop/cypress-python-jammy:20260411` +(post-PR-#2602 baseline), Cirrus-requested `cpu: 6, memory: 6G`, +runs `./utils/test-cypress.sh --debug run`. + +GH Actions mapping: +- `runs-on: ubuntu-22.04` (4 vCPU / 16 GB free). **Do not pre-escalate** to + `ubuntu-22.04-large`. Measure first (see §Cypress measurement). +- `timeout-minutes: 30` initially; adjust after measurement. +- Container: + ```yaml + container: + image: ghcr.io/cryptoadvance/specter-desktop/cypress-python-jammy@sha256: + options: --shm-size=2g # Cypress OOMs on default 64M /dev/shm + ``` + Digest captured from the `:20260411` tag at PR #1 time. Pinned by digest, + not tag, to make future Dockerfile edits visibly require a workflow bump. +- Same bitcoind/elements cache as `test`. +- npm cache via `actions/cache` keyed on `package-lock.json` (not + `setup-node`; node already ships in the container). +- Cypress retries: `{ runMode: 1, openMode: 0 }` in `cypress.json` — one + retry max, logged loudly so flakes are visible, not hidden. +- Run: `./utils/test-cypress.sh --debug run`. +- Artifacts: `cypresstest-output.xml`, `cypress/screenshots/**`, + `cypress/videos/**`. + +**Sharding decision:** deferred. If measured wall-clock > 15 min, split +specs across a 2-shard matrix. Don't split pre-emptively. + +### 3. `extension-smoketest` + +Straight port of Cirrus's `extension_smoketest_task`. Same runner/deps as +`test` job. Preserves the exact bash block: git identity, `ext gen`, server +boot, log-line grep, curl assertion. **Contract must stay byte-compatible** +— this job is the canary for downstream extension developers. + +## Images + +- **`docker/cirrus-jammy/`** — delete in PR #2. pytest job installs deps + inline on `ubuntu-22.04`; no custom image needed. The current GHCR tag + (`cirrus-jammy:20260412`) can stay in GHCR indefinitely as a harmless + artifact after the Dockerfile is deleted from the repo. +- **`cypress-python-jammy`** + **`cypress-base-ubuntu-jammy`** — already + on GHCR via PR #2602. Keep Dockerfiles in `docker/`. Add one sentence + to `docker/cypress-python-jammy/Readme.md`: *"When editing this + Dockerfile, rebuild and push to + `ghcr.io/cryptoadvance/specter-desktop/cypress-python-jammy` with a new + tag, then update the digest pin in `.github/workflows/test.yml`."* + +## Caching + +GH Actions `actions/cache@v4` keyed on: + +``` +${{ runner.os }}-${{ runner.arch }}-noded- + ${{ hashFiles('pyproject.toml', 'tests/elements_gitrev_pinned', 'tests/install_noded.sh', 'tests/bitcoin_SHA256SUMS', 'tests/elements_SHA256SUMS') }}- + binary +``` + +`tests/bitcoin_gitrev_pinned` is not in the repo today (bitcoin version +comes from `pyproject.toml`); the cache key hashes the committed +SHA256SUMS trust anchors instead, so a version bump invalidates the +cache through the trust-anchor files. + +`runner.arch` matters — future ARM runners must not poison x86 caches. + +Paths: `./tests/bitcoin`, `./tests/elements`. + +Populate step: `./tests/install_noded.sh --debug --bitcoin binary` and +`--elements binary` on cache miss (match Cirrus `populate_script`). + +Set **`save-always: true`** on the cache step so a mid-job timeout on a +cold cache still persists the partial binaries. Otherwise first-PR-after- +pin-bump on every branch re-pays the full install cost. + +**Security-critical:** see §Security. `save-always` + unverified +downloads was a persistent-backdoor vector. PR #1 must update +`install_noded.sh` to GPG-verify releases and re-verify on every cache +restore. This is non-negotiable. + +First run after cutover repopulates per branch — one-off ~few-minute cost, +accepted. + +## Concurrency, timeouts, retries + +| Setting | Value | +|-----------------------|----------------------------------------------| +| Concurrency group | `${{ github.workflow }}-${{ github.ref }}` | +| Cancel in progress | `true` | +| `test` timeout | 45 min | +| `cypress` timeout | 30 min (tune after measurement) | +| `extension-smoketest` | 15 min | +| pytest reruns | **0** (fail fast) | +| Cypress `runMode` retries | **1** (log loudly) | + +## Security + +Red-team pass (Ravi, 2026-04-12) surfaced one **CRITICAL** regression +introduced by this migration, plus two HIGH findings tracked as follow-ups. + +### CRITICAL — bitcoind/elementsd download verification (PR #1 blocker) + +**Attack chain:** `tests/install_noded.sh:244` does a bare +`wget https://bitcoincore.org/bin/bitcoin-core-${version}/${binary_file}` +with **no GPG signature verification and no SHA256SUMS check** (same for +Elements at line 241). Under Cirrus this was per-run ephemeral. Under GH +Actions with `actions/cache@v4 save-always: true` — and a cache key that +only hashes the pinned-rev files and the install script, **not** the +binary content — a single poisoned fetch (MITM, DNS/BGP hijack, upstream +compromise) writes a trojaned `bitcoind` into the cache. Every +subsequent run on master and PR branches restores it from cache. The +test runner executes it with the repo checkout mounted. + +**Impact:** persistent backdoor across all PR and master runs until the +cache key rotates. Blast radius includes the release pipeline +(`release.yml`), which shares the repo's `GITHUB_TOKEN` boundary and +runs on the same runner pool. + +**Regression status:** the `install_noded.sh` bare-wget predates this +migration. Cirrus's per-run ephemerality masked the weakness. Moving to +GHA with `save-always` caching **materially worsens** it from transient +to persistent. We do not inherit the risk silently — we fix it in PR #1. + +**Fix (must land in PR #1):** +1. Update `tests/install_noded.sh` to download `SHA256SUMS` and + `SHA256SUMS.asc` alongside the binary tarball. +2. Import the Bitcoin Core release signing keys (fanquake, achow101) + into a temporary `GNUPGHOME` and verify `SHA256SUMS.asc`. +3. Verify the tarball hash matches the entry in `SHA256SUMS`. Abort on + mismatch. +4. Same treatment for ElementsProject downloads (`install_noded.sh:241`) + using Elements release signing keys. +5. Run the verification step **on cache hit as well** (not only cache + miss), so a tampered cache entry fails closed instead of being + trusted on restore. +6. Commit the expected `SHA256SUMS` content (or its content hash) to the + repo so post-restore verification has a trusted reference. Bump it + alongside `tests/bitcoin_gitrev_pinned` / `tests/elements_gitrev_pinned`. + +### HIGH — Cypress image integrity chain (30-day follow-up) + +**Attack chain:** `cypress-python-jammy` is built and pushed by hand. +Maintainer-laptop compromise at push time → attacker uploads a trojaned +image under legitimate GHCR credentials. Next Dockerfile edit, a +maintainer computes the digest from the compromised local build and +pins it in `test.yml`. Cypress runs as root inside, with the repo +checkout mounted. + +**Not a PR #1 blocker.** Image has been untouched for 18 months; edit +frequency bounds exposure. Tracked as a separate hardening issue. + +**Fix (separate PR, target: within 30 days of cutover):** +1. Automate image build in a workflow triggered on `docker/**` path + filter, using default `GITHUB_TOKEN` with `packages: write`. +2. Cosign keyless-sign the pushed image via GitHub OIDC. +3. `test.yml` verifies the signature via `cosign verify` before + consuming the container. Removes maintainer laptops from the trust + path entirely. + +### HIGH (acknowledged) — Release pipeline shares token boundary + +`.github/workflows/release.yml` ships specterd, pip package, and +Electron artifacts to real users. It lives in the same repo as +`test.yml` and shares the same `GITHUB_TOKEN` boundary and runner pool. +Any RCE in a test workflow runs on a runner that can read `release.yml`. +This migration does not change that exposure — but **fixing CRITICAL #1 +above also de-risks the release pipeline**, since the same runner pool +consumes the same (now verified) binaries. + +No spec change beyond this acknowledgment. Full release pipeline +hardening is out of scope. + +### LOW findings (accepted / backlog) + +- **Branch protection cutover gap:** minutes-wide, runbook adequate, + accept. +- **`GITHUB_TOKEN` on fork PRs:** default read-only, no + `pull_request_target`, no third-party actions, no secrets exposed. + Clean. +- **First-party actions pinned to major (`@v4`/`@v5`) not SHA:** GitHub + org compromise is a tier-1 ecosystem event. Defense-in-depth backlog + item, not blocking. + +## Test quality — flake detection, quarantine, steady-state + +Not in Cirrus today. Adding now, at minimum viable. + +- **Flake signal:** parse JUnit XML for retry markers after each run; when + a Cypress spec retries-to-green, emit a GH Actions warning annotation + and append a line to a tracking gist (`flake-log.md`). No dashboard yet. +- **Quarantine policy:** any spec flagged flaky twice in 14 days gets + `@skip(reason="flaky", issue="#NNNN")` with a linked GH issue and a + **2-week SLA** to fix-or-delete. +- **Retry budget:** Cypress `runMode: 1`. Anything needing more is quarantined. +- **Steady-state flake SLO (post-cutover):** rolling 30-day rerun rate + must stay **≤ 1%** across all jobs. Same JUnit parser feeds a daily cron + job that computes the window and auto-opens a P2 issue on breach. This + turns flake detection from a one-shot cutover gate into a living quality + signal — without it we'll be right back here in six months. + +## Cypress performance — measurement protocol + +**Before** approving any runner upgrade: +1. Run the Cypress suite 5× on `ubuntu-22.04` free tier. +2. Record wall-clock p50 / p95 via `/usr/bin/time -v` wrapping the script. +3. Compare to last 5 Cirrus runs' wall-clock. +4. Ship it if p95 is within **Cirrus +20%**. +5. Only if p95 exceeds +20% or hits the 30-min timeout, evaluate in order: + (a) `--shm-size` bump, (b) spec sharding across 2 jobs, + (c) `ubuntu-22.04-large` (paid — requires approval). + +## Migration plan — two PRs + +### PR #1 — Add GH Actions workflow (side-by-side) + +1. **Security blocker:** update `tests/install_noded.sh` per §Security + CRITICAL fix (GPG-verify `SHA256SUMS.asc`, verify tarball hash, + re-verify on cache restore). Commit trusted reference hashes + alongside `tests/bitcoin_gitrev_pinned` / + `tests/elements_gitrev_pinned`. This must land in the same PR as the + workflow. +2. Capture the `cypress-python-jammy` image digest from the `:20260411` + GHCR tag. Write it into `test.yml`. +3. Add `.github/workflows/test.yml` with all three jobs. +4. Add the one-sentence rebuild-and-push note to + `docker/cypress-python-jammy/Readme.md`. +5. **Do not touch** `.cirrus.yml`, `.gitlab-ci.yml`, or branch protection. +6. Land the PR. Both CI systems now run on every PR. +7. Iterate on the workflow until the acceptance gate (§Acceptance) passes. +8. Commit the evidence artifact to `docs/ci-migration-evidence.md` once + the gate is met. + +### PR #2 — Cutover + cleanup + +Only merge when PR #1 meets the acceptance gate. + +1. Delete `.cirrus.yml` and `docker/cirrus-jammy/`. +2. Delete `.gitlab-ci.yml` and `pyinstaller/build-win-ci.bat`. +3. Audit and prune dead GitLab-only code paths in `utils/release.sh`, + `utils/release_helper.py`, `utils/github.py`. +4. Drop Cirrus + GitLab sections from `docs/continuous-integration.md`. +5. Update `AGENTS.md` CI/CD section. +6. **Rename required status checks** in GitHub branch protection (see + §Branch protection). This is a separate manual step, documented in the + PR body. + +Splitting the cleanup out of PR #1 preserves fast revertability: if +anything melts in week 1 of side-by-side, PR #1 is trivially revertable +because it touches only additive files. + +## Branch protection & required checks + +**Scariest silent-failure mode.** If the required-check name on master +branch protection still says `Cirrus CI / test_task` after cutover, +*nothing is gating PRs anymore* and no alert fires. + +Runbook (must execute as part of PR #2 merge): + +1. Before merging PR #2: list current required checks via + `gh api repos/cryptoadvance/specter-desktop/branches/master/protection`. +2. Record the Cirrus check names. +3. Merge PR #2. +4. Immediately update branch protection: remove the Cirrus check names, + then add the exact GitHub Actions required-check contexts in the form + ` / ` as reported by the GitHub UI or API. + For this migration the workflow `name:` is `Tests` (see + `.github/workflows/test.yml`), so the three contexts to require are: + - `Tests / test` + - `Tests / cypress` + - `Tests / extension-smoketest` + + Do NOT add bare `test` / `cypress` / `extension-smoketest` — those are + job ids, not emitted check contexts, and will silently fail to gate. +5. Open a throwaway test PR and verify those exact three contexts are + marked required and are actually gating merge. +6. Only then announce cutover complete. + +## Secrets inventory + +No new secrets required. The workflow only uses the default +auto-provided `GITHUB_TOKEN` (for `ghcr.io` push during the one-shot image +mirror and for any standard action plumbing). + +Historical GitLab secrets (`GH_BIN_UPLOAD_PW`, `TWINE_PASSWORD`, +`GPG_PASSPHRASE`, `SSH_SPECTEREXT_DEPLOY_KEY`, `SSH_SPECTERSTATIC_DEPLOY_KEY`) +are already unused per AGENTS.md. PR #2 should not touch them (release +pipeline lives elsewhere). + +## Stakeholder comms + +This is CI plumbing, not a release. Scope is narrow: + +- **Extension developers:** smoketest contract preserved byte-for-byte. + No comms needed unless the job fails post-cutover. +- **`docs/continuous-integration.md`:** updated in PR #2 to reflect the + new CI topology. That's the extent of external-facing docs. + +## SLOs / acceptance criteria + +### PR wall-clock SLO + +**Baseline captured 2026-04-12** from the Cirrus GraphQL API +(`https://api.cirrus-ci.com/graphql`), last 20 successful master-branch +builds. Cirrus runs all three tasks in parallel, so total build wall-clock +≈ longest task (cypress). + +| Task | n | median | p95 | min | max | GHA ceiling (+20%) | +|---------------------------|----|--------|--------|--------|--------|--------------------| +| `test_task` | 20 | 4m47s | 5m35s | 4m23s | 5m37s | **5m44s** | +| `cypress_test_task` | 20 | 6m02s | 6m55s | 5m27s | 8m15s | **7m14s** | +| `extension_smoketest_task`| 20 | 2m10s | 2m27s | 1m55s | 2m39s | **2m36s** | + +- **Tolerance:** per-job wall-clock on GH Actions must not exceed the + "GHA ceiling" column (Cirrus median + 20%). This threshold is used + uniformly by the Cypress measurement protocol and the acceptance gate + — one number, one decision. +- **Action on breach:** investigate before cutover; do not ship PR #2 until + resolved. + +**Implications for Cypress measurement protocol:** current Cirrus cypress +p95 is 6m55s — well under the 30-min timeout and not remotely at risk of +hitting paid-runner territory. The "4 vCPU vs 6" concern is almost +certainly a non-issue in practice. Measurement protocol stands, but expect +it to pass on free runners. + +### Flake rate ceiling + +- **Baseline:** measured during side-by-side (unknown today). +- **Ceiling:** post-cutover flake rate ≤ Cirrus baseline. Any regression + is a cutover blocker. + +### Cost ceiling + +- GH Actions minutes on public OSS runners are effectively free. No hard + cap needed unless we escalate to `-large` runners (then: $X/month ceiling + requires explicit approval from project lead). + +### Acceptance gate for cutover (PR #2 merge criteria) + +All four must hold: +1. **10 consecutive green runs** of the new workflow on master-branch + schedule (nightly) or via manual dispatch. +2. **At least 3 green PR runs**, including one that touches `src/cryptoadvance/specter/static/` or frontend templates. +3. **Zero new flakes** detected over at least 50 total runs (PR + master). +4. Cypress p95 wall-clock within **Cirrus +20%** per measurement protocol. + +"Green on 3 PRs" alone is insufficient — flake rate is the real KPI. + +### Evidence artifact (mandatory) + +Before merging PR #2, a committed **evidence artifact** must exist at +`docs/ci-migration-evidence.md` containing, at minimum: + +- Measured wall-clock median + p95 per job over the 10+ master runs +- Flake count over the 50+ runs window (expected: 0) +- URLs to 3+ sample PR runs including the frontend-touching one +- Date and commit SHA at measurement time + +This exists so that six months from now "did we actually hit SLO?" has a +grep-able answer instead of a hope. GH Actions logs are GC'd; the evidence +doc is not. Ten minutes to write, permanent value. + +## Rollback procedure + +If anything breaks post-cutover: + +1. **Workflow-level breakage in PR #1 phase:** disable the GH Actions + workflow via `workflow_dispatch` off-switch or delete the file; Cirrus + still gates PRs. Zero user impact. +2. **Post-cutover breakage (PR #2 merged):** revert PR #2. This restores + `.cirrus.yml` and `docker/cirrus-jammy/`. Re-add Cirrus checks to branch + protection. Cirrus is assumed still alive up to shutdown date. +3. **Post-Cirrus-shutdown breakage:** no Cirrus fallback exists. Forward + fix only. This is why PR #2 must merge **≥ 4 weeks before shutdown**. + +## Timeline + +| Date | Milestone | +|-------------|-------------------------------------------------| +| 2026-04-12 | Cirrus baseline captured (§SLOs); GHCR migration merged (PR #2602) | +| 2026-04-13 | Spec finalized post red-team pass | +| 2026-04-14 | Open PR #1 (security fix + workflow + cache) | +| 2026-04-21 | PR #1 green on first PR run | +| 2026-05-05 | Acceptance gate met (10 green + flake-clean) | +| 2026-05-12 | **PR #2 merged + branch protection updated** | +| 2026-05-19 | One full release cycle on new CI (if a tag cuts)| +| 2026-06-30 | Cirrus shutdown (~7 week buffer after cutover) | + +## Deferred (explicit non-goals for this migration) + +- **Test pyramid rebalance.** Cypress likely owns flows that belong in + pytest + a Flask test client. Q3 epic, not this PR. Logged here so it's + not lost. +- **Cypress Dashboard / paid observability.** Vendor lock, not worth the + cost at current scale. JUnit + artifacts cover 90% of the value. +- **Coverage upload / Codecov / coverage-delta gating.** Out of scope for + this migration. Matches current Cirrus (which also doesn't upload). If + we want trend data later, it's a standalone follow-up PR with its own + bus-factor discussion and token plumbing. +- **Local-contributor reproducibility of the Cypress image.** Nice to + have; separate runbook, post-cutover. +- **Cypress image cosign signing + build automation.** Tracked in + §Security HIGH. Separate PR within 30 days of cutover, not blocking. +- **Release pipeline hardening.** Out of scope; §Security HIGH + acknowledges shared token boundary. +- **First-party action SHA pinning.** Defense-in-depth backlog. + +## Risks (updated) + +1. **Supply-chain backdoor via unverified bitcoind (CRITICAL).** Resolved + by the PR #1 blocker fix in §Security. Without that fix, this spec is + unmergeable. +2. **Cypress perf on 4 vCPU.** Largely resolved by baseline data: Cirrus + cypress p95 is 6m55s, so the suite is not heavyweight. Residual risk is + that GH Actions 4-vCPU runners could push it past the +25% ceiling + (7m32s). Mitigation: measurement protocol in §Cypress measurement. + Escape hatch documented, not pre-purchased. +2. **Branch protection silent-ungate.** Scariest risk; has a runbook now + (§Branch protection). +3. **ghcr.io image rebuild drift.** Once mirrored, whoever edits + `docker/cypress-python-jammy/Dockerfile` must remember to rebuild and + re-push. Mitigation: README runbook + digest pinning makes staleness + visible (workflow won't auto-upgrade). +4. **First-run cache miss storm.** One-off few-minute cost. Accepted. + +## Unresolved questions + +None. All prior questions resolved or descoped. diff --git a/tests/bitcoin_SHA256SUMS b/tests/bitcoin_SHA256SUMS new file mode 100644 index 000000000..7649492d4 --- /dev/null +++ b/tests/bitcoin_SHA256SUMS @@ -0,0 +1,35 @@ +# Trusted reference: verbatim plaintext of https://bitcoincore.org/bin/bitcoin-core-22.0/SHA256SUMS +# Provenance: +# Source URL: https://bitcoincore.org/bin/bitcoin-core-22.0/SHA256SUMS +# Signature URL: https://bitcoincore.org/bin/bitcoin-core-22.0/SHA256SUMS.asc +# Verified on: 2026-04-15 +# Verified by: Good signatures from: +# - fanquake (primary fingerprint E777299FC265DD04793070EB944D35F9AC3DB76A, +# signing subkey CFB16E21C950F67FA95E558F2EEB9F5CC09526C1) +# - achow101 (primary fingerprint 152812300785C96444D3334D17565732E08E5E41) +# Builder keys sourced from: https://github.com/bitcoin-core/guix.sigs/tree/main/builder-keys +# Do not edit the hash lines below — they are the on-disk trust anchor compared +# against downloaded tarballs by tests/install_noded.sh::verify_binary. +9547fa03574f8bde296f707c7d9f7d89827c75c5a28f84402578a4fa92a787ec bitcoin-22.0-aarch64-linux-gnu-debug.tar.gz +ac718fed08570a81b3587587872ad85a25173afa5f9fbbd0c03ba4d1714cfa3e bitcoin-22.0-aarch64-linux-gnu.tar.gz +80071e0ecd24edfec8a1972b495b9822c79a5d33c7123bff51688638aac97cab bitcoin-22.0-arm-linux-gnueabihf-debug.tar.gz +b8713c6c5f03f5258b54e9f436e2ed6d85449aa24c2c9972f91963d413e86311 bitcoin-22.0-arm-linux-gnueabihf.tar.gz +8f70852feb39078e02182563517d17bdfc4a12904cf1bdabbae95594d9a1e473 bitcoin-22.0-codesignatures-22.0.tar.gz +d0e9d089b57048b1555efa7cd5a63a7ed042482045f6f33402b1df425bf9613b bitcoin-22.0.tar.gz +bfc04a3c4e8b613bfd9359e54da6cc60f027860e9723f9a6bfd6f13873eb811f bitcoin-22.0-powerpc64-linux-gnu-debug.tar.gz +2cca5f99007d060aca9d8c7cbd035dfe2f040dd8200b210ce32cdf858479f70d bitcoin-22.0-powerpc64-linux-gnu.tar.gz +5f0bf1491bc8825ca1506f7cf586030f06bb17a563ccde92e8c75720022704e6 bitcoin-22.0-powerpc64le-linux-gnu-debug.tar.gz +91b1e012975c5a363b5b5fcc81b5b7495e86ff703ec8262d4b9afcfec633c30d bitcoin-22.0-powerpc64le-linux-gnu.tar.gz +59b16e63aa935f50fd2813efe7f137187fcf0fff84e3205a9c6cb462a8bb160c bitcoin-22.0-riscv64-linux-gnu-debug.tar.gz +9cc3a62c469fe57e11485fdd32c916f10ce7a2899299855a2e479256ff49ff3c bitcoin-22.0-riscv64-linux-gnu.tar.gz +3b3e2680f7d9304c13bfebaf6445ada40d72324b4b3e0a07de9db807389a6c5b bitcoin-22.0-osx-signed.dmg +52449aa894a6ce5653315e1260d0ce87c1d9f490afe3c92b44285710804b11ae bitcoin-22.0-osx-unsigned.dmg +f51156774c24c0ac5cc30237fa08aa17ed04a180dfd72c3e7d20fdc3f45806dc bitcoin-22.0-osx-unsigned.tar.gz +2744d199c3343b2d94faffdfb2c94d75a630ba27301a70e47b0ad30a7e0155e9 bitcoin-22.0-osx64.tar.gz +3a4f05657c048d3e9505bdb9c4fb3658e5e3d4233b0b93c1853e080620589765 bitcoin-22.0-x86_64-linux-gnu-debug.tar.gz +59ebd25dd82a51638b7a6bb914586201e67db67b919b2a1ff08925a7936d1b16 bitcoin-22.0-x86_64-linux-gnu.tar.gz +9169989d649937c0f9ebccd3ab088501328aa319fe9e91fc7ea8e8cf0fcccede bitcoin-22.0-win64-setup.exe +f890473d6d910d478f8ff08f9356d0305d19b46cf06e4fc3b5a49b0b684fd2a7 bitcoin-22.0-win-unsigned.tar.gz +0a97ebc8ae44913e3ef9c5b1ddd2af3a4ffb0ba25b6ab1ee8173e40e60499402 bitcoin-22.0-win64-debug.zip +ecc579d006230d6ffc5a5b7b53ce8c76477d37c1c7bad69694e9c2d69f00331d bitcoin-22.0-win64-setup-unsigned.exe +9485e4b52ed6cebfe474ab4d7d0c1be6d0bb879ba7246a8239326b2230a77eb1 bitcoin-22.0-win64.zip diff --git a/tests/elements_SHA256SUMS b/tests/elements_SHA256SUMS new file mode 100644 index 000000000..05ec0d0d6 --- /dev/null +++ b/tests/elements_SHA256SUMS @@ -0,0 +1,22 @@ +# Trusted reference: plaintext hashes extracted from the clearsigned +# SHA256SUMS.asc published with elements-0.21.0.2. +# Provenance: +# Source URL: https://github.com/ElementsProject/elements/releases/download/elements-0.21.0.2/SHA256SUMS.asc +# Clearsigned: yes (PGP-SIGNED-MESSAGE, Hash: SHA256) +# Verified on: 2026-04-15 +# Verified by: Good signature from Steven Roose +# primary fingerprint 8CC974D9CFD034DCEED213B02A57E0A610D7F19C +# signing subkey DE10E82629A8CAD55B700B972F2A88D7F8D68E87 +# Key sourced from: hkps://keyserver.ubuntu.com (search 0x2F2A88D7F8D68E87) +# Do not edit the hash lines below — they are the on-disk trust anchor compared +# against downloaded tarballs by tests/install_noded.sh::verify_binary. +06de6cd39f00a8b5604f2baa6203c08358762b99560bec6ad90a65bc22fadf6f elements-elements-0.21.0.2-aarch64-linux-gnu.tar.gz +d263ed2182f55d0d08013f005f8db3d8d16c5f2a8e954b93f7938ceadd3798e0 elements-elements-0.21.0.2-arm-linux-gnueabihf.tar.gz +bbeac447c8947cb6788cc5d3f8e8eade82d9d03b159992fa22f4a0198ebd8a60 elements-elements-0.21.0.2-osx64.tar.gz +f7774aba06a4b4a3708be96774cd9e993fc58998f91a1a8cf9b692a9f888f89f elements-elements-0.21.0.2-osx-unsigned.dmg +9cdc53b1f81a8c2f3e4b04d80de04ca1cc182d018c0c4fef1aaaf6dcc87f5b42 elements-elements-0.21.0.2-osx-unsigned.tar.gz +cf3e2d6fc0811a3dd1e1ace640a62319af35443c76a404caf4f26674197c8059 elements-elements-0.21.0.2-riscv64-linux-gnu.tar.gz +a3ec010bd10e8b5947030e6b846069084056418a7a1e111c0d2634a65662e53e elements-elements-0.21.0.2-win64-setup-unsigned.exe +7a8f427be448eaa89f87c31cce705069c2d9528cf54fdd838c104f1257f800d4 elements-elements-0.21.0.2-win64.zip +4863ee842d13bbb0255c2002ccc07f170b87717589a3493805944579a4af6745 elements-elements-0.21.0.2-win-unsigned.tar.gz +3018116794429b77ce0dd7436c2906f8be4eb5d6163b8451c5ce7e7bedad152b elements-elements-0.21.0.2-x86_64-linux-gnu.tar.gz diff --git a/tests/install_noded.sh b/tests/install_noded.sh index e942c39d1..99b909ffb 100755 --- a/tests/install_noded.sh +++ b/tests/install_noded.sh @@ -221,6 +221,184 @@ function sub_compile { echo " --> install_node.sh End $(date) took $DIFF" } +# ----------------------------------------------------------------------------- +# Release signing trust anchors for GPG defense-in-depth verification. +# +# These fingerprints (and the SHA256 trust anchors committed in +# tests/bitcoin_SHA256SUMS / tests/elements_SHA256SUMS) are the reason this +# script exists in its hardened form: CI uses `save-always: true` caches, so +# a single poisoned fetch would persist across every future run if the +# verification path were ever skipped. verify_binary() MUST be invoked on +# both cache miss (fresh download) AND cache hit (restore), and MUST abort +# non-zero on any mismatch. +# +# Builder keys for bitcoin-core releases are published at: +# https://github.com/bitcoin-core/guix.sigs/tree/main/builder-keys +# The elements signing key was fetched from: +# hkps://keyserver.ubuntu.com (search 0x2F2A88D7F8D68E87) +# ----------------------------------------------------------------------------- + +# Pinned release signing keys (fingerprints). The committed SHA256SUMS files +# in tests/ are the primary trust anchor; the GPG step below re-validates the +# upstream chain when the network is reachable. +BITCOIN_RELEASE_KEYS=( + "E777299FC265DD04793070EB944D35F9AC3DB76A" # fanquake + "152812300785C96444D3334D17565732E08E5E41" # achow101 / Ava Chow +) +ELEMENTS_RELEASE_KEYS=( + "8CC974D9CFD034DCEED213B02A57E0A610D7F19C" # Steven Roose +) + +# Compute the sha256 of a file in a cross-platform way. +# Usage: sha256_of +function sha256_of { + local f=$1 + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$f" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$f" | awk '{print $1}' + else + echo "ERROR: neither sha256sum nor shasum found" >&2 + return 127 + fi +} + +# Look up the expected sha256 for ${basename_of_artifact} from the committed +# trust anchor file tests/${node_impl}_SHA256SUMS. Comment lines (starting +# with '#') in the trust anchor are ignored so the provenance preamble does +# not interfere. +# Usage: expected_sha_for +function expected_sha_for { + local node_impl=$1 + local artifact=$2 + local trust_file="" + # tests/install_noded.sh cd's to its own dir at the top of the file, so + # the trust anchors live one directory up from the usual CWD + # (./${node_impl}) and sometimes in the CWD itself, depending on whether + # verify_binary is called before or after we cd into ./${node_impl}. + if [ -f "./${node_impl}_SHA256SUMS" ]; then + trust_file="./${node_impl}_SHA256SUMS" + elif [ -f "../${node_impl}_SHA256SUMS" ]; then + trust_file="../${node_impl}_SHA256SUMS" + else + echo "ERROR: trust anchor tests/${node_impl}_SHA256SUMS not found" >&2 + return 2 + fi + # Format: + grep -v '^[[:space:]]*#' "$trust_file" \ + | awk -v f="$artifact" '$2 == f {print $1; exit}' +} + +# Import release signing keys into a throw-away GNUPGHOME and GPG-verify +# ${sumsfile} against ${sigfile}. Returns 0 on good signature, non-zero on +# anything else (including no network, missing gpg, bad sig). Callers MUST +# still compare ${sumsfile} to the committed trust anchor — this GPG step is +# defense in depth, not the sole trust anchor. +# Usage: gpg_verify_sums +function gpg_verify_sums { + local node_impl=$1 + local sumsfile=$2 + local sigfile=$3 + if ! command -v gpg >/dev/null 2>&1; then + echo " --> WARNING: gpg not installed; skipping upstream GPG re-verify (committed SHA256SUMS trust anchor still enforced)" + return 0 + fi + local tmp_gnupg + tmp_gnupg=$(mktemp -d -t specter-gnupg-XXXXXX) || return 1 + # shellcheck disable=SC2064 + trap "rm -rf '$tmp_gnupg'" RETURN + export GNUPGHOME="$tmp_gnupg" + chmod 700 "$tmp_gnupg" + local keys=() + if [ "$node_impl" = "bitcoin" ]; then + keys=("${BITCOIN_RELEASE_KEYS[@]}") + elif [ "$node_impl" = "elements" ]; then + keys=("${ELEMENTS_RELEASE_KEYS[@]}") + fi + local imported=0 + for fpr in "${keys[@]}"; do + # Try keys.openpgp.org first, then keyserver.ubuntu.com. + if curl -fsSL "https://keys.openpgp.org/vks/v1/by-fingerprint/${fpr}" 2>/dev/null \ + | gpg --import 2>/dev/null; then + imported=$((imported + 1)) + continue + fi + if curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x${fpr}&options=mr" 2>/dev/null \ + | gpg --import 2>/dev/null; then + imported=$((imported + 1)) + fi + done + if [ "$imported" -eq 0 ]; then + echo " --> WARNING: could not fetch any ${node_impl} release signing keys; skipping upstream GPG re-verify" + unset GNUPGHOME + return 0 + fi + # Bitcoin Core SHA256SUMS.asc carries signatures from many maintainers. + # gpg --verify exits non-zero when ANY signature's key is missing, even + # if others verify successfully. We only import a subset of keys, so + # the raw exit code is unreliable. Instead, use --status-fd to check + # for at least one VALIDSIG whose primary-key fingerprint we trust. + # + # VALIDSIG format (last field is always the primary key fingerprint, + # even when the signature was made by a subkey): + # VALIDSIG ... + # GOODSIG uses the signing subkey ID, which may differ from the primary + # fingerprint we pin — so VALIDSIG is the correct field to match. + local status_out + if [ -n "$sigfile" ] && [ -f "$sigfile" ]; then + status_out=$(gpg --status-fd 1 --verify "$sigfile" "$sumsfile" 2>/dev/null) + else + # Clearsigned (elements case): verify in-place. + status_out=$(gpg --status-fd 1 --verify "$sumsfile" 2>/dev/null) + fi + local found_good=0 + for fpr in "${keys[@]}"; do + # Match VALIDSIG line whose last field (primary fingerprint) equals ours. + if echo "$status_out" | grep -q "VALIDSIG.*${fpr}"; then + found_good=1 + echo " --> GPG: valid signature traced to primary key ${fpr}" + break + fi + done + unset GNUPGHOME + if [ "$found_good" -eq 0 ]; then + echo " --> WARNING: no VALIDSIG from a trusted primary key found in GPG status output" + echo " --> Status output: $status_out" + return 1 + fi + return 0 +} + +# Verify a binary artifact (tarball) against the committed trust anchor. +# Aborts the script with exit 2 on any mismatch. +# Usage: verify_binary +function verify_binary { + local node_impl=$1 + local binary_path=$2 + if [[ ! -f "$binary_path" ]]; then + echo "ERROR: verify_binary: $binary_path does not exist" >&2 + exit 2 + fi + local artifact + artifact=$(basename "$binary_path") + local expected + expected=$(expected_sha_for "$node_impl" "$artifact") + if [ -z "$expected" ]; then + echo "ERROR: verify_binary: no trusted sha256 entry for '$artifact' in tests/${node_impl}_SHA256SUMS" >&2 + exit 2 + fi + local actual + actual=$(sha256_of "$binary_path") || exit 2 + if [ "$expected" != "$actual" ]; then + echo "ERROR: verify_binary: sha256 mismatch for $artifact" >&2 + echo " expected $expected" >&2 + echo " actual $actual" >&2 + echo " This is a trust violation. Aborting." >&2 + exit 2 + fi + echo " --> verify_binary: OK ($artifact sha256=$expected)" +} + function sub_binary { node_impl=$1 echo " --> install_noded.sh Start $(date) (binary) for node_impl $node_impl" @@ -236,16 +414,52 @@ function sub_binary { else binary_file=${node_impl}-${version}-x86_64-linux-gnu.tar.gz fi - if [[ ! -f $binary_file ]]; then + + # The tarball lives inside ./${node_impl}/ so that it is captured by the + # cache (which covers ./tests/${node_impl}/) and re-verified on every + # cache restore. This is the whole point of the hardened flow — see the + # "save-always backdoor" rationale at the top of the file. + # Clean up a broken symlink from a partial cache restore (the workflow + # caches ./tests/${node_impl}-* as well, but be defensive). + if [[ -L "./${node_impl}" && ! -e "./${node_impl}" ]]; then + echo " --> cleaning up dangling symlink ./${node_impl}" + rm -f "./${node_impl}" + fi + mkdir -p "./${node_impl}" + local cached_tarball="./${node_impl}/${binary_file}" + + if [[ -f "$cached_tarball" ]]; then + echo " --> cache hit: found existing ${cached_tarball}, re-verifying" + verify_binary "$node_impl" "$cached_tarball" + else + echo " --> cache miss: downloading ${binary_file}" if [ "$node_impl" = "elements" ]; then - wget https://github.com/ElementsProject/elements/releases/download/${version}/${binary_file} + # elements publishes a clearsigned SHA256SUMS.asc only + ( cd "./${node_impl}" && \ + wget -q "https://github.com/ElementsProject/elements/releases/download/${version}/${binary_file}" && \ + wget -q "https://github.com/ElementsProject/elements/releases/download/${version}/SHA256SUMS.asc" ) + gpg_verify_sums "$node_impl" "./${node_impl}/SHA256SUMS.asc" "" \ + || { echo "ERROR: upstream GPG verification failed for elements SHA256SUMS.asc" >&2; exit 2; } fi if [ "$node_impl" = "bitcoin" ]; then - wget https://bitcoincore.org/bin/bitcoin-core-${version}/${binary_file} + ( cd "./${node_impl}" && \ + wget -q "https://bitcoincore.org/bin/bitcoin-core-${version}/${binary_file}" && \ + wget -q "https://bitcoincore.org/bin/bitcoin-core-${version}/SHA256SUMS" && \ + wget -q "https://bitcoincore.org/bin/bitcoin-core-${version}/SHA256SUMS.asc" ) + gpg_verify_sums "$node_impl" "./${node_impl}/SHA256SUMS" "./${node_impl}/SHA256SUMS.asc" \ + || { echo "ERROR: upstream GPG verification failed for bitcoin SHA256SUMS.asc" >&2; exit 2; } fi + # Primary trust anchor: committed tests/${node_impl}_SHA256SUMS. + verify_binary "$node_impl" "$cached_tarball" fi - tar -xzf ${binary_file} + # Extract into ./${node_impl}-${version}/ (tarballs ship that layout). + # Leave the tarball in place inside ./${node_impl}/ to be reused on + # subsequent cache hits (that directory becomes a symlink below, so we + # copy the tarball out to a sibling and re-plant it after the symlink). + local tarball_basename="${binary_file}" + cp "$cached_tarball" "./${tarball_basename}.verified" + tar -xzf "$cached_tarball" if [[ -d ./"$node_impl" ]]; then if [[ -d ./"$node_impl"/src ]]; then mv ./"$node_impl" ./"$node_impl"-src @@ -253,8 +467,12 @@ function sub_binary { rm -rf ./"$node_impl" fi fi - rm "$node_impl" + rm -f "$node_impl" ln -s ./"$node_impl"-${version} "$node_impl" + # Re-plant the verified tarball inside the (now-symlinked) cached dir so + # the next cache restore can re-verify it. + mv "./${tarball_basename}.verified" "./${node_impl}/${binary_file}" + echo " --> Listing binaries" if [ $(uname) = "Darwin" ]; then find ./"$node_impl"/bin -maxdepth 1 -type f -perm +111 -exec ls -ld {} \; @@ -263,6 +481,11 @@ function sub_binary { fi echo " --> checking for ${node_impl}d" test -x ./${node_impl}/bin/${node_impl}d || exit 2 + + # Final defense-in-depth: re-verify the tarball one more time so that + # tampering between extract and exit is caught as well. + verify_binary "$node_impl" "./${node_impl}/${binary_file}" + echo " --> Finished installing ${node_impl}d binary" END=$(date +%s) DIFF=$(echo "$END - $START" | bc)