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
1 change: 1 addition & 0 deletions build/recipes/boot-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ s_boot_check() {
chk "boot scripts" /usr/libexec/kryptik/sysinit.sh x
chk "test control helper" /usr/libexec/kryptik/testctl.sh
chk "keyboard helper" /usr/libexec/kryptik/keyboard.sh
chk "medium root checks" /usr/libexec/kryptik/medium-root.sh
chk "keyboard layouts" /usr/share/kryptik/keyboard-layouts
chk "loadkeys" /usr/bin/loadkeys x
chk "boot-success" /usr/libexec/kryptik/boot-success.sh x
Expand Down
66 changes: 66 additions & 0 deletions build/service-scripts/medium-root.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
#!/bin/sh
# The root image an install medium carries, as kryptik-install and
# kryptik-recover take it onto a disk. root.json is the medium's own record,
# not signed material: a rewritten record must be able to make a tool fail and
# nothing else, so every field is checked for form before it is used, and what
# lands on a disk is verified against the root hash the signed kernel carries.
# POSIX sh, sourced; the caller defines die.
# medium_root JSON CMDLINE the record, checked; sets ROOT_BYTES ROOT_SHA
# VERSION and the table's V_BLOCKS V_HASH_START
# V_HASH V_SALT
# kernel_names_root EFI the kernel's command line carries V_HASH
# root_verifies DEV DEV holds the root V_HASH names

decimal_field() { # decimal_field NAME VALUE: a bounded decimal, or die
case "$2" in ''|*[!0-9]*) die "root.json: $1 is not a number" ;; esac
[ "${#2}" -le 15 ] || die "root.json: $1 is too large"
}
hex_field() { # hex_field NAME VALUE LENGTH: lowercase hex of that length, or die
case "$2" in *[!0-9a-f]*) die "root.json: $1 is not a hash" ;; esac
[ "${#2}" -eq "$3" ] || die "root.json: $1 is not a hash"
}
# The root's dm-verity table from the signed command line, as
# "data_blocks hash_start_block root_hash salt"; empty when there is none.
verity_of() { # verity_of FILE
sed -n 's/.* verity 1 [^ ]* [^ ]* 4096 4096 \([0-9][0-9]*\) \([0-9][0-9]*\) sha256 \([0-9a-f]\{64\}\) \([0-9a-f]*\) .*/\1 \2 \3 \4/p' "$1" | head -1
}
json_field() { # json_field JSON NAME: a top-level field of root.json
sed -n "s/^ \"$2\": \"\{0,1\}\([^\",]*\)\"\{0,1\},\{0,1\}\$/\1/p" "$1" | head -1
}

medium_root() { # medium_root JSON CMDLINE
[ -r "$1" ] || die "no root.json on the medium"
ROOT_BYTES="$(json_field "$1" total_bytes)"; ROOT_SHA="$(json_field "$1" sha256)"; VERSION="$(json_field "$1" version)"
[ -n "$ROOT_BYTES" ] && [ -n "$ROOT_SHA" ] || die "root.json is incomplete"
decimal_field total_bytes "$ROOT_BYTES"
hex_field sha256 "$ROOT_SHA" 64
case "$VERSION" in ''|*[!A-Za-z0-9._-]*) die "root.json: version is not a version" ;; esac
VERITY="$(verity_of "$2")"
[ -n "$VERITY" ] || die "could not read the root's verity table from the signed command line"
read -r V_BLOCKS V_HASH_START V_HASH V_SALT <<EOF
$VERITY
EOF
# The record must name the root the signed kernel carries, and its size
# must hold that root and its hash tree without being absurd. Each field's
# form comes first, so a refusal never prints what is not a hash or number.
mr_hash="$(json_field "$1" root_hash)"; mr_blocks="$(json_field "$1" data_blocks)"
hex_field root_hash "$mr_hash" 64
decimal_field data_blocks "$mr_blocks"
[ "$mr_hash" = "$V_HASH" ] || die "root.json names root hash ${mr_hash}; the signed kernel carries ${V_HASH}"
[ "$mr_blocks" = "$V_BLOCKS" ] || die "root.json names ${mr_blocks} data blocks; the signed kernel carries ${V_BLOCKS}"
[ "$ROOT_BYTES" -ge $(( (V_HASH_START + 1) * 4096 )) ] || die "root.json: total_bytes is smaller than the root and its hash tree"
[ "$ROOT_BYTES" -le $(( V_BLOCKS * 4096 + V_BLOCKS * 64 + 16777216 )) ] || die "root.json: total_bytes is larger than a root and its hash tree can be"
}

