From 584d31fc49c62677e31ef034a590c9498974b084 Mon Sep 17 00:00:00 2001 From: Minseong Choi Date: Tue, 28 Jul 2026 18:01:39 +0900 Subject: [PATCH] feat(bootstrap): refuse to install an unverified Velocity fork jar FELIS_VELOCITY_FORK_JAR replaces the proxy every player connects through, and the only thing checked about it was that the path pointed at a readable file. A truncated copy, a stale build left at the same path, or the two-patch jar where the three-patch one was meant all installed silently. It now requires FELIS_VELOCITY_FORK_JAR_SHA256 and refuses on a mismatch, hashing stdin rather than the path for the reason install_via_plugins already documents: sha256sum escapes its output line for a filename carrying a backslash or newline, and the leading "\" that adds fails every comparison. The absent-digest refusal prints the jar's actual hash, so the first run after a deliberate rebuild is one copy-paste rather than an investigation. The comparison ignores case and internal spaces. The fork is built on a developer machine, which is usually Windows, and nothing there prints a digest the way sha256sum does: Get-FileHash returns uppercase and certutil has shipped the bytes space-separated. Comparing raw would refuse two of the three spellings of the correct answer and word the refusal as tampering. No digest is hardcoded, which is the half of the request this does not deliver. The fork is built from Felis-Legacy and has never been reproduced on a second machine, so a constant here would pin one machine's output rather than the fork. The comment that previously asserted the build "is not byte-reproducible" is gone too -- it was stated more confidently than the evidence supports. The fork jars on disk carry Gradle's constant 1980-02-01 entry timestamps, so the usual reason a jar differs between builds is already absent; that is not proof it reproduces, and neither claim should sit in the script unmeasured. This is deliberately not a supply-chain signature and the comment says so: an operator who can write the jar can write the digest. What it buys is that a path stops being an identity, and that every later re-run re-checks the same build. Scope: the fork jar only. The else branch still curls stock Velocity from PaperMC with no verification at all, and that is the branch a default install takes. The digest is already in hand there -- Fill v3 returns checksums.sha256 and its download URL is content-addressed on that same value -- and papermc_latest_jar discards it. Left alone rather than widened into this change. deploy/bootstrap_test.sh covers the gate's two refusals, its happy path, and the two Windows digest spellings. Each case extracts the block under test out of bootstrap.sh with awk and runs it with die/log stubbed, rather than transcribing it -- a transcribed copy passes forever after someone edits the original. The extraction is length-bounded: awk runs an unmatched end pattern to EOF, which would quietly feed the rest of bootstrap.sh to the shell under test. bootstrap.sh itself cannot run here; it wants root, a package manager and k3s. A `shell` CI job runs that plus a syntax check over every tracked script. The syntax step dispatches on each file's shebang instead of running `sh -n` across the board. The blanket form looks fine and is a false green: on a developer machine `sh` is usually bash and accepts everything, while the runner's `sh` is dash. Verified against the real thing rather than an approximation -- inside ubuntu:24.04, where /bin/sh is /usr/bin/dash, the dispatching loop passes all six scripts and the blanket loop dies at bootstrap.sh:191 on the first of its 14 arrays. Refs: Felis-Legacy #19 --- .github/workflows/ci.yml | 23 +++++++++++++ deploy/bootstrap.sh | 42 +++++++++++++++++++++--- deploy/bootstrap_test.sh | 70 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 deploy/bootstrap_test.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5253371..2a718ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,29 @@ jobs: - run: go vet ./... - run: go test ./... + shell: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # bootstrap.sh is the only thing that ever runs on a fresh host, and nothing here can + # run it — it wants root, a package manager and k3s. Syntax plus the extracted-block + # checks in bootstrap_test.sh is the coverage that is reachable without a machine. + # Each file is parsed by the interpreter its own shebang names. A blanket `sh -n` is + # wrong and not obviously so: on a developer machine `sh` is usually bash and passes + # everything, while the runner's `sh` is dash and rejects bootstrap.sh at the first of + # its arrays. Honouring the shebang is what makes local and CI agree. + - name: Check shell syntax + run: | + for f in $(git ls-files '*.sh'); do + case "$(head -1 "$f")" in + *bash) bash -n "$f" || exit 1 ;; + *) sh -n "$f" || exit 1 ;; + esac + done + + - run: sh deploy/bootstrap_test.sh + panel: runs-on: ubuntu-latest steps: diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index 7334325..adc3ec1 100644 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -34,6 +34,10 @@ # through the handshake address instead of modern forwarding # (default: legacy18). Read once at Velocity start, so changing it # means re-running this script and restarting the proxy. +# FELIS_VELOCITY_FORK_JAR path to a Felis-Legacy Velocity fork build to install as the +# proxy instead of the stock download (default: unset, stock). +# FELIS_VELOCITY_FORK_JAR_SHA256 expected sha256 of that jar. REQUIRED whenever the jar +# above is set; the install refuses on a mismatch. # FELIS_GO_VERSION Go toolchain used to build the nano binary (default: 1.26.4) # FELIS_REPO_URL git URL to build from (raw script mode only) # FELIS_VERSION_BOOTSTRAP release|dev — which version to install (default: release). @@ -132,9 +136,20 @@ FELIS_VELOCITY_VERSION="${FELIS_VELOCITY_VERSION:-3.5.1}" # # Opt-in because it is unmeasured where it counts: FL-008's probe runs offline-mode # against a stub, and this jar would carry every real Mojang session on the server. -# The build lives in Felis-Legacy and is not byte-reproducible, so there is no digest -# to pin here — the jar is trusted because that probe certified the build. FELIS_VELOCITY_FORK_JAR="${FELIS_VELOCITY_FORK_JAR:-}" +# Expected sha256 of that jar, REQUIRED whenever it is set. Case and internal spaces are +# ignored, so whatever sha256sum, Get-FileHash or certutil printed can be pasted as-is. +# No digest is hardcoded here: +# the build lives in Felis-Legacy and has never been reproduced on a second machine, so +# any constant this script carried would pin one machine's output rather than the fork. +# +# So this is not a supply-chain signature and does not pretend to be one — an operator +# who can write the jar can write this value too. What it does buy: a path is not an +# identity, and every re-run of this script re-checks it. A truncated copy, a stale build +# left at the same path, or the two-patch jar where the three-patch one was meant all +# change the digest and stop the install. Naming the digest once is what turns "whatever +# is at that path today" into one specific build. +FELIS_VELOCITY_FORK_JAR_SHA256="${FELIS_VELOCITY_FORK_JAR_SHA256:-}" # Temurin 25: Velocity 3.5 needs 21+, and 25 is also what a future Velocity 4 requires, # so the runtime does not have to move again when the pin does. Distro JDK packaging is # a lottery across four package managers — a tarball is one code path everywhere (same @@ -1471,12 +1486,31 @@ install_jre() { install_velocity() { install_jre - local url tmp + local url tmp have want prepare_velocity_layout if [ -n "$FELIS_VELOCITY_FORK_JAR" ]; then [ -f "$FELIS_VELOCITY_FORK_JAR" ] \ || die "FELIS_VELOCITY_FORK_JAR is not a readable file: ${FELIS_VELOCITY_FORK_JAR}" - log "installing the Felis-Legacy Velocity fork from ${FELIS_VELOCITY_FORK_JAR}" + # Hash stdin, never the path — same reason as install_via_plugins: sha256sum escapes its + # output line for a filename carrying a backslash or a newline, and the leading "\" that + # adds would fail every comparison below. + have="$(sha256sum <"$FELIS_VELOCITY_FORK_JAR" | cut -d' ' -f1)" + # Refuse rather than warn. This jar is the proxy every player connects through, and a + # warning in an install log is not a gate. The digest is printed so the first run after + # a deliberate rebuild is one copy-paste, not an investigation. + [ -n "$FELIS_VELOCITY_FORK_JAR_SHA256" ] || die \ + "FELIS_VELOCITY_FORK_JAR_SHA256 is required whenever FELIS_VELOCITY_FORK_JAR is set. + The jar at that path hashes to ${have}. + Check that against the build you meant to install, then re-run with + FELIS_VELOCITY_FORK_JAR_SHA256=${have}" + # Normalise the operator's digest before comparing. sha256sum prints lowercase, but the + # build host is often Windows, where Get-FileHash prints uppercase and certutil has + # shipped both with and without spaces between the bytes. All three name the same jar, + # so comparing raw would refuse two of the three spellings and word it as tampering. + want="$(printf '%s' "$FELIS_VELOCITY_FORK_JAR_SHA256" | tr -d '[:space:]' | tr 'A-Z' 'a-z')" + [ "$have" = "$want" ] || die \ + "FELIS_VELOCITY_FORK_JAR checksum mismatch: got ${have}, expected ${want}" + log "installing the Felis-Legacy Velocity fork from ${FELIS_VELOCITY_FORK_JAR} (sha256 ${have})" atomic_install_file "$FELIS_VELOCITY_FORK_JAR" "${VELOCITY_DIR}/velocity.jar" 0644 root root else log "resolving the newest Velocity ${FELIS_VELOCITY_VERSION} build" diff --git a/deploy/bootstrap_test.sh b/deploy/bootstrap_test.sh new file mode 100644 index 0000000..ac06448 --- /dev/null +++ b/deploy/bootstrap_test.sh @@ -0,0 +1,70 @@ +#!/bin/sh +# Checks for deploy/bootstrap.sh. Run it as: sh deploy/bootstrap_test.sh +# +# The script it tests cannot be run here — it wants root, a package manager, k3s and the +# network — so each case extracts the block it is about out of bootstrap.sh verbatim and runs +# that with die/log stubbed. Extraction rather than a transcribed copy is the point: a copy +# passes forever after someone edits the original. +set -u + +BS="${1:-$(dirname "$0")/bootstrap.sh}" +[ -f "$BS" ] || { echo "no such script: $BS"; exit 1; } +fails=0 + +expect() { # label needle haystack + case "$3" in + *"$2"*) echo "PASS $1" ;; + *) echo "FAIL $1: expected <$2> in:"; echo "$3"; fails=$((fails + 1)) ;; + esac +} + +# --- the FELIS_VELOCITY_FORK_JAR digest gate ------------------------------------------- +# This jar becomes the proxy every player connects through, so the interesting cases are the +# two refusals, not the happy path. + +gate="$(awk '/have="\$\(sha256sum <"\$FELIS_VELOCITY_FORK_JAR"/,/^ log "installing the Felis-Legacy/' "$BS")" +[ -n "$gate" ] || { echo "FAIL: no fork-jar digest gate found in $BS"; exit 1; } +# awk runs an unmatched end pattern to EOF, which would quietly pipe the rest of bootstrap.sh +# into the `sh -c` below. The emptiness check above only catches a broken start pattern. +[ "$(printf '%s\n' "$gate" | wc -l)" -lt 40 ] \ + || { echo "FAIL: the extracted block is not the gate -- did its last line move?"; exit 1; } + +jar="$(mktemp)" +trap 'rm -f "$jar"' EXIT +printf 'stand-in for a fork build\n' > "$jar" +want="$(sha256sum <"$jar" | cut -d' ' -f1)" + +run_gate() { # digest + FELIS_VELOCITY_FORK_JAR="$jar" FELIS_VELOCITY_FORK_JAR_SHA256="$1" sh -c ' + die() { printf "DIE: %s\n" "$*"; exit 1; } + log() { printf "LOG: %s\n" "$*"; } + '"$gate" +} + +out="$(run_gate '')" +expect "fork jar without a digest is refused" "DIE: FELIS_VELOCITY_FORK_JAR_SHA256 is required" "$out" +expect "the refusal names the jar's real digest" "$want" "$out" + +out="$(run_gate 'deadbeef')" +expect "a mismatched digest is refused" "checksum mismatch: got ${want}, expected deadbeef" "$out" + +out="$(run_gate "$want")" +expect "the matching digest installs" "LOG: installing the Felis-Legacy Velocity fork" "$out" +expect "the install line records the digest" "(sha256 ${want})" "$out" + +# The build host is usually Windows, where nothing prints the digest the way sha256sum does: +# Get-FileHash returns uppercase and certutil has shipped the bytes space-separated. Feeding +# the gate its own output can never catch that -- these two cases are the operator's paste. +out="$(run_gate "$(printf '%s' "$want" | tr 'a-z' 'A-Z')")" +expect "an uppercase digest is the same digest" "LOG: installing the Felis-Legacy Velocity fork" "$out" + +out="$(run_gate "$(printf '%s' "$want" | sed 's/../& /g')")" +expect "a space-separated digest is the same digest" "LOG: installing the Felis-Legacy Velocity fork" "$out" + +# --------------------------------------------------------------------------------------- +if [ "$fails" -eq 0 ]; then + echo "ALL PASS" +else + echo "$fails FAILED" +fi +exit "$fails"