diff --git a/Makefile b/Makefile index 5086f84..5e0a89f 100644 --- a/Makefile +++ b/Makefile @@ -61,7 +61,7 @@ CORPUS_DEST ?= test-corpus .PHONY: release-verify-key release-verify-minisign-pubkey release-verify-keys release-verify-signatures release-verify .PHONY: release-clean bootstrap-script build-all gosec gosec-high update-scoop-manifest .PHONY: version-check version-set version-patch version-minor version-major -.PHONY: print-sfetch-version test-release-verify-checksums test-release-verify-signatures test-bootstrap-sfetch-verified test-bootstrap-range-release +.PHONY: print-sfetch-version test-release-verify-checksums test-release-verify-signatures test-bootstrap-sfetch-verified test-bootstrap-range-release test-install-anchor-select all: build @@ -213,6 +213,7 @@ precommit: ## Run pre-commit checks (goneat assess + Go tests + build) $(MAKE) test-release-verify-signatures $(MAKE) test-bootstrap-sfetch-verified $(MAKE) test-bootstrap-range-release + $(MAKE) test-install-anchor-select @echo "[ok] Pre-commit checks passed" prepush: precommit ## Run pre-push checks (same as precommit + security) @@ -291,6 +292,9 @@ test-bootstrap-sfetch-verified: ## Regression: dual-route bootstrap rejects + fa test-bootstrap-range-release: ## Assert MAX==v(VERSION) and MINISIG_SINCE in range (committed constants) @./scripts/assert-bootstrap-range-release.sh +test-install-anchor-select: ## Regression: installer pin-map anchor selection (unit + coherence) + @./scripts/test-install-anchor-select.sh + release-notes: ## Copy release notes into dist/release @if [ -z "$(RELEASE_TAG)" ]; then echo "error: RELEASE_TAG not set" >&2; exit 1; fi @mkdir -p $(DIST_RELEASE) diff --git a/README.md b/README.md index 7edd654..ed6db3d 100644 --- a/README.md +++ b/README.md @@ -210,6 +210,8 @@ INSTALL_BINDIR=~/bin make install # override install location **Recommended (v0.4.11+):** verify the installer before execution. +Match `-P` to the tag: tags ≤ v0.4.11 verify under the previous key; newer tags use the current key. + ```bash # Three-step path when install-sfetch.sh.minisig is published (v0.4.11+) TAG=v0.4.11 @@ -271,6 +273,8 @@ The installer: #### Verify via signed SHA256SUMS (pins ≤ v0.4.10) +These pins predate the org key rotation and verify only under the previous key; the `-P` below is intentionally not the current key. + ```bash # Detect OS-appropriate SHA256 command (macOS uses shasum, Linux uses sha256sum) if command -v sha256sum &>/dev/null; then diff --git a/docs/examples.md b/docs/examples.md index bf45e54..5217209 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -146,10 +146,12 @@ sfetch --repo 3leaps/sfetch --latest \ **Public key** (3leaps/sfetch): ``` -untrusted comment: sfetch release signing key -RWTAoUJ007VE3h8tbHlBCyk2+y0nn7kyA4QP34LTzdtk8M6A2sryQtZC +untrusted comment: 3leaps org release signing +RWTqUZ/PtmfAbQ3RKIlp/YTKT6zFeNMAZ8iAMelhlmqEsVqjKj3ctSA1 ``` +Releases ≤ v0.4.11 publish the previous key asset instead; auto-detect selects the key from the release being verified. + ## Minisign Workflow B: `jedisct1/minisign` Per-asset signing - signature directly over each asset. No checksum file. diff --git a/scripts/assert-bootstrap-range-release.sh b/scripts/assert-bootstrap-range-release.sh index eb0dfb5..1164c75 100755 --- a/scripts/assert-bootstrap-range-release.sh +++ b/scripts/assert-bootstrap-range-release.sh @@ -65,8 +65,9 @@ extract_const() { MAX="$(extract_const SFETCH_BOOTSTRAP_MAX)" MIN="$(extract_const SFETCH_BOOTSTRAP_MIN)" SINCE="$(extract_const SFETCH_MINISIG_SINCE)" +PREVKEY="$(extract_const SFETCH_PREVKEY_MAX)" -echo "bootstrap-range assert: VERSION=${VER} MAX=${MAX} MIN=${MIN} MINISIG_SINCE=${SINCE}" +echo "bootstrap-range assert: VERSION=${VER} MAX=${MAX} MIN=${MIN} MINISIG_SINCE=${SINCE} PREVKEY_MAX=${PREVKEY}" [ "$MAX" = "$TAG" ] || fail "SFETCH_BOOTSTRAP_MAX (${MAX}) must equal v\$(cat VERSION) (${TAG}). Advance the constant (and boundary tests) before cutting this release — see RELEASE_CHECKLIST.md §1." @@ -137,4 +138,14 @@ semver_cmp_tag() { [ "$(semver_cmp_tag "$MIN" "$MAX")" != "1" ] || fail "SFETCH_BOOTSTRAP_MIN (${MIN}) is above SFETCH_BOOTSTRAP_MAX (${MAX})" -echo "[ok] bootstrap range coherent: MAX=${MAX} == VERSION tag; MINISIG_SINCE=${SINCE} in [${MIN}..${MAX}]" +# PREVKEY_MAX is the last tag signed with the previous org key: a historical +# fact, never advanced. Pins at or below it select the legacy anchor. +[ "$PREVKEY" = "v0.4.11" ] || + fail "SFETCH_PREVKEY_MAX (${PREVKEY}) must stay v0.4.11 (last previous-key tag)" +# Sunset forcing function: once MIN advances past the cutoff, the legacy branch +# is unreachable — delete it (and the LEGACY anchors) instead of leaving a dead +# second root. +[ "$(semver_cmp_tag "$PREVKEY" "$MIN")" != "-1" ] || + fail "SFETCH_PREVKEY_MAX (${PREVKEY}) is below SFETCH_BOOTSTRAP_MIN (${MIN}): sunset — remove the legacy anchor path" + +echo "[ok] bootstrap range coherent: MAX=${MAX} == VERSION tag; MINISIG_SINCE=${SINCE} in [${MIN}..${MAX}]; PREVKEY_MAX=${PREVKEY}" diff --git a/scripts/bootstrap-sfetch-verified.sh b/scripts/bootstrap-sfetch-verified.sh index 9793f65..f965006 100755 --- a/scripts/bootstrap-sfetch-verified.sh +++ b/scripts/bootstrap-sfetch-verified.sh @@ -8,6 +8,9 @@ # Never falls back from a failed .minisig attempt to checksums. # Never accepts "latest", branches, or floating refs. # Trust anchor is embedded here — never fetched from the release being authenticated. +# Pin map (tag -> exactly one pubkey): tags at or below SFETCH_PREVKEY_MAX verify +# against the previous-key anchor; tags above verify against the current anchor. +# Never tries both keys for one tag. # # Usage: # bootstrap-sfetch-verified.sh --version v0.4.11 --dir ~/.local/bin @@ -50,7 +53,17 @@ readonly SFETCH_MINISIG_SINCE="v0.4.11" # main.go (go:embed of that file), and scripts/install-sfetch.sh. Do NOT fetch # sfetch-minisign.pub from the release for authentication (circular). # The published .pub is for human out-of-band comparison only. -readonly SFETCH_MINISIGN_PUBKEY="RWTAoUJ007VE3h8tbHlBCyk2+y0nn7kyA4QP34LTzdtk8M6A2sryQtZC" +readonly SFETCH_MINISIGN_PUBKEY="RWTqUZ/PtmfAbQ3RKIlp/YTKT6zFeNMAZ8iAMelhlmqEsVqjKj3ctSA1" + +# Previous-key anchor, pin-scoped ONLY. SFETCH_PREVKEY_MAX is the last tag signed +# with the previous org key (historical fact, never advance it): pins at or below +# it verify against SFETCH_MINISIGN_PUBKEY_LEGACY; pins above verify against +# SFETCH_MINISIGN_PUBKEY. Never try both keys for one tag, and never treat the +# legacy anchor as a default trust anchor. Sunset: delete this constant and the +# legacy selection branch once nothing in-tree pins <= SFETCH_PREVKEY_MAX (the +# range-release assert fails once MIN advances past it, forcing the removal). +readonly SFETCH_PREVKEY_MAX="v0.4.11" +readonly SFETCH_MINISIGN_PUBKEY_LEGACY="RWTAoUJ007VE3h8tbHlBCyk2+y0nn7kyA4QP34LTzdtk8M6A2sryQtZC" readonly MINISIGN_VERSION_EXPECTED="0.12" readonly MINISIGN_WIN_URL="https://github.com/jedisct1/minisign/releases/download/0.12/minisign-0.12-win64.zip" @@ -522,14 +535,25 @@ if semver_ge "$VERSION" "$SFETCH_MINISIG_SINCE"; then else ROUTE="sha256sums" fi -log "bootstrap-sfetch-verified: version=${VERSION} verify-route=${ROUTE} range=${SFETCH_BOOTSTRAP_MIN}..${SFETCH_BOOTSTRAP_MAX}" + +# Anchor selection: pin map, exactly one key per tag (VERSION is exact semver +# and range-checked above, so the comparison cannot fail open). +ANCHOR_GEN="current" +ACTIVE_MINISIGN_PUBKEY="$SFETCH_MINISIGN_PUBKEY" +if semver_le "$VERSION" "$SFETCH_PREVKEY_MAX"; then + ANCHOR_GEN="legacy" + ACTIVE_MINISIGN_PUBKEY="$SFETCH_MINISIGN_PUBKEY_LEGACY" +fi +log "bootstrap-sfetch-verified: version=${VERSION} verify-route=${ROUTE} anchor=${ANCHOR_GEN} range=${SFETCH_BOOTSTRAP_MIN}..${SFETCH_BOOTSTRAP_MAX}" write_pubkey() { local path="$1" - # minisign -p expects a file with optional comment + RW... key line + # minisign -p expects a file with optional comment + RW... key line. + # ACTIVE_MINISIGN_PUBKEY is the pin-selected anchor (never a second guess). + [ -n "${ACTIVE_MINISIGN_PUBKEY:-}" ] || die "internal error: anchor not selected" { echo "untrusted comment: sfetch release signing key (embedded trust anchor)" - echo "${SFETCH_MINISIGN_PUBKEY}" + echo "${ACTIVE_MINISIGN_PUBKEY}" } >"$path" } diff --git a/scripts/install-sfetch.sh b/scripts/install-sfetch.sh index 469ffc6..1bd292f 100755 --- a/scripts/install-sfetch.sh +++ b/scripts/install-sfetch.sh @@ -40,9 +40,17 @@ SFETCH_API="https://api.github.com/repos/${SFETCH_REPO}/releases" # IMPORTANT: This key must match scripts/sfetch-minisign-anchor.pub (SSOT) # and EmbeddedMinisignPubkey in main.go (go:embed of that file). # Update both when rotating keys (see docs/security/signing-runbook.md) -SFETCH_MINISIGN_PUBKEY="RWTAoUJ007VE3h8tbHlBCyk2+y0nn7kyA4QP34LTzdtk8M6A2sryQtZC" +SFETCH_MINISIGN_PUBKEY="RWTqUZ/PtmfAbQ3RKIlp/YTKT6zFeNMAZ8iAMelhlmqEsVqjKj3ctSA1" # Pinned PGP fingerprint (for optional fallback) -SFETCH_PGP_FPR="94BB7811D4AD49B2310E0C08FA0651DE91B828ED" +SFETCH_PGP_FPR="0CACA49B3119B6BC12B2CA11B9B485F294B9FE07" +# Previous-key anchors, pin-scoped ONLY. SFETCH_PREVKEY_MAX is the last tag signed +# with the previous org keys (historical fact, never advance it): pins at or below +# it verify against the LEGACY anchors; pins above verify against the current ones. +# Never try both generations for one tag. Sunset: delete these constants and the +# legacy selection branch once nothing in-tree pins <= SFETCH_PREVKEY_MAX. +SFETCH_PREVKEY_MAX="v0.4.11" +SFETCH_MINISIGN_PUBKEY_LEGACY="RWTAoUJ007VE3h8tbHlBCyk2+y0nn7kyA4QP34LTzdtk8M6A2sryQtZC" +SFETCH_PGP_FPR_LEGACY="94BB7811D4AD49B2310E0C08FA0651DE91B828ED" TRUST_LEVEL="unknown" # ----------------------------------------------------------------------------- @@ -62,6 +70,31 @@ need_cmd() { fi } +is_exact_semver_tag() { + [[ "$1" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] +} + +# semver_le_tag: true if tag a <= tag b (exact vX.Y.Z inputs, no leading zeros). +# Length-then-lexical per component under C locale: no machine arithmetic, so no +# wraparound on huge components. Mirrors the bootstrap engine's comparison class. +semver_le_tag() { + local LC_ALL=C + local a="${1#v}" b="${2#v}" + local a1 a2 a3 b1 b2 b3 + IFS=. read -r a1 a2 a3 <<<"$a" + IFS=. read -r b1 b2 b3 <<<"$b" + local x y + for pair in "$a1:$b1" "$a2:$b2" "$a3:$b3"; do + x="${pair%%:*}" + y="${pair##*:}" + if [ "${#x}" -lt "${#y}" ]; then return 0; fi + if [ "${#x}" -gt "${#y}" ]; then return 1; fi + if [[ "$x" < "$y" ]]; then return 0; fi + if [[ "$x" > "$y" ]]; then return 1; fi + done + return 0 +} + read_release_tag_name() { local release_json_file="$1" local version="" @@ -322,7 +355,7 @@ verify_signature() { if [ "$VERIFY_MINISIGN" = true ] && [ -f "${sums_file}.minisig" ]; then local pubkey_file="${tmpdir}/sfetch-minisign.pub" echo "untrusted comment: sfetch release signing key" >"$pubkey_file" - echo "$SFETCH_MINISIGN_PUBKEY" >>"$pubkey_file" + echo "$ACTIVE_MINISIGN_PUBKEY" >>"$pubkey_file" log "Verifying signature with minisign (embedded trust anchor)..." if minisign -Vm "$sums_file" -p "$pubkey_file" >/dev/null 2>&1; then @@ -349,8 +382,8 @@ verify_signature() { if [ -f "$gpg_key" ]; then local fpr fpr=$(gpg --with-colons --import-options show-only --fingerprint "$gpg_key" 2>/dev/null | awk -F: '/^fpr:/ {print $10; exit}') - if [ "$fpr" != "$SFETCH_PGP_FPR" ]; then - err "GPG key fingerprint mismatch (expected ${SFETCH_PGP_FPR}, got ${fpr:-unknown})" + if [ "$fpr" != "$ACTIVE_PGP_FPR" ]; then + err "GPG key fingerprint mismatch (expected ${ACTIVE_PGP_FPR}, got ${fpr:-unknown})" fi log "Verifying signature with gpg (pinned fingerprint)..." local gpg_home @@ -541,6 +574,22 @@ main() { version=$(read_release_tag_name "$release_json") log "Installing sfetch ${version}" + # Pin map: the resolved tag selects exactly one key generation. Tags at or + # below SFETCH_PREVKEY_MAX are previous-key-signed; tags above are + # current-key-signed. Refuse non-semver tags rather than guess a key. + if ! is_exact_semver_tag "$version"; then + err "cannot select trust anchor for non-semver tag: ${version:-}" + fi + ACTIVE_MINISIGN_PUBKEY="$SFETCH_MINISIGN_PUBKEY" + ACTIVE_PGP_FPR="$SFETCH_PGP_FPR" + local anchor_gen="current" + if semver_le_tag "$version" "$SFETCH_PREVKEY_MAX"; then + anchor_gen="legacy" + ACTIVE_MINISIGN_PUBKEY="$SFETCH_MINISIGN_PUBKEY_LEGACY" + ACTIVE_PGP_FPR="$SFETCH_PGP_FPR_LEGACY" + fi + log "trust anchor: ${anchor_gen} (pin ${version})" + # Determine archive name local archive_name="sfetch_${platform}" if [[ "$platform" == windows_* ]]; then diff --git a/scripts/sfetch-minisign-anchor.pub b/scripts/sfetch-minisign-anchor.pub index 0f5dbd5..00d3703 100644 --- a/scripts/sfetch-minisign-anchor.pub +++ b/scripts/sfetch-minisign-anchor.pub @@ -1,2 +1,2 @@ -untrusted comment: sfetch release signing key (canonical trust anchor SSOT) -RWTAoUJ007VE3h8tbHlBCyk2+y0nn7kyA4QP34LTzdtk8M6A2sryQtZC +untrusted comment: 3leaps org release signing +RWTqUZ/PtmfAbQ3RKIlp/YTKT6zFeNMAZ8iAMelhlmqEsVqjKj3ctSA1 diff --git a/scripts/test-bootstrap-sfetch-verified.sh b/scripts/test-bootstrap-sfetch-verified.sh index 9cb3077..0906c46 100755 --- a/scripts/test-bootstrap-sfetch-verified.sh +++ b/scripts/test-bootstrap-sfetch-verified.sh @@ -95,14 +95,24 @@ assert_early_version_reject() { assert_early_version_reject "v18446744073709551616.4.10" "huge-major-wrap-zero" assert_early_version_reject "v0.4.18446744073709551627" "huge-patch-wrap-into-range" -PROD_PUBKEY="RWTAoUJ007VE3h8tbHlBCyk2+y0nn7kyA4QP34LTzdtk8M6A2sryQtZC" +PROD_PUBKEY="RWTqUZ/PtmfAbQ3RKIlp/YTKT6zFeNMAZ8iAMelhlmqEsVqjKj3ctSA1" +LEGACY_PUBKEY="RWTAoUJ007VE3h8tbHlBCyk2+y0nn7kyA4QP34LTzdtk8M6A2sryQtZC" + +# Harness pins must track the engine's anchors exactly; drift fails here, loudly. +grep -q "SFETCH_MINISIGN_PUBKEY=\"$PROD_PUBKEY\"" "$SCRIPT" || fail "harness PROD_PUBKEY drifted from engine" +grep -q "SFETCH_MINISIGN_PUBKEY_LEGACY=\"$LEGACY_PUBKEY\"" "$SCRIPT" || fail "harness LEGACY_PUBKEY drifted from engine" +grep -q 'SFETCH_PREVKEY_MAX="v0.4.11"' "$SCRIPT" || fail "engine PREVKEY cutoff moved; update pin-map tests" # Build a temporary engine: optional test pubkey + fixed BASE_URL for sfetch assets. # Minisign always comes from the engine's pinned 0.12 download (not ambient PATH). # CI runners often ship distro minisign 0.11; using ambient would fail the 0.12 assert # and is not how production runs. +# +# varname selects which anchor the test pubkey replaces: pin-map tests must patch +# exactly the anchor the requested pin selects, leaving the other production +# anchor intact (patching both would mask selection bugs). make_fixture_engine() { - local dest="$1" base_url="$2" pubkey="${3-}" + local dest="$1" base_url="$2" pubkey="${3-}" varname="${4-}" local src="$SCRIPT" cp "$src" "$dest" # Fixed download base → fixture URL (literal, no env). @@ -111,9 +121,25 @@ make_fixture_engine() { 's|BASE_URL="https://github.com/${REPO}/releases/download"|BASE_URL="'"${base_url}"'"|' \ "$dest" if [ -n "$pubkey" ]; then - sed -i.bak "s|${PROD_PUBKEY}|${pubkey}|g" "$dest" + [ -n "$varname" ] || fail "make_fixture_engine: varname required with pubkey" + local old other + case "$varname" in + SFETCH_MINISIGN_PUBKEY) + old="$PROD_PUBKEY" + other="$LEGACY_PUBKEY" + ;; + SFETCH_MINISIGN_PUBKEY_LEGACY) + old="$LEGACY_PUBKEY" + other="$PROD_PUBKEY" + ;; + *) + fail "make_fixture_engine: unknown anchor var $varname" + ;; + esac + sed -i.bak "s|${old}|${pubkey}|g" "$dest" grep -q "$pubkey" "$dest" || fail "patched engine missing test pubkey" - grep -q "$PROD_PUBKEY" "$dest" && fail "patched engine still has production pubkey" + grep -q "$old" "$dest" && fail "patched engine still has production $varname" + grep -q "$other" "$dest" || fail "patched engine lost the other anchor (must patch exactly one var)" fi rm -f "${dest}.bak" chmod +x "$dest" @@ -128,6 +154,7 @@ OUT1040="$WORKDIR/out410.txt" "$ROUTE_ENG" --version v0.4.10 --dir "$WORKDIR/d410" >"$OUT1040" 2>&1 set -e grep -Eq 'verify-route=sha256sums' "$OUT1040" || fail "v0.4.10 should select sha256sums (log: $(cat "$OUT1040"))" +grep -Eq 'anchor=legacy' "$OUT1040" || fail "v0.4.10 should select legacy anchor (log: $(cat "$OUT1040"))" pass "v0.4.10 → verify-route=sha256sums" set +e @@ -135,6 +162,7 @@ OUT411="$WORKDIR/out411.txt" "$ROUTE_ENG" --version v0.4.11 --dir "$WORKDIR/d411" >"$OUT411" 2>&1 set -e grep -Eq 'verify-route=minisig' "$OUT411" || fail "v0.4.11 should select minisig (log: $(cat "$OUT411"))" +grep -Eq 'anchor=legacy' "$OUT411" || fail "v0.4.11 should select legacy anchor (log: $(cat "$OUT411"))" pass "v0.4.11 → verify-route=minisig" # Production engine ignores inherited BASE_URL / SKIP env (no seams). @@ -166,8 +194,25 @@ minisign -G -W -p "$PUB" -s "$KEY" >/dev/null 2>&1 || TEST_PUBKEY="$(grep -E '^RW' "$PUB" | head -n1 | tr -d '\r\n')" [ -n "$TEST_PUBKEY" ] || fail "could not read test pubkey" +# Second ephemeral pair: post-cutover selection tests need distinct legacy/current keys. +OLDKEY="$WORKDIR/old.key" +OLDPUB="$WORKDIR/old.pub" +minisign -G -W -p "$OLDPUB" -s "$OLDKEY" >/dev/null 2>&1 || + minisign -G -n -p "$OLDPUB" -s "$OLDKEY" >/dev/null 2>&1 || + fail "keygen (legacy stand-in)" +NEWKEY="$WORKDIR/new.key" +NEWPUB="$WORKDIR/new.pub" +minisign -G -W -p "$NEWPUB" -s "$NEWKEY" >/dev/null 2>&1 || + minisign -G -n -p "$NEWPUB" -s "$NEWKEY" >/dev/null 2>&1 || + fail "keygen (current stand-in)" +OLD_EPHEMERAL_PUBKEY="$(grep -E '^RW' "$OLDPUB" | head -n1 | tr -d '\r\n')" +NEW_EPHEMERAL_PUBKEY="$(grep -E '^RW' "$NEWPUB" | head -n1 | tr -d '\r\n')" +[ -n "$OLD_EPHEMERAL_PUBKEY" ] || fail "could not read legacy stand-in pubkey" +[ -n "$NEW_EPHEMERAL_PUBKEY" ] || fail "could not read current stand-in pubkey" +[ "$OLD_EPHEMERAL_PUBKEY" != "$NEW_EPHEMERAL_PUBKEY" ] || fail "stand-in keys must differ" + SRV_ROOT="$WORKDIR/www" -mkdir -p "$SRV_ROOT/v0.4.11" "$SRV_ROOT/v0.4.10" +mkdir -p "$SRV_ROOT/v0.4.11" "$SRV_ROOT/v0.4.10" "$SRV_ROOT/v0.4.12" make_stub_installer() { local dest="$1" @@ -199,6 +244,7 @@ STUB make_stub_installer "$SRV_ROOT/v0.4.11/install-sfetch.sh" make_stub_installer "$SRV_ROOT/v0.4.10/install-sfetch.sh" +make_stub_installer "$SRV_ROOT/v0.4.12/install-sfetch.sh" minisign -S -s "$KEY" -t "test-v0.4.11" -m "$SRV_ROOT/v0.4.11/install-sfetch.sh" ( @@ -237,7 +283,7 @@ PORT="$(cat "$PORTFILE")" BASE="http://127.0.0.1:${PORT}" PATCHED="$WORKDIR/bootstrap-patched.sh" -make_fixture_engine "$PATCHED" "$BASE" "$TEST_PUBKEY" +make_fixture_engine "$PATCHED" "$BASE" "$TEST_PUBKEY" "SFETCH_MINISIGN_PUBKEY_LEGACY" # Positive: v0.4.11 minisig route GOOD411="$WORKDIR/good411" @@ -251,6 +297,7 @@ set -e ROUTE_LINES="$(awk 'BEGIN{c=0} /^route=/{c++} END{print c}' "$OUT_GOOD")" [ "$ROUTE_LINES" -eq 1 ] || fail "expected exactly one stdout route= field, got ${ROUTE_LINES}" grep -q '^route=minisig$' "$OUT_GOOD" || fail "positive minisig machine route missing" +grep -Eq 'anchor=legacy' "$OUT_GOOD" || fail "positive v0.4.11 should log anchor=legacy" [ -f "$GOOD411/.stub-ran" ] || fail "installer should execute after successful verify" [ -x "$GOOD411/sfetch" ] || fail "sfetch binary should be installed" version_output_matches_pin "$("$GOOD411/sfetch" --version 2>&1)" "v0.4.11" || fail "stub sfetch version" @@ -267,12 +314,78 @@ set -e [ "$RC" -eq 0 ] || fail "positive v0.4.10 sha256sums should succeed (log: $(cat "$OUT_GOOD410"))" [ "$(awk 'BEGIN{c=0} /^route=/{c++} END{print c}' "$OUT_GOOD410")" -eq 1 ] || fail "expected one route= field" grep -q '^route=sha256sums$' "$OUT_GOOD410" || fail "positive sha256sums machine route missing" +grep -Eq 'anchor=legacy' "$OUT_GOOD410" || fail "positive v0.4.10 should log anchor=legacy" [ -f "$GOOD410/.stub-ran" ] || fail "installer should execute after sha256sums verify" pass "positive v0.4.10 sha256sums route (patched ephemeral key)" -# Negative: wrong-key minisig (production pubkey engine against test-key sig) +# Negative: old pin must NOT use the current anchor (pin map, not fallback). +# Fixtures are TEST_PUBKEY-signed; patching only the current anchor leaves the +# selected legacy anchor at its production value, so verify must fail. +CURONLY_ENG="$WORKDIR/current-only-engine.sh" +make_fixture_engine "$CURONLY_ENG" "$BASE" "$TEST_PUBKEY" "SFETCH_MINISIGN_PUBKEY" +CURONLY_DIR="$WORKDIR/curonly" +mkdir -p "$CURONLY_DIR" +set +e +OUT_CURONLY="$WORKDIR/out-curonly.txt" +"$CURONLY_ENG" --version v0.4.11 --dir "$CURONLY_DIR" >"$OUT_CURONLY" 2>&1 +RC=$? +set -e +[ "$RC" -ne 0 ] || fail "v0.4.11 against current-only patched engine must fail" +[ ! -f "$CURONLY_DIR/.stub-ran" ] || fail "installer must not run when pin selects unpatched anchor" +[ ! -f "$CURONLY_DIR/sfetch" ] || fail "sfetch must not be installed on failed verify" +grep -Eq 'anchor=legacy' "$OUT_CURONLY" || fail "expected anchor=legacy selection for v0.4.11" +if grep -q '^route=' "$OUT_CURONLY"; then fail "failed run must not emit machine route="; fi +pass "old pin ignores current anchor (no fallback)" + +# Post-cutover selection: fake v0.4.12 pin (MAX patched in temp copy only). +# Stand-in keys prove the rule: new pins use current, previous-key sigs fail. +NEWENG="$WORKDIR/newpin-engine.sh" +make_fixture_engine "$NEWENG" "$BASE" "$OLD_EPHEMERAL_PUBKEY" "SFETCH_MINISIGN_PUBKEY_LEGACY" +sed -i.bak "s|${PROD_PUBKEY}|${NEW_EPHEMERAL_PUBKEY}|g" "$NEWENG" +grep -q "$NEW_EPHEMERAL_PUBKEY" "$NEWENG" || fail "new-pin engine missing current stand-in" +grep -q "$PROD_PUBKEY" "$NEWENG" && fail "new-pin engine still has production current key" +grep -q "$OLD_EPHEMERAL_PUBKEY" "$NEWENG" || fail "new-pin engine lost legacy stand-in" +sed -i.bak 's|SFETCH_BOOTSTRAP_MAX="v0.4.11"|SFETCH_BOOTSTRAP_MAX="v0.4.12"|' "$NEWENG" +grep -q 'SFETCH_BOOTSTRAP_MAX="v0.4.12"' "$NEWENG" || fail "new-pin engine MAX patch failed" +rm -f "${NEWENG}.bak" + +# Positive: post-cutover pin verifies against the current anchor. +minisign -S -s "$NEWKEY" -t "test-v0.4.12-new" -m "$SRV_ROOT/v0.4.12/install-sfetch.sh" +NEWGOOD_DIR="$WORKDIR/newgood" +mkdir -p "$NEWGOOD_DIR" +set +e +OUT_NEWGOOD="$WORKDIR/out-newgood.txt" +"$NEWENG" --version v0.4.12 --dir "$NEWGOOD_DIR" >"$OUT_NEWGOOD" 2>&1 +RC=$? +set -e +[ "$RC" -eq 0 ] || fail "positive v0.4.12 should succeed (log: $(cat "$OUT_NEWGOOD"))" +[ "$(awk 'BEGIN{c=0} /^route=/{c++} END{print c}' "$OUT_NEWGOOD")" -eq 1 ] || fail "expected one route= field" +grep -q '^route=minisig$' "$OUT_NEWGOOD" || fail "positive v0.4.12 machine route missing" +grep -Eq 'anchor=current' "$OUT_NEWGOOD" || fail "positive v0.4.12 should log anchor=current" +[ -f "$NEWGOOD_DIR/.stub-ran" ] || fail "installer should execute after successful verify" +[ -x "$NEWGOOD_DIR/sfetch" ] || fail "sfetch binary should be installed" +version_output_matches_pin "$("$NEWGOOD_DIR/sfetch" --version 2>&1)" "v0.4.12" || fail "stub sfetch version" +pass "post-cutover pin uses current anchor" + +# Negative: previous-key signature must fail for a post-cutover pin (no downgrade). +minisign -S -s "$OLDKEY" -t "test-v0.4.12-old" -m "$SRV_ROOT/v0.4.12/install-sfetch.sh" +NEWDOWNGRADE_DIR="$WORKDIR/newdowngrade" +mkdir -p "$NEWDOWNGRADE_DIR" +set +e +OUT_DOWNGRADE="$WORKDIR/out-downgrade.txt" +"$NEWENG" --version v0.4.12 --dir "$NEWDOWNGRADE_DIR" >"$OUT_DOWNGRADE" 2>&1 +RC=$? +set -e +[ "$RC" -ne 0 ] || fail "previous-key signature for v0.4.12 must fail" +[ ! -f "$NEWDOWNGRADE_DIR/.stub-ran" ] || fail "installer must not run on downgrade attempt" +[ ! -f "$NEWDOWNGRADE_DIR/sfetch" ] || fail "sfetch must not be installed on failed verify" +grep -Eq 'anchor=current' "$OUT_DOWNGRADE" || fail "expected anchor=current selection for v0.4.12" +if grep -q '^route=' "$OUT_DOWNGRADE"; then fail "failed run must not emit machine route="; fi +pass "post-cutover pin rejects previous-key signature (no downgrade)" + +# Negative: wrong-key minisig (production anchors engine against test-key sig) WRONG_ENG="$WORKDIR/wrong-key-engine.sh" -make_fixture_engine "$WRONG_ENG" "$BASE" # keep production pubkey +make_fixture_engine "$WRONG_ENG" "$BASE" # keep production anchors BAD_DIR="$WORKDIR/bad" mkdir -p "$BAD_DIR" set +e @@ -301,7 +414,7 @@ done [ -f "$PORTFILE2" ] || fail "second HTTP fixture server failed to start" PORT2="$(cat "$PORTFILE2")" NOSIG_ENG="$WORKDIR/nosig-engine.sh" -make_fixture_engine "$NOSIG_ENG" "http://127.0.0.1:${PORT2}" "$TEST_PUBKEY" +make_fixture_engine "$NOSIG_ENG" "http://127.0.0.1:${PORT2}" "$TEST_PUBKEY" "SFETCH_MINISIGN_PUBKEY_LEGACY" NOSIG_DIR="$WORKDIR/nosig" mkdir -p "$NOSIG_DIR" set +e @@ -455,7 +568,7 @@ STUB [ -f "$PORTFILE3" ] || fail "goneat HTTP fixture server failed to start" PORT3="$(cat "$PORTFILE3")" GONEAT_ENG="$WORKDIR/goneat-engine.sh" - make_fixture_engine "$GONEAT_ENG" "http://127.0.0.1:${PORT3}" "$TEST_PUBKEY" + make_fixture_engine "$GONEAT_ENG" "http://127.0.0.1:${PORT3}" "$TEST_PUBKEY" "SFETCH_MINISIGN_PUBKEY_LEGACY" GONEAT_DIR="$WORKDIR/goneat-offline" mkdir -p "$GONEAT_DIR" set +e diff --git a/scripts/test-install-anchor-select.sh b/scripts/test-install-anchor-select.sh new file mode 100755 index 0000000..77c4393 --- /dev/null +++ b/scripts/test-install-anchor-select.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# Unit/coherence tests for install-sfetch.sh pin-map anchor selection. +# Sources installer functions from a temp copy with the `main "$@"` entry call +# stripped (same bytes as shipped; the bootstrap harness sources engine +# functions the same way). Never executes the installer. No network. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} +pass() { echo "PASS: $*"; } + +INSTALLER="${ROOT}/scripts/install-sfetch.sh" +ENGINE="${ROOT}/scripts/bootstrap-sfetch-verified.sh" +ANCHOR="${ROOT}/scripts/sfetch-minisign-anchor.pub" + +[ -f "$INSTALLER" ] || fail "installer missing" +[ -f "$ENGINE" ] || fail "engine missing" +[ -f "$ANCHOR" ] || fail "anchor SSOT missing" + +# Entry-call strip must match exactly one line; sourcing an unstripped copy +# would execute the installer. +[ "$(grep -c '^main "$@"$' "$INSTALLER")" -eq 1 ] || fail "installer entry line not exactly-once (refusing to source)" +SRC_COPY="$(mktemp "${TMPDIR:-/tmp}/sft-install-src.XXXXXX")" +trap 'rm -f "$SRC_COPY"' EXIT +grep -v '^main "$@"$' "$INSTALLER" >"$SRC_COPY" +grep -q '^main ' "$SRC_COPY" && fail "strip failed: main invocation remains" +# shellcheck source=scripts/install-sfetch.sh +# shellcheck disable=SC1090 +. "$SRC_COPY" + +command -v is_exact_semver_tag >/dev/null || fail "is_exact_semver_tag not loaded" +command -v semver_le_tag >/dev/null || fail "semver_le_tag not loaded" + +# --- tag shape: strict vMAJOR.MINOR.PATCH, no leading zeros --- +is_exact_semver_tag "v0.4.11" || fail "accept v0.4.11" +is_exact_semver_tag "v0.4.6" || fail "accept v0.4.6" +is_exact_semver_tag "v0.0.0" || fail "accept v0.0.0" +is_exact_semver_tag "v10.20.30" || fail "accept multi-digit" +if is_exact_semver_tag ""; then fail "reject empty"; fi +if is_exact_semver_tag "latest"; then fail "reject latest"; fi +if is_exact_semver_tag "v0.4.09"; then fail "reject leading zero v0.4.09"; fi +if is_exact_semver_tag "v0.4"; then fail "reject short v0.4"; fi +if is_exact_semver_tag "v1.2.3.4"; then fail "reject long v1.2.3.4"; fi +if is_exact_semver_tag "1.2.3"; then fail "reject missing v"; fi +if is_exact_semver_tag "v1.2.x"; then fail "reject non-numeric"; fi +pass "installer tag shape strict" + +# --- comparison boundaries against the v0.4.11 cutoff --- +semver_le_tag "v0.4.11" "v0.4.11" || fail "v0.4.11 <= cutoff" +semver_le_tag "v0.4.10" "v0.4.11" || fail "v0.4.10 <= cutoff" +semver_le_tag "v0.4.9" "v0.4.11" || fail "v0.4.9 <= cutoff" +semver_le_tag "v0.4.6" "v0.4.11" || fail "v0.4.6 <= cutoff (Intel recovery pin)" +semver_le_tag "v0.4.0" "v0.4.11" || fail "v0.4.0 <= cutoff" +semver_le_tag "v0.3.99" "v0.4.11" || fail "v0.3.99 <= cutoff" +if semver_le_tag "v0.4.12" "v0.4.11"; then fail "v0.4.12 above cutoff"; fi +if semver_le_tag "v0.4.100" "v0.4.11"; then fail "v0.4.100 above cutoff"; fi +if semver_le_tag "v0.5.0" "v0.4.11"; then fail "v0.5.0 above cutoff"; fi +if semver_le_tag "v1.0.0" "v0.4.11"; then fail "v1.0.0 above cutoff"; fi +if semver_le_tag "v10.0.0" "v0.4.11"; then fail "v10.0.0 above cutoff"; fi +# Huge components must not wrap into range (length-then-lexical, no arithmetic). +if semver_le_tag "v18446744073709551616.4.10" "v0.4.11"; then fail "huge major must not alias below cutoff"; fi +if semver_le_tag "v0.4.18446744073709551615" "v0.4.11"; then fail "huge patch must not alias below cutoff"; fi +if semver_le_tag "v0.18446744073709551616.0" "v0.4.11"; then fail "huge minor must not alias below cutoff"; fi +pass "installer pin-map comparison boundaries" + +# --- constants coherence across consumers --- +[ "${SFETCH_PREVKEY_MAX:-}" = "v0.4.11" ] || fail "installer PREVKEY cutoff must be v0.4.11" +ENG_PREVKEY="$(grep -E '^readonly SFETCH_PREVKEY_MAX=' "$ENGINE" | head -n1 | sed 's/^readonly SFETCH_PREVKEY_MAX="//; s/"$//')" +[ -n "$ENG_PREVKEY" ] || fail "engine PREVKEY cutoff missing" +[ "$ENG_PREVKEY" = "$SFETCH_PREVKEY_MAX" ] || fail "installer/engine PREVKEY cutoff drift ($SFETCH_PREVKEY_MAX vs $ENG_PREVKEY)" +ENG_LEGACY="$(grep -E '^readonly SFETCH_MINISIGN_PUBKEY_LEGACY=' "$ENGINE" | head -n1 | sed 's/^readonly SFETCH_MINISIGN_PUBKEY_LEGACY="//; s/"$//')" +[ -n "$ENG_LEGACY" ] || fail "engine legacy anchor missing" +[ "$ENG_LEGACY" = "${SFETCH_MINISIGN_PUBKEY_LEGACY:-}" ] || fail "installer/engine legacy anchor drift" +[ "${SFETCH_MINISIGN_PUBKEY_LEGACY:-}" != "${SFETCH_MINISIGN_PUBKEY:-}" ] || fail "legacy and current minisign anchors must differ" +[ "${SFETCH_PGP_FPR_LEGACY:-}" != "${SFETCH_PGP_FPR:-}" ] || fail "legacy and current GPG pins must differ" +case "${SFETCH_PGP_FPR_LEGACY:-}" in + "") fail "legacy GPG pin missing" ;; +esac +[[ "${SFETCH_PGP_FPR_LEGACY}" =~ ^[0-9A-F]{40}$ ]] || fail "legacy GPG pin shape (got: ${SFETCH_PGP_FPR_LEGACY})" +[[ "${SFETCH_PGP_FPR:-}" =~ ^[0-9A-F]{40}$ ]] || fail "current GPG pin shape (got: ${SFETCH_PGP_FPR:-})" +pass "installer/engine legacy constants coherent" + +# SSOT carries the current key only (legacy anchor is pin-scoped, never default). +ANCHOR_RW="$(grep -E '^RW' "$ANCHOR" | head -n1 | tr -d '\r\n')" +[ -n "$ANCHOR_RW" ] || fail "anchor has no RW line" +[ "$ANCHOR_RW" = "${SFETCH_MINISIGN_PUBKEY:-}" ] || fail "installer current anchor must equal SSOT" +if grep -q "${SFETCH_MINISIGN_PUBKEY_LEGACY}" "$ANCHOR"; then fail "SSOT must not carry the legacy anchor"; fi +pass "SSOT stays single-current" + +echo "[ok] install anchor-select regression harness complete" diff --git a/testdata/shellsentry/shellsentry-minisign.pub b/testdata/shellsentry/shellsentry-minisign.pub index be9a885..d0af167 100644 --- a/testdata/shellsentry/shellsentry-minisign.pub +++ b/testdata/shellsentry/shellsentry-minisign.pub @@ -1,2 +1,2 @@ -untrusted comment: minisign public key DE44B5D37442A1C0 -RWTAoUJ007VE3h8tbHlBCyk2+y0nn7kyA4QP34LTzdtk8M6A2sryQtZC +untrusted comment: minisign public key 6DC067B6CF9F51EA +RWTqUZ/PtmfAbQ3RKIlp/YTKT6zFeNMAZ8iAMelhlmqEsVqjKj3ctSA1