diff --git a/scripts/iso/build-felhom-iso.sh b/scripts/iso/build-felhom-iso.sh index 8d688d5..10d874c 100755 --- a/scripts/iso/build-felhom-iso.sh +++ b/scripts/iso/build-felhom-iso.sh @@ -41,7 +41,7 @@ #=============================================================================== set -euo pipefail -ISO_VERSION="1.22.0" # Felhom release the ISO is tagged to (aligns with felhom-host-install SCRIPT_VERSION). +ISO_VERSION="1.24.0" # Felhom release the ISO is tagged to (aligns with felhom-host-install SCRIPT_VERSION). IMAGE="${FELHOM_ISO_ASSISTANT_IMAGE:-felhom-iso-assistant:trixie}" HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -208,7 +208,15 @@ fi # --- workspace ------------------------------------------------------------------------------------ WORK="$(mktemp -d "${TMPDIR:-/tmp}/felhom-iso.XXXXXX")" # chmod first: the mkimage surgery's osirrox extract leaves read-only file modes that rm can't clear. -cleanup() { chmod -R u+w "$WORK" 2>/dev/null || true; rm -rf "$WORK"; } +# FELHOM_ISO_KEEP_WORK=1 is a DEBUG/TEST escape: keep $WORK for post-mortem (the rootpw-emission +# harness cross-checks the emitted plaintext against the rendered answer's hash). Never a build mode. +cleanup() { + if [[ "${FELHOM_ISO_KEEP_WORK:-0}" == "1" ]]; then + log_warn "FELHOM_ISO_KEEP_WORK=1 — workspace KEPT at $WORK (debug/test only; delete it yourself)" + return 0 + fi + chmod -R u+w "$WORK" 2>/dev/null || true; rm -rf "$WORK" +} trap cleanup EXIT mkdir -p "$OUT_DIR" "$WORK/tmp" ISO_DIR="$(cd "$(dirname "$PVE_ISO")" && pwd)"; ISO_BASE="$(basename "$PVE_ISO")" @@ -226,13 +234,27 @@ EOF log_info "generated secret-free pairing env (hub=$PAIR_HUB_URL)" fi +# --- resolve the output ISO name (needed by the mint below: the rootpw sibling file is named after +# the ISO). Rule 4: the loader mode is loud in the filename — a '-mkimage' ISO implies +# Secure-Boot-off prep. ----------------------------------------------------------------------- +LOADER_SUFFIX=""; [[ "$LOADER" != "shim" ]] && LOADER_SUFFIX="-${LOADER}" +MODE_SUFFIX=""; $PAIRING && MODE_SUFFIX="-generic" # the secret-free universal ISO is unmistakable +OUT_ISO="$OUT_DIR/felhom-pve-${PVE_VERSION}-v${ISO_VERSION}-${PROFILE_NAME}${MODE_SUFFIX}${LOADER_SUFFIX}.iso" + # --- mint fresh THROWAWAY root hash --------------------------------------------------------------- log_step "minting fresh throwaway root password hash" ROOT_PLAIN="felhom-throwaway-$(head -c12 /dev/urandom | base64 | tr -dc 'A-Za-z0-9')" ROOT_HASH="$(openssl passwd -6 "$ROOT_PLAIN")" +# R-61 slice 1: the baked root password becomes KNOWABLE. The plaintext is written to a 0600 sibling +# file next to the ISO — the SINGLE record of truth — and nowhere else: never stdout/stderr/logs, +# never the manifest (manifests get pasted into committed REPORTs; a separate file cannot ride along +# by accident). A fixed well-known password stays REJECTED (operator ruling 2026-07-21): a +# pre-pairing box sits on a stranger's LAN. Note: an aborted build can leave a rootpw file for an +# ISO that was never produced; the next build of the same name overwrites it. +( umask 077; printf '%s %s built %s\n' "$ROOT_PLAIN" "$(basename "$OUT_ISO")" "$(date -Is)" > "$OUT_ISO.rootpw.txt" ) unset ROOT_PLAIN [[ -n "$ROOT_HASH" ]] || die "failed to mint root hash" -log_info "throwaway root hash written to the answer file (value stored out-of-band, not logged)" +log_info "throwaway root hash written to the answer file; plaintext in $(basename "$OUT_ISO").rootpw.txt (0600, operator-only — value not logged)" # --- render answer.toml (pure bash param-expansion; no metachar hazards) --------------------------- log_step "rendering answer.toml" @@ -283,10 +305,7 @@ chmod 0755 "$STUB" grep -q '@@BOOTSTRAP_.*_B64@@' "$STUB" && die "stub still has unfilled markers — injection failed" # --- prepare-iso ---------------------------------------------------------------------------------- -# Rule 4: the loader mode is loud in the filename — a '-mkimage' ISO implies Secure-Boot-off prep. -LOADER_SUFFIX=""; [[ "$LOADER" != "shim" ]] && LOADER_SUFFIX="-${LOADER}" -MODE_SUFFIX=""; $PAIRING && MODE_SUFFIX="-generic" # the secret-free universal ISO is unmistakable -OUT_ISO="$OUT_DIR/felhom-pve-${PVE_VERSION}-v${ISO_VERSION}-${PROFILE_NAME}${MODE_SUFFIX}${LOADER_SUFFIX}.iso" +# (OUT_ISO is resolved above the mint — the rootpw sibling file is named after it.) GRUB_VERSION="" # populated by the mkimage surgery (the grub-mkimage build used) log_step "building ISO: $(basename "$OUT_ISO")" if $DRY_RUN; then @@ -361,6 +380,7 @@ boot-menu : ${BRAND_NOTE} $( $BRAND && cat "$WORK/brand-report.txt" 2>/dev/null || true ) host-install-url : $(grep -oE 'FELHOM_INSTALL_URL=[^ ]*' "$BOOTSTRAP_ENV" 2>/dev/null || echo 'https://felhom.eu/scripts/felhom-host-install.sh (default)') secret-bearing : ${SECRET_BEARING}$( $PAIRING && echo ' (GENERIC ISO — carries NO customer secret)' || echo ' (embeds the customer retrieval passphrase — supervised/single-use, delete after the run)') +root-password : see $(basename "$OUT_ISO").rootpw.txt (operator-only; NEVER commit or paste into REPORTs) output : $(basename "$OUT_ISO") output-sha256 : ${OUT_SHA} output-size-bytes : ${OUT_SIZE} @@ -370,6 +390,7 @@ log_success "ISO built: $OUT_ISO" log_info "sha256 : $OUT_SHA" log_info "size : $OUT_SIZE bytes" log_info "manifest : $OUT_ISO.manifest.txt" +log_info "root-pw : $OUT_ISO.rootpw.txt (0600, operator-only — the console credential for this build; never commit/paste)" if $PAIRING; then log_success "GENERIC secret-free ISO — carries NO customer secret. Bind the box on the hub after it registers." else diff --git a/scripts/iso/test/rootpw-emission.sh b/scripts/iso/test/rootpw-emission.sh new file mode 100755 index 0000000..e693c87 --- /dev/null +++ b/scripts/iso/test/rootpw-emission.sh @@ -0,0 +1,75 @@ +#!/bin/bash +# rootpw-emission.sh — R-61 slice 1 regression harness for build-felhom-iso.sh's root-password +# emission. Runs ON THE BUILD HOST (needs docker + the assistant image present for the build +# script's preflight, but --dry-run never starts a container). Asserts, against a real dry-run +# with a fake source ISO: +# 1. the 0600 sibling .rootpw.txt exists and names the ISO it belongs to; +# 2. its plaintext MATCHES the hash actually rendered into answer.toml +# (openssl passwd -6 -salt cross-check — file <-> answer, not file <-> itself); +# 3. the plaintext appears NOWHERE in the build's stdout/stderr; +# 4. the manifest heredoc in the build script carries the rootpw.txt POINTER and never +# references the plaintext variable (the manifest gets pasted into committed REPORTs — +# the plaintext must be structurally unable to ride along). +# Red-proof (documented, run manually): add `root-password-plain : ${ROOT_PLAIN}` to the manifest +# heredoc -> assertion 4 fails -> restore. +set -uo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BUILD="$HERE/../build-felhom-iso.sh" +fail=0 +say() { echo "TEST: $*"; } +check() { if eval "$2"; then echo " ok: $1"; else echo " FAIL: $1"; fail=1; fi; } + +SCRATCH="$(mktemp -d "${TMPDIR:-/tmp}/felhom-rootpw-test.XXXXXX")" +trap 'chmod -R u+w "$SCRATCH" 2>/dev/null; rm -rf "$SCRATCH"' EXIT +mkdir -p "$SCRATCH/tmp" "$SCRATCH/out" + +# fake source ISO (the dry run only sha-verifies it; nothing reads its content as an ISO) +FAKE_ISO="$SCRATCH/proxmox-ve_9.9-1.iso" +head -c 65536 /dev/urandom > "$FAKE_ISO" +FAKE_SHA="$(sha256sum "$FAKE_ISO" | awk '{print $1}')" + +say "dry-run build (pairing, nested-canary) with FELHOM_ISO_KEEP_WORK=1" +BUILD_LOG="$SCRATCH/build.log" +FELHOM_ISO_KEEP_WORK=1 TMPDIR="$SCRATCH/tmp" bash "$BUILD" \ + --pve-iso "$FAKE_ISO" --iso-sha256 "$FAKE_SHA" \ + --profile "$HERE/../profiles/nested-canary.profile" \ + --pairing --out "$SCRATCH/out" --dry-run > "$BUILD_LOG" 2>&1 +rc=$? +check "dry-run exited 0" "[ $rc -eq 0 ]" + +ROOTPW_FILE="$(ls "$SCRATCH"/out/*.rootpw.txt 2>/dev/null | head -1)" +check "rootpw sibling file exists" "[ -n \"$ROOTPW_FILE\" ] && [ -f \"$ROOTPW_FILE\" ]" +check "rootpw file mode is 0600" "[ \"\$(stat -c %a \"$ROOTPW_FILE\")\" = 600 ]" + +PW="$(awk '{print $1}' "$ROOTPW_FILE" 2>/dev/null)" +ISO_NAME="$(awk '{print $2}' "$ROOTPW_FILE" 2>/dev/null)" +check "password field has the throwaway shape" "[[ \"$PW\" == felhom-throwaway-* ]]" +check "file names the ISO it belongs to" "[ \"$ISO_NAME.rootpw.txt\" = \"$(basename "$ROOTPW_FILE")\" ]" + +# cross-check: the plaintext must correspond to the hash RENDERED INTO THE ANSWER (kept workspace) +ANSWER="$(ls "$SCRATCH"/tmp/felhom-iso.*/answer.toml 2>/dev/null | head -1)" +check "kept workspace has answer.toml" "[ -n \"$ANSWER\" ] && [ -f \"$ANSWER\" ]" +# the hash contains literal $-signs — compare OUTSIDE check()'s eval and pass only a verdict in +HASH="$(sed -n 's/^root-password-hashed = "\(.*\)"$/\1/p' "$ANSWER" 2>/dev/null)" +SALT="$(printf '%s' "$HASH" | awk -F'$' '{print $3}')" +RECOMPUTED="$(openssl passwd -6 -salt "$SALT" "$PW" 2>/dev/null)" +HASH_MATCH=no; [ -n "$HASH" ] && [ "$RECOMPUTED" = "$HASH" ] && HASH_MATCH=yes +check "plaintext matches the answer's rendered hash" "[ \"$HASH_MATCH\" = yes ]" + +# the plaintext must never surface on stdout/stderr +check "plaintext absent from build stdout/stderr" "! grep -qF \"$PW\" \"$BUILD_LOG\"" + +# manifest heredoc guard (source-level: dry-run emits no manifest; the live build's manifest is +# additionally verified in the ISO-build leg). Pointer present, plaintext variable absent. +# the heredoc text contains ${VAR} references — grep it outside check()'s eval, pass verdicts in +MANIFEST_SRC="$(awk '/^cat > "\$OUT_ISO.manifest.txt" <