Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

examples/secure-update-gateway/e2e-journey.sh

#!/usr/bin/env bash
# Secure Update Gateway — end-to-end MVP journey (hermetic; needs openssl + cargo).
#
# Demonstrates: build a firmware payload + SBOM → prepare → 2 approvals (distinct
# roles) → finalize (distinct release signer) → verify offline → copy through an
# untrusted staging dir → verify again → mutate one byte → verify FAILS → import
# into an empty protected store → import again (idempotent) → rollback rejected →
# scrub → backup create/verify → restore into an empty root → scrub restored →
# the audit trail holds the imports. Prove the protected binaries link no outbound HTTP client.
# Also: prepare refuses a candidate with no stated verdict, and finalize refuses
# to release-sign a candidate whose reviewers marked it "block".
#
# STALE SINCE LANE W4 (2026-10-19): `bundle import --repo <raw>` is removed and refused by
# name (holger-import-1); a bundle now lands AVAILABLE through the intake chain
# (`promotion.intake`, see server/lib/src/import_door.rs). Steps 11-16 below still use the raw
# store and need rewriting onto the intake chain, together with `scrub` and `backup`.
#
# Exit 0 only if every assertion holds. On a failure the script names the step
# and the command, and prints that command's own output.
set -Eeuo pipefail

ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$ROOT"
echo "== building binaries =="
cargo build -q -p holger-cli --bin holger-cli -p holger --features secure_update,holger-server-alias --bin holger-server
SB="${CARGO_TARGET_DIR:-$ROOT/target}/debug"
W="$(mktemp -d)"; trap 'rm -rf "$W"' EXIT
cd "$W"

STEP="setup"
pass() { echo "PASS: $*"; }
fail() { echo "FAIL [$STEP]: $*"; exit 1; }
trap 'echo "FAIL [$STEP]: line $LINENO: $BASH_COMMAND" >&2' ERR
step() { STEP="$1"; echo "== $1 =="; }
# Run one command; on failure name the step and the command and show its output.
run() {
  if ! "$@" >"$W/last.log" 2>&1; then
    echo "FAIL [$STEP]: $*" >&2; sed 's/^/    | /' "$W/last.log" >&2; exit 1
  fi
}
# Run one command that MUST fail; show its output if it unexpectedly succeeds.
refused() {
  if "$@" >"$W/last.log" 2>&1; then
    echo "FAIL [$STEP]: expected a refusal, but this succeeded: $*" >&2
    sed 's/^/    | /' "$W/last.log" >&2; exit 1
  fi
}

for tool in openssl perl sha256sum; do
  command -v "$tool" >/dev/null || fail "$tool is required and not on PATH"
done

step "1. test PKI (ed25519, never committed)"
for who in security-reviewer system-owner release-signer; do
  openssl genpkey -algorithm ed25519 -out "$who.key" 2>/dev/null
  openssl req -x509 -key "$who.key" -subj "/CN=$who" -days 3 -out "$who.crt" 2>/dev/null
done
fp() { openssl x509 -in "$1" -outform der 2>/dev/null | sha256sum | cut -d' ' -f1; }

step "2-3. payload + SBOM + scan/advisory metadata"
printf 'EXAMPLEDRIVE-FIRMWARE-2.0.0\n' > firmware.bin
printf '{"bomFormat":"CycloneDX","specVersion":"1.5"}\n' > app.cdx.json
printf '{"scanner":"demo","findings":[]}\n' > scan.json

cat > trust-policy.ron <<EOF
(schema_version:"holger.bundle-trust-policy/v1",trust_mode:Some(PinnedSigners),trusted_roots:[],
 approved_signers:[(fingerprint:"$(fp security-reviewer.crt)",roles:["security-reviewer"]),
                   (fingerprint:"$(fp system-owner.crt)",roles:["system-owner"])],
 required_approval_roles:["security-reviewer","system-owner"],minimum_distinct_approvers:2,
 trusted_release_signers:["$(fp release-signer.crt)"],release_signer_must_be_distinct:true,
 require_expiry:true,require_sbom:true)
EOF

