Skip to content

Commit 40b3f3a

Browse files
committed
Merge recover-verify (#231): kryptik-recover verifies a slot against the signed command line before it commits it
2 parents 7f8a65e + 1b83297 commit 40b3f3a

9 files changed

Lines changed: 202 additions & 60 deletions

File tree

‎build/recipes/boot-check.sh‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ s_boot_check() {
9292
chk "boot scripts" /usr/libexec/kryptik/sysinit.sh x
9393
chk "test control helper" /usr/libexec/kryptik/testctl.sh
9494
chk "keyboard helper" /usr/libexec/kryptik/keyboard.sh
95+
chk "medium root checks" /usr/libexec/kryptik/medium-root.sh
9596
chk "keyboard layouts" /usr/share/kryptik/keyboard-layouts
9697
chk "loadkeys" /usr/bin/loadkeys x
9798
chk "boot-success" /usr/libexec/kryptik/boot-success.sh x
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
#!/bin/sh
2+
# The root image an install medium carries, as kryptik-install and
3+
# kryptik-recover take it onto a disk. root.json is the medium's own record,
4+
# not signed material: a rewritten record must be able to make a tool fail and
5+
# nothing else, so every field is checked for form before it is used, and what
6+
# lands on a disk is verified against the root hash the signed kernel carries.
7+
# POSIX sh, sourced; the caller defines die.
8+
# medium_root JSON CMDLINE the record, checked; sets ROOT_BYTES ROOT_SHA
9+
# VERSION and the table's V_BLOCKS V_HASH_START
10+
# V_HASH V_SALT
11+
# kernel_names_root EFI the kernel's command line carries V_HASH
12+
# root_verifies DEV DEV holds the root V_HASH names
13+
14+
decimal_field() { # decimal_field NAME VALUE: a bounded decimal, or die
15+
case "$2" in ''|*[!0-9]*) die "root.json: $1 is not a number" ;; esac
16+
[ "${#2}" -le 15 ] || die "root.json: $1 is too large"
17+
}
18+
hex_field() { # hex_field NAME VALUE LENGTH: lowercase hex of that length, or die
19+
case "$2" in *[!0-9a-f]*) die "root.json: $1 is not a hash" ;; esac
20+
[ "${#2}" -eq "$3" ] || die "root.json: $1 is not a hash"
21+
}
22+
# The root's dm-verity table from the signed command line, as
23+
# "data_blocks hash_start_block root_hash salt"; empty when there is none.
24+
verity_of() { # verity_of FILE
25+
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
26+
}
27+
json_field() { # json_field JSON NAME: a top-level field of root.json
28+
sed -n "s/^ \"$2\": \"\{0,1\}\([^\",]*\)\"\{0,1\},\{0,1\}\$/\1/p" "$1" | head -1
29+
}
30+
31+
medium_root() { # medium_root JSON CMDLINE
32+
[ -r "$1" ] || die "no root.json on the medium"
33+
ROOT_BYTES="$(json_field "$1" total_bytes)"; ROOT_SHA="$(json_field "$1" sha256)"; VERSION="$(json_field "$1" version)"
34+
[ -n "$ROOT_BYTES" ] && [ -n "$ROOT_SHA" ] || die "root.json is incomplete"
35+
decimal_field total_bytes "$ROOT_BYTES"
36+
hex_field sha256 "$ROOT_SHA" 64
37+
case "$VERSION" in ''|*[!A-Za-z0-9._-]*) die "root.json: version is not a version" ;; esac
38+
VERITY="$(verity_of "$2")"
39+
[ -n "$VERITY" ] || die "could not read the root's verity table from the signed command line"
40+
read -r V_BLOCKS V_HASH_START V_HASH V_SALT <<EOF
41+
$VERITY
42+
EOF
43+
# The record must name the root the signed kernel carries, and its size
44+
# must hold that root and its hash tree without being absurd. Each field's
45+
# form comes first, so a refusal never prints what is not a hash or number.
46+
mr_hash="$(json_field "$1" root_hash)"; mr_blocks="$(json_field "$1" data_blocks)"
47+
hex_field root_hash "$mr_hash" 64
48+
decimal_field data_blocks "$mr_blocks"
49+
[ "$mr_hash" = "$V_HASH" ] || die "root.json names root hash ${mr_hash}; the signed kernel carries ${V_HASH}"
50+
[ "$mr_blocks" = "$V_BLOCKS" ] || die "root.json names ${mr_blocks} data blocks; the signed kernel carries ${V_BLOCKS}"
51+
[ "$ROOT_BYTES" -ge $(( (V_HASH_START + 1) * 4096 )) ] || die "root.json: total_bytes is smaller than the root and its hash tree"
52+
[ "$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"
53+
}
54+
55+
# A slot's kernel boots the root its command line names. Its signature is the
56+
# firmware's to check, at boot; this keeps one that names another release, or
57+
# is no kernel at all, off a disk the tool would call bootable.
58+
kernel_names_root() { # kernel_names_root EFI
59+
[ -f "$1" ] && grep -a -q -F "$V_HASH" "$1"
60+
}
61+
62+
# The record can lie about itself; the signed kernel's root hash cannot.
63+
root_verifies() { # root_verifies DEV
64+
veritysetup verify --no-superblock --hash=sha256 --data-block-size=4096 --hash-block-size=4096 \
65+
--data-blocks="$V_BLOCKS" --hash-offset=$(( V_HASH_START * 4096 )) --salt="$V_SALT" "$1" "$1" "$V_HASH"
66+
}

