Skip to content

Verify the explore setup script and every download it makes - #37

Merged
stormer78 merged 3 commits into
mainfrom
sec-4045/verified-setup-script
Sep 12, 2026
Merged

stormer78 merged 3 commits into
mainfrom
sec-4045/verified-setup-script

Conversation

@stormer78

Copy link
Copy Markdown
Contributor

Summary

scripts/setup-explore.sh runs as root, but the guide told operators to pipe it from the mutable main branch into bash. The script then did the same thing again: rustup via sh.rustup.rs | sh, Node.js via NodeSource's setup_22.x | sudo -E bash -, and Docker's apt key imported with no fingerprint check. Every step trusted whatever a server returned over TLS, and a truncated download could run part of a script.

This PR makes the script and its downloads verifiable, publishes the script as an attested GitHub Release, adds CI to stop the pattern from coming back, and rewrites the guide to download, verify, then run.

What the script installs now, and how each download is checked

Component Before After Integrity
The script itself curl raw.githubusercontent.com/.../main/... | bash Release asset for a v* tag SHA-256 pinned in the guide, SHA256SUMS, build provenance attestation
Rust curl sh.rustup.rs | sh Ubuntu rustup package (1.27.1 in 26.04 universe), then rustup default stable apt, Ubuntu archive signing key
Rust (fallback, no apt package) none rustup-init 1.29.1 from static.rust-lang.org/rustup/archive/1.29.1/<triple>/ for x86_64 or aarch64 SHA-256 pinned in the script, checked before execution; unknown arch refused
Node.js NodeSource setup_22.x | sudo -E bash - in the base script Not in the script. Option B installs Ubuntu nodejs npm right before the did-hosting-ui build, the only step that uses it apt, Ubuntu archive signing key
Docker Docker apt repo, key without fingerprint check, user added to docker group Removed: nothing in the explore stream uses Docker, and docker group membership is root-equivalent n/a
nginx, Valkey, build deps, ufw apt apt (unchanged) apt
certbot snap snap (unchanged) Snap Store signature

Other script changes:

  • set -euo pipefail, with the body in main() { ... } called on the last line, so a truncated download runs nothing.
  • DOMAIN and EMAIL are validated (hostname and simple address regexes, length limits, at most two arguments) before any command runs. certbot arguments are an array, not a word-split string.
  • The distro rustup keeps cargo in /usr/bin and creates no ~/.cargo/env, so the script puts ~/.cargo/bin on PATH in ~/.profile and ~/.bashrc (idempotently) for cargo install output. Step 4 of the guide now says export PATH="$HOME/.cargo/bin:$PATH".
  • Steps renumber from 10 to 8.

Why Node.js falls back to the distro package

NodeSource's official material (the nodesource/distributions README and DEV_README, the "Repository Manual Installation" wiki page, and nodesource.com/products/distributions) does not publish a fingerprint for nodesource-repo.gpg.key. The key currently served has primary fingerprint 6F71F525282841EEDAF851B42F59B5F99B1BE0B4. That value only appears in user-pasted apt errors on NodeSource issues (#1908, #1920) and in third-party material, not in a statement from NodeSource. Pinning a fingerprint read off the same TLS endpoint would be trust-on-first-use with extra steps, so the guide uses Ubuntu's nodejs instead.

Caveat: did-hosting-ui/package.json declares "node": ">=24.3.0", and Ubuntu 26.04 ships Node.js 22.22.1. The guide says so and points at the pre-built did-hosting-daemon if the UI build fails. See open questions.