# A slot's kernel boots the root its command line names. Its signature is the
# firmware's to check, at boot; this keeps one that names another release, or
# is no kernel at all, off a disk the tool would call bootable.
kernel_names_root() { # kernel_names_root EFI
[ -f "$1" ] && grep -a -q -F "$V_HASH" "$1"
}

# The record can lie about itself; the signed kernel's root hash cannot.
root_verifies() { # root_verifies DEV
veritysetup verify --no-superblock --hash=sha256 --data-block-size=4096 --hash-block-size=4096 \
--data-blocks="$V_BLOCKS" --hash-offset=$(( V_HASH_START * 4096 )) --salt="$V_SALT" "$1" "$1" "$V_HASH"
}
9 changes: 9 additions & 0 deletions docs/design/boot-and-updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,14 @@ copies the ESP and root image from the medium, reads slot A back against the
medium's `root.json`, creates the LUKS2 state partition and makes
`BOOTX64.EFI` the slot A kernel. Every failure names its step.

`root.json` is the medium's own record and is not signed, so the installer
and `kryptik-recover --restore-slot` take it through the same checks
(`build/service-scripts/medium-root.sh`): every field has its form before it
is used, the record must name the root hash and size in the medium's signed
command line, each slot kernel copied from the medium must carry that hash,
and the slot written must verify against it (`veritysetup verify`) before
anything names it. A kernel's signature is the firmware's to check, at boot.

The installed disk boots `BOOTX64.EFI` through the removable-media path with
no firmware variables. `kryptik-efiboot` adds a `Kryptik slot <x>` Boot####
entry for a trial. When the trial ends, committed or not, `forget` removes
Expand Down Expand Up @@ -186,5 +194,6 @@ Host-side: `tools/tests/boot-success.sh`, `efiboot.sh`,

`build/stages/06-iso.sh`, `06-kernel-bind.sh`,
`tools/install/kryptik-install.sh`, `build/service-scripts/sysinit.sh`,
`medium-root.sh`,
`boot-success.sh`, `tools/update/kryptik-update`, `kryptik-recover`,
`tools/efi/kryptik-efiboot.c`, `tools/release-manifest.sh`.
5 changes: 3 additions & 2 deletions docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -339,8 +339,9 @@ state partition.