‎docs/design/boot-and-updates.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,14 @@ copies the ESP and root image from the medium, reads slot A back against the
9696
medium's `root.json`, creates the LUKS2 state partition and makes
9797
`BOOTX64.EFI` the slot A kernel. Every failure names its step.
9898

99+
`root.json` is the medium's own record and is not signed, so the installer
100+
and `kryptik-recover --restore-slot` take it through the same checks
101+
(`build/service-scripts/medium-root.sh`): every field has its form before it
102+
is used, the record must name the root hash and size in the medium's signed
103+
command line, each slot kernel copied from the medium must carry that hash,
104+
and the slot written must verify against it (`veritysetup verify`) before
105+
anything names it. A kernel's signature is the firmware's to check, at boot.
106+
99107
The installed disk boots `BOOTX64.EFI` through the removable-media path with
100108
no firmware variables. `kryptik-efiboot` adds a `Kryptik slot <x>` Boot####
101109
entry for a trial. When the trial ends, committed or not, `forget` removes
@@ -194,5 +202,6 @@ Host-side: `tools/tests/boot-success.sh`, `efiboot.sh`,
194202

195203
`build/stages/06-iso.sh`, `06-kernel-bind.sh`,
196204
`tools/install/kryptik-install.sh`, `build/service-scripts/sysinit.sh`,
205+
`medium-root.sh`,
197206
`boot-success.sh`, `tools/update/kryptik-update`, `kryptik-recover`,
198207
`tools/efi/kryptik-efiboot.c`, `tools/release-manifest.sh`.

‎docs/user-guide.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -339,8 +339,9 @@ state partition.
339339

340340
`--restore-slot` writes the medium's own root image and kernel into the
341341
slot, as the installer does, then commits it. Every byte comes from the
342-
medium, and the next boot checks them: the kernel refuses a root that does
343-
not match the hash it carries. The state partition is not touched, so users
342+
medium, and the slot is committed only once it verifies against the root
343+
hash the medium's signed kernel carries. A medium whose root or slot kernel
344+
is not that release's is refused with the reason, and nothing is committed. The state partition is not touched, so users
344345
and zone volumes survive. The result is the medium's version, which may be
345346
older than what was installed: the tool names the version it writes but does
346347
not compare it, so read `--status` first.

