From 2de61bece7c56fca75fa8c4128fed974f5c018e3 Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 9 Oct 2026 13:04:16 -0700 Subject: [PATCH 001/213] The time namespace's stated limit names every source of the machine's clock a zone reads The threat model and the net zone surface note named two ways past a zone's own clocks: the offsets in /proc/self/timens_offsets and the scheduler's clock in /proc//sched. A zone also reads the machine's time data straight from the vDSO, whose next page a time namespace maps readable beside the page of its offsets (lib/vdso/datastore.c, vvar_fault), and the CPU's timestamp counter, which rdtsc reads in user space. Both now say so, as sources code that looks for them reads the machine's uptime from. --- docs/design/net-zone-surface.md | 17 +++++++++++------ docs/threat-model.md | 15 ++++++++------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/docs/design/net-zone-surface.md b/docs/design/net-zone-surface.md index 1732945a..8805ccb5 100644 --- a/docs/design/net-zone-surface.md +++ b/docs/design/net-zone-surface.md @@ -129,11 +129,16 @@ themselves are shown: every process may read `/proc//timens_offsets`, which the kernel keeps for checkpoint and restore. So is the scheduler's clock, which `/proc//sched` prints untranslated (`se.exec_start`, `kernel/sched/debug.c`; 6.18 builds the file without a debug option, -`fs/proc/base.c:3330`). No mount covers a file under every pid, so a program -that looks for either works out the machine's clocks. The namespace keeps the -shared uptime and boot time out of what crash reports and telemetry send, as -the boot ID does; it does not hide them from a zone set on linking itself to -another, which the idle time would allow anyway. +`fs/proc/base.c:3330`). No mount covers a file under every pid. Nor are the +clocks' own sources withheld: a task in a time namespace gets its offsets in +one page of the vDSO's data and the machine's time data in the next, both +readable, since the vDSO reads both to answer it (`lib/vdso/datastore.c`, +`vvar_fault`), and the CPU's timestamp counter, which `rdtsc` reads in user +space, counts on from the CPU's reset whatever namespace reads it. So a +program that looks for any of these works out the machine's clocks. The +namespace keeps the shared uptime and boot time out of what crash reports and +telemetry send, as the boot ID does; it does not hide them from a zone set on +linking itself to another, which the idle time would allow anyway. A zone's programs read the compositor's input and frame times against their own clock, offset from it; the protocol gives those times no base to compare @@ -150,7 +155,7 @@ frame callbacks while the zone's clock stands apart from the compositor's | | genl family filter | wpa_supplicant privsep | time namespace | | --- | --- | --- | --- | | closes | ethtool's setters, other families' namespace-admin commands | the zone's root for a flaw in Wi-Fi parsing | the shared boot and monotonic clocks, for what reads clocks | -| leaves | nf_tables, packet sockets, nl80211 | `wpa_priv` as root; the kernel's own 802.11 parsing | idle time, `CLOCK_REALTIME`, the offsets in `/proc/self/timens_offsets`, the scheduler's clock in `/proc//sched` | +| leaves | nf_tables, packet sockets, nl80211 | `wpa_priv` as root; the kernel's own 802.11 parsing | idle time, `CLOCK_REALTIME`, the offsets in `/proc/self/timens_offsets`, the scheduler's clock in `/proc//sched`, the vDSO's own time data, the CPU's timestamp counter | | needs | BPF and a BPF LSM, or a kernel patch | a second build, a mapped id, a trial of WPA3 | a flag and an offsets write in kryptikd (built) | | a suite can show it | yes | yes, over hwsim | yes | diff --git a/docs/threat-model.md b/docs/threat-model.md index 1cd62562..e80e58a0 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -29,13 +29,14 @@ random point each time the zone starts, so the uptime and boot time that crash reports and telemetry send match no other zone's. Still shown: the per-CPU packet counts in `/proc/net/softnet_stat`, as `/proc/net` follows each process; the idle time of all CPUs summed, to a hundredth of a second, -in `/proc/uptime`, which `ps` and `uptime` read; the offsets that move the -zone's clocks, in `/proc/self/timens_offsets`, which the kernel shows every -process for checkpoint and restore, and the scheduler's clock in -`/proc//sched`, which no time namespace moves, so code that looks for -either works out the machine's uptime; the free memory in `/proc/meminfo`, -coarse, which programs need; and the idle counts of a CPU brought online -after the zone started. +in `/proc/uptime`, which `ps` and `uptime` read; the machine's uptime, to +code that looks past the zone's own clocks: the offsets in +`/proc/self/timens_offsets`, which the kernel shows every process for +checkpoint and restore, the scheduler's clock in `/proc//sched`, the +vDSO's data page, which a time namespace maps beside its own, and the CPU's +timestamp counter, one instruction away, each give it; the free memory in +`/proc/meminfo`, coarse, which programs need; and the idle counts of a CPU +brought online after the zone started. Escaping needs a kernel bug ([kernel local privilege escalation](#kernel-local-privilege-escalation)). From e1fb6a05ed9236c3b5ae2ae80a066f43bc175d26 Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 9 Oct 2026 13:06:33 -0700 Subject: [PATCH 002/213] The net zone surface table names the machine's time data in the vDSO, as its prose does --- docs/design/net-zone-surface.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/design/net-zone-surface.md b/docs/design/net-zone-surface.md index 8805ccb5..21dca09f 100644 --- a/docs/design/net-zone-surface.md +++ b/docs/design/net-zone-surface.md @@ -155,7 +155,7 @@ frame callbacks while the zone's clock stands apart from the compositor's | | genl family filter | wpa_supplicant privsep | time namespace | | --- | --- | --- | --- | | closes | ethtool's setters, other families' namespace-admin commands | the zone's root for a flaw in Wi-Fi parsing | the shared boot and monotonic clocks, for what reads clocks | -| leaves | nf_tables, packet sockets, nl80211 | `wpa_priv` as root; the kernel's own 802.11 parsing | idle time, `CLOCK_REALTIME`, the offsets in `/proc/self/timens_offsets`, the scheduler's clock in `/proc//sched`, the vDSO's own time data, the CPU's timestamp counter | +| leaves | nf_tables, packet sockets, nl80211 | `wpa_priv` as root; the kernel's own 802.11 parsing | idle time, `CLOCK_REALTIME`, the offsets in `/proc/self/timens_offsets`, the scheduler's clock in `/proc//sched`, the machine's time data in the vDSO, the CPU's timestamp counter | | needs | BPF and a BPF LSM, or a kernel patch | a second build, a mapped id, a trial of WPA3 | a flag and an offsets write in kryptikd (built) | | a suite can show it | yes | yes, over hwsim | yes | From 6ecb7a6c61d2c26c62739be6a01ac4facd12c232 Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 9 Oct 2026 13:06:45 -0700 Subject: [PATCH 003/213] The status table names the medium's no-network check and the clock-behind notice --- docs/status.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/status.md b/docs/status.md index 067eb25e..f59d839f 100644 --- a/docs/status.md +++ b/docs/status.md @@ -20,11 +20,11 @@ that ship, under QEMU with OVMF firmware. | Stage 05: hardened kernel | **tested**: the stage refuses a config that drops a fragment line, turns on an option a fragment sets off, or that kernel-hardening-checker faults beyond the accepted list; every VM suite boots the kernel (EFI stub, compiled-in command line with `CMDLINE_OVERRIDE`), `make integrity-test` its dm-init verity root, and `make zones-test` Landlock, seccomp, cgroup v2 and nftables' flow count on it. The count, which holds each routed zone to a share of the net zone's flow table, needs `NFT_CONNLIMIT` and so `NETFILTER_ADVANCED`, whose SCTP and UDP-Lite trackers the fragment keeps off | | Stage 06: install media and release payloads | **tested**: the firmware, install, integrity and update suites boot its USB image and ISO, whose kernels are signed with the build's Secure Boot key; the stage fails the build on any setuid bit or file capability its allowlists do not justify (`tools/tests/audit-setuid.sh`) and, in a development build given the statement key, checks that the image's trust anchor refuses a statement signed by the release key | | Firmware boot of the media | **tested**: `make media-smoke-usb` / `media-smoke-iso`, firmware discovery only (no `-kernel`, `-initrd`, `-append` or host filesystem; acceptance reads the recorded QEMU commands back) | -| Installation and the state partition | **tested**: `make install-test` (install, boot alone, reboot, cold boot, refusals including an injected I/O error), `make state-test` (first-boot setup answered at the console, a cloned disk, ambiguous labels, a corrupt or missing state partition: the system boots degraded and says so), the medium's console suite `tools/image/medium-shell-test.sh` (typed at the medium's root shell: a dry run that writes nothing, ERASE and the passphrase asked before the first write, the refusal of a partition and of a disk with a partition mounted, used as swap or held open by device-mapper, then the user guide's install, state header backup and recovery as it gives them), `make keyboard-test` (the layout a firmware variable names loads before the passphrase is asked; a name this release lacks is said, and the passphrase asked under `us`) | +| Installation and the state partition | **tested**: `make install-test` (install, boot alone, reboot, cold boot, refusals including an injected I/O error), `make state-test` (first-boot setup answered at the console, a cloned disk, ambiguous labels, a corrupt or missing state partition: the system boots degraded and says so), the medium's console suite `tools/image/medium-shell-test.sh` (with a NIC attached, no net zone starts and the NIC stays down in zone 0; typed at the medium's root shell: a dry run that writes nothing, ERASE and the passphrase asked before the first write, the refusal of a partition and of a disk with a partition mounted, used as swap or held open by device-mapper, then the user guide's install, state header backup and recovery as it gives them), `make keyboard-test` (the layout a firmware variable names loads before the passphrase is asked; a name this release lacks is said, and the passphrase asked under `us`) | | Boot integrity | **tested**: `make media-smoke-secureboot`, `make media-refused-foreign-keys` (Microsoft keys refuse the medium), `make integrity-test` (a foreign-signed boot file refused, a tampered root refused by dm-verity, recovery from the medium with state intact) | | Zones, network and encrypted storage on the installed kernel | **tested**: `make zones-test`, running `zones-check.sh` (the net zone associating over a `mac80211_hwsim` radio; its DHCP client split into an unprivileged user's processes, with no capability in an empty root, and a helper that stays its root; its DNS server as nobody with only `CAP_NET_BIND_SERVICE`, and its clock query and update fetcher with no capability; its CPU limit read back, and each routed zone's share of its flow table measured; a NIC it renamed, readdressed or gave altnames, and a radio it left stations on, reaching the next net zone reset; the installed root's setuid bits and file capabilities against the allowlists, and every sysctl read back) and the compartment suites as root on the Kryptik kernel | | The zoned desktop | **tested**: `make gui-test`, covering what a zone's client is offered through its proxy, zone borders and title prefixes windowed and fullscreen, a zone's window getting the keyboard only when the user picks it, per-zone clipboards, the clipboard-move gesture and file transfers answered on the trusted chrome, and a second monitor plugged in and pulled out under the running session | -| A/B updates and recovery | **tested**: `make update-test`, covering apply, trial boot, commit, rollback, refusals (wrong key, modified image, truncated kernel, unlisted file, downgrade, concurrent run, full disk, a trust anchor only root can read, since the signature is checked as nobody), interruptions and a broken trial that falls back; and the update channel: the net zone fetches the statement and the release, zone 0 checks them as nobody, stages, applies and commits it, and a statement more than 30 days old is reported by `kryptik update status` and above the login prompt; an install that has heard none, in the login notice (and in status, by the kryptikd unit tests). The launcher says both too, each set up and read by `make gui-test`. The ESP's records reach a terminal or `update.log` only in the shape Kryptik writes them (`tools/tests/esp-records.sh`) | +| A/B updates and recovery | **tested**: `make update-test`, covering apply, trial boot, commit, rollback, refusals (wrong key, modified image, truncated kernel, unlisted file, downgrade, concurrent run, full disk, a trust anchor only root can read, since the signature is checked as nobody), interruptions and a broken trial that falls back; and the update channel: the net zone fetches the statement and the release, zone 0 checks them as nobody, stages, applies and commits it, and a statement more than 30 days old is reported by `kryptik update status` and above the login prompt; an install that has heard none, in the login notice (and in status, by the kryptikd unit tests); and a clock set back more than a day before the newest statement, which would refuse every newer one, in status and the login notice alike. The launcher says the first two too, each set up and read by `make gui-test`. The ESP's records reach a terminal or `update.log` only in the shape Kryptik writes them (`tools/tests/esp-records.sh`) | | Zone definitions, `kryptikd run`, lifecycle, limits, ephemeral storage | **tested**: kryptikd unit tests, `compartments/tests/` | | Per-zone LUKS2 volumes | **tested**: their lifecycle as root in `compartments/tests/launcher.sh` (init, an encrypted start, destroy refused while the zone runs), on a developer host and, in `zones-test`, on the target kernel beside the guest's own volume checks | | The broker: consented file transfer, per-zone clipboards, the zone 0 clipboard gesture | **tested**: kryptikd unit tests, `compartments/tests/serve.sh`; on the target in `gui-test` | From 17936f2b6634b4e1b9376e706cdf2acd008b9a14 Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 9 Oct 2026 13:06:57 -0700 Subject: [PATCH 004/213] The roadmap's update channel names the notice for a clock behind the newest statement --- docs/roadmap.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index e39d2a29..207f612a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -80,7 +80,10 @@ work in zones from a terminal and a text browser, and keep it up to date. launcher and `kryptik update status` say so: the update suite moves the clock to read the prompt for both and status for an old one, the desktop suite sets up both of the launcher's lines, and kryptikd's - unit tests hold status for an install that heard none. + unit tests hold status for an install that heard none. A clock that + reads more than a day before the newest statement would refuse every + newer one, so the prompt and status say that too, and the update + suite moves the clock 3 days back to read both. - [x] **An encrypted state partition** ([design](design/state-encryption.md)). The install suite finds it is LUKS, asked for at boot and mounted on `/var`; the state suite boots degraded and says why when it is From 32bd7c0e53c68dbfa219136bb0166d0a7c94962e Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 9 Oct 2026 13:08:30 -0700 Subject: [PATCH 005/213] The threat model says a clock agreed back past the newest statement is reported --- docs/threat-model.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/threat-model.md b/docs/threat-model.md index 1cd62562..593afda6 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -85,7 +85,10 @@ It can still: install, in `kryptik update status`, above the login prompt and in the launcher; - claim the clock is off, which zone 0 applies up to an hour and beyond that - only when the user agrees. + only when the user agrees. A clock agreed back more than a day before the + newest statement would refuse every newer one and keep the 30-day report + from coming, so zone 0 says that instead, in `kryptik update status` and + above the login prompt. What limits it, each with its check, the zones suite's on the installed system unless named: From 9e6331c3003964eb5aaa1402217a814dde2efb53 Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 9 Oct 2026 13:13:18 -0700 Subject: [PATCH 006/213] The net zone's supplicant takes no DPP frames, which any radio in range could send it unassociated wpa_supplicant was built with CONFIG_DPP. Built so, a station registers for DPP's public action frames and for GAS's (driver_nl80211.c, nl80211_mgmt_subscribe_non_ap), and wpas_dpp_rx_action parses every DPP frame's attributes before it finds no provisioning under way. Any radio in range can send those frames to the net zone's address unassociated, and nothing in Kryptik provisions networks by DPP: kryptik wifi add takes an SSID and a passphrase. The recipe now builds without DPP. The zones suite checks that the image's supplicant carries none of DPP's control commands, which a DPP build holds. --- build/guest-tests/zones-check.sh | 7 +++++++ build/recipes/wpa-supplicant.sh | 3 ++- docs/design/net-zone-surface.md | 6 +++--- docs/design/net-zone.md | 5 +++++ tools/image/zones-test.sh | 1 + 5 files changed, 18 insertions(+), 4 deletions(-) diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index f61e41a5..cde9079f 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -724,6 +724,13 @@ if [[ -n "$STA_IF" && -n "$AP_IF" ]]; then else fail "wifi-module" "$(tr '\n' ' ' < "$LOG/hwsim.err") radios: $(ls /sys/class/ieee80211 2>/dev/null | tr '\n' ' ')" fi +# Built with DPP, the supplicant would take DPP and GAS action frames from any radio in range. +dpp="$(grep -ac 'DPP_BOOTSTRAP_GEN\|DPP_LISTEN' /usr/sbin/wpa_supplicant 2>/dev/null)" +if [[ -x /usr/sbin/wpa_supplicant && "$dpp" = 0 ]]; then + pass "wifi-no-dpp" "/usr/sbin/wpa_supplicant is built without DPP, so it registers for no DPP or GAS action frames" +else + fail "wifi-no-dpp" "/usr/sbin/wpa_supplicant holds ${dpp:-no} lines naming DPP's control commands" +fi ap_up=0 if [[ -n "$AP_IF" ]]; then unshare -n sleep 900 > /dev/null 2>&1 & AP_HOLD=$! diff --git a/build/recipes/wpa-supplicant.sh b/build/recipes/wpa-supplicant.sh index 37a6f30a..5df5d99f 100644 --- a/build/recipes/wpa-supplicant.sh +++ b/build/recipes/wpa-supplicant.sh @@ -1,6 +1,8 @@ #!/usr/bin/env bash # CONFIG_AP is for the zones suite, whose hwsim access point is this binary in its own namespace. +# No DPP: with it the station takes DPP and GAS action frames from any radio in range, +# unassociated, for a way of joining networks nothing here offers. s_wpa_supplicant() { local src; src="$(unpack "wpa_supplicant-${V_WPA_SUPPLICANT}.tar.gz" "wpa_supplicant-${V_WPA_SUPPLICANT}")" cd "$src/wpa_supplicant" @@ -15,7 +17,6 @@ CONFIG_IEEE80211W=y CONFIG_IEEE80211R=y CONFIG_SAE=y CONFIG_OWE=y -CONFIG_DPP=y CONFIG_EAP_TLS=y CONFIG_EAP_PEAP=y CONFIG_EAP_TTLS=y diff --git a/docs/design/net-zone-surface.md b/docs/design/net-zone-surface.md index 1732945a..de62caa7 100644 --- a/docs/design/net-zone-surface.md +++ b/docs/design/net-zone-surface.md @@ -84,12 +84,12 @@ kernel. - **What works.** The privsep wrapper (`src/drivers/driver_privsep.c`) implements scan, keys, authentication, association and a few events, and - the README warns that it lags the full driver interface. Whether SAE, OWE - and DPP, which the recipe builds (`wpa-supplicant.sh:16-18`), work through + the README warns that it lags the full driver interface. Whether SAE and + OWE, which the recipe builds (`wpa-supplicant.sh:18-19`), work through it is for the hwsim suite to show. - **The build.** With `CONFIG_PRIVSEP`, wpa_supplicant drives only the privsep wrapper, so the zones suite's access point, the same binary in AP mode - (`CONFIG_AP`, `wpa-supplicant.sh:12`), needs a build of its own. + (`CONFIG_AP`, `wpa-supplicant.sh:14`), needs a build of its own. - **The zone.** A fourth mapped id, beside root, dhcpcd's 100 and nobody; `wpa_priv`'s socket directory; `netzone-init.sh` starting `wpa_priv` for each radio, then wpa_supplicant as that user; and its control socket, which diff --git a/docs/design/net-zone.md b/docs/design/net-zone.md index ede36604..efb27c86 100755 --- a/docs/design/net-zone.md +++ b/docs/design/net-zone.md @@ -272,6 +272,11 @@ configured the zone keeps its IPv4 path and the launcher says so. lease once there is a carrier. `wifi=` is `none`, `unconfigured` (no credentials), `connecting`, or the associated SSID; `READY` does not wait for association. +- **It takes no provisioning frames.** `wpa_supplicant` is built without + DPP: with it a station registers for DPP's and GAS's public action frames + (`nl80211_mgmt_subscribe_non_ap` in hostap's `driver_nl80211.c`), which any + radio in range can send it unassociated, for a way of joining networks + Kryptik does not offer. - **The net zone knows the passphrases.** They live in zone 0 at `/var/lib/kryptik/wifi/wpa_supplicant.conf`, written only by `kryptikd serve` for `kryptik wifi add|forget `. The passphrase is read on the diff --git a/tools/image/zones-test.sh b/tools/image/zones-test.sh index 2acd8181..4cb06fd4 100755 --- a/tools/image/zones-test.sh +++ b/tools/image/zones-test.sh @@ -66,6 +66,7 @@ grep 'ZT FAIL' <<<"$T2" | sed 's/^/ /' # The key verdicts one by one, so a pass is not a single line. for name in kernel-support policies net-ready net-dns dhcpcd-separated dnsmasq-unprivileged zone0-nic zone0-no-route zone0-offline routed-egress routed-ping routed-ping6 routed-dns net-lease-names-resolver dns-follows-lease dns-after-reload routed-ipv6-noglobal zone-separation volume-hidden home-hidden fail-closed net-restart-ready reattach-after-restart uplink-returned uplink-retaken reattach-egress uplink-refused wifi-beyond routed-restart-path uplink-address-refused zone-flows-capped net-cpu-max-set \ wifi-module wifi-ap wifi-add wifi-associated wifi-lease wifi-egress wifi-forget \ + wifi-no-dpp \ uplink-renamed-plain uplink-state-reset \ radio-recarried \ zone-clocks-own \ From 1677e3b6b2e3deb65a43da5842bdb43bde547c43 Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 9 Oct 2026 13:43:50 -0700 Subject: [PATCH 007/213] A saved Wi-Fi network is joined by WPA2 or WPA3 as its passphrase allows, never by 802.1X, with protected management frames where offered The network blocks kryptikd writes named no key management, so wpa_supplicant took its default, WPA-PSK and WPA-EAP: an access point offering 802.1X under a saved SSID was joined with EAP, a WPA3 network was never joined with SAE, and management frames went unprotected, so spoofed deauthentication always worked. Each block now names what was saved allows: a passphrase WPA-PSK, WPA-PSK-SHA256 and SAE, a raw key the first two (SAE needs the passphrase), and ieee80211w=1, protection wherever the access point offers it. The parser takes these lines only as kryptikd writes them for the block's psk, or a block from an older kryptikd with neither, and the net zone's start rewrites such a file once in today's form. The zones suite keeps its plain WPA2 access point, where the station joins by its pre-shared key without protection, then offers the same SSID with SAE alone and protection required, which the old file never joins. --- build/guest-tests/zones-check.sh | 42 +++++++++++++++ compartments/kryptikd/src/spawn.rs | 6 +++ compartments/kryptikd/src/wifi.rs | 69 +++++++++++++++++++++---- compartments/kryptikd/src/wifi/tests.rs | 51 +++++++++++++++++- docs/design/net-zone.md | 8 +++ tools/image/zones-test.sh | 1 + 6 files changed, 165 insertions(+), 12 deletions(-) diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index f61e41a5..32c0922b 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -834,6 +834,48 @@ if [[ "$far" -eq 0 && "$near" -ne 0 ]]; then else fail "wifi-beyond" "personal (init ${ppid:-none}): $AP_FAR rc=$far, $AP_ADDR rc=$near; $(netzone_said 'nftables: zones go out' | tail -1); routes: $(nsenter -t "$net_init" -n ip -4 route 2>/dev/null | tr '\n' ';')" fi +# The saved passphrase joins WPA2 by its pre-shared key, unprotected where the access point +# offers no protected management frames, and WPA3 by SAE with them: the same SSID, SAE only. +sta_keys() { ap_wpa all_sta | grep -E '^(AKMSuiteSelector|flags)=' | tr '\n' ' '; } +wpa2_keys="$(sta_keys)" +if [[ "$wpa2_keys" == *"AKMSuiteSelector=00-0f-ac-2 "* && "$wpa2_keys" != *"[MFP]"* ]]; then + pass "wifi-wpa2-plain" "the WPA2 access point lists the station joined by its pre-shared key, without protected management frames: ${wpa2_keys}" +else + fail "wifi-wpa2-plain" "the station at the WPA2 access point: ${wpa2_keys:-none}" +fi +sae_up=0 +if [[ "$ap_up" = 1 ]]; then + ap_pid="$(cat /run/zt-ap-wpa.pid 2>/dev/null)" + [[ -n "$ap_pid" ]] && kill "$ap_pid" 2>/dev/null + for _ in $(seq 1 20); do [[ -n "$ap_pid" ]] && kill -0 "$ap_pid" 2>/dev/null || break; sleep 0.5; done + cat > /root/zt/ap-sae.conf <> "$LOG/ap.err" 2>&1 + for _ in $(seq 1 30); do ap_wpa status | grep -q '^wpa_state=COMPLETED' && { sae_up=1; break; }; sleep 1; done +fi +sae_keys="" +for _ in $(seq 1 90); do + sae_keys="$(sta_keys)" + [[ "$sae_up" = 1 && "$sae_keys" == *"AKMSuiteSelector=00-0f-ac-8 "* ]] && break + sleep 1 +done +if [[ "$sae_up" = 1 && "$sae_keys" == *"AKMSuiteSelector=00-0f-ac-8 "* && "$sae_keys" == *"[MFP]"* ]]; then + pass "wifi-sae" "offered SAE alone with protection required, the same SSID took the saved passphrase by SAE, with protected management frames: ${sae_keys}" +else + fail "wifi-sae" "access point up: ${sae_up}; the station at it: ${sae_keys:-none}; $(tail -3 "$LOG/ap-sae-wpa.log" 2>/dev/null | tr '\n' ' ')" +fi # Back to the wire: the radios go first, so the net zone the forget restarts finds none. for f in /run/zt-ap-dnsmasq.pid /run/zt-ap-wpa.pid; do p="$(cat "$f" 2>/dev/null)"; [[ -n "$p" ]] && kill "$p" 2>/dev/null; done [[ -n "$AP_HOLD" ]] && kill "$AP_HOLD" 2>/dev/null diff --git a/compartments/kryptikd/src/spawn.rs b/compartments/kryptikd/src/spawn.rs index 75495edf..a714dd87 100644 --- a/compartments/kryptikd/src/spawn.rs +++ b/compartments/kryptikd/src/spawn.rs @@ -797,6 +797,12 @@ pub fn run_in_zone( } else { opts.wifi_dir.clone() }; + // A file an older kryptikd wrote leaves key management to wpa_supplicant's default. + match crate::wifi::refresh(&dir, id.privileged.then_some((id.uid, id.gid))) { + Ok(true) => eprintln!("kryptikd: zone {:?}: the Wi-Fi file now names each network's key management", zone.name), + Ok(false) => {} + Err(e) => eprintln!("kryptikd: zone {:?}: the Wi-Fi file was not brought up to date: {e}", zone.name), + } Some(crate::wifi::conf_path(&dir).display().to_string()) } else { None diff --git a/compartments/kryptikd/src/wifi.rs b/compartments/kryptikd/src/wifi.rs index 9cd20bdb..7bef5b34 100644 --- a/compartments/kryptikd/src/wifi.rs +++ b/compartments/kryptikd/src/wifi.rs @@ -18,6 +18,21 @@ pub const SERVICE_DIR: &str = "/run/service/net-zone"; const HEADER: &str = "ctrl_interface=/run/wpa_supplicant\nupdate_config=0\n"; +/* How a saved network may be joined, from what was saved: a passphrase by WPA2's pre-shared + * key or WPA3's SAE, a raw key by the former alone (SAE needs the passphrase), and neither by + * 802.1X, so no access point's beacon chooses EAP for a saved SSID. Unnamed, wpa_supplicant + * takes WPA-PSK and WPA-EAP and never SAE. Management frames are protected where offered. */ +const KEY_MGMT_PASSPHRASE: &str = "\tkey_mgmt=WPA-PSK WPA-PSK-SHA256 SAE"; +const KEY_MGMT_KEY: &str = "\tkey_mgmt=WPA-PSK WPA-PSK-SHA256"; +const PMF: &str = "\tieee80211w=1"; + +fn key_mgmt(psk: &Psk) -> &'static str { + match psk { + Psk::Passphrase(_) => KEY_MGMT_PASSPHRASE, + Psk::Hex(_) => KEY_MGMT_KEY, + } +} + pub fn conf_path(dir: &Path) -> PathBuf { dir.join(FILE_NAME) } @@ -86,7 +101,7 @@ pub fn render(nets: &[Network]) -> String { let mut out = String::from(HEADER); for n in nets { out.push_str("\nnetwork={\n"); - out.push_str(&format!("\tssid=\"{}\"\n", n.ssid)); + out.push_str(&format!("\tssid=\"{}\"\n{}\n{PMF}\n", n.ssid, key_mgmt(&n.psk))); match &n.psk { Psk::Passphrase(p) => out.push_str(&format!("\tpsk=\"{p}\"\n")), Psk::Hex(h) => out.push_str(&format!("\tpsk={h}\n")), @@ -100,7 +115,8 @@ pub fn render(nets: &[Network]) -> String { /// unchecked. Errors name the line, never its content. pub fn parse(text: &str) -> Result, String> { let mut nets = Vec::new(); - let mut block: Option<(Option, Option)> = None; + // ssid, psk, key_mgmt, ieee80211w: a file an older kryptikd wrote names the last two not at all. + let mut block: Option<(Option, Option, Option<&str>, bool)> = None; for (i, line) in text.lines().enumerate() { let n = i + 1; match &mut block { @@ -109,17 +125,24 @@ pub fn parse(text: &str) -> Result, String> { continue; } if line == "network={" { - block = Some((None, None)); + block = Some((None, None, None, false)); continue; } return Err(format!( "line {n} is not one kryptikd writes; refusing to read a file something else edited" )); } - Some((ssid, psk)) => { + Some((ssid, psk, km, pmf)) => { if line == "}" { match (ssid.take(), psk.take()) { - (Some(s), Some(p)) => nets.push(Network { ssid: s, psk: p }), + (Some(s), Some(p)) => { + match (km.take(), *pmf) { + (None, false) => {} + (Some(k), true) if k == key_mgmt(&p) => {} + _ => return Err(format!("line {n}: a block whose key management is not what kryptikd writes for its psk")), + } + nets.push(Network { ssid: s, psk: p }); + } _ => return Err(format!("line {n}: a network block without both an ssid and a psk")), } block = None; @@ -128,6 +151,14 @@ pub fn parse(text: &str) -> Result, String> { if ssid.replace(v.to_string()).is_some() { return Err(format!("line {n}: a second ssid in one block")); } + } else if line == KEY_MGMT_PASSPHRASE || line == KEY_MGMT_KEY { + if km.replace(line).is_some() { + return Err(format!("line {n}: a second key_mgmt in one block")); + } + } else if line == PMF { + if std::mem::replace(pmf, true) { + return Err(format!("line {n}: a second ieee80211w in one block")); + } } else if let Some(v) = line.strip_prefix("\tpsk=") { let quoted = v.strip_prefix('"').and_then(|r| r.strip_suffix('"')); let value = match (quoted, check_passphrase(quoted.unwrap_or(v))) { @@ -153,8 +184,8 @@ pub fn parse(text: &str) -> Result, String> { Ok(nets) } -/// The configured networks, or none when there is no file yet. -pub fn load(dir: &Path) -> Result, String> { +/// The file's text, or none when there is no file yet. +fn read_text(dir: &Path) -> Result, String> { let path = conf_path(dir); let mut f = match fs::OpenOptions::new() .read(true) @@ -162,13 +193,31 @@ pub fn load(dir: &Path) -> Result, String> { .open(&path) { Ok(f) => f, - Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None), Err(e) => return Err(format!("{}: {e}", path.display())), }; let mut bytes = Vec::new(); f.read_to_end(&mut bytes).map_err(|e| format!("{}: {e}", path.display()))?; - let text = String::from_utf8(bytes).map_err(|_| format!("{}: not UTF-8; refusing to read it", path.display()))?; - parse(&text).map_err(|e| format!("{}: {e}", path.display())) + String::from_utf8(bytes).map(Some).map_err(|_| format!("{}: not UTF-8; refusing to read it", path.display())) +} + +/// The configured networks, or none when there is no file yet. +pub fn load(dir: &Path) -> Result, String> { + let Some(text) = read_text(dir)? else { return Ok(Vec::new()) }; + parse(&text).map_err(|e| format!("{}: {e}", conf_path(dir).display())) +} + +/// Rewrite in today's form a file an older kryptikd wrote, so the net zone reads each network's +/// key management; true when it was rewritten. A current, absent or foreign file is left alone. +pub fn refresh(dir: &Path, owner: Option<(u32, u32)>) -> Result { + let Some(text) = read_text(dir)? else { return Ok(false) }; + let nets = parse(&text).map_err(|e| format!("{}: {e}", conf_path(dir).display()))?; + let current = render(&nets); + if current == text { + return Ok(false); + } + write_atomic(dir, ¤t, owner)?; + Ok(true) } /// The SSIDs, in file order. Never a passphrase. diff --git a/compartments/kryptikd/src/wifi/tests.rs b/compartments/kryptikd/src/wifi/tests.rs index 5de0c89a..a4861f63 100644 --- a/compartments/kryptikd/src/wifi/tests.rs +++ b/compartments/kryptikd/src/wifi/tests.rs @@ -18,8 +18,8 @@ fn add_list_forget_round_trip() { assert_eq!( text, "ctrl_interface=/run/wpa_supplicant\nupdate_config=0\n\ - \nnetwork={\n\tssid=\"Home\"\n\tpsk=\"correct horse battery\"\n}\n\ - \nnetwork={\n\tssid=\"Cafe Wifi\"\n\tpsk=0123456789abcdef0123456789ABCDEF0123456789abcdef0123456789abcdef\n}\n" + \nnetwork={\n\tssid=\"Home\"\n\tkey_mgmt=WPA-PSK WPA-PSK-SHA256 SAE\n\tieee80211w=1\n\tpsk=\"correct horse battery\"\n}\n\ + \nnetwork={\n\tssid=\"Cafe Wifi\"\n\tkey_mgmt=WPA-PSK WPA-PSK-SHA256\n\tieee80211w=1\n\tpsk=0123456789abcdef0123456789ABCDEF0123456789abcdef0123456789abcdef\n}\n" ); let md = fs::metadata(conf_path(&dir)).unwrap(); assert_eq!(md.mode() & 0o7777, 0o400); @@ -154,6 +154,53 @@ fn foreign_file_refused() { let _ = fs::remove_dir_all(&dir); } +#[test] +fn key_management_follows_what_was_saved() { + let hex = "f".repeat(64); + let text = render(&[ + Network { ssid: "a".into(), psk: Psk::Passphrase("pass word".into()) }, + Network { ssid: "b".into(), psk: Psk::Hex(hex.clone()) }, + ]); + // A passphrase allows SAE, a raw key cannot; neither ever names 802.1X. + assert!(text.contains("\tssid=\"a\"\n\tkey_mgmt=WPA-PSK WPA-PSK-SHA256 SAE\n\tieee80211w=1\n\tpsk=\"pass word\"\n"), "{text}"); + assert!(text.contains(&format!("\tssid=\"b\"\n\tkey_mgmt=WPA-PSK WPA-PSK-SHA256\n\tieee80211w=1\n\tpsk={hex}\n")), "{text}"); + assert!(!text.contains("EAP"), "{text}"); + for bad in [ + // The key management a raw key cannot have, and a passphrase's without its protection. + format!("network={{\n\tssid=\"b\"\n\tkey_mgmt=WPA-PSK WPA-PSK-SHA256 SAE\n\tieee80211w=1\n\tpsk={hex}\n}}\n"), + "network={\n\tssid=\"a\"\n\tkey_mgmt=WPA-PSK WPA-PSK-SHA256 SAE\n\tpsk=\"pass word\"\n}\n".to_string(), + "network={\n\tssid=\"a\"\n\tieee80211w=1\n\tpsk=\"pass word\"\n}\n".to_string(), + "network={\n\tssid=\"a\"\n\tkey_mgmt=WPA-EAP\n\tieee80211w=1\n\tpsk=\"pass word\"\n}\n".to_string(), + "network={\n\tssid=\"a\"\n\tieee80211w=1\n\tieee80211w=1\n\tpsk=\"pass word\"\n}\n".to_string(), + ] { + let e = parse(&bad).unwrap_err(); + assert!(e.contains("line "), "{bad:?}: {e}"); + assert!(!e.contains("pass word"), "{e}"); + } +} + +#[test] +fn older_file_rewritten_with_key_management() { + let dir = tmpdir("refresh"); + assert!(!refresh(&dir, None).unwrap(), "no file: nothing to rewrite"); + fs::create_dir_all(&dir).unwrap(); + // As an older kryptikd wrote it: no key management, so wpa_supplicant's default with EAP and without SAE. + let older = "ctrl_interface=/run/wpa_supplicant\nupdate_config=0\n\nnetwork={\n\tssid=\"Home\"\n\tpsk=\"correct horse battery\"\n}\n"; + fs::write(conf_path(&dir), older).unwrap(); + assert_eq!(list(&dir).unwrap(), vec!["Home"], "an older file still reads"); + assert!(refresh(&dir, None).unwrap()); + let now = fs::read_to_string(conf_path(&dir)).unwrap(); + assert!(now.contains("\tkey_mgmt=WPA-PSK WPA-PSK-SHA256 SAE\n\tieee80211w=1\n"), "{now}"); + assert_eq!(fs::metadata(conf_path(&dir)).unwrap().mode() & 0o7777, 0o400); + assert!(!refresh(&dir, None).unwrap(), "a current file is left alone"); + // Replaced, as the 0400 file takes no write. + fs::remove_file(conf_path(&dir)).unwrap(); + fs::write(conf_path(&dir), "ap_scan=1\n").unwrap(); + assert!(refresh(&dir, None).unwrap_err().contains("something else edited")); + assert_eq!(fs::read_to_string(conf_path(&dir)).unwrap(), "ap_scan=1\n", "a foreign file is not rewritten"); + let _ = fs::remove_dir_all(&dir); +} + #[test] fn no_restart_for_other_directory() { let m = restart_net_zone(Path::new("/nonexistent/wifi")); diff --git a/docs/design/net-zone.md b/docs/design/net-zone.md index ede36604..7da80cf2 100755 --- a/docs/design/net-zone.md +++ b/docs/design/net-zone.md @@ -283,6 +283,14 @@ configured the zone keeps its IPv4 path and the launcher says so. else runs as N. Only the net zone gets it, bound read-only at `/etc/wpa_supplicant.conf`, and a change restarts the `net-zone` service (`s6-svc -r`). +- **A saved network names how it may be joined.** A passphrase by WPA2's + pre-shared key (`WPA-PSK`, `WPA-PSK-SHA256`) or WPA3's SAE, a raw 64-digit + key by the former alone, since SAE needs the passphrase, and neither by + 802.1X, so no access point's beacon chooses EAP for a saved SSID. Unnamed, + wpa_supplicant would take `WPA-PSK WPA-EAP` and never SAE. Management frames + are protected wherever the access point offers it (`ieee80211w=1`), and SAE + always has them. A file an older kryptikd wrote is rewritten so as the net + zone starts. - On disk the file is plaintext inside the [encrypted](state-encryption.md) state partition, like NetworkManager's connection files. A compromised net zone learns the passphrases of the networks it was given, and nothing more. diff --git a/tools/image/zones-test.sh b/tools/image/zones-test.sh index 2acd8181..0d04b796 100755 --- a/tools/image/zones-test.sh +++ b/tools/image/zones-test.sh @@ -68,6 +68,7 @@ for name in kernel-support policies net-ready net-dns dhcpcd-separated dnsmasq-u wifi-module wifi-ap wifi-add wifi-associated wifi-lease wifi-egress wifi-forget \ uplink-renamed-plain uplink-state-reset \ radio-recarried \ + wifi-wpa2-plain wifi-sae \ zone-clocks-own \ time-floor-ran time-clamp time-floor-forged time-claim-stepped time-claim-floor time-claim-consent pids-limit ephemeral-size-bound cpu-max-set lifecycle-repeat lifecycle-registry resolver-after-attach dns-cache-off dns-counters-hidden net-zone-sysfs-nics-only \ terminal-terminfo man-page text-browser tls-trust no-shared-writable-mount net-zone-no-zone-data no-swap no-dbus \ From bbcd0d3afa5203579418e3fc31bf9f612f33a527 Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 9 Oct 2026 13:48:21 -0700 Subject: [PATCH 008/213] The launcher suite checks from the host that a zone's pid 1 has its own ipc, uts and cgroup namespaces, as the architecture says docs/architecture.md gives zones their own user, pid, ipc, uts, mount, cgroup and time namespaces, and their own network namespace. The root launcher check that reads a running zone's pid 1 from the host compared five of them with the host's pid 1: user, pid, mnt, net and time. Nothing checked ipc or cgroup at run time, and uts only through the zone's hostname. It now compares all eight, so a zone that shared any of them with the host fails the check. --- compartments/tests/launcher.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/compartments/tests/launcher.sh b/compartments/tests/launcher.sh index bd94f4da..a6c4b810 100755 --- a/compartments/tests/launcher.sh +++ b/compartments/tests/launcher.sh @@ -1072,7 +1072,7 @@ if (( PRIVILEGED == 1 )); then [[ "$t11nnp" == "1" ]] || t11bad+=("NoNewPrivs is '$t11nnp', not 1") [[ "$t11sec" == "2" ]] || t11bad+=("Seccomp is '$t11sec', not 2 (filter mode)") - for ns in user pid mnt net time; do + for ns in user pid mnt net time ipc uts cgroup; do a="$(readlink "/proc/$t11init/ns/$ns" 2>/dev/null)" b="$(readlink "/proc/1/ns/$ns" 2>/dev/null)" if [[ -z "$a" ]]; then @@ -1083,7 +1083,7 @@ if (( PRIVILEGED == 1 )); then done if (( ${#t11bad[@]} == 0 )); then - pass "T11 from the host, the zone's pid 1 is uid/gid $ZONE_UID, no groups, CapEff=CapPrm=CapBnd=0000000000000400, NoNewPrivs, seccomp filtered, and in its own user/pid/mnt/net/time namespaces" + pass "T11 from the host, the zone's pid 1 is uid/gid $ZONE_UID, no groups, CapEff=CapPrm=CapBnd=0000000000000400, NoNewPrivs, seccomp filtered, and in its own user/pid/mnt/net/time/ipc/uts/cgroup namespaces" else fail "T11 the host's view of the zone's pid 1 ($t11init) is wrong in ${#t11bad[@]} way(s)" for b in "${t11bad[@]}"; do info " $b"; done From ee413af9a1114c894da75e37559e4db2cf6e1e69 Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 9 Oct 2026 14:24:29 -0700 Subject: [PATCH 009/213] CI's kernel job checks its downloads against sources.lock The Kernel currency job curls the pinned kernel tarball, the linux-hardened patch and kernel-hardening-checker straight from upstream. Nothing compared the first two with sources.lock before resolve-kernel-config.sh unpacked the tree, patched it and ran its make and kconfig scripts on the runner. Each download is now checked against its lock line as it lands; a mismatch or a missing line fails the step. --- .github/workflows/ci.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8ba857c..be06ef01 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -344,8 +344,11 @@ jobs: ./tools/fetch-sources.sh --list \ | awk '$1 == "linux" || $1 == "linux-hardened" || $1 == "kernel-hardening-checker" {print $3}' \ | while read -r u; do - echo "fetching $(basename "$u")" - curl -fL --no-progress-meter -o "sources/$(basename "$u")" "$u" + f="$(basename "$u")" + echo "fetching $f" + curl -fL --no-progress-meter -o "sources/$f" "$u" + # Before anything unpacks it: resolving the config runs the kernel tree's own make and scripts. + awk -v f="$f" '$2 == f' sources.lock | (cd sources && sha256sum --check --strict -) done ./tools/validate-kernel-config.sh ./tools/validate-kernel-config.sh --hardened From 8e741c79bec0933466eea279fb17fde4a8701cd8 Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 9 Oct 2026 14:26:49 -0700 Subject: [PATCH 010/213] The decision records say how services log, which processes the allocator reaches, what a zone's network is, and the measured microcode and root sizes --- docs/decisions.md | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/docs/decisions.md b/docs/decisions.md index 188b9acd..be702fd6 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -41,8 +41,10 @@ runs inside the zone, where it can leak only that zone. ## ADR-005: hardened_malloc as the system allocator It adds slab quarantines, guard slabs, randomized allocation and -heap-overflow canaries. Preloaded for every process of the running system and -of every zone ([hardening](hardening.md#allocator)). +heap-overflow canaries. Preloaded for every dynamically linked process of the +running system and of every zone ([hardening](hardening.md#allocator)). +kryptikd and kryptik-wlproxy are static musl binaries, which no preload +reaches. **Cost:** slower on allocation-heavy workloads. @@ -55,8 +57,10 @@ system that assumes a local attacker looking for privileged code. **Cost:** off the LFS path, so every service definition is written from scratch. Seats come from seatd, not logind. The `net` zone runs its own DHCP -client, and no other zone touches a real interface. Logging is s6-log per -service, with no aggregation. +client, and no other zone touches a real interface. Services have no loggers +of their own: their output goes to s6-linux-init's catch-all s6-log under +`/run`, which a reboot clears. The zone launchers, the updater and two boot +services also write files under `/var/log/kryptik`. **Revisit if** writing service definitions becomes the main cost of the base system. @@ -68,8 +72,9 @@ distribution, and a policy too large to audit gives confidence, not security. AppArmor is path-based, which composes badly with per-zone mount namespaces, where one path means different things in different zones. Isolation rests on Landlock for file access, seccomp-bpf for syscalls, cgroup v2 for resources, -and namespaces for network and IPC: a zone's network is an absent interface, -not a policy rule, so Landlock's late network support does not matter. +and namespaces for network and IPC: a zone's network is the interfaces in its +namespace (loopback alone, or one veth into the net zone), not a policy rule +inside the zone, so Landlock's late network support does not matter. **Cost:** a Landlock bypass has no second MAC layer behind it. @@ -176,8 +181,9 @@ Wi-Fi. These are vendor binaries, not built from source as [supply-chain.md](supply-chain.md) otherwise requires, and run by the device's own processor under the kernel's control of the bus (IOMMU on and strict). -Kryptik checks that the tarball is the one kernel.org signed, pins its hash -and checks each file's licence against `WHENCE`. The kernel loads the +Kryptik checks that the tarball is the one kernel.org signed and pins its +hash; each file's licence is the one the release's `WHENCE` records, and the +image carries `WHENCE` under `/usr/share/licenses`. The kernel loads the firmware from the verified root, so replacing it means re-signing the kernel. **Left out:** NVIDIA (nouveau needs tens of megabytes of GSP firmware per @@ -187,12 +193,12 @@ shipped. **CPU microcode** is the same decision. The early loader runs before any filesystem and there is no initramfs, so Intel and AMD microcode is built into -each signed kernel (`CONFIG_EXTRA_FIRMWARE`, stage 05), about 17 MB. Without +each signed kernel (`CONFIG_EXTRA_FIRMWARE`, stage 05), about 18 MB. Without it a machine runs whatever microcode its firmware last shipped, which on older machines means known, unfixed CPU vulnerabilities. **Cost:** about 135 MB after zstd (385 MB of files; Intel Wi-Fi is 56 MB and -amdgpu 38 MB compressed) on a root image of about 1.7 GB, and an input nobody +amdgpu 38 MB compressed) on a root image of about 1.6 GB, and an input nobody here can read. ## ADR-013: A driver is built in only when boot needs it @@ -212,7 +218,7 @@ module loads only where it is used, through the path real Wi-Fi and graphics need anyway. The VM gets no exception: with virtio-net and virtio-gpu as modules, every acceptance run proves module autoload. -**Not shrunk:** about 17 MB of the kernel is microcode, which is encrypted and +**Not shrunk:** about 18 MB of the kernel is microcode, which is encrypted and does not compress. Pre-2011 CPUs, which cannot boot Kryptik, account for 0.4 MB, not worth a rule. Four server-only Xeon families take 6.8 MB and stay while the README lists server hardware: a server whose microcode is left out still boots From 02906c38b8816cc5fe2104b0138f08c2d0f681e1 Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 9 Oct 2026 14:27:44 -0700 Subject: [PATCH 011/213] The kernel job's log keeps the resolved config, folded A fragment change shows its effect only in the resolved config, and the runner that resolved it is gone with the file. In Actions the script now prints every set option inside a collapsed group, so a reader can compare a pull request's resolved config with main's from the two job logs. --- tools/resolve-kernel-config.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tools/resolve-kernel-config.sh b/tools/resolve-kernel-config.sh index 4c733849..d2e29f6b 100755 --- a/tools/resolve-kernel-config.sh +++ b/tools/resolve-kernel-config.sh @@ -57,6 +57,12 @@ dim " =y: $(grep -c '=y$' .config) =m: $(grep -c '=m$' .config)" mkdir -p "$(dirname "$OUT")" cp .config "$OUT" ok "resolved config: ${OUT}" +# A runner's file is gone with it; the log keeps every set option, folded, for a reader of a fragment change. +if [[ -n "${GITHUB_ACTIONS:-}" ]]; then + echo "::group::resolved config, every set option" + grep '^CONFIG_' "$OUT" + echo "::endgroup::" +fi echo log "the options Kryptik's guarantees rest on" From 75c9e021a9c655b1699cbac845a577020f3a874d Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 19:59:08 -0700 Subject: [PATCH 012/213] Stage 04 installs kryptikd before the updater, whose check refuses to start without it, so a build from an empty cache gets through stage 04 --- build/stages/04-base-system.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/build/stages/04-base-system.sh b/build/stages/04-base-system.sh index aa4815fd..bc2b3c38 100755 --- a/build/stages/04-base-system.sh +++ b/build/stages/04-base-system.sh @@ -297,12 +297,13 @@ PACKAGES=( "services" "s_services $(tree_digest "${KRYPTIK_ROOT}"/build/services/*/* "${KRYPTIK_ROOT}"/build/service-scripts/*.sh "${KRYPTIK_ROOT}"/build/config/sysctl.d/*.conf "${KRYPTIK_ROOT}"/build/config/keyboard-layouts)" # Before the updater, whose check runs kryptik-update, which needs efiboot. "efiboot" "s_efiboot $(sha256_of "${KRYPTIK_ROOT}/tools/efi/kryptik-efiboot.c" 2>/dev/null || echo none)" - "updater" "s_updater $(sha256_of "${KRYPTIK_ROOT}/tools/update/kryptik-update" 2>/dev/null || echo none) $(sha256_of "${KRYPTIK_ROOT}/tools/update/kryptik-recover" 2>/dev/null || echo none)" "netzone" "s_netzone $(sha256_of "${KRYPTIK_ROOT}/tools/net/netzone-init.sh" 2>/dev/null || echo none)-$(sha256_of "${KRYPTIK_ROOT}/tools/net/sntp-offset.py" 2>/dev/null || echo none)-$(sha256_of "${KRYPTIK_ROOT}/tools/net/update-fetch.py" 2>/dev/null || echo none)" "installer" "s_installer $(sha256_of "${KRYPTIK_ROOT}/tools/install/kryptik-install.sh" 2>/dev/null || echo none)" # The binary's path and hash, and a digest of the zone files: kryptikd # validates them at install time, so the two must move together. "kryptikd" "s_kryptikd ${KRYPTIK_KRYPTIKD_BIN:-none} $([[ -f "${KRYPTIK_KRYPTIKD_BIN:-}" ]] && sha256_of "${KRYPTIK_KRYPTIKD_BIN}" || echo absent) $(tree_digest "${KRYPTIK_ROOT}"/compartments/zones/*.toml "${KRYPTIK_ROOT}"/compartments/zones/policy/*) $(sha256_of "${KRYPTIK_ROOT}/tools/kryptik" 2>/dev/null || echo none)" + # After kryptikd and efiboot: its check runs kryptik-update, which refuses to start without either. + "updater" "s_updater $(sha256_of "${KRYPTIK_ROOT}/tools/update/kryptik-update" 2>/dev/null || echo none) $(sha256_of "${KRYPTIK_ROOT}/tools/update/kryptik-recover" 2>/dev/null || echo none)" # The suites and guest checks the VM drivers run; every file is an input. "tests" "s_tests $(tree_digest "${KRYPTIK_ROOT}"/compartments/tests/*.sh "${KRYPTIK_ROOT}"/compartments/kryptikd/probes/*.sh "${KRYPTIK_ROOT}"/compartments/kryptikd/src/isolate.rs "${KRYPTIK_ROOT}"/compartments/kryptikd/src/rootfs.rs "${KRYPTIK_ROOT}"/compartments/kryptikd/src/seccomp.rs "${KRYPTIK_ROOT}"/build/guest-tests/*.sh "${KRYPTIK_ROOT}"/build/guest-tests/*.py)" # The tarballs it reads are pinned by sources.lock and named by fetch-sources. From 5a56d5961de9eb9f1193613ee1198b66581c70db Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 20:00:17 -0700 Subject: [PATCH 013/213] Every tool writes the ESP's records and kernels through two functions in esp-records.sh; the installer wrote its committed-slot record in place and compared BOOTX64.EFI from the page cache --- build/service-scripts/boot-success.sh | 12 +++++------ build/service-scripts/esp-records.sh | 22 +++++++++++++++++++- tools/install/kryptik-install.sh | 10 ++++----- tools/tests/boot-success.sh | 1 + tools/tests/esp-records.sh | 29 ++++++++++++++++++++++++++- tools/update/kryptik-recover | 22 +++++--------------- tools/update/kryptik-update | 10 ++------- 7 files changed, 67 insertions(+), 39 deletions(-) diff --git a/build/service-scripts/boot-success.sh b/build/service-scripts/boot-success.sh index ba756516..02a22830 100755 --- a/build/service-scripts/boot-success.sh +++ b/build/service-scripts/boot-success.sh @@ -11,14 +11,14 @@ ZONES="${KRYPTIK_ZONES:-/usr/lib/kryptik/zones}" ESP_MNT="${RUN}/esp" # shellcheck source=/dev/null . "${KRYPTIK_DEVICES:-/usr/libexec/kryptik/devices.sh}" +# shellcheck source=/dev/null +. "${KRYPTIK_ESP_RECORDS:-/usr/libexec/kryptik/esp-records.sh}" mkdir -p "$B" ident() { sed -n "s/^$1=//p" "$RUN/boot-identity" 2>/dev/null | head -1; } other_slot() { case "$1" in a) echo b ;; b) echo a ;; esac; } slot="$(ident slot)"; media="$(ident media)"; state="$(ident state)" now() { date -Iseconds 2>/dev/null || date; } -# Drop FILE's clean pages, so the next read is what the device holds; best effort. -uncache() { dd if="$1" iflag=nocache count=0 status=none 2>/dev/null || :; } # uncache FILE result() { printf '%s %s\n' "$*" "$(now)" > "$B/last-result.new" && mv -f "$B/last-result.new" "$B/last-result"; } if [ -n "$media" ]; then @@ -74,16 +74,14 @@ commit_slot() { # commit_slot : make BOOTX64.EFI this slot's kernel if cmp -s "$src" "$dst"; then say "BOOTX64.EFI already is slot $1"; rc=0 else - # Copy, fsync, compare as the device holds it, then rename: on FAT only the - # rename is not atomic, and a copy that landed wrong never becomes the boot file. - cp "$src" "$dst.new" && sync -f "$dst.new" && uncache "$dst.new" && cmp -s "$src" "$dst.new" && mv -f "$dst.new" "$dst" && sync -f "$dst" && rc=0 + # On FAT only the rename is not atomic, and a copy that landed wrong never becomes the boot file. + esp_place "$src" "$dst" && sync -f "$dst" && rc=0 [ "$rc" -eq 0 ] && say "committed: BOOTX64.EFI is now slot $1" fi # The record is part of the commit, and follows the boot file: kryptik-update # applies only from the slot it names, so it must not name one that does not boot. if [ "$rc" -eq 0 ]; then - { printf '%s\n' "$1" > "$ESP_MNT/kryptik/committed-slot.new" && sync -f "$ESP_MNT/kryptik/committed-slot.new" && \ - mv -f "$ESP_MNT/kryptik/committed-slot.new" "$ESP_MNT/kryptik/committed-slot"; } \ + esp_put "$ESP_MNT/kryptik/committed-slot" "$1" \ || { say "the committed-slot record could not be written; the trial stays, and the next boot commits again"; rc=1; } fi else diff --git a/build/service-scripts/esp-records.sh b/build/service-scripts/esp-records.sh index 0e120756..ab549fca 100755 --- a/build/service-scripts/esp-records.sh +++ b/build/service-scripts/esp-records.sh @@ -1,13 +1,33 @@ #!/bin/sh # The records Kryptik keeps on an ESP (kryptik/committed-slot, -# kryptik/version-a and version-b), read for a terminal or a log. An ESP is +# kryptik/version-a and version-b) and the kernels beside them. An ESP is # plain FAT that anyone holding the disk can rewrite, so a record is shown # only in the shape Kryptik writes it; anything else reads as unknown. # POSIX sh, sourced. # esp_slot FILE [ABSENT] a or b # esp_version FILE [ABSENT] a release's version +# esp_put FILE LINE write a record +# esp_place SRC DST copy a kernel into place # A missing FILE reads as ABSENT (default unknown). +# Drop FILE's clean pages, so the next read is what the device holds; best effort. +uncache() { dd if="$1" iflag=nocache count=0 status=none 2>/dev/null || :; } # uncache FILE + +# Written, fsynced, renamed: a power cut leaves the old record whole, not empty. +esp_put() { + printf '%s\n' "$2" > "$1.new" && sync -f "$1.new" && mv -f "$1.new" "$1" +} + +# A copy that landed wrong, as the device holds it, never takes DST's name. +esp_place() { + if cp "$1" "$2.new" && sync -f "$2.new" && uncache "$2.new" && cmp -s "$1" "$2.new"; then + mv -f "$2.new" "$2" + else + rm -f "$2.new" + return 1 + fi +} + esp_slot() { [ -e "$1" ] || { echo "${2:-unknown}"; return 0; } case "$(head -c 8 "$1" 2>/dev/null | sed -n 1p)" in diff --git a/tools/install/kryptik-install.sh b/tools/install/kryptik-install.sh index f8f3f230..49366262 100755 --- a/tools/install/kryptik-install.sh +++ b/tools/install/kryptik-install.sh @@ -102,6 +102,8 @@ mounted_on() { # mounted_on DEVS < TABLE: "SOURCE on WHERE" for each source th . /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 +# The ESP's kernels and records, written as every other tool writes them. +. /usr/libexec/kryptik/esp-records.sh [ -z "$KEYBOARD" ] || kb_row "$KEYBOARD" > /dev/null || die "no keyboard layout named ${KEYBOARD}: kryptik keyboard lists them" [ -z "$PRESEED" ] || preseed_ok "$PRESEED" @@ -335,11 +337,9 @@ mkfs.ext4 -q -F -L kryptik-state "/dev/mapper/$MAPPING" || die "mkfs.ext4 inside # --- the target ESP: slot A is the committed boot file ---------------------- mount -t vfat -o rw "$P1" "$MNT_BASE/tesp" || die "could not mount the new ESP" [ -f "$MNT_BASE/tesp/EFI/kryptik/kryptik-a.efi" ] || die "the copied ESP has no slot A kernel" -cp "$MNT_BASE/tesp/EFI/kryptik/kryptik-a.efi" "$MNT_BASE/tesp/EFI/BOOT/BOOTX64.EFI.new" || die "could not stage BOOTX64.EFI" -sync -f "$MNT_BASE/tesp/EFI/BOOT/BOOTX64.EFI.new" -mv -f "$MNT_BASE/tesp/EFI/BOOT/BOOTX64.EFI.new" "$MNT_BASE/tesp/EFI/BOOT/BOOTX64.EFI" || die "could not install BOOTX64.EFI" -cmp -s "$MNT_BASE/tesp/EFI/BOOT/BOOTX64.EFI" "$MNT_BASE/tesp/EFI/kryptik/kryptik-a.efi" || die "BOOTX64.EFI is not the slot A kernel" -printf 'a\n' > "$MNT_BASE/tesp/kryptik/committed-slot" +esp_place "$MNT_BASE/tesp/EFI/kryptik/kryptik-a.efi" "$MNT_BASE/tesp/EFI/BOOT/BOOTX64.EFI" \ + || die "BOOTX64.EFI could not be made the slot A kernel" +esp_put "$MNT_BASE/tesp/kryptik/committed-slot" a || die "slot A could not be recorded as committed" rm -f "$MNT_BASE/tesp/kryptik/media-kernel" sync umount "$MNT_BASE/tesp" || die "could not unmount the new ESP" diff --git a/tools/tests/boot-success.sh b/tools/tests/boot-success.sh index 7a74a008..185e6034 100755 --- a/tools/tests/boot-success.sh +++ b/tools/tests/boot-success.sh @@ -4,6 +4,7 @@ set -uo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" SCRIPT="$ROOT/build/service-scripts/boot-success.sh" +export KRYPTIK_ESP_RECORDS="$ROOT/build/service-scripts/esp-records.sh" PASS=0; FAIL=0 ok() { printf ' PASS %s\n' "$1"; PASS=$((PASS + 1)); } bad() { printf ' FAIL %s\n' "$1"; FAIL=$((FAIL + 1)); } diff --git a/tools/tests/esp-records.sh b/tools/tests/esp-records.sh index 6219b5a3..e0708f44 100755 --- a/tools/tests/esp-records.sh +++ b/tools/tests/esp-records.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash # The ESP's records as the tools show them (build/service-scripts/esp-records.sh): -# in the shape Kryptik writes them, and as unknown otherwise. +# in the shape Kryptik writes them, and as unknown otherwise. Then the writers, +# which every tool that writes the ESP uses. set -uo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" # shellcheck source=/dev/null @@ -39,5 +40,31 @@ rec ''; check "an empty record" "$(esp_version "$T/rec")" unknown check "no record" "$(esp_version "$T/absent")" unknown check "no record, read as the caller says" "$(esp_version "$T/absent" none)" none +echo "-- writing a record" +mkdir -p "$T/esp/kryptik" +esp_put "$T/esp/kryptik/committed-slot" b; rc=$? +check "a record is written whole, and no temporary is left" "$rc|$(cat "$T/esp/kryptik/committed-slot")|$(ls -A "$T/esp/kryptik")" "0|b|committed-slot" +# A temporary that cannot be made, as a full or read-only ESP refuses it: a directory in its place stops root too. +mkdir "$T/esp/kryptik/committed-slot.new" +esp_put "$T/esp/kryptik/committed-slot" a; rc=$? +check "a record that cannot be written fails, and the old one stays whole" "$([[ $rc -ne 0 ]] && echo failed)|$(cat "$T/esp/kryptik/committed-slot")" "failed|b" +rmdir "$T/esp/kryptik/committed-slot.new" + +echo "-- placing a kernel" +printf 'kernel-b' > "$T/esp/kernel-b"; printf 'kernel-a' > "$T/esp/BOOTX64.EFI" +esp_place "$T/esp/kernel-b" "$T/esp/BOOTX64.EFI"; rc=$? +check "a kernel is copied into place, and no temporary is left" "$rc|$(cat "$T/esp/BOOTX64.EFI")|$([[ -e "$T/esp/BOOTX64.EFI.new" ]] && echo left || echo none)" "0|kernel-b|none" +printf 'kernel-a' > "$T/esp/BOOTX64.EFI" +cmp() { return 1; } # the copy reads back from the device other than it was written +esp_place "$T/esp/kernel-b" "$T/esp/BOOTX64.EFI"; rc=$? +unset -f cmp +check "a copy that reads back wrong never takes the name, and its temporary goes" \ + "$([[ $rc -ne 0 ]] && echo failed)|$(cat "$T/esp/BOOTX64.EFI")|$([[ -e "$T/esp/BOOTX64.EFI.new" ]] && echo left || echo none)" "failed|kernel-a|none" + +echo "-- every tool writes the records through esp_put" +own="$(grep -nE '>[[:space:]]*"[^"]*kryptik/(committed-slot|version-)' "$ROOT/tools/install/kryptik-install.sh" "$ROOT/tools/update/kryptik-update" \ + "$ROOT/tools/update/kryptik-recover" "$ROOT/build/service-scripts/boot-success.sh" | sed "s|$ROOT/||")" +check "no tool writes a record by a redirection of its own" "$own" "" + printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" [[ "$FAIL" -eq 0 ]] diff --git a/tools/update/kryptik-recover b/tools/update/kryptik-recover index 83843912..250a7f44 100755 --- a/tools/update/kryptik-recover +++ b/tools/update/kryptik-recover @@ -42,7 +42,7 @@ for lblname in kryptik-state kryptik-media; do case "$(readlink -f "$d")" in "$DISK"*) [ "$lblname" = kryptik-state ] && [ "$d" = "$ST" ] && continue; die "${DISK} is the medium; refusing" ;; esac done done -# The disk's ESP records are shown only in the shape Kryptik writes them. +# The disk's ESP records, shown only in the shape Kryptik writes them, and their writers. . /usr/libexec/kryptik/esp-records.sh mountpoint -q /run/kryptik-recover 2>/dev/null && umount /run/kryptik-recover mkdir -p /run/kryptik-recover @@ -57,10 +57,6 @@ cleanup() { trap cleanup EXIT INT TERM slot_dev() { case "$1" in a) echo "$SA" ;; b) echo "$SB" ;; *) die "slot must be a or b" ;; esac; } has_fs() { [ "$(dd if="$1" bs=1 skip=1080 count=2 status=none | od -An -tx1 | tr -d ' \n')" = "53ef" ]; } -# Written, fsynced, renamed, as the kernels are: a power cut leaves the old file whole, not empty. -put() { printf '%s\n' "$2" > "$1.new" && sync -f "$1.new" && mv -f "$1.new" "$1"; } # put FILE LINE -# Drop FILE's clean pages, so the next read is what the device holds; best effort. -uncache() { dd if="$1" iflag=nocache count=0 status=none 2>/dev/null || :; } # uncache FILE if [ "$STATUS" = 1 ]; then mount -t vfat -o ro "$ESP" /run/kryptik-recover || die "cannot mount the ESP" @@ -91,12 +87,8 @@ commit_slot() { # commit_slot SLOT (ESP mounted rw at /run/kryptik-recover) s="$1"; k="/run/kryptik-recover/EFI/kryptik/kryptik-$s.efi" [ -f "$k" ] || die "no kernel for slot $s on the ESP" has_fs "$(slot_dev "$s")" || die "slot $s holds no filesystem; use --restore-slot $s" - cp "$k" /run/kryptik-recover/EFI/BOOT/BOOTX64.EFI.new - sync -f /run/kryptik-recover/EFI/BOOT/BOOTX64.EFI.new - uncache /run/kryptik-recover/EFI/BOOT/BOOTX64.EFI.new - cmp -s "$k" /run/kryptik-recover/EFI/BOOT/BOOTX64.EFI.new || die "slot $s's kernel did not copy whole; BOOTX64.EFI is unchanged" - mv -f /run/kryptik-recover/EFI/BOOT/BOOTX64.EFI.new /run/kryptik-recover/EFI/BOOT/BOOTX64.EFI - put /run/kryptik-recover/kryptik/committed-slot "$s" || die "cannot record slot $s as committed" + esp_place "$k" /run/kryptik-recover/EFI/BOOT/BOOTX64.EFI || die "slot $s's kernel did not copy whole; BOOTX64.EFI is unchanged" + esp_put /run/kryptik-recover/kryptik/committed-slot "$s" || die "cannot record slot $s as committed" sync say "committed: BOOTX64.EFI is now slot $s ($(esp_version "/run/kryptik-recover/kryptik/version-$s"))" # As boot-success does: forget Kryptik's firmware entries, then give the committed slot its own. @@ -150,13 +142,9 @@ if [ -n "$RESTORE" ]; then || die "slot $RESTORE does not verify against the root hash this medium's signed kernel carries; nothing was committed" say "slot $RESTORE verifies against the root hash the signed kernel carries" mount -t vfat -o rw "$ESP" /run/kryptik-recover || die "cannot mount the target ESP" - cp "$kdir/kryptik-$RESTORE.efi" "/run/kryptik-recover/EFI/kryptik/kryptik-$RESTORE.efi.new" - sync -f "/run/kryptik-recover/EFI/kryptik/kryptik-$RESTORE.efi.new" - uncache "/run/kryptik-recover/EFI/kryptik/kryptik-$RESTORE.efi.new" - cmp -s "$kdir/kryptik-$RESTORE.efi" "/run/kryptik-recover/EFI/kryptik/kryptik-$RESTORE.efi.new" \ + esp_place "$kdir/kryptik-$RESTORE.efi" "/run/kryptik-recover/EFI/kryptik/kryptik-$RESTORE.efi" \ || die "the medium's kernel for slot $RESTORE did not copy whole; nothing was committed" - mv -f "/run/kryptik-recover/EFI/kryptik/kryptik-$RESTORE.efi.new" "/run/kryptik-recover/EFI/kryptik/kryptik-$RESTORE.efi" - put "/run/kryptik-recover/kryptik/version-$RESTORE" "$ver" || die "cannot record slot ${RESTORE}'s version" + esp_put "/run/kryptik-recover/kryptik/version-$RESTORE" "$ver" || die "cannot record slot ${RESTORE}'s version" commit_slot "$RESTORE" umount /run/kryptik-recover mountpoint -q /run/kryptik-recover-esp 2>/dev/null && umount /run/kryptik-recover-esp diff --git a/tools/update/kryptik-update b/tools/update/kryptik-update index 4859f531..059ebe84 100755 --- a/tools/update/kryptik-update +++ b/tools/update/kryptik-update @@ -59,7 +59,7 @@ case "${1:-}" in *) # shellcheck source=/dev/null . /usr/libexec/kryptik/devices.sh - # The ESP's records, shown only in the shape Kryptik writes them. + # The ESP's records, shown only in the shape Kryptik writes them, and their writers. # shellcheck source=/dev/null . /usr/libexec/kryptik/esp-records.sh ;; esac @@ -78,9 +78,6 @@ esp_dev() { own_part kryptik-esp; } slot_dev() { own_part "kryptik-$1"; } hdr() { awk -F': ' -v k="$2" '$1==k {print $2; exit}' "$1"; } -# Drop FILE's clean pages, so the next read is what the device holds; best effort. -uncache() { dd if="$1" iflag=nocache count=0 status=none 2>/dev/null || :; } # uncache FILE - mount_esp() { dev="$(esp_dev)" || exit 1 mkdir -p "$ESP_MNT" @@ -385,10 +382,7 @@ cmd_apply() { die "the kernel staged on the ESP does not hash to the manifest's kryptik-$target.efi" fi mv -f "$ESP_MNT/EFI/kryptik/kryptik-$target.efi.new" "$ESP_MNT/EFI/kryptik/kryptik-$target.efi" - { printf '%s\n' "$VERSION" > "$ESP_MNT/kryptik/version-$target.new" \ - && sync -f "$ESP_MNT/kryptik/version-$target.new" \ - && mv -f "$ESP_MNT/kryptik/version-$target.new" "$ESP_MNT/kryptik/version-$target"; } \ - || die "slot $target's version could not be recorded on the ESP" + esp_put "$ESP_MNT/kryptik/version-$target" "$VERSION" || die "slot $target's version could not be recorded on the ESP" sync umount_esp say "kernel for slot $target installed on the ESP (version $VERSION)" From 8f475e0f8b34ea7d1d6fcc4baa1f056b2b8720cc Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 20:01:34 -0700 Subject: [PATCH 014/213] The kernel moves to 6.18.56 with linux-hardened 6.18.56-hardened1 linux-hardened tagged 6.18.56-hardened1 on 2026-10-11, the day 6.18.56 came out, so the kernel takes the newest longterm release again. Its patch is 6.18.53-hardened1's with new context offsets and SUBLEVEL 56: the same files and the same configuration symbols. The three releases since 6.18.53 carry about 1,200 upstream fixes. Among them, in code a zone, the net zone or a radio in range reaches: - TCP: a use-after-free of the retransmit hint when a SYN-ACK is sent, and retransmit-queue collapsing across a boundary. - IPv4 options freed before an RCU grace period; ipv6_find_hdr() returning an offset past the packet's end. - UDP sockets left in, or misplaced in, the connected 4-tuple hash on disconnect and reconnect. - mac80211 and cfg80211: oversized fragments overflowing extra_len, hidden-SSID beacon elements losing their owner, an empty FILS association request, and timers and work left running when a station is torn down. - POSIX timers around a non-leader thread's exec(), af_unix's garbage collector, and cfg80211 moving a radio between network namespaces, which is how the net zone takes one. - a missing barrier between an anon_vma's initialisation and its publication, and the console's paste_selection() reading past its buffer. The tarball's digest is from kernel.org's sha256sums.asc, signed on 2026-10-11 by the checksum autosigner (B8868C80BA62A1FFFAF5FDA9632D3A06589DA6B1, from pgpkeys.git); the patch's signature verifies with anthraxx's key E240B57E2C4630BA768E2F26FC1B547C8D8172C8, as key-provenance.tsv lists. --- build/config/versions.env | 4 ++-- sources.lock | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/build/config/versions.env b/build/config/versions.env index 4729f88c..490efb70 100644 --- a/build/config/versions.env +++ b/build/config/versions.env @@ -8,8 +8,8 @@ V_BINUTILS=2.43.1 V_GCC=14.4.0 V_GLIBC=2.40 # Longterm kernels only (ADR-009); `make check-kernel-eol` enforces it. -V_LINUX=6.18.53 -V_LINUX_HARDENED=6.18.53-hardened1 +V_LINUX=6.18.56 +V_LINUX_HARDENED=6.18.56-hardened1 # Device firmware (ADR-012); build/config/firmware.list picks what ships. V_LINUX_FIRMWARE=20260916 # regulatory.db, whose signature cfg80211 checks against a key in the kernel. diff --git a/sources.lock b/sources.lock index 9042b72c..ec50d59d 100644 --- a/sources.lock +++ b/sources.lock @@ -63,9 +63,9 @@ b8b45194989022a79ec1317f64a2a75b1551b2a55bea06f67704cb2a2e4690b0 libpipeline-1. 4f7f217f057ce655ff22559ad221a0fd8ef84ad1fc5fcb6990cecc333aa1635d libtool-2.4.7.tar.xz 71513a31c01a428bccd5367a32fd95f115d6dac50fb5b60c779d5c7942aec071 libxcrypt-4.5.2.tar.xz acc4d5f7c3cbba5f9f8d08d8bdbeede84ecede46792f47929aa9321873385528 libxkbcommon-1.13.2.tar.gz -4d6fba95c2244b08a7b4144a4d38b9be4fb31abb5e7682ae40bb5cb11374cfe0 linux-6.18.53.tar.xz +4633bbda2ab95eaf2a7af4041236451d6759c72e6946265531af4b3aacefbf89 linux-6.18.56.tar.xz f80dcb757a623deda62200c08e0e1a88c76fb6b54964f31b35fa74da1c90ccc5 linux-firmware-20260916.tar.xz -e72d4a6e482f9e0a2cb7e94a65eee5752c56357b525ba98cb432dc5805199fd0 linux-hardened-v6.18.53-hardened1.patch +d55376d2df1b185c9ac94be3f75bbbf456ac8590cd87dd6657f9f13067270636 linux-hardened-v6.18.56-hardened1.patch 174b7f2866a60f3247ba75f5c7dbb10b124aede4a1359312de15f3bfebd2050f lynx2.9.3.tar.bz2 63aede5c6d33b6d9b13511cd0be2cac046f2e70fd0a07aa9573a04a82783af96 m4-1.4.19.tar.xz dd16fb1d67bfab79a72f5e8390735c49e3e8e70b4945a15ab1f81ddb78658fb3 make-4.4.1.tar.gz From 24e43e3bda8d42501764608946e389e5a4108745 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 20:03:47 -0700 Subject: [PATCH 015/213] A degraded boot's clock corrections stay out of the hardware clock, since its unasked total starts afresh at every such boot --- compartments/kryptikd/src/time.rs | 22 +++++++++++++++++++++- compartments/kryptikd/src/time/tests.rs | 13 +++++++++++++ docs/design/time.md | 5 ++++- docs/user-guide.md | 3 ++- 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/compartments/kryptikd/src/time.rs b/compartments/kryptikd/src/time.rs index 53441530..d4177d92 100644 --- a/compartments/kryptikd/src/time.rs +++ b/compartments/kryptikd/src/time.rs @@ -182,6 +182,9 @@ use std::path::Path; pub const IMAGE_JSON: &str = "/etc/kryptik-image.json"; /// What zone 0 remembers about the clock between claims and across boots. pub const STATE_DIR: &str = "/var/lib/kryptik/time"; +/// sysinit's mark of a boot whose state partition could not be used: its `/var`, and with it +/// `STATE_DIR`, is memory. +pub const DEGRADED: &str = "/run/kryptik/state-degraded"; /// The wall clock, behind a trait so tests can use a fake: no test should set the real one. pub trait Clock { @@ -401,6 +404,21 @@ pub fn consider( bound: i64, claim: &Claim, ask: &mut dyn FnMut(&str, &str, u8) -> Result<(), String>, +) -> Outcome { + /* A degraded boot keeps the unasked sum in memory, so every such boot starts it afresh: + * its steps stay out of the hardware clock that the next boot starts from. */ + consider_with(clock, dir, floor, bound, claim, ask, !Path::new(DEGRADED).exists()) +} + +/// `consider`, setting the hardware clock after a step only when `set_rtc`. +fn consider_with( + clock: &mut dyn Clock, + dir: &Path, + floor: Option, + bound: i64, + claim: &Claim, + ask: &mut dyn FnMut(&str, &str, u8) -> Result<(), String>, + set_rtc: bool, ) -> Outcome { let Some(floor) = floor else { return Outcome::Refused(format!("no floor is known ({IMAGE_JSON} is missing or unreadable), so no claim can be judged")); @@ -458,7 +476,9 @@ pub fn consider( if applied { state.moved_unasked = moved_after(&know, claim, &decision, consented); if !matches!(outcome, Outcome::Slewed) { - if let Err(e) = clock.sync_rtc() { + if !set_rtc { + record(dir, clock.now(), "the hardware clock was left alone: this boot's state is in memory"); + } else if let Err(e) = clock.sync_rtc() { record(dir, clock.now(), &format!("the hardware clock was not updated: {e}")); } } diff --git a/compartments/kryptikd/src/time/tests.rs b/compartments/kryptikd/src/time/tests.rs index fe0c14fb..fe53f8b8 100644 --- a/compartments/kryptikd/src/time/tests.rs +++ b/compartments/kryptikd/src/time/tests.rs @@ -66,6 +66,19 @@ fn claim_within_bound_steps_clock() { let _ = std::fs::remove_dir_all(&dir2); } +#[test] +fn degraded_boot_leaves_the_rtc_alone() { + // The same step as above, on a boot whose clock state is in memory. + let dir = scratch("degraded"); + let mut clock = FakeClock::at(BUILT as f64 + 1e6); + let out = consider_with(&mut clock, &dir, Some(BUILT), 3600, &Claim { offset: 42.5, sources: 3 }, &mut nobody, false); + assert_eq!(out, Outcome::Stepped); + assert_eq!((clock.steps.len(), clock.rtc_syncs), (1, 0)); + let history = std::fs::read_to_string(dir.join("history")).unwrap(); + assert!(history.contains("the hardware clock was left alone"), "{history}"); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn past_bound_needs_consent() { let dir = scratch("ask"); diff --git a/docs/design/time.md b/docs/design/time.md index 32408413..f1ff4f73 100644 --- a/docs/design/time.md +++ b/docs/design/time.md @@ -80,7 +80,10 @@ and the net zone prints `time=` in its readiness line. interval nor the bound. 6. kryptikd, the only process with `CAP_SYS_TIME`, applies it with `clock_settime(CLOCK_REALTIME)` and after a step sets the RTC - (`RTC_SET_TIME` on `/dev/rtc0` if present). Each claim considered adds a + (`RTC_SET_TIME` on `/dev/rtc0` if present), except on a degraded boot: + its `/var` is memory, so the unasked total starts afresh at each such + boot, and its steps stay out of the RTC the next boot starts from. Each + claim considered adds a line to `/var/lib/kryptik/time/history`; `kryptikd time status` prints the clock, the floor and the last line. diff --git a/docs/user-guide.md b/docs/user-guide.md index 31639a08..bda1cc45 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -273,7 +273,8 @@ open to `untrusted` alone: open that page there (Alt+Shift+u). **Degraded boot.** If the system cannot find exactly one `kryptik-state` partition on its own disk, or cannot unlock or mount it, it boots degraded: it says so on the console, creates no account, starts no desktop and refuses -updates. Nothing on the disk is written in that state. Fix the cause (no +updates. Nothing on the disk is written in that state, and the network's +corrections to the clock stay out of the hardware clock. Fix the cause (no partition labelled `kryptik-state` on the system's own disk, or more than one, as after relabelling; a damaged filesystem or header) and boot again. A clone of the disk attached alongside is ignored, not a cause. After three From 71df9f6a7eb2a7090e5a6ea7def3a9ded1410f17 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 20:01:17 -0700 Subject: [PATCH 016/213] The net zone's wpa_supplicant runs as its own user with CAP_NET_ADMIN and CAP_NET_RAW alone, off nf_tables and the zone root's files wpa_supplicant parses what any radio in range sends, scan results, EAPOL and EAP, and ran as the net zone's root: a flaw there held every capability the zone keeps, nf_tables among them, the broker's socket and dhcpcd's files. kryptikd supplicant starts it as the zone's id 101, mapped beside dhcpcd's 100, with CAP_NET_ADMIN and CAP_NET_RAW raised as ambient capabilities, a socket filter of its own (unix, packet and datagram inet sockets, route and generic netlink: never NETLINK_NETFILTER) and a Landlock ruleset that writes in /run/wpa_supplicant alone, which rootfs.rs makes for that user. The credentials file is 0440, the supplicant's user's and the zone root's group's; spawn rewrites one an older kryptikd wrote. netzone-init reads the status line from the kernel, as the control socket is now the supplicant's, and tells a dead supplicant by its /proc entry, as it cannot signal it. hostap's own privilege separation was the other way: in 2.12 its associate command has no field for protected management frames, which SAE needs, and its deauthenticate is a stub (net-zone-surface.md says where). wifi-supplicant-unprivileged checks the joined supplicant's ids, capability sets and filters; wifi-supplicant-no-nft runs nft through the same door and expects a refusal and no table. wifi-wpa2-plain and wifi-sae still join. --- build/guest-tests/zones-check.sh | 33 ++++++- compartments/kryptikd/src/isolate.rs | 7 +- compartments/kryptikd/src/main.rs | 15 +++ compartments/kryptikd/src/rootfs.rs | 14 ++- compartments/kryptikd/src/rootfs/tests.rs | 9 +- compartments/kryptikd/src/seccomp.rs | 66 +++++++++++++ compartments/kryptikd/src/seccomp/tests.rs | 34 +++++++ compartments/kryptikd/src/spawn.rs | 7 +- compartments/kryptikd/src/supplicant.rs | 107 +++++++++++++++++++++ compartments/kryptikd/src/wifi.rs | 31 ++++-- compartments/kryptikd/src/wifi/tests.rs | 11 ++- compartments/tests/cli.sh | 4 +- compartments/tests/serve.sh | 6 +- compartments/zones/policy/net.seccomp | 5 + docs/design/net-zone-surface.md | 77 +++++++++------ docs/design/net-zone.md | 29 ++++-- docs/threat-model.md | 7 +- tools/image/zones-test.sh | 2 +- tools/net/netzone-init.sh | 37 +++---- 19 files changed, 415 insertions(+), 86 deletions(-) create mode 100644 compartments/kryptikd/src/supplicant.rs diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index 26e49329..b5bb1669 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -764,11 +764,11 @@ before="$(ready_count)" printf '%s\n' "$AP_PASS" | "$KD" wifi add "$AP_SSID" --wifi-dir "$WIFI_DIR" --zones "$Z" > "$LOG/wifi-add.out" 2>&1 NET_UID="$(sed -n 's/^uid_base *= *\([0-9]*\).*/\1/p' "$Z/net.toml")" if grep -q "added network \"$AP_SSID\"; the net zone is restarting" "$LOG/wifi-add.out" \ - && [[ "$(stat -c '%a %u' "$WIFI_DIR/wpa_supplicant.conf" 2>/dev/null)" = "400 $NET_UID" ]] \ + && [[ "$(stat -c '%a %u %g' "$WIFI_DIR/wpa_supplicant.conf" 2>/dev/null)" = "440 $((NET_UID + 101)) $NET_UID" ]] \ && "$KD" wifi list --wifi-dir "$WIFI_DIR" 2>/dev/null | grep -qx "$AP_SSID"; then - pass "wifi-add" "kryptikd wifi add wrote the file 0400 for uid $NET_UID, lists the SSID, and restarted the net zone" + pass "wifi-add" "kryptikd wifi add wrote the file 0440 for the net zone's supplicant ($((NET_UID + 101))) and root's group ($NET_UID), lists the SSID, and restarted the net zone" else - fail "wifi-add" "$(tr '\n' ' ' < "$LOG/wifi-add.out") file: $(stat -c '%a %u' "$WIFI_DIR/wpa_supplicant.conf" 2>&1) list: $("$KD" wifi list --wifi-dir "$WIFI_DIR" 2>&1 | tr '\n' ' ')" + fail "wifi-add" "$(tr '\n' ' ' < "$LOG/wifi-add.out") file: $(stat -c '%a %u %g' "$WIFI_DIR/wpa_supplicant.conf" 2>&1) list: $("$KD" wifi list --wifi-dir "$WIFI_DIR" 2>&1 | tr '\n' ' ')" fi line="$(ready_after "$before" " wifi=$AP_SSID " 90)" kills="$(dmesg 2>/dev/null | grep -a 'type=1326' | grep -ac 'comm="wpa_supplicant"')" @@ -784,6 +784,33 @@ for _ in $(seq 1 40); do [[ "$lease" == 192.168.77.* ]] && break; sleep 1 done [[ "$lease" == 192.168.77.* ]] && pass "wifi-lease" "$STA_IF in the net zone leased $lease from the access point" || fail "wifi-lease" "$STA_IF holds ${lease:-no address}; leases given: $(tr '\n' ' ' < /run/zt-ap.leases 2>/dev/null)" +# The supplicant that joined runs as its own user with CAP_NET_ADMIN and CAP_NET_RAW alone, +# under its own filter on top of the zone's (supplicant.rs). +sup="$(pgrep -f 'wpa_supplicant -B -i [^ ]* -c /etc/wpa_supplicant.conf' | head -1)" +sup_status="$(cat "/proc/${sup:-0}/status" 2>/dev/null)" +want_id=$(( NET_UID + 101 )) +sup_ids="$(awk '/^(Uid|Gid):/ { print $2, $3, $4, $5 }' <<<"$sup_status" | tr '\n' ' ')" +sup_caps="$(awk '/^Cap(Inh|Prm|Eff|Amb):/ { sub(":", "", $1); printf "%s=%s ", $1, $2 }' <<<"$sup_status")" +sup_filters="$(sed -n 's/^Seccomp_filters:[[:space:]]*//p' <<<"$sup_status")" +if [[ "$sup_ids" == "$(printf '%s ' "$want_id" "$want_id" "$want_id" "$want_id" "$want_id" "$want_id" "$want_id" "$want_id")" \ + && "$sup_caps" == "CapInh=0000000000003000 CapPrm=0000000000003000 CapEff=0000000000003000 CapAmb=0000000000003000 " \ + && "${sup_filters:-0}" -ge 2 ]]; then + pass "wifi-supplicant-unprivileged" "wpa_supplicant (pid $sup) runs as uid/gid $want_id with ${sup_caps}under $sup_filters filters" +else + fail "wifi-supplicant-unprivileged" "pid ${sup:-none}: ids ${sup_ids:-unread}; ${sup_caps:-no caps read}; filters ${sup_filters:-unread}" +fi +# Through the same door, a command runs as that user, and nft cannot reach the zone's nf_tables. +door_rc=1; nft_out="no net zone" +if [[ -n "$net_init" ]]; then + nsenter -t "$net_init" -U -m -n /usr/bin/kryptikd supplicant -- /usr/bin/true > "$LOG/supplicant-door.out" 2>&1; door_rc=$? + nft_out="$(nsenter -t "$net_init" -U -m -n /usr/bin/kryptikd supplicant -- nft add table inet supplicant-probe 2>&1)" +fi +probe_tables="$(nsenter -t "${net_init:-0}" -n nft list tables 2>/dev/null | grep -c supplicant-probe)" +if [[ "$door_rc" = 0 && "${nft_out,,}" == *"not permitted"* && "${probe_tables:-1}" = 0 ]]; then + pass "wifi-supplicant-no-nft" "as the supplicant's user and filter a command ran, and nft was refused: $(tr '\n' ' ' <<<"$nft_out" | cut -c1-120)" +else + fail "wifi-supplicant-no-nft" "the door: rc=$door_rc $(tr '\n' ' ' < "$LOG/supplicant-door.out" | cut -c1-120); nft: $(tr '\n' ' ' <<<"$nft_out" | cut -c1-160); tables named supplicant-probe: ${probe_tables:-unread}" +fi zrun untrusted 40 -- sh -c "python3 /usr/lib/kryptik/guest-tests/icmp-echo.py $AP_ADDR 3 >/dev/null 2>&1 && echo AP-REACHED || echo AP-UNREACHED" sta_seen="$(ap_wpa all_sta | grep -ciE '^[0-9a-f]{2}(:[0-9a-f]{2}){5}$')" if [[ "$ZOUT" == *AP-REACHED* && "${sta_seen:-0}" -ge 1 ]]; then diff --git a/compartments/kryptikd/src/isolate.rs b/compartments/kryptikd/src/isolate.rs index 576ebda4..df5e79be 100644 --- a/compartments/kryptikd/src/isolate.rs +++ b/compartments/kryptikd/src/isolate.rs @@ -122,11 +122,13 @@ fn time_offsets(origin: u64, mono: u64, boot: u64) -> String { /// The id a daemon in a zone that keeps `caps::PRIVSEP` drops to: dhcpcd's, in the nic zone. pub const SERVICE_ID: u32 = 100; +/// wpa_supplicant's own user in the nic zone, mapped beside `SERVICE_ID` (supplicant.rs). +pub const WIFI_ID: u32 = 101; /// Write a new user namespace's id maps, denying setgroups first as an unprivileged gid_map /// needs. `with_nobody` maps 65534 too, which needs CAP_SETUID: a root launch only. `service` -/// maps `SERVICE_ID` as well and leaves setgroups allowed, for that daemon's drop: a root launch -/// of a zone that keeps `caps::PRIVSEP` only. +/// maps `SERVICE_ID` and `WIFI_ID` as well and leaves setgroups allowed, for those daemons' +/// drops: a root launch of a zone that keeps `caps::PRIVSEP` only. pub fn write_id_maps( pid: libc::pid_t, outer_uid: u32, @@ -146,6 +148,7 @@ pub fn write_id_maps( let mut m = format!("0 {outer} 1\n"); if service { m.push_str(&format!("{SERVICE_ID} {} 1\n", outer + SERVICE_ID)); + m.push_str(&format!("{WIFI_ID} {} 1\n", outer + WIFI_ID)); } if with_nobody { m.push_str(&format!("65534 {} 1\n", outer + 65534)); diff --git a/compartments/kryptikd/src/main.rs b/compartments/kryptikd/src/main.rs index ff9bec05..c3d1faaa 100644 --- a/compartments/kryptikd/src/main.rs +++ b/compartments/kryptikd/src/main.rs @@ -20,6 +20,7 @@ mod rootfs; mod seccomp; mod serve; mod spawn; +mod supplicant; mod time; mod update; mod volume; @@ -52,6 +53,10 @@ USAGE: kryptikd seccomp-trace -- CMD run CMD under the base seccomp filter; every [--zone NAME] call it refuses is named and fails with ENOSYS (--zone: NAME's filter, with its policy file) + kryptikd supplicant -- CMD [ARGS] in the net zone, as its root: run CMD (its + wpa_supplicant) as user 101 with CAP_NET_ADMIN + and CAP_NET_RAW alone, no nf_tables socket, and + writes only under /run/wpa_supplicant kryptikd run NAME -- CMD [ARGS] create the zone and run CMD inside it kryptikd stop NAME [--now] stop a running zone (--now = SIGKILL) kryptikd status NAME running, stale or absent @@ -194,6 +199,16 @@ fn main() -> ExitCode { } }, "run" => cmd_run(&zone_dir, &args), + "supplicant" => match args.get(1).map(String::as_str) { + Some("--") if args.len() > 2 => { + eprintln!("kryptikd supplicant: {}", supplicant::exec(&args[2..])); + ExitCode::FAILURE + } + _ => { + eprintln!("supplicant: expected `-- COMMAND`"); + ExitCode::from(2) + } + }, "seccomp-trace" => { let Some(sep) = args.iter().position(|a| a == "--") else { eprintln!("seccomp-trace: expected `-- COMMAND`"); diff --git a/compartments/kryptikd/src/rootfs.rs b/compartments/kryptikd/src/rootfs.rs index 1ff35a71..b57c2c34 100644 --- a/compartments/kryptikd/src/rootfs.rs +++ b/compartments/kryptikd/src/rootfs.rs @@ -213,12 +213,14 @@ pub fn zone_home(zone: &str) -> String { pub const SERVICE_HOME: &str = "/var/empty"; /// Synthesized /etc/passwd: the zone's root and nobody, and with `service` dhcpcd at -/// `isolate::SERVICE_ID`, the user its privilege separation drops to. +/// `isolate::SERVICE_ID`, the user its privilege separation drops to, and wpa_supplicant's. pub fn passwd_for(zone: &str, home: &str, service: bool) -> String { let mut s = format!("root:x:0:0:{zone}:{home}:/bin/sh\n"); if service { let id = crate::isolate::SERVICE_ID; s.push_str(&format!("dhcpcd:x:{id}:{id}:dhcpcd:{SERVICE_HOME}:/bin/false\n")); + let wpa = crate::isolate::WIFI_ID; + s.push_str(&format!("wpa:x:{wpa}:{wpa}:wpa_supplicant:{}:/bin/false\n", crate::supplicant::RUN_DIR)); } s.push_str("nobody:x:65534:65534:nobody:/nonexistent:/bin/false\n"); s @@ -228,6 +230,7 @@ pub fn group_for(service: bool) -> String { let mut s = "root:x:0:\n".to_string(); if service { s.push_str(&format!("dhcpcd:x:{}:\n", crate::isolate::SERVICE_ID)); + s.push_str(&format!("wpa:x:{}:\n", crate::isolate::WIFI_ID)); } s.push_str("nogroup:x:65534:\n"); s @@ -450,6 +453,15 @@ pub fn pivot_into( call, )?; } + // wpa_supplicant's own directory: its control socket and pid files, which its user alone + // writes, and anyone may look up by name (supplicant.rs). + if service { + use std::os::unix::fs::PermissionsExt; + let d = mkdir(&crate::supplicant::RUN_DIR[1..])?; + let id = crate::isolate::WIFI_ID; + std::os::unix::fs::chown(&d, Some(id), Some(id)).map_err(|e| RootfsError::Setup(format!("{d}: {e}")))?; + fs::set_permissions(&d, fs::Permissions::from_mode(0o711)).map_err(|e| RootfsError::Setup(format!("{d}: {e}")))?; + } } // The nic zone's Wi-Fi credentials, bound read-only; no file means no networks configured. diff --git a/compartments/kryptikd/src/rootfs/tests.rs b/compartments/kryptikd/src/rootfs/tests.rs index 3fba4aa1..84e17cd9 100644 --- a/compartments/kryptikd/src/rootfs/tests.rs +++ b/compartments/kryptikd/src/rootfs/tests.rs @@ -106,12 +106,15 @@ fn identity_names_zone() { assert!(pw.starts_with("root:x:0:0:work:/home/work:"), "{pw}"); assert_eq!(pw.lines().count(), 2); assert_eq!(group_for(false).lines().count(), 2); - // The nic zone's dhcpcd drops to the third mapped id, chrooted to an empty directory. + // The nic zone's dhcpcd drops to the third mapped id, chrooted to an empty directory, and its + // wpa_supplicant runs as the fourth. let pw = passwd_for("net", "/home/net", true); - assert_eq!(pw.lines().count(), 3); + assert_eq!(pw.lines().count(), 4); assert!(pw.contains("\ndhcpcd:x:100:100:dhcpcd:/var/empty:/bin/false\n")); - assert!(group_for(true).contains("\ndhcpcd:x:100:\n")); + assert!(pw.contains("\nwpa:x:101:101:wpa_supplicant:/run/wpa_supplicant:/bin/false\n")); + assert!(group_for(true).contains("\ndhcpcd:x:100:\nwpa:x:101:\n")); assert_eq!(crate::isolate::SERVICE_ID, 100); + assert_eq!(crate::isolate::WIFI_ID, 101); assert!(hosts_for("work").contains("127.0.0.1 localhost work")); assert_eq!(zone_home("work"), "/home/work"); assert!(nsswitch().contains("passwd: files")); diff --git a/compartments/kryptikd/src/seccomp.rs b/compartments/kryptikd/src/seccomp.rs index e08ff546..8fb3a8b5 100644 --- a/compartments/kryptikd/src/seccomp.rs +++ b/compartments/kryptikd/src/seccomp.rs @@ -668,6 +668,72 @@ fn install_with( Ok(ret) } +const AF_PACKET: u32 = 17; +const NETLINK_GENERIC: u32 = 16; +const SOCK_DGRAM: u32 = 2; +/// A datagram socket's type with the flags socket(2) takes beside it, SOCK_CLOEXEC and SOCK_NONBLOCK. +const SOCK_DGRAM_FLAGS: u32 = SOCK_DGRAM | 0x8_0000 | 0x800; + +/* wpa_supplicant's own filter (supplicant.rs), stacked on its zone's: every call that filter + * allows, but sockets only as the supplicant opens them. AF_UNIX for its control socket, + * AF_PACKET for EAPOL, netlink for nl80211 and link events alone, and AF_INET datagram sockets, + * which its nl80211 driver takes interface ioctls through. Never NETLINK_NETFILTER: with + * CAP_NET_ADMIN it would rewrite the zone's nf_tables. */ +fn supplicant_program() -> Vec { + let refuse = SECCOMP_RET_ERRNO | EPERM; + vec![ + stmt(BPF_LD | BPF_W | BPF_ABS, OFF_ARCH), + jump(BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_X86_64, 1, 0), + stmt(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS), + stmt(BPF_LD | BPF_W | BPF_ABS, OFF_NR), + jump(BPF_JMP | BPF_JGE | BPF_K, X32_SYSCALL_BIT, 0, 1), + stmt(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS), + jump(BPF_JMP | BPF_JEQ | BPF_K, libc::SYS_socket as u32, 1, 0), + stmt(BPF_RET | BPF_K, SECCOMP_RET_ALLOW), + // The family; jumps land on the two returns at the end. + stmt(BPF_LD | BPF_W | BPF_ABS, arg_lo(0)), + jump(BPF_JMP | BPF_JEQ | BPF_K, AF_UNIX, 9, 0), + jump(BPF_JMP | BPF_JEQ | BPF_K, AF_PACKET, 8, 0), + jump(BPF_JMP | BPF_JEQ | BPF_K, AF_NETLINK, 1, 0), + jump(BPF_JMP | BPF_JEQ | BPF_K, AF_INET, 3, 7), + stmt(BPF_LD | BPF_W | BPF_ABS, arg_lo(2)), + jump(BPF_JMP | BPF_JEQ | BPF_K, NETLINK_ROUTE, 4, 0), + jump(BPF_JMP | BPF_JEQ | BPF_K, NETLINK_GENERIC, 3, 4), + stmt(BPF_LD | BPF_W | BPF_ABS, arg_lo(1)), + jump(BPF_JMP | BPF_JSET | BPF_K, !SOCK_DGRAM_FLAGS, 2, 0), + jump(BPF_JMP | BPF_JSET | BPF_K, SOCK_DGRAM, 0, 1), + stmt(BPF_RET | BPF_K, SECCOMP_RET_ALLOW), + stmt(BPF_RET | BPF_K, refuse), + ] +} + +/// Stack the supplicant's filter on this single-threaded process, through prctl: the zone's own +/// filter refuses seccomp(2). +pub fn install_supplicant_filter() -> Result<(), SeccompError> { + if !cfg!(target_arch = "x86_64") { + return Err(SeccompError::UnsupportedArch); + } + let prog = supplicant_program(); + let fprog = SockFprog { len: prog.len() as u16, filter: prog.as_ptr() }; + let os = |call| SeccompError::Syscall { call, errno: io::Error::last_os_error().raw_os_error().unwrap_or(0) }; + if unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) } < 0 { + return Err(os("prctl(PR_SET_NO_NEW_PRIVS)")); + } + let set = unsafe { + libc::prctl( + libc::PR_SET_SECCOMP, + libc::SECCOMP_MODE_FILTER as libc::c_ulong, + &fprog as *const SockFprog as libc::c_ulong, + 0, + 0, + ) + }; + if set < 0 { + return Err(os("prctl(PR_SET_SECCOMP)")); + } + Ok(()) +} + /// Install the standard zone filter. pub fn confine_zone() -> Result<(), SeccompError> { install(BASE_ALLOWLIST) diff --git a/compartments/kryptikd/src/seccomp/tests.rs b/compartments/kryptikd/src/seccomp/tests.rs index b7b77264..37bf26ba 100644 --- a/compartments/kryptikd/src/seccomp/tests.rs +++ b/compartments/kryptikd/src/seccomp/tests.rs @@ -617,3 +617,37 @@ fn each_call_named_once() { } assert_eq!(name_of(libc::SYS_futex), Some("futex")); } + +#[test] +fn supplicant_sockets_only_as_it_opens_them() { + let p = supplicant_program(); + let socket = |family: u64, kind: u64, protocol: u64| { + evaluate_args(&p, AUDIT_ARCH_X86_64, libc::SYS_socket as u32, [family, kind, protocol, 0, 0, 0]) + }; + let refused = SECCOMP_RET_ERRNO | EPERM; + for (family, kind, protocol, why) in [ + (1, 1 | 0x8_0000, 0, "its control socket"), + (17, 3, 0x888e, "EAPOL"), + (16, 3 | 0x8_0000, 16, "nl80211"), + (16, 3, 0, "link events"), + (2, 2, 0, "the driver's ioctl socket"), + (2, 2 | 0x8_0000 | 0x800, 0, "the same, close-on-exec and non-blocking"), + ] { + assert_eq!(socket(family, kind, protocol), SECCOMP_RET_ALLOW, "{why}"); + } + for (family, kind, protocol, why) in [ + (16, 3, 12, "nf_tables"), + (16, 3, 9, "audit"), + (2, 1, 0, "TCP"), + (2, 3, 0, "raw IP"), + (2, 2 | 0x10, 0, "a datagram type with an unknown flag"), + (10, 2, 0, "IPv6"), + (40, 1, 0, "vsock"), + ] { + assert_eq!(socket(family, kind, protocol), refused, "{why}"); + } + // Every other call is its zone filter's to decide. + assert_eq!(evaluate(&p, AUDIT_ARCH_X86_64, libc::SYS_read as u32), SECCOMP_RET_ALLOW); + assert_eq!(evaluate(&p, AUDIT_ARCH_X86_64, X32_SYSCALL_BIT | 41), SECCOMP_RET_KILL_PROCESS, "x32"); + assert_eq!(evaluate(&p, 0x4000_0003, libc::SYS_read as u32), SECCOMP_RET_KILL_PROCESS, "another arch"); +} diff --git a/compartments/kryptikd/src/spawn.rs b/compartments/kryptikd/src/spawn.rs index a714dd87..534ee92f 100644 --- a/compartments/kryptikd/src/spawn.rs +++ b/compartments/kryptikd/src/spawn.rs @@ -797,9 +797,10 @@ pub fn run_in_zone( } else { opts.wifi_dir.clone() }; - // A file an older kryptikd wrote leaves key management to wpa_supplicant's default. - match crate::wifi::refresh(&dir, id.privileged.then_some((id.uid, id.gid))) { - Ok(true) => eprintln!("kryptikd: zone {:?}: the Wi-Fi file now names each network's key management", zone.name), + // A file an older kryptikd wrote leaves key management to wpa_supplicant's default, and + // its user cannot read it. + match crate::wifi::refresh(&dir, id.privileged.then_some(crate::wifi::owner_in(id.uid, id.gid))) { + Ok(true) => eprintln!("kryptikd: zone {:?}: the Wi-Fi file was rewritten for this kryptikd", zone.name), Ok(false) => {} Err(e) => eprintln!("kryptikd: zone {:?}: the Wi-Fi file was not brought up to date: {e}", zone.name), } diff --git a/compartments/kryptikd/src/supplicant.rs b/compartments/kryptikd/src/supplicant.rs new file mode 100644 index 00000000..a066ce7f --- /dev/null +++ b/compartments/kryptikd/src/supplicant.rs @@ -0,0 +1,107 @@ +//! wpa_supplicant off root in the nic zone (docs/design/net-zone.md): `kryptikd supplicant` +//! starts it as its own mapped user with CAP_NET_ADMIN and CAP_NET_RAW alone, under a socket +//! filter that keeps it off nf_tables and a Landlock ruleset that keeps it to what it reads and +//! its own directory. hostap's own privilege separation carries neither SAE nor PMF +//! (docs/design/net-zone-surface.md). + +use std::io; +use std::os::unix::process::CommandExt; + +use crate::caps::cap; +use crate::isolate::WIFI_ID; +use crate::landlock; + +/// Its own directory, which rootfs.rs makes for `WIFI_ID`: the control socket and pid files. +pub const RUN_DIR: &str = "/run/wpa_supplicant"; + +/// nl80211 and interface flags take CAP_NET_ADMIN, EAPOL's packet socket CAP_NET_RAW. +pub const CAPS: [libc::c_int; 2] = [cap::NET_ADMIN, cap::NET_RAW]; + +/// Become the supplicant's user with `CAPS` alone, confined, and exec `argv`. Returns only on +/// failure, with what failed. +pub fn exec(argv: &[String]) -> String { + match confine() { + Err(e) => e, + Ok(()) => format!("{}: {}", argv[0], std::process::Command::new(&argv[0]).args(&argv[1..]).exec()), + } +} + +fn confine() -> Result<(), String> { + // Built while every path it names can still be opened. + let rules = ruleset()?; + let os = |call: &str| format!("{call}: {}", io::Error::last_os_error()); + // SAFETY: plain calls on this process's credentials; none touches memory. + unsafe { + // Permitted capabilities outlive the change of uid; ambient ones are raised after it. + if libc::prctl(libc::PR_SET_KEEPCAPS, 1, 0, 0, 0) < 0 { + return Err(os("prctl(PR_SET_KEEPCAPS)")); + } + if libc::setgroups(0, std::ptr::null()) < 0 { + return Err(os("setgroups")); + } + if libc::setgid(WIFI_ID) < 0 { + return Err(os("setgid")); + } + if libc::setuid(WIFI_ID) < 0 { + return Err(os("setuid")); + } + } + set_caps(&CAPS)?; + for c in CAPS { + let raised = unsafe { + libc::prctl(libc::PR_CAP_AMBIENT, libc::PR_CAP_AMBIENT_RAISE as libc::c_ulong, c as libc::c_ulong, 0 as libc::c_ulong, 0 as libc::c_ulong) + }; + if raised < 0 { + return Err(os("prctl(PR_CAP_AMBIENT_RAISE)")); + } + } + rules.restrict_self().map_err(|e| e.to_string())?; + crate::seccomp::install_supplicant_filter().map_err(|e| e.to_string()) +} + +/// Read where its libraries, configuration and the kernel's views are, run from /usr, the +/// devices it opens, and write nowhere but `RUN_DIR`. +fn ruleset() -> Result { + let mut r = landlock::Ruleset::new().map_err(|e| e.to_string())?; + for (path, access) in [ + ("/usr", landlock::ACCESS_READ | landlock::ACCESS_EXEC), + ("/etc", landlock::ACCESS_READ), + ("/proc", landlock::ACCESS_READ), + ("/sys", landlock::ACCESS_READ), + // /dev/null for daemon(3), /dev/random and /dev/urandom for its entropy. + ("/dev", landlock::ACCESS_READ | landlock::ACCESS_WRITE_FILE), + (RUN_DIR, landlock::ACCESS_READ | landlock::ACCESS_WRITE), + ] { + r.allow(path, access).map_err(|e| format!("{path}: {e}"))?; + } + Ok(r) +} + +/// Effective, permitted and inheritable sets all `caps`: capset narrows what the process holds. +fn set_caps(caps: &[libc::c_int]) -> Result<(), String> { + #[repr(C)] + struct Header { + version: u32, + pid: libc::c_int, + } + #[repr(C)] + #[derive(Clone, Copy, Default)] + struct Data { + effective: u32, + permitted: u32, + inheritable: u32, + } + // _LINUX_CAPABILITY_VERSION_3: two words of each set. + let header = Header { version: 0x2008_0522, pid: 0 }; + let mut data = [Data::default(); 2]; + for &c in caps { + let (word, bit) = ((c / 32) as usize, 1u32 << (c % 32)); + data[word].effective |= bit; + data[word].permitted |= bit; + data[word].inheritable |= bit; + } + if unsafe { libc::syscall(libc::SYS_capset, &header as *const Header, data.as_ptr()) } < 0 { + return Err(format!("capset: {}", io::Error::last_os_error())); + } + Ok(()) +} diff --git a/compartments/kryptikd/src/wifi.rs b/compartments/kryptikd/src/wifi.rs index 7bef5b34..385cde5c 100644 --- a/compartments/kryptikd/src/wifi.rs +++ b/compartments/kryptikd/src/wifi.rs @@ -1,5 +1,6 @@ //! Wi-Fi credentials for the net zone (docs/design/net-zone.md). kryptikd alone writes the file, -//! 0400 and owned by the host uid of the nic zone's root; the zone gets it read-only on next start. +//! 0440, owned by the nic zone's wpa_supplicant user and grouped with its root, which checks it is +//! there; the zone gets it read-only on next start. use std::fs; use std::io::{self, Read, Write}; @@ -207,13 +208,17 @@ pub fn load(dir: &Path) -> Result, String> { parse(&text).map_err(|e| format!("{}: {e}", conf_path(dir).display())) } -/// Rewrite in today's form a file an older kryptikd wrote, so the net zone reads each network's -/// key management; true when it was rewritten. A current, absent or foreign file is left alone. +/// Rewrite in today's form, owner and mode a file an older kryptikd wrote, so the net zone's +/// supplicant reads it and each network's key management; true when it was rewritten. A +/// current, absent or foreign file is left alone. pub fn refresh(dir: &Path, owner: Option<(u32, u32)>) -> Result { + use std::os::unix::fs::MetadataExt; let Some(text) = read_text(dir)? else { return Ok(false) }; let nets = parse(&text).map_err(|e| format!("{}: {e}", conf_path(dir).display()))?; let current = render(&nets); - if current == text { + let md = fs::symlink_metadata(conf_path(dir)).map_err(|e| format!("{}: {e}", conf_path(dir).display()))?; + let owned = owner.map_or(true, |(u, g)| md.uid() == u && md.gid() == g); + if current == text && owned && md.mode() & 0o7777 == MODE { return Ok(false); } write_atomic(dir, ¤t, owner)?; @@ -225,7 +230,7 @@ pub fn list(dir: &Path) -> Result, String> { Ok(load(dir)?.into_iter().map(|n| n.ssid).collect()) } -/// The nic zone's identity, to own the file on a root run; `None` keeps the writer's. +/// Who owns the file on a root run, from the nic zone's identity; `None` keeps the writer's. pub fn owner_for(zones_dir: &Path) -> Result, String> { if unsafe { libc::geteuid() } != 0 { return Ok(None); @@ -236,7 +241,13 @@ pub fn owner_for(zones_dir: &Path) -> Result, String> { .iter() .find(|z| z.network == NetworkMode::Nic) .and_then(|z| z.uid_base) - .map(|b| (b, b))) + .map(|b| owner_in(b, b))) +} + +/// The host ids that own the file for a nic zone whose root is host `uid`/`gid`: its +/// supplicant's user, which reads it, and its root's group, as `MODE` lets both read. +pub fn owner_in(uid: u32, gid: u32) -> (u32, u32) { + (uid + crate::isolate::WIFI_ID, gid) } /// Add a network or replace its block; nothing is written unless both values pass. @@ -292,11 +303,15 @@ fn ensure_dir(dir: &Path) -> Result<(), String> { } } -/// The file, 0400 and owned as asked, replaced whole. +/// Read by its owner, the supplicant's user, and its group, the zone's root; dhcpcd and nobody +/// in the zone cannot. +const MODE: u32 = 0o440; + +/// The file, `MODE` and owned as asked, replaced whole. fn write_atomic(dir: &Path, contents: &str, owner: Option<(u32, u32)>) -> Result<(), String> { ensure_dir(dir)?; let path = conf_path(dir); - crate::files::write_atomic(&path, &[contents.as_bytes()], 0o400, owner).map_err(|e| format!("{}: {e}", path.display())) + crate::files::write_atomic(&path, &[contents.as_bytes()], MODE, owner).map_err(|e| format!("{}: {e}", path.display())) } /// Restart the net zone on the new file and say how it went; other directories (tests) skip this. diff --git a/compartments/kryptikd/src/wifi/tests.rs b/compartments/kryptikd/src/wifi/tests.rs index a4861f63..e140ac72 100644 --- a/compartments/kryptikd/src/wifi/tests.rs +++ b/compartments/kryptikd/src/wifi/tests.rs @@ -22,7 +22,7 @@ fn add_list_forget_round_trip() { \nnetwork={\n\tssid=\"Cafe Wifi\"\n\tkey_mgmt=WPA-PSK WPA-PSK-SHA256\n\tieee80211w=1\n\tpsk=0123456789abcdef0123456789ABCDEF0123456789abcdef0123456789abcdef\n}\n" ); let md = fs::metadata(conf_path(&dir)).unwrap(); - assert_eq!(md.mode() & 0o7777, 0o400); + assert_eq!(md.mode() & 0o7777, 0o440); assert_eq!(fs::metadata(&dir).unwrap().mode() & 0o7777, 0o711); forget(&dir, None, "Home").unwrap(); assert_eq!(list(&dir).unwrap(), vec!["Cafe Wifi"]); @@ -191,9 +191,14 @@ fn older_file_rewritten_with_key_management() { assert!(refresh(&dir, None).unwrap()); let now = fs::read_to_string(conf_path(&dir)).unwrap(); assert!(now.contains("\tkey_mgmt=WPA-PSK WPA-PSK-SHA256 SAE\n\tieee80211w=1\n"), "{now}"); - assert_eq!(fs::metadata(conf_path(&dir)).unwrap().mode() & 0o7777, 0o400); + assert_eq!(fs::metadata(conf_path(&dir)).unwrap().mode() & 0o7777, 0o440); assert!(!refresh(&dir, None).unwrap(), "a current file is left alone"); - // Replaced, as the 0400 file takes no write. + // An older kryptikd's mode, which the supplicant's own user cannot read through its group. + fs::set_permissions(conf_path(&dir), fs::Permissions::from_mode(0o400)).unwrap(); + assert!(refresh(&dir, None).unwrap(), "a current file at 0400 is rewritten"); + assert_eq!(fs::metadata(conf_path(&dir)).unwrap().mode() & 0o7777, 0o440); + assert_eq!(fs::read_to_string(conf_path(&dir)).unwrap(), now, "and keeps its text"); + // Replaced, as the 0440 file takes no write. fs::remove_file(conf_path(&dir)).unwrap(); fs::write(conf_path(&dir), "ap_scan=1\n").unwrap(); assert!(refresh(&dir, None).unwrap_err().contains("something else edited")); diff --git a/compartments/tests/cli.sh b/compartments/tests/cli.sh index 6a869886..6ea090cc 100755 --- a/compartments/tests/cli.sh +++ b/compartments/tests/cli.sh @@ -264,8 +264,8 @@ else fail "wifi add: exit $rc" info "output: $(printf '%s' "$out" | tr '\n' '|' | cut -c1-240)" fi -if [[ "$(stat -c %a "$WCONF" 2>/dev/null)" == 400 ]] && grep -qxF $'\tssid="Home"' "$WCONF" && grep -qxF $'\tpsk="correct horse battery"' "$WCONF"; then - pass "the file is 0400 and holds the ssid= and psk= lines kryptikd writes" +if [[ "$(stat -c %a "$WCONF" 2>/dev/null)" == 440 ]] && grep -qxF $'\tssid="Home"' "$WCONF" && grep -qxF $'\tpsk="correct horse battery"' "$WCONF"; then + pass "the file is 0440 and holds the ssid= and psk= lines kryptikd writes" else fail "the file is not as kryptikd writes it (mode $(stat -c %a "$WCONF" 2>&1))" fi diff --git a/compartments/tests/serve.sh b/compartments/tests/serve.sh index 2b9a193c..bd0b1bd6 100755 --- a/compartments/tests/serve.sh +++ b/compartments/tests/serve.sh @@ -396,7 +396,7 @@ if [[ "$r" == "ok added network \"Home\"; the net zone was not restarted"* && -f else fail "S9b wifi-add: $r" fi -if [[ "$(stat -c %a "$WCONF" 2>/dev/null)" == 400 ]]; then pass "S9c the file is mode 0400"; else fail "S9c mode $(stat -c %a "$WCONF" 2>&1)"; fi +if [[ "$(stat -c %a "$WCONF" 2>/dev/null)" == 440 ]]; then pass "S9c the file is mode 0440"; else fail "S9c mode $(stat -c %a "$WCONF" 2>&1)"; fi if grep -qxF $'\tssid="Home"' "$WCONF" && grep -qxF $'\tpsk="correct horse battery"' "$WCONF"; then pass "S9d ... holding the ssid= and psk= lines, quoted and tab-indented" else @@ -404,8 +404,8 @@ else fi if [[ "$(stat -c %a "$WIFI")" == 711 ]]; then pass "S9e the directory is 0711: traversable, not listable"; else fail "S9e directory mode $(stat -c %a "$WIFI")"; fi if (( PRIVILEGED == 1 )); then - # carrier is the nic zone, the only reader of the file. - if [[ "$(stat -c %u:%g "$WCONF")" == "262144:262144" ]]; then pass "S9f the file is owned by the nic zone's identity (carrier, uid_base 262144)"; else fail "S9f owned by $(stat -c %u:%g "$WCONF"), not 262144:262144"; fi + # carrier is the nic zone, the only reader of the file: its supplicant's user (101) and root's group. + if [[ "$(stat -c %u:%g "$WCONF")" == "262245:262144" ]]; then pass "S9f the file is owned by the nic zone's supplicant user and its root's group (carrier, uid_base 262144)"; else fail "S9f owned by $(stat -c %u:%g "$WCONF"), not 262245:262144"; fi else if [[ "$(stat -c %u "$WCONF")" == "$EUID" ]]; then pass "S9f an unprivileged daemon leaves the file owned by its writer"; else fail "S9f owned by $(stat -c %u "$WCONF")"; fi fi diff --git a/compartments/zones/policy/net.seccomp b/compartments/zones/policy/net.seccomp index 3e4a16be..e6bd659d 100644 --- a/compartments/zones/policy/net.seccomp +++ b/compartments/zones/policy/net.seccomp @@ -10,3 +10,8 @@ allow-netlink NETLINK_GENERIC keep-capability CAP_SETUID keep-capability CAP_SETGID keep-capability CAP_SYS_CHROOT +# `kryptikd supplicant` stacks a Landlock ruleset on wpa_supplicant before it leaves root: +# these only narrow what their caller may reach. +allow-syscall landlock_create_ruleset +allow-syscall landlock_add_rule +allow-syscall landlock_restrict_self diff --git a/docs/design/net-zone-surface.md b/docs/design/net-zone-surface.md index 753b9e43..0f41c94e 100644 --- a/docs/design/net-zone-surface.md +++ b/docs/design/net-zone-surface.md @@ -1,7 +1,7 @@ # Narrowing what the net zone reaches in the kernel -**A proposal, waiting for the owner's decision**, but for the time namespace, -which is built. The +**A proposal, waiting for the owner's decision**, but for the time namespace +and the supplicant's own user, which are built. The [threat model](../threat-model.md#compromised-net-zone) lists what a compromised net zone still reaches. This note weighs three ways to narrow it: a filter on generic-netlink families, wpa_supplicant's privilege separation, @@ -22,7 +22,8 @@ Its policy (`compartments/zones/policy/net.seccomp`) opens packet sockets which it exits; its root helper keeps `CAP_NET_ADMIN`, its parsers keep nothing; - wpa_supplicant: nl80211 through generic netlink, and a packet socket for - EAPOL, as the zone's root (`netzone-init.sh:204`). + EAPOL, as its own user with `CAP_NET_ADMIN` and `CAP_NET_RAW` + (`compartments/kryptikd/src/supplicant.rs`). Generic netlink is one protocol for every family the kernel builds, so the policy cannot open nl80211 alone. A family's commands marked @@ -66,9 +67,9 @@ working. ## wpa_supplicant's privilege separation -**Today.** wpa_supplicant runs as the zone's root with every capability the +**Before.** wpa_supplicant ran as the zone's root with every capability the zone keeps, and parses what the air sends: scan results, EAPOL and EAP. A -flaw there gets `CAP_NET_ADMIN` over nf_tables and the routes, and the +flaw there got `CAP_NET_ADMIN` over nf_tables and the routes, and the broker's socket. **What hostap offers.** `CONFIG_PRIVSEP` (still in hostap 2.12's defconfig, @@ -80,24 +81,39 @@ README's other way, `CAP_NET_ADMIN` and `CAP_NET_RAW` without root, keeps the network administrator's capability, so it narrows file access and not the kernel. -**What it costs.** - -- **What works.** The privsep wrapper (`src/drivers/driver_privsep.c`) - implements scan, keys, authentication, association and a few events, and - the README warns that it lags the full driver interface. Whether SAE and - OWE, which the recipe builds (`wpa-supplicant.sh:18-19`), work through - it is for the hwsim suite to show. -- **The build.** With `CONFIG_PRIVSEP`, wpa_supplicant drives only the privsep - wrapper, so the zones suite's access point, the same binary in AP mode - (`CONFIG_AP`, `wpa-supplicant.sh:14`), needs a build of its own. -- **The zone.** A fourth mapped id, beside root, dhcpcd's 100 and nobody; - `wpa_priv`'s socket directory; `netzone-init.sh` starting `wpa_priv` for each - radio, then wpa_supplicant as that user; and its control socket, which - `netzone-init.sh:217` reads for the status line, moved where that user can - create it. - -**What it closes.** A flaw in what parses scan results, EAPOL or EAP gets an -unprivileged user with no capability, instead of the zone's root. +**Why not hostap's.** Its wrapper lags the driver interface where the +networks Kryptik joins need it. In hostap 2.12, as the recipe builds it: + +- the associate command has no field for protected management frames + (`struct privsep_cmd_associate`, `src/common/privsep_commands.h:60-74`), so + `wpa_priv` never asks nl80211 for them (`wpa_supplicant/wpa_priv.c:296-337`), + and SAE needs them, as every saved network asks for them where offered; +- a deauthentication never reaches the driver: + `wpa_driver_privsep_deauthenticate` logs a TODO and returns 0 + (`src/drivers/driver_privsep.c:379-387`); +- with `CONFIG_PRIVSEP` the binary drives only that wrapper, so the zones + suite's access point, the same binary in AP mode (`CONFIG_AP`, + `wpa-supplicant.sh:14`), would need a build of its own. + +SAE's own frames would pass (`driver_privsep.c:290-291`, +`wpa_priv.c:286-288`); the association after them would not be protected. + +**Built instead.** The README's other way, without root: +`kryptikd supplicant` starts wpa_supplicant as the zone's id 101 with +`CAP_NET_ADMIN` and `CAP_NET_RAW` as ambient capabilities, under a socket +filter of its own (unix, packet and datagram inet sockets, route and generic +netlink) and a Landlock ruleset that writes in `/run/wpa_supplicant` alone. +The credentials file is 0440, readable by that user and the zone root's +group. `wifi-supplicant-unprivileged` checks the running supplicant's ids, +capability sets and filters, `wifi-supplicant-no-nft` that the same +confinement cannot add an nf_tables table, and `wifi-wpa2-plain` and +`wifi-sae` that it still joins both ways. + +**What it closes.** A flaw in what parses scan results, EAPOL or EAP gets +that user: not nf_tables, the broker's socket, or the zone root's and +dhcpcd's files. **What it leaves:** the network administrator's capability +over the zone's links and routes, and nl80211, which hostap's way would have +held in `wpa_priv` as root instead. ## A time namespace per zone @@ -152,20 +168,21 @@ frame callbacks while the zone's clock stands apart from the compositor's ## Side by side -| | genl family filter | wpa_supplicant privsep | time namespace | +| | genl family filter | wpa_supplicant's own user | time namespace | | --- | --- | --- | --- | -| closes | ethtool's setters, other families' namespace-admin commands | the zone's root for a flaw in Wi-Fi parsing | the shared boot and monotonic clocks, for what reads clocks | -| leaves | nf_tables, packet sockets, nl80211 | `wpa_priv` as root; the kernel's own 802.11 parsing | idle time, `CLOCK_REALTIME`, the offsets in `/proc/self/timens_offsets`, the scheduler's clock in `/proc//sched`, the machine's time data in the vDSO, the CPU's timestamp counter | -| needs | BPF and a BPF LSM, or a kernel patch | a second build, a mapped id, a trial of WPA3 | a flag and an offsets write in kryptikd (built) | +| closes | ethtool's setters, other families' namespace-admin commands | nf_tables, the broker's socket and the zone root's files, for a flaw in Wi-Fi parsing | the shared boot and monotonic clocks, for what reads clocks | +| leaves | nf_tables, packet sockets, nl80211 | `CAP_NET_ADMIN` and `CAP_NET_RAW` over the zone's links; the kernel's own 802.11 parsing | idle time, `CLOCK_REALTIME`, the offsets in `/proc/self/timens_offsets`, the scheduler's clock in `/proc//sched`, the machine's time data in the vDSO, the CPU's timestamp counter | +| needs | BPF and a BPF LSM, or a kernel patch | a mapped id, a socket filter and a ruleset in kryptikd (built) | a flag and an offsets write in kryptikd (built) | | a suite can show it | yes | yes, over hwsim | yes | ## Proposed decision - **A time namespace per zone: built.** A flag and a write in kryptikd, no kernel change, and a check in the zones and desktop suites each. -- **wpa_supplicant's privilege separation: a trial first.** Build it beside - the current binary and run the hwsim suite with WPA2 and SAE; adopt it only - if the networks Kryptik supports still join. +- **wpa_supplicant as its own user: built.** hostap's privilege separation + cannot ask for protected management frames, which SAE needs, so the + supplicant runs as its own user with the two capabilities nl80211 and + EAPOL take, and the hwsim suite joins WPA2 and SAE that way. - **The family filter: not now.** Each way to enforce it either builds BPF, which the hardening leaves out for the whole machine, or carries a kernel patch through every LTS bump, for families the zone's root alone reaches. diff --git a/docs/design/net-zone.md b/docs/design/net-zone.md index a90aac78..147981b1 100755 --- a/docs/design/net-zone.md +++ b/docs/design/net-zone.md @@ -89,7 +89,8 @@ query and the [update](update-channel.md) fetcher. Builds on plus `policy/net.seccomp`: `AF_PACKET`, `NETLINK_NETFILTER`, `NETLINK_GENERIC` (dhcpcd opens one for nl80211 and exits if refused), `CAP_NET_ADMIN` / `CAP_NET_RAW` over its own interfaces, and `CAP_SETUID`, - `CAP_SETGID` and `CAP_SYS_CHROOT` for dhcpcd's privilege separation. + `CAP_SETGID` and `CAP_SYS_CHROOT` for dhcpcd's privilege separation and + the supplicant's own user, and Landlock's three calls for its ruleset. `chown`, which dhcpcd calls on its control socket, is in the base list. The net zone alone gets private tmpfs mounts at `/run` and `/var/lib` (writable under Landlock, no exec), where dhcpcd keeps its pid file, @@ -277,15 +278,30 @@ configured the zone keeps its IPv4 path and the launcher says so. (`nl80211_mgmt_subscribe_non_ap` in hostap's `driver_nl80211.c`), which any radio in range can send it unassociated, for a way of joining networks Kryptik does not offer. +- **The supplicant is not the zone's root.** `kryptikd supplicant` starts + each one as `wpa`, the zone's id 101 beside dhcpcd's 100, with + `CAP_NET_ADMIN` and `CAP_NET_RAW` alone, raised as ambient capabilities so + they outlive the exec: nl80211 and interface flags take the first, EAPOL's + packet socket the second. A filter of its own on top of the zone's opens + unix, packet and datagram inet sockets and route and generic netlink, so + not nf_tables, and Landlock lets it read `/usr`, `/etc`, `/proc` and + `/sys`, use `/dev`, and write only in `/run/wpa_supplicant`, where its + control sockets and pid files are. The zone's root reads the status line's + `wifi=` from the kernel (`iw dev NIC link`), as the control socket is the + supplicant's. hostap's own privilege separation was the other way, and + [cannot protect management frames](net-zone-surface.md#wpa_supplicants-privilege-separation). - **The net zone knows the passphrases.** They live in zone 0 at `/var/lib/kryptik/wifi/wpa_supplicant.conf`, written only by `kryptikd serve` for `kryptik wifi add|forget `. The passphrase is read on the terminal and never appears on a command line or in a log, and `kryptik wifi list` shows SSIDs only. The file is in wpa_supplicant's own format, so - kryptikd derives no keys. It is rewritten by temp-and-rename, 0400, owned by - the net zone's identity in a root-owned 0711 directory: the zone's root is - host uid N, so a root-owned 0600 file would be unreadable to it, and nothing - else runs as N. Only the net zone gets it, bound read-only at + kryptikd derives no keys. It is rewritten by temp-and-rename, 0440, owned by + the supplicant's user and the zone root's group, in a root-owned 0711 + directory: the zone's root is host uid N and the supplicant N+101, so a + root-owned 0600 file would be unreadable to both, and nothing else runs as + either; dhcpcd and nobody in the zone cannot read it. A file an older + kryptikd wrote, 0400 and the root's alone, is rewritten so as the net zone + starts. Only the net zone gets it, bound read-only at `/etc/wpa_supplicant.conf`, and a change restarts the `net-zone` service (`s6-svc -r`). - **A saved network names how it may be joined.** A passphrase by WPA2's @@ -294,8 +310,7 @@ configured the zone keeps its IPv4 path and the launcher says so. 802.1X, so no access point's beacon chooses EAP for a saved SSID. Unnamed, wpa_supplicant would take `WPA-PSK WPA-EAP` and never SAE. Management frames are protected wherever the access point offers it (`ieee80211w=1`), and SAE - always has them. A file an older kryptikd wrote is rewritten so as the net - zone starts. + always has them. A file an older kryptikd wrote is rewritten so. - On disk the file is plaintext inside the [encrypted](state-encryption.md) state partition, like NetworkManager's connection files. A compromised net zone learns the passphrases of the networks it was given, and nothing more. diff --git a/docs/threat-model.md b/docs/threat-model.md index 91b5fb40..284a4f58 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -126,8 +126,11 @@ Kept by design: - confidentiality between a zone and the net zone is the application's, as on any network; -- wpa_supplicant runs as the zone's root and reads every passphrase it was - given, as it must to join those networks; +- wpa_supplicant reads every passphrase it was given, as it must to join + those networks, and keeps `CAP_NET_ADMIN` and `CAP_NET_RAW` over the zone's + links and routes for nl80211 and EAPOL; it runs as its own user, without + nf_tables, the broker's socket or the zone root's files + (`wifi-supplicant-unprivileged`, `wifi-supplicant-no-nft`); - nf_tables, packet sockets and generic netlink stay open to it, as NAT, DHCP and Wi-Fi need them; - a routed zone that floods can hold the resolver's 150 forwarding slots, each diff --git a/tools/image/zones-test.sh b/tools/image/zones-test.sh index bad4f868..0de0663c 100755 --- a/tools/image/zones-test.sh +++ b/tools/image/zones-test.sh @@ -69,7 +69,7 @@ for name in kernel-support policies net-ready net-dns dhcpcd-separated dnsmasq-u wifi-no-dpp \ uplink-renamed-plain uplink-state-reset \ radio-recarried \ - wifi-wpa2-plain wifi-sae \ + wifi-wpa2-plain wifi-sae wifi-supplicant-unprivileged wifi-supplicant-no-nft \ zone-clocks-own \ time-floor-ran time-clamp time-floor-forged time-claim-stepped time-claim-floor time-claim-consent pids-limit ephemeral-size-bound cpu-max-set lifecycle-repeat lifecycle-registry resolver-after-attach dns-cache-off dns-counters-hidden net-zone-sysfs-nics-only \ terminal-terminfo man-page text-browser tls-trust no-shared-writable-mount net-zone-no-zone-data no-swap no-dbus \ diff --git a/tools/net/netzone-init.sh b/tools/net/netzone-init.sh index a25f867d..45290812 100755 --- a/tools/net/netzone-init.sh +++ b/tools/net/netzone-init.sh @@ -12,7 +12,7 @@ say() { printf 'netzone: %s\n' "$*"; } BR=kryptik0 STATUS=/run/netzone-status WPA_CONF=/etc/wpa_supplicant.conf # bound in read-only from zone 0 (kryptik wifi add) -WPA_CTRL=/run/wpa_supplicant +WPA_CTRL=/run/wpa_supplicant # the supplicant's own: kryptikd makes it for its user alone forwarding() { # forwarding on|off v=0; [ "$1" = on ] && v=1 @@ -193,15 +193,16 @@ fi # --- wireless: associate before asking for a lease --------------------------- # One supplicant per radio, each with a pid file so the loop can tell a dead one from a slow one. -wpa_pid() { cat "/run/wpa_supplicant.$1.pid" 2>/dev/null; } +# It runs as its own user, which this root cannot signal: its /proc entry says whether it lives. +wpa_pid() { cat "$WPA_CTRL/$1.pid" 2>/dev/null; } +wpa_alive() { p="$(wpa_pid "$1")"; [ -n "$p" ] && [ "$(cat "/proc/$p/comm" 2>/dev/null)" = wpa_supplicant ]; } start_wifi() { # start_wifi : 0 started or already running, 1 not n="$1" - p="$(wpa_pid "$n")" - if [ -n "$p" ] && kill -0 "$p" 2>/dev/null; then return 0; fi + if wpa_alive "$n"; then return 0; fi command -v wpa_supplicant >/dev/null 2>&1 || { say "wifi: no wpa_supplicant in this image; ${n} stays unassociated"; return 1; } [ -r "$WPA_CONF" ] || { say "wifi: ${n}: no ${WPA_CONF} (nothing added with 'kryptik wifi add'); unconfigured"; return 1; } - mkdir -p "$WPA_CTRL" 2>/dev/null - if wpa_supplicant -B -i "$n" -c "$WPA_CONF" -C "$WPA_CTRL" -P "/run/wpa_supplicant.$n.pid" 2>/tmp/wpa.err; then + # Not as root: kryptikd gives it its own user, CAP_NET_ADMIN and CAP_NET_RAW, and no nf_tables. + if kryptikd supplicant -- wpa_supplicant -B -i "$n" -c "$WPA_CONF" -C "$WPA_CTRL" -P "$WPA_CTRL/$n.pid" 2>/tmp/wpa.err; then say "wifi: wpa_supplicant on ${n} (pid $(wpa_pid "$n"))" return 0 fi @@ -209,18 +210,18 @@ start_wifi() { # start_wifi : 0 started or already running, 1 not return 1 } # The status line's wifi=: none (no radio), unconfigured (no credentials), connecting, or the SSID. +# Read from the kernel, as the supplicant's control socket is its own user's: a link the +# supplicant holds dormant until it has the keys, then up, and the SSID it is associated with. wifi_state() { [ -n "$WIRELESS" ] || { echo none; return; } [ -r "$WPA_CONF" ] || { echo unconfigured; return; } st=connecting for n in $WIRELESS; do - s="$(wpa_cli -p "$WPA_CTRL" -i "$n" status 2>/dev/null)" || continue - case "$s" in - *"wpa_state=COMPLETED"*) - st="$(printf '%s\n' "$s" | sed -n 's/^ssid=//p' | head -1)" - [ -n "$st" ] || st=associated - break ;; - esac + [ "$(cat "/sys/class/net/$n/operstate" 2>/dev/null)" = up ] || continue + s="$(iw dev "$n" link 2>/dev/null)" || continue + st="$(printf '%s\n' "$s" | sed -n 's/^[[:space:]]*SSID: //p' | head -1)" + [ -n "$st" ] || st=associated + break done echo "$st" } @@ -387,9 +388,9 @@ status_line "$@" cleanup() { say "stopping" forwarding off - # dnsmasq and dhcpcd's parsers run as users this root cannot signal; the zone's end ends them. + # The supplicant, dnsmasq and dhcpcd's parsers run as users this root cannot signal; the + # zone's end ends them. [ -n "$UPDATE_PID" ] && kill "$UPDATE_PID" 2>/dev/null - for n in $WIRELESS; do p="$(wpa_pid "$n")"; [ -n "$p" ] && kill "$p" 2>/dev/null; done exit 0 } trap cleanup TERM INT @@ -410,9 +411,9 @@ while :; do say "dnsmasq: forwarding to $(tr '\n' ' ' < "$UPSTREAM")from its next query" fi for n in $WIRELESS; do - p="$(wpa_pid "$n")" - if [ -n "$p" ] && ! kill -0 "$p" 2>/dev/null; then - say "wifi: wpa_supplicant on ${n} died; restarting"; rm -f "/run/wpa_supplicant.$n.pid"; start_wifi "$n"; changed=1 + # Its stale pid file is its own user's to replace, which the next one does. + if [ -n "$(wpa_pid "$n")" ] && ! wpa_alive "$n"; then + say "wifi: wpa_supplicant on ${n} died; restarting"; start_wifi "$n"; changed=1 fi done wifi_now="$(wifi_state)" From 8c54ed776ada9caa0f339cd4447bdc00e55e03e9 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 20:07:33 -0700 Subject: [PATCH 017/213] iana-etc moves to 20261009, so the pin gate finds every pin current or reviewed The release changes /etc/services by its header's date line, and the XML catalogues, which the image does not install, by roughtime's RFC number. The digest matches the .sha256 published beside the tarball and GitHub's asset digest. --- build/config/versions.env | 2 +- sources.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/build/config/versions.env b/build/config/versions.env index 490efb70..9eee1c1e 100644 --- a/build/config/versions.env +++ b/build/config/versions.env @@ -73,7 +73,7 @@ V_GPERF=3.1 # --- system utilities --- V_ATTR=2.6.0 V_ACL=2.4.0 -V_IANA_ETC=20260930 +V_IANA_ETC=20261009 V_LESS=710 V_GROFF=1.23.0 V_PROCPS=4.0.7 diff --git a/sources.lock b/sources.lock index ec50d59d..564bd6ef 100644 --- a/sources.lock +++ b/sources.lock @@ -40,7 +40,7 @@ a3c2b80201b89e68616f4ad30bc66aee4927c3ce50e33929ca819d5c43538898 gmp-6.3.0.tar. 9aa0cc780dec156b8282844833b342ab7cb08c25d2cd9a1869cdd0df31deff48 gzip-1.15.tar.xz b4f819bfc3b0f8ea967018948dbf3a71f174a02886d00ee16121739fcd140d6f havoc-0.7.0.tar.gz f0c64cd7e31d70a5fb3a52e53ab50a61e74c0421a6381eaa45114eec3bde5fe7 hwdata-0.412.tar.gz -ead94c150ba26a30a33c36722b97624fd9ff24c80abcd6303a21af52b24f9e55 iana-etc-20260930.tar.gz +c07e8ecc39d4d9f26be3bf773fab1313b1a3ea58ebed9fbcec2413ee792b6e6f iana-etc-20261009.tar.gz 57b3cf4f77555992881e5ba2a09a63b05aa2c56342a60ed4305b5f45938390b5 inetutils-2.8.tar.gz 4c2fa124c2cf0afd7ca34d1eeacba6ba048a56f6374e2aab93dafbdbd4eea9c0 iproute2-7.2.0.tar.xz 6f213700dbf96b5cc4499ca70cb15ecd69c09f405b06785bb4a1a10b572b6276 iputils-20250605.tar.xz From d6fabfa74f31c44543223cb1c91ad1a5d936bd9b Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 20:14:02 -0700 Subject: [PATCH 018/213] The threat model names the bridge ports' isolation among what the supplicant's CAP_NET_ADMIN reaches --- docs/design/net-zone-surface.md | 4 ++-- docs/threat-model.md | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/design/net-zone-surface.md b/docs/design/net-zone-surface.md index 0f41c94e..caaf24f7 100644 --- a/docs/design/net-zone-surface.md +++ b/docs/design/net-zone-surface.md @@ -112,8 +112,8 @@ confinement cannot add an nf_tables table, and `wifi-wpa2-plain` and **What it closes.** A flaw in what parses scan results, EAPOL or EAP gets that user: not nf_tables, the broker's socket, or the zone root's and dhcpcd's files. **What it leaves:** the network administrator's capability -over the zone's links and routes, and nl80211, which hostap's way would have -held in `wpa_priv` as root instead. +over the zone's links and routes, the bridge ports' isolation among them, and +nl80211, which hostap's way would have held in `wpa_priv` as root instead. ## A time namespace per zone diff --git a/docs/threat-model.md b/docs/threat-model.md index 284a4f58..ab63ab48 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -127,10 +127,11 @@ Kept by design: - confidentiality between a zone and the net zone is the application's, as on any network; - wpa_supplicant reads every passphrase it was given, as it must to join - those networks, and keeps `CAP_NET_ADMIN` and `CAP_NET_RAW` over the zone's - links and routes for nl80211 and EAPOL; it runs as its own user, without - nf_tables, the broker's socket or the zone root's files - (`wifi-supplicant-unprivileged`, `wifi-supplicant-no-nft`); + those networks, and keeps `CAP_NET_ADMIN` and `CAP_NET_RAW` for nl80211 + and EAPOL, which reach the zone's links and routes, the bridge ports' + isolation among them; it runs as its own user, without nf_tables, the + broker's socket or the zone root's files (`wifi-supplicant-unprivileged`, + `wifi-supplicant-no-nft`); - nf_tables, packet sockets and generic netlink stay open to it, as NAT, DHCP and Wi-Fi need them; - a routed zone that floods can hold the resolver's 150 forwarding slots, each From 0d24872b5dfb7ec2024b82e4cb05ad84674388eb Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 20:15:08 -0700 Subject: [PATCH 019/213] The image's programs and libraries are stripped, and the audit refuses one with symbols, no canary or an unchecked printf Nothing stripped the image: every object shipped its symbol table. Stage 06 now strips each x86-64 executable and shared object as it stages the root, keeping each file's owner, mode and times; signed kernel modules are ET_REL and stay as they are, and the sysroot keeps its symbols. The audit counted the stack protector and FORTIFY but required neither. Per object it now finds: - NOT-STRIPPED: a .symtab or debug section (--unstripped for the sysroot, which keeps them on purpose). - NO-SSP: code that never reads the canary at %fs:0x28, found in the bytes, so static programs and the loader, whose __stack_chk_fail is no dynamic symbol, count as protected. - NO-FORTIFY: an import of printf, fprintf, vfprintf, dprintf, syslog, longjmp or another call that _FORTIFY_SOURCE=2 or 3 always turns into its _chk variant, whatever the sizes; glibc 2.40's headers do so for each name on the list. - NO-GNU-STACK, always fatal: no GNU_STACK header, which leaves the stack executable. Findings are printed in full, and the _chk count reads imports only, so libc's own exports no longer count as use. This head accepts every NO-SSP and NO-FORTIFY object by a wildcard, so one run records exactly which objects need named entries. --- Makefile | 6 +-- build/config/artifact-accepted.txt | 4 ++ build/stages/06-iso.sh | 40 ++++++++++++++++- docs/hardening.md | 38 +++++++++------- tools/check-artifact-hardening.sh | 62 +++++++++++++++++++-------- tools/tests/artifact-hardening.sh | 69 +++++++++++++++++++++++++++++- 6 files changed, 180 insertions(+), 39 deletions(-) diff --git a/Makefile b/Makefile index d76fac72..e51ca1fd 100644 --- a/Makefile +++ b/Makefile @@ -414,12 +414,12 @@ smoke-userspace: # --- build output ----------------------------------------------------------- # Reads the ELF headers of what stage 04 installed: a package can ignore the -# hardening flags without failing its build. +# hardening flags without failing its build. Stage 06 strips what ships, not the sysroot. audit-artifacts: - @"$(TOOLS)"/check-artifact-hardening.sh "$(KRYPTIK_WORK)/sysroot" + @"$(TOOLS)"/check-artifact-hardening.sh "$(KRYPTIK_WORK)/sysroot" --unstripped audit-artifacts-strict: - @"$(TOOLS)"/check-artifact-hardening.sh "$(KRYPTIK_WORK)/sysroot" --strict + @"$(TOOLS)"/check-artifact-hardening.sh "$(KRYPTIK_WORK)/sysroot" --unstripped --strict # A record of the tree and of what produced it. manifest: diff --git a/build/config/artifact-accepted.txt b/build/config/artifact-accepted.txt index 60a14f35..a7be84ab 100644 --- a/build/config/artifact-accepted.txt +++ b/build/config/artifact-accepted.txt @@ -20,3 +20,7 @@ RPATH usr/lib/libcc1.so.* /usr/lib/../lib # libtool, in GCC's bui RPATH usr/lib/gcc/*/*/plugin/libc[cp]1plugin.so.* /usr/lib/../lib # the same RPATH usr/lib/libskarnet.so.* /usr/lib # skalibs' own link line RPATH usr/lib/perl5/*/auto/Time/HiRes/HiRes.so /usr/lib # Time::HiRes, linking librt + +# Every object without a canary or calling an unchecked printf, recorded once for the named entries that replace these two. +NO-SSP * # recorded for the list +NO-FORTIFY * # recorded for the list diff --git a/build/stages/06-iso.sh b/build/stages/06-iso.sh index 56c61cfd..0ec5414d 100755 --- a/build/stages/06-iso.sh +++ b/build/stages/06-iso.sh @@ -85,7 +85,7 @@ channel_conf() { # --- preflight ---------------------------------------------------------------- log "Kryptik stage 06 — install media ${KRYPTIK_VERSION}" for t in mkfs.ext4 tune2fs dumpe2fs debugfs veritysetup sbsign sbverify mkfs.vfat mmd mcopy \ - xorriso sfdisk openssl ssh-keygen rsync truncate dd sha256sum blkid; do + xorriso sfdisk openssl ssh-keygen rsync truncate dd sha256sum blkid strip python3; do have "$t" || die "required tool not found: ${t}" done # The signing half never touches the sysroot or the chroot. @@ -115,6 +115,40 @@ stage_depends_on "bs-" licences # --- steps ------------------------------------------------------------------ +# strip_root DIR: symbol tables and debug sections off every x86-64 program and +# library under DIR, each name once per file. Signed kernel modules are ET_REL +# and other architectures' firmware is not x86-64, so neither is touched. +strip_root() { + local f mode owner n=0 + while IFS= read -r -d '' f; do + mode="$(stat -c %a "$f")"; owner="$(stat -c %u:%g "$f")" + strip --strip-all --preserve-dates "$f" + # chown before chmod: a change of owner clears the setuid bit. + chown "$owner" "$f"; chmod "$mode" "$f" + n=$((n + 1)) + done < <(python3 - "$1" <<'EOF' +import os, stat, sys +root = sys.argv[1] +dev = os.lstat(root).st_dev +seen = set() +for d, dirs, files in os.walk(root): + dirs[:] = [x for x in dirs if os.lstat(os.path.join(d, x)).st_dev == dev] + for name in files: + p = os.path.join(d, name) + st = os.lstat(p) + if not stat.S_ISREG(st.st_mode) or st.st_ino in seen: + continue + with open(p, "rb") as fh: + h = fh.read(20) + # 64-bit little-endian ELF, ET_EXEC or ET_DYN, for x86-64 + if h[:6] == b"\x7fELF\x02\x01" and h[16:18] in (b"\x02\x00", b"\x03\x00") and h[18:20] == b"\x3e\x00": + seen.add(st.st_ino) + sys.stdout.buffer.write(os.fsencode(p) + b"\0") +EOF +) + echo "stripped ${n} objects" +} + s_rootfs() { local version="$1" channel="${3:-}" role="$4" local work; work="$(mktemp -d "${IMG}/stage.XXXXXX")" @@ -135,6 +169,10 @@ s_rootfs() { "$stage/var" "$stage/home" "$stage/root" "$stage/etc/kryptik" chmod 1777 "$stage/tmp" + # The sysroot keeps its symbols for the build; the image carries none. + echo "--- stripping ---" + strip_root "$stage" + # The system allocator (ADR-005): the image's processes run on it, the build # never does. Zones get their own copy from kryptikd (rootfs.rs). [[ -f "$stage/usr/lib/libhardened_malloc.so" ]] || die "no /usr/lib/libhardened_malloc.so to preload" diff --git a/docs/hardening.md b/docs/hardening.md index 929586a7..eb768837 100644 --- a/docs/hardening.md +++ b/docs/hardening.md @@ -40,22 +40,28 @@ machinery. ### What the audit finds `make audit-artifacts` (`tools/check-artifact-hardening.sh`) reads the ELF -headers of every object. Stage 06 runs it strict on the root tree as it packs -it into the image, so a finding fails the build, and `make acceptance` runs it -strict again on the root image of the release it tests, mounted read-only, for -the record. A writable and executable segment, an executable stack, text -relocations or an RPATH into the build tree always fail it. In strict mode so -do a missing CET note, `BIND_NOW` or RELRO, a non-PIE executable and any other -RPATH, unless [`artifact-accepted.txt`](../build/config/artifact-accepted.txt) -lists the object with its reason: `kryptikd` and `kryptik-wlproxy`, which -stable rustc does not mark for CET, GMP's assembly, and the rpaths that -man-db, perl and glibc's converters need or that repeat the loader's own -directory. An entry that matches nothing fails too, so the list holds only -what the image has. A new finding is fixed in its package's recipe, not by -weakening the check. The stack protector and FORTIFY are counted, not required -of each object: a function without a local array gets no canary and a call -with no known size no `_chk` variant, so an object with neither shows nothing -about its flags. The record lists the objects without either. +headers, dynamic symbols and code of every object. Stage 06 strips every +x86-64 program and library as it stages the root (the sysroot keeps its +symbols for the build), then runs the audit strict on that tree, so a finding +fails the build, and `make acceptance` runs it strict again on the root image +of the release it tests, mounted read-only, for the record. A writable and +executable segment, an executable stack or no `GNU_STACK` header at all (the +loader then makes the stack executable), text relocations or an RPATH into the +build tree always fail it. In strict mode so do a missing CET note, `BIND_NOW` +or RELRO, a non-PIE executable, a symbol table or debug section, any other +RPATH, an object whose code never reads the stack canary, and an object that +imports `printf`, `fprintf`, `vfprintf`, `dprintf`, `syslog`, `longjmp` or +another call that `_FORTIFY_SOURCE=2` or `3` always turns into its `_chk` +variant, which shows a unit built without FORTIFY. Each of these passes only +where [`artifact-accepted.txt`](../build/config/artifact-accepted.txt) lists +the object with its reason. `-fstack-protector-strong` guards only functions +with a local array or an address-taken local, so an object with none of +those has no canary whatever its flags; such objects are listed by name, and a +package that loses the flag shows up as a new name. An entry that matches +nothing fails too, so the list holds only what the image has. A new finding is +fixed in its package's recipe, not by weakening the check. A call whose size +is unknown keeps its plain name under FORTIFY, so those prove nothing either +way: the record counts and lists the objects that call no `_chk` function. Packages stage 04 builds before its glibc (the first built with `--enable-cet`) link stage 01's crt files, which carry no CET note, so each is diff --git a/tools/check-artifact-hardening.sh b/tools/check-artifact-hardening.sh index b2c8281b..0711ca59 100755 --- a/tools/check-artifact-hardening.sh +++ b/tools/check-artifact-hardening.sh @@ -1,13 +1,14 @@ #!/usr/bin/env bash # Audit the hardening of the ELF objects a build produced. # -# tools/check-artifact-hardening.sh [ROOT] [--strict] [--json FILE] [--accepted FILE] +# tools/check-artifact-hardening.sh [ROOT] [--strict] [--unstripped] [--json FILE] [--accepted FILE] # -# ROOT default: the sysroot -# --strict also fail on reported findings and on accepted entries that matched nothing -# --accepted findings kept, each with a reason; default build/config/artifact-accepted.txt +# ROOT default: the sysroot +# --strict also fail on reported findings and on accepted entries that matched nothing +# --unstripped a tree that keeps its symbols on purpose (the sysroot): no NOT-STRIPPED findings +# --accepted findings kept, each with a reason; default build/config/artifact-accepted.txt # -# RWX, TEXTREL, EXEC-STACK and BUILD-RPATH (an rpath into the build tree) always fail. +# RWX, TEXTREL, EXEC-STACK, NO-GNU-STACK and BUILD-RPATH (an rpath into the build tree) always fail. source "$(dirname "${BASH_SOURCE[0]}")/../build/lib/common.sh" @@ -16,12 +17,14 @@ trap - ERR set +e STRICT=0 +STRIPPED=1 JSON="" ROOT="" ACCEPTED="" while [[ "$#" -gt 0 ]]; do case "$1" in --strict) STRICT=1; shift ;; + --unstripped) STRIPPED=0; shift ;; --json) JSON="${2:?--json needs a path}"; shift 2 ;; --accepted) ACCEPTED="${2:?--accepted needs a file}"; shift 2 [[ -f "$ACCEPTED" ]] || die "no accepted list at ${ACCEPTED}" ;; @@ -40,6 +43,8 @@ Give a root to inspect, or build one first: make temp-tools && make system" have readelf || die "readelf is required (binutils)" +# The canary search reads raw bytes with a PCRE pattern. +printf 'a' | LC_ALL=C grep -qaP '\x61' || die "grep -P is required (GNU grep built with PCRE)" # Not shipped: /tools is stage 01's unhardened cross toolchain; /kryptik* are bind-mount points. EXCLUDE_RE='^(tools|kryptik|kryptik-work|kryptik-sources|usr/src|usr/share/doc)(/|$)' @@ -69,7 +74,7 @@ if [[ -n "$ACCEPTED" ]]; then [[ "$line" == *"#"* ]] || die "${ACCEPTED}:${n}: an accepted finding needs its reason after '#'" read -r kind path rp extra <<<"${line%%#*}" case "$kind" in - NO-RELRO|NO-BIND-NOW|NO-PIE|NO-CET) + NO-RELRO|NO-BIND-NOW|NO-PIE|NO-CET|NO-SSP|NO-FORTIFY|NOT-STRIPPED) [[ -n "$path" && -z "$rp" ]] || die "${ACCEPTED}:${n}: ${kind} takes one path" ;; RPATH) [[ -n "$rp" && -z "$extra" ]] || die "${ACCEPTED}:${n}: RPATH takes a path and the rpath it accepts" ;; @@ -94,8 +99,8 @@ accepted() { # accepted KIND REL [RPATH] } hard() { HARD=$((HARD + 1)); HARD_LINES+=(" $1 $2"); bump "$1"; } -soft() { # soft KIND REL [RPATH] - local line=" $1 $2${3:+ [$3]}" +soft() { # soft KIND REL [RPATH [NOTE]]: NOTE is shown, never matched + local line=" $1 $2${3:+ [$3]}${4:+ ($4)}" bump "$1" if accepted "$1" "$2" "${3:-}"; then ACCEPTED_LINES+=("$line"); else SOFT_LINES+=("$line"); fi } @@ -110,7 +115,7 @@ audit_one() { local f="$1" rel="${1#"$ROOT"/}" local out # One readelf run per file covers everything read below. - out="$(readelf -W -h -l -d -n --dyn-syms "$f" 2>/dev/null)" || return 0 + out="$(readelf -W -h -S -l -d -n --dyn-syms "$f" 2>/dev/null)" || return 0 [[ -n "$out" ]] || return 0 local type kind @@ -135,6 +140,8 @@ audit_one() { if grep -qE '^ +GNU_STACK.*RWE' <<<"$out"; then hard "EXEC-STACK" "$rel" fi + # Without the header the kernel and the loader give the object an executable stack. + grep -qE '^ +GNU_STACK' <<<"$out" || hard "NO-GNU-STACK" "$rel" if grep -q 'TEXTREL' <<<"$out"; then hard "TEXTREL" "$rel" @@ -171,13 +178,34 @@ audit_one() { soft "NO-PIE" "$rel" fi - grep -qE 'IBT|SHSTK' <<<"$out" || soft "NO-CET" "$rel" + grep -qE 'x86 feature:.*(IBT|SHSTK)' <<<"$out" || soft "NO-CET" "$rel" - # Counted, never failed: code with no local arrays or sized calls shows neither, whatever its flags. - if grep -q '__stack_chk_fail' <<<"$out"; then bump "HAS-SSP"; else NO_SSP+=("$rel"); fi - if grep -qE '__[a-z_]+_chk@|__[a-z_]+_chk$' <<<"$out"; then bump "HAS-FORTIFY"; else NO_FORTIFY+=("$rel"); fi + [[ "$STRIPPED" -eq 0 ]] || ! grep -qE '^ +\[ *[0-9]+\] \.(symtab|z?debug_[A-Za-z_]+) ' <<<"$out" \ + || soft "NOT-STRIPPED" "$rel" + + local imports + imports="$(awk '/^Symbol table .\.dynsym./ { d = 1; next } d && NF == 0 { d = 0 } + d && $7 == "UND" && $8 != "" { n = $8; sub(/@.*/, "", n); print n }' <<<"$out")" + + # A canary is read from %fs:0x28; static and loader code call a __stack_chk_fail no dynamic symbol shows. + if grep -q '__stack_chk_fail' <<<"$out" || LC_ALL=C grep -qaP "$CANARY" "$f"; then + bump "HAS-SSP" + else + NO_SSP+=("$rel") + soft "NO-SSP" "$rel" + fi + + if grep -qE '^__[a-z0-9_]+_chk$' <<<"$imports"; then bump "HAS-FORTIFY"; else NO_FORTIFY+=("$rel"); fi + local unchecked + unchecked="$(grep -xE "$UNCHECKED" <<<"$imports" | LC_ALL=C sort -u | tr '\n' ' ')" + [[ -z "$unchecked" ]] || soft "NO-FORTIFY" "$rel" "" "${unchecked% }" } +# mov, sub, xor or cmp of any register with %fs:0x28, the stack guard glibc and musl keep there. +CANARY='\x64[\x48\x4c][\x8b\x2b\x33\x3b][\x04\x0c\x14\x1c\x24\x2c\x34\x3c]\x25\x28\x00\x00\x00' +# Under _FORTIFY_SOURCE=2 or 3 every call to these becomes its _chk variant, whatever the sizes. +UNCHECKED='v?f?w?printf|v?dprintf|v?swprintf|v?syslog|_?longjmp|siglongjmp|explicit_bzero|obstack_v?printf' + log "scanning" while IFS= read -r -d '' f; do rel="${f#"$ROOT"/}" @@ -201,7 +229,7 @@ printf ' %-14s %d\n' "with FORTIFY" "${COUNT[HAS-FORTIFY]:-0}" echo log "Findings" -for k in RWX EXEC-STACK TEXTREL BUILD-RPATH NO-RELRO NO-BIND-NOW NO-PIE NO-CET RPATH; do +for k in RWX EXEC-STACK NO-GNU-STACK TEXTREL BUILD-RPATH NO-RELRO NO-BIND-NOW NO-PIE NO-CET NO-SSP NO-FORTIFY NOT-STRIPPED RPATH; do n="${COUNT[$k]:-0}" [[ "$n" -eq 0 ]] && continue printf ' %-13s %5d\n' "$k" "$n" @@ -224,15 +252,13 @@ fi if [[ "${#HARD_LINES[@]}" -gt 0 ]]; then echo err "objects with no legitimate explanation:" - printf '%s\n' "${HARD_LINES[@]}" | sort | head -50 >&2 - [[ "${#HARD_LINES[@]}" -gt 50 ]] && echo " ... and $(( ${#HARD_LINES[@]} - 50 )) more" >&2 + printf '%s\n' "${HARD_LINES[@]}" | sort >&2 fi if [[ "$STRICT" -eq 1 && "${#SOFT_LINES[@]}" -gt 0 ]]; then echo warn "reported findings (--strict promotes these to failures):" - printf '%s\n' "${SOFT_LINES[@]}" | sort | head -50 >&2 - [[ "${#SOFT_LINES[@]}" -gt 50 ]] && echo " ... and $(( ${#SOFT_LINES[@]} - 50 )) more" >&2 + printf '%s\n' "${SOFT_LINES[@]}" | sort >&2 fi if [[ -n "$JSON" ]]; then diff --git a/tools/tests/artifact-hardening.sh b/tools/tests/artifact-hardening.sh index 5921fc3a..6edd222a 100755 --- a/tools/tests/artifact-hardening.sh +++ b/tools/tests/artifact-hardening.sh @@ -31,7 +31,8 @@ libsrc="$W/lib.c" echo 'int twice(int x) { return x * 2; }' > "$libsrc" HARD="-O2 -D_FORTIFY_SOURCE=3 -fstack-protector-strong -fcf-protection=full" -HLD="-Wl,-z,relro -Wl,-z,now -Wl,-z,noexecstack" +# Stripped, as stage 06 ships them. +HLD="-Wl,-z,relro -Wl,-z,now -Wl,-z,noexecstack -s" # One root per case, so findings cannot bleed between cases. mkroot() { local d="$W/$1/usr/bin"; mkdir -p "$d"; printf '%s' "$W/$1"; } @@ -100,6 +101,65 @@ $CC $HARD -Wl,-z,norelro -o "$norelro_root/usr/bin/nr" "$src" 2>/dev/null out="$(run_check "$norelro_root")" check "missing RELRO: reported" "$(grep -q 'NO-RELRO' <<<"$out" && echo ok)" +sym_root="$(mkroot symbols)" +# shellcheck disable=SC2086 +$CC $HARD ${HLD% -s} -o "$sym_root/usr/bin/unstripped" "$src" 2>/dev/null +out="$(run_check "$sym_root" --strict)"; rc=$? +check "symbol table: NOT-STRIPPED fails --strict" \ + "$({ [[ $rc -ne 0 ]] && grep -q 'NOT-STRIPPED' <<<"$out"; } && echo ok)" +out="$(run_check "$sym_root" --strict --unstripped)"; rc=$? +check "symbol table: --unstripped leaves it to the sysroot" "$([[ $rc -eq 0 ]] && echo ok)" + +# A function with a local array and no canary: built without the stack protector. +nossp_root="$(mkroot nossp)" +# shellcheck disable=SC2086 +$CC $HARD -fno-stack-protector $HLD -o "$nossp_root/usr/bin/bare" "$src" 2>/dev/null +out="$(run_check "$nossp_root" --strict)"; rc=$? +check "no canary: NO-SSP fails --strict" \ + "$({ [[ $rc -ne 0 ]] && grep -q 'NO-SSP usr/bin/bare' <<<"$out"; } && echo ok)" +out="$(run_check "$clean_root" --strict)" +check "canary: the protected executable has no NO-SSP" \ + "$(! grep -q 'NO-SSP usr/bin/good' <<<"$out" && echo ok)" + +# Static code calls a __stack_chk_fail no dynamic symbol names; the canary itself is still read. +static_root="$(mkroot static)" +# shellcheck disable=SC2086 +if $CC $HARD $HLD -static -o "$static_root/usr/bin/alone" "$src" 2>/dev/null; then + out="$(run_check "$static_root" --strict)" + check "static and stripped: the canary is found without symbols" \ + "$(! grep -q 'NO-SSP' <<<"$out" && echo ok)" +fi + +# printf through a unit built without FORTIFY stays printf; with it, it is always __printf_chk. +nofort_root="$(mkroot nofortify)" +# shellcheck disable=SC2086 +$CC $HARD -U_FORTIFY_SOURCE $HLD -o "$nofort_root/usr/bin/unchecked" "$src" 2>/dev/null +out="$(run_check "$nofort_root" --strict)"; rc=$? +check "unchecked printf: NO-FORTIFY names the call and fails --strict" \ + "$({ [[ $rc -ne 0 ]] && grep -q 'NO-FORTIFY usr/bin/unchecked (printf)' <<<"$out"; } && echo ok)" +out="$(run_check "$clean_root" --strict)" +check "checked printf: the fortified executable has no NO-FORTIFY" \ + "$(! grep -q 'NO-FORTIFY' <<<"$out" && echo ok)" + +# No PT_GNU_STACK at all: the loader gives such an object an executable stack. +nogs_root="$(mkroot nognustack)" +cp "$clean_root/usr/bin/good" "$nogs_root/usr/bin/headerless" +python3 - "$nogs_root/usr/bin/headerless" <<'PY' +import struct, sys +p = sys.argv[1] +b = bytearray(open(p, "rb").read()) +phoff, = struct.unpack_from(" "$ACC" out="$(run_check "$sysrp_root" --strict)"; rc=$? check "accepted list: an RPATH entry does not accept another rpath" "$([[ $rc -ne 0 ]] && echo ok)" +printf 'NO-FORTIFY usr/bin/unchecked # a test of the list\n' > "$ACC" +out="$(run_check "$nofort_root" --strict)"; rc=$? +check "accepted list: a NO-FORTIFY entry passes --strict" "$([[ $rc -eq 0 ]] && echo ok)" +printf 'NOT-STRIPPED usr/bin/unstripped /usr/lib # one path only\n' > "$ACC" +out="$(run_check "$sym_root")"; rc=$? +check "accepted list: a per-object entry with an rpath is refused" \ + "$({ [[ $rc -ne 0 ]] && grep -q 'takes one path' <<<"$out"; } && echo ok)" : > "$ACC" echo From e9dfdddd2bd01c04e5a63e94c3c5c371e588b352 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 20:17:43 -0700 Subject: [PATCH 020/213] The update payload carries the root image compressed, a quarter of its size, checked against the manifest before it is decompressed and against root.json before it is written --- build/stages/06-iso.sh | 12 +++++--- docs/design/boot-and-updates.md | 9 ++++-- docs/design/delta-updates.md | 15 ++++++---- docs/design/update-channel.md | 2 +- docs/user-guide.md | 4 +-- tools/acceptance.sh | 25 ++++++++++++---- tools/channel-host.sh | 6 ++-- tools/image/update-test.sh | 4 +-- tools/tests/update-manifest-snapshot.sh | 38 +++++++++++++++++-------- tools/update/kryptik-update | 30 ++++++++++++------- 10 files changed, 96 insertions(+), 49 deletions(-) diff --git a/build/stages/06-iso.sh b/build/stages/06-iso.sh index 56c61cfd..f48b0243 100755 --- a/build/stages/06-iso.sh +++ b/build/stages/06-iso.sh @@ -85,7 +85,7 @@ channel_conf() { # --- preflight ---------------------------------------------------------------- log "Kryptik stage 06 — install media ${KRYPTIK_VERSION}" for t in mkfs.ext4 tune2fs dumpe2fs debugfs veritysetup sbsign sbverify mkfs.vfat mmd mcopy \ - xorriso sfdisk openssl ssh-keygen rsync truncate dd sha256sum blkid; do + xorriso sfdisk openssl ssh-keygen rsync truncate dd sha256sum blkid zstd; do have "$t" || die "required tool not found: ${t}" done # The signing half never touches the sysroot or the chroot. @@ -485,18 +485,22 @@ s_sums() { } # The update payload: root image, slot kernels and root.json under a manifest -# of the image's role, signed with the release key its anchor lists. +# of the image's role, signed with the release key its anchor lists. The image +# travels compressed, a fraction of its size, much of it free blocks: the +# updater checks the compressed file against the manifest, then what it +# decompresses to against root.json, before it writes a byte of it. s_payload() { echo "inputs digest: $1" local out="${IMG}/payload-${KRYPTIK_VERSION}" rm -rf "$out"; mkdir -p "$out" - cp --sparse=always "${IMG}/kryptik-root.img" "$out/" + zstd -q -T0 -19 "${IMG}/kryptik-root.img" -o "$out/kryptik-root.img.zst" || { echo "could not compress the root image"; return 1; } + echo "root image: $(stat -c %s "${IMG}/kryptik-root.img") bytes, $(stat -c %s "$out/kryptik-root.img.zst") compressed" cp "${IMG}/kernels/slot-a.signed.efi" "$out/kryptik-a.efi" cp "${IMG}/kernels/slot-b.signed.efi" "$out/kryptik-b.efi" cp "${IMG}/root.json" "$out/" "${KRYPTIK_ROOT}/tools/release-manifest.sh" create --out "$out/manifest" --name kryptik \ --version "$KRYPTIK_VERSION" --role "$ROLE" --root "$out" \ - kryptik-root.img kryptik-a.efi kryptik-b.efi root.json + kryptik-root.img.zst kryptik-a.efi kryptik-b.efi root.json "${KRYPTIK_ROOT}/tools/release-manifest.sh" sign --key "$RELEASE_KEY" "$out/manifest" # Verify as the guest will: the image's anchor, --exact. "${KRYPTIK_ROOT}/tools/release-manifest.sh" verify --signers "$ANCHOR" --principal kryptik-release \ diff --git a/docs/design/boot-and-updates.md b/docs/design/boot-and-updates.md index dc4ea3dc..a77d3eef 100644 --- a/docs/design/boot-and-updates.md +++ b/docs/design/boot-and-updates.md @@ -119,10 +119,15 @@ entry left alone. ## Updates -A payload directory holds exactly `kryptik-root.img`, `kryptik-a.efi`, +A payload directory holds exactly `kryptik-root.img.zst`, `kryptik-a.efi`, `kryptik-b.efi`, `root.json`, `manifest` and `manifest.sig`. The manifest lists each file's sha256 and size, the version and the role, and is signed by -the release key (Ed25519, `ssh-keygen -Y`, namespace `kryptik-release`). +the release key (Ed25519, `ssh-keygen -Y`, namespace `kryptik-release`). The +root image travels compressed, at about a quarter of its size; `root.json`, +which the manifest lists, names the image's own sha256 and size. `apply` +decompresses only a file whose hash matched the manifest, and checks what it +decompresses to against `root.json` before it writes the inactive slot, then +writes it decompressing again and reads the slot back. Versions are ordered as `sort -V` orders them. A production release is numbered MAJOR.MINOR.PATCH, which that order reads as a person does ([release keys](../release-keys.md)). The trust anchor and required role are on the verified root in diff --git a/docs/design/delta-updates.md b/docs/design/delta-updates.md index d0f9fa6b..d06a35ba 100644 --- a/docs/design/delta-updates.md +++ b/docs/design/delta-updates.md @@ -9,7 +9,7 @@ whether it is worth doing at today's release sizes. ## Where things stand -- **A payload** is `kryptik-root.img`, `kryptik-a.efi`, `kryptik-b.efi`, +- **A payload** is `kryptik-root.img.zst`, `kryptik-a.efi`, `kryptik-b.efi`, `root.json`, `manifest` and `manifest.sig`. The manifest lists every file's SHA-256 and size and is signed by the release key ([boot and updates](boot-and-updates.md#updates)). @@ -19,12 +19,13 @@ whether it is worth doing at today's release sizes. (`may_put` and `put` in `compartments/kryptikd/src/update.rs`). Zone 0 stores no more than the release's signed size, and `apply` checks every hash before it writes the inactive slot. -- **The image is sent raw.** `kryptik-root.img` is an ext4 filesystem +- **The image is sent compressed.** `kryptik-root.img` is an ext4 filesystem followed by its hash tree, about 1,560 MiB today. Stage 06 sizes the filesystem at its contents plus an eighth plus 96 MiB, so a large part of - what crosses the network is free blocks: zeros. The v0.1.0 USB medium, ESP - and root together, compresses with zstd to 390,968,131 bytes; a medium is - about 2.2 GB raw. The kernels are about 22 MB each and do not compress, + it is free blocks: zeros. The payload carries it as `kryptik-root.img.zst` + (zstd -19), the first step of the recommendation below; the v0.1.0 USB + medium, ESP and root together, compressed so to 390,968,131 bytes from + about 2.2 GB. The kernels are about 22 MB each and do not compress, since they are zstd inside already. - **The hash tree is an authenticated index.** `root.json`, which the manifest lists, names the root hash, the salt, the number of data blocks @@ -152,7 +153,9 @@ for every release from now on. ## Recommendation 1. Compress the payload's image for transport, verified before it is - decompressed. + decompressed. Built: stage 06 writes `kryptik-root.img.zst`, and `apply` + checks its hash, then what it decompresses to against `root.json`, + before it writes the inactive slot. 2. Add the delta count to the acceptance report. 3. Build delta fetching once builds are reproducible and the reported deltas stay well under a fifth of the image: the plan from the committed slot, diff --git a/docs/design/update-channel.md b/docs/design/update-channel.md index 71fab7bd..4dc8aadc 100644 --- a/docs/design/update-channel.md +++ b/docs/design/update-channel.md @@ -259,7 +259,7 @@ The channel Kryptik ships lives on GitHub, in two parts a statement's `base` joins: the payload's files are among the release's files on the Releases page (`tools/release-publish.sh` uploads them under the manifest's names, so `https://github.com///releases/download/v/` serves -`manifest`, `kryptik-root.img` and the rest), and the statement is served by +`manifest`, `kryptik-root.img.zst` and the rest), and the statement is served by the repository's Pages site at `https://.github.io//stable/`, which is the address an image is built with. The statement key is the `github-pages` environment's `KRYPTIK_LATEST_KEY` secret, an environment that diff --git a/docs/user-guide.md b/docs/user-guide.md index 31639a08..cf361e44 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -309,8 +309,8 @@ key can be accepted while this machine's clock reads N days before ...: set the clock". A release brought by hand is a signed payload directory holding exactly -`manifest`, `manifest.sig`, `kryptik-root.img`, `kryptik-a.efi`, -`kryptik-b.efi` and `root.json` (what `make media` writes as +`manifest`, `manifest.sig`, `kryptik-root.img.zst` (the root image, +compressed), `kryptik-a.efi`, `kryptik-b.efi` and `root.json` (what `make media` writes as `images/payload-VERSION`, and what a release on the repository's page carries). Bring it onto the state partition, for example under `/var/lib/kryptik/updates/`, and as root (the administration login, then diff --git a/tools/acceptance.sh b/tools/acceptance.sh index 65414333..3bbe3b81 100755 --- a/tools/acceptance.sh +++ b/tools/acceptance.sh @@ -260,7 +260,8 @@ need_host() { if [[ "$NOHOST" -eq 1 ]]; then echo "not run (--no-host)"; else need_root() { [[ "$EUID" -eq 0 ]] || echo "needs root (the chroot and the VM disks)"; } need_sysroot() { [[ -x "${SYSROOT}/usr/bin/gcc" ]] || echo "no built sysroot at ${SYSROOT} (make system)"; } need_root_image() { - [[ -f "${PAYLOAD_B}/kryptik-root.img" ]] || { echo "no root image for the release under test (make media writes images/payload-VERSION)"; return; } + [[ -f "${PAYLOAD_B}/kryptik-root.img.zst" ]] || { echo "no root image for the release under test (make media writes images/payload-VERSION)"; return; } + have zstd || { echo "no zstd, which decompresses the release's root image from its payload"; return; } [[ "$EUID" -eq 0 ]] || echo "needs root to mount the release's root image read-only" } need_usb() { [[ -f "$MEDIA_USB" ]] || echo "no USB image (make media)"; } @@ -353,13 +354,24 @@ it_media_hashes() { it_host_suites() { "${SELF}/run-tests.sh" --strict; } it_libc_unwind() { env KRYPTIK_ROOT="$ROOT" KRYPTIK_WORK="$KRYPTIK_WORK" KRYPTIK_SOURCES="$KRYPTIK_SOURCES" "${ROOT}/build/stages/03-chroot-prep.sh" run /kryptik/tools/tests/libc-unwind.sh; } it_userspace() { "${SELF}/tests/userspace-smoke.sh"; } +# B's root image, decompressed from its payload once, outside what a part uploads. +ROOT_IMAGE="${TMPDIR:-/tmp}/kryptik-release-root.$$.img" +trap 'rm -f "$ROOT_IMAGE" "$ROOT_IMAGE.part"' EXIT +release_root() { # release_root: the image's path, decompressed if it is not there yet + if [[ ! -s "$ROOT_IMAGE" ]]; then + zstd -q -d -f "${PAYLOAD_B}/kryptik-root.img.zst" -o "$ROOT_IMAGE.part" && mv -f "$ROOT_IMAGE.part" "$ROOT_IMAGE" \ + || { echo "could not decompress ${PAYLOAD_B}/kryptik-root.img.zst"; rm -f "$ROOT_IMAGE.part"; return 1; } + fi + printf '%s\n' "$ROOT_IMAGE" +} # What the release ships, read from its own root image: the media under test, # wherever they were built, not this machine's sysroot. on_root_image() { # on_root_image CMD...: CMD, given the image mounted read-only as its last argument - local mnt rc + local mnt rc img + img="$(release_root)" || { echo "$img"; return 1; } mnt="$(mktemp -d "${TMPDIR:-/tmp}/kryptik-root.XXXXXX")" || return 1 - if ! mount -o ro,loop,noexec,nosuid,nodev "${PAYLOAD_B}/kryptik-root.img" "$mnt"; then - echo "could not mount ${PAYLOAD_B}/kryptik-root.img read-only"; rmdir "$mnt"; return 1 + if ! mount -o ro,loop,noexec,nosuid,nodev "$img" "$mnt"; then + echo "could not mount ${PAYLOAD_B}'s root image read-only"; rmdir "$mnt"; return 1 fi "$@" "$mnt"; rc=$? umount "$mnt" && rmdir "$mnt" @@ -544,9 +556,10 @@ it_export() { else echo " no signed media checksums: ${IMGDIR}/${sums}"; ok=1 fi - if ! debugfs -R 'cat /usr/share/kryptik/trust/release-signers' "${PAYLOAD_B}/kryptik-root.img" > "${d}/release-signers" 2>/dev/null \ + local root; root="$(release_root)" || root="" + if [[ -z "$root" ]] || ! debugfs -R 'cat /usr/share/kryptik/trust/release-signers' "$root" > "${d}/release-signers" 2>/dev/null \ || [[ ! -s "${d}/release-signers" ]]; then - echo " could not read the anchor out of ${PAYLOAD_B:-(no payload B)}/kryptik-root.img"; rm -f "${d}/release-signers"; ok=1 + echo " could not read the anchor out of ${PAYLOAD_B:-(no payload B)}'s root image"; rm -f "${d}/release-signers"; ok=1 fi if [[ -n "$PAYLOAD_A" && -f "${PAYLOAD_A}/manifest" ]]; then cp "${PAYLOAD_A}/manifest" "${d}/manifest-${VER_A}" diff --git a/tools/channel-host.sh b/tools/channel-host.sh index 60893ede..703c31d2 100755 --- a/tools/channel-host.sh +++ b/tools/channel-host.sh @@ -17,7 +17,7 @@ source "$(dirname "${BASH_SOURCE[0]}")/../build/lib/common.sh" usage() { sed -n '2,13p' "${BASH_SOURCE[0]}"; } CHANNEL_TOOL="${KRYPTIK_ROOT}/tools/release-channel.sh" MANIFEST_TOOL="${KRYPTIK_ROOT}/tools/release-manifest.sh" -PAYLOAD_FILES=(kryptik-root.img kryptik-a.efi kryptik-b.efi root.json manifest manifest.sig) +PAYLOAD_FILES=(kryptik-root.img.zst kryptik-a.efi kryptik-b.efi root.json manifest manifest.sig) CHANNELS=(stable test) MODE="${1:-}"; shift || true @@ -126,12 +126,12 @@ case "$MODE" in } > "${w}/release-signers" KEY="${w}/latest" p="${w}/payload"; mkdir -p "$p" - printf 'a stand-in root image\n' > "${p}/kryptik-root.img" + printf 'a stand-in root image\n' > "${p}/kryptik-root.img.zst" printf 'a stand-in kernel, slot a\n' > "${p}/kryptik-a.efi" printf 'a stand-in kernel, slot b\n' > "${p}/kryptik-b.efi" printf '{"root_hash": "00"}\n' > "${p}/root.json" "$MANIFEST_TOOL" create --out "${p}/manifest" --name kryptik --version 0.0.1 --role production \ - --root "$p" kryptik-root.img kryptik-a.efi kryptik-b.efi root.json > /dev/null + --root "$p" kryptik-root.img.zst kryptik-a.efi kryptik-b.efi root.json > /dev/null "$MANIFEST_TOOL" sign --key "${w}/release" "${p}/manifest" > /dev/null base="https://example.invalid/releases/download/v0.0.1/" publish v0.0.1 "$p" "${w}/release-signers" "$base" diff --git a/tools/image/update-test.sh b/tools/image/update-test.sh index 97aae1ac..e15c19e2 100755 --- a/tools/image/update-test.sh +++ b/tools/image/update-test.sh @@ -132,7 +132,7 @@ changed() { # changed NAME FILE } mk_variant wrongkey; ssh-keygen -q -t ed25519 -N "" -f "$BAD/otherkey" >/dev/null; rm -f "$BAD/wrongkey/manifest.sig" ssh-keygen -Y sign -f "$BAD/otherkey" -n kryptik-release "$BAD/wrongkey/manifest" >/dev/null 2>&1 -mk_variant modified kryptik-root.img; flip "$BAD/modified/kryptik-root.img" $((4096*200+3)); changed modified kryptik-root.img +mk_variant modified kryptik-root.img.zst; flip "$BAD/modified/kryptik-root.img.zst" $((4096*200+3)); changed modified kryptik-root.img.zst mk_variant truncated kryptik-a.efi; truncate -s -1 "$BAD/truncated/kryptik-a.efi"; changed truncated kryptik-a.efi mk_variant extra; echo "ride along" > "$BAD/extra/extra.bin" # An empty lost+found (an ext4 payload disk's own) is allowed; a full one is not. @@ -435,7 +435,7 @@ else "run:kryptik update fetch" "expect:${VB} will be fetched" \ "run:$(wait_arrival)" "run:$(wait_arrival)" "run:$(wait_arrival)" "run:$(wait_arrival)" \ "run:kryptik update status | grep -q 'bytes, complete'" \ - "$(ROOTSH "ls /var/lib/kryptik/update/incoming/${VB} | sort | tr \"\\n\" \" \"; echo LISTED")" "expect:kryptik-a.efi kryptik-b.efi kryptik-root.img manifest manifest.sig root.json LISTED" \ + "$(ROOTSH "ls /var/lib/kryptik/update/incoming/${VB} | sort | tr \"\\n\" \" \"; echo LISTED")" "expect:kryptik-a.efi kryptik-b.efi kryptik-root.img.zst manifest manifest.sig root.json LISTED" \ "run:kryptik update apply" "expect:armed: the next boot tries slot b" \ "$(ROOTSH 'reboot')" "expect:Linux version" "expect:KRYPTIK_SMOKE: END" \ "login:${TUSER}:${TPASS}" \ diff --git a/tools/tests/update-manifest-snapshot.sh b/tools/tests/update-manifest-snapshot.sh index 044e9599..48edbf34 100755 --- a/tools/tests/update-manifest-snapshot.sh +++ b/tools/tests/update-manifest-snapshot.sh @@ -2,8 +2,9 @@ # Test kryptik-update's verify_payload with the real ssh-keygen: the manifest is # read once, before the signature check, so files swapped in after the check # cannot change the version or hashes, and a kernel made for another root is -# refused. Also tests check-manifest, check-pointer and check-release. Needs -# ssh-keygen with -Y (OpenSSH 8.2+). +# refused, as is a compressed image that is not the one root.json names. Also +# tests check-manifest, check-pointer and check-release. Needs ssh-keygen with +# -Y (OpenSSH 8.2+) and zstd. set -uo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" TOOL="$ROOT/tools/update/kryptik-update" @@ -11,6 +12,7 @@ command -v ssh-keygen >/dev/null 2>&1 || { echo "ssh-keygen not found; cannot ru # With -Y, this incomplete call complains about find-principals; without, about -Y. probe="$(ssh-keygen -Y find-principals 2>&1 || true)" grep -q "find-principals" <<<"$probe" || { echo "this ssh-keygen has no -Y; cannot run"; exit 77; } +command -v zstd >/dev/null 2>&1 || { echo "zstd not found; cannot run"; exit 77; } PASS=0; FAIL=0 ok() { printf ' PASS %s\n' "$1"; PASS=$((PASS + 1)); } @@ -21,21 +23,25 @@ trap 'rm -rf "$T"' EXIT cd "$T" || exit 1 # The signed payload (version 2) and an unsigned replacement (version 3). -mkpayload() { # mkpayload DIR VERSION [ROOT-HASH-IN-KERNEL-B] - local d="$1" v="$2" hash +mkpayload() { # mkpayload DIR VERSION [ROOT-HASH-IN-KERNEL-B [OTHER-IMAGE-IN-THE-ZST]] + local d="$1" v="$2" hash img mkdir -p "$d" hash="$(printf '%*s' 64 '' | tr ' ' "$v")" - printf 'harmless fixture root %s\n' "$v" > "$d/kryptik-root.img" + img="$T/image-$(basename "$d")" + printf 'harmless fixture root %s\n' "$v" > "$img" printf '%s\n' "$hash" > "$d/kryptik-a.efi" printf '%s\n' "${3:-$hash}" > "$d/kryptik-b.efi" # The layout stage 06 writes and the tool reads: one key per line, two spaces in. { echo "{" printf ' "root_hash": "%s",\n' "$hash" - printf ' "total_bytes": %s,\n' "$(stat -c %s "$d/kryptik-root.img")" - printf ' "sha256": "%s"\n' "$(sha256sum "$d/kryptik-root.img" | cut -c1-64)" + printf ' "total_bytes": %s,\n' "$(stat -c %s "$img")" + printf ' "sha256": "%s"\n' "$(sha256sum "$img" | cut -c1-64)" echo "}" } > "$d/root.json" + # The payload carries the image compressed, as stage 06 writes it. + if [[ -n "${4:-}" ]]; then printf '%s\n' "$4" | zstd -q -c > "$d/kryptik-root.img.zst" + else zstd -q -c "$img" > "$d/kryptik-root.img.zst"; fi { echo "KRYPTIK-MANIFEST-1" echo "role: development" @@ -43,7 +49,7 @@ mkpayload() { # mkpayload DIR VERSION [ROOT-HASH-IN-KERNEL-B] echo "created: 2027-03-02T12:00:00Z" echo "files: 4" echo "--" - for f in kryptik-a.efi kryptik-b.efi kryptik-root.img root.json; do + for f in kryptik-a.efi kryptik-b.efi kryptik-root.img.zst root.json; do printf '%s %s %s\n' "$(sha256sum "$d/$f" | cut -c1-64)" "$(stat -c %s "$d/$f")" "$f" done } > "$d/manifest" @@ -100,9 +106,9 @@ ssh-keygen() { # Before anything is judged: what a payload directory may simply contain. if [ "\${2:-}" = verify ]; then case "$what" in - link) mv $T/payload/kryptik-root.img $T/payload-root.img; ln -s $T/payload-root.img $T/payload/kryptik-root.img ;; + link) mv $T/payload/kryptik-root.img.zst $T/payload-root.img.zst; ln -s $T/payload-root.img.zst $T/payload/kryptik-root.img.zst ;; dotfile) echo "ride along" > $T/payload/.hidden ;; - lookalike) echo "ride along" > $T/payload/kryptik-rootXimg ;; + lookalike) echo "ride along" > $T/payload/kryptik-rootXimg.zst ;; esac fi return "\$rc" @@ -148,7 +154,7 @@ if [[ "$out" == *"REFUSED:"*"not a regular file"* ]]; then ok "a root image that out="$(run_case dotfile dotfile)" if [[ "$out" == *"REFUSED:"*"unlisted file in the payload: .hidden"* ]]; then ok "an unlisted file whose name begins with a dot is seen and refused"; else bad "a dotfile stowaway: $(tail -2 <<<"$out" | tr '\n' ' ')"; fi out="$(run_case lookalike lookalike)" -if [[ "$out" == *"REFUSED:"*"unlisted file in the payload: kryptik-rootXimg"* ]]; then ok "a name that only matches a listed one as a pattern is refused"; else bad "a look-alike name: $(tail -2 <<<"$out" | tr '\n' ' ')"; fi +if [[ "$out" == *"REFUSED:"*"unlisted file in the payload: kryptik-rootXimg.zst"* ]]; then ok "a name that only matches a listed one as a pattern is refused"; else bad "a look-alike name: $(tail -2 <<<"$out" | tr '\n' ' ')"; fi if command ssh-keygen -Y verify -f signers -I kryptik-release -n kryptik-release -s "$T/payload/manifest.sig" < "$T/replacement/manifest" >/dev/null 2>&1; then bad "control: the replacement manifest has a valid signature, which it must not" @@ -173,11 +179,19 @@ out="$(check verify_payload "$T/mixed" 0)" && ok "a signed release whose kernel embeds another root hash is refused, though every file matches the manifest" \ || bad "a kernel made for another root: $(tail -2 <<<"$out" | tr '\n' ' ')" +# Signed, every file as listed, and the compressed image not the one root.json names. +mkpayload swapped 2 "" "an image root.json does not name" +ssh-keygen -Y sign -f key -n kryptik-release swapped/manifest >/dev/null 2>&1 +out="$(check verify_payload "$T/swapped" 0)" +[[ "$out" == *"match the manifest"* && "$out" == *"REFUSED: kryptik-root.img.zst does not decompress to the image root.json names"* ]] \ + && ok "a signed release whose compressed image is not the one root.json names is refused before anything is written" \ + || bad "a compressed image root.json does not name: $(tail -2 <<<"$out" | tr '\n' ' ')" + staged stage out="$(check cmd_check_manifest "$T/stage")" want_sha="$(sha256sum "$T/signed/manifest" | cut -c1-64)" if [[ "$out" == *"version: 2"* && "$out" == *"sha256: $want_sha"* && "$(grep -c '^file [0-9]* ' <<<"$out")" = 4 ]] \ - && grep -qx "file $(stat -c %s "$T/signed/kryptik-root.img") kryptik-root.img" <<<"$out"; then + && grep -qx "file $(stat -c %s "$T/signed/kryptik-root.img.zst") kryptik-root.img.zst" <<<"$out"; then ok "check-manifest: a signed manifest with no payload beside it verifies, and prints its version, its hash and the four files with their sizes" else bad "check-manifest on a signed manifest: $(tail -3 <<<"$out" | tr '\n' ' ')" diff --git a/tools/update/kryptik-update b/tools/update/kryptik-update index 4859f531..c92649f9 100755 --- a/tools/update/kryptik-update +++ b/tools/update/kryptik-update @@ -13,8 +13,9 @@ # --retry arm again a slot whose last trial failed # --recovery accept an older signed release (a downgrade) # -# DIR holds manifest, manifest.sig, kryptik-root.img, kryptik-a.efi, -# kryptik-b.efi and root.json, and nothing else. +# DIR holds manifest, manifest.sig, kryptik-root.img.zst (the root image, +# compressed for the trip), kryptik-a.efi, kryptik-b.efi and root.json, and +# nothing else. set -eu PROG=kryptik-update @@ -45,7 +46,7 @@ case "${1:-}" in [ "$(id -u)" != 0 ] || die "$1 does not run as root; kryptikd runs it as nobody" tools="ssh-keygen sha256sum cp awk sed sort head mktemp" ;; *) - tools="kryptikd sha256sum blkid dd cp mv sync flock mount umount cmp kryptik-efiboot awk sed sort head wc stat grep blockdev" + tools="kryptikd sha256sum blkid dd cp mv sync flock mount umount cmp kryptik-efiboot awk sed sort head wc stat grep blockdev zstd" [ "$(id -u)" = 0 ] || die "must run as root" mkdir -p "$B" /run/kryptik /var/log/kryptik ;; esac @@ -131,7 +132,7 @@ pin() { # pin FD NAME } payload_path() { # NAME -> where to read it case "$1" in - kryptik-root.img) echo /proc/self/fd/3 ;; kryptik-a.efi) echo /proc/self/fd/4 ;; + kryptik-root.img.zst) echo /proc/self/fd/3 ;; kryptik-a.efi) echo /proc/self/fd/4 ;; kryptik-b.efi) echo /proc/self/fd/5 ;; root.json) echo /proc/self/fd/6 ;; *) echo "$dir/$1" ;; esac @@ -140,7 +141,7 @@ payload_path() { # NAME -> where to read it # --- verification, all of it before any write ------------------------------ verify_payload() { # verify_payload DIR RECOVERY -> sets VERSION ROOT_HASH verify_manifest "$1" "$2" - pin 3 kryptik-root.img; pin 4 kryptik-a.efi; pin 5 kryptik-b.efi; pin 6 root.json + pin 3 kryptik-root.img.zst; pin 4 kryptik-a.efi; pin 5 kryptik-b.efi; pin 6 root.json # Nothing unlisted (a directory listing) before hashing every listed file # (gigabytes). Names are compared whole, as text, dotfiles included. @@ -177,16 +178,22 @@ EOF # Checks after the first write compare against these signed hashes, never # against the payload files again: the payload directory is mutable. manifest_hash() { sed -n '/^--$/,$p' "$m" | sed '1d' | awk -v r="$1" '$3==r {print $1; exit}'; } - H_ROOT="$(manifest_hash kryptik-root.img)"; H_KA="$(manifest_hash kryptik-a.efi)"; H_KB="$(manifest_hash kryptik-b.efi)" - [ "${#H_ROOT}" = 64 ] && [ "${#H_KA}" = 64 ] && [ "${#H_KB}" = 64 ] || die "the manifest does not list the root image and both kernels" + H_ZST="$(manifest_hash kryptik-root.img.zst)"; H_KA="$(manifest_hash kryptik-a.efi)"; H_KB="$(manifest_hash kryptik-b.efi)" + [ "${#H_ZST}" = 64 ] && [ "${#H_KA}" = 64 ] && [ "${#H_KB}" = 64 ] || die "the manifest does not list the root image and both kernels" # root.json must match the signed image, and both kernels carry its root hash. ROOT_HASH="$(sed -n 's/^ "root_hash": "\([0-9a-f]*\)".*/\1/p' /proc/self/fd/6 | head -1)" [ "${#ROOT_HASH}" = 64 ] || die "root.json has no root hash" ROOT_BYTES="$(sed -n 's/^ "total_bytes": \([0-9]*\).*/\1/p' /proc/self/fd/6 | head -1)" ROOT_SHA="$(sed -n 's/^ "sha256": "\([0-9a-f]*\)".*/\1/p' /proc/self/fd/6 | head -1)" - [ "$(stat -L -c %s /proc/self/fd/3)" = "$ROOT_BYTES" ] || die "root.json total_bytes does not match kryptik-root.img" - [ "$ROOT_SHA" = "$H_ROOT" ] || die "root.json's sha256 disagrees with the signed manifest's for kryptik-root.img" + [ "${#ROOT_SHA}" = 64 ] && [ -n "$ROOT_BYTES" ] || die "root.json names no image hash and size" + # The image's own hash is root.json's, which the manifest signs. zstd reads only + # bytes the manifest's hash vouched for, and what it gives is checked before + # anything is written. + got="$(zstd -dcq < /proc/self/fd/3 | sha256sum | cut -c1-64)" + [ "$got" = "$ROOT_SHA" ] || die "kryptik-root.img.zst does not decompress to the image root.json names" + H_ROOT="$ROOT_SHA" + say "the root image decompresses to the one root.json names ($ROOT_BYTES bytes)" for s in a b; do [ "$(grep -a -c -F "$ROOT_HASH" "$(payload_path "kryptik-$s.efi")")" -ge 1 ] \ || die "kryptik-$s.efi does not embed the root hash $ROOT_HASH; kernel and root are not one release" @@ -366,10 +373,11 @@ cmd_apply() { unlist_slot "$target" rm -rf "$B/release-$target" say "writing kryptik-$target ($ROOT_BYTES bytes)" - dd if=/proc/self/fd/3 of="$tdev" bs=4M conv=fsync status=none || die "writing slot $target failed" + # A cut stream writes short, which the read-back below catches. + zstd -dcq < /proc/self/fd/3 | dd of="$tdev" bs=4M iflag=fullblock conv=fsync status=none || die "writing slot $target failed" blockdev --flushbufs "$tdev" # so the read-back is of the device, not of the page cache got="$(dd if="$tdev" bs=4M iflag=count_bytes count="$ROOT_BYTES" status=none | sha256sum | cut -c1-64)" - [ "$got" = "$H_ROOT" ] || die "slot $target read back as $got; the manifest says $H_ROOT" + [ "$got" = "$H_ROOT" ] || die "slot $target read back as $got; root.json says $H_ROOT" say "slot $target verifies after write" mount_esp From b7d6a906ebe0cfda2f02c36bd5f0dc31646c9042 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 20:18:24 -0700 Subject: [PATCH 021/213] The setuid audit refuses an entry with no bit behind it, keeps no strip mode, and runs on the tested image An allowlist entry whose file has no bit, or no capability, passed in silence and would admit the next file of that name unread; it now fails the audit like an unlisted bit. --strip, which took bits off instead of failing, had no caller left but its own tests: a bit the image should not have is removed in its recipe, so the mode and its guards go. make acceptance gains setuid-bits, the same audit over the root image it tests, mounted read-only, beside the artifact audit; until now only stage 06 and the zones suite's in-guest find looked. --- docs/hardening.md | 13 ++--- tools/acceptance.sh | 2 + tools/audit-setuid.sh | 47 ++++++++---------- tools/pin-reviews.tsv | 2 +- tools/tests/audit-setuid.sh | 96 +++++++++++++------------------------ 5 files changed, 63 insertions(+), 97 deletions(-) diff --git a/docs/hardening.md b/docs/hardening.md index 929586a7..a79a0618 100644 --- a/docs/hardening.md +++ b/docs/hardening.md @@ -154,12 +154,13 @@ no other bit: shadow's eight other tools lose theirs after its install, util-linux is built with `--disable-makeinstall-setuid` and `--disable-makeinstall-chown` (wall's setgid tty is under that hook), and inetutils without traceroute. Stage 06 runs `tools/audit-setuid.sh` over the -image's root and fails the build on any unlisted bit or capability; on a root -staged by hand, `--strip` takes them off instead. Either way the audit fails -when it cannot read a directory, on a list entry without a justification, and -on a strip that would take the bit or the capabilities off a listed file -through a hard link. `make zones-test` audits the installed root again: the -bits are on the listed binaries alone, no file carries capabilities, and +image's root and fails the build on any unlisted bit or capability, on a list +entry without a justification, on an entry whose file carries no bit or +capability (it would let the next file of that name in unread), and when it +cannot read a directory. It never takes a bit off: a bit the image should not +have is removed in its recipe. `make acceptance` runs the same audit on the +root image it tests, and `make zones-test` audits the installed root again: +the bits are on the listed binaries alone, no file carries capabilities, and every sysctl reads back as `build/config/sysctl.d` says. ## Zone syscall filter diff --git a/tools/acceptance.sh b/tools/acceptance.sh index 65414333..48e51023 100755 --- a/tools/acceptance.sh +++ b/tools/acceptance.sh @@ -367,6 +367,7 @@ on_root_image() { # on_root_image CMD...: CMD, given the image mounted read-on } audit_root() { "${SELF}/check-artifact-hardening.sh" "$1" --strict --json "${OUT}/artifact-hardening.json"; } it_artifacts() { on_root_image audit_root; } +it_setuid() { on_root_image "${SELF}/audit-setuid.sh"; } it_licences() { on_root_image "${SELF}/check-image-licences.sh"; } it_kernel_config() { "${SELF}/validate-kernel-config.sh" --boot && "${SELF}/validate-kernel-config.sh" --hardened; } it_support_status() { "${SELF}/check-support-status.sh" --strict; } @@ -437,6 +438,7 @@ item build host-suites M host 0 it_host_suites need_host item build libc-unwind M host 0 it_libc_unwind need_sysroot item build userspace-smoke M host 0 it_userspace need_sysroot item build artifact-hardening M host 0 it_artifacts need_root_image +item build setuid-bits M host 0 it_setuid need_root_image item build licences M host 0 it_licences need_root_image item build kernel-config M host 0 it_kernel_config need_sources item build support-status M host 0 it_support_status diff --git a/tools/audit-setuid.sh b/tools/audit-setuid.sh index 861eaf91..64969723 100755 --- a/tools/audit-setuid.sh +++ b/tools/audit-setuid.sh @@ -1,19 +1,15 @@ #!/usr/bin/env bash -# Fail on a setuid/setgid bit or file capability its allowlist does not justify (docs/hardening.md). +# Fail on a setuid/setgid bit or file capability its allowlist does not justify, and on +# an entry the root does not need (docs/hardening.md). # -# ./tools/audit-setuid.sh [--strip] ROOT --strip takes each off instead; stage 06 runs without it and fails +# ./tools/audit-setuid.sh ROOT source "$(dirname "${BASH_SOURCE[0]}")/../build/lib/common.sh" -STRIP=0 -[[ "${1:-}" == --strip ]] && { STRIP=1; shift; } -ARG="${1:?usage: audit-setuid.sh [--strip] ROOT}" +ARG="${1:?usage: audit-setuid.sh ROOT}" # Resolved, so a trailing slash or a symlink cannot make an allowed binary look unknown. TARGET="$(realpath -e -- "$ARG")" || die "no such path: ${ARG}" [[ -d "$TARGET" ]] || die "not a directory: ${TARGET}" -# By device and inode, so a bind mount of / is refused as well as / itself. -[[ "$STRIP" -eq 0 || "$(stat -c %d:%i "$TARGET")" != "$(stat -c %d:%i /)" ]] \ - || die "--strip is for a staged image's root, never this machine's /" ALLOWLIST="${KRYPTIK_ROOT}/build/config/setuid-allowlist.txt" CAPLIST="${KRYPTIK_ROOT}/build/config/capability-allowlist.txt" @@ -32,8 +28,6 @@ listed() { declare -A ALLOWED=() CAP_ALLOWED=() listed "$ALLOWLIST" ALLOWED listed "$CAPLIST" CAP_ALLOWED -# Stripping by a missing or empty list would take the bit off every binary. -[[ "$STRIP" -eq 0 || "${#ALLOWED[@]}" -gt 0 ]] || die "--strip needs ${ALLOWLIST} with at least one entry" # The root's own filesystem only; a directory find cannot read may hide a binary, so it fails. found="$(mktemp)" @@ -65,18 +59,15 @@ sys.exit(1 if bad else 0) EOF mapfile -d '' -t capped < "$found" rm -f "$found" -has_caps() { python3 -c 'import os, sys; os.getxattr(sys.argv[1], "security.capability", follow_symlinks=False)' "$1" 2>/dev/null; } log "Auditing setuid/setgid binaries and file capabilities under ${TARGET}" -violations=0; kept=(); capkept=() +violations=0 +declare -A SEEN=() CAP_SEEN=() for bin in "${bins[@]}"; do rel="${bin#"${TARGET%/}"}" if [[ -n "${ALLOWED[$rel]:-}" ]]; then ok "allowed: ${rel}" - kept+=("$bin") - elif [[ "$STRIP" -eq 1 ]]; then - chmod ug-s "$bin" - warn "setuid/setgid removed: ${rel}" + SEEN["$rel"]=1 else err "unjustified setuid/setgid binary: ${rel} ($(stat -c '%A %U:%G' "$bin"))" violations=$((violations + 1)) @@ -86,28 +77,28 @@ for f in "${capped[@]}"; do rel="${f#"${TARGET%/}"}" if [[ -n "${CAP_ALLOWED[$rel]:-}" ]]; then ok "capabilities allowed: ${rel}" - capkept+=("$f") - elif [[ "$STRIP" -eq 1 ]]; then - python3 -c 'import os, sys; os.removexattr(sys.argv[1], "security.capability", follow_symlinks=False)' "$f" - warn "file capabilities removed: ${rel}" + CAP_SEEN["$rel"]=1 else err "unjustified file capabilities: ${rel} ($(getcap "$f" 2>/dev/null | cut -d' ' -f2- || echo set))" violations=$((violations + 1)) fi done -# Stripping a hard link to an allowed file strips both; the image would ship it broken. -for bin in "${kept[@]}"; do - [[ -u "$bin" || -g "$bin" ]] \ - || die "${bin#"${TARGET%/}"} lost its bit: a stripped file is another name for it" +# An entry with nothing behind it would let the next file of that name in unread. +for rel in "${!ALLOWED[@]}"; do + [[ -n "${SEEN[$rel]:-}" ]] && continue + err "${ALLOWLIST##*/} lists ${rel}, which has no setuid or setgid bit here: remove the entry" + violations=$((violations + 1)) done -for f in "${capkept[@]}"; do - has_caps "$f" || die "${f#"${TARGET%/}"} lost its capabilities: a stripped file is another name for it" +for rel in "${!CAP_ALLOWED[@]}"; do + [[ -n "${CAP_SEEN[$rel]:-}" ]] && continue + err "${CAPLIST##*/} lists ${rel}, which carries no capabilities here: remove the entry" + violations=$((violations + 1)) done echo if [[ "$violations" -gt 0 ]]; then - die "${violations} unjustified setuid/setgid binary(ies) or file capabilities. + die "${violations} unjustified setuid/setgid binary(ies), file capabilities or stale entries. Use a kryptikd-brokered service, or justify the privilege in ${ALLOWLIST} or ${CAPLIST}." fi -ok "no unjustified setuid/setgid binaries or file capabilities" +ok "no unjustified setuid/setgid binaries or file capabilities, and no stale entries" diff --git a/tools/pin-reviews.tsv b/tools/pin-reviews.tsv index 883615be..415febe1 100644 --- a/tools/pin-reviews.tsv +++ b/tools/pin-reviews.tsv @@ -15,7 +15,7 @@ mpc 1.3.1 1.4.1 fine 2026-09-19 NEWS 1.4.0 and 1.4.1: no secur # --- fine: nothing in the gap is a security fix that reaches Kryptik --------------- gawk 5.3.0 5.4.1 fine 2026-09-26 build/patches/gawk-5.3.0 carries upstream's fixes for CVE-2026-40467 (do_getline_redir use-after-free, a2d18c74), -40468 (do_sub and parse_escape overflows, 062f2f25) and -40553 (readdir extension, cca03661 and bfa2e4b8), made against 5.3.0's text; make check passes. -40469 (aa7272a6) guards 32-bit builds only and is not carried. 5.4 makes MinRX the default regex engine under every build script that calls awk, glibc's and the kernel's included: move in a build of its own, not in a batch. -shadow 4.16.0 4.20.3 fine 2026-09-27 build/patches/shadow-4.16.0 carries 4.17.0's sgetgrent use-after-free fix (8424d7c49462). No CVE is fixed in the gap; 4.20.3 only fixes the build with --with-nscd=no. CVE-2024-56433 is unfixed upstream and does not reach Kryptik: it needs accounts defined on the network with UIDs in the default /etc/subuid range and a setuid newuidmap; nsswitch.conf reads passwd and group from files only, and stage 06's audit-setuid.sh --strip leaves only su and passwd setuid, so no user can map the subordinate range that zones (uid_base 100000) also use. 4.19 needs --disable-logind, 4.20 changes the login.defs line the recipe edits, removes PASS_MIN_DAYS and makes su - fail unless TIOCSTI is off. Target 4.19.4, with the recipe. +shadow 4.16.0 4.20.3 fine 2026-09-27 build/patches/shadow-4.16.0 carries 4.17.0's sgetgrent use-after-free fix (8424d7c49462). No CVE is fixed in the gap; 4.20.3 only fixes the build with --with-nscd=no. CVE-2024-56433 is unfixed upstream and does not reach Kryptik: it needs accounts defined on the network with UIDs in the default /etc/subuid range and a setuid newuidmap; nsswitch.conf reads passwd and group from files only, and stage 06's audit-setuid.sh refuses any setuid file but su and passwd, so no user can map the subordinate range that zones (uid_base 100000) also use. 4.19 needs --disable-logind, 4.20 changes the login.defs line the recipe edits, removes PASS_MIN_DAYS and makes su - fail unless TIOCSTI is off. Target 4.19.4, with the recipe. bash 5.2.32 5.3 fine 2026-09-19 Patches 5.2.33 to 5.2.37 and the 5.3 NEWS: no security fix found. 5.3 needs readline 8.3. grep 3.11 3.12 fine 2026-09-19 NEWS 3.12: a failure of grep -r on directories with over 100,000 entries; no security fix. diffutils 3.10 3.12 fine 2026-09-27 NEWS 3.11 and 3.12: no security fix in a release. CVE-2026-53910 (a diff3 overflow on crafted diff output, disputed upstream as a crash) is fixed only in upstream git (9ff04d5b, 73ed7ce8), so a bump would not fix it. It needs whoever runs diff3 to take its diff output from an attacker, e.g. through --diff-program. Nothing in Kryptik runs diff3, and a user or zone that does runs it as itself, crossing no boundary. Never 3.11. diff --git a/tools/tests/audit-setuid.sh b/tools/tests/audit-setuid.sh index 9989ef4e..2b4aee62 100755 --- a/tools/tests/audit-setuid.sh +++ b/tools/tests/audit-setuid.sh @@ -14,7 +14,8 @@ export TMPDIR="$T" mkdir -p "$T/repo/tools" "$T/repo/build/lib" "$T/repo/build/config" "$T/root/usr/bin" cp "$ROOT/tools/audit-setuid.sh" "$T/repo/tools/" cp "$ROOT/build/lib/common.sh" "$T/repo/build/lib/" -printf '# test\n/usr/bin/su # why\n' > "$T/repo/build/config/setuid-allowlist.txt" +L="$T/repo/build/config/setuid-allowlist.txt" +printf '# test\n/usr/bin/su # why\n' > "$L" for f in su mount wall ls; do printf '#!/bin/sh\n' > "$T/root/usr/bin/$f"; done chmod 4755 "$T/root/usr/bin/su" "$T/root/usr/bin/mount" chmod 2755 "$T/root/usr/bin/wall" @@ -25,87 +26,58 @@ mode() { stat -c %a "$T/root/usr/bin/$1"; } out="$(audit "$T/root")"; rc=$? [[ "$rc" -ne 0 && "$out" == *"/usr/bin/mount"* && "$out" == *"/usr/bin/wall"* ]] \ && ok "an unlisted setuid and an unlisted setgid file fail the audit" || bad "audit: rc=$rc" -[[ "$(mode mount)" == 4755 ]] && ok "the audit alone changes nothing" || bad "the audit changed mount to $(mode mount)" +[[ "$(mode mount)/$(mode wall)" == 4755/2755 ]] && ok "the audit changes nothing" || bad "the audit changed mount to $(mode mount)" -out="$(audit --strip "$T/root")"; rc=$? -[[ "$rc" -eq 0 ]] && ok "--strip succeeds" || bad "--strip: rc=$rc: $out" -[[ "$(mode mount)/$(mode wall)" == 755/755 ]] && ok "the unlisted files lose the bit" || bad "after --strip: mount $(mode mount), wall $(mode wall)" -[[ "$(mode su)/$(mode ls)" == 4755/755 ]] && ok "the listed file keeps it, and a plain file is untouched" || bad "after --strip: su $(mode su), ls $(mode ls)" -audit "$T/root" > /dev/null && ok "the stripped tree passes the audit" || bad "the stripped tree still fails the audit" +chmod 755 "$T/root/usr/bin/mount" "$T/root/usr/bin/wall" +audit "$T/root" > /dev/null && ok "only the listed file keeps a bit: the audit passes" || bad "a tree with su alone still fails" # A root named with a trailing slash or through a symlink is the same root. -chmod 4755 "$T/root/usr/bin/mount"; ln -s root "$T/link" -out="$(audit --strip "$T/link/")"; rc=$? -[[ "$rc" -eq 0 && "$(mode su)/$(mode mount)" == 4755/755 ]] \ - && ok "a trailing slash and a symlinked root keep the listed file's bit" || bad "trailing slash: rc=$rc su $(mode su) mount $(mode mount): $out" - -# --strip refuses this machine's root; a stand-in chmod records any attempt and changes nothing. -mkdir -p "$T/bin"; printf '#!/bin/sh\necho "$@" >> "%s/chmod-called"; exit 1\n' "$T" > "$T/bin/chmod"; chmod 755 "$T/bin/chmod" -out="$(PATH="$T/bin:$PATH" audit --strip /)"; rc=$? -[[ "$rc" -ne 0 && "$out" == *"never this machine"* && ! -e "$T/chmod-called" ]] \ - && ok "--strip refuses /" || bad "--strip /: rc=$rc: $out" +ln -s root "$T/link" +out="$(audit "$T/link/")"; rc=$? +[[ "$rc" -eq 0 ]] && ok "a trailing slash and a symlinked root find the listed file" || bad "trailing slash: rc=$rc: $out" -# Stripping a hard link to a listed binary strips the listed one too, so the strip fails. -ln "$T/root/usr/bin/su" "$T/root/usr/bin/su2" -out="$(audit --strip "$T/root")"; rc=$? -[[ "$rc" -ne 0 && "$out" == *"/usr/bin/su lost its bit"* ]] \ - && ok "stripping a hard link to a listed binary fails, naming it" || bad "hard link: rc=$rc: $out" -rm -f "$T/root/usr/bin/su2"; chmod 4755 "$T/root/usr/bin/su" +# An entry with no bit behind it is stale: it would admit the next file of that name unread. +printf '# test\n/usr/bin/su # why\n/usr/bin/passwd # gone\n' > "$L" +out="$(audit "$T/root")"; rc=$? +[[ "$rc" -ne 0 && "$out" == *"lists /usr/bin/passwd, which has no setuid or setgid bit here"* ]] \ + && ok "an entry for a missing file fails the audit, naming it" || bad "missing file: rc=$rc: $out" +printf '# test\n/usr/bin/su # why\n/usr/bin/ls # no bit\n' > "$L" +out="$(audit "$T/root")"; rc=$? +[[ "$rc" -ne 0 && "$out" == *"lists /usr/bin/ls"* ]] \ + && ok "an entry for a file without the bit fails the audit" || bad "file without bit: rc=$rc: $out" -# A last entry with no newline still counts; a missing list strips nothing. -L="$T/repo/build/config/setuid-allowlist.txt" +# A last entry with no newline still counts. printf '# test\n/usr/bin/su # why' > "$L" -chmod 4755 "$T/root/usr/bin/mount" -out="$(audit --strip "$T/root")"; rc=$? -[[ "$rc" -eq 0 && "$(mode su)/$(mode mount)" == 4755/755 ]] \ - && ok "a last entry with no newline after it is still listed" || bad "no final newline: rc=$rc su $(mode su): $out" -mv "$L" "$L.away"; chmod 4755 "$T/root/usr/bin/mount" -out="$(audit --strip "$T/root")"; rc=$? -[[ "$rc" -ne 0 && "$(mode su)/$(mode mount)" == 4755/4755 ]] \ - && ok "--strip without an allowlist refuses, and changes nothing" || bad "no allowlist: rc=$rc su $(mode su) mount $(mode mount): $out" -mv "$L.away" "$L"; chmod 755 "$T/root/usr/bin/mount" +audit "$T/root" > /dev/null && ok "a last entry with no newline after it is still listed" || bad "no final newline" -# An entry without a justification refuses the audit before any file is touched. +# An entry without a justification refuses the audit. printf '# test\n/usr/bin/su\n' > "$L" -chmod 4755 "$T/root/usr/bin/mount" -out="$(audit --strip "$T/root")"; rc=$? -[[ "$rc" -ne 0 && "$out" == *"/usr/bin/su has no justification"* && "$(mode su)/$(mode mount)" == 4755/4755 ]] \ - && ok "an entry without a justification refuses the audit, and nothing is stripped" || bad "no justification: rc=$rc su $(mode su) mount $(mode mount): $out" -printf '# test\n/usr/bin/su # why\n' > "$L"; chmod 755 "$T/root/usr/bin/mount" - -# A bind mount of / is still /; a private mount namespace keeps it out of $T's cleanup. -mkdir -p "$T/slash" -if unshare -rm mount --rbind / "$T/slash" 2>/dev/null; then - out="$(PATH="$T/bin:$PATH" NO_COLOR=1 unshare -rm bash -c 'mount --rbind / "$1" && bash "$2" --strip "$1"' _ "$T/slash" "$T/repo/tools/audit-setuid.sh" 2>&1)"; rc=$? - [[ "$rc" -ne 0 && "$out" == *"never this machine"* && ! -e "$T/chmod-called" ]] \ - && ok "--strip refuses a bind mount of /" || bad "bind mount of /: rc=$rc: $out" -else - printf ' SKIP --strip refuses a bind mount of / (no mount namespace here)\n' -fi +out="$(audit "$T/root")"; rc=$? +[[ "$rc" -ne 0 && "$out" == *"/usr/bin/su has no justification"* ]] \ + && ok "an entry without a justification refuses the audit" || bad "no justification: rc=$rc: $out" +printf '# test\n/usr/bin/su # why\n' > "$L" -# File capabilities: setting or removing one needs CAP_SETFCAP, which a user namespace grants. -printf '# test\n/usr/bin/capok # why\n' > "$T/repo/build/config/capability-allowlist.txt" +# File capabilities: setting one needs CAP_SETFCAP, which a user namespace grants. +C="$T/repo/build/config/capability-allowlist.txt" +printf '# test\n/usr/bin/capok # why\n' > "$C" setcap_ns() { # setcap_ns FILE: cap_net_raw+ep on FILE, set from a user namespace unshare -r python3 -c 'import os, struct, sys; os.setxattr(sys.argv[1], "security.capability", struct.pack("<5I", 0x02000001, 1 << 13, 0, 0, 0))' "$1" 2>/dev/null } -has_cap() { python3 -c 'import os, sys; os.getxattr(sys.argv[1], "security.capability")' "$1" 2>/dev/null; } -strip_ns() { NO_COLOR=1 unshare -r bash "$T/repo/tools/audit-setuid.sh" --strip "$T/root" 2>&1; } for f in capok capno; do printf '#!/bin/sh\n' > "$T/root/usr/bin/$f"; chmod 755 "$T/root/usr/bin/$f"; done if setcap_ns "$T/root/usr/bin/capok" && setcap_ns "$T/root/usr/bin/capno"; then out="$(audit "$T/root")"; rc=$? [[ "$rc" -ne 0 && "$out" == *"unjustified file capabilities: /usr/bin/capno"* && "$out" != *"unjustified file capabilities: /usr/bin/capok"* ]] \ && ok "an unlisted file with capabilities fails the audit, a listed one does not" || bad "capability audit: rc=$rc: $out" - out="$(strip_ns)"; rc=$? - { [[ "$rc" -eq 0 ]] && has_cap "$T/root/usr/bin/capok" && ! has_cap "$T/root/usr/bin/capno"; } \ - && ok "--strip removes unlisted capabilities and keeps listed ones" || bad "capability strip: rc=$rc: $out" - ln "$T/root/usr/bin/capok" "$T/root/usr/bin/capok2" - out="$(strip_ns)"; rc=$? - [[ "$rc" -ne 0 && "$out" == *"/usr/bin/capok lost its capabilities"* ]] \ - && ok "stripping a hard link to a listed file fails, naming it" || bad "capability hard link: rc=$rc: $out" - rm -f "$T/root/usr/bin/capok2" + rm -f "$T/root/usr/bin/capno" + audit "$T/root" > /dev/null && ok "the listed file's capabilities pass" || bad "listed capabilities still fail" + rm -f "$T/root/usr/bin/capok" + out="$(audit "$T/root")"; rc=$? + [[ "$rc" -ne 0 && "$out" == *"lists /usr/bin/capok, which carries no capabilities here"* ]] \ + && ok "a capability entry with no file behind it fails the audit" || bad "stale capability entry: rc=$rc: $out" else printf ' SKIP file capabilities (no user namespace to set one in)\n' fi +: > "$C" # An unreadable directory could hide a binary, so it fails the audit; root reads them all. if [[ "$(id -u)" -ne 0 ]]; then From c43fbc675527422a7b7cf450c7bb3e8507139623 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 20:19:26 -0700 Subject: [PATCH 022/213] The install, integrity and keyboard suites drive and stop their VMs through suite-lib's drive and stop_vm --- tools/image/install-test.sh | 12 ++++++------ tools/image/integrity-test.sh | 16 ++++++++-------- tools/image/keyboard-test.sh | 2 +- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/tools/image/install-test.sh b/tools/image/install-test.sh index 82714cc3..0f55c91b 100755 --- a/tools/image/install-test.sh +++ b/tools/image/install-test.sh @@ -93,7 +93,7 @@ cp "/usr/share/OVMF/OVMF_VARS_4M.fd" "$VARSF" [[ "$VARS" == "enrolled" ]] && cp "${KRYPTIK_WORK}/keys/sb/vars/enrolled.fd" "$VARSF" start_vm install-p2; LOG2="$LOG" REC="${VMDIR}/install-p2.json" -python3 "$DRV" --serial "$SER" --timeout 300 --record "$REC" \ +drive --record "$REC" \ "expect:KRYPTIK_SMOKE: END" \ "seen:kryptik-firstboot: created user '${TUSER}'" \ "login:${TUSER}:${TPASS}" \ @@ -113,7 +113,7 @@ python3 "$DRV" --serial "$SER" --timeout 300 --record "$REC" \ "expect:Power down" \ "wait-exit" drc=$? -sleep 1; [[ -f "$PIDF" ]] && kill "$(cat "$PIDF")" 2>/dev/null +stop_vm P2="${VMDIR}/install-p2.txt"; txt_of "$LOG2" > "$P2" echo " transcript: ${LOG2}" [[ "$drc" -eq 0 ]] && green "first boot, login, reboot, second login and clean poweroff all happened" || red "the serial drive failed (see above)" @@ -143,12 +143,12 @@ if [[ "$QUICK" -eq 0 ]]; then step "step 3: cold boot the installed disk again, with a signed control disk attached" # Step 1's: it names this disk as the target and asks for a poweroff. start_vm install-p3 --testctl "$CTL"; LOG3="$LOG" -python3 "$DRV" --serial "$SER" --timeout 300 \ +drive \ "expect:KRYPTIK_SMOKE: END" "login:${TUSER}:${TPASS}" \ "run:test -f /home/${TUSER}/persisted-p2" \ "su:${RPASS}:poweroff" "expect:Power down" "wait-exit" drc=$? -sleep 1; [[ -f "$PIDF" ]] && kill "$(cat "$PIDF")" 2>/dev/null +stop_vm [[ "$drc" -eq 0 ]] && green "cold boot: login, persisted file present, clean poweroff" || red "cold boot drive failed" P3="${VMDIR}/install-p3.txt"; txt_of "$LOG3" > "$P3" deny "$P3" 'kryptik-firstboot: created user' "first-boot setup did not run again" @@ -308,12 +308,12 @@ if [[ "$QUICK" -eq 0 && -n "$SLOT" ]]; then cp "/usr/share/OVMF/OVMF_VARS_4M.fd" "$VARSF" [[ "$VARS" == "enrolled" ]] && cp "${KRYPTIK_WORK}/keys/sb/vars/enrolled.fd" "$VARSF" start_vm slot-size-boot - python3 "$DRV" --serial "$SER" --timeout 300 \ + drive \ "expect:KRYPTIK_SMOKE: END" "login:${TUSER}:${TPASS}" \ "run:test \"\$(cat /sys/class/block/vda2/size)\" = $(( BIG * 2048 ))" \ "su:${RPASS}:poweroff" "expect:Power down" "wait-exit" drc=$? - sleep 1; [[ -f "$PIDF" ]] && kill "$(cat "$PIDF")" 2>/dev/null + stop_vm DISK="$SAVED_DISK" [[ "$drc" -eq 0 ]] && green "slot-size: the disk boots alone, and slot a is ${BIG} MiB from inside" || red "slot-size: the boot of the disk with larger slots failed" fi diff --git a/tools/image/integrity-test.sh b/tools/image/integrity-test.sh index 16f0fe8a..3e460f67 100755 --- a/tools/image/integrity-test.sh +++ b/tools/image/integrity-test.sh @@ -59,7 +59,7 @@ install_disk integ-install "$USB" --vars enrolled && green "installed from the m boot_txt | grep -q 'KRYPTIK_SMOKE: secureboot=1' && green "the medium itself booted with Secure Boot enforced" || red "medium did not report secureboot=1" start_vm integ-p1; LOG1="$LOG" -python3 "$DRV" --serial "$SER" --timeout 300 \ +drive \ "expect:KRYPTIK_SMOKE: END" "login:${TUSER}:${TPASS}" \ "run:test \"\$(od -An -tu1 -j4 -N1 /sys/firmware/efi/efivars/SecureBoot-8be4df61-93ca-11d2-aa0d-00e098032b8c | tr -d ' ')\" = 1" \ "run:echo integrity-marker > /home/${TUSER}/marker && sync" \ @@ -67,7 +67,7 @@ python3 "$DRV" --serial "$SER" --timeout 300 \ "$(printf 'su:%s:%s' "$RPASS" 'insmod /usr/lib/kryptik/kernel/mac80211_hwsim-unsigned.ko 2>&1; echo UNSIGNED-RC=$?; test ! -d /sys/module/mac80211_hwsim && echo UNSIGNED=refused')" "expect:UNSIGNED=refused" \ "$(printf 'su:%s:%s' "$RPASS" 'modprobe mac80211_hwsim radios=0 && test -d /sys/module/mac80211_hwsim && echo SIGNED=loaded')" "expect:SIGNED=loaded" \ "su:${RPASS}:poweroff" "expect:Power down" "wait-exit" -rc=$?; sleep 1; [[ -f "$PIDF" ]] && kill "$(cat "$PIDF")" 2>/dev/null +rc=$?; stop_vm [[ "$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" @@ -212,11 +212,11 @@ LEFT="$(mdir -/ -b -i "$ESPIMG" ::/ 2>/dev/null | grep -i '\.new$' | tr '\n' ' ' [[ -z "$LEFT" ]] && green "recovery left no .new file on the ESP" || red "recovery left ${LEFT}on the ESP" cp "$ENROLLED" "$VARSF" start_vm integ-p4b; LOG4="$LOG" -python3 "$DRV" --serial "$SER" --timeout 300 \ +drive \ "expect:KRYPTIK_SMOKE: END" "login:${TUSER}:${TPASS}" \ "run:test \"\$(cat /home/${TUSER}/marker)\" = integrity-marker" \ "su:${RPASS}:poweroff" "expect:Power down" "wait-exit" -rc=$?; sleep 1; [[ -f "$PIDF" ]] && kill "$(cat "$PIDF")" 2>/dev/null +rc=$?; stop_vm [[ "$rc" -eq 0 ]] && green "the recovered disk boots alone under Secure Boot; the user and the home file survived" || red "step 4 drive failed" tr -d '\r' < "$LOG4" | grep -q 'KRYPTIK_SMOKE: verity_root=0 [0-9]* verity V' && green "dm-verity reports the restored root valid" || red "restored root not reported valid" tr -d '\r' < "$LOG4" | grep -q 'kryptik-firstboot: created user' && red "first-boot setup ran again (state was lost)" || green "first-boot setup did not run again" @@ -277,7 +277,7 @@ fi cp "$ENROLLED" "$VARSF" start_vm integ-p5 "${EXTRA[@]}"; LOG5="$LOG" # 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 \ +drive \ "expect:KRYPTIK_SMOKE: END" "login:${TUSER}:${TPASS}" \ "grab:overlay:ls /etc/kryptik/ /var/lib/kryptik/etc/quarantine/ 2>&1 | head -12" \ "run:test ! -e /etc/kryptik/trust/release-signers" \ @@ -297,7 +297,7 @@ python3 "$DRV" --serial "$SER" --timeout 300 \ ${EXTRA:+"$(printf 'su:%s:%s' "$RPASS" 'mkdir -p /run/upd/x && mount -o ro /dev/vdb /run/upd/x && kryptik-update apply /run/upd/x; echo UPD-RC=$?')"} \ ${EXTRA:+"expect:not enrolled"} \ "$(printf 'su:%s:%s' "$RPASS" 'poweroff')" "expect:Power down" "wait-exit" -rc=$?; sleep 1; [[ -f "$PIDF" ]] && kill "$(cat "$PIDF")" 2>/dev/null +rc=$?; stop_vm T5="$(tr -d '\r' < "$LOG5")" [[ "$rc" -eq 0 ]] && green "the planted /etc content was quarantined before the overlay was mounted (preload, udev rule, zone, anchor, sysctl), the allowed change is in effect, and nothing planted ran" || red "step 5 drive failed" grep -q 'no zone named "evil"' <<<"$T5" && green "the launch daemon does not know the planted zone (it reads /usr/lib/kryptik/zones)" || red "the daemon honoured a planted zone" @@ -330,11 +330,11 @@ else fi cp "$ENROLLED" "$VARSF" start_vm integ-p6b; LOG6="$LOG" -python3 "$DRV" --serial "$SER" --timeout 300 \ +drive \ "expect:KRYPTIK_SMOKE: END" "login:${TUSER}:${TPASS}" \ "run:test \"\$(cat /home/${TUSER}/marker)\" = integrity-marker" \ "su:${RPASS}:poweroff" "expect:Power down" "wait-exit" -rc=$?; sleep 1; [[ -f "$PIDF" ]] && kill "$(cat "$PIDF")" 2>/dev/null +rc=$?; stop_vm [[ "$rc" -eq 0 ]] && green "the disk unlocks with the restored header; the user and the home file are there" || red "step 6 drive failed" tr -d '\r' < "$LOG6" | grep -q 'STATE DEGRADED' && red "degraded after the header was restored" || green "the state is not degraded after the restore" diff --git a/tools/image/keyboard-test.sh b/tools/image/keyboard-test.sh index c3822177..9dcf8f8a 100755 --- a/tools/image/keyboard-test.sh +++ b/tools/image/keyboard-test.sh @@ -62,7 +62,7 @@ step "step 2: the passphrase pressed where a German keyboard has it" start_vm keyboard-p2 # No answer on the serial line: the passphrase goes in through the keyboard. # The Y key gives z, the Z key y, and the key right of the full stop gives -. -KRYPTIK_STATE_PASSPHRASE='' python3 "$DRV" --serial "$SER" --qmp "$QMP" --timeout 300 \ +KRYPTIK_STATE_PASSPHRASE='' drive --qmp "$QMP" \ "expect:sysinit: keyboard layout de" \ "expect:passphrase for the state partition \(try 1 of 3\): " "sleep:2" \ "key:l" "key:a" "key:y" "key:z" "key:slash" "key:y" "key:e" "key:b" "key:r" "key:a" "key:ret" \ From 1f52ffb7b6b6191acc672810ef534d17f1ee03c7 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 20:19:31 -0700 Subject: [PATCH 023/213] passwd keeps no setuid bit, so su is the image's one setuid program passwd was setuid so that a user could change their own password: a root-side parser of /etc/shadow and login.defs that any local user could drive. Kryptik's first user is in wheel and holds root's password for su, so a password changes as root (su, then passwd NAME) and passwd needs no privilege of its own. shadow's recipe takes the bit off with its eight other tools, the allowlist drops the entry (the setuid audit would now call it stale), and the zones suite expects su alone. --- build/config/setuid-allowlist.txt | 1 - build/guest-tests/zones-check.sh | 2 +- build/recipes/shadow.sh | 4 ++-- docs/hardening.md | 7 ++++--- docs/user-guide.md | 3 ++- tools/pin-reviews.tsv | 2 +- 6 files changed, 10 insertions(+), 9 deletions(-) diff --git a/build/config/setuid-allowlist.txt b/build/config/setuid-allowlist.txt index c32154e0..3ccd8b65 100644 --- a/build/config/setuid-allowlist.txt +++ b/build/config/setuid-allowlist.txt @@ -2,4 +2,3 @@ # /absolute/path # why it stays, and what would remove it # A file capability is no way round this list: it needs an entry in capability-allowlist.txt. /usr/bin/su # the one way from a login to root, for an administrator and for the test drivers (vm-drive.py su:); goes when kryptikd brokers a root session -/usr/bin/passwd # a user changes their own password; goes when a kryptikd verb does that, at the cost of a root-side /etc/shadow parser diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index f61e41a5..c3f96939 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -1117,7 +1117,7 @@ zrun untrusted 30 -- grep -c /usr/lib/libhardened_malloc.so /proc/self/maps # Stage 06 fails the build on any other bit; expected here: the entries of # build/config/setuid-allowlist.txt, and no file capabilities. setuid_found="$(find / -xdev -type f -perm /6000 2>/dev/null | LC_ALL=C sort | tr '\n' ' ')" -[[ "$setuid_found" == "/usr/bin/passwd /usr/bin/su " ]] && pass "setuid-only-allowed" "on the root filesystem: ${setuid_found}" || fail "setuid-only-allowed" "found: ${setuid_found:-none}" +[[ "$setuid_found" == "/usr/bin/su " ]] && pass "setuid-only-allowed" "on the root filesystem: ${setuid_found}" || fail "setuid-only-allowed" "found: ${setuid_found:-none}" # The scan ends with how many files it read: one that died early, or walked # nothing, has not shown that no file carries a capability. capscan="$(python3 - <<'PY' diff --git a/build/recipes/shadow.sh b/build/recipes/shadow.sh index 3d10c2cb..721a02af 100644 --- a/build/recipes/shadow.sh +++ b/build/recipes/shadow.sh @@ -19,6 +19,6 @@ s_shadow() { --without-libbsd --with-group-name-max-length=32 make make exec_prefix=/usr install - # Only su and passwd keep a setuid bit (build/config/setuid-allowlist.txt). - chmod ug-s /usr/bin/{chage,chfn,chsh,expiry,gpasswd,newgidmap,newgrp,newuidmap} + # Only su keeps a setuid bit (build/config/setuid-allowlist.txt); passwords change through it. + chmod ug-s /usr/bin/{chage,chfn,chsh,expiry,gpasswd,newgidmap,newgrp,newuidmap,passwd} } diff --git a/docs/hardening.md b/docs/hardening.md index a79a0618..431fb2a0 100644 --- a/docs/hardening.md +++ b/docs/hardening.md @@ -146,11 +146,12 @@ filters do not need it), kexec, userfaultfd, 32-bit mappings or core dumps. Privilege transitions go through kryptikd, where they can be audited, not through setuid binaries or file capabilities. A setuid binary needs a -justified entry in `build/config/setuid-allowlist.txt`, which lists `su`, the -only way from a login to root, and `passwd`, which nothing brokers yet. A file +justified entry in `build/config/setuid-allowlist.txt`, which lists only +`su`, the only way from a login to root; passwords change through it +(`su -c 'passwd NAME'`), so `passwd` runs as root only for root. A file carrying capabilities (`security.capability`) needs one in `build/config/capability-allowlist.txt`, which is empty. The recipes install -no other bit: shadow's eight other tools lose theirs after its install, +no other bit: shadow's nine other tools lose theirs after its install, util-linux is built with `--disable-makeinstall-setuid` and `--disable-makeinstall-chown` (wall's setgid tty is under that hook), and inetutils without traceroute. Stage 06 runs `tools/audit-setuid.sh` over the diff --git a/docs/user-guide.md b/docs/user-guide.md index 31639a08..1d0a7809 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -251,7 +251,8 @@ is more than 30 days old, or, with none ever accepted, when the install is (section 4). For administration, Ctrl+Alt+F2 gives a text login: log in as the user, and -`su` becomes root. There `kryptik list`, `kryptik status [ZONE]`, `kryptik +`su` becomes root, and a password changes only there: `passwd NAME`. There +`kryptik list`, `kryptik status [ZONE]`, `kryptik explain ZONE` and `kryptik doctor` describe the zones, and `kryptik stop ZONE` stops one. `kryptik shell ZONE` and `kryptik run ZONE -- COMMAND` start the zone through the launch service when run as the user, detached: its output diff --git a/tools/pin-reviews.tsv b/tools/pin-reviews.tsv index 415febe1..90012ce1 100644 --- a/tools/pin-reviews.tsv +++ b/tools/pin-reviews.tsv @@ -15,7 +15,7 @@ mpc 1.3.1 1.4.1 fine 2026-09-19 NEWS 1.4.0 and 1.4.1: no secur # --- fine: nothing in the gap is a security fix that reaches Kryptik --------------- gawk 5.3.0 5.4.1 fine 2026-09-26 build/patches/gawk-5.3.0 carries upstream's fixes for CVE-2026-40467 (do_getline_redir use-after-free, a2d18c74), -40468 (do_sub and parse_escape overflows, 062f2f25) and -40553 (readdir extension, cca03661 and bfa2e4b8), made against 5.3.0's text; make check passes. -40469 (aa7272a6) guards 32-bit builds only and is not carried. 5.4 makes MinRX the default regex engine under every build script that calls awk, glibc's and the kernel's included: move in a build of its own, not in a batch. -shadow 4.16.0 4.20.3 fine 2026-09-27 build/patches/shadow-4.16.0 carries 4.17.0's sgetgrent use-after-free fix (8424d7c49462). No CVE is fixed in the gap; 4.20.3 only fixes the build with --with-nscd=no. CVE-2024-56433 is unfixed upstream and does not reach Kryptik: it needs accounts defined on the network with UIDs in the default /etc/subuid range and a setuid newuidmap; nsswitch.conf reads passwd and group from files only, and stage 06's audit-setuid.sh refuses any setuid file but su and passwd, so no user can map the subordinate range that zones (uid_base 100000) also use. 4.19 needs --disable-logind, 4.20 changes the login.defs line the recipe edits, removes PASS_MIN_DAYS and makes su - fail unless TIOCSTI is off. Target 4.19.4, with the recipe. +shadow 4.16.0 4.20.3 fine 2026-09-27 build/patches/shadow-4.16.0 carries 4.17.0's sgetgrent use-after-free fix (8424d7c49462). No CVE is fixed in the gap; 4.20.3 only fixes the build with --with-nscd=no. CVE-2024-56433 is unfixed upstream and does not reach Kryptik: it needs accounts defined on the network with UIDs in the default /etc/subuid range and a setuid newuidmap; nsswitch.conf reads passwd and group from files only, and stage 06's audit-setuid.sh refuses any setuid file but su, so no user can map the subordinate range that zones (uid_base 100000) also use. 4.19 needs --disable-logind, 4.20 changes the login.defs line the recipe edits, removes PASS_MIN_DAYS and makes su - fail unless TIOCSTI is off. Target 4.19.4, with the recipe. bash 5.2.32 5.3 fine 2026-09-19 Patches 5.2.33 to 5.2.37 and the 5.3 NEWS: no security fix found. 5.3 needs readline 8.3. grep 3.11 3.12 fine 2026-09-19 NEWS 3.12: a failure of grep -r on directories with over 100,000 entries; no security fix. diffutils 3.10 3.12 fine 2026-09-27 NEWS 3.11 and 3.12: no security fix in a release. CVE-2026-53910 (a diff3 overflow on crafted diff output, disputed upstream as a crash) is fixed only in upstream git (9ff04d5b, 73ed7ce8), so a bump would not fix it. It needs whoever runs diff3 to take its diff output from an attacker, e.g. through --diff-program. Nothing in Kryptik runs diff3, and a user or zone that does runs it as itself, crossing no boundary. Never 3.11. From 3dbad7bf35cbea67d3c41a589cdeb9ce3c476879 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 20:22:27 -0700 Subject: [PATCH 024/213] Rule 6's lines reflowed --- docs/design/time.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/design/time.md b/docs/design/time.md index f1ff4f73..6ca8fb71 100644 --- a/docs/design/time.md +++ b/docs/design/time.md @@ -83,9 +83,8 @@ and the net zone prints `time=` in its readiness line. (`RTC_SET_TIME` on `/dev/rtc0` if present), except on a degraded boot: its `/var` is memory, so the unasked total starts afresh at each such boot, and its steps stay out of the RTC the next boot starts from. Each - claim considered adds a - line to `/var/lib/kryptik/time/history`; `kryptikd time status` prints the - clock, the floor and the last line. + claim considered adds a line to `/var/lib/kryptik/time/history`; + `kryptikd time status` prints the clock, the floor and the last line. A net zone that never answers leaves the clock to the RTC; that is reported (`time=no-answer`), not repaired. From 643cbb9e8fe6e174737dbaff8803d87ea07d8cd7 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 20:22:36 -0700 Subject: [PATCH 025/213] The guide's password sentence reads as one clause per step --- docs/user-guide.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/user-guide.md b/docs/user-guide.md index 1d0a7809..73f1a9a7 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -251,8 +251,8 @@ is more than 30 days old, or, with none ever accepted, when the install is (section 4). For administration, Ctrl+Alt+F2 gives a text login: log in as the user, and -`su` becomes root, and a password changes only there: `passwd NAME`. There -`kryptik list`, `kryptik status [ZONE]`, `kryptik +`su` becomes root, where passwords change: `passwd NAME` for the user, +`passwd` for root's own. There `kryptik list`, `kryptik status [ZONE]`, `kryptik explain ZONE` and `kryptik doctor` describe the zones, and `kryptik stop ZONE` stops one. `kryptik shell ZONE` and `kryptik run ZONE -- COMMAND` start the zone through the launch service when run as the user, detached: its output From db90a2a241a82a43d6bfd063a4300f335d6a7753 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 20:23:22 -0700 Subject: [PATCH 026/213] The acceptance records every entry of the image it tests in image-manifest.txt The report said how the image's objects were built and which sources went in, but not what the image holds: no file list, no hash per file. A new image-manifest item runs tools/artifact-manifest.sh over the root image mounted read-only, so each run keeps the image's every path with its type, mode, owner, size and SHA-256, and two runs' manifests show what changed between two images. --- tools/acceptance.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tools/acceptance.sh b/tools/acceptance.sh index 65414333..731d70b0 100755 --- a/tools/acceptance.sh +++ b/tools/acceptance.sh @@ -367,6 +367,9 @@ on_root_image() { # on_root_image CMD...: CMD, given the image mounted read-on } audit_root() { "${SELF}/check-artifact-hardening.sh" "$1" --strict --json "${OUT}/artifact-hardening.json"; } it_artifacts() { on_root_image audit_root; } +# Every entry of the image under test, hashed: what a release ships, and what the next one changes. +record_root() { "${SELF}/artifact-manifest.sh" --root "$1" --out "${OUT}/image-manifest.txt"; } +it_manifest() { on_root_image record_root; } it_licences() { on_root_image "${SELF}/check-image-licences.sh"; } it_kernel_config() { "${SELF}/validate-kernel-config.sh" --boot && "${SELF}/validate-kernel-config.sh" --hardened; } it_support_status() { "${SELF}/check-support-status.sh" --strict; } @@ -437,6 +440,7 @@ item build host-suites M host 0 it_host_suites need_host item build libc-unwind M host 0 it_libc_unwind need_sysroot item build userspace-smoke M host 0 it_userspace need_sysroot item build artifact-hardening M host 0 it_artifacts need_root_image +item build image-manifest M host 0 it_manifest need_root_image item build licences M host 0 it_licences need_root_image item build kernel-config M host 0 it_kernel_config need_sources item build support-status M host 0 it_support_status From 0be94151fe89b92b1753636ba3a04646c259dc56 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 20:24:04 -0700 Subject: [PATCH 027/213] update-esp.sh finds apply's slot write as the decompression into dd --- tools/tests/update-esp.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/tests/update-esp.sh b/tools/tests/update-esp.sh index ae538a29..ed6c2f0b 100755 --- a/tools/tests/update-esp.sh +++ b/tools/tests/update-esp.sh @@ -74,7 +74,7 @@ fresh; out="$(bash -c "source '$T/esp.sh'; cmd_rollback" 2>&1)"; rc=$? && ok "rollback refuses a slot whose write never finished" || bad "rollback: rc=$rc / $out" body="$(sed -n '/^cmd_apply() {/,/^}/p' "$TOOL")" u="$(grep -n 'unlist_slot "$target"' <<< "$body" | head -1 | cut -d: -f1)" -w="$(grep -n 'dd if=/proc/self/fd/3 of=' <<< "$body" | head -1 | cut -d: -f1)" +w="$(grep -n '< /proc/self/fd/3 | dd of=' <<< "$body" | head -1 | cut -d: -f1)" [[ -n "$u" && -n "$w" && "$u" -lt "$w" ]] \ && ok "apply takes the slot off the ESP before it writes a byte of it" \ || bad "apply: the slot is taken off the ESP at line '${u}', written at line '${w}'" From f642053b9dcb61900be9537ff12fdfa8394897ea Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 20:26:06 -0700 Subject: [PATCH 028/213] libnl is built without its command-line tools, which nothing runs libnl installed about forty programs (nl-link-list, nl-route-add, nf-ct-list, genl-ctrl-list, idiag-socket-details and the rest), the libnl-cli-3 library and its qdisc and classifier modules. wpa_supplicant and iw link libnl-3, libnl-genl-3 and libnl-route-3 only, and no suite, service or zone names a tool. --disable-cli leaves the library, and the recipe removes what a cached sysroot still holds from a build with them. --- build/recipes/libnl.sh | 11 +++++++++++ build/stages/04-base-system.sh | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 build/recipes/libnl.sh diff --git a/build/recipes/libnl.sh b/build/recipes/libnl.sh new file mode 100644 index 00000000..709c991b --- /dev/null +++ b/build/recipes/libnl.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +# The library alone: wpa_supplicant and iw link it, and nothing runs its nl-* tools. +s_libnl() { + native_build "libnl-${V_LIBNL}.tar.gz" "libnl-${V_LIBNL}" --sysconfdir=/etc --disable-static --disable-cli + # A cached sysroot still holds what a build with the tools installed. + rm -rf /usr/bin/nl-* /usr/bin/nf-* /usr/bin/genl-ctrl-list /usr/bin/idiag-socket-details \ + /usr/lib/libnl/cli /usr/lib/libnl-cli-3.* /usr/lib/pkgconfig/libnl-cli-3.0.pc \ + /usr/share/man/man8/genl-ctrl-list.8 /usr/share/man/man8/nl-*.8 + [[ ! -e /usr/bin/nl-link-list ]] || { echo "FAIL: libnl's tools are still installed"; return 1; } +} diff --git a/build/stages/04-base-system.sh b/build/stages/04-base-system.sh index aa4815fd..875465d6 100755 --- a/build/stages/04-base-system.sh +++ b/build/stages/04-base-system.sh @@ -253,7 +253,7 @@ PACKAGES=( "dnsmasq" "s_dnsmasq" "dhcpcd" "s_dhcpcd" # --- the net zone's Wi-Fi (docs/design/net-zone.md). - "libnl" "native_build libnl-${V_LIBNL}.tar.gz libnl-${V_LIBNL} --sysconfdir=/etc --disable-static" + "libnl" "s_libnl" "wpa-supplicant" "s_wpa_supplicant" "iw" "s_iw" # --- what the net zone verifies a release server by. From cf66434dde0f6221ceccce68a87c16e513016854 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 20:26:31 -0700 Subject: [PATCH 029/213] inetutils installs no ftp, telnet, tftp or talk, so the image carries no cleartext client The recipe kept inetutils for hostname and ifconfig and already left out its servers, ping and traceroute, but the four clients it builds by default came along: programs that send logins and files unencrypted, which no suite, service or zone uses. They are disabled, and the recipe removes what a cached sysroot still holds from a build with them. --- build/recipes/inetutils.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/build/recipes/inetutils.sh b/build/recipes/inetutils.sh index a9a97573..2c64be17 100644 --- a/build/recipes/inetutils.sh +++ b/build/recipes/inetutils.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash -# hostname and ifconfig; no servers, which nothing starts, and ping comes from iputils. +# hostname and ifconfig; no servers and no cleartext clients, which nothing runs, and ping comes from iputils. s_inetutils() { native_build "inetutils-${V_INETUTILS}.tar.gz" "inetutils-${V_INETUTILS}" --bindir=/usr/bin --localstatedir=/var \ --disable-servers --disable-logger --disable-whois --disable-rlogin --disable-rsh --disable-rcp --disable-rexec \ - --disable-ping --disable-ping6 --disable-traceroute - # A cached sysroot may still hold an old setuid traceroute. - rm -f /usr/bin/traceroute + --disable-ping --disable-ping6 --disable-traceroute --disable-ftp --disable-telnet --disable-tftp --disable-talk + # A cached sysroot may still hold what an older build installed: a setuid traceroute, the clients. + rm -f /usr/bin/{traceroute,ftp,telnet,tftp,talk} /usr/share/man/man1/{ftp,telnet,tftp,talk}.1 } From 78912ea20aa2401c7a3d240f27a47d4800c3eae3 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 20:26:49 -0700 Subject: [PATCH 030/213] Each routed zone asks a resolver of its own, whose cache, lookups in flight and forwarding slots no other zone shares Every routed zone asked one dnsmasq. It kept no cache, as a cached answer would come back sooner and with less TTL to the next zone asking, but two things stayed shared: a zone asking for a name while another zone's lookup of it was in flight got that lookup's answer, so it could tell the name had just been asked, and a zone that flooded it held its 150 forwarding slots, each for up to 10 s, from every other zone. And each lookup cost a round trip. The net zone now starts one more dnsmasq for each routed zone defined when it starts, with the shared one's flags but a cache, on port 5300 + K of the bridge's addresses. A nat chain, dns, sends 10.19.0.K's and fd19::K's queries for port 53 there by dnat (NFT_NAT is already built for the masquerade; no kernel option is added), and only while that resolver runs: sync_dnsnat rewrites the chain in one transaction when the set changes. The input chain takes the resolvers' ports only for what the chain sent (ct status dnat), so a zone asks no other zone's resolver, nor its own directly; the addresses were already pinned to each zone's MAC. A zone defined later, or whose resolver did not start, stays on the shared one, which keeps no cache; one that dies is started once more. The zones suite proves it: dns-own-resolver (untrusted's queries moved its own rule's counter, and its resolver logged a cache), dns-own-port-closed (neither resolver's port answers untrusted directly), and dns-cache-per-zone: a stand-in upstream in the net zone counts what reaches it, and a name untrusted asked twice and personal once reached it twice. dns-cache-off now reads the shared resolver's start line by the pid the net zone logs. netzone-dns.sh checks the zone resolver's flags and the dns chain's rules under sh, bash and dash. --- build/guest-tests/zones-check.sh | 92 +++++++++++++++++++++++++-- docs/design/net-zone.md | 31 +++++---- docs/threat-model.md | 13 ++-- docs/user-guide.md | 4 +- tools/image/zones-test.sh | 2 +- tools/net/netzone-init.sh | 106 +++++++++++++++++++++++++++---- tools/tests/netzone-dns.sh | 43 +++++++++++++ 7 files changed, 253 insertions(+), 38 deletions(-) diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index 26e49329..72f56b9b 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -238,12 +238,17 @@ q = struct.pack(">HHHHHH", 0x4321, 0x0100, 1, 0, 0, 0) + b"\x07kryptik\x04test\x s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.settimeout(4) s.sendto(q, ("10.19.0.1", 53)); d, _ = s.recvfrom(512); print("DNS-STILL-ANSWERED rcode=%d" % (d[3] & 0x0f))' [[ "$ZOUT" == *DNS-STILL-ANSWERED* ]] && pass "dns-after-reload" "$(grep -o 'DNS-STILL-ANSWERED.*' "$LOG/untrusted.out")" || fail "dns-after-reload" "no answer from the resolver: $(tail -1 "$LOG/untrusted.err")" -# The resolver every routed zone shares keeps nothing one zone could read back -# about another's lookups. dnsmasq names its cache in the line it starts with; +# The shared resolver, which answers a zone without one of its own, keeps +# nothing one zone could read back about another's lookups. dnsmasq names its +# cache in the line it starts with, under the pid the net zone logged for it; # and asked from a zone for its version and its hits.bind count, the CHAOS -# records it would answer from itself, it gives neither. -started="$(uncaught | grep -a 'dnsmasq\[[0-9]*\]: started, version' | tail -1)" -[[ "$started" == *"cache disabled"* ]] && pass "dns-cache-off" "${started#*: }" || fail "dns-cache-off" "${started:-dnsmasq logged no start line}" +# records it would answer from itself, a zone's resolver gives neither. +dns_started() { # dns_started WHAT: the start line of the dnsmasq the net zone last logged as "dnsmasq (pid N) WHAT" + local p; p="$(uncaught | sed -n "s/.*netzone: dnsmasq (pid \([0-9]*\)) $1.*/\1/p" | tail -1)" + uncaught | grep -a "dnsmasq\[${p:-none}\]: started, version" | tail -1 +} +started="$(dns_started 'listening on 10\.19\.0\.1')" +[[ "$started" == *"cache disabled"* ]] && pass "dns-cache-off" "the shared one: ${started#*: }" || fail "dns-cache-off" "${started:-the shared dnsmasq logged no start line}" zrun untrusted 20 -- python3 -c 'import socket, struct for n, t in (("version", 0x5101), ("hits", 0x5102)): q = struct.pack(">HHHHHH", t, 0x0100, 1, 0, 0, 0) + bytes([len(n)]) + n.encode() + b"\x04bind\x00" + struct.pack(">HH", 16, 3) @@ -259,6 +264,83 @@ if [[ "$chaos" == *CHAOS-version* && "$chaos" == *CHAOS-hits* && "$chaos" != *na else fail "dns-counters-hidden" "${chaos:-no output} $(tail -1 "$LOG/untrusted.err")" fi +# untrusted's queries to the bridge's resolver went to its own, on port 5300 + K: +# the dns chain's counter for it moved, and that resolver keeps a cache. +dnsnat="$(nsenter -t "${net_init:-0}" -n nft list chain inet kryptik dns 2>&1)" +own_hits="$(awk -v a="ip saddr 10.19.0.${UNT} " 'index($0, a) && / udp dport 53 / { for (i = 1; i < NF; i++) if ($i == "packets") print $(i + 1) }' <<<"$dnsnat" | head -1)" +own_started="$(dns_started "for 10\.19\.0\.${UNT} on port $((5300 + UNT))")" +own_rss="$(awk '/^VmRSS:/ { print $2 " kB" }' "/proc/$(pgrep -f -- "--port=$((5300 + UNT)) " | head -1)/status" 2>/dev/null)" +if [[ "${own_hits:-0}" -ge 1 && "$own_started" == *cachesize* ]]; then + pass "dns-own-resolver" "untrusted's queries reached its own resolver on port $((5300 + UNT)) (${own_hits} packets; it holds ${own_rss:-an unread amount of memory}): ${own_started#*: }" +else + fail "dns-own-resolver" "to untrusted's own: ${own_hits:-no} packets; its start: ${own_started:-none}; the dns chain: $(tr '\n' ' ' <<<"$dnsnat" | cut -c1-300)" +fi +# A resolver's own port takes only what the dns chain sent it: asked there +# directly, neither personal's nor untrusted's own answers untrusted. +zrun untrusted 20 -- python3 -c 'import socket, struct, sys +q = struct.pack(">HHHHHH", 0x5252, 0x0100, 1, 0, 0, 0) + b"\x07kryptik\x04test\x00" + struct.pack(">HH", 1, 1) +for what, port in (("OTHER", int(sys.argv[1])), ("OWN", int(sys.argv[2]))): + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.settimeout(2) + try: + s.sendto(q, ("10.19.0.1", port)); s.recvfrom(512); print("DIRECT-%s answered" % what) + except OSError: + print("DIRECT-%s none" % what)' "$((5300 + PER))" "$((5300 + UNT))" +direct="$(grep -o 'DIRECT-.*' "$LOG/untrusted.out" | tr '\n' ' ')" +if [[ "$direct" == "DIRECT-OTHER none DIRECT-OWN none " ]]; then + pass "dns-own-port-closed" "asked directly on ports $((5300 + PER)) and $((5300 + UNT)), neither resolver answered untrusted" +else + fail "dns-own-port-closed" "${direct:-no output} $(tail -1 "$LOG/untrusted.err")" +fi +# Each zone's cache is its own. A stand-in upstream on 127.0.0.53 in the net +# zone answers every name with one address for 300 s and prints each name that +# reaches it: one untrusted asks twice reaches it once, and asked from personal, +# once more. One cache for all zones would let it through once in all; none, three times. +stub='import socket, struct +s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.bind(("127.0.0.53", 53)) +while True: + d, a = s.recvfrom(512) + i, labels = 12, [] + while i < len(d) and d[i]: + labels.append(d[i + 1:i + 1 + d[i]].decode("ascii", "replace")); i += 1 + d[i] + print(".".join(labels), flush=True) + s.sendto(d[:2] + struct.pack(">HHHHH", 0x8180, 1, 1, 0, 0) + d[12:i + 5] + struct.pack(">HHHIH", 0xc00c, 1, 1, 300, 4) + bytes([192, 0, 2, 1]), a)' +# Asked until a fresh name comes back from the stand-in, then the probe name. +asks='import socket, struct, sys, time +def ask(name): + q = struct.pack(">HHHHHH", 0x5353, 0x0100, 1, 0, 0, 0) + b"".join(bytes([len(l)]) + l.encode() for l in name.split(".")) + b"\x00" + struct.pack(">HH", 1, 1) + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.settimeout(3) + try: + s.sendto(q, ("10.19.0.1", 53)); d, _ = s.recvfrom(512) + return struct.unpack(">H", d[6:8])[0] + except OSError: + return -1 +for n in range(20): + if ask("warm-%s-%d.cache.example" % (sys.argv[1], n)) == 1: + break + time.sleep(1) +else: + print("STUB-UNREACHED") + sys.exit() +print("PROBE " + " ".join(str(ask("probe.cache.example")) for _ in range(int(sys.argv[2]))))' +switched=no; per_zone="" +if [[ -n "$net_init" && "$was" == nameserver* ]]; then + nsenter -t "$net_init" -n python3 -c "$stub" > "$LOG/dns-stub.out" 2> "$LOG/dns-stub.err" & STUB=$! + netsh "printf 'nameserver 127.0.0.53\n' > /etc/resolv.conf" > /dev/null + for _ in $(seq 1 40); do netsh 'grep -qx "nameserver 127.0.0.53" /run/uplink-resolv.conf' > /dev/null && { switched=yes; break; }; sleep 1; done + zrun untrusted 60 -- python3 -c "$asks" u 2 + per_zone="untrusted: $(grep -o 'PROBE.*\|STUB-UNREACHED' "$LOG/untrusted.out")" + zrun personal 60 --passphrase-file /root/zt/personal.pass -- python3 -c "$asks" p 1 + per_zone="${per_zone}; personal: $(grep -o 'PROBE.*\|STUB-UNREACHED' "$LOG/personal.out")" + netsh "printf '%s\n' '${was}' > /etc/resolv.conf" > /dev/null + for _ in $(seq 1 40); do netsh 'grep -q "127\.0\.0\.53" /run/uplink-resolv.conf' > /dev/null || break; sleep 1; done + kill "$STUB" 2>/dev/null +fi +probes="$(grep -cx 'probe\.cache\.example' "$LOG/dns-stub.out" 2>/dev/null)" +if [[ "$switched" == yes && "$per_zone" == "untrusted: PROBE 1 1; personal: PROBE 1" && "$probes" == 2 ]]; then + pass "dns-cache-per-zone" "a name untrusted asked twice and personal once reached the upstream twice: each zone's resolver keeps its own cache" +else + fail "dns-cache-per-zone" "the upstream switched: ${switched}; ${per_zone:-no zone asked}; reached it: ${probes:-0} times; $(tail -2 "$LOG/dns-stub.err" 2>/dev/null | tr '\n' ' ')" +fi # The vault is probed under storage, once its volume exists. diff --git a/docs/design/net-zone.md b/docs/design/net-zone.md index a90aac78..1966065a 100755 --- a/docs/design/net-zone.md +++ b/docs/design/net-zone.md @@ -134,17 +134,26 @@ query and the [update](update-channel.md) fetcher. Builds on next query (at most once a second). A lease that lapsed leaves the last servers in place. It answers the test TLD `.test` itself, so resolving `kryptik.test` tests the path to the resolver, not the internet. -- **The resolver keeps nothing for the next zone.** Every routed zone asks - the same dnsmasq, so it keeps no cache (`--cache-size=0`) and no CHAOS - records (`--no-ident`). A cached answer comes back sooner, and with less of - its TTL left, to the next zone that asks, and `hits.bind` and `misses.bind` - count every zone's lookups: either would tell one zone what another looked - up. Each lookup costs a round trip to the uplink's servers; what a program - caches itself stays in its zone. Two things remain. A zone that asks for a - name while another zone's lookup of it is in flight gets that lookup's - answer, as dnsmasq forwards identical queries once, so it can tell the name - was asked within the last round trip. And the uplink's servers keep caches - of their own, shared with every machine on that network. +- **Each routed zone has a resolver of its own.** One dnsmasq for every + zone would tell one zone what another looked up: a cached answer comes + back sooner, and with less of its TTL left, to the next zone that asks; + `hits.bind` and `misses.bind` count every zone's lookups; and a zone that + asks for a name while another's lookup of it is in flight gets that + lookup's answer, as dnsmasq forwards identical queries once. A zone that + floods it would also hold its 150 forwarding slots, each for up to 10 s, + from the others. So the net zone starts one more dnsmasq for each routed + zone defined when it starts, as the shared one runs but with a cache, on + port 5300 + K of the bridge's addresses, and its `dns` chain sends + 10.19.0.K's and fd19::K's queries for port 53 there (`dnat`, from the NAT + the masquerade already needs: no kernel option is added). The addresses are + pinned to the zone's MAC, so no zone is sent to another's resolver, and a + resolver's port takes only what the chain sent it, so none is asked there + directly. A zone defined after the net zone started, or whose resolver + could not start, asks the shared one, which keeps no cache + (`--cache-size=0`) and no CHAOS records (`--no-ident`); one that dies is + started once more, and its zone is on the shared one until it answers. + The uplink's servers keep caches of their own, shared with every machine + on that network. - **dhcpcd separates its privileges.** What parses a lease, a DHCPv6 reply or a router advertisement runs as the zone's `dhcpcd` user, chrooted to an empty `/var/empty`, with no capability and dhcpcd's own seccomp filter over diff --git a/docs/threat-model.md b/docs/threat-model.md index 91b5fb40..4eaa6576 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -119,8 +119,11 @@ What a routed zone cannot do through it, proven the same way: DNS, echo requests, neighbour discovery and replies (`bridge-ports-closed`); - fill the connection-tracking table: one routed zone holds at most an eighth of it (`zone-flows-capped`); -- learn another zone's lookups: the resolver keeps no cache and no query - counts (`dns-cache-off`, `dns-counters-hidden`). +- learn another zone's lookups: each routed zone asks a resolver of its own, + whose cache only it fills, and the one a zone without its own asks keeps + none; none answers for its counters (`dns-own-resolver`, + `dns-own-port-closed`, `dns-cache-per-zone`, `dns-cache-off`, + `dns-counters-hidden`). Kept by design: @@ -130,9 +133,9 @@ Kept by design: given, as it must to join those networks; - nf_tables, packet sockets and generic netlink stay open to it, as NAT, DHCP and Wi-Fi need them; -- a routed zone that floods can hold the resolver's 150 forwarding slots, each - for up to 10 s, and the uplink's bandwidth from the others: its share bounds - its connections, not its queries or bytes; +- a routed zone that floods can take the uplink's bandwidth and the net + zone's CPU time from the others: its share bounds its connections, not its + queries or bytes, and the forwarding slots it fills are its own resolver's; - EEE, offloads, rings, coalescing and a radio's wake triggers (nl80211's WoWLAN) are not set back between net zones. diff --git a/docs/user-guide.md b/docs/user-guide.md index 31639a08..82790fab 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -404,8 +404,8 @@ shows each timer, its timeout and whether it is running. frozen desktop. The reset during a trial lands on the committed slot. - While one zone floods the network, the others' traffic and name lookups can slow or fail: the net zone holds each zone to an eighth of its - connection table, but every zone shares the uplink's bandwidth and its one - resolver. + connection table and gives each a resolver of its own, but every zone + shares the uplink's bandwidth. - A network, or a net zone it has taken over, can keep releases from the machine: that is reported after 30 days, by `kryptik update status` and above every login prompt, not prevented, and not reported at all if diff --git a/tools/image/zones-test.sh b/tools/image/zones-test.sh index bad4f868..e5817a6c 100755 --- a/tools/image/zones-test.sh +++ b/tools/image/zones-test.sh @@ -71,7 +71,7 @@ for name in kernel-support policies net-ready net-dns dhcpcd-separated dnsmasq-u radio-recarried \ wifi-wpa2-plain wifi-sae \ zone-clocks-own \ - time-floor-ran time-clamp time-floor-forged time-claim-stepped time-claim-floor time-claim-consent pids-limit ephemeral-size-bound cpu-max-set lifecycle-repeat lifecycle-registry resolver-after-attach dns-cache-off dns-counters-hidden net-zone-sysfs-nics-only \ + time-floor-ran time-clamp time-floor-forged time-claim-stepped time-claim-floor time-claim-consent pids-limit ephemeral-size-bound cpu-max-set lifecycle-repeat lifecycle-registry resolver-after-attach dns-cache-off dns-counters-hidden dns-own-resolver dns-own-port-closed dns-cache-per-zone net-zone-sysfs-nics-only \ terminal-terminfo man-page text-browser tls-trust no-shared-writable-mount net-zone-no-zone-data no-swap no-dbus \ volume-init zone-source-pinned bridge-ports-closed encrypted-zone-start stop-closes-volume wrong-passphrase persist-reopen no-mapping-after ephemeral-gone concurrent-start-refused full-volume header-restore volume-destroy vault-offline vault-ping no-passphrase-leak \ setuid-only-allowed no-file-capabilities sysctls-applied host-activity-hidden fetch-and-sntp-no-caps; do diff --git a/tools/net/netzone-init.sh b/tools/net/netzone-init.sh index a25f867d..c5aab026 100755 --- a/tools/net/netzone-init.sh +++ b/tools/net/netzone-init.sh @@ -52,10 +52,10 @@ NICSET="" for n in "$@"; do NICSET="${NICSET:+$NICSET, }\"$n\""; done NICSET="{ $NICSET }" -# The routed zones whose definition says [network] local = true, each by the -# address kryptikd gives it (netzone.rs, host_number). A file this zone cannot -# read names nobody. -local_zones() { # local_zones DIR: "10.19.0.K fd19::K" for each +# Every routed zone by its host number K, which gives it 10.19.0.K and fd19::K +# (netzone.rs, host_number), and whether its definition says [network] +# local = true. A file this zone cannot read names nobody. +routed_zones() { # routed_zones DIR: "K 1|0" for each for f in "$1"/*.toml; do [ -r "$f" ] || continue awk ' @@ -66,10 +66,13 @@ local_zones() { # local_zones DIR: "10.19.0.K fd19::K" for each section == "[identity]" && /^uid_base="?[0-9]+"?$/ { base = $0; gsub(/[^0-9]/, "", base) } END { k = (base - 131072) / 65536 + 2 - if (claims && routed && base != "" && k == int(k) && k >= 2 && k < 250) printf "10.19.0.%d fd19::%x\n", k, k + if (routed && base != "" && k == int(k) && k >= 2 && k < 250) printf "%d %d\n", k, claims }' "$f" done } +local_zones() { # local_zones DIR: "10.19.0.K fd19::K" for each that claims local + routed_zones "$1" | awk '$2 == 1 { printf "10.19.0.%d fd19::%x\n", $1, $1 }' +} LOCAL4="$(local_zones "$ZONES" | awk '{ print $1 }' | tr '\n' ',' | sed 's/,$//')" LOCAL6="$(local_zones "$ZONES" | awk '{ print $2 }' | tr '\n' ',' | sed 's/,$//')" SET4="set local4 { type ipv4_addr; }"; SET6="set local6 { type ipv6_addr; }" @@ -86,6 +89,10 @@ PIN6="$(awk 'BEGIN { for (k = 2; k < 250; k++) printf "%sfd19::%x . 02:19:00:00: FLOWCAP="$(( $(cat /proc/sys/net/netfilter/nf_conntrack_max 2>/dev/null || echo 131072) / 8 ))" [ "$FLOWCAP" -ge 1024 ] 2>/dev/null || FLOWCAP=1024 +# Zone K's own resolver takes its queries on the bridge's addresses at port 5300 + K. +DNS_PORT_BASE=5300 +ZONE_DNS_PORTS="$((DNS_PORT_BASE + 2))-$((DNS_PORT_BASE + 249))" + # A zone goes out by a gateway (gw4, gw6) and never to the gateway itself: # the rest of what an uplink reaches is the network it sits on, open to local4 # and local6 alone. With no gateway in the sets nothing goes out, so a new @@ -93,6 +100,8 @@ FLOWCAP="$(( $(cat /proc/sys/net/netfilter/nf_conntrack_max 2>/dev/null || echo # net zone takes in only what it serves there, DNS and echo on the bridge's # addresses, and neighbour discovery: whatever else listens in it, such as # dhcpcd on every address once it holds two uplinks, is no zone's to reach. +# A zone's DNS goes to a resolver of its own where the dns chain sends it +# (sync_dnsnat), and a resolver's own port takes nothing that chain did not. RULES="table inet kryptik { set gw4 { type ipv4_addr; } set gw6 { type ipv6_addr; } @@ -119,6 +128,9 @@ RULES="table inet kryptik { iifname \"${BR}\" oifname ${NICSET} reject with icmpx type admin-prohibited iifname \"${BR}\" oifname \"${BR}\" drop } + chain dns { + type nat hook prerouting priority dstnat; policy accept; + } chain postrouting { type nat hook postrouting priority srcnat; policy accept; oifname ${NICSET} ip saddr 10.19.0.0/24 masquerade @@ -132,6 +144,8 @@ RULES="table inet kryptik { iifname \"${BR}\" ct state established,related accept iifname \"${BR}\" ip daddr 10.19.0.1 meta l4proto { tcp, udp } th dport 53 accept iifname \"${BR}\" ip6 daddr fd19::1 meta l4proto { tcp, udp } th dport 53 accept + iifname \"${BR}\" ct status dnat ip daddr 10.19.0.1 meta l4proto { tcp, udp } th dport ${ZONE_DNS_PORTS} accept + iifname \"${BR}\" ct status dnat ip6 daddr fd19::1 meta l4proto { tcp, udp } th dport ${ZONE_DNS_PORTS} accept iifname \"${BR}\" ip daddr 10.19.0.1 icmp type echo-request accept iifname \"${BR}\" ip6 daddr fd19::1 icmpv6 type echo-request accept iifname \"${BR}\" icmpv6 type { nd-neighbor-solicit, nd-neighbor-advert } accept @@ -151,7 +165,7 @@ load_policy() { *) say "nftables: the loaded table is not the policy (no \"${want}\")"; nft flush ruleset 2>/dev/null; return 1 ;; esac done - GATEWAYS="" + GATEWAYS=""; DNSNAT="" return 0 } @@ -271,7 +285,7 @@ sync_upstream() { printf '%s\n' "$new" > "$UPSTREAM.new" 2>/dev/null && chmod 644 "$UPSTREAM.new" && mv -f "$UPSTREAM.new" "$UPSTREAM" 2>/dev/null } # dnsmasq runs as nobody, whom this zone's root cannot signal, so kill -0 cannot tell. -dns_alive() { [ -d "/proc/$DNSPID" ] && ! grep -q '^State:.*zombie' "/proc/$DNSPID/status" 2>/dev/null; } +dns_alive() { [ -n "$1" ] && [ -d "/proc/$1" ] && ! grep -q '^State:.*zombie' "/proc/$1/status" 2>/dev/null; } # dns_alive PID start_dns() { command -v dnsmasq >/dev/null 2>&1 || { say "no dnsmasq; routed zones have no resolver"; return 1; } sync_upstream @@ -279,18 +293,18 @@ start_dns() { grep -q '^nameserver' "$UPSTREAM" 2>/dev/null || { echo "nameserver 10.0.2.3" > "$UPSTREAM"; chmod 644 "$UPSTREAM"; } # --local=/test/ (RFC 6761) stays here: the guest check resolves kryptik.test through it. # It binds as root and then drops to nobody, which --no-daemon would stop. A changed - # file is read before the next query, at most once a second. Every zone shares it, so - # it keeps no cache and no CHAOS records (--no-ident): an answer it kept comes back - # sooner, with less TTL, to the next zone asking, and hits.bind and misses.bind count - # every zone's lookups. --pid-file with no path writes none. + # file is read before the next query, at most once a second. It answers every zone + # without a resolver of its own, so it keeps no cache and no CHAOS records (--no-ident): + # an answer it kept comes back sooner, with less TTL, to the next zone asking, and + # hits.bind and misses.bind count every zone's lookups. --pid-file with no path writes none. dnsmasq --keep-in-foreground --log-facility=- --no-hosts --bind-interfaces \ --listen-address=10.19.0.1 --listen-address=fd19::1 --listen-address=127.0.0.1 \ --resolv-file="$UPSTREAM" --cache-size=0 --no-ident --local-service --local=/test/ \ --pid-file --user=nobody --group=nogroup & DNSPID=$! sleep 1 - if dns_alive; then - say "dnsmasq listening on 10.19.0.1/fd19::1, forwarding to $(grep '^nameserver' "$UPSTREAM" | tr '\n' ' ')" + if dns_alive "$DNSPID"; then + say "dnsmasq (pid ${DNSPID}) listening on 10.19.0.1/fd19::1, forwarding to $(grep '^nameserver' "$UPSTREAM" | tr '\n' ' ')" return 0 fi say "dnsmasq exited at once"; DNSPID=""; return 1 @@ -298,6 +312,61 @@ start_dns() { dns_ok=0 start_dns && dns_ok=1 +# Each routed zone's own resolver, on port 5300 + K, where the dns chain sends +# 10.19.0.K's and fd19::K's queries: its cache, the lookups it has in flight and +# the 150 it forwards at once are that zone's alone. One for every routed zone +# defined when this zone starts; a zone defined later, or whose resolver could +# not start, asks the shared one. The same flags as that one's, but its cache. +ZONEDNS="" # "K:PID ..."; no PID once one did not start +zone_dns_set() { # zone_dns_set K PID + ZONEDNS="$(for e in $ZONEDNS; do [ "${e%%:*}" = "$1" ] || printf '%s ' "$e"; done)$1:$2" +} +zone_dns_pid() { for e in $ZONEDNS; do [ "${e%%:*}" = "$1" ] && echo "${e#*:}"; done; } # zone_dns_pid K +start_zone_dns() { # start_zone_dns K: started; zone_dns_said tells whether it stayed + dnsmasq --keep-in-foreground --log-facility=- --no-hosts --bind-interfaces \ + --listen-address=10.19.0.1 --listen-address=fd19::1 --port="$((DNS_PORT_BASE + $1))" \ + --resolv-file="$UPSTREAM" --no-ident --local-service --local=/test/ \ + --pid-file --user=nobody --group=nogroup & + zone_dns_set "$1" "$!" +} +zone_dns_said() { # zone_dns_said K: a second after its start + zp="$(zone_dns_pid "$1")" + if dns_alive "$zp"; then + say "dnsmasq (pid ${zp}) for 10.19.0.${1} on port $((DNS_PORT_BASE + $1))" + return 0 + fi + say "dnsmasq for 10.19.0.${1} exited at once; that zone asks the shared resolver" + zone_dns_set "$1" "" + return 1 +} +# The dns chain: each zone whose resolver runs is sent to it, in one transaction, +# rewritten only when that set changes; every other query stays on port 53. +DNSNAT="" +sync_dnsnat() { + up="" + for e in $ZONEDNS; do dns_alive "${e#*:}" && up="${up} ${e%%:*}"; done + [ "$up" = "$DNSNAT" ] && return 0 + { + echo "flush chain inet kryptik dns" + for k in $up; do + for l4 in udp tcp; do + echo "add rule inet kryptik dns iifname \"${BR}\" ip saddr 10.19.0.${k} ip daddr 10.19.0.1 ${l4} dport 53 counter dnat ip to 10.19.0.1:$((DNS_PORT_BASE + k))" + echo "add rule inet kryptik dns iifname \"${BR}\" ip6 saddr fd19::$(printf %x "$k") ip6 daddr fd19::1 ${l4} dport 53 counter dnat ip6 to [fd19::1]:$((DNS_PORT_BASE + k))" + done + done + } | nft -f - 2>/tmp/nft.err || { say "nftables: the zones' own resolvers could not be set: $(tr '\n' ' ' < /tmp/nft.err)"; return 1; } + DNSNAT="$up" + names="$(for k in $up; do printf ' 10.19.0.%s' "$k"; done)" + say "nftables: zones with a resolver of their own:${names:- none}" +} +if command -v dnsmasq >/dev/null 2>&1; then + ZONE_HOSTS="$(routed_zones "$ZONES" | awk '{ print $1 }')" + for k in $ZONE_HOSTS; do start_zone_dns "$k"; done + [ -z "$ZONE_HOSTS" ] || sleep 1 + for k in $ZONE_HOSTS; do zone_dns_said "$k"; done + [ "$policy_ok" = 1 ] && sync_dnsnat +fi + # --- the time: measured here, decided in zone 0 (docs/design/time.md) -------- # Zone 0's source list, `server HOST` or `pool HOST` per line; without it, pool.ntp.org. @@ -403,12 +472,21 @@ while :; do elif ! sync_gateways "$@"; then forwarding off; policy_ok=0; changed=1 fi - if [ -n "$DNSPID" ] && ! dns_alive; then + if [ -n "$DNSPID" ] && ! dns_alive "$DNSPID"; then say "dnsmasq died; restarting"; DNSPID=""; dns_ok=0; changed=1 start_dns && dns_ok=1 elif [ -n "$DNSPID" ] && sync_upstream; then say "dnsmasq: forwarding to $(tr '\n' ' ' < "$UPSTREAM")from its next query" fi + # A zone's resolver that died is started once more; until it answers, the dns chain + # leaves that zone on the shared one. + for e in $ZONEDNS; do + zp="${e#*:}" + [ -n "$zp" ] && ! dns_alive "$zp" || continue + say "dnsmasq for 10.19.0.${e%%:*} died; restarting" + start_zone_dns "${e%%:*}"; sleep 1; zone_dns_said "${e%%:*}" + done + [ "$policy_ok" = 1 ] && sync_dnsnat for n in $WIRELESS; do p="$(wpa_pid "$n")" if [ -n "$p" ] && ! kill -0 "$p" 2>/dev/null; then diff --git a/tools/tests/netzone-dns.sh b/tools/tests/netzone-dns.sh index 7b268df9..973e208e 100755 --- a/tools/tests/netzone-dns.sh +++ b/tools/tests/netzone-dns.sh @@ -31,6 +31,12 @@ grep -q -- '--resolv-file="\$UPSTREAM" ' <<<"$start" && grep -q -- '--user=nobod grep -qE -- '--cache-size=0( |$)' <<<"$start" && grep -q -- '--no-ident' <<<"$start" && ! grep -q -- '--use-stale-cache' <<<"$start" \ && green "dnsmasq keeps no cache and no CHAOS records" \ || red "dnsmasq keeps a cache, or answers CHAOS queries with its counters" +# A zone's own: as the shared one runs, on its own port and off loopback, and with a cache. +zone="$(sed -n '/^start_zone_dns() {/,/^}/p' "$SCRIPT" | grep -v '^ *#')" +grep -q -- '--resolv-file="\$UPSTREAM" ' <<<"$zone" && grep -q -- '--user=nobody' <<<"$zone" && grep -q -- '--no-ident' <<<"$zone" \ + && grep -qF -- '--port="$((DNS_PORT_BASE + $1))"' <<<"$zone" && ! grep -qE -- '--cache-size=0|--no-poll|--no-daemon|127\.0\.0\.1' <<<"$zone" \ + && green "a zone's dnsmasq runs as nobody on the same file, on its own port, with a cache and no CHAOS records" \ + || red "a zone's dnsmasq is not started as the shared one is, on its own port, with a cache" # step WHAT: one pass, as the loop makes it; prints its status and the file. # umask 077: the file must still be one dnsmasq's nobody can read. @@ -69,6 +75,43 @@ for sh in sh bash dash; do || red "a file that cannot be written" "got '${out}'" done +# The dns chain (sync_dnsnat): each zone whose resolver runs is sent to it, by its +# addresses, on its port; a dead one's zone and one that never started are not. +sed -n '/^dns_alive() {/p; /^zone_dns_set() {/,/^}/p; /^sync_dnsnat() {/,/^}/p' "$SCRIPT" > "$T/dnsnat.sh" +grep -q '^sync_dnsnat() {' "$T/dnsnat.sh" || { echo "could not find sync_dnsnat in ${SCRIPT#"$ROOT"/}"; exit 1; } +# A pid past pid_max is never alive; the harness's own always is. +cat > "$T/dnsnat-harness.sh" <> "\$NFT_IN"; echo "-- sent" >> "\$NFT_IN"; } +BR=kryptik0; DNS_PORT_BASE=5300; DNSNAT="" +ZONEDNS="5:\$\$ 4:999999999 10:\$\$ 2:" +sync_dnsnat; echo "rc=\$? [\$DNSNAT]" +sync_dnsnat; echo "rc=\$? [\$DNSNAT]" +zone_dns_set 10 999999999 +sync_dnsnat; echo "rc=\$? [\$DNSNAT]" +EOF +for sh in sh bash dash; do + command -v "$sh" >/dev/null 2>&1 || continue + echo "-- ${sh}: the dns chain" + : > "$T/nft.in" + out="$(NFT_IN="$T/nft.in" "$sh" "$T/dnsnat-harness.sh" 2>&1)" + same "set for the zones whose resolver runs, unchanged without a change, and set again without one that died" \ + "$(grep '^rc=' <<<"$out")" "$(printf 'rc=0 [ 5 10]\nrc=0 [ 5 10]\nrc=0 [ 5]')" + same "the net zone says which zones have their own" \ + "$(grep '^say: ' <<<"$out")" "$(printf 'say: nftables: zones with a resolver of their own: 10.19.0.5 10.19.0.10\nsay: nftables: zones with a resolver of their own: 10.19.0.5')" + same "each set is one transaction that starts empty: two sent, none for the pass with no change" \ + "$(grep -c -e '^-- sent$' "$T/nft.in")|$(grep -c -x 'flush chain inet kryptik dns' "$T/nft.in")" "2|2" + first="$(sed '/^-- sent$/q' "$T/nft.in")" + same "zone 5 by both its addresses and both protocols, to port 5305" \ + "$(grep -c 'saddr \(10\.19\.0\.5\|fd19::5\) .* dport 53 counter dnat ip6\? to \(10\.19\.0\.1\|\[fd19::1\]\):5305$' <<<"$first")" "4" + grep -qF 'ip6 saddr fd19::a ip6 daddr fd19::1 udp dport 53 counter dnat ip6 to [fd19::1]:5310' <<<"$first" \ + && green "zone 10's IPv6 address is fd19::a, as the pins have it" || red "zone 10's IPv6 rule" "$(grep 'fd19::' <<<"$first" | tail -2)" + ! grep -q '10\.19\.0\.[24] \|fd19::[24] ' "$T/nft.in" && green "no rule for a zone whose resolver died or never started" \ + || red "a rule names zone 2 or 4" "$(grep '10\.19\.0\.[24] ' "$T/nft.in")" + same "after zone 10's died, only zone 5's four" "$(sed '1,/^-- sent$/d' "$T/nft.in" | grep -c '^add rule')" "4" +done + echo echo "${PASS} passed, ${FAIL} failed" [[ "$FAIL" -eq 0 ]] From adda05ceb3c2818b2e1848bdb011383ce348fba0 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 20:27:11 -0700 Subject: [PATCH 031/213] kryptik update forget stops a release that is arriving and deletes what has come of it; automatic fetching then passes that version over --- compartments/kryptikd/src/serve.rs | 16 +++++++++--- compartments/kryptikd/src/update.rs | 30 +++++++++++++++++++++-- compartments/kryptikd/src/update/tests.rs | 26 ++++++++++++++++++++ compartments/tests/cli.sh | 2 +- docs/design/update-channel.md | 20 +++++++-------- docs/user-guide.md | 1 + tools/desktop/kryptik-launch.c | 7 +++--- tools/kryptik | 6 +++-- 8 files changed, 87 insertions(+), 21 deletions(-) diff --git a/compartments/kryptikd/src/serve.rs b/compartments/kryptikd/src/serve.rs index fc94f072..6d4cecbb 100644 --- a/compartments/kryptikd/src/serve.rs +++ b/compartments/kryptikd/src/serve.rs @@ -8,8 +8,8 @@ //! wifi-add, `ssid `, `psk `, `end` //! wifi-forget, `ssid `, `end` //! stop | clipboard-move | info | status | -//! runtime | wifi-list | update-status | update-fetch | update-apply | -//! update-auto on|off +//! runtime | wifi-list | update-status | update-fetch | update-forget | +//! update-apply | update-auto on|off //! reply ok ...\n | error: \n | lines ... end\n //! //! `run` replies `ok ` once the zone's pid 1 exists and the @@ -1055,7 +1055,7 @@ fn handle(cfg: &ServeConfig, conn: UnixStream, jobs: &mut Vec) -> Option { + "update-status" | "update-fetch" | "update-forget" | "update-apply" | "update-auto" => { use crate::update as up; let dir = std::path::Path::new(up::STATE_DIR); let running = up::running_version(); @@ -1071,6 +1071,16 @@ fn handle(cfg: &ServeConfig, conn: UnixStream, jobs: &mut Vec) -> Option { + // kryptik-update reads the stage while it applies it. + if jobs.iter().any(|j| j.what == JobKind::UpdateApply) { + reply(&conn, "error: an update is being applied\n"); + return None; + } + up::forget(dir).map(|v| { + format!("{v} is no longer fetched, and what had arrived of it is deleted; automatic fetching passes it over, and `kryptik update fetch` asks for it again\n") + }) + } "update-auto" => { let on = match first.split_whitespace().collect::>().as_slice() { [_, "on"] => true, diff --git a/compartments/kryptikd/src/update.rs b/compartments/kryptikd/src/update.rs index 9a94d230..e7f8e8ae 100644 --- a/compartments/kryptikd/src/update.rs +++ b/compartments/kryptikd/src/update.rs @@ -449,6 +449,12 @@ fn wanted(dir: &Path) -> Option { is_version(&v).then_some(v) } +/// A release the user dropped (`forget`), which automatic fetching then leaves alone. +fn declined(dir: &Path) -> Option { + let v = std::fs::read_to_string(dir.join("declined")).ok()?.trim().to_string(); + is_version(&v).then_some(v) +} + fn staging(dir: &Path, version: &str) -> PathBuf { dir.join("incoming").join(version) } @@ -503,9 +509,24 @@ pub fn want(dir: &Path, channel: Option<&str>, role: &str, running: &str) -> Res let _ = std::fs::remove_dir_all(dir.join("incoming")); } put_file(&dir.join("wanted"), p.version.as_bytes())?; + // Asked for again: a forgotten version is no longer passed over. + let _ = std::fs::remove_file(dir.join("declined")); Ok(p.version) } +/// `kryptik update forget`: drop the release asked for and what has arrived of it. Automatic +/// fetching then passes that version over; a newer one, or `fetch`, asks again. +pub fn forget(dir: &Path) -> Result { + let v = wanted(dir).ok_or("no release has been asked for")?; + // Recorded first, so the next poll never asks for it again in between. + put_file(&dir.join("declined"), v.as_bytes())?; + let _ = std::fs::remove_file(dir.join("wanted")); + if !discard_stage(dir) { + return Err(format!("{v} is no longer asked for, but what had arrived of it could not be deleted")); + } + Ok(v) +} + /// Anything but `on` is off, a damaged block among them. fn auto(dir: &Path) -> bool { std::fs::read_to_string(dir.join("auto")).is_ok_and(|s| s.trim() == "on") @@ -560,7 +581,9 @@ fn outstanding(dir: &Path, channel: &str, role: &str, running: &str, now: i64) - /// on, the release the newest statement names is first asked for, as `fetch` /// would ask for it. pub fn poll(dir: &Path, channel: &str, role: &str, running: &str, now: i64) -> String { - if auto(dir) && stored_pointer(dir).is_some_and(|p| wanted(dir) != Some(p.version)) { + if auto(dir) + && stored_pointer(dir).is_some_and(|p| wanted(dir).as_ref() != Some(&p.version) && declined(dir).as_ref() != Some(&p.version)) + { // Refused as `fetch` would be: nothing newer, or not fetchable here. let _ = want(dir, Some(channel), role, running); } @@ -657,7 +680,10 @@ pub fn status(dir: &Path, now: i64, running: &str, since: Option) -> String } out.push_str(if auto(dir) { "fetching automatically, as each release is announced\n" } else { "fetching only when asked\n" }); match wanted(dir) { - None => out.push_str("staged nothing asked for\n"), + None => match declined(dir).filter(|v| version_cmp(v, running) == Ordering::Greater) { + Some(v) => out.push_str(&format!("staged nothing asked for; {v} was forgotten, and `kryptik update fetch` asks for it again\n")), + None => out.push_str("staged nothing asked for\n"), + }, Some(v) => { let stage = staging(dir, &v); match verified_files(dir, &v) { diff --git a/compartments/kryptikd/src/update/tests.rs b/compartments/kryptikd/src/update/tests.rs index 9a506a4e..64c13894 100644 --- a/compartments/kryptikd/src/update/tests.rs +++ b/compartments/kryptikd/src/update/tests.rs @@ -376,6 +376,32 @@ fn auto_follows_newest_release() { let _ = std::fs::remove_dir_all(&d); } +#[test] +fn forget_drops_a_release_that_auto_then_passes_over() { + let d = scratch("forget"); + assert!(forget(&d).unwrap_err().contains("no release has been asked for")); + latest(&d, &yes(), T0, "production", "1.0.2", pointer_text("1.0.3", "2027-03-02T14:05:00Z").as_bytes(), b"sig").unwrap(); + set_auto(&d, true, Some(CH)).unwrap(); + assert!(poll(&d, CH, "production", "1.0.2", T0).starts_with("fetch 1.0.3 ")); + put(&d, &yes(), T0, "manifest", 0, b"m").unwrap(); + // Forgotten: nothing of it is kept, and automatic fetching does not ask for it again. + assert_eq!(forget(&d).unwrap(), "1.0.3"); + assert!(wanted(&d).is_none() && !d.join("incoming").exists() && !d.join("files").exists()); + assert_eq!(poll(&d, CH, "production", "1.0.2", T0), "idle"); + assert!(status(&d, T0, "1.0.2", None).contains("staged nothing asked for; 1.0.3 was forgotten")); + assert!(status(&d, T0, "1.0.3", None).contains("staged nothing asked for\n"), "a forgotten release the machine now runs is not named"); + // A newer statement's release is fetched as before. + let t1 = T0 + POINTER_INTERVAL_SECS as i64; + latest(&d, &yes(), t1, "production", "1.0.2", pointer_text("1.0.4", "2027-03-02T15:30:00Z").as_bytes(), b"sig").unwrap(); + assert!(poll(&d, CH, "production", "1.0.2", t1).starts_with("fetch 1.0.4 ")); + // And `fetch` asks for a forgotten one again. + forget(&d).unwrap(); + assert_eq!(poll(&d, CH, "production", "1.0.2", t1), "idle"); + assert_eq!(want(&d, Some(CH), "production", "1.0.2").unwrap(), "1.0.4"); + assert!(poll(&d, CH, "production", "1.0.2", t1).starts_with("fetch 1.0.4 ")); + let _ = std::fs::remove_dir_all(&d); +} + #[test] fn auto_refuses_what_fetch_refuses() { let d = scratch("auto-refused"); diff --git a/compartments/tests/cli.sh b/compartments/tests/cli.sh index 6a869886..b0bf66fc 100755 --- a/compartments/tests/cli.sh +++ b/compartments/tests/cli.sh @@ -289,7 +289,7 @@ fi # --- update: refused here before the launch service is asked ----------------- out="$(K update 2>&1)"; rc=$? -if (( rc == 2 )) && [[ "$out" == *"status, fetch, apply, or auto on|off"* ]]; then +if (( rc == 2 )) && [[ "$out" == *"status, fetch, forget, apply, or auto on|off"* ]]; then pass "update without a subcommand fails and names the subcommands" else fail "update without a subcommand: exit $rc" diff --git a/docs/design/update-channel.md b/docs/design/update-channel.md index 71fab7bd..dc2792c8 100644 --- a/docs/design/update-channel.md +++ b/docs/design/update-channel.md @@ -162,13 +162,16 @@ under the 5 s deadline, so supervision is never more than a piece away. ## What the user sees -`kryptik update status | fetch | apply | auto on|off` go through the launch -service. `status` shows the running version, the newest pointer's version and -age, whether fetching is automatic, and what has arrived. `fetch` asks for the -release the newest pointer names. `apply` runs `kryptik-update apply` on the -complete stage, with the usual trial boot and fallback; the stage goes once -the machine runs that release and no trial is pending or failed, so after a -failed trial the `--retry` the refusal prints names a stage that is still +`kryptik update status | fetch | forget | apply | auto on|off` go through the +launch service. `status` shows the running version, the newest pointer's +version and age, whether fetching is automatic, and what has arrived. `fetch` +asks for the release the newest pointer names. `forget` drops the release +asked for and what has arrived of it, and automatic fetching then passes that +version over until a newer statement names another or `fetch` asks for it +again; it is refused while an apply runs. `apply` runs `kryptik-update apply` +on the complete stage, with the usual trial boot and fallback; the stage goes +once the machine runs that release and no trial is pending or failed, so after +a failed trial the `--retry` the refusal prints names a stage that is still there. Nothing is installed unless the user asks, and nothing is fetched unless the user asks or has turned automatic fetching on. @@ -313,9 +316,6 @@ channel's directory. ## Open points -- A way to stop a release that is arriving: once asked for, by hand or - automatically, it is fetched until it is whole or a newer statement names - another. - Delta updates: dm-verity's block structure would allow fetching only changed blocks instead of the whole image. diff --git a/docs/user-guide.md b/docs/user-guide.md index 31639a08..958f27c1 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -290,6 +290,7 @@ at the administration login without `su`: kryptik update status # the running version, the newest release the channel names and how old that statement is, what has arrived kryptik update fetch # ask for that release: the net zone brings it onto the state partition; it is verified against the signed manifest before anything is installed kryptik update status # again, until the staged release reads "complete" +kryptik update forget # or stop fetching it and delete what has arrived; automatic fetching then passes that release over kryptik update apply # install it, with the trial boot described below kryptik update auto on # fetch each newer release as it is announced; `auto off`, the default, waits for `fetch` ``` diff --git a/tools/desktop/kryptik-launch.c b/tools/desktop/kryptik-launch.c index e72d0b92..935d784b 100644 --- a/tools/desktop/kryptik-launch.c +++ b/tools/desktop/kryptik-launch.c @@ -9,7 +9,7 @@ * kryptik-launch --wifi-list the net zone's Wi-Fi SSIDs, one per line * kryptik-launch --wifi-add SSID add one or replace its passphrase (one line on stdin) * kryptik-launch --wifi-forget SSID remove one - * kryptik-launch --update status|fetch|apply show, fetch or install a release + * kryptik-launch --update status|fetch|forget|apply show, fetch, drop or install a release * kryptik-launch --update auto on|off fetch each release as it is announced, or when asked * * A display goes through the zone's own proxy at $XDG_RUNTIME_DIR/kryptik/ZONE/wayland-0, which the @@ -298,7 +298,7 @@ static void usage(void) " kryptik-launch --clipboard-move FROM TO\n" " kryptik-launch --wifi-list | --wifi-add SSID | --wifi-forget SSID\n" " (--wifi-add reads the passphrase from standard input)\n" - " kryptik-launch --update status|fetch|apply | --update auto on|off\n", stderr); + " kryptik-launch --update status|fetch|forget|apply | --update auto on|off\n", stderr); exit(2); } @@ -390,7 +390,8 @@ static int wifi_main(int argc, char **argv) static int update_main(int argc, char **argv) { int automatic = argc == 4 && strcmp(argv[2], "auto") == 0 && (strcmp(argv[3], "on") == 0 || strcmp(argv[3], "off") == 0); - if (!automatic && (argc != 3 || (strcmp(argv[2], "status") != 0 && strcmp(argv[2], "fetch") != 0 && strcmp(argv[2], "apply") != 0))) + if (!automatic && (argc != 3 || (strcmp(argv[2], "status") != 0 && strcmp(argv[2], "fetch") != 0 && + strcmp(argv[2], "forget") != 0 && strcmp(argv[2], "apply") != 0))) usage(); char req[32]; if (automatic) diff --git a/tools/kryptik b/tools/kryptik index aafaa51b..ba127fb6 100755 --- a/tools/kryptik +++ b/tools/kryptik @@ -41,6 +41,8 @@ USAGE: $PROG update status the release running, the newest one known and how old that news is, what is staged $PROG update fetch ask for the newest release to be fetched + $PROG update forget stop fetching the release asked for, and + delete what has arrived of it $PROG update auto on|off fetch each newer release as it is announced, or only when asked (the default) $PROG update apply install what has arrived; the next boot @@ -332,9 +334,9 @@ cmd_wifi() { # Installed systems only: the update state is zone 0's, reached through the launch service. cmd_update() { case "${1:-}" in - status|fetch|apply) [[ $# -eq 1 ]] || die "$PROG update $1 takes no argument" 2 ;; + status|fetch|forget|apply) [[ $# -eq 1 ]] || die "$PROG update $1 takes no argument" 2 ;; auto) [[ $# -eq 2 && ( "${2:-}" == on || "${2:-}" == off ) ]] || die "$PROG update auto takes on or off" 2 ;; - *) usage >&2; die "update needs a subcommand: status, fetch, apply, or auto on|off" 2 ;; + *) usage >&2; die "update needs a subcommand: status, fetch, forget, apply, or auto on|off" 2 ;; esac via_launch || die "update goes through the launch service, which is not answering here (an installed Kryptik, as a session user)" "$LAUNCH" --update "$@" From 0d191ee0a7d81542ea720285f612675f6f1e7a74 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 20:28:27 -0700 Subject: [PATCH 032/213] Python ships without its test suite, its test modules and the Tk packages that cannot import python-final installed CPython's whole test suite (Lib/test and IDLE's tests), nine test-only C modules (_testcapi, _testinternalcapi, _ctypes_test and the rest) and the xxlimited and xxsubtype templates, none of which anything in the image runs. --disable-test-modules stops building and installing them, and the recipe removes what the first python build left in the sysroot. tkinter, idlelib, turtle and turtledemo go too: they cannot import without _tkinter, which needs a Tcl/Tk that Kryptik does not build, so the idle3 launcher goes with them. --- build/recipes/python.sh | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/build/recipes/python.sh b/build/recipes/python.sh index 9d8e7756..ca5a7aaa 100644 --- a/build/recipes/python.sh +++ b/build/recipes/python.sh @@ -13,9 +13,16 @@ s_python() { s_python_final() { local src; src="$(unpack "Python-${V_PYTHON}.tar.xz" "Python-${V_PYTHON}")" cd "$src" - ./configure --prefix=/usr --enable-shared --with-system-expat + # No test suite or test modules: nothing in the image runs them. + ./configure --prefix=/usr --enable-shared --with-system-expat --disable-test-modules make make install + # The first build's test files outlive it here, and tkinter cannot import without _tkinter, which nothing builds. + local lib="/usr/lib/python${V_PYTHON%.*}" + rm -rf "$lib"/test "$lib"/idlelib "$lib"/tkinter "$lib"/turtledemo "$lib"/turtle.py "$lib"/__pycache__/turtle.* \ + "$lib"/lib-dynload/{_test,_ctypes_test,xx}*.so /usr/bin/idle3 "/usr/bin/idle${V_PYTHON%.*}" + [[ ! -e "$lib/test" ]] && ! compgen -G "$lib/lib-dynload/_test*.so" > /dev/null \ + || { echo "FAIL: python's test suite is still installed"; return 1; } local m for m in ctypes ssl pyexpat readline; do python3 -c "import ${m}" || { echo "FAIL: python3 was built without ${m}"; return 1; } From ca2e3b56b4f9d185876b696b191735babed772c0 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 20:29:49 -0700 Subject: [PATCH 033/213] glibc is built with --enable-fortify-source=3, so its programs and libraries are fortified The hardening exception drops CFLAGS' -D_FORTIFY_SOURCE=3 for glibc, on the grounds that fortifying glibc against itself fails to build. Since 2.38 glibc applies FORTIFY itself with --enable-fortify-source, sparing the files that implement it, so getent, iconv, locale, localedef, zic, ldconfig, libm, libresolv and the rest were shipped unfortified for no remaining reason. The exception stays, since glibc chooses the files, and its reason now says so. --- build/config/hardening-exceptions.txt | 2 +- build/recipes/glibc.sh | 3 ++- docs/hardening.md | 5 +++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/build/config/hardening-exceptions.txt b/build/config/hardening-exceptions.txt index ab4c0327..5f969bbc 100644 --- a/build/config/hardening-exceptions.txt +++ b/build/config/hardening-exceptions.txt @@ -1,4 +1,4 @@ # Packages exempted from a hardening flag; an entry without a justification fails the build. # # what breaks, and what was tried -glibc -D_FORTIFY_SOURCE=3 # glibc provides the fortify machinery; fortifying it against itself fails to build +glibc -D_FORTIFY_SOURCE=3 # glibc implements FORTIFY, so --enable-fortify-source=3 applies it to all but the files that implement it diff --git a/build/recipes/glibc.sh b/build/recipes/glibc.sh index 6ae9e83b..18785140 100644 --- a/build/recipes/glibc.sh +++ b/build/recipes/glibc.sh @@ -15,13 +15,14 @@ s_glibc() { cd build echo "rootsbindir=/usr/sbin" > configparms - # --enable-stack-protector=strong: glibc builds its own stack protection. + # glibc applies the strong stack protector and FORTIFY itself, sparing the files that implement them. # --enable-cet: CFLAGS' -fcf-protection makes rtld call _dl_cet_*, built only with this flag. ../configure \ --prefix=/usr \ --disable-werror \ --enable-kernel=4.19 \ --enable-stack-protector=strong \ + --enable-fortify-source=3 \ --enable-cet \ --disable-nscd \ libc_cv_slibdir=/usr/lib diff --git a/docs/hardening.md b/docs/hardening.md index 929586a7..22bb27b2 100644 --- a/docs/hardening.md +++ b/docs/hardening.md @@ -34,8 +34,9 @@ references `main()`, so every shared library fails to link. A package that cannot build with a flag goes in `build/config/hardening-exceptions.txt` as ` # reason`; `build/lib/common.sh` fails the build on an entry without a reason. The only -one is glibc's `-D_FORTIFY_SOURCE=3`, since glibc defines the fortify -machinery. +one is glibc's `-D_FORTIFY_SOURCE=3`: glibc implements FORTIFY, and its own +`--enable-fortify-source=3` applies it to all but the files that implement +it. ### What the audit finds From 6baf1cfa2178b56baf167d90664b96c9ddc2e26c Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 20:29:56 -0700 Subject: [PATCH 034/213] The zones and desktop suites drive and stop their VMs through suite-lib.sh, as the other suites do zones-test.sh kept a drive() of its own that took the timeout as its first argument, and gui-test.sh called the driver and killed its VM by hand. Both now use suite-lib.sh's drive, with DRIVE_TIMEOUT where a step needs longer than its 300 s (900, 1800, 600 as before), and stop_vm. With #341 no suite under tools/image calls the driver or kills a VM itself. --- tools/image/gui-test.sh | 4 ++-- tools/image/zones-test.sh | 7 +++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/tools/image/gui-test.sh b/tools/image/gui-test.sh index 3a882084..9219cdf9 100755 --- a/tools/image/gui-test.sh +++ b/tools/image/gui-test.sh @@ -60,7 +60,7 @@ install_disk gui-install "$USB" --vars clean && green "installed" || { red "inst step "step 2: the desktop, driven" rm -f "$SHOT" "$SHOT_FS" "$SHOT_OVER" "$SHOT_HEAD" start_vm gui-p2 --net user --gpu --second-head --mem 3072 -python3 "$DRV" --serial "$SER" --qmp "$QMP" --timeout 600 \ +DRIVE_TIMEOUT=600 drive --qmp "$QMP" \ "expect:KRYPTIK_SMOKE: END" "seen:kryptik-firstboot: created user '${TUSER}'" "login:${TUSER}:${TPASS}" \ "send:su - root -c 'bash /usr/lib/kryptik/guest-tests/gui-check.sh ${TUSER} 2>&1 | tee /var/log/kryptik/gui-check.log; echo GCHECK-DONE'" \ "expect:Password: ?" "send:${RPASS}" \ @@ -99,7 +99,7 @@ python3 "$DRV" --serial "$SER" --qmp "$QMP" --timeout 600 \ "send:su - root -c 'poweroff'" "expect:Password: ?" "send:${RPASS}" \ "expect:Power down" "wait-exit" drc=$? -sleep 1; [[ -f "$PIDF" ]] && kill "$(cat "$PIDF")" 2>/dev/null +stop_vm T="$(tr -d '\r' < "$LOG")" [[ "$drc" -eq 0 ]] && green "the guest checks ran to their end with the keystrokes delivered" || red "the drive failed (see above)" summary="$(grep -o 'GT SUMMARY passed=[0-9]* failed=[0-9]*' <<<"$T" | tail -1)" diff --git a/tools/image/zones-test.sh b/tools/image/zones-test.sh index bad4f868..acbb70fa 100755 --- a/tools/image/zones-test.sh +++ b/tools/image/zones-test.sh @@ -40,7 +40,6 @@ DISK="${DISK:-${VMDIR}/zones.img}" # shellcheck source=tools/image/suite-lib.sh source "${SELF}/suite-lib.sh" VARSF="${VMDIR}/zones-vars.fd"; cp /usr/share/OVMF/OVMF_VARS_4M.fd "$VARSF" -drive() { python3 "$DRV" --serial "$SER" --timeout "$1" "${@:2}"; } # ----------------------------------------------------------------- step 1 -- step "step 1: install and boot alone with a NIC" @@ -51,7 +50,7 @@ install_disk zones-install "$USB" --vars clean && green "installed" || { red "in step "step 2: the guest-side zone, network and storage checks (as root)" # 3 GB, not 2: ephemeral-size-bound fills untrusted's 2G tmpfs, which is RAM, until ENOSPC. start_vm zones-p2 --net user --mem 3072 -drive 900 "expect:KRYPTIK_SMOKE: END" "seen:kryptik-firstboot: created user '${TUSER}'" "login:${TUSER}:${TPASS}" \ +DRIVE_TIMEOUT=900 drive "expect:KRYPTIK_SMOKE: END" "seen:kryptik-firstboot: created user '${TUSER}'" "login:${TUSER}:${TPASS}" \ "$(ROOTSH 'bash /usr/lib/kryptik/guest-tests/zones-check.sh 2>&1 | tee /var/log/kryptik/zones-check.log; echo ZCHECK-DONE')" \ "expect:ZT END" "expect:ZCHECK-DONE" rc=$? @@ -86,7 +85,7 @@ suite_cmd() { # suite_cmd TAG TAIL_LINES COMMAND -> the guest command line printf '%s > /var/log/kryptik/%s-suite.log 2>&1; echo %s-RC=$?; tail -%s /var/log/kryptik/%s-suite.log; grep -n -A3 "^ *FAIL" /var/log/kryptik/%s-suite.log | sed "s/^/%s-FAIL: /" | head -80' \ "$3" "${1,,}" "$1" "$2" "${1,,}" "${1,,}" "$1" } -drive 1800 \ +DRIVE_TIMEOUT=1800 drive \ "$(ROOTSH "$(suite_cmd LAUNCHER 12 'cd /usr/lib/kryptik/compartments/tests && KRYPTIKD=/usr/bin/kryptikd KRYPTIK_SKIP_STALE_CHECK=1 NO_COLOR=1 bash ./launcher.sh')")" "expect:LAUNCHER-RC=[0-9]+" \ "$(ROOTSH "$(suite_cmd ADVERSARIAL 6 'cd /usr/lib/kryptik/compartments/tests && KRYPTIKD=/usr/bin/kryptikd NO_COLOR=1 bash ./adversarial.sh')")" "expect:ADVERSARIAL-RC=[0-9]+" \ "$(ROOTSH "$(suite_cmd BOUNDARY 6 'cd /usr/lib/kryptik/compartments/kryptikd/probes && NO_COLOR=1 bash ./boundary-checks.sh /usr/bin/kryptikd')")" "expect:BOUNDARY-RC=[0-9]+" \ @@ -121,7 +120,7 @@ fi # ----------------------------------------------------------------- step 4 -- step "step 4: reboot; the encrypted zone's data is still there" -drive 300 "$(ROOTSH 'reboot')" "expect:Linux version" "expect:KRYPTIK_SMOKE: END" "login:${TUSER}:${TPASS}" \ +drive "$(ROOTSH 'reboot')" "expect:Linux version" "expect:KRYPTIK_SMOKE: END" "login:${TUSER}:${TPASS}" \ "$(ROOTSH 'kryptikd run personal --zones /usr/lib/kryptik/zones --rootfs /var/lib/kryptik/zones --passphrase-file /root/zt/personal.pass -- cat /home/personal/keep; echo REOPEN-RC=$?')" "expect:secret-data-1" "expect:REOPEN-RC=0" \ "$(ROOTSH 'ls /dev/mapper | sed s/^/MAPPER:/; echo MAPPER-DONE')" "expect:MAPPER-DONE" \ "$(ROOTSH 'poweroff')" "expect:Power down" "wait-exit" From 1670b7176d04325c6a476274dd2f0b253650641e Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 20:32:53 -0700 Subject: [PATCH 035/213] netzone-uplink.sh reads routed_zones beside local_zones, and the input chain's two accepts for a zone's own resolver --- tools/tests/netzone-uplink.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/tests/netzone-uplink.sh b/tools/tests/netzone-uplink.sh index 24b67509..28a54c02 100755 --- a/tools/tests/netzone-uplink.sh +++ b/tools/tests/netzone-uplink.sh @@ -16,7 +16,7 @@ T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT # The parts under test, as the script has them: the three functions, and the # lines from the zones' addresses to the end of the ruleset. -for f in local_zones uplink_gateways sync_gateways; do sed -n "/^${f}() {/,/^}/p" "$SCRIPT"; done > "$T/functions.sh" +for f in routed_zones local_zones uplink_gateways sync_gateways; do sed -n "/^${f}() {/,/^}/p" "$SCRIPT"; done > "$T/functions.sh" sed -n '/^LOCAL4=/,/^}"$/p' "$SCRIPT" > "$T/rules.sh" grep -q '^sync_gateways() {' "$T/functions.sh" && grep -q '^RULES=' "$T/rules.sh" \ || { echo "could not find the functions or the ruleset in ${SCRIPT#"$ROOT"/} (did their first lines move?)"; exit 1; } @@ -120,8 +120,8 @@ for sh in sh bash dash; do same "from the bridge, ahead of conntrack: IPv4 off its pin dropped, IPv6 on its pin taken, neighbour discovery from a link-local address taken, other IPv6 dropped" "$pre" \ 'priority raw|ip saddr . ether saddr != @pin4 drop|ip6 saddr . ether saddr @pin6 accept|ip6 saddr fe80::/10 icmpv6 type { nd-neighbor-solicit, nd-neighbor-advert } accept|meta nfproto ipv6 drop|' input="$(sed -n '/chain input {/,/^ }$/p' <<<"$rules" | grep -oE 'ct state new (tcp|udp) dport 53 drop|"kryptik0" .*(accept|drop)$' | sed 's/count over [0-9]*/count over N/' | tr '\n' '|')" - same "into the net zone: each zone's share first; no resolver for an uplink; from the bridge replies, DNS and echo on the bridge's addresses, neighbour discovery, and nothing else" "$input" \ - '"kryptik0" ct state new add @flows { ether saddr ct count over N } drop|ct state new tcp dport 53 drop|ct state new udp dport 53 drop|"kryptik0" ct state established,related accept|"kryptik0" ip daddr 10.19.0.1 meta l4proto { tcp, udp } th dport 53 accept|"kryptik0" ip6 daddr fd19::1 meta l4proto { tcp, udp } th dport 53 accept|"kryptik0" ip daddr 10.19.0.1 icmp type echo-request accept|"kryptik0" ip6 daddr fd19::1 icmpv6 type echo-request accept|"kryptik0" icmpv6 type { nd-neighbor-solicit, nd-neighbor-advert } accept|"kryptik0" drop|' + same "into the net zone: each zone's share first; no resolver for an uplink; from the bridge replies, DNS and echo on the bridge's addresses, a zone's own resolver only as the dns chain sent it, neighbour discovery, and nothing else" "$input" \ + '"kryptik0" ct state new add @flows { ether saddr ct count over N } drop|ct state new tcp dport 53 drop|ct state new udp dport 53 drop|"kryptik0" ct state established,related accept|"kryptik0" ip daddr 10.19.0.1 meta l4proto { tcp, udp } th dport 53 accept|"kryptik0" ip6 daddr fd19::1 meta l4proto { tcp, udp } th dport 53 accept|"kryptik0" ct status dnat ip daddr 10.19.0.1 meta l4proto { tcp, udp } th dport 5302-5549 accept|"kryptik0" ct status dnat ip6 daddr fd19::1 meta l4proto { tcp, udp } th dport 5302-5549 accept|"kryptik0" ip daddr 10.19.0.1 icmp type echo-request accept|"kryptik0" ip6 daddr fd19::1 icmpv6 type echo-request accept|"kryptik0" icmpv6 type { nd-neighbor-solicit, nd-neighbor-advert } accept|"kryptik0" drop|' rules="$(run rules "$T/none")" grep -qF 'set local4 { type ipv4_addr; }' <<<"$rules" && green "with no zone let through the sets are empty, and every zone is refused" || red "the empty local sets" "$(grep 'set local' <<<"$rules" | tr '\n' '|')" done From 5f9175d5f8b401b0c25458903e70552a6755618d Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 20:34:52 -0700 Subject: [PATCH 036/213] kryptikd and kryptik-wlproxy are built with no path of the build in them, and one that names a path is refused cargo built both at the checkout's path, with the runner's CARGO_HOME and into /mnt/kryptik/cargo, and each path landed in panic locations, so two builds of one tree differed there (docs/design/reproducible-builds.md). The static build step now passes --remap-path-prefix for all three, and after the build refuses a binary in which any of them is still found. There is no cargo config in the tree, so RUSTFLAGS overrides nothing. --- .github/workflows/distro.yml | 7 +++++++ docs/design/reproducible-builds.md | 10 ++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/workflows/distro.yml b/.github/workflows/distro.yml index 4cea4f21..008214cc 100644 --- a/.github/workflows/distro.yml +++ b/.github/workflows/distro.yml @@ -244,10 +244,17 @@ jobs: run: | want="$(sed -n 's/.* rustc-\([0-9.]*\)-x86_64-unknown-linux-gnu\.tar\.xz$/\1/p' build/config/rust.lock)" rustc --version | grep -qF "rustc ${want} " || { echo "refusing to build what ships with $(rustc --version); the pin is ${want}"; exit 1; } + # The checkout's, cargo's and the target directory's paths would land in panic + # locations, which then differ between two builds of one tree. + home="${CARGO_HOME:-$HOME/.cargo}" + export RUSTFLAGS="--remap-path-prefix=${GITHUB_WORKSPACE}=/kryptik --remap-path-prefix=${home}=/cargo --remap-path-prefix=${CARGO_TARGET_DIR}=/target" (cd compartments/kryptikd && cargo build --locked --release --target x86_64-unknown-linux-musl) (cd compositor && cargo build --locked --release --target x86_64-unknown-linux-musl -p wlproxy --bin kryptik-wlproxy) cp /mnt/kryptik/cargo/x86_64-unknown-linux-musl/release/kryptikd /mnt/kryptik/kryptikd-musl cp /mnt/kryptik/cargo/x86_64-unknown-linux-musl/release/kryptik-wlproxy /mnt/kryptik/kryptik-wlproxy-musl + for b in /mnt/kryptik/kryptikd-musl /mnt/kryptik/kryptik-wlproxy-musl; do + ! grep -aqF -e "$GITHUB_WORKSPACE" -e "$home" -e "$CARGO_TARGET_DIR" "$b" || { echo "$b names a path of this build"; exit 1; } + done file /mnt/kryptik/kryptikd-musl /mnt/kryptik/kryptik-wlproxy-musl # The licence texts build/licences ships for what they link. tools/check-rust-licences.sh /mnt/kryptik/rust-dist diff --git a/docs/design/reproducible-builds.md b/docs/design/reproducible-builds.md index c68bd29b..fd008138 100644 --- a/docs/design/reproducible-builds.md +++ b/docs/design/reproducible-builds.md @@ -23,7 +23,8 @@ the media checksums' `.sig`. Inside the chroot, paths are fixed whatever the host's work directory: sources at `/kryptik-sources`, work at `/kryptik-work`, the tree at `/kryptik` (`build/stages/03-chroot-prep.sh`). Stages 04 and 05 therefore -never see the host's paths. Stage 06 and the Rust binaries do. +never see the host's paths. Stage 06 does; the Rust binaries are built at +them but name none (below). ## What differs between two builds today @@ -107,9 +108,10 @@ which nothing does today, would take most of them. The comparison finds the rest ### The Rust binaries kryptikd and kryptik-wlproxy are built on the runner by cargo, at the -checkout's path and with the runner's `CARGO_HOME`, and both paths end up in -panic locations. `--remap-path-prefix` for each removes them. The toolchain -is already pinned by hash (`build/config/rust.lock`). +checkout's path, with the runner's `CARGO_HOME` and into `/mnt/kryptik/cargo`, +and each of those paths would end up in panic locations. The Distro workflow +passes `--remap-path-prefix` for all three, and refuses a binary that still +names one. The toolchain is already pinned by hash (`build/config/rust.lock`). ### The media From f3aac267d020c8e7cb0f54f84e08ea6963f898d1 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 20:35:47 -0700 Subject: [PATCH 037/213] The root image is the same bytes for the same tree, and the kernel's banner the same for the same commit SOURCE_DATE_EPOCH is now the commit's time: the Makefile exports it to every stage, never empty (GCC refuses an empty one), and the chroot passes it in; no stamp fingerprints it, so cached steps stay cached. Stage 06 made the root filesystem with the host's mke2fs (1.47.0 on the runners), which took the current time for the superblock and each file's ctime from the staging tree, and drew the UUID, the directory hash seed and the verity salt at random. It now runs the sysroot's mke2fs 1.47.1 in the chroot, which clamps every time it writes to SOURCE_DATE_EPOCH, with the UUID and hash seed derived from the version and the commit and the salt from the image itself; it makes the image twice and fails if the two differ. built_at is SOURCE_DATE_EPOCH in UTC. The kernel's banner carried the build's time, user and host, and a build number that every relink in stage 06's bind step raised; stage 05 and the bind step now set all four. --- Makefile | 7 ++ build/stages/03-chroot-prep.sh | 1 + build/stages/05-kernel.sh | 3 + build/stages/06-iso.sh | 28 ++++++-- build/stages/06-kernel-bind.sh | 3 + docs/design/reproducible-builds.md | 101 ++++++++++++++--------------- 6 files changed, 84 insertions(+), 59 deletions(-) diff --git a/Makefile b/Makefile index d76fac72..f4337ea3 100644 --- a/Makefile +++ b/Makefile @@ -25,6 +25,12 @@ KRYPTIK_JOBS ?= KRYPTIK_STALE ?= refuse # Stamped into /etc/os-release; --dirty so an image never claims a clean tree. KRYPTIK_BUILD_COMMIT ?= $(shell git -C "$(ROOT)" describe --always --dirty --abbrev=40 2>/dev/null || echo unknown) +# The time every timestamp the build writes is set or clamped to: the commit's (docs/design/reproducible-builds.md). +SOURCE_DATE_EPOCH ?= $(shell git -C "$(ROOT)" log -1 --format=%ct 2>/dev/null) +# Never exported empty: GCC refuses a SOURCE_DATE_EPOCH that is not a number. +ifneq ($(SOURCE_DATE_EPOCH),) +export SOURCE_DATE_EPOCH +endif # kryptikd and kryptik-wlproxy are static Rust binaries built outside the # chroot and installed by stage 04. KRYPTIK_KRYPTIKD_BIN ?= @@ -71,6 +77,7 @@ CHROOT_ENV := KRYPTIK_ROOT="$(ROOT)" \ KRYPTIK_JOBS="$(KRYPTIK_JOBS)" \ KRYPTIK_STALE="$(KRYPTIK_STALE)" \ KRYPTIK_BUILD_COMMIT="$(KRYPTIK_BUILD_COMMIT)" \ + $(if $(SOURCE_DATE_EPOCH),SOURCE_DATE_EPOCH="$(SOURCE_DATE_EPOCH)") \ KRYPTIK_KRYPTIKD_BIN="$(KRYPTIK_KRYPTIKD_BIN)" \ KRYPTIK_WLPROXY_BIN="$(KRYPTIK_WLPROXY_BIN)" \ TERM="$(TERM)" \ diff --git a/build/stages/03-chroot-prep.sh b/build/stages/03-chroot-prep.sh index 78587efc..59e8d66a 100755 --- a/build/stages/03-chroot-prep.sh +++ b/build/stages/03-chroot-prep.sh @@ -308,6 +308,7 @@ chroot_env() { "MAKEFLAGS=-j${jobs}" \ "KRYPTIK_STALE=${KRYPTIK_STALE:-refuse}" \ "KRYPTIK_BUILD_COMMIT=${KRYPTIK_BUILD_COMMIT:-unknown}" \ + ${SOURCE_DATE_EPOCH:+"SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH}"} \ "KRYPTIK_KRYPTIKD_BIN=${KRYPTIK_KRYPTIKD_BIN:+$IN_KRYPTIKD}" \ "KRYPTIK_WLPROXY_BIN=${KRYPTIK_WLPROXY_BIN:+$IN_WLPROXY}" \ "KRYPTIK_ALLOW_UNCHROOTED=0" \ diff --git a/build/stages/05-kernel.sh b/build/stages/05-kernel.sh index afd9df66..d7f6915a 100755 --- a/build/stages/05-kernel.sh +++ b/build/stages/05-kernel.sh @@ -229,6 +229,9 @@ s_build() { echo "host link flags: ${1:-none}" echo "config digest : ${2:-none}" cd "$KSRC" + # The banner's time, user, host and build number, the same in every build of one commit. + export KBUILD_BUILD_USER=kryptik KBUILD_BUILD_HOST=kryptik KBUILD_BUILD_VERSION=1 + [[ -z "${SOURCE_DATE_EPOCH:-}" ]] || export KBUILD_BUILD_TIMESTAMP="$(date -u -d "@${SOURCE_DATE_EPOCH}")" make # The banner (/proc/version) records which compiler built the image. echo "--- linux_banner ---" diff --git a/build/stages/06-iso.sh b/build/stages/06-iso.sh index 56c61cfd..8b6ef8c7 100755 --- a/build/stages/06-iso.sh +++ b/build/stages/06-iso.sh @@ -179,7 +179,7 @@ s_rootfs() { printf '%s\n' "$fs_bytes" > "$stage/etc/kryptik/root-image-bytes" cat > "$stage/etc/kryptik-image.json" </dev/null | grep -E 'Filesystem features|Block count|Free blocks' echo "--- dm-verity hash tree, appended ---" local data_blocks=$(( fs_bytes / 4096 )) - local salt; salt="$(openssl rand -hex 32)" + # Unique to the image, and the same in every build of it. + local salt; salt="$(sha256_of "$img")" # Room for the tree (about data/128, generously) in whole 4096-byte blocks: # the ISO maps the image linearly over a CD, and device-mapper refuses a # length that is not a multiple of its 2048-byte block. @@ -264,6 +270,16 @@ EOF cat "${IMG}/root.json" } +# make_ext4 TREE IMAGE BYTES UUID: the sysroot's mke2fs (1.47.1), which clamps every time it +# writes to SOURCE_DATE_EPOCH, in the chroot; TREE and IMAGE are under the work tree's images/. +make_ext4() { + rm -f "$2"; truncate -s "$3" "$2" + env KRYPTIK_ROOT="$KRYPTIK_ROOT" KRYPTIK_WORK="$KRYPTIK_WORK" KRYPTIK_SOURCES="$KRYPTIK_SOURCES" NO_COLOR=1 \ + "$CHROOTD" run /usr/sbin/mke2fs -q -F -t ext4 -L kryptik-root -O '^has_journal' -U "$4" \ + -E "root_owner=0:0,hash_seed=$4" -d "/kryptik-work${1#"$KRYPTIK_WORK"}" "/kryptik-work${2#"$KRYPTIK_WORK"}" \ + > /dev/null +} + root_json() { sed -n "s/^ \"$1\": \"\{0,1\}\([^\",]*\)\"\{0,1\},\{0,1\}\$/\1/p" "${IMG}/root.json"; } verity_table() { # verity_table : the root image's dm-verity table diff --git a/build/stages/06-kernel-bind.sh b/build/stages/06-kernel-bind.sh index a75bd207..578eb7f6 100755 --- a/build/stages/06-kernel-bind.sh +++ b/build/stages/06-kernel-bind.sh @@ -17,6 +17,9 @@ JOBS="${KRYPTIK_JOBS:-$(kryptik_default_jobs)}" mkdir -p "$KERNELS" cd "$KSRC" +# As stage 05 built it, or each relink would stamp its own time and build number. +export KBUILD_BUILD_USER=kryptik KBUILD_BUILD_HOST=kryptik KBUILD_BUILD_VERSION=1 +[[ -z "${SOURCE_DATE_EPOCH:-}" ]] || export KBUILD_BUILD_TIMESTAMP="$(date -u -d "@${SOURCE_DATE_EPOCH}")" grep -q '^CONFIG_CMDLINE_OVERRIDE=y' .config || die "CONFIG_CMDLINE_OVERRIDE is not set in the kernel config; the command line would not be bound" grep -q '^CONFIG_EFI_STUB=y' .config || die "CONFIG_EFI_STUB is not set; the kernel is not a UEFI application" diff --git a/docs/design/reproducible-builds.md b/docs/design/reproducible-builds.md index c68bd29b..753c4326 100644 --- a/docs/design/reproducible-builds.md +++ b/docs/design/reproducible-builds.md @@ -5,8 +5,8 @@ Builds are not reproducible, so "built from source" still means trusting the machine that built it. And the first compiler comes from the host, so Thompson's "Reflections on Trusting Trust" applies in full. The [roadmap](../roadmap.md) asks for reproducible builds checked by CI, then a -bootstrappable toolchain. This document lists what differs between two -builds today, how each difference goes, what CI compares, what happens to +bootstrappable toolchain. This document lists what differed between two +builds, what is fixed and how the rest goes, what CI compares, what happens to the module signing key, and how live-bootstrap fits in front of stage 01. The proposed decision is ADR-018 in [decisions](../decisions.md). @@ -25,50 +25,38 @@ sources at `/kryptik-sources`, work at `/kryptik-work`, the tree at `/kryptik` (`build/stages/03-chroot-prep.sh`). Stages 04 and 05 therefore never see the host's paths. Stage 06 and the Rust binaries do. -## What differs between two builds today +## What differs between two builds + +`SOURCE_DATE_EPOCH` is the commit's time (`git log -1 --format=%ct`): the +Makefile exports it to every stage and the chroot passes it in. No stamp +fingerprints it, or every commit would rebuild everything, so a step taken +from the cache keeps the time it was built with; the comparison below builds +without a cache. ### Stage 06's root image -`s_rootfs` in `build/stages/06-iso.sh`: - -- **The verity salt** is `openssl rand -hex 32`, so the root hash, and with - it every kernel's command line, differs in every build. The salt can be - the SHA-256 of the ext4 image before the tree is appended: still unique to - that content, and the same in every build of it. -- **The filesystem is made by the host's `mkfs.ext4 -d`.** The Ubuntu 24.04 - runners have e2fsprogs 1.47.0, which copies each file's ctime from the - staging tree (when rsync wrote it, not when the package was built) and - takes the current time for the superblock. Support for - `SOURCE_DATE_EPOCH`, with every time mke2fs writes clamped to it, and a - tarball as `-d`'s input both arrived in 1.47.1 - ([release notes](https://e2fsprogs.sourceforge.net/e2fsprogs-release.html)). - Three ways out: - - run the sysroot's own `mkfs.ext4`, which stage 04 builds at 1.47.1, in - the chroot, from the step that already enters it to bind the kernels; - - build a host e2fsprogs at 1.47.1 or later from the pinned tarball before - stage 06; - - feed mke2fs a normalised tarball (sorted names, fixed owner, clamped - times). That needs libarchive in whichever e2fsprogs runs, which the - sysroot's does not have. - - The first adds no tool and no recipe. -- **The UUID and the directory hash seed** are random unless given: - `-U ` and `-E hash_seed=`, both derived from the version and - the commit. +`s_rootfs` in `build/stages/06-iso.sh`, fixed: + +- **The verity salt** is the SHA-256 of the ext4 image before the tree is + appended: unique to that content, and the same in every build of it. +- **The filesystem is made by the sysroot's mke2fs** (e2fsprogs 1.47.1, from + stage 04) in the chroot, not the host's (the Ubuntu 24.04 runners have + 1.47.0). With `SOURCE_DATE_EPOCH`, 1.47.1 writes the commit's time into the + superblock and clamps every time it copies from the tree to it + (`lib/ext2fs/openfs.c`, `misc/create_inode.c`). +- **The UUID and the directory hash seed** are derived from the version and + the commit (`-U`, `-E hash_seed=`). - **Directory order.** mke2fs walks each directory with `scandir` and - `alphasort` (in 1.47.0 and 1.47.1 alike), so the order follows the - collation of the locale it runs in. `LC_ALL=C` makes it bytewise. -- **What stage 06 writes into the tree**: `os-release`, `kryptik-image.json`, - `ld.so.preload`, the trust anchor and role, `update.conf`, `fstab`, and - the directories it creates. Their contents are fixed by the inputs except - `built_at`, which is `date -Iseconds`, and the default version, - `0.1..`. Their times are now; mke2fs 1.47.1 clamps them. - -The fix is one `SOURCE_DATE_EPOCH`, the commit's time, exported for the -whole build, with `built_at` written from it in UTC, and the same -`KRYPTIK_VERSION` passed to both builds being compared. - -**The clock floor moves with it.** `built_at` is the floor kryptikd keeps the + `alphasort`; the chroot's environment sets no locale, so the order is + bytewise. +- **`built_at`** in `kryptik-image.json` is `SOURCE_DATE_EPOCH` in UTC. +- **The check:** stage 06 makes the image twice from one tree and fails if + the two differ. + +What remains is the version: the default is `0.1..`, so the +two builds being compared take the same `KRYPTIK_VERSION`. + +**The clock floor moved with `built_at`.** It is the floor kryptikd keeps the clock above (`floor_from_image_json` in `time.rs`), and the `time-floor` service raises a dead RTC to it ([clock](time.md)). With the commit's time there, the floor becomes the time of the commit rather than of the build: @@ -79,10 +67,12 @@ time design still holds. The parser takes the `+00:00` offset that ### Stage 05's kernel -- `/proc/version` carries the build's time, user and host, and a runner's - host name changes with every run. `KBUILD_BUILD_TIMESTAMP`, - `KBUILD_BUILD_USER` and `KBUILD_BUILD_HOST` fix them, as the kernel's - documentation describes (`Documentation/kbuild/reproducible-builds.rst`). +- `/proc/version` carried the build's time, user, host and build number, + and the number grew with every relink in stage 06's bind step. Stage 05 + and the bind step set `KBUILD_BUILD_TIMESTAMP` (from `SOURCE_DATE_EPOCH`), + `KBUILD_BUILD_USER`, `KBUILD_BUILD_HOST` and `KBUILD_BUILD_VERSION=1`, as + the kernel's documentation describes + (`Documentation/kbuild/reproducible-builds.rst`). - `RANDSTRUCT_FULL` lays out structures from a seed generated for each tree (`scripts/basic/randstruct.seed`). Reproducing the kernel needs that seed fixed. For a published kernel this costs nothing: anyone holding the @@ -101,8 +91,8 @@ static archives (member times, unless binutils is in deterministic mode), Python's bytecode (a timestamp in each `.pyc`, unless `SOURCE_DATE_EPOCH` is set, when `py_compile` writes hash-checked files), and perl's `Config.pm`, which records when and by whom perl was built. -Exporting `SOURCE_DATE_EPOCH` from `build/lib/common.sh` for every step, -which nothing does today, would take most of them. The comparison finds the rest package by package. +`SOURCE_DATE_EPOCH` in every step takes most of them. The comparison finds +the rest package by package. ### The Rust binaries @@ -111,6 +101,12 @@ checkout's path and with the runner's `CARGO_HOME`, and both paths end up in panic locations. `--remap-path-prefix` for each removes them. The toolchain is already pinned by hash (`build/config/rust.lock`). +### The update manifest + +`tools/release-manifest.sh` writes a `created:` line from the clock into +every manifest, so the manifest's text differs in every build until it takes +`SOURCE_DATE_EPOCH`. + ### The media `mkfs.vfat` picks a random volume ID, mtools stamps the ESP's files with the @@ -214,11 +210,10 @@ diverse-double-compiling test. ## Recommendation -1. **Reproducible root and kernels:** `SOURCE_DATE_EPOCH` from the commit; - stage 06's mke2fs run in the chroot from the sysroot's e2fsprogs 1.47.1, - under `LC_ALL=C`, with a fixed UUID and hash seed; the salt from the - image; `KBUILD_BUILD_*` and a fixed randstruct seed; `--remap-path-prefix` - for the Rust binaries; fixed IDs and times on the media. +1. **Reproducible root and kernels:** done for stage 06's root image and the + kernel's banner; left are a fixed randstruct seed, `--remap-path-prefix` + for the Rust binaries, the manifest's `created:` line, deterministic + static archives, and fixed IDs and times on the media. 2. **CI compares** two builds weekly and for every tag, with one throwaway key medium, on two runner images. 3. **Releases sign modules in two passes** and publish the module From f35ea9e1b2662360dc1be4f14c665c4a9f1ab097 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 20:39:58 -0700 Subject: [PATCH 038/213] No resolver answers an uplink: the zones' own resolvers' ports are dropped there beside 53, and every dnsmasq queries from above them Linux takes a packet for 10.19.0.1 or fd19::1 on any interface, and the uplinks' drop named port 53 alone, so a host on the uplink's network could route to the bridge's address through the net zone and ask a zone's own resolver on 5300 + K: an open resolver there, and a read of what that zone looked up (0f's read of 78912ea2). One rule now drops new connections on an uplink for 53 and 5302-5549. --min-port keeps every dnsmasq's own queries above that range, so none holds a port a zone's resolver is about to bind. dns-uplink-closed asks both from the hwsim access point with a route through the lease; netzone-uplink.sh pins the rule. --- build/guest-tests/zones-check.sh | 20 ++++++++++++++++++++ docs/design/net-zone.md | 12 ++++++++---- tools/image/zones-test.sh | 2 +- tools/net/netzone-init.sh | 11 +++++++---- tools/tests/netzone-uplink.sh | 6 +++--- 5 files changed, 39 insertions(+), 12 deletions(-) diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index 72f56b9b..54ff20e4 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -923,6 +923,26 @@ if [[ "$far" -eq 0 && "$near" -ne 0 ]]; then else fail "wifi-beyond" "personal (init ${ppid:-none}): $AP_FAR rc=$far, $AP_ADDR rc=$near; $(netzone_said 'nftables: zones go out' | tail -1); routes: $(nsenter -t "$net_init" -n ip -4 route 2>/dev/null | tr '\n' ';')" fi +# No resolver answers the uplink's network. The access point, given a route to the bridge +# through the net zone's lease, asks the shared resolver and untrusted's own: Linux takes a +# packet for a local address on any interface, so only the net zone's rules refuse it. +uplink_dns="no route through ${lease:-no lease}" +if [[ "$lease" == 192.168.77.* ]] && ap ip route replace 10.19.0.0/24 via "${lease%/*}" dev "$AP_IF" 2>> "$LOG/ap.err"; then + uplink_dns="$(ap python3 -c 'import socket, struct, sys +q = struct.pack(">HHHHHH", 0x5454, 0x0100, 1, 0, 0, 0) + b"\x07kryptik\x04test\x00" + struct.pack(">HH", 1, 1) +for port in sys.argv[1:]: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.settimeout(2) + try: + s.sendto(q, ("10.19.0.1", int(port))); s.recvfrom(512); print(port + ":answered", end=" ") + except OSError: + print(port + ":none", end=" ")' 53 "$((5300 + UNT))" 2>&1)" + ap ip route del 10.19.0.0/24 2>/dev/null +fi +if [[ "$uplink_dns" == "53:none $((5300 + UNT)):none " ]]; then + pass "dns-uplink-closed" "asked from the access point's network, neither the shared resolver nor untrusted's own answered" +else + fail "dns-uplink-closed" "from the access point: ${uplink_dns}" +fi # The saved passphrase joins WPA2 by its pre-shared key, unprotected where the access point # offers no protected management frames, and WPA3 by SAE with them: the same SSID, SAE only. sta_keys() { ap_wpa all_sta | grep -E '^(AKMSuiteSelector|flags)=' | tr '\n' ' '; } diff --git a/docs/design/net-zone.md b/docs/design/net-zone.md index 1966065a..3364e636 100755 --- a/docs/design/net-zone.md +++ b/docs/design/net-zone.md @@ -112,8 +112,9 @@ query and the [update](update-channel.md) fetcher. Builds on from the bridge to an uplink, what a gateway carries accepted and the uplink's own network refused but to the zones whose definition opens it ([below](#the-uplinks-own-networks)); bridge to bridge dropped; - `10.19.0.0/24` and `fd19::/64` masqueraded out of every uplink; new DNS - connections arriving on an uplink dropped. Each routed zone may hold an + `10.19.0.0/24` and `fd19::/64` masqueraded out of every uplink; new + connections arriving on an uplink for DNS, or for a zone's own resolver, + dropped. Each routed zone may hold an eighth of the zone's conntrack table, which every zone's flows fill together: nft's `ct count` on the MAC both its addresses are pinned to, for flows through the net zone and flows to it. Past its share, a zone's @@ -148,8 +149,11 @@ query and the [update](update-channel.md) fetcher. Builds on the masquerade already needs: no kernel option is added). The addresses are pinned to the zone's MAC, so no zone is sent to another's resolver, and a resolver's port takes only what the chain sent it, so none is asked there - directly. A zone defined after the net zone started, or whose resolver - could not start, asks the shared one, which keeps no cache + directly, and none is asked from an uplink: Linux takes a packet for the + bridge's addresses on any interface, so the uplinks' drop names the + resolvers' ports beside 53. Every dnsmasq sends its own queries from ports + above them (`--min-port`). A zone defined after the net zone started, or + whose resolver could not start, asks the shared one, which keeps no cache (`--cache-size=0`) and no CHAOS records (`--no-ident`); one that dies is started once more, and its zone is on the shared one until it answers. The uplink's servers keep caches of their own, shared with every machine diff --git a/tools/image/zones-test.sh b/tools/image/zones-test.sh index e5817a6c..c5a4bad3 100755 --- a/tools/image/zones-test.sh +++ b/tools/image/zones-test.sh @@ -71,7 +71,7 @@ for name in kernel-support policies net-ready net-dns dhcpcd-separated dnsmasq-u radio-recarried \ wifi-wpa2-plain wifi-sae \ zone-clocks-own \ - time-floor-ran time-clamp time-floor-forged time-claim-stepped time-claim-floor time-claim-consent pids-limit ephemeral-size-bound cpu-max-set lifecycle-repeat lifecycle-registry resolver-after-attach dns-cache-off dns-counters-hidden dns-own-resolver dns-own-port-closed dns-cache-per-zone net-zone-sysfs-nics-only \ + time-floor-ran time-clamp time-floor-forged time-claim-stepped time-claim-floor time-claim-consent pids-limit ephemeral-size-bound cpu-max-set lifecycle-repeat lifecycle-registry resolver-after-attach dns-cache-off dns-counters-hidden dns-own-resolver dns-own-port-closed dns-cache-per-zone dns-uplink-closed net-zone-sysfs-nics-only \ terminal-terminfo man-page text-browser tls-trust no-shared-writable-mount net-zone-no-zone-data no-swap no-dbus \ volume-init zone-source-pinned bridge-ports-closed encrypted-zone-start stop-closes-volume wrong-passphrase persist-reopen no-mapping-after ephemeral-gone concurrent-start-refused full-volume header-restore volume-destroy vault-offline vault-ping no-passphrase-leak \ setuid-only-allowed no-file-capabilities sysctls-applied host-activity-hidden fetch-and-sntp-no-caps; do diff --git a/tools/net/netzone-init.sh b/tools/net/netzone-init.sh index c5aab026..fa3e5821 100755 --- a/tools/net/netzone-init.sh +++ b/tools/net/netzone-init.sh @@ -102,6 +102,8 @@ ZONE_DNS_PORTS="$((DNS_PORT_BASE + 2))-$((DNS_PORT_BASE + 249))" # dhcpcd on every address once it holds two uplinks, is no zone's to reach. # A zone's DNS goes to a resolver of its own where the dns chain sends it # (sync_dnsnat), and a resolver's own port takes nothing that chain did not. +# No resolver answers an uplink: Linux takes a packet for 10.19.0.1 on any +# interface, so a host on the uplink's network could otherwise ask one. RULES="table inet kryptik { set gw4 { type ipv4_addr; } set gw6 { type ipv6_addr; } @@ -139,8 +141,7 @@ RULES="table inet kryptik { chain input { type filter hook input priority filter; policy accept; iifname \"${BR}\" ct state new add @flows { ether saddr ct count over ${FLOWCAP} } drop - iifname ${NICSET} ct state new tcp dport 53 drop - iifname ${NICSET} ct state new udp dport 53 drop + iifname ${NICSET} ct state new meta l4proto { tcp, udp } th dport { 53, ${ZONE_DNS_PORTS} } drop iifname \"${BR}\" ct state established,related accept iifname \"${BR}\" ip daddr 10.19.0.1 meta l4proto { tcp, udp } th dport 53 accept iifname \"${BR}\" ip6 daddr fd19::1 meta l4proto { tcp, udp } th dport 53 accept @@ -297,10 +298,12 @@ start_dns() { # without a resolver of its own, so it keeps no cache and no CHAOS records (--no-ident): # an answer it kept comes back sooner, with less TTL, to the next zone asking, and # hits.bind and misses.bind count every zone's lookups. --pid-file with no path writes none. + # Its queries go out from ports above the zones' resolvers' (--min-port), so none holds + # the port one of them is about to listen on. dnsmasq --keep-in-foreground --log-facility=- --no-hosts --bind-interfaces \ --listen-address=10.19.0.1 --listen-address=fd19::1 --listen-address=127.0.0.1 \ --resolv-file="$UPSTREAM" --cache-size=0 --no-ident --local-service --local=/test/ \ - --pid-file --user=nobody --group=nogroup & + --min-port="$((DNS_PORT_BASE + 250))" --pid-file --user=nobody --group=nogroup & DNSPID=$! sleep 1 if dns_alive "$DNSPID"; then @@ -326,7 +329,7 @@ start_zone_dns() { # start_zone_dns K: started; zone_dns_said tells whether it dnsmasq --keep-in-foreground --log-facility=- --no-hosts --bind-interfaces \ --listen-address=10.19.0.1 --listen-address=fd19::1 --port="$((DNS_PORT_BASE + $1))" \ --resolv-file="$UPSTREAM" --no-ident --local-service --local=/test/ \ - --pid-file --user=nobody --group=nogroup & + --min-port="$((DNS_PORT_BASE + 250))" --pid-file --user=nobody --group=nogroup & zone_dns_set "$1" "$!" } zone_dns_said() { # zone_dns_said K: a second after its start diff --git a/tools/tests/netzone-uplink.sh b/tools/tests/netzone-uplink.sh index 28a54c02..ff996865 100755 --- a/tools/tests/netzone-uplink.sh +++ b/tools/tests/netzone-uplink.sh @@ -119,9 +119,9 @@ for sh in sh bash dash; do pre="$(sed -n '/chain prerouting {/,/^ }$/p' <<<"$rules" | grep -oE 'priority raw|ip saddr \. ether saddr != @pin4 drop|ip6 saddr \. ether saddr @pin6 accept|ip6 saddr fe80::/10 icmpv6 type \{ nd-neighbor-solicit, nd-neighbor-advert \} accept|meta nfproto ipv6 drop' | tr '\n' '|')" same "from the bridge, ahead of conntrack: IPv4 off its pin dropped, IPv6 on its pin taken, neighbour discovery from a link-local address taken, other IPv6 dropped" "$pre" \ 'priority raw|ip saddr . ether saddr != @pin4 drop|ip6 saddr . ether saddr @pin6 accept|ip6 saddr fe80::/10 icmpv6 type { nd-neighbor-solicit, nd-neighbor-advert } accept|meta nfproto ipv6 drop|' - input="$(sed -n '/chain input {/,/^ }$/p' <<<"$rules" | grep -oE 'ct state new (tcp|udp) dport 53 drop|"kryptik0" .*(accept|drop)$' | sed 's/count over [0-9]*/count over N/' | tr '\n' '|')" - same "into the net zone: each zone's share first; no resolver for an uplink; from the bridge replies, DNS and echo on the bridge's addresses, a zone's own resolver only as the dns chain sent it, neighbour discovery, and nothing else" "$input" \ - '"kryptik0" ct state new add @flows { ether saddr ct count over N } drop|ct state new tcp dport 53 drop|ct state new udp dport 53 drop|"kryptik0" ct state established,related accept|"kryptik0" ip daddr 10.19.0.1 meta l4proto { tcp, udp } th dport 53 accept|"kryptik0" ip6 daddr fd19::1 meta l4proto { tcp, udp } th dport 53 accept|"kryptik0" ct status dnat ip daddr 10.19.0.1 meta l4proto { tcp, udp } th dport 5302-5549 accept|"kryptik0" ct status dnat ip6 daddr fd19::1 meta l4proto { tcp, udp } th dport 5302-5549 accept|"kryptik0" ip daddr 10.19.0.1 icmp type echo-request accept|"kryptik0" ip6 daddr fd19::1 icmpv6 type echo-request accept|"kryptik0" icmpv6 type { nd-neighbor-solicit, nd-neighbor-advert } accept|"kryptik0" drop|' + input="$(sed -n '/chain input {/,/^ }$/p' <<<"$rules" | grep -oE 'ct state new meta l4proto \{ tcp, udp \} th dport \{ 53, [0-9]+-[0-9]+ \} drop|"kryptik0" .*(accept|drop)$' | sed 's/count over [0-9]*/count over N/' | tr '\n' '|')" + same "into the net zone: each zone's share first; no resolver, shared or a zone's own, for an uplink; from the bridge replies, DNS and echo on the bridge's addresses, a zone's own resolver only as the dns chain sent it, neighbour discovery, and nothing else" "$input" \ + '"kryptik0" ct state new add @flows { ether saddr ct count over N } drop|ct state new meta l4proto { tcp, udp } th dport { 53, 5302-5549 } drop|"kryptik0" ct state established,related accept|"kryptik0" ip daddr 10.19.0.1 meta l4proto { tcp, udp } th dport 53 accept|"kryptik0" ip6 daddr fd19::1 meta l4proto { tcp, udp } th dport 53 accept|"kryptik0" ct status dnat ip daddr 10.19.0.1 meta l4proto { tcp, udp } th dport 5302-5549 accept|"kryptik0" ct status dnat ip6 daddr fd19::1 meta l4proto { tcp, udp } th dport 5302-5549 accept|"kryptik0" ip daddr 10.19.0.1 icmp type echo-request accept|"kryptik0" ip6 daddr fd19::1 icmpv6 type echo-request accept|"kryptik0" icmpv6 type { nd-neighbor-solicit, nd-neighbor-advert } accept|"kryptik0" drop|' rules="$(run rules "$T/none")" grep -qF 'set local4 { type ipv4_addr; }' <<<"$rules" && green "with no zone let through the sets are empty, and every zone is refused" || red "the empty local sets" "$(grep 'set local' <<<"$rules" | tr '\n' '|')" done From 52db7660f7e54db021298aa05620bbab53be3c6e Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 20:42:23 -0700 Subject: [PATCH 039/213] The pin survey reads libevdev's releases from its GitLab, which it can still see www.freedesktop.org now answers a scripted client with a challenge page (HTTP 418), so the survey read libevdev as "not determined" and missed 1.14.0 (2026-10-07). The project's GitLab tags API answers, and lists it. The review row main already has for 1.14.0 then matches. --- tools/currency-rules.tsv | 2 ++ tools/fetch-sources.sh | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/tools/currency-rules.tsv b/tools/currency-rules.tsv index a0a04885..5b8b72d6 100644 --- a/tools/currency-rules.tsv +++ b/tools/currency-rules.tsv @@ -35,6 +35,8 @@ procps-ng api https://gitlab.com/api/v4/projects/procps-ng%2Fprocps/releases?per # Tags: the release list misses a release. psmisc api https://gitlab.com/api/v4/projects/psmisc%2Fpsmisc/repository/tags?per_page=100 name - wayland api https://gitlab.freedesktop.org/api/v4/projects/wayland%2Fwayland/releases?per_page=50 tag_name drop90 +# www.freedesktop.org answers a script with a challenge page; the project's GitLab lists its tags. +libevdev listing https://gitlab.freedesktop.org/api/v4/projects/libevdev%2Flibevdev/repository/tags?per_page=100 libevdev-([0-9]+(\.[0-9]+)+) - libinput api https://gitlab.freedesktop.org/api/v4/projects/libinput%2Flibinput/releases?per_page=50 tag_name drop90 wayland-protocols api https://gitlab.freedesktop.org/api/v4/projects/wayland%2Fwayland-protocols/releases?per_page=50 tag_name - libdisplay-info api https://gitlab.freedesktop.org/api/v4/projects/emersion%2Flibdisplay-info/releases?per_page=50 tag_name - diff --git a/tools/fetch-sources.sh b/tools/fetch-sources.sh index 823cdd1d..20010af1 100755 --- a/tools/fetch-sources.sh +++ b/tools/fetch-sources.sh @@ -148,7 +148,7 @@ libxkbcommon|${V_LIBXKBCOMMON}|${MIRROR_GITHUB}/xkbcommon/libxkbcommon/archive/x xkeyboard-config|${V_XKEYBOARD_CONFIG}|${MIRROR_XORG}/data/xkeyboard-config/xkeyboard-config-${V_XKEYBOARD_CONFIG}.tar.xz|sig|listing pixman|${V_PIXMAN}|${MIRROR_CAIRO}/pixman-${V_PIXMAN}.tar.gz|sums:pixman-${V_PIXMAN}.tar.gz.sha512.asc|listing libdrm|${V_LIBDRM}|${MIRROR_DRI}/libdrm-${V_LIBDRM}.tar.xz|sig|listing -libevdev|${V_LIBEVDEV}|${MIRROR_FDO_SW}/libevdev/libevdev-${V_LIBEVDEV}.tar.xz|sig|listing +libevdev|${V_LIBEVDEV}|${MIRROR_FDO_SW}/libevdev/libevdev-${V_LIBEVDEV}.tar.xz|sig|rule mtdev|${V_MTDEV}|${MIRROR_BITMATH}/mtdev-${V_MTDEV}.tar.bz2|probe|listing libinput|${V_LIBINPUT}|${MIRROR_FDO_GITLAB}/libinput/libinput/-/archive/${V_LIBINPUT}/libinput-${V_LIBINPUT}.tar.gz|none|rule seatd|${V_SEATD}|${MIRROR_SRHT}/~kennylevinsen/seatd/archive/${V_SEATD}.tar.gz|none|rule From dff8ff95c547dd638cd13c93eed1aedeb03db731 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 20:43:00 -0700 Subject: [PATCH 040/213] Put, fetch, forget and the discards change the update stage under one lock, so a piece checked against a stage that forget cleared never lands in the next --- compartments/kryptikd/src/update.rs | 51 ++++++++++++++++++----- compartments/kryptikd/src/update/tests.rs | 36 ++++++++++++++++ 2 files changed, 76 insertions(+), 11 deletions(-) diff --git a/compartments/kryptikd/src/update.rs b/compartments/kryptikd/src/update.rs index e7f8e8ae..f59a1708 100644 --- a/compartments/kryptikd/src/update.rs +++ b/compartments/kryptikd/src/update.rs @@ -3,7 +3,7 @@ use std::cmp::Ordering; use std::io::Write as _; -use std::os::unix::fs::OpenOptionsExt; +use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; use std::path::{Path, PathBuf}; pub const POINTER_MAGIC: &str = "KRYPTIK-LATEST-1"; @@ -276,10 +276,16 @@ fn run_check(tool: &Path, base: &Path, verb: &str, inputs: &[(&Path, &str)], who /// The lock that keeps checks one at a time; held while the returned file is open. fn check_lock() -> Result { + lock(Path::new(CHECK_LOCK)) +} + +/// An exclusive flock on `path`, made if missing; held while the returned file is open. +fn lock(path: &Path) -> Result { use std::os::unix::io::AsRawFd; - let f = std::fs::OpenOptions::new().create(true).append(true).open(CHECK_LOCK).map_err(|e| format!("{CHECK_LOCK}: {e}"))?; + let f = std::fs::OpenOptions::new().create(true).append(true).mode(0o600).custom_flags(libc::O_NOFOLLOW).open(path) + .map_err(|e| format!("{}: {e}", path.display()))?; if unsafe { libc::flock(f.as_raw_fd(), libc::LOCK_EX) } < 0 { - return Err(format!("{CHECK_LOCK}: {}", std::io::Error::last_os_error())); + return Err(format!("{}: {}", path.display(), std::io::Error::last_os_error())); } Ok(f) } @@ -440,6 +446,13 @@ fn put_file(path: &Path, bytes: &[u8]) -> Result<(), String> { crate::files::write_atomic(path, &[bytes], 0o600, None).map_err(|e| format!("{}: {e}", path.display())) } +/// Held by `put`, `want`, `forget` and the discards while they read and change the request and +/// its stage, so a piece checked against one stage never lands in the next. +fn stage_lock(dir: &Path) -> Result { + private_dir(dir)?; + lock(&dir.join("stage.lock")) +} + fn stored_pointer(dir: &Path) -> Option { parse_pointer(&std::fs::read_to_string(dir.join("pointer")).ok()?).ok() } @@ -504,9 +517,9 @@ pub fn want(dir: &Path, channel: Option<&str>, role: &str, running: &str) -> Res let channel = channel.ok_or_else(|| format!("this image names no update channel ({CONF})"))?; // Refused here if this image would not fetch it: the poll would only say `idle`. resolve_base(channel, &p.base, role).map_err(|e| format!("{} cannot be fetched: {e}", p.version))?; + let _lock = stage_lock(dir)?; if wanted(dir).as_deref() != Some(p.version.as_str()) { - let _ = std::fs::remove_file(dir.join("files")); - let _ = std::fs::remove_dir_all(dir.join("incoming")); + clear_stage(dir); } put_file(&dir.join("wanted"), p.version.as_bytes())?; // Asked for again: a forgotten version is no longer passed over. @@ -517,11 +530,12 @@ pub fn want(dir: &Path, channel: Option<&str>, role: &str, running: &str) -> Res /// `kryptik update forget`: drop the release asked for and what has arrived of it. Automatic /// fetching then passes that version over; a newer one, or `fetch`, asks again. pub fn forget(dir: &Path) -> Result { + let _lock = stage_lock(dir)?; let v = wanted(dir).ok_or("no release has been asked for")?; // Recorded first, so the next poll never asks for it again in between. put_file(&dir.join("declined"), v.as_bytes())?; let _ = std::fs::remove_file(dir.join("wanted")); - if !discard_stage(dir) { + if !clear_stage(dir) { return Err(format!("{v} is no longer asked for, but what had arrived of it could not be deleted")); } Ok(v) @@ -605,6 +619,7 @@ fn free_bytes(path: &Path) -> Option { /// `update-put`: bytes for the wanted release, under `may_put`'s rule. pub fn put(dir: &Path, checks: &Checks, now: i64, name: &str, offset: u64, bytes: &[u8]) -> Result { + let lock = stage_lock(dir)?; let version = wanted(dir).ok_or("no release has been asked for")?; let p = stored_pointer(dir).filter(|p| p.version == version).ok_or("the release asked for is not the one the newest statement names")?; let stage = staging(dir, &version); @@ -643,7 +658,16 @@ pub fn put(dir: &Path, checks: &Checks, now: i64, name: &str, offset: u64, bytes put_file(&dir.join("refused"), now.to_string().as_bytes())?; refuse(why) }; - let listing = match (checks.manifest)(&stage) { + /* Checked unlocked, so `forget` and `fetch` need not wait for it; the stage held open + * meanwhile shows whether one of them cleared it. */ + let held_open = std::fs::File::open(&stage).map_err(|e| format!("{}: {e}", stage.display()))?; + drop(lock); + let checked = (checks.manifest)(&stage); + let _lock = stage_lock(dir)?; + if !held_open.metadata().is_ok_and(|m| m.nlink() > 0) { + return Err(format!("the stage of {version} was cleared while its manifest was checked")); + } + let listing = match checked { Ok(l) => l, Err(why) => return refuse_checked(why), }; @@ -767,6 +791,12 @@ pub fn refresh_login_notice(dir: &Path, notice: &Path, now: i64, since: Option bool { + let Ok(_lock) = stage_lock(dir) else { return false }; + clear_stage(dir) +} + +/// `discard_stage` for a caller that holds the lock. +fn clear_stage(dir: &Path) -> bool { let _ = std::fs::remove_file(dir.join("files")); match std::fs::remove_dir_all(dir.join("incoming")) { Ok(()) => true, @@ -797,11 +827,10 @@ fn forget_unless_trial(dir: &Path, running: &str, trial: &Path) { if trial.exists() || trial.with_file_name("trial.failed").exists() { return; } + let Ok(_lock) = stage_lock(dir) else { return }; if wanted(dir).is_some_and(|v| version_cmp(&v, running) != Ordering::Greater) { - for f in ["wanted", "files"] { - let _ = std::fs::remove_file(dir.join(f)); - } - let _ = std::fs::remove_dir_all(dir.join("incoming")); + let _ = std::fs::remove_file(dir.join("wanted")); + clear_stage(dir); } } diff --git a/compartments/kryptikd/src/update/tests.rs b/compartments/kryptikd/src/update/tests.rs index 64c13894..aa8d7847 100644 --- a/compartments/kryptikd/src/update/tests.rs +++ b/compartments/kryptikd/src/update/tests.rs @@ -402,6 +402,42 @@ fn forget_drops_a_release_that_auto_then_passes_over() { let _ = std::fs::remove_dir_all(&d); } +#[test] +fn forget_waits_for_a_put_in_flight() { + let d = scratch("forget-waits"); + latest(&d, &yes(), T0, "production", "1.0.2", pointer_text("1.0.3", "2027-03-02T14:05:00Z").as_bytes(), b"sig").unwrap(); + want(&d, Some(CH), "production", "1.0.2").unwrap(); + // Held as `put` holds it, from its first look at the stage to its last write there. + let held = stage_lock(&d).unwrap(); + let dir = d.clone(); + let forgetting = std::thread::spawn(move || forget(&dir)); + std::thread::sleep(std::time::Duration::from_millis(200)); + assert!(!forgetting.is_finished() && wanted(&d).is_some(), "forget went ahead while a put held the stage"); + drop(held); + assert_eq!(forgetting.join().unwrap().unwrap(), "1.0.3"); + let _ = std::fs::remove_dir_all(&d); +} + +#[test] +fn a_manifest_checked_while_its_stage_was_cleared_is_not_kept() { + let d = scratch("cleared-mid-check"); + latest(&d, &yes(), T0, "production", "1.0.2", pointer_text("1.0.3", "2027-03-02T14:05:00Z").as_bytes(), b"sig").unwrap(); + want(&d, Some(CH), "production", "1.0.2").unwrap(); + // `forget` and `fetch` run while the pair is checked, which they do not wait for. + let meanwhile = |stage: &Path| { + forget(&d).unwrap(); + want(&d, Some(CH), "production", "1.0.2").unwrap(); + (yes().manifest)(stage) + }; + let checks = Checks { pointer: &|_, _| Ok(()), manifest: &meanwhile }; + put(&d, &checks, T0, "manifest", 0, b"m").unwrap(); + assert!(put(&d, &checks, T0, "manifest.sig", 0, b"s").unwrap_err().contains("cleared while its manifest was checked")); + // The new request starts from nothing, and no refusal holds its pair back. + assert!(verified_files(&d, "1.0.3").is_none() && !d.join("refused").exists()); + assert!(poll(&d, CH, "production", "1.0.2", T0).ends_with(" need manifest 0 manifest.sig 0")); + let _ = std::fs::remove_dir_all(&d); +} + #[test] fn auto_refuses_what_fetch_refuses() { let d = scratch("auto-refused"); From 2e0566c8d4d36199cc00644c78e9d6b4ce330de9 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 20:43:51 -0700 Subject: [PATCH 041/213] The kernel's build time is exported apart from its assignment, as shellcheck asks --- build/stages/05-kernel.sh | 2 +- build/stages/06-kernel-bind.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/build/stages/05-kernel.sh b/build/stages/05-kernel.sh index d7f6915a..8a9598ec 100755 --- a/build/stages/05-kernel.sh +++ b/build/stages/05-kernel.sh @@ -231,7 +231,7 @@ s_build() { cd "$KSRC" # The banner's time, user, host and build number, the same in every build of one commit. export KBUILD_BUILD_USER=kryptik KBUILD_BUILD_HOST=kryptik KBUILD_BUILD_VERSION=1 - [[ -z "${SOURCE_DATE_EPOCH:-}" ]] || export KBUILD_BUILD_TIMESTAMP="$(date -u -d "@${SOURCE_DATE_EPOCH}")" + [[ -z "${SOURCE_DATE_EPOCH:-}" ]] || { KBUILD_BUILD_TIMESTAMP="$(date -u -d "@${SOURCE_DATE_EPOCH}")"; export KBUILD_BUILD_TIMESTAMP; } make # The banner (/proc/version) records which compiler built the image. echo "--- linux_banner ---" diff --git a/build/stages/06-kernel-bind.sh b/build/stages/06-kernel-bind.sh index 578eb7f6..d8d79259 100755 --- a/build/stages/06-kernel-bind.sh +++ b/build/stages/06-kernel-bind.sh @@ -19,7 +19,7 @@ mkdir -p "$KERNELS" cd "$KSRC" # As stage 05 built it, or each relink would stamp its own time and build number. export KBUILD_BUILD_USER=kryptik KBUILD_BUILD_HOST=kryptik KBUILD_BUILD_VERSION=1 -[[ -z "${SOURCE_DATE_EPOCH:-}" ]] || export KBUILD_BUILD_TIMESTAMP="$(date -u -d "@${SOURCE_DATE_EPOCH}")" +[[ -z "${SOURCE_DATE_EPOCH:-}" ]] || { KBUILD_BUILD_TIMESTAMP="$(date -u -d "@${SOURCE_DATE_EPOCH}")"; export KBUILD_BUILD_TIMESTAMP; } grep -q '^CONFIG_CMDLINE_OVERRIDE=y' .config || die "CONFIG_CMDLINE_OVERRIDE is not set in the kernel config; the command line would not be bound" grep -q '^CONFIG_EFI_STUB=y' .config || die "CONFIG_EFI_STUB is not set; the kernel is not a UEFI application" From 75e4996930cf2e049286af123a51391eb94f3766 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 20:44:20 -0700 Subject: [PATCH 042/213] The manifest item sits beside the licences, apart from the other audits' new items --- tools/acceptance.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/acceptance.sh b/tools/acceptance.sh index 731d70b0..f6499380 100755 --- a/tools/acceptance.sh +++ b/tools/acceptance.sh @@ -367,10 +367,10 @@ on_root_image() { # on_root_image CMD...: CMD, given the image mounted read-on } audit_root() { "${SELF}/check-artifact-hardening.sh" "$1" --strict --json "${OUT}/artifact-hardening.json"; } it_artifacts() { on_root_image audit_root; } +it_licences() { on_root_image "${SELF}/check-image-licences.sh"; } # Every entry of the image under test, hashed: what a release ships, and what the next one changes. record_root() { "${SELF}/artifact-manifest.sh" --root "$1" --out "${OUT}/image-manifest.txt"; } it_manifest() { on_root_image record_root; } -it_licences() { on_root_image "${SELF}/check-image-licences.sh"; } it_kernel_config() { "${SELF}/validate-kernel-config.sh" --boot && "${SELF}/validate-kernel-config.sh" --hardened; } it_support_status() { "${SELF}/check-support-status.sh" --strict; } it_ovmf_vars() { @@ -440,8 +440,8 @@ item build host-suites M host 0 it_host_suites need_host item build libc-unwind M host 0 it_libc_unwind need_sysroot item build userspace-smoke M host 0 it_userspace need_sysroot item build artifact-hardening M host 0 it_artifacts need_root_image -item build image-manifest M host 0 it_manifest need_root_image item build licences M host 0 it_licences need_root_image +item build image-manifest M host 0 it_manifest need_root_image item build kernel-config M host 0 it_kernel_config need_sources item build support-status M host 0 it_support_status item boot media-smoke-usb M vm 25 it_smoke_usb need_vm From 6f3f68436de7cf7d4dfcd2c56e277631a5c8db0e Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 20:44:58 -0700 Subject: [PATCH 043/213] The trial test makes its state directory private, as zone 0 does, since the stage lock is refused in any other --- compartments/kryptikd/src/update/tests.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/compartments/kryptikd/src/update/tests.rs b/compartments/kryptikd/src/update/tests.rs index aa8d7847..5eca115e 100644 --- a/compartments/kryptikd/src/update/tests.rs +++ b/compartments/kryptikd/src/update/tests.rs @@ -529,6 +529,8 @@ fn channel_read_from_config() { #[test] fn stage_kept_through_trial() { let d = scratch("trial"); + // As zone 0 makes it: the stage lock is refused in a directory others can reach. + private_dir(&d).unwrap(); std::fs::create_dir_all(staging(&d, "1.0.3")).unwrap(); std::fs::write(d.join("wanted"), "1.0.3").unwrap(); std::fs::write(d.join("files"), LISTING).unwrap(); From 94e7c9af5e432778fa162b3e3140626039c0ca0e Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 20:46:42 -0700 Subject: [PATCH 044/213] A Wi-Fi network with no passphrase can be saved as open, and is joined without a key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kryptik wifi add took a passphrase and nothing else, so a café's, an airport's or a hotel's open network could not be joined at all, though the guide sends the login page such networks show to untrusted. `kryptik wifi add SSID --open` saves one. kryptik-launch sends it as --wifi-add-open, the daemon's request carries an `open` line in place of `psk` (one of the two, never both), and `kryptikd wifi add SSID --open` reads no passphrase. wifi.rs writes the block as key_mgmt=NONE with no psk and no ieee80211w, reads only that shape back for a block without a psk, and one wording for what add did serves the command and the daemon. Choosing the request by its mode, the launcher no longer infers forget from an empty passphrase buffer. wifi-open: the hwsim access point offers no security under another SSID, `kryptikd wifi add --open` saves it, and the restarted net zone joins it and leases there. Unit tests cover the block's form, replacing a key with none and back, and the request's one-of-two rule; serve.sh and cli.sh the daemon and the launch path. --- build/guest-tests/zones-check.sh | 35 ++++++++++++++ compartments/kryptikd/src/main.rs | 25 +++++----- compartments/kryptikd/src/serve.rs | 34 ++++++++------ compartments/kryptikd/src/serve/tests.rs | 5 ++ compartments/kryptikd/src/wifi.rs | 58 ++++++++++++++++-------- compartments/kryptikd/src/wifi/tests.rs | 52 ++++++++++++++++----- compartments/tests/cli.sh | 8 ++++ compartments/tests/serve.sh | 13 ++++++ docs/design/net-zone.md | 8 +++- docs/user-guide.md | 8 ++++ tools/desktop/kryptik-launch.c | 12 +++-- tools/image/zones-test.sh | 2 +- tools/kryptik | 24 +++++++--- 13 files changed, 215 insertions(+), 69 deletions(-) diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index b5bb1669..80e55f72 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -910,6 +910,41 @@ if [[ "$sae_up" = 1 && "$sae_keys" == *"AKMSuiteSelector=00-0f-ac-8 "* && "$sae_ else fail "wifi-sae" "access point up: ${sae_up}; the station at it: ${sae_keys:-none}; $(tail -3 "$LOG/ap-sae-wpa.log" 2>/dev/null | tr '\n' ' ')" fi +# A network saved as open is joined without a key: the access point offers none under another +# SSID, `wifi add --open` saves it with no passphrase, and the restarted net zone leases there. +OPEN_SSID=kryptik-hwsim-open +open_up=0 +if [[ "$ap_up" = 1 ]]; then + ap_pid="$(cat /run/zt-ap-wpa.pid 2>/dev/null)" + [[ -n "$ap_pid" ]] && kill "$ap_pid" 2>/dev/null + for _ in $(seq 1 20); do [[ -n "$ap_pid" ]] && kill -0 "$ap_pid" 2>/dev/null || break; sleep 0.5; done + cat > /root/zt/ap-open.conf <> "$LOG/ap.err" 2>&1 + for _ in $(seq 1 30); do ap_wpa status | grep -q '^wpa_state=COMPLETED' && { open_up=1; break; }; sleep 1; done +fi +before="$(ready_count)" +"$KD" wifi add "$OPEN_SSID" --open --wifi-dir "$WIFI_DIR" --zones "$Z" < /dev/null > "$LOG/wifi-add-open.out" 2>&1 +line="$(ready_after "$before" " wifi=$OPEN_SSID " 90)" +open_lease="" +for _ in $(seq 1 40); do + open_lease="$(nsenter -t "$(cut -d' ' -f1 /run/kryptik/zones/net/init.pid 2>/dev/null)" -n ip -4 -o addr show "$STA_IF" 2>/dev/null | awk '{print $4}' | head -1)" + [[ "$open_lease" == 192.168.77.* ]] && break; sleep 1 +done +if [[ "$open_up" = 1 && "$line" == *" wifi=$OPEN_SSID "* && "$open_lease" == 192.168.77.* ]] \ + && grep -q "added open network \"$OPEN_SSID\"" "$LOG/wifi-add-open.out"; then + pass "wifi-open" "saved with --open and no passphrase, $OPEN_SSID was joined and the net zone leased ${open_lease} there" +else + fail "wifi-open" "access point up: ${open_up}; $(tr '\n' ' ' < "$LOG/wifi-add-open.out"); newest READY: ${line:-none}; lease: ${open_lease:-none}; $(tail -2 "$LOG/ap-open-wpa.log" 2>/dev/null | tr '\n' ' ')" +fi # Back to the wire: the radios go first, so the net zone the forget restarts finds none. for f in /run/zt-ap-dnsmasq.pid /run/zt-ap-wpa.pid; do p="$(cat "$f" 2>/dev/null)"; [[ -n "$p" ]] && kill "$p" 2>/dev/null; done [[ -n "$AP_HOLD" ]] && kill "$AP_HOLD" 2>/dev/null diff --git a/compartments/kryptikd/src/main.rs b/compartments/kryptikd/src/main.rs index c3d1faaa..411397ae 100644 --- a/compartments/kryptikd/src/main.rs +++ b/compartments/kryptikd/src/main.rs @@ -70,8 +70,9 @@ USAGE: [--group G] instance that serves only your own uid) [--proxy-exe P] [--wifi-dir DIR] kryptikd wifi list the net zone's Wi-Fi networks, SSIDs only - kryptikd wifi add SSID add one, or replace its passphrase; the - passphrase is read from stdin, never argv + kryptikd wifi add SSID [--open] add one, or replace its passphrase; the + passphrase is read from stdin, never argv; + --open saves it with none, an open network kryptikd wifi forget SSID remove one [--wifi-dir DIR] (the session does this through `kryptik wifi` and the launch daemon; this is root's @@ -1079,7 +1080,7 @@ fn cmd_wifi(zones_dir: &Path, args: &[String]) -> ExitCode { let sub = args.get(1).map(String::as_str).unwrap_or(""); let ssid = args.get(2).filter(|a| !a.starts_with("--")); let usage = || { - eprintln!("usage: kryptikd wifi list | add SSID | forget SSID [--wifi-dir DIR]"); + eprintln!("usage: kryptikd wifi list | add SSID [--open] | forget SSID [--wifi-dir DIR]"); ExitCode::from(2) }; let refused = |e: String| { @@ -1101,19 +1102,21 @@ fn cmd_wifi(zones_dir: &Path, args: &[String]) -> ExitCode { if let Err(e) = wifi::check_ssid(ssid) { return refused(e); } - let pass = match wifi::read_passphrase(&format!("passphrase for {ssid}: ")) { - Ok(p) => p, - Err(e) => return refused(e), + let open = args.iter().any(|a| a == "--open"); + let pass = if open { + None + } else { + match wifi::read_passphrase(&format!("passphrase for {ssid}: ")) { + Ok(p) => Some(p), + Err(e) => return refused(e), + } }; let owner = match wifi::owner_for(zones_dir) { Ok(o) => o, Err(e) => return refused(e), }; - match wifi::add(&dir, owner, ssid, &pass) { - Ok(wifi::Added::New) => println!("added network {ssid:?}; {}", wifi::restart_net_zone(&dir)), - Ok(wifi::Added::Replaced) => { - println!("replaced the passphrase of network {ssid:?}; {}", wifi::restart_net_zone(&dir)) - } + match wifi::add(&dir, owner, ssid, pass.as_deref()) { + Ok(a) => println!("{}; {}", wifi::said(ssid, a, open), wifi::restart_net_zone(&dir)), Err(e) => return refused(e), } ExitCode::SUCCESS diff --git a/compartments/kryptikd/src/serve.rs b/compartments/kryptikd/src/serve.rs index fc94f072..918a5ac2 100644 --- a/compartments/kryptikd/src/serve.rs +++ b/compartments/kryptikd/src/serve.rs @@ -5,7 +5,7 @@ //! request one per connection, NUL-free text lines: //! run [wayland=] [pass=fd], `arg ` lines, `end` //! (with pass=fd, one descriptor rides with the first bytes) -//! wifi-add, `ssid `, `psk `, `end` +//! wifi-add, `ssid `, `psk ` or `open`, `end` //! wifi-forget, `ssid `, `end` //! stop | clipboard-move | info | status | //! runtime | wifi-list | update-status | update-fetch | update-apply | @@ -276,15 +276,18 @@ fn parse_run(text: &str) -> Result { Ok(req) } -/// Debug prints the SSID only: the passphrase is never formatted. +/// Debug prints the SSID only: the passphrase is never formatted. `open`: a network saved +/// with no passphrase, which `wifi-add` takes in place of `psk`. struct WifiRequest { ssid: String, psk: Option, + open: bool, } impl std::fmt::Debug for WifiRequest { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "WifiRequest {{ ssid: {:?}, psk: {} }}", self.ssid, if self.psk.is_some() { "" } else { "None" }) + let psk = if self.psk.is_some() { "" } else { "None" }; + write!(f, "WifiRequest {{ ssid: {:?}, psk: {psk}, open: {} }}", self.ssid, self.open) } } @@ -296,8 +299,8 @@ fn parse_wifi(text: &str) -> Result { let word = verb.split_whitespace().next().unwrap_or("wifi"); return Err(format!("{word}: the verb stands alone on its line")); } - let shape = if verb == "wifi-add" { "`ssid `, `psk `, `end`" } else { "`ssid `, `end`" }; - let (mut ssid, mut psk, mut ended) = (None, None, false); + let shape = if verb == "wifi-add" { "`ssid `, `psk ` or `open`, `end`" } else { "`ssid `, `end`" }; + let (mut ssid, mut psk, mut open, mut ended) = (None, None, false, false); for l in lines { if l == "end" { ended = true; @@ -311,6 +314,10 @@ fn parse_wifi(text: &str) -> Result { if psk.replace(v.to_string()).is_some() { return Err(format!("{verb}: two psk lines")); } + } else if l == "open" && verb == "wifi-add" { + if std::mem::replace(&mut open, true) { + return Err(format!("{verb}: two open lines")); + } } else { return Err(format!( "{verb}: unexpected line; the request is {shape}, one per line, and neither value may hold a newline" @@ -321,10 +328,10 @@ fn parse_wifi(text: &str) -> Result { return Err(format!("{verb}: request not terminated by `end`")); } let ssid = ssid.ok_or_else(|| format!("{verb}: no ssid line"))?; - if verb == "wifi-add" && psk.is_none() { - return Err("wifi-add: no psk line".into()); + if verb == "wifi-add" && psk.is_some() == open { + return Err("wifi-add: a psk line or an open line, one of them".into()); } - Ok(WifiRequest { ssid, psk }) + Ok(WifiRequest { ssid, psk, open }) } // --- the proxy socket ---------------------------------------------------------- @@ -1146,20 +1153,17 @@ fn handle(cfg: &ServeConfig, conn: UnixStream, jobs: &mut Vec) -> Option "added network", - crate::wifi::Added::Replaced => "replaced the passphrase of network", - }) + crate::wifi::add(&cfg.wifi_dir, owner, &req.ssid, req.psk.as_deref()).map(|a| crate::wifi::said(&req.ssid, a, req.open)) } else { - crate::wifi::forget(&cfg.wifi_dir, owner, &req.ssid).map(|()| "forgot network") + crate::wifi::forget(&cfg.wifi_dir, owner, &req.ssid).map(|()| format!("forgot network {:?}", req.ssid)) }; match done { Ok(what) => { /* The file is bound in at zone start, so a restart applies it. A * restart that cannot happen is reported, not a failure. */ let restart = crate::wifi::restart_net_zone(&cfg.wifi_dir); - eprintln!("kryptikd serve: uid {uid} {what} {:?}; {restart}", req.ssid); - reply(&conn, &format!("ok {what} {:?}; {restart}\n", req.ssid)); + eprintln!("kryptikd serve: uid {uid} {what}; {restart}"); + reply(&conn, &format!("ok {what}; {restart}\n")); } Err(e) => { eprintln!("kryptikd serve: uid {uid} {verb} {:?} refused: {e}", req.ssid); diff --git a/compartments/kryptikd/src/serve/tests.rs b/compartments/kryptikd/src/serve/tests.rs index 15534bf9..9b93a11a 100644 --- a/compartments/kryptikd/src/serve/tests.rs +++ b/compartments/kryptikd/src/serve/tests.rs @@ -46,8 +46,13 @@ fn parse_wifi_hides_psk() { assert_eq!(r.psk.as_deref(), Some("pass word")); let r = parse_wifi("wifi-forget\nssid Home\nend\n").unwrap(); assert_eq!((r.ssid.as_str(), r.psk), ("Home", None)); + let r = parse_wifi("wifi-add\nssid Cafe\nopen\nend\n").unwrap(); + assert_eq!((r.ssid.as_str(), r.psk, r.open), ("Cafe", None, true)); for bad in [ "wifi-add\nssid a\nend\n", // no psk + "wifi-add\nssid a\npsk secret\nopen\nend\n", // a psk and open + "wifi-add\nssid a\nopen\nopen\nend\n", // one open + "wifi-forget\nssid a\nopen\nend\n", // forget takes no open "wifi-add\npsk secret\nend\n", // no ssid "wifi-add\nssid a\npsk se\ncret\nend\n", // a newline in the psk "wifi-add\nssid a\npsk secret\n", // no end diff --git a/compartments/kryptikd/src/wifi.rs b/compartments/kryptikd/src/wifi.rs index 385cde5c..ba11f95e 100644 --- a/compartments/kryptikd/src/wifi.rs +++ b/compartments/kryptikd/src/wifi.rs @@ -22,15 +22,19 @@ const HEADER: &str = "ctrl_interface=/run/wpa_supplicant\nupdate_config=0\n"; /* How a saved network may be joined, from what was saved: a passphrase by WPA2's pre-shared * key or WPA3's SAE, a raw key by the former alone (SAE needs the passphrase), and neither by * 802.1X, so no access point's beacon chooses EAP for a saved SSID. Unnamed, wpa_supplicant - * takes WPA-PSK and WPA-EAP and never SAE. Management frames are protected where offered. */ + * takes WPA-PSK and WPA-EAP and never SAE. Management frames are protected where offered. + * A network saved as open is joined only without a key, so a beacon offering one changes + * nothing; it has no management frames to protect. */ const KEY_MGMT_PASSPHRASE: &str = "\tkey_mgmt=WPA-PSK WPA-PSK-SHA256 SAE"; const KEY_MGMT_KEY: &str = "\tkey_mgmt=WPA-PSK WPA-PSK-SHA256"; +const KEY_MGMT_OPEN: &str = "\tkey_mgmt=NONE"; const PMF: &str = "\tieee80211w=1"; fn key_mgmt(psk: &Psk) -> &'static str { match psk { Psk::Passphrase(_) => KEY_MGMT_PASSPHRASE, Psk::Hex(_) => KEY_MGMT_KEY, + Psk::Open => KEY_MGMT_OPEN, } } @@ -38,11 +42,13 @@ pub fn conf_path(dir: &Path) -> PathBuf { dir.join(FILE_NAME) } -/// A `psk=` value: a passphrase (written quoted) or the raw 64-hex-digit key. +/// A `psk=` value: a passphrase (written quoted) or the raw 64-hex-digit key; or none, for a +/// network saved as open. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Psk { Passphrase(String), Hex(String), + Open, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -102,10 +108,11 @@ pub fn render(nets: &[Network]) -> String { let mut out = String::from(HEADER); for n in nets { out.push_str("\nnetwork={\n"); - out.push_str(&format!("\tssid=\"{}\"\n{}\n{PMF}\n", n.ssid, key_mgmt(&n.psk))); + out.push_str(&format!("\tssid=\"{}\"\n{}\n", n.ssid, key_mgmt(&n.psk))); match &n.psk { - Psk::Passphrase(p) => out.push_str(&format!("\tpsk=\"{p}\"\n")), - Psk::Hex(h) => out.push_str(&format!("\tpsk={h}\n")), + Psk::Passphrase(p) => out.push_str(&format!("{PMF}\n\tpsk=\"{p}\"\n")), + Psk::Hex(h) => out.push_str(&format!("{PMF}\n\tpsk={h}\n")), + Psk::Open => {} } out.push_str("}\n"); } @@ -135,24 +142,24 @@ pub fn parse(text: &str) -> Result, String> { } Some((ssid, psk, km, pmf)) => { if line == "}" { - match (ssid.take(), psk.take()) { - (Some(s), Some(p)) => { - match (km.take(), *pmf) { - (None, false) => {} - (Some(k), true) if k == key_mgmt(&p) => {} - _ => return Err(format!("line {n}: a block whose key management is not what kryptikd writes for its psk")), - } - nets.push(Network { ssid: s, psk: p }); - } - _ => return Err(format!("line {n}: a network block without both an ssid and a psk")), - } + let Some(s) = ssid.take() else { + return Err(format!("line {n}: a network block without an ssid")); + }; + let p = match (psk.take(), km.take(), *pmf) { + (Some(p), None, false) => p, + (Some(p), Some(k), true) if k == key_mgmt(&p) => p, + (None, Some(KEY_MGMT_OPEN), false) => Psk::Open, + (None, None, _) => return Err(format!("line {n}: a network block with neither a psk nor key_mgmt=NONE")), + _ => return Err(format!("line {n}: a block whose key management is not what kryptikd writes for its psk")), + }; + nets.push(Network { ssid: s, psk: p }); block = None; } else if let Some(v) = line.strip_prefix("\tssid=\"").and_then(|r| r.strip_suffix('"')) { check_ssid(v).map_err(|e| format!("line {n}: {e}"))?; if ssid.replace(v.to_string()).is_some() { return Err(format!("line {n}: a second ssid in one block")); } - } else if line == KEY_MGMT_PASSPHRASE || line == KEY_MGMT_KEY { + } else if line == KEY_MGMT_PASSPHRASE || line == KEY_MGMT_KEY || line == KEY_MGMT_OPEN { if km.replace(line).is_some() { return Err(format!("line {n}: a second key_mgmt in one block")); } @@ -250,10 +257,11 @@ pub fn owner_in(uid: u32, gid: u32) -> (u32, u32) { (uid + crate::isolate::WIFI_ID, gid) } -/// Add a network or replace its block; nothing is written unless both values pass. -pub fn add(dir: &Path, owner: Option<(u32, u32)>, ssid: &str, passphrase: &str) -> Result { +/// Add a network or replace its block, open when there is no passphrase; nothing is written +/// unless both values pass. +pub fn add(dir: &Path, owner: Option<(u32, u32)>, ssid: &str, passphrase: Option<&str>) -> Result { check_ssid(ssid)?; - let psk = check_passphrase(passphrase)?; + let psk = passphrase.map_or(Ok(Psk::Open), check_passphrase)?; let mut nets = load(dir)?; let outcome = match nets.iter_mut().find(|n| n.ssid == ssid) { Some(n) => { @@ -269,6 +277,16 @@ pub fn add(dir: &Path, owner: Option<(u32, u32)>, ssid: &str, passphrase: &str) Ok(outcome) } +/// What `add` did, in the words the command and the daemon both print. +pub fn said(ssid: &str, added: Added, open: bool) -> String { + match (added, open) { + (Added::New, false) => format!("added network {ssid:?}"), + (Added::Replaced, false) => format!("replaced the passphrase of network {ssid:?}"), + (Added::New, true) => format!("added open network {ssid:?}"), + (Added::Replaced, true) => format!("network {ssid:?} is now open, joined without a passphrase"), + } +} + /// Remove one network's block; an unknown SSID is an error and writes nothing. pub fn forget(dir: &Path, owner: Option<(u32, u32)>, ssid: &str) -> Result<(), String> { check_ssid(ssid)?; diff --git a/compartments/kryptikd/src/wifi/tests.rs b/compartments/kryptikd/src/wifi/tests.rs index e140ac72..581d0f68 100644 --- a/compartments/kryptikd/src/wifi/tests.rs +++ b/compartments/kryptikd/src/wifi/tests.rs @@ -11,8 +11,8 @@ fn tmpdir(tag: &str) -> PathBuf { fn add_list_forget_round_trip() { let dir = tmpdir("roundtrip"); assert_eq!(list(&dir).unwrap(), Vec::::new(), "no file is no networks"); - assert_eq!(add(&dir, None, "Home", "correct horse battery").unwrap(), Added::New); - assert_eq!(add(&dir, None, "Cafe Wifi", "0123456789abcdef0123456789ABCDEF0123456789abcdef0123456789abcdef").unwrap(), Added::New); + assert_eq!(add(&dir, None, "Home", Some("correct horse battery")).unwrap(), Added::New); + assert_eq!(add(&dir, None, "Cafe Wifi", Some("0123456789abcdef0123456789ABCDEF0123456789abcdef0123456789abcdef")).unwrap(), Added::New); assert_eq!(list(&dir).unwrap(), vec!["Home", "Cafe Wifi"]); let text = fs::read_to_string(conf_path(&dir)).unwrap(); assert_eq!( @@ -35,6 +35,36 @@ fn add_list_forget_round_trip() { let _ = fs::remove_dir_all(&dir); } +#[test] +fn open_network_saved_without_key_or_protection() { + let dir = tmpdir("open"); + assert_eq!(add(&dir, None, "Cafe", None).unwrap(), Added::New); + add(&dir, None, "Home", Some("correct horse battery")).unwrap(); + let text = fs::read_to_string(conf_path(&dir)).unwrap(); + assert!(text.contains("\nnetwork={\n\tssid=\"Cafe\"\n\tkey_mgmt=NONE\n}\n"), "{text}"); + assert_eq!(list(&dir).unwrap(), vec!["Cafe", "Home"]); + // A passphrase given later replaces the open block, and saving it open again drops it. + assert_eq!(add(&dir, None, "Cafe", Some("now it has one")).unwrap(), Added::Replaced); + assert!(fs::read_to_string(conf_path(&dir)).unwrap().contains("\tssid=\"Cafe\"\n\tkey_mgmt=WPA-PSK WPA-PSK-SHA256 SAE\n")); + assert_eq!(add(&dir, None, "Home", None).unwrap(), Added::Replaced); + assert_eq!(load(&dir).unwrap()[1], Network { ssid: "Home".into(), psk: Psk::Open }); + assert!(!fs::read_to_string(conf_path(&dir)).unwrap().contains("correct horse battery")); + let _ = fs::remove_dir_all(&dir); +} + +#[test] +fn open_block_refused_with_a_key_or_protection() { + let head = "ctrl_interface=/run/wpa_supplicant\nupdate_config=0\n\nnetwork={\n\tssid=\"Cafe\"\n"; + for (body, why) in [ + ("\tkey_mgmt=NONE\n\tpsk=\"long enough\"\n}\n", "key management"), + ("\tkey_mgmt=NONE\n\tieee80211w=1\n}\n", "key management"), + ("}\n", "neither a psk nor key_mgmt=NONE"), + ] { + let e = parse(&format!("{head}{body}")).unwrap_err(); + assert!(e.contains(why), "{body:?}: {e}"); + } +} + #[test] fn passphrase_quoted_raw_psk_bare() { assert_eq!(check_passphrase("eight ch").unwrap(), Psk::Passphrase("eight ch".into())); @@ -89,12 +119,12 @@ fn ssid_and_passphrase_rules() { // A refusal writes nothing and leaves an existing file as it was. let dir = tmpdir("refusals"); - assert!(add(&dir, None, "bad\"ssid", "long enough").is_err()); + assert!(add(&dir, None, "bad\"ssid", Some("long enough")).is_err()); assert!(!conf_path(&dir).exists(), "nothing written on refusal"); - add(&dir, None, "Home", "long enough").unwrap(); + add(&dir, None, "Home", Some("long enough")).unwrap(); let before = fs::read(conf_path(&dir)).unwrap(); let ino = fs::metadata(conf_path(&dir)).unwrap().ino(); - assert!(add(&dir, None, "Home", "short").is_err()); + assert!(add(&dir, None, "Home", Some("short")).is_err()); assert!(forget(&dir, None, "Nowhere").unwrap_err().contains("no network named")); assert_eq!(fs::read(conf_path(&dir)).unwrap(), before); assert_eq!(fs::metadata(conf_path(&dir)).unwrap().ino(), ino, "a refusal does not rewrite the file"); @@ -104,9 +134,9 @@ fn ssid_and_passphrase_rules() { #[test] fn readd_replaces_block_in_place() { let dir = tmpdir("replace"); - add(&dir, None, "First", "first pass").unwrap(); - add(&dir, None, "Second", "second pass").unwrap(); - assert_eq!(add(&dir, None, "First", "changed pass").unwrap(), Added::Replaced); + add(&dir, None, "First", Some("first pass")).unwrap(); + add(&dir, None, "Second", Some("second pass")).unwrap(); + assert_eq!(add(&dir, None, "First", Some("changed pass")).unwrap(), Added::Replaced); let nets = load(&dir).unwrap(); assert_eq!(nets.len(), 2, "replaced, not appended"); assert_eq!(nets[0], Network { ssid: "First".into(), psk: Psk::Passphrase("changed pass".into()) }); @@ -118,11 +148,11 @@ fn readd_replaces_block_in_place() { #[test] fn write_is_atomic_rename() { let dir = tmpdir("atomic"); - add(&dir, None, "Home", "long enough").unwrap(); + add(&dir, None, "Home", Some("long enough")).unwrap(); let ino = fs::metadata(conf_path(&dir)).unwrap().ino(); // A temporary left by a writer that died before its rename is removed. fs::write(dir.join(format!(".{FILE_NAME}.4294967295")), "psk=\"secret\"").unwrap(); - add(&dir, None, "Other", "long enough").unwrap(); + add(&dir, None, "Other", Some("long enough")).unwrap(); assert_ne!(fs::metadata(conf_path(&dir)).unwrap().ino(), ino, "a new inode replaced the old file"); let names: Vec = fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); assert_eq!(names, vec![FILE_NAME], "only the file itself is in the directory"); @@ -148,7 +178,7 @@ fn foreign_file_refused() { let dir = tmpdir("foreign"); fs::create_dir_all(&dir).unwrap(); fs::write(conf_path(&dir), "ap_scan=1\n").unwrap(); - let e = add(&dir, None, "Home", "long enough").unwrap_err(); + let e = add(&dir, None, "Home", Some("long enough")).unwrap_err(); assert!(e.contains("something else edited"), "{e}"); assert_eq!(fs::read_to_string(conf_path(&dir)).unwrap(), "ap_scan=1\n"); let _ = fs::remove_dir_all(&dir); diff --git a/compartments/tests/cli.sh b/compartments/tests/cli.sh index 6ea090cc..3ee14797 100755 --- a/compartments/tests/cli.sh +++ b/compartments/tests/cli.sh @@ -336,6 +336,8 @@ case "\${1:-}" in --wifi-add) IFS= read -r pass r="\$(printf 'wifi-add\nssid %s\npsk %s\nend\n' "\$2" "\$pass" | client)" printf '%s\n' "\$r"; [[ "\$r" == ok* ]] ;; + --wifi-add-open) r="\$(printf 'wifi-add\nssid %s\nopen\nend\n' "\$2" | client)" + printf '%s\n' "\$r"; [[ "\$r" == ok* ]] ;; --wifi-forget) r="\$(printf 'wifi-forget\nssid %s\nend\n' "\$2" | client)" printf '%s\n' "\$r"; [[ "\$r" == ok* ]] ;; *) exit 2 ;; @@ -365,6 +367,12 @@ SHIM else fail "a too-short passphrase through the launch service: exit $rc, output '$out'" fi + out="$(KRYPTIK_LAUNCH="$WORK/launch-shim" K wifi add Airport --open < /dev/null 2>&1)"; rc=$? + if (( rc == 0 )) && [[ "$out" == "ok added open network \"Airport\";"* ]] && grep -qxF $'\tkey_mgmt=NONE' "$WDAEMON/wpa_supplicant.conf"; then + pass "wifi add --open asks for no passphrase and saves an open network through the launch service" + else + fail "wifi add --open through the launch service: exit $rc, output '$out'" + fi else fail "the launch daemon did not start for the launch-service checks" sed 's/^/ /' "$WORK/serve.log" diff --git a/compartments/tests/serve.sh b/compartments/tests/serve.sh index bd0b1bd6..5d475897 100755 --- a/compartments/tests/serve.sh +++ b/compartments/tests/serve.sh @@ -437,6 +437,19 @@ else fi r="$(ask 'wifi-forget\nssid Home\nend\n')" if [[ "$r" == "error: no network named \"Home\""* ]]; then pass "S9n forgetting a network that is not there is an error"; else fail "S9n: $r"; fi +# A network with no passphrase, a cafe's: `open` in place of the psk line, joined only without a key. +r="$(ask 'wifi-add\nssid Airport\nopen\nend\n')" +if [[ "$r" == "ok added open network \"Airport\";"* && "$(sed -n '/^\tssid="Airport"$/,/^}$/p' "$WCONF")" == $'\tssid="Airport"\n\tkey_mgmt=NONE\n}' ]]; then + pass "S9v an open network is saved as key_mgmt=NONE, with no psk and no ieee80211w" +else + fail "S9v: $r"; sed 's/^/ /' "$WCONF" +fi +r="$(ask 'wifi-add\nssid Airport\npsk now has one\nopen\nend\n')" +if [[ "$r" == "error: wifi-add: a psk line or an open line, one of them"* ]] && ! grep -qF 'now has one' "$WCONF"; then + pass "S9w a request with both a psk and open is refused, and writes nothing" +else + fail "S9w: $r" +fi # Refusals: each names its rule, and the file is byte-identical afterwards. cp "$WCONF" "$WORK/wifi-before" diff --git a/docs/design/net-zone.md b/docs/design/net-zone.md index 147981b1..a4149d53 100755 --- a/docs/design/net-zone.md +++ b/docs/design/net-zone.md @@ -310,7 +310,13 @@ configured the zone keeps its IPv4 path and the launcher says so. 802.1X, so no access point's beacon chooses EAP for a saved SSID. Unnamed, wpa_supplicant would take `WPA-PSK WPA-EAP` and never SAE. Management frames are protected wherever the access point offers it (`ieee80211w=1`), and SAE - always has them. A file an older kryptikd wrote is rewritten so. + always has them. A file an older kryptikd wrote is rewritten so. A network + saved as open (`kryptik wifi add SSID --open`, an `open` line in place of + `psk` in the request) is joined by no key at all (`key_mgmt=NONE`), so a + beacon offering one changes nothing, and has no management frames to + protect. Anyone in range reads what crosses it, as on any open network, + and any access point may take its name: what a zone sends there is private + only where the zone encrypts it. - On disk the file is plaintext inside the [encrypted](state-encryption.md) state partition, like NetworkManager's connection files. A compromised net zone learns the passphrases of the networks it was given, and nothing more. diff --git a/docs/user-guide.md b/docs/user-guide.md index 31639a08..cce45300 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -265,6 +265,14 @@ typed and then Enter, allows the file. Anything else refuses, keys typed before the code appears are dropped, and after a refusal that zone may not ask again for a minute. +**Wi-Fi.** `kryptik wifi add SSID` saves a network and asks for its +passphrase; the net zone restarts and joins it, by WPA3 where the network +offers it. A network with no passphrase, a café's or an airport's, is saved +with `kryptik wifi add SSID --open`: anyone in range can read what crosses it +unencrypted, so only what a zone encrypts itself, as HTTPS does, stays +private there. `kryptik wifi list` names the saved networks, and `kryptik +wifi forget SSID` removes one. + **The local network.** Every zone with a network reaches the internet through the net zone. The network the machine itself is on, with its router, its printers and the page a hotel's or café's Wi-Fi asks you to log in on, is diff --git a/tools/desktop/kryptik-launch.c b/tools/desktop/kryptik-launch.c index e72d0b92..54289e37 100644 --- a/tools/desktop/kryptik-launch.c +++ b/tools/desktop/kryptik-launch.c @@ -8,6 +8,7 @@ * kryptik-launch --clipboard-move FROM TO the zone 0 gesture: move FROM's clipboard to TO * kryptik-launch --wifi-list the net zone's Wi-Fi SSIDs, one per line * kryptik-launch --wifi-add SSID add one or replace its passphrase (one line on stdin) + * kryptik-launch --wifi-add-open SSID add one as an open network, with no passphrase * kryptik-launch --wifi-forget SSID remove one * kryptik-launch --update status|fetch|apply show, fetch or install a release * kryptik-launch --update auto on|off fetch each release as it is announced, or when asked @@ -296,7 +297,7 @@ static void usage(void) " kryptik-launch --info ZONE\n" " kryptik-launch --runtime-dir\n" " kryptik-launch --clipboard-move FROM TO\n" - " kryptik-launch --wifi-list | --wifi-add SSID | --wifi-forget SSID\n" + " kryptik-launch --wifi-list | --wifi-add SSID | --wifi-add-open SSID | --wifi-forget SSID\n" " (--wifi-add reads the passphrase from standard input)\n" " kryptik-launch --update status|fetch|apply | --update auto on|off\n", stderr); exit(2); @@ -361,20 +362,23 @@ static int wifi_main(int argc, char **argv) } return 0; } - if (argc != 3 || (strcmp(mode, "--wifi-add") != 0 && strcmp(mode, "--wifi-forget") != 0)) + int add = strcmp(mode, "--wifi-add") == 0, add_open = strcmp(mode, "--wifi-add-open") == 0; + if (argc != 3 || (!add && !add_open && strcmp(mode, "--wifi-forget") != 0)) usage(); const char *ssid = argv[2]; if (!*ssid || strlen(ssid) > 32 || strchr(ssid, '\n')) die("an SSID is 1 to 32 bytes without a newline"); char pass[256] = ""; - if (strcmp(mode, "--wifi-add") == 0) { + if (add) { char prompt[64]; snprintf(prompt, sizeof prompt, "passphrase for %s: ", ssid); secret_from_stdin(prompt, pass, sizeof pass); } char req[512]; - if (pass[0]) + if (add) snprintf(req, sizeof req, "wifi-add\nssid %s\npsk %s\nend\n", ssid, pass); + else if (add_open) + snprintf(req, sizeof req, "wifi-add\nssid %s\nopen\nend\n", ssid); else snprintf(req, sizeof req, "wifi-forget\nssid %s\nend\n", ssid); char *r = talk(req, -1); diff --git a/tools/image/zones-test.sh b/tools/image/zones-test.sh index 0de0663c..9f8713f3 100755 --- a/tools/image/zones-test.sh +++ b/tools/image/zones-test.sh @@ -69,7 +69,7 @@ for name in kernel-support policies net-ready net-dns dhcpcd-separated dnsmasq-u wifi-no-dpp \ uplink-renamed-plain uplink-state-reset \ radio-recarried \ - wifi-wpa2-plain wifi-sae wifi-supplicant-unprivileged wifi-supplicant-no-nft \ + wifi-wpa2-plain wifi-sae wifi-open wifi-supplicant-unprivileged wifi-supplicant-no-nft \ zone-clocks-own \ time-floor-ran time-clamp time-floor-forged time-claim-stepped time-claim-floor time-claim-consent pids-limit ephemeral-size-bound cpu-max-set lifecycle-repeat lifecycle-registry resolver-after-attach dns-cache-off dns-counters-hidden net-zone-sysfs-nics-only \ terminal-terminfo man-page text-browser tls-trust no-shared-writable-mount net-zone-no-zone-data no-swap no-dbus \ diff --git a/tools/kryptik b/tools/kryptik index aafaa51b..338feb5f 100755 --- a/tools/kryptik +++ b/tools/kryptik @@ -37,6 +37,9 @@ USAGE: $PROG wifi list the Wi-Fi networks the net zone knows $PROG wifi add SSID add one, or change its passphrase (asked for on the terminal; never an argument) + $PROG wifi add SSID --open add one that has no passphrase, a cafe's + or an airport's: anyone in range can read + what is not encrypted again above it $PROG wifi forget SSID remove one $PROG update status the release running, the newest one known and how old that news is, what is staged @@ -307,15 +310,20 @@ cmd_wifi() { local sub="${1:-}"; [[ $# -gt 0 ]] && shift case "$sub" in list) [[ $# -eq 0 ]] || die "$PROG wifi list takes no argument" 2 ;; - add|forget) [[ $# -eq 1 ]] || die "$PROG wifi $sub SSID: exactly one SSID" 2 ;; + add) [[ $# -eq 1 || ( $# -eq 2 && "$2" == --open ) ]] || die "$PROG wifi add SSID [--open]: exactly one SSID" 2 ;; + forget) [[ $# -eq 1 ]] || die "$PROG wifi forget SSID: exactly one SSID" 2 ;; *) usage >&2; die "wifi needs a subcommand: list, add SSID or forget SSID" 2 ;; esac if via_launch; then case "$sub" in list) "$LAUNCH" --wifi-list ;; - add) read_passphrase "$1" - # printf is a builtin, so the passphrase is never in argv. - printf '%s\n' "$PASSPHRASE" | "$LAUNCH" --wifi-add "$1" ;; + add) if [[ "${2:-}" == --open ]]; then + "$LAUNCH" --wifi-add-open "$1" + else + read_passphrase "$1" + # printf is a builtin, so the passphrase is never in argv. + printf '%s\n' "$PASSPHRASE" | "$LAUNCH" --wifi-add "$1" + fi ;; forget) "$LAUNCH" --wifi-forget "$1" ;; esac return @@ -323,8 +331,12 @@ cmd_wifi() { have_kryptikd case "$sub" in list) "$KRYPTIKD" wifi list --wifi-dir "$WIFI_DIR" ;; - add) read_passphrase "$1" - printf '%s\n' "$PASSPHRASE" | "$KRYPTIKD" wifi add "$1" --wifi-dir "$WIFI_DIR" --zones "$ZONES_DIR" ;; + add) if [[ "${2:-}" == --open ]]; then + "$KRYPTIKD" wifi add "$1" --open --wifi-dir "$WIFI_DIR" --zones "$ZONES_DIR" + else + read_passphrase "$1" + printf '%s\n' "$PASSPHRASE" | "$KRYPTIKD" wifi add "$1" --wifi-dir "$WIFI_DIR" --zones "$ZONES_DIR" + fi ;; forget) "$KRYPTIKD" wifi forget "$1" --wifi-dir "$WIFI_DIR" --zones "$ZONES_DIR" ;; esac } From f43a6263608eff77c944ddbf32a97366c8607954 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 20:49:03 -0700 Subject: [PATCH 045/213] The update manifest's created date is SOURCE_DATE_EPOCH's second when that is set, so two builds of a commit write the same manifest text --- docs/design/reproducible-builds.md | 7 ++++++- tools/release-manifest.sh | 5 ++++- tools/tests/release-manifest.sh | 18 ++++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/docs/design/reproducible-builds.md b/docs/design/reproducible-builds.md index c68bd29b..c7417f61 100644 --- a/docs/design/reproducible-builds.md +++ b/docs/design/reproducible-builds.md @@ -63,6 +63,9 @@ never see the host's paths. Stage 06 and the Rust binaries do. the directories it creates. Their contents are fixed by the inputs except `built_at`, which is `date -Iseconds`, and the default version, `0.1..`. Their times are now; mke2fs 1.47.1 clamps them. +- **The update manifest's `created:` line**, written by + `tools/release-manifest.sh create` from `s_payload`, is the second it ran, + unless `SOURCE_DATE_EPOCH` is set: then it is that second, in UTC. The fix is one `SOURCE_DATE_EPOCH`, the commit's time, exported for the whole build, with `built_at` written from it in UTC, and the same @@ -75,7 +78,9 @@ there, the floor becomes the time of the commit rather than of the build: earlier by hours for a dated build, by days at most for a release tagged after its commit. A floor may be early and never late, so every rule in the time design still holds. The parser takes the `+00:00` offset that -`date -u -Iseconds` prints. +`date -u -Iseconds` prints. The manifest's signed `created:` date, the +floor once its release is committed (`release_in` in `time.rs`), moves +earlier the same way. ### Stage 05's kernel diff --git a/tools/release-manifest.sh b/tools/release-manifest.sh index 4dd716ea..a640d678 100755 --- a/tools/release-manifest.sh +++ b/tools/release-manifest.sh @@ -49,6 +49,9 @@ do_create() { *) die "create: --role must be development or production, not '${role}'" ;; esac [[ -d "$root" ]] || die "create: --root ${root} is not a directory" + # The commit's time where SOURCE_DATE_EPOCH gives it, so every build of a commit writes the same text. + local epoch="${SOURCE_DATE_EPOCH:-$(date +%s)}" + [[ "$epoch" =~ ^[0-9]+$ ]] || die "create: SOURCE_DATE_EPOCH must be whole seconds since 1970, not '${epoch}'" local tmp; tmp="$(mktemp)" { @@ -56,7 +59,7 @@ do_create() { printf 'name: %s\n' "$name" printf 'version: %s\n' "$version" printf 'role: %s\n' "$role" - printf 'created: %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + printf 'created: %s\n' "$(date -u -d "@${epoch}" +%Y-%m-%dT%H:%M:%SZ)" } > "$tmp" # Relative to --root, or verify would check files other than the ones being installed. diff --git a/tools/tests/release-manifest.sh b/tools/tests/release-manifest.sh index 512a7189..9779216f 100755 --- a/tools/tests/release-manifest.sh +++ b/tools/tests/release-manifest.sh @@ -304,6 +304,24 @@ else red "create did not warn that the manifest is unsigned"; show fi +# Two builds of one commit write the same text: the date is SOURCE_DATE_EPOCH's, not the run's. +for n in 1 2; do + SOURCE_DATE_EPOCH=1700000000 bash "$TOOL" create --out "${W}/epoch${n}.manifest" --root "$REL" \ + --version 6.0 boot usr > /dev/null 2>&1 +done +if cmp -s "${W}/epoch1.manifest" "${W}/epoch2.manifest" && grep -qx 'created: 2023-11-14T22:13:20Z' "${W}/epoch1.manifest"; then + green "with SOURCE_DATE_EPOCH, create dates the manifest by it, and two runs write the same bytes" +else + red "SOURCE_DATE_EPOCH did not date the manifest"; grep '^created' "${W}"/epoch*.manifest | sed 's/^/ /' +fi +NO_COLOR=1 SOURCE_DATE_EPOCH=yesterday bash "$TOOL" create --out "${W}/epoch3.manifest" --root "$REL" boot \ + > "$OUT" 2>&1 +if [[ "$?" -ne 0 ]] && grep -qF "whole seconds since 1970" "$OUT" && [[ ! -e "${W}/epoch3.manifest" ]]; then + green "create refuses a SOURCE_DATE_EPOCH that is not a count of seconds" +else + red "create accepted SOURCE_DATE_EPOCH=yesterday"; show +fi + # An unlisted file at the root is refused, whatever its name. build_release; make_signed printf 'x\n' > "${REL}/.kryptik-update" From 5282002b517d623bd7f34b0ad3a97060d3c7c421 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 20:55:13 -0700 Subject: [PATCH 046/213] Each saved Wi-Fi network sees the machine by an address of its own, and DHCP asks with nothing a network could follow it by Every network the radio joined saw its hardware address, and dhcpcd, run on the stock sample configuration, sent a client ID built from a DUID made from a hardware address: any two networks, or the logs of one over months, could tell the same machine had been there. kryptikd now gives each saved network a random, locally administered unicast address when it is first saved, or when an older file is rewritten, and keeps it through a new passphrase; the block names it (mac_addr=3, mac_value=), and wpa_supplicant sets it before associating. The image's /etc/dhcpcd.conf is Kryptik's own: RFC 7844's anonymity profile (no hostname, no vendor class, a client ID made from the address the interface has when it asks), with the options the net zone reads asked for again. parse reads blocks through a small Block struct now that there are six things a block may say. Scans still go out from the radio's address of the moment; the design note says why. wifi-own-address: the access point sees the station by the address saved for its network, not the radio's own, and the lease's client ID is 01 and that address. wifi-address-per-network: the open network sees another. Unit tests: addresses are local, unicast, distinct, kept through a new passphrase and a forget, given once to an older file; parse refuses a policy without a value and the reverse. serve.sh S9x. --- build/guest-tests/zones-check.sh | 22 +++ build/recipes/dhcpcd.sh | 12 ++ compartments/kryptikd/src/wifi.rs | 189 ++++++++++++++++-------- compartments/kryptikd/src/wifi/tests.rs | 69 ++++++++- compartments/tests/serve.sh | 10 +- docs/design/net-zone.md | 14 ++ docs/user-guide.md | 8 +- tools/image/zones-test.sh | 2 +- 8 files changed, 253 insertions(+), 73 deletions(-) diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index 80e55f72..190920db 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -719,6 +719,8 @@ if modprobe mac80211_hwsim radios=2 2> "$LOG/hwsim.err"; then for _ in $(seq 1 20); do [[ -e /sys/class/ieee80211/phy1 ]] && break; sleep 0.5; done STA_IF="$(wl_of_phy phy0)"; AP_IF="$(wl_of_phy phy1)" fi +# The station radio's own address, read while it is still zone 0's: no network may see it. +STA_PERM="$(cat "/sys/class/net/${STA_IF:-none}/address" 2>/dev/null)" if [[ -n "$STA_IF" && -n "$AP_IF" ]]; then pass "wifi-module" "the signed mac80211_hwsim loaded with two radios: $STA_IF on phy0, $AP_IF on phy1" else @@ -784,6 +786,19 @@ for _ in $(seq 1 40); do [[ "$lease" == 192.168.77.* ]] && break; sleep 1 done [[ "$lease" == 192.168.77.* ]] && pass "wifi-lease" "$STA_IF in the net zone leased $lease from the access point" || fail "wifi-lease" "$STA_IF holds ${lease:-no address}; leases given: $(tr '\n' ' ' < /run/zt-ap.leases 2>/dev/null)" +# The network sees the station by the address saved for it, never the radio's own: the access +# point's station and its lease name that address, and the lease's client ID is made from it +# (01 and the address), not from a DUID a network could follow the machine by. +saved_mac() { sed -n "/^\tssid=\"$1\"\$/,/^}\$/s/^\tmac_value=//p" "$WIFI_DIR/wpa_supplicant.conf" 2>/dev/null; } +seen_mac() { ap_wpa all_sta | grep -iE '^[0-9a-f]{2}(:[0-9a-f]{2}){5}$' | head -1 | tr 'A-F' 'a-f'; } +own_mac="$(saved_mac "$AP_SSID")"; own_seen="$(seen_mac)" +own_lease="$(grep -F " ${lease%/*} " /run/zt-ap.leases 2>/dev/null | head -1)" +if [[ -n "$own_mac" && -n "$STA_PERM" && "$own_mac" != "$STA_PERM" && "$own_seen" == "$own_mac" \ + && "$own_lease" == *" $own_mac "* && "$own_lease" == *" 01:$own_mac" ]]; then + pass "wifi-own-address" "the access point saw the station as $own_mac, the address saved for $AP_SSID, not the radio's own $STA_PERM; lease: $own_lease" +else + fail "wifi-own-address" "saved: ${own_mac:-none}; the radio's own: ${STA_PERM:-unread}; seen by the access point: ${own_seen:-none}; lease: ${own_lease:-none}" +fi # The supplicant that joined runs as its own user with CAP_NET_ADMIN and CAP_NET_RAW alone, # under its own filter on top of the zone's (supplicant.rs). sup="$(pgrep -f 'wpa_supplicant -B -i [^ ]* -c /etc/wpa_supplicant.conf' | head -1)" @@ -945,6 +960,13 @@ if [[ "$open_up" = 1 && "$line" == *" wifi=$OPEN_SSID "* && "$open_lease" == 192 else fail "wifi-open" "access point up: ${open_up}; $(tr '\n' ' ' < "$LOG/wifi-add-open.out"); newest READY: ${line:-none}; lease: ${open_lease:-none}; $(tail -2 "$LOG/ap-open-wpa.log" 2>/dev/null | tr '\n' ' ')" fi +# Another network, another address: the open one saw the station by its own, not $AP_SSID's. +open_mac="$(saved_mac "$OPEN_SSID")"; open_seen="$(seen_mac)" +if [[ -n "$open_mac" && "$open_seen" == "$open_mac" && "$open_mac" != "${own_mac:-none}" && "$open_mac" != "$STA_PERM" ]]; then + pass "wifi-address-per-network" "$OPEN_SSID saw the station as $open_mac, its own address, where $AP_SSID saw ${own_mac}" +else + fail "wifi-address-per-network" "saved for $OPEN_SSID: ${open_mac:-none}; seen there: ${open_seen:-none}; $AP_SSID's: ${own_mac:-none}" +fi # Back to the wire: the radios go first, so the net zone the forget restarts finds none. for f in /run/zt-ap-dnsmasq.pid /run/zt-ap-wpa.pid; do p="$(cat "$f" 2>/dev/null)"; [[ -n "$p" ]] && kill "$p" 2>/dev/null; done [[ -n "$AP_HOLD" ]] && kill "$AP_HOLD" 2>/dev/null diff --git a/build/recipes/dhcpcd.sh b/build/recipes/dhcpcd.sh index 5a0ea3ea..cd431c46 100644 --- a/build/recipes/dhcpcd.sh +++ b/build/recipes/dhcpcd.sh @@ -7,5 +7,17 @@ s_dhcpcd() { --dbdir=/var/lib/dhcpcd --runstatedir=/run --privsepuser=dhcpcd make make install + # Kryptik's own in place of the sample, which sends a DUID made from a hardware address: + # RFC 7844's anonymity profile, so a network learns no hostname and no vendor class, and a + # client ID made from the address the interface has when it asks, which Wi-Fi sets per + # network. The options the net zone reads are asked for again after it. + cat > /etc/dhcpcd.conf <<'EOF' +# The net zone's dhcpcd (docs/design/net-zone.md). +persistent +anonymous +option domain_name_servers, classless_static_routes, interface_mtu +require dhcp_server_identifier +slaac private +EOF dhcpcd --version | sed -n 1p } diff --git a/compartments/kryptikd/src/wifi.rs b/compartments/kryptikd/src/wifi.rs index ba11f95e..c7cbd89a 100644 --- a/compartments/kryptikd/src/wifi.rs +++ b/compartments/kryptikd/src/wifi.rs @@ -55,6 +55,44 @@ pub enum Psk { pub struct Network { pub ssid: String, pub psk: Psk, + /// The station's address on this network alone (`mac_value`); none in a file an older + /// kryptikd wrote, until it is rewritten. + pub mac: Option, +} + +/* Each saved network is joined from an address of its own, made when it is first saved and + * kept with it: the network sees the same station each time, and no other network, nor the + * radio's own address, is seen there (wpa_supplicant's dedicated address per ESS). */ +const MAC_POLICY: &str = "\tmac_addr=3"; + +/// A locally administered unicast address, as `mac_value` holds one. +fn check_mac(v: &str) -> Result<(), String> { + let octets: Vec<&str> = v.split(':').collect(); + let hex = octets.len() == 6 && octets.iter().all(|o| o.len() == 2 && o.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))); + match hex.then(|| u8::from_str_radix(octets[0], 16)) { + Some(Ok(first)) if first & 0x03 == 0x02 => Ok(()), + _ => Err("an address is six lowercase hex octets, locally administered and unicast".into()), + } +} + +/// A new address for a network: random, with the locally administered bit set and the +/// group bit clear. +fn new_mac() -> Result { + let mut b = [0u8; 6]; + let n = unsafe { libc::getrandom(b.as_mut_ptr() as *mut libc::c_void, b.len(), 0) }; + if n != b.len() as isize { + return Err(format!("no randomness for a network's address: {}", io::Error::last_os_error())); + } + b[0] = (b[0] & 0xfc) | 0x02; + Ok(b.iter().map(|o| format!("{o:02x}")).collect::>().join(":")) +} + +/// Give each network without an address one of its own. +fn give_addresses(nets: &mut [Network]) -> Result<(), String> { + for n in nets.iter_mut().filter(|n| n.mac.is_none()) { + n.mac = Some(new_mac()?); + } + Ok(()) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -114,76 +152,101 @@ pub fn render(nets: &[Network]) -> String { Psk::Hex(h) => out.push_str(&format!("{PMF}\n\tpsk={h}\n")), Psk::Open => {} } + if let Some(m) = &n.mac { + out.push_str(&format!("{MAC_POLICY}\n\tmac_value={m}\n")); + } out.push_str("}\n"); } out } +/// What one `network={` block has said so far. +#[derive(Default)] +struct Block { + ssid: Option, + psk: Option, + key_mgmt: Option<&'static str>, + pmf: bool, + mac_policy: bool, + mac: Option, +} + +impl Block { + /// The network the closed block names; `n` is its closing line. + fn close(self, n: usize) -> Result { + let Some(ssid) = self.ssid else { + return Err(format!("line {n}: a network block without an ssid")); + }; + // A file an older kryptikd wrote names neither key management nor protection, and no address. + let psk = match (self.psk, self.key_mgmt, self.pmf) { + (Some(p), None, false) => p, + (Some(p), Some(k), true) if k == key_mgmt(&p) => p, + (None, Some(KEY_MGMT_OPEN), false) => Psk::Open, + (None, None, _) => return Err(format!("line {n}: a network block with neither a psk nor key_mgmt=NONE")), + _ => return Err(format!("line {n}: a block whose key management is not what kryptikd writes for its psk")), + }; + if self.mac_policy != self.mac.is_some() { + return Err(format!("line {n}: a block with only one of mac_addr and mac_value")); + } + Ok(Network { ssid, psk, mac: self.mac }) + } +} + /// Read back what `render` writes and refuse anything else, which a rewrite would lose or keep /// unchecked. Errors name the line, never its content. pub fn parse(text: &str) -> Result, String> { let mut nets = Vec::new(); - // ssid, psk, key_mgmt, ieee80211w: a file an older kryptikd wrote names the last two not at all. - let mut block: Option<(Option, Option, Option<&str>, bool)> = None; + let mut block: Option = None; + let foreign = |n: usize| format!("line {n} is not one kryptikd writes; refusing to read a file something else edited"); for (i, line) in text.lines().enumerate() { let n = i + 1; - match &mut block { - None => { - if line.trim().is_empty() || HEADER.lines().any(|h| h == line) { - continue; - } - if line == "network={" { - block = Some((None, None, None, false)); - continue; - } - return Err(format!( - "line {n} is not one kryptikd writes; refusing to read a file something else edited" - )); + let Some(b) = &mut block else { + if line.trim().is_empty() || HEADER.lines().any(|h| h == line) { + continue; + } + if line != "network={" { + return Err(foreign(n)); + } + block = Some(Block::default()); + continue; + }; + if line == "}" { + nets.push(block.take().expect("a block is open").close(n)?); + } else if let Some(v) = line.strip_prefix("\tssid=\"").and_then(|r| r.strip_suffix('"')) { + check_ssid(v).map_err(|e| format!("line {n}: {e}"))?; + if b.ssid.replace(v.to_string()).is_some() { + return Err(format!("line {n}: a second ssid in one block")); + } + } else if let Some(k) = [KEY_MGMT_PASSPHRASE, KEY_MGMT_KEY, KEY_MGMT_OPEN].into_iter().find(|k| *k == line) { + if b.key_mgmt.replace(k).is_some() { + return Err(format!("line {n}: a second key_mgmt in one block")); + } + } else if line == PMF { + if std::mem::replace(&mut b.pmf, true) { + return Err(format!("line {n}: a second ieee80211w in one block")); + } + } else if line == MAC_POLICY { + if std::mem::replace(&mut b.mac_policy, true) { + return Err(format!("line {n}: a second mac_addr in one block")); + } + } else if let Some(v) = line.strip_prefix("\tmac_value=") { + check_mac(v).map_err(|e| format!("line {n}: {e}"))?; + if b.mac.replace(v.to_string()).is_some() { + return Err(format!("line {n}: a second mac_value in one block")); } - Some((ssid, psk, km, pmf)) => { - if line == "}" { - let Some(s) = ssid.take() else { - return Err(format!("line {n}: a network block without an ssid")); - }; - let p = match (psk.take(), km.take(), *pmf) { - (Some(p), None, false) => p, - (Some(p), Some(k), true) if k == key_mgmt(&p) => p, - (None, Some(KEY_MGMT_OPEN), false) => Psk::Open, - (None, None, _) => return Err(format!("line {n}: a network block with neither a psk nor key_mgmt=NONE")), - _ => return Err(format!("line {n}: a block whose key management is not what kryptikd writes for its psk")), - }; - nets.push(Network { ssid: s, psk: p }); - block = None; - } else if let Some(v) = line.strip_prefix("\tssid=\"").and_then(|r| r.strip_suffix('"')) { - check_ssid(v).map_err(|e| format!("line {n}: {e}"))?; - if ssid.replace(v.to_string()).is_some() { - return Err(format!("line {n}: a second ssid in one block")); - } - } else if line == KEY_MGMT_PASSPHRASE || line == KEY_MGMT_KEY || line == KEY_MGMT_OPEN { - if km.replace(line).is_some() { - return Err(format!("line {n}: a second key_mgmt in one block")); - } - } else if line == PMF { - if std::mem::replace(pmf, true) { - return Err(format!("line {n}: a second ieee80211w in one block")); - } - } else if let Some(v) = line.strip_prefix("\tpsk=") { - let quoted = v.strip_prefix('"').and_then(|r| r.strip_suffix('"')); - let value = match (quoted, check_passphrase(quoted.unwrap_or(v))) { - (Some(_), Ok(p @ Psk::Passphrase(_))) => p, - (None, Ok(p @ Psk::Hex(_))) => p, - (_, Ok(_)) => return Err(format!("line {n}: the psk is quoted the wrong way for its form")), - (_, Err(e)) => return Err(format!("line {n}: {e}")), - }; - if psk.replace(value).is_some() { - return Err(format!("line {n}: a second psk in one block")); - } - } else { - return Err(format!( - "line {n} is not one kryptikd writes; refusing to read a file something else edited" - )); - } + } else if let Some(v) = line.strip_prefix("\tpsk=") { + let quoted = v.strip_prefix('"').and_then(|r| r.strip_suffix('"')); + let value = match (quoted, check_passphrase(quoted.unwrap_or(v))) { + (Some(_), Ok(p @ Psk::Passphrase(_))) => p, + (None, Ok(p @ Psk::Hex(_))) => p, + (_, Ok(_)) => return Err(format!("line {n}: the psk is quoted the wrong way for its form")), + (_, Err(e)) => return Err(format!("line {n}: {e}")), + }; + if b.psk.replace(value).is_some() { + return Err(format!("line {n}: a second psk in one block")); } + } else { + return Err(foreign(n)); } } if block.is_some() { @@ -216,12 +279,13 @@ pub fn load(dir: &Path) -> Result, String> { } /// Rewrite in today's form, owner and mode a file an older kryptikd wrote, so the net zone's -/// supplicant reads it and each network's key management; true when it was rewritten. A -/// current, absent or foreign file is left alone. +/// supplicant reads it, each network's key management and an address of its own; true when it +/// was rewritten. A current, absent or foreign file is left alone. pub fn refresh(dir: &Path, owner: Option<(u32, u32)>) -> Result { use std::os::unix::fs::MetadataExt; let Some(text) = read_text(dir)? else { return Ok(false) }; - let nets = parse(&text).map_err(|e| format!("{}: {e}", conf_path(dir).display()))?; + let mut nets = parse(&text).map_err(|e| format!("{}: {e}", conf_path(dir).display()))?; + give_addresses(&mut nets)?; let current = render(&nets); let md = fs::symlink_metadata(conf_path(dir)).map_err(|e| format!("{}: {e}", conf_path(dir).display()))?; let owned = owner.map_or(true, |(u, g)| md.uid() == u && md.gid() == g); @@ -269,10 +333,12 @@ pub fn add(dir: &Path, owner: Option<(u32, u32)>, ssid: &str, passphrase: Option Added::Replaced } None => { - nets.push(Network { ssid: ssid.to_string(), psk }); + nets.push(Network { ssid: ssid.to_string(), psk, mac: None }); Added::New } }; + // A replaced network keeps its address; a new one, and any an older kryptikd wrote, get one. + give_addresses(&mut nets)?; write_atomic(dir, &render(&nets), owner)?; Ok(outcome) } @@ -296,6 +362,7 @@ pub fn forget(dir: &Path, owner: Option<(u32, u32)>, ssid: &str) -> Result<(), S if nets.len() == before { return Err(format!("no network named {ssid:?} is configured")); } + give_addresses(&mut nets)?; write_atomic(dir, &render(&nets), owner) } diff --git a/compartments/kryptikd/src/wifi/tests.rs b/compartments/kryptikd/src/wifi/tests.rs index 581d0f68..a85a8c56 100644 --- a/compartments/kryptikd/src/wifi/tests.rs +++ b/compartments/kryptikd/src/wifi/tests.rs @@ -15,8 +15,10 @@ fn add_list_forget_round_trip() { assert_eq!(add(&dir, None, "Cafe Wifi", Some("0123456789abcdef0123456789ABCDEF0123456789abcdef0123456789abcdef")).unwrap(), Added::New); assert_eq!(list(&dir).unwrap(), vec!["Home", "Cafe Wifi"]); let text = fs::read_to_string(conf_path(&dir)).unwrap(); + // Each block also ends in its own address, which `each_network_has_an_address_of_its_own` reads. + let without_addresses: String = text.lines().filter(|l| !l.starts_with("\tmac_")).map(|l| format!("{l}\n")).collect(); assert_eq!( - text, + without_addresses, "ctrl_interface=/run/wpa_supplicant\nupdate_config=0\n\ \nnetwork={\n\tssid=\"Home\"\n\tkey_mgmt=WPA-PSK WPA-PSK-SHA256 SAE\n\tieee80211w=1\n\tpsk=\"correct horse battery\"\n}\n\ \nnetwork={\n\tssid=\"Cafe Wifi\"\n\tkey_mgmt=WPA-PSK WPA-PSK-SHA256\n\tieee80211w=1\n\tpsk=0123456789abcdef0123456789ABCDEF0123456789abcdef0123456789abcdef\n}\n" @@ -35,19 +37,70 @@ fn add_list_forget_round_trip() { let _ = fs::remove_dir_all(&dir); } +#[test] +fn each_network_has_an_address_of_its_own() { + let dir = tmpdir("addresses"); + let address = |ssid: &str| load(&dir).unwrap().into_iter().find(|n| n.ssid == ssid).and_then(|n| n.mac).unwrap(); + add(&dir, None, "Home", Some("correct horse battery")).unwrap(); + add(&dir, None, "Cafe", None).unwrap(); + let (home, cafe) = (address("Home"), address("Cafe")); + assert!(check_mac(&home).is_ok() && check_mac(&cafe).is_ok(), "{home} {cafe}"); + assert_ne!(home, cafe, "two networks never see one address"); + // Kept through a new passphrase and another network's removal. + add(&dir, None, "Home", Some("a different one")).unwrap(); + forget(&dir, None, "Cafe").unwrap(); + assert_eq!(address("Home"), home); + let text = fs::read_to_string(conf_path(&dir)).unwrap(); + assert!(text.contains(&format!("\tpsk=\"a different one\"\n\tmac_addr=3\n\tmac_value={home}\n}}\n")), "{text}"); + // A file an older kryptikd wrote gets one per network once, then is current. + fs::write(conf_path(&dir), "ctrl_interface=/run/wpa_supplicant\nupdate_config=0\n\nnetwork={\n\tssid=\"Old\"\n\tpsk=\"older pass\"\n}\n").unwrap(); + assert!(refresh(&dir, None).unwrap()); + let old = address("Old"); + assert!(check_mac(&old).is_ok(), "{old}"); + assert!(!refresh(&dir, None).unwrap(), "an address once given stays"); + assert_eq!(address("Old"), old); + let _ = fs::remove_dir_all(&dir); +} + +#[test] +fn an_address_is_local_unicast_and_paired_with_its_policy() { + for good in ["02:00:00:00:00:00", "fe:12:34:56:78:9a", "06:ab:cd:ef:01:23"] { + assert!(check_mac(good).is_ok(), "{good}"); + } + // Globally administered, a group address, upper case, five octets, not hex. + for bad in ["00:11:22:33:44:55", "03:11:22:33:44:55", "02:AB:CD:EF:01:23", "02:11:22:33:44", "02:11:22:33:44:zz"] { + assert!(check_mac(bad).is_err(), "{bad}"); + } + for _ in 0..64 { + let m = new_mac().unwrap(); + assert!(check_mac(&m).is_ok(), "{m}"); + } + let head = "network={\n\tssid=\"a\"\n\tkey_mgmt=NONE\n"; + for (body, why) in [ + ("\tmac_addr=3\n}\n", "only one of mac_addr and mac_value"), + ("\tmac_value=02:00:00:00:00:01\n}\n", "only one of mac_addr and mac_value"), + ("\tmac_addr=3\n\tmac_value=00:11:22:33:44:55\n}\n", "locally administered"), + ("\tmac_addr=3\n\tmac_addr=3\n\tmac_value=02:00:00:00:00:01\n}\n", "a second mac_addr"), + ] { + let e = parse(&format!("{head}{body}")).unwrap_err(); + assert!(e.contains(why), "{body:?}: {e}"); + } +} + #[test] fn open_network_saved_without_key_or_protection() { let dir = tmpdir("open"); assert_eq!(add(&dir, None, "Cafe", None).unwrap(), Added::New); add(&dir, None, "Home", Some("correct horse battery")).unwrap(); let text = fs::read_to_string(conf_path(&dir)).unwrap(); - assert!(text.contains("\nnetwork={\n\tssid=\"Cafe\"\n\tkey_mgmt=NONE\n}\n"), "{text}"); + assert!(text.contains("\nnetwork={\n\tssid=\"Cafe\"\n\tkey_mgmt=NONE\n\tmac_addr=3\n\tmac_value="), "{text}"); assert_eq!(list(&dir).unwrap(), vec!["Cafe", "Home"]); // A passphrase given later replaces the open block, and saving it open again drops it. assert_eq!(add(&dir, None, "Cafe", Some("now it has one")).unwrap(), Added::Replaced); assert!(fs::read_to_string(conf_path(&dir)).unwrap().contains("\tssid=\"Cafe\"\n\tkey_mgmt=WPA-PSK WPA-PSK-SHA256 SAE\n")); assert_eq!(add(&dir, None, "Home", None).unwrap(), Added::Replaced); - assert_eq!(load(&dir).unwrap()[1], Network { ssid: "Home".into(), psk: Psk::Open }); + let home = &load(&dir).unwrap()[1]; + assert_eq!((home.ssid.as_str(), &home.psk), ("Home", &Psk::Open)); assert!(!fs::read_to_string(conf_path(&dir)).unwrap().contains("correct horse battery")); let _ = fs::remove_dir_all(&dir); } @@ -71,8 +124,8 @@ fn passphrase_quoted_raw_psk_bare() { let hex = "f".repeat(64); assert_eq!(check_passphrase(&hex).unwrap(), Psk::Hex(hex.clone())); let nets = vec![ - Network { ssid: "a".into(), psk: Psk::Passphrase("pass word".into()) }, - Network { ssid: "b".into(), psk: Psk::Hex(hex.clone()) }, + Network { ssid: "a".into(), psk: Psk::Passphrase("pass word".into()), mac: None }, + Network { ssid: "b".into(), psk: Psk::Hex(hex.clone()), mac: None }, ]; let text = render(&nets); assert!(text.contains("\tpsk=\"pass word\"\n")); @@ -139,7 +192,7 @@ fn readd_replaces_block_in_place() { assert_eq!(add(&dir, None, "First", Some("changed pass")).unwrap(), Added::Replaced); let nets = load(&dir).unwrap(); assert_eq!(nets.len(), 2, "replaced, not appended"); - assert_eq!(nets[0], Network { ssid: "First".into(), psk: Psk::Passphrase("changed pass".into()) }); + assert_eq!((nets[0].ssid.as_str(), &nets[0].psk), ("First", &Psk::Passphrase("changed pass".into()))); assert_eq!(nets[1].ssid, "Second", "order kept"); assert_eq!(fs::read_to_string(conf_path(&dir)).unwrap().matches("network={").count(), 2); let _ = fs::remove_dir_all(&dir); @@ -188,8 +241,8 @@ fn foreign_file_refused() { fn key_management_follows_what_was_saved() { let hex = "f".repeat(64); let text = render(&[ - Network { ssid: "a".into(), psk: Psk::Passphrase("pass word".into()) }, - Network { ssid: "b".into(), psk: Psk::Hex(hex.clone()) }, + Network { ssid: "a".into(), psk: Psk::Passphrase("pass word".into()), mac: None }, + Network { ssid: "b".into(), psk: Psk::Hex(hex.clone()), mac: None }, ]); // A passphrase allows SAE, a raw key cannot; neither ever names 802.1X. assert!(text.contains("\tssid=\"a\"\n\tkey_mgmt=WPA-PSK WPA-PSK-SHA256 SAE\n\tieee80211w=1\n\tpsk=\"pass word\"\n"), "{text}"); diff --git a/compartments/tests/serve.sh b/compartments/tests/serve.sh index 5d475897..1d742ae7 100755 --- a/compartments/tests/serve.sh +++ b/compartments/tests/serve.sh @@ -439,7 +439,7 @@ r="$(ask 'wifi-forget\nssid Home\nend\n')" if [[ "$r" == "error: no network named \"Home\""* ]]; then pass "S9n forgetting a network that is not there is an error"; else fail "S9n: $r"; fi # A network with no passphrase, a cafe's: `open` in place of the psk line, joined only without a key. r="$(ask 'wifi-add\nssid Airport\nopen\nend\n')" -if [[ "$r" == "ok added open network \"Airport\";"* && "$(sed -n '/^\tssid="Airport"$/,/^}$/p' "$WCONF")" == $'\tssid="Airport"\n\tkey_mgmt=NONE\n}' ]]; then +if [[ "$r" == "ok added open network \"Airport\";"* && "$(sed -n '/^\tssid="Airport"$/,/^}$/p' "$WCONF" | grep -v $'^\tmac_')" == $'\tssid="Airport"\n\tkey_mgmt=NONE\n}' ]]; then pass "S9v an open network is saved as key_mgmt=NONE, with no psk and no ieee80211w" else fail "S9v: $r"; sed 's/^/ /' "$WCONF" @@ -450,6 +450,14 @@ if [[ "$r" == "error: wifi-add: a psk line or an open line, one of them"* ]] && else fail "S9w: $r" fi +# Each saved network carries an address of its own, which wpa_supplicant joins it from. +macs="$(sed -n 's/^\tmac_value=//p' "$WCONF")" +if [[ "$(grep -c $'^\tmac_addr=3$' "$WCONF")" == "$(grep -c '^network={' "$WCONF")" && "$(wc -l <<<"$macs")" == "$(sort -u <<<"$macs" | wc -l)" ]] \ + && ! grep -vqE '^[0-9a-f][26ae](:[0-9a-f]{2}){5}$' <<<"$macs"; then + pass "S9x each saved network has an address of its own, locally administered and unicast" +else + fail "S9x addresses: $(tr '\n' ' ' <<<"$macs")" +fi # Refusals: each names its rule, and the file is byte-identical afterwards. cp "$WCONF" "$WORK/wifi-before" diff --git a/docs/design/net-zone.md b/docs/design/net-zone.md index a4149d53..c4a61069 100755 --- a/docs/design/net-zone.md +++ b/docs/design/net-zone.md @@ -317,6 +317,20 @@ configured the zone keeps its IPv4 path and the launcher says so. protect. Anyone in range reads what crosses it, as on any open network, and any access point may take its name: what a zone sends there is private only where the zone encrypts it. +- **Each saved network sees an address of its own.** kryptikd gives every + network a random, locally administered unicast address when it is first + saved, or when a file an older kryptikd wrote is rewritten, and keeps it + through a new passphrase (`mac_addr=3`, `mac_value=`: wpa_supplicant sets + it before it associates). A network sees the same station each time, and + no other network, nor the radio's own address, is seen there. dhcpcd runs + RFC 7844's anonymity profile (`anonymous` in the image's + `/etc/dhcpcd.conf`): no hostname, no vendor class, and a client ID made + from the address the interface has when it asks, not a DUID made from a + hardware address, which the stock configuration would send to every + network. Left as before: a scan goes out from the address the radio has + then, its own until it first joins and the last network's after; random + scan addresses would change the interface's address at every disconnect. + A wired uplink keeps its own address. - On disk the file is plaintext inside the [encrypted](state-encryption.md) state partition, like NetworkManager's connection files. A compromised net zone learns the passphrases of the networks it was given, and nothing more. diff --git a/docs/user-guide.md b/docs/user-guide.md index cce45300..674b8334 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -270,8 +270,12 @@ passphrase; the net zone restarts and joins it, by WPA3 where the network offers it. A network with no passphrase, a café's or an airport's, is saved with `kryptik wifi add SSID --open`: anyone in range can read what crosses it unencrypted, so only what a zone encrypts itself, as HTTPS does, stays -private there. `kryptik wifi list` names the saved networks, and `kryptik -wifi forget SSID` removes one. +private there. Each saved network sees the machine by an address of its own, +made when the network was saved and kept with it, never the radio's: two +networks cannot tell they saw one machine by its address or by what it asks +for a lease. A scan, before a network is joined, goes out from whatever +address the radio has then. `kryptik wifi list` names the saved networks, +and `kryptik wifi forget SSID` removes one. **The local network.** Every zone with a network reaches the internet through the net zone. The network the machine itself is on, with its router, its diff --git a/tools/image/zones-test.sh b/tools/image/zones-test.sh index 9f8713f3..fd965bfa 100755 --- a/tools/image/zones-test.sh +++ b/tools/image/zones-test.sh @@ -69,7 +69,7 @@ for name in kernel-support policies net-ready net-dns dhcpcd-separated dnsmasq-u wifi-no-dpp \ uplink-renamed-plain uplink-state-reset \ radio-recarried \ - wifi-wpa2-plain wifi-sae wifi-open wifi-supplicant-unprivileged wifi-supplicant-no-nft \ + wifi-wpa2-plain wifi-sae wifi-open wifi-own-address wifi-address-per-network wifi-supplicant-unprivileged wifi-supplicant-no-nft \ zone-clocks-own \ time-floor-ran time-clamp time-floor-forged time-claim-stepped time-claim-floor time-claim-consent pids-limit ephemeral-size-bound cpu-max-set lifecycle-repeat lifecycle-registry resolver-after-attach dns-cache-off dns-counters-hidden net-zone-sysfs-nics-only \ terminal-terminfo man-page text-browser tls-trust no-shared-writable-mount net-zone-no-zone-data no-swap no-dbus \ From 1b7b2735d0f0ac0fffad7af2a7b746d1ae7fefa0 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 20:56:26 -0700 Subject: [PATCH 047/213] medium-shell-test.sh drives through suite-lib.sh too, with its --timeout as DRIVE_TIMEOUT --- tools/image/medium-shell-test.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/image/medium-shell-test.sh b/tools/image/medium-shell-test.sh index 5ae98ff5..96840e17 100755 --- a/tools/image/medium-shell-test.sh +++ b/tools/image/medium-shell-test.sh @@ -83,7 +83,7 @@ for ((i = 0; i < ${#MEDIA[@]}; i += 2)); do # The shell's own arithmetic and substitutions answer, never the echo of # what was typed; a getty drops its first second's input, so the first # line goes twice. - python3 "$DRV" --serial "$SER" --timeout "$TIMEOUT" \ + DRIVE_TIMEOUT="$TIMEOUT" drive \ "expect:KRYPTIK_SMOKE: END" "sleep:3" \ 'send:echo shell-$((6 * 7))' "sleep:2" 'send:echo shell-$((6 * 7))' "expect:shell-42" \ 'send:echo uid-$(id -u)' "expect:uid-0" \ @@ -130,7 +130,7 @@ for ((i = 0; i < ${#MEDIA[@]}; i += 2)); do out="$("${SELF}/run-ovmf.sh" --no-media --disk "$TARGET" --mode serve --name medium-shell-installed)" SER="$(sed -n 's/^serial=//p' <<<"$out")"; PIDF="$(sed -n 's/^pid=//p' <<<"$out")"; LOG="$(sed -n 's/^log=//p' <<<"$out")" [[ -S "$SER" ]] || die "no serial socket: ${out}" - python3 "$DRV" --serial "$SER" --timeout "$TIMEOUT" "expect:User name: " + DRIVE_TIMEOUT="$TIMEOUT" drive "expect:User name: " irc=$? stop_vm [[ "$irc" -eq 0 ]] && green "usb: that disk boots alone, unlocks with the passphrase typed at the console, and asks for its first user" \ From f1e4ec29e0a1c18e1d2cb768c3c2dbd849872183 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 20:57:46 -0700 Subject: [PATCH 048/213] The threat model says what a Wi-Fi network can no longer match the machine by --- docs/threat-model.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/threat-model.md b/docs/threat-model.md index ab63ab48..a1879495 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -52,7 +52,10 @@ already assumed hostile. *Capability: observes and modifies traffic, passively or on-path.* -**Partially defended.** Kryptik segments; it does not anonymize. A compromised +**Partially defended.** Kryptik segments; it does not anonymize. A Wi-Fi +network sees the machine by an address of its own and a DHCP client ID made +from it, never the radio's own, so two networks cannot match the machine by +those (`wifi-own-address`); what it sends can still identify it. A compromised `net` zone gets no zone's files and not `vault`. Confidentiality on the wire is the application's job. From 1ba8f22bc0e5cd60340dc0f27217edd8b31f0548 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 20:57:58 -0700 Subject: [PATCH 049/213] The acceptance report says what a delta update from A would have fetched of B: B's data blocks no block of A holds, and B's hash tree --- docs/design/delta-updates.md | 3 ++ tools/acceptance.sh | 9 ++++ tools/delta-count.py | 87 ++++++++++++++++++++++++++++++++++++ tools/tests/delta-count.sh | 76 +++++++++++++++++++++++++++++++ 4 files changed, 175 insertions(+) create mode 100755 tools/delta-count.py create mode 100755 tools/tests/delta-count.sh diff --git a/docs/design/delta-updates.md b/docs/design/delta-updates.md index d0f9fa6b..3425d985 100644 --- a/docs/design/delta-updates.md +++ b/docs/design/delta-updates.md @@ -154,6 +154,9 @@ for every release from now on. 1. Compress the payload's image for transport, verified before it is decompressed. 2. Add the delta count to the acceptance report. + Built: the update suite's `delta-count` (`tools/delta-count.py`) counts + B's data blocks that no data block of A holds, adds B's hash tree, and + the report shows the sum as "a delta update, A to B". 3. Build delta fetching once builds are reproducible and the reported deltas stay well under a fifth of the image: the plan from the committed slot, blocks checked against the authenticated tree, assembly in the stage, and diff --git a/tools/acceptance.sh b/tools/acceptance.sh index 65414333..1e2a884f 100755 --- a/tools/acceptance.sh +++ b/tools/acceptance.sh @@ -292,6 +292,10 @@ need_update() { [[ "$(printf '%s\n' "$VER_A" "$VER_B" | sort -V | tail -1)" == "$VER_B" ]] \ || echo "release A (${VER_A}) is not older than B (${VER_B}); the update test applies a newer release over an older one" } +need_payloads() { + [[ -n "$PAYLOAD_B" && -f "${PAYLOAD_B}/root.json" ]] || { echo "no payload for the release under test (${VER:-unknown})"; return; } + [[ -n "$PAYLOAD_A" && -f "${PAYLOAD_A}/root.json" ]] || echo "no payload of a release below ${VER_B} to count from" +} need_production() { local r; r="$(need_update)"; [[ -n "$r" ]] && { echo "$r"; return; } [[ -n "$PROD_B" && -f "${PROD_A}/manifest" && -f "${PROD_B}/manifest" && -f "$PROD_USB_A" ]] \ @@ -394,6 +398,8 @@ it_update() { local foreign=(); [[ -n "$PROD_B" ]] && foreign=(--foreign "$PROD_B") "${IMG}/update-test.sh" --usb-a "$MEDIA_USB_A" --payload-a "$PAYLOAD_A" --payload-b "$PAYLOAD_B" --vars clean "${foreign[@]}" } +# What a delta update from A would have fetched of B (docs/design/delta-updates.md): reported, not judged. +it_delta() { "${SELF}/delta-count.py" "$PAYLOAD_A" "$PAYLOAD_B"; } it_production() { echo "production pair:" sha256sum "$PROD_USB_A" "${PROD_A}/manifest" "${PROD_B}/manifest" "${PRODDIR}/kryptik-sb.crt" | sed 's/^/ /' @@ -453,6 +459,7 @@ item integrity integrity-test M vm 8 it_integrity need_enrolled item zones zones-test M vm 10 it_zones need_vm item desktop gui-test M vm 25 it_gui need_vm item update update-test M vm 10 it_update need_update +item update delta-count O host 0 it_delta need_payloads item production update-test-production M vm 10 it_production need_production item boot firmware-only-boot M post 0 it_firmware_only # A part of a split run leaves its boot records beside its report, for --merge. @@ -460,6 +467,7 @@ item boot firmware-only-boot M post 0 it_firmware_only # ------------------------------------------------------------- report -- KERNEL_LINE="$(grep -h -o 'KRYPTIK_SMOKE: kernel=[^ ]*' "${OUT}"/boot-media-smoke-usb.log 2>/dev/null | head -1 | sed 's/KRYPTIK_SMOKE: kernel=//')" +DELTA_LINE="$(sed -n 's/^delta: //p' "${OUT}/update-delta-count.log" 2>/dev/null | tail -1)" verdict_of() { local i fail=0 inc=0 for i in "${!R_SUITE[@]}"; do @@ -483,6 +491,7 @@ write_report() { echo "| ISO | ${MEDIA_ISO:-none}${H_ISO:+ (sha256 \`$H_ISO\`)} |" echo "| release under test | ${VER:-none} (payload B: ${PAYLOAD_B:-none}) |" echo "| update test, A to B | ${VER_A:-none} (medium ${MEDIA_USB_A:-none}; ${PAYLOAD_A:-no payload}) to ${VER_B:-none} |" + echo "| a delta update, A to B | ${DELTA_LINE:-not measured} |" echo "| kernel (as the medium reported it) | ${KERNEL_LINE:-not observed} |" echo "| firmware | ${FW}${H_FW:+ (sha256 \`$H_FW\`)}; ${FW_PKG} |" echo "| QEMU | ${QEMU_VER}; kvm=${KVM} |" diff --git a/tools/delta-count.py b/tools/delta-count.py new file mode 100755 index 00000000..47fdc0da --- /dev/null +++ b/tools/delta-count.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""What a delta update from release A would fetch of release B (docs/design/delta-updates.md). + + delta-count.py PAYLOAD_A PAYLOAD_B + +Each payload directory holds root.json and the root image, as kryptik-root.img or +kryptik-root.img.zst. A delta matches blocks by content: B's 4 KiB data blocks that no data +block of A holds would travel, and B's hash tree, which the client checks first, travels +whole. The last line, "delta: ...", is what the acceptance report shows. Exit 1 on a payload +whose image is not what its root.json describes. +""" +import hashlib +import json +import os +import subprocess +import sys + +BLOCK = 4096 +MIB = 1 << 20 + + +def meta_of(payload): + with open(os.path.join(payload, "root.json"), encoding="utf-8") as f: + meta = json.load(f) + blocks, total = int(meta["data_blocks"]), int(meta["total_bytes"]) + if blocks <= 0 or total < blocks * BLOCK: + raise ValueError(f"{payload}/root.json: {blocks} data blocks do not fit in {total} bytes") + return meta["version"], blocks, total + + +def packed_size(payload): + p = os.path.join(payload, "kryptik-root.img.zst") + return os.path.getsize(p) if os.path.isfile(p) else None + + +def digests(payload): + """Each data block's digest, in order; the tree after them is only measured. An image + shorter or longer than root.json says, or one zstd cannot unpack, is refused.""" + _, blocks, total = meta_of(payload) + plain = os.path.join(payload, "kryptik-root.img") + unpack = None + if os.path.isfile(plain): + stream = open(plain, "rb") + elif packed_size(payload) is not None: + unpack = subprocess.Popen(["zstd", "-dcq", plain + ".zst"], stdout=subprocess.PIPE) + stream = unpack.stdout + else: + raise ValueError(f"{payload} holds no kryptik-root.img or kryptik-root.img.zst") + with stream: + for _ in range(blocks): + b = stream.read(BLOCK) + if len(b) != BLOCK: + raise ValueError(f"{payload}: the image ends before root.json's {blocks} data blocks") + yield hashlib.blake2b(b, digest_size=16).digest() + rest = 0 + while chunk := stream.read(MIB): + rest += len(chunk) + if unpack is not None and unpack.wait() != 0: + raise ValueError(f"{payload}: zstd could not unpack kryptik-root.img.zst") + if blocks * BLOCK + rest != total: + raise ValueError(f"{payload}: the image is {blocks * BLOCK + rest} bytes, and root.json says {total}") + + +def main(argv): + if len(argv) != 3: + print(__doc__.strip(), file=sys.stderr) + return 2 + a, b = argv[1:] + try: + (va, na, _), (vb, nb, total) = meta_of(a), meta_of(b) + have = set(digests(a)) + found = sum(d in have for d in digests(b)) + except (OSError, ValueError, KeyError, json.JSONDecodeError) as e: + print(f"delta-count: {e}", file=sys.stderr) + return 1 + tree = total - nb * BLOCK + fetch = (nb - found) * BLOCK + tree + print(f"delta-count: A {va}: {na} data blocks; B {vb}: {nb} data blocks and a hash tree of {tree} bytes") + print(f"delta-count: of B's data blocks {found} are in A and {nb - found} are not") + packed = packed_size(b) + whole = f"; its kryptik-root.img.zst is {packed / MIB:.0f} MiB" if packed is not None else "" + print(f"delta: {fetch / MIB:.0f} MiB of B's {total / MIB:.0f} MiB image from A ({100 * fetch / total:.0f}%){whole}") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/tools/tests/delta-count.sh b/tools/tests/delta-count.sh new file mode 100755 index 00000000..06f1179b --- /dev/null +++ b/tools/tests/delta-count.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# Tests for tools/delta-count.py: two small payloads whose blocks are known, the second also +# zstd'd as stage 06 may ship it, and payloads whose image is not what root.json says. +set -uo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +TOOL="${ROOT}/tools/delta-count.py" + +PASS=0; FAIL=0 +green() { printf ' PASS %s\n' "$1"; PASS=$((PASS + 1)); } +red() { printf ' FAIL %s\n' "$1"; FAIL=$((FAIL + 1)); [[ $# -gt 1 ]] && printf ' %s\n' "$2"; } +T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT + +# payload DIR VERSION TREE_BLOCKS CHAR...: one 4 KiB data block of each CHAR (0 for zeros), then the tree. +payload() { + local d="$1" v="$2" tree="$3" c n=0; shift 3 + mkdir -p "$d"; : > "$d/kryptik-root.img" + for c in "$@"; do + if [[ "$c" == 0 ]]; then head -c 4096 /dev/zero; else head -c 4096 /dev/zero | tr '\0' "$c"; fi >> "$d/kryptik-root.img" + n=$((n + 1)) + done + head -c $((tree * 4096)) /dev/zero | tr '\0' t >> "$d/kryptik-root.img" + printf '{\n "version": "%s",\n "data_blocks": %d,\n "hash_start_block": %d,\n "total_bytes": %d\n}\n' \ + "$v" "$n" "$n" "$(stat -c %s "$d/kryptik-root.img")" > "$d/root.json" +} +run() { python3 "$TOOL" "$@" > "$T/out" 2>&1; echo $?; } + +payload "$T/a" 1.0.0 1 x y z 0 +payload "$T/b" 1.0.1 2 y w 0 x v +rc="$(run "$T/a" "$T/b")" +if [[ "$rc" == 0 ]] && grep -qx "delta-count: of B's data blocks 3 are in A and 2 are not" "$T/out"; then + green "y, x and the zeros are found in A wherever they lie, and w and v are not" +else + red "the count of B's blocks" "rc ${rc}: $(tr '\n' '|' < "$T/out")" +fi +grep -q '^delta: .* MiB image from A' "$T/out" && grep -qF 'and a hash tree of 8192 bytes' "$T/out" \ + && green "the last line is the report's, and the tree is counted whole" \ + || red "the delta line or the tree" "$(tr '\n' '|' < "$T/out")" + +if command -v zstd >/dev/null 2>&1; then + zstd -q -19 "$T/b/kryptik-root.img" -o "$T/b/kryptik-root.img.zst" && rm "$T/b/kryptik-root.img" + rc="$(run "$T/a" "$T/b")" + if [[ "$rc" == 0 ]] && grep -qx "delta-count: of B's data blocks 3 are in A and 2 are not" "$T/out" \ + && grep -q 'its kryptik-root.img.zst is [0-9]* MiB$' "$T/out"; then + green "a payload that ships the image zstd'd is read through zstd, and its size is named" + else + red "the zstd'd payload" "rc ${rc}: $(tr '\n' '|' < "$T/out")" + fi + printf 'not zstd' > "$T/b/kryptik-root.img.zst" + rc="$(run "$T/a" "$T/b")" + [[ "$rc" == 1 ]] && grep -q "^delta-count: " "$T/out" && green "a .zst zstd cannot unpack is refused" \ + || red "a damaged .zst" "rc ${rc}: $(tr '\n' '|' < "$T/out")" +else + echo " SKIP no zstd: the zstd'd payload" +fi + +# Images that are not what their root.json says. +payload "$T/short" 1.0.2 0 x y +sed -i -e 's/"data_blocks": 2/"data_blocks": 3/' -e 's/"total_bytes": 8192/"total_bytes": 12288/' "$T/short/root.json" +rc="$(run "$T/a" "$T/short")" +[[ "$rc" == 1 ]] && grep -qF "the image ends before root.json's 3 data blocks" "$T/out" \ + && green "an image shorter than its data blocks is refused" || red "a short image" "rc ${rc}: $(tr '\n' '|' < "$T/out")" +payload "$T/long" 1.0.3 1 x y +printf 'extra' >> "$T/long/kryptik-root.img" +rc="$(run "$T/long" "$T/b")" +[[ "$rc" == 1 ]] && grep -qF "and root.json says" "$T/out" \ + && green "an image longer than root.json's total is refused, A as well as B" || red "a long image" "rc ${rc}: $(tr '\n' '|' < "$T/out")" +mkdir -p "$T/none"; cp "$T/a/root.json" "$T/none/" +rc="$(run "$T/a" "$T/none")" +[[ "$rc" == 1 ]] && grep -qF "holds no kryptik-root.img" "$T/out" && green "a payload with no image is refused" \ + || red "no image" "rc ${rc}: $(tr '\n' '|' < "$T/out")" +rc="$(run "$T/a")" +[[ "$rc" == 2 ]] && green "one payload is a usage error" || red "usage" "rc ${rc}" + +echo +echo "${PASS} passed, ${FAIL} failed" +[[ "$FAIL" -eq 0 ]] From 624cd5be1d03a209f54f17d0bf1e17c68657585b Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 20:58:54 -0700 Subject: [PATCH 050/213] dns-cache-per-zone runs once personal's volume exists, which it needs to start personal --- build/guest-tests/zones-check.sh | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index 54ff20e4..5780ea39 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -291,10 +291,18 @@ if [[ "$direct" == "DIRECT-OTHER none DIRECT-OWN none " ]]; then else fail "dns-own-port-closed" "${direct:-no output} $(tail -1 "$LOG/untrusted.err")" fi -# Each zone's cache is its own. A stand-in upstream on 127.0.0.53 in the net -# zone answers every name with one address for 300 s and prints each name that -# reaches it: one untrusted asks twice reaches it once, and asked from personal, -# once more. One cache for all zones would let it through once in all; none, three times. + +# The vault is probed under storage, once its volume exists. + +# --- zones: separation between routed zones; fail-closed on a net restart --- +printf 'personal-pass\n' > /root/zt/personal.pass; chmod 600 /root/zt/personal.pass +"$KD" volume init personal --size 64M --passphrase-file /root/zt/personal.pass > "$LOG/vol-personal.out" 2>&1 \ + && pass "volume-init" "personal: $(tail -1 "$LOG/vol-personal.out")" || fail "volume-init" "$(tail -2 "$LOG/vol-personal.out" | tr '\n' ' ')" +# Each zone's cache is its own, checked here, once personal's volume exists. A +# stand-in upstream on 127.0.0.53 in the net zone answers every name with one +# address for 300 s and prints each name that reaches it: one untrusted asks +# twice reaches it once, and asked from personal, once more. One cache for all +# zones would let it through once in all; none, three times. stub='import socket, struct s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.bind(("127.0.0.53", 53)) while True: @@ -341,13 +349,6 @@ if [[ "$switched" == yes && "$per_zone" == "untrusted: PROBE 1 1; personal: PROB else fail "dns-cache-per-zone" "the upstream switched: ${switched}; ${per_zone:-no zone asked}; reached it: ${probes:-0} times; $(tail -2 "$LOG/dns-stub.err" 2>/dev/null | tr '\n' ' ')" fi - -# The vault is probed under storage, once its volume exists. - -# --- zones: separation between routed zones; fail-closed on a net restart --- -printf 'personal-pass\n' > /root/zt/personal.pass; chmod 600 /root/zt/personal.pass -"$KD" volume init personal --size 64M --passphrase-file /root/zt/personal.pass > "$LOG/vol-personal.out" 2>&1 \ - && pass "volume-init" "personal: $(tail -1 "$LOG/vol-personal.out")" || fail "volume-init" "$(tail -2 "$LOG/vol-personal.out" | tr '\n' ' ')" # A zone that sends from another zone's address. The rule that opens the # uplink's own network goes by a packet's source, and IPV6_FREEBIND lets an # unprivileged socket send from an address its host does not hold. personal From 743631c161a3c1b727c37e223ceb90ced111cf8f Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 21:04:46 -0700 Subject: [PATCH 051/213] The desktop's menu lists, adds and forgets Wi-Fi networks, with the passphrase typed in its own window A Wi-Fi network could be saved only with kryptik wifi at the administration login (Ctrl+Alt+F2), so joining one from the desktop meant leaving it. The chrome's menu now has w: it lists the networks the launch daemon knows, adds one or changes its passphrase, which kryptik-launch reads unechoed in the menu's own zone 0 window, and forgets one, through the verbs kryptik wifi already uses. chrome-wifi-add: the desktop suite opens a menu window with Alt+p, and the host types w, a, an SSID and a passphrase as the guest asks for each; the network and its passphrase must reach the net zone's file. It runs last, as the add restarts the net zone. --- build/guest-tests/gui-check.sh | 26 +++++++++++++++++++++++++ docs/user-guide.md | 6 ++++-- tools/desktop/kryptik-chrome | 35 +++++++++++++++++++++++++++++++--- tools/image/gui-test.sh | 5 ++++- 4 files changed, 66 insertions(+), 6 deletions(-) diff --git a/build/guest-tests/gui-check.sh b/build/guest-tests/gui-check.sh index c99af30c..7b4684bb 100755 --- a/build/guest-tests/gui-check.sh +++ b/build/guest-tests/gui-check.sh @@ -18,6 +18,8 @@ # GT KEY-FOCUS-HEAD press Alt+period (the next monitor) # GT KEY-FOCUS-HEAD-WINDOW press Alt+j # GT SCREENSHOT-HEAD take a screenshot of the second monitor +# GT KEY-MENU-WIFI press Alt+p (a menu window for Wi-Fi) +# GT WIFI-TYPE N TEXT type TEXT and Enter (letters and digits) # GT END # Verdicts: "GT PASS|FAIL|INFO name - detail". set -u @@ -658,6 +660,30 @@ wait_for 20 back_on_first_head && pass "chrome-back-on-first-head" "$(tr '\n' ' || fail "chrome-back-on-first-head" "focus: $(tr '\n' ' ' < "$RT/kryptik/focus" 2>/dev/null)" stop_zone untrusted +# --- Wi-Fi from the menu ---------------------------------------------------------------- +# A network saved from the chrome's own window, as `kryptik wifi add` saves one at the +# administration login: the host types each answer once it is asked for, and the passphrase, +# read unechoed by kryptik-launch in that window, reaches the net zone's file. +WCONF=/var/lib/kryptik/wifi/wpa_supplicant.conf +menus_before="$(menu_windows)" +echo "GT KEY-MENU-WIFI" +wifi_typed=no +if wait_for 20 more_menus; then + sleep 2; echo "GT WIFI-TYPE 1 w" + sleep 3; echo "GT WIFI-TYPE 2 a" + sleep 2; echo "GT WIFI-TYPE 3 ztmenunet" + sleep 2; echo "GT WIFI-TYPE 4 menupassphrase1" + wifi_typed=yes +fi +if [[ "$wifi_typed" == yes ]] && wait_for 30 grep -qx $'\tssid="ztmenunet"' "$WCONF" \ + && grep -qx $'\tpsk="menupassphrase1"' "$WCONF"; then + pass "chrome-wifi-add" "the menu's w, then a, saved ztmenunet, its passphrase typed in the chrome's own window" +else + fail "chrome-wifi-add" "typed: ${wifi_typed}; networks in the file: $(grep -c '^network=' "$WCONF" 2>/dev/null); menu windows: $(menu_windows)" +fi +pkill -n -u "$USER_NAME" -f 'havoc /usr/bin/kryptik-chrome --menu' 2>/dev/null +"$KD" wifi forget ztmenunet --wifi-dir /var/lib/kryptik/wifi > /dev/null 2>&1 + # --- teardown ------------------------------------------------------------------------------ # The runtime tmpfs does not survive power-off: copy the session log and the # last focus record to the state partition (p4, log/kryptik/) for a post-mortem. diff --git a/docs/user-guide.md b/docs/user-guide.md index 31639a08..47600d1e 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -229,7 +229,7 @@ pointer moving onto it moves the keyboard there. Keys (Alt is the modifier): | keys | what | | --- | --- | -| Alt+p | the chrome menu: zones, their applications, stop a zone, move a clipboard between zones, and with f the zone of the window you last had | +| Alt+p | the chrome menu: zones, their applications, stop a zone, move a clipboard between zones, the Wi-Fi networks with w, and with f the zone of the window you last had | | Alt+Shift+Return | a terminal (havoc) in the work zone | | Alt+Shift+p | a terminal in the personal zone | | Alt+Shift+u | the browser (lynx) in the untrusted zone | @@ -248,7 +248,9 @@ in the menu's window or, for a key, in a small prompt window; the passphrase never appears on a command line. The menu also names a release that has arrived and waits to be installed, and says when the newest update statement is more than 30 days old, or, with none ever accepted, when the install is -(section 4). +(section 4). With `w` it lists the Wi-Fi networks the net zone may join, adds +one, asking for its passphrase in the menu's own window, or forgets one, as +`kryptik wifi` does at the administration login. For administration, Ctrl+Alt+F2 gives a text login: log in as the user, and `su` becomes root. There `kryptik list`, `kryptik status [ZONE]`, `kryptik diff --git a/tools/desktop/kryptik-chrome b/tools/desktop/kryptik-chrome index 81117c8e..dcf4ead5 100755 --- a/tools/desktop/kryptik-chrome +++ b/tools/desktop/kryptik-chrome @@ -15,8 +15,9 @@ # focus as text (and the last zone window's # in focus.zone), and opens the launcher # kryptik-chrome --menu the launcher: zones, their state, apps, -# a release that waits to be installed, and -# a channel with nothing new for 30 days +# a release that waits to be installed, a +# channel with nothing new for 30 days, and +# the net zone's Wi-Fi networks (w) # kryptik-chrome --prompt ZONE [--no-display] -- CMD [ARG...] # ask for the zone's passphrase in a trusted # window, then launch (kryptik-launch hands @@ -176,13 +177,14 @@ run_menu() { echo echo "Apps: t terminal e editor (nano) b browser (lynx, needs routed network)" echo "Also: s stop zone N m move zone N's clipboard to zone M (one payload, once)" - echo " f the last zone window's zone r refresh q close this window" + echo " w Wi-Fi networks f the last zone window's zone r refresh q close this window" echo printf 'choice (e.g. 1t): ' if ! read -r choice; then break; fi case "$choice" in q) break ;; r|"") continue ;; + w) run_wifi; continue ;; f) if [ -r "$FOCUS_ZONE" ]; then printf 'The last zone window you had: %s %s (zone %s)\n %s\n' \ "$(sed -n 's/^glyph=//p' "$FOCUS_ZONE")" "$(sed -n 's/^label=//p' "$FOCUS_ZONE")" \ @@ -216,6 +218,33 @@ run_menu() { done } +# --- Wi-Fi ---------------------------------------------------------------------- + +# The net zone's saved networks, through the launch daemon, as `kryptik wifi` has +# them at the administration login: kryptik-launch reads a passphrase here, +# unechoed, and the net zone restarts to join. +run_wifi() { + while :; do + clear 2>/dev/null + echo "================ KRYPTIK - Wi-Fi networks (zone 0) ================" + echo "Saved networks:" + "$LAUNCH" --wifi-list 2>&1 | sed 's/^/ /' + echo + echo "a add a network, or change its passphrase f forget one Enter back" + printf 'choice: ' + read -r c || return 0 + case "$c" in + "") return 0 ;; + a|f) printf 'network name (SSID): ' + read -r ssid || return 0 + [ -n "$ssid" ] || continue + if [ "$c" = a ]; then "$LAUNCH" --wifi-add "$ssid"; else "$LAUNCH" --wifi-forget "$ssid"; fi + sleep 3 ;; + *) echo "?"; sleep 1 ;; + esac + done +} + # --- the prompt ----------------------------------------------------------------- run_prompt() { # ZONE [--no-display] -- CMD... diff --git a/tools/image/gui-test.sh b/tools/image/gui-test.sh index 3a882084..b07a1da4 100755 --- a/tools/image/gui-test.sh +++ b/tools/image/gui-test.sh @@ -95,6 +95,9 @@ python3 "$DRV" --serial "$SER" --qmp "$QMP" --timeout 600 \ "expect:GT KEY-FOCUS-HEAD-WINDOW" "key:alt+j" \ "expect:GT SCREENSHOT-HEAD" "sleep:2" "screendump-head:1:${SHOT_HEAD}" \ "expect:GT HEAD-OFF" "head:${HEAD2}:0x0" \ + "expect:GT KEY-MENU-WIFI" "key:alt+p" \ + "type-from:GT WIFI-TYPE 1 ([a-z0-9]+)" "type-from:GT WIFI-TYPE 2 ([a-z0-9]+)" \ + "type-from:GT WIFI-TYPE 3 ([a-z0-9]+)" "type-from:GT WIFI-TYPE 4 ([a-z0-9]+)" \ "expect:GT END" "expect:GCHECK-DONE" \ "send:su - root -c 'poweroff'" "expect:Password: ?" "send:${RPASS}" \ "expect:Power down" "wait-exit" @@ -110,7 +113,7 @@ grep 'GT FAIL' <<<"$T" | sed 's/^/ /' for name in session-socket compositor-running chrome-focus-record chrome-window-is-zone0 zone0-sees-capture zone-proxy-path zone-sees-needed zone-hidden-globals zone-bind-refused proxy-logged-refusal \ map-keeps-zone0-focus close-keeps-zone0-focus alone-keeps-no-keyboard alone-reached-by-key focus-shows-zone focus-shows-label title-prefixed last-zone-recorded menu-opens-on-key menu-keeps-last-zone menu-names-stale-statement menu-names-install-age zone-fullscreen-refused compositor-survives-close oversize-window forged-title-named-by-zone second-zone-window zone0-own-programs-only zone-app-in-cgroup no-virtual-input clipboard-isolated clipboard-move-gesture clipboard-moved \ transfer-policy no-question-for-policy-refusal consent-code-shown transfer-approved transfer-landed plain-y-refused denied-file-absent \ - second-head-appears chrome-follows-head second-head-zone-window second-head-names-zone second-head-gone compositor-survives-unplug zone-survives-unplug chrome-back-on-first-head \ + second-head-appears chrome-follows-head second-head-zone-window second-head-names-zone second-head-gone compositor-survives-unplug zone-survives-unplug chrome-back-on-first-head chrome-wifi-add \ zone0-cursor-set zone0-cursor-shown zone-cursor-asked zone-hears-of-outputs zone-cursor-not-shown compositor-socket-unreached \ zone0-frame-clock zone-window-animates \ zone0-fullscreen-granted zone0-fullscreen-needs-focus fullscreen-by-key zone-child-mapped child-focused parent-focused parent-fullscreen fullscreen-keeps-focus keyboard-stays-on-fullscreen zoom-keeps-keyboard zoom-twice-keeps-keyboard zone0-over-fullscreen-gets-keyboard zone0-window-ends-fullscreen child-ends-fullscreen; do From a831175dffcde350fd0d515a0e856c329b8e6f63 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 21:05:07 -0700 Subject: [PATCH 052/213] The reproducibility design's manifest lines are left to the change that rewrites that section, so the two land in either order --- docs/design/reproducible-builds.md | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/docs/design/reproducible-builds.md b/docs/design/reproducible-builds.md index c7417f61..c68bd29b 100644 --- a/docs/design/reproducible-builds.md +++ b/docs/design/reproducible-builds.md @@ -63,9 +63,6 @@ never see the host's paths. Stage 06 and the Rust binaries do. the directories it creates. Their contents are fixed by the inputs except `built_at`, which is `date -Iseconds`, and the default version, `0.1..`. Their times are now; mke2fs 1.47.1 clamps them. -- **The update manifest's `created:` line**, written by - `tools/release-manifest.sh create` from `s_payload`, is the second it ran, - unless `SOURCE_DATE_EPOCH` is set: then it is that second, in UTC. The fix is one `SOURCE_DATE_EPOCH`, the commit's time, exported for the whole build, with `built_at` written from it in UTC, and the same @@ -78,9 +75,7 @@ there, the floor becomes the time of the commit rather than of the build: earlier by hours for a dated build, by days at most for a release tagged after its commit. A floor may be early and never late, so every rule in the time design still holds. The parser takes the `+00:00` offset that -`date -u -Iseconds` prints. The manifest's signed `created:` date, the -floor once its release is committed (`release_in` in `time.rs`), moves -earlier the same way. +`date -u -Iseconds` prints. ### Stage 05's kernel From 609da3d62cb1445e745c4abae7951d21db8de978 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 21:05:14 -0700 Subject: [PATCH 053/213] The guide and the design say what a shared network name and a driver refusing the address mean --- docs/design/net-zone.md | 5 ++++- docs/user-guide.md | 5 +++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/design/net-zone.md b/docs/design/net-zone.md index c4a61069..aac2224b 100755 --- a/docs/design/net-zone.md +++ b/docs/design/net-zone.md @@ -330,7 +330,10 @@ configured the zone keeps its IPv4 path and the launcher says so. network. Left as before: a scan goes out from the address the radio has then, its own until it first joins and the last network's after; random scan addresses would change the interface's address at every disconnect. - A wired uplink keeps its own address. + A wired uplink keeps its own address. A driver that refuses to change the + interface's address never joins: wpa_supplicant gives up the association + rather than use the radio's own, and the status line stays at + `wifi=connecting`. - On disk the file is plaintext inside the [encrypted](state-encryption.md) state partition, like NetworkManager's connection files. A compromised net zone learns the passphrases of the networks it was given, and nothing more. diff --git a/docs/user-guide.md b/docs/user-guide.md index 674b8334..0a076049 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -273,8 +273,9 @@ unencrypted, so only what a zone encrypts itself, as HTTPS does, stays private there. Each saved network sees the machine by an address of its own, made when the network was saved and kept with it, never the radio's: two networks cannot tell they saw one machine by its address or by what it asks -for a lease. A scan, before a network is joined, goes out from whatever -address the radio has then. `kryptik wifi list` names the saved networks, +for a lease. Networks that share a name, a chain's cafés for one, are one +saved network and see one address. A scan, before a network is joined, goes +out from whatever address the radio has then. `kryptik wifi list` names the saved networks, and `kryptik wifi forget SSID` removes one. **The local network.** Every zone with a network reaches the internet through From 95dd95e2a6aa2cd90323f7eca563a0fc13852c2e Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 21:05:54 -0700 Subject: [PATCH 054/213] The menu keeps an SSID's spaces as typed, and names an empty list --- tools/desktop/kryptik-chrome | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tools/desktop/kryptik-chrome b/tools/desktop/kryptik-chrome index dcf4ead5..2f056df4 100755 --- a/tools/desktop/kryptik-chrome +++ b/tools/desktop/kryptik-chrome @@ -228,15 +228,17 @@ run_wifi() { clear 2>/dev/null echo "================ KRYPTIK - Wi-Fi networks (zone 0) ================" echo "Saved networks:" - "$LAUNCH" --wifi-list 2>&1 | sed 's/^/ /' + nets="$("$LAUNCH" --wifi-list 2>&1)" + printf '%s\n' "${nets:-none}" | sed 's/^/ /' echo echo "a add a network, or change its passphrase f forget one Enter back" printf 'choice: ' read -r c || return 0 case "$c" in "") return 0 ;; + # IFS=: an SSID may begin or end with a space. a|f) printf 'network name (SSID): ' - read -r ssid || return 0 + IFS= read -r ssid || return 0 [ -n "$ssid" ] || continue if [ "$c" = a ]; then "$LAUNCH" --wifi-add "$ssid"; else "$LAUNCH" --wifi-forget "$ssid"; fi sleep 3 ;; From 58230859ab4873beb1354e0976551e5cd3f83c46 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 21:06:14 -0700 Subject: [PATCH 055/213] A tree without .git takes the Makefile's time, and a build with no SOURCE_DATE_EPOCH skips the second mke2fs --- Makefile | 3 ++- build/stages/06-iso.sh | 12 ++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index f4337ea3..83117872 100644 --- a/Makefile +++ b/Makefile @@ -26,7 +26,8 @@ KRYPTIK_STALE ?= refuse # Stamped into /etc/os-release; --dirty so an image never claims a clean tree. KRYPTIK_BUILD_COMMIT ?= $(shell git -C "$(ROOT)" describe --always --dirty --abbrev=40 2>/dev/null || echo unknown) # The time every timestamp the build writes is set or clamped to: the commit's (docs/design/reproducible-builds.md). -SOURCE_DATE_EPOCH ?= $(shell git -C "$(ROOT)" log -1 --format=%ct 2>/dev/null) +# Without .git, as in the source bundle, the Makefile's time is the commit's: git archive stamps every member with it. +SOURCE_DATE_EPOCH ?= $(shell git -C "$(ROOT)" log -1 --format=%ct 2>/dev/null || stat -c %Y "$(ROOT)/Makefile" 2>/dev/null) # Never exported empty: GCC refuses a SOURCE_DATE_EPOCH that is not a number. ifneq ($(SOURCE_DATE_EPOCH),) export SOURCE_DATE_EPOCH diff --git a/build/stages/06-iso.sh b/build/stages/06-iso.sh index 8b6ef8c7..cfa2f17a 100755 --- a/build/stages/06-iso.sh +++ b/build/stages/06-iso.sh @@ -215,10 +215,14 @@ EOF id="$(printf 'kryptik-root %s %s' "$version" "$COMMIT" | sha256_of_stdin)" id="${id:0:8}-${id:8:4}-${id:12:4}-${id:16:4}-${id:20:12}" make_ext4 "$stage" "$img" "$fs_bytes" "$id" - make_ext4 "$stage" "${img}.again" "$fs_bytes" "$id" - cmp -s "$img" "${img}.again" || { echo "FAIL: two mke2fs runs over one tree wrote different images"; return 1; } - rm -f "${img}.again" - echo "ok: the same tree makes the same image" + if [[ -n "${SOURCE_DATE_EPOCH:-}" ]]; then + make_ext4 "$stage" "${img}.again" "$fs_bytes" "$id" + cmp -s "$img" "${img}.again" || { echo "FAIL: two mke2fs runs over one tree wrote different images"; return 1; } + rm -f "${img}.again" + echo "ok: the same tree makes the same image" + else + echo "no SOURCE_DATE_EPOCH: the image carries this build's times and is not reproducible" + fi dumpe2fs -h "$img" 2>/dev/null | grep -E 'Filesystem features|Block count|Free blocks' echo "--- dm-verity hash tree, appended ---" From cae09d674de30122f686f8d2d3dd9a59734b1e69 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 21:10:04 -0700 Subject: [PATCH 056/213] The guide says where macOS and Windows get zstd for the compressed media, and how Windows reads the Secure Boot certificate's fingerprint --- docs/user-guide.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/user-guide.md b/docs/user-guide.md index 31639a08..0780ea26 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -44,10 +44,15 @@ you took. `SHA256SUMS` describes this directory as the acceptance run wrote it, so `sha256sum -c SHA256SUMS` checks every other file there, and does not fit a download from the page. -On macOS the hash check is `shasum -a 256 -c --ignore-missing`. On Windows, -run the `ssh-keygen` line in Command Prompt, on one line (PowerShell has no -`<`), and compare what `certutil -hashfile kryptik-VERSION-usb.img SHA256` -prints with the image's line in `kryptik-VERSION.SHA256SUMS`. +Neither macOS nor Windows comes with `zstd`. On macOS, `brew install zstd` +(Homebrew) adds it, and the hash check is `shasum -a 256 -c --ignore-missing`. +On Windows, the zstd project's own build (`zstd-vX.Y.Z-win64.zip` from +github.com/facebook/zstd/releases) runs `zstd -d` as above. Run the +`ssh-keygen` line in Command Prompt, on one line (PowerShell has no `<`), and +compare what `certutil -hashfile kryptik-VERSION-usb.img SHA256` prints with +the image's line in `kryptik-VERSION.SHA256SUMS`. `certutil -hashfile +kryptik-sb.der SHA256` prints the certificate's SHA-256 fingerprint: the hex +`openssl` shows, in lower case and without the colons. The signature is only as good as the `release-signers` it is checked with, and whoever could change the download could change that file too. From 1.0.0 From f7c86ac4ac9a52e403dda1a880d05f4b58dd4440 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 21:10:23 -0700 Subject: [PATCH 057/213] The accepted list names each object without a canary or with an unchecked printf, with its reason Run 38199064800 on the recording head stripped 1301 objects and found 78 without a canary and 41 importing a call FORTIFY never leaves plain. The two wildcards give way to those objects by name: functions with nothing for -fstack-protector-strong to guard, glibc's stubs and loader, libgcc, the Rust daemons; glibc's unfortified programs, and the programs that pass fprintf or vfprintf as a value (gawk, gettext's libxml, objdump and libbfd, GMP's printf tables). bison's one plain fprintf import is listed as not yet traced. A new object of either kind now fails the build. --- build/config/artifact-accepted.txt | 111 ++++++++++++++++++++++++++++- build/stages/06-iso.sh | 5 +- 2 files changed, 110 insertions(+), 6 deletions(-) diff --git a/build/config/artifact-accepted.txt b/build/config/artifact-accepted.txt index a7be84ab..9bea75df 100644 --- a/build/config/artifact-accepted.txt +++ b/build/config/artifact-accepted.txt @@ -21,6 +21,111 @@ RPATH usr/lib/gcc/*/*/plugin/libc[cp]1plugin.so.* /usr/lib/../lib # the sam RPATH usr/lib/libskarnet.so.* /usr/lib # skalibs' own link line RPATH usr/lib/perl5/*/auto/Time/HiRes/HiRes.so /usr/lib # Time::HiRes, linking librt -# Every object without a canary or calling an unchecked printf, recorded once for the named entries that replace these two. -NO-SSP * # recorded for the list -NO-FORTIFY * # recorded for the list +# No canary anywhere in the object's code. -fstack-protector-strong (in CFLAGS, and GCC's default +# here) guards only functions with a local array or an address-taken local; these have none. +NO-SSP usr/bin/bzip2recover # no function with a local array or an address-taken local +NO-SSP usr/bin/exch # the same +NO-SSP usr/bin/getconf # the same +NO-SSP usr/libexec/getconf/* # getconf again, under the names of the specifications it reports +NO-SSP usr/bin/nl-link-enslave # no function with a local array or an address-taken local +NO-SSP usr/bin/nl-link-name2ifindex # the same +NO-SSP usr/bin/nl-link-release # the same +NO-SSP usr/bin/rev # the same +NO-SSP usr/bin/s6-linux-init-nuke # the same +NO-SSP usr/lib/bash/basename # the same +NO-SSP usr/lib/bash/csv # the same +NO-SSP usr/lib/bash/dirname # the same +NO-SSP usr/lib/bash/dsv # the same +NO-SSP usr/lib/bash/getconf # the same +NO-SSP usr/lib/bash/head # the same +NO-SSP usr/lib/bash/logname # the same +NO-SSP usr/lib/bash/mkfifo # the same +NO-SSP usr/lib/bash/mypid # the same +NO-SSP usr/lib/bash/printenv # the same +NO-SSP usr/lib/bash/push # the same +NO-SSP usr/lib/bash/rmdir # the same +NO-SSP usr/lib/bash/sync # the same +NO-SSP usr/lib/bash/tee # the same +NO-SSP usr/lib/bash/truefalse # the same +NO-SSP usr/lib/bash/tty # the same +NO-SSP usr/lib/bash/unlink # the same +NO-SSP usr/lib/bash/whoami # the same +NO-SSP usr/lib/gawk/revtwoway.so # the same +NO-SSP usr/libexec/awk/grcat # the same +NO-SSP usr/libexec/awk/pwcat # the same +NO-SSP usr/lib/libfl.so.2.* # the same +NO-SSP usr/lib/libmvec.so.1 # the same +NO-SSP usr/lib/libpanelw.so.6.* # the same +NO-SSP usr/lib/libwayland-egl.so.1.* # the same +NO-SSP usr/lib/gconv/libCNS.so # glibc's conversion tables, with no function that takes a buffer +NO-SSP usr/lib/gconv/libGB.so # the same +NO-SSP usr/lib/gconv/libISOIR165.so # the same +NO-SSP usr/lib/gconv/libJIS.so # the same +NO-SSP usr/lib/gconv/libJISX0213.so # the same +NO-SSP usr/lib/gconv/libKSC.so # the same +NO-SSP usr/lib/perl5/*/auto/Devel/Peek/Peek.so # no function with a local array or an address-taken local +NO-SSP usr/lib/perl5/*/auto/Encode/Byte/Byte.so # Encode's conversion tables, with no function that takes a buffer +NO-SSP usr/lib/perl5/*/auto/Encode/CN/CN.so # the same +NO-SSP usr/lib/perl5/*/auto/Encode/EBCDIC/EBCDIC.so # the same +NO-SSP usr/lib/perl5/*/auto/Encode/JP/JP.so # the same +NO-SSP usr/lib/perl5/*/auto/Encode/KR/KR.so # the same +NO-SSP usr/lib/perl5/*/auto/Encode/Symbol/Symbol.so # the same +NO-SSP usr/lib/perl5/*/auto/Encode/TW/TW.so # the same +NO-SSP usr/lib/perl5/*/auto/Fcntl/Fcntl.so # no function with a local array or an address-taken local +NO-SSP usr/lib/perl5/*/auto/File/DosGlob/DosGlob.so # the same +NO-SSP usr/lib/perl5/*/auto/Filter/Util/Call/Call.so # the same +NO-SSP usr/lib/perl5/*/auto/I18N/Langinfo/Langinfo.so # the same +NO-SSP usr/lib/python3.*/lib-dynload/_contextvars.cpython-*.so # the same +NO-SSP usr/lib/python3.*/lib-dynload/_heapq.cpython-*.so # the same +NO-SSP usr/lib/python3.*/lib-dynload/_statistics.cpython-*.so # the same +NO-SSP usr/lib/python3.*/lib-dynload/_testimportmultiple.cpython-*.so # the same +NO-SSP usr/sbin/accessdb # the same +NO-SSP usr/sbin/findfs # the same +NO-SSP usr/sbin/nologin # the same +NO-SSP usr/sbin/pivot_root # the same +NO-SSP usr/bin/perl # its main() hands everything to libperl, which carries the canaries +NO-SSP usr/bin/perl5.* # the same +NO-SSP usr/bin/python3.* # its main() hands everything to libpython, which carries the canaries +NO-SSP usr/lib/libpython3.so # the stable-ABI library forwards to libpython and has no code of its own +NO-SSP usr/lib/engines-3/capi.so # OpenSSL's CAPI engine wraps a Windows API: on Linux it builds empty +NO-SSP usr/lib/libanl.so.1 # glibc 2.34 moved this library's code into libc, leaving an empty stub +NO-SSP usr/lib/libdl.so.2 # the same +NO-SSP usr/lib/libpthread.so.0 # the same +NO-SSP usr/lib/librt.so.1 # the same +NO-SSP usr/lib/libutil.so.1 # the same +NO-SSP usr/lib/libnss_dns.so.2 # the same +NO-SSP usr/lib/libnss_files.so.2 # the same +NO-SSP usr/lib/ld-linux-x86-64.so.2 # glibc builds the loader without the stack protector: the loader sets up the guard +NO-SSP usr/lib/libgcc_s.so.1 # GCC builds libgcc with -fno-stack-protector: it runs beneath any stack guard +NO-SSP usr/bin/kryptikd # Rust: stable rustc has no stack protector, and safe Rust checks every index +NO-SSP usr/bin/kryptik-wlproxy # the same + +# Imports of calls that _FORTIFY_SOURCE=2 or 3 always turns into _chk ones. +NO-FORTIFY usr/bin/gencat # glibc's build leaves FORTIFY out of its own code (hardening-exceptions.txt) +NO-FORTIFY usr/bin/getconf # the same +NO-FORTIFY usr/libexec/getconf/* # the same +NO-FORTIFY usr/bin/getent # the same +NO-FORTIFY usr/bin/iconv # the same +NO-FORTIFY usr/bin/locale # the same +NO-FORTIFY usr/bin/localedef # the same +NO-FORTIFY usr/bin/makedb # the same +NO-FORTIFY usr/bin/pcprofiledump # the same +NO-FORTIFY usr/bin/pldd # the same +NO-FORTIFY usr/bin/sprof # the same +NO-FORTIFY usr/bin/zdump # the same +NO-FORTIFY usr/sbin/iconvconfig # the same +NO-FORTIFY usr/sbin/zic # the same +NO-FORTIFY usr/lib/audit/sotruss-lib.so # the same +NO-FORTIFY usr/lib/libc_malloc_debug.so.0 # the same +NO-FORTIFY usr/lib/libmemusage.so # the same +NO-FORTIFY usr/lib/libnsl.so.1 # the same +NO-FORTIFY usr/lib/libresolv.so.2 # the same +NO-FORTIFY usr/bin/gawk # passes fprintf as its printing function (print_vars, pp_string_fp, the debugger's print_func) +NO-FORTIFY usr/bin/gawk-* # the same +NO-FORTIFY usr/bin/msg* # gettext's bundled libxml, linked into each tool, keeps fprintf as its error channel +NO-FORTIFY usr/bin/xgettext # the same +NO-FORTIFY usr/lib/gettext/cldr-plurals # the same +NO-FORTIFY usr/bin/objdump # passes fprintf as the disassembler's print function (fprintf_func) +NO-FORTIFY usr/lib/libbfd-*.so # passes fprintf as the print callback of its error output (_bfd_doprnt) +NO-FORTIFY usr/lib/libgmp.so.* # gmp_printf's output tables hold vfprintf and obstack_vprintf +NO-FORTIFY usr/bin/bison # one plain fprintf import beside its fortified calls, from code not yet found: no fprintf taken as a value in bison's tree diff --git a/build/stages/06-iso.sh b/build/stages/06-iso.sh index 0ec5414d..93b9a18c 100755 --- a/build/stages/06-iso.sh +++ b/build/stages/06-iso.sh @@ -115,9 +115,8 @@ stage_depends_on "bs-" licences # --- steps ------------------------------------------------------------------ -# strip_root DIR: symbol tables and debug sections off every x86-64 program and -# library under DIR, each name once per file. Signed kernel modules are ET_REL -# and other architectures' firmware is not x86-64, so neither is touched. +# strip_root DIR: symbols and debug sections off every x86-64 program and library, once per file; signed +# modules (ET_REL) and other architectures' firmware stay as they are, and a file capability would be lost. strip_root() { local f mode owner n=0 while IFS= read -r -d '' f; do From ce8a7fffb3118079be85c5a805c97735dd91f8e8 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 21:12:06 -0700 Subject: [PATCH 058/213] The supplicant's ruleset covers every system path the zone binds, so its loader and libraries can be run Distro 38198740816 on 71df9f6a: every exec through kryptikd supplicant failed with EACCES (wpa_supplicant, /usr/bin/true), so no supplicant started and every Wi-Fi check failed. Landlock follows mounts: the zone binds /lib, /lib64, /bin and /sbin each on its own, the ELF interpreter comes by /lib64 and the libraries by /lib, and a rule on /usr covers none of them. The zone's own ruleset grants /, which is why nothing else saw it. The ruleset now grants read and exec on each of rootfs::SYSTEM_PATHS the zone has. --- compartments/kryptikd/src/supplicant.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/compartments/kryptikd/src/supplicant.rs b/compartments/kryptikd/src/supplicant.rs index a066ce7f..3259ddf4 100644 --- a/compartments/kryptikd/src/supplicant.rs +++ b/compartments/kryptikd/src/supplicant.rs @@ -59,12 +59,19 @@ fn confine() -> Result<(), String> { crate::seccomp::install_supplicant_filter().map_err(|e| e.to_string()) } -/// Read where its libraries, configuration and the kernel's views are, run from /usr, the -/// devices it opens, and write nowhere but `RUN_DIR`. +/// Read and run what the system paths hold, read its configuration and the kernel's views, use +/// the devices it opens, and write nowhere but `RUN_DIR`. fn ruleset() -> Result { let mut r = landlock::Ruleset::new().map_err(|e| e.to_string())?; + /* A rule covers what is reached through its path, and each system path is a bind of its + * own: the loader comes by /lib64 and libraries by /lib, which a rule on /usr does not + * cover. One the zone lacks is skipped, as the zone skips it. */ + for path in crate::rootfs::SYSTEM_PATHS { + if std::path::Path::new(path).is_dir() { + r.allow(path, landlock::ACCESS_READ | landlock::ACCESS_EXEC).map_err(|e| format!("{path}: {e}"))?; + } + } for (path, access) in [ - ("/usr", landlock::ACCESS_READ | landlock::ACCESS_EXEC), ("/etc", landlock::ACCESS_READ), ("/proc", landlock::ACCESS_READ), ("/sys", landlock::ACCESS_READ), From 61d86860cf814d6dfbcad92fc00b5036cbcad015 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 21:12:09 -0700 Subject: [PATCH 059/213] The accepted list drops the three libnl tools it named --- build/config/artifact-accepted.txt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/build/config/artifact-accepted.txt b/build/config/artifact-accepted.txt index 9bea75df..b2b9c4e5 100644 --- a/build/config/artifact-accepted.txt +++ b/build/config/artifact-accepted.txt @@ -27,10 +27,7 @@ NO-SSP usr/bin/bzip2recover # no function with a local array or an addre NO-SSP usr/bin/exch # the same NO-SSP usr/bin/getconf # the same NO-SSP usr/libexec/getconf/* # getconf again, under the names of the specifications it reports -NO-SSP usr/bin/nl-link-enslave # no function with a local array or an address-taken local -NO-SSP usr/bin/nl-link-name2ifindex # the same -NO-SSP usr/bin/nl-link-release # the same -NO-SSP usr/bin/rev # the same +NO-SSP usr/bin/rev # no function with a local array or an address-taken local NO-SSP usr/bin/s6-linux-init-nuke # the same NO-SSP usr/lib/bash/basename # the same NO-SSP usr/lib/bash/csv # the same From cad84ab1dfc8107bf95283a439566c293e4700c0 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 21:12:12 -0700 Subject: [PATCH 060/213] The accepted list drops the test module it named --- build/config/artifact-accepted.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/build/config/artifact-accepted.txt b/build/config/artifact-accepted.txt index 9bea75df..4140639f 100644 --- a/build/config/artifact-accepted.txt +++ b/build/config/artifact-accepted.txt @@ -78,7 +78,6 @@ NO-SSP usr/lib/perl5/*/auto/I18N/Langinfo/Langinfo.so # the same NO-SSP usr/lib/python3.*/lib-dynload/_contextvars.cpython-*.so # the same NO-SSP usr/lib/python3.*/lib-dynload/_heapq.cpython-*.so # the same NO-SSP usr/lib/python3.*/lib-dynload/_statistics.cpython-*.so # the same -NO-SSP usr/lib/python3.*/lib-dynload/_testimportmultiple.cpython-*.so # the same NO-SSP usr/sbin/accessdb # the same NO-SSP usr/sbin/findfs # the same NO-SSP usr/sbin/nologin # the same From afd794a09b3f6c7325a9888d5856018f0ab4d87c Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 21:12:15 -0700 Subject: [PATCH 061/213] The accepted list drops glibc's programs and libraries, which FORTIFY now covers --- build/config/artifact-accepted.txt | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/build/config/artifact-accepted.txt b/build/config/artifact-accepted.txt index 9bea75df..a13d2560 100644 --- a/build/config/artifact-accepted.txt +++ b/build/config/artifact-accepted.txt @@ -101,25 +101,6 @@ NO-SSP usr/bin/kryptikd # Rust: stable rustc has no stack protector, NO-SSP usr/bin/kryptik-wlproxy # the same # Imports of calls that _FORTIFY_SOURCE=2 or 3 always turns into _chk ones. -NO-FORTIFY usr/bin/gencat # glibc's build leaves FORTIFY out of its own code (hardening-exceptions.txt) -NO-FORTIFY usr/bin/getconf # the same -NO-FORTIFY usr/libexec/getconf/* # the same -NO-FORTIFY usr/bin/getent # the same -NO-FORTIFY usr/bin/iconv # the same -NO-FORTIFY usr/bin/locale # the same -NO-FORTIFY usr/bin/localedef # the same -NO-FORTIFY usr/bin/makedb # the same -NO-FORTIFY usr/bin/pcprofiledump # the same -NO-FORTIFY usr/bin/pldd # the same -NO-FORTIFY usr/bin/sprof # the same -NO-FORTIFY usr/bin/zdump # the same -NO-FORTIFY usr/sbin/iconvconfig # the same -NO-FORTIFY usr/sbin/zic # the same -NO-FORTIFY usr/lib/audit/sotruss-lib.so # the same -NO-FORTIFY usr/lib/libc_malloc_debug.so.0 # the same -NO-FORTIFY usr/lib/libmemusage.so # the same -NO-FORTIFY usr/lib/libnsl.so.1 # the same -NO-FORTIFY usr/lib/libresolv.so.2 # the same NO-FORTIFY usr/bin/gawk # passes fprintf as its printing function (print_vars, pp_string_fp, the debugger's print_func) NO-FORTIFY usr/bin/gawk-* # the same NO-FORTIFY usr/bin/msg* # gettext's bundled libxml, linked into each tool, keeps fprintf as its error channel From 1b959443b81bbb38cbcd1093fc4248e30759abd6 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 21:15:35 -0700 Subject: [PATCH 062/213] The desktop's menu shows the update status under u, and fetches, forgets, installs after a y, or turns automatic fetching on and off --- build/guest-tests/gui-check.sh | 18 ++++++++++++ docs/design/update-channel.md | 11 ++++++-- docs/user-guide.md | 4 ++- tools/desktop/kryptik-chrome | 39 ++++++++++++++++++++++++-- tools/image/gui-test.sh | 2 +- tools/tests/chrome-update.sh | 51 ++++++++++++++++++++++++++++++++++ 6 files changed, 117 insertions(+), 8 deletions(-) create mode 100755 tools/tests/chrome-update.sh diff --git a/build/guest-tests/gui-check.sh b/build/guest-tests/gui-check.sh index c99af30c..15041564 100755 --- a/build/guest-tests/gui-check.sh +++ b/build/guest-tests/gui-check.sh @@ -384,6 +384,24 @@ if [[ -n "$installed" && "$told" == *"since this machine was installed, 45 days else fail "menu-names-install-age" "install record dated ${installed:-unread}; the menu said: $(tr '\n' ' ' <<<"$never" | cut -c1-300)" fi +# The menu's u reaches the channel's verbs as the user: automatic fetching turned on +# there is on in kryptikd's record, and off again. A channel is named for it, and the +# setting and the configuration are put back. +rm -f /root/gt/conf.kept /root/gt/auto.kept +[[ -e "$CONF" ]] && cp -p "$CONF" /root/gt/conf.kept +[[ -e "$UPD/auto" ]] && cp -p "$UPD/auto" /root/gt/auto.kept +printf 'channel = https://channel.invalid/\n' > "$CONF" +printf 'u\na\n\nq\n' | as_user "TERM=dumb /usr/bin/kryptik-chrome --menu" > "$LOG/menu-update.out" 2>&1 +auto_on="$(cat "$UPD/auto" 2>/dev/null)" +printf 'u\nm\n\nq\n' | as_user "TERM=dumb /usr/bin/kryptik-chrome --menu" >> "$LOG/menu-update.out" 2>&1 +auto_off=gone; [[ -e "$UPD/auto" ]] && auto_off="still $(cat "$UPD/auto")" +if [[ -e /root/gt/conf.kept ]]; then mv -f /root/gt/conf.kept "$CONF"; else rm -f "$CONF"; fi +if [[ -e /root/gt/auto.kept ]]; then mv -f /root/gt/auto.kept "$UPD/auto"; else rm -f "$UPD/auto"; fi +if [[ "$auto_on" == on && "$auto_off" == gone ]]; then + pass "menu-update-auto" "the menu's u, then a, turned automatic fetching on through the launch daemon, and u, then m, off" +else + fail "menu-update-auto" "after a: ${auto_on:-no record}; after m: ${auto_off}; $(tail -4 "$LOG/menu-update.out" | tr '\n' ' ')" +fi # A zone runs one command at a time, so its window is stopped before the next. stop_zone() { as_user "kryptik-launch --stop $1" > /dev/null 2>&1; wait_for 15 test ! -e "/run/kryptik/zones/$1/init.pid"; sleep 1; } diff --git a/docs/design/update-channel.md b/docs/design/update-channel.md index dc2792c8..7e8932c7 100644 --- a/docs/design/update-channel.md +++ b/docs/design/update-channel.md @@ -214,7 +214,9 @@ already asked for keeps arriving, as one asked for by hand does. - **How the user learns.** `kryptik update status` says `staged : ... complete; kryptik update apply installs it` once a release has arrived whole, and while it waits the chrome's launcher names - it and where to apply it. Once the newest accepted statement is more than + it; the launcher's `u` shows the status and fetches, forgets, installs + (after a `y`) or turns automatic fetching on and off, through the same + verbs as `kryptik update`. Once the newest accepted statement is more than 30 days old, or, on a machine that names a channel and has accepted none, once its install is, `status` says so and the launcher shows one line with that age: statements are re-signed on a schedule, so either nothing has @@ -309,7 +311,10 @@ channel's directory. refused, and each leaves the old pair. - The desktop suite runs the launcher with a stored statement from today and with one 45 days old, and with none and the install 45 days old: only the - last two name the age. + last two name the age. It turns automatic fetching on and off from the + launcher's `u` and reads kryptikd's record each time + (`menu-update-auto`); `tools/tests/chrome-update.sh` drives every choice + against a stand-in launch daemon. - `tools/tests/channel-setting.sh`: the addresses stage 06 takes and refuses for `KRYPTIK_CHANNEL` for each role, and, for every address it takes, the fetcher reading the written `update.conf` into requests it can send. @@ -325,7 +330,7 @@ channel's directory. `rootfs.rs` (`update.conf`), `tools/kryptik` and `tools/desktop/kryptik-launch.c` (`kryptik update`), `tools/desktop/kryptik-chrome` (the launcher's lines for a release that waits -and for a stale statement), +and for a stale statement, and its `u`), `tools/update/kryptik-update` (`check-manifest`, `check-pointer`), `tools/net/update-fetch.py`, `tools/net/netzone-init.sh`, `tools/release-manifest.sh` (`pointer`), `tools/release-channel.sh`, diff --git a/docs/user-guide.md b/docs/user-guide.md index 958f27c1..ae122250 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -297,7 +297,9 @@ kryptik update auto on # fetch each newer release as it is announced; `auto o `fetch` only records the request, and the release arrives when the net zone next asks; `apply` refuses a release that is still arriving. Then reboot, as -root (`su`, then `reboot`): the next boot is the trial. +root (`su`, then `reboot`): the next boot is the trial. The desktop's menu +(Alt+p) does the same under `u`: it shows the status and fetches, forgets, +installs (after a `y`) or turns automatic fetching on and off. When the newest statement is more than 30 days old, `status` says so, and so does every login prompt: "kryptik update: no statement from the release key diff --git a/tools/desktop/kryptik-chrome b/tools/desktop/kryptik-chrome index 81117c8e..8893079a 100755 --- a/tools/desktop/kryptik-chrome +++ b/tools/desktop/kryptik-chrome @@ -157,10 +157,41 @@ menu_update() { quiet="$(printf '%s\n' "$st" | sed -n 's/^ *\(no statement from the release key [^:]*\):.*/\1/p' | head -1)" [ -n "$v$quiet" ] || return 0 echo - [ -z "$quiet" ] || echo "Update: ${quiet} (kryptik update status)" + [ -z "$quiet" ] || echo "Update: ${quiet} (u for more)" [ -z "$v" ] && return 0 - echo "Update: ${v} has arrived. To install it: kryptik update apply, at the" - echo " administration login (Ctrl+Alt+F2); the next boot tries it." + echo "Update: ${v} has arrived. u installs it, and the next boot tries it." +} + +# The update channel's verbs through the launch daemon, as `kryptik update` has +# them at the administration login. Installing asks first: it arms a trial of +# the release at the next boot. +run_update() { + while :; do + clear 2>/dev/null + echo "================ KRYPTIK - updates (zone 0) ================" + "$LAUNCH" --update status 2>&1 | sed 's/^/ /' + echo + echo "f fetch the newest release x forget it i install what has arrived" + echo "a fetch each release as it comes m only when asked Enter back" + printf 'choice: ' + read -r c || return 0 + case "$c" in + "") return 0 ;; + f) "$LAUNCH" --update fetch ;; + x) "$LAUNCH" --update forget ;; + i) printf 'install it, for a trial at the next boot? (y/N): ' + read -r yes || return 0 + [ "$yes" = y ] || continue + if "$LAUNCH" --update apply; then + echo "To try it, reboot: at the administration login (Ctrl+Alt+F2), su, then reboot." + fi + printf '(Enter) '; read -r _ || return 0; continue ;; + a) "$LAUNCH" --update auto on ;; + m) "$LAUNCH" --update auto off ;; + *) echo "?" ;; + esac + sleep 3 + done } run_menu() { @@ -175,6 +206,7 @@ run_menu() { menu_update echo echo "Apps: t terminal e editor (nano) b browser (lynx, needs routed network)" + echo "Admin: u updates: what the channel names, fetch, forget, install" echo "Also: s stop zone N m move zone N's clipboard to zone M (one payload, once)" echo " f the last zone window's zone r refresh q close this window" echo @@ -182,6 +214,7 @@ run_menu() { if ! read -r choice; then break; fi case "$choice" in q) break ;; + u) run_update; continue ;; r|"") continue ;; f) if [ -r "$FOCUS_ZONE" ]; then printf 'The last zone window you had: %s %s (zone %s)\n %s\n' \ diff --git a/tools/image/gui-test.sh b/tools/image/gui-test.sh index 3a882084..14089f4a 100755 --- a/tools/image/gui-test.sh +++ b/tools/image/gui-test.sh @@ -108,7 +108,7 @@ gp="$(sed -n 's/.*passed=\([0-9]*\).*/\1/p' <<<"$summary")"; gf="$(sed -n 's/.*f if [[ -n "$summary" && "${gf:-1}" -eq 0 && "${gp:-0}" -ge 25 ]]; then green "every guest check passed (${gp})"; else red "guest checks: ${gp:-0} passed, ${gf:-?} failed"; fi grep 'GT FAIL' <<<"$T" | sed 's/^/ /' for name in session-socket compositor-running chrome-focus-record chrome-window-is-zone0 zone0-sees-capture zone-proxy-path zone-sees-needed zone-hidden-globals zone-bind-refused proxy-logged-refusal \ - map-keeps-zone0-focus close-keeps-zone0-focus alone-keeps-no-keyboard alone-reached-by-key focus-shows-zone focus-shows-label title-prefixed last-zone-recorded menu-opens-on-key menu-keeps-last-zone menu-names-stale-statement menu-names-install-age zone-fullscreen-refused compositor-survives-close oversize-window forged-title-named-by-zone second-zone-window zone0-own-programs-only zone-app-in-cgroup no-virtual-input clipboard-isolated clipboard-move-gesture clipboard-moved \ + map-keeps-zone0-focus close-keeps-zone0-focus alone-keeps-no-keyboard alone-reached-by-key focus-shows-zone focus-shows-label title-prefixed last-zone-recorded menu-opens-on-key menu-keeps-last-zone menu-names-stale-statement menu-names-install-age menu-update-auto zone-fullscreen-refused compositor-survives-close oversize-window forged-title-named-by-zone second-zone-window zone0-own-programs-only zone-app-in-cgroup no-virtual-input clipboard-isolated clipboard-move-gesture clipboard-moved \ transfer-policy no-question-for-policy-refusal consent-code-shown transfer-approved transfer-landed plain-y-refused denied-file-absent \ second-head-appears chrome-follows-head second-head-zone-window second-head-names-zone second-head-gone compositor-survives-unplug zone-survives-unplug chrome-back-on-first-head \ zone0-cursor-set zone0-cursor-shown zone-cursor-asked zone-hears-of-outputs zone-cursor-not-shown compositor-socket-unreached \ diff --git a/tools/tests/chrome-update.sh b/tools/tests/chrome-update.sh new file mode 100755 index 00000000..2b4caf68 --- /dev/null +++ b/tools/tests/chrome-update.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Test the chrome's update window (run_update in tools/desktop/kryptik-chrome): each choice +# reaches the launch daemon by the verb `kryptik update` uses, installing only after a y, +# and Enter or the end of input goes back. +set -uo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +CHROME="$ROOT/tools/desktop/kryptik-chrome" +PASS=0; FAIL=0 +ok() { printf ' PASS %s\n' "$1"; PASS=$((PASS + 1)); } +bad() { printf ' FAIL %s\n' "$1"; FAIL=$((FAIL + 1)); [[ $# -gt 1 ]] && printf ' %s\n' "$2"; } +same() { if [[ "$2" == "$3" ]]; then ok "$1"; else bad "$1" "got '$2', want '$3'"; fi; } + +T="$(mktemp -d)" +trap 'rm -rf "$T"' EXIT +sed -n '/^run_update() {/,/^}/p' "$CHROME" > "$T/fn.sh" +grep -q '^run_update() {' "$T/fn.sh" || { echo "could not extract run_update from $CHROME"; exit 1; } +# A launch daemon that records each request, and refuses to apply when REFUSE is set. +cat > "$T/launch" <<'EOF' +#!/bin/sh +printf '%s\n' "$*" >> "$CALLS" +if [ "$*" = "--update apply" ] && [ -n "${REFUSE:-}" ]; then + echo "error: no release has been asked for" >&2 + exit 1 +fi +EOF +chmod +x "$T/launch" +# drive INPUT: run_update under sh, as the chrome runs, with INPUT as what is typed; prints +# the requests other than the status each pass shows. +drive() { + : > "$T/calls" + printf '%b' "$1" | CALLS="$T/calls" LAUNCH="$T/launch" \ + sh -c 'sleep() { :; }; clear() { :; }; . "$1"; run_update' sh "$T/fn.sh" > "$T/out" 2>&1 + grep -vx -- '--update status' "$T/calls" | tr '\n' '|' +} + +same "f fetches, then Enter goes back" "$(drive 'f\n\n')" "--update fetch|" +same "each pass shows the status first" "$(grep -cx -- '--update status' "$T/calls")" "2" +same "x forgets the release asked for" "$(drive 'x\n\n')" "--update forget|" +same "a and m turn automatic fetching on and off" "$(drive 'a\nm\n\n')" "--update auto on|--update auto off|" +same "i installs after a y" "$(drive 'i\ny\n\n\n')" "--update apply|" +grep -q 'To try it, reboot' "$T/out" && ok "and says how to try it" || bad "no reboot hint after an install" "$(tr '\n' '|' < "$T/out")" +same "i with anything but y installs nothing" "$(drive 'i\n\n\ni\nyes\n\n')" "" +same "the end of input goes back, installing nothing" "$(drive 'i\n')" "" +same "a refused install is asked for once" "$(REFUSE=1 drive 'i\ny\n\n\n')" "--update apply|" +grep -q 'no release has been asked for' "$T/out" && ! grep -q 'To try it' "$T/out" \ + && ok "and its refusal is shown, with no reboot hint" || bad "a refused install" "$(tr '\n' '|' < "$T/out")" +same "anything else asks nothing of the daemon" "$(drive 'z\n\n')" "" + +echo +echo "${PASS} passed, ${FAIL} failed" +[[ "$FAIL" -eq 0 ]] From 0bdc0d6b8ed26f982a9417fc3948e11e371b1c0a Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 21:15:31 -0700 Subject: [PATCH 063/213] The desktop's menu has a Network line: what the net zone reports, passed on by the daemon in its own words Nothing on the desktop said whether the machine was online, or which Wi-Fi network it had joined. kryptikd serve's info verb now carries, for the nic zone, a network line built from the status line its program writes last in its own /run: whether it is ready, the Wi-Fi network (an SSID as Rust quotes a string, so nothing unprintable reaches the chrome's terminal), the uplink's address and DNS. The net zone is untrusted: the file is opened O_NOFOLLOW and non-blocking through /proc/PID/root, must be a regular file of the init the registry names, is read to 1 KiB, and only fields of a known form leave net_summary; an SSID's words that look like fields stand for nothing. The chrome prints the line under the zones. menu-network-line: in the desktop suite, kryptik-launch --info net carries 'ready: uplink A/N, DNS' (wired, no radio). Unit tests give net_summary every form, an SSID holding an escape and fields of its own, and lines it must refuse. --- build/guest-tests/gui-check.sh | 9 ++++ compartments/kryptikd/src/serve.rs | 69 +++++++++++++++++++++++- compartments/kryptikd/src/serve/tests.rs | 32 +++++++++++ docs/user-guide.md | 10 ++-- tools/desktop/kryptik-chrome | 21 +++++++- tools/image/gui-test.sh | 2 +- 6 files changed, 136 insertions(+), 7 deletions(-) diff --git a/build/guest-tests/gui-check.sh b/build/guest-tests/gui-check.sh index 7b4684bb..c45e8f17 100755 --- a/build/guest-tests/gui-check.sh +++ b/build/guest-tests/gui-check.sh @@ -335,6 +335,15 @@ if wait_for 20 more_menus; then else fail "menu-opens-on-key" "no new menu window after Alt+p: $(pgrep -u "$USER_NAME" -af 'kryptik-chrome --menu' | cut -c1-80 | tr '\n' ';')" fi +# The menu's network line: what the daemon passes on of the net zone's own status, here a +# wired uplink with no radio. +net_line() { as_user "kryptik-launch --info net" 2>/dev/null | sed -n 's/^network //p'; } +net_ready() { [[ "$(net_line)" == "ready: "* ]]; } +if wait_for 60 net_ready && [[ "$(net_line)" =~ ^ready:\ uplink\ [0-9.]+/[0-9]+,\ DNS$ ]]; then + pass "menu-network-line" "Network: $(net_line)" +else + fail "menu-network-line" "kryptik-launch --info net: $(as_user "kryptik-launch --info net" 2>&1 | tr '\n' ' ')" +fi # A zone 0 window taking focus keeps the last zone window's record, which the menu's f shows. as_user "/usr/libexec/kryptik/wlprobe oversize 0 15 zone-0" > "$LOG/zone0-window.out" 2>&1 & if wait_for 20 grep -q '^zone=0' "$RT/kryptik/focus"; then diff --git a/compartments/kryptikd/src/serve.rs b/compartments/kryptikd/src/serve.rs index fc94f072..ea23b6a7 100644 --- a/compartments/kryptikd/src/serve.rs +++ b/compartments/kryptikd/src/serve.rs @@ -962,6 +962,70 @@ fn zone_running(name: &str) -> bool { matches!(crate::registry::state(name), Ok(crate::registry::State::Running { .. })) } +/// The nic zone's word on the network: the line its program wrote last +/// (tools/net/netzone-init.sh, `report`) in its own /run. The zone is untrusted, so only +/// `net_summary`'s words leave here, and zone 0's window shows nothing else of the line. +fn net_status(name: &str) -> Option { + use std::io::Read; + use std::os::unix::fs::OpenOptionsExt; + let Ok(crate::registry::State::Running { init: Some(init), .. }) = crate::registry::state(name) else { + return None; + }; + let f = std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC) + .open(format!("/proc/{}/root/run/netzone-status", init.pid)) + .ok()?; + // A regular file of the zone the pid still names, not of one that has reused it. + if !f.metadata().ok()?.is_file() || !init.still_alive() { + return None; + } + let mut text = String::new(); + f.take(1024).read_to_string(&mut text).ok()?; + net_summary(text.lines().next()?) +} + +/// The words for a net zone's status line, or none for a line of another form: whether it is +/// ready, the Wi-Fi network (an SSID as Rust quotes a string, so nothing unprintable reaches a +/// terminal), the uplink's address and whether there is DNS. +fn net_summary(line: &str) -> Option { + if line.starts_with("NOT READY") { + return Some("not ready: forwarding is off".into()); + } + let rest = line.strip_prefix("READY ")?; + // The fields before wifi= come first under their names; an SSID may hold spaces and words + // that look like fields, so it runs from wifi= to the last time=. + let field = |key: &str| rest.split(' ').find_map(|w| w.strip_prefix(key)); + let uplink = match field("uplink=")? { + "none" => "no uplink address".to_string(), + a => { + let (ip, len) = a.split_once('/')?; + let ip: std::net::Ipv4Addr = ip.parse().ok()?; + let len: u8 = len.parse().ok().filter(|l| *l <= 32)?; + format!("uplink {ip}/{len}") + } + }; + let dns = match field("dns=")? { + "yes" => "DNS", + "no" => "no DNS", + _ => return None, + }; + let from = rest.find(" wifi=")? + " wifi=".len(); + let to = rest.rfind(" time=").filter(|t| *t >= from)?; + let wifi = match &rest[from..to] { + "none" => None, + "unconfigured" => Some("Wi-Fi with no network saved".to_string()), + "connecting" => Some("Wi-Fi connecting".to_string()), + "associated" => Some("Wi-Fi joined".to_string()), + ssid if !ssid.is_empty() && ssid.len() <= 32 => Some(format!("Wi-Fi {ssid:?}")), + _ => return None, + }; + Some(match wifi { + Some(w) => format!("ready: {w}, {uplink}, {dns}"), + None => format!("ready: {uplink}, {dns}"), + }) +} + /// Serve one connection; a started launch is returned and a slow command pushed to `jobs`. fn handle(cfg: &ServeConfig, conn: UnixStream, jobs: &mut Vec) -> Option { let fd = conn.as_raw_fd(); @@ -1006,13 +1070,16 @@ fn handle(cfg: &ServeConfig, conn: UnixStream, jobs: &mut Vec) -> Option { let enc = z.storage == crate::zone::StorageMode::Encrypted; + // The nic zone's own status, for the chrome's network line. + let net = (z.network == crate::zone::NetworkMode::Nic).then(|| net_status(name)).flatten(); reply( &conn, &format!( - "encrypted {}\nrunning {}\nlabel {}\nend\n", + "encrypted {}\nrunning {}\nlabel {}\n{}end\n", if enc { "yes" } else { "no" }, if zone_running(name) { "yes" } else { "no" }, z.label.as_deref().unwrap_or(&z.name), + net.map_or(String::new(), |n| format!("network {n}\n")), ), ); } diff --git a/compartments/kryptikd/src/serve/tests.rs b/compartments/kryptikd/src/serve/tests.rs index 15534bf9..a8d23d1e 100644 --- a/compartments/kryptikd/src/serve/tests.rs +++ b/compartments/kryptikd/src/serve/tests.rs @@ -1,5 +1,37 @@ use super::*; +#[test] +fn net_summary_passes_on_known_fields_only() { + let line = "READY uplink=10.0.2.15/24 nat=yes dns=yes wifi=Cafe Wifi time=+0.5 bridge=kryptik0 uplinks=eth0 wlan0"; + assert_eq!(net_summary(line).as_deref(), Some("ready: Wi-Fi \"Cafe Wifi\", uplink 10.0.2.15/24, DNS")); + let wired = "READY uplink=none nat=yes dns=no wifi=none time=no-uplink bridge=kryptik0 uplinks=eth0"; + assert_eq!(net_summary(wired).as_deref(), Some("ready: no uplink address, no DNS")); + let joining = "READY uplink=10.0.2.15/24 nat=yes dns=yes wifi=connecting time=-2"; + assert_eq!(net_summary(joining).as_deref(), Some("ready: Wi-Fi connecting, uplink 10.0.2.15/24, DNS")); + let down = "NOT READY firewall policy not loaded; forwarding off; uplink=none dns=no wifi=none time=x"; + assert_eq!(net_summary(down).as_deref(), Some("not ready: forwarding is off")); + // An SSID is any access point's: what a terminal would act on comes out escaped, and words + // in it that look like fields do not stand for the line's own. + let hostile = "READY uplink=10.0.2.15/24 nat=yes dns=yes wifi=\u{1b}[2J x time=0 dns=no time=+1 bridge=k"; + let s = net_summary(hostile).unwrap(); + assert!(!s.contains('\u{1b}') && s.contains("\\u{1b}[2J x time=0 dns=no"), "{s}"); + assert!(s.ends_with(", uplink 10.0.2.15/24, DNS"), "{s}"); + for bad in [ + "", + "READY", + "anything else", + "READY uplink=10.0.2.300/24 nat=yes dns=yes wifi=none time=0", + "READY uplink=10.0.2.15/33 nat=yes dns=yes wifi=none time=0", + "READY uplink=10.0.2.15 nat=yes dns=yes wifi=none time=0", + "READY uplink=none nat=yes dns=maybe wifi=none time=0", + "READY uplink=none nat=yes dns=yes wifi= time=0", + "READY uplink=none nat=yes dns=yes wifi=an SSID of thirty-three bytes!!!! time=0", + "READY uplink=none nat=yes dns=yes time=0 wifi=late", + ] { + assert_eq!(net_summary(bad), None, "{bad:?}"); + } +} + #[test] fn parse_run_refuses_bad_requests() { let r = parse_run("run work wayland=/run/user/1000/kryptik/work/wayland-0 pass=fd\narg havoc\narg -e\narg sh\nend\n").unwrap(); diff --git a/docs/user-guide.md b/docs/user-guide.md index 47600d1e..9891b71a 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -245,10 +245,12 @@ Zone windows come from the menu: Alt+p, then a zone's number and `t` (a terminal), `e` (the editor) or `b` (the browser), for example `1t`, then Enter. A zone with encrypted storage asks for its passphrase when it starts, in the menu's window or, for a key, in a small prompt window; the passphrase -never appears on a command line. The menu also names a release that has -arrived and waits to be installed, and says when the newest update statement -is more than 30 days old, or, with none ever accepted, when the install is -(section 4). With `w` it lists the Wi-Fi networks the net zone may join, adds +never appears on a command line. Under the zones, a Network line says what +the net zone reports: whether it is ready, the Wi-Fi network it joined, its +address on the uplink and whether there is DNS. The menu also names a +release that has arrived and waits to be installed, and says when the newest +update statement is more than 30 days old, or, with none ever accepted, when +the install is (section 4). With `w` it lists the Wi-Fi networks the net zone may join, adds one, asking for its passphrase in the menu's own window, or forgets one, as `kryptik wifi` does at the administration login. diff --git a/tools/desktop/kryptik-chrome b/tools/desktop/kryptik-chrome index 2f056df4..4d11c872 100755 --- a/tools/desktop/kryptik-chrome +++ b/tools/desktop/kryptik-chrome @@ -15,7 +15,8 @@ # focus as text (and the last zone window's # in focus.zone), and opens the launcher # kryptik-chrome --menu the launcher: zones, their state, apps, -# a release that waits to be installed, a +# the network as the net zone reports it, a +# release that waits to be installed, a # channel with nothing new for 30 days, and # the net zone's Wi-Fi networks (w) # kryptik-chrome --prompt ZONE [--no-display] -- CMD [ARG...] @@ -148,6 +149,23 @@ menu_zones() { done } +# The net zone's own word on the network, which the daemon passes on only in its own words: +# ready or not, the Wi-Fi network, the uplink's address and DNS. +menu_network() { + for f in "$ZONES"/*.toml; do + [ -f "$f" ] || continue + z="$(basename "$f" .toml)" + zone_is_nic "$z" || continue + info="$("$LAUNCH" --info "$z" 2>/dev/null)" + net="$(printf '%s\n' "$info" | sed -n 's/^network //p')" + if [ -z "$net" ]; then + printf '%s\n' "$info" | grep -qx 'running yes' && net="starting" || net="the net zone is not running" + fi + echo + echo "Network: $net" + done +} + # A release that has arrived whole waits for the user, so the launcher says so. # So does a channel gone quiet: statements are re-signed on a schedule, and a # net zone that withholds them leaves only their age to show it. @@ -173,6 +191,7 @@ run_menu() { echo echo "Zones:" menu_zones + menu_network menu_update echo echo "Apps: t terminal e editor (nano) b browser (lynx, needs routed network)" diff --git a/tools/image/gui-test.sh b/tools/image/gui-test.sh index b07a1da4..0aae320a 100755 --- a/tools/image/gui-test.sh +++ b/tools/image/gui-test.sh @@ -111,7 +111,7 @@ gp="$(sed -n 's/.*passed=\([0-9]*\).*/\1/p' <<<"$summary")"; gf="$(sed -n 's/.*f if [[ -n "$summary" && "${gf:-1}" -eq 0 && "${gp:-0}" -ge 25 ]]; then green "every guest check passed (${gp})"; else red "guest checks: ${gp:-0} passed, ${gf:-?} failed"; fi grep 'GT FAIL' <<<"$T" | sed 's/^/ /' for name in session-socket compositor-running chrome-focus-record chrome-window-is-zone0 zone0-sees-capture zone-proxy-path zone-sees-needed zone-hidden-globals zone-bind-refused proxy-logged-refusal \ - map-keeps-zone0-focus close-keeps-zone0-focus alone-keeps-no-keyboard alone-reached-by-key focus-shows-zone focus-shows-label title-prefixed last-zone-recorded menu-opens-on-key menu-keeps-last-zone menu-names-stale-statement menu-names-install-age zone-fullscreen-refused compositor-survives-close oversize-window forged-title-named-by-zone second-zone-window zone0-own-programs-only zone-app-in-cgroup no-virtual-input clipboard-isolated clipboard-move-gesture clipboard-moved \ + map-keeps-zone0-focus close-keeps-zone0-focus alone-keeps-no-keyboard alone-reached-by-key focus-shows-zone focus-shows-label title-prefixed last-zone-recorded menu-opens-on-key menu-keeps-last-zone menu-names-stale-statement menu-names-install-age menu-network-line zone-fullscreen-refused compositor-survives-close oversize-window forged-title-named-by-zone second-zone-window zone0-own-programs-only zone-app-in-cgroup no-virtual-input clipboard-isolated clipboard-move-gesture clipboard-moved \ transfer-policy no-question-for-policy-refusal consent-code-shown transfer-approved transfer-landed plain-y-refused denied-file-absent \ second-head-appears chrome-follows-head second-head-zone-window second-head-names-zone second-head-gone compositor-survives-unplug zone-survives-unplug chrome-back-on-first-head chrome-wifi-add \ zone0-cursor-set zone0-cursor-shown zone-cursor-asked zone-hears-of-outputs zone-cursor-not-shown compositor-socket-unreached \ From f0af8bf50a6b709324962c82389ee163d2be5d14 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 21:16:07 -0700 Subject: [PATCH 064/213] udev's hardware database is compiled into the image, so its keyboard, mouse and touchpad entries apply eudev installs its hardware database as sources (/etc/udev/hwdb.d, 9.6 MB) and reads only the compiled form, /etc/udev/hwdb.bin or /usr/lib/udev/hwdb.bin. Nothing in the build or on a booted system ran udevadm hwdb --update, so every IMPORT{builtin}="hwdb" in the rules found nothing: no laptop keyboard remaps, mouse DPI, touchpad resolution fixes or sensor orientation, and no vendor names from the USB and PCI tables. The recipe compiles it onto the verified root and fails unless it answers a lookup. --- build/recipes/eudev.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/build/recipes/eudev.sh b/build/recipes/eudev.sh index 3cc2e8b1..d93b6705 100644 --- a/build/recipes/eudev.sh +++ b/build/recipes/eudev.sh @@ -9,4 +9,10 @@ s_eudev() { mkdir -pv /usr/lib/udev/rules.d mkdir -pv /etc/udev/rules.d make install + # The hardware database the rules query (keyboard maps, mouse and touchpad fixes), compiled + # onto the verified root: nothing on a booted system runs udevadm hwdb --update. + udevadm hwdb --update --output=/usr/lib/udev/hwdb.bin + local got; got="$(udevadm hwdb --test='usb:v1D6Bp0001' 2>&1 || true)" + [[ "$got" == *ID_VENDOR_FROM_DATABASE=* ]] || { echo "FAIL: the hardware database does not answer: ${got}"; return 1; } + printf 'hwdb.bin answers: %s\n' "$got" } From 0c7cb18c5d885e25f496be2f61857de1af3a16c5 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 21:16:56 -0700 Subject: [PATCH 065/213] menu-network-line is listed beside chrome-wifi-add, off the line #360 adds to --- tools/image/gui-test.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/image/gui-test.sh b/tools/image/gui-test.sh index 0aae320a..14e46c31 100755 --- a/tools/image/gui-test.sh +++ b/tools/image/gui-test.sh @@ -111,9 +111,9 @@ gp="$(sed -n 's/.*passed=\([0-9]*\).*/\1/p' <<<"$summary")"; gf="$(sed -n 's/.*f if [[ -n "$summary" && "${gf:-1}" -eq 0 && "${gp:-0}" -ge 25 ]]; then green "every guest check passed (${gp})"; else red "guest checks: ${gp:-0} passed, ${gf:-?} failed"; fi grep 'GT FAIL' <<<"$T" | sed 's/^/ /' for name in session-socket compositor-running chrome-focus-record chrome-window-is-zone0 zone0-sees-capture zone-proxy-path zone-sees-needed zone-hidden-globals zone-bind-refused proxy-logged-refusal \ - map-keeps-zone0-focus close-keeps-zone0-focus alone-keeps-no-keyboard alone-reached-by-key focus-shows-zone focus-shows-label title-prefixed last-zone-recorded menu-opens-on-key menu-keeps-last-zone menu-names-stale-statement menu-names-install-age menu-network-line zone-fullscreen-refused compositor-survives-close oversize-window forged-title-named-by-zone second-zone-window zone0-own-programs-only zone-app-in-cgroup no-virtual-input clipboard-isolated clipboard-move-gesture clipboard-moved \ + map-keeps-zone0-focus close-keeps-zone0-focus alone-keeps-no-keyboard alone-reached-by-key focus-shows-zone focus-shows-label title-prefixed last-zone-recorded menu-opens-on-key menu-keeps-last-zone menu-names-stale-statement menu-names-install-age zone-fullscreen-refused compositor-survives-close oversize-window forged-title-named-by-zone second-zone-window zone0-own-programs-only zone-app-in-cgroup no-virtual-input clipboard-isolated clipboard-move-gesture clipboard-moved \ transfer-policy no-question-for-policy-refusal consent-code-shown transfer-approved transfer-landed plain-y-refused denied-file-absent \ - second-head-appears chrome-follows-head second-head-zone-window second-head-names-zone second-head-gone compositor-survives-unplug zone-survives-unplug chrome-back-on-first-head chrome-wifi-add \ + second-head-appears chrome-follows-head second-head-zone-window second-head-names-zone second-head-gone compositor-survives-unplug zone-survives-unplug chrome-back-on-first-head chrome-wifi-add menu-network-line \ zone0-cursor-set zone0-cursor-shown zone-cursor-asked zone-hears-of-outputs zone-cursor-not-shown compositor-socket-unreached \ zone0-frame-clock zone-window-animates \ zone0-fullscreen-granted zone0-fullscreen-needs-focus fullscreen-by-key zone-child-mapped child-focused parent-focused parent-fullscreen fullscreen-keeps-focus keyboard-stays-on-fullscreen zoom-keeps-keyboard zoom-twice-keeps-keyboard zone0-over-fullscreen-gets-keyboard zone0-window-ends-fullscreen child-ends-fullscreen; do From 1eba0bf0cd3c7c85fa42e63b5f3527dd88417f82 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 21:17:02 -0700 Subject: [PATCH 066/213] Python ships no static libpython and no bytecode for -O or -OO On main's image (manifest from run 38199573414) the static libpython3.12.a is 43 MB, beside the shared library every embedder links, and the optimized bytecode (.opt-1.pyc, .opt-2.pyc) another 29.5 MB that only python -O and -OO read; on the read-only root such a run compiles in memory instead. --- build/recipes/python.sh | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/build/recipes/python.sh b/build/recipes/python.sh index ca5a7aaa..b8d8b41a 100644 --- a/build/recipes/python.sh +++ b/build/recipes/python.sh @@ -13,14 +13,17 @@ s_python() { s_python_final() { local src; src="$(unpack "Python-${V_PYTHON}.tar.xz" "Python-${V_PYTHON}")" cd "$src" - # No test suite or test modules: nothing in the image runs them. - ./configure --prefix=/usr --enable-shared --with-system-expat --disable-test-modules + # No test suite or test modules, which nothing runs, and no static libpython beside the shared one. + ./configure --prefix=/usr --enable-shared --with-system-expat --disable-test-modules --without-static-libpython make make install # The first build's test files outlive it here, and tkinter cannot import without _tkinter, which nothing builds. local lib="/usr/lib/python${V_PYTHON%.*}" rm -rf "$lib"/test "$lib"/idlelib "$lib"/tkinter "$lib"/turtledemo "$lib"/turtle.py "$lib"/__pycache__/turtle.* \ - "$lib"/lib-dynload/{_test,_ctypes_test,xx}*.so /usr/bin/idle3 "/usr/bin/idle${V_PYTHON%.*}" + "$lib"/lib-dynload/{_test,_ctypes_test,xx}*.so /usr/bin/idle3 "/usr/bin/idle${V_PYTHON%.*}" \ + "$lib"/config-*/libpython*.a + # Bytecode for python -O and -OO, which nothing runs; such a run compiles in memory on a read-only root. + find "$lib" -name '*.opt-[12].pyc' -delete [[ ! -e "$lib/test" ]] && ! compgen -G "$lib/lib-dynload/_test*.so" > /dev/null \ || { echo "FAIL: python's test suite is still installed"; return 1; } local m From 2a8105d687178e5bc940c3d9d58b1850067b9199 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 21:17:36 -0700 Subject: [PATCH 067/213] The menu says installing takes minutes before it waits on apply --- tools/desktop/kryptik-chrome | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/desktop/kryptik-chrome b/tools/desktop/kryptik-chrome index 8893079a..46364dee 100755 --- a/tools/desktop/kryptik-chrome +++ b/tools/desktop/kryptik-chrome @@ -182,6 +182,7 @@ run_update() { i) printf 'install it, for a trial at the next boot? (y/N): ' read -r yes || return 0 [ "$yes" = y ] || continue + echo "installing: the other slot is written and read back, which takes a few minutes" if "$LAUNCH" --update apply; then echo "To try it, reboot: at the administration login (Ctrl+Alt+F2), su, then reboot." fi From 3a227b9ef66a79ac9e969b78f1ae13472ce4ad85 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 21:17:58 -0700 Subject: [PATCH 068/213] The address test replaces the 0440 file before writing an older one in its place --- compartments/kryptikd/src/wifi/tests.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/compartments/kryptikd/src/wifi/tests.rs b/compartments/kryptikd/src/wifi/tests.rs index a85a8c56..1525d75a 100644 --- a/compartments/kryptikd/src/wifi/tests.rs +++ b/compartments/kryptikd/src/wifi/tests.rs @@ -52,7 +52,9 @@ fn each_network_has_an_address_of_its_own() { assert_eq!(address("Home"), home); let text = fs::read_to_string(conf_path(&dir)).unwrap(); assert!(text.contains(&format!("\tpsk=\"a different one\"\n\tmac_addr=3\n\tmac_value={home}\n}}\n")), "{text}"); - // A file an older kryptikd wrote gets one per network once, then is current. + // A file an older kryptikd wrote gets one per network once, then is current. Put in place + // of the 0440 file, which takes no write. + fs::remove_file(conf_path(&dir)).unwrap(); fs::write(conf_path(&dir), "ctrl_interface=/run/wpa_supplicant\nupdate_config=0\n\nnetwork={\n\tssid=\"Old\"\n\tpsk=\"older pass\"\n}\n").unwrap(); assert!(refresh(&dir, None).unwrap()); let old = address("Old"); From 7af098ab0b4eff457169bef8515aed5a98b654da Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 21:19:26 -0700 Subject: [PATCH 069/213] Static archives in the image lose their debug sections, and their members' times The image carries 38 static archives, 129.5 MB on main's image; strip took symbols off every program and library but left the archives as built, and GCC's target libraries keep their -g. strip --strip-debug leaves every symbol a link needs, and -D zeroes the members' times and owners, which a reproducible image wants anyway. --- build/stages/06-iso.sh | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/build/stages/06-iso.sh b/build/stages/06-iso.sh index 93b9a18c..28b00b35 100755 --- a/build/stages/06-iso.sh +++ b/build/stages/06-iso.sh @@ -115,16 +115,22 @@ stage_depends_on "bs-" licences # --- steps ------------------------------------------------------------------ -# strip_root DIR: symbols and debug sections off every x86-64 program and library, once per file; signed -# modules (ET_REL) and other architectures' firmware stay as they are, and a file capability would be lost. +# strip_root DIR: symbols and debug sections off every x86-64 program and library, debug sections off every +# static archive; signed modules (ET_REL) and foreign firmware are left alone, and a file capability would be lost. strip_root() { - local f mode owner n=0 + local f mode owner n=0 a=0 while IFS= read -r -d '' f; do mode="$(stat -c %a "$f")"; owner="$(stat -c %u:%g "$f")" - strip --strip-all --preserve-dates "$f" + if [[ "$f" == *.a ]]; then + # The archive keeps every symbol a link needs; -D zeroes the members' times and owners. + strip --strip-debug -D --preserve-dates "$f" + a=$((a + 1)) + else + strip --strip-all --preserve-dates "$f" + n=$((n + 1)) + fi # chown before chmod: a change of owner clears the setuid bit. chown "$owner" "$f"; chmod "$mode" "$f" - n=$((n + 1)) done < <(python3 - "$1" <<'EOF' import os, stat, sys root = sys.argv[1] @@ -139,13 +145,14 @@ for d, dirs, files in os.walk(root): continue with open(p, "rb") as fh: h = fh.read(20) - # 64-bit little-endian ELF, ET_EXEC or ET_DYN, for x86-64 - if h[:6] == b"\x7fELF\x02\x01" and h[16:18] in (b"\x02\x00", b"\x03\x00") and h[18:20] == b"\x3e\x00": + # 64-bit little-endian ELF, ET_EXEC or ET_DYN, for x86-64; or an ar archive named .a + elf = h[:6] == b"\x7fELF\x02\x01" and h[16:18] in (b"\x02\x00", b"\x03\x00") and h[18:20] == b"\x3e\x00" + if elf or (name.endswith(".a") and h[:8] == b"!\n"): seen.add(st.st_ino) sys.stdout.buffer.write(os.fsencode(p) + b"\0") EOF ) - echo "stripped ${n} objects" + echo "stripped ${n} objects, and the debug sections of ${a} archives" } s_rootfs() { From 8c2ba8802be25f28d7a54a63be5a080b7ca3d22a Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 21:19:33 -0700 Subject: [PATCH 070/213] The net zone's status file is opened inside its root with no link followed, as the broker reaches a zone O_NOFOLLOW guarded only the last component of /proc/PID/root/run/netzone-status, and an absolute link earlier on it would have resolved against serve's own root (0f's read). The root is now an O_PATH directory and the file is opened through it with openat2's RESOLVE_IN_ROOT, NO_SYMLINKS and NO_MAGICLINKS; broker.rs's wrapper and flags are shared for it. --- compartments/kryptikd/src/broker.rs | 8 ++++---- compartments/kryptikd/src/serve.rs | 12 +++++++++--- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/compartments/kryptikd/src/broker.rs b/compartments/kryptikd/src/broker.rs index eb3c2c37..7ab27730 100755 --- a/compartments/kryptikd/src/broker.rs +++ b/compartments/kryptikd/src/broker.rs @@ -355,13 +355,13 @@ struct OpenHow { mode: u64, resolve: u64, } -const RESOLVE_NO_MAGICLINKS: u64 = 0x02; -const RESOLVE_NO_SYMLINKS: u64 = 0x04; +pub(crate) const RESOLVE_NO_MAGICLINKS: u64 = 0x02; +pub(crate) const RESOLVE_NO_SYMLINKS: u64 = 0x04; const RESOLVE_BENEATH: u64 = 0x08; -const RESOLVE_IN_ROOT: u64 = 0x10; +pub(crate) const RESOLVE_IN_ROOT: u64 = 0x10; /// openat2(2): open with resolution restrictions the kernel enforces. -fn openat2(dirfd: RawFd, path: &str, flags: u64, mode: u64, resolve: u64) -> io::Result { +pub(crate) fn openat2(dirfd: RawFd, path: &str, flags: u64, mode: u64, resolve: u64) -> io::Result { let c = CString::new(path).map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "NUL in path"))?; let how = OpenHow { flags, mode, resolve }; let r = unsafe { diff --git a/compartments/kryptikd/src/serve.rs b/compartments/kryptikd/src/serve.rs index ea23b6a7..92b765fc 100644 --- a/compartments/kryptikd/src/serve.rs +++ b/compartments/kryptikd/src/serve.rs @@ -966,16 +966,22 @@ fn zone_running(name: &str) -> bool { /// (tools/net/netzone-init.sh, `report`) in its own /run. The zone is untrusted, so only /// `net_summary`'s words leave here, and zone 0's window shows nothing else of the line. fn net_status(name: &str) -> Option { + use crate::broker::{openat2, RESOLVE_IN_ROOT, RESOLVE_NO_MAGICLINKS, RESOLVE_NO_SYMLINKS}; use std::io::Read; use std::os::unix::fs::OpenOptionsExt; + use std::os::unix::io::AsRawFd; let Ok(crate::registry::State::Running { init: Some(init), .. }) = crate::registry::state(name) else { return None; }; - let f = std::fs::OpenOptions::new() + // Resolved inside the zone's root with no link followed, as the broker reaches a zone. + let root = std::fs::OpenOptions::new() .read(true) - .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC) - .open(format!("/proc/{}/root/run/netzone-status", init.pid)) + .custom_flags(libc::O_PATH | libc::O_DIRECTORY | libc::O_CLOEXEC) + .open(format!("/proc/{}/root", init.pid)) .ok()?; + let flags = (libc::O_RDONLY | libc::O_NONBLOCK | libc::O_CLOEXEC) as u64; + let resolve = RESOLVE_IN_ROOT | RESOLVE_NO_SYMLINKS | RESOLVE_NO_MAGICLINKS; + let f = std::fs::File::from(openat2(root.as_raw_fd(), "run/netzone-status", flags, 0, resolve).ok()?); // A regular file of the zone the pid still names, not of one that has reused it. if !f.metadata().ok()?.is_file() || !init.still_alive() { return None; From 92b5ff634c9e6d628f1f2e4ea9d34fd2425d0857 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 21:20:16 -0700 Subject: [PATCH 071/213] binutils is built without gold, which nothing selects and 2.44 drops The recipe asked for the gold linker (--enable-gold), which installs ld.gold (7.1 MB) and its DWARF packer dwp (3.8 MB). ld stays the BFD linker either way, nothing in the build or the image passes -fuse-ld=gold, and binutils 2.44 moves gold out of its tarball, so the pin's next bump would lose it anyway. A cached sysroot's copies are removed. --- build/recipes/binutils.sh | 5 ++++- tools/pin-reviews.tsv | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/build/recipes/binutils.sh b/build/recipes/binutils.sh index 78bc9dac..ab31e00d 100644 --- a/build/recipes/binutils.sh +++ b/build/recipes/binutils.sh @@ -5,13 +5,16 @@ s_binutils_native() { cd "$src" mkdir -p build && cd build # --with-stage1-ldflags=: the default static libgcc and libstdc++ (stage 02's) lack CET notes. - ../configure --prefix=/usr --sysconfdir=/etc --enable-gold \ + # No gold: nothing selects it, and binutils drops it from 2.44. + ../configure --prefix=/usr --sysconfdir=/etc \ --enable-ld=default --enable-plugins --enable-shared --disable-werror \ --enable-64-bit-bfd --enable-new-dtags --with-system-zlib \ --enable-default-hash-style=gnu --with-stage1-ldflags= --enable-gprofng=no make tooldir=/usr make tooldir=/usr install rm -fv /usr/lib/lib{bfd,ctf,ctf-nobfd,opcodes,sframe}.a + # A cached sysroot still holds what a build with gold installed. + rm -fv /usr/bin/ld.gold /usr/bin/dwp # gcc searches stage 02's /usr/ tools before PATH; tooldir=/usr put these in /usr/bin. local t; t="$(gcc -dumpmachine)" rm -rf "/usr/${t:?}" diff --git a/tools/pin-reviews.tsv b/tools/pin-reviews.tsv index 883615be..eb11ab56 100644 --- a/tools/pin-reviews.tsv +++ b/tools/pin-reviews.tsv @@ -8,7 +8,7 @@ # Read from upstream NEWS and changelogs, security pages, the Debian security tracker and NVD. # --- toolchain ----------------------------------------------------------------- -binutils 2.43.1 2.47 fine 2026-09-19 NEWS 2.44 to 2.47 and the Debian tracker: every CVE since 2.43 is a crafted-input bug in tools that are only fed the project's own objects, and upstream's SECURITY.txt excludes them; no wrong-code or hardening fix found. 2.44 drops gold, which the recipe enables. +binutils 2.43.1 2.47 fine 2026-09-19 NEWS 2.44 to 2.47 and the Debian tracker: every CVE since 2.43 is a crafted-input bug in tools that are only fed the project's own objects, and upstream's SECURITY.txt excludes them; no wrong-code or hardening fix found. gcc 14.4.0 16.2.0 fine 2026-09-19 14.4.0 is the newest 14.x. 15 changes the default C standard and 16 the default C++ one; neither is a security release. GCC Bugzilla milestones for 14.3 and 14.4 were read when the pin moved off 14.2.0. glibc 2.40 2.44 fine 2026-09-19 The tarball is 2.40 and build/patches/glibc-2.40 carries upstream's maintained release/2.40/master branch to cdaa5d6d (2026-09-10), which is where 2.40's security fixes live. Re-read that branch's head when this row is next reviewed. mpc 1.3.1 1.4.1 fine 2026-09-19 NEWS 1.4.0 and 1.4.1: no security or wrong-result fix. 1.4.0 is to be skipped if this ever moves. From 685b4fef9df01414024a0ae5e6f336c3a48292c9 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 21:20:39 -0700 Subject: [PATCH 072/213] The guide's administration paragraph is wrapped again --- docs/user-guide.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/user-guide.md b/docs/user-guide.md index 73f1a9a7..33a8b12d 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -252,9 +252,9 @@ is more than 30 days old, or, with none ever accepted, when the install is For administration, Ctrl+Alt+F2 gives a text login: log in as the user, and `su` becomes root, where passwords change: `passwd NAME` for the user, -`passwd` for root's own. There `kryptik list`, `kryptik status [ZONE]`, `kryptik -explain ZONE` and `kryptik doctor` describe the zones, and `kryptik stop ZONE` -stops one. `kryptik shell ZONE` and `kryptik run ZONE -- COMMAND` start the +`passwd` for root's own. There `kryptik list`, `kryptik status [ZONE]`, +`kryptik explain ZONE` and `kryptik doctor` describe the zones, and `kryptik +stop ZONE` stops one. `kryptik shell ZONE` and `kryptik run ZONE -- COMMAND` start the zone through the launch service when run as the user, detached: its output goes to `/var/log/kryptik/zone-ZONE.log`, which root can read. Run as root, they use the terminal, for a zone without encrypted storage only. From b17a1f69098e1a75329b7d702e33258226971c29 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 21:22:14 -0700 Subject: [PATCH 073/213] s_payload checks for zstd itself, the one step of stage 06 that uses it --- build/stages/06-iso.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/build/stages/06-iso.sh b/build/stages/06-iso.sh index f48b0243..38c005ed 100755 --- a/build/stages/06-iso.sh +++ b/build/stages/06-iso.sh @@ -85,7 +85,7 @@ channel_conf() { # --- preflight ---------------------------------------------------------------- log "Kryptik stage 06 — install media ${KRYPTIK_VERSION}" for t in mkfs.ext4 tune2fs dumpe2fs debugfs veritysetup sbsign sbverify mkfs.vfat mmd mcopy \ - xorriso sfdisk openssl ssh-keygen rsync truncate dd sha256sum blkid zstd; do + xorriso sfdisk openssl ssh-keygen rsync truncate dd sha256sum blkid; do have "$t" || die "required tool not found: ${t}" done # The signing half never touches the sysroot or the chroot. @@ -491,6 +491,7 @@ s_sums() { # decompresses to against root.json, before it writes a byte of it. s_payload() { echo "inputs digest: $1" + have zstd || { echo "zstd is required: the payload's image travels compressed"; return 1; } local out="${IMG}/payload-${KRYPTIK_VERSION}" rm -rf "$out"; mkdir -p "$out" zstd -q -T0 -19 "${IMG}/kryptik-root.img" -o "$out/kryptik-root.img.zst" || { echo "could not compress the root image"; return 1; } From e45fbdcb65d5f3dcdf186bc0ee41ccd42405b1f3 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 21:23:52 -0700 Subject: [PATCH 074/213] The glibc pin review names GLIBC-SA-2026-0022 and why it does not reach Kryptik The advisory (CVE-2026-86805, 2026-07-06) is a race in ld.so's $ORIGIN check for setuid programs, in 2.14 to 2.44 and fixed only for 2.45. It needs a setuid program whose RPATH climbs out of $ORIGIN and fs.protected_hardlinks off; su and passwd carry no RPATH and the sysctl is on. The row reviewed glibc 2.40 against 2.44 without naming it. --- tools/pin-reviews.tsv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/pin-reviews.tsv b/tools/pin-reviews.tsv index 883615be..a0147dbe 100644 --- a/tools/pin-reviews.tsv +++ b/tools/pin-reviews.tsv @@ -10,7 +10,7 @@ # --- toolchain ----------------------------------------------------------------- binutils 2.43.1 2.47 fine 2026-09-19 NEWS 2.44 to 2.47 and the Debian tracker: every CVE since 2.43 is a crafted-input bug in tools that are only fed the project's own objects, and upstream's SECURITY.txt excludes them; no wrong-code or hardening fix found. 2.44 drops gold, which the recipe enables. gcc 14.4.0 16.2.0 fine 2026-09-19 14.4.0 is the newest 14.x. 15 changes the default C standard and 16 the default C++ one; neither is a security release. GCC Bugzilla milestones for 14.3 and 14.4 were read when the pin moved off 14.2.0. -glibc 2.40 2.44 fine 2026-09-19 The tarball is 2.40 and build/patches/glibc-2.40 carries upstream's maintained release/2.40/master branch to cdaa5d6d (2026-09-10), which is where 2.40's security fixes live. Re-read that branch's head when this row is next reviewed. +glibc 2.40 2.44 fine 2026-09-19 The tarball is 2.40 and build/patches/glibc-2.40 carries upstream's maintained release/2.40/master branch to cdaa5d6d (2026-09-10), which is where 2.40's security fixes live. Re-read that branch's head when this row is next reviewed. GLIBC-SA-2026-0022 (CVE-2026-86805, the loader's $ORIGIN check racing for setuid programs; fixed only for 2.45) needs a setuid program whose RPATH climbs out of $ORIGIN, and fs.protected_hardlinks off: su and passwd carry no RPATH (the artifact audit lists every one in the image) and build/config/sysctl.d sets protected_hardlinks to 1. mpc 1.3.1 1.4.1 fine 2026-09-19 NEWS 1.4.0 and 1.4.1: no security or wrong-result fix. 1.4.0 is to be skipped if this ever moves. # --- fine: nothing in the gap is a security fix that reaches Kryptik --------------- From 88fb47f9e7ceac587a146c89bc7e52c1ef9a83ae Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 21:26:17 -0700 Subject: [PATCH 075/213] The hardening doc says stage 06 strips the archives' debug sections too --- docs/hardening.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/hardening.md b/docs/hardening.md index eb768837..1c2b6b19 100644 --- a/docs/hardening.md +++ b/docs/hardening.md @@ -41,8 +41,9 @@ machinery. `make audit-artifacts` (`tools/check-artifact-hardening.sh`) reads the ELF headers, dynamic symbols and code of every object. Stage 06 strips every -x86-64 program and library as it stages the root (the sysroot keeps its -symbols for the build), then runs the audit strict on that tree, so a finding +x86-64 program and library, and the debug sections of every static archive, +as it stages the root (the sysroot keeps its symbols for the build), then +runs the audit strict on that tree, so a finding fails the build, and `make acceptance` runs it strict again on the root image of the release it tests, mounted read-only, for the record. A writable and executable segment, an executable stack or no `GNU_STACK` header at all (the From 0ffd704c9216422d5b5758af7e2206eb933502a1 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 21:27:45 -0700 Subject: [PATCH 076/213] kryptik-recover --status names which slot's kernel the boot file is, says when that is not the committed slot, and gives the medium's own version --- docs/user-guide.md | 5 +++-- tools/image/medium-shell-test.sh | 3 +++ tools/update/kryptik-recover | 20 ++++++++++++++++++-- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/docs/user-guide.md b/docs/user-guide.md index 31639a08..604f4ba1 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -367,8 +367,9 @@ 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. +the tool does not compare versions, so read `--status` first. It shows each +slot's version, which slot's kernel the firmware boots by default, and the +medium's own version, the one `--restore-slot` writes. A machine that stops responding resets itself after about a minute (the software watchdog's timeout; a hardware watchdog keeps its own): a service diff --git a/tools/image/medium-shell-test.sh b/tools/image/medium-shell-test.sh index 5ae98ff5..20ccf8e6 100755 --- a/tools/image/medium-shell-test.sh +++ b/tools/image/medium-shell-test.sh @@ -73,6 +73,7 @@ for ((i = 0; i < ${#MEDIA[@]}; i += 2)); do "send:kryptik-recover --disk /dev/vda --backup-state-header /mnt/kryptik-state-header" "expect:is in /mnt/kryptik-state-header" "send:umount /mnt" "send:kryptik-recover --disk /dev/vda --status" "expect:committed slot a" "expect:slot a +version [0-9]" + "expect:BOOTX64.EFI +slot a's kernel" "expect:this medium +version [0-9][^ ]*, which --restore-slot writes" "send:kryptik-recover --disk /dev/vda --commit-slot a" "expect:committed: BOOTX64.EFI is now slot a" "send:kryptik-recover --disk /dev/vda --restore-slot a" "expect:done: boot /dev/vda without the medium" ) @@ -123,6 +124,8 @@ for ((i = 0; i < ${#MEDIA[@]}; i += 2)); do said 'installed [^ ]* to /dev/vda: boot it from firmware' "an install typed at the console, ERASE and the passphrase twice, completed" said 'is in /mnt/kryptik-state-header' "kryptik-recover kept the state header on a second disk mounted at /mnt" said 'committed slot a' "kryptik-recover --status read the installed disk" + said "BOOTX64.EFI *slot a's kernel" "--status names the slot whose kernel the firmware boots by default" + said 'this medium *version [0-9][^ ]*, which --restore-slot writes' "--status names the version the medium would write" said 'committed: BOOTX64.EFI is now slot a' "kryptik-recover --commit-slot a, typed, made slot a the boot file" said 'done: boot /dev/vda without the medium' "kryptik-recover --restore-slot a, typed, rewrote slot a from the medium" if LC_ALL=C grep -aq $'LUKS\xba\xbe' "$BACKUP"; then green "usb: the second disk holds the LUKS2 header"; else red "usb: no LUKS2 header on the second disk"; fi diff --git a/tools/update/kryptik-recover b/tools/update/kryptik-recover index 83843912..a19e4365 100755 --- a/tools/update/kryptik-recover +++ b/tools/update/kryptik-recover @@ -64,15 +64,31 @@ uncache() { dd if="$1" iflag=nocache count=0 status=none 2>/dev/null || :; } # if [ "$STATUS" = 1 ]; then mount -t vfat -o ro "$ESP" /run/kryptik-recover || die "cannot mount the ESP" + committed="$(esp_slot /run/kryptik-recover/kryptik/committed-slot)" echo "disk $DISK" - echo "committed slot $(esp_slot /run/kryptik-recover/kryptik/committed-slot)" + echo "committed slot $committed" for s in a b; do printf 'slot %s version %s, kernel %s, filesystem %s\n' "$s" \ "$(esp_version "/run/kryptik-recover/kryptik/version-$s" none)" \ "$([ -f "/run/kryptik-recover/EFI/kryptik/kryptik-$s.efi" ] && echo present || echo absent)" \ "$(has_fs "$(slot_dev "$s")" && echo present || echo NONE)" done - echo "BOOTX64.EFI $(sha256sum /run/kryptik-recover/EFI/BOOT/BOOTX64.EFI 2>/dev/null | cut -c1-16)..." + # The kernel the firmware boots by default, by which slot's it is, and what --restore-slot writes. + boot=/run/kryptik-recover/EFI/BOOT/BOOTX64.EFI; boots="" + for s in a b; do + cmp -s "$boot" "/run/kryptik-recover/EFI/kryptik/kryptik-$s.efi" && boots="$s" + done + if [ -n "$boots" ]; then + echo "BOOTX64.EFI slot ${boots}'s kernel" + elif [ -f "$boot" ]; then + echo "BOOTX64.EFI neither slot's kernel (sha256 $(sha256sum "$boot" | cut -c1-16)...)" + else + echo "BOOTX64.EFI absent: the firmware finds nothing to boot by default" + fi + case "$committed" in + a|b) [ "$boots" = "$committed" ] || echo " not the committed slot's kernel: --commit-slot $committed makes it so" ;; + esac + echo "this medium version $(sed -n 's/^VERSION_ID=//p' /etc/os-release | tr -d '"' | head -1), which --restore-slot writes" exit 0 fi From 35ecd8978687dce78cd194336c4433d6e9b7ec57 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 21:30:20 -0700 Subject: [PATCH 077/213] The guide says the medium reads FAT and ext4, not exFAT or NTFS, for the header's copy, and how to make a stick ext4 there --- docs/user-guide.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/user-guide.md b/docs/user-guide.md index 31639a08..298426f6 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -167,8 +167,11 @@ system then asks for at every boot. There is no escrow: without the passphrase, or without the partition's header, the state is lost. It ends with `kryptik-install: installed VERSION to /dev/sdY: boot it from firmware with the medium removed.` and a reminder to keep a copy of that header. Make -the copy now, on a second removable disk (FAT or ext4): `/root`, `/var` and -`/tmp` on a medium are memory, and a file left there is gone at power-off. +the copy now, on a second removable disk: `/root`, `/var` and `/tmp` on a +medium are memory, and a file left there is gone at power-off. The medium +reads FAT and ext4, not exFAT or NTFS, which large sticks and Windows disks +often come as; `mkfs.ext4 /dev/sdZ1` at the medium's shell makes such a +partition ext4, erasing what it holds. ```sh mount /dev/sdZ1 /mnt From 2a8bc029d3415fb023ed9383e22d848433c714a2 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 21:31:09 -0700 Subject: [PATCH 078/213] The guide names zstd for Windows as the README does, with 7-Zip beside it --- docs/user-guide.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/user-guide.md b/docs/user-guide.md index 0780ea26..01c92c90 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -46,8 +46,8 @@ fit a download from the page. Neither macOS nor Windows comes with `zstd`. On macOS, `brew install zstd` (Homebrew) adds it, and the hash check is `shasum -a 256 -c --ignore-missing`. -On Windows, the zstd project's own build (`zstd-vX.Y.Z-win64.zip` from -github.com/facebook/zstd/releases) runs `zstd -d` as above. Run the +On Windows, `zstd.exe` from zstd's releases page runs `zstd -d` as above, +and 7-Zip 24 and later unpacks the image too, as the README says. Run the `ssh-keygen` line in Command Prompt, on one line (PowerShell has no `<`), and compare what `certutil -hashfile kryptik-VERSION-usb.img SHA256` prints with the image's line in `kryptik-VERSION.SHA256SUMS`. `certutil -hashfile From 4d13f34f800afb294344ba733c7e65d7b2328c56 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 21:32:28 -0700 Subject: [PATCH 079/213] kryptik-update calls the manifest's date the created date it names, which a build that sets SOURCE_DATE_EPOCH makes the commit's --- tools/update/kryptik-update | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/update/kryptik-update b/tools/update/kryptik-update index 4859f531..8db0bd2e 100755 --- a/tools/update/kryptik-update +++ b/tools/update/kryptik-update @@ -297,7 +297,7 @@ cmd_check_pointer() { # For the clock's floor (docs/design/time.md): the manifest a slot was applied # from, kept on the state partition, believed only as apply would believe it, -# in any version order, and the date it was signed with. +# in any version order, and the created date its signed manifest names. cmd_check_release() { [ $# = 1 ] && [ -d "$1" ] || die "check-release needs the directory holding manifest and manifest.sig" verify_manifest "$1" any >&2 @@ -393,7 +393,7 @@ cmd_apply() { umount_esp say "kernel for slot $target installed on the ESP (version $VERSION)" # Once boot-success commits the slot, the clock's floor may rise to the - # date this manifest was signed with (docs/design/time.md). + # created date this signed manifest names (docs/design/time.md). { mkdir -p "$B/release-$target" && cp "$m" "$sig" "$B/release-$target/"; } \ || say "the manifest was not kept; committing $VERSION will leave the clock's floor where it is" From ab97b562251b59af91d68dc6b0f4c1c58689648e Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 21:37:16 -0700 Subject: [PATCH 080/213] A failed trial says why in kryptik update status, kryptik-update status, apply's refusal and the menu, where before the release it tried read as waiting to be installed --- build/guest-tests/gui-check.sh | 14 ++++++++++++++ build/service-scripts/boot-success.sh | 7 ++++++- compartments/kryptikd/src/serve.rs | 4 +++- compartments/kryptikd/src/update.rs | 10 ++++++++++ compartments/kryptikd/src/update/tests.rs | 22 ++++++++++++++++++++++ docs/design/boot-and-updates.md | 6 +++++- docs/user-guide.md | 3 ++- tools/desktop/kryptik-chrome | 8 +++++++- tools/image/gui-test.sh | 2 +- tools/image/update-test.sh | 5 +++-- tools/tests/boot-success.sh | 3 +++ tools/update/kryptik-update | 5 +++-- 12 files changed, 79 insertions(+), 10 deletions(-) diff --git a/build/guest-tests/gui-check.sh b/build/guest-tests/gui-check.sh index 15041564..061eed3b 100755 --- a/build/guest-tests/gui-check.sh +++ b/build/guest-tests/gui-check.sh @@ -402,6 +402,20 @@ if [[ "$auto_on" == on && "$auto_off" == gone ]]; then else fail "menu-update-auto" "after a: ${auto_on:-no record}; after m: ${auto_off}; $(tail -4 "$LOG/menu-update.out" | tr '\n' ' ')" fi +# A trial that failed reads as one, not as a release to install: the menu names it from +# kryptikd's status with boot-success's reason, and the record is put back as it was. +BOOTST=/var/lib/kryptik/boot +rm -f /root/gt/trial.failed.kept +[[ -e "$BOOTST/trial.failed" ]] && cp -p "$BOOTST/trial.failed" /root/gt/trial.failed.kept +printf 'b\narmed=1\nwhy=came up unhealthy: service net-zone is not up\n' > "$BOOTST/trial.failed" +failed_menu="$(menu_text)" +if [[ -e /root/gt/trial.failed.kept ]]; then mv -f /root/gt/trial.failed.kept "$BOOTST/trial.failed"; else rm -f "$BOOTST/trial.failed"; fi +told="$(grep -o 'Update: the trial of slot b .*' <<<"$failed_menu")" +if [[ "$told" == "Update: the trial of slot b came up unhealthy: service net-zone is not up; this machine runs the release before it (u for more)" ]]; then + pass "menu-names-failed-trial" "$told" +else + fail "menu-names-failed-trial" "the menu said: $(tr '\n' ' ' <<<"$failed_menu" | cut -c1-300)" +fi # A zone runs one command at a time, so its window is stopped before the next. stop_zone() { as_user "kryptik-launch --stop $1" > /dev/null 2>&1; wait_for 15 test ! -e "/run/kryptik/zones/$1/init.pid"; sleep 1; } diff --git a/build/service-scripts/boot-success.sh b/build/service-scripts/boot-success.sh index ba756516..0fd571fa 100755 --- a/build/service-scripts/boot-success.sh +++ b/build/service-scripts/boot-success.sh @@ -155,7 +155,11 @@ if [ -n "$trial" ]; then printf 'trial-unhealthy %s: %s\n' "$slot" "$(printf '%s' "$failures" | tr '\n' ';')" > "$B/last-result.new" \ && mv -f "$B/last-result.new" "$B/last-result" forgot=1; forget_entries "$(other_slot "$slot")" || forgot=0 - [ ! -f "$B/trial" ] || mv -f "$B/trial" "$B/trial.failed" + if [ -f "$B/trial" ]; then + mv -f "$B/trial" "$B/trial.failed" + # Why, kept with the record: the next boot's own result replaces last-result. + printf 'why=came up unhealthy: %s\n' "$(printf '%s' "$failures" | tr '\n' ';' | sed 's/;$//; s/;/; /g')" >> "$B/trial.failed" + fi sync if [ "$forgot" = 0 ] && [ -n "$unrecorded" ]; then # No trial record: only removing its entries stops the next boot repeating it. @@ -176,6 +180,7 @@ if [ -n "$trial" ]; then result "trial-failed $trial" forget_entries "$slot" mv -f "$B/trial" "$B/trial.failed" + echo "why=did not come up to be judged" >> "$B/trial.failed" else say "the arming of slot $trial was interrupted before BootNext was set; nothing was tried" result "arming-interrupted $trial" diff --git a/compartments/kryptikd/src/serve.rs b/compartments/kryptikd/src/serve.rs index 6d4cecbb..9991417d 100644 --- a/compartments/kryptikd/src/serve.rs +++ b/compartments/kryptikd/src/serve.rs @@ -1063,7 +1063,9 @@ fn handle(cfg: &ServeConfig, conn: UnixStream, jobs: &mut Vec) -> Option { let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64); - Ok(up::status(dir, now, &running, up::expecting_since())) + let mut out = up::status(dir, now, &running, up::expecting_since()); + out.extend(up::trial_line(Path::new(up::TRIAL), &running)); + Ok(out) } "update-fetch" => { let conf = std::fs::read_to_string(up::CONF).unwrap_or_default(); diff --git a/compartments/kryptikd/src/update.rs b/compartments/kryptikd/src/update.rs index f59a1708..e64e37ea 100644 --- a/compartments/kryptikd/src/update.rs +++ b/compartments/kryptikd/src/update.rs @@ -815,6 +815,16 @@ pub fn complete_stage(dir: &Path) -> Result { } } +/// `status`'s line for a trial that failed, from the record boot-success set aside as +/// `trial.failed` with its `why=`: the release it tried still reads as staged and complete. +pub fn trial_line(trial: &Path, running: &str) -> Option { + let text = std::fs::read_to_string(trial.with_file_name("trial.failed")).ok()?; + let slot = text.lines().next().filter(|s| matches!(*s, "a" | "b"))?; + // A record from before boot-success said why reads as failed. + let why: String = text.lines().find_map(|l| l.strip_prefix("why=")).unwrap_or("failed").chars().filter(|c| !c.is_control()).take(300).collect(); + Some(format!("trial the trial of slot {slot} {why}; this machine runs {running} again, and `kryptik update apply` says how to try once more\n")) +} + /// Clear the staged release once the machine runs it or a newer one. pub fn forget_if_installed(dir: &Path, running: &str) { forget_unless_trial(dir, running, Path::new(TRIAL)); diff --git a/compartments/kryptikd/src/update/tests.rs b/compartments/kryptikd/src/update/tests.rs index 5eca115e..6c77343c 100644 --- a/compartments/kryptikd/src/update/tests.rs +++ b/compartments/kryptikd/src/update/tests.rs @@ -438,6 +438,28 @@ fn a_manifest_checked_while_its_stage_was_cleared_is_not_kept() { let _ = std::fs::remove_dir_all(&d); } +#[test] +fn a_failed_trial_is_said_with_its_reason() { + let d = scratch("trial-line"); + std::fs::create_dir_all(&d).unwrap(); + let trial = d.join("trial"); + assert_eq!(trial_line(&trial, "1.0.2"), None, "no record, no line"); + let failed = d.join("trial.failed"); + std::fs::write(&failed, "b\narmed=1\nwhy=came up unhealthy: service net-zone is not up; service seatd is not up\n").unwrap(); + assert_eq!( + trial_line(&trial, "1.0.2").as_deref(), + Some("trial the trial of slot b came up unhealthy: service net-zone is not up; service seatd is not up; this machine runs 1.0.2 again, and `kryptik update apply` says how to try once more\n") + ); + // An older record has no reason; one naming no slot, or carrying control characters, is not repeated as it is. + std::fs::write(&failed, "a\narmed=1\n").unwrap(); + assert!(trial_line(&trial, "1.0.2").unwrap().starts_with("trial the trial of slot a failed; ")); + std::fs::write(&failed, "c\nwhy=x\n").unwrap(); + assert_eq!(trial_line(&trial, "1.0.2"), None); + std::fs::write(&failed, "b\nwhy=did not \u{1b}[2Jcome up\n").unwrap(); + assert!(!trial_line(&trial, "1.0.2").unwrap().contains('\u{1b}')); + let _ = std::fs::remove_dir_all(&d); +} + #[test] fn auto_refuses_what_fetch_refuses() { let d = scratch("auto-refused"); diff --git a/docs/design/boot-and-updates.md b/docs/design/boot-and-updates.md index dc4ea3dc..bb41ff53 100644 --- a/docs/design/boot-and-updates.md +++ b/docs/design/boot-and-updates.md @@ -160,7 +160,11 @@ payloads come from [the update channel](update-channel.md) or by hand. committed slot, `BootNext` being spent. On a degraded state the trial record is out of reach, so `committed-slot` says whether the boot is a trial. A trial that never comes up also lands on the committed slot; the - fallback records `trial.failed` and forgets the entries, and the updater + fallback records `trial.failed` and forgets the entries. Either way + `trial.failed` keeps a `why=` line (the failures, or that it never came up + to be judged), since the next boot's own result replaces `last-result`; + `kryptik update status`, `kryptik-update status` and the launcher repeat + it, so the release reads as tried, not as waiting. The updater will not re-arm that payload without `--retry`, which root gives (`su` from the administration login on tty2; the refusal prints the command). Only a trial reboots; an unhealthy committed slot is reported and left running, diff --git a/docs/user-guide.md b/docs/user-guide.md index ae122250..cfaa2ea8 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -333,7 +333,8 @@ kernel on the ESP under its own name and arms one trial boot (`BootNext`). The next boot runs the new slot; `boot-success` judges it (state partition usable, services up, the zone supervisor healthy) and only then makes it the committed boot file. An unhealthy trial reboots into the previous slot by -itself. Persistent zone data on `kryptik-state` is never written by any of +itself, and `kryptik update status` and the menu then say what it found. +Persistent zone data on `kryptik-state` is never written by any of this. `apply` refuses while another trial is armed (reboot first), while the state partition is degraded, and on any slot but the committed one, such as one picked in the firmware's boot menu: the refusal says to boot the diff --git a/tools/desktop/kryptik-chrome b/tools/desktop/kryptik-chrome index 46364dee..7c9031c5 100755 --- a/tools/desktop/kryptik-chrome +++ b/tools/desktop/kryptik-chrome @@ -155,9 +155,15 @@ menu_update() { v="$(printf '%s\n' "$st" | sed -n 's/^staged *\([^:]*\): .*, complete.*/\1/p')" # kryptikd's words up to their reason: the newest statement's age or, with none ever accepted, the install's. quiet="$(printf '%s\n' "$st" | sed -n 's/^ *\(no statement from the release key [^:]*\):.*/\1/p' | head -1)" - [ -n "$v$quiet" ] || return 0 + # A trial that failed: what is staged is the release it tried, which apply would refuse. + failed="$(printf '%s\n' "$st" | sed -n 's/^trial *\(the trial of [^;]*\);.*/\1/p' | head -1)" + [ -n "$v$quiet$failed" ] || return 0 echo [ -z "$quiet" ] || echo "Update: ${quiet} (u for more)" + if [ -n "$failed" ]; then + echo "Update: ${failed}; this machine runs the release before it (u for more)" + return 0 + fi [ -z "$v" ] && return 0 echo "Update: ${v} has arrived. u installs it, and the next boot tries it." } diff --git a/tools/image/gui-test.sh b/tools/image/gui-test.sh index 14089f4a..a4377be2 100755 --- a/tools/image/gui-test.sh +++ b/tools/image/gui-test.sh @@ -108,7 +108,7 @@ gp="$(sed -n 's/.*passed=\([0-9]*\).*/\1/p' <<<"$summary")"; gf="$(sed -n 's/.*f if [[ -n "$summary" && "${gf:-1}" -eq 0 && "${gp:-0}" -ge 25 ]]; then green "every guest check passed (${gp})"; else red "guest checks: ${gp:-0} passed, ${gf:-?} failed"; fi grep 'GT FAIL' <<<"$T" | sed 's/^/ /' for name in session-socket compositor-running chrome-focus-record chrome-window-is-zone0 zone0-sees-capture zone-proxy-path zone-sees-needed zone-hidden-globals zone-bind-refused proxy-logged-refusal \ - map-keeps-zone0-focus close-keeps-zone0-focus alone-keeps-no-keyboard alone-reached-by-key focus-shows-zone focus-shows-label title-prefixed last-zone-recorded menu-opens-on-key menu-keeps-last-zone menu-names-stale-statement menu-names-install-age menu-update-auto zone-fullscreen-refused compositor-survives-close oversize-window forged-title-named-by-zone second-zone-window zone0-own-programs-only zone-app-in-cgroup no-virtual-input clipboard-isolated clipboard-move-gesture clipboard-moved \ + map-keeps-zone0-focus close-keeps-zone0-focus alone-keeps-no-keyboard alone-reached-by-key focus-shows-zone focus-shows-label title-prefixed last-zone-recorded menu-opens-on-key menu-keeps-last-zone menu-names-stale-statement menu-names-install-age menu-update-auto menu-names-failed-trial zone-fullscreen-refused compositor-survives-close oversize-window forged-title-named-by-zone second-zone-window zone0-own-programs-only zone-app-in-cgroup no-virtual-input clipboard-isolated clipboard-move-gesture clipboard-moved \ transfer-policy no-question-for-policy-refusal consent-code-shown transfer-approved transfer-landed plain-y-refused denied-file-absent \ second-head-appears chrome-follows-head second-head-zone-window second-head-names-zone second-head-gone compositor-survives-unplug zone-survives-unplug chrome-back-on-first-head \ zone0-cursor-set zone0-cursor-shown zone-cursor-asked zone-hears-of-outputs zone-cursor-not-shown compositor-socket-unreached \ diff --git a/tools/image/update-test.sh b/tools/image/update-test.sh index 97aae1ac..eb95409f 100755 --- a/tools/image/update-test.sh +++ b/tools/image/update-test.sh @@ -342,9 +342,10 @@ drive "expect:BdsDxe: starting Boot" \ "expect:Linux version" "expect:KRYPTIK_SMOKE: END" \ "login:${TUSER}:${TPASS}" \ "$(ROOTSH 'cat /run/kryptik/boot-identity | head -1; cat /var/lib/kryptik/boot/last-result; kryptik-update status; echo P7B-OK')" \ - "expect:slot=a" "expect:trial-failed b" "expect:failed trial: +b\r?\ncommitted slot:" "expect:P7B-OK" \ + "expect:slot=a" "expect:trial-failed b" "expect:failed trial: +b: did not come up to be judged\r?\ncommitted slot:" "expect:P7B-OK" \ + "run:kryptik update status" "expect:trial +the trial of slot b did not come up to be judged; this machine runs [^ ]+ again" \ "$(ROOTSH 'mkdir -p /run/upd/p && mount -o ro /dev/vdb /run/upd/p; kryptik-update apply /run/upd/p; echo RC=$?')" \ - "expect:the last trial of slot b failed: it did not boot, or came up unhealthy" "expect:RC=1\r?\n" \ + "expect:the last trial of slot b did not come up to be judged\. To try it again" "expect:RC=1\r?\n" \ "$(ROOTSH 'kryptik-update apply /run/upd/p --retry && echo RETRY-OK')" "expect:slot b verifies after write" "expect:RETRY-OK" \ "$(ROOTSH 'reboot')" "expect:Linux version" "expect:KRYPTIK_SMOKE: END" \ "login:${TUSER}:${TPASS}" \ diff --git a/tools/tests/boot-success.sh b/tools/tests/boot-success.sh index 7a74a008..ec5417e0 100755 --- a/tools/tests/boot-success.sh +++ b/tools/tests/boot-success.sh @@ -162,6 +162,8 @@ for missing in net-zone kryptikd-serve seatd getty-tty1 eudev; do check "trial with $missing down: not committed, recorded, rebooted" "${RESULT%%:*}|$(cat "$KTEST/esp/EFI/BOOT/BOOTX64.EFI")|$([[ -e "$KTEST/boot/trial.failed" ]] && echo failed)|$(reboots)" "trial-unhealthy b|kernel-a|failed|1" done run_case unkernel b "" persistent 'b\narmed=1\n' $ALL; rm -f "$KTEST/kernel_ok"; go +check "... and the set-aside record says why, which the next boot's result does not replace" \ + "$(sed -n 's/^why=//p' "$KTEST/boot/trial.failed")" "came up unhealthy: service eudev is not up" check "trial without kernel zone support: not committed, rebooted" "${RESULT%%:*}|$(reboots)" "trial-unhealthy b|1" run_case unzones b "" persistent 'b\narmed=1\n' $ALL; rm -f "$KTEST/zones_ok"; go check "trial whose zones do not load: not committed" "${RESULT%%:*}" "trial-unhealthy b" @@ -193,6 +195,7 @@ echo "-- a trial that did not boot" run_case failed a "" persistent 'b\narmed=1\n' $ALL; go check "back on the old slot with BootNext consumed: trial-failed" "$RESULT" "trial-failed b" check "the record moved to trial.failed" "$([[ -e "$KTEST/boot/trial.failed" ]] && cat "$KTEST/boot/trial.failed" | head -1)" "b" +check "... saying it never came up to be judged" "$(sed -n 's/^why=//p' "$KTEST/boot/trial.failed")" "did not come up to be judged" check "BOOTX64.EFI untouched" "$(cat "$KTEST/esp/EFI/BOOT/BOOTX64.EFI")" "kernel-a" check "the failed trial's entries are forgotten, and the committed slot gets its own" "$CALLS" "efiboot forget efiboot ensure a " check "... before its record is set aside" "$(cat "$KTEST/forget-saw" 2>/dev/null)" "kept" diff --git a/tools/update/kryptik-update b/tools/update/kryptik-update index 4859f531..5531dfdc 100755 --- a/tools/update/kryptik-update +++ b/tools/update/kryptik-update @@ -103,7 +103,7 @@ cmd_status() { slot="$(running_slot)"; echo "running slot: ${slot:-unknown} (version $(running_version))" echo "trial pending: $(sed -n 1p "$B/trial" 2>/dev/null || true)$([ -r "$B/trial" ] && printf ' (%s)' "$(sed -n 's/^armed=//p' "$B/trial" | sed 's/1/BootNext armed/;s/0/not yet armed/')" || echo none)" echo "last boot result: $(cat "$B/last-result" 2>/dev/null || echo none)" - echo "failed trial: $(sed -n 1p "$B/trial.failed" 2>/dev/null || echo none)" + echo "failed trial: $(sed -n 1p "$B/trial.failed" 2>/dev/null || echo none)$(sed -n 's/^why=/: /p' "$B/trial.failed" 2>/dev/null | head -1)" # The ESP is an apply's or a rollback's while one holds the lock. exec 9>"$LOCK" if flock -s -n 9; then @@ -350,7 +350,8 @@ cmd_apply() { die "a trial of slot $(sed -n 1p "$B/trial") is already armed; reboot first" fi if [ "$retry" != 1 ] && [ -f "$B/trial.failed" ]; then - die "the last trial of slot $(sed -n 1p "$B/trial.failed") failed: it did not boot, or came up unhealthy. To try it again, run as root: kryptik-update apply $dir --retry" + why="$(sed -n 's/^why=//p' "$B/trial.failed" | head -1)" + die "the last trial of slot $(sed -n 1p "$B/trial.failed") ${why:-failed: it did not boot, or came up unhealthy}. To try it again, run as root: kryptik-update apply $dir --retry" fi from_committed "$slot" From 3cb972369435a9f334326accb54277e46a703246 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 21:38:47 -0700 Subject: [PATCH 081/213] Release notes give one line per accepted reason, naming every object it covers, where each object had its own --- tools/release-notes.sh | 10 ++++++++-- tools/tests/release-notes.sh | 4 ++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/tools/release-notes.sh b/tools/release-notes.sh index 8816ce4f..cf1b0d46 100755 --- a/tools/release-notes.sh +++ b/tools/release-notes.sh @@ -100,7 +100,8 @@ if [[ -n "$SINCE" ]]; then | sed 's/^Merge [^:]*: //' | grep -v '^Merge ' | sed 's/^./\U&/; s/^/- /' || true)" fi -# The ELF audit: nothing refused, reported or stale; each accepted kind with its list's reasons. +# The ELF audit: nothing refused, reported or stale; each accepted kind with its list's reasons, +# one line per reason, naming every object it covers. audit="$(python3 - "${RUN}/artifact-hardening.json" "${KRYPTIK_ROOT}/build/config/artifact-accepted.txt" <<'PY' import json, sys, collections def many(n, one, other=None): @@ -132,8 +133,13 @@ print(f"- ELF objects: {a.get('objects', 0)} audited ({many(a.get('executables', + (" (`build/config/artifact-accepted.txt`):" if kinds else ".")) for kind in sorted(kinds): print(f" - {kind}, {many(kinds[kind], 'object')}:") + shared = {} for what, why in reasons.get(kind, []): - print(f" - {what}: {why}") + shared.setdefault(why, []).append(what) + for why, whats in shared.items(): + # An RPATH entry has a comma of its own. + sep = "; " if any(", rpath" in w for w in whats) else ", " + print(f" - {sep.join(whats)}: {why}") print(f"- Stack protector in {f.get('HAS-SSP', 0)} objects, FORTIFY_SOURCE in {f.get('HAS-FORTIFY', 0)}.") PY )" || die "the hardening audit does not allow notes" diff --git a/tools/tests/release-notes.sh b/tools/tests/release-notes.sh index f6ae0b1d..60ffa84a 100755 --- a/tools/tests/release-notes.sh +++ b/tools/tests/release-notes.sh @@ -128,10 +128,10 @@ fi if has "Kernel: 2 kernel-hardening-checker findings accepted" && has "Compiler flags: 1 exception to" \ && has "setuid and setgid: 2 programs," && has "Programs with file capabilities: 0." \ && has "(766 executables, 554 libraries). None refused, none reported, 3 accepted" && has "NO-CET, 2 objects:" \ - && has "\`usr/bin/kryptik-wlproxy\`: rustc marks nothing for CET" && has "RPATH, 1 object:" \ + && has "\`usr/bin/kryptikd\`, \`usr/bin/kryptik-wlproxy\`: rustc marks nothing for CET" && has "RPATH, 1 object:" \ && has "\`usr/lib/gconv/*.so\`, rpath \`\$ORIGIN\`: glibc's converters" \ && has "Stack protector in 1233 objects, FORTIFY_SOURCE in 854."; then - green "the audit's counts per list, and each accepted kind with its reasons" + green "the audit's counts per list, and each accepted kind with its reasons, one line per reason" else red "the hardening audit"; sed 's/^/ /' "$OUT" fi From 61b31ffc76086888fb92d4d73539306b35fb1a69 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 21:38:52 -0700 Subject: [PATCH 082/213] libinput ships its C tools only: the Python ones cannot import what they need libinput installs eleven Python tools (measure-fuzz, the touchpad measures, replay, the analyzers, list-kernel-devices), each importing python-libevdev, pyudev or PyYAML. The image has none of them (pip is the only site package), so every one fails at its imports; measure and analyze only dispatch to them. They and their man pages go; debug-events, list-devices, record, quirks and the tablet debuggers, which a report from physical hardware needs, stay. --- build/recipes/libinput.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/build/recipes/libinput.sh b/build/recipes/libinput.sh index 7afc879d..5f9c46e9 100644 --- a/build/recipes/libinput.sh +++ b/build/recipes/libinput.sh @@ -3,4 +3,12 @@ s_libinput() { meson_build "libinput-${V_LIBINPUT}.tar.gz" "libinput-${V_LIBINPUT}" \ -Dlibwacom=false -Ddebug-gui=false -Dtests=false -Ddocumentation=false -Dzshcompletiondir=no + # The Python tools import python-libevdev, pyudev or PyYAML, none of which the image has, and measure and + # analyze only dispatch to them; debug-events, list-devices, record and quirks stay. + local t + for t in measure analyze analyze-buttons analyze-per-slot-delta analyze-recording analyze-touch-down-state list-kernel-devices \ + measure-fuzz measure-touchpad-size measure-touchpad-tap measure-touchpad-pressure measure-touch-size replay; do + rm -f "/usr/libexec/libinput/libinput-${t}" "/usr/share/man/man1/libinput-${t}.1" + done + [[ -x /usr/libexec/libinput/libinput-debug-events ]] || { echo "FAIL: libinput debug-events is missing"; return 1; } } From 49830f283d258c92c9bd53fc8dd8760a297d2e6f Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 21:39:46 -0700 Subject: [PATCH 083/213] zone-source-pinned counts what the net zone took in by the port it was sent to, as a zone's own resolver takes it on another Distro 38201913776 on 624cd5be: 107 of 108; zone-source-pinned found none of personal's own datagrams taken in, because its input-chain counters matched udp dport 53 and the dns chain now sends personal's queries to port 5304 before input. The counters match ct original proto-dst 53, which conntrack keeps through the dnat; what reaches the net zone ahead of its chains is counted as before. --- build/guest-tests/zones-check.sh | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index 5780ea39..432cbb9a 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -354,7 +354,8 @@ fi # unprivileged socket send from an address its host does not hold. personal # sends to the bridge's resolver port from its own addresses (the control) and # with FREEBIND from untrusted's. Counters in the net zone say what reached it, ahead of its -# own chains, and what it took in after them. +# own chains, and what it took in after them, by the port it was sent to: a zone's own +# resolver takes its queries on another (the dns chain). net_init="$(cut -d' ' -f1 /run/kryptik/zones/net/init.pid 2>/dev/null)" netns() { nsenter -t "${net_init:-0}" -n "$@"; } own4="10.19.0.$PER"; other4="10.19.0.$UNT" @@ -371,10 +372,10 @@ table inet ztprobe { } chain taken { type filter hook input priority 100; - ip saddr $own4 udp dport 53 counter comment "taken-own4" - ip saddr $other4 udp dport 53 counter comment "taken-other4" - ip6 saddr $own6 udp dport 53 counter comment "taken-own6" - ip6 saddr $other6 udp dport 53 counter comment "taken-other6" + ip saddr $own4 meta l4proto udp ct original proto-dst 53 counter comment "taken-own4" + ip saddr $other4 meta l4proto udp ct original proto-dst 53 counter comment "taken-other4" + ip6 saddr $own6 meta l4proto udp ct original proto-dst 53 counter comment "taken-own6" + ip6 saddr $other6 meta l4proto udp ct original proto-dst 53 counter comment "taken-other6" } } EOF From 51a6b3ca1896eeee2b3fe6bc3b9748f12424ec7d Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 21:42:07 -0700 Subject: [PATCH 084/213] The boot-success test reads each why= in the case that wrote it, and checks several failures joined --- tools/tests/boot-success.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tools/tests/boot-success.sh b/tools/tests/boot-success.sh index ec5417e0..6123b23c 100755 --- a/tools/tests/boot-success.sh +++ b/tools/tests/boot-success.sh @@ -162,14 +162,16 @@ for missing in net-zone kryptikd-serve seatd getty-tty1 eudev; do check "trial with $missing down: not committed, recorded, rebooted" "${RESULT%%:*}|$(cat "$KTEST/esp/EFI/BOOT/BOOTX64.EFI")|$([[ -e "$KTEST/boot/trial.failed" ]] && echo failed)|$(reboots)" "trial-unhealthy b|kernel-a|failed|1" done run_case unkernel b "" persistent 'b\narmed=1\n' $ALL; rm -f "$KTEST/kernel_ok"; go -check "... and the set-aside record says why, which the next boot's result does not replace" \ - "$(sed -n 's/^why=//p' "$KTEST/boot/trial.failed")" "came up unhealthy: service eudev is not up" check "trial without kernel zone support: not committed, rebooted" "${RESULT%%:*}|$(reboots)" "trial-unhealthy b|1" +check "... and the set-aside record says why, which the next boot's result does not replace" \ + "$(sed -n 's/^why=//p' "$KTEST/boot/trial.failed")" "came up unhealthy: kryptikd check: no kernel support for zones" run_case unzones b "" persistent 'b\narmed=1\n' $ALL; rm -f "$KTEST/zones_ok"; go check "trial whose zones do not load: not committed" "${RESULT%%:*}" "trial-unhealthy b" run_case unforget b "" persistent 'b\narmed=1\n' eudev; go check "an unhealthy trial forgets its entries, gives the committed slot its own, then reboots" "$CALLS" "efiboot forget efiboot ensure a reboot " check "an unhealthy trial's entries go before its record is set aside" "$(cat "$KTEST/forget-saw" 2>/dev/null)" "kept" +check "... and several failures are kept in the order they were found" "$(sed -n 's/^why=//p' "$KTEST/boot/trial.failed")" \ + "came up unhealthy: service seatd is not up; service kryptikd-serve is not up; service net-zone is not up; service getty-tty1 is not up" run_case unforget2 b "" persistent 'b\narmed=1\n' eudev; : > "$KTEST/efiboot_fails"; go check "... and reboots if they stay: its record, now trial.failed, keeps it from coming back" "$(reboots)" "1" # On a degraded state /var is a tmpfs, so the trial record is out of reach. From f164a802b189ba323a84391e5ae91fb2c928722e Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 21:57:56 -0700 Subject: [PATCH 085/213] The releases page's list names the update payload it carries, which the guide's release brought by hand is made of --- docs/releases.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/releases.md b/docs/releases.md index 80ef3097..077ab445 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -59,6 +59,7 @@ the release key made offline. The dated builds CI makes of every push to main | `kryptik-VERSION.SHA256SUMS`, `.sig`, `release-signers` | the media's hashes, the release key's signature over them, and the anchor that checks it | | `kryptik-sb.crt`, `kryptik-sb.der` | the Secure Boot certificate that signed the kernels | | `root.json`, `manifest-VERSION`, `.sig` | the root's dm-verity record and the signed release manifest | +| `manifest`, `manifest.sig`, the root image, `kryptik-a.efi`, `kryptik-b.efi` | the update payload under the names its manifest gives: a channel's base, and with `root.json` the directory a release brought by hand holds ([user guide](user-guide.md), section 4) | | `source-REVISION.tar` | the corresponding source: every tarball with its signatures, the crates the Rust binaries link, and the tree | | `RELEASE.txt`, `REVISION.txt`, `RELEASE-NOTES.md`, `INSTRUCTIONS.md`, `ACCEPTANCE-REPORT.md`, `SHA256SUMS`, `kryptik-VERSION-acceptance-logs.tar.zst` | the record: the verdict, the revision, the notes, the instructions, and every item's result and log | From 9e08fa2aeb8c339d66134c016544146d07bc98d7 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 21:59:03 -0700 Subject: [PATCH 086/213] ADR-007 names the net zone's own interfaces beside loopback and the veth --- docs/decisions.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/decisions.md b/docs/decisions.md index be702fd6..703fc0e1 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -73,8 +73,9 @@ AppArmor is path-based, which composes badly with per-zone mount namespaces, where one path means different things in different zones. Isolation rests on Landlock for file access, seccomp-bpf for syscalls, cgroup v2 for resources, and namespaces for network and IPC: a zone's network is the interfaces in its -namespace (loopback alone, or one veth into the net zone), not a policy rule -inside the zone, so Landlock's late network support does not matter. +namespace (loopback alone, one veth into the net zone, or, for the net zone, +the machine's own NICs), not a policy rule inside the zone, so Landlock's +late network support does not matter. **Cost:** a Landlock bypass has no second MAC layer behind it. From d419d3845715c6b1b53e9d136a6331fcdcbccc2b Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 21:59:56 -0700 Subject: [PATCH 087/213] ADR-007 names the bridge beside the net zone's NICs --- docs/decisions.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/decisions.md b/docs/decisions.md index 703fc0e1..d31efbea 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -74,8 +74,8 @@ where one path means different things in different zones. Isolation rests on Landlock for file access, seccomp-bpf for syscalls, cgroup v2 for resources, and namespaces for network and IPC: a zone's network is the interfaces in its namespace (loopback alone, one veth into the net zone, or, for the net zone, -the machine's own NICs), not a policy rule inside the zone, so Landlock's -late network support does not matter. +the machine's own NICs and the bridge the others' veths join), not a policy +rule inside the zone, so Landlock's late network support does not matter. **Cost:** a Landlock bypass has no second MAC layer behind it. From 2dc54ef1b73f491456e2f09af018130c32e7bb3b Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 22:00:33 -0700 Subject: [PATCH 088/213] hardening.md says the preload reaches every dynamically linked process, as ADR-005 now does --- docs/hardening.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/hardening.md b/docs/hardening.md index 929586a7..1f19e925 100644 --- a/docs/hardening.md +++ b/docs/hardening.md @@ -69,8 +69,10 @@ left in the image. ADR-005 makes hardened_malloc the system allocator. Stage 04 builds it without `-march=native` and installs `/usr/lib/libhardened_malloc.so`. Stage 06 writes -`/etc/ld.so.preload` into the image's root, so every process of the running -system uses it, and kryptikd writes each zone its own preload naming only that +`/etc/ld.so.preload` into the image's root, so every dynamically linked +process of the running system uses it (kryptikd and kryptik-wlproxy are +static musl binaries, which no preload reaches), and kryptikd writes each +zone its own preload naming only that library (a zone never sees the host's). The build chroot never has the file. hardened_malloc's guard pages are separate mappings, so `vm.max_map_count` is 1048576. The booted medium and the zones suite check that a process has the From b9b87e4aeb9dafae9b0fb65ab6f7a15cda73c180 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 22:00:55 -0700 Subject: [PATCH 089/213] The allocator paragraph is wrapped again --- docs/hardening.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/hardening.md b/docs/hardening.md index 1f19e925..3a725001 100644 --- a/docs/hardening.md +++ b/docs/hardening.md @@ -72,8 +72,8 @@ ADR-005 makes hardened_malloc the system allocator. Stage 04 builds it without `/etc/ld.so.preload` into the image's root, so every dynamically linked process of the running system uses it (kryptikd and kryptik-wlproxy are static musl binaries, which no preload reaches), and kryptikd writes each -zone its own preload naming only that -library (a zone never sees the host's). The build chroot never has the file. +zone its own preload naming only that library (a zone never sees the +host's). The build chroot never has the file. hardened_malloc's guard pages are separate mappings, so `vm.max_map_count` is 1048576. The booted medium and the zones suite check that a process has the library mapped. No benchmark numbers are claimed. From 3146851ae5598aeb4f8b0537f657351c6767d611 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 22:02:44 -0700 Subject: [PATCH 090/213] An armed trial shows in kryptik update status and the menu, where after an install the release read as waiting to be installed again --- build/guest-tests/gui-check.sh | 12 ++++++++++++ compartments/kryptikd/src/update.rs | 8 ++++++-- compartments/kryptikd/src/update/tests.rs | 6 ++++++ docs/user-guide.md | 5 +++-- tools/desktop/kryptik-chrome | 14 +++++++------- tools/image/gui-test.sh | 2 +- tools/image/update-test.sh | 1 + 7 files changed, 36 insertions(+), 12 deletions(-) diff --git a/build/guest-tests/gui-check.sh b/build/guest-tests/gui-check.sh index 061eed3b..adde4aa9 100755 --- a/build/guest-tests/gui-check.sh +++ b/build/guest-tests/gui-check.sh @@ -416,6 +416,18 @@ if [[ "$told" == "Update: the trial of slot b came up unhealthy: service net-zon else fail "menu-names-failed-trial" "the menu said: $(tr '\n' ' ' <<<"$failed_menu" | cut -c1-300)" fi +# A trial armed for the next boot reads as armed, not as a release to install again. +rm -f /root/gt/trial.kept +[[ -e "$BOOTST/trial" ]] && cp -p "$BOOTST/trial" /root/gt/trial.kept +printf 'b\narmed=1\n' > "$BOOTST/trial" +armed_menu="$(menu_text)" +if [[ -e /root/gt/trial.kept ]]; then mv -f /root/gt/trial.kept "$BOOTST/trial"; else rm -f "$BOOTST/trial"; fi +told="$(grep -o 'Update: slot b is armed.*' <<<"$armed_menu")" +if [[ "$told" == "Update: slot b is armed: the next boot tries it (u for more)" ]]; then + pass "menu-names-armed-trial" "$told" +else + fail "menu-names-armed-trial" "the menu said: $(tr '\n' ' ' <<<"$armed_menu" | cut -c1-300)" +fi # A zone runs one command at a time, so its window is stopped before the next. stop_zone() { as_user "kryptik-launch --stop $1" > /dev/null 2>&1; wait_for 15 test ! -e "/run/kryptik/zones/$1/init.pid"; sleep 1; } diff --git a/compartments/kryptikd/src/update.rs b/compartments/kryptikd/src/update.rs index e64e37ea..0d802cce 100644 --- a/compartments/kryptikd/src/update.rs +++ b/compartments/kryptikd/src/update.rs @@ -815,9 +815,13 @@ pub fn complete_stage(dir: &Path) -> Result { } } -/// `status`'s line for a trial that failed, from the record boot-success set aside as -/// `trial.failed` with its `why=`: the release it tried still reads as staged and complete. +/// `status`'s line for a trial: one armed for the next boot, or one that failed, from the +/// record boot-success set aside as `trial.failed` with its `why=`. Either way the release it +/// tries, or tried, still reads as staged and complete. pub fn trial_line(trial: &Path, running: &str) -> Option { + if let Some(slot) = std::fs::read_to_string(trial).ok().and_then(|t| t.lines().next().filter(|s| matches!(*s, "a" | "b")).map(str::to_string)) { + return Some(format!("trial slot {slot} is armed: the next boot tries it, and it stays once it comes up healthy\n")); + } let text = std::fs::read_to_string(trial.with_file_name("trial.failed")).ok()?; let slot = text.lines().next().filter(|s| matches!(*s, "a" | "b"))?; // A record from before boot-success said why reads as failed. diff --git a/compartments/kryptikd/src/update/tests.rs b/compartments/kryptikd/src/update/tests.rs index 6c77343c..423689d0 100644 --- a/compartments/kryptikd/src/update/tests.rs +++ b/compartments/kryptikd/src/update/tests.rs @@ -457,6 +457,12 @@ fn a_failed_trial_is_said_with_its_reason() { assert_eq!(trial_line(&trial, "1.0.2"), None); std::fs::write(&failed, "b\nwhy=did not \u{1b}[2Jcome up\n").unwrap(); assert!(!trial_line(&trial, "1.0.2").unwrap().contains('\u{1b}')); + // A trial armed again, as apply --retry arms it, is said as armed, not as the old failure. + std::fs::write(&trial, "b\narmed=1\n").unwrap(); + assert_eq!( + trial_line(&trial, "1.0.2").as_deref(), + Some("trial slot b is armed: the next boot tries it, and it stays once it comes up healthy\n") + ); let _ = std::fs::remove_dir_all(&d); } diff --git a/docs/user-guide.md b/docs/user-guide.md index cfaa2ea8..84111220 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -332,8 +332,9 @@ root hash. It writes the inactive slot, reads it back, installs the slot's kernel on the ESP under its own name and arms one trial boot (`BootNext`). The next boot runs the new slot; `boot-success` judges it (state partition usable, services up, the zone supervisor healthy) and only then makes it the -committed boot file. An unhealthy trial reboots into the previous slot by -itself, and `kryptik update status` and the menu then say what it found. +committed boot file. Until that boot, `kryptik update status` and the menu +say the trial is armed. An unhealthy trial reboots into the previous slot by +itself, and they then say what it found. Persistent zone data on `kryptik-state` is never written by any of this. `apply` refuses while another trial is armed (reboot first), while the state partition is degraded, and on any slot but the committed one, such as diff --git a/tools/desktop/kryptik-chrome b/tools/desktop/kryptik-chrome index 7c9031c5..d74246d8 100755 --- a/tools/desktop/kryptik-chrome +++ b/tools/desktop/kryptik-chrome @@ -155,15 +155,15 @@ menu_update() { v="$(printf '%s\n' "$st" | sed -n 's/^staged *\([^:]*\): .*, complete.*/\1/p')" # kryptikd's words up to their reason: the newest statement's age or, with none ever accepted, the install's. quiet="$(printf '%s\n' "$st" | sed -n 's/^ *\(no statement from the release key [^:]*\):.*/\1/p' | head -1)" - # A trial that failed: what is staged is the release it tried, which apply would refuse. - failed="$(printf '%s\n' "$st" | sed -n 's/^trial *\(the trial of [^;]*\);.*/\1/p' | head -1)" - [ -n "$v$quiet$failed" ] || return 0 + # A trial armed or failed: what is staged is the release it tries or tried, which apply would refuse. + trial="$(printf '%s\n' "$st" | sed -n 's/^trial *//p' | head -1)" + [ -n "$v$quiet$trial" ] || return 0 echo [ -z "$quiet" ] || echo "Update: ${quiet} (u for more)" - if [ -n "$failed" ]; then - echo "Update: ${failed}; this machine runs the release before it (u for more)" - return 0 - fi + case "$trial" in + "the trial of "*) echo "Update: ${trial%%;*}; this machine runs the release before it (u for more)"; return 0 ;; + "slot "?" is armed:"*) echo "Update: ${trial%%:*}: the next boot tries it (u for more)"; return 0 ;; + esac [ -z "$v" ] && return 0 echo "Update: ${v} has arrived. u installs it, and the next boot tries it." } diff --git a/tools/image/gui-test.sh b/tools/image/gui-test.sh index a4377be2..9d07a984 100755 --- a/tools/image/gui-test.sh +++ b/tools/image/gui-test.sh @@ -108,7 +108,7 @@ gp="$(sed -n 's/.*passed=\([0-9]*\).*/\1/p' <<<"$summary")"; gf="$(sed -n 's/.*f if [[ -n "$summary" && "${gf:-1}" -eq 0 && "${gp:-0}" -ge 25 ]]; then green "every guest check passed (${gp})"; else red "guest checks: ${gp:-0} passed, ${gf:-?} failed"; fi grep 'GT FAIL' <<<"$T" | sed 's/^/ /' for name in session-socket compositor-running chrome-focus-record chrome-window-is-zone0 zone0-sees-capture zone-proxy-path zone-sees-needed zone-hidden-globals zone-bind-refused proxy-logged-refusal \ - map-keeps-zone0-focus close-keeps-zone0-focus alone-keeps-no-keyboard alone-reached-by-key focus-shows-zone focus-shows-label title-prefixed last-zone-recorded menu-opens-on-key menu-keeps-last-zone menu-names-stale-statement menu-names-install-age menu-update-auto menu-names-failed-trial zone-fullscreen-refused compositor-survives-close oversize-window forged-title-named-by-zone second-zone-window zone0-own-programs-only zone-app-in-cgroup no-virtual-input clipboard-isolated clipboard-move-gesture clipboard-moved \ + map-keeps-zone0-focus close-keeps-zone0-focus alone-keeps-no-keyboard alone-reached-by-key focus-shows-zone focus-shows-label title-prefixed last-zone-recorded menu-opens-on-key menu-keeps-last-zone menu-names-stale-statement menu-names-install-age menu-update-auto menu-names-failed-trial menu-names-armed-trial zone-fullscreen-refused compositor-survives-close oversize-window forged-title-named-by-zone second-zone-window zone0-own-programs-only zone-app-in-cgroup no-virtual-input clipboard-isolated clipboard-move-gesture clipboard-moved \ transfer-policy no-question-for-policy-refusal consent-code-shown transfer-approved transfer-landed plain-y-refused denied-file-absent \ second-head-appears chrome-follows-head second-head-zone-window second-head-names-zone second-head-gone compositor-survives-unplug zone-survives-unplug chrome-back-on-first-head \ zone0-cursor-set zone0-cursor-shown zone-cursor-asked zone-hears-of-outputs zone-cursor-not-shown compositor-socket-unreached \ diff --git a/tools/image/update-test.sh b/tools/image/update-test.sh index eb95409f..749f90ff 100755 --- a/tools/image/update-test.sh +++ b/tools/image/update-test.sh @@ -187,6 +187,7 @@ drive "expect:KRYPTIK_SMOKE: END" "login:${TUSER}:${TPASS}" \ "$(ROOTSH 'mkdir -p /run/upd/p && mount -o ro /dev/vdb /run/upd/p && kryptik-update apply /run/upd/p && echo APPLY-OK')" \ "expect:signature verifies, checked as nobody" "expect:armed: the next boot tries slot b" "expect:APPLY-OK" \ "$(ROOTSH 'kryptik-update status')" "expect:trial pending: b" \ + "run:kryptik update status" "expect:trial +slot b is armed: the next boot tries it" \ "$(ROOTSH 'kryptik-update apply /run/upd/p; echo RC=$?')" "expect:a trial of slot b is already armed; reboot first" "expect:RC=1\r?\n" \ "$(ROOTSH 'reboot')" "expect:Linux version" "expect:KRYPTIK_SMOKE: END" \ "login:${TUSER}:${TPASS}" \ From 34b26089f645c95455f500a2caed493aabd4c2ec Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 22:04:16 -0700 Subject: [PATCH 091/213] During the trial boot itself the line says the slot is on trial, by the running slot from the signed command line --- compartments/kryptikd/src/serve.rs | 2 +- compartments/kryptikd/src/update.rs | 22 ++++++++++++++++------ compartments/kryptikd/src/update/tests.rs | 17 +++++++++++------ docs/user-guide.md | 6 +++--- tools/desktop/kryptik-chrome | 1 + 5 files changed, 32 insertions(+), 16 deletions(-) diff --git a/compartments/kryptikd/src/serve.rs b/compartments/kryptikd/src/serve.rs index 9991417d..a432ba9b 100644 --- a/compartments/kryptikd/src/serve.rs +++ b/compartments/kryptikd/src/serve.rs @@ -1064,7 +1064,7 @@ fn handle(cfg: &ServeConfig, conn: UnixStream, jobs: &mut Vec) -> Option { let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64); let mut out = up::status(dir, now, &running, up::expecting_since()); - out.extend(up::trial_line(Path::new(up::TRIAL), &running)); + out.extend(up::trial_line(Path::new(up::TRIAL), &running, up::running_slot().as_deref())); Ok(out) } "update-fetch" => { diff --git a/compartments/kryptikd/src/update.rs b/compartments/kryptikd/src/update.rs index 0d802cce..005b431a 100644 --- a/compartments/kryptikd/src/update.rs +++ b/compartments/kryptikd/src/update.rs @@ -815,12 +815,22 @@ pub fn complete_stage(dir: &Path) -> Result { } } -/// `status`'s line for a trial: one armed for the next boot, or one that failed, from the -/// record boot-success set aside as `trial.failed` with its `why=`. Either way the release it -/// tries, or tried, still reads as staged and complete. -pub fn trial_line(trial: &Path, running: &str) -> Option { - if let Some(slot) = std::fs::read_to_string(trial).ok().and_then(|t| t.lines().next().filter(|s| matches!(*s, "a" | "b")).map(str::to_string)) { - return Some(format!("trial slot {slot} is armed: the next boot tries it, and it stays once it comes up healthy\n")); +/// The slot this boot runs, from the signed kernel's command line. +pub fn running_slot() -> Option { + let line = std::fs::read_to_string("/proc/cmdline").ok()?; + line.split_whitespace().find_map(|w| w.strip_prefix("kryptik.slot=")).filter(|s| matches!(*s, "a" | "b")).map(str::to_string) +} + +/// `status`'s line for a trial: one armed for the next boot or running now (`booted`, the slot +/// this boot runs, tells which), or one that failed, from the record boot-success set aside as +/// `trial.failed` with its `why=`. Either way the release still reads as staged and complete. +pub fn trial_line(trial: &Path, running: &str, booted: Option<&str>) -> Option { + if let Some(armed) = std::fs::read_to_string(trial).ok().and_then(|t| t.lines().next().filter(|s| matches!(*s, "a" | "b")).map(str::to_string)) { + return Some(if booted == Some(armed.as_str()) { + format!("trial slot {armed} is on trial: it stays once it comes up healthy\n") + } else { + format!("trial slot {armed} is armed: the next boot tries it, and it stays once it comes up healthy\n") + }); } let text = std::fs::read_to_string(trial.with_file_name("trial.failed")).ok()?; let slot = text.lines().next().filter(|s| matches!(*s, "a" | "b"))?; diff --git a/compartments/kryptikd/src/update/tests.rs b/compartments/kryptikd/src/update/tests.rs index 423689d0..6aeacd74 100644 --- a/compartments/kryptikd/src/update/tests.rs +++ b/compartments/kryptikd/src/update/tests.rs @@ -443,26 +443,31 @@ fn a_failed_trial_is_said_with_its_reason() { let d = scratch("trial-line"); std::fs::create_dir_all(&d).unwrap(); let trial = d.join("trial"); - assert_eq!(trial_line(&trial, "1.0.2"), None, "no record, no line"); + assert_eq!(trial_line(&trial, "1.0.2", Some("a")), None, "no record, no line"); let failed = d.join("trial.failed"); std::fs::write(&failed, "b\narmed=1\nwhy=came up unhealthy: service net-zone is not up; service seatd is not up\n").unwrap(); assert_eq!( - trial_line(&trial, "1.0.2").as_deref(), + trial_line(&trial, "1.0.2", Some("a")).as_deref(), Some("trial the trial of slot b came up unhealthy: service net-zone is not up; service seatd is not up; this machine runs 1.0.2 again, and `kryptik update apply` says how to try once more\n") ); // An older record has no reason; one naming no slot, or carrying control characters, is not repeated as it is. std::fs::write(&failed, "a\narmed=1\n").unwrap(); - assert!(trial_line(&trial, "1.0.2").unwrap().starts_with("trial the trial of slot a failed; ")); + assert!(trial_line(&trial, "1.0.2", Some("a")).unwrap().starts_with("trial the trial of slot a failed; ")); std::fs::write(&failed, "c\nwhy=x\n").unwrap(); - assert_eq!(trial_line(&trial, "1.0.2"), None); + assert_eq!(trial_line(&trial, "1.0.2", Some("a")), None); std::fs::write(&failed, "b\nwhy=did not \u{1b}[2Jcome up\n").unwrap(); - assert!(!trial_line(&trial, "1.0.2").unwrap().contains('\u{1b}')); + assert!(!trial_line(&trial, "1.0.2", Some("a")).unwrap().contains('\u{1b}')); // A trial armed again, as apply --retry arms it, is said as armed, not as the old failure. std::fs::write(&trial, "b\narmed=1\n").unwrap(); assert_eq!( - trial_line(&trial, "1.0.2").as_deref(), + trial_line(&trial, "1.0.2", Some("a")).as_deref(), Some("trial slot b is armed: the next boot tries it, and it stays once it comes up healthy\n") ); + // Booted into it, the trial is running, not to come. + assert_eq!( + trial_line(&trial, "1.0.3", Some("b")).as_deref(), + Some("trial slot b is on trial: it stays once it comes up healthy\n") + ); let _ = std::fs::remove_dir_all(&d); } diff --git a/docs/user-guide.md b/docs/user-guide.md index 84111220..818fc344 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -332,9 +332,9 @@ root hash. It writes the inactive slot, reads it back, installs the slot's kernel on the ESP under its own name and arms one trial boot (`BootNext`). The next boot runs the new slot; `boot-success` judges it (state partition usable, services up, the zone supervisor healthy) and only then makes it the -committed boot file. Until that boot, `kryptik update status` and the menu -say the trial is armed. An unhealthy trial reboots into the previous slot by -itself, and they then say what it found. +committed boot file. Until it is judged, `kryptik update status` and the +menu say the trial is armed or running. An unhealthy trial reboots into the +previous slot by itself, and they then say what it found. Persistent zone data on `kryptik-state` is never written by any of this. `apply` refuses while another trial is armed (reboot first), while the state partition is degraded, and on any slot but the committed one, such as diff --git a/tools/desktop/kryptik-chrome b/tools/desktop/kryptik-chrome index d74246d8..1ec617b7 100755 --- a/tools/desktop/kryptik-chrome +++ b/tools/desktop/kryptik-chrome @@ -163,6 +163,7 @@ menu_update() { case "$trial" in "the trial of "*) echo "Update: ${trial%%;*}; this machine runs the release before it (u for more)"; return 0 ;; "slot "?" is armed:"*) echo "Update: ${trial%%:*}: the next boot tries it (u for more)"; return 0 ;; + "slot "?" is on trial:"*) echo "Update: ${trial%%:*}: it stays once it comes up healthy (u for more)"; return 0 ;; esac [ -z "$v" ] && return 0 echo "Update: ${v} has arrived. u installs it, and the next boot tries it." From 8e4997b8858f494edd53dc238401d842d8d6ce4a Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 22:21:16 -0700 Subject: [PATCH 092/213] Ship only the Intel firmware 6.18 asks for iwlwifi asks for a device's highest API version first and loads the first file it finds (iwl_request_firmware). For every device in linux-firmware 20260916 that is its newest numbered file: 77 for the 22000 family, 89 for AX210, 100 and 101 for BZ and SC, where 6.18 asks for core 99 (no file has it) and then 101 and 100. The two older versions "newest 3" kept never load; the -cNNN files start at c101 and were all kept, since the rule ranks numbers only; and the DVM firmware has no driver here (iwldvm is not built). i915 and xe name every file they request (intel_uc_fw.c, xe_uc_fw.c, intel_dmc.c); the other 82 files under i915/ and xe/ are older GuC, HuC and DMC versions for older kernels, Nova Lake's GuC and Panther Lake's GSC, which 6.18 does not ask for. xe's fan-control file is requested by the card's ids (xe_late_bind_fw.c) and stays. /lib/firmware goes from 1060 files and 138.6 MB to 896 and 88.4 MB. --- build/config/firmware.list | 68 +++++++++++++++++++++++++++++++++++--- docs/decisions.md | 7 ++-- 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/build/config/firmware.list b/build/config/firmware.list index 9d718aad..29f4c7aa 100644 --- a/build/config/firmware.list +++ b/build/config/firmware.list @@ -3,9 +3,19 @@ # newest N PATTERN only the N highest trailing -NUMBERs (API versions) per device # A pattern that matches nothing fails the build. -# Intel Wi-Fi: three API versions per device, as which one loads depends on MAC and RF (iwl-drv.c). -# The glob also keeps about 2 MB of DVM firmware this kernel cannot load. -newest 3 iwlwifi-*.ucode +# Intel Wi-Fi (iwlmvm and iwlmld; iwldvm is not built). iwlwifi asks for a device's highest API +# version first and loads the first file it finds (iwl-drv.c), which for every device here is its +# newest numbered file. 6.18 asks for core 99 at most, so the -cNNN files (c101 up) never load. +iwlwifi-3160-17.ucode +iwlwifi-3168-29.ucode +iwlwifi-7260-17.ucode +iwlwifi-7265-17.ucode +iwlwifi-7265D-29.ucode +iwlwifi-8000C-36.ucode +iwlwifi-8265-36.ucode +iwlwifi-9000-pu-b0-jf-b0-46.ucode +iwlwifi-9260-th-b0-jf-b0-46.ucode +newest 1 iwlwifi-[[:alpha:]]*-[0-9]*.ucode iwlwifi-*.pnvm # Qualcomm Atheros: the laptop parts, by board revision. @@ -36,6 +46,54 @@ rtlwifi/rtl8188eufw.bin brcm/brcmfmac*-pcie* # Graphics: Intel and AMD, not NVIDIA (GSP firmware is tens of MB; the framebuffer still works). -i915/* -xe/* +# Intel: the files 6.18's i915 and xe name (intel_uc_fw.c, xe_uc_fw.c, intel_dmc.c), not the +# older versions linux-firmware keeps for older kernels, and xe's fan control, which it asks +# for by the card's ids (xe_late_bind_fw.c). +i915/*_guc_70.bin +i915/adlp_guc_69.0.3.bin +i915/adlp_guc_70.1.1.bin +i915/bxt_guc_70.1.1.bin +i915/cml_guc_70.1.1.bin +i915/ehl_guc_70.1.1.bin +i915/glk_guc_70.1.1.bin +i915/icl_guc_70.1.1.bin +i915/kbl_guc_70.1.1.bin +i915/skl_guc_70.1.1.bin +i915/tgl_guc_69.0.3.bin +i915/tgl_guc_70.1.1.bin +i915/*_huc_gsc.bin +i915/dg1_huc.bin +i915/tgl_huc.bin +i915/bxt_huc_2.0.0.bin +i915/cml_huc_4.0.0.bin +i915/ehl_huc_9.0.0.bin +i915/glk_huc_4.0.0.bin +i915/icl_huc_9.0.0.bin +i915/kbl_huc_4.0.0.bin +i915/skl_huc_2.0.0.bin +i915/tgl_huc_7.9.3.bin +i915/mtl_gsc_1.bin +i915/adlp_dmc.bin +i915/bmg_dmc.bin +i915/mtl_dmc.bin +i915/xe2lpd_dmc.bin +i915/xe3lpd_dmc.bin +i915/xe3lpd_3002_dmc.bin +i915/adlp_dmc_ver2_16.bin +i915/adls_dmc_ver2_01.bin +i915/bxt_dmc_ver1_07.bin +i915/dg1_dmc_ver2_02.bin +i915/dg2_dmc_ver2_08.bin +i915/glk_dmc_ver1_04.bin +i915/icl_dmc_ver1_09.bin +i915/kbl_dmc_ver1_04.bin +i915/rkl_dmc_ver2_03.bin +i915/skl_dmc_ver1_27.bin +i915/tgl_dmc_ver2_12.bin +xe/bmg_guc_70.bin +xe/lnl_guc_70.bin +xe/ptl_guc_70.bin +xe/*_huc.bin +xe/lnl_gsc_1.bin +xe/fan_control_*.bin amdgpu/* diff --git a/docs/decisions.md b/docs/decisions.md index d31efbea..2a4ad061 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -198,9 +198,10 @@ each signed kernel (`CONFIG_EXTRA_FIRMWARE`, stage 05), about 18 MB. Without it a machine runs whatever microcode its firmware last shipped, which on older machines means known, unfixed CPU vulnerabilities. -**Cost:** about 135 MB after zstd (385 MB of files; Intel Wi-Fi is 56 MB and -amdgpu 38 MB compressed) on a root image of about 1.6 GB, and an input nobody -here can read. +**Cost:** about 88 MB after zstd (amdgpu is 38 MB of it, Intel Wi-Fi 15 MB and +Intel graphics 6 MB: only the file each Intel driver asks for, not the older +versions linux-firmware keeps for older kernels) on a root image of about +1.6 GB, and an input nobody here can read. ## ADR-013: A driver is built in only when boot needs it From 743b8fbbaa6d3b91fb4cb943b7818e26c49cf389 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 22:27:15 -0700 Subject: [PATCH 093/213] Ship only the Realtek and Atheros firmware 6.18 asks for rtw89 asks for the newest firmware format it reads first and counts down (fw_format_max). linux-firmware also carries formats only later kernels read (8851B and 8852A -1, 8852B -2, 8922A below -4 never load once -4 is there) and chips this kernel does not build (8852BT, 8922D): 4.6 MB of 5.8. rtw88 needs the five files its four PCIe chips name. ath10k asks for API 6 first, so firmware-4 and firmware-5 never load, and the firmware-sdio files are for SDIO parts, which are not built. rtl8xxxu names its files with MODULE_FIRMWARE. The list kept the WoWLAN files it never asks for and lacked the 8188FU, 8192FU, 8710BU and 8723AU ones, so those sticks had no firmware. /lib/firmware goes to 869 files and about 82 MB. --- build/config/firmware.list | 41 +++++++++++++++++++++++++++++--------- docs/decisions.md | 8 ++++---- 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/build/config/firmware.list b/build/config/firmware.list index 29f4c7aa..4e540934 100644 --- a/build/config/firmware.list +++ b/build/config/firmware.list @@ -18,9 +18,12 @@ iwlwifi-9260-th-b0-jf-b0-46.ucode newest 1 iwlwifi-[[:alpha:]]*-[0-9]*.ucode iwlwifi-*.pnvm -# Qualcomm Atheros: the laptop parts, by board revision. -ath10k/QCA6174/hw3.0/* -ath10k/QCA9377/hw1.0/* +# Qualcomm Atheros: the laptop parts, by board revision. ath10k asks for API 6 first, and the +# -sdio- files are for SDIO parts, which are not built. +ath10k/QCA6174/hw3.0/board*.bin +ath10k/QCA6174/hw3.0/*firmware-6.* +ath10k/QCA9377/hw1.0/board*.bin +ath10k/QCA9377/hw1.0/*firmware-6.* ath11k/QCA6390/hw2.0/* ath11k/WCN6855/hw2.0/* ath12k/WCN7850/hw2.0/* @@ -34,13 +37,33 @@ mediatek/WIFI_MT7922_patch_mcu_1_1_hdr.bin mediatek/WIFI_RAM_CODE_MT7922_1.bin mediatek/mt7925/WIFI_* -# Realtek: the rtw88 and rtw89 families, and the USB sticks rtl8xxxu drives. -rtw88/* -rtw89/* -rtlwifi/rtl8192cufw*.bin -rtlwifi/rtl8192eu_*.bin -rtlwifi/rtl8723bu_*.bin +# Realtek: the PCIe chips rtw88 and rtw89 drive here, and every USB stick rtl8xxxu drives (its +# MODULE_FIRMWARE names; linux-firmware has no rtl8723bu_bt). rtw89 asks for the newest format it +# reads first (fw_format_max); linux-firmware also has formats only later kernels read, and chips +# not built here (8852BT, 8922D). +rtw88/rtw8723d_fw.bin +rtw88/rtw8821c_fw.bin +rtw88/rtw8822b_fw.bin +rtw88/rtw8822c_fw.bin +rtw88/rtw8822c_wow_fw.bin +rtw89/rtw8851b_fw.bin +rtw89/rtw8852a_fw.bin +rtw89/rtw8852b_fw-1.bin +rtw89/rtw8852c_fw-2.bin +rtw89/rtw8922a_fw-4.bin rtlwifi/rtl8188eufw.bin +rtlwifi/rtl8188fufw.bin +rtlwifi/rtl8192cufw_A.bin +rtlwifi/rtl8192cufw_B.bin +rtlwifi/rtl8192cufw_TMSC.bin +rtlwifi/rtl8192eu_nic.bin +rtlwifi/rtl8192fufw.bin +rtlwifi/rtl8710bufw_SMIC.bin +rtlwifi/rtl8710bufw_UMC.bin +rtlwifi/rtl8723aufw_A.bin +rtlwifi/rtl8723aufw_B.bin +rtlwifi/rtl8723aufw_B_NoBT.bin +rtlwifi/rtl8723bu_nic.bin # Broadcom over PCIe (Dell and Apple machines); the SDIO parts are not. brcm/brcmfmac*-pcie* diff --git a/docs/decisions.md b/docs/decisions.md index 2a4ad061..d9b3c609 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -198,10 +198,10 @@ each signed kernel (`CONFIG_EXTRA_FIRMWARE`, stage 05), about 18 MB. Without it a machine runs whatever microcode its firmware last shipped, which on older machines means known, unfixed CPU vulnerabilities. -**Cost:** about 88 MB after zstd (amdgpu is 38 MB of it, Intel Wi-Fi 15 MB and -Intel graphics 6 MB: only the file each Intel driver asks for, not the older -versions linux-firmware keeps for older kernels) on a root image of about -1.6 GB, and an input nobody here can read. +**Cost:** about 82 MB after zstd (amdgpu is 38 MB of it, Intel Wi-Fi 15 MB and +Intel graphics 6 MB: only the files each driver asks for, not the versions +linux-firmware keeps for other kernels) on a root image of about 1.6 GB, and +an input nobody here can read. ## ADR-013: A driver is built in only when boot needs it From f3190c399e8a2fff7ba7c897ac8360d96abc6cab Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 22:30:17 -0700 Subject: [PATCH 094/213] ADR-012 says which vendors' firmware is cut to what the driver asks for --- docs/decisions.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/decisions.md b/docs/decisions.md index d9b3c609..8aa8eade 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -199,9 +199,9 @@ it a machine runs whatever microcode its firmware last shipped, which on older machines means known, unfixed CPU vulnerabilities. **Cost:** about 82 MB after zstd (amdgpu is 38 MB of it, Intel Wi-Fi 15 MB and -Intel graphics 6 MB: only the files each driver asks for, not the versions -linux-firmware keeps for other kernels) on a root image of about 1.6 GB, and -an input nobody here can read. +Intel graphics 6 MB; for Intel, Realtek and Atheros the list names the files +each driver asks for, not the versions linux-firmware keeps for other kernels) +on a root image of about 1.6 GB, and an input nobody here can read. ## ADR-013: A driver is built in only when boot needs it From 5f6bb3c9862259ffd22c6a6f05e1f4ca942c0bd8 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 22:31:35 -0700 Subject: [PATCH 095/213] Ship kernel modules zstd-compressed The 166 modules are 68 MB on the root, uncompressed; amdgpu alone is 22 MB. modules_install now compresses each one after it signs it, as CONFIG_MODULE_COMPRESS_ALL does, with zstd -19 like the firmware. kmod is built with zstd, so libkmod (modprobe and eudev's coldplug) decompresses a module and hands the kernel the signed image, which the kernel checks as before. CONFIG_MODULE_DECOMPRESS stays off: the kernel never runs a decompressor over a module file, which it would do before checking the signature. Stage 05 fails if a module was left uncompressed. --- build/config/kernel/boot.fragment | 6 ++++++ build/stages/05-kernel.sh | 11 +++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/build/config/kernel/boot.fragment b/build/config/kernel/boot.fragment index 32111c45..1323cf15 100644 --- a/build/config/kernel/boot.fragment +++ b/build/config/kernel/boot.fragment @@ -282,6 +282,12 @@ CONFIG_HID_TOPSEED=m # and does not compress. CONFIG_KERNEL_ZSTD=y # CONFIG_KERNEL_GZIP is not set +# Modules as well, compressed by modules_install after it signs them. kmod +# decompresses them, so the kernel parses no compressed module itself. +CONFIG_MODULE_COMPRESS=y +CONFIG_MODULE_COMPRESS_ZSTD=y +CONFIG_MODULE_COMPRESS_ALL=y +# CONFIG_MODULE_DECOMPRESS is not set # --- a machine that has stopped reboots itself ------------------------------- # build/services/watchdog feeds these. Built in, so recovery does not depend on # module loading. NOWAYOUT: killing the feeder does not stop the timer. diff --git a/build/stages/05-kernel.sh b/build/stages/05-kernel.sh index afd9df66..2edfb155 100755 --- a/build/stages/05-kernel.sh +++ b/build/stages/05-kernel.sh @@ -90,7 +90,7 @@ s_compiler_check() { # Kernel build prerequisites, checked before a long compile. local missing=0 t2 for t2 in make ld objcopy objdump ar nm perl bison flex openssl python3 \ - gawk tar xz gzip depmod; do + gawk tar xz gzip zstd depmod; do if have "$t2"; then printf ' ok %s\n' "$t2" else @@ -259,8 +259,9 @@ s_size() { s_modules() { echo "config digest: ${1:-none}" cd "$KSRC" - # KRYPTIK_DESTDIR is empty: inside the chroot the target is the root. - make INSTALL_MOD_PATH="${KRYPTIK_DESTDIR}" modules_install + # KRYPTIK_DESTDIR is empty: inside the chroot the target is the root. -19, as for + # the firmware: a module costs the same to decompress at any level. + make INSTALL_MOD_PATH="${KRYPTIK_DESTDIR}" ZSTD="zstd -19" modules_install } s_install() { @@ -299,8 +300,10 @@ s_verify_install() { echo "kernel release: ${krel}" local modver="${MODDIR}/${krel}" if [[ -d "$modver" ]]; then - echo "modules : ${modver} ($(find "$modver" -name '*.ko*' | wc -l) objects)" + echo "modules : ${modver} ($(find "$modver" -name '*.ko*' | wc -l) objects, $(du -sh "$modver" | cut -f1))" [[ -f "${modver}/modules.dep" ]] || { echo "FAIL: depmod did not run"; n=$((n + 1)); } + local plain; plain="$(find "$modver" -name '*.ko' | wc -l)" + [[ "$plain" -eq 0 ]] || { echo "FAIL: ${plain} modules are not compressed (CONFIG_MODULE_COMPRESS_ALL)"; n=$((n + 1)); } else echo "FAIL: no module tree at ${modver}"; n=$((n + 1)) fi From dee022c869d2b5010c17e68e32b42a997efe24b4 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 22:34:25 -0700 Subject: [PATCH 096/213] The open network's check reads the lease off the station's name in the net zone, and its access point has an address of its own Distro 38203941757 failed wifi-open on two counts. The lease was read off wlan0, a name the radio loses in the rounds before it, where the net zone calls it nic0, so no lease could be found. And the open access point took the WPA3 one's BSSID: the station held both networks' entries under one BSSID for the 30 s the kernel keeps an entry, the open access point logged one SAE attempt from it, and the station never associated. Another network is another access point, so it gets an address of its own. A failure now says what the net zone's supplicant sees (its status, its networks, its scan results and the BSSIDs it ignores, asked through kryptikd supplicant, since the socket is its user's) and what the access point is. --- build/guest-tests/zones-check.sh | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index 80e55f72..dbd57686 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -713,6 +713,14 @@ wl_of_phy() { # wl_of_phy phyN: the netdev on that wiphy, in this namespace # A sleeper's network namespace alone: a cloned mount namespace would keep a zone's volume open. ap() { nsenter -t "$AP_HOLD" -n "$@"; } ap_wpa() { ap wpa_cli -p /run/zt-ap-ctrl -i "$AP_IF" "$@" 2>/dev/null; } +# The station in the net zone, by the name that zone gave it (nicN once the radio has moved). +sta_if() { nsenter -t "$(cut -d' ' -f1 /run/kryptik/zones/net/init.pid 2>/dev/null)" -n iw dev 2>/dev/null | awk '$1 == "Interface" { print $2; exit }'; } +# What the net zone's supplicant sees, for a failure's message: its socket is its own user's. +sta_wpa() { + local ni; ni="$(cut -d' ' -f1 /run/kryptik/zones/net/init.pid 2>/dev/null)" + nsenter -t "${ni:-0}" -U -m -n /usr/bin/kryptikd supplicant -- \ + wpa_cli -s /run/wpa_supplicant -p /run/wpa_supplicant -i "$(sta_if)" "$@" 2>&1 | tr '\n' ' ' +} STA_IF=""; AP_IF=""; AP_HOLD="" # QEMU has no radio, so hwsim makes two: phy1 is the access point, phy0 goes to the net zone. if modprobe mac80211_hwsim radios=2 2> "$LOG/hwsim.err"; then @@ -918,6 +926,10 @@ if [[ "$ap_up" = 1 ]]; then ap_pid="$(cat /run/zt-ap-wpa.pid 2>/dev/null)" [[ -n "$ap_pid" ]] && kill "$ap_pid" 2>/dev/null for _ in $(seq 1 20); do [[ -n "$ap_pid" ]] && kill -0 "$ap_pid" 2>/dev/null || break; sleep 0.5; done + # Another network is another access point, with an address of its own: at the WPA3 one's, + # the station would hold both networks under one BSSID for the 30 s the kernel keeps an entry. + ap ip link set dev "$AP_IF" down + ap ip link set dev "$AP_IF" address 02:00:00:00:01:01 >> "$LOG/ap.err" 2>&1 cat > /root/zt/ap-open.conf </dev/null)" -n ip -4 -o addr show "$STA_IF" 2>/dev/null | awk '{print $4}' | head -1)" + open_lease="$(nsenter -t "$(cut -d' ' -f1 /run/kryptik/zones/net/init.pid 2>/dev/null)" -n ip -4 -o addr show "$(sta_if)" 2>/dev/null | awk '{print $4}' | head -1)" [[ "$open_lease" == 192.168.77.* ]] && break; sleep 1 done if [[ "$open_up" = 1 && "$line" == *" wifi=$OPEN_SSID "* && "$open_lease" == 192.168.77.* ]] \ && grep -q "added open network \"$OPEN_SSID\"" "$LOG/wifi-add-open.out"; then pass "wifi-open" "saved with --open and no passphrase, $OPEN_SSID was joined and the net zone leased ${open_lease} there" else - fail "wifi-open" "access point up: ${open_up}; $(tr '\n' ' ' < "$LOG/wifi-add-open.out"); newest READY: ${line:-none}; lease: ${open_lease:-none}; $(tail -2 "$LOG/ap-open-wpa.log" 2>/dev/null | tr '\n' ' ')" + fail "wifi-open" "access point up: ${open_up} ($(ap iw dev "$AP_IF" info 2>&1 | grep -E 'addr|ssid|type|channel' | tr -s '\t\n' ' ')); $(tr '\n' ' ' < "$LOG/wifi-add-open.out"); newest READY: ${line:-none}; lease: ${open_lease:-none}; $(tail -2 "$LOG/ap-open-wpa.log" 2>/dev/null | tr '\n' ' '); the station: $(sta_wpa status | cut -c1-240); networks: $(sta_wpa list_networks | cut -c1-240); seen: $(sta_wpa scan_results | cut -c1-400); ignored: $(sta_wpa bssid_ignore | cut -c1-120)" fi # Back to the wire: the radios go first, so the net zone the forget restarts finds none. for f in /run/zt-ap-dnsmasq.pid /run/zt-ap-wpa.pid; do p="$(cat "$f" 2>/dev/null)"; [[ -n "$p" ]] && kill "$p" 2>/dev/null; done From 15c5ef137209a320d9a01f248bc39f2e1f386b5d Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 22:36:21 -0700 Subject: [PATCH 097/213] perl's Config.pm records no build host, and SOURCE_DATE_EPOCH's time perl's Configure looks up the machine it runs on: myhostname, mydomain, perladmin and cf_email from its name, myuname from uname -a, osvers from the running kernel, cf_time from the clock. All of it lands in Config.pm and Config_heavy.pl, so the image named the CI runner and its Azure kernel, and two builds of one tree differed there. Configure ignores SOURCE_DATE_EPOCH, and sets cf_time after it applies -D options, so the recipe writes a config.over, which Configure loads once it has looked everything up. The recipe fails if the installed perl does not report the fixed host name. --- build/recipes/perl.sh | 8 ++++++++ docs/design/reproducible-builds.md | 7 ++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/build/recipes/perl.sh b/build/recipes/perl.sh index 312ed8d6..0eabb756 100644 --- a/build/recipes/perl.sh +++ b/build/recipes/perl.sh @@ -6,6 +6,13 @@ s_perl() { # Configure ignores CFLAGS and LDFLAGS, so the hardening goes in as its own settings. # -shared: a given lddlflags replaces Configure's default instead of adding to it. # No GDBM_File: nothing needs it, and the build must not depend on a cached gdbm. + # Config.pm would carry the build host's name, kernel and clock, and Configure ignores + # SOURCE_DATE_EPOCH; config.over, which it reads last, sets them (osvers: the series shipped). + { + echo "myhostname='localhost' mydomain='.localdomain' myuname='linux localhost' osvers='6.18'" + echo "perladmin='root@localhost' cf_email='root@localhost'" + [[ -z "${SOURCE_DATE_EPOCH:-}" ]] || echo "cf_time='$(LC_ALL=C date -u -d "@${SOURCE_DATE_EPOCH}")'" + } > config.over sh Configure -des \ -Dprefix=/usr \ -Dvendorprefix=/usr \ @@ -18,6 +25,7 @@ s_perl() { -Dnoextensions=GDBM_File make make install + [[ "$(perl -V:myhostname)" == "myhostname='localhost';" ]] || { echo "FAIL: Configure did not read config.over"; return 1; } # A cached sysroot may hold the module from a build that saw gdbm; nothing else removes it. rm -rf /usr/lib/perl5/*/*/auto/GDBM_File /usr/lib/perl5/*/*/GDBM_File.pm } diff --git a/docs/design/reproducible-builds.md b/docs/design/reproducible-builds.md index 753c4326..236a6721 100644 --- a/docs/design/reproducible-builds.md +++ b/docs/design/reproducible-builds.md @@ -90,9 +90,10 @@ compressed man and info pages (gzip stores a time and a name unless `-n`), static archives (member times, unless binutils is in deterministic mode), Python's bytecode (a timestamp in each `.pyc`, unless `SOURCE_DATE_EPOCH` is set, when `py_compile` writes hash-checked files), and perl's -`Config.pm`, which records when and by whom perl was built. -`SOURCE_DATE_EPOCH` in every step takes most of them. The comparison finds -the rest package by package. +`Config.pm`, which records when perl was built and the build host's name and +kernel; perl's Configure ignores `SOURCE_DATE_EPOCH`, so the recipe gives it +fixed values in a `config.over`. `SOURCE_DATE_EPOCH` in every step takes most +of the rest. The comparison finds what is left package by package. ### The Rust binaries From 19dfb8478b715cd90f7259d2419bb725d0013d74 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 22:38:33 -0700 Subject: [PATCH 098/213] A radio scans from random addresses, never its own or a network's Each net zone start set a radio back to its hardware address, so every scan before the first join went out from the radio's own address, and a scan after leaving a network went out from that network's address, which any radio in range hears: a scan could follow the machine where the networks it joins cannot. kryptikd gives a radio's station a new random, locally administered address each time it carries the radio into the net zone, in place of the hardware's (a wired NIC still gets its own back). As its supplicant starts, the net zone turns on wpa_supplicant's MAC_RAND_SCAN, so a scan while no network is joined goes out from a new random address each time without the interface's address changing; it asks through kryptikd supplicant, as the control socket is that user's, and says whether the driver gave random scan addresses. The kernel refuses them to a station that has joined, so a scan then goes out from that network's own address. wifi-scan-address hears every probe request on hwsim's monitor interface from the first network's save to the open network's join, and fails on a broadcast one (a scan's) from the radio's own address or a saved network's. On the old code the first scans came from the radio's own. --- build/guest-tests/probe-sources.py | 28 ++++++++++++++++++++++++++++ build/guest-tests/zones-check.sh | 24 ++++++++++++++++++++++++ compartments/kryptikd/src/netzone.rs | 24 ++++++++++++++++-------- compartments/kryptikd/src/wifi.rs | 13 +++++++++---- docs/design/net-zone.md | 12 +++++++++--- docs/threat-model.md | 3 ++- docs/user-guide.md | 8 +++++--- tools/net/netzone-init.sh | 9 +++++++++ 8 files changed, 102 insertions(+), 19 deletions(-) create mode 100644 build/guest-tests/probe-sources.py diff --git a/build/guest-tests/probe-sources.py b/build/guest-tests/probe-sources.py new file mode 100644 index 00000000..3747a7b8 --- /dev/null +++ b/build/guest-tests/probe-sources.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +"""The source and destination of every probe request a radio sends, one line each, until killed. + + probe-sources.py MONITOR + +MONITOR is hwsim's own monitor interface (hwsim0), which hears every frame any of its radios +sends, behind a radiotap header. +""" +import socket +import sys + +ETH_P_ALL = 0x0003 + + +def mac(b): + return ":".join("%02x" % o for o in b) + + +s = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(ETH_P_ALL)) +s.bind((sys.argv[1], 0)) +while True: + f = s.recv(4096) + if len(f) < 4: + continue + h = f[int.from_bytes(f[2:4], "little"):] + # A management frame of subtype 4: its frame control's first byte is 0x40. + if len(h) >= 16 and h[0] == 0x40: + print(mac(h[10:16]), mac(h[4:10]), flush=True) diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index 9ea8682f..fd02ffbc 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -770,6 +770,15 @@ EOF --dhcp-leasefile=/run/zt-ap.leases --pid-file=/run/zt-ap-dnsmasq.pid --user=root >> "$LOG/ap.err" 2>&1 || ap_up=0 fi [[ "$ap_up" = 1 ]] && pass "wifi-ap" "$AP_SSID beacons on $AP_IF in its own namespace ($(ap_wpa status | grep -E '^(mode|freq)=' | tr '\n' ' ')) with a DHCP server" || fail "wifi-ap" "$(tr '\n' ' ' < "$LOG/ap.err" | cut -c1-200) status: $(ap_wpa status | tr '\n' ' ' | cut -c1-120)" +# From here to the open network, every probe request on the air, as hwsim's own monitor +# interface hears it: the scans of a station that has joined no network yet, or has left one. +ap_addr() { ap ip -o link show dev "$AP_IF" 2>/dev/null | sed -n 's/.*link\/ether \([0-9a-f:]*\) .*/\1/p'; } +AP_ADDRS="$(ap_addr)" +probes_pid="" +if ip link set dev hwsim0 up 2>> "$LOG/hwsim.err"; then + python3 /usr/lib/kryptik/guest-tests/probe-sources.py hwsim0 > "$LOG/probes.txt" 2>> "$LOG/hwsim.err" & + probes_pid=$! +fi before="$(ready_count)" printf '%s\n' "$AP_PASS" | "$KD" wifi add "$AP_SSID" --wifi-dir "$WIFI_DIR" --zones "$Z" > "$LOG/wifi-add.out" 2>&1 NET_UID="$(sed -n 's/^uid_base *= *\([0-9]*\).*/\1/p' "$Z/net.toml")" @@ -957,6 +966,7 @@ network={ EOF ap wpa_supplicant -B -i "$AP_IF" -c /root/zt/ap-open.conf -P /run/zt-ap-wpa.pid -f "$LOG/ap-open-wpa.log" >> "$LOG/ap.err" 2>&1 for _ in $(seq 1 30); do ap_wpa status | grep -q '^wpa_state=COMPLETED' && { open_up=1; break; }; sleep 1; done + AP_ADDRS="$AP_ADDRS $(ap_addr)" fi before="$(ready_count)" "$KD" wifi add "$OPEN_SSID" --open --wifi-dir "$WIFI_DIR" --zones "$Z" < /dev/null > "$LOG/wifi-add-open.out" 2>&1 @@ -979,6 +989,20 @@ if [[ -n "$open_mac" && "$open_seen" == "$open_mac" && "$open_mac" != "${own_mac else fail "wifi-address-per-network" "saved for $OPEN_SSID: ${open_mac:-none}; seen there: ${open_seen:-none}; $AP_SSID's: ${own_mac:-none}" fi +# No scan went out from an address the machine is known by: the radio's own or a network's. +# A scan's probe requests are broadcast; one to a BSSID is the station asking after the access +# point it has joined, which knows that address already. +[[ -n "$probes_pid" ]] && kill "$probes_pid" 2>/dev/null +scans="$(awk -v aps="$AP_ADDRS" 'BEGIN { split(aps, a, " "); for (i in a) ap[a[i]] = 1 } $2 == "ff:ff:ff:ff:ff:ff" && !($1 in ap) { print $1 }' "$LOG/probes.txt" 2>/dev/null)" +known="" +for a in "$STA_PERM" "$own_mac" "$open_mac"; do + [[ -n "$a" ]] && grep -qxF "$a" <<<"$scans" && known="$known $a" +done +if [[ -n "$scans" && -n "$STA_PERM" && -z "$known" ]]; then + pass "wifi-scan-address" "$(wc -l <<<"$scans") probe requests went out from $(sort -u <<<"$scans" | wc -l) addresses, none the radio's own ($STA_PERM) or a network's (${own_mac:-none}, ${open_mac:-none}); $(netzone_said 'wifi: .*scan' | tail -1)" +else + fail "wifi-scan-address" "probe requests heard: $(wc -l < "$LOG/probes.txt" 2>/dev/null); scans from a known address:${known:- none}; by source: $(sort <<<"$scans" | uniq -c | tr -s ' \n' ' ' | cut -c1-300); $(netzone_said 'wifi: .*scan' | tail -1); $(tail -2 "$LOG/hwsim.err" | tr '\n' ' ')" +fi # Back to the wire: the radios go first, so the net zone the forget restarts finds none. for f in /run/zt-ap-dnsmasq.pid /run/zt-ap-wpa.pid; do p="$(cat "$f" 2>/dev/null)"; [[ -n "$p" ]] && kill "$p" 2>/dev/null; done [[ -n "$AP_HOLD" ]] && kill "$AP_HOLD" 2>/dev/null diff --git a/compartments/kryptikd/src/netzone.rs b/compartments/kryptikd/src/netzone.rs index 3d53e3c3..7cf28d27 100755 --- a/compartments/kryptikd/src/netzone.rs +++ b/compartments/kryptikd/src/netzone.rs @@ -281,12 +281,19 @@ pub fn physical_interfaces() -> io::Result> { /// What a NIC keeps from the net zone that held it, set back as its hardware gives it: an /// address that zone chose would follow the machine from network to network, and Wake-on-LAN -/// would let anyone on the network wake it. The MTU goes back to Ethernet's. Its altnames go, -/// as one named `BRIDGE` would stop the next start making the bridge, and so does its alias, -/// free text that `ip link` prints. +/// would let anyone on the network wake it. A radio gets a new random address instead of its +/// own, which would go out in every scan before it joins a network; each network it joins sees +/// an address of its own (wifi.rs). The MTU goes back to Ethernet's. Its altnames go, as one +/// named `BRIDGE` would stop the next start making the bridge, and so does its alias, free text +/// that `ip link` prints. fn reset_nic(nic: &str) { let sys = Path::new("/sys/class/net").join(nic); - let own = netlink::perm_mac_of(nic).ok().flatten().filter(|p| netlink::mac_of(nic).ok() != Some(*p)); + let radio = sys.join("phy80211").exists(); + let addr = if radio { + crate::wifi::random_mac().map_err(|e| eprintln!("kryptikd: {nic:?}: no random address: {e}")).ok() + } else { + netlink::perm_mac_of(nic).ok().flatten().filter(|p| netlink::mac_of(nic).ok() != Some(*p)) + }; let mtu = (sysfs_u32(&sys.join("type")) == Some(1) && sysfs_u32(&sys.join("mtu")) != Some(1500)).then_some(1500); let (alt, alias) = netlink::names_left(nic).unwrap_or_else(|e| { eprintln!("kryptikd: {nic:?}: its altnames could not be read: {e}"); @@ -304,10 +311,11 @@ fn reset_nic(nic: &str) { Err(e) => eprintln!("kryptikd: {nic:?}: its altnames could not be removed: {e}"), } } - if own.is_some() || mtu.is_some() || alias { - match netlink::set_link(nic, own, mtu, alias) { - Ok(()) => eprintln!("kryptikd: {nic:?}: set back as its hardware gives it"), - Err(e) => eprintln!("kryptikd: {nic:?}: could not be set back as its hardware gives it: {e}"), + if addr.is_some() || mtu.is_some() || alias { + let how = if radio { "set back, under a new random address" } else { "set back as its hardware gives it" }; + match netlink::set_link(nic, addr, mtu, alias) { + Ok(()) => eprintln!("kryptikd: {nic:?}: {how}"), + Err(e) => eprintln!("kryptikd: {nic:?}: could not be {how}: {e}"), } } match netlink::wol_off(nic) { diff --git a/compartments/kryptikd/src/wifi.rs b/compartments/kryptikd/src/wifi.rs index c7cbd89a..eb5e5d85 100644 --- a/compartments/kryptikd/src/wifi.rs +++ b/compartments/kryptikd/src/wifi.rs @@ -75,15 +75,20 @@ fn check_mac(v: &str) -> Result<(), String> { } } -/// A new address for a network: random, with the locally administered bit set and the -/// group bit clear. -fn new_mac() -> Result { +/// A random address with the locally administered bit set and the group bit clear. +pub fn random_mac() -> io::Result<[u8; 6]> { let mut b = [0u8; 6]; let n = unsafe { libc::getrandom(b.as_mut_ptr() as *mut libc::c_void, b.len(), 0) }; if n != b.len() as isize { - return Err(format!("no randomness for a network's address: {}", io::Error::last_os_error())); + return Err(io::Error::last_os_error()); } b[0] = (b[0] & 0xfc) | 0x02; + Ok(b) +} + +/// A new address for a network, as `mac_value` takes it. +fn new_mac() -> Result { + let b = random_mac().map_err(|e| format!("no randomness for a network's address: {e}"))?; Ok(b.iter().map(|o| format!("{o:02x}")).collect::>().join(":")) } diff --git a/docs/design/net-zone.md b/docs/design/net-zone.md index aac2224b..1d565814 100755 --- a/docs/design/net-zone.md +++ b/docs/design/net-zone.md @@ -327,9 +327,15 @@ configured the zone keeps its IPv4 path and the launcher says so. `/etc/dhcpcd.conf`): no hostname, no vendor class, and a client ID made from the address the interface has when it asks, not a DUID made from a hardware address, which the stock configuration would send to every - network. Left as before: a scan goes out from the address the radio has - then, its own until it first joins and the last network's after; random - scan addresses would change the interface's address at every disconnect. + network. The radio's own address never goes on the air: kryptikd gives + its station a new random address each time the net zone starts, and a + scan while no network is joined goes out from a new random address each + time (wpa_supplicant's `MAC_RAND_SCAN`, which the net zone turns on as its + supplicant starts; the interface's address stays as it is). A driver + without random scan addresses scans from the address the station has + then: the start's, or after a network the last network's, and the net + zone says which it got. The kernel refuses random addresses to a station + that has joined, so a scan then goes out from that network's address. A wired uplink keeps its own address. A driver that refuses to change the interface's address never joins: wpa_supplicant gives up the association rather than use the radio's own, and the status line stays at diff --git a/docs/threat-model.md b/docs/threat-model.md index a1879495..fdc6f752 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -55,7 +55,8 @@ already assumed hostile. **Partially defended.** Kryptik segments; it does not anonymize. A Wi-Fi network sees the machine by an address of its own and a DHCP client ID made from it, never the radio's own, so two networks cannot match the machine by -those (`wifi-own-address`); what it sends can still identify it. A compromised +those (`wifi-own-address`), and a scan goes out from neither +(`wifi-scan-address`); what it sends can still identify it. A compromised `net` zone gets no zone's files and not `vault`. Confidentiality on the wire is the application's job. diff --git a/docs/user-guide.md b/docs/user-guide.md index 0a076049..c249c8f6 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -274,9 +274,11 @@ private there. Each saved network sees the machine by an address of its own, made when the network was saved and kept with it, never the radio's: two networks cannot tell they saw one machine by its address or by what it asks for a lease. Networks that share a name, a chain's cafés for one, are one -saved network and see one address. A scan, before a network is joined, goes -out from whatever address the radio has then. `kryptik wifi list` names the saved networks, -and `kryptik wifi forget SSID` removes one. +saved network and see one address. The radio never uses its own address: a +scan for networks, before one is joined, goes out from a new random address +each time on most radios, so a scan does not follow the machine either. +`kryptik wifi list` names the saved networks, and `kryptik wifi forget SSID` +removes one. **The local network.** Every zone with a network reaches the internet through the net zone. The network the machine itself is on, with its router, its diff --git a/tools/net/netzone-init.sh b/tools/net/netzone-init.sh index 45290812..d96931c5 100755 --- a/tools/net/netzone-init.sh +++ b/tools/net/netzone-init.sh @@ -204,6 +204,15 @@ start_wifi() { # start_wifi : 0 started or already running, 1 not # Not as root: kryptikd gives it its own user, CAP_NET_ADMIN and CAP_NET_RAW, and no nf_tables. if kryptikd supplicant -- wpa_supplicant -B -i "$n" -c "$WPA_CONF" -C "$WPA_CTRL" -P "$WPA_CTRL/$n.pid" 2>/tmp/wpa.err; then say "wifi: wpa_supplicant on ${n} (pid $(wpa_pid "$n"))" + # A scan while no network is joined goes out from a new random address each time, not + # the last network's; a driver that offers none scans from the radio's address, which + # kryptikd made new for this start. The socket is the supplicant's, so its user asks. + rs="$(kryptikd supplicant -- wpa_cli -s "$WPA_CTRL" -p "$WPA_CTRL" -i "$n" mac_rand_scan all enable=1 2>&1)" + if [ "$rs" = OK ]; then + say "wifi: ${n}: scans go out from random addresses" + else + say "wifi: ${n}: no random scan addresses ($(printf '%s' "$rs" | tr '\n' ' ')); scans go out from the radio's" + fi return 0 fi say "wifi: wpa_supplicant did not start on ${n}: $(tr '\n' ' ' < /tmp/wpa.err)" From 56daf1fd78b8f31fce09b15353b4100d1f80a44c Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 22:42:33 -0700 Subject: [PATCH 099/213] The desktop menu's Wi-Fi window saves an open network with o, asking for no passphrase kryptik wifi add --open could save a network with no passphrase, but the menu could not: w offered only a network with a passphrase. Now w, then o asks for the network's name alone, says first that anyone in range reads what crosses an open network, and saves it through kryptik-launch --wifi-add-open, as the command line does. chrome-wifi-add-open (desktop suite) types w, o and a name into the menu's own window and finds the network in the net zone's file with key_mgmt=NONE and no psk line. On the old menu, o is answered with "?" and nothing is saved. --- build/guest-tests/gui-check.sh | 21 +++++++++++++++++++++ docs/user-guide.md | 4 ++-- tools/desktop/kryptik-chrome | 17 +++++++++++------ tools/image/gui-test.sh | 5 ++++- 4 files changed, 38 insertions(+), 9 deletions(-) diff --git a/build/guest-tests/gui-check.sh b/build/guest-tests/gui-check.sh index c45e8f17..8a365be6 100755 --- a/build/guest-tests/gui-check.sh +++ b/build/guest-tests/gui-check.sh @@ -20,6 +20,8 @@ # GT SCREENSHOT-HEAD take a screenshot of the second monitor # GT KEY-MENU-WIFI press Alt+p (a menu window for Wi-Fi) # GT WIFI-TYPE N TEXT type TEXT and Enter (letters and digits) +# GT KEY-MENU-OPEN press Alt+p (a menu window for an open network) +# GT OPEN-TYPE N TEXT type TEXT and Enter (letters and digits) # GT END # Verdicts: "GT PASS|FAIL|INFO name - detail". set -u @@ -692,6 +694,25 @@ else fi pkill -n -u "$USER_NAME" -f 'havoc /usr/bin/kryptik-chrome --menu' 2>/dev/null "$KD" wifi forget ztmenunet --wifi-dir /var/lib/kryptik/wifi > /dev/null 2>&1 +# An open network from the same menu, with o: no passphrase is asked for or saved. +menus_before="$(menu_windows)" +echo "GT KEY-MENU-OPEN" +open_typed=no +if wait_for 20 more_menus; then + sleep 2; echo "GT OPEN-TYPE 1 w" + sleep 3; echo "GT OPEN-TYPE 2 o" + sleep 2; echo "GT OPEN-TYPE 3 ztmenuopen" + open_typed=yes +fi +open_block() { sed -n '/^\tssid="ztmenuopen"$/,/^}$/p' "$WCONF" 2>/dev/null; } +if [[ "$open_typed" == yes ]] && wait_for 30 grep -qx $'\tssid="ztmenuopen"' "$WCONF" \ + && open_block | grep -qx $'\tkey_mgmt=NONE' && ! open_block | grep -q 'psk='; then + pass "chrome-wifi-add-open" "the menu's w, then o, saved ztmenuopen as an open network without asking for a passphrase" +else + fail "chrome-wifi-add-open" "typed: ${open_typed}; ztmenuopen's block: $(open_block | tr '\n\t' ' '); menu windows: $(menu_windows)" +fi +pkill -n -u "$USER_NAME" -f 'havoc /usr/bin/kryptik-chrome --menu' 2>/dev/null +"$KD" wifi forget ztmenuopen --wifi-dir /var/lib/kryptik/wifi > /dev/null 2>&1 # --- teardown ------------------------------------------------------------------------------ # The runtime tmpfs does not survive power-off: copy the session log and the diff --git a/docs/user-guide.md b/docs/user-guide.md index 8d3ca0d2..d14e112c 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -251,8 +251,8 @@ address on the uplink and whether there is DNS. The menu also names a release that has arrived and waits to be installed, and says when the newest update statement is more than 30 days old, or, with none ever accepted, when the install is (section 4). With `w` it lists the Wi-Fi networks the net zone may join, adds -one, asking for its passphrase in the menu's own window, or forgets one, as -`kryptik wifi` does at the administration login. +one, asking for its passphrase in the menu's own window, adds an open one +with `o`, or forgets one, as `kryptik wifi` does at the administration login. For administration, Ctrl+Alt+F2 gives a text login: log in as the user, and `su` becomes root. There `kryptik list`, `kryptik status [ZONE]`, `kryptik diff --git a/tools/desktop/kryptik-chrome b/tools/desktop/kryptik-chrome index 4d11c872..6273020f 100755 --- a/tools/desktop/kryptik-chrome +++ b/tools/desktop/kryptik-chrome @@ -250,17 +250,22 @@ run_wifi() { nets="$("$LAUNCH" --wifi-list 2>&1)" printf '%s\n' "${nets:-none}" | sed 's/^/ /' echo - echo "a add a network, or change its passphrase f forget one Enter back" + echo "a add a network, or change its passphrase o add an open one f forget one Enter back" printf 'choice: ' read -r c || return 0 case "$c" in "") return 0 ;; # IFS=: an SSID may begin or end with a space. - a|f) printf 'network name (SSID): ' - IFS= read -r ssid || return 0 - [ -n "$ssid" ] || continue - if [ "$c" = a ]; then "$LAUNCH" --wifi-add "$ssid"; else "$LAUNCH" --wifi-forget "$ssid"; fi - sleep 3 ;; + a|o|f) [ "$c" = o ] && echo "An open network has no passphrase: anyone in range reads what crosses it." + printf 'network name (SSID): ' + IFS= read -r ssid || return 0 + [ -n "$ssid" ] || continue + case "$c" in + a) "$LAUNCH" --wifi-add "$ssid" ;; + o) "$LAUNCH" --wifi-add-open "$ssid" ;; + f) "$LAUNCH" --wifi-forget "$ssid" ;; + esac + sleep 3 ;; *) echo "?"; sleep 1 ;; esac done diff --git a/tools/image/gui-test.sh b/tools/image/gui-test.sh index 14e46c31..c9a25916 100755 --- a/tools/image/gui-test.sh +++ b/tools/image/gui-test.sh @@ -98,6 +98,9 @@ python3 "$DRV" --serial "$SER" --qmp "$QMP" --timeout 600 \ "expect:GT KEY-MENU-WIFI" "key:alt+p" \ "type-from:GT WIFI-TYPE 1 ([a-z0-9]+)" "type-from:GT WIFI-TYPE 2 ([a-z0-9]+)" \ "type-from:GT WIFI-TYPE 3 ([a-z0-9]+)" "type-from:GT WIFI-TYPE 4 ([a-z0-9]+)" \ + "expect:GT KEY-MENU-OPEN" "key:alt+p" \ + "type-from:GT OPEN-TYPE 1 ([a-z0-9]+)" "type-from:GT OPEN-TYPE 2 ([a-z0-9]+)" \ + "type-from:GT OPEN-TYPE 3 ([a-z0-9]+)" \ "expect:GT END" "expect:GCHECK-DONE" \ "send:su - root -c 'poweroff'" "expect:Password: ?" "send:${RPASS}" \ "expect:Power down" "wait-exit" @@ -113,7 +116,7 @@ grep 'GT FAIL' <<<"$T" | sed 's/^/ /' for name in session-socket compositor-running chrome-focus-record chrome-window-is-zone0 zone0-sees-capture zone-proxy-path zone-sees-needed zone-hidden-globals zone-bind-refused proxy-logged-refusal \ map-keeps-zone0-focus close-keeps-zone0-focus alone-keeps-no-keyboard alone-reached-by-key focus-shows-zone focus-shows-label title-prefixed last-zone-recorded menu-opens-on-key menu-keeps-last-zone menu-names-stale-statement menu-names-install-age zone-fullscreen-refused compositor-survives-close oversize-window forged-title-named-by-zone second-zone-window zone0-own-programs-only zone-app-in-cgroup no-virtual-input clipboard-isolated clipboard-move-gesture clipboard-moved \ transfer-policy no-question-for-policy-refusal consent-code-shown transfer-approved transfer-landed plain-y-refused denied-file-absent \ - second-head-appears chrome-follows-head second-head-zone-window second-head-names-zone second-head-gone compositor-survives-unplug zone-survives-unplug chrome-back-on-first-head chrome-wifi-add menu-network-line \ + second-head-appears chrome-follows-head second-head-zone-window second-head-names-zone second-head-gone compositor-survives-unplug zone-survives-unplug chrome-back-on-first-head chrome-wifi-add menu-network-line chrome-wifi-add-open \ zone0-cursor-set zone0-cursor-shown zone-cursor-asked zone-hears-of-outputs zone-cursor-not-shown compositor-socket-unreached \ zone0-frame-clock zone-window-animates \ zone0-fullscreen-granted zone0-fullscreen-needs-focus fullscreen-by-key zone-child-mapped child-focused parent-focused parent-fullscreen fullscreen-keeps-focus keyboard-stays-on-fullscreen zoom-keeps-keyboard zoom-twice-keeps-keyboard zone0-over-fullscreen-gets-keyboard zone0-window-ends-fullscreen child-ends-fullscreen; do From 3962b74a3b4a8247fe1542e275f3776f07243411 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 22:43:05 -0700 Subject: [PATCH 100/213] rtl8192cufw is left out: rtl8xxxu lists the 8192CU only under RTL8XXXU_UNTESTED --- build/config/firmware.list | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/build/config/firmware.list b/build/config/firmware.list index 4e540934..0abd4b13 100644 --- a/build/config/firmware.list +++ b/build/config/firmware.list @@ -37,10 +37,11 @@ mediatek/WIFI_MT7922_patch_mcu_1_1_hdr.bin mediatek/WIFI_RAM_CODE_MT7922_1.bin mediatek/mt7925/WIFI_* -# Realtek: the PCIe chips rtw88 and rtw89 drive here, and every USB stick rtl8xxxu drives (its -# MODULE_FIRMWARE names; linux-firmware has no rtl8723bu_bt). rtw89 asks for the newest format it -# reads first (fw_format_max); linux-firmware also has formats only later kernels read, and chips -# not built here (8852BT, 8922D). +# Realtek: the PCIe chips rtw88 and rtw89 drive here, and the USB sticks rtl8xxxu drives (its +# MODULE_FIRMWARE names; linux-firmware has no rtl8723bu_bt, and the 8192CU ids are behind +# RTL8XXXU_UNTESTED, which is not built). rtw89 asks for the newest format it reads first +# (fw_format_max); linux-firmware also has formats only later kernels read, and chips not built +# here (8852BT, 8922D). rtw88/rtw8723d_fw.bin rtw88/rtw8821c_fw.bin rtw88/rtw8822b_fw.bin @@ -53,9 +54,6 @@ rtw89/rtw8852c_fw-2.bin rtw89/rtw8922a_fw-4.bin rtlwifi/rtl8188eufw.bin rtlwifi/rtl8188fufw.bin -rtlwifi/rtl8192cufw_A.bin -rtlwifi/rtl8192cufw_B.bin -rtlwifi/rtl8192cufw_TMSC.bin rtlwifi/rtl8192eu_nic.bin rtlwifi/rtl8192fufw.bin rtlwifi/rtl8710bufw_SMIC.bin From 05461a7f4fe09951fc1ffc96290e4f9dfd7e6bb8 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 22:44:30 -0700 Subject: [PATCH 101/213] perl's config.over sets cf_by too, and the doc names every file the values reach --- build/recipes/perl.sh | 4 ++-- docs/design/reproducible-builds.md | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/build/recipes/perl.sh b/build/recipes/perl.sh index 0eabb756..0e15a1f8 100644 --- a/build/recipes/perl.sh +++ b/build/recipes/perl.sh @@ -6,11 +6,11 @@ s_perl() { # Configure ignores CFLAGS and LDFLAGS, so the hardening goes in as its own settings. # -shared: a given lddlflags replaces Configure's default instead of adding to it. # No GDBM_File: nothing needs it, and the build must not depend on a cached gdbm. - # Config.pm would carry the build host's name, kernel and clock, and Configure ignores + # perl's Config files would carry the build host's name, kernel and clock, and Configure ignores # SOURCE_DATE_EPOCH; config.over, which it reads last, sets them (osvers: the series shipped). { echo "myhostname='localhost' mydomain='.localdomain' myuname='linux localhost' osvers='6.18'" - echo "perladmin='root@localhost' cf_email='root@localhost'" + echo "perladmin='root@localhost' cf_email='root@localhost' cf_by='root'" [[ -z "${SOURCE_DATE_EPOCH:-}" ]] || echo "cf_time='$(LC_ALL=C date -u -d "@${SOURCE_DATE_EPOCH}")'" } > config.over sh Configure -des \ diff --git a/docs/design/reproducible-builds.md b/docs/design/reproducible-builds.md index 236a6721..a67c6035 100644 --- a/docs/design/reproducible-builds.md +++ b/docs/design/reproducible-builds.md @@ -89,10 +89,11 @@ Paths are fixed in the chroot, so what remains are the usual suspects: compressed man and info pages (gzip stores a time and a name unless `-n`), static archives (member times, unless binutils is in deterministic mode), Python's bytecode (a timestamp in each `.pyc`, unless `SOURCE_DATE_EPOCH` -is set, when `py_compile` writes hash-checked files), and perl's -`Config.pm`, which records when perl was built and the build host's name and -kernel; perl's Configure ignores `SOURCE_DATE_EPOCH`, so the recipe gives it -fixed values in a `config.over`. `SOURCE_DATE_EPOCH` in every step takes most +is set, when `py_compile` writes hash-checked files), and perl's Config files +(`Config.pm`, `Config_heavy.pl`, `CORE/config.h`, `Errno.pm`), which record +when perl was built and the build host's name and kernel; perl's Configure +ignores `SOURCE_DATE_EPOCH`, so the recipe gives it fixed values in a +`config.over`. `SOURCE_DATE_EPOCH` in every step takes most of the rest. The comparison finds what is left package by package. ### The Rust binaries From 78102f4b57926465cc8470542717e881b17f28b4 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 22:45:57 -0700 Subject: [PATCH 102/213] firmware.list: RTL8XXXU_UNTESTED is left unset, not unbuilt --- build/config/firmware.list | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/config/firmware.list b/build/config/firmware.list index 0abd4b13..528295bf 100644 --- a/build/config/firmware.list +++ b/build/config/firmware.list @@ -39,7 +39,7 @@ mediatek/mt7925/WIFI_* # Realtek: the PCIe chips rtw88 and rtw89 drive here, and the USB sticks rtl8xxxu drives (its # MODULE_FIRMWARE names; linux-firmware has no rtl8723bu_bt, and the 8192CU ids are behind -# RTL8XXXU_UNTESTED, which is not built). rtw89 asks for the newest format it reads first +# RTL8XXXU_UNTESTED, which this kernel leaves unset). rtw89 asks for the newest format it reads first # (fw_format_max); linux-firmware also has formats only later kernels read, and chips not built # here (8852BT, 8922D). rtw88/rtw8723d_fw.bin From 4c4046d2fe06b81ed9190aa687f31fe90b05e9ba Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 22:47:25 -0700 Subject: [PATCH 103/213] Build C++ with libstdc++'s assertions -D_GLIBCXX_ASSERTIONS makes libstdc++ check its preconditions: an index or iterator past a container's end, a front() of an empty vector, a null unique_ptr dereferenced. The program aborts instead of reading or writing past the object, as with Fedora's and Arch's default flags and the OpenSSF compiler hardening guide. The image's C++ is groff, ninja, GCC's own programs, libgmpxx and ncurses++; groff and ninja read files a user may have downloaded. The flag goes in CXXFLAGS only, through KRYPTIK_CXXFLAGS_HARDENING, which stage 04's per-package flags use too. hardening-flags.sh builds a C++ program with the flag set and expects an index past a vector's end to abort. --- build/config/hardening.env | 5 ++++- build/stages/04-base-system.sh | 2 +- docs/hardening.md | 1 + tools/tests/hardening-flags.sh | 29 +++++++++++++++++++++++++++++ 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/build/config/hardening.env b/build/config/hardening.env index 4f30bb3a..84efc641 100644 --- a/build/config/hardening.env +++ b/build/config/hardening.env @@ -21,8 +21,11 @@ KRYPTIK_LDFLAGS_HARDENING="\ -Wl,-z,separate-code \ -Wl,--as-needed" +# C++ only: libstdc++ checks its preconditions, so an index past a container's end aborts. +KRYPTIK_CXXFLAGS_HARDENING="-D_GLIBCXX_ASSERTIONS" + export CFLAGS="${KRYPTIK_OPT} ${KRYPTIK_CFLAGS_HARDENING}" -export CXXFLAGS="${KRYPTIK_OPT} ${KRYPTIK_CFLAGS_HARDENING}" +export CXXFLAGS="${KRYPTIK_OPT} ${KRYPTIK_CFLAGS_HARDENING} ${KRYPTIK_CXXFLAGS_HARDENING}" export LDFLAGS="${KRYPTIK_LDFLAGS_HARDENING}" # Per-package flag exemptions, each with the justification validate_hardening_exceptions enforces. diff --git a/build/stages/04-base-system.sh b/build/stages/04-base-system.sh index aa4815fd..dd27d3cc 100755 --- a/build/stages/04-base-system.sh +++ b/build/stages/04-base-system.sh @@ -65,7 +65,7 @@ exception_flags_for() { set_flags_for() { local pkg="$1" drop export CFLAGS="${KRYPTIK_OPT} ${KRYPTIK_CFLAGS_HARDENING}" - export CXXFLAGS="${KRYPTIK_OPT} ${KRYPTIK_CFLAGS_HARDENING}" + export CXXFLAGS="${KRYPTIK_OPT} ${KRYPTIK_CFLAGS_HARDENING} ${KRYPTIK_CXXFLAGS_HARDENING}" export LDFLAGS="${KRYPTIK_LDFLAGS_HARDENING}" while read -r drop; do diff --git a/docs/hardening.md b/docs/hardening.md index 929586a7..4b0f56db 100644 --- a/docs/hardening.md +++ b/docs/hardening.md @@ -20,6 +20,7 @@ temporary tools (stages 01 and 02) are built without them. | `-Wl,-z,noexecstack` | executable stack payloads | none | | `-ftrivial-auto-var-init=zero` | uninitialized-memory disclosure | ~0.5% | | `-fno-delete-null-pointer-checks` | NULL checks removed as dead code | none | +| `-D_GLIBCXX_ASSERTIONS` (C++) | an index or iterator past a libstdc++ container's end: the program aborts | small | It also passes `-fno-strict-aliasing`, `-Wl,-z,separate-code` and `-Wl,--as-needed`. diff --git a/tools/tests/hardening-flags.sh b/tools/tests/hardening-flags.sh index f6e05527..af8807fa 100755 --- a/tools/tests/hardening-flags.sh +++ b/tools/tests/hardening-flags.sh @@ -31,12 +31,15 @@ WORK="$(mktemp -d)" trap 'rm -rf "$WORK"' EXIT HCFLAGS="${KRYPTIK_OPT} ${KRYPTIK_CFLAGS_HARDENING}" +HCXXFLAGS="${HCFLAGS} ${KRYPTIK_CXXFLAGS_HARDENING}" HLDFLAGS="${KRYPTIK_LDFLAGS_HARDENING}" +CXX="${CXX:-g++}" echo "Hardening flag verification" echo " compiler : ${CC} ($("$CC" -dumpmachine 2>/dev/null))" echo " version : $("$CC" --version 2>/dev/null | head -1)" echo " CFLAGS : ${HCFLAGS}" +echo " CXXFLAGS : ${HCXXFLAGS}" echo " LDFLAGS : ${HLDFLAGS}" echo @@ -128,6 +131,32 @@ else fi echo +echo "-- C++" +cat > "$WORK/oob.cc" <<'CPP' +#include +int main(int argc, char **) { std::vector v(1); return v[argc]; } +CPP +if ! have "$CXX"; then + note "no ${CXX}: the C++ checks did not run" +else + # shellcheck disable=SC2086 + "$CXX" $HCXXFLAGS $HLDFLAGS -o "$WORK/oob" "$WORK/oob.cc" 2> "$WORK/oob.err" + if [[ $? -ne 0 ]]; then + red "a C++ program does NOT link under the full flag set" + sed 's/^/ /' "$WORK/oob.err" + else + # The braces take the shell's own "Aborted" line along with the program's message. + { "$WORK/oob"; } 2> /dev/null + rc=$? + if [[ "$rc" -eq 134 ]]; then + green "an index past a vector's end aborts (-D_GLIBCXX_ASSERTIONS)" + else + red "an index past a vector's end did not abort (exit ${rc})" + fi + fi +fi +echo + # An accepted flag can still produce nothing, so check the ELF. echo "-- what landed in the binaries" From 007ecf3aacbbb0f5da9749f4837b59331175987a Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 22:52:43 -0700 Subject: [PATCH 104/213] The scan-address wording holds for a driver that refuses a new address, and for scans once joined A driver that refuses to change the interface's address keeps the radio's own and scans from it unless it offers random scan addresses: net-zone.md and the guide said the radio's own never goes on the air without that condition. The threat model's sentence now covers scans before joining only, as net-zone.md says a scan while joined goes out from that network's address. netzone-init.sh's comment and its line for a driver without random scan addresses name the station's address, the last network's after one. --- docs/design/net-zone.md | 27 ++++++++++++++------------- docs/threat-model.md | 2 +- docs/user-guide.md | 7 ++++--- tools/net/netzone-init.sh | 7 ++++--- 4 files changed, 23 insertions(+), 20 deletions(-) diff --git a/docs/design/net-zone.md b/docs/design/net-zone.md index 1d565814..9b373fd7 100755 --- a/docs/design/net-zone.md +++ b/docs/design/net-zone.md @@ -327,19 +327,20 @@ configured the zone keeps its IPv4 path and the launcher says so. `/etc/dhcpcd.conf`): no hostname, no vendor class, and a client ID made from the address the interface has when it asks, not a DUID made from a hardware address, which the stock configuration would send to every - network. The radio's own address never goes on the air: kryptikd gives - its station a new random address each time the net zone starts, and a - scan while no network is joined goes out from a new random address each - time (wpa_supplicant's `MAC_RAND_SCAN`, which the net zone turns on as its - supplicant starts; the interface's address stays as it is). A driver - without random scan addresses scans from the address the station has - then: the start's, or after a network the last network's, and the net - zone says which it got. The kernel refuses random addresses to a station - that has joined, so a scan then goes out from that network's address. - A wired uplink keeps its own address. A driver that refuses to change the - interface's address never joins: wpa_supplicant gives up the association - rather than use the radio's own, and the status line stays at - `wifi=connecting`. + network. Where its driver takes a new address, the radio's own never goes + on the air: kryptikd gives its station a new random address each time the + net zone starts, and a scan while no network is joined goes out from a new + random address each time (wpa_supplicant's `MAC_RAND_SCAN`, which the net + zone turns on as its supplicant starts; the interface's address stays as + it is). A driver without random scan addresses scans from the address the + station has then: the start's, or after a network the last network's, and + the net zone says which it got. The kernel refuses random addresses to a + station that has joined, so a scan then goes out from that network's + address. A wired uplink keeps its own address. A driver that refuses to + change the interface's address keeps the radio's own, scans from it unless + it offers random scan addresses, and never joins: wpa_supplicant gives up + the association rather than use the radio's own, and the status line stays + at `wifi=connecting`. - On disk the file is plaintext inside the [encrypted](state-encryption.md) state partition, like NetworkManager's connection files. A compromised net zone learns the passphrases of the networks it was given, and nothing more. diff --git a/docs/threat-model.md b/docs/threat-model.md index fdc6f752..6b3936cc 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -55,7 +55,7 @@ already assumed hostile. **Partially defended.** Kryptik segments; it does not anonymize. A Wi-Fi network sees the machine by an address of its own and a DHCP client ID made from it, never the radio's own, so two networks cannot match the machine by -those (`wifi-own-address`), and a scan goes out from neither +those (`wifi-own-address`), and a scan before joining goes out from neither (`wifi-scan-address`); what it sends can still identify it. A compromised `net` zone gets no zone's files and not `vault`. Confidentiality on the wire is the application's job. diff --git a/docs/user-guide.md b/docs/user-guide.md index c249c8f6..4870bf3f 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -274,9 +274,10 @@ private there. Each saved network sees the machine by an address of its own, made when the network was saved and kept with it, never the radio's: two networks cannot tell they saw one machine by its address or by what it asks for a lease. Networks that share a name, a chain's cafés for one, are one -saved network and see one address. The radio never uses its own address: a -scan for networks, before one is joined, goes out from a new random address -each time on most radios, so a scan does not follow the machine either. +saved network and see one address. A radio that takes a new address, as most +do, never uses its own: a scan for networks, before one is joined, goes out +from a new random address each time, so a scan does not follow the machine +either. `kryptik wifi list` names the saved networks, and `kryptik wifi forget SSID` removes one. diff --git a/tools/net/netzone-init.sh b/tools/net/netzone-init.sh index d96931c5..00434816 100755 --- a/tools/net/netzone-init.sh +++ b/tools/net/netzone-init.sh @@ -205,13 +205,14 @@ start_wifi() { # start_wifi : 0 started or already running, 1 not if kryptikd supplicant -- wpa_supplicant -B -i "$n" -c "$WPA_CONF" -C "$WPA_CTRL" -P "$WPA_CTRL/$n.pid" 2>/tmp/wpa.err; then say "wifi: wpa_supplicant on ${n} (pid $(wpa_pid "$n"))" # A scan while no network is joined goes out from a new random address each time, not - # the last network's; a driver that offers none scans from the radio's address, which - # kryptikd made new for this start. The socket is the supplicant's, so its user asks. + # the last network's. A driver that offers none scans from the station's address: the + # one kryptikd made for this start, or the last network's after one. The socket is the + # supplicant's, so its user asks. rs="$(kryptikd supplicant -- wpa_cli -s "$WPA_CTRL" -p "$WPA_CTRL" -i "$n" mac_rand_scan all enable=1 2>&1)" if [ "$rs" = OK ]; then say "wifi: ${n}: scans go out from random addresses" else - say "wifi: ${n}: no random scan addresses ($(printf '%s' "$rs" | tr '\n' ' ')); scans go out from the radio's" + say "wifi: ${n}: no random scan addresses ($(printf '%s' "$rs" | tr '\n' ' ')); scans go out from the station's address" fi return 0 fi From 402e5bc59f74adb157d20b4ea38bddf649579b38 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 22:53:21 -0700 Subject: [PATCH 105/213] The menu's Wi-Fi line fits 80 columns, and the open network's check waits for the last window to close The help line was 89 characters, which wraps in an 80-column window; it is 77 now. chrome-wifi-add-open counted the menu windows right after closing the last one, which may still be going: the new window would then only bring the count back to where it was. It waits up to 5 s for the count to drop first. --- build/guest-tests/gui-check.sh | 6 +++++- tools/desktop/kryptik-chrome | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/build/guest-tests/gui-check.sh b/build/guest-tests/gui-check.sh index 8a365be6..36b9d4e7 100755 --- a/build/guest-tests/gui-check.sh +++ b/build/guest-tests/gui-check.sh @@ -692,9 +692,13 @@ if [[ "$wifi_typed" == yes ]] && wait_for 30 grep -qx $'\tssid="ztmenunet"' "$WC else fail "chrome-wifi-add" "typed: ${wifi_typed}; networks in the file: $(grep -c '^network=' "$WCONF" 2>/dev/null); menu windows: $(menu_windows)" fi +menus_open="$(menu_windows)" pkill -n -u "$USER_NAME" -f 'havoc /usr/bin/kryptik-chrome --menu' 2>/dev/null "$KD" wifi forget ztmenunet --wifi-dir /var/lib/kryptik/wifi > /dev/null 2>&1 -# An open network from the same menu, with o: no passphrase is asked for or saved. +# An open network from the same menu, with o: no passphrase is asked for or saved. The window +# just closed is gone first, or the new one would only bring the count back to where it was. +menus_fewer() { [[ "$(menu_windows)" -lt "$menus_open" ]]; } +wait_for 5 menus_fewer menus_before="$(menu_windows)" echo "GT KEY-MENU-OPEN" open_typed=no diff --git a/tools/desktop/kryptik-chrome b/tools/desktop/kryptik-chrome index 6273020f..95131186 100755 --- a/tools/desktop/kryptik-chrome +++ b/tools/desktop/kryptik-chrome @@ -250,7 +250,7 @@ run_wifi() { nets="$("$LAUNCH" --wifi-list 2>&1)" printf '%s\n' "${nets:-none}" | sed 's/^/ /' echo - echo "a add a network, or change its passphrase o add an open one f forget one Enter back" + echo "a add, or change a passphrase o add an open one f forget one Enter back" printf 'choice: ' read -r c || return 0 case "$c" in From 5470770a151be91771ce754b72ba5b322216537b Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 22:55:05 -0700 Subject: [PATCH 106/213] WCN6855 revision 2.1, QCA2066 and QCA6698AQ cards get their firmware ath11k reads a WCN6855 at SoC revision 2.1 from WCN6855/hw2.1, and the QCA2066 and QCA6698AQ, which share its PCI id, from their own hw2.1 directories (ath11k_pci_probe). The list named WCN6855/hw2.0 alone, so every such card, the common revision in laptops, found no firmware and had no Wi-Fi. linux-firmware's hw2.1 entries for the WCN6855 are links into hw2.0, so they cost nothing. ath11k and ath12k read a variant directory (nfa765, ncm865) only when a devicetree names one (firmware-name, or three Qualcomm Arm boards in ath11k's use-case table); an x86 machine has none, so [!n]* leaves those 5 MB out. --- build/config/firmware.list | 11 ++++++++--- docs/decisions.md | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/build/config/firmware.list b/build/config/firmware.list index 528295bf..bfadbf8a 100644 --- a/build/config/firmware.list +++ b/build/config/firmware.list @@ -19,14 +19,19 @@ newest 1 iwlwifi-[[:alpha:]]*-[0-9]*.ucode iwlwifi-*.pnvm # Qualcomm Atheros: the laptop parts, by board revision. ath10k asks for API 6 first, and the -# -sdio- files are for SDIO parts, which are not built. +# -sdio- files are for SDIO parts, which are not built. A WCN6855 at SoC revision 2.1 is read +# from WCN6855/hw2.1, QCA2066/hw2.1 or QCA6698AQ/hw2.1 (ath11k pci.c). ath11k and ath12k read a +# variant directory (nfa765, ncm865) only when a devicetree names one, so [!n]* leaves it out. ath10k/QCA6174/hw3.0/board*.bin ath10k/QCA6174/hw3.0/*firmware-6.* ath10k/QCA9377/hw1.0/board*.bin ath10k/QCA9377/hw1.0/*firmware-6.* +ath11k/QCA2066/hw2.1/* ath11k/QCA6390/hw2.0/* -ath11k/WCN6855/hw2.0/* -ath12k/WCN7850/hw2.0/* +ath11k/QCA6698AQ/hw2.1/* +ath11k/WCN6855/hw2.0/[!n]* +ath11k/WCN6855/hw2.1/[!n]* +ath12k/WCN7850/hw2.0/[!n]* # MediaTek MT7921 (7961 firmware), MT7922 and MT7925, without their Bluetooth. mediatek/WIFI_MT7961_patch_mcu_1_2_hdr.bin diff --git a/docs/decisions.md b/docs/decisions.md index 8aa8eade..7c76daa3 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -198,7 +198,7 @@ each signed kernel (`CONFIG_EXTRA_FIRMWARE`, stage 05), about 18 MB. Without it a machine runs whatever microcode its firmware last shipped, which on older machines means known, unfixed CPU vulnerabilities. -**Cost:** about 82 MB after zstd (amdgpu is 38 MB of it, Intel Wi-Fi 15 MB and +**Cost:** about 81 MB after zstd (amdgpu is 38 MB of it, Intel Wi-Fi 15 MB and Intel graphics 6 MB; for Intel, Realtek and Atheros the list names the files each driver asks for, not the versions linux-firmware keeps for other kernels) on a root image of about 1.6 GB, and an input nobody here can read. From 39caaad725542e79df69177edee85753ee130eef Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 22:55:45 -0700 Subject: [PATCH 107/213] hardening.md names what libstdc++'s assertions check: no iterators _GLIBCXX_ASSERTIONS checks operator[] bounds, front and back of an empty container and null smart pointers; checked iterators are debug mode (_GLIBCXX_DEBUG). GCC's native build copies CXXFLAGS into CXXFLAGS_FOR_TARGET, so libstdc++ itself is built with the flag as well. --- docs/hardening.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/hardening.md b/docs/hardening.md index 4b0f56db..f108a6d5 100644 --- a/docs/hardening.md +++ b/docs/hardening.md @@ -20,7 +20,7 @@ temporary tools (stages 01 and 02) are built without them. | `-Wl,-z,noexecstack` | executable stack payloads | none | | `-ftrivial-auto-var-init=zero` | uninitialized-memory disclosure | ~0.5% | | `-fno-delete-null-pointer-checks` | NULL checks removed as dead code | none | -| `-D_GLIBCXX_ASSERTIONS` (C++) | an index or iterator past a libstdc++ container's end: the program aborts | small | +| `-D_GLIBCXX_ASSERTIONS` (C++) | an index past a libstdc++ container's end, front or back of an empty one, or a null smart pointer dereferenced: the program aborts | small | It also passes `-fno-strict-aliasing`, `-Wl,-z,separate-code` and `-Wl,--as-needed`. From 75d7164fda98955881e0278b9c17f4c5f3496660 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 22:57:59 -0700 Subject: [PATCH 108/213] Realtek Ethernet gets the PHY and MAC patches r8169 and r8152 ask for r8169 and r8152 name a patch file per chip revision (33 in 6.18, all in linux-firmware 20260916) and apply it when the interface comes up (rtl_fw.c). The list named none, so every Realtek NIC and USB adapter ran without its revision's PHY fixes; the driver logs the missing file and carries on. The nine other rtl_nic files are older versions and chips 6.18 does not drive. ADR-012 names Ethernet beside graphics and Wi-Fi. --- build/config/firmware.list | 36 ++++++++++++++++++++++++++++++++++++ docs/decisions.md | 13 +++++++------ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/build/config/firmware.list b/build/config/firmware.list index bfadbf8a..60e1ab1f 100644 --- a/build/config/firmware.list +++ b/build/config/firmware.list @@ -71,6 +71,42 @@ rtlwifi/rtl8723bu_nic.bin # Broadcom over PCIe (Dell and Apple machines); the SDIO parts are not. brcm/brcmfmac*-pcie* +# Realtek Ethernet: the PHY and MAC patches r8169 and r8152 ask for by chip revision (rtl_fw.c). +# Without one the NIC still runs, unpatched. +rtl_nic/rtl8105e-1.fw +rtl_nic/rtl8106e-1.fw +rtl_nic/rtl8106e-2.fw +rtl_nic/rtl8107e-2.fw +rtl_nic/rtl8125a-3.fw +rtl_nic/rtl8125b-2.fw +rtl_nic/rtl8125bp-2.fw +rtl_nic/rtl8125d-1.fw +rtl_nic/rtl8125d-2.fw +rtl_nic/rtl8126a-2.fw +rtl_nic/rtl8126a-3.fw +rtl_nic/rtl8127a-1.fw +rtl_nic/rtl8153a-2.fw +rtl_nic/rtl8153a-3.fw +rtl_nic/rtl8153a-4.fw +rtl_nic/rtl8153b-2.fw +rtl_nic/rtl8153c-1.fw +rtl_nic/rtl8156a-2.fw +rtl_nic/rtl8156b-2.fw +rtl_nic/rtl8168d-1.fw +rtl_nic/rtl8168d-2.fw +rtl_nic/rtl8168e-1.fw +rtl_nic/rtl8168e-2.fw +rtl_nic/rtl8168e-3.fw +rtl_nic/rtl8168f-1.fw +rtl_nic/rtl8168f-2.fw +rtl_nic/rtl8168fp-3.fw +rtl_nic/rtl8168g-2.fw +rtl_nic/rtl8168g-3.fw +rtl_nic/rtl8168h-2.fw +rtl_nic/rtl8402-1.fw +rtl_nic/rtl8411-1.fw +rtl_nic/rtl8411-2.fw + # Graphics: Intel and AMD, not NVIDIA (GSP firmware is tens of MB; the framebuffer still works). # Intel: the files 6.18's i915 and xe name (intel_uc_fw.c, xe_uc_fw.c, intel_dmc.c), not the # older versions linux-firmware keeps for older kernels, and xe's fan control, which it asks diff --git a/docs/decisions.md b/docs/decisions.md index 7c76daa3..aec6a4ce 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -170,14 +170,15 @@ sibling thread online the kernel refuses the cookie (`ENODEV`) and ## ADR-012: Device firmware from linux-firmware, on the verified root -The image ships the firmware laptop graphics and Wi-Fi need, from the pinned -`linux-firmware` release, as selected by `build/config/firmware.list`, -zstd-compressed under `/lib/firmware` on the dm-verity root. The drivers that -load it are signed modules, so they probe after the root is mounted. +The image ships the firmware laptop graphics, Wi-Fi and Realtek Ethernet need, +from the pinned `linux-firmware` release, as selected by +`build/config/firmware.list`, zstd-compressed under `/lib/firmware` on the +dm-verity root. The drivers that load it are signed modules, so they probe +after the root is mounted. **Why:** Intel, AMD and Qualcomm Wi-Fi and AMD GPUs do not run without vendor -firmware, and Intel graphics runs degraded. Without it most laptops have no -Wi-Fi. +firmware, Intel graphics runs degraded, and Realtek Ethernet runs without its +chips' PHY fixes. Without it most laptops have no Wi-Fi. These are vendor binaries, not built from source as [supply-chain.md](supply-chain.md) otherwise requires, and run by the device's From 42855361678e2a06d852939a4d2e55ff309bdbd5 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 22:58:58 -0700 Subject: [PATCH 109/213] tg3's four firmware files ship: a 5701 A0 needs one to come up tg3 names tg3.bin (the 5701 A0, which fails to open without it), tg3_tso.bin and tg3_tso5.bin (TSO on the 5703 to 5705, disabled without them) and tg357766.bin (MODULE_FIRMWARE, tg3.c:220-231). The list named none. --- build/config/firmware.list | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/build/config/firmware.list b/build/config/firmware.list index 60e1ab1f..bba800af 100644 --- a/build/config/firmware.list +++ b/build/config/firmware.list @@ -71,6 +71,13 @@ rtlwifi/rtl8723bu_nic.bin # Broadcom over PCIe (Dell and Apple machines); the SDIO parts are not. brcm/brcmfmac*-pcie* +# Broadcom Tigon3 (tg3): a 5701 A0 does not come up without tg3.bin, the 5703 to 5705 lose TSO +# without theirs, and the 57766 loads its own. +tigon/tg3.bin +tigon/tg3_tso.bin +tigon/tg3_tso5.bin +tigon/tg357766.bin + # Realtek Ethernet: the PHY and MAC patches r8169 and r8152 ask for by chip revision (rtl_fw.c). # Without one the NIC still runs, unpatched. rtl_nic/rtl8105e-1.fw From 2d2e5fcec2009f890f9e57d56c81dfde11cfc472 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 22:59:14 -0700 Subject: [PATCH 110/213] ADR-012 says Ethernet, Realtek's and Tigon3's --- docs/decisions.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/decisions.md b/docs/decisions.md index aec6a4ce..123727c0 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -170,15 +170,16 @@ sibling thread online the kernel refuses the cookie (`ENODEV`) and ## ADR-012: Device firmware from linux-firmware, on the verified root -The image ships the firmware laptop graphics, Wi-Fi and Realtek Ethernet need, -from the pinned `linux-firmware` release, as selected by +The image ships the firmware laptop graphics, Wi-Fi and Ethernet need, from +the pinned `linux-firmware` release, as selected by `build/config/firmware.list`, zstd-compressed under `/lib/firmware` on the dm-verity root. The drivers that load it are signed modules, so they probe after the root is mounted. **Why:** Intel, AMD and Qualcomm Wi-Fi and AMD GPUs do not run without vendor -firmware, Intel graphics runs degraded, and Realtek Ethernet runs without its -chips' PHY fixes. Without it most laptops have no Wi-Fi. +firmware, Intel graphics runs degraded, Realtek Ethernet runs without its +chips' PHY fixes, and an old Broadcom Tigon3 does not come up. Without it most +laptops have no Wi-Fi. These are vendor binaries, not built from source as [supply-chain.md](supply-chain.md) otherwise requires, and run by the device's From 172194a292d93d51ab08bb4ce674427e75119fce Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 22:59:53 -0700 Subject: [PATCH 111/213] firmware.list's comments name the MT7920 and keep the file's width --- build/config/firmware.list | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/build/config/firmware.list b/build/config/firmware.list index bba800af..91035ad2 100644 --- a/build/config/firmware.list +++ b/build/config/firmware.list @@ -33,7 +33,8 @@ ath11k/WCN6855/hw2.0/[!n]* ath11k/WCN6855/hw2.1/[!n]* ath12k/WCN7850/hw2.0/[!n]* -# MediaTek MT7921 (7961 firmware), MT7922 and MT7925, without their Bluetooth. +# MediaTek MT7920 and MT7921 (the 7961 firmware, 1a for the MT7920), MT7922 and MT7925, without +# their Bluetooth. mediatek/WIFI_MT7961_patch_mcu_1_2_hdr.bin mediatek/WIFI_MT7961_patch_mcu_1a_2_hdr.bin mediatek/WIFI_RAM_CODE_MT7961_1.bin @@ -44,9 +45,9 @@ mediatek/mt7925/WIFI_* # Realtek: the PCIe chips rtw88 and rtw89 drive here, and the USB sticks rtl8xxxu drives (its # MODULE_FIRMWARE names; linux-firmware has no rtl8723bu_bt, and the 8192CU ids are behind -# RTL8XXXU_UNTESTED, which this kernel leaves unset). rtw89 asks for the newest format it reads first -# (fw_format_max); linux-firmware also has formats only later kernels read, and chips not built -# here (8852BT, 8922D). +# RTL8XXXU_UNTESTED, which this kernel leaves unset). rtw89 asks for the newest format it reads +# first (fw_format_max); linux-firmware also has formats only later kernels read, and chips not +# built here (8852BT, 8922D). rtw88/rtw8723d_fw.bin rtw88/rtw8821c_fw.bin rtw88/rtw8822b_fw.bin From 9269ff2909911b33f541cd53c80aead2eba06ce9 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 23:01:47 -0700 Subject: [PATCH 112/213] kryptik wifi scan and the menu's s list the networks in range, as the net zone hears them A network could be saved only by a name typed exactly, with nothing on the machine to say which networks were in range or how they are secured. kryptikd asks the nic zone with a SIGUSR1 to its pid 1, sent through a pidfd taken while the registry's start time matches; the signal carries nothing. netzone-init.sh scans each radio with iw, from a random address while no network is joined and only listening once one is, so nothing goes out from the address that network knows, and reads a busy radio's last results. It writes a line per network (signal, security, SSID as iw escapes it) and names a radio rfkill holds off, in its own /run/netzone-scan. kryptikd reads that inside the zone's root with no link followed, as it reads the status line (now one function on the registry's pid record), and takes it as an untrusted zone's word: a line of any other form is dropped, an SSID is unescaped and shown quoted with nothing unprintable, each network once at its strongest, at most 40, saved ones marked. kryptikd wifi scan does this as root. kryptikd serve runs it as a job for the desktop (wifi-scan, one at a time, so a scan's seconds do not hold the daemon), kryptik-launch --wifi-scan and kryptik wifi scan ask it, and the menu's Wi-Fi window shows it under s. Checks: tools/tests/netzone-scan.sh (iw's listing to the zone's answer, and the scan's fallbacks); wifi.rs tests (an answer holding everything a zone might write); the cli suite through the launch service; the zones suite before the net zone holds a radio and joined to the simulated access point (wifi-scan-no-radio, wifi-scan); the desktop suite through the launcher (chrome-wifi-scan). --- build/guest-tests/gui-check.sh | 8 ++ build/guest-tests/zones-check.sh | 16 +++ compartments/kryptikd/src/main.rs | 12 +- compartments/kryptikd/src/registry.rs | 38 ++++++ compartments/kryptikd/src/serve.rs | 41 +++--- compartments/kryptikd/src/wifi.rs | 168 +++++++++++++++++++++++- compartments/kryptikd/src/wifi/tests.rs | 86 ++++++++++++ compartments/tests/cli.sh | 12 +- docs/design/net-zone.md | 19 ++- docs/user-guide.md | 12 +- tools/desktop/kryptik-chrome | 8 +- tools/desktop/kryptik-launch.c | 12 +- tools/image/gui-test.sh | 2 +- tools/kryptik | 8 +- tools/net/netzone-init.sh | 54 ++++++++ tools/tests/netzone-scan.sh | 115 ++++++++++++++++ 16 files changed, 579 insertions(+), 32 deletions(-) create mode 100755 tools/tests/netzone-scan.sh diff --git a/build/guest-tests/gui-check.sh b/build/guest-tests/gui-check.sh index 36b9d4e7..06f2f1eb 100755 --- a/build/guest-tests/gui-check.sh +++ b/build/guest-tests/gui-check.sh @@ -717,6 +717,14 @@ else fi pkill -n -u "$USER_NAME" -f 'havoc /usr/bin/kryptik-chrome --menu' 2>/dev/null "$KD" wifi forget ztmenuopen --wifi-dir /var/lib/kryptik/wifi > /dev/null 2>&1 +# The menu's s, as the user's launcher asks it: the daemon's scan job signals the net zone, +# which has no radio here, and passes on its answer. +scan_said="$(as_user "kryptik-launch --wifi-scan" 2>&1)" +if [[ "$scan_said" == "the net zone has no Wi-Fi radio" ]]; then + pass "chrome-wifi-scan" "kryptik-launch --wifi-scan: $scan_said" +else + fail "chrome-wifi-scan" "kryptik-launch --wifi-scan: $(tr '\n' ' ' <<<"$scan_said" | cut -c1-200)" +fi # --- teardown ------------------------------------------------------------------------------ # The runtime tmpfs does not survive power-off: copy the session log and the diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index dbd57686..dd2c1baa 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -732,6 +732,14 @@ if [[ -n "$STA_IF" && -n "$AP_IF" ]]; then else fail "wifi-module" "$(tr '\n' ' ' < "$LOG/hwsim.err") radios: $(ls /sys/class/ieee80211 2>/dev/null | tr '\n' ' ')" fi +# Asked what is in range while the radios are still zone 0's, the net zone says it has none: +# kryptikd signals it, and reads the answer it writes in its own /run. +noradio="$("$KD" wifi scan --zones "$Z" --wifi-dir "$WIFI_DIR" 2>&1)" +if [[ "$noradio" == "the net zone has no Wi-Fi radio" ]]; then + pass "wifi-scan-no-radio" "kryptikd wifi scan before the net zone holds a radio: $noradio" +else + fail "wifi-scan-no-radio" "$(tr '\n' ' ' <<<"$noradio" | cut -c1-200); $(netzone_said 'scan' | tail -2 | tr '\n' ' ')" +fi # Built with DPP, the supplicant would take DPP and GAS action frames from any radio in range. dpp="$(grep -ac 'DPP_BOOTSTRAP_GEN\|DPP_LISTEN' /usr/sbin/wpa_supplicant 2>/dev/null)" if [[ -x /usr/sbin/wpa_supplicant && "$dpp" = 0 ]]; then @@ -826,6 +834,14 @@ if [[ "$ZOUT" == *AP-REACHED* && "${sta_seen:-0}" -ge 1 ]]; then else fail "wifi-egress" "$ZOUT; stations at the access point: ${sta_seen:-0}; $(tail -2 "$LOG/untrusted.err" | tr '\n' ' ')" fi +# Joined, the radio's scan only listens, and the access point is named with its security, its +# signal and that it is saved. +scan_out="$("$KD" wifi scan --zones "$Z" --wifi-dir "$WIFI_DIR" 2>&1)" +if grep -qxE "\"$AP_SSID\" WPA2 -[0-9]+ dBm saved" <<<"$scan_out"; then + pass "wifi-scan" "kryptikd wifi scan, joined: $(tr '\n' ';' <<<"$scan_out" | cut -c1-200)" +else + fail "wifi-scan" "$(tr '\n' ';' <<<"$scan_out" | cut -c1-300); $(netzone_said 'scan' | tail -2 | tr '\n' ' ')" +fi # A net zone can leave its radio with a second netdev or with none, and the # radio comes back to zone 0 so. Each round makes that change in the net # zone, stops it, waits for phy0 in zone 0 and starts it: the next zone must diff --git a/compartments/kryptikd/src/main.rs b/compartments/kryptikd/src/main.rs index 411397ae..88a28558 100644 --- a/compartments/kryptikd/src/main.rs +++ b/compartments/kryptikd/src/main.rs @@ -70,6 +70,8 @@ USAGE: [--group G] instance that serves only your own uid) [--proxy-exe P] [--wifi-dir DIR] kryptikd wifi list the net zone's Wi-Fi networks, SSIDs only + kryptikd wifi scan the networks in range, as the net zone hears + them, with their security and signal kryptikd wifi add SSID [--open] add one, or replace its passphrase; the passphrase is read from stdin, never argv; --open saves it with none, an open network @@ -1080,7 +1082,7 @@ fn cmd_wifi(zones_dir: &Path, args: &[String]) -> ExitCode { let sub = args.get(1).map(String::as_str).unwrap_or(""); let ssid = args.get(2).filter(|a| !a.starts_with("--")); let usage = || { - eprintln!("usage: kryptikd wifi list | add SSID [--open] | forget SSID [--wifi-dir DIR]"); + eprintln!("usage: kryptikd wifi list | scan | add SSID [--open] | forget SSID [--wifi-dir DIR]"); ExitCode::from(2) }; let refused = |e: String| { @@ -1097,6 +1099,14 @@ fn cmd_wifi(zones_dir: &Path, args: &[String]) -> ExitCode { } Err(e) => refused(e), }, + // Run as root, as `kryptikd serve` runs it for the desktop: it signals the nic zone. + ("scan", None) => match wifi::scan(zones_dir) { + Ok(s) => { + print!("{}", wifi::scan_said(&s, &wifi::list(&dir).unwrap_or_default())); + ExitCode::SUCCESS + } + Err(e) => refused(e), + }, ("add", Some(ssid)) => { // Refuse a bad SSID before asking for its passphrase. if let Err(e) = wifi::check_ssid(ssid) { diff --git a/compartments/kryptikd/src/registry.rs b/compartments/kryptikd/src/registry.rs index f1200837..cb8bc63e 100644 --- a/compartments/kryptikd/src/registry.rs +++ b/compartments/kryptikd/src/registry.rs @@ -178,6 +178,44 @@ impl PidStamp { } } + /// A regular file at `rel` in this process's root, resolved inside it with no link + /// followed, as the broker reaches a zone; none once the pid names another process. + pub fn file_in_root(&self, rel: &str) -> Option { + use crate::broker::{openat2, RESOLVE_IN_ROOT, RESOLVE_NO_MAGICLINKS, RESOLVE_NO_SYMLINKS}; + use std::os::unix::fs::OpenOptionsExt; + use std::os::unix::io::AsRawFd; + let root = fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_PATH | libc::O_DIRECTORY | libc::O_CLOEXEC) + .open(format!("/proc/{}/root", self.pid)) + .ok()?; + let flags = (libc::O_RDONLY | libc::O_NONBLOCK | libc::O_CLOEXEC) as u64; + let resolve = RESOLVE_IN_ROOT | RESOLVE_NO_SYMLINKS | RESOLVE_NO_MAGICLINKS; + let f = fs::File::from(openat2(root.as_raw_fd(), rel, flags, 0, resolve).ok()?); + (f.metadata().ok()?.is_file() && self.still_alive()).then_some(f) + } + + /// Send `sig` through a pidfd taken while the start time still matches, so a pid reused + /// since is never signalled. + pub fn signal(&self, sig: libc::c_int) -> io::Result<()> { + use std::os::unix::io::{AsRawFd, FromRawFd, OwnedFd}; + let fd = unsafe { libc::syscall(libc::SYS_pidfd_open, self.pid, 0) } as libc::c_int; + if fd < 0 { + return Err(io::Error::last_os_error()); + } + let fd = unsafe { OwnedFd::from_raw_fd(fd) }; + if !self.still_alive() { + return Err(io::Error::new(io::ErrorKind::NotFound, "the process has ended")); + } + let sent = unsafe { + libc::syscall(libc::SYS_pidfd_send_signal, fd.as_raw_fd(), sig, std::ptr::null::(), 0) + }; + if sent < 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) + } + fn encode(&self) -> String { format!("{} {}\n", self.pid, self.start) } diff --git a/compartments/kryptikd/src/serve.rs b/compartments/kryptikd/src/serve.rs index f79f8867..b1e5348d 100644 --- a/compartments/kryptikd/src/serve.rs +++ b/compartments/kryptikd/src/serve.rs @@ -8,7 +8,7 @@ //! wifi-add, `ssid `, `psk ` or `open`, `end` //! wifi-forget, `ssid `, `end` //! stop | clipboard-move | info | status | -//! runtime | wifi-list | update-status | update-fetch | update-apply | +//! runtime | wifi-list | wifi-scan | update-status | update-fetch | update-apply | //! update-auto on|off //! reply ok ...\n | error: \n | lines ... end\n //! @@ -695,6 +695,7 @@ struct Job { enum JobKind { Stop { uid: u32, zone: String }, UpdateApply, + WifiScan, } fn memfile(name: &str) -> Result { @@ -767,6 +768,12 @@ fn finish_job(j: &mut Job, st: i32) { eprintln!("kryptikd serve: update-apply failed: {why}{again}"); reply(&j.conn, &format!("error: {why}{again}\n")); } + // `kryptikd wifi scan` printed its lines from what it made of the zone's answer. + JobKind::WifiScan if ok => reply(&j.conn, &format!("ok\n{}", read_back(&mut j.out))), + JobKind::WifiScan => { + let why = why(j, "the scan failed"); + reply(&j.conn, &format!("error: {why}\n")); + } } } @@ -973,28 +980,11 @@ fn zone_running(name: &str) -> bool { /// (tools/net/netzone-init.sh, `report`) in its own /run. The zone is untrusted, so only /// `net_summary`'s words leave here, and zone 0's window shows nothing else of the line. fn net_status(name: &str) -> Option { - use crate::broker::{openat2, RESOLVE_IN_ROOT, RESOLVE_NO_MAGICLINKS, RESOLVE_NO_SYMLINKS}; - use std::io::Read; - use std::os::unix::fs::OpenOptionsExt; - use std::os::unix::io::AsRawFd; let Ok(crate::registry::State::Running { init: Some(init), .. }) = crate::registry::state(name) else { return None; }; - // Resolved inside the zone's root with no link followed, as the broker reaches a zone. - let root = std::fs::OpenOptions::new() - .read(true) - .custom_flags(libc::O_PATH | libc::O_DIRECTORY | libc::O_CLOEXEC) - .open(format!("/proc/{}/root", init.pid)) - .ok()?; - let flags = (libc::O_RDONLY | libc::O_NONBLOCK | libc::O_CLOEXEC) as u64; - let resolve = RESOLVE_IN_ROOT | RESOLVE_NO_SYMLINKS | RESOLVE_NO_MAGICLINKS; - let f = std::fs::File::from(openat2(root.as_raw_fd(), "run/netzone-status", flags, 0, resolve).ok()?); - // A regular file of the zone the pid still names, not of one that has reused it. - if !f.metadata().ok()?.is_file() || !init.still_alive() { - return None; - } let mut text = String::new(); - f.take(1024).read_to_string(&mut text).ok()?; + init.file_in_root("run/netzone-status")?.take(1024).read_to_string(&mut text).ok()?; net_summary(text.lines().next()?) } @@ -1131,6 +1121,19 @@ fn handle(cfg: &ServeConfig, conn: UnixStream, jobs: &mut Vec) -> Option reply(&conn, &format!("error: {e}\n")), } } + // The networks in range: the nic zone scans for seconds, so a job answers, one at a time. + "wifi-scan" => { + if jobs.iter().any(|j| j.what == JobKind::WifiScan) { + reply(&conn, "error: a scan is already running\n"); + return None; + } + let mut cmd = std::process::Command::new("/proc/self/exe"); + cmd.args(["wifi", "scan", "--zones"]).arg(&cfg.zones_dir).arg("--wifi-dir").arg(&cfg.wifi_dir); + match start_job(conn, JobKind::WifiScan, &mut cmd) { + Ok(j) => jobs.push(j), + Err((conn, e)) => reply(&conn, &format!("error: {e}\n")), + } + } /* The user's side of the update channel (update.rs). Until `fetch`, or * with `auto on` a newer statement, the net zone is told `idle`; * `apply` hands the stage to kryptik-update, which verifies it all diff --git a/compartments/kryptikd/src/wifi.rs b/compartments/kryptikd/src/wifi.rs index ba11f95e..de248fb5 100644 --- a/compartments/kryptikd/src/wifi.rs +++ b/compartments/kryptikd/src/wifi.rs @@ -1,6 +1,7 @@ //! Wi-Fi credentials for the net zone (docs/design/net-zone.md). kryptikd alone writes the file, //! 0440, owned by the nic zone's wpa_supplicant user and grouped with its root, which checks it is -//! there; the zone gets it read-only on next start. +//! there; the zone gets it read-only on next start. A scan of what is in range is asked of the +//! zone, which holds the radios, and its answer read as an untrusted zone's. use std::fs; use std::io::{self, Read, Write}; @@ -356,6 +357,171 @@ pub fn restart_net_zone(dir: &Path) -> String { } } +/// Where the nic zone answers a scan, in its own root (tools/net/netzone-init.sh, `scan_radios`). +const SCAN_FILE: &str = "run/netzone-scan"; +/// From the ask to the answer: the zone scans every channel of each radio, after anything its +/// start or a supplicant is busy with. +const SCAN_WAIT: std::time::Duration = std::time::Duration::from_secs(25); +const SCAN_CAP: u64 = 64 * 1024; +const MAX_HEARD: usize = 40; +const SECURITIES: [&str; 9] = ["open", "wep", "wpa", "wpa2", "wpa3", "wpa2/wpa3", "owe", "enterprise", "other"]; + +/// A network the nic zone heard, as its scan put it. +#[derive(Debug, PartialEq)] +pub struct Heard { + pub ssid: Vec, + pub security: &'static str, + pub signal: i32, +} + +/// The nic zone's answer to a scan: its radios, those the kernel holds switched off, and each +/// network heard once, at its strongest, strongest first. +#[derive(Debug, Default, PartialEq)] +pub struct Scan { + pub radios: Vec, + pub off: Vec, + pub heard: Vec, +} + +/// Ask the nic zone to scan, and read its answer. The ask is a signal, which carries nothing; +/// the answer is a file of the zone's, read as `parse_scan` reads what an untrusted zone wrote. +pub fn scan(zones_dir: &Path) -> Result { + use std::os::unix::fs::MetadataExt; + let zones = crate::zone::load_all(zones_dir).map_err(|e| format!("the zone definitions: {e}"))?; + let name = zones + .iter() + .find(|z| z.network == NetworkMode::Nic) + .map(|z| z.name.clone()) + .ok_or("no zone holds the network (mode = \"nic\")")?; + let Ok(crate::registry::State::Running { init: Some(init), .. }) = crate::registry::state(&name) else { + return Err(format!("the {name} zone is not running")); + }; + // A new answer is a new file: the zone renames each one into place. + let stamp = |f: &fs::File| f.metadata().ok().map(|m| (m.ino(), m.mtime(), m.mtime_nsec())); + let before = init.file_in_root(SCAN_FILE).as_ref().and_then(stamp); + init.signal(libc::SIGUSR1).map_err(|e| format!("the {name} zone could not be asked: {e}"))?; + let until = std::time::Instant::now() + SCAN_WAIT; + while std::time::Instant::now() < until { + std::thread::sleep(std::time::Duration::from_millis(250)); + if let Some(f) = init.file_in_root(SCAN_FILE).filter(|f| stamp(f) != before) { + let mut raw = Vec::new(); + f.take(SCAN_CAP).read_to_end(&mut raw).map_err(|e| format!("the {name} zone's answer: {e}"))?; + return Ok(parse_scan(&String::from_utf8_lossy(&raw))); + } + if !init.still_alive() { + return Err(format!("the {name} zone stopped before it answered")); + } + } + Err(format!("the {name} zone did not answer within {} s", SCAN_WAIT.as_secs())) +} + +/// Read an answer the nic zone wrote: `radios NAME...` (or `none`), `off NAME` for a radio +/// switched off, and a line per network heard: its signal in dBm, its security and its SSID as +/// iw escapes one, tab-separated. The zone is untrusted, so a line of any other form is left +/// out, and so is a network with no name, which cannot be added by it. +pub fn parse_scan(text: &str) -> Scan { + let plain = |n: &str| (1..16).contains(&n.len()) && n.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()); + let mut s = Scan::default(); + for line in text.lines().take(1000) { + if let Some(names) = line.strip_prefix("radios ") { + s.radios = names.split(' ').filter(|&n| n != "none" && plain(n)).map(String::from).collect(); + } else if let Some(n) = line.strip_prefix("off ").filter(|&n| plain(n)) { + s.off.push(n.to_string()); + } else if let Some(h) = heard_line(line) { + match s.heard.iter_mut().find(|o| o.ssid == h.ssid && o.security == h.security) { + Some(o) => o.signal = o.signal.max(h.signal), + None => s.heard.push(h), + } + } + } + s.heard.sort_by(|a, b| b.signal.cmp(&a.signal).then_with(|| a.ssid.cmp(&b.ssid))); + s.heard.truncate(MAX_HEARD); + s +} + +fn heard_line(line: &str) -> Option { + let mut f = line.split('\t'); + let (signal, security, ssid) = (f.next()?, f.next()?, f.next()?); + if f.next().is_some() { + return None; + } + let signal = signal.parse::().ok().filter(|s| (-150..=0).contains(s))?; + let security = SECURITIES.into_iter().find(|s| *s == security)?; + let ssid = unescape_iw(ssid).filter(|b| (1..=32).contains(&b.len()) && b.iter().any(|c| *c != 0))?; + Some(Heard { ssid, security, signal }) +} + +/// An SSID as iw prints one: printable ASCII but a backslash, and \xHH for every other byte. +fn unescape_iw(s: &str) -> Option> { + let b = s.as_bytes(); + let mut out = Vec::new(); + let mut i = 0; + while i < b.len() { + if b[i] == b'\\' { + let hex = b.get(i + 1..i + 4).filter(|h| h[0] == b'x' && h[1..].iter().all(u8::is_ascii_hexdigit))?; + out.push(u8::from_str_radix(std::str::from_utf8(&hex[1..]).ok()?, 16).ok()?); + i += 4; + } else if b[i] == b' ' || b[i].is_ascii_graphic() { + out.push(b[i]); + i += 1; + } else { + return None; + } + } + Some(out) +} + +/// An SSID for a terminal: quoted as Rust quotes a string where it is UTF-8, so nothing +/// unprintable reaches the screen, else with \xHH for each byte that is not printable ASCII. +pub fn shown(ssid: &[u8]) -> String { + match std::str::from_utf8(ssid) { + Ok(s) => format!("{s:?}"), + Err(_) => { + let mut out = String::from("\""); + for &c in ssid { + if c == b' ' || (c.is_ascii_graphic() && c != b'"' && c != b'\\') { + out.push(c as char); + } else { + out.push_str(&format!("\\x{c:02x}")); + } + } + out.push('"'); + out + } + } +} + +/// A scan in words, a line each: a radio switched off, then every network heard with its +/// security and signal, and whether it is saved. +pub fn scan_said(s: &Scan, saved: &[String]) -> String { + if s.radios.is_empty() { + return "the net zone has no Wi-Fi radio\n".into(); + } + let mut out = String::new(); + for r in &s.off { + out.push_str(&format!("radio {r} is switched off, by its switch or an airplane-mode key\n")); + } + if s.heard.is_empty() { + out.push_str("no network heard\n"); + } + for h in &s.heard { + let security = match h.security { + "open" => "open", + "wep" => "WEP, which Kryptik does not join", + "wpa" => "WPA", + "wpa2" => "WPA2", + "wpa3" => "WPA3", + "wpa2/wpa3" => "WPA2/WPA3", + "owe" => "OWE, which Kryptik does not join yet", + "enterprise" => "802.1X, which Kryptik does not join", + _ => "another kind", + }; + let is_saved = std::str::from_utf8(&h.ssid).is_ok_and(|n| saved.iter().any(|s| s == n)); + out.push_str(&format!("{} {security} {} dBm{}\n", shown(&h.ssid), h.signal, if is_saved { " saved" } else { "" })); + } + out +} + /// Read the passphrase from stdin, echo off on a terminal; never from argv or the environment. pub fn read_passphrase(prompt: &str) -> Result { let mut line = String::new(); diff --git a/compartments/kryptikd/src/wifi/tests.rs b/compartments/kryptikd/src/wifi/tests.rs index 581d0f68..efa5d1f5 100644 --- a/compartments/kryptikd/src/wifi/tests.rs +++ b/compartments/kryptikd/src/wifi/tests.rs @@ -241,3 +241,89 @@ fn no_restart_for_other_directory() { let m = restart_net_zone(Path::new("/nonexistent/wifi")); assert!(m.contains("not restarted") && m.contains(DEFAULT_DIR), "{m}"); } + +#[test] +fn scan_answer_read_strongest_first_once_each() { + let text = "radios nic0\n\ + -71\topen\tCafe\n\ + -40\twpa2/wpa3\tHome Net\n\ + -55\topen\tCafe\n\ + -80\twpa2\tCafe\n\ + -63\twpa3\tcaf\\xc3\\xa9\n"; + let s = parse_scan(text); + assert_eq!(s.radios, vec!["nic0"]); + assert!(s.off.is_empty()); + let got: Vec<(Vec, &str, i32)> = s.heard.iter().map(|h| (h.ssid.clone(), h.security, h.signal)).collect(); + assert_eq!( + got, + vec![ + (b"Home Net".to_vec(), "wpa2/wpa3", -40), + (b"Cafe".to_vec(), "open", -55), + ("café".as_bytes().to_vec(), "wpa3", -63), + (b"Cafe".to_vec(), "wpa2", -80), + ], + "one entry per name and security, at its strongest, strongest first" + ); +} + +#[test] +fn scan_answer_drops_what_is_not_its_form() { + let text = "radios nic0 ../etc NIC1 nic2\n\ + off nic0\n\ + off nic0;rm\n\ + -30\topen\tfine\n\ + +5\topen\tpositive\n\ + -30\topen\tthree\textra\n\ + -30\twpa9\tunknown kind\n\ + -200\topen\ttoo weak\n\ + -30\topen\t\n\ + -30\topen\t\\x00\\x00\n\ + -30\topen\tlone \\ backslash\n\ + -30\topen\tbad \\xZZ hex\n\ + -30\topen\tshort \\x4\n\ + -30\topen\tabcdefghijklmnopqrstuvwxyz0123456\n\ + -30\topen\ttab\u{1b}[2Jescape\n\ + -30\topen\tdel\u{7f}\n"; + let s = parse_scan(text); + assert_eq!(s.radios, vec!["nic0", "nic2"], "a radio's name is a plain one"); + assert_eq!(s.off, vec!["nic0"]); + assert_eq!(s.heard.len(), 1, "{:?}", s.heard); + assert_eq!(s.heard[0].ssid, b"fine".to_vec()); + assert_eq!(parse_scan("radios none\n").radios, Vec::::new()); +} + +#[test] +fn scan_answer_is_bounded() { + let mut text = String::from("radios nic0\n"); + for i in 0..200 { + text.push_str(&format!("-{}\topen\tnet{i}\n", 20 + i % 80)); + } + let s = parse_scan(&text); + assert_eq!(s.heard.len(), MAX_HEARD); + assert!(s.heard.windows(2).all(|w| w[0].signal >= w[1].signal)); +} + +#[test] +fn ssid_shown_with_nothing_unprintable() { + assert_eq!(shown(b"Home"), "\"Home\""); + assert_eq!(shown("café".as_bytes()), "\"café\""); + assert_eq!(shown(b"a\"b\\c"), "\"a\\\"b\\\\c\""); + assert_eq!(shown(b"x\x1b[2Jy"), "\"x\\u{1b}[2Jy\""); + assert_eq!(shown(b"\xff\xfeok \"q\""), "\"\\xff\\xfeok \\x22q\\x22\""); +} + +#[test] +fn scan_said_in_words() { + let none = Scan::default(); + assert_eq!(scan_said(&none, &[]), "the net zone has no Wi-Fi radio\n"); + let quiet = Scan { radios: vec!["nic0".into()], off: vec!["nic0".into()], heard: vec![] }; + assert_eq!( + scan_said(&quiet, &[]), + "radio nic0 is switched off, by its switch or an airplane-mode key\nno network heard\n" + ); + let s = parse_scan("radios nic0\n-40\twpa2\tHome\n-70\tenterprise\tWork\n-75\topen\tCafe\n"); + assert_eq!( + scan_said(&s, &["Cafe".to_string()]), + "\"Home\" WPA2 -40 dBm\n\"Work\" 802.1X, which Kryptik does not join -70 dBm\n\"Cafe\" open -75 dBm saved\n" + ); +} diff --git a/compartments/tests/cli.sh b/compartments/tests/cli.sh index 3ee14797..ac14b04e 100755 --- a/compartments/tests/cli.sh +++ b/compartments/tests/cli.sh @@ -242,7 +242,7 @@ fi # A passphrase never goes on a command line, where any process could read it: `kryptik wifi add` # takes it from the terminal or a pipe and passes it on stdin. out="$(K wifi 2>&1)"; rc=$? -if (( rc != 0 )) && [[ "$out" == *"list, add SSID or forget SSID"* ]]; then +if (( rc != 0 )) && [[ "$out" == *"list, scan, add SSID or forget SSID"* ]]; then pass "wifi without a subcommand fails and names the subcommands" else fail "wifi without a subcommand: exit $rc" @@ -340,6 +340,8 @@ case "\${1:-}" in printf '%s\n' "\$r"; [[ "\$r" == ok* ]] ;; --wifi-forget) r="\$(printf 'wifi-forget\nssid %s\nend\n' "\$2" | client)" printf '%s\n' "\$r"; [[ "\$r" == ok* ]] ;; + --wifi-scan) r="\$(printf 'wifi-scan\n' | client)" + printf '%s\n' "\$r"; [[ "\$r" == ok* ]] ;; *) exit 2 ;; esac SHIM @@ -373,6 +375,14 @@ SHIM else fail "wifi add --open through the launch service: exit $rc, output '$out'" fi + # No zone runs here: the daemon's scan job asks the registry and says the nic zone is down. + out="$(KRYPTIK_LAUNCH="$WORK/launch-shim" K wifi scan 2>&1)"; rc=$? + if (( rc != 0 )) && [[ "$out" == "error: kryptikd: wifi: the carrier zone is not running" ]]; then + pass "through the launch service, wifi scan runs as the daemon's job and says when the nic zone is not running" + else + fail "wifi scan through the launch service: exit $rc, output '$out'" + sed 's/^/ /' "$WORK/serve.log" + fi else fail "the launch daemon did not start for the launch-service checks" sed 's/^/ /' "$WORK/serve.log" diff --git a/docs/design/net-zone.md b/docs/design/net-zone.md index a4149d53..00cc550e 100755 --- a/docs/design/net-zone.md +++ b/docs/design/net-zone.md @@ -273,6 +273,17 @@ configured the zone keeps its IPv4 path and the launcher says so. lease once there is a carrier. `wifi=` is `none`, `unconfigured` (no credentials), `connecting`, or the associated SSID; `READY` does not wait for association. +- **It scans when zone 0 asks.** `kryptik wifi scan`, and `s` in the menu's + Wi-Fi window, have kryptikd send the net zone's pid 1 a SIGUSR1, which + carries nothing. The zone scans each radio with `iw` and writes a line per + network heard to its own `/run/netzone-scan`. kryptikd reads that file + inside the zone's root with no link followed, as it reads the status line, + and takes it as an untrusted zone's word: each line is checked, an SSID is + shown quoted with nothing unprintable, and at most 40 networks are listed. + A scan goes out from a random address while no network is joined; once one + is, it only listens, so nothing goes out from the address that network + knows. A radio rfkill holds off is named. Nothing else scans but the + supplicant, to join what is saved. - **It takes no provisioning frames.** `wpa_supplicant` is built without DPP: with it a station registers for DPP's and GAS's public action frames (`nl80211_mgmt_subscribe_non_ap` in hostap's `driver_nl80211.c`), which any @@ -396,7 +407,13 @@ as `SIGSYS` in the zone's log and a `wifi=connecting` that never changes. radio refusing to move as a netdev and moving by its wiphy (`netlink::tests::wireless_moves_by_wiphy`). - `wifi.rs` unit tests and the serve and cli suites cover the credentials - file and `kryptik wifi`. + file and `kryptik wifi`. `tools/tests/netzone-scan.sh` turns iw's listing + into the zone's answer and follows the scan from a random address to a + listening one to the kernel's last results; `wifi.rs` tests read an answer + that holds everything a zone might write. The zones suite asks for a scan + before the net zone holds a radio and after it has joined the simulated + access point (`wifi-scan-no-radio`, `wifi-scan`), and the desktop suite asks + through the launcher (`chrome-wifi-scan`). - The zones suite has the net zone rename its NIC `-x"y` and restarts it: the NIC is back in zone 0 under that name, and the next start comes up `READY` with it as `nic`, with its own address and an MTU of 1500 again after diff --git a/docs/user-guide.md b/docs/user-guide.md index d14e112c..b98a25b7 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -250,9 +250,10 @@ the net zone reports: whether it is ready, the Wi-Fi network it joined, its address on the uplink and whether there is DNS. The menu also names a release that has arrived and waits to be installed, and says when the newest update statement is more than 30 days old, or, with none ever accepted, when -the install is (section 4). With `w` it lists the Wi-Fi networks the net zone may join, adds -one, asking for its passphrase in the menu's own window, adds an open one -with `o`, or forgets one, as `kryptik wifi` does at the administration login. +the install is (section 4). With `w` it lists the Wi-Fi networks the net zone may join, +shows the ones in range with `s`, adds one, asking for its passphrase in the +menu's own window, adds an open one with `o`, or forgets one, as `kryptik +wifi` does at the administration login. For administration, Ctrl+Alt+F2 gives a text login: log in as the user, and `su` becomes root. There `kryptik list`, `kryptik status [ZONE]`, `kryptik @@ -269,7 +270,10 @@ typed and then Enter, allows the file. Anything else refuses, keys typed before the code appears are dropped, and after a refusal that zone may not ask again for a minute. -**Wi-Fi.** `kryptik wifi add SSID` saves a network and asks for its +**Wi-Fi.** `kryptik wifi scan` lists the networks in range with their +security and signal, as the net zone hears them; it scans only when asked, +from a random address while no network is joined, and only listens once one is. +`kryptik wifi add SSID` saves a network and asks for its passphrase; the net zone restarts and joins it, by WPA3 where the network offers it. A network with no passphrase, a café's or an airport's, is saved with `kryptik wifi add SSID --open`: anyone in range can read what crosses it diff --git a/tools/desktop/kryptik-chrome b/tools/desktop/kryptik-chrome index 95131186..61ab624f 100755 --- a/tools/desktop/kryptik-chrome +++ b/tools/desktop/kryptik-chrome @@ -250,11 +250,17 @@ run_wifi() { nets="$("$LAUNCH" --wifi-list 2>&1)" printf '%s\n' "${nets:-none}" | sed 's/^/ /' echo - echo "a add, or change a passphrase o add an open one f forget one Enter back" + echo "s the networks in range a add, or change a passphrase o add an open one" + echo "f forget one Enter back" printf 'choice: ' read -r c || return 0 case "$c" in "") return 0 ;; + # The net zone scans every channel, which takes some seconds. + s) echo "asking the net zone what it hears..." + "$LAUNCH" --wifi-scan + printf 'Enter: back to the networks ' + read -r c || return 0 ;; # IFS=: an SSID may begin or end with a space. a|o|f) [ "$c" = o ] && echo "An open network has no passphrase: anyone in range reads what crosses it." printf 'network name (SSID): ' diff --git a/tools/desktop/kryptik-launch.c b/tools/desktop/kryptik-launch.c index 54289e37..41955429 100644 --- a/tools/desktop/kryptik-launch.c +++ b/tools/desktop/kryptik-launch.c @@ -7,6 +7,7 @@ * kryptik-launch --runtime-dir print the session's XDG_RUNTIME_DIR, creating it * kryptik-launch --clipboard-move FROM TO the zone 0 gesture: move FROM's clipboard to TO * kryptik-launch --wifi-list the net zone's Wi-Fi SSIDs, one per line + * kryptik-launch --wifi-scan the networks in range, as the net zone hears them * kryptik-launch --wifi-add SSID add one or replace its passphrase (one line on stdin) * kryptik-launch --wifi-add-open SSID add one as an open network, with no passphrase * kryptik-launch --wifi-forget SSID remove one @@ -297,7 +298,7 @@ static void usage(void) " kryptik-launch --info ZONE\n" " kryptik-launch --runtime-dir\n" " kryptik-launch --clipboard-move FROM TO\n" - " kryptik-launch --wifi-list | --wifi-add SSID | --wifi-add-open SSID | --wifi-forget SSID\n" + " kryptik-launch --wifi-list | --wifi-scan | --wifi-add SSID | --wifi-add-open SSID | --wifi-forget SSID\n" " (--wifi-add reads the passphrase from standard input)\n" " kryptik-launch --update status|fetch|apply | --update auto on|off\n", stderr); exit(2); @@ -362,6 +363,15 @@ static int wifi_main(int argc, char **argv) } return 0; } + /* An `ok` line and the daemon's lines, or one `error:` line; the net zone takes seconds. */ + if (strcmp(mode, "--wifi-scan") == 0) { + if (argc != 2) + usage(); + char *r = talk("wifi-scan\n", -1); + int ok = strncmp(r, "ok\n", 3) == 0; + fputs(ok ? r + 3 : r, ok ? stdout : stderr); + return ok ? 0 : 1; + } int add = strcmp(mode, "--wifi-add") == 0, add_open = strcmp(mode, "--wifi-add-open") == 0; if (argc != 3 || (!add && !add_open && strcmp(mode, "--wifi-forget") != 0)) usage(); diff --git a/tools/image/gui-test.sh b/tools/image/gui-test.sh index c9a25916..83ec481b 100755 --- a/tools/image/gui-test.sh +++ b/tools/image/gui-test.sh @@ -116,7 +116,7 @@ grep 'GT FAIL' <<<"$T" | sed 's/^/ /' for name in session-socket compositor-running chrome-focus-record chrome-window-is-zone0 zone0-sees-capture zone-proxy-path zone-sees-needed zone-hidden-globals zone-bind-refused proxy-logged-refusal \ map-keeps-zone0-focus close-keeps-zone0-focus alone-keeps-no-keyboard alone-reached-by-key focus-shows-zone focus-shows-label title-prefixed last-zone-recorded menu-opens-on-key menu-keeps-last-zone menu-names-stale-statement menu-names-install-age zone-fullscreen-refused compositor-survives-close oversize-window forged-title-named-by-zone second-zone-window zone0-own-programs-only zone-app-in-cgroup no-virtual-input clipboard-isolated clipboard-move-gesture clipboard-moved \ transfer-policy no-question-for-policy-refusal consent-code-shown transfer-approved transfer-landed plain-y-refused denied-file-absent \ - second-head-appears chrome-follows-head second-head-zone-window second-head-names-zone second-head-gone compositor-survives-unplug zone-survives-unplug chrome-back-on-first-head chrome-wifi-add menu-network-line chrome-wifi-add-open \ + second-head-appears chrome-follows-head second-head-zone-window second-head-names-zone second-head-gone compositor-survives-unplug zone-survives-unplug chrome-back-on-first-head chrome-wifi-add menu-network-line chrome-wifi-add-open chrome-wifi-scan \ zone0-cursor-set zone0-cursor-shown zone-cursor-asked zone-hears-of-outputs zone-cursor-not-shown compositor-socket-unreached \ zone0-frame-clock zone-window-animates \ zone0-fullscreen-granted zone0-fullscreen-needs-focus fullscreen-by-key zone-child-mapped child-focused parent-focused parent-fullscreen fullscreen-keeps-focus keyboard-stays-on-fullscreen zoom-keeps-keyboard zoom-twice-keeps-keyboard zone0-over-fullscreen-gets-keyboard zone0-window-ends-fullscreen child-ends-fullscreen; do diff --git a/tools/kryptik b/tools/kryptik index 338feb5f..60a0c461 100755 --- a/tools/kryptik +++ b/tools/kryptik @@ -35,6 +35,8 @@ USAGE: $PROG gc clean up after zones whose manager died $PROG doctor check this system can run zones at all $PROG wifi list the Wi-Fi networks the net zone knows + $PROG wifi scan the networks in range, with their security + and signal, as the net zone hears them $PROG wifi add SSID add one, or change its passphrase (asked for on the terminal; never an argument) $PROG wifi add SSID --open add one that has no passphrase, a cafe's @@ -309,14 +311,15 @@ read_passphrase() { # ssid cmd_wifi() { local sub="${1:-}"; [[ $# -gt 0 ]] && shift case "$sub" in - list) [[ $# -eq 0 ]] || die "$PROG wifi list takes no argument" 2 ;; + list|scan) [[ $# -eq 0 ]] || die "$PROG wifi $sub takes no argument" 2 ;; add) [[ $# -eq 1 || ( $# -eq 2 && "$2" == --open ) ]] || die "$PROG wifi add SSID [--open]: exactly one SSID" 2 ;; forget) [[ $# -eq 1 ]] || die "$PROG wifi forget SSID: exactly one SSID" 2 ;; - *) usage >&2; die "wifi needs a subcommand: list, add SSID or forget SSID" 2 ;; + *) usage >&2; die "wifi needs a subcommand: list, scan, add SSID or forget SSID" 2 ;; esac if via_launch; then case "$sub" in list) "$LAUNCH" --wifi-list ;; + scan) "$LAUNCH" --wifi-scan ;; add) if [[ "${2:-}" == --open ]]; then "$LAUNCH" --wifi-add-open "$1" else @@ -331,6 +334,7 @@ cmd_wifi() { have_kryptikd case "$sub" in list) "$KRYPTIKD" wifi list --wifi-dir "$WIFI_DIR" ;; + scan) "$KRYPTIKD" wifi scan --wifi-dir "$WIFI_DIR" --zones "$ZONES_DIR" ;; add) if [[ "${2:-}" == --open ]]; then "$KRYPTIKD" wifi add "$1" --open --wifi-dir "$WIFI_DIR" --zones "$ZONES_DIR" else diff --git a/tools/net/netzone-init.sh b/tools/net/netzone-init.sh index 45290812..cefbba5f 100755 --- a/tools/net/netzone-init.sh +++ b/tools/net/netzone-init.sh @@ -13,6 +13,11 @@ BR=kryptik0 STATUS=/run/netzone-status WPA_CONF=/etc/wpa_supplicant.conf # bound in read-only from zone 0 (kryptik wifi add) WPA_CTRL=/run/wpa_supplicant # the supplicant's own: kryptikd makes it for its user alone +SCAN=/run/netzone-scan # the networks in range, when zone 0 asks (kryptikd wifi scan) +# zone 0 asks with SIGUSR1, which carries nothing; the main loop answers, so a scan asked for +# while the zone starts waits for it. +scan_wanted=0 +trap 'scan_wanted=1' USR1 forwarding() { # forwarding on|off v=0; [ "$1" = on ] && v=1 @@ -384,6 +389,53 @@ status_line() { } status_line "$@" +# --- what is in range, when zone 0 asks (kryptikd wifi scan) ----------------- + +# iw's listing reduced to a line per network heard: its signal in dBm, its security, and its +# SSID as iw prints one (printable ASCII but a backslash, \xHH for any other byte), with tabs +# between. zone 0 checks each line again: this zone's word is not taken on trust. +scan_lines() { + awk -v OFS='\t' ' + function out() { + if (!bss) return + sec = "open" + if (rsn != "") { + if (rsn ~ / SAE/) sec = (rsn ~ / PSK/) ? "wpa2/wpa3" : "wpa3" + else if (rsn ~ / OWE/) sec = "owe" + else if (rsn ~ / PSK/) sec = "wpa2" + else if (rsn ~ /802\.1X/) sec = "enterprise" + else sec = "other" + } else if (wpa) sec = "wpa" + else if (priv) sec = "wep" + print sig, sec, ssid + } + /^BSS / { out(); bss = 1; sig = "?"; ssid = ""; rsn = ""; wpa = 0; priv = 0; sect = ""; next } + /^\t[^\t]/ { sect = $1 } + /^\tsignal: / { sig = $2; sub(/\..*/, "", sig) } + /^\tcapability: / { priv = ($0 ~ / Privacy /) } + /^\tSSID: / { ssid = substr($0, 8) } + /^\tWPA:/ { wpa = 1 } + sect == "RSN:" && /Authentication suites:/ { s = $0; sub(/.*suites:/, "", s); rsn = rsn s } + END { out() }' +} +# Every radio's networks in range. A scan goes out from a random address while no network is +# joined. Once one is, the kernel refuses a random address, and the scan only listens, so +# nothing goes out from the address that network knows. A radio its supplicant is scanning +# with answers busy, and the kernel's last results are read instead. A radio rfkill holds off +# is named. +scan_radios() { + { + echo "radios ${WIRELESS:-none}" + for n in $WIRELESS; do + for k in /sys/class/net/"$n"/phy80211/rfkill*/soft /sys/class/net/"$n"/phy80211/rfkill*/hard; do + [ "$(cat "$k" 2>/dev/null)" = 1 ] && { echo "off $n"; break; } + done + ip link set dev "$n" up 2>/dev/null + { iw dev "$n" scan randomise 2>/dev/null || iw dev "$n" scan passive 2>/dev/null || iw dev "$n" scan dump 2>/dev/null; } | scan_lines + done + } > "$SCAN.new" 2>/dev/null && mv -f "$SCAN.new" "$SCAN" +} + # Until SIGTERM: retry a failed policy, restart a dead resolver or supplicant, report any change. cleanup() { say "stopping" @@ -396,6 +448,8 @@ cleanup() { trap cleanup TERM INT while :; do changed=0 + # A scan zone 0 asked for: its signal ends the wait below at once. + if [ "$scan_wanted" = 1 ]; then scan_wanted=0; scan_radios; fi if [ "$policy_ok" != 1 ]; then if load_policy && sync_gateways "$@" && forwarding on; then policy_ok=1; changed=1; say "nftables: policy loaded on retry; forwarding enabled"; fi elif ! nft list table inet kryptik >/dev/null 2>&1; then diff --git a/tools/tests/netzone-scan.sh b/tools/tests/netzone-scan.sh new file mode 100755 index 00000000..29d03f9a --- /dev/null +++ b/tools/tests/netzone-scan.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# Tests for the net zone's answer to a scan (tools/net/netzone-init.sh, scan_lines and +# scan_radios): iw's listing becomes a line per network with its signal, security and SSID as +# iw escapes it, a random address is asked for first, a joined radio only listens, and a busy +# radio's last results are read. +# Offline, under each POSIX shell here, with iw and ip stood in for. +set -uo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SCRIPT="${ROOT}/tools/net/netzone-init.sh" + +PASS=0; FAIL=0 +green() { printf ' PASS %s\n' "$1"; PASS=$((PASS + 1)); } +red() { printf ' FAIL %s\n' "$1"; FAIL=$((FAIL + 1)); [[ $# -gt 1 ]] && printf ' %s\n' "$2"; } +same() { if [[ "$2" == "$3" ]]; then green "$1"; else red "$1" "got '$(tr '\n\t' '|>' <<<"$2")', want '$(tr '\n\t' '|>' <<<"$3")'"; fi; } +T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT + +{ sed -n '/^scan_lines() {/,/^}/p' "$SCRIPT"; sed -n '/^scan_radios() {/,/^}/p' "$SCRIPT"; } > "$T/functions.sh" +grep -q '^scan_lines() {' "$T/functions.sh" && grep -q '^scan_radios() {' "$T/functions.sh" \ + || { echo "could not find scan_lines or scan_radios in ${SCRIPT#"$ROOT"/} (did their first lines move?)"; exit 1; } +grep -q "^trap 'scan_wanted=1' USR1" "$SCRIPT" \ + && green "the zone takes zone 0's ask (SIGUSR1) from its first lines, so one sent at its start is not lost" \ + || red "netzone-init.sh sets no SIGUSR1 handler before its start" + +printf '%s\n' \ + 'BSS 02:00:00:00:01:00(on nic0) -- associated' \ + ' freq: 2412.0' \ + ' capability: ESS Privacy ShortSlotTime (0x0411)' \ + ' signal: -30.00 dBm' \ + ' SSID: Home net' \ + ' RSN: * Version: 1' \ + ' * Group cipher: CCMP' \ + ' * Authentication suites: PSK SAE' \ + 'BSS 02:00:00:00:01:01(on nic0)' \ + ' capability: ESS ShortSlotTime (0x0401)' \ + ' signal: -55.00 dBm' \ + ' SSID: Caf\xc3\xa9' \ + 'BSS 02:00:00:00:01:02(on nic0)' \ + ' capability: ESS Privacy (0x0011)' \ + ' signal: -61.00 dBm' \ + ' SSID: wpa3 only' \ + ' RSN: * Version: 1' \ + ' * Authentication suites: SAE' \ + 'BSS 02:00:00:00:01:03(on nic0)' \ + ' capability: ESS Privacy (0x0011)' \ + ' signal: -71.00 dBm' \ + ' SSID: corp' \ + ' RSN: * Version: 1' \ + ' * Authentication suites: IEEE 802.1X' \ + ' WPA: * Version: 1' \ + ' * Authentication suites: PSK' \ + 'BSS 02:00:00:00:01:04(on nic0)' \ + ' capability: ESS Privacy (0x0011)' \ + ' signal: -75.00 dBm' \ + ' SSID: old' \ + ' WPA: * Version: 1' \ + ' * Authentication suites: PSK' \ + 'BSS 02:00:00:00:01:05(on nic0)' \ + ' capability: ESS Privacy (0x0011)' \ + ' signal: -80.00 dBm' \ + ' SSID: wep' \ + 'BSS 02:00:00:00:01:06(on nic0)' \ + ' capability: ESS Privacy (0x0011)' \ + ' signal: -82.00 dBm' \ + ' SSID: enhanced' \ + ' RSN: * Version: 1' \ + ' * Authentication suites: OWE' > "$T/iw.out" +want_lines="$(printf '%s\t%s\t%s\n' \ + -30 wpa2/wpa3 'Home net' \ + -55 open 'Caf\xc3\xa9' \ + -61 wpa3 'wpa3 only' \ + -71 enterprise corp \ + -75 wpa old \ + -80 wep wep \ + -82 owe enhanced)" + +# iw and ip stood in for: iw answers as $IW_SAYS tells it, by the scan's own flags. +mkdir -p "$T/bin" +cat > "$T/bin/iw" <> "$T/iw.calls" +case "\$*" in + *"scan randomise") [ "\${IW_SAYS:-}" = random ] || exit 1 ;; + *"scan dump") [ "\${IW_SAYS:-}" = busy ] || exit 1 ;; + *"scan passive") [ "\${IW_SAYS:-}" = joined ] || exit 1 ;; +esac +cat "$T/iw.out" +EOF +printf '#!/bin/sh\nexit 0\n' > "$T/bin/ip" +chmod +x "$T/bin/iw" "$T/bin/ip" +cat > "$T/harness.sh" </dev/null ;; +esac +EOF + +for sh in sh bash dash; do + command -v "$sh" >/dev/null 2>&1 || continue + echo "-- ${sh}" + same "each network becomes its signal, security and SSID as iw escaped it" "$("$sh" "$T/harness.sh" "" lines)" "$want_lines" + same "a radio with no network joined scans from a random address" \ + "$("$sh" "$T/harness.sh" nic0 radios random)" "$(printf 'radios nic0\n%s\n--\ndev nic0 scan randomise' "$want_lines")" + same "a joined radio, refused a random address, only listens" \ + "$("$sh" "$T/harness.sh" nic0 radios joined)" "$(printf 'radios nic0\n%s\n--\ndev nic0 scan randomise\ndev nic0 scan passive' "$want_lines")" + same "a radio its supplicant is scanning with gives the kernel's last results" \ + "$("$sh" "$T/harness.sh" nic0 radios busy)" "$(printf 'radios nic0\n%s\n--\ndev nic0 scan randomise\ndev nic0 scan passive\ndev nic0 scan dump' "$want_lines")" + same "no radio is said, and nothing is scanned" "$("$sh" "$T/harness.sh" "" radios random)" "$(printf 'radios none\n--')" +done + +echo +echo "${PASS} passed, ${FAIL} failed" +[[ "$FAIL" -eq 0 ]] From b27342d870cce12d97509bb49a8d74d6cf0f8853 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 23:02:46 -0700 Subject: [PATCH 113/213] ADR-012: Realtek's files patch the MAC as well as the PHY --- docs/decisions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/decisions.md b/docs/decisions.md index 123727c0..7e12affd 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -178,7 +178,7 @@ after the root is mounted. **Why:** Intel, AMD and Qualcomm Wi-Fi and AMD GPUs do not run without vendor firmware, Intel graphics runs degraded, Realtek Ethernet runs without its -chips' PHY fixes, and an old Broadcom Tigon3 does not come up. Without it most +chips' patches, and an old Broadcom Tigon3 does not come up. Without it most laptops have no Wi-Fi. These are vendor binaries, not built from source as From 13961e5960aaf778e2ec654c6664130f6620c932 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 23:05:33 -0700 Subject: [PATCH 114/213] The daemon's verb list names wifi-scan on a line of its own, beside the other Wi-Fi verbs So it no longer rewrites the line that lists the update verbs, which an update PR rewraps. --- compartments/kryptikd/src/serve.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/compartments/kryptikd/src/serve.rs b/compartments/kryptikd/src/serve.rs index b1e5348d..db0034c8 100644 --- a/compartments/kryptikd/src/serve.rs +++ b/compartments/kryptikd/src/serve.rs @@ -7,8 +7,9 @@ //! (with pass=fd, one descriptor rides with the first bytes) //! wifi-add, `ssid `, `psk ` or `open`, `end` //! wifi-forget, `ssid `, `end` +//! wifi-scan, answered once the nic zone has scanned //! stop | clipboard-move | info | status | -//! runtime | wifi-list | wifi-scan | update-status | update-fetch | update-apply | +//! runtime | wifi-list | update-status | update-fetch | update-apply | //! update-auto on|off //! reply ok ...\n | error: \n | lines ... end\n //! From 1375375be25cc2db9ceb7d84808d9902e702fca5 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 23:05:47 -0700 Subject: [PATCH 115/213] The guide says an AMD machine needs a 2025 firmware for Zen 5 microcode to load On Zen 1 to 5, while the running microcode is at or below the EntrySign cutoff (a firmware older than AMD's 2025 microcode-signature fix), 6.18's loader takes a patch only if amd_shas.c holds its SHA-256 (amd.c, verify_sha256_digest and need_sha_check). Of the pinned linux-firmware's containers, every fam17h patch is listed (7 of 7), fam19h lacks five EPYC ones (18 of 23), and fam1ah has none (0 of 9). Such a machine keeps its firmware's microcode, and dmesg says "No sha256 digest for patch ID". The guide's first section says to update the firmware first on AMD and what the log line means; the README's microcode line points there. --- README.md | 7 +++++-- docs/user-guide.md | 7 +++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 54bd948b..2395d6b6 100644 --- a/README.md +++ b/README.md @@ -121,8 +121,11 @@ the firmware from the pinned linux-firmware release - **Input:** USB HID, PS/2, I2C touchpads and touchscreens on Intel and AMD laptops, VMware and Hyper-V input. -CPU microcode for Intel and AMD is built into the signed kernel. Hyper-V -trusts only Microsoft's keys, so run Kryptik there with Secure Boot off. +CPU microcode for Intel and AMD is built into the signed kernel. On a Zen 5 +processor, and a few EPYC models, the kernel loads it only once the machine's +firmware has AMD's 2025 microcode-signature fix +([user guide](docs/user-guide.md#1-boot-the-medium)). Hyper-V trusts only +Microsoft's keys, so run Kryptik there with Secure Boot off. A machine outside that list boots a kernel that cannot find its disk or its network. Adding a driver is one line in the fragment, a firmware file one line diff --git a/docs/user-guide.md b/docs/user-guide.md index 31639a08..49a8c9f8 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -91,6 +91,13 @@ refuses either (the acceptance run proves the refusal: the firmware setup menu; or turn Secure Boot off. The medium reports which it got: `KRYPTIK_SMOKE: secureboot=1` or `=0` on the console. +**AMD processors.** Update the machine's firmware (BIOS) first. With a +firmware older than AMD's 2025 fix for microcode signatures, the kernel loads +only the microcode patches it holds a digest for, which leaves out every Zen 5 +processor (Ryzen 9000, Ryzen AI 300, EPYC 9005) and a few older EPYC models: +they keep the firmware's own microcode, and the kernel log says +`No sha256 digest for patch ID`. + **Under QEMU** (a checkout of the source tree is needed): ```sh From f9835746f1b2a629a3659e85ef67270e07369800 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 23:05:56 -0700 Subject: [PATCH 116/213] The QCA6174's first revision gets its firmware too ath10k drives the QCA6174 hw2.1 from its own directory (QCA6174_HW_2_1_FW_DIR); it asks for API 6, which hw2.1 has not, and loads firmware-5. The list named hw3.0 only. --- build/config/firmware.list | 1 + 1 file changed, 1 insertion(+) diff --git a/build/config/firmware.list b/build/config/firmware.list index 91035ad2..d2541f7e 100644 --- a/build/config/firmware.list +++ b/build/config/firmware.list @@ -22,6 +22,7 @@ iwlwifi-*.pnvm # -sdio- files are for SDIO parts, which are not built. A WCN6855 at SoC revision 2.1 is read # from WCN6855/hw2.1, QCA2066/hw2.1 or QCA6698AQ/hw2.1 (ath11k pci.c). ath11k and ath12k read a # variant directory (nfa765, ncm865) only when a devicetree names one, so [!n]* leaves it out. +ath10k/QCA6174/hw2.1/* ath10k/QCA6174/hw3.0/board*.bin ath10k/QCA6174/hw3.0/*firmware-6.* ath10k/QCA9377/hw1.0/board*.bin From 736553cf298309fd1c8c6df0615fca1047d8b349 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 23:07:25 -0700 Subject: [PATCH 117/213] The guide names Ryzen AI Max among the Zen 5 processors --- docs/user-guide.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/user-guide.md b/docs/user-guide.md index 49a8c9f8..b0ad1433 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -94,9 +94,9 @@ got: `KRYPTIK_SMOKE: secureboot=1` or `=0` on the console. **AMD processors.** Update the machine's firmware (BIOS) first. With a firmware older than AMD's 2025 fix for microcode signatures, the kernel loads only the microcode patches it holds a digest for, which leaves out every Zen 5 -processor (Ryzen 9000, Ryzen AI 300, EPYC 9005) and a few older EPYC models: -they keep the firmware's own microcode, and the kernel log says -`No sha256 digest for patch ID`. +processor (Ryzen 9000, Ryzen AI 300 and Ryzen AI Max, EPYC 9005) and a few +older EPYC models: they keep the firmware's own microcode, and the kernel log +says `No sha256 digest for patch ID`. **Under QEMU** (a checkout of the source tree is needed): From b2d63936e980326b83bcbd963cbe8641e5af05c6 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 23:09:04 -0700 Subject: [PATCH 118/213] brcmfmac is built for PCIe only: the image ships no SDIO firmware BRCMFMAC_SDIO defaults to y once MMC is built in, which boot.fragment does for eMMC. firmware.list ships the PCIe parts' firmware and none for SDIO, so the SDIO bus code (sdio.c, the BCDC protocol it selects, and 25 firmware names) could only fail a device's probe. --- build/config/kernel/boot.fragment | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build/config/kernel/boot.fragment b/build/config/kernel/boot.fragment index 32111c45..62337cb1 100644 --- a/build/config/kernel/boot.fragment +++ b/build/config/kernel/boot.fragment @@ -241,6 +241,8 @@ CONFIG_RTW89_8852CE=m CONFIG_RTW89_8922AE=m CONFIG_BRCMFMAC=m CONFIG_BRCMFMAC_PCIE=y +# SDIO is on by default with MMC; firmware.list ships no SDIO firmware, so its bus would drive nothing. +# CONFIG_BRCMFMAC_SDIO is not set CONFIG_RTL8XXXU=m CONFIG_MAC80211_HWSIM=m From 4d0be5286c6eb3f051df673db25d04947bc5d089 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 23:09:18 -0700 Subject: [PATCH 119/213] boot.fragment says what brcmfmac's SDIO bus would do without firmware --- build/config/kernel/boot.fragment | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/build/config/kernel/boot.fragment b/build/config/kernel/boot.fragment index 62337cb1..ad618776 100644 --- a/build/config/kernel/boot.fragment +++ b/build/config/kernel/boot.fragment @@ -241,7 +241,8 @@ CONFIG_RTW89_8852CE=m CONFIG_RTW89_8922AE=m CONFIG_BRCMFMAC=m CONFIG_BRCMFMAC_PCIE=y -# SDIO is on by default with MMC; firmware.list ships no SDIO firmware, so its bus would drive nothing. +# SDIO is on by default once MMC is built in, and firmware.list ships no SDIO +# firmware: a device it bound could not start. # CONFIG_BRCMFMAC_SDIO is not set CONFIG_RTL8XXXU=m CONFIG_MAC80211_HWSIM=m From f25a4dc5cbf84b653ebfdab090a954cef7627881 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 23:11:58 -0700 Subject: [PATCH 120/213] A scan asked for during the net zone's pass is answered at its end, not after the wait The signal lands once a foreground step ends, the time query among them (8 s at most), so the loop then slept its full 10 s before it scanned: up to 18 s before a listening scan of every channel, against kryptikd's 25 s. The pass now goes straight round when a scan was asked for, and kryptikd waits 40 s. The loop's comment says a scan's signal brings the next tick's work forward by up to 10 s. The docs and a comment say a driver without random scan addresses only listens, before joining as after. --- compartments/kryptikd/src/wifi.rs | 6 +++--- docs/design/net-zone.md | 6 +++--- docs/user-guide.md | 3 ++- tools/net/netzone-init.sh | 12 ++++++++---- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/compartments/kryptikd/src/wifi.rs b/compartments/kryptikd/src/wifi.rs index de248fb5..32fc6919 100644 --- a/compartments/kryptikd/src/wifi.rs +++ b/compartments/kryptikd/src/wifi.rs @@ -359,9 +359,9 @@ pub fn restart_net_zone(dir: &Path) -> String { /// Where the nic zone answers a scan, in its own root (tools/net/netzone-init.sh, `scan_radios`). const SCAN_FILE: &str = "run/netzone-scan"; -/// From the ask to the answer: the zone scans every channel of each radio, after anything its -/// start or a supplicant is busy with. -const SCAN_WAIT: std::time::Duration = std::time::Duration::from_secs(25); +/// From the ask to the answer: the zone may first finish a time query (8 s at most), then +/// scans every channel of each radio, a listening scan taking some seconds a radio. +const SCAN_WAIT: std::time::Duration = std::time::Duration::from_secs(40); const SCAN_CAP: u64 = 64 * 1024; const MAX_HEARD: usize = 40; const SECURITIES: [&str; 9] = ["open", "wep", "wpa", "wpa2", "wpa3", "wpa2/wpa3", "owe", "enterprise", "other"]; diff --git a/docs/design/net-zone.md b/docs/design/net-zone.md index 00cc550e..24d4fa8b 100755 --- a/docs/design/net-zone.md +++ b/docs/design/net-zone.md @@ -281,9 +281,9 @@ configured the zone keeps its IPv4 path and the launcher says so. and takes it as an untrusted zone's word: each line is checked, an SSID is shown quoted with nothing unprintable, and at most 40 networks are listed. A scan goes out from a random address while no network is joined; once one - is, it only listens, so nothing goes out from the address that network - knows. A radio rfkill holds off is named. Nothing else scans but the - supplicant, to join what is saved. + is, and on a driver without random scan addresses, it only listens, so + nothing goes out from an address a network knows. A radio rfkill holds off + is named. Nothing else scans but the supplicant, to join what is saved. - **It takes no provisioning frames.** `wpa_supplicant` is built without DPP: with it a station registers for DPP's and GAS's public action frames (`nl80211_mgmt_subscribe_non_ap` in hostap's `driver_nl80211.c`), which any diff --git a/docs/user-guide.md b/docs/user-guide.md index b98a25b7..a09ff70d 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -272,7 +272,8 @@ ask again for a minute. **Wi-Fi.** `kryptik wifi scan` lists the networks in range with their security and signal, as the net zone hears them; it scans only when asked, -from a random address while no network is joined, and only listens once one is. +from a random address while no network is joined where the radio allows one, +and otherwise only listens. `kryptik wifi add SSID` saves a network and asks for its passphrase; the net zone restarts and joins it, by WPA3 where the network offers it. A network with no passphrase, a café's or an airport's, is saved diff --git a/tools/net/netzone-init.sh b/tools/net/netzone-init.sh index cefbba5f..6e4f0422 100755 --- a/tools/net/netzone-init.sh +++ b/tools/net/netzone-init.sh @@ -419,10 +419,10 @@ scan_lines() { END { out() }' } # Every radio's networks in range. A scan goes out from a random address while no network is -# joined. Once one is, the kernel refuses a random address, and the scan only listens, so -# nothing goes out from the address that network knows. A radio its supplicant is scanning -# with answers busy, and the kernel's last results are read instead. A radio rfkill holds off -# is named. +# joined. Once one is, the kernel refuses a random address, as a driver without them always +# does, and the scan only listens, so nothing goes out from an address a network knows. A +# radio its supplicant is scanning with answers busy, and the kernel's last results are read +# instead. A radio rfkill holds off is named. scan_radios() { { echo "radios ${WIRELESS:-none}" @@ -477,6 +477,7 @@ while :; do case "$wifi_now" in none|unconfigured|connecting) ;; *) time_ticks=999999 ;; esac fi # 10 s ticks: the time hourly once measured, else every 5 min (zone 0 takes a claim per 10 min). + # A scan's signal ends a wait early, so each scan brings what follows forward by up to 10 s. time_ticks=$((time_ticks + 1)) case "$TIME_STATE" in -*|+*|[0-9]*) time_every=360 ;; @@ -499,6 +500,9 @@ while :; do fi fi [ "$changed" = 1 ] && status_line "$@" + # A scan asked for while this pass ran (its signal lands once a foreground step such as the + # time query ends) is answered now, not after the wait. + [ "$scan_wanted" = 1 ] && continue sleep 10 & wait $! done From 9fc58cf0132fec48eb91d5799e84ec32cf7888bd Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 23:12:25 -0700 Subject: [PATCH 121/213] The acceptance checks every firmware file a module names is shipped firmware.list picks what ships, so nothing noticed when a driver asks for a file the list leaves out: WCN6855 revision 2.1, the Realtek NIC patches and tg3's files all went unshipped that way. The new item reads each module's .modinfo firmware= names (MODULE_FIRMWARE, globs included), keeps those linux-firmware's WHENCE has, and fails on one the image lacks unless build/config/firmware-accepted.txt says why, and on an accepted entry that matches none. It found one more: e100 refuses an 82551 without d102e_ucode.bin, which carries the B-step receive fixes, so that file now ships. The D101M and D101S microcode only adds CPUSaver and is accepted. --- build/config/firmware-accepted.txt | 21 ++++ build/config/firmware.list | 4 + docs/decisions.md | 4 +- tools/acceptance.sh | 2 + tools/check-firmware-requested.sh | 124 ++++++++++++++++++++++++ tools/tests/check-firmware-requested.sh | 59 +++++++++++ 6 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 build/config/firmware-accepted.txt create mode 100755 tools/check-firmware-requested.sh create mode 100755 tools/tests/check-firmware-requested.sh diff --git a/build/config/firmware-accepted.txt b/build/config/firmware-accepted.txt new file mode 100644 index 00000000..5e6b592d --- /dev/null +++ b/build/config/firmware-accepted.txt @@ -0,0 +1,21 @@ +# Firmware a shipped module names (MODULE_FIRMWARE) that linux-firmware has and the image leaves +# out; tools/check-firmware-requested.sh fails on any other, and on an entry that matches none. +# PATTERN # why (a glob over linux-firmware's names) + +# ath10k asks for its newest API file first, and that one ships. +ath10k/QCA6174/hw3.0/firmware-4.bin # firmware-6 ships +ath10k/QCA9377/hw1.0/firmware-5.bin # firmware-6 ships + +# Access point and add-in card chips: the list carries the laptop parts (ADR-012). +ath10k/QCA988X/* # QCA9880 and QCA9882 +ath10k/QCA9887/* # an access point part +ath11k/QCN9074/* # an access point part + +# A variant directory is read only when a devicetree names it, and an x86 machine has none. +ath11k/*/nfa765/* # ath11k_core_create_firmware_path: firmware-name, or three Qualcomm Arm boards + +# rtl8xxxu lists the 8192CU's ids only under RTL8XXXU_UNTESTED, which this kernel leaves unset. +rtlwifi/rtl8192cufw_* # no 8192CU stick binds + +# e100 runs a D101M or D101S without its microcode, which only adds receive bundling (CPUSaver). +e100/d101[ms]_ucode.bin # optional diff --git a/build/config/firmware.list b/build/config/firmware.list index d2541f7e..44023a7b 100644 --- a/build/config/firmware.list +++ b/build/config/firmware.list @@ -73,6 +73,10 @@ rtlwifi/rtl8723bu_nic.bin # Broadcom over PCIe (Dell and Apple machines); the SDIO parts are not. brcm/brcmfmac*-pcie* +# Intel PRO/100 (e100): an 82551 does not come up without d102e_ucode.bin, which fixes its receive +# path; the D101M and D101S microcode is optional (firmware-accepted.txt). +e100/d102e_ucode.bin + # Broadcom Tigon3 (tg3): a 5701 A0 does not come up without tg3.bin, the 5703 to 5705 lose TSO # without theirs, and the 57766 loads its own. tigon/tg3.bin diff --git a/docs/decisions.md b/docs/decisions.md index 7e12affd..f84029b0 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -192,7 +192,9 @@ firmware from the verified root, so replacing it means re-signing the kernel. **Left out:** NVIDIA (nouveau needs tens of megabytes of GSP firmware per generation, and the firmware framebuffer gives those machines a display); Bluetooth and sound, which no zone uses yet. What is not on the list is not -shipped. +shipped, and the other way round the acceptance checks that every file a +shipped module names (`MODULE_FIRMWARE`) and linux-firmware has ships, or that +`build/config/firmware-accepted.txt` says why it does not. **CPU microcode** is the same decision. The early loader runs before any filesystem and there is no initramfs, so Intel and AMD microcode is built into diff --git a/tools/acceptance.sh b/tools/acceptance.sh index 65414333..852ec1d6 100755 --- a/tools/acceptance.sh +++ b/tools/acceptance.sh @@ -369,6 +369,7 @@ audit_root() { "${SELF}/check-artifact-hardening.sh" "$1" --strict --json it_artifacts() { on_root_image audit_root; } it_licences() { on_root_image "${SELF}/check-image-licences.sh"; } it_kernel_config() { "${SELF}/validate-kernel-config.sh" --boot && "${SELF}/validate-kernel-config.sh" --hardened; } +it_firmware() { on_root_image "${SELF}/check-firmware-requested.sh"; } it_support_status() { "${SELF}/check-support-status.sh" --strict; } it_ovmf_vars() { [[ -n "$MEDIUM_CERT" ]] || { echo "no certificate could be read out of ${MEDIA_USB:-(no medium)}: /kryptik/kryptik-sb.crt on its ESP"; return 1; } @@ -439,6 +440,7 @@ item build userspace-smoke M host 0 it_userspace need_sysroot item build artifact-hardening M host 0 it_artifacts need_root_image item build licences M host 0 it_licences need_root_image item build kernel-config M host 0 it_kernel_config need_sources +item build firmware-requested M host 0 it_firmware need_root_image item build support-status M host 0 it_support_status item boot media-smoke-usb M vm 25 it_smoke_usb need_vm item boot medium-shell M vm 3 it_medium_shell need_vm diff --git a/tools/check-firmware-requested.sh b/tools/check-firmware-requested.sh new file mode 100755 index 00000000..7ae52977 --- /dev/null +++ b/tools/check-firmware-requested.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# Fail on a file a module under ROOT names (MODULE_FIRMWARE) that linux-firmware's WHENCE has and +# ROOT's /usr/lib/firmware lacks, unless build/config/firmware-accepted.txt says why it stays out. +# +# tools/check-firmware-requested.sh ROOT [--accepted FILE] + +source "$(dirname "${BASH_SOURCE[0]}")/../build/lib/common.sh" +# The verdict is python's exit status; the ERR trap would abort before it is read. +trap - ERR +set +e + +ROOT="" +ACCEPTED="${KRYPTIK_ROOT}/build/config/firmware-accepted.txt" +while [[ "$#" -gt 0 ]]; do + case "$1" in + --accepted) ACCEPTED="${2:?--accepted needs a file}"; shift 2 ;; + -*) die "unknown option: $1" ;; + *) ROOT="$1"; shift ;; + esac +done +[[ -n "$ROOT" ]] || die "usage: check-firmware-requested.sh ROOT [--accepted FILE]" +[[ -d "$ROOT" ]] || die "not a directory: ${ROOT}" +[[ -f "$ACCEPTED" ]] || die "no accepted list at ${ACCEPTED}" + +log "Firmware the modules under ${ROOT} name, against linux-firmware's WHENCE" +python3 - "$ROOT" "$ACCEPTED" <<'EOF' +import fnmatch, glob, lzma, os, re, struct, subprocess, sys + +root, accepted = sys.argv[1:3] +whence = os.path.join(root, "usr/share/licenses/linux-firmware/WHENCE") +fwdir = os.path.join(root, "usr/lib/firmware") + +# Every name linux-firmware installs: its files and the links copy-firmware.sh makes. +upstream = set() +with open(whence, encoding="utf-8", errors="replace") as fh: + for line in fh: + m = re.match(r"(?:File|RawFile|Link):\s*(.+?)\s*(?:->.*)?$", line) + if m: + upstream.add(m.group(1).strip('"')) + +def module_bytes(path): + if path.endswith(".zst"): + return subprocess.run(["zstd", "-dcq", path], check=True, capture_output=True).stdout + if path.endswith(".xz"): + with lzma.open(path) as fh: + return fh.read() + with open(path, "rb") as fh: + return fh.read() + +def firmware_names(data): + # The firmware= strings of the .modinfo section; every module here is 64-bit little-endian ELF. + if data[:4] != b"\x7fELF" or data[4] != 2 or data[5] != 1: + raise ValueError("not a 64-bit little-endian ELF object") + shoff, = struct.unpack_from(" the modules naming it +for path in modules: + mod = re.sub(r"\.ko(\.zst|\.xz)?$", "", os.path.basename(path)) + try: + names = list(firmware_names(module_bytes(path))) + except (ValueError, struct.error, subprocess.CalledProcessError, lzma.LZMAError) as e: + sys.exit(f"{path}: {e}") + for pattern in names: + # A name may be a glob: ath11k names whole directories. + if any(c in pattern for c in "*?["): + hits = fnmatch.filter(upstream, pattern) + else: + hits = [pattern] if pattern in upstream else [] + for name in hits: + wanted.setdefault(name, set()).add(mod) + +acc = [] # [pattern, line, uses] +for n, line in enumerate(open(accepted, encoding="utf-8"), 1): + entry, _, why = line.partition("#") + entry = entry.strip() + if not entry: + continue + if not why.strip(): + sys.exit(f"{accepted}:{n}: {entry}: no reason after #") + acc.append([entry, n, 0]) + +missing, accepted_n = [], 0 +for name in sorted(wanted): + if shipped(name): + continue + hit = next((a for a in acc if fnmatch.fnmatchcase(name, a[0])), None) + if hit: + hit[2] += 1 + accepted_n += 1 + else: + missing.append(name) + +print(f" {len(modules)} modules name {len(wanted)} files linux-firmware has: " + f"{len(wanted) - accepted_n - len(missing)} shipped, {accepted_n} accepted") +for name in missing: + print(f" NOT SHIPPED {name} ({', '.join(sorted(wanted[name]))})", file=sys.stderr) +stale = [a for a in acc if not a[2]] +for a in stale: + print(f" ACCEPTS NOTHING line {a[1]}: {a[0]}", file=sys.stderr) +if missing or stale: + sys.exit(f"{len(missing)} named file(s) not shipped, {len(stale)} accepted entr{'y' if len(stale) == 1 else 'ies'} " + "matching none: add the file to build/config/firmware.list, or say in the accepted list why it stays out") +EOF +rc=$? +[[ "$rc" -eq 0 ]] || exit "$rc" +ok "every file a module names and linux-firmware has ships, or the accepted list says why not" diff --git a/tools/tests/check-firmware-requested.sh b/tools/tests/check-firmware-requested.sh new file mode 100755 index 00000000..9f1fa94e --- /dev/null +++ b/tools/tests/check-firmware-requested.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Tests for tools/check-firmware-requested.sh on a fixture root. +set -uo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +CHECK="${ROOT}/tools/check-firmware-requested.sh" +PASS=0; FAIL=0 +ok() { printf ' PASS %s\n' "$1"; PASS=$((PASS + 1)); } +bad() { printf ' FAIL %s\n' "$1"; FAIL=$((FAIL + 1)); } +command -v gcc > /dev/null && command -v zstd > /dev/null || { echo "gcc and zstd make the fixture modules"; exit 77; } + +T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT +R="$T/root"; A="$T/accepted.txt"; K="$R/usr/lib/modules/6.18.0/kernel" +mkdir -p "$K" "$R/usr/lib/firmware/dir" "$R/usr/share/licenses/linux-firmware" +n=0 +# A module whose .modinfo names firmware, as MODULE_FIRMWARE writes it. +module() { # module NAME FIRMWARE... + local f + : > "$T/$1.c" + for f in "${@:2}"; do + n=$((n + 1)) + printf 'static const char fw%d[] __attribute__((section(".modinfo"), used)) = "firmware=%s";\n' "$n" "$f" >> "$T/$1.c" + done + gcc -c -o "$K/$1.ko" "$T/$1.c" +} +printf 'File: a.bin\nFile: b.bin\nRawFile: dir/x.bin\nFile: dir/y.bin\nLink: l.bin -> a.bin\n' \ + > "$R/usr/share/licenses/linux-firmware/WHENCE" +touch "$R/usr/lib/firmware/a.bin.zst" "$R/usr/lib/firmware/dir/x.bin.zst" +ln -s a.bin.zst "$R/usr/lib/firmware/l.bin.zst" +module wifi a.bin 'dir/*' c99.ucode l.bin +zstd -q --rm "$K/wifi.ko" +module eth b.bin +check() { NO_COLOR=1 bash "$CHECK" "$R" --accepted "$A" 2>&1; } + +printf 'b.bin # optional\ndir/y.bin # an older version\n' > "$A" +out="$(check)"; rc=$? +[[ "$rc" -eq 0 && "$out" == *"2 modules name 5 files linux-firmware has: 3 shipped, 2 accepted"* ]] \ + && ok "shipped, accepted, globbed and compressed modules pass; a name linux-firmware lacks is not counted" \ + || bad "passing root: rc=$rc: $out" + +printf 'dir/y.bin # an older version\n' > "$A" +out="$(check)"; rc=$? +[[ "$rc" -ne 0 && "$out" == *"NOT SHIPPED b.bin (eth)"* ]] \ + && ok "a named file linux-firmware has and the image lacks fails, with its module" || bad "missing file: rc=$rc: $out" + +printf 'b.bin # optional\ndir/* # older versions\n' > "$A" +out="$(check)"; rc=$? +[[ "$rc" -eq 0 ]] && ok "an accepted glob covers what it matches" || bad "accepted glob: rc=$rc: $out" + +printf 'b.bin # optional\ndir/y.bin # an older version\nz.bin # gone\n' > "$A" +out="$(check)"; rc=$? +[[ "$rc" -ne 0 && "$out" == *"ACCEPTS NOTHING line 3: z.bin"* ]] \ + && ok "an accepted entry matching nothing fails, with its line" || bad "stale entry: rc=$rc: $out" + +printf 'b.bin\ndir/y.bin # an older version\n' > "$A" +out="$(check)"; rc=$? +[[ "$rc" -ne 0 && "$out" == *"no reason after #"* ]] && ok "an entry without a reason is refused" || bad "no reason: rc=$rc: $out" + +printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" +[[ "$FAIL" -eq 0 ]] From 509f096e07945e0a1a9e534c9e7cb6d364ef009b Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 23:13:58 -0700 Subject: [PATCH 122/213] A radio switched off and on again rejoins by itself, and the net zone says wifi=off meanwhile Switching a radio off (rfkill: its switch, an airplane-mode key) takes its link down: cfg80211_rfkill_set_block runs dev_close on each of its netdevs. wpa_supplicant brings the link up again only on rfkill's events, which it reads from /dev/rfkill, and the zone's /dev does not hold it ("rfkill: Cannot open RFKILL control device" in every start's log). So once the radio was switched on again its link stayed down, and Wi-Fi stayed off until the net zone restarted. The status line said wifi=connecting throughout. netzone-init.sh's loop now brings a supplicant's radio up when its link is down and rfkill no longer holds it, which the supplicant takes as its interface enabled again and scans and joins; and wifi= reads off when every radio is switched off, which the menu's Network line words as "Wi-Fi switched off". radio_off is one function for the status and the scan's answer. wifi-radio-off (zones suite) soft-blocks the station radio's rfkill (phy0's, from zone 0): the status says wifi=off and kryptikd wifi scan names the radio switched off; unblocked, the radio joins the access point again. On the old code the status stays connecting and the radio never rejoins. serve's tests word wifi=off. --- build/guest-tests/zones-check.sh | 20 +++++++++++++++ compartments/kryptikd/src/serve.rs | 1 + compartments/kryptikd/src/serve/tests.rs | 2 ++ docs/design/net-zone.md | 15 +++++++++--- docs/user-guide.md | 6 +++-- tools/net/netzone-init.sh | 31 ++++++++++++++++++------ tools/tests/netzone-scan.sh | 7 +++--- 7 files changed, 66 insertions(+), 16 deletions(-) diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index dd2c1baa..e83f10fc 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -842,6 +842,26 @@ if grep -qxE "\"$AP_SSID\" WPA2 -[0-9]+ dBm saved" <<<"$scan_out"; then else fail "wifi-scan" "$(tr '\n' ';' <<<"$scan_out" | cut -c1-300); $(netzone_said 'scan' | tail -2 | tr '\n' ' ')" fi +# Switched off by rfkill, as its switch or an airplane-mode key would, the radio's link goes down: +# the status and the scan say so. Switched on again, the net zone brings the link up, which its +# supplicant cannot see for itself, and the radio joins again. +sta_kill="" +for r in /sys/class/rfkill/rfkill*; do [[ "$(cat "$r/name" 2>/dev/null)" == phy0 ]] && sta_kill="$r"; done +off_line=""; off_scan=""; back_line="" +if [[ -n "$sta_kill" ]]; then + before="$(ready_count)" + echo 1 > "$sta_kill/soft" + off_line="$(ready_after "$before" " wifi=off " 60)" + off_scan="$("$KD" wifi scan --zones "$Z" --wifi-dir "$WIFI_DIR" 2>&1)" + before="$(ready_count)" + echo 0 > "$sta_kill/soft" + back_line="$(ready_after "$before" " wifi=$AP_SSID " 90)" +fi +if [[ "$off_line" == *" wifi=off "* && "$off_scan" == *" is switched off"* && "$back_line" == *" wifi=$AP_SSID "* ]]; then + pass "wifi-radio-off" "switched off: ${off_line#*netzone: }; scan: $(head -1 <<<"$off_scan"); switched on, joined again: ${back_line#*netzone: }" +else + fail "wifi-radio-off" "rfkill: ${sta_kill:-none named phy0}; off: ${off_line:-none}; scan: $(tr '\n' ';' <<<"$off_scan" | cut -c1-120); back: ${back_line:-none}; $(netzone_said 'wifi' | tail -3 | tr '\n' ' ')" +fi # A net zone can leave its radio with a second netdev or with none, and the # radio comes back to zone 0 so. Each round makes that change in the net # zone, stops it, waits for phy0 in zone 0 and starts it: the next zone must diff --git a/compartments/kryptikd/src/serve.rs b/compartments/kryptikd/src/serve.rs index db0034c8..393db015 100644 --- a/compartments/kryptikd/src/serve.rs +++ b/compartments/kryptikd/src/serve.rs @@ -1019,6 +1019,7 @@ fn net_summary(line: &str) -> Option { let wifi = match &rest[from..to] { "none" => None, "unconfigured" => Some("Wi-Fi with no network saved".to_string()), + "off" => Some("Wi-Fi switched off".to_string()), "connecting" => Some("Wi-Fi connecting".to_string()), "associated" => Some("Wi-Fi joined".to_string()), ssid if !ssid.is_empty() && ssid.len() <= 32 => Some(format!("Wi-Fi {ssid:?}")), diff --git a/compartments/kryptikd/src/serve/tests.rs b/compartments/kryptikd/src/serve/tests.rs index 6a279d74..0f3dfaf6 100644 --- a/compartments/kryptikd/src/serve/tests.rs +++ b/compartments/kryptikd/src/serve/tests.rs @@ -8,6 +8,8 @@ fn net_summary_passes_on_known_fields_only() { assert_eq!(net_summary(wired).as_deref(), Some("ready: no uplink address, no DNS")); let joining = "READY uplink=10.0.2.15/24 nat=yes dns=yes wifi=connecting time=-2"; assert_eq!(net_summary(joining).as_deref(), Some("ready: Wi-Fi connecting, uplink 10.0.2.15/24, DNS")); + let off = "READY uplink=10.0.2.15/24 nat=yes dns=yes wifi=off time=-2"; + assert_eq!(net_summary(off).as_deref(), Some("ready: Wi-Fi switched off, uplink 10.0.2.15/24, DNS")); let down = "NOT READY firewall policy not loaded; forwarding off; uplink=none dns=no wifi=none time=x"; assert_eq!(net_summary(down).as_deref(), Some("not ready: forwarding is off")); // An SSID is any access point's: what a terminal would act on comes out escaped, and words diff --git a/docs/design/net-zone.md b/docs/design/net-zone.md index 24d4fa8b..c32c9c10 100755 --- a/docs/design/net-zone.md +++ b/docs/design/net-zone.md @@ -270,9 +270,14 @@ configured the zone keeps its IPv4 path and the launcher says so. - **It associates before it leases.** The script finds radios by their `phy80211` link, not by name, and runs one `wpa_supplicant` per radio on `/etc/wpa_supplicant.conf`, restarting one that dies; dhcpcd takes the - lease once there is a carrier. `wifi=` is `none`, `unconfigured` (no - credentials), `connecting`, or the associated SSID; `READY` does not wait - for association. + lease once there is a carrier. `wifi=` is `none`, `off` (every radio + switched off by rfkill, its switch or an airplane-mode key), `unconfigured` + (no credentials), `connecting`, or the associated SSID; `READY` does not + wait for association. Switching a radio off takes its link down + (`cfg80211_rfkill_set_block`), and a supplicant brings it up again only on + rfkill's events, which `/dev/rfkill` would give and the zone's `/dev` does + not hold: the script's loop brings a supplicant's radio up once it is + switched on again, within ten seconds. - **It scans when zone 0 asks.** `kryptik wifi scan`, and `s` in the menu's Wi-Fi window, have kryptikd send the net zone's pid 1 a SIGUSR1, which carries nothing. The zone scans each radio with `iw` and writes a line per @@ -413,7 +418,9 @@ as `SIGSYS` in the zone's log and a `wifi=connecting` that never changes. that holds everything a zone might write. The zones suite asks for a scan before the net zone holds a radio and after it has joined the simulated access point (`wifi-scan-no-radio`, `wifi-scan`), and the desktop suite asks - through the launcher (`chrome-wifi-scan`). + through the launcher (`chrome-wifi-scan`). The zones suite switches the + station's radio off through rfkill and on again: the status says `off`, the + scan names the radio, and the radio joins again (`wifi-radio-off`). - The zones suite has the net zone rename its NIC `-x"y` and restarts it: the NIC is back in zone 0 under that name, and the next start comes up `READY` with it as `nic`, with its own address and an MTU of 1500 again after diff --git a/docs/user-guide.md b/docs/user-guide.md index a09ff70d..acaed8de 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -246,8 +246,10 @@ terminal), `e` (the editor) or `b` (the browser), for example `1t`, then Enter. A zone with encrypted storage asks for its passphrase when it starts, in the menu's window or, for a key, in a small prompt window; the passphrase never appears on a command line. Under the zones, a Network line says what -the net zone reports: whether it is ready, the Wi-Fi network it joined, its -address on the uplink and whether there is DNS. The menu also names a +the net zone reports: whether it is ready, the Wi-Fi network it joined (or +that Wi-Fi is switched off, by a switch or an airplane-mode key; switched on +again, it rejoins by itself), its address on the uplink and whether there is +DNS. The menu also names a release that has arrived and waits to be installed, and says when the newest update statement is more than 30 days old, or, with none ever accepted, when the install is (section 4). With `w` it lists the Wi-Fi networks the net zone may join, diff --git a/tools/net/netzone-init.sh b/tools/net/netzone-init.sh index 6e4f0422..d234990c 100755 --- a/tools/net/netzone-init.sh +++ b/tools/net/netzone-init.sh @@ -2,7 +2,7 @@ # The net zone's command (docs/design/net-zone.md): firewall, Wi-Fi, DHCP, DNS, time and updates. # Fails closed: forwarding stays off until the nft policy is loaded and read back. Reports: # -# netzone: READY uplink= nat=yes dns= wifi= +# netzone: READY uplink= nat=yes dns= wifi= # time= ... # netzone: NOT READY (forwarding is off) set -u @@ -214,11 +214,22 @@ start_wifi() { # start_wifi : 0 started or already running, 1 not say "wifi: wpa_supplicant did not start on ${n}: $(tr '\n' ' ' < /tmp/wpa.err)" return 1 } -# The status line's wifi=: none (no radio), unconfigured (no credentials), connecting, or the SSID. -# Read from the kernel, as the supplicant's control socket is its own user's: a link the -# supplicant holds dormant until it has the keys, then up, and the SSID it is associated with. +# Is a radio switched off by rfkill, its switch or an airplane-mode key (soft or hard)? +radio_off() { + for k in /sys/class/net/"$1"/phy80211/rfkill*/soft /sys/class/net/"$1"/phy80211/rfkill*/hard; do + [ "$(cat "$k" 2>/dev/null)" = 1 ] && return 0 + done + return 1 +} +# The status line's wifi=: none (no radio), off (every radio switched off), unconfigured (no +# credentials), connecting, or the SSID. Read from the kernel, as the supplicant's control socket +# is its own user's: a link the supplicant holds dormant until it has the keys, then up, and the +# SSID it is associated with. wifi_state() { [ -n "$WIRELESS" ] || { echo none; return; } + on="" + for n in $WIRELESS; do radio_off "$n" || on=1; done + [ -n "$on" ] || { echo off; return; } [ -r "$WPA_CONF" ] || { echo unconfigured; return; } st=connecting for n in $WIRELESS; do @@ -427,9 +438,7 @@ scan_radios() { { echo "radios ${WIRELESS:-none}" for n in $WIRELESS; do - for k in /sys/class/net/"$n"/phy80211/rfkill*/soft /sys/class/net/"$n"/phy80211/rfkill*/hard; do - [ "$(cat "$k" 2>/dev/null)" = 1 ] && { echo "off $n"; break; } - done + radio_off "$n" && echo "off $n" ip link set dev "$n" up 2>/dev/null { iw dev "$n" scan randomise 2>/dev/null || iw dev "$n" scan passive 2>/dev/null || iw dev "$n" scan dump 2>/dev/null; } | scan_lines done @@ -469,6 +478,14 @@ while :; do if [ -n "$(wpa_pid "$n")" ] && ! wpa_alive "$n"; then say "wifi: wpa_supplicant on ${n} died; restarting"; start_wifi "$n"; changed=1 fi + # Switching a radio off takes its interface down, and its supplicant, which cannot + # read rfkill's events here, would leave it so once it is switched on again: up it goes, + # and the supplicant carries on. + flags="$(cat "/sys/class/net/$n/flags" 2>/dev/null)" + if [ -n "$flags" ] && [ $((flags & 1)) = 0 ] && wpa_alive "$n" && ! radio_off "$n" \ + && ip link set dev "$n" up 2>/dev/null; then + say "wifi: ${n} is switched on again; up" + fi done wifi_now="$(wifi_state)" if [ "$wifi_now" != "$wifi_last" ]; then diff --git a/tools/tests/netzone-scan.sh b/tools/tests/netzone-scan.sh index 29d03f9a..3bed7d84 100755 --- a/tools/tests/netzone-scan.sh +++ b/tools/tests/netzone-scan.sh @@ -14,9 +14,10 @@ red() { printf ' FAIL %s\n' "$1"; FAIL=$((FAIL + 1)); [[ $# -gt 1 ]] && prin same() { if [[ "$2" == "$3" ]]; then green "$1"; else red "$1" "got '$(tr '\n\t' '|>' <<<"$2")', want '$(tr '\n\t' '|>' <<<"$3")'"; fi; } T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT -{ sed -n '/^scan_lines() {/,/^}/p' "$SCRIPT"; sed -n '/^scan_radios() {/,/^}/p' "$SCRIPT"; } > "$T/functions.sh" -grep -q '^scan_lines() {' "$T/functions.sh" && grep -q '^scan_radios() {' "$T/functions.sh" \ - || { echo "could not find scan_lines or scan_radios in ${SCRIPT#"$ROOT"/} (did their first lines move?)"; exit 1; } +{ for f in radio_off scan_lines scan_radios; do sed -n "/^$f() {/,/^}/p" "$SCRIPT"; done; } > "$T/functions.sh" +for f in radio_off scan_lines scan_radios; do + grep -q "^$f() {" "$T/functions.sh" || { echo "could not find $f in ${SCRIPT#"$ROOT"/} (did its first line move?)"; exit 1; } +done grep -q "^trap 'scan_wanted=1' USR1" "$SCRIPT" \ && green "the zone takes zone 0's ask (SIGUSR1) from its first lines, so one sent at its start is not lost" \ || red "netzone-init.sh sets no SIGUSR1 handler before its start" From eb102e2abadbe35a946a70e9f9c7b94097b7784e Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 23:18:38 -0700 Subject: [PATCH 123/213] The menu numbers the networks a scan heard, and a or o takes one by its number With s the menu listed what was in range, but saving one still meant typing its name exactly, which a scan had just shown. The scan's answer is now kept and its networks numbered (heard_list), and a or o take a number from it in place of a name (heard_name): the name between the daemon's quotes. A name the daemon quoted with a backslash holds a byte quoted so no terminal acts on it, so the menu does not pass it on by number and asks for the name. f still takes a name, as the saved networks are listed by it. tools/tests/chrome-wifi-pick.sh (under sh, bash and dash): the networks are numbered and a radio's line is not; a number gives back a plain name, a UTF-8 one and one with spaces at its ends; a name quoted with an escape, one with an escaped quote, a number past the list and 0 are refused. --- docs/user-guide.md | 7 ++-- tools/desktop/kryptik-chrome | 38 ++++++++++++++++++++-- tools/tests/chrome-wifi-pick.sh | 57 +++++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 6 deletions(-) create mode 100755 tools/tests/chrome-wifi-pick.sh diff --git a/docs/user-guide.md b/docs/user-guide.md index acaed8de..7650a7e9 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -253,9 +253,10 @@ DNS. The menu also names a release that has arrived and waits to be installed, and says when the newest update statement is more than 30 days old, or, with none ever accepted, when the install is (section 4). With `w` it lists the Wi-Fi networks the net zone may join, -shows the ones in range with `s`, adds one, asking for its passphrase in the -menu's own window, adds an open one with `o`, or forgets one, as `kryptik -wifi` does at the administration login. +shows the ones in range with `s`, numbered, adds one by its name or that +number, asking for its passphrase in the menu's own window, adds an open one +with `o`, or forgets one, as `kryptik wifi` does at the administration login. +A name with a character a terminal would not show plainly is typed instead. For administration, Ctrl+Alt+F2 gives a text login: log in as the user, and `su` becomes root. There `kryptik list`, `kryptik status [ZONE]`, `kryptik diff --git a/tools/desktop/kryptik-chrome b/tools/desktop/kryptik-chrome index 61ab624f..185c595d 100755 --- a/tools/desktop/kryptik-chrome +++ b/tools/desktop/kryptik-chrome @@ -239,6 +239,21 @@ run_menu() { # --- Wi-Fi ---------------------------------------------------------------------- +# A scan's answer with the networks numbered; the lines about radios as they came. +heard_list() { # heard_list SCAN + printf '%s\n' "$1" | awk '/^"/ { printf "%2d %s\n", ++n, $0; next } { print " " $0 }' +} +# The name of the Nth network a scan heard, from the daemon's quoted line. A name with a +# backslash in that line holds a byte quoted so no terminal acts on it: it is typed instead. +heard_name() { # heard_name SCAN N + line="$(printf '%s\n' "$1" | grep '^"' | sed -n "${2}p" 2>/dev/null)" + case "$line" in + "" | *\\*) return 1 ;; + esac + line="${line#\"}" + printf '%s\n' "${line%%\"*}" +} + # The net zone's saved networks, through the launch daemon, as `kryptik wifi` has # them at the administration login: kryptik-launch reads a passphrase here, # unechoed, and the net zone restarts to join. @@ -256,16 +271,33 @@ run_wifi() { read -r c || return 0 case "$c" in "") return 0 ;; - # The net zone scans every channel, which takes some seconds. + # The net zone scans every channel, which takes some seconds. Its answer is kept, so + # a or o can take a network by its number. s) echo "asking the net zone what it hears..." - "$LAUNCH" --wifi-scan + heard="$("$LAUNCH" --wifi-scan 2>&1)" + heard_list "$heard" printf 'Enter: back to the networks ' read -r c || return 0 ;; # IFS=: an SSID may begin or end with a space. a|o|f) [ "$c" = o ] && echo "An open network has no passphrase: anyone in range reads what crosses it." - printf 'network name (SSID): ' + if [ "$c" != f ] && [ -n "${heard:-}" ]; then + printf 'network name (SSID), or its number from the scan: ' + else + printf 'network name (SSID): ' + fi IFS= read -r ssid || return 0 [ -n "$ssid" ] || continue + case "$c:$ssid" in + [ao]:[1-9] | [ao]:[1-9][0-9]) + if [ -n "${heard:-}" ]; then + n="$ssid" + ssid="$(heard_name "$heard" "$n")" || { + echo "the scan has no network $n the menu can pass on by number: type its name" + sleep 3; continue + } + echo "network: $ssid" + fi ;; + esac case "$c" in a) "$LAUNCH" --wifi-add "$ssid" ;; o) "$LAUNCH" --wifi-add-open "$ssid" ;; diff --git a/tools/tests/chrome-wifi-pick.sh b/tools/tests/chrome-wifi-pick.sh new file mode 100755 index 00000000..0ebc41ca --- /dev/null +++ b/tools/tests/chrome-wifi-pick.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Test the menu's Wi-Fi window taking a network by its number in a scan (heard_list and +# heard_name in tools/desktop/kryptik-chrome): the networks are numbered and the radio lines +# are not, a number gives back the name the daemon quoted, and a name quoted with a backslash +# (a byte quoted so no terminal acts on it) is not passed on. Under each POSIX shell here. +set -uo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +CHROME="$ROOT/tools/desktop/kryptik-chrome" +PASS=0; FAIL=0 +ok() { printf ' PASS %s\n' "$1"; PASS=$((PASS + 1)); } +bad() { printf ' FAIL %s\n' "$1"; FAIL=$((FAIL + 1)); } +same() { if [[ "$2" == "$3" ]]; then ok "$1"; else bad "$1: got '$(tr '\n' '|' <<<"$2")', want '$(tr '\n' '|' <<<"$3")'"; fi; } + +T="$(mktemp -d)" +trap 'rm -rf "$T"' EXIT +sed -n '/^heard_list() {/,/^}/p; /^heard_name() {/,/^}/p' "$CHROME" > "$T/fn.sh" +for fn in heard_list heard_name; do + grep -q "^${fn}() {" "$T/fn.sh" || { echo "could not extract ${fn} from $CHROME"; exit 1; } +done + +# An answer as kryptikd wifi scan words one: a radio switched off first, then the networks. +cat > "$T/scan" <<'EOF' +radio nic1 is switched off, by its switch or an airplane-mode key +"Home Net" WPA2/WPA3 -40 dBm saved +"café" open -55 dBm +"x\u{1b}[2Jy" open -60 dBm +" spaced " WPA2 -61 dBm +"q\"uote" WPA2 -70 dBm +EOF + +for sh in sh bash dash; do + command -v "$sh" >/dev/null 2>&1 || continue + echo "-- ${sh}" + run() { "$sh" -c '. "$1"; shift; "$@"' sh "$T/fn.sh" "$@" 2>/dev/null; } + scan="$(cat "$T/scan")" + same "the networks are numbered, the radio's line is not" "$(run heard_list "$scan")" \ +" radio nic1 is switched off, by its switch or an airplane-mode key + 1 \"Home Net\" WPA2/WPA3 -40 dBm saved + 2 \"café\" open -55 dBm + 3 \"x\\u{1b}[2Jy\" open -60 dBm + 4 \" spaced \" WPA2 -61 dBm + 5 \"q\\\"uote\" WPA2 -70 dBm" + same "a number gives the name the daemon quoted" "$(run heard_name "$scan" 1)" "Home Net" + same "a UTF-8 name comes back whole" "$(run heard_name "$scan" 2)" "café" + same "spaces at either end are kept" "$(run heard_name "$scan" 4)" " spaced " + for n in 3 5 6 0; do + if run heard_name "$scan" "$n" >/dev/null; then + bad "number $n is refused (an escaped byte, or no such network)" + else + ok "number $n is refused (an escaped byte, or no such network)" + fi + done +done + +echo +echo "${PASS} passed, ${FAIL} failed" +[[ "$FAIL" -eq 0 ]] From 344bd899005907bcf3b180574cffa608fc4a59ae Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 23:18:48 -0700 Subject: [PATCH 124/213] The firmware check reads built-in drivers too, and says what it cannot see A driver built into the kernel declares its firmware in modules.builtin.modinfo, not in a .ko, so the check reads that file as well. A dangling link no longer counts as shipped: request_firmware follows the link. The header names what declarations miss: iwlwifi's lower API versions, ath11k's QCA2066 and QCA6698AQ directories, and all of ath12k. --- tools/check-firmware-requested.sh | 34 +++++++++++++++++-------- tools/tests/check-firmware-requested.sh | 17 ++++++++++--- 2 files changed, 36 insertions(+), 15 deletions(-) diff --git a/tools/check-firmware-requested.sh b/tools/check-firmware-requested.sh index 7ae52977..28d23c74 100755 --- a/tools/check-firmware-requested.sh +++ b/tools/check-firmware-requested.sh @@ -1,6 +1,8 @@ #!/usr/bin/env bash # Fail on a file a module under ROOT names (MODULE_FIRMWARE) that linux-firmware's WHENCE has and # ROOT's /usr/lib/firmware lacks, unless build/config/firmware-accepted.txt says why it stays out. +# It sees declared names only: iwlwifi declares each device's highest API, not the one it steps +# down to; ath11k declares no QCA2066 or QCA6698AQ directory; ath12k declares nothing. # # tools/check-firmware-requested.sh ROOT [--accepted FILE] @@ -65,27 +67,37 @@ def firmware_names(data): yield item[len(b"firmware="):].decode() def shipped(name): - return any(os.path.lexists(os.path.join(fwdir, name + ext)) for ext in ("", ".zst", ".xz")) + # The names request_firmware tries (FW_LOADER_COMPRESS_ZSTD and _XZ); a dangling link is not one. + return any(os.path.exists(os.path.join(fwdir, name + ext)) for ext in ("", ".zst", ".xz")) modules = sorted(p for p in glob.glob(os.path.join(root, "usr/lib/modules/*/**/*.ko*"), recursive=True) if re.search(r"\.ko(\.zst|\.xz)?$", p)) if not modules: sys.exit(f"no modules under {root}/usr/lib/modules") -wanted = {} # a name linux-firmware has -> the modules naming it +declared = [] # (module, name or glob) for path in modules: mod = re.sub(r"\.ko(\.zst|\.xz)?$", "", os.path.basename(path)) try: - names = list(firmware_names(module_bytes(path))) + declared += [(mod, n) for n in firmware_names(module_bytes(path))] except (ValueError, struct.error, subprocess.CalledProcessError, lzma.LZMAError) as e: sys.exit(f"{path}: {e}") - for pattern in names: - # A name may be a glob: ath11k names whole directories. - if any(c in pattern for c in "*?["): - hits = fnmatch.filter(upstream, pattern) - else: - hits = [pattern] if pattern in upstream else [] - for name in hits: - wanted.setdefault(name, set()).add(mod) +# A driver built into the kernel declares in modules.builtin.modinfo, as "module.firmware=NAME". +for path in glob.glob(os.path.join(root, "usr/lib/modules/*/modules.builtin.modinfo")): + with open(path, "rb") as fh: + for item in fh.read().split(b"\0"): + mod, dot, rest = item.partition(b".") + if dot and rest.startswith(b"firmware="): + declared.append((mod.decode() + " (built in)", rest[len(b"firmware="):].decode())) + +wanted = {} # a name linux-firmware has -> the modules naming it +for mod, pattern in declared: + # A name may be a glob: ath11k names whole directories. + if any(c in pattern for c in "*?["): + hits = fnmatch.filter(upstream, pattern) + else: + hits = [pattern] if pattern in upstream else [] + for name in hits: + wanted.setdefault(name, set()).add(mod) acc = [] # [pattern, line, uses] for n, line in enumerate(open(accepted, encoding="utf-8"), 1): diff --git a/tools/tests/check-firmware-requested.sh b/tools/tests/check-firmware-requested.sh index 9f1fa94e..732c2172 100755 --- a/tools/tests/check-firmware-requested.sh +++ b/tools/tests/check-firmware-requested.sh @@ -22,9 +22,11 @@ module() { # module NAME FIRMWARE... done gcc -c -o "$K/$1.ko" "$T/$1.c" } -printf 'File: a.bin\nFile: b.bin\nRawFile: dir/x.bin\nFile: dir/y.bin\nLink: l.bin -> a.bin\n' \ +printf 'File: a.bin\nFile: b.bin\nFile: c.bin\nRawFile: dir/x.bin\nFile: dir/y.bin\nLink: l.bin -> a.bin\n' \ > "$R/usr/share/licenses/linux-firmware/WHENCE" -touch "$R/usr/lib/firmware/a.bin.zst" "$R/usr/lib/firmware/dir/x.bin.zst" +touch "$R/usr/lib/firmware/a.bin.zst" "$R/usr/lib/firmware/c.bin.zst" "$R/usr/lib/firmware/dir/x.bin.zst" +# A driver built into the kernel declares here, not in a .ko. +printf 'core.firmware=c.bin\0core.license=GPL\0' > "$R/usr/lib/modules/6.18.0/modules.builtin.modinfo" ln -s a.bin.zst "$R/usr/lib/firmware/l.bin.zst" module wifi a.bin 'dir/*' c99.ucode l.bin zstd -q --rm "$K/wifi.ko" @@ -33,8 +35,8 @@ check() { NO_COLOR=1 bash "$CHECK" "$R" --accepted "$A" 2>&1; } printf 'b.bin # optional\ndir/y.bin # an older version\n' > "$A" out="$(check)"; rc=$? -[[ "$rc" -eq 0 && "$out" == *"2 modules name 5 files linux-firmware has: 3 shipped, 2 accepted"* ]] \ - && ok "shipped, accepted, globbed and compressed modules pass; a name linux-firmware lacks is not counted" \ +[[ "$rc" -eq 0 && "$out" == *"2 modules name 6 files linux-firmware has: 4 shipped, 2 accepted"* ]] \ + && ok "shipped, accepted, globbed, built-in and compressed declarations pass; a name linux-firmware lacks is not counted" \ || bad "passing root: rc=$rc: $out" printf 'dir/y.bin # an older version\n' > "$A" @@ -42,6 +44,13 @@ out="$(check)"; rc=$? [[ "$rc" -ne 0 && "$out" == *"NOT SHIPPED b.bin (eth)"* ]] \ && ok "a named file linux-firmware has and the image lacks fails, with its module" || bad "missing file: rc=$rc: $out" +ln -sf gone.bin.zst "$R/usr/lib/firmware/c.bin.zst" +printf 'b.bin # optional\ndir/y.bin # an older version\n' > "$A" +out="$(check)"; rc=$? +[[ "$rc" -ne 0 && "$out" == *"NOT SHIPPED c.bin (core (built in))"* ]] \ + && ok "a dangling link is not shipped, and a built-in driver is named as such" || bad "dangling link: rc=$rc: $out" +rm -f "$R/usr/lib/firmware/c.bin.zst"; touch "$R/usr/lib/firmware/c.bin.zst" + printf 'b.bin # optional\ndir/* # older versions\n' > "$A" out="$(check)"; rc=$? [[ "$rc" -eq 0 ]] && ok "an accepted glob covers what it matches" || bad "accepted glob: rc=$rc: $out" From f03ae6975207b9a8b54d9146e666bfe4ecd580fa Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 23:20:59 -0700 Subject: [PATCH 125/213] A radio switched off as the net zone starts gets its supplicant once it is switched on Without /dev/rfkill, wpa_supplicant's start on a radio rfkill holds off fails to bring its link up (ERFKILL), and it ends before it writes its pid file; the loop restarted only a supplicant that had one, and brought up only a running supplicant's link. So a laptop started with its switch or airplane-mode key off had no Wi-Fi once switched on, until the net zone restarted. Such a radio is now held at the start, and the loop starts its supplicant once it is switched on: once, so a supplicant failing for another reason says so and is not tried every pass. wifi-radio-off goes on to switch the radio off, restart the net zone (wifi=off), and switch it on: it joins. --- build/guest-tests/zones-check.sh | 19 ++++++++++++++----- docs/design/net-zone.md | 4 +++- tools/net/netzone-init.sh | 16 +++++++++++++++- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index e83f10fc..706f4661 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -844,10 +844,11 @@ else fi # Switched off by rfkill, as its switch or an airplane-mode key would, the radio's link goes down: # the status and the scan say so. Switched on again, the net zone brings the link up, which its -# supplicant cannot see for itself, and the radio joins again. +# supplicant cannot see for itself, and the radio joins again. Switched off when the net zone +# starts, it gets no supplicant, which cannot bring the link up; switched on, it gets one. sta_kill="" for r in /sys/class/rfkill/rfkill*; do [[ "$(cat "$r/name" 2>/dev/null)" == phy0 ]] && sta_kill="$r"; done -off_line=""; off_scan=""; back_line="" +off_line=""; off_scan=""; back_line=""; start_off=""; start_back="" if [[ -n "$sta_kill" ]]; then before="$(ready_count)" echo 1 > "$sta_kill/soft" @@ -856,11 +857,19 @@ if [[ -n "$sta_kill" ]]; then before="$(ready_count)" echo 0 > "$sta_kill/soft" back_line="$(ready_after "$before" " wifi=$AP_SSID " 90)" + before="$(ready_count)" + echo 1 > "$sta_kill/soft" + s6-svc -r /run/service/net-zone + start_off="$(ready_after "$before" " wifi=off " 90)" + before="$(ready_count)" + echo 0 > "$sta_kill/soft" + start_back="$(ready_after "$before" " wifi=$AP_SSID " 90)" fi -if [[ "$off_line" == *" wifi=off "* && "$off_scan" == *" is switched off"* && "$back_line" == *" wifi=$AP_SSID "* ]]; then - pass "wifi-radio-off" "switched off: ${off_line#*netzone: }; scan: $(head -1 <<<"$off_scan"); switched on, joined again: ${back_line#*netzone: }" +if [[ "$off_line" == *" wifi=off "* && "$off_scan" == *" is switched off"* && "$back_line" == *" wifi=$AP_SSID "* \ + && "$start_off" == *" wifi=off "* && "$start_back" == *" wifi=$AP_SSID "* ]]; then + pass "wifi-radio-off" "switched off: ${off_line#*netzone: }; scan: $(head -1 <<<"$off_scan"); switched on, joined again: ${back_line#*netzone: }; off as the net zone started, then on: ${start_back#*netzone: }" else - fail "wifi-radio-off" "rfkill: ${sta_kill:-none named phy0}; off: ${off_line:-none}; scan: $(tr '\n' ';' <<<"$off_scan" | cut -c1-120); back: ${back_line:-none}; $(netzone_said 'wifi' | tail -3 | tr '\n' ' ')" + fail "wifi-radio-off" "rfkill: ${sta_kill:-none named phy0}; off: ${off_line:-none}; scan: $(tr '\n' ';' <<<"$off_scan" | cut -c1-120); back: ${back_line:-none}; off at the start: ${start_off:-none}; then on: ${start_back:-none}; $(netzone_said 'wifi' | tail -4 | tr '\n' ' ')" fi # A net zone can leave its radio with a second netdev or with none, and the # radio comes back to zone 0 so. Each round makes that change in the net diff --git a/docs/design/net-zone.md b/docs/design/net-zone.md index c32c9c10..f8aad749 100755 --- a/docs/design/net-zone.md +++ b/docs/design/net-zone.md @@ -277,7 +277,9 @@ configured the zone keeps its IPv4 path and the launcher says so. (`cfg80211_rfkill_set_block`), and a supplicant brings it up again only on rfkill's events, which `/dev/rfkill` would give and the zone's `/dev` does not hold: the script's loop brings a supplicant's radio up once it is - switched on again, within ten seconds. + switched on again, within ten seconds. A radio switched off as the net + zone starts gets no supplicant at first, as one cannot bring its link up + and ends; the loop starts it once the radio is switched on. - **It scans when zone 0 asks.** `kryptik wifi scan`, and `s` in the menu's Wi-Fi window, have kryptikd send the net zone's pid 1 a SIGUSR1, which carries nothing. The zone scans each radio with `iw` and writes a line per diff --git a/tools/net/netzone-init.sh b/tools/net/netzone-init.sh index d234990c..3fc23b49 100755 --- a/tools/net/netzone-init.sh +++ b/tools/net/netzone-init.sh @@ -241,7 +241,12 @@ wifi_state() { done echo "$st" } -for n in $WIRELESS; do start_wifi "$n"; done +# A radio switched off already gets no supplicant, which fails to bring its link up and ends: +# it is held, and the loop starts one once the radio is switched on. +wifi_held="" +for n in $WIRELESS; do + if ! start_wifi "$n" && [ -r "$WPA_CONF" ] && radio_off "$n"; then wifi_held="$wifi_held $n"; fi +done wifi_last="$(wifi_state)" # --- uplink: DHCP if anyone answers, else what zone 0 carried over --------- @@ -487,6 +492,15 @@ while :; do say "wifi: ${n} is switched on again; up" fi done + # A radio held since the start gets its supplicant once switched on, tried once, so one + # that fails for another reason says so and is not tried every pass. + held="" + for n in $wifi_held; do + if radio_off "$n"; then held="$held $n"; continue; fi + say "wifi: ${n} is switched on; starting its supplicant" + start_wifi "$n"; changed=1 + done + wifi_held="$held" wifi_now="$(wifi_state)" if [ "$wifi_now" != "$wifi_last" ]; then wifi_last="$wifi_now"; changed=1; say "wifi: ${wifi_now}" From f479325b5bbd63d0b269110b53c9b4cb00ee0772 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 23:23:29 -0700 Subject: [PATCH 126/213] A network taken by its number is saved only as its scan line says it is secured, from this window's scan o on a WPA2 line would have saved that network as open, replacing a saved passphrase with key_mgmt=NONE, which an RSN network never matches; a on an open line would have asked for a passphrase a network that offers none never uses. heard_name also gives the security its line words, and heard_pick lets a take only WPA, WPA2, WPA3 or WPA2/WPA3 and o only open, saying which key does, and refuses what Kryptik does not join. The kept scan is cleared when the Wi-Fi window opens, so a number never picks from an earlier one. chrome-wifi-pick.sh covers each case. --- tools/desktop/kryptik-chrome | 38 ++++++++++++++++++++++++++------- tools/tests/chrome-wifi-pick.sh | 34 +++++++++++++++++++---------- 2 files changed, 53 insertions(+), 19 deletions(-) diff --git a/tools/desktop/kryptik-chrome b/tools/desktop/kryptik-chrome index 185c595d..f238d74b 100755 --- a/tools/desktop/kryptik-chrome +++ b/tools/desktop/kryptik-chrome @@ -243,21 +243,47 @@ run_menu() { heard_list() { # heard_list SCAN printf '%s\n' "$1" | awk '/^"/ { printf "%2d %s\n", ++n, $0; next } { print " " $0 }' } -# The name of the Nth network a scan heard, from the daemon's quoted line. A name with a -# backslash in that line holds a byte quoted so no terminal acts on it: it is typed instead. +# The name of the Nth network a scan heard, from the daemon's quoted line, and on a second line +# its security as the line words it. A name with a backslash in that line holds a byte quoted so +# no terminal acts on it: it is typed instead. heard_name() { # heard_name SCAN N line="$(printf '%s\n' "$1" | grep '^"' | sed -n "${2}p" 2>/dev/null)" case "$line" in "" | *\\*) return 1 ;; esac line="${line#\"}" - printf '%s\n' "${line%%\"*}" + rest="${line#*\" }" + printf '%s\n%s\n' "${line%%\"*}" "${rest%% *}" +} +# What a or o may save of the Nth network a scan heard: its name, or why not. Saved the other +# way, a network would never be joined, and an open one saved over a network's passphrase would +# lose the passphrase. +heard_pick() { # heard_pick a|o SCAN N + picked="$(heard_name "$2" "$3")" || { echo "the scan has no network $3 the menu can pass on by number: type its name"; return 1; } + ssid="$(printf '%s\n' "$picked" | sed -n 1p)" + kind="$(printf '%s\n' "$picked" | sed -n 2p)" + case "$kind" in + open) want=o ;; + WPA | WPA2 | WPA3 | WPA2/WPA3) want=a ;; + *) echo "network $ssid is $kind"; return 1 ;; + esac + if [ "$1" != "$want" ]; then + if [ "$want" = o ]; then + echo "network $ssid is open: o saves it" + else + echo "network $ssid is $kind: a saves it, with its passphrase" + fi + return 1 + fi + printf '%s\n' "$ssid" } # The net zone's saved networks, through the launch daemon, as `kryptik wifi` has # them at the administration login: kryptik-launch reads a passphrase here, # unechoed, and the net zone restarts to join. run_wifi() { + # A number picks from a scan made in this window, never an earlier one. + heard="" while :; do clear 2>/dev/null echo "================ KRYPTIK - Wi-Fi networks (zone 0) ================" @@ -290,11 +316,7 @@ run_wifi() { case "$c:$ssid" in [ao]:[1-9] | [ao]:[1-9][0-9]) if [ -n "${heard:-}" ]; then - n="$ssid" - ssid="$(heard_name "$heard" "$n")" || { - echo "the scan has no network $n the menu can pass on by number: type its name" - sleep 3; continue - } + ssid="$(heard_pick "$c" "$heard" "$ssid")" || { echo "$ssid"; sleep 3; continue; } echo "network: $ssid" fi ;; esac diff --git a/tools/tests/chrome-wifi-pick.sh b/tools/tests/chrome-wifi-pick.sh index 0ebc41ca..24437102 100755 --- a/tools/tests/chrome-wifi-pick.sh +++ b/tools/tests/chrome-wifi-pick.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash -# Test the menu's Wi-Fi window taking a network by its number in a scan (heard_list and -# heard_name in tools/desktop/kryptik-chrome): the networks are numbered and the radio lines -# are not, a number gives back the name the daemon quoted, and a name quoted with a backslash -# (a byte quoted so no terminal acts on it) is not passed on. Under each POSIX shell here. +# Test the menu's Wi-Fi window taking a network by its number in a scan (heard_list, heard_name +# and heard_pick in tools/desktop/kryptik-chrome): the networks are numbered and the radio lines +# are not, a number gives back the name the daemon quoted, a name quoted with a backslash (a +# byte quoted so no terminal acts on it) is not passed on, and a takes only a network with a +# passphrase and o only an open one. Under each POSIX shell here. set -uo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" CHROME="$ROOT/tools/desktop/kryptik-chrome" @@ -13,8 +14,8 @@ same() { if [[ "$2" == "$3" ]]; then ok "$1"; else bad "$1: got '$(tr '\n' '|' < T="$(mktemp -d)" trap 'rm -rf "$T"' EXIT -sed -n '/^heard_list() {/,/^}/p; /^heard_name() {/,/^}/p' "$CHROME" > "$T/fn.sh" -for fn in heard_list heard_name; do +sed -n '/^heard_list() {/,/^}/p; /^heard_name() {/,/^}/p; /^heard_pick() {/,/^}/p' "$CHROME" > "$T/fn.sh" +for fn in heard_list heard_name heard_pick; do grep -q "^${fn}() {" "$T/fn.sh" || { echo "could not extract ${fn} from $CHROME"; exit 1; } done @@ -26,6 +27,7 @@ radio nic1 is switched off, by its switch or an airplane-mode key "x\u{1b}[2Jy" open -60 dBm " spaced " WPA2 -61 dBm "q\"uote" WPA2 -70 dBm +"old" WEP, which Kryptik does not join -80 dBm EOF for sh in sh bash dash; do @@ -39,17 +41,27 @@ for sh in sh bash dash; do 2 \"café\" open -55 dBm 3 \"x\\u{1b}[2Jy\" open -60 dBm 4 \" spaced \" WPA2 -61 dBm - 5 \"q\\\"uote\" WPA2 -70 dBm" - same "a number gives the name the daemon quoted" "$(run heard_name "$scan" 1)" "Home Net" - same "a UTF-8 name comes back whole" "$(run heard_name "$scan" 2)" "café" - same "spaces at either end are kept" "$(run heard_name "$scan" 4)" " spaced " - for n in 3 5 6 0; do + 5 \"q\\\"uote\" WPA2 -70 dBm + 6 \"old\" WEP, which Kryptik does not join -80 dBm" + same "a number gives the name the daemon quoted, and its security" "$(run heard_name "$scan" 1)" "$(printf 'Home Net\nWPA2/WPA3')" + same "a UTF-8 name comes back whole" "$(run heard_name "$scan" 2)" "$(printf 'café\nopen')" + same "spaces at either end are kept" "$(run heard_name "$scan" 4)" "$(printf ' spaced \nWPA2')" + for n in 3 5 7 0; do if run heard_name "$scan" "$n" >/dev/null; then bad "number $n is refused (an escaped byte, or no such network)" else ok "number $n is refused (an escaped byte, or no such network)" fi done + # a saves a network with a passphrase and o an open one, as each line says it is. + same "a takes a WPA2/WPA3 network" "$(run heard_pick a "$scan" 1)" "Home Net" + same "o takes an open network" "$(run heard_pick o "$scan" 2)" "café" + same "o refuses a WPA2 network" "$(run heard_pick o "$scan" 4; echo "rc=$?")" \ + "$(printf 'network spaced is WPA2: a saves it, with its passphrase\nrc=1')" + same "a refuses an open network" "$(run heard_pick a "$scan" 2; echo "rc=$?")" \ + "$(printf 'network café is open: o saves it\nrc=1')" + same "neither takes one Kryptik does not join" "$(run heard_pick a "$scan" 6; echo "rc=$?")" \ + "$(printf 'network old is WEP, which Kryptik does not join\nrc=1')" done echo From 2beb5b38658e7004bdff7013c12002441585fd69 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 23:24:01 -0700 Subject: [PATCH 127/213] The README's Wi-Fi list names the QCA2066, QCA6698AQ and MT7920 --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 54bd948b..6a7421ae 100644 --- a/README.md +++ b/README.md @@ -110,9 +110,9 @@ the firmware from the pinned linux-firmware release and bnxt; Realtek r8169; Aquantia AQtion; Mellanox ConnectX-4 and later; USB adapters (AX88179, RTL8152); virtio, VMware vmxnet3, Hyper-V. - **Wi-Fi:** Intel from the 7260 on; Qualcomm QCA6174, QCA9377, QCA6390, - WCN6855, WCN7850; MediaTek MT7921, MT7922, MT7925; Realtek rtw88, rtw89 and - rtl8xxxu; Broadcom over PCIe. The net zone owns the radio - ([net zone design](docs/design/net-zone.md)). No Bluetooth. + WCN6855, QCA2066, QCA6698AQ, WCN7850; MediaTek MT7920, MT7921, MT7922, + MT7925; Realtek rtw88, rtw89 and rtl8xxxu; Broadcom over PCIe. The net zone + owns the radio ([net zone design](docs/design/net-zone.md)). No Bluetooth. - **Display:** the firmware framebuffer (simpledrm) everywhere; Intel (i915, xe) and AMD (amdgpu) with their firmware; ASPEED and Matrox server BMCs; virtio-gpu, VMware SVGA, Hyper-V. NVIDIA machines use the firmware From 0b59154cdca35aaf1d987eaeecbdd082241f1ee5 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 23:24:31 -0700 Subject: [PATCH 128/213] The login prompt says when the kernel refused this processor's microcode An AMD processor whose firmware predates AMD's 2025 fix for microcode signatures takes only a patch whose SHA-256 the kernel holds, and keeps its firmware's microcode otherwise: on every Zen 5 that is all of Kryptik's, and only dmesg said so. sysinit now runs microcode-notice.sh before the first login prompt; it finds amd.c's "No sha256 digest for patch ID" line in the kernel log and writes a line to /run/issue.d/kryptik-microcode.issue, which agetty prints above every prompt, telling the user to update the machine's firmware. tools/tests/microcode-notice.sh runs the script under sh, bash and dash with dmesg stood in for. The zones suite's microcode-notice check writes the kernel's line to /dev/kmsg, as no virtual machine loads microcode, runs the script and reads the notice; on the old image there is no script, so it fails. --- build/guest-tests/zones-check.sh | 15 ++++++ build/service-scripts/microcode-notice.sh | 15 ++++++ build/service-scripts/sysinit.sh | 2 + docs/user-guide.md | 5 +- tools/image/zones-test.sh | 3 +- tools/tests/microcode-notice.sh | 59 +++++++++++++++++++++++ 6 files changed, 96 insertions(+), 3 deletions(-) create mode 100755 build/service-scripts/microcode-notice.sh create mode 100755 tools/tests/microcode-notice.sh diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index f61e41a5..3a8626e3 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -1156,6 +1156,21 @@ done < /usr/lib/kryptik/sysctl.d/99-kryptik-hardening.conf # A file that is missing or holds no key reads back nothing wrong. [[ "$sysctl_keys" -gt 0 && -z "$sysctl_bad" ]] && pass "sysctls-applied" "all $sysctl_keys keys in 99-kryptik-hardening.conf read back as written" || fail "sysctls-applied" "${sysctl_bad:-no key read from 99-kryptik-hardening.conf}" +# --- microcode the kernel refused, on the login prompt ---------------------- +# A virtual machine loads no microcode, so the kernel's line for a refused AMD patch is written +# to its log here, and the boot's script run again. +mc_notice=/run/issue.d/kryptik-microcode.issue +mc_boot="$(cat "$mc_notice" 2>/dev/null)" +echo '<3>microcode: No sha256 digest for patch ID: 0xb404108 found' > /dev/kmsg +/usr/libexec/kryptik/microcode-notice.sh > /dev/null 2>&1 +mc_told="$(cat "$mc_notice" 2>/dev/null)" +rm -f "$mc_notice" +if [[ -z "$mc_boot" && "$mc_told" == *"Update the machine's firmware (BIOS)."* ]]; then + pass "microcode-notice" "no notice at boot here; a refused patch in the kernel log puts one in issue.d: ${mc_told:0:120}" +else + fail "microcode-notice" "at boot: ${mc_boot:-none}; after the kernel's line: ${mc_told:-none}" +fi + echo "ZT SUMMARY passed=$PASS failed=$FAIL" echo "ZT END" [[ "$FAIL" -eq 0 ]] diff --git a/build/service-scripts/microcode-notice.sh b/build/service-scripts/microcode-notice.sh new file mode 100755 index 00000000..ed99f1f1 --- /dev/null +++ b/build/service-scripts/microcode-notice.sh @@ -0,0 +1,15 @@ +#!/bin/sh +# A line on the login prompt when the kernel refused this processor's microcode. An AMD processor +# whose firmware predates AMD's 2025 fix for microcode signatures takes only a patch whose SHA-256 +# the kernel holds (arch/x86/kernel/cpu/microcode/amd.c), and otherwise keeps the firmware's own +# microcode: without this, only the kernel log would say so. +notice="${KRYPTIK_MICROCODE_NOTICE:-/run/issue.d/kryptik-microcode.issue}" +if dmesg 2>/dev/null | grep -q 'microcode: No sha256 digest for patch ID'; then + mkdir -p "${notice%/*}" + echo "kryptik: this processor runs its firmware's older microcode: the kernel refused Kryptik's, as the machine's firmware predates AMD's 2025 fix for microcode signatures. Update the machine's firmware (BIOS)." > "$notice" + echo "microcode-notice: the kernel refused this processor's microcode; the login prompt says so" +else + rm -f "$notice" +fi +# Always 0: sysinit runs under sh -e. +exit 0 diff --git a/build/service-scripts/sysinit.sh b/build/service-scripts/sysinit.sh index 94dbdbc3..1df5cd9a 100755 --- a/build/service-scripts/sysinit.sh +++ b/build/service-scripts/sysinit.sh @@ -228,4 +228,6 @@ if [ -d /usr/lib/kryptik/sysctl.d ]; then sysctl -p "$f" >/dev/null || echo "sysinit: sysctl -p $f reported errors" >&2 done fi +# Before the first login prompt: microcode the kernel refused is named there. +/usr/libexec/kryptik/microcode-notice.sh || true echo "sysinit: complete" diff --git a/docs/user-guide.md b/docs/user-guide.md index b0ad1433..48c48b6e 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -95,8 +95,9 @@ got: `KRYPTIK_SMOKE: secureboot=1` or `=0` on the console. firmware older than AMD's 2025 fix for microcode signatures, the kernel loads only the microcode patches it holds a digest for, which leaves out every Zen 5 processor (Ryzen 9000, Ryzen AI 300 and Ryzen AI Max, EPYC 9005) and a few -older EPYC models: they keep the firmware's own microcode, and the kernel log -says `No sha256 digest for patch ID`. +older EPYC models: they keep the firmware's own microcode, the kernel log +says `No sha256 digest for patch ID`, and every login prompt says to update +the firmware. **Under QEMU** (a checkout of the source tree is needed): diff --git a/tools/image/zones-test.sh b/tools/image/zones-test.sh index 2acd8181..60498011 100755 --- a/tools/image/zones-test.sh +++ b/tools/image/zones-test.sh @@ -72,7 +72,8 @@ for name in kernel-support policies net-ready net-dns dhcpcd-separated dnsmasq-u time-floor-ran time-clamp time-floor-forged time-claim-stepped time-claim-floor time-claim-consent pids-limit ephemeral-size-bound cpu-max-set lifecycle-repeat lifecycle-registry resolver-after-attach dns-cache-off dns-counters-hidden net-zone-sysfs-nics-only \ terminal-terminfo man-page text-browser tls-trust no-shared-writable-mount net-zone-no-zone-data no-swap no-dbus \ volume-init zone-source-pinned bridge-ports-closed encrypted-zone-start stop-closes-volume wrong-passphrase persist-reopen no-mapping-after ephemeral-gone concurrent-start-refused full-volume header-restore volume-destroy vault-offline vault-ping no-passphrase-leak \ - setuid-only-allowed no-file-capabilities sysctls-applied host-activity-hidden fetch-and-sntp-no-caps; do + setuid-only-allowed no-file-capabilities sysctls-applied host-activity-hidden fetch-and-sntp-no-caps \ + microcode-notice; do grep -q "ZT PASS ${name}" <<<"$T2" && green "guest: ${name}" || red "guest: ${name} (not passed)" done diff --git a/tools/tests/microcode-notice.sh b/tools/tests/microcode-notice.sh new file mode 100755 index 00000000..f95b4b30 --- /dev/null +++ b/tools/tests/microcode-notice.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Tests for build/service-scripts/microcode-notice.sh: the login prompt names microcode the +# kernel refused, and says nothing once none was. Offline, with dmesg stood in for. +set -uo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SCRIPT="${ROOT}/build/service-scripts/microcode-notice.sh" + +PASS=0; FAIL=0 +green() { printf ' PASS %s\n' "$1"; PASS=$((PASS + 1)); } +red() { printf ' FAIL %s\n' "$1"; FAIL=$((FAIL + 1)); [[ $# -gt 1 ]] && printf ' %s\n' "$2"; } +T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT + +mkdir -p "$T/bin" +printf '#!/bin/sh\ncat "%s/kernel.log"\n' "$T" > "$T/bin/dmesg" +chmod +x "$T/bin/dmesg" +NOTICE="$T/issue.d/kryptik-microcode.issue" +boot() { # boot SHELL LOG-LINE...: the script under SHELL, with that kernel log + local sh="$1"; shift + printf '%s\n' "$@" > "$T/kernel.log" + PATH="$T/bin:$PATH" KRYPTIK_MICROCODE_NOTICE="$NOTICE" "$sh" "$SCRIPT" > "$T/said" 2>&1 +} + +for sh in sh bash dash; do + command -v "$sh" >/dev/null 2>&1 || continue + echo "-- ${sh}" + rm -rf "$T/issue.d" + # amd.c's line on a Zen 5 whose firmware predates the fix, as each CPU logs it. + boot "$sh" '[ 0.000000] microcode: Current revision: 0x0b404023' \ + '[ 0.000000] microcode: No sha256 digest for patch ID: 0xb404108 found' \ + '[ 0.412345] microcode: No sha256 digest for patch ID: 0xb404108 found'; rc=$? + if [[ "$rc" -eq 0 ]] && grep -q "firmware predates AMD's 2025 fix for microcode signatures. Update the machine's firmware" "$NOTICE" 2>/dev/null \ + && grep -q 'login prompt says so' "$T/said"; then + green "a refused patch is named on the login prompt and the console" + else + red "a refused patch" "exit ${rc}; notice: $(cat "$NOTICE" 2>&1); said: $(cat "$T/said")" + fi + [[ "$(wc -l < "$NOTICE" 2>/dev/null)" -eq 1 ]] && green "one line however many CPUs logged it" || red "one line" "$(cat "$NOTICE" 2>&1)" + + # Microcode applied (or none to apply, as in a virtual machine): no notice, and the last + # boot's is gone. + boot "$sh" '[ 0.000000] microcode: Current revision: 0x0b404023' \ + '[ 0.000000] microcode: Updated early from: 0x0b404023'; rc=$? + [[ "$rc" -eq 0 && ! -e "$NOTICE" && ! -s "$T/said" ]] && green "applied microcode leaves no notice, and clears an old one" \ + || red "applied microcode" "exit ${rc}; notice: $(cat "$NOTICE" 2>&1); said: $(cat "$T/said")" + + # A kernel log it cannot read is not a refusal; sysinit runs under sh -e, so it exits 0. + printf '#!/bin/sh\nexit 1\n' > "$T/bin/dmesg" + boot "$sh"; rc=$? + printf '#!/bin/sh\ncat "%s/kernel.log"\n' "$T" > "$T/bin/dmesg" + [[ "$rc" -eq 0 && ! -e "$NOTICE" ]] && green "an unreadable kernel log names nothing, and exits 0" \ + || red "an unreadable kernel log" "exit ${rc}; notice: $(cat "$NOTICE" 2>&1)" +done + +grep -q '^/usr/libexec/kryptik/microcode-notice.sh || true$' "${ROOT}/build/service-scripts/sysinit.sh" \ + && green "sysinit runs it before the first login prompt" || red "sysinit does not run microcode-notice.sh" + +echo +echo "${PASS} passed, ${FAIL} failed" +[[ "$FAIL" -eq 0 ]] From 91c91334af048dc1bc71ec40095847e87b9a7da7 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 23:29:23 -0700 Subject: [PATCH 129/213] The microcode notice reads as the update notice does: what, then what to do --- build/guest-tests/zones-check.sh | 2 +- build/service-scripts/microcode-notice.sh | 2 +- tools/tests/microcode-notice.sh | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index 3a8626e3..331d2ac9 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -1165,7 +1165,7 @@ echo '<3>microcode: No sha256 digest for patch ID: 0xb404108 found' > /dev/kmsg /usr/libexec/kryptik/microcode-notice.sh > /dev/null 2>&1 mc_told="$(cat "$mc_notice" 2>/dev/null)" rm -f "$mc_notice" -if [[ -z "$mc_boot" && "$mc_told" == *"Update the machine's firmware (BIOS)."* ]]; then +if [[ -z "$mc_boot" && "$mc_told" == "kryptik microcode: "*": update the machine's firmware (BIOS)" ]]; then pass "microcode-notice" "no notice at boot here; a refused patch in the kernel log puts one in issue.d: ${mc_told:0:120}" else fail "microcode-notice" "at boot: ${mc_boot:-none}; after the kernel's line: ${mc_told:-none}" diff --git a/build/service-scripts/microcode-notice.sh b/build/service-scripts/microcode-notice.sh index ed99f1f1..8db00795 100755 --- a/build/service-scripts/microcode-notice.sh +++ b/build/service-scripts/microcode-notice.sh @@ -6,7 +6,7 @@ notice="${KRYPTIK_MICROCODE_NOTICE:-/run/issue.d/kryptik-microcode.issue}" if dmesg 2>/dev/null | grep -q 'microcode: No sha256 digest for patch ID'; then mkdir -p "${notice%/*}" - echo "kryptik: this processor runs its firmware's older microcode: the kernel refused Kryptik's, as the machine's firmware predates AMD's 2025 fix for microcode signatures. Update the machine's firmware (BIOS)." > "$notice" + echo "kryptik microcode: the kernel refused this processor's microcode, as the machine's firmware predates AMD's 2025 fix for microcode signatures, so it runs the firmware's older one: update the machine's firmware (BIOS)" > "$notice" echo "microcode-notice: the kernel refused this processor's microcode; the login prompt says so" else rm -f "$notice" diff --git a/tools/tests/microcode-notice.sh b/tools/tests/microcode-notice.sh index f95b4b30..a77dcd1a 100755 --- a/tools/tests/microcode-notice.sh +++ b/tools/tests/microcode-notice.sh @@ -28,7 +28,7 @@ for sh in sh bash dash; do boot "$sh" '[ 0.000000] microcode: Current revision: 0x0b404023' \ '[ 0.000000] microcode: No sha256 digest for patch ID: 0xb404108 found' \ '[ 0.412345] microcode: No sha256 digest for patch ID: 0xb404108 found'; rc=$? - if [[ "$rc" -eq 0 ]] && grep -q "firmware predates AMD's 2025 fix for microcode signatures. Update the machine's firmware" "$NOTICE" 2>/dev/null \ + if [[ "$rc" -eq 0 ]] && grep -qx "kryptik microcode: the kernel refused this processor's microcode, as the machine's firmware predates AMD's 2025 fix for microcode signatures, so it runs the firmware's older one: update the machine's firmware (BIOS)" "$NOTICE" 2>/dev/null \ && grep -q 'login prompt says so' "$T/said"; then green "a refused patch is named on the login prompt and the console" else From 8325a7e290e12c8dbd229dd5fe962b69df4d3eae Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 23:30:10 -0700 Subject: [PATCH 130/213] A failed open-network check says what the station's radio hears by itself and what the kernel said of it Distro 38208557591 and 38208559843 both failed wifi-open the same way: the open access point up under its own BSSID on channel 1, and the station's supplicant DISCONNECTED with no scan results at all, ignoring only the departed WPA3 access point's BSSID. The failure now adds a scan iw makes on the station's radio, its link and channel, the access point's station count, and the kernel's last lines naming the station. --- build/guest-tests/zones-check.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index dbd57686..126e58cf 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -715,6 +715,8 @@ ap() { nsenter -t "$AP_HOLD" -n "$@"; } ap_wpa() { ap wpa_cli -p /run/zt-ap-ctrl -i "$AP_IF" "$@" 2>/dev/null; } # The station in the net zone, by the name that zone gave it (nicN once the radio has moved). sta_if() { nsenter -t "$(cut -d' ' -f1 /run/kryptik/zones/net/init.pid 2>/dev/null)" -n iw dev 2>/dev/null | awk '$1 == "Interface" { print $2; exit }'; } +# What the station's radio itself is and hears, beside its supplicant's view. +sta_iw() { nsenter -t "$(cut -d' ' -f1 /run/kryptik/zones/net/init.pid 2>/dev/null)" -n iw dev "$(sta_if)" "$@" 2>&1; } # What the net zone's supplicant sees, for a failure's message: its socket is its own user's. sta_wpa() { local ni; ni="$(cut -d' ' -f1 /run/kryptik/zones/net/init.pid 2>/dev/null)" @@ -955,7 +957,7 @@ if [[ "$open_up" = 1 && "$line" == *" wifi=$OPEN_SSID "* && "$open_lease" == 192 && grep -q "added open network \"$OPEN_SSID\"" "$LOG/wifi-add-open.out"; then pass "wifi-open" "saved with --open and no passphrase, $OPEN_SSID was joined and the net zone leased ${open_lease} there" else - fail "wifi-open" "access point up: ${open_up} ($(ap iw dev "$AP_IF" info 2>&1 | grep -E 'addr|ssid|type|channel' | tr -s '\t\n' ' ')); $(tr '\n' ' ' < "$LOG/wifi-add-open.out"); newest READY: ${line:-none}; lease: ${open_lease:-none}; $(tail -2 "$LOG/ap-open-wpa.log" 2>/dev/null | tr '\n' ' '); the station: $(sta_wpa status | cut -c1-240); networks: $(sta_wpa list_networks | cut -c1-240); seen: $(sta_wpa scan_results | cut -c1-400); ignored: $(sta_wpa bssid_ignore | cut -c1-120)" + fail "wifi-open" "access point up: ${open_up} ($(ap iw dev "$AP_IF" info 2>&1 | grep -E 'addr|ssid|type|channel' | tr -s '\t\n' ' ')); $(tr '\n' ' ' < "$LOG/wifi-add-open.out"); newest READY: ${line:-none}; lease: ${open_lease:-none}; $(tail -2 "$LOG/ap-open-wpa.log" 2>/dev/null | tr '\n' ' '); the station: $(sta_wpa status | cut -c1-240); networks: $(sta_wpa list_networks | cut -c1-240); seen: $(sta_wpa scan_results | cut -c1-400); ignored: $(sta_wpa bssid_ignore | cut -c1-120); its radio: $(sta_iw info | grep -E 'addr|type|channel' | tr -s '\t\n' ' '); link: $(sta_iw link | head -2 | tr '\n' ' '); a scan of its own: $(sta_iw scan | grep -E '^BSS|SSID:|command failed' | tr -s '\t\n' ' ' | cut -c1-300); the access point's stations: $(ap iw dev "$AP_IF" station dump 2>&1 | grep -c '^Station'); the kernel: $(dmesg 2>/dev/null | grep -aE 'nic[0-9]+:|wlan[0-9]+:' | tail -12 | tr '\n' ';' | cut -c1-900)" fi # Back to the wire: the radios go first, so the net zone the forget restarts finds none. for f in /run/zt-ap-dnsmasq.pid /run/zt-ap-wpa.pid; do p="$(cat "$f" 2>/dev/null)"; [[ -n "$p" ]] && kill "$p" 2>/dev/null; done From 42f2909e8b1a49e9e716cbb7a7974641c1d5d72d Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 23:33:17 -0700 Subject: [PATCH 131/213] The installer gives the firmware an entry for slot a, for firmware that boots a disk only from one The installed disk boots EFI/BOOT/BOOTX64.EFI by the removable-media path, which most firmware takes for an internal disk too; some boots a fixed disk only from a Boot#### entry, and such a machine never booted a fresh install. kryptik-install now runs kryptik-efiboot forget and then ensure a on the new ESP once it is written, as kryptik-recover --commit-slot and boot-success already do: a "Kryptik slot a" entry, last in BootOrder, so no other system's entry is passed over. A firmware that refuses it is said, and the disk boots by BOOTX64.EFI as before; kryptik-efiboot is checked with the other tools before the first write. The install suite's first step wants the installer's "firmware" line naming the entry on /dev/vda1; its second step still boots the disk with the firmware's variables reset, so the removable-media path stays proved. --- docs/design/boot-and-updates.md | 6 +++++- docs/user-guide.md | 6 ++++-- tools/image/install-test.sh | 2 ++ tools/install/kryptik-install.sh | 11 ++++++++++- 4 files changed, 21 insertions(+), 4 deletions(-) diff --git a/docs/design/boot-and-updates.md b/docs/design/boot-and-updates.md index dc4ea3dc..c79969ed 100644 --- a/docs/design/boot-and-updates.md +++ b/docs/design/boot-and-updates.md @@ -106,7 +106,11 @@ 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 ` Boot#### +no firmware variables. Some firmware boots an internal disk only from a +Boot#### entry, so the installer also gives slot a its own, last in +`BootOrder`, after `forget` has removed an earlier install's; a firmware that +refuses it leaves the disk to `BOOTX64.EFI`, and the installer says so. +`kryptik-efiboot` adds a `Kryptik slot ` Boot#### entry for a trial. When the trial ends, committed or not, `forget` removes Kryptik's entries and `BootNext`, and the committed slot gets its own entry back. Firmware re-adds its own disk entry at the end of `BootOrder`, so an diff --git a/docs/user-guide.md b/docs/user-guide.md index 31639a08..98743f14 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -177,8 +177,10 @@ umount /mnt poweroff ``` -Remove the medium. The installed disk boots on its own; it does not need -the medium again unless it has to be recovered. +Remove the medium. The installed disk boots on its own, by its +`EFI/BOOT/BOOTX64.EFI` or by the `Kryptik slot a` entry the installer gave +the firmware (a firmware that refused it is named in the install's output); +it does not need the medium again unless it has to be recovered. **Unattended install** (what the VM tests do): a small control disk labelled `kryptik-testctl`, made with `tools/image/mk-testctl.sh --out FILE --key diff --git a/tools/image/install-test.sh b/tools/image/install-test.sh index 82714cc3..3e76990f 100755 --- a/tools/image/install-test.sh +++ b/tools/image/install-test.sh @@ -69,6 +69,8 @@ want "$P1" 'KRYPTIK_INSTALL: verify: kryptik-a=/dev/vda2' "partition 2 is krypt want "$P1" 'KRYPTIK_INSTALL: verify: kryptik-b=/dev/vda3 type=(none)?$' "partition 3 is kryptik-b, and empty" want "$P1" 'KRYPTIK_INSTALL: verify: kryptik-state=/dev/vda4 type=crypto_LUKS' "partition 4 is the state partition, and it is LUKS" want "$P1" 'KRYPTIK_INSTALL: verify: esp_files=.*EFI/BOOT/BOOTX64.EFI' "the ESP has the removable-media boot file" +want "$P1" 'KRYPTIK_INSTALL: kryptik-install: firmware +Boot[0-9A-F]{4} -> .EFI.kryptik.kryptik-a\.efi on /dev/vda1 ' \ + "the firmware has an entry for slot a on the new ESP as well" want "$P1" 'KRYPTIK_INSTALL: verify: install_json=yes' "install.json was written" want "$P1" 'KRYPTIK_INSTALL: verify: preseed=present' "the first-boot preseed was written" want "$P1" 'KRYPTIK_INSTALL: .*kryptik-a verifies' "the root image was read back and verified" diff --git a/tools/install/kryptik-install.sh b/tools/install/kryptik-install.sh index f8f3f230..29d3f3e6 100755 --- a/tools/install/kryptik-install.sh +++ b/tools/install/kryptik-install.sh @@ -71,7 +71,7 @@ done missing="" for tool in sfdisk partx blockdev blkid cryptsetup veritysetup stty mkfs.ext4 dd sha256sum mount umount sync awk sed \ grep readlink lsblk head tail wc cmp cp mkdir stat tr cut basename mv mountpoint rmdir chmod rm cat sleep \ - loadkeys chattr; do + loadkeys chattr kryptik-efiboot; do command -v "$tool" >/dev/null 2>&1 || missing="${missing} ${tool}" done [ -z "$missing" ] || die "this system is missing:${missing} @@ -343,6 +343,15 @@ printf 'a\n' > "$MNT_BASE/tesp/kryptik/committed-slot" rm -f "$MNT_BASE/tesp/kryptik/media-kernel" sync umount "$MNT_BASE/tesp" || die "could not unmount the new ESP" +# A firmware entry for slot a as well, last in BootOrder, as kryptik-recover and boot-success +# leave one: some firmware boots an internal disk only from an entry, not by BOOTX64.EFI. +# Entries of an earlier Kryptik go first, as they name a partition this install replaced. +kryptik-efiboot forget > /dev/null 2>&1 || true +if entry="$(kryptik-efiboot ensure a "$P1" 2>&1)"; then + say "firmware $(printf '%s\n' "$entry" | tail -1)" +else + say "firmware no entry for slot a ($(printf '%s\n' "$entry" | tail -1)): the disk boots by BOOTX64.EFI alone" +fi # --- the state partition: what installed this, and the first-boot preseed -- mount -t ext4 -o rw "/dev/mapper/$MAPPING" "$MNT_BASE/state" || die "could not mount kryptik-state" From 4d9b3525c86b761530c628c40fd83ae557e53dbc Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 23:35:11 -0700 Subject: [PATCH 132/213] The threat model says a compromised net zone can list networks that are not there, or leave out ones that are The scan list is the net zone's answer, which kryptikd checks line by line and quotes; what it lists is still that zone's word. --- docs/threat-model.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/threat-model.md b/docs/threat-model.md index ab63ab48..a6b93eed 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -81,6 +81,9 @@ It can still: - keep the machine offline, or a routed zone without its lookups or its bandwidth; - read the passphrase of every Wi-Fi network it was given; +- list networks in range that are not there, or leave out ones that are, in + `kryptik wifi scan` and the menu, which show its answer quoted and checked + line by line; - withhold releases, which zone 0 reports once the newest statement is 30 days old, or, where the image names a channel and none was accepted, the install, in `kryptik update status`, above the login prompt and in the From 11bf39a59972eb98356e8faefbbb0bb991f31201 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 23:36:26 -0700 Subject: [PATCH 133/213] microcode-notice.sh is a function sysinit sources, as its other helpers are tools/tests/services.sh holds every installed script to being run by a service or sourced by a script one runs; sysinit executed this one. It now sources the file and calls microcode_notice || true, like ask.sh and keyboard.sh, and both tests call the function. --- build/guest-tests/zones-check.sh | 2 +- build/service-scripts/microcode-notice.sh | 25 +++++++++++++---------- build/service-scripts/sysinit.sh | 3 ++- tools/tests/microcode-notice.sh | 22 ++++++++++++-------- 4 files changed, 31 insertions(+), 21 deletions(-) diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index 331d2ac9..0888b215 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -1162,7 +1162,7 @@ done < /usr/lib/kryptik/sysctl.d/99-kryptik-hardening.conf mc_notice=/run/issue.d/kryptik-microcode.issue mc_boot="$(cat "$mc_notice" 2>/dev/null)" echo '<3>microcode: No sha256 digest for patch ID: 0xb404108 found' > /dev/kmsg -/usr/libexec/kryptik/microcode-notice.sh > /dev/null 2>&1 +sh -c '. /usr/libexec/kryptik/microcode-notice.sh; microcode_notice' > /dev/null 2>&1 mc_told="$(cat "$mc_notice" 2>/dev/null)" rm -f "$mc_notice" if [[ -z "$mc_boot" && "$mc_told" == "kryptik microcode: "*": update the machine's firmware (BIOS)" ]]; then diff --git a/build/service-scripts/microcode-notice.sh b/build/service-scripts/microcode-notice.sh index 8db00795..c3fc151f 100755 --- a/build/service-scripts/microcode-notice.sh +++ b/build/service-scripts/microcode-notice.sh @@ -2,14 +2,17 @@ # A line on the login prompt when the kernel refused this processor's microcode. An AMD processor # whose firmware predates AMD's 2025 fix for microcode signatures takes only a patch whose SHA-256 # the kernel holds (arch/x86/kernel/cpu/microcode/amd.c), and otherwise keeps the firmware's own -# microcode: without this, only the kernel log would say so. -notice="${KRYPTIK_MICROCODE_NOTICE:-/run/issue.d/kryptik-microcode.issue}" -if dmesg 2>/dev/null | grep -q 'microcode: No sha256 digest for patch ID'; then - mkdir -p "${notice%/*}" - echo "kryptik microcode: the kernel refused this processor's microcode, as the machine's firmware predates AMD's 2025 fix for microcode signatures, so it runs the firmware's older one: update the machine's firmware (BIOS)" > "$notice" - echo "microcode-notice: the kernel refused this processor's microcode; the login prompt says so" -else - rm -f "$notice" -fi -# Always 0: sysinit runs under sh -e. -exit 0 +# microcode: without this, only the kernel log would say so. POSIX sh, sourced by sysinit. +# microcode_notice write the notice when the kernel log has a refusal, else remove it + +MICROCODE_NOTICE="${MICROCODE_NOTICE:-/run/issue.d/kryptik-microcode.issue}" + +microcode_notice() { + if dmesg 2>/dev/null | grep -q 'microcode: No sha256 digest for patch ID'; then + mkdir -p "${MICROCODE_NOTICE%/*}" + echo "kryptik microcode: the kernel refused this processor's microcode, as the machine's firmware predates AMD's 2025 fix for microcode signatures, so it runs the firmware's older one: update the machine's firmware (BIOS)" > "$MICROCODE_NOTICE" + echo "sysinit: the kernel refused this processor's microcode; the login prompt says so" + else + rm -f "$MICROCODE_NOTICE" + fi +} diff --git a/build/service-scripts/sysinit.sh b/build/service-scripts/sysinit.sh index 1df5cd9a..591bd434 100755 --- a/build/service-scripts/sysinit.sh +++ b/build/service-scripts/sysinit.sh @@ -229,5 +229,6 @@ if [ -d /usr/lib/kryptik/sysctl.d ]; then done fi # Before the first login prompt: microcode the kernel refused is named there. -/usr/libexec/kryptik/microcode-notice.sh || true +. /usr/libexec/kryptik/microcode-notice.sh +microcode_notice || true echo "sysinit: complete" diff --git a/tools/tests/microcode-notice.sh b/tools/tests/microcode-notice.sh index a77dcd1a..2f20d447 100755 --- a/tools/tests/microcode-notice.sh +++ b/tools/tests/microcode-notice.sh @@ -1,9 +1,11 @@ #!/usr/bin/env bash -# Tests for build/service-scripts/microcode-notice.sh: the login prompt names microcode the -# kernel refused, and says nothing once none was. Offline, with dmesg stood in for. +# Tests for build/service-scripts/microcode-notice.sh, sourced as sysinit sources it: the login +# prompt names microcode the kernel refused, and says nothing once none was. Offline, with dmesg +# stood in for. set -uo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" SCRIPT="${ROOT}/build/service-scripts/microcode-notice.sh" +SYSINIT="${ROOT}/build/service-scripts/sysinit.sh" PASS=0; FAIL=0 green() { printf ' PASS %s\n' "$1"; PASS=$((PASS + 1)); } @@ -14,10 +16,10 @@ mkdir -p "$T/bin" printf '#!/bin/sh\ncat "%s/kernel.log"\n' "$T" > "$T/bin/dmesg" chmod +x "$T/bin/dmesg" NOTICE="$T/issue.d/kryptik-microcode.issue" -boot() { # boot SHELL LOG-LINE...: the script under SHELL, with that kernel log +boot() { # boot SHELL LOG-LINE...: microcode_notice under SHELL, with that kernel log local sh="$1"; shift printf '%s\n' "$@" > "$T/kernel.log" - PATH="$T/bin:$PATH" KRYPTIK_MICROCODE_NOTICE="$NOTICE" "$sh" "$SCRIPT" > "$T/said" 2>&1 + PATH="$T/bin:$PATH" MICROCODE_NOTICE="$NOTICE" "$sh" -c '. "$1"; microcode_notice' sh "$SCRIPT" > "$T/said" 2>&1 } for sh in sh bash dash; do @@ -43,16 +45,20 @@ for sh in sh bash dash; do [[ "$rc" -eq 0 && ! -e "$NOTICE" && ! -s "$T/said" ]] && green "applied microcode leaves no notice, and clears an old one" \ || red "applied microcode" "exit ${rc}; notice: $(cat "$NOTICE" 2>&1); said: $(cat "$T/said")" - # A kernel log it cannot read is not a refusal; sysinit runs under sh -e, so it exits 0. + # A kernel log it cannot read is not a refusal, and the function returns 0. printf '#!/bin/sh\nexit 1\n' > "$T/bin/dmesg" boot "$sh"; rc=$? printf '#!/bin/sh\ncat "%s/kernel.log"\n' "$T" > "$T/bin/dmesg" - [[ "$rc" -eq 0 && ! -e "$NOTICE" ]] && green "an unreadable kernel log names nothing, and exits 0" \ + [[ "$rc" -eq 0 && ! -e "$NOTICE" ]] && green "an unreadable kernel log names nothing, and returns 0" \ || red "an unreadable kernel log" "exit ${rc}; notice: $(cat "$NOTICE" 2>&1)" done -grep -q '^/usr/libexec/kryptik/microcode-notice.sh || true$' "${ROOT}/build/service-scripts/sysinit.sh" \ - && green "sysinit runs it before the first login prompt" || red "sysinit does not run microcode-notice.sh" +# sysinit runs under sh -e: it sources the file and calls the function where a failure is let pass. +if grep -qxF '. /usr/libexec/kryptik/microcode-notice.sh' "$SYSINIT" && grep -qxF 'microcode_notice || true' "$SYSINIT"; then + green "sysinit sources it and calls it before the first login prompt" +else + red "sysinit does not source microcode-notice.sh and call microcode_notice || true" +fi echo echo "${PASS} passed, ${FAIL} failed" From d11a54535213870a2cf77ff7094bce88217c5b7c Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 23:36:55 -0700 Subject: [PATCH 134/213] Python's _xxtestfuzz goes with the other test modules The first Python build in stage 04 installs every test module; the final one, built with --disable-test-modules, removes the ones its list names. _xxtestfuzz, a fuzzing harness gated on TEST_MODULES like the rest (configure.ac), was not on the list, so the first build's copy shipped, without CET. The recipe now removes it and fails if it is still there. --- build/recipes/python.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/build/recipes/python.sh b/build/recipes/python.sh index b8d8b41a..5b1e0b0a 100644 --- a/build/recipes/python.sh +++ b/build/recipes/python.sh @@ -20,11 +20,12 @@ s_python_final() { # The first build's test files outlive it here, and tkinter cannot import without _tkinter, which nothing builds. local lib="/usr/lib/python${V_PYTHON%.*}" rm -rf "$lib"/test "$lib"/idlelib "$lib"/tkinter "$lib"/turtledemo "$lib"/turtle.py "$lib"/__pycache__/turtle.* \ - "$lib"/lib-dynload/{_test,_ctypes_test,xx}*.so /usr/bin/idle3 "/usr/bin/idle${V_PYTHON%.*}" \ + "$lib"/lib-dynload/{_test,_ctypes_test,xx,_xxtestfuzz}*.so /usr/bin/idle3 "/usr/bin/idle${V_PYTHON%.*}" \ "$lib"/config-*/libpython*.a # Bytecode for python -O and -OO, which nothing runs; such a run compiles in memory on a read-only root. find "$lib" -name '*.opt-[12].pyc' -delete [[ ! -e "$lib/test" ]] && ! compgen -G "$lib/lib-dynload/_test*.so" > /dev/null \ + && ! compgen -G "$lib/lib-dynload/_xxtestfuzz*.so" > /dev/null \ || { echo "FAIL: python's test suite is still installed"; return 1; } local m for m in ctypes ssl pyexpat readline; do From ddc6d701d75e6f4b1e41db10e39691d08459335d Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 23:37:29 -0700 Subject: [PATCH 135/213] pldd, sprof, sotruss-lib and pcprofiledump stay accepted: glibc never fortifies them glibc's Makeconfig builds everything in elf/, dlfcn/, csu/ and debug/ without _FORTIFY_SOURCE (do-fortify), since the loader's bootstrap and the checking routines cannot be fortified, so --enable-fortify-source=3 leaves the programs built there unchecked. The combined run's strict audit named the four; their entries return with that reason, and the docs say which code glibc spares. --- build/config/artifact-accepted.txt | 4 ++++ build/config/hardening-exceptions.txt | 2 +- build/recipes/glibc.sh | 2 +- docs/hardening.md | 6 ++++-- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/build/config/artifact-accepted.txt b/build/config/artifact-accepted.txt index a13d2560..3b9677f7 100644 --- a/build/config/artifact-accepted.txt +++ b/build/config/artifact-accepted.txt @@ -101,6 +101,10 @@ NO-SSP usr/bin/kryptikd # Rust: stable rustc has no stack protector, NO-SSP usr/bin/kryptik-wlproxy # the same # Imports of calls that _FORTIFY_SOURCE=2 or 3 always turns into _chk ones. +NO-FORTIFY usr/bin/pldd # glibc builds elf/, dlfcn/, csu/ and debug/ without FORTIFY (Makeconfig's do-fortify): the loader's bootstrap and the checks themselves +NO-FORTIFY usr/bin/sprof # the same: elf/ +NO-FORTIFY usr/lib/audit/sotruss-lib.so # the same: elf/ +NO-FORTIFY usr/bin/pcprofiledump # the same: debug/ NO-FORTIFY usr/bin/gawk # passes fprintf as its printing function (print_vars, pp_string_fp, the debugger's print_func) NO-FORTIFY usr/bin/gawk-* # the same NO-FORTIFY usr/bin/msg* # gettext's bundled libxml, linked into each tool, keeps fprintf as its error channel diff --git a/build/config/hardening-exceptions.txt b/build/config/hardening-exceptions.txt index 5f969bbc..8607b16e 100644 --- a/build/config/hardening-exceptions.txt +++ b/build/config/hardening-exceptions.txt @@ -1,4 +1,4 @@ # Packages exempted from a hardening flag; an entry without a justification fails the build. # # what breaks, and what was tried -glibc -D_FORTIFY_SOURCE=3 # glibc implements FORTIFY, so --enable-fortify-source=3 applies it to all but the files that implement it +glibc -D_FORTIFY_SOURCE=3 # glibc implements FORTIFY, so --enable-fortify-source=3 applies it to all but its loader, startup and checking code diff --git a/build/recipes/glibc.sh b/build/recipes/glibc.sh index 18785140..b9469636 100644 --- a/build/recipes/glibc.sh +++ b/build/recipes/glibc.sh @@ -15,7 +15,7 @@ s_glibc() { cd build echo "rootsbindir=/usr/sbin" > configparms - # glibc applies the strong stack protector and FORTIFY itself, sparing the files that implement them. + # glibc applies the strong stack protector and FORTIFY itself, sparing its loader, startup and checking code. # --enable-cet: CFLAGS' -fcf-protection makes rtld call _dl_cet_*, built only with this flag. ../configure \ --prefix=/usr \ diff --git a/docs/hardening.md b/docs/hardening.md index c064f52f..ddde5ff1 100644 --- a/docs/hardening.md +++ b/docs/hardening.md @@ -35,8 +35,10 @@ A package that cannot build with a flag goes in `build/config/hardening-exceptions.txt` as ` # reason`; `build/lib/common.sh` fails the build on an entry without a reason. The only one is glibc's `-D_FORTIFY_SOURCE=3`: glibc implements FORTIFY, and its own -`--enable-fortify-source=3` applies it to all but the files that implement -it. +`--enable-fortify-source=3` applies it to everything but the loader, startup +and checking code (its `elf`, `dlfcn`, `csu` and `debug` directories), whose +programs `pldd`, `sprof`, `pcprofiledump` and `sotruss-lib.so` the audit +accepts by name. ### What the audit finds From f23232c93cc2852bf11e80264664005cb2d6e64c Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 23:39:45 -0700 Subject: [PATCH 136/213] The install suite checks the entry's PARTUUID against the new GPT; the design says a machine holds one Kryptik's entries --- docs/design/boot-and-updates.md | 2 ++ tools/image/install-test.sh | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/docs/design/boot-and-updates.md b/docs/design/boot-and-updates.md index c79969ed..31fc363d 100644 --- a/docs/design/boot-and-updates.md +++ b/docs/design/boot-and-updates.md @@ -110,6 +110,8 @@ no firmware variables. Some firmware boots an internal disk only from a Boot#### entry, so the installer also gives slot a its own, last in `BootOrder`, after `forget` has removed an earlier install's; a firmware that refuses it leaves the disk to `BOOTX64.EFI`, and the installer says so. +Entries are kept by slot, not by disk, so a machine holds one Kryptik's: a +second Kryptik disk in it boots by its own `BOOTX64.EFI`. `kryptik-efiboot` adds a `Kryptik slot ` Boot#### entry for a trial. When the trial ends, committed or not, `forget` removes Kryptik's entries and `BootNext`, and the committed slot gets its own entry diff --git a/tools/image/install-test.sh b/tools/image/install-test.sh index 3e76990f..056a603d 100755 --- a/tools/image/install-test.sh +++ b/tools/image/install-test.sh @@ -71,6 +71,14 @@ want "$P1" 'KRYPTIK_INSTALL: verify: kryptik-state=/dev/vda4 type=crypto_LUKS' " want "$P1" 'KRYPTIK_INSTALL: verify: esp_files=.*EFI/BOOT/BOOTX64.EFI' "the ESP has the removable-media boot file" want "$P1" 'KRYPTIK_INSTALL: kryptik-install: firmware +Boot[0-9A-F]{4} -> .EFI.kryptik.kryptik-a\.efi on /dev/vda1 ' \ "the firmware has an entry for slot a on the new ESP as well" +# The entry names the partition by PARTUUID: the one the new GPT holds, not one a cache kept. +fw_uuid="$(sed -n 's/.*kryptik-install: firmware .*(PARTUUID \([0-9A-Fa-f-]*\)).*/\1/p' "$P1" | head -1)" +gpt_uuid="$(sfdisk -d "$DISK" 2>/dev/null | awk -v p="${DISK}1" '$1 == p && match($0, /uuid=[0-9A-Fa-f-]+/) { print substr($0, RSTART + 5, RLENGTH - 5) }')" +if [[ -n "$fw_uuid" && "${fw_uuid,,}" == "${gpt_uuid,,}" ]]; then + green "the entry names the new ESP by its PARTUUID (${gpt_uuid})" +else + red "the entry names PARTUUID ${fw_uuid:-none}; the new GPT gives the ESP ${gpt_uuid:-none}" +fi want "$P1" 'KRYPTIK_INSTALL: verify: install_json=yes' "install.json was written" want "$P1" 'KRYPTIK_INSTALL: verify: preseed=present' "the first-boot preseed was written" want "$P1" 'KRYPTIK_INSTALL: .*kryptik-a verifies' "the root image was read back and verified" From 017dd77d6ed8a85c2f20f0a5928c71d1b322b56d Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 23:45:08 -0700 Subject: [PATCH 137/213] The installer's plan names the disk's model and what each partition holds On a medium nothing is mounted, so a backup disk or another system's disk passes every refusal, and ERASE was the only thing between a wrong --target and its loss; the plan named the disk only by its node and size. It now says the disk's model, and on a "holds" line the partition table and each partition's filesystem, label and size, probed with blkid -p so a stale udev record cannot misname it. What a device or a filesystem calls itself is printed as printable bytes only. The medium's console suite wants "holds no partition" on its first dry run, of a blank disk, and "holds gpt: vda1 crypto_LUKS 64M" before the ERASE of its final install, after the LUKS refusal it sets up. The guide says to read the line. --- docs/user-guide.md | 5 ++++- tools/image/medium-shell-test.sh | 4 ++-- tools/install/kryptik-install.sh | 19 ++++++++++++++++++- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/docs/user-guide.md b/docs/user-guide.md index 31639a08..23df8faf 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -161,7 +161,10 @@ It writes four GPT partitions: `kryptik-esp` (the medium's ESP, with the slot A kernel as the boot file), `kryptik-a` (the verified root image, read back and hashed against the medium's record), `kryptik-b` (empty until the first update), and `kryptik-state` (LUKS2 with ext4 inside: users, zone volumes, -updates). Before its first write it asks you to type `ERASE` (`--yes` +updates). Its plan names the disk's model and what each of its partitions +holds (`holds gpt: sda1 ntfs "Windows" 237G; ...`): read it, as nothing +else tells a backup disk from the one you mean. Before its first write it +asks you to type `ERASE` (`--yes` skips that), then twice for the state partition's passphrase, which the system then asks for at every boot. There is no escrow: without the passphrase, or without the partition's header, the state is lost. It ends diff --git a/tools/image/medium-shell-test.sh b/tools/image/medium-shell-test.sh index 5ae98ff5..14fb63f8 100755 --- a/tools/image/medium-shell-test.sh +++ b/tools/image/medium-shell-test.sh @@ -49,7 +49,7 @@ for ((i = 0; i < ${#MEDIA[@]}; i += 2)); do # As above, each answer is made by the shell or said by the installer. typed=( 'send:echo mem-$(stat -f -c %T /root)-$(stat -f -c %T /var)-$(stat -f -c %T /tmp)' "expect:mem-tmpfs-tmpfs-tmpfs" - "send:kryptik-install --target /dev/vda --dry-run" "expect:layout +esp [0-9]+ MiB, kryptik-a [0-9]+ MiB" + "send:kryptik-install --target /dev/vda --dry-run" "expect:holds +no partition" "expect:layout +esp [0-9]+ MiB, kryptik-a [0-9]+ MiB" "expect:dry run: every check passed; nothing was written" "send:kryptik-install --target /dev/vda" "expect:Type ERASE to continue: ?" "send:no" "expect:not confirmed; nothing was written" "send:kryptik-install --target /dev/vda" "expect:Type ERASE to continue: ?" "send:ERASE" @@ -65,7 +65,7 @@ for ((i = 0; i < ${#MEDIA[@]}; i += 2)); do "send:printf x | cryptsetup -q luksFormat --type luks2 --pbkdf pbkdf2 --pbkdf-force-iterations 1000 --key-file=- /dev/vda1 && printf x | cryptsetup open --key-file=- /dev/vda1 held; kryptik-install --target /dev/vda --dry-run; cryptsetup close held" "expect:is in use: held open by" "send:kryptik keyboard" 'expect:\* us\r?\n +uk' - "send:kryptik-install --target /dev/vda" "expect:Type ERASE to continue: " "send:ERASE" + "send:kryptik-install --target /dev/vda" "expect:holds +gpt: vda1 crypto_LUKS 64M" "expect:Type ERASE to continue: " "send:ERASE" "expect:asked at every boot: " "send:${KRYPTIK_STATE_PASSPHRASE}" "expect:again: " "send:${KRYPTIK_STATE_PASSPHRASE}" "expect:installed [^ ]+ to /dev/vda: boot it from firmware with the medium removed" 'send:echo ,,L | sfdisk -q -X gpt /dev/vdb; sleep 1; mkfs.ext4 -q -F /dev/vdb1; echo second-$(test -b /dev/vdb1 && echo disk)' "expect:second-disk" diff --git a/tools/install/kryptik-install.sh b/tools/install/kryptik-install.sh index f8f3f230..c44b84a7 100755 --- a/tools/install/kryptik-install.sh +++ b/tools/install/kryptik-install.sh @@ -241,8 +241,25 @@ have_mib=$(( size_bytes / MIB )) [ "$have_mib" -ge "$need_mib" ] || die "${TARGET} is ${have_mib} MiB; this layout needs at least ${need_mib} MiB (ESP ${esp_mib} + two root slots of ${slot_mib} + state ${state_min_mib})." +# The disk by its model and what it holds, read before ERASE is typed: on a medium nothing is +# mounted, so a backup disk passes every refusal above. Each partition is probed afresh, and +# what a device or a filesystem calls itself reaches the terminal as printable bytes only. +shown() { printf '%s' "$1" | LC_ALL=C tr -c '[:print:]' '?'; } +model="$(shown "$(lsblk -dno MODEL "$TARGET_REAL" 2>/dev/null | sed 's/ *$//')")" +holds="" +for p in "/sys/class/block/$tname/$tname"*; do + [ -e "$p/partition" ] || continue + pn="${p##*/}" + fs="$(shown "$(blkid -p -o value -s TYPE "/dev/$pn" 2>/dev/null)")" + lb="$(shown "$(blkid -p -o value -s LABEL "/dev/$pn" 2>/dev/null)")" + sz="$(lsblk -dno SIZE "/dev/$pn" 2>/dev/null || true)" + holds="${holds:+$holds; }${pn} ${fs:-unformatted}${lb:+ \"$lb\"} ${sz}" +done +pt="$(blkid -p -o value -s PTTYPE "$TARGET_REAL" 2>/dev/null || true)" + say "medium ${media} (version ${VERSION})" -say "target ${TARGET} -> ${TARGET_REAL}, ${have_mib} MiB" +say "target ${TARGET} -> ${TARGET_REAL}, ${have_mib} MiB${model:+, ${model}}" +say "holds ${pt:+${pt}: }${holds:-no partition}" say "running root ${root_src} on ${root_disks:-unknown}" say "layout esp ${esp_mib} MiB, kryptik-a ${slot_mib} MiB, kryptik-b ${slot_mib} MiB, kryptik-state $(( have_mib - need_mib + state_min_mib )) MiB" say "root image ${ROOT_BYTES} bytes, sha256 ${ROOT_SHA}" From 233323bb49148df64d9c8c2ddb8a7194bd4a276c Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 23:48:45 -0700 Subject: [PATCH 138/213] The installer refuses a state passphrase shorter than 8 characters The state passphrase is the only lock on the state partition, a lost or stolen disk is guessed at offline, and the installer took any non-empty one, a single character included. It now refuses one under 8 characters before anything is written, saying why, as NIST's floor for a chosen secret; the guide says so, and that a long phrase is better. The medium's console suite types a 5-character passphrase twice after ERASE and wants the refusal; the disk-untouched check that follows covers it as well as the mismatch before it. The suites' own passphrases are 8 characters or longer. --- docs/user-guide.md | 4 +++- tools/image/medium-shell-test.sh | 2 ++ tools/install/kryptik-install.sh | 2 ++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/user-guide.md b/docs/user-guide.md index 31639a08..7eaa4933 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -163,7 +163,9 @@ and hashed against the medium's record), `kryptik-b` (empty until the first update), and `kryptik-state` (LUKS2 with ext4 inside: users, zone volumes, updates). Before its first write it asks you to type `ERASE` (`--yes` skips that), then twice for the state partition's passphrase, which the -system then asks for at every boot. There is no escrow: without the +system then asks for at every boot: at least 8 characters, and a long phrase +is better, as a stolen disk can be guessed at offline (`kryptik state +passphrase` changes it later). There is no escrow: without the passphrase, or without the partition's header, the state is lost. It ends with `kryptik-install: installed VERSION to /dev/sdY: boot it from firmware with the medium removed.` and a reminder to keep a copy of that header. Make diff --git a/tools/image/medium-shell-test.sh b/tools/image/medium-shell-test.sh index 5ae98ff5..3b2d4252 100755 --- a/tools/image/medium-shell-test.sh +++ b/tools/image/medium-shell-test.sh @@ -54,6 +54,8 @@ for ((i = 0; i < ${#MEDIA[@]}; i += 2)); do "send:kryptik-install --target /dev/vda" "expect:Type ERASE to continue: ?" "send:no" "expect:not confirmed; nothing was written" "send:kryptik-install --target /dev/vda" "expect:Type ERASE to continue: ?" "send:ERASE" "expect:asked at every boot: ?" "send:one" "expect:again: ?" "send:two" "expect:the two passphrases differ; nothing was written" + "send:kryptik-install --target /dev/vda" "expect:Type ERASE to continue: ?" "send:ERASE" + "expect:asked at every boot: ?" "send:short" "expect:again: ?" "send:short" "expect:is 5 characters; it needs at least 8, as a stolen disk can be guessed at offline; nothing was written" 'send:echo written-$(head -c 4194304 /dev/vda | tr -d "\000" | wc -c)-table-$(sfdisk -d /dev/vda >/dev/null 2>&1 && echo yes || echo no)' "expect:written-0-table-no" 'send:echo size=64M | sfdisk -q -X gpt /dev/vda; sleep 1; echo part-$(test -b /dev/vda1 && echo made)' "expect:part-made" diff --git a/tools/install/kryptik-install.sh b/tools/install/kryptik-install.sh index f8f3f230..321dbf7b 100755 --- a/tools/install/kryptik-install.sh +++ b/tools/install/kryptik-install.sh @@ -281,6 +281,8 @@ else IFS= read -r STATE_PASS || STATE_PASS="" fi [ -n "$STATE_PASS" ] || die "no state passphrase given; nothing was written" +# A stolen disk is guessed at offline, slowed only by argon2id: a short passphrase is no lock. +[ "${#STATE_PASS}" -ge 8 ] || die "the state passphrase is ${#STATE_PASS} characters; it needs at least 8, as a stolen disk can be guessed at offline; nothing was written" # Named in the firmware once nothing is left to refuse, and before anything # is written to the disk: an install refused above has changed nothing. kb_store "$LAYOUT" || die "the firmware did not take the keyboard layout ${LAYOUT}; nothing was written" From be91e2105a26aa81da570925429054e10e9d87dc Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 23:51:52 -0700 Subject: [PATCH 139/213] ADR-012 says when an AMD processor keeps its firmware's microcode An AMD Zen processor whose firmware predates AMD's 2025 fix for microcode signatures takes only a patch whose SHA-256 the kernel holds; 6.18's amd_shas.c has none of the pinned Zen 5 patches and lacks five EPYC ones. The decision record now says so beside the microcode it ships, as the guide and the README do, and that the login prompt names such a machine. --- docs/decisions.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/decisions.md b/docs/decisions.md index 7e12affd..d3bafa3a 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -198,7 +198,11 @@ shipped. filesystem and there is no initramfs, so Intel and AMD microcode is built into each signed kernel (`CONFIG_EXTRA_FIRMWARE`, stage 05), about 18 MB. Without it a machine runs whatever microcode its firmware last shipped, which on older -machines means known, unfixed CPU vulnerabilities. +machines means known, unfixed CPU vulnerabilities. An AMD Zen processor whose +firmware predates AMD's 2025 fix for microcode signatures takes only a patch +whose SHA-256 the kernel holds, and 6.18 holds none for Zen 5 and not five +EPYC ones: such a machine keeps its firmware's microcode until the firmware is +updated, and the login prompt says so. **Cost:** about 81 MB after zstd (amdgpu is 38 MB of it, Intel Wi-Fi 15 MB and Intel graphics 6 MB; for Intel, Realtek and Atheros the list names the files From 6e91861c930d3e9181be7f362b779ecd95342057 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 23:52:10 -0700 Subject: [PATCH 140/213] ADR-012's microcode line names no login notice, which another change adds --- docs/decisions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/decisions.md b/docs/decisions.md index d3bafa3a..0303f873 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -202,7 +202,7 @@ machines means known, unfixed CPU vulnerabilities. An AMD Zen processor whose firmware predates AMD's 2025 fix for microcode signatures takes only a patch whose SHA-256 the kernel holds, and 6.18 holds none for Zen 5 and not five EPYC ones: such a machine keeps its firmware's microcode until the firmware is -updated, and the login prompt says so. +updated. **Cost:** about 81 MB after zstd (amdgpu is 38 MB of it, Intel Wi-Fi 15 MB and Intel graphics 6 MB; for Intel, Realtek and Atheros the list names the files From 3d8b6b916e6669ebc37147b4c63ed883701ced5d Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 23:52:35 -0700 Subject: [PATCH 141/213] The passphrase floor counts characters, not the C locale's bytes The medium's shell runs in the C locale, where ${#} counts bytes, so a three-character CJK phrase passed as nine. The length is taken under C.UTF-8, which glibc builds in; the phrase stays in the shell. --- tools/install/kryptik-install.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tools/install/kryptik-install.sh b/tools/install/kryptik-install.sh index 321dbf7b..275220b1 100755 --- a/tools/install/kryptik-install.sh +++ b/tools/install/kryptik-install.sh @@ -282,7 +282,9 @@ else fi [ -n "$STATE_PASS" ] || die "no state passphrase given; nothing was written" # A stolen disk is guessed at offline, slowed only by argon2id: a short passphrase is no lock. -[ "${#STATE_PASS}" -ge 8 ] || die "the state passphrase is ${#STATE_PASS} characters; it needs at least 8, as a stolen disk can be guessed at offline; nothing was written" +# Counted in characters, as the medium's shell runs in the C locale, where ${#} counts bytes. +plen="$(LC_ALL=C.UTF-8; echo "${#STATE_PASS}")" +[ "$plen" -ge 8 ] || die "the state passphrase is ${plen} characters; it needs at least 8, as a stolen disk can be guessed at offline; nothing was written" # Named in the firmware once nothing is left to refuse, and before anything # is written to the disk: an install refused above has changed nothing. kb_store "$LAYOUT" || die "the firmware did not take the keyboard layout ${LAYOUT}; nothing was written" From 3cb63ea82fafc84813ae2f9276d375e61c575260 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 23:54:21 -0700 Subject: [PATCH 142/213] ADR-012's microcode sentence reads plainly --- docs/decisions.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/decisions.md b/docs/decisions.md index 0303f873..e38d2535 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -200,9 +200,9 @@ each signed kernel (`CONFIG_EXTRA_FIRMWARE`, stage 05), about 18 MB. Without it a machine runs whatever microcode its firmware last shipped, which on older machines means known, unfixed CPU vulnerabilities. An AMD Zen processor whose firmware predates AMD's 2025 fix for microcode signatures takes only a patch -whose SHA-256 the kernel holds, and 6.18 holds none for Zen 5 and not five -EPYC ones: such a machine keeps its firmware's microcode until the firmware is -updated. +whose SHA-256 the kernel holds, and 6.18 holds none for Zen 5, nor for the +current patches of five EPYC models: such a machine keeps its firmware's +microcode until the firmware is updated. **Cost:** about 81 MB after zstd (amdgpu is 38 MB of it, Intel Wi-Fi 15 MB and Intel graphics 6 MB; for Intel, Realtek and Atheros the list names the files From b03a1b63d5ff037784d9022307c4a802c4b92af5 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 23:55:59 -0700 Subject: [PATCH 143/213] The desktop suite's scan waits for the net zone the open network's forget restarted Distro 38211405809 failed chrome-wifi-scan with the daemon's own answer, "the net zone is not running": the check before it ends with a forget, which restarts the net zone, and the scan asked while it was down. It waits for the zone's ready Network line first, as menu-network-line does. --- build/guest-tests/gui-check.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build/guest-tests/gui-check.sh b/build/guest-tests/gui-check.sh index 06f2f1eb..d1c6772d 100755 --- a/build/guest-tests/gui-check.sh +++ b/build/guest-tests/gui-check.sh @@ -719,6 +719,8 @@ pkill -n -u "$USER_NAME" -f 'havoc /usr/bin/kryptik-chrome --menu' 2>/dev/null "$KD" wifi forget ztmenuopen --wifi-dir /var/lib/kryptik/wifi > /dev/null 2>&1 # The menu's s, as the user's launcher asks it: the daemon's scan job signals the net zone, # which has no radio here, and passes on its answer. +# The forget above restarted the net zone: it is asked once it is ready again. +wait_for 60 net_ready scan_said="$(as_user "kryptik-launch --wifi-scan" 2>&1)" if [[ "$scan_said" == "the net zone has no Wi-Fi radio" ]]; then pass "chrome-wifi-scan" "kryptik-launch --wifi-scan: $scan_said" From 51c6bb701385f0b8798745a0c527439f914b0f5a Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 23:56:21 -0700 Subject: [PATCH 144/213] The installer refuses a disk with 4096-byte logical sectors before writing to it The ESP is the medium's FAT, copied whole, and its sectors are 512 bytes. On a disk with 4096-byte logical sectors (4Kn, as an NVMe drive formatted with a 4K LBA format presents itself) a FAT's sector may not be smaller than the disk's: Linux refuses to mount it and firmware does not read it. The installer found that out only after partitioning and writing the root image, at "could not mount the new ESP", leaving the disk half-written. It now compares the FAT's own sector size, read from its boot sector, with blockdev --getss before anything is written, and says what to do. run-ovmf.sh's --disk-4k gives the first disk 4096-byte sectors, and the install suite's step 4 adds the sector4k refusal case: the installer must fail for that reason and leave no installation. The guide names the refusal. --- docs/user-guide.md | 4 +++- tools/image/install-test.sh | 5 ++++- tools/image/run-ovmf.sh | 8 +++++++- tools/install/kryptik-install.sh | 7 ++++++- 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/docs/user-guide.md b/docs/user-guide.md index 31639a08..790cb445 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -144,7 +144,9 @@ uses it too. `kryptik keyboard NAME`, as root, changes it later The installer refuses the disk the medium is on; anything with a mounted partition, active swap, or a partition held open (an unlocked LUKS volume, -LVM); anything that is not a whole, writable disk; and a disk too small to +LVM); anything that is not a whole, writable disk; a disk with 4096-byte +logical sectors (an NVMe drive can be formatted so), where neither the +firmware nor Linux reads the medium's boot partition; and a disk too small to hold the boot partition, two root slots with room to grow, and a state partition with space for one update and a gigabyte of data (about 8 GB for the current image; the refusal names the exact minimum). A disk that already diff --git a/tools/image/install-test.sh b/tools/image/install-test.sh index 82714cc3..8f53c410 100755 --- a/tools/image/install-test.sh +++ b/tools/image/install-test.sh @@ -8,7 +8,8 @@ # step 2 boot the disk alone: first boot, login, reboot, login, poweroff # step 3 cold boot it again (not with --quick), with step 1's signed control # disk attached: an installed system ignores it -# step 4 a disk too small, a read-only disk, and an I/O error in the root +# step 4 a disk too small, a read-only disk, one with 4096-byte logical +# sectors, and an I/O error in the root # image copy (not with --quick): each must fail, installing nothing; # the medium's own disk, even with --replace-kryptik, and a copy of # the installed disk without it: each refused, the copy untouched; @@ -197,6 +198,8 @@ refusal_case() { # refusal_case NAME DISK-SIZE WHY EXTRA-RUN-ARGS... ; expects } refusal_case toosmall 1G 'this layout needs at least' refusal_case readonly "$SIZE" 'is read-only' --disk-readonly +# A disk with 4096-byte logical sectors, which the medium's 512-byte FAT cannot boot from. +refusal_case sector4k "$SIZE" '4096-byte logical sectors' --disk-4k # An I/O error in the root image copy, after the partition table is written; # the runner must report that failure. A disk of step 1's size is laid out as # step 1's was, so the error sits 4 MiB into where kryptik-a will be: past diff --git a/tools/image/run-ovmf.sh b/tools/image/run-ovmf.sh index 8c772410..8ec80c99 100755 --- a/tools/image/run-ovmf.sh +++ b/tools/image/run-ovmf.sh @@ -16,6 +16,8 @@ # --disk-readonly, --blkdebug CONF # failure injection for the first --disk: read-only, or I/O # errors through QEMU's blkdebug driver +# --disk-4k the first --disk with 4096-byte logical sectors (4Kn), as an +# NVMe drive formatted so presents itself # --testctl FILE a disk labelled kryptik-testctl (tools/image/mk-testctl.sh) # --vars X variable store template, copied fresh for this run: # clean OVMF_VARS_4M.fd, no keys, Secure Boot off @@ -42,7 +44,7 @@ source "${SELF}/../../build/lib/common.sh" USB=""; ISO=""; NOMEDIA=0; DISKS=(); TESTCTL=""; VARS="clean"; VARS_FILE="" MODE="smoke"; TIMEOUT=300; LOG=""; NET="none"; MEM=2048; CPUS=2; GPU=0; HEAD2=0; ALLOW_REBOOT=0; NAME="vm" -DISK_RO=0; BLKDEBUG=""; UNTIL="" +DISK_RO=0; DISK_4K=0; BLKDEBUG=""; UNTIL="" while [[ "$#" -gt 0 ]]; do case "$1" in --usb) USB="${2:?}"; shift 2 ;; @@ -50,6 +52,7 @@ while [[ "$#" -gt 0 ]]; do --no-media) NOMEDIA=1; shift ;; --disk) DISKS+=("${2:?}"); shift 2 ;; --disk-readonly) DISK_RO=1; shift ;; + --disk-4k) DISK_4K=1; shift ;; --blkdebug) BLKDEBUG="${2:?}"; shift 2 ;; --testctl) TESTCTL="${2:?}"; shift 2 ;; --vars) VARS="${2:?}"; shift 2 ;; @@ -171,6 +174,9 @@ for d in "${DISKS[@]}"; do ARGS+=( -drive "file=blkdebug:${BLKDEBUG}:${d},format=raw,if=virtio,cache=writeback,id=vd${i}" ) elif [[ "$i" -eq 0 && "$DISK_RO" -eq 1 ]]; then ARGS+=( -drive "file=${d},format=raw,if=virtio,readonly=on,id=vd${i}" ) + elif [[ "$i" -eq 0 && "$DISK_4K" -eq 1 ]]; then + ARGS+=( -drive "file=${d},format=raw,if=none,cache=writeback,id=vd${i}" + -device "virtio-blk-pci,drive=vd${i},logical_block_size=4096,physical_block_size=4096" ) else ARGS+=( -drive "file=${d},format=raw,if=virtio,cache=writeback,id=vd${i}" ) fi diff --git a/tools/install/kryptik-install.sh b/tools/install/kryptik-install.sh index f8f3f230..18a98b7d 100755 --- a/tools/install/kryptik-install.sh +++ b/tools/install/kryptik-install.sh @@ -69,7 +69,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 \ +for tool in sfdisk partx blockdev blkid cryptsetup veritysetup stty mkfs.ext4 dd sha256sum mount umount sync awk sed od \ 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}" @@ -219,6 +219,11 @@ ESP_BYTES="$(stat -c %s "$ESP_SRC" 2>/dev/null || blockdev --getsize64 "$ESP_SRC size_bytes="$(blockdev --getsize64 "$TARGET_REAL" 2>/dev/null || echo 0)" [ "$size_bytes" -gt 0 ] || die "could not read the size of ${TARGET}" +# The ESP is the medium's FAT, copied whole, and a FAT's sectors may not be smaller than the +# disk's: on a disk with 4096-byte logical sectors (4Kn) neither Linux nor the firmware reads it. +esp_ss="$(od -An -tu2 -j11 -N2 "$ESP_SRC" 2>/dev/null | tr -d ' ')" +disk_ss="$(blockdev --getss "$TARGET_REAL" 2>/dev/null || echo 512)" +[ "$disk_ss" -le "${esp_ss:-512}" ] || die "${TARGET} has ${disk_ss}-byte logical sectors, and the boot partition this medium copies is a FAT of ${esp_ss:-512}-byte sectors, which neither the firmware nor Linux reads there; give the disk a 512-byte sector format (an NVMe drive's LBA format), or pick another disk; nothing was written" MIB=1048576 esp_mib=$(( (ESP_BYTES + MIB - 1) / MIB )) # A slot must also hold later, larger images: the image plus half again (at From bfd562133df6c8e771053317f4e10f89338ef448 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 11 Oct 2026 23:56:21 -0700 Subject: [PATCH 145/213] The desktop suite asks for the scan until the restarted net zone answers Waiting for the ready Network line could still meet the zone the forget is stopping, since s6-svc -r returns at once: the check asks again each second, for a minute, until the answer is the zone's own. --- build/guest-tests/gui-check.sh | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/build/guest-tests/gui-check.sh b/build/guest-tests/gui-check.sh index d1c6772d..250a1d31 100755 --- a/build/guest-tests/gui-check.sh +++ b/build/guest-tests/gui-check.sh @@ -718,11 +718,10 @@ fi pkill -n -u "$USER_NAME" -f 'havoc /usr/bin/kryptik-chrome --menu' 2>/dev/null "$KD" wifi forget ztmenuopen --wifi-dir /var/lib/kryptik/wifi > /dev/null 2>&1 # The menu's s, as the user's launcher asks it: the daemon's scan job signals the net zone, -# which has no radio here, and passes on its answer. -# The forget above restarted the net zone: it is asked once it is ready again. -wait_for 60 net_ready -scan_said="$(as_user "kryptik-launch --wifi-scan" 2>&1)" -if [[ "$scan_said" == "the net zone has no Wi-Fi radio" ]]; then +# which has no radio here, and passes on its answer. The forget above restarts the net zone, +# for which the daemon answers that the zone is not running, so it is asked until it answers. +scan_answered() { scan_said="$(as_user "kryptik-launch --wifi-scan" 2>&1)"; [[ "$scan_said" == "the net zone has no Wi-Fi radio" ]]; } +if wait_for 60 scan_answered; then pass "chrome-wifi-scan" "kryptik-launch --wifi-scan: $scan_said" else fail "chrome-wifi-scan" "kryptik-launch --wifi-scan: $(tr '\n' ' ' <<<"$scan_said" | cut -c1-200)" From fda077b7a4d0a809eb85b55fc3ff67e19c91e244 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 00:00:39 -0700 Subject: [PATCH 146/213] The guide says which firmware settings keep the stick or the disk out of sight Fast boot can skip USB, so the stick never shows in the boot menu; an older laptop's controller in RAID mode (Intel RST) remaps its NVMe disk where Linux cannot reach it, and ahci logs "Switch your BIOS from RAID to AHCI mode to use them" (drivers/ata/ahci.c). The kernel builds VMD, so a newer machine's RST needs nothing. --- docs/user-guide.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/user-guide.md b/docs/user-guide.md index 31639a08..eb9ba8a9 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -81,6 +81,13 @@ any offer to initialise or format the stick afterwards. **Optical.** Burn `kryptik-VERSION.iso` as an image. +**Firmware settings.** A firmware's fast boot can skip USB devices, so the +stick never shows in its boot menu: turn fast boot off for the install. Some +older laptops set their disk controller to RAID (Intel RST), which hides an +NVMe disk from Linux, and the kernel log then says `Switch your BIOS from RAID +to AHCI mode`: set it to AHCI in the firmware setup. A newer machine's RST +(Intel VMD) needs no change. + **Secure Boot.** The kernel is signed with the build's Secure Boot key: a release's, from 1.0.0 on, is the project's ([release keys](release-keys.md)), enrolled once for every release after it; a development build's is made by From 9a290a89723ec887664cf013f87013345d8071ab Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 00:02:45 -0700 Subject: [PATCH 147/213] The 4Kn refusal says what is certain: Linux does not mount that FAT, and firmware may not read it --- docs/user-guide.md | 4 ++-- tools/install/kryptik-install.sh | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/user-guide.md b/docs/user-guide.md index 790cb445..665b6c53 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -145,8 +145,8 @@ uses it too. `kryptik keyboard NAME`, as root, changes it later The installer refuses the disk the medium is on; anything with a mounted partition, active swap, or a partition held open (an unlocked LUKS volume, LVM); anything that is not a whole, writable disk; a disk with 4096-byte -logical sectors (an NVMe drive can be formatted so), where neither the -firmware nor Linux reads the medium's boot partition; and a disk too small to +logical sectors (an NVMe drive can be formatted so), where Linux does not +mount the medium's boot partition; and a disk too small to hold the boot partition, two root slots with room to grow, and a state partition with space for one update and a gigabyte of data (about 8 GB for the current image; the refusal names the exact minimum). A disk that already diff --git a/tools/install/kryptik-install.sh b/tools/install/kryptik-install.sh index 18a98b7d..483410d7 100755 --- a/tools/install/kryptik-install.sh +++ b/tools/install/kryptik-install.sh @@ -220,10 +220,11 @@ ESP_BYTES="$(stat -c %s "$ESP_SRC" 2>/dev/null || blockdev --getsize64 "$ESP_SRC size_bytes="$(blockdev --getsize64 "$TARGET_REAL" 2>/dev/null || echo 0)" [ "$size_bytes" -gt 0 ] || die "could not read the size of ${TARGET}" # The ESP is the medium's FAT, copied whole, and a FAT's sectors may not be smaller than the -# disk's: on a disk with 4096-byte logical sectors (4Kn) neither Linux nor the firmware reads it. +# disk's: on a disk with 4096-byte logical sectors (4Kn) Linux does not mount it, so neither the +# install nor any update could write it, and firmware may not read it either. esp_ss="$(od -An -tu2 -j11 -N2 "$ESP_SRC" 2>/dev/null | tr -d ' ')" disk_ss="$(blockdev --getss "$TARGET_REAL" 2>/dev/null || echo 512)" -[ "$disk_ss" -le "${esp_ss:-512}" ] || die "${TARGET} has ${disk_ss}-byte logical sectors, and the boot partition this medium copies is a FAT of ${esp_ss:-512}-byte sectors, which neither the firmware nor Linux reads there; give the disk a 512-byte sector format (an NVMe drive's LBA format), or pick another disk; nothing was written" +[ "$disk_ss" -le "${esp_ss:-512}" ] || die "${TARGET} has ${disk_ss}-byte logical sectors, and the boot partition this medium copies is a FAT of ${esp_ss:-512}-byte sectors, which Linux does not mount there (and firmware may not read); give the disk a 512-byte sector format (an NVMe drive's LBA format), or pick another disk; nothing was written" MIB=1048576 esp_mib=$(( (ESP_BYTES + MIB - 1) / MIB )) # A slot must also hold later, larger images: the image plus half again (at From 814bf7fda49391f45fae48f300049f06e2647ec3 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 00:04:21 -0700 Subject: [PATCH 148/213] The guide's firmware paragraph says where to read the RAID message, and that a Windows on another disk needs AHCI first --- docs/user-guide.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/user-guide.md b/docs/user-guide.md index eb9ba8a9..17bff4d9 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -83,10 +83,11 @@ any offer to initialise or format the stick afterwards. **Firmware settings.** A firmware's fast boot can skip USB devices, so the stick never shows in its boot menu: turn fast boot off for the install. Some -older laptops set their disk controller to RAID (Intel RST), which hides an -NVMe disk from Linux, and the kernel log then says `Switch your BIOS from RAID -to AHCI mode`: set it to AHCI in the firmware setup. A newer machine's RST -(Intel VMD) needs no change. +older machines set their disk controller to RAID (Intel RST), which hides an +NVMe disk from Linux, and `dmesg` at the medium's shell then says `Switch your +BIOS from RAID to AHCI mode`: set it to AHCI in the firmware setup. A Windows +kept on another disk must be made ready for AHCI first, or it stops booting. A +newer machine's RST (Intel VMD) needs no change. **Secure Boot.** The kernel is signed with the build's Secure Boot key: a release's, from 1.0.0 on, is the project's ([release keys](release-keys.md)), From 1db07dd784b6d45bb876094de825fe53f80776f3 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 00:07:13 -0700 Subject: [PATCH 149/213] The README says the RTL8188CU and RTL8192CU sticks are not driven rtl8xxxu lists their ids only under RTL8XXXU_UNTESTED, which this kernel leaves unset, and they are among the commonest cheap USB sticks. --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6a7421ae..19b5abaa 100644 --- a/README.md +++ b/README.md @@ -111,8 +111,9 @@ the firmware from the pinned linux-firmware release USB adapters (AX88179, RTL8152); virtio, VMware vmxnet3, Hyper-V. - **Wi-Fi:** Intel from the 7260 on; Qualcomm QCA6174, QCA9377, QCA6390, WCN6855, QCA2066, QCA6698AQ, WCN7850; MediaTek MT7920, MT7921, MT7922, - MT7925; Realtek rtw88, rtw89 and rtl8xxxu; Broadcom over PCIe. The net zone - owns the radio ([net zone design](docs/design/net-zone.md)). No Bluetooth. + MT7925; Realtek rtw88, rtw89 and rtl8xxxu (not its RTL8188CU and RTL8192CU + sticks); Broadcom over PCIe. The net zone owns the radio + ([net zone design](docs/design/net-zone.md)). No Bluetooth. - **Display:** the firmware framebuffer (simpledrm) everywhere; Intel (i915, xe) and AMD (amdgpu) with their firmware; ASPEED and Matrox server BMCs; virtio-gpu, VMware SVGA, Hyper-V. NVIDIA machines use the firmware From 25e9630b144927d421b1df3a384826a762bbbcff Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 00:11:54 -0700 Subject: [PATCH 150/213] The install medium's shell opens under a few lines saying what it is for Booted, the medium showed a bare root prompt on the screen and on the serial console, and nothing there named the installer or the recovery tool. medium-banner.sh prints the version, that this is the install medium's root shell, and the first command of each thing the guide does there (lsblk, kryptik-install --target /dev/sdY --dry-run, kryptik-recover --disk /dev/sdY --status), on tty1 from getty-tty1's run and on the serial console from kryptik-console, on media only. The medium's console suite now wants those lines in each medium's serial transcript; the old image prints nothing of the kind. --- build/recipes/console.sh | 1 + build/service-scripts/medium-banner.sh | 13 +++++++++++++ build/services/getty-tty1/run | 1 + docs/user-guide.md | 4 +++- tools/image/medium-shell-test.sh | 5 +++++ 5 files changed, 23 insertions(+), 1 deletion(-) create mode 100755 build/service-scripts/medium-banner.sh diff --git a/build/recipes/console.sh b/build/recipes/console.sh index 53246ee1..94bf4740 100644 --- a/build/recipes/console.sh +++ b/build/recipes/console.sh @@ -34,6 +34,7 @@ esac if [ -x /usr/sbin/agetty ]; then # Install media (kryptik.media= on the signed command line) get a root shell, others a login. if grep -qE '(^| )kryptik\.media=[a-z]+( |$)' /proc/cmdline 2>/dev/null; then + /usr/libexec/kryptik/medium-banner.sh > "/dev/$dev" 2>/dev/null || true exec /usr/sbin/agetty -n -l /usr/bin/bash --keep-baud \ 115200,57600,38400,9600 "$dev" vt220 fi diff --git a/build/service-scripts/medium-banner.sh b/build/service-scripts/medium-banner.sh new file mode 100755 index 00000000..4c47915c --- /dev/null +++ b/build/service-scripts/medium-banner.sh @@ -0,0 +1,13 @@ +#!/bin/sh +# What the install medium's root shell is for, above its first prompt on the screen and on a +# serial console: the guide's install and recovery, by their first commands. +v="$(sed -n 's/^VERSION_ID=//p' /etc/os-release 2>/dev/null | tr -d '"' | head -1)" +cat </dev/null; then + /usr/libexec/kryptik/medium-banner.sh > /dev/tty1 2>/dev/null || true exec /usr/sbin/agetty -n -l /usr/bin/bash --noclear tty1 linux fi # A login; the user's profile starts the desktop session. diff --git a/docs/user-guide.md b/docs/user-guide.md index 31639a08..6faf8882 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -121,7 +121,9 @@ tools/image/run-ovmf.sh --no-media --disk disk.img --vars-file vars.fd --allow-r the desktop is not seen this way. The medium presents a root shell with no password, on the display and, if -the machine has one, on the serial console (the serial line under QEMU). +the machine has one, on the serial console (the serial line under QEMU), +under a few lines naming the commands that list the disks, show the +install's plan and read an installed disk. That shell exists only on install media; installed systems have root locked at every terminal. The medium uses no network: a cable plugged in, or a radio, is left down. diff --git a/tools/image/medium-shell-test.sh b/tools/image/medium-shell-test.sh index 5ae98ff5..bd20236f 100755 --- a/tools/image/medium-shell-test.sh +++ b/tools/image/medium-shell-test.sh @@ -99,6 +99,11 @@ for ((i = 0; i < ${#MEDIA[@]}; i += 2)); do [[ "$drc" -eq 0 ]] && green "${kind}: commands typed at the console ran as root, the installer is on its path, and poweroff took" \ || red "${kind}: the console did not take commands (see above)" if txt | grep -q 'kryptik login:'; then red "${kind}: the medium's console asked for a login"; else green "${kind}: no login prompt on the medium"; fi + if txt | grep -q 'install medium: a root shell' && txt | grep -q 'kryptik-install --target /dev/sdY --dry-run'; then + green "${kind}: the console says what the medium's shell is for, and the installer's first command" + else + red "${kind}: nothing on the console says what the medium's shell is for" + fi t="$(txt)" if grep -q 'net-zone: install medium; no network' <<<"$t" && grep -qE 'nic-[a-z0-9]+-down-in-zone-0' <<<"$t" \ && grep -qE 'netzones-0([^0-9]|$)' <<<"$t"; then From 86ccb59018e9d73645b5d745b23d953652fa4ca5 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 00:15:15 -0700 Subject: [PATCH 151/213] The banner names INSTRUCTIONS.md, which a release ships beside its image, and keeps one space without a version --- build/service-scripts/medium-banner.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build/service-scripts/medium-banner.sh b/build/service-scripts/medium-banner.sh index 4c47915c..521f009d 100755 --- a/build/service-scripts/medium-banner.sh +++ b/build/service-scripts/medium-banner.sh @@ -4,10 +4,10 @@ v="$(sed -n 's/^VERSION_ID=//p' /etc/os-release 2>/dev/null | tr -d '"' | head -1)" cat < Date: Mon, 12 Oct 2026 00:15:56 -0700 Subject: [PATCH 152/213] The design's installer paragraph names the 4Kn refusal --- docs/design/boot-and-updates.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/design/boot-and-updates.md b/docs/design/boot-and-updates.md index dc4ea3dc..1580d126 100644 --- a/docs/design/boot-and-updates.md +++ b/docs/design/boot-and-updates.md @@ -88,8 +88,9 @@ from `/etc`. There is no swap. `kryptik-install --target DISK [--yes] [--dry-run] [--replace-kryptik]` checks everything first: a whole, writable disk that is not the running root's (through any dm or loop layer, so from a medium never the medium), -with nothing mounted, swapped on or held open by device-mapper or md, and -enough room. A disk that carries any `kryptik-` partition (an old +with nothing mounted, swapped on or held open by device-mapper or md, +enough room, and logical sectors no larger than the medium's FAT's (512 +bytes: Linux does not mount that ESP on a 4Kn disk). A disk that carries any `kryptik-` partition (an old installation, a medium, a test-control disk) is refused unless `--replace-kryptik` asks for it; nothing overrides the other refusals. Then it asks for the state passphrase, partitions, From cd104c60aeab983d0b0ae86243921a284a4a5da3 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 00:15:59 -0700 Subject: [PATCH 153/213] The design's installer paragraph names the passphrase floor --- docs/design/boot-and-updates.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/design/boot-and-updates.md b/docs/design/boot-and-updates.md index dc4ea3dc..dce28c68 100644 --- a/docs/design/boot-and-updates.md +++ b/docs/design/boot-and-updates.md @@ -92,7 +92,7 @@ with nothing mounted, swapped on or held open by device-mapper or md, and enough room. A disk that carries any `kryptik-` partition (an old installation, a medium, a test-control disk) is refused unless `--replace-kryptik` asks for it; nothing overrides the other refusals. Then it -asks for the state passphrase, partitions, +asks for the state passphrase (8 characters at least), partitions, 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. From f489a32a344d977222bd87cc97a6f82b161916fb Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 00:18:00 -0700 Subject: [PATCH 154/213] kryptik-recover --status names the firmware's Kryptik entries, BootOrder's hold on them and BootNext A disk that does not boot may be one its firmware has no entry for: some firmware boots a fixed disk only from a Boot#### entry, and --commit-slot is what writes one. --status now reads kryptik-efiboot list and says each "Kryptik slot" entry and whether BootOrder holds it, a BootNext if one is set, or that there is none and --commit-slot writes one. Entries are kept by slot, not disk, so it says one may be another Kryptik's. The medium's console suite wants a firmware line in --status after its install; the guide's recovery section says what the line is for. --- docs/user-guide.md | 4 ++++ tools/image/medium-shell-test.sh | 2 +- tools/update/kryptik-recover | 16 ++++++++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/user-guide.md b/docs/user-guide.md index 604f4ba1..497649c9 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -354,6 +354,10 @@ kryptik-recover --disk /dev/sdY --commit-slot a # the other slot is intact: kryptik-recover --disk /dev/sdY --restore-slot a # the slot's root is damaged: rewrite it from this medium ``` +`--status` also names the firmware's Kryptik entries. A firmware that boots +a fixed disk only from such an entry, and has none, boots the disk again +after `--commit-slot`, which writes one for the slot it commits. + `--backup-state-header FILE` and `--restore-state-header FILE` save and put back the state partition's LUKS2 header. FILE is on a disk mounted for it, as in section 2: the medium keeps nothing of its own. Keep the backup somewhere diff --git a/tools/image/medium-shell-test.sh b/tools/image/medium-shell-test.sh index 20ccf8e6..6638a50f 100755 --- a/tools/image/medium-shell-test.sh +++ b/tools/image/medium-shell-test.sh @@ -72,7 +72,7 @@ for ((i = 0; i < ${#MEDIA[@]}; i += 2)); do "send:mount /dev/vdb1 /mnt" "send:kryptik-recover --disk /dev/vda --backup-state-header /mnt/kryptik-state-header" "expect:is in /mnt/kryptik-state-header" "send:umount /mnt" - "send:kryptik-recover --disk /dev/vda --status" "expect:committed slot a" "expect:slot a +version [0-9]" + "send:kryptik-recover --disk /dev/vda --status" "expect:committed slot a" "expect:slot a +version [0-9]" "expect:firmware +(Boot[0-9A-F]{4} for slot a|no Kryptik entry)" "expect:BOOTX64.EFI +slot a's kernel" "expect:this medium +version [0-9][^ ]*, which --restore-slot writes" "send:kryptik-recover --disk /dev/vda --commit-slot a" "expect:committed: BOOTX64.EFI is now slot a" "send:kryptik-recover --disk /dev/vda --restore-slot a" "expect:done: boot /dev/vda without the medium" diff --git a/tools/update/kryptik-recover b/tools/update/kryptik-recover index a19e4365..87b821bf 100755 --- a/tools/update/kryptik-recover +++ b/tools/update/kryptik-recover @@ -88,6 +88,22 @@ if [ "$STATUS" = 1 ]; then case "$committed" in a|b) [ "$boots" = "$committed" ] || echo " not the committed slot's kernel: --commit-slot $committed makes it so" ;; esac + # The firmware's entries for the slots, which --commit-slot writes: some firmware boots a + # fixed disk only from one. They are kept by slot, not disk, so one may be another Kryptik's. + if [ -d /sys/firmware/efi/efivars ] && fw="$(kryptik-efiboot list 2>/dev/null)"; then + order=" $(printf '%s\n' "$fw" | sed -n 's/^BootOrder: *//p') " + ents="$(printf '%s\n' "$fw" | sed -n 's/^ \(Boot[0-9A-F]\{4\}\): Kryptik slot \([ab]\) file=.EFI.kryptik.kryptik-[ab]\.efi .*/\1 \2/p')" + if [ -z "$ents" ]; then + echo "firmware no Kryptik entry: the firmware boots BOOTX64.EFI by itself, or --commit-slot writes one" + fi + printf '%s\n' "$ents" | while read -r var s; do + [ -n "$var" ] || continue + case "$order" in *" $var "*) where="in BootOrder" ;; *) where="not in BootOrder" ;; esac + echo "firmware $var for slot $s, $where" + done + next="$(printf '%s\n' "$fw" | sed -n 's/^BootNext: *//p')" + case "$next" in ""|"(none)") ;; *) echo " BootNext ${next}: the next boot alone goes there" ;; esac + fi echo "this medium version $(sed -n 's/^VERSION_ID=//p' /etc/os-release | tr -d '"' | head -1), which --restore-slot writes" exit 0 fi From 918701e256f4defa25e66af33bde5a5263ed6a31 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 00:23:41 -0700 Subject: [PATCH 155/213] --status takes an entry as a slot's only when its file is that slot's kernel --- tools/update/kryptik-recover | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/update/kryptik-recover b/tools/update/kryptik-recover index 87b821bf..6607553d 100755 --- a/tools/update/kryptik-recover +++ b/tools/update/kryptik-recover @@ -92,7 +92,7 @@ if [ "$STATUS" = 1 ]; then # fixed disk only from one. They are kept by slot, not disk, so one may be another Kryptik's. if [ -d /sys/firmware/efi/efivars ] && fw="$(kryptik-efiboot list 2>/dev/null)"; then order=" $(printf '%s\n' "$fw" | sed -n 's/^BootOrder: *//p') " - ents="$(printf '%s\n' "$fw" | sed -n 's/^ \(Boot[0-9A-F]\{4\}\): Kryptik slot \([ab]\) file=.EFI.kryptik.kryptik-[ab]\.efi .*/\1 \2/p')" + ents="$(printf '%s\n' "$fw" | sed -n 's/^ \(Boot[0-9A-F]\{4\}\): Kryptik slot \([ab]\) file=.EFI.kryptik.kryptik-\2\.efi .*/\1 \2/p')" if [ -z "$ents" ]; then echo "firmware no Kryptik entry: the firmware boots BOOTX64.EFI by itself, or --commit-slot writes one" fi From 489cf4dbadc914b33a1f76c1bf60a16ff0695ed8 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 00:26:08 -0700 Subject: [PATCH 156/213] The radio-off check switches phy0 by its rfkill index, as the radio's sysfs leaves zone 0 with it --- build/guest-tests/zones-check.sh | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index 706f4661..cb93c350 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -721,12 +721,21 @@ sta_wpa() { nsenter -t "${ni:-0}" -U -m -n /usr/bin/kryptikd supplicant -- \ wpa_cli -s /run/wpa_supplicant -p /run/wpa_supplicant -i "$(sta_if)" "$@" 2>&1 | tr '\n' ' ' } -STA_IF=""; AP_IF=""; AP_HOLD="" +STA_IF=""; AP_IF=""; AP_HOLD=""; STA_RFKILL="" # QEMU has no radio, so hwsim makes two: phy1 is the access point, phy0 goes to the net zone. if modprobe mac80211_hwsim radios=2 2> "$LOG/hwsim.err"; then for _ in $(seq 1 20); do [[ -e /sys/class/ieee80211/phy1 ]] && break; sleep 0.5; done STA_IF="$(wl_of_phy phy0)"; AP_IF="$(wl_of_phy phy1)" + # phy0's rfkill switch by index, read while zone 0 still sees it: its sysfs leaves with the radio. + for r in /sys/class/rfkill/rfkill*; do [[ "$(cat "$r/name" 2>/dev/null)" == phy0 ]] && STA_RFKILL="${r##*/rfkill}"; done fi +# Switch rfkill INDEX soft-off (1) or on (0) through its control device, which takes no name: +# struct rfkill_event { u32 idx; u8 type (0, any); u8 op (2, change one); u8 soft; u8 hard; }. +rfkill_soft() { + python3 -c 'import os, struct, sys +fd = os.open("/dev/rfkill", os.O_WRONLY) +os.write(fd, struct.pack("=IBBBB", int(sys.argv[1]), 0, 2, int(sys.argv[2]), 0))' "$1" "$2" +} if [[ -n "$STA_IF" && -n "$AP_IF" ]]; then pass "wifi-module" "the signed mac80211_hwsim loaded with two radios: $STA_IF on phy0, $AP_IF on phy1" else @@ -846,30 +855,28 @@ fi # the status and the scan say so. Switched on again, the net zone brings the link up, which its # supplicant cannot see for itself, and the radio joins again. Switched off when the net zone # starts, it gets no supplicant, which cannot bring the link up; switched on, it gets one. -sta_kill="" -for r in /sys/class/rfkill/rfkill*; do [[ "$(cat "$r/name" 2>/dev/null)" == phy0 ]] && sta_kill="$r"; done off_line=""; off_scan=""; back_line=""; start_off=""; start_back="" -if [[ -n "$sta_kill" ]]; then +if [[ -n "$STA_RFKILL" ]]; then before="$(ready_count)" - echo 1 > "$sta_kill/soft" + rfkill_soft "$STA_RFKILL" 1 off_line="$(ready_after "$before" " wifi=off " 60)" off_scan="$("$KD" wifi scan --zones "$Z" --wifi-dir "$WIFI_DIR" 2>&1)" before="$(ready_count)" - echo 0 > "$sta_kill/soft" + rfkill_soft "$STA_RFKILL" 0 back_line="$(ready_after "$before" " wifi=$AP_SSID " 90)" before="$(ready_count)" - echo 1 > "$sta_kill/soft" + rfkill_soft "$STA_RFKILL" 1 s6-svc -r /run/service/net-zone start_off="$(ready_after "$before" " wifi=off " 90)" before="$(ready_count)" - echo 0 > "$sta_kill/soft" + rfkill_soft "$STA_RFKILL" 0 start_back="$(ready_after "$before" " wifi=$AP_SSID " 90)" fi if [[ "$off_line" == *" wifi=off "* && "$off_scan" == *" is switched off"* && "$back_line" == *" wifi=$AP_SSID "* \ && "$start_off" == *" wifi=off "* && "$start_back" == *" wifi=$AP_SSID "* ]]; then pass "wifi-radio-off" "switched off: ${off_line#*netzone: }; scan: $(head -1 <<<"$off_scan"); switched on, joined again: ${back_line#*netzone: }; off as the net zone started, then on: ${start_back#*netzone: }" else - fail "wifi-radio-off" "rfkill: ${sta_kill:-none named phy0}; off: ${off_line:-none}; scan: $(tr '\n' ';' <<<"$off_scan" | cut -c1-120); back: ${back_line:-none}; off at the start: ${start_off:-none}; then on: ${start_back:-none}; $(netzone_said 'wifi' | tail -4 | tr '\n' ' ')" + fail "wifi-radio-off" "rfkill: ${STA_RFKILL:+rfkill}${STA_RFKILL:-none named phy0}; off: ${off_line:-none}; scan: $(tr '\n' ';' <<<"$off_scan" | cut -c1-120); back: ${back_line:-none}; off at the start: ${start_off:-none}; then on: ${start_back:-none}; $(netzone_said 'wifi' | tail -4 | tr '\n' ' ')" fi # A net zone can leave its radio with a second netdev or with none, and the # radio comes back to zone 0 so. Each round makes that change in the net From 1b10f742b1bc954feedc2249d71a91d39f131c71 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 00:26:32 -0700 Subject: [PATCH 157/213] A scan asked as the net zone restarts says the zone is not running, not what the signal hit --- compartments/kryptikd/src/wifi.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/compartments/kryptikd/src/wifi.rs b/compartments/kryptikd/src/wifi.rs index 32fc6919..f51bc23d 100644 --- a/compartments/kryptikd/src/wifi.rs +++ b/compartments/kryptikd/src/wifi.rs @@ -399,7 +399,13 @@ pub fn scan(zones_dir: &Path) -> Result { // A new answer is a new file: the zone renames each one into place. let stamp = |f: &fs::File| f.metadata().ok().map(|m| (m.ino(), m.mtime(), m.mtime_nsec())); let before = init.file_in_root(SCAN_FILE).as_ref().and_then(stamp); - init.signal(libc::SIGUSR1).map_err(|e| format!("the {name} zone could not be asked: {e}"))?; + if let Err(e) = init.signal(libc::SIGUSR1) { + // Ended since the registry said it runs, as a zone restarting after a save does. + return Err(match init.still_alive() { + true => format!("the {name} zone could not be asked: {e}"), + false => format!("the {name} zone is not running"), + }); + } let until = std::time::Instant::now() + SCAN_WAIT; while std::time::Instant::now() < until { std::thread::sleep(std::time::Duration::from_millis(250)); From ed1df0c9d6e6ad4abd7d07577919d4e6e20da455 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 00:27:35 -0700 Subject: [PATCH 158/213] The medium's banner says whether Secure Boot is on, which only a serial console heard before --- build/service-scripts/medium-banner.sh | 10 ++++++++-- docs/user-guide.md | 5 +++-- tools/image/medium-shell-test.sh | 4 ++-- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/build/service-scripts/medium-banner.sh b/build/service-scripts/medium-banner.sh index 521f009d..851f653a 100755 --- a/build/service-scripts/medium-banner.sh +++ b/build/service-scripts/medium-banner.sh @@ -1,10 +1,16 @@ #!/bin/sh # What the install medium's root shell is for, above its first prompt on the screen and on a -# serial console: the guide's install and recovery, by their first commands. +# serial console: the guide's install and recovery, by their first commands, and whether the +# firmware checked the kernel's signature, which boot-smoke tells only the serial console. v="$(sed -n 's/^VERSION_ID=//p' /etc/os-release 2>/dev/null | tr -d '"' | head -1)" +case "$(od -An -tu1 -j4 -N1 /sys/firmware/efi/efivars/SecureBoot-8be4df61-93ca-11d2-aa0d-00e098032b8c 2>/dev/null | tr -d ' ')" in + 1) sb="on: the firmware checked this kernel's signature" ;; + 0) sb="off" ;; + *) sb="unknown (no SecureBoot variable)" ;; +esac cat < Date: Mon, 12 Oct 2026 00:30:16 -0700 Subject: [PATCH 159/213] A zone volume's new passphrase needs at least 8 characters, from volume init and volume passwd alike --- compartments/kryptikd/src/volume.rs | 17 +++++++++++++++++ compartments/kryptikd/src/volume/tests.rs | 18 ++++++++++++++++++ docs/design/encrypted-volumes.md | 4 +++- tools/image/update-test.sh | 2 +- 4 files changed, 39 insertions(+), 2 deletions(-) diff --git a/compartments/kryptikd/src/volume.rs b/compartments/kryptikd/src/volume.rs index 362895be..544d232c 100644 --- a/compartments/kryptikd/src/volume.rs +++ b/compartments/kryptikd/src/volume.rs @@ -48,10 +48,25 @@ impl std::fmt::Display for VolumeError { /// A passphrase that is zeroed when it goes out of scope. pub struct Passphrase(Vec); +/// The fewest characters a passphrase set on a volume may have: a copied volume header can be +/// guessed at offline. +const MIN_CHARS: usize = 8; + impl Passphrase { pub fn as_bytes(&self) -> &[u8] { &self.0 } + /// Refuse one too short to set on a volume; an existing volume opens with whatever it has. + /// UTF-8 characters are counted by their lead bytes, so the bytes are never copied. + pub fn check_new(&self) -> Result<(), VolumeError> { + let n = self.0.iter().filter(|&&b| b & 0xc0 != 0x80).count(); + if n < MIN_CHARS { + return Err(VolumeError::Passphrase(format!( + "{n} characters, and a new one needs at least {MIN_CHARS}: a copied volume header can be guessed at offline" + ))); + } + Ok(()) + } /// Read from a regular file owned by root or the caller, with no group or other access. pub fn from_file(path: &Path) -> Result { /* Check the opened inode, not the path, which could be swapped. NONBLOCK @@ -300,6 +315,7 @@ pub fn is_mountpoint(path: &str) -> bool { /// Create LUKS2 (argon2id) on a sparse file or blank device, holding an ext4 owned by the zone. pub fn init(zone: &str, volume: &str, size: u64, pass: &Passphrase, uid: u32, gid: u32) -> Result<(), VolumeError> { + pass.check_new()?; let p = Path::new(volume); if p.exists() { let md = fs::metadata(p).map_err(|e| VolumeError::Io(format!("{volume}: {e}")))?; @@ -346,6 +362,7 @@ pub fn init(zone: &str, volume: &str, size: u64, pass: &Passphrase, uid: u32, gi /// Replace passphrase `old` with `new`; both travel through memfds, never argv. pub fn change_key(zone: &str, volume: &str, old: &Passphrase, new: &Passphrase) -> Result<(), VolumeError> { + new.check_new()?; let oldfd = memfd("old", old.as_bytes())?; let newfd = memfd("new", new.as_bytes())?; let oldp = format!("/proc/self/fd/{}", oldfd.as_raw_fd()); diff --git a/compartments/kryptikd/src/volume/tests.rs b/compartments/kryptikd/src/volume/tests.rs index ea385894..5b9a9970 100644 --- a/compartments/kryptikd/src/volume/tests.rs +++ b/compartments/kryptikd/src/volume/tests.rs @@ -100,6 +100,24 @@ fn mount_paths_escaped() { assert_eq!(mount_escaped("/z/a\\040b"), "/z/a\\134040b"); } +#[test] +fn new_passphrase_needs_eight_characters() { + let check = |s: &str| Passphrase(s.as_bytes().to_vec()).check_new(); + assert!(matches!(check("hunter2"), Err(VolumeError::Passphrase(_))), "7 characters refused"); + assert!(check("dev-pass").is_ok(), "8 characters taken"); + // Characters, not bytes: seven two-byte letters are seven. + assert!(check("\u{e4}\u{f6}\u{fc}\u{e4}\u{f6}\u{fc}\u{e4}").is_err()); + assert!(check("p\u{e4}ssw\u{f6}rd").is_ok()); + // Refused before the container or cryptsetup is touched. + let dir = std::env::temp_dir().join(format!("kryptik-vol-floor-{}", std::process::id())); + let vol = dir.join("v.luks").display().to_string(); + let short = Passphrase(b"short".to_vec()); + assert!(matches!(init("floor", &vol, 64 * 1024 * 1024, &short, 0, 0), Err(VolumeError::Passphrase(_)))); + assert!(!dir.exists(), "nothing is made for a refused passphrase"); + let old = Passphrase(b"long enough".to_vec()); + assert!(matches!(change_key("floor", &vol, &old, &short), Err(VolumeError::Passphrase(_)))); +} + /// Needs root, cryptsetup and dm-crypt; returns early without them. #[test] fn luks2_lifecycle_when_root() { diff --git a/docs/design/encrypted-volumes.md b/docs/design/encrypted-volumes.md index f1b69608..671471e4 100755 --- a/docs/design/encrypted-volumes.md +++ b/docs/design/encrypted-volumes.md @@ -54,7 +54,9 @@ a passphrase is a UX choice; kryptikd works either way. runs `mkfs.ext4 -E root_owner=:` and closes. The installer creates no zone volumes. - `volume passwd` runs `cryptsetup luksChangeKey` with both passphrases in - memfds. `volume backup-header NAME FILE` writes the header only and never + memfds. A passphrase either sets has at least 8 characters, as a copied + header can be guessed at offline; one a volume already has still opens it. + `volume backup-header NAME FILE` writes the header only and never over an existing file; the backup holds the wrapped key, so it is sensitive. `restore-header` and `status` complete the set. - `volume destroy NAME` erases a stopped zone's key slots and deletes its diff --git a/tools/image/update-test.sh b/tools/image/update-test.sh index 97aae1ac..c6ca35d2 100755 --- a/tools/image/update-test.sh +++ b/tools/image/update-test.sh @@ -169,7 +169,7 @@ start_vm update-p1 --disk "$PA" drive "expect:KRYPTIK_SMOKE: END" "login:${TUSER}:${TPASS}" \ "grab:v1:grep ^VERSION_ID= /etc/os-release; cat /run/kryptik/boot-identity" \ "run:echo before-update > /home/${TUSER}/marker && sync" \ - "$(ROOTSH 'printf zone-pw > /root/zp && chmod 600 /root/zp && kryptikd volume init work --size 64M --passphrase-file /root/zp && sha256sum /var/lib/kryptik/volumes/work.luks > /root/work.sha && echo VOL-OK')" \ + "$(ROOTSH 'printf zone-pass > /root/zp && chmod 600 /root/zp && kryptikd volume init work --size 64M --passphrase-file /root/zp && sha256sum /var/lib/kryptik/volumes/work.luks > /root/work.sha && echo VOL-OK')" \ "expect:VOL-OK" \ "$(ROOTSH 'kryptikd time status')" \ "$(ROOTSH 'poweroff')" "expect:Power down" "wait-exit" From 22589bf497d7b3af583076a84cf28beea0aab355 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 00:44:09 -0700 Subject: [PATCH 160/213] A failed Wi-Fi join is followed by a new scan, so a second saved network is joined once the first one's access point is gone --- build/guest-tests/zones-check.sh | 43 +++++++++++++++++++++++++ compartments/kryptikd/src/wifi.rs | 6 +++- compartments/kryptikd/src/wifi/tests.rs | 4 ++- compartments/tests/serve.sh | 3 +- docs/design/net-zone.md | 11 ++++++- tools/image/zones-test.sh | 1 + 6 files changed, 64 insertions(+), 4 deletions(-) diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index b5bb1669..f35e8abc 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -910,6 +910,49 @@ if [[ "$sae_up" = 1 && "$sae_keys" == *"AKMSuiteSelector=00-0f-ac-8 "* && "$sae_ else fail "wifi-sae" "access point up: ${sae_up}; the station at it: ${sae_keys:-none}; $(tail -3 "$LOG/ap-sae-wpa.log" 2>/dev/null | tr '\n' ' ')" fi +# A second saved network is joined once it appears, after the first one's access point went. +# The restarted net zone's supplicant tries the departed one first, as the kernel keeps its scan +# entry for 30 s. On that first failure wpa_supplicant 2.12 picks again from its last scan, gets +# the same access point, counts itself still joining it and asks for no new scan: it would wait +# forever. kryptikd's file counts no scan as recent, so each failure is followed by a scan. +AP2_SSID=kryptik-hwsim-2 +gone_tries() { dmesg 2>/dev/null | grep -ac 'authentication with 02:00:00:00:01:00 timed out'; } +second_up=0; tried="" +if [[ "$ap_up" = 1 ]]; then + ap_pid="$(cat /run/zt-ap-wpa.pid 2>/dev/null)" + [[ -n "$ap_pid" ]] && kill "$ap_pid" 2>/dev/null + for _ in $(seq 1 20); do [[ -n "$ap_pid" ]] && kill -0 "$ap_pid" 2>/dev/null || break; sleep 0.5; done +fi +tries_before="$(gone_tries)" +before="$(ready_count)" +printf '%s\n' "$AP_PASS" | "$KD" wifi add "$AP2_SSID" --wifi-dir "$WIFI_DIR" --zones "$Z" > "$LOG/wifi-add-2.out" 2>&1 +for _ in $(seq 1 60); do [[ "$(gone_tries)" -gt "$tries_before" ]] && { tried=1; break; }; sleep 0.5; done +if [[ "$ap_up" = 1 ]]; then + # Another network is another access point, with an address of its own. + ap ip link set dev "$AP_IF" down + ap ip link set dev "$AP_IF" address 02:00:00:00:01:01 >> "$LOG/ap.err" 2>&1 + cat > /root/zt/ap-2.conf <> "$LOG/ap.err" 2>&1 + for _ in $(seq 1 30); do ap_wpa status | grep -q '^wpa_state=COMPLETED' && { second_up=1; break; }; sleep 1; done +fi +line="$(ready_after "$before" " wifi=$AP2_SSID " 90)" +if [[ "$second_up" = 1 && -n "$tried" && "$line" == *" wifi=$AP2_SSID "* ]]; then + pass "wifi-second-network" "the restarted net zone tried the departed access point, then joined $AP2_SSID once it beaconed: ${line#*netzone: }" +else + fail "wifi-second-network" "access point up: ${second_up}; the departed one tried: ${tried:-no}; $(tr '\n' ' ' < "$LOG/wifi-add-2.out"); newest READY: ${line:-none}; $(netzone_said wifi | tail -2 | tr '\n' ' '); the kernel: $(dmesg 2>/dev/null | grep -aE 'nic[0-9]+:' | tail -6 | tr '\n' ';' | cut -c1-600)" +fi # Back to the wire: the radios go first, so the net zone the forget restarts finds none. for f in /run/zt-ap-dnsmasq.pid /run/zt-ap-wpa.pid; do p="$(cat "$f" 2>/dev/null)"; [[ -n "$p" ]] && kill "$p" 2>/dev/null; done [[ -n "$AP_HOLD" ]] && kill "$AP_HOLD" 2>/dev/null diff --git a/compartments/kryptikd/src/wifi.rs b/compartments/kryptikd/src/wifi.rs index 385cde5c..04541f7b 100644 --- a/compartments/kryptikd/src/wifi.rs +++ b/compartments/kryptikd/src/wifi.rs @@ -17,7 +17,11 @@ pub const IN_ZONE: &str = "/etc/wpa_supplicant.conf"; /// The net zone's service directory on an installed system. pub const SERVICE_DIR: &str = "/run/service/net-zone"; -const HEADER: &str = "ctrl_interface=/run/wpa_supplicant\nupdate_config=0\n"; +/* No scan is recent enough to join from without a new one. With two networks saved, a first + * failed join makes wpa_supplicant 2.12 pick again from its last scan; picking the access point + * that just failed (one gone, which the kernel's scan cache keeps for 30 s), it counts itself + * still joining it and asks for no scan, and waits until something else scans. */ +const HEADER: &str = "ctrl_interface=/run/wpa_supplicant\nupdate_config=0\nscan_res_valid_for_connect=0\n"; /* How a saved network may be joined, from what was saved: a passphrase by WPA2's pre-shared * key or WPA3's SAE, a raw key by the former alone (SAE needs the passphrase), and neither by diff --git a/compartments/kryptikd/src/wifi/tests.rs b/compartments/kryptikd/src/wifi/tests.rs index e140ac72..58bb0e49 100644 --- a/compartments/kryptikd/src/wifi/tests.rs +++ b/compartments/kryptikd/src/wifi/tests.rs @@ -17,7 +17,7 @@ fn add_list_forget_round_trip() { let text = fs::read_to_string(conf_path(&dir)).unwrap(); assert_eq!( text, - "ctrl_interface=/run/wpa_supplicant\nupdate_config=0\n\ + "ctrl_interface=/run/wpa_supplicant\nupdate_config=0\nscan_res_valid_for_connect=0\n\ \nnetwork={\n\tssid=\"Home\"\n\tkey_mgmt=WPA-PSK WPA-PSK-SHA256 SAE\n\tieee80211w=1\n\tpsk=\"correct horse battery\"\n}\n\ \nnetwork={\n\tssid=\"Cafe Wifi\"\n\tkey_mgmt=WPA-PSK WPA-PSK-SHA256\n\tieee80211w=1\n\tpsk=0123456789abcdef0123456789ABCDEF0123456789abcdef0123456789abcdef\n}\n" ); @@ -191,6 +191,8 @@ fn older_file_rewritten_with_key_management() { assert!(refresh(&dir, None).unwrap()); let now = fs::read_to_string(conf_path(&dir)).unwrap(); assert!(now.contains("\tkey_mgmt=WPA-PSK WPA-PSK-SHA256 SAE\n\tieee80211w=1\n"), "{now}"); + // And the header's line that keeps the supplicant scanning after a failed join. + assert!(now.starts_with(HEADER) && HEADER.contains("\nscan_res_valid_for_connect=0\n"), "{now}"); assert_eq!(fs::metadata(conf_path(&dir)).unwrap().mode() & 0o7777, 0o440); assert!(!refresh(&dir, None).unwrap(), "a current file is left alone"); // An older kryptikd's mode, which the supplicant's own user cannot read through its group. diff --git a/compartments/tests/serve.sh b/compartments/tests/serve.sh index bd0b1bd6..161fed6e 100755 --- a/compartments/tests/serve.sh +++ b/compartments/tests/serve.sh @@ -430,7 +430,8 @@ else fi r="$(ask 'wifi-forget\nssid Home\nend\n')" if [[ "$r" == "ok forgot network \"Home\";"* ]] && ! grep -qF Home "$WCONF" && grep -qxF $'\tssid="Cafe Wifi"' "$WCONF" \ - && grep -qxF 'ctrl_interface=/run/wpa_supplicant' "$WCONF" && grep -qxF 'update_config=0' "$WCONF"; then + && grep -qxF 'ctrl_interface=/run/wpa_supplicant' "$WCONF" && grep -qxF 'update_config=0' "$WCONF" \ + && grep -qxF 'scan_res_valid_for_connect=0' "$WCONF"; then pass "S9m wifi-forget removes only its block; the header and the other network stay" else fail "S9m: $r"; sed 's/^/ /' "$WCONF" diff --git a/docs/design/net-zone.md b/docs/design/net-zone.md index 147981b1..96735cf1 100755 --- a/docs/design/net-zone.md +++ b/docs/design/net-zone.md @@ -311,6 +311,14 @@ configured the zone keeps its IPv4 path and the launcher says so. wpa_supplicant would take `WPA-PSK WPA-EAP` and never SAE. Management frames are protected wherever the access point offers it (`ieee80211w=1`), and SAE always has them. A file an older kryptikd wrote is rewritten so. +- **A failed join is followed by a scan.** The file's header sets + `scan_res_valid_for_connect=0`, so the supplicant never joins from an + earlier scan without a new one. With two networks saved, wpa_supplicant + 2.12 answers a first failed join by picking again from its last scan; when + it picks the access point that just failed (one switched off, whose entry + the kernel's scan cache keeps for 30 s), it counts itself still joining it, + asks for no scan, and waits until something else scans. A file an older + kryptikd wrote is rewritten so. - On disk the file is plaintext inside the [encrypted](state-encryption.md) state partition, like NetworkManager's connection files. A compromised net zone learns the passphrases of the networks it was given, and nothing more. @@ -320,7 +328,8 @@ kernel's simulated radio, from the signed module the image carries: one goes into a namespace of its own as the access point (the image's `wpa_supplicant` in AP mode, `dnsmasq` for the lease), the other is left for the net zone, which joins the network `kryptik wifi add` gave it, leases an address over -the radio and carries a routed zone's traffic to the access point. Real +the radio and carries a routed zone's traffic to the access point. Once that +access point is gone, a second saved network is joined when it appears. Real hardware is untested: a seccomp refusal of `wpa_supplicant` there would show as `SIGSYS` in the zone's log and a `wifi=connecting` that never changes. diff --git a/tools/image/zones-test.sh b/tools/image/zones-test.sh index 0de0663c..03e2d84e 100755 --- a/tools/image/zones-test.sh +++ b/tools/image/zones-test.sh @@ -67,6 +67,7 @@ grep 'ZT FAIL' <<<"$T2" | sed 's/^/ /' for name in kernel-support policies net-ready net-dns dhcpcd-separated dnsmasq-unprivileged zone0-nic zone0-no-route zone0-offline routed-egress routed-ping routed-ping6 routed-dns net-lease-names-resolver dns-follows-lease dns-after-reload routed-ipv6-noglobal zone-separation volume-hidden home-hidden fail-closed net-restart-ready reattach-after-restart uplink-returned uplink-retaken reattach-egress uplink-refused wifi-beyond routed-restart-path uplink-address-refused zone-flows-capped net-cpu-max-set \ wifi-module wifi-ap wifi-add wifi-associated wifi-lease wifi-egress wifi-forget \ wifi-no-dpp \ + wifi-second-network \ uplink-renamed-plain uplink-state-reset \ radio-recarried \ wifi-wpa2-plain wifi-sae wifi-supplicant-unprivileged wifi-supplicant-no-nft \ From cfb18c75406fd998c4f0b602b48ab2760606c1a3 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 00:50:54 -0700 Subject: [PATCH 161/213] The scan's refusal reads as an if on whether the zone still runs --- compartments/kryptikd/src/wifi.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/compartments/kryptikd/src/wifi.rs b/compartments/kryptikd/src/wifi.rs index f91adf47..16624a22 100644 --- a/compartments/kryptikd/src/wifi.rs +++ b/compartments/kryptikd/src/wifi.rs @@ -405,9 +405,10 @@ pub fn scan(zones_dir: &Path) -> Result { let before = init.file_in_root(SCAN_FILE).as_ref().and_then(stamp); if let Err(e) = init.signal(libc::SIGUSR1) { // Ended since the registry said it runs, as a zone restarting after a save does. - return Err(match init.still_alive() { - true => format!("the {name} zone could not be asked: {e}"), - false => format!("the {name} zone is not running"), + return Err(if init.still_alive() { + format!("the {name} zone could not be asked: {e}") + } else { + format!("the {name} zone is not running") }); } let until = std::time::Instant::now() + SCAN_WAIT; From bd7e527e2d5904bb72bc462ed6f5cd253af12b15 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 01:00:07 -0700 Subject: [PATCH 162/213] The plan's partition sizes come from lsblk's raw output, which pads no column lsblk -dno SIZE right-aligns its one column, so the plan read "vda1 crypto_LUKS 64M"; the console suite's run of night-0f-2 timed out on the single space it wants. --- tools/install/kryptik-install.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/install/kryptik-install.sh b/tools/install/kryptik-install.sh index c44b84a7..f6e09eb0 100755 --- a/tools/install/kryptik-install.sh +++ b/tools/install/kryptik-install.sh @@ -252,7 +252,7 @@ for p in "/sys/class/block/$tname/$tname"*; do pn="${p##*/}" fs="$(shown "$(blkid -p -o value -s TYPE "/dev/$pn" 2>/dev/null)")" lb="$(shown "$(blkid -p -o value -s LABEL "/dev/$pn" 2>/dev/null)")" - sz="$(lsblk -dno SIZE "/dev/$pn" 2>/dev/null || true)" + sz="$(lsblk -dnro SIZE "/dev/$pn" 2>/dev/null || true)" # raw: a column's padding left out holds="${holds:+$holds; }${pn} ${fs:-unformatted}${lb:+ \"$lb\"} ${sz}" done pt="$(blkid -p -o value -s PTTYPE "$TARGET_REAL" 2>/dev/null || true)" From 931a38488424dbcc389a389384012ef5911eef9c Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 01:03:06 -0700 Subject: [PATCH 163/213] The README's storage line says an install disk needs 512-byte logical sectors --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 54bd948b..cf2d2937 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,8 @@ the firmware from the pinned linux-firmware release - **Storage:** NVMe (including Intel VMD), AHCI and PIIX SATA, eMMC and SD on SDHCI, LSI MegaRAID and MPT3 SAS, HPE Smart Array, USB mass storage and UAS, - virtio, VMware PVSCSI, Hyper-V. + virtio, VMware PVSCSI, Hyper-V. The disk Kryptik installs to needs 512-byte + logical sectors: 512e is fine, and a 4Kn disk is refused. - **Wired network:** Intel e1000/e1000e, igb, igc, ixgbe, i40e; Broadcom tg3 and bnxt; Realtek r8169; Aquantia AQtion; Mellanox ConnectX-4 and later; USB adapters (AX88179, RTL8152); virtio, VMware vmxnet3, Hyper-V. From 9243239ab42c1779f8ea9345ea027be0be096cee Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 01:15:09 -0700 Subject: [PATCH 164/213] Changing the state passphrase holds the new one to 8 characters kryptik state passphrase handed the terminal to cryptsetup luksChangeKey, which takes any new passphrase, so the installer's floor lasted until the first change. The verb now asks for the current passphrase and checks it with cryptsetup open --test-passphrase, asks twice for the new one, counts it in characters under C.UTF-8 as the installer does, and gives both to luksChangeKey through pipes from the printf builtin. Without a terminal it reads two lines, the old then the new. The state suite's step 8 types a wrong current passphrase and a 5-character new one, each refused with nothing changed, before the change it already proved. The design's coverage list said neither this verb nor the header commands had a check; both have one now. --- docs/design/state-encryption.md | 12 ++++++--- docs/user-guide.md | 3 ++- tools/image/state-test.sh | 30 +++++++++++++--------- tools/kryptik | 44 ++++++++++++++++++++++++--------- 4 files changed, 62 insertions(+), 27 deletions(-) diff --git a/docs/design/state-encryption.md b/docs/design/state-encryption.md index 2333617e..0d1c9e01 100644 --- a/docs/design/state-encryption.md +++ b/docs/design/state-encryption.md @@ -35,8 +35,9 @@ every machine, and verified. It uses the dm-crypt and XTS support the and `--restore-state-header FILE` run from the medium, and the installer warns that a lost header or passphrase is a lost partition: there is no escrow and no back door. -- `kryptik state passphrase` (root) runs `cryptsetup luksChangeKey`, which asks - for the old and new passphrases on the terminal. The key it protects stays +- `kryptik state passphrase` (root) asks for the current passphrase and checks + it, then for a new one twice, held to the installer's 8 characters, and + hands both to `cryptsetup luksChangeKey` through pipes. The key it protects stays the same, so a copy of the header made before still opens with the old passphrase; only re-encrypting under a new volume key (`cryptsetup reencrypt`) shuts out whoever holds one. @@ -91,8 +92,11 @@ show that the machine runs Kryptik. - `integrity-test.sh` plants its `/etc` entries through the mapping opened on the host (`suite-lib.sh`), and they are still quarantined. - `update-test.sh` and `zones-test.sh` answer the prompt at each boot. -- Not covered yet: `kryptik state passphrase` and `kryptik-recover`'s header - commands. +- `state-test.sh` changes the passphrase with `kryptik state passphrase`, + after it refuses a wrong current one and a new one of 5 characters; the + old one then fails three times and the new one unlocks. `integrity-test.sh` + saves the header on the medium with `kryptik-recover --backup-state-header`, + wipes it, and puts it back with `--restore-state-header`. ## Files diff --git a/docs/user-guide.md b/docs/user-guide.md index 7eaa4933..93f3efeb 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -201,7 +201,8 @@ holds (`/keys/release/kryptik-testctl` for a development build). Every boot asks for the state passphrase, three times at most, before anything else starts: on the display and, if the machine has one, on the serial console, and the first answer counts. Root changes it with `kryptik -state passphrase`. A header backup made before the change, or any earlier +state passphrase`, which asks for the current one, then twice for a new one +of at least 8 characters. A header backup made before the change, or any earlier copy of the disk, still opens with the old passphrase: back the header up again and destroy the older backups. That does not shut out anyone who already copied the old header; only re-encrypting the partition under a new diff --git a/tools/image/state-test.sh b/tools/image/state-test.sh index fb0b3e67..e879895b 100755 --- a/tools/image/state-test.sh +++ b/tools/image/state-test.sh @@ -13,8 +13,9 @@ # step 5 no kryptik-state partition: degraded # step 6 the watchdog feeder stopped: the machine resets and comes back # step 7 three wrong passphrases: degraded; then the right one -# step 8 root changes the passphrase with `kryptik state passphrase`: the -# old one no longer unlocks, the new one does +# step 8 root changes the passphrase with `kryptik state passphrase`, which +# first refuses a wrong current one and a new one under 8 +# characters: the old one no longer unlocks, the new one does # # After each repair a login must find the user's file, and no degraded boot # writes the ESP or the state partition's header. Every disk is a file made here. @@ -197,21 +198,28 @@ KRYPTIK_STATE_PASSPHRASE=not-the-passphrase degraded_boot state-p7 '/dev/vda4 wa normal_boot state-p7b # ----------------------------------------------------------------- step 8 -- -# Last: the disk keeps the new passphrase. cryptsetup asks on the terminal, -# so the answers are typed there, not passed to it. It prints each prompt and -# then discards what the terminal already holds, so an answer waits a moment. +# Last: the disk keeps the new passphrase. The answers are typed at the +# terminal, each a moment after its prompt, once echo is off. A wrong current +# passphrase and a new one under the installer's floor change nothing. step "step 8: root changes the state passphrase; the old one no longer unlocks" NEWPASS=state-pw-changed start_vm state-p8 drive "expect:KRYPTIK_SMOKE: END" "login:${TUSER}:${TPASS}" \ - "send:su - root -c 'kryptik state passphrase; echo CHANGED=\$?'" "expect:Password: ?" "send:${RPASS}" \ - "expect:Enter passphrase to be changed: ?" "sleep:2" "send:${KRYPTIK_STATE_PASSPHRASE}" \ - "expect:Enter new passphrase: ?" "sleep:2" "send:${NEWPASS}" \ - "expect:Verify passphrase: ?" "sleep:2" "send:${NEWPASS}" \ - "expect:CHANGED=0" \ + "send:su - root -c 'kryptik state passphrase; echo WRONG=\$?; kryptik state passphrase; echo SHORT=\$?; kryptik state passphrase; echo CHANGED=\$?'" \ + "expect:Password: ?" "send:${RPASS}" \ + "expect:the state passphrase now: ?" "sleep:1" "send:not-the-passphrase" \ + "expect:that is not the state passphrase; nothing was changed" "expect:WRONG=1" \ + "expect:the state passphrase now: ?" "sleep:1" "send:${KRYPTIK_STATE_PASSPHRASE}" \ + "expect:a new one, asked at every boot: ?" "sleep:1" "send:short" \ + "expect:again: ?" "sleep:1" "send:short" \ + "expect:the new passphrase is 5 characters; it needs at least 8" "expect:SHORT=1" \ + "expect:the state passphrase now: ?" "sleep:1" "send:${KRYPTIK_STATE_PASSPHRASE}" \ + "expect:a new one, asked at every boot: ?" "sleep:1" "send:${NEWPASS}" \ + "expect:again: ?" "sleep:1" "send:${NEWPASS}" \ + "expect:the state passphrase is changed; the next boot asks for the new one" "expect:CHANGED=0" \ "$(ROOTSH 'poweroff')" "expect:Power down" "wait-exit" rc=$?; stop_vm -[[ "$rc" -eq 0 ]] && green "state-p8: kryptik state passphrase took the old passphrase and the new one twice" || red "state-p8: changing the passphrase failed" +[[ "$rc" -eq 0 ]] && green "state-p8: kryptik state passphrase refused a wrong current passphrase and a 5-character new one, then took the old one and the new one twice" || red "state-p8: changing the passphrase, or its refusals, failed" degraded_boot state-p8b '/dev/vda4 was not unlocked in three tries' KRYPTIK_STATE_PASSPHRASE="$NEWPASS" normal_boot state-p8c diff --git a/tools/kryptik b/tools/kryptik index aafaa51b..0e2e0bd7 100755 --- a/tools/kryptik +++ b/tools/kryptik @@ -46,7 +46,8 @@ USAGE: $PROG update apply install what has arrived; the next boot tries it once and falls back if it fails $PROG state passphrase change the passphrase the state partition - asks for at boot (root; the old one first) + asks for at boot (root; the old one first, + then a new one of 8 characters or more) $PROG keyboard the keyboard layouts, the one in force marked $PROG keyboard NAME use that layout (root): the console now, the desktop at its next login, the passphrase's @@ -288,14 +289,11 @@ cmd_run() { start_zone "$name" "$@" } -# --- the net zone's Wi-Fi networks, kept by kryptikd in zone 0 -------------- -# The passphrase always goes on stdin; kryptikd restarts the net zone after a change. - # Read one line into PASSPHRASE: from the terminal with echo off, or a pipe. PASSPHRASE="" -read_passphrase() { # ssid +read_passphrase() { # prompt if [[ -t 0 ]]; then - IFS= read -rs -p "passphrase for $1: " PASSPHRASE + IFS= read -rs -p "$1" PASSPHRASE printf '\n' >&2 else IFS= read -r PASSPHRASE @@ -303,6 +301,9 @@ read_passphrase() { # ssid [[ -n "$PASSPHRASE" ]] || die "no passphrase given" } +# --- the net zone's Wi-Fi networks, kept by kryptikd in zone 0 -------------- +# The passphrase always goes on stdin; kryptikd restarts the net zone after a change. + cmd_wifi() { local sub="${1:-}"; [[ $# -gt 0 ]] && shift case "$sub" in @@ -313,7 +314,7 @@ cmd_wifi() { if via_launch; then case "$sub" in list) "$LAUNCH" --wifi-list ;; - add) read_passphrase "$1" + add) read_passphrase "passphrase for $1: " # printf is a builtin, so the passphrase is never in argv. printf '%s\n' "$PASSPHRASE" | "$LAUNCH" --wifi-add "$1" ;; forget) "$LAUNCH" --wifi-forget "$1" ;; @@ -323,7 +324,7 @@ cmd_wifi() { have_kryptikd case "$sub" in list) "$KRYPTIKD" wifi list --wifi-dir "$WIFI_DIR" ;; - add) read_passphrase "$1" + add) read_passphrase "passphrase for $1: " printf '%s\n' "$PASSPHRASE" | "$KRYPTIKD" wifi add "$1" --wifi-dir "$WIFI_DIR" --zones "$ZONES_DIR" ;; forget) "$KRYPTIKD" wifi forget "$1" --wifi-dir "$WIFI_DIR" --zones "$ZONES_DIR" ;; esac @@ -340,13 +341,34 @@ cmd_update() { "$LAUNCH" --update "$@" } -# cryptsetup asks for the passphrases on the terminal; none pass through here. +# Asked for here, so the new one is held to the installer's floor: on a +# terminal the new one twice, else two lines of stdin, the old then the new. +# They reach cryptsetup through pipes from the printf builtin, never argv. cmd_state() { [[ "${1:-}" == passphrase && $# -eq 1 ]] || { usage >&2; die "state needs a subcommand: passphrase" 2; } [[ $EUID -eq 0 ]] || die "the state passphrase is root's to change" - local dev; dev="$(sed -n 's/^state_dev=//p' /run/kryptik/boot-identity 2>/dev/null)" + local dev old new n + dev="$(sed -n 's/^state_dev=//p' /run/kryptik/boot-identity 2>/dev/null)" [[ -b "$dev" ]] || die "this system has no state partition in use" - exec cryptsetup luksChangeKey "$dev" + read_passphrase "the state passphrase now: "; old="$PASSPHRASE" + printf '%s' "$old" | cryptsetup open --test-passphrase --type luks2 --key-file=- "$dev" 2>/dev/null + case $? in + 0) ;; + 2) die "that is not the state passphrase; nothing was changed" ;; + *) die "cryptsetup could not check the passphrase against $dev; nothing was changed" ;; + esac + read_passphrase "a new one, asked at every boot: "; new="$PASSPHRASE" + if [[ -t 0 ]]; then + read_passphrase "again: " + [[ "$PASSPHRASE" == "$new" ]] || die "the two passphrases differ; nothing was changed" + fi + PASSPHRASE="" + # Characters, not the C locale's bytes. + n="$(LC_ALL=C.UTF-8; echo "${#new}")" + (( n >= 8 )) || die "the new passphrase is $n characters; it needs at least 8, as a stolen disk can be guessed at offline; nothing was changed" + cryptsetup luksChangeKey --batch-mode --key-file=<(printf '%s' "$old") "$dev" <(printf '%s' "$new") \ + || die "cryptsetup did not change the state passphrase" + echo "the state passphrase is changed; the next boot asks for the new one" } # The layout's name is kept in the firmware and its keymaps on the verified From c71edf0b2652abd360b356c6480b39b2476a2d0d Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 01:23:17 -0700 Subject: [PATCH 165/213] The net zone's supplicant ages scan results by the kernel's own count, which its time namespace leaves alone --- build/guest-tests/zones-check.sh | 12 +++++ ...t-clock-only-where-the-kernel-agrees.patch | 49 +++++++++++++++++++ build/patches/wpa_supplicant-2.12/README.md | 19 +++++++ build/patches/wpa_supplicant-2.12/SHA256SUMS | 1 + build/recipes/wpa-supplicant.sh | 5 +- docs/design/net-zone-surface.md | 8 +++ tools/image/zones-test.sh | 1 + 7 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 build/patches/wpa_supplicant-2.12/0001-nl80211-scan-age-from-the-boot-clock-only-where-the-kernel-agrees.patch create mode 100644 build/patches/wpa_supplicant-2.12/README.md create mode 100644 build/patches/wpa_supplicant-2.12/SHA256SUMS diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index f35e8abc..ba54c38a 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -811,6 +811,18 @@ if [[ "$door_rc" = 0 && "${nft_out,,}" == *"not permitted"* && "${probe_tables:- else fail "wifi-supplicant-no-nft" "the door: rc=$door_rc $(tr '\n' ' ' < "$LOG/supplicant-door.out" | cut -c1-120); nft: $(tr '\n' ' ' <<<"$nft_out" | cut -c1-160); tables named supplicant-probe: ${probe_tables:-unread}" fi +# The supplicant ages a scan result by the kernel's own count. The kernel also stamps the +# machine's boot clock, which the net zone's time namespace starts an hour or more apart: read +# against that, the access point it joined would be hours old, as would every scan result. +sta_ctl() { nsenter -t "${net_init:-0}" -U -m -n /usr/bin/kryptikd supplicant -- wpa_cli -s /run/wpa_supplicant -p /run/wpa_supplicant "$@" 2>&1; } +net_if="$(nsenter -t "${net_init:-0}" -n iw dev 2>/dev/null | awk '$1 == "Interface" { print $2; exit }')" +joined="$(sta_ctl -i "$net_if" status | sed -n 's/^bssid=//p')" +bss_age=""; [[ -n "$joined" ]] && bss_age="$(sta_ctl -i "$net_if" bss "$joined" | sed -n 's/^age=//p')" +if [[ "$bss_age" =~ ^[0-9]+$ && "$bss_age" -lt 600 ]]; then + pass "wifi-bss-age" "the supplicant counts the access point it joined ($joined) as heard ${bss_age} s ago" +else + fail "wifi-bss-age" "joined: ${joined:-none}; its age: ${bss_age:-unread}; the net zone's boot clock offset: $(sed -n 's/^boottime //p' "/proc/${net_init:-0}/timens_offsets" 2>/dev/null)" +fi zrun untrusted 40 -- sh -c "python3 /usr/lib/kryptik/guest-tests/icmp-echo.py $AP_ADDR 3 >/dev/null 2>&1 && echo AP-REACHED || echo AP-UNREACHED" sta_seen="$(ap_wpa all_sta | grep -ciE '^[0-9a-f]{2}(:[0-9a-f]{2}){5}$')" if [[ "$ZOUT" == *AP-REACHED* && "${sta_seen:-0}" -ge 1 ]]; then diff --git a/build/patches/wpa_supplicant-2.12/0001-nl80211-scan-age-from-the-boot-clock-only-where-the-kernel-agrees.patch b/build/patches/wpa_supplicant-2.12/0001-nl80211-scan-age-from-the-boot-clock-only-where-the-kernel-agrees.patch new file mode 100644 index 00000000..bbee1b84 --- /dev/null +++ b/build/patches/wpa_supplicant-2.12/0001-nl80211-scan-age-from-the-boot-clock-only-where-the-kernel-agrees.patch @@ -0,0 +1,49 @@ +nl80211: a scan result's boot-clock age only where the kernel's own agrees + +Carried by Kryptik against wpa_supplicant 2.12; not upstream at the time of +writing, and hostap's main branch has the same code. The kernel gives each +BSS two ages: NL80211_BSS_SEEN_MS_AGO, which it counts itself, and +NL80211_BSS_LAST_SEEN_BOOTTIME, a stamp on the machine's boot clock that +wpa_supplicant subtracts from its own CLOCK_BOOTTIME. In a time namespace +that clock is offset from the machine's and the kernel does not translate the +stamp, so every scan result reads as old as the offset, hours or days. The +BSS table then drops each entry at its next expiry pass (bss_expiration_age, +180 s), within ten seconds of the scan that found it. + +The stamp's age is now taken only when it agrees with SEEN_MS_AGO within a +second, or when the kernel sends no SEEN_MS_AGO. Outside a time namespace the +two agree, and nothing changes. + +--- a/src/drivers/driver_nl80211_scan.c ++++ b/src/drivers/driver_nl80211_scan.c +@@ -857,7 +857,7 @@ + if (bss[NL80211_BSS_SEEN_MS_AGO]) + r->age = nla_get_u32(bss[NL80211_BSS_SEEN_MS_AGO]); + if (bss[NL80211_BSS_LAST_SEEN_BOOTTIME]) { +- u64 boottime; ++ u64 boottime, now, age; + struct timespec ts; + + #ifndef CLOCK_BOOTTIME +@@ -866,11 +866,18 @@ + if (clock_gettime(CLOCK_BOOTTIME, &ts) == 0) { + /* Use more accurate boottime information to update the + * scan result age since the driver reports this and +- * CLOCK_BOOTTIME is available. */ ++ * CLOCK_BOOTTIME is available. The kernel stamps the ++ * machine's boot clock, which a time namespace offsets ++ * from ours, so the kernel's own age stands whenever ++ * the two disagree by more than a second. */ + boottime = nla_get_u64( + bss[NL80211_BSS_LAST_SEEN_BOOTTIME]); +- r->age = ((u64) ts.tv_sec * 1000000000 + +- ts.tv_nsec - boottime) / 1000000; ++ now = (u64) ts.tv_sec * 1000000000 + ts.tv_nsec; ++ age = (now - boottime) / 1000000; ++ if (now >= boottime && ++ (!bss[NL80211_BSS_SEEN_MS_AGO] || ++ (age + 1000 >= r->age && age <= r->age + 1000ULL))) ++ r->age = age; + } + } + r->ie_len = ie_len; diff --git a/build/patches/wpa_supplicant-2.12/README.md b/build/patches/wpa_supplicant-2.12/README.md new file mode 100644 index 00000000..cdbe7c26 --- /dev/null +++ b/build/patches/wpa_supplicant-2.12/README.md @@ -0,0 +1,19 @@ +# wpa_supplicant 2.12: scan ages in a time namespace + +Applied by `s_wpa_supplicant` in stage 04 through `apply_repo_patches`; +`SHA256SUMS` is verified before anything is applied. + +Every zone runs in a time namespace of its own, whose boot clock starts at a +random point between an hour and thirty days +([net zone surface](../../../docs/design/net-zone-surface.md#a-time-namespace-per-zone)). +The net zone's supplicant took a scan result's age from the kernel's stamp on +the machine's boot clock against its own, so every result read as hours or +days old and left the BSS table within ten seconds of its scan: +`wpa_cli scan_results` came back empty while the radio heard its networks, +and anything decided from the table between scans had nothing to go on. The +patch keeps the kernel's own millisecond age whenever the two disagree. The +zones suite's `wifi-bss-age` reads the age of the access point the station +joined. + +The patch is Kryptik's own; delete this directory when a wpa_supplicant +release carries an equivalent. diff --git a/build/patches/wpa_supplicant-2.12/SHA256SUMS b/build/patches/wpa_supplicant-2.12/SHA256SUMS new file mode 100644 index 00000000..a252d514 --- /dev/null +++ b/build/patches/wpa_supplicant-2.12/SHA256SUMS @@ -0,0 +1 @@ +55b8a9cffff7b553f96fb93eed4a8c5c9b34576e3a65c1ccdf80a2e1cd7a2bdc 0001-nl80211-scan-age-from-the-boot-clock-only-where-the-kernel-agrees.patch diff --git a/build/recipes/wpa-supplicant.sh b/build/recipes/wpa-supplicant.sh index 5df5d99f..3d80059e 100644 --- a/build/recipes/wpa-supplicant.sh +++ b/build/recipes/wpa-supplicant.sh @@ -5,7 +5,10 @@ # unassociated, for a way of joining networks nothing here offers. s_wpa_supplicant() { local src; src="$(unpack "wpa_supplicant-${V_WPA_SUPPLICANT}.tar.gz" "wpa_supplicant-${V_WPA_SUPPLICANT}")" - cd "$src/wpa_supplicant" + cd "$src" + # Scan ages that the net zone's time namespace would otherwise put hours in the past. + apply_repo_patches "wpa_supplicant-${V_WPA_SUPPLICANT}" + cd wpa_supplicant cat > .config <<'EOF' CONFIG_DRIVER_NL80211=y CONFIG_LIBNL32=y diff --git a/docs/design/net-zone-surface.md b/docs/design/net-zone-surface.md index caaf24f7..87a07cf5 100644 --- a/docs/design/net-zone-surface.md +++ b/docs/design/net-zone-surface.md @@ -166,6 +166,14 @@ compares the boot time two zones and zone 0 each work out frame callbacks while the zone's clock stands apart from the compositor's (`zone-window-animates`). +The kernel's own stamps on the boot clock are not translated either. nl80211 +gives each scan result one (`NL80211_BSS_LAST_SEEN_BOOTTIME`), which +wpa_supplicant 2.12 reads against its own boot clock, so in the net zone every +result read as hours or days old and left its BSS table within ten seconds of +the scan that found it. Kryptik's patch keeps the kernel's own millisecond age +whenever the two disagree (`build/patches/wpa_supplicant-2.12`), and the zones +suite reads the age of the access point the station joined (`wifi-bss-age`). + ## Side by side | | genl family filter | wpa_supplicant's own user | time namespace | diff --git a/tools/image/zones-test.sh b/tools/image/zones-test.sh index 03e2d84e..47518faa 100755 --- a/tools/image/zones-test.sh +++ b/tools/image/zones-test.sh @@ -72,6 +72,7 @@ for name in kernel-support policies net-ready net-dns dhcpcd-separated dnsmasq-u radio-recarried \ wifi-wpa2-plain wifi-sae wifi-supplicant-unprivileged wifi-supplicant-no-nft \ zone-clocks-own \ + wifi-bss-age \ time-floor-ran time-clamp time-floor-forged time-claim-stepped time-claim-floor time-claim-consent pids-limit ephemeral-size-bound cpu-max-set lifecycle-repeat lifecycle-registry resolver-after-attach dns-cache-off dns-counters-hidden net-zone-sysfs-nics-only \ terminal-terminfo man-page text-browser tls-trust no-shared-writable-mount net-zone-no-zone-data no-swap no-dbus \ volume-init zone-source-pinned bridge-ports-closed encrypted-zone-start stop-closes-volume wrong-passphrase persist-reopen no-mapping-after ephemeral-gone concurrent-start-refused full-volume header-restore volume-destroy vault-offline vault-ping no-passphrase-leak \ From 0704de6beb7ea9a8720c9384139ebb42faf94f48 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 01:23:50 -0700 Subject: [PATCH 166/213] kryptik-recover rewrites an ESP that no longer mounts before it restores a slot Both slots' kernels and BOOTX64.EFI live on one FAT. When it no longer mounted, every kryptik-recover command stopped at "cannot mount", and only a reinstall, which erases the state partition, brought the disk back. --restore-slot now rewrites such an ESP from the medium's, as the installer writes it, then restores the slot as before. The rewritten ESP's kernel for the other slot is the medium's, which need not boot what that slot holds, so that slot is unlisted, and the medium's media-kernel record goes as the installer removes it. It refuses an ESP mounted elsewhere (a read-only mount refuses a writable one, which is not damage) and one smaller than the medium's. --status says the ESP does not mount and still shows each slot's filesystem and the medium's version; --commit-slot points to --restore-slot. The integrity suite zeroes the ESP's boot sector before its restore and wants the rewrite named, the records whole, slot b unlisted, no media-kernel record, and the disk booting with the user's data. --- docs/design/boot-and-updates.md | 6 ++++- docs/user-guide.md | 6 ++++- tools/image/integrity-test.sh | 22 +++++++++++++---- tools/update/kryptik-recover | 42 ++++++++++++++++++++++++++++----- 4 files changed, 64 insertions(+), 12 deletions(-) diff --git a/docs/design/boot-and-updates.md b/docs/design/boot-and-updates.md index dc4ea3dc..30886883 100644 --- a/docs/design/boot-and-updates.md +++ b/docs/design/boot-and-updates.md @@ -187,7 +187,11 @@ payloads come from [the update channel](update-channel.md) or by hand. Zone data is never written. On the FAT ESP the renames are the only non-atomic steps; each follows a complete, fsynced copy and leaves a system that boots either way. From the medium, `kryptik-recover` commits or rewrites -a slot and restores the state header ([user guide](../user-guide.md)). +a slot and restores the state header ([user guide](../user-guide.md)). The +ESP is the one place both slots' kernels live, so a FAT that no longer +mounts is rewritten from the medium's before a slot is restored; its kernel +for the other slot is the medium's, which need not boot what that slot +holds, so that slot is left unlisted. ## Tests diff --git a/docs/user-guide.md b/docs/user-guide.md index 497649c9..bdf7e122 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -373,7 +373,11 @@ 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 does not compare versions, so read `--status` first. It shows each slot's version, which slot's kernel the firmware boots by default, and the -medium's own version, the one `--restore-slot` writes. +medium's own version, the one `--restore-slot` writes. If the disk's boot +partition (the ESP) no longer mounts, `--status` says so, and `--restore-slot` +first rewrites it from the medium, as the installer does. The other slot then +has no kernel there, so it cannot be committed or rolled back to; the next +update writes it again. A machine that stops responding resets itself after about a minute (the software watchdog's timeout; a hardware watchdog keeps its own): a service diff --git a/tools/image/integrity-test.sh b/tools/image/integrity-test.sh index 16f0fe8a..893860cb 100755 --- a/tools/image/integrity-test.sh +++ b/tools/image/integrity-test.sh @@ -15,9 +15,10 @@ # step 3 a byte of slot a's root flipped: dm-verity stops the boot # step 4 kryptik-recover --restore-slot a refuses a medium whose root is # not the one its signed kernel names (--status then shows nothing -# naming slot a), then restores slot a from the medium, naming the -# version; its records on the ESP are whole, --status shows them, and -# the user's data survives +# naming slot a), then, with the ESP's boot sector zeroed, rewrites +# the ESP from the medium and restores slot a, naming the version; +# its records on the ESP are whole, slot b is unlisted, --status +# shows them, 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 @@ -36,7 +37,7 @@ while [[ "$#" -gt 0 ]]; do --usb) USB="${2:?}"; shift 2 ;; --disk) DISK="${2:?}"; shift 2 ;; --timeout) TIMEOUT="${2:?}"; shift 2 ;; - -h|--help) sed -n '2,25p' "${BASH_SOURCE[0]}"; exit 0 ;; + -h|--help) sed -n '2,26p' "${BASH_SOURCE[0]}"; exit 0 ;; *) die "unknown argument: $1" ;; esac done @@ -195,8 +196,13 @@ else red "could not alter a copy of the medium (free block '${A_FREE}', total_bytes '${A_BYTES}')" fi rm -rf "$ALT" "$ALTUSB" +# The ESP's boot sector zeroed, as a write cut short can leave it: it no longer +# mounts, and the restore rewrites it from the medium before the slot. +dd if=/dev/zero of="$DISK" bs=512 seek=$(( ESP_OFF / 512 )) count=1 conv=notrunc status=none 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/^/ /'; } +boot_txt | grep -qF "KRYPTIK_RECOVER: kryptik-recover: /dev/vda1 does not mount: rewriting it from this medium's ESP" \ + && green "an ESP that did not mount was rewritten from the medium" || red "kryptik-recover did not say it rewrote the ESP that did not mount" # 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}" @@ -210,6 +216,14 @@ boot_txt | grep -F 'KRYPTIK_RECOVER: status: slot a ' | grep -qF "version ${MVER || red "--status after the restore: $(boot_txt | grep 'KRYPTIK_RECOVER: status:' | tr '\n' ' ')" LEFT="$(mdir -/ -b -i "$ESPIMG" ::/ 2>/dev/null | grep -i '\.new$' | tr '\n' ' ')" [[ -z "$LEFT" ]] && green "recovery left no .new file on the ESP" || red "recovery left ${LEFT}on the ESP" +# The medium's kernel for slot b need not boot what slot b holds. +if mtype -i "$ESPIMG" ::/EFI/kryptik/kryptik-b.efi >/dev/null 2>&1 || mtype -i "$ESPIMG" ::/kryptik/version-b >/dev/null 2>&1; then + red "the rewritten ESP still names a kernel or version for slot b" +else + green "the rewritten ESP names no kernel or version for slot b" +fi +mtype -i "$ESPIMG" ::/kryptik/media-kernel >/dev/null 2>&1 && red "the rewritten ESP kept the medium's media-kernel record" \ + || green "the rewritten ESP has no media-kernel record" cp "$ENROLLED" "$VARSF" start_vm integ-p4b; LOG4="$LOG" python3 "$DRV" --serial "$SER" --timeout 300 \ diff --git a/tools/update/kryptik-recover b/tools/update/kryptik-recover index 6607553d..a107fedb 100755 --- a/tools/update/kryptik-recover +++ b/tools/update/kryptik-recover @@ -9,6 +9,7 @@ # kryptik-recover --disk DEV --restore-state-header FILE # # --restore-slot also commits the slot, at the medium's (maybe older) version; state is untouched. +# It rewrites an ESP that no longer mounts from the medium's, and the other slot then has no kernel. # The state partition is lost without its LUKS2 header (no escrow): keep a backup off this disk. set -eu PROG=kryptik-recover @@ -23,7 +24,7 @@ while [ $# -gt 0 ]; do --status) STATUS=1; shift ;; --backup-state-header) HDR_OUT="${2:-}"; shift 2 ;; --restore-state-header) HDR_IN="${2:-}"; shift 2 ;; - -h|--help) sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + -h|--help) sed -n '2,13p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; *) die "unknown argument: $1" ;; esac done @@ -63,9 +64,18 @@ put() { printf '%s\n' "$2" > "$1.new" && sync -f "$1.new" && mv -f "$1.new" "$1" uncache() { dd if="$1" iflag=nocache count=0 status=none 2>/dev/null || :; } # uncache FILE if [ "$STATUS" = 1 ]; then - mount -t vfat -o ro "$ESP" /run/kryptik-recover || die "cannot mount the ESP" - committed="$(esp_slot /run/kryptik-recover/kryptik/committed-slot)" echo "disk $DISK" + mver="$(sed -n 's/^VERSION_ID=//p' /etc/os-release | tr -d '"' | head -1)" + # A FAT that no longer mounts records nothing: only the slots' own filesystems are known. + if ! mount -t vfat -o ro "$ESP" /run/kryptik-recover 2>/dev/null; then + echo "ESP ${ESP} does not mount: --restore-slot a or b rewrites it from this medium" + for s in a b; do + printf 'slot %s filesystem %s\n' "$s" "$(has_fs "$(slot_dev "$s")" && echo present || echo NONE)" + done + echo "this medium version ${mver}, which --restore-slot writes" + exit 0 + fi + committed="$(esp_slot /run/kryptik-recover/kryptik/committed-slot)" echo "committed slot $committed" for s in a b; do printf 'slot %s version %s, kernel %s, filesystem %s\n' "$s" \ @@ -104,7 +114,7 @@ if [ "$STATUS" = 1 ]; then next="$(printf '%s\n' "$fw" | sed -n 's/^BootNext: *//p')" case "$next" in ""|"(none)") ;; *) echo " BootNext ${next}: the next boot alone goes there" ;; esac fi - echo "this medium version $(sed -n 's/^VERSION_ID=//p' /etc/os-release | tr -d '"' | head -1), which --restore-slot writes" + echo "this medium version ${mver}, which --restore-slot writes" exit 0 fi @@ -166,8 +176,27 @@ if [ -n "$RESTORE" ]; then kernel_names_root "$kdir/kryptik-$RESTORE.efi" \ || die "the medium's kernel for slot $RESTORE does not carry the root hash its signed command line names; nothing was written" [ "$(blockdev --getsize64 "$sd")" -ge "$bytes" ] || die "slot $RESTORE is smaller than the medium's root image" + if ! mount -t vfat -o rw "$ESP" /run/kryptik-recover 2>/dev/null; then + # An ESP that no longer mounts is rewritten from this medium's, as the + # installer writes it. Its kernel for the other slot is this medium's, + # which need not boot what that slot holds, so that slot is unlisted. + # Mounted read-only elsewhere, it refuses a second, writable mount: that is not damage. + while read -r msrc mwhere _; do + case "$msrc" in /*) ;; *) continue ;; esac + if [ "$(readlink -f "$msrc")" = "$ESP" ]; then + die "${ESP} is mounted on ${mwhere}: unmount it, then run this again; nothing was written" + fi + done < /proc/mounts + case "$media" in usb) esrc="$mesp" ;; *) esrc=/run/kryptik-recover-media/esp.img ;; esac + ebytes="$(stat -c %s "$esrc")"; [ -b "$esrc" ] && ebytes="$(blockdev --getsize64 "$esrc")" + [ "$(blockdev --getsize64 "$ESP")" -ge "$ebytes" ] || die "${ESP} does not mount, and it is smaller than this medium's ESP; nothing was written" + say "${ESP} does not mount: rewriting it from this medium's ESP" + dd if="$esrc" of="$ESP" bs=4M conv=fsync status=none || die "rewriting ${ESP} failed" + mount -t vfat -o rw "$ESP" /run/kryptik-recover || die "cannot mount ${ESP} after rewriting it" + o=b; [ "$RESTORE" = b ] && o=a + rm -f /run/kryptik-recover/kryptik/media-kernel "/run/kryptik-recover/EFI/kryptik/kryptik-$o.efi" "/run/kryptik-recover/kryptik/version-$o" + fi # Like kryptik-update: unlist the slot and unmount, so a cut restore leaves nothing to commit. - mount -t vfat -o rw "$ESP" /run/kryptik-recover || die "cannot mount the target ESP" rm -f "/run/kryptik-recover/EFI/kryptik/kryptik-$RESTORE.efi" "/run/kryptik-recover/kryptik/version-$RESTORE" umount /run/kryptik-recover || die "cannot unmount the target ESP" say "restoring slot $RESTORE from this medium (${ver}, ${bytes} bytes); the state partition is untouched" @@ -198,7 +227,8 @@ if [ -n "$RESTORE" ]; then fi if [ -n "$COMMIT" ]; then - mount -t vfat -o rw "$ESP" /run/kryptik-recover || die "cannot mount the target ESP" + mount -t vfat -o rw "$ESP" /run/kryptik-recover 2>/dev/null \ + || die "${ESP} does not mount, so it holds no kernel to commit: --restore-slot $COMMIT rewrites it, and the slot, from this medium" commit_slot "$COMMIT" umount /run/kryptik-recover exit 0 From eac90a1b6f4a981ee7c4d16da9bdff1eadb0a16e Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 01:29:05 -0700 Subject: [PATCH 167/213] The installer leaves no kernel or version on the ESP for the empty slot b The installer copies the medium's ESP whole, with a kernel and a version record for each slot, then clears slot b. The ESP went on naming slot b at the medium's version, so kryptik-update status and kryptik-recover --status showed a release in a slot that holds nothing, which the guide calls empty until the first update. Rollback was already refused there by its ext4 check; now nothing names the slot either. install-test's first boot runs kryptik-update status as root and wants slot b with no version and no kernel. --- docs/design/boot-and-updates.md | 3 ++- tools/image/install-test.sh | 2 ++ tools/install/kryptik-install.sh | 3 ++- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/design/boot-and-updates.md b/docs/design/boot-and-updates.md index dc4ea3dc..84746623 100644 --- a/docs/design/boot-and-updates.md +++ b/docs/design/boot-and-updates.md @@ -95,7 +95,8 @@ installation, a medium, a test-control disk) is refused unless asks for the state passphrase, partitions, 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. +`BOOTX64.EFI` the slot A kernel. Slot B is cleared, and the ESP names no +kernel or version for it until an update writes one. 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 diff --git a/tools/image/install-test.sh b/tools/image/install-test.sh index 82714cc3..d49452b6 100755 --- a/tools/image/install-test.sh +++ b/tools/image/install-test.sh @@ -101,6 +101,7 @@ python3 "$DRV" --serial "$SER" --timeout 300 --record "$REC" \ "grab:mounts:awk '\$2==\"/\"||\$2==\"/var\"||\$2==\"/etc\"||\$2==\"/home\" {print \$2, \$1, \$3, \$4}' /proc/mounts" \ "grab:secureboot:od -An -tu1 -j4 -N1 /sys/firmware/efi/efivars/SecureBoot-8be4df61-93ca-11d2-aa0d-00e098032b8c 2>/dev/null || echo none" \ "grab:bootresult:cat /var/lib/kryptik/boot/last-result" \ + "su:${RPASS}:kryptik-update status" \ "run:echo KRYPTIK-CLEAR-MARKER-7f3a91 > /home/${TUSER}/persisted-p2 && sync" \ "$(ROOTSH "grep -rqa state[-]pw /proc/[0-9]*/cmdline /run /etc 2>/dev/null && echo PW-LEAK || echo PW-NOLEAK")" "expect:PW-NOLEAK" \ "su:${RPASS}:reboot" \ @@ -132,6 +133,7 @@ else green "a file written under /home is not readable from the raw partition"; want "$P2" 'KRYPTIK_SMOKE: etc_source=overlay' "/etc is an overlay" want "$P2" 'KRYPTIK_SMOKE: root_writable=no' "the verified root is not writable" want "$P2" 'boot-success: slot a up' "boot-success recorded slot a" +want "$P2" 'slot b: +version none, kernel absent' "the ESP names no release for the empty slot b" want "$P2" 'reboot: Restarting system' "the guest rebooted itself" if [[ "$(grep -c 'Linux version' "$P2")" -ge 2 ]]; then green "two kernel boots in one session (reboot worked)"; else red "expected two kernel boots"; fi want "$P2" 'Power down' "the guest powered off from inside" diff --git a/tools/install/kryptik-install.sh b/tools/install/kryptik-install.sh index f8f3f230..d3dd7bbd 100755 --- a/tools/install/kryptik-install.sh +++ b/tools/install/kryptik-install.sh @@ -340,7 +340,8 @@ sync -f "$MNT_BASE/tesp/EFI/BOOT/BOOTX64.EFI.new" mv -f "$MNT_BASE/tesp/EFI/BOOT/BOOTX64.EFI.new" "$MNT_BASE/tesp/EFI/BOOT/BOOTX64.EFI" || die "could not install BOOTX64.EFI" cmp -s "$MNT_BASE/tesp/EFI/BOOT/BOOTX64.EFI" "$MNT_BASE/tesp/EFI/kryptik/kryptik-a.efi" || die "BOOTX64.EFI is not the slot A kernel" printf 'a\n' > "$MNT_BASE/tesp/kryptik/committed-slot" -rm -f "$MNT_BASE/tesp/kryptik/media-kernel" +# Slot b was cleared above: the medium's kernel and version for it would name a release it does not hold. +rm -f "$MNT_BASE/tesp/kryptik/media-kernel" "$MNT_BASE/tesp/EFI/kryptik/kryptik-b.efi" "$MNT_BASE/tesp/kryptik/version-b" sync umount "$MNT_BASE/tesp" || die "could not unmount the new ESP" From f4a2031f3b64475426e70fa90ae72636248c1de5 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 01:31:14 -0700 Subject: [PATCH 168/213] The guide's ESP limitation names kryptik-recover's rewrite too --- docs/user-guide.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/user-guide.md b/docs/user-guide.md index bdf7e122..84d4cc1b 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -428,8 +428,9 @@ shows each timer, its timeout and whether it is running. the disk can damage it, or, with an earlier copy, put a block or its header back as it was, and nothing notices ([state partition](design/state-encryption.md)). -- The installer copies the medium's ESP as it is, and only the kernels on - it are signed, so anything else someone put on a medium's ESP, files or +- The installer copies the medium's ESP as it is, as does `kryptik-recover + --restore-slot` when the disk's own no longer mounts, and only the kernels + on it are signed, so anything else someone put on a medium's ESP, files or its FAT structures, ends up on the installed disk: write the medium from a verified image and keep it out of others' hands. - The keyboard layout is kept in the machine's firmware, not on the disk: From ee77a362f78faad80799a0723ab76117a5b66923 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 01:32:10 -0700 Subject: [PATCH 169/213] The guide's limitations name the 4Kn refusal --- docs/user-guide.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/user-guide.md b/docs/user-guide.md index 665b6c53..bcd082cc 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -394,6 +394,9 @@ shows each timer, its timeout and whether it is running. - No independent security review has been made. - Tested under QEMU with OVMF firmware. Testing on physical hardware is planned for October 2026. +- A disk with 4096-byte logical sectors (4Kn) is refused, as the medium's + boot partition is a FAT of 512-byte sectors that Linux does not mount + there. Disks that present 512-byte sectors (512e), as most do, are fine. - The builds are not reproducible bit for bit; the hashes name what was tested, not what a rebuild would produce. - A program in a zone cannot make its window fullscreen; Alt+e does, and From efd166f6b022649a52a3de696cc0f2a9fb6636bd Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 01:35:29 -0700 Subject: [PATCH 170/213] A failing disk is not rewritten, mount says why, and --status keeps the firmware lines mount's own error now shows, so a damaged FAT reads apart from an I/O error; an ESP whose first block will not read is a failing disk, which --restore-slot refuses before any write and --status names. --status prints the firmware's Kryptik entries and the medium's version whether or not the ESP mounts. --- tools/update/kryptik-recover | 69 +++++++++++++++++++----------------- 1 file changed, 37 insertions(+), 32 deletions(-) diff --git a/tools/update/kryptik-recover b/tools/update/kryptik-recover index a107fedb..369a8aec 100755 --- a/tools/update/kryptik-recover +++ b/tools/update/kryptik-recover @@ -58,6 +58,8 @@ cleanup() { trap cleanup EXIT INT TERM slot_dev() { case "$1" in a) echo "$SA" ;; b) echo "$SB" ;; *) die "slot must be a or b" ;; esac; } has_fs() { [ "$(dd if="$1" bs=1 skip=1080 count=2 status=none | od -An -tx1 | tr -d ' \n')" = "53ef" ]; } +# A FAT that will not mount but reads is damaged; one whose first block will not read is a failing disk. +esp_readable() { dd if="$ESP" of=/dev/null bs=4096 count=1 status=none 2>/dev/null; } # Written, fsynced, renamed, as the kernels are: a power cut leaves the old file whole, not empty. put() { printf '%s\n' "$2" > "$1.new" && sync -f "$1.new" && mv -f "$1.new" "$1"; } # put FILE LINE # Drop FILE's clean pages, so the next read is what the device holds; best effort. @@ -65,39 +67,41 @@ uncache() { dd if="$1" iflag=nocache count=0 status=none 2>/dev/null || :; } # if [ "$STATUS" = 1 ]; then echo "disk $DISK" - mver="$(sed -n 's/^VERSION_ID=//p' /etc/os-release | tr -d '"' | head -1)" - # A FAT that no longer mounts records nothing: only the slots' own filesystems are known. - if ! mount -t vfat -o ro "$ESP" /run/kryptik-recover 2>/dev/null; then - echo "ESP ${ESP} does not mount: --restore-slot a or b rewrites it from this medium" + if mount -t vfat -o ro "$ESP" /run/kryptik-recover; then + committed="$(esp_slot /run/kryptik-recover/kryptik/committed-slot)" + echo "committed slot $committed" for s in a b; do - printf 'slot %s filesystem %s\n' "$s" "$(has_fs "$(slot_dev "$s")" && echo present || echo NONE)" + printf 'slot %s version %s, kernel %s, filesystem %s\n' "$s" \ + "$(esp_version "/run/kryptik-recover/kryptik/version-$s" none)" \ + "$([ -f "/run/kryptik-recover/EFI/kryptik/kryptik-$s.efi" ] && echo present || echo absent)" \ + "$(has_fs "$(slot_dev "$s")" && echo present || echo NONE)" done - echo "this medium version ${mver}, which --restore-slot writes" - exit 0 - fi - committed="$(esp_slot /run/kryptik-recover/kryptik/committed-slot)" - echo "committed slot $committed" - for s in a b; do - printf 'slot %s version %s, kernel %s, filesystem %s\n' "$s" \ - "$(esp_version "/run/kryptik-recover/kryptik/version-$s" none)" \ - "$([ -f "/run/kryptik-recover/EFI/kryptik/kryptik-$s.efi" ] && echo present || echo absent)" \ - "$(has_fs "$(slot_dev "$s")" && echo present || echo NONE)" - done - # The kernel the firmware boots by default, by which slot's it is, and what --restore-slot writes. - boot=/run/kryptik-recover/EFI/BOOT/BOOTX64.EFI; boots="" - for s in a b; do - cmp -s "$boot" "/run/kryptik-recover/EFI/kryptik/kryptik-$s.efi" && boots="$s" - done - if [ -n "$boots" ]; then - echo "BOOTX64.EFI slot ${boots}'s kernel" - elif [ -f "$boot" ]; then - echo "BOOTX64.EFI neither slot's kernel (sha256 $(sha256sum "$boot" | cut -c1-16)...)" + # The kernel the firmware boots by default, by which slot's it is, and what --restore-slot writes. + boot=/run/kryptik-recover/EFI/BOOT/BOOTX64.EFI; boots="" + for s in a b; do + cmp -s "$boot" "/run/kryptik-recover/EFI/kryptik/kryptik-$s.efi" && boots="$s" + done + if [ -n "$boots" ]; then + echo "BOOTX64.EFI slot ${boots}'s kernel" + elif [ -f "$boot" ]; then + echo "BOOTX64.EFI neither slot's kernel (sha256 $(sha256sum "$boot" | cut -c1-16)...)" + else + echo "BOOTX64.EFI absent: the firmware finds nothing to boot by default" + fi + case "$committed" in + a|b) [ "$boots" = "$committed" ] || echo " not the committed slot's kernel: --commit-slot $committed makes it so" ;; + esac else - echo "BOOTX64.EFI absent: the firmware finds nothing to boot by default" + # A FAT that no longer mounts records nothing: only the slots' own filesystems are known. + if esp_readable; then + echo "ESP ${ESP} does not mount: --restore-slot a or b rewrites it from this medium" + else + echo "ESP ${ESP} cannot be read: the disk may be failing, which no rewrite mends" + fi + for s in a b; do + printf 'slot %s filesystem %s\n' "$s" "$(has_fs "$(slot_dev "$s")" && echo present || echo NONE)" + done fi - case "$committed" in - a|b) [ "$boots" = "$committed" ] || echo " not the committed slot's kernel: --commit-slot $committed makes it so" ;; - esac # The firmware's entries for the slots, which --commit-slot writes: some firmware boots a # fixed disk only from one. They are kept by slot, not disk, so one may be another Kryptik's. if [ -d /sys/firmware/efi/efivars ] && fw="$(kryptik-efiboot list 2>/dev/null)"; then @@ -114,7 +118,7 @@ if [ "$STATUS" = 1 ]; then next="$(printf '%s\n' "$fw" | sed -n 's/^BootNext: *//p')" case "$next" in ""|"(none)") ;; *) echo " BootNext ${next}: the next boot alone goes there" ;; esac fi - echo "this medium version ${mver}, which --restore-slot writes" + echo "this medium version $(sed -n 's/^VERSION_ID=//p' /etc/os-release | tr -d '"' | head -1), which --restore-slot writes" exit 0 fi @@ -176,10 +180,11 @@ if [ -n "$RESTORE" ]; then kernel_names_root "$kdir/kryptik-$RESTORE.efi" \ || die "the medium's kernel for slot $RESTORE does not carry the root hash its signed command line names; nothing was written" [ "$(blockdev --getsize64 "$sd")" -ge "$bytes" ] || die "slot $RESTORE is smaller than the medium's root image" - if ! mount -t vfat -o rw "$ESP" /run/kryptik-recover 2>/dev/null; then + if ! mount -t vfat -o rw "$ESP" /run/kryptik-recover; then # An ESP that no longer mounts is rewritten from this medium's, as the # installer writes it. Its kernel for the other slot is this medium's, # which need not boot what that slot holds, so that slot is unlisted. + esp_readable || die "${ESP} cannot be read: the disk may be failing, which no rewrite mends; nothing was written" # Mounted read-only elsewhere, it refuses a second, writable mount: that is not damage. while read -r msrc mwhere _; do case "$msrc" in /*) ;; *) continue ;; esac @@ -227,7 +232,7 @@ if [ -n "$RESTORE" ]; then fi if [ -n "$COMMIT" ]; then - mount -t vfat -o rw "$ESP" /run/kryptik-recover 2>/dev/null \ + mount -t vfat -o rw "$ESP" /run/kryptik-recover \ || die "${ESP} does not mount, so it holds no kernel to commit: --restore-slot $COMMIT rewrites it, and the slot, from this medium" commit_slot "$COMMIT" umount /run/kryptik-recover From 5f4b09bb1e181ac6fd7bc20f7844ded9b6ac034f Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 01:41:00 -0700 Subject: [PATCH 171/213] Stage 06 and the acceptance refuse an executable file the list does not name A file a recipe stops installing stays in the cached sysroot, and stage 06 copies the sysroot into the image: traceroute shipped that way after its recipe dropped it. tools/check-image-programs.sh fails on a regular file with an execute bit that build/config/image-programs.txt does not name, and on an entry that names nothing. Stage 06 runs it on the staged root after the hardening audit, with the list and the tool in s_rootfs's fingerprint, and the acceptance runs it again on the image. The list is every executable file of the image the open build changes make together (the strip, and the libnl, inetutils, Python, gold, libinput and exclude cuts); globs stand only for version numbers and glibc's gconv modules. If one of those changes is declined, its programs come back as NOT NAMED. Data files are not covered: that needs a record of what each step wrote, as status.md's gap now says. --- build/config/image-programs.txt | 1287 +++++++++++++++++++++++++++ build/stages/06-iso.sh | 4 +- docs/status.md | 11 +- tools/acceptance.sh | 2 + tools/check-image-programs.sh | 98 ++ tools/tests/check-image-programs.sh | 51 ++ 6 files changed, 1448 insertions(+), 5 deletions(-) create mode 100644 build/config/image-programs.txt create mode 100755 tools/check-image-programs.sh create mode 100755 tools/tests/check-image-programs.sh diff --git a/build/config/image-programs.txt b/build/config/image-programs.txt new file mode 100644 index 00000000..96e2cceb --- /dev/null +++ b/build/config/image-programs.txt @@ -0,0 +1,1287 @@ +# Every executable file the image carries (tools/check-image-programs.sh): a new one is named +# here, and one a recipe stops installing is removed in that recipe. Patterns stand for versions. +etc/s6-linux-init/skel/rc.init +etc/s6-linux-init/skel/rc.shutdown +etc/s6-linux-init/skel/rc.shutdown.final +etc/s6-linux-init/skel/runlevel +etc/ssl/misc/CA.pl +etc/ssl/misc/tsget.pl +usr/bin/2to3-3.12 +usr/bin/[ +usr/bin/addftinfo +usr/bin/addr2line +usr/bin/afmtodit +usr/bin/ar +usr/bin/as +usr/bin/attr +usr/bin/autopoint +usr/bin/b2sum +usr/bin/background +usr/bin/backtick +usr/bin/base32 +usr/bin/base64 +usr/bin/basename +usr/bin/basenc +usr/bin/bash +usr/bin/bashbug +usr/bin/bc +usr/bin/bison +usr/bin/bits +usr/bin/bomtool +usr/bin/bunzip2 +usr/bin/bzcat +usr/bin/bzdiff +usr/bin/bzgrep +usr/bin/bzip2 +usr/bin/bzip2recover +usr/bin/bzmore +usr/bin/c++ +usr/bin/c++filt +usr/bin/c_rehash +usr/bin/cal +usr/bin/case +usr/bin/cat +usr/bin/catman +usr/bin/chacl +usr/bin/chage +usr/bin/chattr +usr/bin/chem +usr/bin/chfn +usr/bin/chgrp +usr/bin/chmem +usr/bin/chmod +usr/bin/choom +usr/bin/chown +usr/bin/chrt +usr/bin/chsh +usr/bin/chvt +usr/bin/cksum +usr/bin/clear +usr/bin/cmp +usr/bin/col +usr/bin/colcrt +usr/bin/colrm +usr/bin/column +usr/bin/comm +usr/bin/compile_et +usr/bin/copyfilerange +usr/bin/corelist +usr/bin/coresched +usr/bin/cp +usr/bin/cpan +usr/bin/cpp +usr/bin/csplit +usr/bin/cut +usr/bin/date +usr/bin/dc +usr/bin/dd +usr/bin/deallocvt +usr/bin/define +usr/bin/df +usr/bin/di-edid-decode +usr/bin/diff +usr/bin/diff3 +usr/bin/dir +usr/bin/dircolors +usr/bin/dirname +usr/bin/dmesg +usr/bin/dnsdomainname +usr/bin/dollarat +usr/bin/du +usr/bin/dumpkeys +usr/bin/dwl +usr/bin/echo +usr/bin/egrep +usr/bin/eject +usr/bin/elfedit +usr/bin/elgetopt +usr/bin/elgetpositionals +usr/bin/elglob +usr/bin/eltest +usr/bin/emptyenv +usr/bin/enc2xs +usr/bin/encguess +usr/bin/enosys +usr/bin/env +usr/bin/envfile +usr/bin/envsubst +usr/bin/eqn +usr/bin/eqn2graph +usr/bin/exch +usr/bin/exec +usr/bin/execline-cd +usr/bin/execline-umask +usr/bin/execlineb +usr/bin/exit +usr/bin/expand +usr/bin/expiry +usr/bin/export +usr/bin/export-array +usr/bin/expr +usr/bin/factor +usr/bin/fadvise +usr/bin/faillog +usr/bin/fallocate +usr/bin/false +usr/bin/fdblock +usr/bin/fdclose +usr/bin/fdmove +usr/bin/fdreserve +usr/bin/fdswap +usr/bin/fgconsole +usr/bin/fgrep +usr/bin/file +usr/bin/fincore +usr/bin/find +usr/bin/findmnt +usr/bin/flex +usr/bin/flock +usr/bin/fmt +usr/bin/fold +usr/bin/forbacktickx +usr/bin/foreground +usr/bin/forstdin +usr/bin/forx +usr/bin/free +usr/bin/fuser +usr/bin/g++ +usr/bin/gawk +usr/bin/gawk-* +usr/bin/gawkbug +usr/bin/gcc +usr/bin/gcc-ar +usr/bin/gcc-nm +usr/bin/gcc-ranlib +usr/bin/gcov +usr/bin/gcov-dump +usr/bin/gcov-tool +usr/bin/gdbm_dump +usr/bin/gdbm_load +usr/bin/gdbmtool +usr/bin/gdiffmk +usr/bin/gencat +usr/bin/getconf +usr/bin/getcwd +usr/bin/getent +usr/bin/getfacl +usr/bin/getfattr +usr/bin/getino +usr/bin/getkeycodes +usr/bin/getopt +usr/bin/getpid +usr/bin/getsubids +usr/bin/gettext +usr/bin/gettext.sh +usr/bin/gettextize +usr/bin/glilypond +usr/bin/gpasswd +usr/bin/gperf +usr/bin/gperl +usr/bin/gpinyin +usr/bin/gprof +usr/bin/grap2graph +usr/bin/grep +usr/bin/grn +usr/bin/grodvi +usr/bin/groff +usr/bin/grog +usr/bin/grolbp +usr/bin/grolj4 +usr/bin/gropdf +usr/bin/grops +usr/bin/grotty +usr/bin/groups +usr/bin/gunzip +usr/bin/gzexe +usr/bin/gzip +usr/bin/h2ph +usr/bin/h2xs +usr/bin/hardlink +usr/bin/havoc +usr/bin/head +usr/bin/heredoc +usr/bin/hexdump +usr/bin/homeof +usr/bin/hostid +usr/bin/hostname +usr/bin/hpftodit +usr/bin/hugetop +usr/bin/iconv +usr/bin/id +usr/bin/if +usr/bin/ifconfig +usr/bin/ifelse +usr/bin/ifte +usr/bin/ifthenelse +usr/bin/importas +usr/bin/indxbib +usr/bin/info +usr/bin/infocmp +usr/bin/install +usr/bin/install-info +usr/bin/instmodsh +usr/bin/ionice +usr/bin/ipcmk +usr/bin/ipcrm +usr/bin/ipcs +usr/bin/irqtop +usr/bin/isosize +usr/bin/join +usr/bin/json_pp +usr/bin/kbd_mode +usr/bin/kbdinfo +usr/bin/kbdrate +usr/bin/kill +usr/bin/killall +usr/bin/kmod +usr/bin/kryptik +usr/bin/kryptik-chrome +usr/bin/kryptik-launch +usr/bin/kryptik-session +usr/bin/kryptik-wlproxy +usr/bin/kryptikd +usr/bin/last +usr/bin/ld +usr/bin/ld.bfd +usr/bin/ldd +usr/bin/less +usr/bin/lexgrog +usr/bin/libevdev-tweak-device +usr/bin/libinput +usr/bin/libnetcfg +usr/bin/libtool +usr/bin/libtoolize +usr/bin/link +usr/bin/lkbib +usr/bin/ln +usr/bin/loadkeys +usr/bin/loadunimap +usr/bin/locale +usr/bin/localedef +usr/bin/locate +usr/bin/logger +usr/bin/login +usr/bin/logname +usr/bin/look +usr/bin/lookbib +usr/bin/loopwhilex +usr/bin/ls +usr/bin/lsattr +usr/bin/lsblk +usr/bin/lsclocks +usr/bin/lscpu +usr/bin/lsfd +usr/bin/lsipc +usr/bin/lsirq +usr/bin/lslocks +usr/bin/lslogins +usr/bin/lsmem +usr/bin/lsns +usr/bin/lynx +usr/bin/lzmadec +usr/bin/lzmainfo +usr/bin/m4 +usr/bin/make +usr/bin/makedb +usr/bin/man +usr/bin/man-recode +usr/bin/mandb +usr/bin/manpath +usr/bin/mapscrn +usr/bin/mcookie +usr/bin/md5sum +usr/bin/mesg +usr/bin/meson +usr/bin/mk_cmds +usr/bin/mkdir +usr/bin/mkfifo +usr/bin/mknod +usr/bin/mktemp +usr/bin/more +usr/bin/mount +usr/bin/mountpoint +usr/bin/mouse-dpi-tool +usr/bin/msgattrib +usr/bin/msgcat +usr/bin/msgcmp +usr/bin/msgcomm +usr/bin/msgconv +usr/bin/msgen +usr/bin/msgexec +usr/bin/msgfilter +usr/bin/msgfmt +usr/bin/msggrep +usr/bin/msginit +usr/bin/msgmerge +usr/bin/msgunfmt +usr/bin/msguniq +usr/bin/mtdev-test +usr/bin/mtrace +usr/bin/multidefine +usr/bin/multisubstitute +usr/bin/mv +usr/bin/namei +usr/bin/nano +usr/bin/ncursesw6-config +usr/bin/neqn +usr/bin/newgidmap +usr/bin/newgrp +usr/bin/newuidmap +usr/bin/ngettext +usr/bin/nice +usr/bin/ninja +usr/bin/nl +usr/bin/nm +usr/bin/nohup +usr/bin/nproc +usr/bin/nroff +usr/bin/nsenter +usr/bin/numfmt +usr/bin/objcopy +usr/bin/objdump +usr/bin/od +usr/bin/openssl +usr/bin/openvt +usr/bin/passwd +usr/bin/paste +usr/bin/patch +usr/bin/pathchk +usr/bin/pcprofiledump +usr/bin/pdfroff +usr/bin/pdftexi2dvi +usr/bin/peekfd +usr/bin/perl +usr/bin/perl5.* +usr/bin/perlbug +usr/bin/perldoc +usr/bin/perlivp +usr/bin/perlthanks +usr/bin/pfbtops +usr/bin/pgrep +usr/bin/pic +usr/bin/pic2graph +usr/bin/piconv +usr/bin/pidof +usr/bin/pidwait +usr/bin/ping +usr/bin/pinky +usr/bin/pip3 +usr/bin/pip3.12 +usr/bin/pipeline +usr/bin/piperw +usr/bin/pipesz +usr/bin/pkgconf +usr/bin/pkill +usr/bin/pl2pm +usr/bin/pldd +usr/bin/pmap +usr/bin/pod2html +usr/bin/pod2man +usr/bin/pod2texi +usr/bin/pod2text +usr/bin/pod2usage +usr/bin/podchecker +usr/bin/posix-cd +usr/bin/posix-umask +usr/bin/pr +usr/bin/preconv +usr/bin/printenv +usr/bin/printf +usr/bin/prlimit +usr/bin/prove +usr/bin/prtstat +usr/bin/ps +usr/bin/psfxtable +usr/bin/pslog +usr/bin/pstree +usr/bin/ptar +usr/bin/ptardiff +usr/bin/ptargrep +usr/bin/ptx +usr/bin/pwd +usr/bin/pwdx +usr/bin/pydoc3.12 +usr/bin/python3.12 +usr/bin/python3.12-config +usr/bin/ranlib +usr/bin/readelf +usr/bin/readlink +usr/bin/realpath +usr/bin/recode-sr-latin +usr/bin/redirfd +usr/bin/refer +usr/bin/rename +usr/bin/renice +usr/bin/rev +usr/bin/rm +usr/bin/rmdir +usr/bin/runblock +usr/bin/s6-accessrules-cdb-from-fs +usr/bin/s6-accessrules-fs-from-cdb +usr/bin/s6-applyuidgid +usr/bin/s6-background-watch +usr/bin/s6-cleanfifodir +usr/bin/s6-connlimit +usr/bin/s6-envdir +usr/bin/s6-envuidgid +usr/bin/s6-fdholder-daemon +usr/bin/s6-fdholder-delete +usr/bin/s6-fdholder-getdump +usr/bin/s6-fdholder-list +usr/bin/s6-fdholder-retrieve +usr/bin/s6-fdholder-setdump +usr/bin/s6-fdholder-store +usr/bin/s6-fdholder-transferdump +usr/bin/s6-fdholderd +usr/bin/s6-fghack +usr/bin/s6-ftrig-listen +usr/bin/s6-ftrig-listen1 +usr/bin/s6-ftrig-notify +usr/bin/s6-ftrig-wait +usr/bin/s6-instance-control +usr/bin/s6-instance-create +usr/bin/s6-instance-delete +usr/bin/s6-instance-list +usr/bin/s6-instance-maker +usr/bin/s6-instance-status +usr/bin/s6-ioconnect +usr/bin/s6-ipcclient +usr/bin/s6-ipcserver +usr/bin/s6-ipcserver-access +usr/bin/s6-ipcserver-socketbinder +usr/bin/s6-ipcserverd +usr/bin/s6-linux-init +usr/bin/s6-linux-init-echo +usr/bin/s6-linux-init-hpr +usr/bin/s6-linux-init-logouthookd +usr/bin/s6-linux-init-maker +usr/bin/s6-linux-init-nuke +usr/bin/s6-linux-init-shutdown +usr/bin/s6-linux-init-shutdownd +usr/bin/s6-linux-init-telinit +usr/bin/s6-linux-init-umountall +usr/bin/s6-log +usr/bin/s6-mkfifodir +usr/bin/s6-notify-fd-from-socket +usr/bin/s6-notify-socket-from-fd +usr/bin/s6-notifyoncheck +usr/bin/s6-permafailon +usr/bin/s6-rc +usr/bin/s6-rc-compile +usr/bin/s6-rc-db +usr/bin/s6-rc-dryrun +usr/bin/s6-rc-format-upgrade +usr/bin/s6-rc-init +usr/bin/s6-rc-repo-init +usr/bin/s6-rc-repo-list +usr/bin/s6-rc-repo-sync +usr/bin/s6-rc-set-change +usr/bin/s6-rc-set-commit +usr/bin/s6-rc-set-copy +usr/bin/s6-rc-set-delete +usr/bin/s6-rc-set-fix +usr/bin/s6-rc-set-install +usr/bin/s6-rc-set-new +usr/bin/s6-rc-set-status +usr/bin/s6-rc-update +usr/bin/s6-setlock +usr/bin/s6-setsid +usr/bin/s6-setuidgid +usr/bin/s6-socklog +usr/bin/s6-softlimit +usr/bin/s6-sudo +usr/bin/s6-sudoc +usr/bin/s6-sudod +usr/bin/s6-supervise +usr/bin/s6-svc +usr/bin/s6-svdt +usr/bin/s6-svdt-clear +usr/bin/s6-svlink +usr/bin/s6-svlisten +usr/bin/s6-svlisten1 +usr/bin/s6-svok +usr/bin/s6-svperms +usr/bin/s6-svscan +usr/bin/s6-svscanboot +usr/bin/s6-svscanctl +usr/bin/s6-svstat +usr/bin/s6-svunlink +usr/bin/s6-svwait +usr/bin/s6-tai64n +usr/bin/s6-tai64nlocal +usr/bin/s6-usertree-maker +usr/bin/script +usr/bin/scriptlive +usr/bin/scriptreplay +usr/bin/sdiff +usr/bin/seatd +usr/bin/seatd-launch +usr/bin/sed +usr/bin/seq +usr/bin/setarch +usr/bin/setfacl +usr/bin/setfattr +usr/bin/setfont +usr/bin/setkeycodes +usr/bin/setleds +usr/bin/setmetamode +usr/bin/setpgid +usr/bin/setsid +usr/bin/setterm +usr/bin/setvtrgb +usr/bin/sha1sum +usr/bin/sha224sum +usr/bin/sha256sum +usr/bin/sha384sum +usr/bin/sha512sum +usr/bin/shasum +usr/bin/shift +usr/bin/showconsolefont +usr/bin/showkey +usr/bin/shred +usr/bin/shuf +usr/bin/size +usr/bin/slabtop +usr/bin/sleep +usr/bin/soelim +usr/bin/sort +usr/bin/sotruss +usr/bin/splain +usr/bin/split +usr/bin/sprof +usr/bin/ssh-keygen +usr/bin/stat +usr/bin/stdbuf +usr/bin/streamzip +usr/bin/strings +usr/bin/strip +usr/bin/stty +usr/bin/su +usr/bin/sum +usr/bin/sync +usr/bin/tabs +usr/bin/tac +usr/bin/tail +usr/bin/tar +usr/bin/taskset +usr/bin/tbl +usr/bin/tee +usr/bin/test +usr/bin/texi2any +usr/bin/texi2dvi +usr/bin/texi2pdf +usr/bin/texindex +usr/bin/tfmtodit +usr/bin/tic +usr/bin/timeout +usr/bin/tload +usr/bin/toe +usr/bin/top +usr/bin/touch +usr/bin/touchpad-edge-detector +usr/bin/tput +usr/bin/tr +usr/bin/trap +usr/bin/troff +usr/bin/true +usr/bin/truncate +usr/bin/tryexec +usr/bin/tset +usr/bin/tsort +usr/bin/tty +usr/bin/tzselect +usr/bin/uclampset +usr/bin/ucspilogd +usr/bin/ul +usr/bin/umount +usr/bin/uname +usr/bin/uncompress +usr/bin/unexpand +usr/bin/unexport +usr/bin/unicode_start +usr/bin/unicode_stop +usr/bin/uniq +usr/bin/unlink +usr/bin/unshare +usr/bin/updatedb +usr/bin/uptime +usr/bin/users +usr/bin/utmpdump +usr/bin/uuidgen +usr/bin/uuidparse +usr/bin/vdir +usr/bin/vmstat +usr/bin/w +usr/bin/wait +usr/bin/waitpid +usr/bin/wall +usr/bin/watch +usr/bin/wayland-scanner +usr/bin/wc +usr/bin/wdctl +usr/bin/whatis +usr/bin/whereis +usr/bin/who +usr/bin/whoami +usr/bin/withstdinas +usr/bin/x86_64-kryptik-linux-gnu-c++ +usr/bin/x86_64-kryptik-linux-gnu-g++ +usr/bin/x86_64-kryptik-linux-gnu-gcc +usr/bin/x86_64-kryptik-linux-gnu-gcc-* +usr/bin/x86_64-kryptik-linux-gnu-gcc-ar +usr/bin/x86_64-kryptik-linux-gnu-gcc-nm +usr/bin/x86_64-kryptik-linux-gnu-gcc-ranlib +usr/bin/xargs +usr/bin/xgettext +usr/bin/xmlwf +usr/bin/xsubpp +usr/bin/xtrace +usr/bin/xz +usr/bin/xzdec +usr/bin/xzdiff +usr/bin/xzgrep +usr/bin/xzless +usr/bin/xzmore +usr/bin/yacc +usr/bin/yes +usr/bin/zcat +usr/bin/zcmp +usr/bin/zdiff +usr/bin/zdump +usr/bin/zegrep +usr/bin/zfgrep +usr/bin/zforce +usr/bin/zgrep +usr/bin/zipdetails +usr/bin/zless +usr/bin/zmore +usr/bin/znew +usr/bin/zstd +usr/bin/zstdgrep +usr/bin/zstdless +usr/lib/audit/sotruss-lib.so +usr/lib/bash/accept +usr/lib/bash/basename +usr/lib/bash/csv +usr/lib/bash/cut +usr/lib/bash/dirname +usr/lib/bash/dsv +usr/lib/bash/fdflags +usr/lib/bash/finfo +usr/lib/bash/getconf +usr/lib/bash/head +usr/lib/bash/id +usr/lib/bash/ln +usr/lib/bash/logname +usr/lib/bash/mkdir +usr/lib/bash/mkfifo +usr/lib/bash/mktemp +usr/lib/bash/mypid +usr/lib/bash/pathchk +usr/lib/bash/print +usr/lib/bash/printenv +usr/lib/bash/push +usr/lib/bash/realpath +usr/lib/bash/rm +usr/lib/bash/rmdir +usr/lib/bash/seq +usr/lib/bash/setpgid +usr/lib/bash/sleep +usr/lib/bash/stat +usr/lib/bash/strftime +usr/lib/bash/sync +usr/lib/bash/tee +usr/lib/bash/truefalse +usr/lib/bash/tty +usr/lib/bash/uname +usr/lib/bash/unlink +usr/lib/bash/whoami +usr/lib/bfd-plugins/libdep.so +usr/lib/dhcpcd/dev/udev.so +usr/lib/dhcpcd/dhcpcd-run-hooks +usr/lib/e2initrd_helper +usr/lib/engines-3/afalg.so +usr/lib/engines-3/capi.so +usr/lib/engines-3/loader_attic.so +usr/lib/engines-3/padlock.so +usr/lib/gawk/filefuncs.so +usr/lib/gawk/fnmatch.so +usr/lib/gawk/fork.so +usr/lib/gawk/inplace.so +usr/lib/gawk/intdiv.so +usr/lib/gawk/ordchr.so +usr/lib/gawk/readdir.so +usr/lib/gawk/readfile.so +usr/lib/gawk/revoutput.so +usr/lib/gawk/revtwoway.so +usr/lib/gawk/rwarray.so +usr/lib/gawk/time.so +usr/lib/gcc/x86_64-kryptik-linux-gnu/*/plugin/libcc1plugin.la +usr/lib/gcc/x86_64-kryptik-linux-gnu/*/plugin/libcc1plugin.so.0.* +usr/lib/gcc/x86_64-kryptik-linux-gnu/*/plugin/libcp1plugin.la +usr/lib/gcc/x86_64-kryptik-linux-gnu/*/plugin/libcp1plugin.so.0.* +usr/lib/gconv/*.so +usr/lib/gettext/cldr-plurals +usr/lib/gettext/hostname +usr/lib/gettext/project-id +usr/lib/gettext/urlget +usr/lib/gettext/user-email +usr/lib/kryptik/compartments/kryptikd/probes/boundary-checks.sh +usr/lib/kryptik/compartments/kryptikd/probes/fixtures.sh +usr/lib/kryptik/compartments/tests/adversarial.sh +usr/lib/kryptik/compartments/tests/cli.sh +usr/lib/kryptik/compartments/tests/launcher.sh +usr/lib/kryptik/compartments/tests/serve.sh +usr/lib/kryptik/guest-tests/broker-client.py +usr/lib/kryptik/guest-tests/gui-check.sh +usr/lib/kryptik/guest-tests/icmp-echo.py +usr/lib/kryptik/guest-tests/zones-check.sh +usr/lib/kryptik/s6-rc/compiled/servicedirs/eudev/run +usr/lib/kryptik/s6-rc/compiled/servicedirs/getty-tty1/run +usr/lib/kryptik/s6-rc/compiled/servicedirs/getty-tty2/run +usr/lib/kryptik/s6-rc/compiled/servicedirs/kryptikd-serve/run +usr/lib/kryptik/s6-rc/compiled/servicedirs/net-zone/run +usr/lib/kryptik/s6-rc/compiled/servicedirs/s6rc-fdholder/run +usr/lib/kryptik/s6-rc/compiled/servicedirs/s6rc-oneshot-runner/run +usr/lib/kryptik/s6-rc/compiled/servicedirs/seatd/run +usr/lib/kryptik/s6-rc/compiled/servicedirs/watchdog/run +usr/lib/ld-linux-x86-64.so.2 +usr/lib/libBrokenLocale.so.1 +usr/lib/libacl.la +usr/lib/libacl.so.1.* +usr/lib/libaio.so.1.* +usr/lib/libanl.so.1 +usr/lib/libasprintf.la +usr/lib/libattr.la +usr/lib/libattr.so.1.* +usr/lib/libbfd-*.so +usr/lib/libbfd.la +usr/lib/libblkid.la +usr/lib/libblkid.so.1.* +usr/lib/libbz2.so.1.* +usr/lib/libc.so.6 +usr/lib/libc_malloc_debug.so.0 +usr/lib/libcap.so.2.* +usr/lib/libcc1.la +usr/lib/libcc1.so.0.* +usr/lib/libcom_err.so.2.* +usr/lib/libcrypt.la +usr/lib/libcrypt.so.2.* +usr/lib/libcrypto.so.3 +usr/lib/libcryptsetup.la +usr/lib/libcryptsetup.so.12.* +usr/lib/libctf-nobfd.la +usr/lib/libctf-nobfd.so.0.* +usr/lib/libctf.la +usr/lib/libctf.so.0.* +usr/lib/libdevmapper.so.1.* +usr/lib/libdisplay-info.so.0.* +usr/lib/libdl.so.2 +usr/lib/libdrm.so.2.* +usr/lib/libe2p.so.2.* +usr/lib/libelf-*.so +usr/lib/libevdev.so.2.* +usr/lib/libexpat.la +usr/lib/libexpat.so.1.* +usr/lib/libext2fs.so.2.* +usr/lib/libfdisk.la +usr/lib/libfdisk.so.1.* +usr/lib/libffi.la +usr/lib/libffi.so.8.* +usr/lib/libfl.la +usr/lib/libfl.so.2.* +usr/lib/libformw.so.6.* +usr/lib/libgdbm.so.6.* +usr/lib/libgettextpo.la +usr/lib/libgmp.la +usr/lib/libgmp.so.10.* +usr/lib/libgmpxx.la +usr/lib/libgmpxx.so.4.* +usr/lib/libhardened_malloc.so +usr/lib/libhistory.so.8.* +usr/lib/libinput.so.10.* +usr/lib/libitm.la +usr/lib/libitm.so.1.* +usr/lib/libjson-c.so.5.* +usr/lib/libkmod.la +usr/lib/libkmod.so.2.* +usr/lib/libltdl.la +usr/lib/libltdl.so.7.* +usr/lib/liblzma.so.5.* +usr/lib/libm.so.6 +usr/lib/libmagic.la +usr/lib/libmagic.so.1.* +usr/lib/libmemusage.so +usr/lib/libmenuw.so.6.* +usr/lib/libmnl.la +usr/lib/libmnl.so.0.* +usr/lib/libmount.la +usr/lib/libmount.so.1.* +usr/lib/libmpc.la +usr/lib/libmpc.so.3.* +usr/lib/libmpfr.la +usr/lib/libmpfr.so.6.* +usr/lib/libmtdev.la +usr/lib/libmtdev.so.1.* +usr/lib/libmvec.so.1 +usr/lib/libncurses++w.so.6.* +usr/lib/libncursesw.so.6.* +usr/lib/libnftables.la +usr/lib/libnftables.so.1.* +usr/lib/libnftnl.la +usr/lib/libnftnl.so.11.* +usr/lib/libnl-3.la +usr/lib/libnl-3.so.200.* +usr/lib/libnl-genl-3.la +usr/lib/libnl-genl-3.so.200.* +usr/lib/libnl-idiag-3.la +usr/lib/libnl-idiag-3.so.200.* +usr/lib/libnl-nf-3.la +usr/lib/libnl-nf-3.so.200.* +usr/lib/libnl-route-3.la +usr/lib/libnl-route-3.so.200.* +usr/lib/libnl-xfrm-3.la +usr/lib/libnl-xfrm-3.so.200.* +usr/lib/libnsl.so.1 +usr/lib/libnss_compat.so.2 +usr/lib/libnss_db.so.2 +usr/lib/libnss_dns.so.2 +usr/lib/libnss_files.so.2 +usr/lib/libnss_hesiod.so.2 +usr/lib/libopcodes-*.so +usr/lib/libopcodes.la +usr/lib/libpanelw.so.6.* +usr/lib/libpcprofile.so +usr/lib/libpipeline.la +usr/lib/libpipeline.so.1.* +usr/lib/libpixman-1.so.0.* +usr/lib/libpkgconf.la +usr/lib/libpkgconf.so.5.* +usr/lib/libpopt.la +usr/lib/libpopt.so.0.* +usr/lib/libproc2.la +usr/lib/libproc2.so.1.* +usr/lib/libpsx.so.2.* +usr/lib/libpthread.so.0 +usr/lib/libpython3.12.so.1.* +usr/lib/libpython3.so +usr/lib/libreadline.so.8.* +usr/lib/libresolv.so.2 +usr/lib/librt.so.1 +usr/lib/libseat.so.1 +usr/lib/libsframe.la +usr/lib/libsframe.so.1.* +usr/lib/libskarnet.so.2.* +usr/lib/libsmartcols.la +usr/lib/libsmartcols.so.1.* +usr/lib/libss.so.2.* +usr/lib/libssl.so.3 +usr/lib/libstdc++.la +usr/lib/libstdc++.so.6.* +usr/lib/libstdc++exp.la +usr/lib/libstdc++fs.la +usr/lib/libsubid.la +usr/lib/libsubid.so.5.* +usr/lib/libsupc++.la +usr/lib/libtextstyle.la +usr/lib/libthread_db.so.1 +usr/lib/libudev.la +usr/lib/libudev.so.1.* +usr/lib/libutil.so.1 +usr/lib/libuuid.la +usr/lib/libuuid.so.1.* +usr/lib/libwayland-client.so.0.* +usr/lib/libwayland-cursor.so.0.* +usr/lib/libwayland-egl.so.1.* +usr/lib/libwayland-server.so.0.* +usr/lib/libwlroots-*.so +usr/lib/libxkbcommon.so.0.* +usr/lib/libz.so.1.* +usr/lib/libzstd.so.1.* +usr/lib/man-db/libman-*.so +usr/lib/man-db/libman.la +usr/lib/man-db/libmandb-*.so +usr/lib/man-db/libmandb.la +usr/lib/meson/meson.py +usr/lib/meson/mesonbuild/msubprojects.py +usr/lib/meson/mesonbuild/scripts/cmake_run_ctgt.py +usr/lib/meson/mesonbuild/scripts/env2mfile.py +usr/lib/meson/mesonbuild/scripts/python_info.py +usr/lib/meson/mesonbuild/scripts/reprotest.py +usr/lib/ossl-modules/legacy.so +usr/lib/perl5/*/x86_64-linux-thread-multi/CORE/libperl.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/B/B.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/Compress/Raw/Bzip2/Bzip2.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/Compress/Raw/Zlib/Zlib.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/Cwd/Cwd.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/Data/Dumper/Dumper.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/Devel/Peek/Peek.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/Digest/MD5/MD5.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/Digest/SHA/SHA.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/Encode/Byte/Byte.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/Encode/CN/CN.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/Encode/EBCDIC/EBCDIC.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/Encode/Encode.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/Encode/JP/JP.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/Encode/KR/KR.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/Encode/Symbol/Symbol.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/Encode/TW/TW.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/Encode/Unicode/Unicode.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/Fcntl/Fcntl.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/File/DosGlob/DosGlob.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/File/Glob/Glob.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/Filter/Util/Call/Call.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/Hash/Util/FieldHash/FieldHash.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/Hash/Util/Util.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/I18N/Langinfo/Langinfo.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/IO/IO.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/IPC/SysV/SysV.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/List/Util/Util.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/MIME/Base64/Base64.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/Math/BigInt/FastCalc/FastCalc.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/Opcode/Opcode.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/POSIX/POSIX.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/PerlIO/encoding/encoding.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/PerlIO/mmap/mmap.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/PerlIO/via/via.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/SDBM_File/SDBM_File.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/Socket/Socket.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/Storable/Storable.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/Sys/Hostname/Hostname.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/Sys/Syslog/Syslog.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/Time/HiRes/HiRes.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/Time/Piece/Piece.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/Unicode/Collate/Collate.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/Unicode/Normalize/Normalize.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/attributes/attributes.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/mro/mro.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/re/re.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/threads/shared/shared.so +usr/lib/perl5/*/x86_64-linux-thread-multi/auto/threads/threads.so +usr/lib/python3.12/base64.py +usr/lib/python3.12/cProfile.py +usr/lib/python3.12/cgi.py +usr/lib/python3.12/config-3.12-x86_64-linux-gnu/install-sh +usr/lib/python3.12/config-3.12-x86_64-linux-gnu/makesetup +usr/lib/python3.12/config-3.12-x86_64-linux-gnu/python-config.py +usr/lib/python3.12/ctypes/macholib/fetch_macholib +usr/lib/python3.12/encodings/rot_13.py +usr/lib/python3.12/lib-dynload/_asyncio.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/_bisect.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/_blake2.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/_bz2.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/_codecs_cn.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/_codecs_hk.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/_codecs_iso2022.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/_codecs_jp.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/_codecs_kr.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/_codecs_tw.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/_contextvars.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/_crypt.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/_csv.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/_ctypes.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/_curses.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/_curses_panel.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/_datetime.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/_decimal.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/_elementtree.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/_gdbm.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/_hashlib.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/_heapq.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/_json.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/_lsprof.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/_lzma.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/_md5.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/_multibytecodec.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/_multiprocessing.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/_opcode.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/_pickle.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/_posixshmem.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/_posixsubprocess.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/_queue.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/_random.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/_sha1.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/_sha2.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/_sha3.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/_socket.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/_ssl.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/_statistics.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/_struct.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/_uuid.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/_xxinterpchannels.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/_xxsubinterpreters.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/_zoneinfo.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/array.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/audioop.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/binascii.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/cmath.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/fcntl.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/grp.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/math.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/mmap.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/ossaudiodev.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/pyexpat.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/readline.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/resource.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/select.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/spwd.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/syslog.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/termios.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/unicodedata.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib-dynload/zlib.cpython-312-x86_64-linux-gnu.so +usr/lib/python3.12/lib2to3/pgen2/token.py +usr/lib/python3.12/pdb.py +usr/lib/python3.12/platform.py +usr/lib/python3.12/profile.py +usr/lib/python3.12/pydoc.py +usr/lib/python3.12/quopri.py +usr/lib/python3.12/smtplib.py +usr/lib/python3.12/tabnanny.py +usr/lib/python3.12/tarfile.py +usr/lib/python3.12/timeit.py +usr/lib/python3.12/trace.py +usr/lib/python3.12/webbrowser.py +usr/lib/s6-linux-init/current/bin/halt +usr/lib/s6-linux-init/current/bin/init +usr/lib/s6-linux-init/current/bin/poweroff +usr/lib/s6-linux-init/current/bin/reboot +usr/lib/s6-linux-init/current/bin/shutdown +usr/lib/s6-linux-init/current/bin/telinit +usr/lib/s6-linux-init/current/run-image/service/.s6-svscan/SIGINT +usr/lib/s6-linux-init/current/run-image/service/.s6-svscan/SIGPWR +usr/lib/s6-linux-init/current/run-image/service/.s6-svscan/SIGQUIT +usr/lib/s6-linux-init/current/run-image/service/.s6-svscan/SIGTERM +usr/lib/s6-linux-init/current/run-image/service/.s6-svscan/SIGUSR1 +usr/lib/s6-linux-init/current/run-image/service/.s6-svscan/SIGUSR2 +usr/lib/s6-linux-init/current/run-image/service/.s6-svscan/SIGWINCH +usr/lib/s6-linux-init/current/run-image/service/.s6-svscan/crash +usr/lib/s6-linux-init/current/run-image/service/.s6-svscan/finish +usr/lib/s6-linux-init/current/run-image/service/s6-linux-init-early-getty/run +usr/lib/s6-linux-init/current/run-image/service/s6-linux-init-runleveld/run +usr/lib/s6-linux-init/current/run-image/service/s6-linux-init-shutdownd/run +usr/lib/s6-linux-init/current/run-image/service/s6-svscan-log/run +usr/lib/s6-linux-init/current/scripts/rc.init +usr/lib/s6-linux-init/current/scripts/rc.shutdown +usr/lib/s6-linux-init/current/scripts/rc.shutdown.final +usr/lib/s6-linux-init/current/scripts/runlevel +usr/lib/udev/ata_id +usr/lib/udev/cdrom_id +usr/lib/udev/collect +usr/lib/udev/dmi_memory_id +usr/lib/udev/fido_id +usr/lib/udev/libinput-device-group +usr/lib/udev/libinput-fuzz-extract +usr/lib/udev/libinput-fuzz-to-zero +usr/lib/udev/mtd_probe +usr/lib/udev/scsi_id +usr/lib/udev/v4l_id +usr/libexec/awk/grcat +usr/libexec/awk/pwcat +usr/libexec/coreutils/libstdbuf.so +usr/libexec/frcode +usr/libexec/gcc/x86_64-kryptik-linux-gnu/*/cc1 +usr/libexec/gcc/x86_64-kryptik-linux-gnu/*/cc1plus +usr/libexec/gcc/x86_64-kryptik-linux-gnu/*/collect2 +usr/libexec/gcc/x86_64-kryptik-linux-gnu/*/g++-mapper-server +usr/libexec/gcc/x86_64-kryptik-linux-gnu/*/install-tools/mkinstalldirs +usr/libexec/gcc/x86_64-kryptik-linux-gnu/*/liblto_plugin.la +usr/libexec/gcc/x86_64-kryptik-linux-gnu/*/liblto_plugin.so +usr/libexec/gcc/x86_64-kryptik-linux-gnu/*/lto-wrapper +usr/libexec/gcc/x86_64-kryptik-linux-gnu/*/lto1 +usr/libexec/gcc/x86_64-kryptik-linux-gnu/*/plugin/gengtype +usr/libexec/getconf/POSIX_V6_LP64_OFF64 +usr/libexec/getconf/POSIX_V7_LP64_OFF64 +usr/libexec/getconf/XBS5_LP64_OFF64 +usr/libexec/kryptik-console +usr/libexec/kryptik/ask.sh +usr/libexec/kryptik/boot-smoke.sh +usr/libexec/kryptik/boot-success.sh +usr/libexec/kryptik/devices.sh +usr/libexec/kryptik/dhcpcd-hook +usr/libexec/kryptik/esp-records.sh +usr/libexec/kryptik/eudev-trigger.sh +usr/libexec/kryptik/firstboot.sh +usr/libexec/kryptik/installer-run.sh +usr/libexec/kryptik/keyboard.sh +usr/libexec/kryptik/kryptikd-check.sh +usr/libexec/kryptik/medium-root.sh +usr/libexec/kryptik/netzone-init.sh +usr/libexec/kryptik/sntp-offset.py +usr/libexec/kryptik/sysinit.sh +usr/libexec/kryptik/testctl.sh +usr/libexec/kryptik/time-floor.sh +usr/libexec/kryptik/update-fetch.py +usr/libexec/kryptik/watchdog.sh +usr/libexec/kryptik/wlprobe +usr/libexec/less-osc8-open +usr/libexec/lessecho +usr/libexec/libinput/libinput-debug-events +usr/libexec/libinput/libinput-debug-tablet +usr/libexec/libinput/libinput-debug-tablet-pad +usr/libexec/libinput/libinput-list-devices +usr/libexec/libinput/libinput-quirks +usr/libexec/libinput/libinput-record +usr/libexec/libinput/libinput-test +usr/libexec/man-db/globbing +usr/libexec/man-db/manconv +usr/libexec/man-db/zsoelim +usr/libexec/rmt +usr/libexec/s6-ftrigrd +usr/libexec/s6-rc-fdholder-filler +usr/libexec/s6-rc-oneshot-run +usr/sbin/accessdb +usr/sbin/addpart +usr/sbin/agetty +usr/sbin/badblocks +usr/sbin/blkdeactivate +usr/sbin/blkdiscard +usr/sbin/blkid +usr/sbin/blkpr +usr/sbin/blkzone +usr/sbin/blockdev +usr/sbin/bridge +usr/sbin/capsh +usr/sbin/cfdisk +usr/sbin/chcpu +usr/sbin/chgpasswd +usr/sbin/chpasswd +usr/sbin/chroot +usr/sbin/cryptsetup +usr/sbin/ctrlaltdel +usr/sbin/dcb +usr/sbin/debugfs +usr/sbin/delpart +usr/sbin/devlink +usr/sbin/dhcpcd +usr/sbin/dmsetup +usr/sbin/dnsmasq +usr/sbin/dpll +usr/sbin/dumpe2fs +usr/sbin/e2freefrag +usr/sbin/e2fsck +usr/sbin/e2image +usr/sbin/e2label +usr/sbin/e2mmpstatus +usr/sbin/e2scrub +usr/sbin/e2scrub_all +usr/sbin/e2undo +usr/sbin/e4crypt +usr/sbin/e4defrag +usr/sbin/fdisk +usr/sbin/filefrag +usr/sbin/findfs +usr/sbin/fsck +usr/sbin/fsck.cramfs +usr/sbin/fsck.ext2 +usr/sbin/fsck.ext3 +usr/sbin/fsck.ext4 +usr/sbin/fsck.minix +usr/sbin/fsfreeze +usr/sbin/fstrim +usr/sbin/genl +usr/sbin/getcap +usr/sbin/getpcaps +usr/sbin/groupadd +usr/sbin/groupdel +usr/sbin/groupmems +usr/sbin/groupmod +usr/sbin/grpck +usr/sbin/grpconv +usr/sbin/grpunconv +usr/sbin/halt +usr/sbin/hwclock +usr/sbin/iconvconfig +usr/sbin/ifstat +usr/sbin/init +usr/sbin/integritysetup +usr/sbin/ip +usr/sbin/iw +usr/sbin/kryptik-efiboot +usr/sbin/kryptik-install +usr/sbin/kryptik-recover +usr/sbin/kryptik-update +usr/sbin/ldattach +usr/sbin/ldconfig +usr/sbin/lnstat +usr/sbin/logoutd +usr/sbin/logsave +usr/sbin/losetup +usr/sbin/mke2fs +usr/sbin/mkfs +usr/sbin/mkfs.bfs +usr/sbin/mkfs.cramfs +usr/sbin/mkfs.ext2 +usr/sbin/mkfs.ext3 +usr/sbin/mkfs.ext4 +usr/sbin/mkfs.minix +usr/sbin/mklost+found +usr/sbin/mkswap +usr/sbin/netshaper +usr/sbin/newusers +usr/sbin/nft +usr/sbin/nologin +usr/sbin/nstat +usr/sbin/partx +usr/sbin/pivot_root +usr/sbin/poweroff +usr/sbin/pwck +usr/sbin/pwconv +usr/sbin/pwunconv +usr/sbin/rdma +usr/sbin/readprofile +usr/sbin/reboot +usr/sbin/resize2fs +usr/sbin/resizepart +usr/sbin/rfkill +usr/sbin/routel +usr/sbin/rtacct +usr/sbin/rtcwake +usr/sbin/rtmon +usr/sbin/setcap +usr/sbin/sfdisk +usr/sbin/shutdown +usr/sbin/sln +usr/sbin/ss +usr/sbin/sulogin +usr/sbin/swaplabel +usr/sbin/swapoff +usr/sbin/swapon +usr/sbin/switch_root +usr/sbin/sysctl +usr/sbin/tc +usr/sbin/telinit +usr/sbin/tipc +usr/sbin/tune2fs +usr/sbin/udevadm +usr/sbin/udevd +usr/sbin/useradd +usr/sbin/userdel +usr/sbin/usermod +usr/sbin/uuidd +usr/sbin/vdpa +usr/sbin/veritysetup +usr/sbin/vipw +usr/sbin/wipefs +usr/sbin/wpa_cli +usr/sbin/wpa_passphrase +usr/sbin/wpa_supplicant +usr/sbin/zic +usr/sbin/zramctl +usr/share/doc/gettext/examples/* +usr/share/doc/nftables/examples/* +usr/share/gettext/config.rpath +usr/share/gettext/projects/GNOME/team-address +usr/share/gettext/projects/GNOME/trigger +usr/share/gettext/projects/KDE/team-address +usr/share/gettext/projects/KDE/trigger +usr/share/gettext/projects/TP/team-address +usr/share/gettext/projects/TP/trigger +usr/share/gettext/projects/team-address +usr/share/libtool/build-aux/compile +usr/share/libtool/build-aux/config.guess +usr/share/libtool/build-aux/config.sub +usr/share/libtool/build-aux/depcomp +usr/share/libtool/build-aux/install-sh +usr/share/libtool/build-aux/missing +usr/share/libtool/configure +usr/share/texinfo/texindex.awk diff --git a/build/stages/06-iso.sh b/build/stages/06-iso.sh index 13463889..77d04981 100755 --- a/build/stages/06-iso.sh +++ b/build/stages/06-iso.sh @@ -216,6 +216,8 @@ EOF # The objects that ship, audited as they are packed: a finding fails the # build here; make acceptance audits the image again for the record. "${KRYPTIK_ROOT}/tools/check-artifact-hardening.sh" --strict "$stage" + # Every program the image carries is named, so one a recipe stopped installing cannot ship. + "${KRYPTIK_ROOT}/tools/check-image-programs.sh" "$stage" echo "--- ext4 (${fs_bytes} bytes, no journal, read-only by design) ---" local img="${IMG}/kryptik-root.img" @@ -627,7 +629,7 @@ check_bound() { # --- run -------------------------------------------------------------------- [[ "$PHASE" != sign ]] || check_bound -bound rootfs s_rootfs "$KRYPTIK_VERSION" "$(cat "${KRYPTIK_ROOT}/build/config/image-excludes.txt" "${KRYPTIK_ROOT}/build/config/setuid-allowlist.txt" "${KRYPTIK_ROOT}/build/config/capability-allowlist.txt" "${KRYPTIK_ROOT}/tools/audit-setuid.sh" "${KRYPTIK_ROOT}/build/config/artifact-accepted.txt" "${KRYPTIK_ROOT}/tools/check-artifact-hardening.sh" | sha256_of_stdin)" "$KRYPTIK_CHANNEL" "$ROLE" "$(_hash_file "$ANCHOR")" +bound rootfs s_rootfs "$KRYPTIK_VERSION" "$(cat "${KRYPTIK_ROOT}/build/config/image-excludes.txt" "${KRYPTIK_ROOT}/build/config/setuid-allowlist.txt" "${KRYPTIK_ROOT}/build/config/capability-allowlist.txt" "${KRYPTIK_ROOT}/tools/audit-setuid.sh" "${KRYPTIK_ROOT}/build/config/artifact-accepted.txt" "${KRYPTIK_ROOT}/tools/check-artifact-hardening.sh" "${KRYPTIK_ROOT}/build/config/image-programs.txt" "${KRYPTIK_ROOT}/tools/check-image-programs.sh" | sha256_of_stdin)" "$KRYPTIK_CHANNEL" "$ROLE" "$(_hash_file "$ANCHOR")" bound cmdlines s_cmdlines "$(_hash_file "${IMG}/root.json")" bound bind-kernels s_bind_kernels "$(cat "${IMG}"/cmdlines/{slot-a,slot-b,media-usb}.txt | sha256_of_stdin)" bound bind-iso s_bind_iso "$(cat "${IMG}/root.json" "$SB_CERT" | sha256_of_stdin)" diff --git a/docs/status.md b/docs/status.md index 067eb25e..21c32425 100644 --- a/docs/status.md +++ b/docs/status.md @@ -71,7 +71,10 @@ media it tested. from the stage 02 tree, but a recipe change does not: a file its new version no longer installs stays in the image, as it does in a local work directory rebuilt with `KRYPTIK_STALE=rebuild`. A tag's run restores no - cache, so a release is free of this; the dated builds are not. Removing such files safely - needs a record of what each rebuild wrote, not a before and after listing, - with removals held to the end of the stage and never of a shared object - something still links. + cache, so a release is free of this; the dated builds are not. Stage 06 and + the acceptance refuse an image whose executable files differ from + `build/config/image-programs.txt`, so a program left behind fails the build + until its recipe removes it; a data file left behind is not caught. + Removing such files without the recipe needs a record of what each rebuild + wrote, not a before and after listing, with removals held to the end of the + stage and never of a shared object something still links. diff --git a/tools/acceptance.sh b/tools/acceptance.sh index 65414333..2899f717 100755 --- a/tools/acceptance.sh +++ b/tools/acceptance.sh @@ -370,6 +370,7 @@ it_artifacts() { on_root_image audit_root; } it_licences() { on_root_image "${SELF}/check-image-licences.sh"; } it_kernel_config() { "${SELF}/validate-kernel-config.sh" --boot && "${SELF}/validate-kernel-config.sh" --hardened; } it_support_status() { "${SELF}/check-support-status.sh" --strict; } +it_programs() { on_root_image "${SELF}/check-image-programs.sh"; } it_ovmf_vars() { [[ -n "$MEDIUM_CERT" ]] || { echo "no certificate could be read out of ${MEDIA_USB:-(no medium)}: /kryptik/kryptik-sb.crt on its ESP"; return 1; } echo "the certificate the medium carries: ${CERT_NAME} ($(openssl x509 -in "$MEDIUM_CERT" -noout -fingerprint -sha256 | cut -d= -f2))" @@ -440,6 +441,7 @@ item build artifact-hardening M host 0 it_artifacts need_root_image item build licences M host 0 it_licences need_root_image item build kernel-config M host 0 it_kernel_config need_sources item build support-status M host 0 it_support_status +item build image-programs M host 0 it_programs need_root_image item boot media-smoke-usb M vm 25 it_smoke_usb need_vm item boot medium-shell M vm 3 it_medium_shell need_vm item boot media-smoke-iso M vm 25 it_smoke_iso need_vm_iso diff --git a/tools/check-image-programs.sh b/tools/check-image-programs.sh new file mode 100755 index 00000000..647f1f86 --- /dev/null +++ b/tools/check-image-programs.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# Fail on an executable file the image carries that build/config/image-programs.txt does not name, +# and on an entry naming nothing: a file a recipe stopped installing stays in a cached sysroot. +# +# tools/check-image-programs.sh ROOT [--list FILE] [--write FILE] +# +# --list the names, one path or glob per line; default build/config/image-programs.txt +# --write write the names ROOT holds to FILE instead, for a list to start from + +source "$(dirname "${BASH_SOURCE[0]}")/../build/lib/common.sh" +# The verdict is python's exit status; the ERR trap would abort before it is read. +trap - ERR +set +e + +ROOT="" +LIST="${KRYPTIK_ROOT}/build/config/image-programs.txt" +WRITE="" +while [[ "$#" -gt 0 ]]; do + case "$1" in + --list) LIST="${2:?--list needs a file}"; shift 2 ;; + --write) WRITE="${2:?--write needs a file}"; shift 2 ;; + -*) die "unknown option: $1" ;; + *) ROOT="$1"; shift ;; + esac +done +[[ -n "$ROOT" ]] || die "usage: check-image-programs.sh ROOT [--list FILE] [--write FILE]" +ROOT="$(realpath -e -- "$ROOT")" || die "no such directory: ${ROOT}" +[[ -d "$ROOT" ]] || die "not a directory: ${ROOT}" +[[ -n "$WRITE" || -f "$LIST" ]] || die "no list at ${LIST}" + +log "Executable files under ${ROOT} against ${LIST#"$KRYPTIK_ROOT"/}" +python3 - "$ROOT" "$LIST" "$WRITE" <<'EOF' +import fnmatch, os, stat, sys + +root, listfile, write = sys.argv[1:4] + +def programs(): + # Every regular file with an execute bit: what a manifest's modes can list as well. + dev = os.lstat(root).st_dev + found, bad = [], [] + for d, dirs, files in os.walk(root, onerror=bad.append): + dirs[:] = [x for x in dirs if os.lstat(os.path.join(d, x)).st_dev == dev] + for name in files: + p = os.path.join(d, name) + st = os.lstat(p) + if stat.S_ISREG(st.st_mode) and st.st_mode & 0o111: + found.append(os.path.relpath(p, root)) + if bad: + for e in bad: + print(f" could not read: {e}", file=sys.stderr) + sys.exit(f"the walk of {root} was incomplete") + return sorted(found) + +found = programs() +if write: + with open(write, "w") as fh: + fh.write("".join(p + "\n" for p in found)) + print(f" wrote {len(found)} names to {write}") + sys.exit(0) + +exact, globs = {}, [] +for n, line in enumerate(open(listfile), 1): + entry = line.split("#", 1)[0].strip() + if not entry: + continue + if entry.startswith("/"): + sys.exit(f"{listfile}:{n}: {entry}: paths are relative to the root, with no leading /") + if any(c in entry for c in "*?["): + globs.append([entry, n, 0]) + else: + exact[entry] = [n, 0] + +unnamed = [] +for p in found: + hit = exact.get(p) + if hit: + hit[1] += 1 + continue + for g in globs: + if fnmatch.fnmatchcase(p, g[0]): + g[2] += 1 + break + else: + unnamed.append(p) + +stale = [(n, e) for e, (n, c) in exact.items() if not c] + [(n, e) for e, n, c in globs if not c] +print(f" {len(found)} executable files, {len(exact)} names and {len(globs)} patterns in the list") +for p in unnamed: + print(f" NOT NAMED {p}", file=sys.stderr) +for n, e in sorted(stale): + print(f" NAMES NOTHING line {n}: {e}", file=sys.stderr) +if unnamed or stale: + sys.exit(f"{len(unnamed)} executable file(s) the list does not name, {len(stale)} entr{'y' if len(stale) == 1 else 'ies'} naming nothing: " + "name a new program, remove an old one in its recipe, or drop the entry") +EOF +rc=$? +[[ "$rc" -eq 0 ]] || exit "$rc" +[[ -n "$WRITE" ]] || ok "every executable file is named, and every name has one" diff --git a/tools/tests/check-image-programs.sh b/tools/tests/check-image-programs.sh new file mode 100755 index 00000000..7cb2cd3c --- /dev/null +++ b/tools/tests/check-image-programs.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Tests for tools/check-image-programs.sh on a fixture root. +set -uo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +CHECK="${ROOT}/tools/check-image-programs.sh" +PASS=0; FAIL=0 +ok() { printf ' PASS %s\n' "$1"; PASS=$((PASS + 1)); } +bad() { printf ' FAIL %s\n' "$1"; FAIL=$((FAIL + 1)); } + +T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT +R="$T/root"; L="$T/list.txt" +mkdir -p "$R/usr/bin" "$R/usr/lib" "$R/usr/share/doc" +printf '#!/bin/sh\n' > "$R/usr/bin/hostname"; chmod 755 "$R/usr/bin/hostname" +printf '#!/bin/sh\n' > "$R/usr/bin/ifconfig"; chmod 755 "$R/usr/bin/ifconfig" +printf '\177ELF rest' > "$R/usr/lib/libz.so.1.3.1"; chmod 755 "$R/usr/lib/libz.so.1.3.1" +printf 'text\n' > "$R/usr/share/doc/README"; chmod 644 "$R/usr/share/doc/README" +cp "$R/usr/lib/libz.so.1.3.1" "$R/usr/lib/libplain.so"; chmod 644 "$R/usr/lib/libplain.so" +ln -s hostname "$R/usr/bin/dnsdomainname" +check() { NO_COLOR=1 bash "$CHECK" "$R" --list "$L" "$@" 2>&1; } + +printf 'usr/bin/hostname # inetutils\nusr/bin/ifconfig\nusr/lib/libz.so.1.*\n' > "$L" +out="$(check)"; rc=$? +[[ "$rc" -eq 0 && "$out" == *"3 executable files"* ]] \ + && ok "executable files are named; data, a non-executable library and symlinks are not" || bad "named tree: rc=$rc: $out" + +printf '#!/bin/sh\n' > "$R/usr/bin/traceroute"; chmod 4755 "$R/usr/bin/traceroute" +out="$(check)"; rc=$? +[[ "$rc" -ne 0 && "$out" == *"NOT NAMED usr/bin/traceroute"* ]] \ + && ok "a program left behind fails, named" || bad "left-behind program: rc=$rc: $out" +rm -f "$R/usr/bin/traceroute" + +printf 'usr/bin/hostname\nusr/bin/ifconfig\nusr/lib/libz.so.1.*\nusr/bin/telnet # gone\n' > "$L" +out="$(check)"; rc=$? +[[ "$rc" -ne 0 && "$out" == *"NAMES NOTHING line 4: usr/bin/telnet"* ]] \ + && ok "an entry naming nothing fails, with its line" || bad "stale entry: rc=$rc: $out" + +printf 'usr/bin/hostname\nusr/bin/ifconfig\nusr/lib/libz.so.2.*\n' > "$L" +out="$(check)"; rc=$? +[[ "$rc" -ne 0 && "$out" == *"NOT NAMED usr/lib/libz.so.1.3.1"* && "$out" == *"usr/lib/libz.so.2.*"* ]] \ + && ok "a pattern for another soname names nothing, and the library stays unnamed" || bad "pattern: rc=$rc: $out" + +printf '/usr/bin/hostname\n' > "$L" +out="$(check)"; rc=$? +[[ "$rc" -ne 0 && "$out" == *"no leading /"* ]] && ok "an absolute entry is refused" || bad "absolute entry: rc=$rc: $out" + +out="$(NO_COLOR=1 bash "$CHECK" "$R" --write "$T/written.txt" 2>&1)"; rc=$? +[[ "$rc" -eq 0 && "$(cat "$T/written.txt")" == $'usr/bin/hostname\nusr/bin/ifconfig\nusr/lib/libz.so.1.3.1' ]] \ + && ok "--write lists what the root holds, sorted" || bad "--write: rc=$rc: $out" + +printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" +[[ "$FAIL" -eq 0 ]] From 1dd7f5ac86220c2ccbe6bdb166cba51a41f911e6 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 01:46:02 -0700 Subject: [PATCH 172/213] A radio switched off as the net zone starts moves in down, so the zone starts, wire and all --- compartments/kryptikd/src/netzone.rs | 29 +++++++++++++++++++--- compartments/kryptikd/src/netzone/tests.rs | 21 ++++++++++++++++ docs/design/net-zone.md | 7 ++++-- 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/compartments/kryptikd/src/netzone.rs b/compartments/kryptikd/src/netzone.rs index 3d53e3c3..2e0f0352 100755 --- a/compartments/kryptikd/src/netzone.rs +++ b/compartments/kryptikd/src/netzone.rs @@ -193,7 +193,8 @@ pub fn uplink_config(nic: &str) -> io::Result { } /// Move `nic` into `zone_ns`, re-applying the IPv4 configuration it loses (DHCP is the zone's job). -fn carry_nic(nic: &str, zone_ns: i32) -> Result { +/// A radio switched `off` moves down and stays so. +fn carry_nic(nic: &str, zone_ns: i32, off: bool) -> Result { let cfg = uplink_config(nic).map_err(|e| io(&format!("read the configuration of {nic:?}"), e))?; // A wireless netdev is namespace-local; move its wiphy instead. match netlink::wiphy_index_of(nic).map_err(|e| io(&format!("read the wiphy of {nic:?}"), e))? { @@ -205,7 +206,9 @@ fn carry_nic(nic: &str, zone_ns: i32) -> Result { for (a, p) in &cfg.addrs { netlink::add_addr4(nic, *a, *p)?; } - netlink::set_up(nic)?; + if !off { + netlink::set_up(nic)?; + } if let Some(gw) = cfg.gateway { // The first uplink's default route stands; the zone's DHCP client sorts them out later. match netlink::add_default_route4(gw, nic) { @@ -356,6 +359,17 @@ fn bare_radios(class: &Path) -> Vec { bare } +/// Whether `nic`'s radio is switched off by rfkill, by software or by a switch, as a sysfs +/// `class/net` directory says. Read in zone 0 before the radio moves, as its sysfs moves with it. +fn switched_off(class_net: &Path, nic: &str) -> bool { + std::fs::read_dir(class_net.join(nic).join("phy80211")) + .into_iter() + .flatten() + .flatten() + .filter(|e| e.file_name().as_bytes().starts_with(b"rfkill")) + .any(|e| ["soft", "hard"].iter().any(|k| sysfs_u32(&e.path().join(k)).is_some_and(|v| v != 0))) +} + fn sysfs_u32(path: &Path) -> Option { std::fs::read_to_string(path).ok()?.trim().parse().ok() } @@ -431,9 +445,16 @@ fn plumb_nic_zone_bridge(zone: &Zone, zone_ns: i32) -> Result<(), NetError> { ), _ => {} } + /* A radio switched off stays down: brought up, its link is refused (ERFKILL), and with it the + * whole zone, wire and all. The zone's script holds it and starts its supplicant, which brings + * the link up, once it is switched on. */ + let off: Vec<&String> = nics.iter().filter(|n| switched_off(Path::new("/sys/class/net"), n)).collect(); for n in &nics { - let carried = carry_nic(n, zone_ns)?; + let carried = carry_nic(n, zone_ns, off.contains(&n))?; eprintln!("kryptikd: zone {:?}: {n:?} moved in with {carried}", zone.name); + if off.contains(&n) { + eprintln!("kryptikd: zone {:?}: {n:?} is switched off (rfkill); it stays down until switched on", zone.name); + } } netlink::with_netns(zone_ns, || { up_lo()?; @@ -450,7 +471,7 @@ fn plumb_nic_zone_bridge(zone: &Zone, zone_ns: i32) -> Result<(), NetError> { if Path::new("/proc/sys/net/ipv6").exists() { sysctl("/proc/sys/net/ipv6/conf/all/forwarding", "0")?; } - for n in &nics { + for n in nics.iter().filter(|n| !off.contains(n)) { netlink::set_up(n)?; } Ok(()) diff --git a/compartments/kryptikd/src/netzone/tests.rs b/compartments/kryptikd/src/netzone/tests.rs index 24872ce5..00cbaa69 100644 --- a/compartments/kryptikd/src/netzone/tests.rs +++ b/compartments/kryptikd/src/netzone/tests.rs @@ -377,3 +377,24 @@ fn bare_radios_are_the_ones_no_netdev_holds() { assert_eq!(bare_radios(&dir), vec![3]); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn a_radio_switched_off_is_told_by_its_rfkill() { + let dir = std::env::temp_dir().join(format!("kryptik-rfkill-{}", std::process::id())); + let put = |path: &str, value: &str| { + let f = dir.join(path); + std::fs::create_dir_all(f.parent().unwrap()).unwrap(); + std::fs::write(f, value).unwrap(); + }; + for (nic, soft, hard) in [("wlan0", "1", "0"), ("wlan1", "0", "1"), ("wlan2", "0", "0")] { + put(&format!("{nic}/phy80211/rfkill{}/soft", &nic[4..]), soft); + put(&format!("{nic}/phy80211/rfkill{}/hard", &nic[4..]), hard); + } + put("eth0/ifindex", "2\n"); + assert!(switched_off(&dir, "wlan0"), "off by software"); + assert!(switched_off(&dir, "wlan1"), "off by a switch"); + assert!(!switched_off(&dir, "wlan2")); + assert!(!switched_off(&dir, "eth0"), "a wire has no rfkill"); + assert!(!switched_off(&dir, "gone")); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/docs/design/net-zone.md b/docs/design/net-zone.md index e0e691ce..f71fe040 100755 --- a/docs/design/net-zone.md +++ b/docs/design/net-zone.md @@ -278,8 +278,11 @@ configured the zone keeps its IPv4 path and the launcher says so. rfkill's events, which `/dev/rfkill` would give and the zone's `/dev` does not hold: the script's loop brings a supplicant's radio up once it is switched on again, within ten seconds. A radio switched off as the net - zone starts gets no supplicant at first, as one cannot bring its link up - and ends; the loop starts it once the radio is switched on. + zone starts moves in down, as kryptikd reads its rfkill state in zone 0 + first: brought up, its link is refused (`ERFKILL`), and that once failed + the whole start, wire and all, until the radio was switched on. It gets no + supplicant at first, as one cannot bring its link up and ends; the loop + starts it once the radio is switched on. - **It scans when zone 0 asks.** `kryptik wifi scan`, and `s` in the menu's Wi-Fi window, have kryptikd send the net zone's pid 1 a SIGUSR1, which carries nothing. The zone scans each radio with `iw` and writes a line per From 1d8ca0151317fd5e62720388c3646d524f9020bd Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 01:48:51 -0700 Subject: [PATCH 173/213] A radio switched off as the net zone starts moves in down, so the zone starts on its wire --- build/guest-tests/zones-check.sh | 33 +++++++++++++++++++++- compartments/kryptikd/src/netzone.rs | 29 ++++++++++++++++--- compartments/kryptikd/src/netzone/tests.rs | 21 ++++++++++++++ docs/design/net-zone.md | 6 +++- tools/image/zones-test.sh | 1 + 5 files changed, 84 insertions(+), 6 deletions(-) diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index f61e41a5..db47bf3f 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -713,12 +713,21 @@ wl_of_phy() { # wl_of_phy phyN: the netdev on that wiphy, in this namespace # A sleeper's network namespace alone: a cloned mount namespace would keep a zone's volume open. ap() { nsenter -t "$AP_HOLD" -n "$@"; } ap_wpa() { ap wpa_cli -p /run/zt-ap-ctrl -i "$AP_IF" "$@" 2>/dev/null; } -STA_IF=""; AP_IF=""; AP_HOLD="" +STA_IF=""; AP_IF=""; AP_HOLD=""; STA_RFKILL="" # QEMU has no radio, so hwsim makes two: phy1 is the access point, phy0 goes to the net zone. if modprobe mac80211_hwsim radios=2 2> "$LOG/hwsim.err"; then for _ in $(seq 1 20); do [[ -e /sys/class/ieee80211/phy1 ]] && break; sleep 0.5; done STA_IF="$(wl_of_phy phy0)"; AP_IF="$(wl_of_phy phy1)" + # phy0's rfkill switch by index, read while zone 0 still sees it: its sysfs leaves with the radio. + for r in /sys/class/rfkill/rfkill*; do [[ "$(cat "$r/name" 2>/dev/null)" == phy0 ]] && STA_RFKILL="${r##*/rfkill}"; done fi +# Switch rfkill INDEX soft-off (1) or on (0) through its control device, which takes no name: +# struct rfkill_event { u32 idx; u8 type (0, any); u8 op (2, change one); u8 soft; u8 hard; }. +rfkill_soft() { + python3 -c 'import os, struct, sys +fd = os.open("/dev/rfkill", os.O_WRONLY) +os.write(fd, struct.pack("=IBBBB", int(sys.argv[1]), 0, 2, int(sys.argv[2]), 0))' "$1" "$2" +} if [[ -n "$STA_IF" && -n "$AP_IF" ]]; then pass "wifi-module" "the signed mac80211_hwsim loaded with two radios: $STA_IF on phy0, $AP_IF on phy1" else @@ -817,6 +826,28 @@ if [[ "$radio_rounds" =~ $fresh_radio ]]; then else fail "radio-recarried" "${radio_rounds}$(uncaught | grep -a 'kryptikd: \(wiphy\|interface\)' | tail -3 | tr '\n' ' ') $(tr '\n' ' ' < "$LOG/radio-extra.out")" fi +# A radio switched off as the net zone starts, by an airplane-mode key or a switch, moves in down: +# brought up, its link would be refused (ERFKILL) and the zone with it, wire and all. Switched on, +# the next start joins again. +off_new=0; off_line=""; on_line="" +if [[ -n "$STA_RFKILL" ]]; then + # Switched off while no net zone runs, so only the next one can say READY. + s6-svc -d /run/service/net-zone + for _ in $(seq 1 30); do [[ -e /sys/class/ieee80211/phy0 ]] && break; sleep 0.5; done + rfkill_soft "$STA_RFKILL" 1 + before="$(ready_count)" + s6-svc -u /run/service/net-zone + off_line="$(ready_after "$before" " nat=yes " 60)" && off_new=1 + before="$(ready_count)" + rfkill_soft "$STA_RFKILL" 0 + s6-svc -r /run/service/net-zone + on_line="$(ready_after "$before" " wifi=$AP_SSID " 90)" +fi +if [[ "$off_new" = 1 && "$off_line" != *" wifi=$AP_SSID "* && "$on_line" == *" wifi=$AP_SSID "* ]]; then + pass "radio-off-at-start" "switched off, the net zone came up on its wire: ${off_line#*netzone: }; switched on, the next start joined: ${on_line#*netzone: }" +else + fail "radio-off-at-start" "rfkill: ${STA_RFKILL:+rfkill}${STA_RFKILL:-none named phy0}; switched off, a new READY: ${off_new} (${off_line:-none}); switched on: ${on_line:-none}; $(uncaught | grep -a 'kryptikd: zone "net"' | tail -3 | tr '\n' ' ')" +fi # personal's definition does not open the radio's own network: once the net # zone has taken the access point as a gateway, its address is refused # personal, and what lies past it is not. diff --git a/compartments/kryptikd/src/netzone.rs b/compartments/kryptikd/src/netzone.rs index 3d53e3c3..2e0f0352 100755 --- a/compartments/kryptikd/src/netzone.rs +++ b/compartments/kryptikd/src/netzone.rs @@ -193,7 +193,8 @@ pub fn uplink_config(nic: &str) -> io::Result { } /// Move `nic` into `zone_ns`, re-applying the IPv4 configuration it loses (DHCP is the zone's job). -fn carry_nic(nic: &str, zone_ns: i32) -> Result { +/// A radio switched `off` moves down and stays so. +fn carry_nic(nic: &str, zone_ns: i32, off: bool) -> Result { let cfg = uplink_config(nic).map_err(|e| io(&format!("read the configuration of {nic:?}"), e))?; // A wireless netdev is namespace-local; move its wiphy instead. match netlink::wiphy_index_of(nic).map_err(|e| io(&format!("read the wiphy of {nic:?}"), e))? { @@ -205,7 +206,9 @@ fn carry_nic(nic: &str, zone_ns: i32) -> Result { for (a, p) in &cfg.addrs { netlink::add_addr4(nic, *a, *p)?; } - netlink::set_up(nic)?; + if !off { + netlink::set_up(nic)?; + } if let Some(gw) = cfg.gateway { // The first uplink's default route stands; the zone's DHCP client sorts them out later. match netlink::add_default_route4(gw, nic) { @@ -356,6 +359,17 @@ fn bare_radios(class: &Path) -> Vec { bare } +/// Whether `nic`'s radio is switched off by rfkill, by software or by a switch, as a sysfs +/// `class/net` directory says. Read in zone 0 before the radio moves, as its sysfs moves with it. +fn switched_off(class_net: &Path, nic: &str) -> bool { + std::fs::read_dir(class_net.join(nic).join("phy80211")) + .into_iter() + .flatten() + .flatten() + .filter(|e| e.file_name().as_bytes().starts_with(b"rfkill")) + .any(|e| ["soft", "hard"].iter().any(|k| sysfs_u32(&e.path().join(k)).is_some_and(|v| v != 0))) +} + fn sysfs_u32(path: &Path) -> Option { std::fs::read_to_string(path).ok()?.trim().parse().ok() } @@ -431,9 +445,16 @@ fn plumb_nic_zone_bridge(zone: &Zone, zone_ns: i32) -> Result<(), NetError> { ), _ => {} } + /* A radio switched off stays down: brought up, its link is refused (ERFKILL), and with it the + * whole zone, wire and all. The zone's script holds it and starts its supplicant, which brings + * the link up, once it is switched on. */ + let off: Vec<&String> = nics.iter().filter(|n| switched_off(Path::new("/sys/class/net"), n)).collect(); for n in &nics { - let carried = carry_nic(n, zone_ns)?; + let carried = carry_nic(n, zone_ns, off.contains(&n))?; eprintln!("kryptikd: zone {:?}: {n:?} moved in with {carried}", zone.name); + if off.contains(&n) { + eprintln!("kryptikd: zone {:?}: {n:?} is switched off (rfkill); it stays down until switched on", zone.name); + } } netlink::with_netns(zone_ns, || { up_lo()?; @@ -450,7 +471,7 @@ fn plumb_nic_zone_bridge(zone: &Zone, zone_ns: i32) -> Result<(), NetError> { if Path::new("/proc/sys/net/ipv6").exists() { sysctl("/proc/sys/net/ipv6/conf/all/forwarding", "0")?; } - for n in &nics { + for n in nics.iter().filter(|n| !off.contains(n)) { netlink::set_up(n)?; } Ok(()) diff --git a/compartments/kryptikd/src/netzone/tests.rs b/compartments/kryptikd/src/netzone/tests.rs index 24872ce5..00cbaa69 100644 --- a/compartments/kryptikd/src/netzone/tests.rs +++ b/compartments/kryptikd/src/netzone/tests.rs @@ -377,3 +377,24 @@ fn bare_radios_are_the_ones_no_netdev_holds() { assert_eq!(bare_radios(&dir), vec![3]); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn a_radio_switched_off_is_told_by_its_rfkill() { + let dir = std::env::temp_dir().join(format!("kryptik-rfkill-{}", std::process::id())); + let put = |path: &str, value: &str| { + let f = dir.join(path); + std::fs::create_dir_all(f.parent().unwrap()).unwrap(); + std::fs::write(f, value).unwrap(); + }; + for (nic, soft, hard) in [("wlan0", "1", "0"), ("wlan1", "0", "1"), ("wlan2", "0", "0")] { + put(&format!("{nic}/phy80211/rfkill{}/soft", &nic[4..]), soft); + put(&format!("{nic}/phy80211/rfkill{}/hard", &nic[4..]), hard); + } + put("eth0/ifindex", "2\n"); + assert!(switched_off(&dir, "wlan0"), "off by software"); + assert!(switched_off(&dir, "wlan1"), "off by a switch"); + assert!(!switched_off(&dir, "wlan2")); + assert!(!switched_off(&dir, "eth0"), "a wire has no rfkill"); + assert!(!switched_off(&dir, "gone")); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/docs/design/net-zone.md b/docs/design/net-zone.md index ede36604..d8c4d783 100755 --- a/docs/design/net-zone.md +++ b/docs/design/net-zone.md @@ -271,7 +271,11 @@ configured the zone keeps its IPv4 path and the launcher says so. `/etc/wpa_supplicant.conf`, restarting one that dies; dhcpcd takes the lease once there is a carrier. `wifi=` is `none`, `unconfigured` (no credentials), `connecting`, or the associated SSID; `READY` does not wait - for association. + for association. A radio switched off by rfkill (an airplane-mode key, a + switch) as the net zone starts moves in down, as kryptikd reads its rfkill + state in zone 0 first: brought up, its link is refused (`ERFKILL`), and that + once failed the whole start, wire and all, until the radio was switched on. + Switched on, it is joined at the net zone's next start. - **The net zone knows the passphrases.** They live in zone 0 at `/var/lib/kryptik/wifi/wpa_supplicant.conf`, written only by `kryptikd serve` for `kryptik wifi add|forget `. The passphrase is read on the diff --git a/tools/image/zones-test.sh b/tools/image/zones-test.sh index 2acd8181..4407a745 100755 --- a/tools/image/zones-test.sh +++ b/tools/image/zones-test.sh @@ -67,6 +67,7 @@ grep 'ZT FAIL' <<<"$T2" | sed 's/^/ /' for name in kernel-support policies net-ready net-dns dhcpcd-separated dnsmasq-unprivileged zone0-nic zone0-no-route zone0-offline routed-egress routed-ping routed-ping6 routed-dns net-lease-names-resolver dns-follows-lease dns-after-reload routed-ipv6-noglobal zone-separation volume-hidden home-hidden fail-closed net-restart-ready reattach-after-restart uplink-returned uplink-retaken reattach-egress uplink-refused wifi-beyond routed-restart-path uplink-address-refused zone-flows-capped net-cpu-max-set \ wifi-module wifi-ap wifi-add wifi-associated wifi-lease wifi-egress wifi-forget \ uplink-renamed-plain uplink-state-reset \ + radio-off-at-start \ radio-recarried \ zone-clocks-own \ time-floor-ran time-clamp time-floor-forged time-claim-stepped time-claim-floor time-claim-consent pids-limit ephemeral-size-bound cpu-max-set lifecycle-repeat lifecycle-registry resolver-after-attach dns-cache-off dns-counters-hidden net-zone-sysfs-nics-only \ From ae6e4f65f00783fcb6eca396a4f5da5bb41b9176 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 01:49:57 -0700 Subject: [PATCH 174/213] A trial the firmware never tried says so, as BootNext is still set after it A boot manager deletes BootNext before it boots that entry. When the committed slot comes up with a trial armed and BootNext still naming the trial's own entry, the firmware never used it, which some firmware does by design; boot-success said the trial "did not come up to be judged", as if the new release had failed. Its why= line now says the trial was not tried, and that after --retry the firmware's boot menu offers "Kryptik slot X" to boot it once, so kryptik update status, kryptik-update's refusal and the launcher all say so. A BootNext naming any other entry changes nothing. The boot-success suite's fake efiboot answers list from the case, and two cases cover BootNext on the trial's entry and on another system's. --- build/service-scripts/boot-success.sh | 18 ++++++++++++++++-- docs/design/boot-and-updates.md | 5 ++++- docs/user-guide.md | 4 +++- tools/tests/boot-success.sh | 13 ++++++++++++- 4 files changed, 35 insertions(+), 5 deletions(-) diff --git a/build/service-scripts/boot-success.sh b/build/service-scripts/boot-success.sh index 0fd571fa..ea53a4bb 100755 --- a/build/service-scripts/boot-success.sh +++ b/build/service-scripts/boot-success.sh @@ -108,6 +108,14 @@ forget_entries() { # forget_entries COMMITTED-SLOT return 0 } +# The trial slot's entry, if BootNext still names it: a firmware's boot manager deletes +# BootNext before it boots that entry, so one left standing was never used. +bootnext_left() { # bootnext_left SLOT + fw="$(kryptik-efiboot list 2>/dev/null)" || return 1 + nx="$(printf '%s\n' "$fw" | sed -n 's/^BootNext: *\(Boot[0-9A-F]\{4\}\)$/\1/p')" + [ -n "$nx" ] && printf '%s\n' "$fw" | grep -q "^ $nx: Kryptik slot $1 " && echo "$nx" +} + # The committed slot as the ESP names it, read whenever no trial is on record. esp_committed() { e="$(kryptik_part kryptik-esp 2>/dev/null)" && [ -n "$e" ] || return 0 @@ -176,11 +184,17 @@ if [ -n "$trial" ]; then else if [ "$armed" = 1 ]; then # trial.failed stops kryptik-update re-arming this payload without --retry. - say "trial slot $trial did NOT boot; running slot $slot again" + if nx="$(bootnext_left "$trial")"; then + say "trial slot $trial was not tried: BootNext still names $nx, so the firmware did not use it; running slot $slot again" + why="was not tried: the firmware left BootNext set, so it may not boot an entry once on request (after --retry, pick \"Kryptik slot $trial\" in its boot menu)" + else + say "trial slot $trial did NOT boot; running slot $slot again" + why="did not come up to be judged" + fi result "trial-failed $trial" forget_entries "$slot" mv -f "$B/trial" "$B/trial.failed" - echo "why=did not come up to be judged" >> "$B/trial.failed" + echo "why=$why" >> "$B/trial.failed" else say "the arming of slot $trial was interrupted before BootNext was set; nothing was tried" result "arming-interrupted $trial" diff --git a/docs/design/boot-and-updates.md b/docs/design/boot-and-updates.md index bb41ff53..e730bea5 100644 --- a/docs/design/boot-and-updates.md +++ b/docs/design/boot-and-updates.md @@ -160,7 +160,10 @@ payloads come from [the update channel](update-channel.md) or by hand. committed slot, `BootNext` being spent. On a degraded state the trial record is out of reach, so `committed-slot` says whether the boot is a trial. A trial that never comes up also lands on the committed slot; the - fallback records `trial.failed` and forgets the entries. Either way + fallback records `trial.failed` and forgets the entries. A boot manager + deletes `BootNext` before it boots that entry, so one still naming the + trial's entry then was never used: the record says the trial was not + tried, which a firmware that ignores `BootNext` would repeat. Either way `trial.failed` keeps a `why=` line (the failures, or that it never came up to be judged), since the next boot's own result replaces `last-result`; `kryptik update status`, `kryptik-update status` and the launcher repeat diff --git a/docs/user-guide.md b/docs/user-guide.md index cfaa2ea8..00855f68 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -340,7 +340,9 @@ state partition is degraded, and on any slot but the committed one, such as one picked in the firmware's boot menu: the refusal says to boot the committed slot, or to make the running one committed from the medium. After a trial that failed, one that did not boot or came up unhealthy, it refuses -that slot again until you pass `--retry`. +that slot again until you pass `--retry`. A firmware that leaves `BootNext` +unused never tries the slot, and the status says it was not tried: after +`--retry`, pick "Kryptik slot X" in the firmware's boot menu to boot it once. ```sh kryptik-update rollback # back to the other slot, if it is intact diff --git a/tools/tests/boot-success.sh b/tools/tests/boot-success.sh index 6123b23c..c4a29dbd 100755 --- a/tools/tests/boot-success.sh +++ b/tools/tests/boot-success.sh @@ -39,6 +39,8 @@ cat > "$T/bin/kryptik-efiboot" <<'EOF' echo "efiboot $*" >> "$KTEST/calls" # Whether the trial record still stood when the entries were forgotten. [ "$1" = forget ] && { [ -e "$KTEST/boot/trial" ] && echo kept || echo gone; } >> "$KTEST/forget-saw" +# What the firmware's variables say, as the case wrote them. +[ "$1" = list ] && cat "$KTEST/efilist" 2>/dev/null [ ! -e "$KTEST/efiboot_fails" ] && [ ! -e "$KTEST/efiboot_fails_$1" ] EOF cat > "$T/bin/reboot" <<'EOF' @@ -199,8 +201,17 @@ check "back on the old slot with BootNext consumed: trial-failed" "$RESULT" "tri check "the record moved to trial.failed" "$([[ -e "$KTEST/boot/trial.failed" ]] && cat "$KTEST/boot/trial.failed" | head -1)" "b" check "... saying it never came up to be judged" "$(sed -n 's/^why=//p' "$KTEST/boot/trial.failed")" "did not come up to be judged" check "BOOTX64.EFI untouched" "$(cat "$KTEST/esp/EFI/BOOT/BOOTX64.EFI")" "kernel-a" -check "the failed trial's entries are forgotten, and the committed slot gets its own" "$CALLS" "efiboot forget efiboot ensure a " +check "the failed trial's entries are forgotten, and the committed slot gets its own" "$CALLS" "efiboot list efiboot forget efiboot ensure a " check "... before its record is set aside" "$(cat "$KTEST/forget-saw" 2>/dev/null)" "kept" +run_case untried a "" persistent 'b\narmed=1\n' $ALL +printf 'BootNext: BootB0B0\nBootOrder: Boot0001 BootB0B0\n BootB0B0: Kryptik slot b file=\\EFI\\kryptik\\kryptik-b.efi [active]\n' > "$KTEST/efilist"; go +check "BootNext still naming the trial's entry: the firmware never tried it, and the record says so" \ + "$RESULT|$(sed -n 's/^why=//p' "$KTEST/boot/trial.failed")" \ + 'trial-failed b|was not tried: the firmware left BootNext set, so it may not boot an entry once on request (after --retry, pick "Kryptik slot b" in its boot menu)' +check "... and its entries and BootNext are forgotten as for any failed trial" "$CALLS" "efiboot list efiboot forget efiboot ensure a " +run_case untried2 a "" persistent 'b\narmed=1\n' $ALL +printf 'BootNext: Boot0003\n Boot0003: Windows Boot Manager file=\\EFI\\Microsoft\\Boot\\bootmgfw.efi [active]\n' > "$KTEST/efilist"; go +check "a BootNext naming another system's entry says nothing about the trial" "$(sed -n 's/^why=//p' "$KTEST/boot/trial.failed")" "did not come up to be judged" run_case interrupted a "" persistent 'b\narmed=0\n' $ALL; go check "old slot with an armed=0 record: arming was interrupted, nothing failed" "$RESULT" "arming-interrupted b" check "the interrupted arming's entry is forgotten, and the committed slot gets its own" "$CALLS" "efiboot forget efiboot ensure a " From 1c9a079892de3f56ae0a3a897a6b62792c5de6a6 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 01:51:55 -0700 Subject: [PATCH 175/213] A trial the firmware never used reads as never started The reason reads after "the trial of slot b", where "was not tried" said the same thing twice. --- build/service-scripts/boot-success.sh | 4 ++-- docs/design/boot-and-updates.md | 4 ++-- docs/user-guide.md | 2 +- tools/tests/boot-success.sh | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/build/service-scripts/boot-success.sh b/build/service-scripts/boot-success.sh index ea53a4bb..afef7aa7 100755 --- a/build/service-scripts/boot-success.sh +++ b/build/service-scripts/boot-success.sh @@ -185,8 +185,8 @@ if [ -n "$trial" ]; then if [ "$armed" = 1 ]; then # trial.failed stops kryptik-update re-arming this payload without --retry. if nx="$(bootnext_left "$trial")"; then - say "trial slot $trial was not tried: BootNext still names $nx, so the firmware did not use it; running slot $slot again" - why="was not tried: the firmware left BootNext set, so it may not boot an entry once on request (after --retry, pick \"Kryptik slot $trial\" in its boot menu)" + say "trial slot $trial was never started: BootNext still names $nx, so the firmware did not use it; running slot $slot again" + why="was never started: the firmware left BootNext set, so it may ignore a one-time boot request (after --retry, pick \"Kryptik slot $trial\" in its boot menu)" else say "trial slot $trial did NOT boot; running slot $slot again" why="did not come up to be judged" diff --git a/docs/design/boot-and-updates.md b/docs/design/boot-and-updates.md index e730bea5..77ff4d4e 100644 --- a/docs/design/boot-and-updates.md +++ b/docs/design/boot-and-updates.md @@ -162,8 +162,8 @@ payloads come from [the update channel](update-channel.md) or by hand. trial. A trial that never comes up also lands on the committed slot; the fallback records `trial.failed` and forgets the entries. A boot manager deletes `BootNext` before it boots that entry, so one still naming the - trial's entry then was never used: the record says the trial was not - tried, which a firmware that ignores `BootNext` would repeat. Either way + trial's entry then was never used: the record says the trial was never + started, which a firmware that ignores `BootNext` would repeat. Either way `trial.failed` keeps a `why=` line (the failures, or that it never came up to be judged), since the next boot's own result replaces `last-result`; `kryptik update status`, `kryptik-update status` and the launcher repeat diff --git a/docs/user-guide.md b/docs/user-guide.md index 00855f68..f7e3779b 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -341,7 +341,7 @@ one picked in the firmware's boot menu: the refusal says to boot the committed slot, or to make the running one committed from the medium. After a trial that failed, one that did not boot or came up unhealthy, it refuses that slot again until you pass `--retry`. A firmware that leaves `BootNext` -unused never tries the slot, and the status says it was not tried: after +unused never starts the slot, and the status says it was never started: after `--retry`, pick "Kryptik slot X" in the firmware's boot menu to boot it once. ```sh diff --git a/tools/tests/boot-success.sh b/tools/tests/boot-success.sh index c4a29dbd..dc0f0fc1 100755 --- a/tools/tests/boot-success.sh +++ b/tools/tests/boot-success.sh @@ -207,7 +207,7 @@ run_case untried a "" persistent 'b\narmed=1\n' $ALL printf 'BootNext: BootB0B0\nBootOrder: Boot0001 BootB0B0\n BootB0B0: Kryptik slot b file=\\EFI\\kryptik\\kryptik-b.efi [active]\n' > "$KTEST/efilist"; go check "BootNext still naming the trial's entry: the firmware never tried it, and the record says so" \ "$RESULT|$(sed -n 's/^why=//p' "$KTEST/boot/trial.failed")" \ - 'trial-failed b|was not tried: the firmware left BootNext set, so it may not boot an entry once on request (after --retry, pick "Kryptik slot b" in its boot menu)' + 'trial-failed b|was never started: the firmware left BootNext set, so it may ignore a one-time boot request (after --retry, pick "Kryptik slot b" in its boot menu)' check "... and its entries and BootNext are forgotten as for any failed trial" "$CALLS" "efiboot list efiboot forget efiboot ensure a " run_case untried2 a "" persistent 'b\narmed=1\n' $ALL printf 'BootNext: Boot0003\n Boot0003: Windows Boot Manager file=\\EFI\\Microsoft\\Boot\\bootmgfw.efi [active]\n' > "$KTEST/efilist"; go From a9e8e5bd711c83a88606c086796c3882ce7d88a3 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 01:53:03 -0700 Subject: [PATCH 176/213] kryptik-recover --status says whether the state partition still has its header The state partition opens with its LUKS2 header and nothing else, and the header is what --backup-state-header and --restore-state-header are for, yet --status said nothing of it. It now names the partition and whether a LUKS2 header is there, or that nothing opens it and what puts a backup back, whether or not the ESP mounts. The integrity suite's header round trip runs --status after the wipe and after the restore, and wants both answers. --- build/service-scripts/installer-run.sh | 2 ++ docs/user-guide.md | 3 ++- tools/image/integrity-test.sh | 4 ++++ tools/update/kryptik-recover | 6 ++++++ 4 files changed, 14 insertions(+), 1 deletion(-) diff --git a/build/service-scripts/installer-run.sh b/build/service-scripts/installer-run.sh index 7c27481a..6c99492e 100755 --- a/build/service-scripts/installer-run.sh +++ b/build/service-scripts/installer-run.sh @@ -26,9 +26,11 @@ header_roundtrip() { # header_roundtrip DISK dd if=/dev/zero of="$st" bs=1M iflag=count_bytes count="$n" conv=fsync status=none || return 1 if cryptsetup isLuks "$st" 2>/dev/null; then echo "header: ${st} is still LUKS after the wipe"; return 1; fi echo "header: wiped ${n} bytes, and ${st} is no longer LUKS" + /usr/sbin/kryptik-recover --disk "$1" --status 2>/dev/null | grep '^state ' || true /usr/sbin/kryptik-recover --disk "$1" --restore-state-header "$h" || return 1 [ "$(head -c "$n" "$st" | sha256sum)" = "$(sha256sum < "$h")" ] || { echo "header: ${st} does not read back as the backup"; return 1; } echo "header: restored, and ${st} reads back as the backup" + /usr/sbin/kryptik-recover --disk "$1" --status 2>/dev/null | grep '^state ' || true } # Recovery of an installed disk (kryptik-recover), armed the same way. diff --git a/docs/user-guide.md b/docs/user-guide.md index 84d4cc1b..520c8b0b 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -362,7 +362,8 @@ after `--commit-slot`, which writes one for the slot it commits. back the state partition's LUKS2 header. FILE is on a disk mounted for it, as in section 2: the medium keeps nothing of its own. Keep the backup somewhere that is not the installed disk: a damaged header with no backup is a lost -state partition. +state partition. `--status` says whether the state partition still has its +header. `--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 diff --git a/tools/image/integrity-test.sh b/tools/image/integrity-test.sh index 893860cb..ada75b94 100755 --- a/tools/image/integrity-test.sh +++ b/tools/image/integrity-test.sh @@ -337,11 +337,15 @@ smoke integ-p6 --usb "$USB" --disk "$DISK" --testctl "$CTLH" --vars enrolled --t T6="$(boot_txt)" grep -q 'KRYPTIK_RECOVER: header: wiped [0-9]* bytes, and /dev/vda4 is no longer LUKS' <<<"$T6" \ && green "--backup-state-header saved the header; wiped on the disk, the partition is no longer LUKS" || red "the header was not saved and wiped" +grep -qE 'KRYPTIK_RECOVER: state +/dev/vda4: no LUKS2 header, so nothing opens it: --restore-state-header' <<<"$T6" \ + && green "--status then says the state partition has no header, and what puts one back" || red "--status did not name the state partition's missing header" if grep -q 'KRYPTIK_RECOVER: header: restored' <<<"$T6" && grep -q 'KRYPTIK_RECOVER: rc=0' <<<"$T6"; then green "--restore-state-header put it back: the partition reads back as the backup" else red "the header was not restored"; grep 'KRYPTIK_RECOVER' <<<"$T6" | tail -6 | sed 's/^/ /' fi +grep -qE 'KRYPTIK_RECOVER: state +/dev/vda4: a LUKS2 header$' <<<"$T6" \ + && green "--status names the restored header" || red "--status did not name the restored header" cp "$ENROLLED" "$VARSF" start_vm integ-p6b; LOG6="$LOG" python3 "$DRV" --serial "$SER" --timeout 300 \ diff --git a/tools/update/kryptik-recover b/tools/update/kryptik-recover index 369a8aec..c8a210a0 100755 --- a/tools/update/kryptik-recover +++ b/tools/update/kryptik-recover @@ -102,6 +102,12 @@ if [ "$STATUS" = 1 ]; then printf 'slot %s filesystem %s\n' "$s" "$(has_fs "$(slot_dev "$s")" && echo present || echo NONE)" done fi + # The state partition opens with its header and nothing else. + if cryptsetup isLuks --type luks2 "$ST" 2>/dev/null; then + echo "state ${ST}: a LUKS2 header" + else + echo "state ${ST}: no LUKS2 header, so nothing opens it: --restore-state-header FILE puts a backup back" + fi # The firmware's entries for the slots, which --commit-slot writes: some firmware boots a # fixed disk only from one. They are kept by slot, not disk, so one may be another Kryptik's. if [ -d /sys/firmware/efi/efivars ] && fw="$(kryptik-efiboot list 2>/dev/null)"; then From 38dda66249825ca920a2be6446968eb441964ae4 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 01:53:07 -0700 Subject: [PATCH 177/213] The carry tests name a radio's rfkill state, which carry_nic now takes --- compartments/kryptikd/src/netzone/tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/compartments/kryptikd/src/netzone/tests.rs b/compartments/kryptikd/src/netzone/tests.rs index 00cbaa69..5e77b584 100644 --- a/compartments/kryptikd/src/netzone/tests.rs +++ b/compartments/kryptikd/src/netzone/tests.rs @@ -275,7 +275,7 @@ fn uplink_config_travels_with_nic() { eprintln!("read back {before}"); return Err(7); } - let carried = carry_nic("up0", zone_ns).map_err(|e| { + let carried = carry_nic("up0", zone_ns, false).map_err(|e| { eprintln!("carry_nic: {e}"); 8 })?; @@ -337,7 +337,7 @@ fn unknown_nic_refused_before_move() { fn nic_name_escaped_in_errors() { /* A net zone can rename its NIC to bytes a terminal acts on, and the NIC comes back to * zone 0 under that name: kryptikd's words and netlink's both escape it. */ - let e = carry_nic("e\u{1b}[2Jth0", -1).unwrap_err().to_string(); + let e = carry_nic("e\u{1b}[2Jth0", -1, false).unwrap_err().to_string(); assert!(!e.contains('\u{1b}') && e.matches("\\u{1b}").count() == 2, "{e:?}"); } From 83271c3ebcc8fd6647a68c38d066a11c0a8a2d88 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 01:57:33 -0700 Subject: [PATCH 178/213] The image's program list reads a lone [ as a name and a closed [...] as a pattern An entry was a pattern if it held *, ? or [, so coreutils' usr/bin/[ was counted as one. It matched itself only because fnmatch reads an unclosed [ literally. A pattern now needs * or ?, or a closed [...] pair; the fixture adds a file named [ listed as a name and a bracket class that matches a soname, and wants each counted as what it is. --- tools/check-image-programs.sh | 5 +++-- tools/tests/check-image-programs.sh | 8 ++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/tools/check-image-programs.sh b/tools/check-image-programs.sh index 647f1f86..015defbf 100755 --- a/tools/check-image-programs.sh +++ b/tools/check-image-programs.sh @@ -30,7 +30,7 @@ ROOT="$(realpath -e -- "$ROOT")" || die "no such directory: ${ROOT}" log "Executable files under ${ROOT} against ${LIST#"$KRYPTIK_ROOT"/}" python3 - "$ROOT" "$LIST" "$WRITE" <<'EOF' -import fnmatch, os, stat, sys +import fnmatch, os, re, stat, sys root, listfile, write = sys.argv[1:4] @@ -65,7 +65,8 @@ for n, line in enumerate(open(listfile), 1): continue if entry.startswith("/"): sys.exit(f"{listfile}:{n}: {entry}: paths are relative to the root, with no leading /") - if any(c in entry for c in "*?["): + # A pattern has * or ?, or a closed [...]: a lone [ is a name, as coreutils' usr/bin/[ is. + if re.search(r"[*?]|\[[^\]]+\]", entry): globs.append([entry, n, 0]) else: exact[entry] = [n, 0] diff --git a/tools/tests/check-image-programs.sh b/tools/tests/check-image-programs.sh index 7cb2cd3c..d09b4792 100755 --- a/tools/tests/check-image-programs.sh +++ b/tools/tests/check-image-programs.sh @@ -23,6 +23,14 @@ out="$(check)"; rc=$? [[ "$rc" -eq 0 && "$out" == *"3 executable files"* ]] \ && ok "executable files are named; data, a non-executable library and symlinks are not" || bad "named tree: rc=$rc: $out" +printf '#!/bin/sh\n' > "$R/usr/bin/["; chmod 755 "$R/usr/bin/[" +printf 'usr/bin/[\nusr/bin/hostname\nusr/bin/ifconfig\nusr/lib/libz.so.[0-9].*\n' > "$L" +out="$(check)"; rc=$? +[[ "$rc" -eq 0 && "$out" == *"4 executable files, 3 names and 1 patterns"* ]] \ + && ok "a lone [ is a name, and a closed [...] is a pattern" || bad "brackets: rc=$rc: $out" +rm -f "$R/usr/bin/[" +printf 'usr/bin/hostname # inetutils\nusr/bin/ifconfig\nusr/lib/libz.so.1.*\n' > "$L" + printf '#!/bin/sh\n' > "$R/usr/bin/traceroute"; chmod 4755 "$R/usr/bin/traceroute" out="$(check)"; rc=$? [[ "$rc" -ne 0 && "$out" == *"NOT NAMED usr/bin/traceroute"* ]] \ From 04a56b7a9b995bd6803086a953b6dfcd65b6c072 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 01:58:10 -0700 Subject: [PATCH 179/213] USB networking for NetChip 1080 cables, CDC-subset links and the Sharp Zaurus is not built boot.fragment builds usbnet for the AX88179 and RTL8152 adapters, and usbnet's Kconfig defaults bring in net1080, zaurus and cdc_subset beside them: drivers for host-to-host cables and a PDA of around 2001. Nothing Kryptik supports needs them, and any USB device can bind one by claiming its ids. asix, cdc_ether, cdc_ncm and r8153_ecm, which serve common adapters, docks and phones, stay. --- build/config/kernel/boot.fragment | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/build/config/kernel/boot.fragment b/build/config/kernel/boot.fragment index 32111c45..1e8d08a0 100644 --- a/build/config/kernel/boot.fragment +++ b/build/config/kernel/boot.fragment @@ -134,6 +134,11 @@ CONFIG_R8169=m CONFIG_USB_USBNET=m CONFIG_USB_NET_AX88179_178A=m CONFIG_USB_RTL8152=m +# usbnet's defaults also build drivers for 2001-era host-to-host cables (NetChip 1080 and the +# CDC subset ones) and the Sharp Zaurus, which any USB device could bind by claiming their ids. +# CONFIG_USB_NET_NET1080 is not set +# CONFIG_USB_NET_ZAURUS is not set +# CONFIG_USB_NET_CDC_SUBSET is not set CONFIG_DRM_AST=m CONFIG_DRM_MGAG200=m # VMD: from Intel's 11th generation the NVMe drive sits behind it ("RAID" or From 27386e3583b8c946efd532e9dba22edf6ee9a36f Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 01:58:35 -0700 Subject: [PATCH 180/213] wifi-bss-age's verdict line sits with the radio checks, clear of the lines other changes edit --- tools/image/zones-test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/image/zones-test.sh b/tools/image/zones-test.sh index 47518faa..49ef5b27 100755 --- a/tools/image/zones-test.sh +++ b/tools/image/zones-test.sh @@ -68,11 +68,11 @@ for name in kernel-support policies net-ready net-dns dhcpcd-separated dnsmasq-u wifi-module wifi-ap wifi-add wifi-associated wifi-lease wifi-egress wifi-forget \ wifi-no-dpp \ wifi-second-network \ + wifi-bss-age \ uplink-renamed-plain uplink-state-reset \ radio-recarried \ wifi-wpa2-plain wifi-sae wifi-supplicant-unprivileged wifi-supplicant-no-nft \ zone-clocks-own \ - wifi-bss-age \ time-floor-ran time-clamp time-floor-forged time-claim-stepped time-claim-floor time-claim-consent pids-limit ephemeral-size-bound cpu-max-set lifecycle-repeat lifecycle-registry resolver-after-attach dns-cache-off dns-counters-hidden net-zone-sysfs-nics-only \ terminal-terminfo man-page text-browser tls-trust no-shared-writable-mount net-zone-no-zone-data no-swap no-dbus \ volume-init zone-source-pinned bridge-ports-closed encrypted-zone-start stop-closes-volume wrong-passphrase persist-reopen no-mapping-after ephemeral-gone concurrent-start-refused full-volume header-restore volume-destroy vault-offline vault-ping no-passphrase-leak \ From 230c8a1a5fd830c3b25f2d4cbd67af70b748e5e6 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 02:00:00 -0700 Subject: [PATCH 181/213] The design says where a switched-off radio's carry belongs: with how a radio leaves zone 0 --- docs/design/net-zone.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/design/net-zone.md b/docs/design/net-zone.md index d8c4d783..23cd7db1 100755 --- a/docs/design/net-zone.md +++ b/docs/design/net-zone.md @@ -265,17 +265,17 @@ configured the zone keeps its IPv4 path and the launcher says so. netdev failed the start once its radio had moved with the first, and a radio with none stayed in zone 0 until a reboot. A driver that cannot add an interface (some FullMAC ones) leaves such a radio in zone 0, and the - log says why. + log says why. A radio switched off by rfkill (an airplane-mode key, a + switch) as the net zone starts moves in down, as kryptikd reads its rfkill + state in zone 0 first: brought up, its link is refused (`ERFKILL`), and that + once failed the whole start, wire and all, until the radio was switched on. + Switched on, it is joined at the net zone's next start. - **It associates before it leases.** The script finds radios by their `phy80211` link, not by name, and runs one `wpa_supplicant` per radio on `/etc/wpa_supplicant.conf`, restarting one that dies; dhcpcd takes the lease once there is a carrier. `wifi=` is `none`, `unconfigured` (no credentials), `connecting`, or the associated SSID; `READY` does not wait - for association. A radio switched off by rfkill (an airplane-mode key, a - switch) as the net zone starts moves in down, as kryptikd reads its rfkill - state in zone 0 first: brought up, its link is refused (`ERFKILL`), and that - once failed the whole start, wire and all, until the radio was switched on. - Switched on, it is joined at the net zone's next start. + for association. - **The net zone knows the passphrases.** They live in zone 0 at `/var/lib/kryptik/wifi/wpa_supplicant.conf`, written only by `kryptikd serve` for `kryptik wifi add|forget `. The passphrase is read on the From 39128a4d6867a6ffb95b78b550954773f33c475b Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 02:01:24 -0700 Subject: [PATCH 182/213] The radio's rfkill index is taken in a block of its own, below the module's load --- build/guest-tests/zones-check.sh | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index db47bf3f..0f050cec 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -713,14 +713,15 @@ wl_of_phy() { # wl_of_phy phyN: the netdev on that wiphy, in this namespace # A sleeper's network namespace alone: a cloned mount namespace would keep a zone's volume open. ap() { nsenter -t "$AP_HOLD" -n "$@"; } ap_wpa() { ap wpa_cli -p /run/zt-ap-ctrl -i "$AP_IF" "$@" 2>/dev/null; } -STA_IF=""; AP_IF=""; AP_HOLD=""; STA_RFKILL="" +STA_IF=""; AP_IF=""; AP_HOLD="" # QEMU has no radio, so hwsim makes two: phy1 is the access point, phy0 goes to the net zone. if modprobe mac80211_hwsim radios=2 2> "$LOG/hwsim.err"; then for _ in $(seq 1 20); do [[ -e /sys/class/ieee80211/phy1 ]] && break; sleep 0.5; done STA_IF="$(wl_of_phy phy0)"; AP_IF="$(wl_of_phy phy1)" - # phy0's rfkill switch by index, read while zone 0 still sees it: its sysfs leaves with the radio. - for r in /sys/class/rfkill/rfkill*; do [[ "$(cat "$r/name" 2>/dev/null)" == phy0 ]] && STA_RFKILL="${r##*/rfkill}"; done fi +# phy0's rfkill switch by index, read while zone 0 still sees it: its sysfs leaves with the radio. +STA_RFKILL="" +for r in /sys/class/rfkill/rfkill*; do [[ "$(cat "$r/name" 2>/dev/null)" == phy0 ]] && STA_RFKILL="${r##*/rfkill}"; done # Switch rfkill INDEX soft-off (1) or on (0) through its control device, which takes no name: # struct rfkill_event { u32 idx; u8 type (0, any); u8 op (2, change one); u8 soft; u8 hard; }. rfkill_soft() { From 2bfa604ff6362a3ebeca0ad2055bfa35d3b258f7 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 02:01:44 -0700 Subject: [PATCH 183/213] The image leaves out groff's, openssl's and gettext's documentation trees, 24.9 MiB Of the 29.3 MiB under /usr/share/doc, three trees are 24.9 MiB that no program reads: groff-1.23.0 (13.2 MiB, its manuals again as text, HTML and PostScript and its examples rendered; man reads the pages in /usr/share/man and the macros in /usr/share/groff), openssl (7.2 MiB, an HTML copy of the 901 man pages /usr/share/man holds) and gettext (4.5 MiB, examples and the manual as HTML). image-excludes.txt leaves them out with those reasons; bash's and readline's documentation and every man and info page stay. The image's program list drops usr/share/doc/gettext/examples/*, whose scripts leave with the tree: had the exclusion not taken, stage 06 would refuse those programs as unnamed. --- build/config/image-excludes.txt | 3 +++ build/config/image-programs.txt | 1 - 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/build/config/image-excludes.txt b/build/config/image-excludes.txt index bf6c4f56..56c9db90 100644 --- a/build/config/image-excludes.txt +++ b/build/config/image-excludes.txt @@ -10,3 +10,6 @@ /usr/share/pkgconfig/hwdata.pc # the same /usr/bin/lto-dump # GCC's dump of LTO bytecode, which no build runs /usr/share/man/man1/lto-dump.1 # the same +/usr/share/doc/groff-* # groff's manuals again as text, HTML and PostScript, and its examples rendered: man reads the pages in /usr/share/man and the macros in /usr/share/groff +/usr/share/doc/openssl # an HTML copy of openssl's 900 man pages, which /usr/share/man holds +/usr/share/doc/gettext # gettext's examples and its manual as HTML, which no program reads diff --git a/build/config/image-programs.txt b/build/config/image-programs.txt index 96e2cceb..fe7f1b11 100644 --- a/build/config/image-programs.txt +++ b/build/config/image-programs.txt @@ -1267,7 +1267,6 @@ usr/sbin/wpa_passphrase usr/sbin/wpa_supplicant usr/sbin/zic usr/sbin/zramctl -usr/share/doc/gettext/examples/* usr/share/doc/nftables/examples/* usr/share/gettext/config.rpath usr/share/gettext/projects/GNOME/team-address From 9a5b07c487f41d76e74034a6747ee68bedb343a3 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 02:03:00 -0700 Subject: [PATCH 184/213] The rfkill index is read inside the module's load, and its switch defined where it is used, clear of others' edits --- build/guest-tests/zones-check.sh | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index 0f050cec..6b7e54b2 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -718,17 +718,9 @@ STA_IF=""; AP_IF=""; AP_HOLD="" if modprobe mac80211_hwsim radios=2 2> "$LOG/hwsim.err"; then for _ in $(seq 1 20); do [[ -e /sys/class/ieee80211/phy1 ]] && break; sleep 0.5; done STA_IF="$(wl_of_phy phy0)"; AP_IF="$(wl_of_phy phy1)" + # phy0's rfkill switch by index, read while zone 0 still sees it: its sysfs leaves with the radio. + for r in /sys/class/rfkill/rfkill*; do [[ "$(cat "$r/name" 2>/dev/null)" == phy0 ]] && STA_RFKILL="${r##*/rfkill}"; done fi -# phy0's rfkill switch by index, read while zone 0 still sees it: its sysfs leaves with the radio. -STA_RFKILL="" -for r in /sys/class/rfkill/rfkill*; do [[ "$(cat "$r/name" 2>/dev/null)" == phy0 ]] && STA_RFKILL="${r##*/rfkill}"; done -# Switch rfkill INDEX soft-off (1) or on (0) through its control device, which takes no name: -# struct rfkill_event { u32 idx; u8 type (0, any); u8 op (2, change one); u8 soft; u8 hard; }. -rfkill_soft() { - python3 -c 'import os, struct, sys -fd = os.open("/dev/rfkill", os.O_WRONLY) -os.write(fd, struct.pack("=IBBBB", int(sys.argv[1]), 0, 2, int(sys.argv[2]), 0))' "$1" "$2" -} if [[ -n "$STA_IF" && -n "$AP_IF" ]]; then pass "wifi-module" "the signed mac80211_hwsim loaded with two radios: $STA_IF on phy0, $AP_IF on phy1" else @@ -830,6 +822,13 @@ fi # A radio switched off as the net zone starts, by an airplane-mode key or a switch, moves in down: # brought up, its link would be refused (ERFKILL) and the zone with it, wire and all. Switched on, # the next start joins again. +# Switch rfkill INDEX soft-off (1) or on (0) through its control device, which takes no name: +# struct rfkill_event { u32 idx; u8 type (0, any); u8 op (2, change one); u8 soft; u8 hard; }. +rfkill_soft() { + python3 -c 'import os, struct, sys +fd = os.open("/dev/rfkill", os.O_WRONLY) +os.write(fd, struct.pack("=IBBBB", int(sys.argv[1]), 0, 2, int(sys.argv[2]), 0))' "$1" "$2" +} off_new=0; off_line=""; on_line="" if [[ -n "$STA_RFKILL" ]]; then # Switched off while no net zone runs, so only the next one can say READY. From 188bd8b332e5f363b7693c639125bfad61f1e7c7 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 02:03:35 -0700 Subject: [PATCH 185/213] rfkill_soft is defined before the radio-off check, its first caller here --- build/guest-tests/zones-check.sh | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index 783b8440..519ef35c 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -850,6 +850,13 @@ fi # the status and the scan say so. Switched on again, the net zone brings the link up, which its # supplicant cannot see for itself, and the radio joins again. Switched off when the net zone # starts, it gets no supplicant, which cannot bring the link up; switched on, it gets one. +# Switch rfkill INDEX soft-off (1) or on (0) through its control device, which takes no name: +# struct rfkill_event { u32 idx; u8 type (0, any); u8 op (2, change one); u8 soft; u8 hard; }. +rfkill_soft() { + python3 -c 'import os, struct, sys +fd = os.open("/dev/rfkill", os.O_WRONLY) +os.write(fd, struct.pack("=IBBBB", int(sys.argv[1]), 0, 2, int(sys.argv[2]), 0))' "$1" "$2" +} off_line=""; off_scan=""; back_line=""; start_off=""; start_back="" if [[ -n "$STA_RFKILL" ]]; then before="$(ready_count)" @@ -909,13 +916,6 @@ fi # A radio switched off as the net zone starts, by an airplane-mode key or a switch, moves in down: # brought up, its link would be refused (ERFKILL) and the zone with it, wire and all. Switched on, # the next start joins again. -# Switch rfkill INDEX soft-off (1) or on (0) through its control device, which takes no name: -# struct rfkill_event { u32 idx; u8 type (0, any); u8 op (2, change one); u8 soft; u8 hard; }. -rfkill_soft() { - python3 -c 'import os, struct, sys -fd = os.open("/dev/rfkill", os.O_WRONLY) -os.write(fd, struct.pack("=IBBBB", int(sys.argv[1]), 0, 2, int(sys.argv[2]), 0))' "$1" "$2" -} off_new=0; off_line=""; on_line="" if [[ -n "$STA_RFKILL" ]]; then # Switched off while no net zone runs, so only the next one can say READY. From 8042e8f3cffa468f881fc9cd77484da7bbca42e1 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 02:05:49 -0700 Subject: [PATCH 186/213] A proposal weighs three ways to say why a Wi-Fi network was not joined A wrong passphrase leaves the net zone at wifi=connecting with nothing said. The note sets out what wpa_supplicant 2.12 knows of a refused join and where it says so (an event only, and nothing at all for a WPA3 access point that drops a wrong confirm), weighs reading the TEMP-DISABLED flag in the loop, keeping the supplicant's log, and a listener on its socket, and recommends the first. Nothing is built. --- docs/design/wifi-refusals.md | 76 ++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 docs/design/wifi-refusals.md diff --git a/docs/design/wifi-refusals.md b/docs/design/wifi-refusals.md new file mode 100644 index 00000000..9fc5f76c --- /dev/null +++ b/docs/design/wifi-refusals.md @@ -0,0 +1,76 @@ +# Saying why a Wi-Fi network was not joined + +**A proposal, waiting for the owner's decision.** Nothing here is built. A +saved network whose passphrase is wrong leaves the net zone's status at +`wifi=connecting` for as long as the zone runs, and nothing says why. This +note sets out what wpa_supplicant 2.12 knows about a refused join, where it +says so, and three ways the net zone could pass it on, with a +recommendation. + +## What wpa_supplicant knows + +- **WPA2.** A four-way handshake that fails on a new connection to a PSK + network is taken for a wrong key: `could_be_psk_mismatch` + (`events.c:5092`) logs "pre-shared key may be incorrect" and calls + `wpas_auth_failed(…, "WRONG_KEY")` (`events.c:5149`). +- **WPA3.** That test is skipped for SAE (`events.c:5098`). A wrong SAE + password fails the authentication itself. An access point that answers + the station's confirm with a status code makes it report + `CTRL-EVENT-AUTH-REJECT … auth_type=3 … status_code=N` (`sme.c:2915`); + one that drops the frame leaves a timeout, as an access point that has + gone does. Kryptik saves a network given a passphrase (not a 64-digit + key) as `WPA-PSK WPA-PSK-SHA256 SAE`, and the supplicant takes SAE + wherever the access point offers it, so on a WPA3 network a wrong + passphrase can look exactly like an absent one. +- **Any cause, after four.** More than three failed joins in a row reach + `wpas_auth_failed(…, "CONN_FAILED")` (`wpa_supplicant.c:9156-9159`), + whatever failed. +- `wpas_auth_failed` holds the network back, 10 s at first and up to 300 s, + and says so once, as the event `CTRL-EVENT-SSID-TEMP-DISABLED id=… + ssid="…" auth_failures=N duration=S reason=…` with `WRONG_KEY`, + `AUTH_FAILED`, `CONN_FAILED` or `NO_PSK_AVAILABLE` + (`wpa_supplicant.c:9624`). The control interface keeps no reason: + `LIST_NETWORKS` flags `[TEMP-DISABLED]` for all of them + (`ctrl_iface.c:2857`). +- `wpa_cli -a` hands its script CONNECTED, DISCONNECTED and a fixed list of + access point, mesh, P2P, WPS and DPP events (`wpa_cli.c:4582-4690`); + neither TEMP-DISABLED nor AUTH-REJECT is among them. +- The net zone starts the supplicant with `-B` + (`tools/net/netzone-init.sh:204`), so its own output goes nowhere. The + recipe builds `CONFIG_DEBUG_FILE` (`build/recipes/wpa-supplicant.sh:24`), + so `-f FILE` would keep its messages, events among them. + +## Three ways + +**Read the flag in the loop.** `wifi_state` (`netzone-init.sh:212`) asks +each radio's supplicant for `status` every ten seconds already. While no +radio has joined, it would also ask `list_networks`, and a saved network +flagged `[TEMP-DISABLED]` would give the status a word of its own, shown as +"SSID refused the last joins: the passphrase may be wrong, or the network is +out of reach". It adds one control command to a loop that exists and one +status word: no process, no file, no privilege. It cannot tell a wrong +passphrase from an absent network, and on WPA3 nothing can. + +**Keep the supplicant's log.** Started with +`-f /run/wpa_supplicant.RADIO.log`, the supplicant writes every event to a +file in the zone's tmpfs. The loop would take the last TEMP-DISABLED and +AUTH-REJECT lines and turn `reason=WRONG_KEY`, or SAE's `status_code=15` +(the access point could not verify the station's confirm), into "the +passphrase is wrong". Every scan and event lengthens the file, so the loop +must also cut it, and the reading depends on the text of informational +messages, of which only the event names are kept stable. The answer is +definite for WPA2, and for WPA3 access points that reject rather than drop. + +**Listen on the control socket.** A process in the net zone attached +(`ATTACH`) to each supplicant's socket, writing the last reason to a file +the loop reads. `wpa_cli` cannot be it (above), so it is a new program that +holds the supplicant's socket for as long as the zone runs. It gives the +same answers as the log, at the cost of a program to build, review and ship. + +## Recommendation + +The first. It adds nothing that runs and no file, and what it says is true +on every network; neither of the others can say more on a WPA3 network that +drops a wrong confirm. If the owner also wants WPA2's definite answer, the +log is the smaller step on top of it, and the listener is not worth its +program. From 948dd01cbf26e9ad83db2d54d4b185bae5bab81c Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 02:07:37 -0700 Subject: [PATCH 187/213] The --write case goes through check(), so its arguments are used shellcheck's SC2120 refused check() forwarding "$@" with no caller passing any. Write mode exits before it reads the list, so the --list check() adds is harmless. --- tools/tests/check-image-programs.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/tests/check-image-programs.sh b/tools/tests/check-image-programs.sh index 7cb2cd3c..200490e4 100755 --- a/tools/tests/check-image-programs.sh +++ b/tools/tests/check-image-programs.sh @@ -43,7 +43,7 @@ printf '/usr/bin/hostname\n' > "$L" out="$(check)"; rc=$? [[ "$rc" -ne 0 && "$out" == *"no leading /"* ]] && ok "an absolute entry is refused" || bad "absolute entry: rc=$rc: $out" -out="$(NO_COLOR=1 bash "$CHECK" "$R" --write "$T/written.txt" 2>&1)"; rc=$? +out="$(check --write "$T/written.txt")"; rc=$? [[ "$rc" -eq 0 && "$(cat "$T/written.txt")" == $'usr/bin/hostname\nusr/bin/ifconfig\nusr/lib/libz.so.1.3.1' ]] \ && ok "--write lists what the root holds, sorted" || bad "--write: rc=$rc: $out" From 1812628106048355680d41896ab902a918365f7d Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 02:07:58 -0700 Subject: [PATCH 188/213] The Wi-Fi refusals note reads the link as the loop does, and keeps the log where Landlock allows The loop reads the link from the kernel; the supplicant's socket is reached only through kryptikd's door, so the first way adds a short-lived door call per pass, not a command. The log goes under /run/wpa_supplicant, the one place the supplicant may write. Two notes for a builder: the flag comes and goes between attempts, and refused:SSID cannot be read as a network's name. --- docs/design/wifi-refusals.md | 51 +++++++++++++++++++++++------------- 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/docs/design/wifi-refusals.md b/docs/design/wifi-refusals.md index 9fc5f76c..8af298a7 100644 --- a/docs/design/wifi-refusals.md +++ b/docs/design/wifi-refusals.md @@ -35,25 +35,31 @@ recommendation. - `wpa_cli -a` hands its script CONNECTED, DISCONNECTED and a fixed list of access point, mesh, P2P, WPS and DPP events (`wpa_cli.c:4582-4690`); neither TEMP-DISABLED nor AUTH-REJECT is among them. -- The net zone starts the supplicant with `-B` - (`tools/net/netzone-init.sh:204`), so its own output goes nowhere. The - recipe builds `CONFIG_DEBUG_FILE` (`build/recipes/wpa-supplicant.sh:24`), - so `-f FILE` would keep its messages, events among them. +- The net zone starts the supplicant with `-B`, so its own output goes + nowhere. The recipe builds `CONFIG_DEBUG_FILE` + (`build/recipes/wpa-supplicant.sh:24`), so `-f FILE` would keep its + messages, events among them. +- The supplicant's control socket is its own user's. The net zone's loop + (`wifi_state` in `tools/net/netzone-init.sh`) reads the link from the + kernel instead, `operstate` and `iw dev N link`; the zone's root reaches + the socket only through `kryptikd supplicant -- wpa_cli …`, as + `start_wifi` does once per start. ## Three ways -**Read the flag in the loop.** `wifi_state` (`netzone-init.sh:212`) asks -each radio's supplicant for `status` every ten seconds already. While no -radio has joined, it would also ask `list_networks`, and a saved network -flagged `[TEMP-DISABLED]` would give the status a word of its own, shown as -"SSID refused the last joins: the passphrase may be wrong, or the network is -out of reach". It adds one control command to a loop that exists and one -status word: no process, no file, no privilege. It cannot tell a wrong +**Read the flag in the loop.** While no radio has joined, each pass of the +ten-second loop would also run `list_networks` through that door, and a +saved network flagged `[TEMP-DISABLED]` would give the status a shape of its +own, `wifi=refused:SSID`, shown as "SSID refused the last joins: the +passphrase may be wrong, or the network is out of reach". It adds a +short-lived door call to each pass while nothing is joined, and one status +shape: no process that stays, no file, no privilege. It cannot tell a wrong passphrase from an absent network, and on WPA3 nothing can. **Keep the supplicant's log.** Started with -`-f /run/wpa_supplicant.RADIO.log`, the supplicant writes every event to a -file in the zone's tmpfs. The loop would take the last TEMP-DISABLED and +`-f /run/wpa_supplicant/RADIO.log` (its Landlock rules let it write under +`/run/wpa_supplicant` alone), the supplicant writes every event to a file +in the zone's tmpfs. The loop would take the last TEMP-DISABLED and AUTH-REJECT lines and turn `reason=WRONG_KEY`, or SAE's `status_code=15` (the access point could not verify the station's confirm), into "the passphrase is wrong". Every scan and event lengthens the file, so the loop @@ -69,8 +75,17 @@ same answers as the log, at the cost of a program to build, review and ship. ## Recommendation -The first. It adds nothing that runs and no file, and what it says is true -on every network; neither of the others can say more on a WPA3 network that -drops a wrong confirm. If the owner also wants WPA2's definite answer, the -log is the smaller step on top of it, and the listener is not worth its -program. +The first. It adds nothing that stays running and no file, and what it says +is true on every network; neither of the others can say more on a WPA3 +network that drops a wrong confirm. If the owner also wants WPA2's definite +answer, the log is the smaller step on top of it, and the listener is not +worth its program. + +Two things for whoever builds it. The flag clears once its hold-off has +passed and the supplicant picks networks again (10 s at first, then 20, 30, +60, 120 and 300 s), so on a ten-second loop it would come and go between +attempts: the status should keep `refused` until a join, or until the saved +networks change. And `wifi=` already mixes words with SSIDs (`none`, +`unconfigured`, `connecting`, or the SSID), so a network named like a word +reads as that word; a shape that is no SSID, `refused:SSID`, adds no such +name. From 64d4ecb1f4db3b36da40a4082358585ece3ae40b Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 02:16:07 -0700 Subject: [PATCH 189/213] esp_readable sits beside slot_dev, apart from the ESP helpers that moved to esp-records.sh --- tools/update/kryptik-recover | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/update/kryptik-recover b/tools/update/kryptik-recover index 369a8aec..091a6c02 100755 --- a/tools/update/kryptik-recover +++ b/tools/update/kryptik-recover @@ -57,9 +57,9 @@ cleanup() { } trap cleanup EXIT INT TERM slot_dev() { case "$1" in a) echo "$SA" ;; b) echo "$SB" ;; *) die "slot must be a or b" ;; esac; } -has_fs() { [ "$(dd if="$1" bs=1 skip=1080 count=2 status=none | od -An -tx1 | tr -d ' \n')" = "53ef" ]; } # A FAT that will not mount but reads is damaged; one whose first block will not read is a failing disk. esp_readable() { dd if="$ESP" of=/dev/null bs=4096 count=1 status=none 2>/dev/null; } +has_fs() { [ "$(dd if="$1" bs=1 skip=1080 count=2 status=none | od -An -tx1 | tr -d ' \n')" = "53ef" ]; } # Written, fsynced, renamed, as the kernels are: a power cut leaves the old file whole, not empty. put() { printf '%s\n' "$2" > "$1.new" && sync -f "$1.new" && mv -f "$1.new" "$1"; } # put FILE LINE # Drop FILE's clean pages, so the next read is what the device holds; best effort. From 910c0d9132f9b3eede6ee2b69b761963de48584a Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 02:16:30 -0700 Subject: [PATCH 190/213] Slot b's records go on a line of their own, apart from the ESP writes before them --- tools/install/kryptik-install.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/install/kryptik-install.sh b/tools/install/kryptik-install.sh index d3dd7bbd..d9e4f6fa 100755 --- a/tools/install/kryptik-install.sh +++ b/tools/install/kryptik-install.sh @@ -340,8 +340,9 @@ sync -f "$MNT_BASE/tesp/EFI/BOOT/BOOTX64.EFI.new" mv -f "$MNT_BASE/tesp/EFI/BOOT/BOOTX64.EFI.new" "$MNT_BASE/tesp/EFI/BOOT/BOOTX64.EFI" || die "could not install BOOTX64.EFI" cmp -s "$MNT_BASE/tesp/EFI/BOOT/BOOTX64.EFI" "$MNT_BASE/tesp/EFI/kryptik/kryptik-a.efi" || die "BOOTX64.EFI is not the slot A kernel" printf 'a\n' > "$MNT_BASE/tesp/kryptik/committed-slot" +rm -f "$MNT_BASE/tesp/kryptik/media-kernel" # Slot b was cleared above: the medium's kernel and version for it would name a release it does not hold. -rm -f "$MNT_BASE/tesp/kryptik/media-kernel" "$MNT_BASE/tesp/EFI/kryptik/kryptik-b.efi" "$MNT_BASE/tesp/kryptik/version-b" +rm -f "$MNT_BASE/tesp/EFI/kryptik/kryptik-b.efi" "$MNT_BASE/tesp/kryptik/version-b" sync umount "$MNT_BASE/tesp" || die "could not unmount the new ESP" From 7494672b3e5d60a46096330b1b1f9309c7c5e108 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 02:17:19 -0700 Subject: [PATCH 191/213] The Wi-Fi verb's calls stay as they were; the state verb asks through ask_passphrase read_passphrase SSID is a line over the shared ask_passphrase PROMPT, so cmd_wifi is untouched and merges beside the open-network change to it. --- tools/kryptik | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tools/kryptik b/tools/kryptik index 0e2e0bd7..0f741aa2 100755 --- a/tools/kryptik +++ b/tools/kryptik @@ -291,7 +291,7 @@ cmd_run() { # Read one line into PASSPHRASE: from the terminal with echo off, or a pipe. PASSPHRASE="" -read_passphrase() { # prompt +ask_passphrase() { # prompt if [[ -t 0 ]]; then IFS= read -rs -p "$1" PASSPHRASE printf '\n' >&2 @@ -304,6 +304,8 @@ read_passphrase() { # prompt # --- the net zone's Wi-Fi networks, kept by kryptikd in zone 0 -------------- # The passphrase always goes on stdin; kryptikd restarts the net zone after a change. +read_passphrase() { ask_passphrase "passphrase for $1: "; } # read_passphrase SSID + cmd_wifi() { local sub="${1:-}"; [[ $# -gt 0 ]] && shift case "$sub" in @@ -314,7 +316,7 @@ cmd_wifi() { if via_launch; then case "$sub" in list) "$LAUNCH" --wifi-list ;; - add) read_passphrase "passphrase for $1: " + add) read_passphrase "$1" # printf is a builtin, so the passphrase is never in argv. printf '%s\n' "$PASSPHRASE" | "$LAUNCH" --wifi-add "$1" ;; forget) "$LAUNCH" --wifi-forget "$1" ;; @@ -324,7 +326,7 @@ cmd_wifi() { have_kryptikd case "$sub" in list) "$KRYPTIKD" wifi list --wifi-dir "$WIFI_DIR" ;; - add) read_passphrase "passphrase for $1: " + add) read_passphrase "$1" printf '%s\n' "$PASSPHRASE" | "$KRYPTIKD" wifi add "$1" --wifi-dir "$WIFI_DIR" --zones "$ZONES_DIR" ;; forget) "$KRYPTIKD" wifi forget "$1" --wifi-dir "$WIFI_DIR" --zones "$ZONES_DIR" ;; esac @@ -350,16 +352,16 @@ cmd_state() { local dev old new n dev="$(sed -n 's/^state_dev=//p' /run/kryptik/boot-identity 2>/dev/null)" [[ -b "$dev" ]] || die "this system has no state partition in use" - read_passphrase "the state passphrase now: "; old="$PASSPHRASE" + ask_passphrase "the state passphrase now: "; old="$PASSPHRASE" printf '%s' "$old" | cryptsetup open --test-passphrase --type luks2 --key-file=- "$dev" 2>/dev/null case $? in 0) ;; 2) die "that is not the state passphrase; nothing was changed" ;; *) die "cryptsetup could not check the passphrase against $dev; nothing was changed" ;; esac - read_passphrase "a new one, asked at every boot: "; new="$PASSPHRASE" + ask_passphrase "a new one, asked at every boot: "; new="$PASSPHRASE" if [[ -t 0 ]]; then - read_passphrase "again: " + ask_passphrase "again: " [[ "$PASSPHRASE" == "$new" ]] || die "the two passphrases differ; nothing was changed" fi PASSPHRASE="" From eb428dabf69312e6b4f2c0edd442e370f39c4b68 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 02:26:22 -0700 Subject: [PATCH 192/213] kryptikd's words on a switched-off radio say what it does, and leave the rest to the zone --- compartments/kryptikd/src/netzone.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/compartments/kryptikd/src/netzone.rs b/compartments/kryptikd/src/netzone.rs index 2e0f0352..921cb34f 100755 --- a/compartments/kryptikd/src/netzone.rs +++ b/compartments/kryptikd/src/netzone.rs @@ -446,14 +446,13 @@ fn plumb_nic_zone_bridge(zone: &Zone, zone_ns: i32) -> Result<(), NetError> { _ => {} } /* A radio switched off stays down: brought up, its link is refused (ERFKILL), and with it the - * whole zone, wire and all. The zone's script holds it and starts its supplicant, which brings - * the link up, once it is switched on. */ + * whole zone, wire and all. Bringing it up once it is switched on is the zone's. */ let off: Vec<&String> = nics.iter().filter(|n| switched_off(Path::new("/sys/class/net"), n)).collect(); for n in &nics { let carried = carry_nic(n, zone_ns, off.contains(&n))?; eprintln!("kryptikd: zone {:?}: {n:?} moved in with {carried}", zone.name); if off.contains(&n) { - eprintln!("kryptikd: zone {:?}: {n:?} is switched off (rfkill); it stays down until switched on", zone.name); + eprintln!("kryptikd: zone {:?}: {n:?} is switched off (rfkill), so it is left down", zone.name); } } netlink::with_netns(zone_ns, || { From 0e99769469606c4620e53ea35b79bb3def6340bb Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 03:03:30 -0700 Subject: [PATCH 193/213] medium-shell expects --status's lines in the order it prints them: the boot file, then the firmware's entries expect consumes the transcript up to each match, and --status prints the BOOTX64.EFI line before the firmware lines, so the firmware line expected first left the boot file's behind it and the suite would have timed out there. --- tools/image/medium-shell-test.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tools/image/medium-shell-test.sh b/tools/image/medium-shell-test.sh index 6638a50f..786502e2 100755 --- a/tools/image/medium-shell-test.sh +++ b/tools/image/medium-shell-test.sh @@ -72,8 +72,9 @@ for ((i = 0; i < ${#MEDIA[@]}; i += 2)); do "send:mount /dev/vdb1 /mnt" "send:kryptik-recover --disk /dev/vda --backup-state-header /mnt/kryptik-state-header" "expect:is in /mnt/kryptik-state-header" "send:umount /mnt" - "send:kryptik-recover --disk /dev/vda --status" "expect:committed slot a" "expect:slot a +version [0-9]" "expect:firmware +(Boot[0-9A-F]{4} for slot a|no Kryptik entry)" - "expect:BOOTX64.EFI +slot a's kernel" "expect:this medium +version [0-9][^ ]*, which --restore-slot writes" + "send:kryptik-recover --disk /dev/vda --status" "expect:committed slot a" "expect:slot a +version [0-9]" + "expect:BOOTX64.EFI +slot a's kernel" "expect:firmware +(Boot[0-9A-F]{4} for slot a|no Kryptik entry)" + "expect:this medium +version [0-9][^ ]*, which --restore-slot writes" "send:kryptik-recover --disk /dev/vda --commit-slot a" "expect:committed: BOOTX64.EFI is now slot a" "send:kryptik-recover --disk /dev/vda --restore-slot a" "expect:done: boot /dev/vda without the medium" ) From 12d5f4447310fede0b37f0b35b2e92a8b7f0016a Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 04:54:26 -0700 Subject: [PATCH 194/213] The program list names #379's guest-test probe, which lands before it probe-sources.py is installed 0755 under /usr/lib/kryptik/guest-tests, and #379 comes before this change in the merge order, so the image this list checks carries it. --- build/config/image-programs.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/build/config/image-programs.txt b/build/config/image-programs.txt index 96e2cceb..a67324d5 100644 --- a/build/config/image-programs.txt +++ b/build/config/image-programs.txt @@ -735,6 +735,7 @@ usr/lib/kryptik/compartments/tests/serve.sh usr/lib/kryptik/guest-tests/broker-client.py usr/lib/kryptik/guest-tests/gui-check.sh usr/lib/kryptik/guest-tests/icmp-echo.py +usr/lib/kryptik/guest-tests/probe-sources.py usr/lib/kryptik/guest-tests/zones-check.sh usr/lib/kryptik/s6-rc/compiled/servicedirs/eudev/run usr/lib/kryptik/s6-rc/compiled/servicedirs/getty-tty1/run From 9dbfac1d317f88b303ee7d8d29ac138b3e2802c4 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 04:55:26 -0700 Subject: [PATCH 195/213] The image's program list names microcode-notice.sh, which this installs Stage 06 and the acceptance refuse an executable file the list does not name, so the script that this change adds to /usr/libexec/kryptik is named with it. --- build/config/image-programs.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/build/config/image-programs.txt b/build/config/image-programs.txt index a67324d5..8a1a51ec 100644 --- a/build/config/image-programs.txt +++ b/build/config/image-programs.txt @@ -1106,6 +1106,7 @@ usr/libexec/kryptik/installer-run.sh usr/libexec/kryptik/keyboard.sh usr/libexec/kryptik/kryptikd-check.sh usr/libexec/kryptik/medium-root.sh +usr/libexec/kryptik/microcode-notice.sh usr/libexec/kryptik/netzone-init.sh usr/libexec/kryptik/sntp-offset.py usr/libexec/kryptik/sysinit.sh From fd82bf31f91556a0916d34b4078aaecff83a10cb Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 04:55:30 -0700 Subject: [PATCH 196/213] The image's program list names medium-banner.sh, which this installs Stage 06 and the acceptance refuse an executable file the list does not name, so the script that this change adds to /usr/libexec/kryptik is named with it. --- build/config/image-programs.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/build/config/image-programs.txt b/build/config/image-programs.txt index a67324d5..d32b8864 100644 --- a/build/config/image-programs.txt +++ b/build/config/image-programs.txt @@ -1105,6 +1105,7 @@ usr/libexec/kryptik/firstboot.sh usr/libexec/kryptik/installer-run.sh usr/libexec/kryptik/keyboard.sh usr/libexec/kryptik/kryptikd-check.sh +usr/libexec/kryptik/medium-banner.sh usr/libexec/kryptik/medium-root.sh usr/libexec/kryptik/netzone-init.sh usr/libexec/kryptik/sntp-offset.py From 07ce483df7a12301c5da64c8b55b2775353bc09a Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 05:19:20 -0700 Subject: [PATCH 197/213] The kernel takes no initramfs and no boot parameters from whatever loader starts it Without BLK_DEV_INITRD the EFI stub loads no LoadFile2 initrd, and without the handover entry no loader's boot parameters reach the kernel, so a loader the firmware trusts cannot run an /init of its own before the verified root. --- build/config/kernel/boot.fragment | 7 ++++++- docs/design/boot-and-updates.md | 4 +++- docs/threat-model.md | 4 +++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/build/config/kernel/boot.fragment b/build/config/kernel/boot.fragment index 32111c45..55334120 100644 --- a/build/config/kernel/boot.fragment +++ b/build/config/kernel/boot.fragment @@ -9,10 +9,12 @@ # --- the kernel is the UEFI application --- # The EFI stub boots the kernel without a bootloader. No EFI_MIXED (32-bit -# firmware): x86_64 only, and a smaller signed stub. +# firmware): x86_64 only, and a smaller signed stub. No handover entry, which +# runs on boot parameters a loader filled in: the firmware uses the PE entry. CONFIG_EFI=y CONFIG_EFI_STUB=y # CONFIG_EFI_MIXED is not set +# CONFIG_EFI_HANDOVER_PROTOCOL is not set CONFIG_EFI_PARTITION=y # efivarfs, for the updater's BootNext and Boot#### entries (the A/B trial). CONFIG_EFIVAR_FS=y @@ -28,6 +30,9 @@ CONFIG_CMDLINE_OVERRIDE=y # --- the verified root, without an initramfs --- # dm-init builds the dm-verity (and for the ISO, dm-linear) device from # dm-mod.create= (devices by PARTLABEL=), so no unsigned initramfs is needed. +# None is taken either: a loader that starts this kernel can offer one (a +# LoadFile2 initrd), and its /init would run as root before the verified root. +# CONFIG_BLK_DEV_INITRD is not set CONFIG_BLK_DEV_DM=y CONFIG_DM_INIT=y CONFIG_DM_VERITY=y diff --git a/docs/design/boot-and-updates.md b/docs/design/boot-and-updates.md index dc4ea3dc..db98b2d8 100644 --- a/docs/design/boot-and-updates.md +++ b/docs/design/boot-and-updates.md @@ -6,7 +6,9 @@ line compiled in. The command line names the root slot and carries its dm-verity root hash, and the kernel's `dm-mod.create=` (`CONFIG_DM_INIT`) builds the verity device before mounting root: no initramfs, bootloader or editable file lies between the firmware's signature check and the read-only -verified root. Mutable state is on a separate +verified root. The kernel has no initramfs support and no handover entry, so +a loader that starts it anyway, even one the firmware trusts, cannot give it +an initramfs or boot parameters of its own. Mutable state is on a separate [encrypted partition](state-encryption.md). Two root slots and a firmware `BootNext` trial give A/B updates with a bounded fallback. diff --git a/docs/threat-model.md b/docs/threat-model.md index 1cd62562..af549f03 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -139,7 +139,9 @@ media.* **Defended at rest against a reader, not against a writer of the state partition.** dm-verity and Secure Boot make a modified root or a swapped kernel -fail to boot. The state partition (`/home`, `/var`, the Wi-Fi passphrases, the +fail to boot, and the kernel takes no initramfs, so a loader the firmware also +trusts cannot run code of its own before the verified root. The state +partition (`/home`, `/var`, the Wi-Fi passphrases, the shadow file, the `/etc` overlay, the zone volumes' headers) is LUKS2 with a passphrase at every boot, and the zone volumes inside it are encrypted again; a stolen disk gives up none of it. It is not authenticated: someone holding From bdb97c1bd53f867fff8ef0aa94ebed5080aa8899 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 05:19:25 -0700 Subject: [PATCH 198/213] MSG_OOB on unix sockets, TCP MD5 signatures and HugeTLB pages, which any zone could reach, are not built Nothing in the image uses them, and every zone's filter lets their calls through. --- build/config/kernel/hardening.fragment | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/build/config/kernel/hardening.fragment b/build/config/kernel/hardening.fragment index c82fcdb8..e2075495 100644 --- a/build/config/kernel/hardening.fragment +++ b/build/config/kernel/hardening.fragment @@ -140,6 +140,12 @@ CONFIG_SECURITYFS=y # network.mode = "none" would have more than the loopback it promises. # CONFIG_IPV6_SIT is not set +# What any zone could ask of the kernel and nothing here uses: MSG_OOB on unix +# sockets, TCP's MD5 signature option (for BGP sessions), and HugeTLB pages. +# CONFIG_AF_UNIX_OOB is not set +# CONFIG_TCP_MD5SIG is not set +# CONFIG_HUGETLBFS is not set + # aes-xts-plain64, the cipher of standard LUKS2 volumes, for dm-crypt. CONFIG_CRYPTO_XTS=y From 06d10a3ce26ba3319e2413242e385cb1f74f5351 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 05:19:30 -0700 Subject: [PATCH 199/213] Defconfig's unused network, debug and device options are not built IP autoconfiguration, multicast routing, SELinux's and Smack's packet labels, NFLOG, tc's classifier frameworks, the network filesystems' DNS upcall, rmmod -f, the power-management and cgroup debug hooks, /dev/nvram and /dev/hpet. --- build/config/kernel/hardening.fragment | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/build/config/kernel/hardening.fragment b/build/config/kernel/hardening.fragment index c82fcdb8..072afe90 100644 --- a/build/config/kernel/hardening.fragment +++ b/build/config/kernel/hardening.fragment @@ -254,3 +254,25 @@ CONFIG_LSM="landlock,lockdown,yama" # CONFIG_NF_CONNTRACK_IRC is not set # CONFIG_NF_CONNTRACK_SIP is not set # CONFIG_NETCONSOLE is not set +# Kernel IP autoconfiguration (ip=, for an NFS root), multicast routing, the +# packet labels of SELinux and Smack (neither is built), NFLOG (left on by +# xtables' NFLOG target; the net zone's rules log nothing), tc's classifier, +# ematch and action frameworks (no filter is ever added), and the network +# filesystems' DNS upcall. The net zone's capabilities reach most of them. +# CONFIG_IP_PNP is not set +# CONFIG_IP_MROUTE is not set +# CONFIG_NETLABEL is not set +# CONFIG_NETWORK_SECMARK is not set +# CONFIG_NETFILTER_NETLINK_LOG is not set +# CONFIG_NET_CLS_CGROUP is not set +# CONFIG_NET_EMATCH is not set +# CONFIG_NET_CLS_ACT is not set +# CONFIG_DNS_RESOLVER is not set +# rmmod -f, the power-management debug hooks (pm_trace keeps its trace in the +# RTC), the cgroup debug controller, and /dev/nvram and /dev/hpet, which +# nothing opens. +# CONFIG_MODULE_FORCE_UNLOAD is not set +# CONFIG_PM_DEBUG is not set +# CONFIG_CGROUP_DEBUG is not set +# CONFIG_NVRAM is not set +# CONFIG_HPET is not set From 5784c61da5d228738d7b82b54ca9f0baed12d115 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 05:25:10 -0700 Subject: [PATCH 200/213] The guide and the threat model say what Microsoft's keys in db still allow A firmware that keeps Microsoft's certificates beside Kryptik's boots whatever they sign, so someone holding the machine can start such a system and have it imitate the state passphrase prompt; Secure Boot stops only a swapped Kryptik kernel. The guide's Secure Boot section says that taking Microsoft's certificates out of db and setting a firmware password close it, and what that costs (other systems, and the firmware drivers on discrete graphics and other cards, with the warning that a screen hung off such a card can leave the firmware's menus dark). The threat model's offline section states the same condition. --- docs/threat-model.md | 7 +++++++ docs/user-guide.md | 11 +++++++++++ 2 files changed, 18 insertions(+) diff --git a/docs/threat-model.md b/docs/threat-model.md index 1cd62562..cb43a1cb 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -150,6 +150,13 @@ limited to a list on the verified root ([state partition](design/state-encryption.md)). The ESP, the root slots and the LUKS header are in the clear, so the disk shows it is Kryptik. +**The passphrase prompt is Kryptik's only if the firmware boots nothing +else.** Secure Boot keeps a swapped kernel from booting, but a firmware that +also holds Microsoft's keys boots anything Microsoft signs, and such a system +can ask for the state passphrase as Kryptik does and keep the answer. Only +Kryptik's key alone in `db`, with a firmware password, closes that +([user guide](user-guide.md#1-boot-the-medium)). + **Not defended while running or suspended.** Keys are in RAM; see [coercion, and access to a running or suspended machine](#coercion-and-access-to-a-running-or-suspended-machine). diff --git a/docs/user-guide.md b/docs/user-guide.md index 31639a08..6b4c302f 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -91,6 +91,17 @@ refuses either (the acceptance run proves the refusal: the firmware setup menu; or turn Secure Boot off. The medium reports which it got: `KRYPTIK_SMOKE: secureboot=1` or `=0` on the console. +A firmware that also keeps Microsoft's certificates in `db` boots whatever +Microsoft signs, so someone with the machine in hand can start such a system +and have it ask for the state passphrase as Kryptik does. Taking Microsoft's +certificates out of `db`, leaving Kryptik's alone there, and setting a +firmware password, so that neither the keys nor Secure Boot can be changed +without it, closes that. Whatever Microsoft signed then no longer starts: +another operating system on the machine, and the firmware drivers that +discrete graphics cards and some other cards carry. Where the screen hangs +off such a card, even the firmware's own menus can stay dark until the +firmware is reset, so do it only on a machine whose screen works without one. + **Under QEMU** (a checkout of the source tree is needed): ```sh From 682223bfb578d4206046321c3c3debb22a003b4c Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 05:27:03 -0700 Subject: [PATCH 201/213] Microsoft's keys out of db: a password that a cleared CMOS keeps, option ROMs by name and by hash A firmware password closes the path only where clearing the firmware's settings does not remove it; the cards whose firmware drivers stop are mostly a desktop's add-in cards, and losing the screen can be for good on some machines. Where the screen needs such a card, db can hold its option ROM by hash instead of Microsoft's certificate. --- docs/threat-model.md | 3 ++- docs/user-guide.md | 16 +++++++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/threat-model.md b/docs/threat-model.md index cb43a1cb..1e91450e 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -154,7 +154,8 @@ the LUKS header are in the clear, so the disk shows it is Kryptik. else.** Secure Boot keeps a swapped kernel from booting, but a firmware that also holds Microsoft's keys boots anything Microsoft signs, and such a system can ask for the state passphrase as Kryptik does and keep the answer. Only -Kryptik's key alone in `db`, with a firmware password, closes that +Kryptik's key alone in `db`, with a firmware password that clearing the +firmware's settings does not remove, closes that ([user guide](user-guide.md#1-boot-the-medium)). **Not defended while running or suspended.** Keys are in RAM; see diff --git a/docs/user-guide.md b/docs/user-guide.md index 6b4c302f..bdd4ff0b 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -96,11 +96,17 @@ Microsoft signs, so someone with the machine in hand can start such a system and have it ask for the state passphrase as Kryptik does. Taking Microsoft's certificates out of `db`, leaving Kryptik's alone there, and setting a firmware password, so that neither the keys nor Secure Boot can be changed -without it, closes that. Whatever Microsoft signed then no longer starts: -another operating system on the machine, and the firmware drivers that -discrete graphics cards and some other cards carry. Where the screen hangs -off such a card, even the firmware's own menus can stay dark until the -firmware is reset, so do it only on a machine whose screen works without one. +without it, closes that where the password survives clearing the firmware's +settings: most business laptops' does, while on many desktop boards a jumper +or the battery clears it. Whatever Microsoft signed then no longer starts: +another operating system on the machine, and the firmware drivers (option +ROMs) that add-in graphics cards, and some network and storage cards, carry. +Where the screen hangs off such a card, mostly on a desktop, even the +firmware's own menus can stay dark until the firmware is reset or reflashed, +and on some machines for good. There `db` can hold that card's option ROM by +its hash in place of Microsoft's certificate (sbctl reads the hashes from the +TPM's event log), which keeps the screen and still boots nothing else +Microsoft signs. **Under QEMU** (a checkout of the source tree is needed): From 59654d819bf34a6954f503c74e4bcce3bfbf4ed0 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 05:27:50 -0700 Subject: [PATCH 202/213] The threat model's condition on db covers a card's option ROM held by hash --- docs/threat-model.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/threat-model.md b/docs/threat-model.md index 1e91450e..48bc6177 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -153,9 +153,10 @@ the LUKS header are in the clear, so the disk shows it is Kryptik. **The passphrase prompt is Kryptik's only if the firmware boots nothing else.** Secure Boot keeps a swapped kernel from booting, but a firmware that also holds Microsoft's keys boots anything Microsoft signs, and such a system -can ask for the state passphrase as Kryptik does and keep the answer. Only -Kryptik's key alone in `db`, with a firmware password that clearing the -firmware's settings does not remove, closes that +can ask for the state passphrase as Kryptik does and keep the answer. Only a +`db` that holds nothing able to boot another system (Kryptik's key, and at +most a card's option ROM by its hash), with a firmware password that clearing +the firmware's settings does not remove, closes that ([user guide](user-guide.md#1-boot-the-medium)). **Not defended while running or suspended.** Keys are in RAM; see From b101d32c94e7b5aaca27ea42eeae465ff3fb4bf2 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 05:39:47 -0700 Subject: [PATCH 203/213] The image leaves out the amdgpu firmware this kernel's amdgpu never loads firmware.list gains except lines. SI and CIK parts (not built), the camera ISP (not built), Instinct accelerators (no KFD), IP versions 6.18 does not know, and MEC2 and ASD files it never asks for: 167 of linux-firmware's 699 amdgpu files, 23 of 119 MB before compression. --- README.md | 8 +++--- build/config/firmware-accepted.txt | 18 ++++++++++++ build/config/firmware.list | 45 ++++++++++++++++++++++++++++++ build/recipes/firmware.sh | 13 ++++++--- 4 files changed, 76 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 54bd948b..487ce63a 100644 --- a/README.md +++ b/README.md @@ -114,10 +114,10 @@ the firmware from the pinned linux-firmware release rtl8xxxu; Broadcom over PCIe. The net zone owns the radio ([net zone design](docs/design/net-zone.md)). No Bluetooth. - **Display:** the firmware framebuffer (simpledrm) everywhere; Intel (i915, - xe) and AMD (amdgpu) with their firmware; ASPEED and Matrox server BMCs; - virtio-gpu, VMware SVGA, Hyper-V. NVIDIA machines use the firmware - framebuffer. The compositor renders with pixman, so no GPU acceleration is - needed. + xe) and AMD from GCN 3 on (amdgpu) with their firmware; ASPEED and Matrox + server BMCs; virtio-gpu, VMware SVGA, Hyper-V. NVIDIA machines, and AMD's + GCN 1 and 2 parts, use the firmware framebuffer. The compositor renders + with pixman, so no GPU acceleration is needed. - **Input:** USB HID, PS/2, I2C touchpads and touchscreens on Intel and AMD laptops, VMware and Hyper-V input. diff --git a/build/config/firmware-accepted.txt b/build/config/firmware-accepted.txt index 5e6b592d..8e4f1e08 100644 --- a/build/config/firmware-accepted.txt +++ b/build/config/firmware-accepted.txt @@ -19,3 +19,21 @@ rtlwifi/rtl8192cufw_* # no 8192CU stick binds # e100 runs a D101M or D101S without its microcode, which only adds receive bundling (CPUSaver). e100/d101[ms]_ucode.bin # optional + +# amdgpu binds no CIK part without DRM_AMDGPU_CIK, but code it shares with Topaz and with later +# parts' power management names Bonaire's and Hawaii's files (gmc_v7_0.c, smumgr.c). +amdgpu/bonaire_* # CIK +amdgpu/hawaii_* # CIK + +# Instinct accelerators compute and have no display; without KFD (HSA_AMD) nothing here uses one. +amdgpu/aldebaran_* # Instinct +amdgpu/arcturus_* # Instinct +amdgpu/gc_9_4_[34]_* # Instinct +amdgpu/gc_9_5_0_* # Instinct +amdgpu/psp_13_0_6_* # Instinct +amdgpu/psp_13_0_12_* # Instinct +amdgpu/psp_13_0_14_* # Instinct +amdgpu/sdma_4_4_[245].bin # Instinct +amdgpu/smu_13_0_6.bin # Instinct +amdgpu/smu_13_0_14.bin # Instinct +amdgpu/vcn_4_0_3.bin # Instinct diff --git a/build/config/firmware.list b/build/config/firmware.list index 44023a7b..0b977b01 100644 --- a/build/config/firmware.list +++ b/build/config/firmware.list @@ -1,6 +1,7 @@ # The only firmware shipped under /lib/firmware on the verified root (ADR-012). # PATTERN a `find -path` pattern in copy-firmware.sh's layout; a link brings its target # newest N PATTERN only the N highest trailing -NUMBERs (API versions) per device +# except PATTERN not what PATTERN matches, whichever line chose it # A pattern that matches nothing fails the build. # Intel Wi-Fi (iwlmvm and iwlmld; iwldvm is not built). iwlwifi asks for a device's highest API @@ -171,4 +172,48 @@ xe/ptl_guc_70.bin xe/*_huc.bin xe/lnl_gsc_1.bin xe/fan_control_*.bin +# AMD: amdgpu/, less what 6.18's amdgpu never loads here. SI and CIK parts: DRM_AMDGPU_SI and +# DRM_AMDGPU_CIK are off and radeon is not built, so they keep the firmware framebuffer. amdgpu/* +except amdgpu/tahiti_* +except amdgpu/pitcairn_* +except amdgpu/verde_* +except amdgpu/oland_* +except amdgpu/hainan_* +except amdgpu/si58_* +except amdgpu/banks_* +except amdgpu/vce_1_0_0.bin +except amdgpu/bonaire_* +except amdgpu/hawaii_* +except amdgpu/kaveri_* +except amdgpu/kabini_* +except amdgpu/mullins_* +# The camera's ISP (DRM_AMD_ISP is off). +except amdgpu/isp_* +# Instinct accelerators, which have no display and no use without KFD (HSA_AMD is off). +except amdgpu/aldebaran_* +except amdgpu/arcturus_* +except amdgpu/gc_9_4_[34]_* +except amdgpu/gc_9_5_0_* +except amdgpu/psp_13_0_6_* +except amdgpu/psp_13_0_12_* +except amdgpu/psp_13_0_14_* +except amdgpu/sdma_4_4_[245].bin +except amdgpu/smu_13_0_6.bin +except amdgpu/smu_13_0_14.bin +except amdgpu/vcn_4_0_3.bin +# IP versions amdgpu_discovery.c does not know yet. +except amdgpu/gc_11_7_* +except amdgpu/psp_15_0_* +except amdgpu/psp_13_0_15_* +except amdgpu/vcn_5_3_0.bin +except amdgpu/sdma_6_1_4.bin +except amdgpu/dcn_4_2_dmcub.bin +except amdgpu/vpe_2_0_0.bin +# Never asked for: MEC2 on GC 9.3 and Topaz (gfx_v9_0.c, gfx_v8_0.c), and ASD where the PSP +# loads only its TOC and TA (psp_v13_0.c). +except amdgpu/renoir_mec2.bin +except amdgpu/topaz_mec2.bin +except amdgpu/psp_13_0_5_asd.bin +except amdgpu/psp_13_0_8_asd.bin +except amdgpu/yellow_carp_asd.bin diff --git a/build/recipes/firmware.sh b/build/recipes/firmware.sh index 0b4fc547..91288bd0 100644 --- a/build/recipes/firmware.sh +++ b/build/recipes/firmware.sh @@ -14,16 +14,18 @@ s_firmware() { local dest="${KRYPTIK_DESTDIR}/lib/firmware" rm -rf "$dest"; mkdir -p "$dest" - local line keep pattern matches n total=0 missing=0 + local line keep pattern out matches n total=0 missing=0 local selected="${src}/.selected" - : > "$selected" + : > "$selected"; : > "${selected}.except" while IFS= read -r line; do line="${line%%#*}" line="${line#"${line%%[![:space:]]*}"}"; line="${line%"${line##*[![:space:]]}"}" [[ -n "$line" ]] || continue - keep=0; pattern="$line" + keep=0; pattern="$line"; out="$selected" if [[ "$line" =~ ^newest[[:space:]]+([0-9]+)[[:space:]]+(.+)$ ]]; then keep="${BASH_REMATCH[1]}"; pattern="${BASH_REMATCH[2]}" + elif [[ "$line" =~ ^except[[:space:]]+(.+)$ ]]; then + pattern="${BASH_REMATCH[1]}"; out="${selected}.except" fi matches="$(find "$tree" -path "${tree}/${pattern}" \( -type f -o -type l \) -print | sort)" if [[ "$keep" -gt 0 && -n "$matches" ]]; then @@ -42,7 +44,7 @@ s_firmware() { echo " MISSING ${pattern}: matches nothing in linux-firmware-${V_LINUX_FIRMWARE}" missing=$((missing + 1)) else - printf '%s\n' "$matches" >> "$selected" + printf '%s\n' "$matches" >> "$out" printf ' %5d %s\n' "$n" "$line" fi total=$((total + n)) @@ -51,6 +53,9 @@ s_firmware() { echo "FAIL: ${missing} pattern(s) in firmware.list match nothing; the list must name what this release has" return 1 fi + # An except line takes back what any other line chose. + sort -u "$selected" | comm -23 - <(sort -u "${selected}.except") > "${selected}.kept" + mv "${selected}.kept" "$selected" # A symlink's target comes too, wherever it points inside the tree. local f t while IFS= read -r f; do From 10a360ef03840e0fc1453a5acbd68ef029fc247b Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 05:42:25 -0700 Subject: [PATCH 204/213] The firmware step's count of selected files leaves out what except lines matched --- build/recipes/firmware.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/recipes/firmware.sh b/build/recipes/firmware.sh index 91288bd0..37da301d 100644 --- a/build/recipes/firmware.sh +++ b/build/recipes/firmware.sh @@ -47,7 +47,7 @@ s_firmware() { printf '%s\n' "$matches" >> "$out" printf ' %5d %s\n' "$n" "$line" fi - total=$((total + n)) + if [[ "$out" == "$selected" ]]; then total=$((total + n)); fi done < "$list" if [[ "$missing" -gt 0 ]]; then echo "FAIL: ${missing} pattern(s) in firmware.list match nothing; the list must name what this release has" From 51cb1eacef2ffd80a4d19407009380e781e4abd3 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 05:44:50 -0700 Subject: [PATCH 205/213] Policy and multipath routing, which nothing here uses, are not built either --- build/config/kernel/hardening.fragment | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/build/config/kernel/hardening.fragment b/build/config/kernel/hardening.fragment index 072afe90..effb2db0 100644 --- a/build/config/kernel/hardening.fragment +++ b/build/config/kernel/hardening.fragment @@ -254,12 +254,15 @@ CONFIG_LSM="landlock,lockdown,yama" # CONFIG_NF_CONNTRACK_IRC is not set # CONFIG_NF_CONNTRACK_SIP is not set # CONFIG_NETCONSOLE is not set -# Kernel IP autoconfiguration (ip=, for an NFS root), multicast routing, the -# packet labels of SELinux and Smack (neither is built), NFLOG (left on by -# xtables' NFLOG target; the net zone's rules log nothing), tc's classifier, -# ematch and action frameworks (no filter is ever added), and the network -# filesystems' DNS upcall. The net zone's capabilities reach most of them. +# Kernel IP autoconfiguration (ip=, for an NFS root), policy and multipath +# routing (nothing adds a rule, and kryptikd's routes are a gateway and a +# device), multicast routing, the packet labels of SELinux and Smack (neither +# is built), NFLOG (left on by xtables' NFLOG target; the net zone's rules log +# nothing), tc's classifier, ematch and action frameworks (no filter is ever +# added), and the network filesystems' DNS upcall. The net zone's +# capabilities reach most of them. # CONFIG_IP_PNP is not set +# CONFIG_IP_ADVANCED_ROUTER is not set # CONFIG_IP_MROUTE is not set # CONFIG_NETLABEL is not set # CONFIG_NETWORK_SECMARK is not set From 81ca31f82bdc0a1a8dff33c071b138dae099884e Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 05:49:09 -0700 Subject: [PATCH 206/213] Airplane-mode keys that come through the platform's ACPI device reach rfkill intel-hid, wireless-hotkey and asus-wireless, modules loaded by their ACPI ids; rfkill-input (RFKILL_INPUT, already on) switches the radios, and the net zone follows rfkill. --- build/config/kernel/boot.fragment | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/build/config/kernel/boot.fragment b/build/config/kernel/boot.fragment index 32111c45..fece1956 100644 --- a/build/config/kernel/boot.fragment +++ b/build/config/kernel/boot.fragment @@ -243,6 +243,12 @@ CONFIG_BRCMFMAC=m CONFIG_BRCMFMAC_PCIE=y CONFIG_RTL8XXXU=m CONFIG_MAC80211_HWSIM=m +# Airplane-mode keys that come through the platform's ACPI device rather than +# the keyboard: Intel's HID event filter, the AMD, HP and Xiaomi wireless +# button, and ASUS's radio control. rfkill-input acts on the key. +CONFIG_INTEL_HID_EVENT=m +CONFIG_WIRELESS_HOTKEY=m +CONFIG_ASUS_WIRELESS=m # --- built in by defconfig, needed only as modules -------------------------- # As a module, cfg80211 reads the regulatory database from the verified root From 3aeaf23d3d4e9c121b1e6d323ce3b37038f3bd70 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 05:56:08 -0700 Subject: [PATCH 207/213] hardening.md names what the kernel leaves out since the initramfs, zone-reachable and leftover cuts --- docs/hardening.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/hardening.md b/docs/hardening.md index 929586a7..69d44457 100644 --- a/docs/hardening.md +++ b/docs/hardening.md @@ -108,6 +108,12 @@ ADR-009) and `boot.fragment` (ADR-013). `/proc/kcore`, MSR and CPUID devices, legacy PTYs, `binfmt_misc`, kexec, hibernation, IA-32 emulation, debugfs, ftrace, kprobes, io_uring, sysrq, ACPI table overrides, core dumps, `/proc/pid/pagemap`. +- Off as well: initramfs support and the EFI stub's handover entry, so + nothing that starts the kernel can run code before the verified root; + `MSG_OOB` on unix sockets, TCP MD5 signatures and HugeTLB pages, which any + zone could reach; and what defconfig left on that nothing here uses: IP + autoconfiguration, policy, multipath and multicast routing, NetLabel and + SECMARK, NFLOG, tc's classifiers and actions, forced module unloading. Stage 05 refuses a `.config` that lost any fragment line (`build/lib/kconfig-check.sh`, shared with `tools/resolve-kernel-config.sh` From e4bc0778de6f24fb151b7feb06e341b9fb3f6c81 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 05:57:19 -0700 Subject: [PATCH 208/213] The hardening bullet says what a loader can no longer hand the kernel, and names all fifteen leftovers --- docs/hardening.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/hardening.md b/docs/hardening.md index 69d44457..eeb28791 100644 --- a/docs/hardening.md +++ b/docs/hardening.md @@ -109,11 +109,13 @@ ADR-009) and `boot.fragment` (ADR-013). hibernation, IA-32 emulation, debugfs, ftrace, kprobes, io_uring, sysrq, ACPI table overrides, core dumps, `/proc/pid/pagemap`. - Off as well: initramfs support and the EFI stub's handover entry, so - nothing that starts the kernel can run code before the verified root; - `MSG_OOB` on unix sockets, TCP MD5 signatures and HugeTLB pages, which any - zone could reach; and what defconfig left on that nothing here uses: IP - autoconfiguration, policy, multipath and multicast routing, NetLabel and - SECMARK, NFLOG, tc's classifiers and actions, forced module unloading. + nothing that starts the kernel can give it code to run before the verified + root; `MSG_OOB` on unix sockets, TCP MD5 signatures and HugeTLB pages, + which any zone could reach; and what defconfig left on that nothing here + uses: IP autoconfiguration, policy, multipath and multicast routing, + NetLabel and SECMARK, NFLOG, tc's classifiers and actions, the network + filesystems' DNS upcall, forced module unloading, the power-management and + cgroup debug hooks, `/dev/nvram` and `/dev/hpet`. Stage 05 refuses a `.config` that lost any fragment line (`build/lib/kconfig-check.sh`, shared with `tools/resolve-kernel-config.sh` From 148e02f9cecad1e62b0519995e97efdbc8c3428d Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 06:24:21 -0700 Subject: [PATCH 209/213] The acceptance verdict matches its parts by the full revision, not describe's short name Each part writes what it tested, and the merge refuses parts that tested something else. The line held `git describe`'s name, whose abbreviation git sizes by the clone's object count: the parts check out shallow and get 7 characters, while the verdict job's full clone, grown by the repository's branches, gets 8. Every part of run 38234029691 passed and the verdict still refused them as "not one run". The identity line is now the full revision, marked when the tree was dirty, which was the one other thing the describe carried; the usb and iso hashes are compared as before. The acceptance-inputs suite adds a part whose describe is shorter than the merge's (one run), a dirty merge over a clean part and a clean merge over a dirty part (both refused). --- tools/acceptance.sh | 4 +++- tools/tests/acceptance-inputs.sh | 11 +++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/tools/acceptance.sh b/tools/acceptance.sh index 65414333..d94f5434 100755 --- a/tools/acceptance.sh +++ b/tools/acceptance.sh @@ -154,7 +154,9 @@ REV_DESC="$(g describe --always --dirty --long 2>/dev/null || echo unknown)" DIRTY="$(g status --porcelain 2>/dev/null)" # A merge takes only parts that tested this revision and media, all on one firmware and QEMU. -tested() { printf 'revision %s (%s)\nusb %s\niso %s\n' "$REV" "$REV_DESC" "$H_USB" "$H_ISO"; } +# The revision by its full name and whether the tree was dirty: describe's short name is as long +# as the clone's object count calls for, and the verdict's full clone holds more than a part's. +tested() { printf 'revision %s%s\nusb %s\niso %s\n' "$REV" "${DIRTY:+ (dirty)}" "$H_USB" "$H_ISO"; } ran_on() { printf 'firmware-sha256 %s\nfirmware-package %s\nqemu %s\nkvm %s\n' "$H_FW" "$FW_PKG" "$QEMU_VER" "$KVM"; } parts_disagree() { # the first part that tested or ran on something else, and what local f d id first="" k diff --git a/tools/tests/acceptance-inputs.sh b/tools/tests/acceptance-inputs.sh index 60b9827e..1bc9cf80 100755 --- a/tools/tests/acceptance-inputs.sh +++ b/tools/tests/acceptance-inputs.sh @@ -169,6 +169,17 @@ R_RES[0]=SKIPPED; R_RES[1]=PASS part() { { tested; ran_on; } > "$T/parts/$1/identity"; } part a; part b [[ -z "$(parts_disagree)" ]] && ok "parts that tested one revision on one medium are one run" || bad "parts disagree: $(parts_disagree)" +# A shallow part's describe is shorter than the merge's full clone gives; the revision is the same. +# shellcheck disable=SC2034 # the describe a part's clone and the merge's would print +{ REV_DESC=abc1234; part a; REV_DESC=abc12345; } +[[ -z "$(parts_disagree)" ]] && ok "a part whose describe is shorter than the merge's, of the same revision, is one run with it" || bad "describe length: '$(parts_disagree)'" +# shellcheck disable=SC2034 # read by tested +{ DIRTY=" M tools/acceptance.sh"; } +[[ "$(parts_disagree)" == "the part in $T/parts/a tested revision abc;"* ]] && ok "a merge over a dirty tree does not take a clean tree's parts" || bad "dirty merge: '$(parts_disagree)'" +# shellcheck disable=SC2034 # read by tested +{ part a; DIRTY=""; } +[[ "$(parts_disagree)" == "the part in $T/parts/a tested revision abc (dirty);"* ]] && ok "nor a clean merge a dirty tree's part" || bad "dirty part: '$(parts_disagree)'" +part a sed -i 's/^usb .*/usb u2/' "$T/parts/b/identity" [[ "$(parts_disagree)" == "the part in $T/parts/b tested"*"usb u2"* ]] && ok "a part that tested another medium is refused, by name" || bad "another medium: '$(parts_disagree)'" part b From 3789bc6759ecd1530998dbba5e3d2fda906ed954 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 07:29:44 -0700 Subject: [PATCH 210/213] kryptik-recover --status is checked before the repairs it names, and the guide says what it shows in one place The integrity suite's recoveries print --status before the repair as well as after it, as a user reads it first. Step 2 checks that it names the foreign-signed boot file as neither slot's kernel, with --commit-slot a as the repair, and that the commit gives a store with no Kryptik entry one for slot a, in BootOrder. Step 4 checks that it says the zeroed ESP does not mount and that --restore-slot rewrites it. The guide's recover section says in one paragraph what --status shows and which repair each finding takes. --- build/service-scripts/installer-run.sh | 8 +++---- docs/user-guide.md | 28 +++++++++++++----------- tools/image/integrity-test.sh | 30 ++++++++++++++++++++------ 3 files changed, 43 insertions(+), 23 deletions(-) diff --git a/build/service-scripts/installer-run.sh b/build/service-scripts/installer-run.sh index 6c99492e..21e0cb00 100755 --- a/build/service-scripts/installer-run.sh +++ b/build/service-scripts/installer-run.sh @@ -40,6 +40,9 @@ if [ -n "$rdisk" ]; then echo echo "KRYPTIK_RECOVER: BEGIN disk=${rdisk} slot=${rslot} mode=${rmode}" logr=/run/kryptik-recover.log + # The disk as --status shows it, which a user reads before a repair and again after it. + status_as() { /usr/sbin/kryptik-recover --disk "$rdisk" --status 2>&1 | sed "s/^/KRYPTIK_RECOVER: $1: /"; } # status_as LABEL + case "$rmode" in restore|commit) status_as before ;; esac case "$rmode" in restore) /usr/sbin/kryptik-recover --disk "$rdisk" --restore-slot "$rslot" > "$logr" 2>&1 ;; commit) /usr/sbin/kryptik-recover --disk "$rdisk" --commit-slot "$rslot" > "$logr" 2>&1 ;; @@ -49,10 +52,7 @@ if [ -n "$rdisk" ]; then rrc=$? sed 's/^/KRYPTIK_RECOVER: /' "$logr" echo "KRYPTIK_RECOVER: rc=${rrc}" - # Then the disk as --status shows it, which a user reads next. - case "$rmode" in - restore|commit) /usr/sbin/kryptik-recover --disk "$rdisk" --status 2>&1 | sed 's/^/KRYPTIK_RECOVER: status: /' ;; - esac + case "$rmode" in restore|commit) status_as status ;; esac echo "KRYPTIK_RECOVER: END" fi diff --git a/docs/user-guide.md b/docs/user-guide.md index 520c8b0b..9911bb1a 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -354,16 +354,23 @@ kryptik-recover --disk /dev/sdY --commit-slot a # the other slot is intact: kryptik-recover --disk /dev/sdY --restore-slot a # the slot's root is damaged: rewrite it from this medium ``` -`--status` also names the firmware's Kryptik entries. A firmware that boots -a fixed disk only from such an entry, and has none, boots the disk again -after `--commit-slot`, which writes one for the slot it commits. +Start with `--status`. It shows which slot is committed, each slot's version +and whether its kernel and filesystem are there, which slot's kernel the +firmware boots by default, the firmware's Kryptik entries, whether the state +partition still has its LUKS2 header, and the medium's own version, the one +`--restore-slot` writes. Where something is wrong it names the repair: a +boot file that is missing or is not the committed slot's kernel is put back +by `--commit-slot`, a boot partition (the ESP) that no longer mounts is +rewritten by `--restore-slot`, and a state partition with no header takes a +backup with `--restore-state-header`. `--commit-slot` also writes the +firmware an entry for the slot it commits, which a firmware that boots a +fixed disk only from such an entry needs. `--backup-state-header FILE` and `--restore-state-header FILE` save and put back the state partition's LUKS2 header. FILE is on a disk mounted for it, as in section 2: the medium keeps nothing of its own. Keep the backup somewhere that is not the installed disk: a damaged header with no backup is a lost -state partition. `--status` says whether the state partition still has its -header. +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 @@ -372,13 +379,10 @@ 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 does not compare versions, so read `--status` first. It shows each -slot's version, which slot's kernel the firmware boots by default, and the -medium's own version, the one `--restore-slot` writes. If the disk's boot -partition (the ESP) no longer mounts, `--status` says so, and `--restore-slot` -first rewrites it from the medium, as the installer does. The other slot then -has no kernel there, so it cannot be committed or rolled back to; the next -update writes it again. +the tool does not compare versions, which is why `--status` shows both. An +ESP that no longer mounts is first rewritten from the medium, as the +installer does. The other slot then has no kernel there, so it cannot be +committed or rolled back to; the next update writes it again. A machine that stops responding resets itself after about a minute (the software watchdog's timeout; a hardware watchdog keeps its own): a service diff --git a/tools/image/integrity-test.sh b/tools/image/integrity-test.sh index ada75b94..14f80fb3 100755 --- a/tools/image/integrity-test.sh +++ b/tools/image/integrity-test.sh @@ -10,15 +10,17 @@ # module signing checked from inside # step 2 BOOTX64.EFI re-signed with a foreign key: the firmware refuses it # (control: the signed medium boots under the same store); then -# kryptik-recover --commit-slot a puts the signed kernel back, and -# --status shows slot a committed +# kryptik-recover --status names it as neither slot's kernel, and +# --commit-slot a puts the signed kernel back and gives the firmware +# an entry for slot a; --status then shows slot a committed # step 3 a byte of slot a's root flipped: dm-verity stops the boot # step 4 kryptik-recover --restore-slot a refuses a medium whose root is # not the one its signed kernel names (--status then shows nothing -# naming slot a), then, with the ESP's boot sector zeroed, rewrites -# the ESP from the medium and restores slot a, naming the version; -# its records on the ESP are whole, slot b is unlisted, --status -# shows them, and the user's data survives +# naming slot a), then, with the ESP's boot sector zeroed (--status +# says it does not mount), rewrites the ESP from the medium and +# restores slot a, naming the version; its records on the ESP are +# whole, slot b is unlisted, --status shows them, 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 @@ -37,7 +39,7 @@ while [[ "$#" -gt 0 ]]; do --usb) USB="${2:?}"; shift 2 ;; --disk) DISK="${2:?}"; shift 2 ;; --timeout) TIMEOUT="${2:?}"; shift 2 ;; - -h|--help) sed -n '2,26p' "${BASH_SOURCE[0]}"; exit 0 ;; + -h|--help) sed -n '2,28p' "${BASH_SOURCE[0]}"; exit 0 ;; *) die "unknown argument: $1" ;; esac done @@ -125,9 +127,20 @@ boot_txt | grep -q 'Linux version' && green "control: the developer-signed mediu CTLC="${VMDIR}/testctl-commit.img" "${SELF}/mk-testctl.sh" --out "$CTLC" --key "$TESTCTL_KEY" recover_disk=/dev/vda recover_slot=a recover_mode=commit smoke_poweroff=1 install_wait=5 > /dev/null smoke integ-p2r --usb "$USB" --disk "$DISK" --testctl "$CTLC" --vars enrolled --timeout "$TIMEOUT" > /dev/null +# What --status shows first, as a user reads it before choosing the repair. +B2="$(boot_txt | grep 'KRYPTIK_RECOVER: before:')" +if grep -qE "BOOTX64\.EFI +neither slot's kernel" <<<"$B2" && grep -qE "before: +not the committed slot's kernel: --commit-slot a makes it so" <<<"$B2"; then + green "--status first named the boot file as neither slot's kernel, and --commit-slot a as the repair" +else + red "--status before the commit: $(tr '\n' ' ' <<<"$B2")" +fi boot_txt | grep -q 'KRYPTIK_RECOVER: rc=0' && green "kryptik-recover --commit-slot a succeeded from the medium" || { red "--commit-slot did not report success"; boot_txt | grep 'KRYPTIK_RECOVER' | tail -5 | sed 's/^/ /'; } boot_txt | grep -qF 'KRYPTIK_RECOVER: status: committed slot a' && green "--status then shows slot a as the committed one" \ || red "--status after the commit: $(boot_txt | grep 'KRYPTIK_RECOVER: status:' | tr '\n' ' ')" +# The store is a fresh copy of the enrolled one, so the firmware has no Kryptik entry until the commit writes it. +grep -qE 'firmware +no Kryptik entry' <<<"$B2" && boot_txt | grep -qE 'KRYPTIK_RECOVER: status: firmware +Boot[0-9A-F]{4} for slot a, in BootOrder' \ + && green "--commit-slot a gave the firmware an entry for slot a, in BootOrder, where it had none" \ + || red "the firmware's Kryptik entries before and after the commit: $(boot_txt | grep -E 'KRYPTIK_RECOVER: (before|status): firmware' | tr '\n' ' ')" dd if="$DISK" of="$ESPIMG" bs=1M iflag=skip_bytes,count_bytes skip="$ESP_OFF" count=$((512*1024*1024)) status=none mcopy -n -i "$ESPIMG" ::/EFI/BOOT/BOOTX64.EFI "$TMPK/committed.efi" 2>/dev/null mcopy -n -i "${ESPIMG}.pristine" ::/EFI/BOOT/BOOTX64.EFI "$TMPK/installed.efi" 2>/dev/null @@ -200,6 +213,9 @@ rm -rf "$ALT" "$ALTUSB" # mounts, and the restore rewrites it from the medium before the slot. dd if=/dev/zero of="$DISK" bs=512 seek=$(( ESP_OFF / 512 )) count=1 conv=notrunc status=none smoke integ-p4 --usb "$USB" --disk "$DISK" --testctl "$CTLR" --vars enrolled --timeout "$TIMEOUT" > /dev/null +boot_txt | grep -qE 'KRYPTIK_RECOVER: before: ESP +/dev/vda1 does not mount: --restore-slot a or b rewrites it from this medium' \ + && green "--status first said the ESP does not mount, and that --restore-slot rewrites it" \ + || red "--status before the restore: $(boot_txt | grep 'KRYPTIK_RECOVER: before:' | tr '\n' ' ')" 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/^/ /'; } boot_txt | grep -qF "KRYPTIK_RECOVER: kryptik-recover: /dev/vda1 does not mount: rewriting it from this medium's ESP" \ && green "an ESP that did not mount was rewritten from the medium" || red "kryptik-recover did not say it rewrote the ESP that did not mount" From d2ff2f502fc5595de707dcd82a08002713a27088 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 07:33:48 -0700 Subject: [PATCH 211/213] After the restore, --status is checked to show slot b with no version, kernel or filesystem, and the guide claims only the boot-file repair the suite runs --- docs/user-guide.md | 4 ++-- tools/image/integrity-test.sh | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/user-guide.md b/docs/user-guide.md index 9911bb1a..852a7be9 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -359,8 +359,8 @@ and whether its kernel and filesystem are there, which slot's kernel the firmware boots by default, the firmware's Kryptik entries, whether the state partition still has its LUKS2 header, and the medium's own version, the one `--restore-slot` writes. Where something is wrong it names the repair: a -boot file that is missing or is not the committed slot's kernel is put back -by `--commit-slot`, a boot partition (the ESP) that no longer mounts is +boot file that is not the committed slot's kernel is put back by +`--commit-slot`, a boot partition (the ESP) that no longer mounts is rewritten by `--restore-slot`, and a state partition with no header takes a backup with `--restore-state-header`. `--commit-slot` also writes the firmware an entry for the slot it commits, which a firmware that boots a diff --git a/tools/image/integrity-test.sh b/tools/image/integrity-test.sh index 14f80fb3..03fa0a43 100755 --- a/tools/image/integrity-test.sh +++ b/tools/image/integrity-test.sh @@ -230,6 +230,10 @@ boot_txt | grep -qF "KRYPTIK_RECOVER: kryptik-recover: restoring slot a from thi boot_txt | grep -F 'KRYPTIK_RECOVER: status: slot a ' | grep -qF "version ${MVER}, kernel present, filesystem present" \ && green "--status then shows slot a at the medium's version, with its kernel and filesystem" \ || red "--status after the restore: $(boot_txt | grep 'KRYPTIK_RECOVER: status:' | tr '\n' ' ')" +# Slot b was never written: the rewritten ESP lists nothing for it, so nothing commits it. +boot_txt | grep -F 'KRYPTIK_RECOVER: status: slot b ' | grep -qF 'version none, kernel absent, filesystem NONE' \ + && green "--status then shows slot b with no version, kernel or filesystem" \ + || red "--status after the restore, slot b: $(boot_txt | grep 'KRYPTIK_RECOVER: status: slot b' | tr '\n' ' ')" LEFT="$(mdir -/ -b -i "$ESPIMG" ::/ 2>/dev/null | grep -i '\.new$' | tr '\n' ' ')" [[ -z "$LEFT" ]] && green "recovery left no .new file on the ESP" || red "recovery left ${LEFT}on the ESP" # The medium's kernel for slot b need not boot what slot b holds. From 1babc2ec3be7b56355549342fd78d5a433c3fbe4 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 07:40:05 -0700 Subject: [PATCH 212/213] A build from scratch and a rebuild on a cached tree install the same programs libmnl is built before iproute2, whose libmnl tools are left out (ip and tc keep it for the kernel's error messages), and Python is built without its dbm modules, which only a cached tree holding gdbm gave it. --- build/recipes/iproute2.sh | 10 ++++++++++ build/recipes/python.sh | 6 ++++-- build/stages/04-base-system.sh | 3 ++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/build/recipes/iproute2.sh b/build/recipes/iproute2.sh index 08c6136b..df91a5cd 100644 --- a/build/recipes/iproute2.sh +++ b/build/recipes/iproute2.sh @@ -6,6 +6,16 @@ s_iproute2() { # arpd needs Berkeley DB, which Kryptik does not ship. sed -i /ARPD/d Makefile rm -fv man/man8/arpd.8 + # Not the tools that come with libmnl (devlink, rdma, tipc and the rest), which nothing here + # runs; ip and tc keep libmnl, with which they print the kernel's own error messages. + sed -i '/^SUBDIRS += tipc devlink/d' Makefile + if grep -q '^SUBDIRS.*devlink' Makefile; then echo "FAIL: iproute2 would still build devlink"; return 1; fi make NETNS_RUN_DIR=/run/netns make SBINDIR=/usr/sbin install + # Their pages and completions install regardless, and a cached sysroot may hold the programs. + local t + for t in tipc devlink rdma dcb vdpa netshaper dpll; do + rm -f "/usr/sbin/$t" "/usr/share/man/man8/$t.8" /usr/share/man/man8/"$t"-*.8 \ + "/usr/share/bash-completion/completions/$t" + done } diff --git a/build/recipes/python.sh b/build/recipes/python.sh index 5b1e0b0a..d06713e0 100644 --- a/build/recipes/python.sh +++ b/build/recipes/python.sh @@ -14,13 +14,15 @@ s_python_final() { local src; src="$(unpack "Python-${V_PYTHON}.tar.xz" "Python-${V_PYTHON}")" cd "$src" # No test suite or test modules, which nothing runs, and no static libpython beside the shared one. - ./configure --prefix=/usr --enable-shared --with-system-expat --disable-test-modules --without-static-libpython + # No dbm modules: nothing needs them, and gdbm is built later, so only a cached tree would have one. + ./configure --prefix=/usr --enable-shared --with-system-expat --disable-test-modules --without-static-libpython \ + py_cv_module__gdbm=n/a py_cv_module__dbm=n/a make make install # The first build's test files outlive it here, and tkinter cannot import without _tkinter, which nothing builds. local lib="/usr/lib/python${V_PYTHON%.*}" rm -rf "$lib"/test "$lib"/idlelib "$lib"/tkinter "$lib"/turtledemo "$lib"/turtle.py "$lib"/__pycache__/turtle.* \ - "$lib"/lib-dynload/{_test,_ctypes_test,xx,_xxtestfuzz}*.so /usr/bin/idle3 "/usr/bin/idle${V_PYTHON%.*}" \ + "$lib"/lib-dynload/{_test,_ctypes_test,xx,_xxtestfuzz,_gdbm,_dbm}*.so /usr/bin/idle3 "/usr/bin/idle${V_PYTHON%.*}" \ "$lib"/config-*/libpython*.a # Bytecode for python -O and -OO, which nothing runs; such a run compiles in memory on a read-only root. find "$lib" -name '*.opt-[12].pyc' -delete diff --git a/build/stages/04-base-system.sh b/build/stages/04-base-system.sh index aa4815fd..04a57709 100755 --- a/build/stages/04-base-system.sh +++ b/build/stages/04-base-system.sh @@ -229,6 +229,8 @@ PACKAGES=( "procps-ng" "native_build procps-ng-${V_PROCPS}.tar.xz procps-ng-${V_PROCPS} --docdir=/usr/share/doc/procps-ng-${V_PROCPS} --disable-static --disable-kill" "e2fsprogs" "s_e2fsprogs" "elfutils" "s_elfutils" + # Before iproute2, which otherwise builds with it only when a cached tree already holds it. + "libmnl" "native_build libmnl-${V_LIBMNL}.tar.bz2 libmnl-${V_LIBMNL} --disable-static" "iproute2" "s_iproute2" "kbd" "s_kbd" "eudev" "s_eudev" @@ -247,7 +249,6 @@ PACKAGES=( # --- updates: the installed system verifies update manifests itself. "openssh" "s_openssh" # --- the net zone: NAT, a resolver and a DHCP client. - "libmnl" "native_build libmnl-${V_LIBMNL}.tar.bz2 libmnl-${V_LIBMNL} --disable-static" "libnftnl" "native_build libnftnl-${V_LIBNFTNL}.tar.xz libnftnl-${V_LIBNFTNL} --disable-static" "nftables" "native_build nftables-${V_NFTABLES}.tar.xz nftables-${V_NFTABLES} --without-cli --disable-man-doc --disable-python --with-json=no --disable-static" "dnsmasq" "s_dnsmasq" From 05204bc1e3b7888f8aabc1328463c5c7c5dcd5a8 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 07:40:29 -0700 Subject: [PATCH 213/213] The program list names what a build from scratch installs A rebuild on a cached tree had built Python's _gdbm (gdbm comes after Python) and iproute2's libmnl tools (libmnl came after iproute2), and the list was made from such a tree. A build from scratch, as a release is, has neither. --- build/config/image-programs.txt | 8 -------- 1 file changed, 8 deletions(-) diff --git a/build/config/image-programs.txt b/build/config/image-programs.txt index a67324d5..902893f1 100644 --- a/build/config/image-programs.txt +++ b/build/config/image-programs.txt @@ -986,7 +986,6 @@ usr/lib/python3.12/lib-dynload/_curses_panel.cpython-312-x86_64-linux-gnu.so usr/lib/python3.12/lib-dynload/_datetime.cpython-312-x86_64-linux-gnu.so usr/lib/python3.12/lib-dynload/_decimal.cpython-312-x86_64-linux-gnu.so usr/lib/python3.12/lib-dynload/_elementtree.cpython-312-x86_64-linux-gnu.so -usr/lib/python3.12/lib-dynload/_gdbm.cpython-312-x86_64-linux-gnu.so usr/lib/python3.12/lib-dynload/_hashlib.cpython-312-x86_64-linux-gnu.so usr/lib/python3.12/lib-dynload/_heapq.cpython-312-x86_64-linux-gnu.so usr/lib/python3.12/lib-dynload/_json.cpython-312-x86_64-linux-gnu.so @@ -1149,14 +1148,11 @@ usr/sbin/chpasswd usr/sbin/chroot usr/sbin/cryptsetup usr/sbin/ctrlaltdel -usr/sbin/dcb usr/sbin/debugfs usr/sbin/delpart -usr/sbin/devlink usr/sbin/dhcpcd usr/sbin/dmsetup usr/sbin/dnsmasq -usr/sbin/dpll usr/sbin/dumpe2fs usr/sbin/e2freefrag usr/sbin/e2fsck @@ -1217,7 +1213,6 @@ usr/sbin/mkfs.ext4 usr/sbin/mkfs.minix usr/sbin/mklost+found usr/sbin/mkswap -usr/sbin/netshaper usr/sbin/newusers usr/sbin/nft usr/sbin/nologin @@ -1228,7 +1223,6 @@ usr/sbin/poweroff usr/sbin/pwck usr/sbin/pwconv usr/sbin/pwunconv -usr/sbin/rdma usr/sbin/readprofile usr/sbin/reboot usr/sbin/resize2fs @@ -1251,7 +1245,6 @@ usr/sbin/switch_root usr/sbin/sysctl usr/sbin/tc usr/sbin/telinit -usr/sbin/tipc usr/sbin/tune2fs usr/sbin/udevadm usr/sbin/udevd @@ -1259,7 +1252,6 @@ usr/sbin/useradd usr/sbin/userdel usr/sbin/usermod usr/sbin/uuidd -usr/sbin/vdpa usr/sbin/veritysetup usr/sbin/vipw usr/sbin/wipefs