diff --git a/build/recipes/boot-check.sh b/build/recipes/boot-check.sh index 05a3d61e..04010f6d 100644 --- a/build/recipes/boot-check.sh +++ b/build/recipes/boot-check.sh @@ -1,6 +1,4 @@ #!/usr/bin/env bash -# boot-check: a stage 04 recipe, sourced by build/stages/04-base-system.sh, -# which runs it in the order its list gives. # Everything a boot needs, checked from the target's point of view. s_boot_check() { diff --git a/compartments/kryptikd/src/time.rs b/compartments/kryptikd/src/time.rs index ddd24045..c5949d65 100644 --- a/compartments/kryptikd/src/time.rs +++ b/compartments/kryptikd/src/time.rs @@ -1,30 +1,23 @@ -//! Wall-clock policy for zone 0 (docs/design/time.md). -//! -//! Only zone 0 may set the clock and it has no network, so the time arrives -//! as a claim from the untrusted net zone. A claim may not cross the floor, -//! and past a bound on what is believed unasked, the user decides. -//! `decide` is pure: it reads no clock, file or environment. +//! Wall-clock policy for zone 0 (docs/design/time.md). Only zone 0 sets the clock and it has no +//! network, so the time is a claim from the untrusted net zone: no claim crosses the floor, and +//! past the bound the user decides. /// Corrections below this many seconds are slewed, so the clock never runs backwards. pub const SLEW_BELOW_SECS: f64 = 1.0; -/// Offsets below this many seconds are ignored. pub const IGNORE_BELOW_SECS: f64 = 0.005; /// Seconds believed without asking, per claim and in total, either direction. pub const DEFAULT_BOUND_SECS: i64 = 3600; -/// One claim is considered per interval; others are refused unread, so a -/// hostile zone cannot flood the user with consent prompts. +/// One claim is considered per interval, so a hostile zone cannot flood the user with prompts. pub const CLAIM_INTERVAL_SECS: u64 = 600; -/// The net zone's claim: add `offset` seconds, the median of what `sources` -/// time servers answered. Untrusted, like the zone. +/// The net zone's untrusted claim: add `offset` seconds, the median of `sources` time servers. #[derive(Debug, Clone, Copy, PartialEq)] pub struct Claim { pub offset: f64, pub sources: u8, } -/// Parse ` `: optional sign, up to ten integer and six -/// fractional digits, and 1 to 16 sources. No exponent, infinity or NaN. +/// Parse ` `: plain decimal seconds (no exponent, inf or NaN), 1 to 16 sources. pub fn parse_claim(args: &str) -> Result { let mut it = args.split(' '); let (Some(secs), Some(sources), None) = (it.next(), it.next(), it.next()) else { @@ -54,8 +47,7 @@ pub struct Knowledge { pub floor: i64, /// What is believed without asking. pub bound: i64, - /// Unasked corrections since the clock was last anchored (by consent or - /// the floor). The bound applies to this sum, so small lies cannot add up. + /// Unasked corrections since the last consent or floor, bounded so small lies cannot add up. pub moved_unasked: f64, } @@ -65,13 +57,13 @@ pub enum Decision { Ignore, /// Apply gradually; the clock never steps backwards. Slew { offset: f64 }, - /// Set the clock to `to`. Step { to: f64 }, /// Past the bound: the user decides, shown both times. Ask { to: f64 }, Refuse(String), } +/// Pure: reads no clock, file or environment. pub fn decide(k: &Knowledge, c: &Claim) -> Decision { if !c.offset.is_finite() || !k.now.is_finite() { return Decision::Refuse("the offset is not a number".into()); @@ -99,8 +91,7 @@ pub fn decide(k: &Knowledge, c: &Claim) -> Decision { } } -/// `moved_unasked` once `d` is carried out: an unasked correction adds to it, -/// and consent resets it. +/// `moved_unasked` once `d` is carried out: unasked corrections add to it, consent resets it. pub fn moved_after(k: &Knowledge, c: &Claim, d: &Decision, consented: bool) -> f64 { match d { Decision::Slew { .. } | Decision::Step { .. } => k.moved_unasked + c.offset.abs(), @@ -109,14 +100,12 @@ pub fn moved_after(k: &Knowledge, c: &Claim, d: &Decision, consented: bool) -> f } } -/// The time to set a clock that reads below the floor (say, after a dead RTC -/// battery), or None. Needs no network: the system cannot predate its build. +/// The floor if `now` is below it, as after a dead RTC battery: no system predates its build. pub fn clamp_to_floor(now: f64, floor: i64) -> Option { (now < floor as f64).then_some(floor as f64) } -/// `built_at` from the image record, as written by `date -Iseconds`, in epoch -/// seconds. Anything else is None: no floor known, never zero. +/// The image record's `built_at` (from `date -Iseconds`) in epoch seconds, or None: never zero. pub fn floor_from_image_json(text: &str) -> Option { let at = text.find("\"built_at\"")?; let rest = &text[at + "\"built_at\"".len()..]; @@ -365,8 +354,7 @@ impl Outcome { } } -/// Decide on a claim, ask the user if needed, and carry it out. -/// `ask(now, proposed, sources)` is only called with a proposal at or above the floor. +/// Decide on a claim and carry it out, calling `ask(now, to, sources)` only at or above the floor. pub fn consider( clock: &mut dyn Clock, dir: &Path, diff --git a/docs/design/time.md b/docs/design/time.md index 4201b378..9f61ecb0 100644 --- a/docs/design/time.md +++ b/docs/design/time.md @@ -42,11 +42,10 @@ reported, so one liar among three is outvoted. The servers come from `/etc/kryptik/time.conf` on the verified root (`server HOST` or `pool HOST`, a pool giving up to four addresses; the public pool without the file). The zone reports an offset, not a time: it reads the same `CLOCK_REALTIME` as -zone 0, so nothing is lost to the delay before zone 0 acts. It is a script -rather than an NTP daemon because a daemon is a whole package for one number, -and the script can be tested against a real server on loopback. NTS is not -used: the image has no gnutls, and it would not authenticate a compromised -net zone. +zone 0, so nothing is lost to the delay before zone 0 acts. It is a script, +not an NTP daemon, because a daemon is a whole package for one number and a +script can be tested against a real server on loopback. NTS is not used: the +image has no gnutls, and it would not authenticate a compromised net zone. **The claim.** `time-offset `: a signed decimal with at most 10 integer and 6 fractional digits, and the number of servers (1 to 16) diff --git a/tools/image/integrity-test.sh b/tools/image/integrity-test.sh index fe6e995a..fa709aea 100755 --- a/tools/image/integrity-test.sh +++ b/tools/image/integrity-test.sh @@ -68,8 +68,7 @@ rc=$?; sleep 1; [[ -f "$PIDF" ]] && kill "$(cat "$PIDF")" 2>/dev/null [[ "$rc" -eq 0 ]] && green "installed system boots with Secure Boot enforced (SecureBoot=1 inside the guest)" || red "step 1 drive failed" T1="$(tr -d '\r' < "$LOG1")" grep -q 'KRYPTIK_SMOKE: verity_root=0 [0-9]* verity V' <<<"$T1" && green "dm-verity reports the root valid" || red "no valid verity root reported" -# Lockdown in confidentiality mode, and module signing both ways: stage 05's -# unsigned copy of a driver is refused with the kernel's reason, the signed one loads. +# Stage 05's unsigned copy of a driver is refused with the kernel's reason; the signed one loads. grep -q 'LOCKDOWN=confidentiality' <<<"$T1" && green "lockdown reports confidentiality" || red "lockdown is not in confidentiality mode" if grep -q 'UNSIGNED=refused' <<<"$T1" && grep -q 'Key was rejected by service\|Required key not available' <<<"$T1"; then green "an unsigned module is refused (Key was rejected by service)" @@ -136,15 +135,12 @@ rm -rf "$TMPK" # ----------------------------------------------------------------- step 3 -- step "step 3: a tampered root is refused by dm-verity before userspace" A_OFF=$(( $(part_start "$DISK" 2) * 512 )) -# Flip a byte of the ext4 superblock (the volume name, 1024 + 0x78): mounting -# the root reads it first, so dm-verity fails before userspace. A block that -# nothing reads at boot would go unnoticed. +# The superblock's volume name (1024 + 0x78): the root mount reads it first, so verity fails early. printf '\xa5' | dd of="$DISK" bs=1 seek=$(( A_OFF + 1024 + 0x78 )) conv=notrunc status=none cp "$ENROLLED" "$VARSF" smoke integ-p3 --no-media --disk "$DISK" --vars-file "$VARSF" --timeout 300 > /dev/null T3="$(boot_txt)" -# loglevel=4 hides the KERN_NOTICE banner, so the kernel's timestamped console -# lines are the proof it started. +# loglevel=4 hides the KERN_NOTICE banner; timestamped console lines show the kernel started. grep -qE '^\[ *[0-9]+\.[0-9]+\] |Linux version' <<<"$T3" && green "the (untampered) kernel still starts" || red "the kernel did not start after the root tamper" # The kernel's own message, not the command line's "panic_on_corruption". grep -qE 'device-mapper: verity:.*(corrupt|mismatch|error)|dm-verity device corrupted' <<<"$T3" && green "dm-verity named the corruption" || red "no dm-verity corruption report" @@ -193,8 +189,7 @@ 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 -# nothing written through a .new left behind. +# Recovery's records on the ESP, read from the host: whole, and no .new file left behind. dd if="$DISK" of="$ESPIMG" bs=1M iflag=skip_bytes,count_bytes skip="$ESP_OFF" count=$((512*1024*1024)) status=none MVER="$(basename "$USB")"; MVER="${MVER#kryptik-}"; MVER="${MVER%-usb.img}" CSLOT="$(mtype -i "$ESPIMG" ::/kryptik/committed-slot 2>/dev/null)"; CVER="$(mtype -i "$ESPIMG" ::/kryptik/version-a 2>/dev/null)" @@ -240,8 +235,7 @@ uid_base = 1310720 border_color = "#000001" EOF printf 'kernel.kptr_restrict = 0\n' > "$up/sysctl.d/99-evil.conf" - # Also a preload library and a udev rule run as root, both pointing at the - # state partition, and one allowed change (a subuid line) as a control. + # A preload library and a root udev rule on the state partition; a subuid line is the control. mkdir -p "$up/udev/rules.d" "$MNT/lib/kryptik" printf '/var/lib/kryptik/evil.so\n' > "$up/ld.so.preload" printf 'ACTION=="add", RUN+="/var/lib/kryptik/evil.sh"\n' > "$up/udev/rules.d/99-evil.rules" @@ -269,10 +263,7 @@ else fi cp "$ENROLLED" "$VARSF" start_vm integ-p5 "${EXTRA[@]}"; LOG5="$LOG" -# The planted kryptik/ directory must be quarantined and gone from /etc, and -# the updater's anchor on the verified root must still name the release key. -# The root has an ld.so.preload of its own (the allocator), so the planted -# library is looked for by name. +# The root has its own ld.so.preload (the allocator), so the planted library is looked for by name. python3 "$DRV" --serial "$SER" --timeout 300 \ "expect:KRYPTIK_SMOKE: END" "login:${TUSER}:${TPASS}" \ "grab:overlay:ls /etc/kryptik/ /var/lib/kryptik/etc/quarantine/ 2>&1 | head -12" \ diff --git a/tools/tests/installer.sh b/tools/tests/installer.sh index 2aeb1643..a1584c91 100755 --- a/tools/tests/installer.sh +++ b/tools/tests/installer.sh @@ -84,8 +84,7 @@ grep -q 'testctl_get install_replace' "$RUNNER" \ echo echo "-- the runner reports the installer's exit status, not something else's" -# After `cmd | sed`, $? is sed's. Match a call at the start of a line, not the -# word: comments and kryptik-install.json are not calls. +# After `cmd | sed`, $? is sed's. A call starts its line, unlike comments or kryptik-install.json. piped="$(grep -nE '^[[:space:]]*(/usr/sbin/)?kryptik-install[^|#]*\|' "$RUNNER" || true)" if [[ -n "$piped" ]]; then red "the installer is still piped; rc would be the pipeline's last element"