`--restore-slot` writes the medium's own root image and kernel into the
slot, as the installer does, then commits it. Every byte comes from the
medium, and the next boot checks them: the kernel refuses a root that does
not match the hash it carries. The state partition is not touched, so users
medium, and the slot is committed only once it verifies against the root
hash the medium's signed kernel carries. A medium whose root or slot kernel
is not that release's is refused with the reason, and nothing is committed. The state partition is not touched, so users
and zone volumes survive. The result is the medium's version, which may be
older than what was installed: the tool names the version it writes but does
not compare it, so read `--status` first.
Expand Down
44 changes: 40 additions & 4 deletions tools/image/integrity-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@
# (control: the signed medium boots under the same store); then
# kryptik-recover --commit-slot a puts the signed kernel back
# step 3 a byte of slot a's root flipped: dm-verity stops the boot
# step 4 kryptik-recover --restore-slot a from the medium; its records on
# the ESP are whole, and the user's data survives
# step 4 kryptik-recover --restore-slot a refuses a medium whose root is
# not the one its signed kernel names, then restores slot a from the
# medium; its records on the ESP are whole, and the user's data survives
# step 5 an anchor, zone, sysctl, preload library and udev rule planted in
# the state's /etc layer: none takes effect
# step 6 the state header saved, wiped and restored by kryptik-recover
Expand All @@ -32,12 +33,12 @@ while [[ "$#" -gt 0 ]]; do
--usb) USB="${2:?}"; shift 2 ;;
--disk) DISK="${2:?}"; shift 2 ;;
--timeout) TIMEOUT="${2:?}"; shift 2 ;;
-h|--help) sed -n '2,21p' "${BASH_SOURCE[0]}"; exit 0 ;;
-h|--help) sed -n '2,22p' "${BASH_SOURCE[0]}"; exit 0 ;;
*) die "unknown argument: $1" ;;
esac
done
[[ -f "$USB" ]] || die "--usb IMG is required"
for t in python3 sbsign sbverify sbattach openssl mcopy mdel mdir mtype sfdisk cryptsetup losetup; do have "$t" || die "required tool not found: $t"; done
for t in python3 sbsign sbverify sbattach openssl mcopy mdel mdir mtype sfdisk cryptsetup losetup debugfs; do have "$t" || die "required tool not found: $t"; done
VMDIR="${KRYPTIK_WORK}/vm"; mkdir -p "$VMDIR"
DISK="${DISK:-${VMDIR}/integrity.img}"
[[ -e "$DISK" && ! -f "$DISK" ]] && die "refusing: ${DISK} is not a regular file"
Expand Down Expand Up @@ -155,6 +156,41 @@ grep -q 'login:' <<<"$T3" && red "a login prompt appeared on a tampered root" ||
step "step 4: recovery from the medium restores slot a; state survives"
CTLR="${VMDIR}/testctl-recover.img"
"${SELF}/mk-testctl.sh" --out "$CTLR" --key "$TESTCTL_KEY" recover_disk=/dev/vda recover_slot=a recover_mode=restore smoke_poweroff=1 install_wait=5 > /dev/null
# First a medium altered as whoever can write to it could alter it: a byte of a
# root block nothing reads at boot, and root.json rewritten to match. Slot a
# reads back as that record says; only the signed root hash tells.
ALT="$(mktemp -d)"; ALTUSB="${VMDIR}/integrity-altered-usb.img"
cp --sparse=always "$USB" "$ALTUSB"
U_ESP=$(( $(part_start "$ALTUSB" 1) * 512 )); U_ROOT=$(( $(part_start "$ALTUSB" 2) * 512 ))
mtype -i "${ALTUSB}@@${U_ESP}" ::/kryptik/root.json > "$ALT/root.json"
A_BLOCKS="$(sed -n 's/^ "data_blocks": \([0-9]*\).*/\1/p' "$ALT/root.json")"
A_BYTES="$(sed -n 's/^ "total_bytes": \([0-9]*\).*/\1/p' "$ALT/root.json")"
# A free block from the middle of the root's ext4: never read, still verified.
A_FREE=""
if [[ -n "$A_BLOCKS" ]] && A_LOOP="$(losetup --find --show --read-only --offset "$U_ROOT" --sizelimit $(( A_BLOCKS * 4096 )) "$ALTUSB")"; then
A_FREE="$(debugfs -R "ffb 1 $(( A_BLOCKS / 2 ))" "$A_LOOP" 2>/dev/null | sed -n 's/^Free blocks found: \([0-9][0-9]*\).*/\1/p')"
losetup -d "$A_LOOP"
fi
if [[ -n "$A_FREE" && -n "$A_BYTES" ]]; then
printf '\x5a' | dd of="$ALTUSB" bs=1 seek=$(( U_ROOT + A_FREE * 4096 + 100 )) conv=notrunc status=none
A_SHA="$(dd if="$ALTUSB" bs=4M iflag=skip_bytes,count_bytes skip="$U_ROOT" count="$A_BYTES" status=none | sha256sum | cut -c1-64)"
sed -i "s/^ \"sha256\": \"[0-9a-f]*\"/ \"sha256\": \"${A_SHA}\"/" "$ALT/root.json"
mcopy -o -i "${ALTUSB}@@${U_ESP}" "$ALT/root.json" ::/kryptik/root.json
smoke integ-p4a --usb "$ALTUSB" --disk "$DISK" --testctl "$CTLR" --vars enrolled --timeout "$TIMEOUT" > /dev/null
T4A="$(boot_txt)"
if grep -q 'KRYPTIK_RECOVER: rc=1' <<<"$T4A" && grep -q 'does not verify against the root hash' <<<"$T4A"; then
green "a medium whose root is not the one its signed kernel names is refused, though its root.json was rewritten to match"
else
red "the altered medium's root was not refused"; grep 'KRYPTIK_RECOVER' <<<"$T4A" | tail -5 | sed 's/^/ /'
fi
dd if="$DISK" of="$ESPIMG" bs=1M iflag=skip_bytes,count_bytes skip="$ESP_OFF" count=$((512*1024*1024)) status=none
mtype -i "$ESPIMG" ::/EFI/kryptik/kryptik-a.efi >/dev/null 2>&1 \
&& red "the refused restore left a kernel for slot a on the ESP" \
|| green "the refused restore left the ESP naming no kernel for slot a, so --commit-slot a takes nothing"
else
red "could not alter a copy of the medium (free block '${A_FREE}', total_bytes '${A_BYTES}')"
fi
rm -rf "$ALT" "$ALTUSB"
smoke integ-p4 --usb "$USB" --disk "$DISK" --testctl "$CTLR" --vars enrolled --timeout "$TIMEOUT" > /dev/null
boot_txt | grep -q 'KRYPTIK_RECOVER: rc=0' && green "kryptik-recover --restore-slot a succeeded from the medium" || { red "recovery did not report success"; boot_txt | grep 'KRYPTIK_RECOVER' | tail -5 | sed 's/^/ /'; }
# The records recovery wrote on the ESP, read from the host: whole, and
Expand Down
51 changes: 11 additions & 40 deletions tools/install/kryptik-install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,6 @@ PROG="kryptik-install"
say() { printf '%s: %s\n' "$PROG" "$*"; }
die() { printf '%s: FAILED: %s\n' "$PROG" "$*" >&2; exit 1; }