‎tools/image/integrity-test.sh‎

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@
1212
# (control: the signed medium boots under the same store); then
1313
# kryptik-recover --commit-slot a puts the signed kernel back
1414
# step 3 a byte of slot a's root flipped: dm-verity stops the boot
15-
# step 4 kryptik-recover --restore-slot a from the medium; its records on
16-
# the ESP are whole, and the user's data survives
15+
# step 4 kryptik-recover --restore-slot a refuses a medium whose root is
16+
# not the one its signed kernel names, then restores slot a from the
17+
# medium; its records on the ESP are whole, and the user's data survives
1718
# step 5 an anchor, zone, sysctl, preload library and udev rule planted in
1819
# the state's /etc layer: none takes effect
1920
# step 6 the state header saved, wiped and restored by kryptik-recover
@@ -32,12 +33,12 @@ while [[ "$#" -gt 0 ]]; do
3233
--usb) USB="${2:?}"; shift 2 ;;
3334
--disk) DISK="${2:?}"; shift 2 ;;
3435
--timeout) TIMEOUT="${2:?}"; shift 2 ;;
35-
-h|--help) sed -n '2,21p' "${BASH_SOURCE[0]}"; exit 0 ;;
36+
-h|--help) sed -n '2,22p' "${BASH_SOURCE[0]}"; exit 0 ;;
3637
*) die "unknown argument: $1" ;;
3738
esac
3839
done
3940
[[ -f "$USB" ]] || die "--usb IMG is required"
40-
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
41+
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
4142
VMDIR="${KRYPTIK_WORK}/vm"; mkdir -p "$VMDIR"
4243
DISK="${DISK:-${VMDIR}/integrity.img}"
4344
[[ -e "$DISK" && ! -f "$DISK" ]] && die "refusing: ${DISK} is not a regular file"
@@ -155,6 +156,41 @@ grep -q 'login:' <<<"$T3" && red "a login prompt appeared on a tampered root" ||
155156
step "step 4: recovery from the medium restores slot a; state survives"
156157
CTLR="${VMDIR}/testctl-recover.img"
157158
"${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
159+
# First a medium altered as whoever can write to it could alter it: a byte of a
160+
# root block nothing reads at boot, and root.json rewritten to match. Slot a
161+
# reads back as that record says; only the signed root hash tells.
162+
ALT="$(mktemp -d)"; ALTUSB="${VMDIR}/integrity-altered-usb.img"
163+
cp --sparse=always "$USB" "$ALTUSB"
164+
U_ESP=$(( $(part_start "$ALTUSB" 1) * 512 )); U_ROOT=$(( $(part_start "$ALTUSB" 2) * 512 ))
165+
mtype -i "${ALTUSB}@@${U_ESP}" ::/kryptik/root.json > "$ALT/root.json"
166+
A_BLOCKS="$(sed -n 's/^ "data_blocks": \([0-9]*\).*/\1/p' "$ALT/root.json")"
167+
A_BYTES="$(sed -n 's/^ "total_bytes": \([0-9]*\).*/\1/p' "$ALT/root.json")"
168+
# A free block from the middle of the root's ext4: never read, still verified.
169+
A_FREE=""
170+
if [[ -n "$A_BLOCKS" ]] && A_LOOP="$(losetup --find --show --read-only --offset "$U_ROOT" --sizelimit $(( A_BLOCKS * 4096 )) "$ALTUSB")"; then
171+
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')"
172+
losetup -d "$A_LOOP"
173+
fi
174+
if [[ -n "$A_FREE" && -n "$A_BYTES" ]]; then
175+
printf '\x5a' | dd of="$ALTUSB" bs=1 seek=$(( U_ROOT + A_FREE * 4096 + 100 )) conv=notrunc status=none
176+
A_SHA="$(dd if="$ALTUSB" bs=4M iflag=skip_bytes,count_bytes skip="$U_ROOT" count="$A_BYTES" status=none | sha256sum | cut -c1-64)"
177+
sed -i "s/^ \"sha256\": \"[0-9a-f]*\"/ \"sha256\": \"${A_SHA}\"/" "$ALT/root.json"
178+
mcopy -o -i "${ALTUSB}@@${U_ESP}" "$ALT/root.json" ::/kryptik/root.json
179+
smoke integ-p4a --usb "$ALTUSB" --disk "$DISK" --testctl "$CTLR" --vars enrolled --timeout "$TIMEOUT" > /dev/null
180+
T4A="$(boot_txt)"
181+
if grep -q 'KRYPTIK_RECOVER: rc=1' <<<"$T4A" && grep -q 'does not verify against the root hash' <<<"$T4A"; then
182+
green "a medium whose root is not the one its signed kernel names is refused, though its root.json was rewritten to match"
183+
else
184+
red "the altered medium's root was not refused"; grep 'KRYPTIK_RECOVER' <<<"$T4A" | tail -5 | sed 's/^/ /'
185+
fi
186+
dd if="$DISK" of="$ESPIMG" bs=1M iflag=skip_bytes,count_bytes skip="$ESP_OFF" count=$((512*1024*1024)) status=none
187+
mtype -i "$ESPIMG" ::/EFI/kryptik/kryptik-a.efi >/dev/null 2>&1 \
188+
&& red "the refused restore left a kernel for slot a on the ESP" \
189+
|| green "the refused restore left the ESP naming no kernel for slot a, so --commit-slot a takes nothing"
190+
else
191+
red "could not alter a copy of the medium (free block '${A_FREE}', total_bytes '${A_BYTES}')"
192+
fi
193+
rm -rf "$ALT" "$ALTUSB"
158194
smoke integ-p4 --usb "$USB" --disk "$DISK" --testctl "$CTLR" --vars enrolled --timeout "$TIMEOUT" > /dev/null
159195
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/^/ /'; }
160196
# The records recovery wrote on the ESP, read from the host: whole, and