step "4. prepare candidate (the verdict is stated, never defaulted)"
refused "$SB/holger-cli" bundle prepare --candidate noverdict \
  --bundle-id acme-drive-2.0.0 --sequence 7 \
  --supplier ACME --product ExampleDrive --release-version 2.0.0 \
  --payload firmware.bin --sbom app.cdx.json
grep -q -- "--verdict" "$W/last.log" || fail "the refusal does not name --verdict"
test ! -e noverdict || fail "a refused prepare wrote a candidate"
pass "prepare without --verdict refused"
run "$SB/holger-cli" bundle prepare --candidate cand \
  --bundle-id acme-drive-2.0.0 --sequence 7 \
  --supplier ACME --product ExampleDrive --release-version 2.0.0 \
  --model ACS880 --revision R4 --verdict pass \
  --payload firmware.bin --sbom app.cdx.json --scan scan.json
pass "prepared candidate"

step "5. two distinct approvals"
run "$SB/holger-cli" bundle approve --candidate cand --role security-reviewer --cert security-reviewer.crt --key security-reviewer.key
run "$SB/holger-cli" bundle approve --candidate cand --role system-owner --cert system-owner.crt --key system-owner.key
pass "two approvals signed"

step "6. finalize (distinct release signer)"
run "$SB/holger-cli" bundle finalize --candidate cand --policy trust-policy.ron \
  --release-cert release-signer.crt --release-key release-signer.key \
  --output bundle.holger.znippy
pass "finalized signed bundle"

step "6b. a candidate the reviewers blocked is never release-signed"
run "$SB/holger-cli" bundle prepare --candidate blocked \
  --bundle-id acme-drive-2.0.1 --sequence 8 \
  --supplier ACME --product ExampleDrive --release-version 2.0.1 \
  --model ACS880 --revision R4 --verdict block \
  --payload firmware.bin --sbom app.cdx.json
run "$SB/holger-cli" bundle approve --candidate blocked --role security-reviewer --cert security-reviewer.crt --key security-reviewer.key
run "$SB/holger-cli" bundle approve --candidate blocked --role system-owner --cert system-owner.crt --key system-owner.key
refused "$SB/holger-cli" bundle finalize --candidate blocked --policy trust-policy.ron \
  --release-cert release-signer.crt --release-key release-signer.key \
  --output blocked.holger.znippy
grep -q "verdict" "$W/last.log" || fail "the refusal does not name the verdict"
test ! -e blocked.holger.znippy || fail "a blocked candidate was written as a bundle"
pass "blocked verdict refused at finalize"

step "7-8. verify offline, then carry through untrusted staging"
run "$SB/holger-cli" verify bundle.holger.znippy --policy trust-policy.ron \
  --target-product ExampleDrive --target-model ACS880 --target-revision R4
mkdir -p staging && cp bundle.holger.znippy staging/carried.holger.znippy
run "$SB/holger-cli" verify staging/carried.holger.znippy --policy trust-policy.ron
pass "verified before and after transport"

step "9. wrong target rejected"
refused "$SB/holger-cli" verify bundle.holger.znippy --policy trust-policy.ron --target-product WrongDrive
pass "wrong target rejected"

step "10. one mutated byte fails verification"
cp bundle.holger.znippy tampered.holger.znippy
perl -e '
  my $p = "tampered.holger.znippy"; local $/; open(my $f, "+<:raw", $p) or die "open: $!";
  my $b = <$f>; my $i = index($b, "EXAMPLEDRIVE-FIRMWARE-2.0.0");
  die "the payload text is not in the bundle, so nothing was mutated\n" if $i < 0;
  substr($b, $i + 3, 1) = chr(ord(substr($b, $i + 3, 1)) ^ 1);
  seek($f, 0, 0); print $f $b; close $f;'
refused "$SB/holger-cli" verify tampered.holger.znippy --policy trust-policy.ron
pass "tampered bundle rejected"

step "11-12. import into empty protected store"
mkdir -p store/bundles
cat > ot.ron <<EOF
(repositories:[(ron_name:"bundles",ron_repo_type:"raw",ron_upstreams:[],ron_in:None,ron_out:None,ron_data_dir:Some("$W/store/bundles"))],
 exposed_endpoints:[(ron_name:"g",ron_url:"127.0.0.1:0")],storage_endpoints:[],audit:(dir:"$W/store/audit",enabled:true))