# root.json is the medium's own record, not signed material: a rewritten
# record must be able to make the install fail and nothing else, so every
# field is checked for form before it is used, and what lands on the disk is
# verified against the root hash the signed kernel carries.
decimal_field() { # decimal_field NAME VALUE: a bounded decimal, or die
case "$2" in ''|*[!0-9]*) die "root.json: $1 is not a number" ;; esac
[ "${#2}" -le 15 ] || die "root.json: $1 is too large"
}
hex_field() { # hex_field NAME VALUE LENGTH: lowercase hex of that length, or die
case "$2" in *[!0-9a-f]*) die "root.json: $1 is not a hash" ;; esac
[ "${#2}" -eq "$3" ] || die "root.json: $1 is not a hash"
}
# The root's dm-verity table from the signed command line, as
# "data_blocks hash_start_block root_hash salt"; empty when there is none.
verity_of() { # verity_of FILE
sed -n 's/.* verity 1 [^ ]* [^ ]* 4096 4096 \([0-9][0-9]*\) \([0-9][0-9]*\) sha256 \([0-9a-f]\{64\}\) \([0-9a-f]*\) .*/\1 \2 \3 \4/p' "$1" | head -1
}
# A slot may be made larger than this image needs, never smaller.
slot_size_ok() { # slot_size_ok ASKED NEEDED: both in MiB, or die
case "$1" in ''|0*|*[!0-9]*) die "--slot-size takes a number of MiB" ;; esac
Expand Down Expand Up @@ -72,7 +55,7 @@ done
# --- every external tool, checked before the first write -------------------
missing=""
for tool in sfdisk partx blockdev blkid cryptsetup veritysetup stty mkfs.ext4 dd sha256sum mount umount sync awk sed \
readlink lsblk head tail wc cmp cp mkdir stat tr cut basename mv mountpoint rmdir chmod rm cat sleep \
grep readlink lsblk head tail wc cmp cp mkdir stat tr cut basename mv mountpoint rmdir chmod rm cat sleep \
loadkeys chattr; do
command -v "$tool" >/dev/null 2>&1 || missing="${missing} ${tool}"
done
Expand All @@ -92,6 +75,8 @@ part_dev() {
# are this system's own.
. /usr/libexec/kryptik/devices.sh
. /usr/libexec/kryptik/keyboard.sh
# What the medium's root.json may be trusted for, shared with kryptik-recover.
. /usr/libexec/kryptik/medium-root.sh
[ -z "$KEYBOARD" ] || kb_row "$KEYBOARD" > /dev/null || die "no keyboard layout named ${KEYBOARD}: kryptik keyboard lists them"

# --- refuse anything that is not a disposable whole disk -------------------
Expand Down Expand Up @@ -184,6 +169,8 @@ case "$media" in
mount -t iso9660 -o ro /dev/sr0 "$MNT_BASE/media" || die "could not mount the medium (/dev/sr0)"
ESP_SRC="$MNT_BASE/media/esp.img"
[ -f "$ESP_SRC" ] || die "no esp.img on the medium"
# For its kernels, read before anything is written.
mount -t vfat -o ro,loop "$ESP_SRC" "$MNT_BASE/esp" || die "could not mount the medium's ESP image"
ROOT_JSON="$MNT_BASE/media/root.json"
ROOT_SRC=/dev/sr0
# The signed command line's linear table is "0 N linear /dev/sr0 START":
Expand All @@ -194,24 +181,11 @@ case "$media" in
;;
*) die "unknown medium type '${media}'" ;;
esac
[ -r "$ROOT_JSON" ] || die "no root.json on the medium"
jget() { sed -n "s/^ \"$1\": \"\{0,1\}\([^\",]*\)\"\{0,1\},\{0,1\}\$/\1/p" "$ROOT_JSON" | head -1; }
ROOT_BYTES="$(jget total_bytes)"; ROOT_SHA="$(jget sha256)"; VERSION="$(jget version)"
[ -n "$ROOT_BYTES" ] && [ -n "$ROOT_SHA" ] || die "root.json is incomplete"
decimal_field total_bytes "$ROOT_BYTES"
hex_field sha256 "$ROOT_SHA" 64
case "$VERSION" in *[!A-Za-z0-9._-]*) die "root.json: version has characters a version cannot" ;; esac
# The record must name the root the signed kernel carries, and its size must
# hold that root and its hash tree without being absurd.
VERITY="$(verity_of /proc/cmdline)"
[ -n "$VERITY" ] || die "could not read the root's verity table from the signed command line"
read -r V_BLOCKS V_HASH_START V_HASH V_SALT <<EOF
$VERITY
EOF
[ "$(jget root_hash)" = "$V_HASH" ] || die "root.json names root hash $(jget root_hash); the signed kernel carries ${V_HASH}"
[ "$(jget data_blocks)" = "$V_BLOCKS" ] || die "root.json names $(jget data_blocks) data blocks; the signed kernel carries ${V_BLOCKS}"
[ "$ROOT_BYTES" -ge $(( (V_HASH_START + 1) * 4096 )) ] || die "root.json: total_bytes is smaller than the root and its hash tree"
[ "$ROOT_BYTES" -le $(( V_BLOCKS * 4096 + V_BLOCKS * 64 + 16777216 )) ] || die "root.json: total_bytes is larger than a root and its hash tree can be"
medium_root "$ROOT_JSON" /proc/cmdline
for s in a b; do
kernel_names_root "$MNT_BASE/esp/EFI/kryptik/kryptik-$s.efi" \
|| die "the medium's kernel for slot $s does not carry the root hash its signed command line names; nothing was written"
done
ESP_BYTES="$(stat -c %s "$ESP_SRC" 2>/dev/null || blockdev --getsize64 "$ESP_SRC")"
[ -b "$ESP_SRC" ] && ESP_BYTES="$(blockdev --getsize64 "$ESP_SRC")"

Expand Down Expand Up @@ -318,10 +292,7 @@ say "reading kryptik-a back"
got="$(dd if="$P2" bs=4M iflag=count_bytes count="$ROOT_BYTES" status=none | sha256sum | cut -c1-64)"
[ "$got" = "$ROOT_SHA" ] || die "kryptik-a does not verify: wrote ${got}, the medium says ${ROOT_SHA}"
say "kryptik-a verifies (${got})"
# The record can lie about itself; the kernel's root hash cannot.
veritysetup verify --no-superblock --hash=sha256 --data-block-size=4096 --hash-block-size=4096 \
--data-blocks="$V_BLOCKS" --hash-offset=$(( V_HASH_START * 4096 )) --salt="$V_SALT" "$P2" "$P2" "$V_HASH" \
|| die "kryptik-a does not verify against the root hash the signed kernel carries"
root_verifies "$P2" || die "kryptik-a does not verify against the root hash the signed kernel carries"
say "kryptik-a verifies against the signed kernel's root hash"
say "clearing kryptik-b"
dd if=/dev/zero of="$P3" bs=1M count=4 conv=fsync status=none || die "clearing kryptik-b failed"
Expand Down
Loading
Loading