‎tools/install/kryptik-install.sh‎

Lines changed: 11 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -12,23 +12,6 @@ PROG="kryptik-install"
1212
say() { printf '%s: %s\n' "$PROG" "$*"; }
1313
die() { printf '%s: FAILED: %s\n' "$PROG" "$*" >&2; exit 1; }
1414

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

9782
# --- refuse anything that is not a disposable whole disk -------------------
@@ -184,6 +169,8 @@ case "$media" in
184169
mount -t iso9660 -o ro /dev/sr0 "$MNT_BASE/media" || die "could not mount the medium (/dev/sr0)"
185170
ESP_SRC="$MNT_BASE/media/esp.img"
186171
[ -f "$ESP_SRC" ] || die "no esp.img on the medium"
172+
# For its kernels, read before anything is written.
173+
mount -t vfat -o ro,loop "$ESP_SRC" "$MNT_BASE/esp" || die "could not mount the medium's ESP image"
187174
ROOT_JSON="$MNT_BASE/media/root.json"
188175
ROOT_SRC=/dev/sr0
189176
# The signed command line's linear table is "0 N linear /dev/sr0 START":
@@ -194,24 +181,11 @@ case "$media" in
194181
;;
195182
*) die "unknown medium type '${media}'" ;;
196183
esac
197-
[ -r "$ROOT_JSON" ] || die "no root.json on the medium"
198-
jget() { sed -n "s/^ \"$1\": \"\{0,1\}\([^\",]*\)\"\{0,1\},\{0,1\}\$/\1/p" "$ROOT_JSON" | head -1; }
199-
ROOT_BYTES="$(jget total_bytes)"; ROOT_SHA="$(jget sha256)"; VERSION="$(jget version)"
200-
[ -n "$ROOT_BYTES" ] && [ -n "$ROOT_SHA" ] || die "root.json is incomplete"
201-
decimal_field total_bytes "$ROOT_BYTES"
202-
hex_field sha256 "$ROOT_SHA" 64
203-
case "$VERSION" in *[!A-Za-z0-9._-]*) die "root.json: version has characters a version cannot" ;; esac
204-
# The record must name the root the signed kernel carries, and its size must
205-
# hold that root and its hash tree without being absurd.
206-
VERITY="$(verity_of /proc/cmdline)"
207-
[ -n "$VERITY" ] || die "could not read the root's verity table from the signed command line"
208-
read -r V_BLOCKS V_HASH_START V_HASH V_SALT <<EOF
209-
$VERITY
210-
EOF
211-
[ "$(jget root_hash)" = "$V_HASH" ] || die "root.json names root hash $(jget root_hash); the signed kernel carries ${V_HASH}"
212-
[ "$(jget data_blocks)" = "$V_BLOCKS" ] || die "root.json names $(jget data_blocks) data blocks; the signed kernel carries ${V_BLOCKS}"
213-
[ "$ROOT_BYTES" -ge $(( (V_HASH_START + 1) * 4096 )) ] || die "root.json: total_bytes is smaller than the root and its hash tree"
214-
[ "$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"
184+
medium_root "$ROOT_JSON" /proc/cmdline
185+
for s in a b; do
186+
kernel_names_root "$MNT_BASE/esp/EFI/kryptik/kryptik-$s.efi" \
187+
|| die "the medium's kernel for slot $s does not carry the root hash its signed command line names; nothing was written"
188+
done
215189
ESP_BYTES="$(stat -c %s "$ESP_SRC" 2>/dev/null || blockdev --getsize64 "$ESP_SRC")"
216190
[ -b "$ESP_SRC" ] && ESP_BYTES="$(blockdev --getsize64 "$ESP_SRC")"
217191

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

0 commit comments

Comments
 (0)