EOF
# The store is empty, so there is no anti-rollback floor yet: an import without
# --first-import is refused by name (holger-update-38), and nothing is written.
refused "$SB/holger-server" bundle import --config ot.ron --repo bundles --bundle bundle.holger.znippy --policy trust-policy.ron
grep -q "first-import" "$W/last.log" || fail "the floor-absent refusal does not name --first-import"
test ! -e store/bundles/ACME || fail "a refused import wrote into the store"
pass "no floor and no --first-import: refused"
run "$SB/holger-server" bundle import --config ot.ron --repo bundles --bundle bundle.holger.znippy --policy trust-policy.ron \
  --target-product ExampleDrive --target-model ACS880 --target-revision R4 --first-import
test -f store/bundles/ACME/ExampleDrive/2.0.0.holger.znippy || fail "bundle not stored"
grep -rq "bundle-import" store/audit || fail "the import left no audit record"
pass "imported into protected store, and audited"

run "$SB/holger-server" bundle import --config ot.ron --repo bundles --bundle bundle.holger.znippy --policy trust-policy.ron
pass "idempotent re-import"

step "13. anti-rollback: an older sequence is rejected"
run "$SB/holger-cli" bundle prepare --candidate old --bundle-id acme-drive-1.0.0 --sequence 3 \
  --supplier ACME --product ExampleDrive --release-version 1.0.0 --model ACS880 --revision R4 \
  --verdict pass --payload firmware.bin --sbom app.cdx.json
run "$SB/holger-cli" bundle approve --candidate old --role security-reviewer --cert security-reviewer.crt --key security-reviewer.key
run "$SB/holger-cli" bundle approve --candidate old --role system-owner --cert system-owner.crt --key system-owner.key
run "$SB/holger-cli" bundle finalize --candidate old --policy trust-policy.ron --release-cert release-signer.crt --release-key release-signer.key --output old.holger.znippy
refused "$SB/holger-server" bundle import --config ot.ron --repo bundles --bundle old.holger.znippy --policy trust-policy.ron
pass "rollback (older sequence) rejected"

step "14. scrub"
run "$SB/holger-server" scrub --config ot.ron --repo bundles --policy trust-policy.ron
pass "scrub clean"

step "15-16. backup create + verify, restore into empty root, scrub restored"
# A backup is signed (an unsigned one is refused unless the operator opts out).
# The trusted release signer may sign it; the CLI takes DER cert + PKCS#8 DER key.
openssl x509 -in release-signer.crt -outform der -out release-signer.der
openssl pkcs8 -topk8 -nocrypt -in release-signer.key -outform der -out release-signer.pk8
run "$SB/holger-server" backup create --config ot.ron --repo bundles --output backup.znippy \
  --signer-cert release-signer.der --signer-key release-signer.pk8
run "$SB/holger-server" backup verify --input backup.znippy --policy trust-policy.ron
run "$SB/holger-server" backup restore --input backup.znippy --data-root restored --policy trust-policy.ron
test -f "restored/bundles/ACME/ExampleDrive/2.0.0.holger.znippy" || fail "restore missing bundle"
pass "backup create/verify/restore round-trip"

step "17. the audit trail holds every import"
# The Arrow audit backend and its `audit verify` verb were removed in the lean
# cut (9521bf0); the trail is append-only JSON lines under the audit dir.
n=$(cat store/audit/* 2>/dev/null | grep -c '"bundle-import"' || true)
test "${n:-0}" -ge 2 || fail "expected 2 bundle-import audit records (import + re-import), found ${n:-0}"
pass "audit trail holds $n bundle-import records"

step "18. protected binaries link no outbound HTTP client"
( cd "$ROOT" && run cargo test -q -p holger-build-space --test auth_ship_small \
    secure_update_zero_egress_links_no_http_client )
pass "zero-egress link audit"

echo
echo "ALL STEPS PASSED — the secure update gateway journey is complete."