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
2 changes: 0 additions & 2 deletions build/recipes/boot-check.sh
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down
36 changes: 12 additions & 24 deletions compartments/kryptikd/src/time.rs
Original file line number Diff line number Diff line change
@@ -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 `<seconds> <sources>`: optional sign, up to ten integer and six
/// fractional digits, and 1 to 16 sources. No exponent, infinity or NaN.
/// Parse `<seconds> <sources>`: plain decimal seconds (no exponent, inf or NaN), 1 to 16 sources.
pub fn parse_claim(args: &str) -> Result<Claim, String> {
let mut it = args.split(' ');
let (Some(secs), Some(sources), None) = (it.next(), it.next(), it.next()) else {
Expand Down Expand Up @@ -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,
}

Expand All @@ -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());
Expand Down Expand Up @@ -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(),
Expand All @@ -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<f64> {
(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<i64> {
let at = text.find("\"built_at\"")?;
let rest = &text[at + "\"built_at\"".len()..];
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 4 additions & 5 deletions docs/design/time.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <seconds> <sources>`: a signed decimal with at
most 10 integer and 6 fractional digits, and the number of servers (1 to 16)
Expand Down
21 changes: 6 additions & 15 deletions tools/image/integrity-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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)"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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" \
Expand Down
3 changes: 1 addition & 2 deletions tools/tests/installer.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading