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 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/Makefile b/Makefile index d76fac72..d20b66b4 100644 --- a/Makefile +++ b/Makefile @@ -25,6 +25,13 @@ 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). +# 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 +endif # kryptikd and kryptik-wlproxy are static Rust binaries built outside the # chroot and installed by stage 04. KRYPTIK_KRYPTIKD_BIN ?= @@ -71,6 +78,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)" \ @@ -414,12 +422,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/README.md b/README.md index 54bd948b..924591d2 100644 --- a/README.md +++ b/README.md @@ -105,24 +105,29 @@ 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. - **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 + WCN6855, QCA2066, QCA6698AQ, WCN7850; MediaTek MT7920, MT7921, MT7922, + 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 - 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. -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/build/config/artifact-accepted.txt b/build/config/artifact-accepted.txt index 60a14f35..5dabdd03 100644 --- a/build/config/artifact-accepted.txt +++ b/build/config/artifact-accepted.txt @@ -20,3 +20,93 @@ 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 + +# 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/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 +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/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/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 +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/config/firmware-accepted.txt b/build/config/firmware-accepted.txt new file mode 100644 index 00000000..8e4f1e08 --- /dev/null +++ b/build/config/firmware-accepted.txt @@ -0,0 +1,39 @@ +# 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 + +# 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 9d718aad..0b977b01 100644 --- a/build/config/firmware.list +++ b/build/config/firmware.list @@ -1,21 +1,42 @@ # 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: 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. -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. 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 +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 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 @@ -24,18 +45,175 @@ 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 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). +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/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* +# 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 +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 +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). -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 +# 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/config/hardening-exceptions.txt b/build/config/hardening-exceptions.txt index ab4c0327..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 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 its loader, startup and checking code 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/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 new file mode 100644 index 00000000..f20894a9 --- /dev/null +++ b/build/config/image-programs.txt @@ -0,0 +1,1281 @@ +# 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/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 +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/_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-banner.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 +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/debugfs +usr/sbin/delpart +usr/sbin/dhcpcd +usr/sbin/dmsetup +usr/sbin/dnsmasq +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/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/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/tune2fs +usr/sbin/udevadm +usr/sbin/udevd +usr/sbin/useradd +usr/sbin/userdel +usr/sbin/usermod +usr/sbin/uuidd +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/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/config/kernel/boot.fragment b/build/config/kernel/boot.fragment index 32111c45..a9784825 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 @@ -134,6 +139,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 @@ -241,8 +251,17 @@ CONFIG_RTW89_8852CE=m CONFIG_RTW89_8922AE=m CONFIG_BRCMFMAC=m CONFIG_BRCMFMAC_PCIE=y +# 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 +# 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 @@ -282,6 +301,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/config/kernel/hardening.fragment b/build/config/kernel/hardening.fragment index c82fcdb8..2a4e7d47 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 @@ -254,3 +260,28 @@ 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), 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 +# 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 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/config/versions.env b/build/config/versions.env index 4729f88c..9eee1c1e 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. @@ -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/build/guest-tests/gui-check.sh b/build/guest-tests/gui-check.sh index c99af30c..effa7e07 100755 --- a/build/guest-tests/gui-check.sh +++ b/build/guest-tests/gui-check.sh @@ -18,6 +18,10 @@ # 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 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 @@ -333,6 +337,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 @@ -384,6 +397,50 @@ 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 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 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; } @@ -658,6 +715,62 @@ 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 +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. 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 +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 +# 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 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)" +fi + # --- 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/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 f61e41a5..ea9358b2 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,33 @@ 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 # The vault is probed under storage, once its volume exists. @@ -266,12 +298,64 @@ fi 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: + 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 # 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 # 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" @@ -288,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 @@ -713,17 +797,46 @@ 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 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)" + 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 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 +# 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 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 + 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=$! @@ -753,15 +866,24 @@ 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")" 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"')" @@ -777,6 +899,58 @@ 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)" +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 +# 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 @@ -784,6 +958,48 @@ 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 +# 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. 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)" + 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)" + rfkill_soft "$STA_RFKILL" 0 + back_line="$(ready_after "$before" " wifi=$AP_SSID " 90)" + before="$(ready_count)" + rfkill_soft "$STA_RFKILL" 1 + s6-svc -r /run/service/net-zone + start_off="$(ready_after "$before" " wifi=off " 90)" + before="$(ready_count)" + 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_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 # zone, stops it, waits for phy0 in zone 0 and starts it: the next zone must @@ -817,6 +1033,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. @@ -834,6 +1072,172 @@ 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' ' '; } +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 +# 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 +# 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 + # An address of its own again: under the second network'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:02 >> "$LOG/ap.err" 2>&1 + 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 + 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 +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} ($(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 +# 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 +# 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 @@ -1117,7 +1521,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' @@ -1156,6 +1560,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 +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 + 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/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/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/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/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/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" } diff --git a/build/recipes/firmware.sh b/build/recipes/firmware.sh index 0b4fc547..37da301d 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,15 +44,18 @@ 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)) + 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" 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 diff --git a/build/recipes/glibc.sh b/build/recipes/glibc.sh index 6ae9e83b..b9469636 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 its loader, startup and checking code. # --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/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 } 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/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; } } 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/recipes/perl.sh b/build/recipes/perl.sh index 312ed8d6..0e15a1f8 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. + # 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' cf_by='root'" + [[ -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/build/recipes/python.sh b/build/recipes/python.sh index 9d8e7756..d06713e0 100644 --- a/build/recipes/python.sh +++ b/build/recipes/python.sh @@ -13,9 +13,22 @@ 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, which nothing runs, and no static libpython beside the shared one. + # 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,_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 + [[ ! -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 python3 -c "import ${m}" || { echo "FAIL: python3 was built without ${m}"; return 1; } 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/build/recipes/wpa-supplicant.sh b/build/recipes/wpa-supplicant.sh index 37a6f30a..3d80059e 100644 --- a/build/recipes/wpa-supplicant.sh +++ b/build/recipes/wpa-supplicant.sh @@ -1,9 +1,14 @@ #!/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" + 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 @@ -15,7 +20,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/build/service-scripts/boot-success.sh b/build/service-scripts/boot-success.sh index ba756516..9b0a2040 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 @@ -108,6 +106,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 @@ -155,7 +161,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. @@ -172,10 +182,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 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" + fi result "trial-failed $trial" forget_entries "$slot" mv -f "$B/trial" "$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/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/build/service-scripts/installer-run.sh b/build/service-scripts/installer-run.sh index 7c27481a..21e0cb00 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. @@ -38,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 ;; @@ -47,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/build/service-scripts/medium-banner.sh b/build/service-scripts/medium-banner.sh new file mode 100755 index 00000000..851f653a --- /dev/null +++ b/build/service-scripts/medium-banner.sh @@ -0,0 +1,19 @@ +#!/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, 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 </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 94dbdbc3..591bd434 100755 --- a/build/service-scripts/sysinit.sh +++ b/build/service-scripts/sysinit.sh @@ -228,4 +228,7 @@ 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 +microcode_notice || true echo "sysinit: complete" diff --git a/build/services/getty-tty1/run b/build/services/getty-tty1/run index da8c5a77..dc4d264f 100755 --- a/build/services/getty-tty1/run +++ b/build/services/getty-tty1/run @@ -1,6 +1,7 @@ #!/bin/sh # Install media get a root shell here as on serial: whoever installs may be at either. if grep -qE '(^| )kryptik\.media=[a-z]+( |$)' /proc/cmdline 2>/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/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/04-base-system.sh b/build/stages/04-base-system.sh index aa4815fd..38ac6a18 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 @@ -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,13 +249,12 @@ 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" "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. @@ -297,12 +298,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. diff --git a/build/stages/05-kernel.sh b/build/stages/05-kernel.sh index afd9df66..4484be1c 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 @@ -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:-}" ]] || { 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 ---" @@ -259,8 +262,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 +303,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 diff --git a/build/stages/06-iso.sh b/build/stages/06-iso.sh index 13463889..89cb0e5b 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,46 @@ stage_depends_on "bs-" licences # --- steps ------------------------------------------------------------------ +# 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 a=0 + while IFS= read -r -d '' f; do + mode="$(stat -c %a "$f")"; owner="$(stat -c %u:%g "$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" + 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; 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, and the debug sections of ${a} archives" +} + s_rootfs() { local version="$1" channel="${3:-}" role="$4" local work; work="$(mktemp -d "${IMG}/stage.XXXXXX")" @@ -143,6 +183,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" @@ -187,7 +231,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. @@ -272,6 +328,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 @@ -493,18 +559,23 @@ 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" + 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" - 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 \ @@ -627,7 +698,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/build/stages/06-kernel-bind.sh b/build/stages/06-kernel-bind.sh index a75bd207..d8d79259 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:-}" ]] || { 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" 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/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..88a28558 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 @@ -65,8 +70,11 @@ 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 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 kryptikd wifi forget SSID remove one [--wifi-dir DIR] (the session does this through `kryptik wifi` and the launch daemon; this is root's @@ -194,6 +202,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`"); @@ -1064,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 | 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| { @@ -1081,24 +1099,34 @@ 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) { 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/netzone.rs b/compartments/kryptikd/src/netzone.rs index 3d53e3c3..055e4c46 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) { @@ -281,12 +284,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 +314,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) { @@ -356,6 +367,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 +453,15 @@ 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. 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)?; + 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), so it is left down", zone.name); + } } netlink::with_netns(zone_ns, || { up_lo()?; @@ -450,7 +478,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..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:?}"); } @@ -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/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/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/serve.rs b/compartments/kryptikd/src/serve.rs index fc94f072..f0740685 100644 --- a/compartments/kryptikd/src/serve.rs +++ b/compartments/kryptikd/src/serve.rs @@ -5,11 +5,12 @@ //! 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` +//! wifi-scan, answered once the nic zone has scanned //! 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 @@ -276,15 +277,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 +300,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 +315,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 +329,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 ---------------------------------------------------------- @@ -688,6 +696,7 @@ struct Job { enum JobKind { Stop { uid: u32, zone: String }, UpdateApply, + WifiScan, } fn memfile(name: &str) -> Result { @@ -760,6 +769,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")); + } } } @@ -962,6 +977,60 @@ 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 { + let Ok(crate::registry::State::Running { init: Some(init), .. }) = crate::registry::state(name) else { + return None; + }; + let mut text = String::new(); + init.file_in_root("run/netzone-status")?.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()), + "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:?}")), + _ => 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 +1075,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")), ), ); } @@ -1051,11 +1123,24 @@ 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 * again before writing a slot. */ - "update-status" | "update-fetch" | "update-apply" | "update-auto" => { + "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(); @@ -1063,7 +1148,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, up::running_slot().as_deref())); + Ok(out) } "update-fetch" => { let conf = std::fs::read_to_string(up::CONF).unwrap_or_default(); @@ -1071,6 +1158,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, @@ -1146,20 +1243,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..0f3dfaf6 100644 --- a/compartments/kryptikd/src/serve/tests.rs +++ b/compartments/kryptikd/src/serve/tests.rs @@ -1,5 +1,39 @@ 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 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 + // 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(); @@ -46,8 +80,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/spawn.rs b/compartments/kryptikd/src/spawn.rs index 75495edf..534ee92f 100644 --- a/compartments/kryptikd/src/spawn.rs +++ b/compartments/kryptikd/src/spawn.rs @@ -797,6 +797,13 @@ pub fn run_in_zone( } else { opts.wifi_dir.clone() }; + // 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), + } Some(crate::wifi::conf_path(&dir).display().to_string()) } else { None diff --git a/compartments/kryptikd/src/supplicant.rs b/compartments/kryptikd/src/supplicant.rs new file mode 100644 index 00000000..3259ddf4 --- /dev/null +++ b/compartments/kryptikd/src/supplicant.rs @@ -0,0 +1,114 @@ +//! 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 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 [ + ("/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/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/compartments/kryptikd/src/update.rs b/compartments/kryptikd/src/update.rs index 9a94d230..005b431a 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() } @@ -449,6 +462,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) } @@ -498,14 +517,30 @@ 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. + 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 _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 !clear_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 +595,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); } @@ -582,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); @@ -620,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), }; @@ -657,7 +704,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) { @@ -741,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, @@ -759,6 +815,30 @@ pub fn complete_stage(dir: &Path) -> Result { } } +/// 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"))?; + // 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)); @@ -771,11 +851,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 9a506a4e..6aeacd74 100644 --- a/compartments/kryptikd/src/update/tests.rs +++ b/compartments/kryptikd/src/update/tests.rs @@ -376,6 +376,101 @@ 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 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 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", 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", 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", 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", Some("a")), None); + std::fs::write(&failed, "b\nwhy=did not \u{1b}[2Jcome up\n").unwrap(); + 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", 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); +} + #[test] fn auto_refuses_what_fetch_refuses() { let d = scratch("auto-refused"); @@ -467,6 +562,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(); 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/compartments/kryptikd/src/wifi.rs b/compartments/kryptikd/src/wifi.rs index 9cd20bdb..8df76bb5 100644 --- a/compartments/kryptikd/src/wifi.rs +++ b/compartments/kryptikd/src/wifi.rs @@ -1,5 +1,7 @@ //! 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. 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}; @@ -16,23 +18,91 @@ 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 + * 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. + * 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, + } +} 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)] 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 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(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(":")) +} + +/// 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)] @@ -86,65 +156,107 @@ 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", 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 => {} + } + 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(); - let mut block: Option<(Option, Option)> = 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)); - 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; } - Some((ssid, psk)) => { - if line == "}" { - match (ssid.take(), psk.take()) { - (Some(s), Some(p)) => nets.push(Network { ssid: s, psk: p }), - _ => return Err(format!("line {n}: a network block without both an ssid and a psk")), - } - 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 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" - )); - } + 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")); + } + } 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() { @@ -153,8 +265,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 +274,36 @@ 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, owner and mode a file an older kryptikd wrote, so the net zone's +/// 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 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); + if current == text && owned && md.mode() & 0o7777 == MODE { + return Ok(false); + } + write_atomic(dir, ¤t, owner)?; + Ok(true) } /// The SSIDs, in file order. Never a passphrase. @@ -176,7 +311,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); @@ -187,13 +322,20 @@ 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. -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) => { @@ -201,14 +343,26 @@ pub fn add(dir: &Path, owner: Option<(u32, u32)>, ssid: &str, passphrase: &str) 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) } +/// 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)?; @@ -218,6 +372,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) } @@ -243,11 +398,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. @@ -274,6 +433,178 @@ 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 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"]; + +/// 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); + 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(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; + 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 5de0c89a..3a0e0520 100644 --- a/compartments/kryptikd/src/wifi/tests.rs +++ b/compartments/kryptikd/src/wifi/tests.rs @@ -11,18 +11,20 @@ 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(); + // 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, - "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" + without_addresses, + "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" ); 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"]); @@ -35,14 +37,97 @@ 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. 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"); + 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\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); + 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); +} + +#[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())); 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")); @@ -89,12 +174,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,12 +189,12 @@ 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()) }); + 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); @@ -118,11 +203,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,14 +233,154 @@ 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); } +#[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()), 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}"); + 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}"); + // 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. + 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")); + 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")); 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 6a869886..5f67e5d7 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" @@ -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 @@ -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" @@ -336,8 +336,12 @@ 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* ]] ;; + --wifi-scan) r="\$(printf 'wifi-scan\n' | client)" + printf '%s\n' "\$r"; [[ "\$r" == ok* ]] ;; *) exit 2 ;; esac SHIM @@ -365,6 +369,20 @@ 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 + # 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/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 diff --git a/compartments/tests/serve.sh b/compartments/tests/serve.sh index 2b9a193c..5a66b1c0 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 @@ -430,13 +430,35 @@ 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" 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" | 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" +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 +# 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/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/decisions.md b/docs/decisions.md index 188b9acd..7b40635b 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,10 @@ 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, one veth into the net zone, or, for the net zone, +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. @@ -164,36 +170,46 @@ 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 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, Realtek Ethernet runs without its +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 [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 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 -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. +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, nor for the +current patches of five EPYC models: such a machine keeps its firmware's +microcode until the firmware is updated. -**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 -here can read. +**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. ## ADR-013: A driver is built in only when boot needs it @@ -212,7 +228,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 diff --git a/docs/design/boot-and-updates.md b/docs/design/boot-and-updates.md index dc4ea3dc..7b25c829 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. @@ -88,14 +90,16 @@ 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, +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. +`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 @@ -106,7 +110,13 @@ 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. +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 back. Firmware re-adds its own disk entry at the end of `BootOrder`, so an @@ -119,10 +129,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 @@ -160,7 +175,14 @@ 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. 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 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 + 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, @@ -187,7 +209,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/design/delta-updates.md b/docs/design/delta-updates.md index d0f9fa6b..ea049c7a 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,8 +153,13 @@ 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. + 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/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/docs/design/net-zone-surface.md b/docs/design/net-zone-surface.md index 1732945a..87a07cf5 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, OWE - and DPP, which the recipe builds (`wpa-supplicant.sh:16-18`), 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. -- **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, 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 @@ -129,11 +145,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 @@ -145,22 +166,31 @@ 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 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` | -| 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 ede36604..52f1cce3 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, @@ -112,8 +113,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 @@ -134,17 +136,29 @@ 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, 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 + 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 @@ -265,24 +279,110 @@ 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. - **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. 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 + 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, 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 + 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 + 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. 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. +- **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. +- **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. 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. @@ -292,7 +392,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. @@ -362,7 +463,15 @@ 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 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/design/reproducible-builds.md b/docs/design/reproducible-builds.md index c68bd29b..184d06b0 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). @@ -23,52 +23,41 @@ 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 +## 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 +68,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 @@ -99,17 +90,26 @@ 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 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. +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 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 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 @@ -214,11 +214,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 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/design/time.md b/docs/design/time.md index 32408413..6ca8fb71 100644 --- a/docs/design/time.md +++ b/docs/design/time.md @@ -80,9 +80,11 @@ 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 - line to `/var/lib/kryptik/time/history`; `kryptikd time status` prints the - clock, the floor and the last 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. A net zone that never answers leaves the clock to the RTC; that is reported (`time=no-answer`), not repaired. diff --git a/docs/design/update-channel.md b/docs/design/update-channel.md index 71fab7bd..ad208bcf 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. @@ -211,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 @@ -259,7 +264,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 @@ -306,16 +311,16 @@ 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. ## 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. @@ -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/design/wifi-refusals.md b/docs/design/wifi-refusals.md new file mode 100644 index 00000000..8af298a7 --- /dev/null +++ b/docs/design/wifi-refusals.md @@ -0,0 +1,91 @@ +# 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`, 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.** 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` (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 +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 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. diff --git a/docs/hardening.md b/docs/hardening.md index 929586a7..f2cc62d9 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 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`. @@ -34,28 +35,38 @@ 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 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 `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, 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 +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 @@ -69,9 +80,11 @@ 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 -library (a zone never sees the host's). The build chroot never has the file. +`/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 library mapped. No benchmark numbers are claimed. @@ -108,6 +121,14 @@ 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 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` @@ -146,20 +167,22 @@ 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 -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/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 | 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 diff --git a/docs/status.md b/docs/status.md index 067eb25e..a59a70ba 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` | @@ -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/docs/threat-model.md b/docs/threat-model.md index 1cd62562..647b6d3b 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)). @@ -51,7 +52,11 @@ 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`), 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. @@ -80,12 +85,18 @@ 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 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: @@ -115,20 +126,27 @@ 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: - 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` 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 - 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. @@ -139,7 +157,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 @@ -150,6 +170,15 @@ 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 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 [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..3ec445f5 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, `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 +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 @@ -81,6 +86,14 @@ 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 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)), enrolled once for every release after it; a development build's is made by @@ -88,8 +101,34 @@ that build and is its own. A firmware that carries only Microsoft's keys refuses either (the acceptance run proves the refusal: `media-refused-foreign-keys`). To boot with Secure Boot on, enrol `kryptik-sb.der` in the firmware's `db` (and, on most machines, PK/KEK) from -the firmware setup menu; or turn Secure Boot off. The medium reports which it -got: `KRYPTIK_SMOKE: secureboot=1` or `=0` on the console. +the firmware setup menu; or turn Secure Boot off. The medium says which it +got above its shell (`Secure Boot is on` or `off`), and as +`KRYPTIK_SMOKE: secureboot=1` or `=0` on a serial 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 and Ryzen AI Max, EPYC 9005) and a few +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. + +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 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): @@ -121,7 +160,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. @@ -144,7 +185,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 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 @@ -161,14 +204,22 @@ 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 +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 -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 @@ -177,8 +228,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 @@ -199,7 +252,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 @@ -229,7 +283,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 | @@ -245,15 +299,24 @@ 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). +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 (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, +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 -explain ZONE` and `kryptik doctor` describe the zones, and `kryptik stop ZONE` -stops one. `kryptik shell ZONE` and `kryptik run ZONE -- COMMAND` start the +`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 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. @@ -265,6 +328,26 @@ 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 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 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 +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. 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 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. + **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 @@ -273,7 +356,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 @@ -290,13 +374,16 @@ 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` ``` `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 @@ -309,8 +396,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 @@ -329,14 +416,18 @@ 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. Persistent zone data on `kryptik-state` is never written by any of +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 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 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 kryptik-update rollback # back to the other slot, if it is intact @@ -354,6 +445,18 @@ 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 ``` +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 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 @@ -367,8 +470,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 names the version it writes but does not compare it, so read -`--status` first. +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 @@ -392,6 +497,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 @@ -404,8 +512,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 @@ -419,8 +527,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: diff --git a/sources.lock b/sources.lock index 9042b72c..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 @@ -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 diff --git a/tools/acceptance.sh b/tools/acceptance.sh index 65414333..43926560 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 @@ -260,7 +262,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)"; } @@ -292,6 +295,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" ]] \ @@ -353,13 +360,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" @@ -367,9 +385,15 @@ 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"; } +# 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_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_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))" @@ -394,6 +418,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/^/ /' @@ -437,9 +463,13 @@ 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 image-manifest M host 0 it_manifest 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 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 @@ -453,6 +483,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 +491,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 +515,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} |" @@ -544,9 +577,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/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/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/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/check-firmware-requested.sh b/tools/check-firmware-requested.sh new file mode 100755 index 00000000..28d23c74 --- /dev/null +++ b/tools/check-firmware-requested.sh @@ -0,0 +1,136 @@ +#!/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] + +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("/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. @@ -155,12 +174,51 @@ 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 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} (kryptik update status)" + [ -z "$quiet" ] || echo "Update: ${quiet} (u for more)" + 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. 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 + 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 + printf '(Enter) '; read -r _ || return 0; continue ;; + a) "$LAUNCH" --update auto on ;; + m) "$LAUNCH" --update auto off ;; + *) echo "?" ;; + esac + sleep 3 + done } run_menu() { @@ -172,17 +230,21 @@ 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)" + 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 " 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 ;; + u) run_update; continue ;; 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 +278,100 @@ run_menu() { done } +# --- 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, 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#\"}" + 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) ================" + echo "Saved networks:" + nets="$("$LAUNCH" --wifi-list 2>&1)" + printf '%s\n' "${nets:-none}" | sed 's/^/ /' + echo + 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. Its answer is kept, so + # a or o can take a network by its number. + s) echo "asking the net zone what it hears..." + 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." + 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 + ssid="$(heard_pick "$c" "$heard" "$ssid")" || { echo "$ssid"; sleep 3; continue; } + echo "network: $ssid" + fi ;; + esac + 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 +} + # --- the prompt ----------------------------------------------------------------- run_prompt() { # ZONE [--no-display] -- CMD... diff --git a/tools/desktop/kryptik-launch.c b/tools/desktop/kryptik-launch.c index e72d0b92..9c69d0d7 100644 --- a/tools/desktop/kryptik-launch.c +++ b/tools/desktop/kryptik-launch.c @@ -7,9 +7,11 @@ * 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 - * 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 @@ -296,9 +298,9 @@ 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-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); + " kryptik-launch --update status|fetch|forget|apply | --update auto on|off\n", stderr); exit(2); } @@ -361,20 +363,32 @@ static int wifi_main(int argc, char **argv) } return 0; } - if (argc != 3 || (strcmp(mode, "--wifi-add") != 0 && strcmp(mode, "--wifi-forget") != 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(); 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); @@ -390,7 +404,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/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 diff --git a/tools/image/gui-test.sh b/tools/image/gui-test.sh index 3a882084..ba4256af 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}" \ @@ -95,11 +95,17 @@ 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 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" 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)" @@ -108,9 +114,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 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 \ + 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/image/install-test.sh b/tools/image/install-test.sh index 82714cc3..f1881703 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; @@ -69,6 +70,16 @@ 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" +# 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" @@ -93,7 +104,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}" \ @@ -101,6 +112,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" \ @@ -113,7 +125,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)" @@ -132,6 +144,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" @@ -143,12 +156,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" @@ -197,6 +210,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 @@ -308,12 +323,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..d22877c4 100755 --- a/tools/image/integrity-test.sh +++ b/tools/image/integrity-test.sh @@ -10,14 +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 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 (--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 @@ -36,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,25p' "${BASH_SOURCE[0]}"; exit 0 ;; + -h|--help) sed -n '2,28p' "${BASH_SOURCE[0]}"; exit 0 ;; *) die "unknown argument: $1" ;; esac done @@ -59,7 +62,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 +70,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" @@ -124,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 @@ -195,8 +209,16 @@ 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 -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" # 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}" @@ -208,15 +230,27 @@ 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. +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 \ +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 +311,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 +331,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" @@ -323,18 +357,22 @@ 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 \ +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" \ diff --git a/tools/image/medium-shell-test.sh b/tools/image/medium-shell-test.sh index 5ae98ff5..3b3e553c 100755 --- a/tools/image/medium-shell-test.sh +++ b/tools/image/medium-shell-test.sh @@ -49,11 +49,13 @@ 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" "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" @@ -65,7 +67,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" @@ -73,6 +75,8 @@ 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: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" ) @@ -83,7 +87,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" \ @@ -99,6 +103,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 -qE 'install medium: a root shell\. Secure Boot is (on|off)' && txt | grep -q 'kryptik-install --target /dev/sdY --dry-run'; then + green "${kind}: the console says what the medium's shell is for, whether Secure Boot is on, 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 @@ -123,6 +132,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 @@ -130,7 +141,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" \ 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/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/image/update-test.sh b/tools/image/update-test.sh index 97aae1ac..2832d52d 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. @@ -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" @@ -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}" \ @@ -342,9 +343,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}" \ @@ -435,7 +437,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/image/zones-test.sh b/tools/image/zones-test.sh index 2acd8181..4aebbda3 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=$? @@ -66,13 +65,19 @@ 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 \ + wifi-second-network \ + wifi-bss-age \ uplink-renamed-plain uplink-state-reset \ + radio-off-at-start \ radio-recarried \ + 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 \ + 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 + 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 @@ -84,7 +89,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]+" \ @@ -119,7 +124,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" diff --git a/tools/install/kryptik-install.sh b/tools/install/kryptik-install.sh index f8f3f230..0935493b 100755 --- a/tools/install/kryptik-install.sh +++ b/tools/install/kryptik-install.sh @@ -69,9 +69,9 @@ 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 + loadkeys chattr kryptik-efiboot; do command -v "$tool" >/dev/null 2>&1 || missing="${missing} ${tool}" done [ -z "$missing" ] || die "this system is missing:${missing} @@ -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" @@ -219,6 +221,12 @@ 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) 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 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 @@ -241,8 +249,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 -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)" + 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}" @@ -281,6 +306,10 @@ 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. +# 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" @@ -335,14 +364,23 @@ 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" +# 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/EFI/kryptik/kryptik-b.efi" "$MNT_BASE/tesp/kryptik/version-b" 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" diff --git a/tools/kryptik b/tools/kryptik index aafaa51b..030d0989 100755 --- a/tools/kryptik +++ b/tools/kryptik @@ -35,18 +35,26 @@ 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 + 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 $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 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 +296,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 +ask_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,19 +308,30 @@ 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. + +read_passphrase() { ask_passphrase "passphrase for $1: "; } # 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 ;; - add|forget) [[ $# -eq 1 ]] || die "$PROG wifi $sub SSID: exactly one SSID" 2 ;; - *) usage >&2; die "wifi needs a subcommand: list, add SSID or forget SSID" 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, scan, 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" ;; + scan) "$LAUNCH" --wifi-scan ;; + 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 +339,13 @@ 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" ;; + 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 + 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 } @@ -332,21 +353,42 @@ 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 "$@" } -# 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" + 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 + ask_passphrase "a new one, asked at every boot: "; new="$PASSPHRASE" + if [[ -t 0 ]]; then + ask_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 diff --git a/tools/net/netzone-init.sh b/tools/net/netzone-init.sh index a25f867d..7853018b 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 @@ -12,7 +12,12 @@ 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 +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 @@ -52,10 +57,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 +71,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 +94,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 +105,10 @@ 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. +# 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; } @@ -119,6 +135,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 @@ -127,11 +146,12 @@ 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 + 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 +171,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 } @@ -193,38 +213,65 @@ 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"))" + # 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 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 station's address" + fi return 0 fi 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. +# 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 - 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" } -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 --------- @@ -271,7 +318,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 +326,20 @@ 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. + # 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; 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 +347,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/ \ + --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 + 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. @@ -383,18 +487,65 @@ 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, 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}" + for n in $WIRELESS; do + 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 + } > "$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" 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 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 @@ -403,18 +554,44 @@ 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 - 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 + # 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 + # 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}" @@ -422,6 +599,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 ;; @@ -444,6 +622,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 diff --git a/tools/pin-reviews.tsv b/tools/pin-reviews.tsv index 883615be..e416380b 100644 --- a/tools/pin-reviews.tsv +++ b/tools/pin-reviews.tsv @@ -8,14 +8,14 @@ # 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. +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 --------------- 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, 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/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/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/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" 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 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 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 diff --git a/tools/tests/boot-success.sh b/tools/tests/boot-success.sh index 7a74a008..ea3488ad 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)); } @@ -39,6 +40,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' @@ -163,11 +166,15 @@ for missing in net-zone kryptikd-serve seatd getty-tty1 eudev; do done run_case unkernel b "" persistent 'b\narmed=1\n' $ALL; rm -f "$KTEST/kernel_ok"; go 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. @@ -193,9 +200,19 @@ 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 "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 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 +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 " diff --git a/tools/tests/check-firmware-requested.sh b/tools/tests/check-firmware-requested.sh new file mode 100755 index 00000000..732c2172 --- /dev/null +++ b/tools/tests/check-firmware-requested.sh @@ -0,0 +1,68 @@ +#!/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\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/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" +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 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" +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" + +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 ]] diff --git a/tools/tests/check-image-programs.sh b/tools/tests/check-image-programs.sh new file mode 100755 index 00000000..ef14925b --- /dev/null +++ b/tools/tests/check-image-programs.sh @@ -0,0 +1,59 @@ +#!/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/["; 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"* ]] \ + && 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="$(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" + +printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" +[[ "$FAIL" -eq 0 ]] 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 ]] diff --git a/tools/tests/chrome-wifi-pick.sh b/tools/tests/chrome-wifi-pick.sh new file mode 100755 index 00000000..24437102 --- /dev/null +++ b/tools/tests/chrome-wifi-pick.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# 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" +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; /^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 + +# 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 +"old" WEP, which Kryptik does not join -80 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 + 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 +echo "${PASS} passed, ${FAIL} failed" +[[ "$FAIL" -eq 0 ]] 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 ]] 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/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" diff --git a/tools/tests/microcode-notice.sh b/tools/tests/microcode-notice.sh new file mode 100755 index 00000000..2f20d447 --- /dev/null +++ b/tools/tests/microcode-notice.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# 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)); } +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...: microcode_notice under SHELL, with that kernel log + local sh="$1"; shift + printf '%s\n' "$@" > "$T/kernel.log" + 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 + 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 -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 + 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, 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 returns 0" \ + || red "an unreadable kernel log" "exit ${rc}; notice: $(cat "$NOTICE" 2>&1)" +done + +# 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" +[[ "$FAIL" -eq 0 ]] 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 ]] diff --git a/tools/tests/netzone-scan.sh b/tools/tests/netzone-scan.sh new file mode 100755 index 00000000..3bed7d84 --- /dev/null +++ b/tools/tests/netzone-scan.sh @@ -0,0 +1,116 @@ +#!/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 + +{ 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" + +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 ]] diff --git a/tools/tests/netzone-uplink.sh b/tools/tests/netzone-uplink.sh index 24b67509..ff996865 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; } @@ -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, 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|' + 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 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" 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 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}'" 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-recover b/tools/update/kryptik-recover index 83843912..5603a815 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 @@ -42,7 +43,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 @@ -56,23 +57,70 @@ 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; } +# 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. -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" echo "disk $DISK" - echo "committed slot $(esp_slot /run/kryptik-recover/kryptik/committed-slot)" - 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)..." + 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 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)...)" + 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 + # 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 + # 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 + 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-\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 + 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 @@ -91,12 +139,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. @@ -134,8 +178,28 @@ 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; 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 + 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" @@ -150,13 +214,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 @@ -166,7 +226,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 \ + || 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 diff --git a/tools/update/kryptik-update b/tools/update/kryptik-update index 4859f531..4a2f8341 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 @@ -59,7 +60,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 +79,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" @@ -103,7 +101,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 @@ -131,7 +129,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 +138,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 +175,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" @@ -297,7 +301,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 @@ -350,7 +354,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" @@ -366,10 +371,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 @@ -385,15 +391,12 @@ 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)" # 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"