Release and CI

  • .github/workflows/release.yml (tag push v*):
    • Job attest (contents: read, id-token: write, attestations: write) requires the tagged commit to be on main, builds dist/setup-explore.sh and dist/SHA256SUMS, and runs actions/attest-build-provenance over both.
    • Job publish (contents: write, environment: release) rebuilds the asset, checks it against the attested digest, and runs gh release create --verify-tag with the hash and the gh attestation verify command in the notes.
    • No third-party actions; no ${{ }} expressions inside run: blocks.
  • .github/workflows/lint.yml (contents: read): shellcheck scripts/*.sh .github/scripts/*.sh (preinstalled on ubuntu-24.04), and .github/scripts/guard-remote-exec.sh, which fails on curl/wget piped into (sudo [-E]) (ba|z|da)sh, on sh <(curl ...), and on raw.githubusercontent.com/OpenVTC/vti-setup/main/ in scripts/, sysop/, developer/, community-manager/ and README.md.
  • .github/dependabot.yml: github-actions, weekly, cooldown: default-days: 7, grouped.
  • Pins, resolved with gh api repos/<owner>/<repo>/git/ref/tags/<tag> (both are lightweight tags pointing at commits):
    • actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
    • actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 (as of v4 a wrapper over actions/attest; Dependabot will track it)
  • Every checkout sets persist-credentials: false.

How the guide pins the hash

The release hash covers only scripts/setup-explore.sh, not the guide, so the hash is known before the tag exists and can be committed ahead of it. Step 3 of sysop/explore/01-server-setup.md now has:

  1. VER=v1.0.0 and SHA256=c59f53c74f423327a233bb3178651bf7958449efd88df05df8c2f8d179db1340, which is the script in this PR.
  2. Download setup-explore.sh and SHA256SUMS from that release.
  3. echo "${SHA256} setup-explore.sh" | sha256sum -c -. This is the out-of-band anchor, because the hash comes from the reviewed guide, not from the release.
  4. sha256sum -c SHA256SUMS, a consistency check only.
  5. gh attestation verify setup-explore.sh --repo OpenVTC/vti-setup --signer-workflow OpenVTC/vti-setup/.github/workflows/release.yml. This needs gh >= 2.49 and gh auth login. Ubuntu 26.04's gh is 2.46, so the guide says to run it on a workstation and compare the hash.
  6. sudo bash setup-explore.sh <domain> [email]

RELEASING.md documents the routine for later releases: pin VER and SHA256 in a PR, merge, tag that commit, approve the environment, then verify the assets.

Maintainer steps after merge

  1. Before tagging: create the release environment with required reviewers (otherwise GitHub creates it unprotected on first use), and add a tag ruleset restricting v* to maintainers.

  2. Confirm the merge commit did not change the script: sha256sum scripts/setup-explore.sh must print c59f53c74f423327a233bb3178651bf7958449efd88df05df8c2f8d179db1340. If it doesn't, re-pin in a follow-up PR first.

  3. Tag the merge commit and push the tag right away, because the guide already points at v1.0.0:

    git tag -a v1.0.0 -m "v1.0.0" <merge-commit>
    git push origin v1.0.0
  4. Approve the release deployment.

  5. Verify the published assets exactly as Step 3 of the guide does, including gh attestation verify.

If any check fails, delete the release and tag, fix, and release a new version. Never move a published tag.

Follow-up outside this repo: binary checksums

sysop/explore/01-server-setup.md Step 5 (and developer/02-openvtc-tui.md for openvtc) still downloads service binaries from download.firstperson.dev into /usr/local/bin with no integrity check: vta/latest/vta.sha256 and vta/latest/SHA256SUMS return 404. This PR only labels Option A as not integrity-checked and the /main/ channel as unverified, throwaway hosts only. The fix belongs to the binary release pipeline: publish SHA256SUMS per release plus a signature (minisign, or cosign / GitHub attestations). The guide can then add sha256sum -c --ignore-missing SHA256SUMS before each install, and the lint guard can require it.

Validation

  • bash -n passes on scripts/setup-explore.sh and .github/scripts/guard-remote-exec.sh.
  • shellcheck 0.11.0 reports nothing on either file. CI uses the runner's 0.9.0.
  • Workflow and Dependabot YAML parse (PyYAML).
  • The guard passes on this branch, and fails on main (the old one-liners).
  • A local stub harness (not committed) ran the script under bash 3.2 with apt-get, apt-cache, curl, snap, sudo, systemctl, certbot, rustup and others stubbed. 25/25 checks passed:
    • x.com;id, $(id).x.com, example.com -d attacker.com, -d, an embedded newline, a single label, a bad email, no args and three args all exit 1 before any stubbed command runs.
    • The script truncated at every 10th line runs no command.
    • The happy path installs rustup via apt, never touches rustup-init, NodeSource or Docker, passes certbot arguments as separate words, and doesn't duplicate the PATH line on re-run.
    • A tampered rustup-init is refused and never executed. The genuine 1.29.1 x86_64 and aarch64 binaries pass the checksum. An unknown architecture is refused without downloading.
  • Not run: markdownlint (not installed locally), actionlint/zizmor, or a real Ubuntu 26.04 run.

Open questions

  • Tag scheme: v1.0.0 is proposed. The repo's existing tag is Banyan, and VTI uses VTI-Dogwood.
  • Node.js for did-hosting-ui: the UI wants >= 24.3.0 but Ubuntu 26.04 has 22. Options:
    • confirm the UI builds on 22 (or relax engines)
    • NodeSource once it publishes a key fingerprint
    • nodejs.org tarballs checked against SHASUMS256.txt.asc with the Node release keys listed in the nodejs/node README
  • Should CI also assert that the guide's pinned SHA256 equals scripts/setup-explore.sh on main? That would force a release for every script change.

setup-explore.sh runs as root, but it installed Rust by piping
sh.rustup.rs into sh, Node.js by piping the NodeSource setup script into
sudo -E bash, and imported Docker's apt key with no fingerprint check.
Each step trusted whatever the server returned over TLS.

- Wrap the body in main() and call it on the last line, under
  set -euo pipefail, so a download that is cut short runs nothing.
- Check that the domain is a plain hostname and the email a plain
  address before any command runs. Both end up in nginx server_name
  lines and certbot arguments; certbot now takes them as an array.
- Install Rust from Ubuntu's rustup package, which apt verifies against
  the archive key, then `rustup default stable`. Where the archive has
  no rustup package, fetch rustup-init 1.29.1 for the host architecture
  from rustup's versioned archive and refuse to run it unless it matches
  the SHA-256 pinned in the script.
- Put ~/.cargo/bin on PATH for login shells, since the distro rustup
  keeps cargo in /usr/bin but `cargo install` still writes there.
- Stop installing Node.js. Only the optional did-hosting-ui source build
  needs it, and the guide now installs Ubuntu's nodejs at that step.
- Stop installing Docker and adding the user to the docker group.
  Nothing in the explore stream uses Docker, and group membership is
  equivalent to root.

Steps renumber from 10 to 8.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
The guide had operators fetch setup-explore.sh from the main branch
with no way to check what they were about to run as root, and the repo
had no CI.

- release.yml: on a v* tag, attach setup-explore.sh and SHA256SUMS to a
  GitHub Release and record a build provenance attestation for both, so
  `gh attestation verify` can tie a download to this workflow. Tagged
  commits must be on main. Attesting (id-token, attestations) and
  publishing (contents: write, `release` environment) run as separate
  jobs, and the publish job rebuilds the asset and checks it against the
  attested digest.
- lint.yml: run shellcheck, plus a guard that fails on downloads piped
  into a shell and on raw fetches from this repository's main branch.
- dependabot.yml: keep the action SHA pins current, with a 7-day
  cooldown on new releases.
- RELEASING.md: how to pin the hash in the guide, tag, and verify the
  published assets.

Every action is pinned to a full commit SHA, and no checkout keeps
credentials.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
Step 3 piped setup-explore.sh from the main branch straight into bash,
so a changed branch or a truncated download ran as root unchecked.

- sysop/explore/01-server-setup.md, Step 3: download setup-explore.sh
  and SHA256SUMS from a tagged release, check the script against the
  SHA-256 pinned on the page for that tag, check SHA256SUMS, verify the
  build provenance attestation (on a workstation if the server's gh is
  older than 2.49), then run `sudo bash setup-explore.sh <domain>`.
  The pin is v1.0.0; RELEASING.md covers updating it.
- The step list drops Node.js and Docker. Option B installs Ubuntu's
  nodejs and npm right before the did-hosting-ui build, with a note that
  the UI declares Node.js >= 24.3.0 while Ubuntu 26.04 ships 22.
- Step 4 puts ~/.cargo/bin on PATH instead of sourcing ~/.cargo/env,
  which the distro rustup does not create.
- Option A says download.firstperson.dev publishes no checksums, and the
  main-branch binaries are labelled unverified, for throwaway hosts only.
- The debconf troubleshooting section no longer downloads from main.
- sysop/README.md: the Explore stream no longer lists Node or Docker as
  installed.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
@stormer78
stormer78 merged commit 632747b into main Sep 12, 2026
2 checks passed
@stormer78
stormer78 deleted the sec-4045/verified-setup-script branch September 12, 2026 05:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant