Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions Buildroot/board/FOG/FOS/rootfs_overlay/bin/fog.enrollsb
Original file line number Diff line number Diff line change
Expand Up @@ -56,18 +56,26 @@ fi
echo "Done"
debugPause

# Printed for the record, not as a security gate: FOS is already running a
# kernel this same server signed and served, so a server that could hand us the
# wrong certificate could equally have handed us the wrong kernel. The value of
# showing it is that it matches what the Secure Boot page displays, so an
# enrolment can be audited after the fact.
# Shown and reported for the record, not as a security gate: FOS is already
# running a kernel this same server signed and served, so a server that could
# hand us the wrong certificate could equally have handed us the wrong kernel.
# The value of it is that it matches what the Secure Boot page displays and
# what sbReport stores against the host, so an enrolment can be audited after
# the fact -- "enrolled in March" says nothing about whether this machine
# trusts what the server is serving today, and a fingerprint does.
fingerprint=$(sbCertFingerprint "$cert")
echo " * Certificate SHA-256:"
echo " $(sbCertFingerprint "$cert")"
echo " $fingerprint"
echo

if sbCertTrusted "$cert"; then
echo " * This certificate is already trusted by this machine. Nothing to do."
echo
# Reported even though this task enrolled nothing. "This machine already
# trusts the certificate the server is serving today" is the answer the
# administrator was after; recording nothing would leave the host looking
# never-enrolled and invite the same task again on the next sweep.
sbReport "trusted" "$fingerprint"
. /bin/fog.nonimgcomplete
exit 0
fi
Expand Down Expand Up @@ -108,6 +116,7 @@ if [[ $state == setup ]]; then
echo "=============================================================================="
echo

sbReport "db" "$fingerprint"
. /bin/fog.nonimgcomplete
exit 0
fi
Expand Down Expand Up @@ -147,4 +156,8 @@ echo " and nothing has been enrolled."
echo "=============================================================================="
echo

# "mok", NOT "db": nothing is enrolled yet and will not be until a human works
# through MokManager. The server records this as pending for exactly that
# reason -- see ADR 0029 decision 6.
sbReport "mok" "$fingerprint"
. /bin/fog.nonimgcomplete
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,54 @@ sbCertFingerprint() {
[[ -z $cert ]] && handleError "No certificate passed (${FUNCNAME[0]})\n Args Passed: $*"
sha256sum "$cert" 2>/dev/null | awk '{print toupper($1)}' | sed 's/../&:/g;s/:$//'
}
# Tell the server what this enrolment actually did.
#
# WHY THIS IS NOT INFERRED FROM THE TASK COMPLETING
#
# fog.enrollsb has three exits and every one of them ends with the same
# argument-free `. /bin/fog.nonimgcomplete`, so from the server all three look
# identical: the task completed. They are not remotely the same thing.
#
# db the machine was in Setup Mode, `db` was written, it IS enrolled
# trusted it already trusted this certificate; nothing was enrolled, but the
# trust is real and is worth recording
# mok a request was STAGED. The machine is NOT enrolled and will not
# boot with Secure Boot on until a human confirms it at MokManager
#
# Recording the third as an enrolment is a lie an administrator acts on: they
# turn Secure Boot on in firmware and the machine stops booting. So the outcome
# is reported by the only party that knows it, which is this one.
#
# Best-effort by design. A server too old to have the endpoint answers 404 and
# a server that never hears us changes nothing -- in both cases the enrolment
# itself already happened and the task must still complete. So this NEVER calls
# handleError and never fails the task; it says what it did and moves on. The
# record is a convenience for the administrator, not a step in the enrolment.
#
# $1 the result: db, trusted or mok
# $2 the certificate's SHA-256 fingerprint
sbReport() {
local result="$1"
local cert="$2"
local mactosend="${mac}"
[[ -z $result || -z $cert ]] && return 0
[[ -z $web ]] && return 0
[[ -z $mactosend ]] && return 0
# sbState() again rather than a value cached from the top of the task: the
# db path CHANGES the state it is reporting -- writing the PK is what
# leaves Setup Mode -- so the state at the end is the one that describes
# the machine the administrator will next look at.
callServer "${web}service/secureboot.report.php" \
"mac=${mactosend}&result=${result}&cert=${cert}&sbstate=$(sbState)"
[[ $serverBody == "##ok" ]] && return 0
# Said out loud rather than swallowed. It is not a failure of the
# enrolment, and the wording has to make that clear, or the next person
# reads it as one and goes looking at firmware that is perfectly fine.
echo " * Note: the enrolment succeeded but could not be recorded on the"
echo " FOG server (${serverReason:-${serverBody:-no answer}})."
echo " Set it by hand on the host's General tab if you need the record."
return 0
}
# Return 0 if the certificate is present in the platform's db.
#
# Reads the variable and looks for the certificate's own bytes, rather than
Expand Down
12 changes: 12 additions & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,18 @@ tests/checks/secureboot.sh # secureboot-funcs.sh: derives the right firmware
# a failed download writes nothing at all, and a
# SetupMode that does not flip 1 -> 0 is a refusal
# rather than a success
tests/checks/secureboot-enrolment-report.sh
# sbReport() tells the FOG server WHICH of
# fog.enrollsb's three exits was reached -- all
# three otherwise end in the same argument-free
# completion POST and look identical from the
# server. Pins that a staged MOK is reported as
# 'mok' and never as an enrolment (turning Secure
# Boot on after that lie stops the machine
# booting), that every exit reports BEFORE
# completing the task, and that a server that is
# unreachable, 404s or answers nothing never
# fails the task the enrolment already finished
tests/checks/secureboot-config.sh
# configs/kernel*.config carry the Secure Boot
# hardening symbols (lockdown LSM in CONFIG_LSM,
Expand Down
217 changes: 217 additions & 0 deletions tests/checks/secureboot-enrolment-report.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
#!/bin/bash
#
# Assertion harness for sbReport() and the three exits that call it.
#
# tests/checks/secureboot-enrolment-report.sh
#
# What it proves: that a Secure Boot enrolment task tells the FOG server WHICH
# of its three outcomes it reached, and that failing to tell it never fails the
# task.
#
# fog.enrollsb has three exits and every one of them ends with the same
# argument-free `. /bin/fog.nonimgcomplete`, so from the server all three look
# identical -- the task completed:
#
# db the machine was in Setup Mode, `db` was written, it IS enrolled
# trusted it already trusted this certificate; nothing was enrolled here,
# and nothing observed how the trust got there
# mok a request was STAGED. The machine is NOT enrolled and will not
# boot with Secure Boot on until a human confirms it at MokManager
#
# Recording the third as an enrolment is a lie an administrator acts on: they
# turn Secure Boot on in firmware and the machine stops booting. Case 4 is the
# one that holds that shut, and it is anchored on the whole call line -- a grep
# for "sbReport" alone passes when the argument has been changed to "db", which
# is the exact regression worth catching.
#
# The other half is that this must never be able to fail a task. The enrolment
# has already happened by the time sbReport runs; a server that is too old to
# have the endpoint answers 404, and one that is unreachable answers nothing.
# In both cases the machine is enrolled and the task must still complete. So
# sbReport returns 0 unconditionally, says on screen what went wrong, and says
# it in words that do not read as an enrolment failure -- cases 5 to 8.
#
# Mechanism mirrors tests/checks/server-post-reporting.sh: source sandbox
# copies of funcs.sh (for callServer) and secureboot-funcs.sh with their
# absolute paths rewritten, and PATH-shadow curl with a stub that emulates
# -w '\n%{http_code}' from the environment and logs its argv.

HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_LIB="$HERE/../../Buildroot/board/FOG/FOS/rootfs_overlay/usr/share/fog/lib"
REPO_BIN="$HERE/../../Buildroot/board/FOG/FOS/rootfs_overlay/bin"

for f in funcs.sh secureboot-funcs.sh; do
[[ -f $REPO_LIB/$f ]] || { echo "ERROR: cannot find $f under $REPO_LIB" >&2; exit 2; }
done
[[ -f $REPO_BIN/fog.enrollsb ]] || { echo "ERROR: cannot find fog.enrollsb under $REPO_BIN" >&2; exit 2; }

SANDBOX="$(mktemp -d)"
trap 'rm -rf "$SANDBOX"' EXIT
export SANDBOX

mkdir -p "$SANDBOX/proc" "$SANDBOX/bin"
: > "$SANDBOX/proc/mounts"

cp "$REPO_LIB/partition-funcs.sh" "$SANDBOX/partition-funcs.sh"
sed -e "s#^\. /usr/share/fog/lib/partition-funcs\.sh#. $SANDBOX/partition-funcs.sh#" \
"$REPO_LIB/funcs.sh" > "$SANDBOX/funcs.sh"

# Same ordering rule as tests/checks/secureboot.sh: the /tmp rule must run
# before the /sys and /proc ones, because $SANDBOX is itself under /tmp.
sed -e "s#\"/tmp/#\"$SANDBOX/#g" \
-e "s#/sys/firmware/efi#$SANDBOX/sys/firmware/efi#g" \
-e "s#/proc/mounts#$SANDBOX/proc/mounts#g" \
"$REPO_LIB/secureboot-funcs.sh" > "$SANDBOX/secureboot-funcs.sh"

STUBBIN="$SANDBOX/bin"
cat > "$STUBBIN/curl" <<'EOF'
#!/bin/bash
printf '%s\n' "$@" > "$SANDBOX/curl.argv"
[[ ${FAKE_CURL_RC:-0} -ne 0 ]] && exit "$FAKE_CURL_RC"
printf '%s' "${FAKE_BODY-}"
printf '\n%s' "${FAKE_CODE:-200}"
exit 0
EOF
chmod +x "$STUBBIN/curl"

PASS=0
FAIL=0
note() {
if [[ -z $2 ]]; then
echo "PASS: $1"; PASS=$((PASS + 1))
else
echo "FAIL: $1 ($2)"; FAIL=$((FAIL + 1))
fi
}

# Drives sbReport with the environment a real task has: web and mac come from
# the kernel command line, and sbState() reads the sandbox firmware. Prints the
# return code so a case can assert the task is never failed.
drive() {
(
set +u
export PATH="$STUBBIN:$PATH" SANDBOX="$SANDBOX"
export FAKE_CURL_RC FAKE_CODE FAKE_BODY
. "$SANDBOX/funcs.sh" >/dev/null 2>&1
. "$SANDBOX/secureboot-funcs.sh" >/dev/null 2>&1
web="http://fog.example/fog/"
mac="00:11:22:33:44:55"
sbReport "$@"
printf '\nRC=%s\n' "$?"
)
}

argv() { grep -Fxq -- "$1" "$SANDBOX/curl.argv"; }

# --- 1. the happy path posts everything the server needs ------------------
rm -f "$SANDBOX/curl.argv"
out=$(FAKE_CODE=200 FAKE_BODY="##ok" drive "db" "AA:BB")
err=""
[[ $out == *"RC=0"* ]] || err="rc not 0"
argv "http://fog.example/fog/service/secureboot.report.php" \
|| err="$err; wrong url: $(grep -c . "$SANDBOX/curl.argv") args"
data=$(grep -m1 'result=' "$SANDBOX/curl.argv")
[[ $data == *"mac=00:11:22:33:44:55"* ]] || err="$err; no mac"
[[ $data == *"result=db"* ]] || err="$err; no result"
[[ $data == *"cert=AA:BB"* ]] || err="$err; no cert"
[[ $data == *"sbstate="* ]] || err="$err; no sbstate"
note "1. a report posts mac, result, cert and state to the endpoint" "$err"

# --- 2. it is a POST, not a GET -------------------------------------------
# The endpoint reads INPUT_POST only. A call that lost its data argument would
# still reach a 200 and look fine from here.
err=""
argv "--data" || err="--data not on the argv"
note "2. the report is a POST carrying its body" "$err"

# --- 3. nothing is posted when there is nothing to say --------------------
# Guards against a half-populated row: a blank fingerprint stored against a
# host reads as "enrolled, certificate unknown", which is worse than no record
# because it cannot be told apart from a real enrolment whose cert was cleared.
for args in '"db" ""' '"" "AA:BB"'; do
rm -f "$SANDBOX/curl.argv"
eval "out=\$(FAKE_CODE=200 FAKE_BODY='##ok' drive $args)"
err=""
[[ -e $SANDBOX/curl.argv ]] && err="posted anyway"
[[ $out == *"RC=0"* ]] || err="$err; rc not 0"
note "3. sbReport($args) posts nothing and still returns 0" "$err"
done

# --- 4-8. a failure to record must never fail the task --------------------
# Each arm returns 0 and says something. The wording matters as much as the
# status: "enrolment failed" sends the next person to a firmware screen that is
# perfectly fine, so the message has to name the RECORDING as the thing that
# did not happen.
for arm in "unreachable:FAKE_CURL_RC=7" "http404:FAKE_CODE=404" \
"emptybody:FAKE_CODE=200 FAKE_BODY=" "wrongbody:FAKE_CODE=200 FAKE_BODY=##notasking"; do
name=${arm%%:*}
env=${arm#*:}
rm -f "$SANDBOX/curl.argv"
out=$(eval "$env drive 'db' 'AA:BB'")
err=""
[[ $out == *"RC=0"* ]] || err="rc not 0 -- this would fail the task"
grep -qi 'could not be recorded' <<<"$out" || err="$err; said nothing"
grep -qi 'enrolment succeeded' <<<"$out" \
|| err="$err; does not say the enrolment itself was fine"
note "4. $name: reports the problem and still returns 0" "$err"
done

# --- 9. a successful report is quiet --------------------------------------
# Noise on the happy path is not free: this text lands on the screen of a
# machine somebody is watching, directly under a banner that already told them
# what happened.
out=$(FAKE_CODE=200 FAKE_BODY="##ok" drive "db" "AA:BB")
err=""
grep -qi 'could not be recorded' <<<"$out" && err="complained on success"
note "9. a successful report prints nothing" "$err"

# --- 10. every exit in fog.enrollsb reports, and reports the right thing --
# Whole-line anchored, for the same reason tests/checks/server-post-reporting.sh
# anchors its call sites: a grep for the function name alone passes when the
# argument has been changed, and the argument is the entire point.
err=""
for want in 'sbReport "trusted" "$fingerprint"' \
'sbReport "db" "$fingerprint"' \
'sbReport "mok" "$fingerprint"'; do
grep -Fq "$want" "$REPO_BIN/fog.enrollsb" || err="$err; missing: $want"
done
note "10. all three exits report their own outcome" "$err"

# --- 11. the staged-MOK exit must not claim an enrolment ------------------
# THE assertion in this file. Everything else is plumbing; this is the one that
# stops FOG telling an administrator a machine is enrolled when it is not.
err=""
staged=$(awk '/^sbReport /{ last=$0 } END { print last }' "$REPO_BIN/fog.enrollsb")
[[ $staged == 'sbReport "mok" "$fingerprint"' ]] \
|| err="the last exit reports [$staged], not a staged MOK"
note "11. the staged-MOK exit reports 'mok', never 'db'" "$err"

# --- 12. the report happens BEFORE the task is completed ------------------
# Order is load-bearing: fog.nonimgcomplete is what clears the task, and a
# report that arrives after it is refused -- the endpoint requires the enrolment
# task to still be in flight, so that a caller who merely knows a MAC cannot
# stamp an enrolment onto a host that was never asked to enrol.
err=""
last_report=""
while read -r n line; do
case $line in
'sbReport '*)
last_report=$n
;;
'. /bin/fog.nonimgcomplete'*)
[[ -n $last_report ]] \
|| err="$err; the completion at line $n has no report before it"
# CONSUMED, not merely remembered. Without this the report belonging
# to the PREVIOUS exit satisfies the next one, and moving a report
# below its own completion passes -- which is the whole failure this
# case exists to catch. It was green until the mutation was run.
last_report=""
;;
esac
done < <(grep -n '^[[:space:]]*\(sbReport \|\. /bin/fog\.nonimgcomplete\)' \
"$REPO_BIN/fog.enrollsb" | sed 's/:[[:space:]]*/ /')
note "12. each exit reports before completing the task" "$err"

echo "----"
echo "$PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]] || exit 1