From 07fa72d20579b324daa753d6ef1039bbd03f3069 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 22:59:52 -0700 Subject: [PATCH 001/123] kryptikd stacks the signed software images a zone file names over that zone's /usr, checked against the root it runs A zone file's `[software] images = "NAME..."` names images; at each start kryptikd reads the list kept for the running root (its root hash, from the signed kernel's command line), has kryptik-update check-images verify it as nobody on a copy of those bytes, refuses an image the list lacks or one whose needs lines the zone's policy file does not grant, opens each image with dm-verity and the list's parameters, mounts it read-only, nosuid and nodev, and stacks the set over zone 0's /usr in a read-only overlay the zone binds as its /usr. A mapping dm-verity has marked corrupted is not used again. The release key's anchor line grants it kryptik-image; check --images FILE holds each zone's images to a list at build time. --- build/lib/release-keys.sh | 14 +- compartments/kryptikd/src/images.rs | 316 ++++++++++++++++++++++ compartments/kryptikd/src/images/tests.rs | 136 ++++++++++ compartments/kryptikd/src/main.rs | 37 ++- compartments/kryptikd/src/rootfs.rs | 9 +- compartments/kryptikd/src/spawn.rs | 29 +- compartments/kryptikd/src/update.rs | 10 +- compartments/kryptikd/src/volume.rs | 2 +- compartments/kryptikd/src/zone.rs | 27 ++ compartments/kryptikd/src/zone/tests.rs | 10 + docs/design/software-delivery.md | 28 +- docs/design/update-channel.md | 4 +- docs/release-keys.md | 3 +- tools/channel-host.sh | 2 +- tools/make-release-keys.sh | 2 +- tools/tests/image-list.sh | 99 +++++++ tools/tests/release-keys.sh | 10 +- tools/tests/release-publish.sh | 2 +- tools/tests/testctl.sh | 2 +- tools/throwaway-key-medium.sh | 2 +- tools/update/kryptik-update | 23 +- 21 files changed, 724 insertions(+), 43 deletions(-) create mode 100644 compartments/kryptikd/src/images.rs create mode 100644 compartments/kryptikd/src/images/tests.rs create mode 100755 tools/tests/image-list.sh diff --git a/build/lib/release-keys.sh b/build/lib/release-keys.sh index 6093babe..53d9a437 100644 --- a/build/lib/release-keys.sh +++ b/build/lib/release-keys.sh @@ -46,7 +46,7 @@ dev_keys() { # for re-signing the channel's statement, for that alone; and the # control-disk key's, which arms an unattended install and nothing else. { - printf 'kryptik-release namespaces="kryptik-release,kryptik-media" %s\n' "$(cut -d' ' -f1,2 "${rel}/kryptik-release.pub")" + printf 'kryptik-release namespaces="kryptik-release,kryptik-media,kryptik-image" %s\n' "$(cut -d' ' -f1,2 "${rel}/kryptik-release.pub")" printf 'kryptik-latest namespaces="kryptik-latest" %s\n' "$(cut -d' ' -f1,2 "${rel}/kryptik-latest.pub")" printf 'kryptik-testctl namespaces="kryptik-testctl" %s\n' "$(cut -d' ' -f1,2 "${rel}/kryptik-testctl.pub")" } > "${rel}/release-signers" @@ -72,7 +72,7 @@ EOF # A probe signed by each developer key and by a foreign one, in each namespace, # verified through the anchor as its readers verify: only the release key, in -# kryptik-release and kryptik-media, the statement key, in kryptik-latest, and +# kryptik-release, kryptik-media and kryptik-image, the statement key, in kryptik-latest, and # the control-disk key, in kryptik-testctl, may pass. The production keys sign # nothing but what they are for, so this runs for development alone. probe_anchor() { @@ -82,7 +82,7 @@ probe_anchor() { ssh-keygen -q -t ed25519 -N "" -f "$t/foreign" < /dev/null printf 'probe\n' > "$t/probe" for key in "$RELEASE_KEY" "$LATEST_KEY" "$tkey" "$t/foreign"; do - for ns in kryptik-release kryptik-media kryptik-latest kryptik-testctl; do + for ns in kryptik-release kryptik-media kryptik-image kryptik-latest kryptik-testctl; do case "$ns" in kryptik-latest) who=kryptik-latest ;; kryptik-testctl) who=kryptik-testctl ;; @@ -90,7 +90,7 @@ probe_anchor() { esac want=refused case "$key:$ns" in - "$RELEASE_KEY:kryptik-release"|"$RELEASE_KEY:kryptik-media"|"$LATEST_KEY:kryptik-latest"|"$tkey:kryptik-testctl") want=accepted ;; + "$RELEASE_KEY:kryptik-release"|"$RELEASE_KEY:kryptik-media"|"$RELEASE_KEY:kryptik-image"|"$LATEST_KEY:kryptik-latest"|"$tkey:kryptik-testctl") want=accepted ;; esac rm -f "$t/probe.sig" # A refusal counts only when there was a signature to refuse. @@ -178,8 +178,8 @@ private_ok() { [[ "$owner" == 0 || "$owner" == "$who" ]] || die "$1 belongs to uid ${owner}, not to root or the user running the build (${who})" } -# kryptik-release, held to kryptik-release (manifests) and kryptik-media (the -# media's checksums), kryptik-latest, held to kryptik-latest alone, and +# kryptik-release, held to kryptik-release (manifests), kryptik-media (the +# media's checksums) and kryptik-image (image lists), kryptik-latest, held to kryptik-latest alone, and # kryptik-testctl, if listed, held to kryptik-testctl alone (the control disk # that arms an unattended install), with Ed25519 keys (a security key's # included). A principal may be listed more @@ -191,7 +191,7 @@ anchor_ok() { while read -r p ns t k _; do case "$p" in ""|"#"*) continue ;; esac case "$p" in - kryptik-release) want='namespaces="kryptik-release,kryptik-media"' ;; + kryptik-release) want='namespaces="kryptik-release,kryptik-media,kryptik-image"' ;; kryptik-latest) want='namespaces="kryptik-latest"' ;; kryptik-testctl) want='namespaces="kryptik-testctl"' ;; *) die "${a}: ${p} is neither kryptik-release, kryptik-latest nor kryptik-testctl" ;; diff --git a/compartments/kryptikd/src/images.rs b/compartments/kryptikd/src/images.rs new file mode 100644 index 00000000..50b3968a --- /dev/null +++ b/compartments/kryptikd/src/images.rs @@ -0,0 +1,316 @@ +//! Software images over a zone's /usr (docs/design/software-delivery.md). A list the release key +//! signs in `kryptik-image` names each image's dm-verity root hash for one root. kryptikd opens +//! the images a zone file names, over that root alone, and the zone binds one read-only overlay +//! of them over zone 0's /usr. Every start checks the list again, as nobody. + +use std::fs; +use std::io::Read as _; +use std::os::unix::fs::MetadataExt; +use std::path::{Path, PathBuf}; + +use crate::policy::{self, Policy}; +use crate::zone::Zone; + +pub const MAGIC: &str = "KRYPTIK-IMAGES-1"; +/// Images and the lists that name them, on the state partition, root's alone. +pub const STORE: &str = "/var/lib/kryptik/images"; +/// Byte limits for the list and its signature, as for a release manifest. +const LIST_MAX: u64 = 64 * 1024; +const SIG_MAX: u64 = 8 * 1024; +/// Where each opened image is mounted, and where each set of them is stacked over /usr. +const MOUNTS: &str = "/run/kryptik/images"; +const SETS: &str = "/run/kryptik/usr"; +/// One start at a time opens images, so two never race for one mapping. +const LOCK: &str = "/run/kryptik/images.lock"; +/// The largest image taken: the state partition holds every zone's data beside it. +const IMAGE_MAX: u64 = 16 << 30; +const BLOCK: u64 = 4096; + +/// One image, as the signed list gives it. +#[derive(Debug, Clone, PartialEq)] +pub struct Image { + pub name: String, + pub version: String, + /// The whole file: the data, then its hash tree. + pub bytes: u64, + pub sha256: String, + pub data_blocks: u64, + pub root_hash: String, + pub salt: String, +} + +/// The signed list of images built over one root. +#[derive(Debug, Clone, PartialEq)] +pub struct Manifest { + pub release: String, + /// The root hash of the root the images were built over. + pub base: String, + pub images: Vec, + /// Each image's policy-file lines: what a zone's policy must grant before it gets the image. + pub needs: Vec<(String, String)>, +} + +/// An image's name, as a zone file and the store's paths use it. +pub fn valid_name(s: &str) -> bool { + (1..=32).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') +} + +fn hex64(s: &str) -> bool { + s.len() == 64 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) +} + +fn version_ok(s: &str) -> bool { + (1..=64).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_graphic()) +} + +/// Digits alone, with no leading zero. +fn number(s: &str) -> Option { + if s.is_empty() || (s.len() > 1 && s.starts_with('0')) || !s.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + s.parse().ok() +} + +/// The list, line by line as stage 06 writes it; anything else is refused, not skipped. +pub fn parse(text: &str) -> Result { + let body = text.strip_suffix('\n').ok_or("the image list does not end with a newline")?; + let mut lines = body.split('\n'); + if lines.next() != Some(MAGIC) { + return Err(format!("the image list is not a {MAGIC}")); + } + let field = |line: Option<&str>, key: &str| -> Result { + line.and_then(|l| l.strip_prefix(key)) + .and_then(|l| l.strip_prefix(": ")) + .map(str::to_string) + .ok_or_else(|| format!("the image list has no {key} line where one belongs")) + }; + let release = field(lines.next(), "release")?; + if !version_ok(&release) { + return Err(format!("the image list's release {release:?} is not a version")); + } + let base = field(lines.next(), "base")?; + if !hex64(&base) { + return Err("the image list's base is not a root hash".into()); + } + let mut images: Vec = Vec::new(); + let mut needs: Vec<(String, String)> = Vec::new(); + for line in lines { + if let Some(rest) = line.strip_prefix("image: ") { + if !needs.is_empty() { + return Err("an image line follows the needs lines".into()); + } + let img = image_line(rest)?; + // Sorted, which also keeps one line per image. + if images.last().is_some_and(|last| last.name >= img.name) { + return Err(format!("image {} is out of order or listed twice", img.name)); + } + images.push(img); + } else if let Some(rest) = line.strip_prefix("needs: ") { + let (name, what) = rest.split_once(' ').ok_or_else(|| format!("{line:?} names no policy line"))?; + let at = images + .iter() + .position(|i| i.name == name) + .ok_or_else(|| format!("a needs line names {name:?}, which the list does not hold"))?; + let last = needs.last().and_then(|(n, _)| images.iter().position(|i| &i.name == n)); + if last.is_some_and(|l| l > at) { + return Err(format!("the needs lines for {name} are not with their image's")); + } + policy::parse(what, "the image list").map_err(|e| e.to_string())?; + needs.push((name.to_string(), what.to_string())); + } else { + return Err(format!("unexpected line {line:?} in the image list")); + } + } + if images.is_empty() { + return Err("the image list names no image".into()); + } + Ok(Manifest { release, base, images, needs }) +} + +fn image_line(rest: &str) -> Result { + let bad = || format!("{rest:?} is not `NAME VERSION bytes= sha256= data-blocks= root-hash= salt=`"); + let words: Vec<&str> = rest.split(' ').collect(); + let &[name, version, bytes, sha256, data_blocks, root_hash, salt] = words.as_slice() else { + return Err(bad()); + }; + if !valid_name(name) || !version_ok(version) { + return Err(bad()); + } + let value = |w: &str, key: &str| w.strip_prefix(key).and_then(|v| v.strip_prefix('=')).map(str::to_string); + let img = Image { + name: name.to_string(), + version: version.to_string(), + bytes: value(bytes, "bytes").as_deref().and_then(number).ok_or_else(bad)?, + sha256: value(sha256, "sha256").filter(|h| hex64(h)).ok_or_else(bad)?, + data_blocks: value(data_blocks, "data-blocks").as_deref().and_then(number).ok_or_else(bad)?, + root_hash: value(root_hash, "root-hash").filter(|h| hex64(h)).ok_or_else(bad)?, + salt: value(salt, "salt").filter(|h| hex64(h)).ok_or_else(bad)?, + }; + // The data, then its hash tree, in whole blocks, and nothing past the cap. + let data = img.data_blocks.checked_mul(BLOCK).filter(|d| *d > 0); + if !data.is_some_and(|d| img.bytes > d) || img.bytes % BLOCK != 0 || img.bytes > IMAGE_MAX { + return Err(format!( + "image {}: {} bytes cannot hold {} data blocks and their hash tree", + img.name, img.bytes, img.data_blocks + )); + } + Ok(img) +} + +/// The running root's dm-verity root hash, from the signed kernel's command line: the word after +/// `sha256` in the verity table of `dm-mod.create`'s root device. +pub fn root_hash_from(cmdline: &str) -> Option { + const KEY: &str = "dm-mod.create=\""; + let start = cmdline.find(KEY)? + KEY.len(); + let end = start + cmdline[start..].find('"')?; + let table = cmdline[start..end].split(';').find(|d| d.starts_with("kroot,"))?; + let mut words = table.split_whitespace().skip_while(|w| *w != "verity"); + words.find(|w| *w == "sha256")?; + words.next().filter(|h| hex64(h)).map(str::to_string) +} + +/// Whether the zone's policy grants every line the list says `image` needs: an image widens +/// nothing, and a line the base policy already allows needs no grant. +pub fn needs_met(m: &Manifest, image: &str, granted: Option<&Policy>) -> Result<(), String> { + fn holds(want: &[String], have: Option<&Vec>) -> bool { + want.iter().all(|w| have.is_some_and(|h| h.contains(w))) + } + for (_, line) in m.needs.iter().filter(|(n, _)| n == image) { + let need = policy::parse(line, "the image list").map_err(|e| e.to_string())?; + let ok = holds(&need.extra_syscall_names, granted.map(|p| &p.extra_syscall_names)) + && holds(&need.socket_names, granted.map(|p| &p.socket_names)) + && holds(&need.netlink_names, granted.map(|p| &p.netlink_names)) + && holds(&need.keep_cap_names, granted.map(|p| &p.keep_cap_names)); + if !ok { + return Err(format!("image {image:?} needs `{line}`, which the zone's policy file does not grant")); + } + } + Ok(()) +} + +/// Where a set of images is stacked: one directory per set, whichever zone asks for it. +pub fn set_dir(images: &[&Image]) -> String { + let parts: Vec = images.iter().map(|i| format!("{}-{}", i.name, &i.root_hash[..16])).collect(); + format!("{SETS}/{}", parts.join("+")) +} + +/// Open the images `zone` names over the root this kernel runs and stack them over /usr, in name +/// order: the directory the zone binds as its /usr. +pub fn prepare(zone: &Zone, granted: Option<&Policy>) -> Result { + if unsafe { libc::geteuid() } != 0 { + return Err("only a root launch opens software images".into()); + } + let _one = crate::update::lock(Path::new(LOCK))?; + let cmdline = fs::read_to_string("/proc/cmdline").map_err(|e| format!("/proc/cmdline: {e}"))?; + let base = root_hash_from(&cmdline) + .ok_or("this kernel's command line names no verity root, so no image can be matched to it")?; + let m = verified_list(&base)?; + let chosen: Vec<&Image> = m.images.iter().filter(|i| zone.images.contains(&i.name)).collect(); + for name in &zone.images { + if !chosen.iter().any(|i| &i.name == name) { + return Err(format!("the image list for this root ({}) holds no image {name:?}", m.release)); + } + needs_met(&m, name, granted)?; + } + let mut lower: Vec = Vec::new(); + for img in &chosen { + lower.push(format!("{}/usr", open(img)?)); + } + lower.push("/usr".into()); + let set = set_dir(&chosen); + if !crate::volume::is_mountpoint(&set) { + fs::create_dir_all(&set).map_err(|e| format!("{set}: {e}"))?; + mount("kryptik-usr", &set, "overlay", Some(format!("lowerdir={}", lower.join(":")).as_str()))?; + } + Ok(set) +} + +/// The list for `base`, read once, its signature checked as nobody on a copy of those bytes. +fn verified_list(base: &str) -> Result { + let list = Path::new(STORE).join(format!("{base}.manifest")); + let text = read_bounded(&list, LIST_MAX) + .map_err(|e| format!("no software images are kept for this root ({}...): {e}", &base[..12]))?; + let sig = read_bounded(&list.with_extension("manifest.sig"), SIG_MAX)?; + let dir = crate::update::make_scratch(Path::new("/run/kryptik"))?; + let checked = fs::write(dir.join("manifest"), &text) + .and_then(|_| fs::write(dir.join("manifest.sig"), &sig)) + .map_err(|e| format!("{}: {e}", dir.display())) + .and_then(|_| crate::update::check_images(&dir)); + let _ = fs::remove_dir_all(&dir); + checked.map_err(|e| format!("the image list for this root is refused: {e}"))?; + let m = parse(std::str::from_utf8(&text).map_err(|_| "the image list is not text".to_string())?)?; + if m.base != base { + return Err(format!("the image list kept for this root is for another root ({})", m.release)); + } + Ok(m) +} + +fn read_bounded(path: &Path, max: u64) -> Result, String> { + let f = fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?; + let mut bytes = Vec::new(); + f.take(max + 1).read_to_end(&mut bytes).map_err(|e| format!("{}: {e}", path.display()))?; + if bytes.len() as u64 > max { + return Err(format!("{} is larger than {max} bytes", path.display())); + } + Ok(bytes) +} + +/// Open one image with dm-verity and mount it read-only: where it is mounted. +fn open(img: &Image) -> Result { + let file = PathBuf::from(format!("{STORE}/{}/{}.img", img.name, img.root_hash)); + let shown = file.display().to_string(); + let md = fs::symlink_metadata(&file).map_err(|e| format!("image {}: {shown}: {e}", img.name))?; + if !md.file_type().is_file() || md.uid() != 0 || md.mode() & 0o022 != 0 { + return Err(format!("image {}: {shown} is not a regular file only root writes", img.name)); + } + if md.len() != img.bytes { + return Err(format!("image {}: {shown} holds {} bytes, and the list signs {}", img.name, md.len(), img.bytes)); + } + let mapper = format!("kryptik-image-{}-{}", img.name, &img.root_hash[..16]); + let dev = format!("/dev/mapper/{mapper}"); + if Path::new(&dev).exists() { + // dm-verity marks a mapping corrupted at the first block that reads back other than signed. + let status = crate::volume::run("veritysetup status", "veritysetup", &["status", mapper.as_str()], None).map_err(|e| e.1)?; + if status.lines().any(|l| l.split_whitespace().eq(["status:", "corrupted"])) { + return Err(format!( + "image {} read back other than signed while in use, so it is not used again until this machine restarts", + img.name + )); + } + } else { + let blocks = format!("--data-blocks={}", img.data_blocks); + let offset = format!("--hash-offset={}", img.data_blocks * BLOCK); + let salt = format!("--salt={}", img.salt); + let args: [&str; 12] = [ + "open", "--no-superblock", "--hash=sha256", "--data-block-size=4096", "--hash-block-size=4096", + &blocks, &offset, &salt, &shown, &mapper, &shown, &img.root_hash, + ]; + crate::volume::run("veritysetup open", "veritysetup", &args, None).map_err(|e| format!("image {}: {}", img.name, e.1))?; + } + let at = format!("{MOUNTS}/{}/{}", img.name, &img.root_hash[..16]); + if !crate::volume::is_mountpoint(&at) { + fs::create_dir_all(&at).map_err(|e| format!("{at}: {e}"))?; + // A journal is never replayed: the device under it is read-only. + mount(&dev, &at, "ext4", Some("noload"))?; + } + if !Path::new(&at).join("usr").is_dir() { + return Err(format!("image {} holds no usr directory to stack over /usr", img.name)); + } + Ok(at) +} + +/// Read-only, with no set-id programs and no device files, whatever the image holds. +fn mount(src: &str, at: &str, fstype: &str, data: Option<&str>) -> Result<(), String> { + let c = |s: &str| std::ffi::CString::new(s).map_err(|_| format!("{s:?} holds a NUL")); + let (src_c, at_c, fs_c) = (c(src)?, c(at)?, c(fstype)?); + let data_c = data.map(c).transpose()?; + let flags = libc::MS_RDONLY | libc::MS_NOSUID | libc::MS_NODEV; + let data_p = data_c.as_ref().map_or(std::ptr::null(), |d| d.as_ptr().cast()); + if unsafe { libc::mount(src_c.as_ptr(), at_c.as_ptr(), fs_c.as_ptr(), flags, data_p) } < 0 { + return Err(format!("mount {fstype} at {at}: {}", std::io::Error::last_os_error())); + } + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/compartments/kryptikd/src/images/tests.rs b/compartments/kryptikd/src/images/tests.rs new file mode 100644 index 00000000..cfd70557 --- /dev/null +++ b/compartments/kryptikd/src/images/tests.rs @@ -0,0 +1,136 @@ +use super::*; + +fn hex(c: char) -> String { + std::iter::repeat(c).take(64).collect() +} + +fn image(name: &str) -> String { + format!( + "image: {name} 140.4.0 bytes=12288 sha256={} data-blocks=2 root-hash={} salt={}", + hex('a'), + hex('b'), + hex('c') + ) +} + +/// A list over the root `1…1`, with these lines after its header. +fn list(lines: &[&str]) -> String { + let mut s = format!("{MAGIC}\nrelease: 1.2.0\nbase: {}\n", hex('1')); + for l in lines { + s.push_str(l); + s.push('\n'); + } + s +} + +#[test] +fn parses_a_list_as_stage_06_writes_it() { + let m = parse(&list(&[&image("browser"), &image("viewer"), "needs: browser allow-syscall seccomp"])).unwrap(); + assert_eq!((m.release.as_str(), m.base.as_str()), ("1.2.0", hex('1').as_str())); + assert_eq!( + m.images[0], + Image { + name: "browser".into(), + version: "140.4.0".into(), + bytes: 12288, + sha256: hex('a'), + data_blocks: 2, + root_hash: hex('b'), + salt: hex('c'), + } + ); + assert_eq!(m.images[1].name, "viewer"); + assert_eq!(m.needs, vec![("browser".to_string(), "allow-syscall seccomp".to_string())]); +} + +#[test] +fn refuses_what_stage_06_never_writes() { + let good = image("browser"); + for (why, text) in [ + ("no header", list(&[&good]).replacen(MAGIC, "KRYPTIK-IMAGES-2", 1)), + ("no final newline", list(&[&good]).trim_end().to_string()), + ("a carriage return", list(&[&good]).replace('\n', "\r\n")), + ("a blank line", list(&["", &good])), + ("a comment", list(&["# browser", &good])), + ("no image", list(&[])), + ("a short base", list(&[&good]).replace(&hex('1'), &hex('1')[..63])), + ("an upper-case root hash", list(&[&good.replace(&hex('b'), &hex('B'))])), + ("a name with a slash", list(&[&image("a/b")])), + ("a name of 33 bytes", list(&[&image(&"x".repeat(33))])), + ("a field out of order", list(&[&good.replace("bytes=12288 ", "").replace(" salt=", " bytes=12288 salt=")])), + ("a leading zero", list(&[&good.replace("bytes=12288", "bytes=012288")])), + ("no room for the hash tree", list(&[&good.replace("bytes=12288", "bytes=8192")])), + ("bytes not whole blocks", list(&[&good.replace("bytes=12288", "bytes=12289")])), + ("no data", list(&[&good.replace("data-blocks=2", "data-blocks=0")])), + ("past the cap", list(&[&good.replace("bytes=12288", &format!("bytes={}", (16u64 << 30) + 4096))])), + ("two spaces", list(&[&good.replacen(' ', " ", 1)])), + ("out of order", list(&[&image("viewer"), &good])), + ("listed twice", list(&[&good, &good])), + ("needs for an image not listed", list(&[&good, "needs: viewer allow-syscall seccomp"])), + ("needs no line", list(&[&good, "needs: browser"])), + ("needs a line no policy file takes", list(&[&good, "needs: browser allow-syscall nosuchcall"])), + ("needs a denied call", list(&[&good, "needs: browser allow-syscall ptrace"])), + ("an image after the needs", list(&[&good, "needs: browser allow-syscall seccomp", &image("viewer")])), + ( + "needs out of their image's order", + list(&[&good, &image("viewer"), "needs: viewer allow-syscall seccomp", "needs: browser allow-syscall mlockall"]), + ), + ("an unknown line", list(&[&good, "signer: someone"])), + ] { + assert!(parse(&text).is_err(), "{why}: {text:?}"); + } +} + +#[test] +fn root_hash_from_the_signed_command_line() { + let table = |dev: &str| { + format!("0 2048000 verity 1 {dev} {dev} 4096 4096 256000 256001 sha256 {} {} 1 panic_on_corruption", hex('d'), hex('e')) + }; + let installed = format!( + "dm-mod.waitfor=PARTLABEL=kryptik-a dm-mod.create=\"kroot,,0,ro,{}\" root=/dev/dm-0 lockdown=confidentiality kryptik.slot=a", + table("PARTLABEL=kryptik-a") + ); + assert_eq!(root_hash_from(&installed), Some(hex('d'))); + let iso = format!( + "dm-mod.create=\"kmedia,,0,ro,0 4000 linear /dev/sr0 128;kroot,,1,ro,{}\" root=/dev/dm-1 kryptik.media=iso", + table("/dev/dm-0") + ); + assert_eq!(root_hash_from(&iso), Some(hex('d'))); + assert_eq!(root_hash_from("root=/dev/vda2 ro"), None, "no verity root"); + assert_eq!(root_hash_from("dm-mod.create=\"kroot,,0,ro,0 8 linear /dev/vda2 0\""), None, "a root that is not verity"); + let short = installed.replace(&hex('d'), &hex('d')[..40]); + assert_eq!(root_hash_from(&short), None, "a hash that is not 64 digits"); +} + +#[test] +fn needs_must_be_granted_by_the_zone_policy() { + let m = parse(&list(&[&image("browser"), &image("viewer"), "needs: browser allow-syscall seccomp"])).unwrap(); + let grants = |text: &str| policy::parse(text, "zone").unwrap(); + assert!(needs_met(&m, "browser", Some(&grants("allow-syscall seccomp\nallow-syscall mlockall\n"))).is_ok()); + let refused = needs_met(&m, "browser", Some(&grants("allow-syscall mlockall\n"))).unwrap_err(); + assert!(refused.contains("allow-syscall seccomp"), "{refused}"); + assert!(needs_met(&m, "browser", None).is_err(), "a zone with no policy file grants nothing"); + assert!(needs_met(&m, "viewer", None).is_ok(), "an image that needs nothing"); + // What the base policy already allows needs no grant. + let base = parse(&list(&[&image("browser"), "needs: browser allow-syscall read"])).unwrap(); + assert!(needs_met(&base, "browser", None).is_ok()); +} + +#[test] +fn a_set_is_named_by_its_images_and_their_hashes() { + let m = parse(&list(&[&image("browser"), &image("viewer")])).unwrap(); + let both: Vec<&Image> = m.images.iter().collect(); + assert_eq!(set_dir(&both), format!("/run/kryptik/usr/browser-{0}+viewer-{0}", &hex('b')[..16])); + assert_eq!(set_dir(&both[..1]), format!("/run/kryptik/usr/browser-{}", &hex('b')[..16])); +} + +#[test] +fn names_are_what_a_path_and_a_zone_file_take() { + for ok in ["browser", "pdf-viewer", "a", "x1"] { + assert!(valid_name(ok), "{ok}"); + } + let long = "x".repeat(33); + for bad in ["", "Browser", "a b", "a/b", "..", "a.b", "a_b", long.as_str()] { + assert!(!valid_name(bad), "{bad:?}"); + } +} diff --git a/compartments/kryptikd/src/main.rs b/compartments/kryptikd/src/main.rs index 88a28558..04f54385 100644 --- a/compartments/kryptikd/src/main.rs +++ b/compartments/kryptikd/src/main.rs @@ -10,6 +10,7 @@ mod caps; mod cgroup; mod consent; mod files; +mod images; mod isolate; mod landlock; mod netlink; @@ -47,6 +48,8 @@ USAGE: [--target] and require what a Kryptik kernel must have: root, unprivileged userns restricted, cgroup2 with memory+pids, every zone with [identity] + [--images FILE] and hold each zone's images to an image list: + listed, and what they need granted kryptikd list [--zones DIR] list configured zones kryptikd show NAME [--zones DIR] kryptikd explain NAME what starting this zone would do @@ -132,7 +135,7 @@ fn main() -> ExitCode { let zone_dir = zone_dir_from(&args); match args[0].as_str() { - "check" => cmd_check(&zone_dir, args.iter().any(|a| a == "--target")), + "check" => cmd_check(&zone_dir, args.iter().any(|a| a == "--target"), value(&args, "--images").map(Path::new)), "list" if args.iter().any(|a| a == "--running") => cmd_list_running(), "list" => cmd_list(&zone_dir), "show" => match args.get(1) { @@ -529,8 +532,12 @@ fn zone_dir_from(args: &[String]) -> PathBuf { PathBuf::from(value(args, "--zones").unwrap_or(DEFAULT_ZONE_DIR)) } -fn cmd_check(dir: &Path, target: bool) -> ExitCode { +fn cmd_check(dir: &Path, target: bool, image_list: Option<&Path>) -> ExitCode { let mut failed = false; + // Read unsigned, as the build writes it before signing; a zone start checks the signature. + let list = image_list.map(|p| { + std::fs::read_to_string(p).map_err(|e| format!("{}: {e}", p.display())).and_then(|t| images::parse(&t)) + }); let euid = unsafe { libc::geteuid() }; if target { println!("target contract: required (--target)"); @@ -684,6 +691,32 @@ fn cmd_check(dir: &Path, target: bool) -> ExitCode { } } } + if !z.images.is_empty() { + let granted = z.seccomp.as_ref().and_then(|rel| policy::load(&policy::resolve(dir, rel)).ok()); + match &list { + None => println!(" images {}: held to the signed list when the zone starts", z.images.join(" ")), + Some(Err(e)) => { + eprintln!(" images: {e}"); + failed = true; + } + Some(Ok(m)) => { + for name in &z.images { + let held = m.images.iter().any(|i| &i.name == name); + match images::needs_met(m, name, granted.as_ref()) { + Ok(()) if held => println!(" image {name}: listed, and what it needs is granted"), + Ok(()) => { + eprintln!(" image {name}: the list holds no such image"); + failed = true; + } + Err(e) => { + eprintln!(" {e}"); + failed = true; + } + } + } + } + } + } if target && z.uid_base.is_none() { eprintln!( " --target: zone {:?} declares no [identity] uid_base; a root launch cannot start it", diff --git a/compartments/kryptikd/src/rootfs.rs b/compartments/kryptikd/src/rootfs.rs index b57c2c34..78d7b319 100644 --- a/compartments/kryptikd/src/rootfs.rs +++ b/compartments/kryptikd/src/rootfs.rs @@ -306,8 +306,9 @@ pub fn check_data_dir(path: &str, expected_uid: u32) -> Result<(), RootfsError> } /// Pivot into a root holding only what the zone should see; returns the home. Runs as root in the -/// new user namespace, before Landlock and seccomp; `ephemeral` is a tmpfs home's size, and -/// `service` adds dhcpcd's user and `SERVICE_HOME`. +/// new user namespace, before Landlock and seccomp; `ephemeral` is a tmpfs home's size, `usr` what +/// the zone sees as /usr (zone 0's, or images stacked over it), and `service` adds dhcpcd's user +/// and `SERVICE_HOME`. #[allow(clippy::too_many_arguments)] pub fn pivot_into( data_dir: &str, @@ -317,6 +318,7 @@ pub fn pivot_into( broker: Option<&str>, wayland: Option<&str>, wifi_conf: Option<&str>, + usr: &str, service: bool, ) -> Result { let home = zone_home(zone); @@ -373,7 +375,8 @@ pub fn pivot_into( for p in SYSTEM_PATHS { // Skip paths the host lacks; is_dir follows a merged-usr /bin symlink. if Path::new(p).is_dir() { - bind_ro_dir(p, &format!("{root}{p}"))?; + let src = if *p == "/usr" { usr } else { *p }; + bind_ro_dir(src, &format!("{root}{p}"))?; } } diff --git a/compartments/kryptikd/src/spawn.rs b/compartments/kryptikd/src/spawn.rs index 534ee92f..bf6842bc 100644 --- a/compartments/kryptikd/src/spawn.rs +++ b/compartments/kryptikd/src/spawn.rs @@ -714,6 +714,15 @@ pub fn run_in_zone( } } + /* The images the zone names, opened and stacked now in zone 0's namespace, where the list's + * signature and the zone's policy are checked first: the zone only binds the result. */ + let usr = if zone.images.is_empty() { + "/usr".to_string() + } else { + crate::images::prepare(zone, zone_policy.as_ref()) + .map_err(|e| SpawnError::Setup(format!("zone {:?}: {e}", zone.name)))? + }; + // One launcher per zone: two would share a data directory, a cgroup and a veth name. let entry = registry::claim(&zone.name).map_err(|e| SpawnError::Setup(e.to_string()))?; @@ -855,7 +864,7 @@ pub fn run_in_zone( let rc = intermediate_main( zone, rootfs, argv, &id, parent_pid, &placed, &ready, &mapped, &initpid, zone_policy.as_ref(), &fs_rules, &broker_path_str, wayland_path_str.as_deref(), opts.wayland_inode, - wifi_conf.as_deref(), + wifi_conf.as_deref(), &usr, ); // Never return: this process must not run the parent's cleanup. unsafe { libc::_exit(rc) }; @@ -1068,6 +1077,7 @@ fn intermediate_main( wayland_path: Option<&str>, wayland_inode: Option, wifi_conf: Option<&str>, + usr: &str, ) -> i32 { macro_rules! bail { ($($arg:tt)*) => {{ @@ -1262,7 +1272,7 @@ fn intermediate_main( if inner == 0 { alive.close_write(); let service = maps_service(id.privileged, zone_policy); - let rc = zone_init(zone, rootfs, argv, flags, zone_policy, fs_rules, plumbed, &broker_in_zone, wayland_in_zone.as_deref(), wifi_conf, service, &alive); + let rc = zone_init(zone, rootfs, argv, flags, zone_policy, fs_rules, plumbed, &broker_in_zone, wayland_in_zone.as_deref(), wifi_conf, usr, service, &alive); unsafe { libc::_exit(rc) }; } alive.close_read(); @@ -1300,6 +1310,7 @@ fn zone_init( broker_path: &str, wayland_path: Option<&str>, wifi_conf: Option<&str>, + usr: &str, service: bool, alive: &SyncPipe, ) -> i32 { @@ -1334,7 +1345,7 @@ fn zone_init( (crate::zone::NetworkMode::Routed, _) => rootfs::Resolver::Bridge, _ => rootfs::Resolver::None, }; - let home = match rootfs::pivot_into(rootfs, &zone.name, ephemeral, resolver, Some(broker_path), wayland_path, wifi_conf, service) { + let home = match rootfs::pivot_into(rootfs, &zone.name, ephemeral, resolver, Some(broker_path), wayland_path, wifi_conf, usr, service) { Ok(h) => h, Err(e) => bail!("could not build the zone root: {e}"), }; @@ -1591,7 +1602,17 @@ pub fn explain(zone: &Zone, rootfs: &str, zones_dir: &std::path::Path) -> String identity_line, data_line, storage, - rootfs::SYSTEM_PATHS.join(" "), + rootfs::SYSTEM_PATHS + .iter() + .map(|p| match (*p, zone.images.is_empty()) { + ("/usr", false) => format!( + "/usr (with the images {} over it, signed for this root and opened with dm-verity)", + zone.images.join(" ") + ), + _ => p.to_string(), + }) + .collect::>() + .join(" "), rootfs::ETC_RO_FILES .iter() .chain(rootfs::ETC_RO_DIRS) diff --git a/compartments/kryptikd/src/update.rs b/compartments/kryptikd/src/update.rs index 005b431a..98cbf1bf 100644 --- a/compartments/kryptikd/src/update.rs +++ b/compartments/kryptikd/src/update.rs @@ -280,7 +280,7 @@ fn check_lock() -> Result { } /// An exclusive flock on `path`, made if missing; held while the returned file is open. -fn lock(path: &Path) -> Result { +pub(crate) fn lock(path: &Path) -> Result { use std::os::unix::io::AsRawFd; 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()))?; @@ -376,7 +376,7 @@ fn check_in(tool: &Path, dir: &Path, verb: &str, inputs: &[(&Path, &str)], whole } /// A fresh directory that mkdtemp names under `base`, or under the system's temporary directory. -fn make_scratch(base: &Path) -> Result { +pub(crate) fn make_scratch(base: &Path) -> Result { use std::os::unix::ffi::{OsStrExt, OsStringExt}; let parent = if base.is_dir() { base.to_path_buf() } else { std::env::temp_dir() }; let mut template = parent.join("kryptik-check.XXXXXX").as_os_str().as_bytes().to_vec(); @@ -405,6 +405,12 @@ pub fn tool_checks() -> Checks<'static> { } } +/// `kryptik-update check-images` on the image list and signature in `dir`, named as a release's +/// manifest is: whether the list is signed in the image namespace. +pub fn check_images(dir: &Path) -> Result { + check_signed_pair("check-images", dir) +} + /// `kryptik-update check-release` on the manifest and signature in `dir`: /// its version and signed date once both verify, in any version order. pub fn check_release(dir: &Path) -> Result { diff --git a/compartments/kryptikd/src/volume.rs b/compartments/kryptikd/src/volume.rs index 544d232c..519af0ed 100644 --- a/compartments/kryptikd/src/volume.rs +++ b/compartments/kryptikd/src/volume.rs @@ -174,7 +174,7 @@ pub fn default_volume_path(zone: &str) -> String { format!("{DEFAULT_VOLUME_DIR}/{zone}.luks") } -fn run(what: &str, prog: &str, args: &[&str], stdin: Option<&[u8]>) -> Result { +pub(crate) fn run(what: &str, prog: &str, args: &[&str], stdin: Option<&[u8]>) -> Result { let mut cmd = Command::new(prog); cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped()).env_clear().env("PATH", "/usr/sbin:/usr/bin:/sbin:/bin"); cmd.stdin(if stdin.is_some() { Stdio::piped() } else { Stdio::null() }); diff --git a/compartments/kryptikd/src/zone.rs b/compartments/kryptikd/src/zone.rs index 7c489938..b999716e 100644 --- a/compartments/kryptikd/src/zone.rs +++ b/compartments/kryptikd/src/zone.rs @@ -97,6 +97,9 @@ pub struct Zone { /// `[transfer] max_bytes = N`: the largest file this zone sends or /// receives through the broker, at most its cap. None: the cap alone. pub transfer_max: Option, + /// `[software] images = "browser viewer"`: signed images stacked over this zone's /usr + /// (images.rs). Adding one takes a zone file on the verified root. + pub images: Vec, } /// Smallest `identity.uid_base`; every base is a multiple of `IDENTITY_STRIDE`. @@ -135,6 +138,7 @@ pub const KNOWN_KEYS: &[&str] = &[ "limits.memory_max", "limits.pids_max", "limits.cpu_max", "limits.io_max", "identity.uid_base", "transfer.to", "transfer.max_bytes", + "software.images", "ui.border_color", "ui.border_pattern", "ui.glyph", "ui.label", ]; @@ -462,11 +466,34 @@ impl Zone { }, }; + let images: Vec = match get("software.images") { + None => Vec::new(), + Some(v) => { + let mut out: Vec = Vec::new(); + for n in v.split_whitespace() { + if !crate::images::valid_name(n) { + return Err(bad("software.images", &v, "space-separated image names")); + } + if out.iter().any(|o| o == n) { + return Err(ZoneError::Invalid(format!( + "zone {name:?}: [software] images names {n:?} twice" + ))); + } + out.push(n.to_string()); + } + if out.is_empty() { + return Err(bad("software.images", &v, "at least one image name, or omit [software]")); + } + out + } + }; + let zone = Zone { nic, uid_base, transfer_to, transfer_max, + images, description: get("zone.description").unwrap_or_default(), volume: get("storage.volume"), size: get("storage.size"), diff --git a/compartments/kryptikd/src/zone/tests.rs b/compartments/kryptikd/src/zone/tests.rs index bdbc5d48..2895a627 100644 --- a/compartments/kryptikd/src/zone/tests.rs +++ b/compartments/kryptikd/src/zone/tests.rs @@ -375,3 +375,13 @@ fn duplicate_colours_are_rejected() { let err = check_invariants(&zones).unwrap_err(); assert!(format!("{err}").contains("border_color"), "got: {err}"); } + +#[test] +fn software_images_are_space_separated_names() { + let with = |images: &str| VAULT.replace("[ui]", &format!("[software]\nimages = \"{images}\"\n[ui]")); + assert_eq!(Zone::from_str(&with("browser pdf-viewer")).unwrap().images, vec!["browser", "pdf-viewer"]); + assert!(Zone::from_str(VAULT).unwrap().images.is_empty(), "no [software], no images"); + for bad in ["", "Browser", "a/b", "browser browser", "x_y"] { + assert!(Zone::from_str(&with(bad)).is_err(), "{bad:?}"); + } +} diff --git a/docs/design/software-delivery.md b/docs/design/software-delivery.md index ee2487de..6a655bad 100644 --- a/docs/design/software-delivery.md +++ b/docs/design/software-delivery.md @@ -189,14 +189,22 @@ up ADR-001 in the zones where the applications run. ```text KRYPTIK-IMAGES-1 - base: 1.2.0 - image: browser 140.4.0 root-hash=<64 hex> salt= bytes= sha256=<64 hex> + release: 1.2.0 + base: + image: browser 140.4.0 bytes= sha256=<64 hex> data-blocks= root-hash=<64 hex> salt=<64 hex> needs: browser allow-syscall seccomp ``` signed by the release key in the namespace `kryptik-image`, which the - anchor grants the release key alone. -- **Stored** under `/var/lib/kryptik/images//.img`, root + anchor grants the release key alone. The base is the root hash, not the + version, as a version can name two builds; kryptikd reads the running one + from the signed kernel's command line. The verity parameters are the + root's (SHA-256, 4096-byte blocks, no superblock, the hash tree after + `data-blocks`), so every value kryptikd passes to dm-verity is signed. + Image lines are sorted by name and a needs line uses the policy-file + syntax; anything else in the file refuses it. +- **Stored** under `/var/lib/kryptik/images//.img`, with + the list for each root as `.manifest` and `.manifest.sig`, root only. Zone 0 keeps the generation the committed root uses and, during a trial, the one the trial root uses. - **Fetched** by the same verbs as a release. The statement of what is @@ -204,10 +212,12 @@ up ADR-001 in the zones where the applications run. only images whose hash it does not hold, never past the signed size. - **Mounted** by kryptikd on first use: a read-only loop device, dm-verity with the manifest's root hash, and ext4 mounted read-only, `nosuid,nodev` - at `/run/kryptik/images/`. A mapping that dm-verity has marked - corrupted is not used again: the next start of a zone that names the image - is refused, with the reason, until the image is fetched again. For each distinct set of images a zone - names, kryptikd mounts a read-only overlay (no upper directory; the + under `/run/kryptik/images//`, one directory per root hash. Each + start checks the list's signature again, as nobody, on a copy of the bytes + it then reads. A mapping that dm-verity has marked corrupted is not used + again: the next start of a zone that names the image is refused, with the + reason, until the image is fetched again. For each distinct set of images + a zone names, kryptikd mounts a read-only overlay (no upper directory; the images' `usr` over the root's `/usr`) at `/run/kryptik/usr/` in the initial namespace, and the zone's bind of `/usr` takes that instead. Since zone 0 mounts it and the zone only receives a bind, the hardened kernel's @@ -217,7 +227,7 @@ up ADR-001 in the zones where the applications run. ```toml [software] - images = ["browser"] + images = "browser" ``` `untrusted` names the browser image too, since the diff --git a/docs/design/update-channel.md b/docs/design/update-channel.md index ad208bcf..e6a09167 100644 --- a/docs/design/update-channel.md +++ b/docs/design/update-channel.md @@ -89,8 +89,8 @@ latest.sig an OpenSSH signature over those bytes, namespace kryptik-latest reach, and the release key signs only in a release run its maintainer approves, so the build uses two. The anchor stage 06 puts on the image lists the release key as -`kryptik-release namespaces="kryptik-release,kryptik-media"` (manifests, and -the media's checksums) and the statement key as +`kryptik-release namespaces="kryptik-release,kryptik-media,kryptik-image"` +(manifests, the media's checksums and image lists) and the statement key as `kryptik-latest namespaces="kryptik-latest"`, each honoured in its own namespaces only, and no key under both. While a key is replaced it lists the old one and the new one. A development build proves the split with a probe diff --git a/docs/release-keys.md b/docs/release-keys.md index 2528bb29..d1891c97 100644 --- a/docs/release-keys.md +++ b/docs/release-keys.md @@ -18,7 +18,8 @@ run. The build never makes these keys (`build/lib/release-keys.sh`). An image trusts the three Ed25519 keys through its anchor, `/usr/share/kryptik/trust/release-signers`. Each key is listed under its own name and held to its own namespaces: the release key to `kryptik-release` for -manifests and `kryptik-media` for the media's checksums, the statement key to +manifests, `kryptik-media` for the media's checksums and `kryptik-image` for +the lists of software images, the statement key to `kryptik-latest`, the control-disk key to `kryptik-testctl`. So a statement key cannot sign a release or its media, a control-disk key can arm nothing but an install, and no signature passes for one of another kind. A machine diff --git a/tools/channel-host.sh b/tools/channel-host.sh index 703c31d2..8bb82133 100755 --- a/tools/channel-host.sh +++ b/tools/channel-host.sh @@ -121,7 +121,7 @@ case "$MODE" in ssh-keygen -q -t ed25519 -N '' -C kryptik-release -f "${w}/release" < /dev/null ssh-keygen -q -t ed25519 -N '' -C kryptik-latest -f "${w}/latest" < /dev/null { - printf 'kryptik-release namespaces="kryptik-release,kryptik-media" %s\n' "$(cut -d' ' -f1,2 "${w}/release.pub")" + printf 'kryptik-release namespaces="kryptik-release,kryptik-media,kryptik-image" %s\n' "$(cut -d' ' -f1,2 "${w}/release.pub")" printf 'kryptik-latest namespaces="kryptik-latest" %s\n' "$(cut -d' ' -f1,2 "${w}/latest.pub")" } > "${w}/release-signers" KEY="${w}/latest" diff --git a/tools/make-release-keys.sh b/tools/make-release-keys.sh index 24fdc45c..8a463ca5 100755 --- a/tools/make-release-keys.sh +++ b/tools/make-release-keys.sh @@ -51,7 +51,7 @@ for n in kryptik-release kryptik-latest kryptik-testctl; do ssh-keygen -q -t ed25519 -N '' -C "$n" -f "$n" < /dev/null done { - printf 'kryptik-release namespaces="kryptik-release,kryptik-media" %s\n' "$(cut -d' ' -f1,2 kryptik-release.pub)" + printf 'kryptik-release namespaces="kryptik-release,kryptik-media,kryptik-image" %s\n' "$(cut -d' ' -f1,2 kryptik-release.pub)" printf 'kryptik-latest namespaces="kryptik-latest" %s\n' "$(cut -d' ' -f1,2 kryptik-latest.pub)" printf 'kryptik-testctl namespaces="kryptik-testctl" %s\n' "$(cut -d' ' -f1,2 kryptik-testctl.pub)" } > release-signers diff --git a/tools/tests/image-list.sh b/tools/tests/image-list.sh new file mode 100755 index 00000000..d1c71d9d --- /dev/null +++ b/tools/tests/image-list.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# Tests for kryptik-update's check-images, offline, with throwaway ed25519 keys: an image list +# passes in its own namespace, signed by the release key, and as a KRYPTIK-IMAGES-1 alone. + +set -uo pipefail + +unset KRYPTIK_SOURCES KRYPTIK_WORK KRYPTIK_LOCK KRYPTIK_OUT KRYPTIK_ROOT + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +UPD="${ROOT}/tools/update/kryptik-update" + +PASS=0 +FAIL=0 +green() { printf '\033[32m PASS\033[0m %s\n' "$1"; PASS=$((PASS + 1)); } +red() { printf '\033[31m FAIL\033[0m %s\n' "$1"; FAIL=$((FAIL + 1)); } + +command -v ssh-keygen >/dev/null 2>&1 || { echo "ssh-keygen required"; exit 1; } + +W="$(mktemp -d)" +trap 'rm -rf "$W"' EXIT + +for k in rel latest; do + ssh-keygen -q -t ed25519 -N '' -C "$k" -f "${W}/${k}" &2; exit 1; } +done +ANCHOR="${W}/anchor" +{ + printf 'kryptik-release namespaces="kryptik-release,kryptik-media,kryptik-image" %s\n' "$(cut -d' ' -f1,2 < "${W}/rel.pub")" + printf 'kryptik-latest namespaces="kryptik-latest" %s\n' "$(cut -d' ' -f1,2 < "${W}/latest.pub")" +} > "$ANCHOR" + +# The updater's own check, lifted out of the tool as release-manifest.sh lifts check-pointer. +{ + echo 'IMAGE_NAMESPACE=kryptik-image'; echo 'IMAGES_MAGIC=KRYPTIK-IMAGES-1' + echo "SIGNERS=${ANCHOR}" + echo 'say() { printf "%s\n" "$*"; }' + echo 'die() { printf "REFUSED: %s\n" "$*"; exit 1; }' + sed -n '/^verify_signed() {/,/^}/p' "$UPD" + sed -n '/^cmd_check_images() {/,/^}/p' "$UPD" + printf 'SNAP=%q\n' "${W}/snap"; echo 'mkdir -p "$SNAP"' + echo 'cmd_check_images "$1" && echo ACCEPTED' +} > "${W}/check-images.sh" +grep -q '^cmd_check_images() {' "${W}/check-images.sh" || { echo "FATAL: no cmd_check_images in ${UPD}" >&2; exit 1; } + +# A list in DIR signed with KEY in NAMESPACE, its first line FIRST. +list() { # list DIR KEY NAMESPACE [FIRST] + mkdir -p "$1" + printf '%s\nrelease: 1.2.0\nbase: %064d\nimage: probe 1 bytes=8192 sha256=%064d data-blocks=1 root-hash=%064d salt=%064d\n' \ + "${4:-KRYPTIK-IMAGES-1}" 0 0 0 0 > "$1/manifest" + ssh-keygen -Y sign -f "$2" -n "$3" "$1/manifest" /dev/null 2>&1 + [[ -s "$1/manifest.sig" ]] || { echo "FATAL: could not sign $1/manifest" >&2; exit 1; } +} +# Captured whole: under pipefail a refusal's exit 1 would fail a pipe to grep. +said() { bash "${W}/check-images.sh" "$1" 2>&1; } + +list "${W}/good" "${W}/rel" kryptik-image +out="$(said "${W}/good")" +[[ "$out" == *ACCEPTED* ]] && green "a list the release key signs in kryptik-image is accepted" \ + || red "a list signed as it should be was refused: ${out}" + +list "${W}/as-release" "${W}/rel" kryptik-release +out="$(said "${W}/as-release")" +[[ "$out" == *"REFUSED:"*"does NOT verify"* && "$out" != *ACCEPTED* ]] \ + && green "a list signed in kryptik-release is refused: a release manifest's signature does not pass for a list's" \ + || red "a list signed in kryptik-release was not refused: ${out}" + +list "${W}/by-latest" "${W}/latest" kryptik-image +out="$(said "${W}/by-latest")" +[[ "$out" == *"REFUSED:"*"does NOT verify"* && "$out" != *ACCEPTED* ]] \ + && green "a list the statement key signs is refused: the anchor gives kryptik-image to the release key alone" \ + || red "a list signed by the statement key was not refused: ${out}" + +list "${W}/manifest" "${W}/rel" kryptik-image KRYPTIK-MANIFEST-1 +out="$(said "${W}/manifest")" +[[ "$out" == *"REFUSED: the image list is not a KRYPTIK-IMAGES-1"* ]] \ + && green "a signed file that is not an image list is refused" \ + || red "a signed file of another kind was not refused as such: ${out}" + +# The same good list, under an anchor that grants the release key the older namespaces only. +sed -i 's/,kryptik-image"/"/' "$ANCHOR" +out="$(said "${W}/good")" +[[ "$out" == *"REFUSED:"* && "$out" != *ACCEPTED* ]] \ + && green "under an anchor without kryptik-image, the same list is refused" \ + || red "a list passed under an anchor that does not grant its namespace: ${out}" + +# kryptikd runs the verb as nobody; the tool refuses it as root, as it refuses the other checks. +if grep -q '^ check-manifest|check-pointer|check-release|check-images)$' "$UPD" \ + && grep -q '^ check-images) shift; cmd_check_images "\$@" ;;$' "$UPD"; then + green "check-images is one of the checks that never run as root, and the tool dispatches it" +else + red "check-images is missing from kryptik-update's checks or its dispatcher" +fi + +echo +if [[ "$FAIL" -gt 0 ]]; then + echo "${FAIL} of $((PASS + FAIL)) checks failed." + exit 1 +fi +echo "All ${PASS} checks passed." diff --git a/tools/tests/release-keys.sh b/tools/tests/release-keys.sh index 1c29b4e6..93b00992 100755 --- a/tools/tests/release-keys.sh +++ b/tools/tests/release-keys.sh @@ -72,7 +72,7 @@ make_medium() { # a complete key medium, as the ceremony leaves it ssh-keygen -q -t ed25519 -N '' -C 'kryptik-release (test)' -f "$M/kryptik-release" < /dev/null ssh-keygen -q -t ed25519 -N '' -C 'kryptik-latest (test)' -f "$M/kryptik-latest" < /dev/null { - printf 'kryptik-release namespaces="kryptik-release,kryptik-media" %s\n' "$(cut -d' ' -f1,2 < "$M/kryptik-release.pub")" + printf 'kryptik-release namespaces="kryptik-release,kryptik-media,kryptik-image" %s\n' "$(cut -d' ' -f1,2 < "$M/kryptik-release.pub")" printf 'kryptik-latest namespaces="kryptik-latest" %s\n' "$(cut -d' ' -f1,2 < "$M/kryptik-latest.pub")" } > "$M/release-signers" openssl req -new -x509 -newkey rsa:2048 -nodes -days 30 -sha256 -subj "/CN=test Secure Boot key/" \ @@ -142,10 +142,12 @@ make_medium; sed -i 's/namespaces="kryptik-latest"/namespaces="kryptik-release"/ refused "an anchor that lets the statement key sign releases" "not held to its own namespace" make_medium; sed -i 's/namespaces="kryptik-latest"/namespaces="kryptik-latest,kryptik-media"/' "$M/release-signers" refused "an anchor that lets the statement key sign the media's checksums" "kryptik-latest is not held to its own namespaces" -make_medium; sed -i 's/namespaces="kryptik-release,kryptik-media"/namespaces="kryptik-release"/' "$M/release-signers" +make_medium; sed -i 's/namespaces="kryptik-release,kryptik-media,kryptik-image"/namespaces="kryptik-release"/' "$M/release-signers" refused "an anchor that leaves the media's checksums to no key" "kryptik-release is not held to its own namespaces" -make_medium; sed -i 's/namespaces="kryptik-release,kryptik-media"/namespaces="kryptik-release,kryptik-media,kryptik-latest"/' "$M/release-signers" +make_medium; sed -i 's/namespaces="kryptik-release,kryptik-media,kryptik-image"/namespaces="kryptik-release,kryptik-media,kryptik-image,kryptik-latest"/' "$M/release-signers" refused "an anchor that lets the release key sign statements" "kryptik-release is not held to its own namespaces" +make_medium; sed -i 's/namespaces="kryptik-release,kryptik-media,kryptik-image"/namespaces="kryptik-release,kryptik-media"/' "$M/release-signers" +refused "an anchor that leaves image lists to no key" "kryptik-release is not held to its own namespaces" make_medium; sed -i '1p' "$M/release-signers" refused "an anchor listing a key twice" "a key is listed twice" make_medium; printf 'someone namespaces="someone" %s\n' "$(cut -d' ' -f1,2 < "$M/kryptik-release.pub")" >> "$M/release-signers" @@ -158,7 +160,7 @@ refused "a release key the anchor does not list" "kryptik-release.pub is not a k # While a key is replaced, the anchor lists the old one and the new one, and # the medium may hold either. make_medium; ssh-keygen -q -t ed25519 -N '' -C 'kryptik-release (next)' -f "${W}/next" < /dev/null -printf 'kryptik-release namespaces="kryptik-release,kryptik-media" %s\n' "$(cut -d' ' -f1,2 < "${W}/next.pub")" >> "$M/release-signers" +printf 'kryptik-release namespaces="kryptik-release,kryptik-media,kryptik-image" %s\n' "$(cut -d' ' -f1,2 < "${W}/next.pub")" >> "$M/release-signers" rm "$M/kryptik-release" "$M/kryptik-release.pub"; mv "${W}/next" "$M/kryptik-release"; mv "${W}/next.pub" "$M/kryptik-release.pub" keys production "$PW" "$M"; rc=$? if [[ "$rc" -eq 0 && "$(got RELEASE_KEY)" == "${M}/kryptik-release" ]]; then diff --git a/tools/tests/release-publish.sh b/tools/tests/release-publish.sh index 4982aabf..19567601 100755 --- a/tools/tests/release-publish.sh +++ b/tools/tests/release-publish.sh @@ -46,7 +46,7 @@ git -C "$T" tag v0.1.1 # --- the release key, and the anchor the image carries --------------------------- K="${W}/keys"; mkdir -p "$K" ssh-keygen -q -t ed25519 -N '' -C kryptik-release -f "${K}/kryptik-release" -printf 'kryptik-release namespaces="kryptik-release,kryptik-media" %s\n' "$(cut -d' ' -f1,2 "${K}/kryptik-release.pub")" > "${K}/release-signers" +printf 'kryptik-release namespaces="kryptik-release,kryptik-media,kryptik-image" %s\n' "$(cut -d' ' -f1,2 "${K}/kryptik-release.pub")" > "${K}/release-signers" # --- an export as make acceptance writes one -------------------------------------- make_export() { # make_export DIR VERSION ROLE VERDICT REVISION diff --git a/tools/tests/testctl.sh b/tools/tests/testctl.sh index 9fd6c8da..3eb37e07 100755 --- a/tools/tests/testctl.sh +++ b/tools/tests/testctl.sh @@ -21,7 +21,7 @@ for k in kryptik-release kryptik-latest kryptik-testctl stranger; do ssh-keygen -q -t ed25519 -N '' -C "$k" -f "${W}/${k}" < /dev/null done { - printf 'kryptik-release namespaces="kryptik-release,kryptik-media" %s\n' "$(cut -d' ' -f1,2 "${W}/kryptik-release.pub")" + printf 'kryptik-release namespaces="kryptik-release,kryptik-media,kryptik-image" %s\n' "$(cut -d' ' -f1,2 "${W}/kryptik-release.pub")" printf 'kryptik-latest namespaces="kryptik-latest" %s\n' "$(cut -d' ' -f1,2 "${W}/kryptik-latest.pub")" printf 'kryptik-testctl namespaces="kryptik-testctl" %s\n' "$(cut -d' ' -f1,2 "${W}/kryptik-testctl.pub")" } > "${W}/release-signers" diff --git a/tools/throwaway-key-medium.sh b/tools/throwaway-key-medium.sh index d86f3b5a..84212724 100755 --- a/tools/throwaway-key-medium.sh +++ b/tools/throwaway-key-medium.sh @@ -19,7 +19,7 @@ ssh-keygen -q -t ed25519 -N '' -C kryptik-release -f "${m}/kryptik-release" < /d ssh-keygen -q -t ed25519 -N '' -C kryptik-latest -f "${m}/kryptik-latest" < /dev/null ssh-keygen -q -t ed25519 -N '' -C kryptik-testctl -f "${m}/kryptik-testctl" < /dev/null { - printf 'kryptik-release namespaces="kryptik-release,kryptik-media" %s\n' "$(cut -d' ' -f1,2 "${m}/kryptik-release.pub")" + printf 'kryptik-release namespaces="kryptik-release,kryptik-media,kryptik-image" %s\n' "$(cut -d' ' -f1,2 "${m}/kryptik-release.pub")" printf 'kryptik-latest namespaces="kryptik-latest" %s\n' "$(cut -d' ' -f1,2 "${m}/kryptik-latest.pub")" printf 'kryptik-testctl namespaces="kryptik-testctl" %s\n' "$(cut -d' ' -f1,2 "${m}/kryptik-testctl.pub")" } > "${m}/release-signers" diff --git a/tools/update/kryptik-update b/tools/update/kryptik-update index 4a2f8341..adb132f4 100755 --- a/tools/update/kryptik-update +++ b/tools/update/kryptik-update @@ -9,6 +9,7 @@ # kryptik-update check-manifest DIR verify only: signature, role, version # kryptik-update check-pointer FILE SIG verify only: the pointer's signature # kryptik-update check-release DIR verify only: signature, role; any version +# kryptik-update check-images DIR verify only: an image list's signature # # --retry arm again a slot whose last trial failed # --recovery accept an older signed release (a downgrade) @@ -26,6 +27,8 @@ NAMESPACE=kryptik-release MAGIC=KRYPTIK-MANIFEST-1 LATEST_NAMESPACE=kryptik-latest LATEST_MAGIC=KRYPTIK-LATEST-1 +IMAGE_NAMESPACE=kryptik-image +IMAGES_MAGIC=KRYPTIK-IMAGES-1 # On the verified root: never under /etc, which the state partition can shadow. SIGNERS=/usr/share/kryptik/trust/release-signers ROLE_FILE=/usr/share/kryptik/trust/required-role @@ -42,7 +45,7 @@ SNAP="" # outside their own directory. They read what the net zone sent, so they do not # run as root: kryptikd runs them as nobody, on copies. case "${1:-}" in - check-manifest|check-pointer|check-release) + check-manifest|check-pointer|check-release|check-images) [ "$(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" ;; *) @@ -56,7 +59,7 @@ done # Only partitions on the root's own disk; an ambiguous label is refused, never # resolved by taking the first (devices.sh). case "${1:-}" in - check-manifest|check-pointer|check-release) ;; + check-manifest|check-pointer|check-release|check-images) ;; *) # shellcheck source=/dev/null . /usr/libexec/kryptik/devices.sh @@ -299,6 +302,19 @@ cmd_check_pointer() { verify_signed "$SNAP/latest" "$SNAP/latest.sig" "$LATEST_NAMESPACE" pointer } +# Exit 0 if a copy of DIR's image list is signed by an enrolled key in the +# image namespace. What it lists, and whether it fits the running root, +# kryptikd judges (images.rs). +cmd_check_images() { + [ $# = 1 ] && [ -d "$1" ] || die "check-images needs the directory holding manifest and manifest.sig" + SNAP="${SNAP:-$(mktemp -d /run/kryptik/update-manifest.XXXXXX 2>/dev/null || mktemp -d)}" + chmod 0700 "$SNAP" + cp -- "$1/manifest" "$SNAP/manifest" || die "cannot copy the image list" + cp -- "$1/manifest.sig" "$SNAP/manifest.sig" || die "cannot copy the image list's signature" + [ "$(head -1 "$SNAP/manifest")" = "$IMAGES_MAGIC" ] || die "the image list is not a $IMAGES_MAGIC" + verify_signed "$SNAP/manifest" "$SNAP/manifest.sig" "$IMAGE_NAMESPACE" "image list" +} + # 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 created date its signed manifest names. @@ -447,5 +463,6 @@ case "${1:-}" in check-manifest) shift; cmd_check_manifest "$@" ;; check-pointer) shift; cmd_check_pointer "$@" ;; check-release) shift; cmd_check_release "$@" ;; - *) sed -n '2,17p' "$0" | sed 's/^# \{0,1\}//'; exit 2 ;; + check-images) shift; cmd_check_images "$@" ;; + *) sed -n '2,19p' "$0" | sed 's/^# \{0,1\}//'; exit 2 ;; esac From e0d5e83cd5efd9d2fcaf4040b70838ed809d8629 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 23:02:23 -0700 Subject: [PATCH 002/123] The image stage builds each software image over the base system, and stage 06 packs and signs them build/stages/04-images.sh runs an image's rows in the chroot through an overlay on the finished sysroot; tools/check-image-tree.sh refuses an upper directory that removes, replaces or changes the root's files, or adds outside /usr; stage 06 packs images/.img as it packs the root, writes images.manifest and signs it in kryptik-image, with the two refusal controls in a development build. The probe image proves it. Stage 04's flag and recipe helpers move to build/lib/recipes.sh, word for word. --- .github/workflows/distro.yml | 5 ++ Makefile | 7 +- build/images/probe.needs | 2 + build/images/probe.programs | 2 + build/images/probe.sh | 17 ++++ build/images/probe/kryptik-probe.c | 23 ++++++ build/lib/recipes.sh | 107 +++++++++++++++++++++++++ build/stages/04-base-system.sh | 105 +------------------------ build/stages/04-images.sh | 117 ++++++++++++++++++++++++++++ build/stages/06-iso.sh | 120 ++++++++++++++++++++++++++++- docs/design/software-delivery.md | 20 ++++- tools/check-image-tree.sh | 88 +++++++++++++++++++++ tools/tests/check-image-tree.sh | 69 +++++++++++++++++ 13 files changed, 573 insertions(+), 109 deletions(-) create mode 100644 build/images/probe.needs create mode 100644 build/images/probe.programs create mode 100644 build/images/probe.sh create mode 100644 build/images/probe/kryptik-probe.c create mode 100644 build/lib/recipes.sh create mode 100755 build/stages/04-images.sh create mode 100755 tools/check-image-tree.sh create mode 100755 tools/tests/check-image-tree.sh diff --git a/.github/workflows/distro.yml b/.github/workflows/distro.yml index 008214cc..7f9b4532 100644 --- a/.github/workflows/distro.yml +++ b/.github/workflows/distro.yml @@ -330,6 +330,11 @@ jobs: if: always() run: rm -f /mnt/kryptik/work-after-05.tar.zst + # After the cache is packed, so no image tree or overlay reaches it; stage 06 packs what this builds. + - name: Software images (over the base system, in the chroot) + timeout-minutes: 30 + run: make -o system images + # The throwaway production pair, then A and B; a production release is only bound here, where upstream build scripts ran as root. - name: Stage 06, the media (production 1.0.0 and 1.0.1, release A and the update B) id: media diff --git a/Makefile b/Makefile index d20b66b4..6f0150be 100644 --- a/Makefile +++ b/Makefile @@ -92,7 +92,7 @@ CHROOT_RUN := $(SUDO) env $(CHROOT_ENV) "$(CHROOTD)" .PHONY: help paths check sources lock verify verify-provenance check-pins \ validate-kernel validate-kernel-hardened validate-kernel-boot check-kernel-eol \ resolve-kernel-config check-kernel-hardening \ - toolchain temp-tools sysroot-ready system kernel \ + toolchain temp-tools sysroot-ready system images kernel \ chroot chroot-enter chroot-umount chroot-status \ iso media production-pair ovmf-vars media-smoke-usb media-smoke-iso media-smoke-secureboot \ media-refused-foreign-keys install-test integrity-test update-test \ @@ -112,6 +112,7 @@ help: @echo " make temp-tools stage 02: temporary tools" @echo " (stages 01 and 02 are UNPRIVILEGED - run them as you)" @echo " make system stage 04: hardened base system" + @echo " make images software images over the base system (build/images)" @echo " make kernel stage 05: hardened kernel" @echo " make iso stage 06: verified root image, signed kernels, USB image + ISO" @echo " make media stage 06 only (sysroot and kernel already built)" @@ -273,6 +274,10 @@ sysroot-ready: system: sysroot-ready @$(CHROOT_RUN) run /kryptik/build/stages/04-base-system.sh +# Root for the overlay each image builds through; its rows run in the chroot over it. +images: system + @$(SUDO) env $(CHROOT_ENV) "$(STAGES)"/04-images.sh + # The EOL check needs the network and the chroot has none, so it runs here first. kernel: check-kernel-eol system @$(CHROOT_RUN) run /kryptik/build/stages/05-kernel.sh diff --git a/build/images/probe.needs b/build/images/probe.needs new file mode 100644 index 00000000..cc478b66 --- /dev/null +++ b/build/images/probe.needs @@ -0,0 +1,2 @@ +# The policy lines a zone that names the probe image must have (images.manifest's needs lines). +allow-syscall mlockall diff --git a/build/images/probe.programs b/build/images/probe.programs new file mode 100644 index 00000000..e01edf00 --- /dev/null +++ b/build/images/probe.programs @@ -0,0 +1,2 @@ +# The probe image's executable files, as build/config/image-programs.txt names the root's. +usr/bin/kryptik-probe diff --git a/build/images/probe.sh b/build/images/probe.sh new file mode 100644 index 00000000..3942a43b --- /dev/null +++ b/build/images/probe.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# The probe image: one small program built over the base system, which the zones suite stacks on a +# zone's /usr and runs there. It proves the image stage end to end and ships to no one. +# shellcheck disable=SC2034 # read by build/stages/04-images.sh +image_version="1.0" +# shellcheck disable=SC2034 +image_rows=( + "kryptik-probe" "s_image_probe" +) + +s_image_probe() { + local dir; dir="$(mktemp -d "${BUILDDIR}/probe.XXXXXX")" + # shellcheck disable=SC2086 # the hardening flags are word lists + gcc ${CFLAGS} ${LDFLAGS} -o "${dir}/kryptik-probe" "${KRYPTIK_ROOT}/build/images/probe/kryptik-probe.c" + install -D -m 0755 "${dir}/kryptik-probe" /usr/bin/kryptik-probe + rm -rf "$dir" +} diff --git a/build/images/probe/kryptik-probe.c b/build/images/probe/kryptik-probe.c new file mode 100644 index 00000000..1ded7fcd --- /dev/null +++ b/build/images/probe/kryptik-probe.c @@ -0,0 +1,23 @@ +/* kryptik-probe: a zone that names the probe image runs this from the image's /usr; it says where. It + also locks its memory, a call outside the zone filter's base set, so the image needs a policy line. */ +#include +#include +#include +#include +#include +#include + +int main(void) +{ + char path[PATH_MAX]; + ssize_t n = readlink("/proc/self/exe", path, sizeof path - 1); + + if (n < 0) { + perror("kryptik-probe: /proc/self/exe"); + return 1; + } + path[n] = '\0'; + int locked = mlockall(MCL_CURRENT) == 0; + printf("kryptik-probe 1.0, from the probe image: %s (mlockall: %s)\n", path, locked ? "ok" : strerror(errno)); + return 0; +} diff --git a/build/lib/recipes.sh b/build/lib/recipes.sh new file mode 100644 index 00000000..facc4937 --- /dev/null +++ b/build/lib/recipes.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# The hardening flags each step builds with, the helpers its recipe calls, and the recipes themselves: +# sourced by stage 04 and the image stage, which build recipes the same way. + +# --- hardening exceptions --------------------------------------------------- + +# The flags to drop for a package, from hardening-exceptions.txt. +exception_flags_for() { + local pkg="$1" f="${KRYPTIK_ROOT}/build/config/hardening-exceptions.txt" + [[ -f "$f" ]] || return 0 + awk -v p="$pkg" '!/^[[:space:]]*#/ && $1 == p { print $2 }' "$f" +} + +# Hardening minus the package's exceptions; a -final row takes its package's. +set_flags_for() { + local pkg="$1" drop + export CFLAGS="${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 + [[ -z "$drop" ]] && continue + warn "${pkg}: dropping ${drop} (justified exception)" + CFLAGS="${CFLAGS//${drop}/}" + CXXFLAGS="${CXXFLAGS//${drop}/}" + LDFLAGS="${LDFLAGS//${drop}/}" + done < <(exception_flags_for "${pkg%-final}") + + export CFLAGS CXXFLAGS LDFLAGS +} + +# --- step machinery --------------------------------------------------------- + +# The shared step() calls this after printing the tail of a failed log. +step_failure_hint() { + # Two locals: one `local` expands all its words before assigning any. + local name="$1" + local logfile="${LOGS}/${STAMP_PREFIX}${name}.log" + + # The error can be thousands of lines above the tail; show likely causes. + if [[ -f "$logfile" ]]; then + local hits + hits="$(grep -nE '^(make(\[[0-9]+\])?: \*\*\*|.*: \*\*\* )|\[ERROR\]|undefined (symbol|reference)|No such file or directory|Permission denied|command not found|configure: error|fatal error|cannot find -l|ModuleNotFoundError|ImportError|^[A-Za-z_.]*Error:|Could not build' \ + "$logfile" 2>/dev/null | tail -15)" + if [[ -n "$hits" ]]; then + err "" + err "Lines in ${logfile} that look like the actual cause:" + printf '%s\n' "$hits" | sed 's/^/ /' >&2 + fi + fi + + err "" + err "Check the actual error before assuming it is the hardening flags." + err "The first stage 04 failure looked like one and was not - it was a" + err "missing native glibc and no generated locales." + err "" + err "If it IS a hardening incompatibility, add an entry to" + err "build/config/hardening-exceptions.txt WITH a justification, so" + err "only that flag is dropped and only for that package." +} + +# Native build: no --host, as this runs on the target. +native_build() { + local tarball="$1" dirname="$2"; shift 2 + local src; src="$(unpack "$tarball" "$dirname")" + cd "$src" + ./configure --prefix=/usr "$@" + make + make install +} + +# meson packages; the Wayland stack is meson-only. --buildtype=plain leaves the +# flags to the hardening CFLAGS (release adds -O3 and -DNDEBUG), and +# --wrap-mode=nodownload keeps a subproject from fetching unlocked sources. +meson_build() { + local tarball="$1" dirname="$2"; shift 2 + local src; src="$(unpack "$tarball" "$dirname")" + cd "$src" + meson setup build --prefix=/usr --buildtype=plain --wrap-mode=nodownload "$@" + ninja -C build + ninja -C build install +} + +# cmake only generates json-c's build files (cryptsetup needs json-c for LUKS2 +# headers) and stays out of the image. The chroot runs Kitware's binary, pinned +# in sources.lock and never installed, instead of a long source build; where it +# cannot run, s_cmake builds from source with its bundled libraries. Unpacked +# on demand: the build tree is cleared between runs, and a resumed json-c step +# must not rely on a skipped cmake step. +prebuilt_cmake() { + local dir="${BUILDDIR}/cmake-${V_CMAKE}-linux-x86_64" + if [[ ! -x "${dir}/bin/cmake" ]]; then + rm -rf "$dir" + tar -xf "${KRYPTIK_SOURCES}/cmake-${V_CMAKE}-linux-x86_64.tar.gz" -C "$BUILDDIR" + fi + [[ -x "${dir}/bin/cmake" ]] || return 1 + "${dir}/bin/cmake" --version > /dev/null 2>&1 || return 1 + printf '%s' "${dir}/bin/cmake" +} + +# --- recipes ---------------------------------------------------------------- +# One file per step under build/recipes, sourced here; each stage lists the order it builds +# them in. A step's fingerprint is its function's text, wherever it lives. +for recipe in "$(dirname "${BASH_SOURCE[0]}")/../recipes/"*.sh; do + # shellcheck source=/dev/null + source "$recipe" +done diff --git a/build/stages/04-base-system.sh b/build/stages/04-base-system.sh index 38ac6a18..7857c92a 100755 --- a/build/stages/04-base-system.sh +++ b/build/stages/04-base-system.sh @@ -52,109 +52,8 @@ tree_digest() { done | LC_ALL=C sort -z | xargs -0r sha256sum | sed "s| ${KRYPTIK_ROOT}/| |" | sha256_of_stdin } -# --- hardening exceptions --------------------------------------------------- - -# The flags to drop for a package, from hardening-exceptions.txt. -exception_flags_for() { - local pkg="$1" f="${KRYPTIK_ROOT}/build/config/hardening-exceptions.txt" - [[ -f "$f" ]] || return 0 - awk -v p="$pkg" '!/^[[:space:]]*#/ && $1 == p { print $2 }' "$f" -} - -# Hardening minus the package's exceptions; a -final row takes its package's. -set_flags_for() { - local pkg="$1" drop - export CFLAGS="${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 - [[ -z "$drop" ]] && continue - warn "${pkg}: dropping ${drop} (justified exception)" - CFLAGS="${CFLAGS//${drop}/}" - CXXFLAGS="${CXXFLAGS//${drop}/}" - LDFLAGS="${LDFLAGS//${drop}/}" - done < <(exception_flags_for "${pkg%-final}") - - export CFLAGS CXXFLAGS LDFLAGS -} - -# --- step machinery --------------------------------------------------------- - -# The shared step() calls this after printing the tail of a failed log. -step_failure_hint() { - # Two locals: one `local` expands all its words before assigning any. - local name="$1" - local logfile="${LOGS}/${STAMP_PREFIX}${name}.log" - - # The error can be thousands of lines above the tail; show likely causes. - if [[ -f "$logfile" ]]; then - local hits - hits="$(grep -nE '^(make(\[[0-9]+\])?: \*\*\*|.*: \*\*\* )|\[ERROR\]|undefined (symbol|reference)|No such file or directory|Permission denied|command not found|configure: error|fatal error|cannot find -l|ModuleNotFoundError|ImportError|^[A-Za-z_.]*Error:|Could not build' \ - "$logfile" 2>/dev/null | tail -15)" - if [[ -n "$hits" ]]; then - err "" - err "Lines in ${logfile} that look like the actual cause:" - printf '%s\n' "$hits" | sed 's/^/ /' >&2 - fi - fi - - err "" - err "Check the actual error before assuming it is the hardening flags." - err "The first stage 04 failure looked like one and was not - it was a" - err "missing native glibc and no generated locales." - err "" - err "If it IS a hardening incompatibility, add an entry to" - err "build/config/hardening-exceptions.txt WITH a justification, so" - err "only that flag is dropped and only for that package." -} - -# Native build: no --host, as this runs on the target. -native_build() { - local tarball="$1" dirname="$2"; shift 2 - local src; src="$(unpack "$tarball" "$dirname")" - cd "$src" - ./configure --prefix=/usr "$@" - make - make install -} - -# meson packages; the Wayland stack is meson-only. --buildtype=plain leaves the -# flags to the hardening CFLAGS (release adds -O3 and -DNDEBUG), and -# --wrap-mode=nodownload keeps a subproject from fetching unlocked sources. -meson_build() { - local tarball="$1" dirname="$2"; shift 2 - local src; src="$(unpack "$tarball" "$dirname")" - cd "$src" - meson setup build --prefix=/usr --buildtype=plain --wrap-mode=nodownload "$@" - ninja -C build - ninja -C build install -} - -# cmake only generates json-c's build files (cryptsetup needs json-c for LUKS2 -# headers) and stays out of the image. The chroot runs Kitware's binary, pinned -# in sources.lock and never installed, instead of a long source build; where it -# cannot run, s_cmake builds from source with its bundled libraries. Unpacked -# on demand: the build tree is cleared between runs, and a resumed json-c step -# must not rely on a skipped cmake step. -prebuilt_cmake() { - local dir="${BUILDDIR}/cmake-${V_CMAKE}-linux-x86_64" - if [[ ! -x "${dir}/bin/cmake" ]]; then - rm -rf "$dir" - tar -xf "${KRYPTIK_SOURCES}/cmake-${V_CMAKE}-linux-x86_64.tar.gz" -C "$BUILDDIR" - fi - [[ -x "${dir}/bin/cmake" ]] || return 1 - "${dir}/bin/cmake" --version > /dev/null 2>&1 || return 1 - printf '%s' "${dir}/bin/cmake" -} - -# --- recipes ---------------------------------------------------------------- -# One file per step under build/recipes, sourced here. The build order is the -# list below; a step's fingerprint is its function's text, wherever it lives. -for recipe in "$(dirname "${BASH_SOURCE[0]}")/../recipes/"*.sh; do - # shellcheck source=/dev/null - source "$recipe" -done +# --- recipes, and the flags and helpers they build with (shared with the image stage) --- +source "$(dirname "${BASH_SOURCE[0]}")/../lib/recipes.sh" # --- build order: by dependency, not alphabetical ---------------------------- PACKAGES=( diff --git a/build/stages/04-images.sh b/build/stages/04-images.sh new file mode 100755 index 00000000..d646d9ca --- /dev/null +++ b/build/stages/04-images.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# Software images (docs/design/software-delivery.md): each image's recipes built in the chroot over the +# finished base system, through an overlay whose upper directory keeps what they install. Stage 06 +# packs each tree as it packs the root. +# usage: make images (as root, for the overlay; or 04-images.sh NAME... for some of build/images) +# 04-images.sh --inside NAME in the chroot: one image's rows, as stage 04 runs its own + +source "$(dirname "${BASH_SOURCE[0]}")/../lib/common.sh" +load_config + +DEFS="${KRYPTIK_ROOT}/build/images" + +# --- in the chroot: one image's rows ---------------------------------------- + +if [[ "${1:-}" == --inside ]]; then + name="${2:?--inside needs an image name}" + require_inside_chroot "the image stage" + load_hardening + validate_hardening_exceptions + stage_contract "${BASH_SOURCE[0]}" "image-${name}-" gcc + # shellcheck disable=SC2034 # consumed by step() and the recipes + STAMPS="${KRYPTIK_WORK}/.stamps" LOGS="${KRYPTIK_WORK}/logs" BUILDDIR="${KRYPTIK_WORK}/build" + KRYPTIK_JOBS="${KRYPTIK_JOBS:-$(kryptik_default_jobs)}" + # As stage 04 builds: gnulib refuses root without this, and every make runs in parallel. + export FORCE_UNSAFE_CONFIGURE=1 MAKEFLAGS="-j${KRYPTIK_JOBS}" + # The root's index of info manuals is the root's; automake leaves it alone. And a Python tool the + # build runs leaves no bytecode for the root's modules in the image. + export AM_UPDATE_INFO_DIR=no PYTHONDONTWRITEBYTECODE=1 + umask 022 + source "${KRYPTIK_ROOT}/build/lib/recipes.sh" + # shellcheck source=/dev/null + source "${DEFS}/${name}.sh" + # Stage 04's last build step: a rebuilt base system rebuilds every image. + stage_depends_on "bs-" licences + for ((i = 0; i < ${#image_rows[@]}; i += 2)); do + # shellcheck disable=SC2086 # a recipe is a word-split command, as in stage 04 + step "${image_rows[i]}" ${image_rows[i+1]} + done + exit 0 +fi + +# --- on the host: an overlay per image, its rows run inside it --------------- + +[[ "$EUID" -eq 0 ]] || die "the image stage mounts an overlay over the sysroot: run it as root (make images)" +require_outside_chroot "the image stage" +SYSROOT="${KRYPTIK_WORK}/sysroot" +CHROOTD="${KRYPTIK_ROOT}/build/stages/03-chroot-prep.sh" +[[ -f "${KRYPTIK_WORK}/.stamps/bs-licences" ]] || die "stage 04 has not finished: make system first" + +# What an image is built from: its definition and files, the recipes and flags, the pins, and the base system. +image_inputs() { # image_inputs NAME + local f + for f in "${DEFS}/${1}.sh" "${DEFS}/${1}"/* "${KRYPTIK_ROOT}/build/lib/recipes.sh" "${KRYPTIK_ROOT}"/build/recipes/*.sh \ + "${BASH_SOURCE[0]}" "${KRYPTIK_ROOT}/tools/check-image-tree.sh" "${KRYPTIK_ROOT}/build/config/hardening.env" \ + "${KRYPTIK_ROOT}/build/config/hardening-exceptions.txt" "${KRYPTIK_ROOT}/build/config/versions.env" \ + "${KRYPTIK_ROOT}/sources.lock"; do + if [[ -f "$f" ]]; then printf '%s %s\n' "${f#"${KRYPTIK_ROOT}/"}" "$(sha256_of "$f")"; fi + done + printf 'base %s\n' "$(_stamp_read "${KRYPTIK_WORK}/.stamps/bs-licences")" +} + +# The image that is being built, so an exit takes its mounts down with it. +MOUNTED="" +unmount_image() { # unmount_image TOP + env KRYPTIK_WORK="$1" "$CHROOTD" umount > /dev/null 2>&1 || true + if mountpoint -q "$1/sysroot"; then umount "$1/sysroot" || umount -l "$1/sysroot"; fi + # Not grep -q: under pipefail its early exit could fail findmnt and hide a mount. + if findmnt -rn -o TARGET | grep -F "$1/" >&2; then die "still mounted under $1 (above): nothing there is removed"; fi +} +trap '[[ -z "$MOUNTED" ]] || unmount_image "$MOUNTED"' EXIT + +build_image() { # build_image NAME + local name="$1" top="${KRYPTIK_WORK}/image-build/${1}" version want d + [[ "$name" =~ ^[a-z0-9-]{1,32}$ ]] || die "image name ${name}: a-z, 0-9 and -, at most 32" + # shellcheck source=/dev/null + version="$(source "${DEFS}/${name}.sh"; printf '%s' "${image_version:-}")" + [[ "$version" =~ ^[!-~]{1,64}$ ]] || die "image ${name}: version '${version}' is not 1-64 printable characters without a space" + want="$(image_inputs "$name" | sha256_of_stdin)" + if [[ -d "${top}/tree/usr" ]] && grep -qx "fingerprint: ${want}" "${top}/record" 2>/dev/null; then + dim " skip ${name} ${version} (already built, inputs unchanged)" + return 0 + fi + log "image ${name} ${version}" + unmount_image "$top" + rm -rf "$top" + # A fresh upper directory: no row may skip on a stamp from the last one. + rm -f "${KRYPTIK_WORK}/.stamps/image-${name}-"* + mkdir -p "${top}/upper" "${top}/overlay-work" "${top}/sysroot" + # Stage 03 takes the chroot's root from KRYPTIK_WORK, so the overlay is that work tree's sysroot, + # and the real work tree's stamps, logs, build and images are bound into it as usual. + for d in .stamps logs build images; do ln -s "${KRYPTIK_WORK}/${d}" "${top}/${d}"; done + MOUNTED="$top" + mount -t overlay overlay -o "lowerdir=${SYSROOT},upperdir=${top}/upper,workdir=${top}/overlay-work" "${top}/sysroot" + env KRYPTIK_WORK="$top" KRYPTIK_KRYPTIKD_BIN= KRYPTIK_WLPROXY_BIN= \ + "$CHROOTD" run /kryptik/build/stages/04-images.sh --inside "$name" + unmount_image "$top" + MOUNTED="" + "${KRYPTIK_ROOT}/tools/check-image-tree.sh" "$SYSROOT" "${top}/upper" + # Without extended attributes: overlayfs keeps its own on the upper directory. + mkdir -p "${top}/tree" + rsync -aH --numeric-ids "${top}/upper/usr" "${top}/tree/" + rm -rf "${top}/upper" "${top}/overlay-work" + printf 'name: %s\nversion: %s\nfingerprint: %s\n' "$name" "$version" "$want" > "${top}/record" + ok "image ${name} ${version}: $(find "${top}/tree" ! -type d | wc -l) files and links" +} + +names=("$@") +if [[ "${#names[@]}" -eq 0 ]]; then + for d in "${DEFS}"/*.sh; do names+=("$(basename "$d" .sh)"); done +fi +log "Kryptik software images: ${names[*]}" +for n in "${names[@]}"; do + [[ -f "${DEFS}/${n}.sh" ]] || die "no image ${n}: nothing at ${DEFS}/${n}.sh" + build_image "$n" +done +ok "software images built: ${names[*]}" +dim "Next: make media packs each one beside the root (stage 06)" diff --git a/build/stages/06-iso.sh b/build/stages/06-iso.sh index 89cb0e5b..17bf8729 100755 --- a/build/stages/06-iso.sh +++ b/build/stages/06-iso.sh @@ -328,18 +328,123 @@ EOF cat "${IMG}/root.json" } -# make_ext4 TREE IMAGE BYTES UUID: the sysroot's mke2fs (1.47.1), which clamps every time it +# make_ext4 TREE IMAGE BYTES UUID [LABEL]: 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" \ + "$CHROOTD" run /usr/sbin/mke2fs -q -F -t ext4 -L "${5:-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"; } +# pack_image NAME VERSION TREE BASE: IMG/images/NAME.img as the root is packed (ext4, then its dm-verity +# tree at data-blocks*4096), and its line of images.manifest on stdout. +pack_image() { + local name="$1" version="$2" tree="$3" base="$4" + local stage; stage="$(mktemp -d "${IMG}/image-stage.XXXXXX")" + chmod 0755 "$stage" + rsync -aH --numeric-ids "${tree}/" "${stage}/" + strip_root "$stage" >&2 + if find "$stage" -perm /6000 -print | grep . >&2; then echo "FAIL: a setuid or setgid file in image ${name}" >&2; return 1; fi + "${KRYPTIK_ROOT}/tools/check-artifact-hardening.sh" --strict "$stage" >&2 + "${KRYPTIK_ROOT}/tools/check-image-programs.sh" "$stage" --list "${KRYPTIK_ROOT}/build/images/${name}.programs" >&2 + local bytes; bytes="$(du -sb "$stage" | cut -f1)" + local fs_bytes=$(( bytes + bytes / 8 + 16 * 1024 * 1024 )) + fs_bytes=$(( (fs_bytes + 4095) / 4096 * 4096 )) + local img="${IMG}/images/${name}.img" id + # The UUID and directory hash seed, from what the image is and the root it is built for. + id="$(printf 'kryptik-image %s %s %s' "$name" "$version" "$base" | sha256_of_stdin)" + id="${id:0:8}-${id:8:4}-${id:12:4}-${id:16:4}-${id:20:12}" + make_ext4 "$stage" "$img" "$fs_bytes" "$id" "kryptik-${name}" + if [[ -n "${SOURCE_DATE_EPOCH:-}" ]]; then + make_ext4 "$stage" "${img}.again" "$fs_bytes" "$id" "kryptik-${name}" + cmp -s "$img" "${img}.again" || { echo "FAIL: two mke2fs runs over image ${name} wrote different images" >&2; return 1; } + rm -f "${img}.again" + fi + local data_blocks=$(( fs_bytes / 4096 )) + local salt; salt="$(sha256_of "$img")" + local total=$(( fs_bytes + fs_bytes / 64 + 4 * 1024 * 1024 )) + truncate -s $(( (total + 4095) / 4096 * 4096 )) "$img" + veritysetup format --no-superblock --hash=sha256 --data-block-size=4096 --hash-block-size=4096 \ + --data-blocks="$data_blocks" --hash-offset="$fs_bytes" --salt="$salt" \ + --root-hash-file="${stage}.hash" "$img" "$img" >&2 + local root_hash; root_hash="$(tr -d '\n' < "${stage}.hash")" + [[ ${#root_hash} -eq 64 ]] || { echo "image ${name}: bad root hash ${root_hash}" >&2; return 1; } + veritysetup verify --no-superblock --hash=sha256 --data-block-size=4096 --hash-block-size=4096 \ + --data-blocks="$data_blocks" --hash-offset="$fs_bytes" --salt="$salt" "$img" "$img" "$root_hash" >&2 + rm -rf "$stage" "${stage}.hash" + printf 'image: %s %s bytes=%s sha256=%s data-blocks=%s root-hash=%s salt=%s\n' \ + "$name" "$version" "$(stat -c %s "$img")" "$(sha256_of "$img")" "$data_blocks" "$root_hash" "$salt" +} + +# Each software image the image stage built (build/stages/04-images.sh), in IMG/images/ with +# images.manifest naming them for this root: the release, the root's hash as their base, an image line +# each by name, then each image's policy lines (build/images/NAME.needs). sign-images signs it. +s_images() { + local version="$1" base; base="$(root_json root_hash)" + local rec name ver line needs=() images=() + rm -rf "${IMG}/images"; mkdir -p "${IMG}/images" + for rec in "${KRYPTIK_WORK}"/image-build/*/record; do + [[ -f "$rec" ]] || continue + name="$(sed -n 's/^name: //p' "$rec")"; ver="$(sed -n 's/^version: //p' "$rec")" + echo "--- image ${name} ${ver} ---" + images+=("$(pack_image "$name" "$ver" "$(dirname "$rec")/tree" "$base")") + if [[ -f "${KRYPTIK_ROOT}/build/images/${name}.needs" ]]; then + while IFS= read -r line; do + [[ -z "$line" || "$line" == \#* ]] || needs+=("needs: ${name} ${line}") + done < "${KRYPTIK_ROOT}/build/images/${name}.needs" + fi + done + { + printf 'KRYPTIK-IMAGES-1\nrelease: %s\nbase: %s\n' "$version" "$base" + if [[ "${#images[@]}" -gt 0 ]]; then printf '%s\n' "${images[@]}" | LC_ALL=C sort; fi + if [[ "${#needs[@]}" -gt 0 ]]; then printf '%s\n' "${needs[@]}"; fi + } > "${IMG}/images/images.manifest" + echo "--- images.manifest ---" + cat "${IMG}/images/images.manifest" +} + +# images.manifest signed by the release key in kryptik-image, and checked as the guest checks it. A +# development build also signs the two lists the guest must refuse: release-ns, the same list in the +# release manifests' namespace, and other-base, a list for another root. +s_sign_images() { + echo "inputs digest: $1" + local dir="${IMG}/images" m="${IMG}/images/images.manifest" + [[ -f "$m" ]] || { echo "no images.manifest: the images step packed none"; return 1; } + rm -rf "${m}.sig" "${dir}/controls" + # As s_sums: a security key asks on stderr to be touched, and the terminal, when there is one, gets it. + local to=/dev/null; { : > /dev/tty; } 2>/dev/null && to=/dev/tty + ssh-keygen -Y sign -f "$RELEASE_KEY" -n kryptik-image "$m" >/dev/null 2>"$to" \ + || { echo "could not sign ${m}"; return 1; } + ssh-keygen -Y verify -f "$ANCHOR" -I kryptik-release -n kryptik-image -s "${m}.sig" < "$m" \ + || { echo "FAIL: the image's anchor refuses the image list's signature"; return 1; } + if ssh-keygen -Y verify -f "$ANCHOR" -I kryptik-release -n kryptik-release -s "${m}.sig" < "$m" >/dev/null 2>&1; then + echo "FAIL: the image list's signature passes for a release manifest's"; return 1 + fi + if [[ "$ROLE" == development ]]; then + mkdir -p "${dir}/controls/release-ns" "${dir}/controls/other-base" + cp "$m" "${dir}/controls/release-ns/manifest" + ssh-keygen -Y sign -f "$RELEASE_KEY" -n kryptik-release "${dir}/controls/release-ns/manifest" >/dev/null 2>&1 \ + || { echo "could not sign the release-ns control"; return 1; } + sed "s/^base: .*/base: $(printf '0%.0s' {1..64})/" "$m" > "${dir}/controls/other-base/manifest" + ssh-keygen -Y sign -f "$RELEASE_KEY" -n kryptik-image "${dir}/controls/other-base/manifest" >/dev/null 2>&1 \ + || { echo "could not sign the other-base control"; return 1; } + fi + find "$dir" -type f | LC_ALL=C sort +} + +# What s_images reads: each built image's record, the image definitions, and the root it is built for. +images_inputs() { + local f + for f in "${KRYPTIK_WORK}"/image-build/*/record "${KRYPTIK_ROOT}"/build/images/*.* "${KRYPTIK_ROOT}/tools/check-artifact-hardening.sh" \ + "${KRYPTIK_ROOT}/tools/check-image-programs.sh" "${KRYPTIK_ROOT}/build/config/artifact-accepted.txt" "${IMG}/root.json"; do + if [[ -f "$f" ]]; then printf '%s %s\n' "$f" "$(sha256_of "$f")"; fi + done +} + verity_table() { # verity_table : the root image's dm-verity table printf '0 %s verity 1 %s %s 4096 4096 %s %s sha256 %s %s 1 panic_on_corruption' \ "$(root_json data_sectors)" "$1" "$1" "$(root_json data_blocks)" \ @@ -694,11 +799,21 @@ check_bound() { grep -q -a -F -e "$(root_json root_hash)" "${IMG}/kernels/${v}.efi" || die "${v}.efi is not bound to this root" done ok "the bound root trusts ${ANCHOR}, and its four kernels are bound to it" + if [[ -f "${IMG}/images/images.manifest" ]]; then + [[ "$(sed -n 's/^base: //p' "${IMG}/images/images.manifest")" == "$(root_json root_hash)" ]] || die "images.manifest is for another root" + local name sha + while read -r _ name _ _ sha _; do + sha="${sha#sha256=}" + [[ "$(sha256_of "${IMG}/images/${name}.img")" == "$sha" ]] || die "image ${name} changed since images.manifest named it" + done < <(grep '^image: ' "${IMG}/images/images.manifest") + ok "the bound images are the ones images.manifest names" + fi } # --- 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" "${KRYPTIK_ROOT}/build/config/image-programs.txt" "${KRYPTIK_ROOT}/tools/check-image-programs.sh" | sha256_of_stdin)" "$KRYPTIK_CHANNEL" "$ROLE" "$(_hash_file "$ANCHOR")" +bound images s_images "$KRYPTIK_VERSION" "$(images_inputs | sha256_of_stdin)" 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)" @@ -707,6 +822,7 @@ if [[ "$PHASE" == bind ]]; then ok "Stage 06 bound ${KRYPTIK_VERSION} in ${IMG}: KRYPTIK_MEDIA_PHASE=sign signs it" exit 0 fi +step sign-images s_sign_images "$(_hash_file "${IMG}/images/images.manifest")$(_hash_file "$ANCHOR")" step sign-kernels s_sign_kernels "$(cat "${IMG}"/kernels/{slot-a,slot-b,media-usb}.efi | sha256_of_stdin)$(_hash_file "$SB_CERT")" step esp s_esp "$(cat "${IMG}"/kernels/{slot-a,slot-b,media-usb}.signed.efi "${IMG}/root.json" | sha256_of_stdin)" step usb s_usb "$(cat "${IMG}/esp-usb.img" | sha256_of_stdin)$(root_json sha256)" diff --git a/docs/design/software-delivery.md b/docs/design/software-delivery.md index 6a655bad..21b1072a 100644 --- a/docs/design/software-delivery.md +++ b/docs/design/software-delivery.md @@ -184,8 +184,20 @@ up ADR-001 in the zones where the applications run. or a path the root already has fails the step, so the root's files stay the ones verified at boot. The build also writes, per image, the policy lines its programs need, found with `kryptikd seccomp-trace`. -- **Packed.** Stage 06 makes `image-.img` the way it makes the root - (ext4 without a journal, then the verity tree) and a manifest: + + The stage is `build/stages/04-images.sh` (`make images`). An image is a + file under `build/images/` naming its version and its rows, which run + through the same `step()` and hardening flags as stage 04's + (`build/lib/recipes.sh`). `tools/check-image-tree.sh` checks the upper + directory: no whiteout or opaque directory, no file the root has, no + directory of the root's `/usr` with another mode or owner, nothing outside + `/usr`, no device node, setuid or setgid bit, or file capability. The + probe image (`build/images/probe.sh`), one small program that locks its + memory, proves the stage and the zones suite's checks; it ships to no one. +- **Packed.** Stage 06 makes `images/.img` the way it makes the root + (ext4 without a journal, then the verity tree, with a salt and UUID + derived from the content), runs the root's artifact audit and the image's + own program list over it, and writes a manifest, `images/images.manifest`: ```text KRYPTIK-IMAGES-1 @@ -202,7 +214,9 @@ up ADR-001 in the zones where the applications run. root's (SHA-256, 4096-byte blocks, no superblock, the hash tree after `data-blocks`), so every value kryptikd passes to dm-verity is signed. Image lines are sorted by name and a needs line uses the policy-file - syntax; anything else in the file refuses it. + syntax; anything else in the file refuses it. A development build also + signs the two lists the guest must refuse, under `images/controls/`: the + same list in `kryptik-release`, and one for another root. - **Stored** under `/var/lib/kryptik/images//.img`, with the list for each root as `.manifest` and `.manifest.sig`, root only. Zone 0 keeps the generation the committed root uses and, during a diff --git a/tools/check-image-tree.sh b/tools/check-image-tree.sh new file mode 100755 index 00000000..af4144ae --- /dev/null +++ b/tools/check-image-tree.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Fail on what an image's recipes did to the root instead of adding to /usr: the upper directory of the +# overlay they ran on, against the sysroot under it (docs/design/software-delivery.md). +# +# tools/check-image-tree.sh LOWER UPPER +# +# Refused: a whiteout or an opaque or redirected directory (a root path removed or hidden), a file the +# root already has, a directory of the root's under /usr with another mode or owner, a file outside +# /usr, a device node, a setuid or setgid bit, a file capability. The chroot's own scratch, mount points +# and marks are let be. +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 +[[ "$#" -eq 2 ]] || die "usage: check-image-tree.sh LOWER UPPER" +[[ -d "$1" && -d "$2" ]] || die "not directories: $1 $2" +log "What the image's recipes left in $2, against $1" +python3 - "$1" "$2" <<'EOF' +import os, stat, sys +lower, upper = sys.argv[1:3] +# Written by the chroot itself (stage 03), or left by builds; none of it is the image's. +SCRATCH = ("tmp/", "var/tmp/", "root/", "run/", "dev/", "proc/", "sys/") +MOUNTS = {"kryptik", "kryptik-sources", "kryptik-work", "kryptik-kryptikd", "kryptik-wlproxy"} +# Stage 03 writes these on every run: its marker, and the two links it sets again. +MARKS = {"etc/kryptik/inside-chroot", "var/run", "var/lock"} + +def xattr(path, name): + try: + return os.getxattr(path, name, follow_symlinks=False) + except OSError: + return None + +bad, files, dirs = [], 0, 0 +for d, subdirs, names in os.walk(upper): + for entry in sorted(subdirs) + sorted(names): + p = os.path.join(d, entry) + rel = os.path.relpath(p, upper) + top = rel.split("/", 1)[0] + if (rel + "/").startswith(SCRATCH) or top in MOUNTS or rel in MARKS: + continue + st = os.lstat(p) + lp = os.path.join(lower, rel) + try: + lst = os.lstat(lp) + except FileNotFoundError: + lst = None + if stat.S_ISCHR(st.st_mode) and st.st_rdev == 0 or xattr(p, "trusted.overlay.whiteout") is not None: + bad.append(f"removes the root's /{rel}") + continue + if xattr(p, "trusted.overlay.redirect") is not None: + bad.append(f"moves /{rel} within the root") + continue + if stat.S_ISDIR(st.st_mode): + if xattr(p, "trusted.overlay.opaque") == b"y": + bad.append(f"hides what the root has under /{rel}") + elif lst is not None and not stat.S_ISDIR(lst.st_mode): + bad.append(f"replaces the root's /{rel} with a directory") + elif lst is not None and top == "usr" and (stat.S_IMODE(st.st_mode), st.st_uid, st.st_gid) != (stat.S_IMODE(lst.st_mode), lst.st_uid, lst.st_gid): + bad.append(f"changes the root's directory /{rel} " + f"({oct(stat.S_IMODE(lst.st_mode))} {lst.st_uid}:{lst.st_gid} -> {oct(stat.S_IMODE(st.st_mode))} {st.st_uid}:{st.st_gid})") + elif lst is None and top != "usr": + bad.append(f"makes /{rel} outside /usr") + elif top == "usr": + dirs += 1 + continue + if lst is not None: + bad.append(f"replaces the root's /{rel}") + elif top != "usr": + bad.append(f"installs /{rel} outside /usr") + elif not (stat.S_ISREG(st.st_mode) or stat.S_ISLNK(st.st_mode)): + bad.append(f"makes /{rel}, neither a file nor a link") + elif stat.S_ISREG(st.st_mode) and st.st_mode & (stat.S_ISUID | stat.S_ISGID): + bad.append(f"/{rel} is setuid or setgid") + elif xattr(p, "security.capability") is not None: + bad.append(f"/{rel} carries a file capability") + else: + files += 1 +for b in bad: + print(f" REFUSED {b}", file=sys.stderr) +if bad: + sys.exit(f"{len(bad)} change(s) to the root: an image may only add files under /usr") +if not os.path.isdir(os.path.join(upper, "usr")): + sys.exit("the recipes installed nothing under /usr") +print(f" {files} files and links, {dirs} directories under /usr; nothing of the root's replaced, removed or changed") +EOF +rc=$? +[[ "$rc" -eq 0 ]] || exit "$rc" +ok "the image only adds to /usr" diff --git a/tools/tests/check-image-tree.sh b/tools/tests/check-image-tree.sh new file mode 100755 index 00000000..bf28ec9a --- /dev/null +++ b/tools/tests/check-image-tree.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Tests for tools/check-image-tree.sh on a fixture sysroot and the upper directory an overlay left. +set -uo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +CHECK="${ROOT}/tools/check-image-tree.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 +L="$T/lower"; U="$T/upper" +mkdir -p "$L/usr/bin" "$L/usr/share/info" "$L/etc/kryptik" "$L/tmp" +printf 'root\n' > "$L/usr/bin/bash"; chmod 755 "$L/usr/bin/bash" +printf 'index\n' > "$L/usr/share/info/dir" +check() { NO_COLOR=1 bash "$CHECK" "$L" "$U" 2>&1; } +# The upper directory a clean build leaves: new files under /usr, copied-up directories, the chroot's own marks. +fresh() { + rm -rf "$U"; mkdir -p "$U/usr/bin" "$U/usr/share/probe" "$U/etc/kryptik" "$U/tmp/build" "$U/kryptik-work" + printf 'probe\n' > "$U/usr/bin/kryptik-probe"; chmod 755 "$U/usr/bin/kryptik-probe" + printf 'data\n' > "$U/usr/share/probe/README" + ln -s kryptik-probe "$U/usr/bin/probe" + printf 'marker\n' > "$U/etc/kryptik/inside-chroot" + printf 'scratch\n' > "$U/tmp/build/object.o" + chmod --reference="$L/usr/bin" "$U/usr/bin"; chmod --reference="$L/etc/kryptik" "$U/etc/kryptik" + chmod --reference="$L/tmp" "$U/tmp" +} + +fresh +out="$(check)"; rc=$? +[[ "$rc" -eq 0 && "$out" == *"3 files and links"* ]] \ + && ok "new files under /usr pass; the chroot's marker and scratch are let be" || bad "clean tree: rc=$rc: $out" + +fresh; mkdir -p "$U/usr/share/info"; printf 'changed\n' > "$U/usr/share/info/dir" +out="$(check)"; rc=$? +[[ "$rc" -ne 0 && "$out" == *"replaces the root's /usr/share/info/dir"* ]] \ + && ok "a file of the root's, rewritten, fails" || bad "replaced file: rc=$rc: $out" + +fresh; mkdir -p "$U/etc/probe"; printf 'conf\n' > "$U/etc/probe/probe.conf" +out="$(check)"; rc=$? +[[ "$rc" -ne 0 && "$out" == *"makes /etc/probe outside /usr"* && "$out" == *"installs /etc/probe/probe.conf outside /usr"* ]] \ + && ok "a file outside /usr fails, with its directory" || bad "outside /usr: rc=$rc: $out" + +fresh; chmod 4755 "$U/usr/bin/kryptik-probe" +out="$(check)"; rc=$? +[[ "$rc" -ne 0 && "$out" == *"/usr/bin/kryptik-probe is setuid or setgid"* ]] \ + && ok "a setuid bit fails" || bad "setuid: rc=$rc: $out" + +fresh; chmod 0777 "$U/usr/bin" +out="$(check)"; rc=$? +[[ "$rc" -ne 0 && "$out" == *"changes the root's directory /usr/bin"* ]] \ + && ok "a root directory with another mode fails" || bad "directory mode: rc=$rc: $out" + +fresh; rm -rf "$U/usr" +out="$(check)"; rc=$? +[[ "$rc" -ne 0 && "$out" == *"installed nothing under /usr"* ]] \ + && ok "an image with nothing under /usr fails" || bad "empty image: rc=$rc: $out" + +# A whiteout is a character device 0/0, which only root can make. +fresh +if mknod "$U/usr/bin/bash" c 0 0 2>/dev/null; then + out="$(check)"; rc=$? + [[ "$rc" -ne 0 && "$out" == *"removes the root's /usr/bin/bash"* ]] \ + && ok "a whiteout fails" || bad "whiteout: rc=$rc: $out" +else + printf ' SKIP a whiteout fails (making one needs root)\n' +fi + +printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" +[[ "$FAIL" -eq 0 ]] From 17b690ba06424ddd08eda4cd8444817174c06b5b Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 23:09:31 -0700 Subject: [PATCH 003/123] The zones suite stacks the probe image stage 06 signed for this root over a zone's /usr, and holds every refusal to it zones-test puts images-VERSION (the signed list, the probe image and the two controls stage 06 writes beside the medium) on a disk labelled kryptik-zt-images. The guest keeps them as images.rs reads them, then checks: a zone granted the probe's call runs /usr/bin/kryptik-probe from the image; a zone without the grant is refused for the image's needs line; a zone that names no image does not see it; explain names it; the list signed in kryptik-release and the list for another root are refused; and a flipped block of the probe reads as an I/O error in the zone, after which the next start is refused. --- build/guest-tests/zones-check.sh | 94 ++++++++++++++++++++++++++++++++ tools/image/zones-test.sh | 25 +++++++-- 2 files changed, 114 insertions(+), 5 deletions(-) diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index ea9358b2..e9612f8c 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -1575,6 +1575,100 @@ else fail "microcode-notice" "at boot: ${mc_boot:-none}; after the kernel's line: ${mc_told:-none}" fi +# --- software images over a zone's /usr (images.rs) ------------------------- +# The list stage 06 signed for this root, the probe image it names and its two +# controls, from the disk zones-test attached, kept under the names images.rs +# reads; then zones of this check's own: one granted the probe's call, one not, +# and one that names no image. +IMGS=/var/lib/kryptik/images; IZ=/root/zt/image-zones +img_dev="$(blkid -L kryptik-zt-images 2>/dev/null)" +mkdir -p /run/zt-images "$IZ/policy" +if [[ -z "$img_dev" ]] || ! mount -o ro "$img_dev" /run/zt-images; then + fail "image-store" "no kryptik-zt-images disk holds the signed images: ${img_dev:-none attached}" +else + D=/run/zt-images + this_root="$(sed -n 's/.*sha256 \([0-9a-f]\{64\}\) .*/\1/p' /proc/cmdline)" + img_base="$(sed -n 's/^base: //p' "$D/images.manifest")" + probe_hash="$(sed -n 's/^image: probe .* root-hash=\([0-9a-f]\{64\}\) .*/\1/p' "$D/images.manifest")" + keep_list() { # keep_list DIR FILE: DIR/FILE and its signature, as the list kept for this root + install -D -m 0644 "$1/$2" "$IMGS/$img_base.manifest" && install -m 0644 "$1/$2.sig" "$IMGS/$img_base.manifest.sig" + } + if [[ -n "$this_root" && "$img_base" == "$this_root" && -n "$probe_hash" ]] && keep_list "$D" images.manifest \ + && install -D -m 0644 "$D/probe.img" "$IMGS/probe/$probe_hash.img"; then + pass "image-store" "the list for this root (${img_base:0:12}...) and the probe image (${probe_hash:0:12}...) kept" + else + fail "image-store" "list base ${img_base:-none}, probe ${probe_hash:-none}; this root ${this_root:-unknown}" + fi + izone() { # izone NAME UID_BASE COLOUR [IMAGES] [POLICY] + { + printf '[zone]\nname = "%s"\ndescription = "software image check"\n' "$1" + printf '[network]\nmode = "none"\n[storage]\nmode = "ephemeral"\nsize = "64M"\n' + [[ -z "${5:-}" ]] || printf '[policy]\nseccomp = "%s"\n' "$5" + [[ -z "${4:-}" ]] || printf '[software]\nimages = "%s"\n' "$4" + printf '[identity]\nuid_base = %s\n[ui]\nborder_color = "%s"\n' "$2" "$3" + } > "$IZ/$1.toml" + } + printf 'allow-syscall mlockall\n' > "$IZ/policy/imgok.seccomp" + izone imgok 3932160 '#0a1b2c' probe policy/imgok.seccomp + izone imgbare 3997696 '#0a1b3c' probe + izone imgnone 4063232 '#0a1b4c' + irun() { # irun ZONE CMD...: sets ZRC, ZOUT and ZERR + local zone="$1"; shift + timeout -k 5 60 "$KD" run "$zone" --zones "$IZ" --rootfs "$R" -- "$@" > "$LOG/$zone.out" 2> "$LOG/$zone.err" + ZRC=$?; ZOUT="$(cat "$LOG/$zone.out")"; ZERR="$(cat "$LOG/$zone.err")" + } + irun imgok /usr/bin/kryptik-probe + if [[ "$ZRC" -eq 0 && "$ZOUT" == "kryptik-probe 1.0, from the probe image: /usr/bin/kryptik-probe (mlockall: "* ]]; then + pass "image-runs" "a zone granted the probe's call runs it from the image over its /usr: $ZOUT" + else + fail "image-runs" "rc ${ZRC}: ${ZOUT:-no output}; $(tail -3 <<<"$ZERR" | tr '\n' ' ')" + fi + irun imgbare /usr/bin/kryptik-probe + if [[ "$ZRC" -ne 0 && -z "$ZOUT" && "$ZERR" == *'needs `allow-syscall mlockall`'* ]]; then + pass "image-needs-refused" "a zone whose policy does not grant the image's call is refused before it starts" + else + fail "image-needs-refused" "rc ${ZRC}: ${ZOUT:-no output}; $(tail -2 <<<"$ZERR" | tr '\n' ' ')" + fi + irun imgnone sh -c 'test -e /usr/bin/kryptik-probe && echo PRESENT || echo ABSENT' + [[ "$ZOUT" == ABSENT ]] && pass "image-hidden" "a zone that names no image does not see the probe" \ + || fail "image-hidden" "rc ${ZRC}: ${ZOUT:-no output}; $(tail -2 <<<"$ZERR" | tr '\n' ' ')" + expl="$("$KD" explain imgok --zones "$IZ" 2>&1)" + [[ "$expl" == *"/usr (with the images probe over it, signed for this root and opened with dm-verity)"* ]] \ + && pass "image-explained" "explain names the image over the zone's /usr" \ + || fail "image-explained" "$(grep -m1 '^root ' <<<"$expl")" + # The controls stage 06 signed: a list in the release manifests' namespace, and one for another root. + keep_list "$D/controls/release-ns" manifest && irun imgok /usr/bin/kryptik-probe + if [[ "$ZRC" -ne 0 && "$ZERR" == *"the image list for this root is refused"* ]]; then + pass "image-list-namespace" "a list the release key signed in kryptik-release is refused" + else + fail "image-list-namespace" "rc ${ZRC}: ${ZOUT:-no output}; $(tail -2 <<<"$ZERR" | tr '\n' ' ')" + fi + keep_list "$D/controls/other-base" manifest && irun imgok /usr/bin/kryptik-probe + if [[ "$ZRC" -ne 0 && "$ZERR" == *"is for another root"* ]]; then + pass "image-list-other-base" "a signed list for another root is refused" + else + fail "image-list-other-base" "rc ${ZRC}: ${ZOUT:-no output}; $(tail -2 <<<"$ZERR" | tr '\n' ' ')" + fi + keep_list "$D" images.manifest + # A byte of the probe program flipped in the stored image: with the cache dropped, dm-verity + # turns the read into an I/O error in the zone, and the image is refused from then on. + blk="$(debugfs -R 'bmap /usr/bin/kryptik-probe 0' "$IMGS/probe/$probe_hash.img" 2>/dev/null | tail -1)" + if [[ "$blk" =~ ^[0-9]+$ ]] && printf '\xa5' | dd of="$IMGS/probe/$probe_hash.img" bs=1 seek=$(( blk * 4096 + 64 )) conv=notrunc,fsync status=none; then + echo 3 > /proc/sys/vm/drop_caches + irun imgok /usr/bin/kryptik-probe + rc1="$ZRC"; said1="$ZOUT $ZERR" + irun imgok /usr/bin/kryptik-probe + if [[ "$rc1" -ne 0 && "$said1" == *"Input/output error"* && "$ZRC" -ne 0 && "$ZERR" == *"read back other than signed"* ]]; then + pass "image-corrupt" "a flipped block reads as an I/O error in the zone, and the next start is refused" + else + fail "image-corrupt" "first rc ${rc1}: $(tail -2 <<<"$said1" | tr '\n' ' '); then rc ${ZRC}: $(tail -2 <<<"$ZERR" | tr '\n' ' ')" + fi + else + fail "image-corrupt" "could not find or flip the probe's first block (${blk:-none})" + fi + umount /run/zt-images +fi + echo "ZT SUMMARY passed=$PASS failed=$FAIL" echo "ZT END" [[ "$FAIL" -eq 0 ]] diff --git a/tools/image/zones-test.sh b/tools/image/zones-test.sh index 4aebbda3..0fa663cb 100755 --- a/tools/image/zones-test.sh +++ b/tools/image/zones-test.sh @@ -10,7 +10,8 @@ # step 2 guest-tests/zones-check.sh: kernel support, the net zone, zone 0 # offline, routed egress and DNS, zone separation, fail-closed # restart, the net zone over a mac80211_hwsim radio, time claims, -# limits, and the LUKS2 volume lifecycle +# limits, the LUKS2 volume lifecycle, and the software images stage +# 06 signed for this root, stacked over a zone's /usr # step 3 the compartment suites' [vm] rows on the target kernel (not with # --skip-suites) # step 4 reboot; the encrypted zone's data is still there @@ -27,12 +28,12 @@ while [[ "$#" -gt 0 ]]; do --disk) DISK="${2:?}"; shift 2 ;; --timeout) TIMEOUT="${2:?}"; shift 2 ;; --skip-suites) SUITES=0; shift ;; - -h|--help) sed -n '2,15p' "${BASH_SOURCE[0]}"; exit 0 ;; + -h|--help) sed -n '2,17p' "${BASH_SOURCE[0]}"; exit 0 ;; *) die "unknown argument: $1" ;; esac done [[ -f "$USB" ]] || die "--usb IMG is required" -for t in python3 sfdisk truncate; do have "$t" || die "required tool not found: $t"; done +for t in python3 sfdisk truncate mkfs.ext4; do have "$t" || die "required tool not found: $t"; done VMDIR="${KRYPTIK_WORK}/vm"; mkdir -p "$VMDIR" DISK="${DISK:-${VMDIR}/zones.img}" [[ -e "$DISK" && ! -f "$DISK" ]] && die "refusing: ${DISK} is not a regular file" @@ -48,8 +49,21 @@ install_disk zones-install "$USB" --vars clean && green "installed" || { red "in # ----------------------------------------------------------------- step 2 -- step "step 2: the guest-side zone, network and storage checks (as root)" +# The software images stage 06 packed and signed for this medium's root, beside it as +# images-VERSION, on a disk of their own: the guest's image checks keep them from there. +MVER="$(basename "$USB")"; MVER="${MVER#kryptik-}"; MVER="${MVER%-usb.img}" +IMAGES="$(dirname "$USB")/images-${MVER}" +IMGDISK="${VMDIR}/zones-images.img" +IMGARGS=() +if [[ -f "${IMAGES}/images.manifest.sig" ]] && rm -f "$IMGDISK" \ + && truncate -s "$(( $(du -sm "$IMAGES" | cut -f1) + 64 ))M" "$IMGDISK" \ + && mkfs.ext4 -q -F -L kryptik-zt-images -d "$IMAGES" "$IMGDISK"; then + IMGARGS=(--disk "$IMGDISK") +else + red "no signed software images for ${MVER} at ${IMAGES}: the guest's image checks have nothing to keep" +fi # 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 +start_vm zones-p2 --net user --mem 3072 "${IMGARGS[@]}" 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" @@ -77,7 +91,8 @@ for name in kernel-support policies net-ready net-dns dhcpcd-separated dnsmasq-u 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 \ - microcode-notice; do + microcode-notice \ + image-store image-runs image-needs-refused image-hidden image-explained image-list-namespace image-list-other-base image-corrupt; do grep -q "ZT PASS ${name}" <<<"$T2" && green "guest: ${name}" || red "guest: ${name} (not passed)" done From d1e8150906caf998074b672da6aefd5f41df96e8 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 23:09:48 -0700 Subject: [PATCH 004/123] Each release's images sit in their own directory, and the image stage calls its stamp contract at the top level A development run builds two releases into one work tree; the second's images step removed the first's list, so the first release's medium had only a list for another root. The chroot half of the image stage now runs at the top level, where the step tests look for each stage's stamp contract. --- build/stages/04-images.sh | 93 ++++++++++++++++---------------- build/stages/06-iso.sh | 24 +++++---- docs/design/software-delivery.md | 14 ++--- 3 files changed, 69 insertions(+), 62 deletions(-) diff --git a/build/stages/04-images.sh b/build/stages/04-images.sh index d646d9ca..6f3bcf65 100755 --- a/build/stages/04-images.sh +++ b/build/stages/04-images.sh @@ -9,43 +9,10 @@ source "$(dirname "${BASH_SOURCE[0]}")/../lib/common.sh" load_config DEFS="${KRYPTIK_ROOT}/build/images" - -# --- in the chroot: one image's rows ---------------------------------------- - -if [[ "${1:-}" == --inside ]]; then - name="${2:?--inside needs an image name}" - require_inside_chroot "the image stage" - load_hardening - validate_hardening_exceptions - stage_contract "${BASH_SOURCE[0]}" "image-${name}-" gcc - # shellcheck disable=SC2034 # consumed by step() and the recipes - STAMPS="${KRYPTIK_WORK}/.stamps" LOGS="${KRYPTIK_WORK}/logs" BUILDDIR="${KRYPTIK_WORK}/build" - KRYPTIK_JOBS="${KRYPTIK_JOBS:-$(kryptik_default_jobs)}" - # As stage 04 builds: gnulib refuses root without this, and every make runs in parallel. - export FORCE_UNSAFE_CONFIGURE=1 MAKEFLAGS="-j${KRYPTIK_JOBS}" - # The root's index of info manuals is the root's; automake leaves it alone. And a Python tool the - # build runs leaves no bytecode for the root's modules in the image. - export AM_UPDATE_INFO_DIR=no PYTHONDONTWRITEBYTECODE=1 - umask 022 - source "${KRYPTIK_ROOT}/build/lib/recipes.sh" - # shellcheck source=/dev/null - source "${DEFS}/${name}.sh" - # Stage 04's last build step: a rebuilt base system rebuilds every image. - stage_depends_on "bs-" licences - for ((i = 0; i < ${#image_rows[@]}; i += 2)); do - # shellcheck disable=SC2086 # a recipe is a word-split command, as in stage 04 - step "${image_rows[i]}" ${image_rows[i+1]} - done - exit 0 -fi - -# --- on the host: an overlay per image, its rows run inside it --------------- - -[[ "$EUID" -eq 0 ]] || die "the image stage mounts an overlay over the sysroot: run it as root (make images)" -require_outside_chroot "the image stage" SYSROOT="${KRYPTIK_WORK}/sysroot" CHROOTD="${KRYPTIK_ROOT}/build/stages/03-chroot-prep.sh" -[[ -f "${KRYPTIK_WORK}/.stamps/bs-licences" ]] || die "stage 04 has not finished: make system first" + +# --- on the host: an overlay per image, its rows run inside it --------------- # What an image is built from: its definition and files, the recipes and flags, the pins, and the base system. image_inputs() { # image_inputs NAME @@ -67,7 +34,6 @@ unmount_image() { # unmount_image TOP # Not grep -q: under pipefail its early exit could fail findmnt and hide a mount. if findmnt -rn -o TARGET | grep -F "$1/" >&2; then die "still mounted under $1 (above): nothing there is removed"; fi } -trap '[[ -z "$MOUNTED" ]] || unmount_image "$MOUNTED"' EXIT build_image() { # build_image NAME local name="$1" top="${KRYPTIK_WORK}/image-build/${1}" version want d @@ -104,14 +70,51 @@ build_image() { # build_image NAME ok "image ${name} ${version}: $(find "${top}/tree" ! -type d | wc -l) files and links" } -names=("$@") -if [[ "${#names[@]}" -eq 0 ]]; then - for d in "${DEFS}"/*.sh; do names+=("$(basename "$d" .sh)"); done +build_images() { # build_images [NAME...] + [[ "$EUID" -eq 0 ]] || die "the image stage mounts an overlay over the sysroot: run it as root (make images)" + require_outside_chroot "the image stage" + [[ -f "${KRYPTIK_WORK}/.stamps/bs-licences" ]] || die "stage 04 has not finished: make system first" + trap '[[ -z "$MOUNTED" ]] || unmount_image "$MOUNTED"' EXIT + local names=("$@") d n + if [[ "${#names[@]}" -eq 0 ]]; then + for d in "${DEFS}"/*.sh; do names+=("$(basename "$d" .sh)"); done + fi + log "Kryptik software images: ${names[*]}" + for n in "${names[@]}"; do + [[ -f "${DEFS}/${n}.sh" ]] || die "no image ${n}: nothing at ${DEFS}/${n}.sh" + build_image "$n" + done + ok "software images built: ${names[*]}" + dim "Next: make media packs each one beside the root (stage 06)" +} + +if [[ "${1:-}" != --inside ]]; then + build_images "$@" + exit 0 fi -log "Kryptik software images: ${names[*]}" -for n in "${names[@]}"; do - [[ -f "${DEFS}/${n}.sh" ]] || die "no image ${n}: nothing at ${DEFS}/${n}.sh" - build_image "$n" + +# --- in the chroot: one image's rows, as stage 04 runs its own --------------- + +name="${2:?--inside needs an image name}" +require_inside_chroot "the image stage" +load_hardening +validate_hardening_exceptions +stage_contract "${BASH_SOURCE[0]}" "image-${name}-" gcc +# shellcheck disable=SC2034 # consumed by step() and the recipes +STAMPS="${KRYPTIK_WORK}/.stamps" LOGS="${KRYPTIK_WORK}/logs" BUILDDIR="${KRYPTIK_WORK}/build" +KRYPTIK_JOBS="${KRYPTIK_JOBS:-$(kryptik_default_jobs)}" +# As stage 04 builds: gnulib refuses root without this, and every make runs in parallel. +export FORCE_UNSAFE_CONFIGURE=1 MAKEFLAGS="-j${KRYPTIK_JOBS}" +# The root's index of info manuals is the root's; automake leaves it alone. And a Python tool the +# build runs leaves no bytecode for the root's modules in the image. +export AM_UPDATE_INFO_DIR=no PYTHONDONTWRITEBYTECODE=1 +umask 022 +source "${KRYPTIK_ROOT}/build/lib/recipes.sh" +# shellcheck source=/dev/null +source "${DEFS}/${name}.sh" +# Stage 04's last build step: a rebuilt base system rebuilds every image. +stage_depends_on "bs-" licences +for ((i = 0; i < ${#image_rows[@]}; i += 2)); do + # shellcheck disable=SC2086 # a recipe is a word-split command, as in stage 04 + step "${image_rows[i]}" ${image_rows[i+1]} done -ok "software images built: ${names[*]}" -dim "Next: make media packs each one beside the root (stage 06)" diff --git a/build/stages/06-iso.sh b/build/stages/06-iso.sh index 17bf8729..9a9540be 100755 --- a/build/stages/06-iso.sh +++ b/build/stages/06-iso.sh @@ -48,6 +48,8 @@ KRYPTIK_VERSION="${KRYPTIK_VERSION:-0.1.$(date +%Y%m%d).$(git -c safe.directory= COMMIT="$(git -c safe.directory="$TOP" -C "$TOP" describe --always --dirty --abbrev=40 2>/dev/null || echo unknown)" ESP_MIB=512 export KRYPTIK_VERSION +# This release's software images, beside its payload and channel: two releases' media can share IMG. +IMAGES="${IMG}/images-${KRYPTIK_VERSION}" # Where the image's net zone asks for releases (docs/design/update-channel.md). # Empty, the image names no channel and fetches nothing. KRYPTIK_CHANNEL="${KRYPTIK_CHANNEL:-}" @@ -354,7 +356,7 @@ pack_image() { local bytes; bytes="$(du -sb "$stage" | cut -f1)" local fs_bytes=$(( bytes + bytes / 8 + 16 * 1024 * 1024 )) fs_bytes=$(( (fs_bytes + 4095) / 4096 * 4096 )) - local img="${IMG}/images/${name}.img" id + local img="${IMAGES}/${name}.img" id # The UUID and directory hash seed, from what the image is and the root it is built for. id="$(printf 'kryptik-image %s %s %s' "$name" "$version" "$base" | sha256_of_stdin)" id="${id:0:8}-${id:8:4}-${id:12:4}-${id:16:4}-${id:20:12}" @@ -380,13 +382,13 @@ pack_image() { "$name" "$version" "$(stat -c %s "$img")" "$(sha256_of "$img")" "$data_blocks" "$root_hash" "$salt" } -# Each software image the image stage built (build/stages/04-images.sh), in IMG/images/ with +# Each software image the image stage built (build/stages/04-images.sh), in IMAGES with # images.manifest naming them for this root: the release, the root's hash as their base, an image line # each by name, then each image's policy lines (build/images/NAME.needs). sign-images signs it. s_images() { local version="$1" base; base="$(root_json root_hash)" local rec name ver line needs=() images=() - rm -rf "${IMG}/images"; mkdir -p "${IMG}/images" + rm -rf "$IMAGES"; mkdir -p "$IMAGES" for rec in "${KRYPTIK_WORK}"/image-build/*/record; do [[ -f "$rec" ]] || continue name="$(sed -n 's/^name: //p' "$rec")"; ver="$(sed -n 's/^version: //p' "$rec")" @@ -402,9 +404,9 @@ s_images() { printf 'KRYPTIK-IMAGES-1\nrelease: %s\nbase: %s\n' "$version" "$base" if [[ "${#images[@]}" -gt 0 ]]; then printf '%s\n' "${images[@]}" | LC_ALL=C sort; fi if [[ "${#needs[@]}" -gt 0 ]]; then printf '%s\n' "${needs[@]}"; fi - } > "${IMG}/images/images.manifest" + } > "${IMAGES}/images.manifest" echo "--- images.manifest ---" - cat "${IMG}/images/images.manifest" + cat "${IMAGES}/images.manifest" } # images.manifest signed by the release key in kryptik-image, and checked as the guest checks it. A @@ -412,7 +414,7 @@ s_images() { # release manifests' namespace, and other-base, a list for another root. s_sign_images() { echo "inputs digest: $1" - local dir="${IMG}/images" m="${IMG}/images/images.manifest" + local dir="$IMAGES" m="${IMAGES}/images.manifest" [[ -f "$m" ]] || { echo "no images.manifest: the images step packed none"; return 1; } rm -rf "${m}.sig" "${dir}/controls" # As s_sums: a security key asks on stderr to be touched, and the terminal, when there is one, gets it. @@ -799,13 +801,13 @@ check_bound() { grep -q -a -F -e "$(root_json root_hash)" "${IMG}/kernels/${v}.efi" || die "${v}.efi is not bound to this root" done ok "the bound root trusts ${ANCHOR}, and its four kernels are bound to it" - if [[ -f "${IMG}/images/images.manifest" ]]; then - [[ "$(sed -n 's/^base: //p' "${IMG}/images/images.manifest")" == "$(root_json root_hash)" ]] || die "images.manifest is for another root" + if [[ -f "${IMAGES}/images.manifest" ]]; then + [[ "$(sed -n 's/^base: //p' "${IMAGES}/images.manifest")" == "$(root_json root_hash)" ]] || die "images.manifest is for another root" local name sha while read -r _ name _ _ sha _; do sha="${sha#sha256=}" - [[ "$(sha256_of "${IMG}/images/${name}.img")" == "$sha" ]] || die "image ${name} changed since images.manifest named it" - done < <(grep '^image: ' "${IMG}/images/images.manifest") + [[ "$(sha256_of "${IMAGES}/${name}.img")" == "$sha" ]] || die "image ${name} changed since images.manifest named it" + done < <(grep '^image: ' "${IMAGES}/images.manifest") ok "the bound images are the ones images.manifest names" fi } @@ -822,7 +824,7 @@ if [[ "$PHASE" == bind ]]; then ok "Stage 06 bound ${KRYPTIK_VERSION} in ${IMG}: KRYPTIK_MEDIA_PHASE=sign signs it" exit 0 fi -step sign-images s_sign_images "$(_hash_file "${IMG}/images/images.manifest")$(_hash_file "$ANCHOR")" +step sign-images s_sign_images "$(_hash_file "${IMAGES}/images.manifest")$(_hash_file "$ANCHOR")" step sign-kernels s_sign_kernels "$(cat "${IMG}"/kernels/{slot-a,slot-b,media-usb}.efi | sha256_of_stdin)$(_hash_file "$SB_CERT")" step esp s_esp "$(cat "${IMG}"/kernels/{slot-a,slot-b,media-usb}.signed.efi "${IMG}/root.json" | sha256_of_stdin)" step usb s_usb "$(cat "${IMG}/esp-usb.img" | sha256_of_stdin)$(root_json sha256)" diff --git a/docs/design/software-delivery.md b/docs/design/software-delivery.md index 21b1072a..fe82806a 100644 --- a/docs/design/software-delivery.md +++ b/docs/design/software-delivery.md @@ -194,10 +194,11 @@ up ADR-001 in the zones where the applications run. `/usr`, no device node, setuid or setgid bit, or file capability. The probe image (`build/images/probe.sh`), one small program that locks its memory, proves the stage and the zones suite's checks; it ships to no one. -- **Packed.** Stage 06 makes `images/.img` the way it makes the root - (ext4 without a journal, then the verity tree, with a salt and UUID - derived from the content), runs the root's artifact audit and the image's - own program list over it, and writes a manifest, `images/images.manifest`: +- **Packed.** Stage 06 makes `images-/.img` the way it + makes the root (ext4 without a journal, then the verity tree, with a + salt and UUID derived from the content), runs the root's artifact audit + and the image's own program list over it, and writes a manifest, + `images-/images.manifest`: ```text KRYPTIK-IMAGES-1 @@ -215,8 +216,9 @@ up ADR-001 in the zones where the applications run. `data-blocks`), so every value kryptikd passes to dm-verity is signed. Image lines are sorted by name and a needs line uses the policy-file syntax; anything else in the file refuses it. A development build also - signs the two lists the guest must refuse, under `images/controls/`: the - same list in `kryptik-release`, and one for another root. + signs the two lists the guest must refuse, under + `images-/controls/`: the same list in `kryptik-release`, and + one for another root. - **Stored** under `/var/lib/kryptik/images//.img`, with the list for each root as `.manifest` and `.manifest.sig`, root only. Zone 0 keeps the generation the committed root uses and, during a From 9ea7c694f9016c1fc32a0a1fee02b6db24057ee8 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 23:13:11 -0700 Subject: [PATCH 005/123] An image's link that leads out of /usr is refused, and a build with no image signs no list A link resolves in the zone's own view, where outside /usr is the zone's writable files rather than the image's verified ones. kryptikd refuses a list that names no image, so stage 06 writes none. --- build/stages/06-iso.sh | 12 +++++++++--- docs/design/software-delivery.md | 3 ++- tools/check-image-tree.sh | 11 +++++++++-- tools/tests/check-image-tree.sh | 14 ++++++++++++++ 4 files changed, 34 insertions(+), 6 deletions(-) diff --git a/build/stages/06-iso.sh b/build/stages/06-iso.sh index 9a9540be..a5cfd396 100755 --- a/build/stages/06-iso.sh +++ b/build/stages/06-iso.sh @@ -384,7 +384,8 @@ pack_image() { # Each software image the image stage built (build/stages/04-images.sh), in IMAGES with # images.manifest naming them for this root: the release, the root's hash as their base, an image line -# each by name, then each image's policy lines (build/images/NAME.needs). sign-images signs it. +# each by name, then each image's policy lines (build/images/NAME.needs). sign-images signs it. With no +# image built there is no list, as kryptikd refuses one that names none. s_images() { local version="$1" base; base="$(root_json root_hash)" local rec name ver line needs=() images=() @@ -400,9 +401,14 @@ s_images() { done < "${KRYPTIK_ROOT}/build/images/${name}.needs" fi done + if [[ "${#images[@]}" -eq 0 ]]; then + rmdir "$IMAGES" + echo "no software image built (make images): no images.manifest" + return 0 + fi { printf 'KRYPTIK-IMAGES-1\nrelease: %s\nbase: %s\n' "$version" "$base" - if [[ "${#images[@]}" -gt 0 ]]; then printf '%s\n' "${images[@]}" | LC_ALL=C sort; fi + printf '%s\n' "${images[@]}" | LC_ALL=C sort if [[ "${#needs[@]}" -gt 0 ]]; then printf '%s\n' "${needs[@]}"; fi } > "${IMAGES}/images.manifest" echo "--- images.manifest ---" @@ -415,7 +421,7 @@ s_images() { s_sign_images() { echo "inputs digest: $1" local dir="$IMAGES" m="${IMAGES}/images.manifest" - [[ -f "$m" ]] || { echo "no images.manifest: the images step packed none"; return 1; } + [[ -f "$m" ]] || { echo "no images.manifest: no software image to sign"; return 0; } rm -rf "${m}.sig" "${dir}/controls" # As s_sums: a security key asks on stderr to be touched, and the terminal, when there is one, gets it. local to=/dev/null; { : > /dev/tty; } 2>/dev/null && to=/dev/tty diff --git a/docs/design/software-delivery.md b/docs/design/software-delivery.md index fe82806a..ebdac872 100644 --- a/docs/design/software-delivery.md +++ b/docs/design/software-delivery.md @@ -191,7 +191,8 @@ up ADR-001 in the zones where the applications run. (`build/lib/recipes.sh`). `tools/check-image-tree.sh` checks the upper directory: no whiteout or opaque directory, no file the root has, no directory of the root's `/usr` with another mode or owner, nothing outside - `/usr`, no device node, setuid or setgid bit, or file capability. The + `/usr` and no link that leads out of it, no device node, setuid or setgid + bit, or file capability. The probe image (`build/images/probe.sh`), one small program that locks its memory, proves the stage and the zones suite's checks; it ships to no one. - **Packed.** Stage 06 makes `images-/.img` the way it diff --git a/tools/check-image-tree.sh b/tools/check-image-tree.sh index af4144ae..bd9e1e63 100755 --- a/tools/check-image-tree.sh +++ b/tools/check-image-tree.sh @@ -6,8 +6,8 @@ # # Refused: a whiteout or an opaque or redirected directory (a root path removed or hidden), a file the # root already has, a directory of the root's under /usr with another mode or owner, a file outside -# /usr, a device node, a setuid or setgid bit, a file capability. The chroot's own scratch, mount points -# and marks are let be. +# /usr, a link that leads out of /usr, a device node, a setuid or setgid bit, a file capability. The +# chroot's own scratch, mount points and marks are let be. 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 @@ -30,6 +30,11 @@ def xattr(path, name): except OSError: return None +# A link resolves in the zone's view, where outside /usr is the zone's own files, not the image's. +def in_usr(rel, target): + path = os.path.normpath(os.path.join("/" + os.path.dirname(rel), target)) + return path == "/usr" or path.startswith("/usr/") + bad, files, dirs = [], 0, 0 for d, subdirs, names in os.walk(upper): for entry in sorted(subdirs) + sorted(names): @@ -73,6 +78,8 @@ for d, subdirs, names in os.walk(upper): bad.append(f"/{rel} is setuid or setgid") elif xattr(p, "security.capability") is not None: bad.append(f"/{rel} carries a file capability") + elif stat.S_ISLNK(st.st_mode) and not in_usr(rel, os.readlink(p)): + bad.append(f"/{rel} links to {os.readlink(p)}, outside /usr") else: files += 1 for b in bad: diff --git a/tools/tests/check-image-tree.sh b/tools/tests/check-image-tree.sh index bf28ec9a..6be37488 100755 --- a/tools/tests/check-image-tree.sh +++ b/tools/tests/check-image-tree.sh @@ -50,6 +50,20 @@ out="$(check)"; rc=$? [[ "$rc" -ne 0 && "$out" == *"changes the root's directory /usr/bin"* ]] \ && ok "a root directory with another mode fails" || bad "directory mode: rc=$rc: $out" +fresh; ln -s /etc/passwd "$U/usr/bin/passwd-link" +out="$(check)"; rc=$? +[[ "$rc" -ne 0 && "$out" == *"/usr/bin/passwd-link links to /etc/passwd, outside /usr"* ]] \ + && ok "a link to a path outside /usr fails" || bad "absolute link out: rc=$rc: $out" + +fresh; ln -s ../../tmp/probe "$U/usr/bin/climb" +out="$(check)"; rc=$? +[[ "$rc" -ne 0 && "$out" == *"/usr/bin/climb links to ../../tmp/probe, outside /usr"* ]] \ + && ok "a relative link that climbs out of /usr fails" || bad "relative link out: rc=$rc: $out" + +fresh; ln -s /usr/share/probe/README "$U/usr/bin/probe-readme" +out="$(check)"; rc=$? +[[ "$rc" -eq 0 ]] && ok "an absolute link that stays in /usr passes" || bad "absolute link in: rc=$rc: $out" + fresh; rm -rf "$U/usr" out="$(check)"; rc=$? [[ "$rc" -ne 0 && "$out" == *"installed nothing under /usr"* ]] \ From 5f40319a85ac906d3faeb7590ece1b93464f4dbe Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 23:18:17 -0700 Subject: [PATCH 006/123] The image stage and its test pass shellcheck --- build/stages/04-images.sh | 1 + tools/tests/check-image-tree.sh | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/build/stages/04-images.sh b/build/stages/04-images.sh index 6f3bcf65..df473dcd 100755 --- a/build/stages/04-images.sh +++ b/build/stages/04-images.sh @@ -114,6 +114,7 @@ source "${KRYPTIK_ROOT}/build/lib/recipes.sh" source "${DEFS}/${name}.sh" # Stage 04's last build step: a rebuilt base system rebuilds every image. stage_depends_on "bs-" licences +# shellcheck disable=SC2154 # image_rows comes from the image's definition for ((i = 0; i < ${#image_rows[@]}; i += 2)); do # shellcheck disable=SC2086 # a recipe is a word-split command, as in stage 04 step "${image_rows[i]}" ${image_rows[i+1]} diff --git a/tools/tests/check-image-tree.sh b/tools/tests/check-image-tree.sh index 6be37488..4ed2027a 100755 --- a/tools/tests/check-image-tree.sh +++ b/tools/tests/check-image-tree.sh @@ -64,7 +64,7 @@ fresh; ln -s /usr/share/probe/README "$U/usr/bin/probe-readme" out="$(check)"; rc=$? [[ "$rc" -eq 0 ]] && ok "an absolute link that stays in /usr passes" || bad "absolute link in: rc=$rc: $out" -fresh; rm -rf "$U/usr" +fresh; rm -rf "${U:?}/usr" out="$(check)"; rc=$? [[ "$rc" -ne 0 && "$out" == *"installed nothing under /usr"* ]] \ && ok "an image with nothing under /usr fails" || bad "empty image: rc=$rc: $out" From 3b894c84c7696cf6c483d4cbf7326a7dd40c681d Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 23:19:08 -0700 Subject: [PATCH 007/123] The install media carry the release's software images: a kryptik-images partition on the USB, /images on the ISO The USB image's third partition is an ext4 made as the root's is, holding the signed list, its signature and each image; the ISO carries the same files in its own filesystem, which the installer already mounts. The ISO's layout pass grafts a one-block placeholder for the signature, so the root does not move between the passes. A release without images carries neither, and the development controls stay with the build. --- build/stages/06-iso.sh | 95 ++++++++++++++++++++++++-------- docs/design/boot-and-updates.md | 6 +- docs/design/software-delivery.md | 4 ++ 3 files changed, 82 insertions(+), 23 deletions(-) diff --git a/build/stages/06-iso.sh b/build/stages/06-iso.sh index a5cfd396..9c1cb916 100755 --- a/build/stages/06-iso.sh +++ b/build/stages/06-iso.sh @@ -542,39 +542,73 @@ s_esp() { echo "--- ESP (usb) ---"; mdir -i "${IMG}/esp-usb.img" -/ :: | grep -vE '^\s*$' } -make_gpt_image() { # make_gpt_image OUT ESP-IMG ROOT-IMG ROOT-LABEL - local out="$1" esp="$2" root="$3" label="$4" - local esp_bytes root_bytes +# The medium's copy of this release's software images: the list, its signature and each image, not +# the controls a development build signs for the suites. Fails when the build made no list. +medium_images() { # medium_images DIR + [[ -f "${IMAGES}/images.manifest" ]] || return 1 + rm -rf "$1"; mkdir -p "$1"; chmod 0755 "$1" + install -m 0644 "${IMAGES}"/images.manifest* "${IMAGES}"/*.img "$1/" +} + +# make_images_fs DIR OUT: DIR as the ext4 of the USB's kryptik-images partition, made as the root's is. +make_images_fs() { + local bytes; bytes="$(du -sb "$1" | cut -f1)" + local fs_bytes=$(( bytes + bytes / 8 + 16 * 1024 * 1024 )) + fs_bytes=$(( (fs_bytes + 4095) / 4096 * 4096 )) + local id; id="$(printf 'kryptik-images %s %s' "$KRYPTIK_VERSION" "$(root_json root_hash)" | sha256_of_stdin)" + id="${id:0:8}-${id:8:4}-${id:12:4}-${id:16:4}-${id:20:12}" + make_ext4 "$1" "$2" "$fs_bytes" "$id" kryptik-images + if [[ -n "${SOURCE_DATE_EPOCH:-}" ]]; then + make_ext4 "$1" "${2}.again" "$fs_bytes" "$id" kryptik-images + cmp -s "$2" "${2}.again" || { echo "FAIL: two mke2fs runs over the medium's images wrote different filesystems"; return 1; } + rm -f "${2}.again" + fi +} + +make_gpt_image() { # make_gpt_image OUT ESP-IMG ROOT-IMG ROOT-LABEL [IMAGES-FS] + local out="$1" esp="$2" root="$3" label="$4" images="${5:-}" + local esp_bytes root_bytes images_sectors=0 esp_bytes="$(stat -c %s "$esp")"; root_bytes="$(stat -c %s "$root")" local esp_sectors=$(( esp_bytes / 512 )) local root_sectors=$(( (root_bytes + 1048575) / 1048576 * 2048 )) + [[ -z "$images" ]] || images_sectors=$(( ($(stat -c %s "$images") + 1048575) / 1048576 * 2048 )) local esp_start=2048 local root_start=$(( esp_start + esp_sectors )) - local total=$(( root_start + root_sectors + 2048 + 34 )) + local images_start=$(( root_start + root_sectors )) + local total=$(( images_start + images_sectors + 2048 + 34 )) + local table="label: gpt +unit: sectors +start=${esp_start}, size=${esp_sectors}, type=C12A7328-F81F-11D2-BA4B-00A0C93EC93B, name=\"kryptik-esp\" +start=${root_start}, size=${root_sectors}, type=4F68BCE3-E8CD-4DB1-96E7-FBCAF984B709, name=\"${label}\"" + # The release's software images, after the root: the installer copies them into the store. + [[ -z "$images" ]] || table+=" +start=${images_start}, size=${images_sectors}, type=0FC63DAF-8483-4772-8E79-3D69D8477DE4, name=\"kryptik-images\"" rm -f "$out" truncate -s $(( total * 512 )) "$out" - sfdisk --quiet --wipe always "$out" < "${out}.sha256" echo "usb image: $(stat -c %s "$out") bytes ($(du -h "$out" | cut -f1) on disk)" cat "${out}.sha256" } -make_iso() { # make_iso OUT ESP-IMG - local out="$1" esp="$2" +make_iso() { # make_iso OUT ESP-IMG [IMAGES-DIR] + local out="$1" esp="$2" images="${3:-}" local t; t="$(mktemp -d)" cp "$SB_CERT" "$t/kryptik-sb.crt" cp "${IMG}/root.json" "$t/" @@ -585,6 +619,12 @@ appended partition (see root.json); the ISO kernel names its offset. To boot from a USB stick use the USB image instead: this ISO's kernel looks for /dev/sr0. EOF + local graft=(esp.img="$esp" kryptik-sb.crt="$t/kryptik-sb.crt" root.json="$t/root.json" README.txt="$t/README.txt") + if [[ -n "$images" ]]; then + printf '%s\n' "The software images under images/ are named by images.manifest, which the" \ + "release key signs (images.manifest.sig); the installer copies them." >> "$t/README.txt" + graft+=(images="$images") + fi rm -f "$out" # Linux filesystem data, as a GUID: xorriso takes an MBR type byte or a # GUID, not sfdisk's 0x8300 shorthand. @@ -592,8 +632,7 @@ EOF xorriso -as mkisofs -quiet -o "$out" -iso-level 3 -V KRYPTIK -J -R \ -e esp.img -no-emul-boot -isohybrid-gpt-basdat \ -append_partition 2 0FC63DAF-8483-4772-8E79-3D69D8477DE4 "${IMG}/kryptik-root.img" -appended_part_as_gpt \ - -graft-points esp.img="$esp" kryptik-sb.crt="$t/kryptik-sb.crt" root.json="$t/root.json" README.txt="$t/README.txt" \ - 2>&1 | grep -vE '^\s*$' + -graft-points "${graft[@]}" 2>&1 | grep -vE '^\s*$' rc="${PIPESTATUS[0]}" rm -rf "$t" [[ "$rc" -eq 0 ]] || { echo "xorriso exited ${rc}"; return 1; } @@ -607,7 +646,13 @@ s_bind_iso() { echo "inputs digest: $1" echo "--- pass 1: layout (a placeholder ESP of the final size) ---" make_esp "${IMG}/esp-iso.img" slot-a .efi - make_iso "${IMG}/layout.iso" "${IMG}/esp-iso.img" + local images="" + if medium_images "${IMG}/iso-images"; then + images="${IMG}/iso-images" + # Signed at sign time: one 2048-byte block holds the signature, as it holds this. + [[ -f "${images}/images.manifest.sig" ]] || printf 'placeholder\n' > "${images}/images.manifest.sig" + fi + make_iso "${IMG}/layout.iso" "${IMG}/esp-iso.img" ${images:+"$images"} local start; start="$(part2_start "${IMG}/layout.iso")" [[ -n "$start" ]] || { echo "could not read the appended partition's start"; sfdisk -d "${IMG}/layout.iso"; return 1; } echo "appended root partition starts at sector ${start}" @@ -621,7 +666,7 @@ s_bind_iso() { "$total_sectors" "$start" "$(verity_table /dev/dm-0)" "$COMMON_ARGS" > "${IMG}/cmdlines/media-iso.txt" printf '%s\n' "$start" > "${IMG}/iso-root-start" cat "${IMG}/cmdlines/media-iso.txt" - rm -f "${IMG}/layout.iso" "${IMG}/esp-iso.img" + rm -rf "${IMG}/layout.iso" "${IMG}/esp-iso.img" "${IMG}/iso-images" echo "--- bind the ISO kernel ---" bind_in_chroot media-iso } @@ -634,7 +679,13 @@ s_iso() { sign_one media-iso echo "--- pass 2: the real ESP and ISO ---" make_esp "${IMG}/esp-iso.img" media-iso - make_iso "$iso" "${IMG}/esp-iso.img" + local images="" + if medium_images "${IMG}/iso-images"; then + images="${IMG}/iso-images" + [[ -f "${images}/images.manifest.sig" ]] || { echo "images.manifest is not signed"; return 1; } + fi + make_iso "$iso" "${IMG}/esp-iso.img" ${images:+"$images"} + rm -rf "${IMG}/iso-images" local start2; start2="$(part2_start "$iso")" [[ "$start2" == "$start" ]] || { echo "FAIL: the root partition moved from where the ISO kernel was bound (${start} -> ${start2})"; return 1; } echo "ok: root partition start unchanged at ${start2}" @@ -824,7 +875,7 @@ bound rootfs s_rootfs "$KRYPTIK_VERSION" "$(cat "${KRYPTIK_ROOT}/build/co bound images s_images "$KRYPTIK_VERSION" "$(images_inputs | sha256_of_stdin)" 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)" +bound bind-iso s_bind_iso "$(cat "${IMG}/root.json" "$SB_CERT" | sha256_of_stdin)$(_hash_file "${IMAGES}/images.manifest")" if [[ "$PHASE" == bind ]]; then echo ok "Stage 06 bound ${KRYPTIK_VERSION} in ${IMG}: KRYPTIK_MEDIA_PHASE=sign signs it" @@ -833,8 +884,8 @@ fi step sign-images s_sign_images "$(_hash_file "${IMAGES}/images.manifest")$(_hash_file "$ANCHOR")" step sign-kernels s_sign_kernels "$(cat "${IMG}"/kernels/{slot-a,slot-b,media-usb}.efi | sha256_of_stdin)$(_hash_file "$SB_CERT")" step esp s_esp "$(cat "${IMG}"/kernels/{slot-a,slot-b,media-usb}.signed.efi "${IMG}/root.json" | sha256_of_stdin)" -step usb s_usb "$(cat "${IMG}/esp-usb.img" | sha256_of_stdin)$(root_json sha256)" -step iso s_iso "$(cat "${IMG}"/kernels/{slot-a,slot-b}.signed.efi "${IMG}/kernels/media-iso.efi" "${IMG}/root.json" "${IMG}/iso-root-start" | sha256_of_stdin)" +step usb s_usb "$(cat "${IMG}/esp-usb.img" | sha256_of_stdin)$(root_json sha256)$(_hash_file "${IMAGES}/images.manifest")$(_hash_file "${IMAGES}/images.manifest.sig")" +step iso s_iso "$(cat "${IMG}"/kernels/{slot-a,slot-b}.signed.efi "${IMG}/kernels/media-iso.efi" "${IMG}/root.json" "${IMG}/iso-root-start" | sha256_of_stdin)$(_hash_file "${IMAGES}/images.manifest")$(_hash_file "${IMAGES}/images.manifest.sig")" step sums s_sums "$(cat "${IMG}/kryptik-${KRYPTIK_VERSION}"{-usb.img,.iso}.sha256 | sha256_of_stdin)$(_hash_file "$ANCHOR")" step payload s_payload "$(cat "${IMG}"/kernels/{slot-a,slot-b}.signed.efi "${IMG}/root.json" "${KRYPTIK_ROOT}"/tools/release-{manifest,channel}.sh | sha256_of_stdin)" "${LATEST_KEY:+with a statement key}" step export s_export "$(cat "${IMG}"/kryptik-*.sha256 "${IMG}/payload-${KRYPTIK_VERSION}/manifest" "${IMG}/kryptik-${KRYPTIK_VERSION}.SHA256SUMS.sig" "$ANCHOR" | sha256_of_stdin)" diff --git a/docs/design/boot-and-updates.md b/docs/design/boot-and-updates.md index 7b25c829..a32f360c 100644 --- a/docs/design/boot-and-updates.md +++ b/docs/design/boot-and-updates.md @@ -33,7 +33,11 @@ The USB image holds `kryptik-esp` and `kryptik-media`; the ISO holds the ESP as its El Torito image and the root image at a sector offset (a `linear` dm target over `/dev/sr0`, verity on top). The root image is byte-identical on the medium, in slot A and in a payload, so the installer copies bytes and -signs nothing. +signs nothing. A release with +[software images](software-delivery.md) also carries them: the USB image in +a third partition, `kryptik-images`, an ext4 made as the root's is, and the +ISO as files under `/images` in its own filesystem. Either holds the signed +list, its signature and each image. ## The command line diff --git a/docs/design/software-delivery.md b/docs/design/software-delivery.md index ebdac872..81e390dd 100644 --- a/docs/design/software-delivery.md +++ b/docs/design/software-delivery.md @@ -220,6 +220,10 @@ up ADR-001 in the zones where the applications run. signs the two lists the guest must refuse, under `images-/controls/`: the same list in `kryptik-release`, and one for another root. +- **On the medium.** The USB image carries the list, its signature and each + image in a third partition, `kryptik-images`, an ext4 made as the root's + is; the ISO carries the same files under `/images` in its filesystem. A + release without images has neither. The controls stay with the build. - **Stored** under `/var/lib/kryptik/images//.img`, with the list for each root as `.manifest` and `.manifest.sig`, root only. Zone 0 keeps the generation the committed root uses and, during a From 6a30e0d64e3432848c7814164a97e1c96d38d2e8 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 23:19:48 -0700 Subject: [PATCH 008/123] A release's update payload carries its software images, and apply keeps them in the store for the root it installs Stage 06 puts the signed images.manifest, its signature and each image as NAME.img.zst into payload-VERSION, listed in the release manifest, so the channel fetches them with the rest. Before it writes the slot, kryptik-update apply copies the list from the payload and checks it against the manifest's hash, requires its base to be the root the payload carries, and decompresses each image into /var/lib/kryptik/images/NAME/ROOT-HASH.img, checking the copy against the size and hash the list signs before it takes its name. Once the trial is armed, the store keeps the lists of the roots in the two slots and the images they name. The update suite runs the probe from B's image once B is committed, and again after A was applied to the other slot and B rolled back to; tools/tests/update-images.sh holds the staging's refusals and the pruning to it. --- build/config/image-programs.txt | 1 + build/guest-tests/image-probe.sh | 33 ++++++++ build/stages/06-iso.sh | 15 +++- docs/design/boot-and-updates.md | 21 +++-- docs/design/delta-updates.md | 5 +- docs/design/software-delivery.md | 20 +++-- docs/user-guide.md | 6 +- tools/image/update-test.sh | 8 ++ tools/tests/update-images.sh | 130 +++++++++++++++++++++++++++++++ tools/update/kryptik-update | 78 ++++++++++++++++++- 10 files changed, 297 insertions(+), 20 deletions(-) create mode 100755 build/guest-tests/image-probe.sh create mode 100755 tools/tests/update-images.sh diff --git a/build/config/image-programs.txt b/build/config/image-programs.txt index f20894a9..dcfc4d46 100644 --- a/build/config/image-programs.txt +++ b/build/config/image-programs.txt @@ -735,6 +735,7 @@ usr/lib/kryptik/compartments/tests/serve.sh usr/lib/kryptik/guest-tests/broker-client.py usr/lib/kryptik/guest-tests/gui-check.sh usr/lib/kryptik/guest-tests/icmp-echo.py +usr/lib/kryptik/guest-tests/image-probe.sh 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 diff --git a/build/guest-tests/image-probe.sh b/build/guest-tests/image-probe.sh new file mode 100755 index 00000000..13adfe60 --- /dev/null +++ b/build/guest-tests/image-probe.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# For the update suite, as root in the guest: a zone granted the probe image's call runs +# /usr/bin/kryptik-probe from the image the running root's list names, wherever that list came from. +# Prints one line: "IMAGE-PROBE ", or "IMAGE-PROBE FAILED ". +set -u +d=/root/zt/image-probe +mkdir -p "$d/policy" +printf 'allow-syscall mlockall\n' > "$d/policy/probe.seccomp" +cat > "$d/upimg.toml" <<'EOF' +[zone] +name = "upimg" +description = "software image check" +[network] +mode = "none" +[storage] +mode = "ephemeral" +size = "64M" +[policy] +seccomp = "policy/probe.seccomp" +[software] +images = "probe" +[identity] +uid_base = 4128768 +[ui] +border_color = "#0a1b5c" +EOF +out="$(timeout -k 5 60 /usr/bin/kryptikd run upimg --zones "$d" --rootfs /var/lib/kryptik/zones -- /usr/bin/kryptik-probe 2>&1)" +line="$(grep -m1 '^kryptik-probe 1.0, from the probe image: ' <<<"$out")" +if [[ -n "$line" ]]; then + echo "IMAGE-PROBE $line" +else + echo "IMAGE-PROBE FAILED $(tail -3 <<<"$out" | tr '\n' ' ')" +fi diff --git a/build/stages/06-iso.sh b/build/stages/06-iso.sh index a5cfd396..ae8f6042 100755 --- a/build/stages/06-iso.sh +++ b/build/stages/06-iso.sh @@ -686,9 +686,20 @@ s_payload() { 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/" + # The release's software images, signed for this root and each compressed for the trip as the + # root image is: apply decompresses them into the state partition's store. + local images=() img + if [[ -f "${IMAGES}/images.manifest.sig" ]]; then + cp "${IMAGES}/images.manifest" "${IMAGES}/images.manifest.sig" "$out/" + images=(images.manifest images.manifest.sig) + for img in "${IMAGES}"/*.img; do + zstd -q -T0 -19 "$img" -o "$out/$(basename "$img").zst" || { echo "could not compress ${img}"; return 1; } + images+=("$(basename "$img").zst") + done + fi "${KRYPTIK_ROOT}/tools/release-manifest.sh" create --out "$out/manifest" --name kryptik \ --version "$KRYPTIK_VERSION" --role "$ROLE" --root "$out" \ - kryptik-root.img.zst kryptik-a.efi kryptik-b.efi root.json + kryptik-root.img.zst kryptik-a.efi kryptik-b.efi root.json "${images[@]}" "${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 \ @@ -836,7 +847,7 @@ step esp s_esp "$(cat "${IMG}"/kernels/{slot-a,slot-b,media-usb}.sign step usb s_usb "$(cat "${IMG}/esp-usb.img" | sha256_of_stdin)$(root_json sha256)" step iso s_iso "$(cat "${IMG}"/kernels/{slot-a,slot-b}.signed.efi "${IMG}/kernels/media-iso.efi" "${IMG}/root.json" "${IMG}/iso-root-start" | sha256_of_stdin)" step sums s_sums "$(cat "${IMG}/kryptik-${KRYPTIK_VERSION}"{-usb.img,.iso}.sha256 | sha256_of_stdin)$(_hash_file "$ANCHOR")" -step payload s_payload "$(cat "${IMG}"/kernels/{slot-a,slot-b}.signed.efi "${IMG}/root.json" "${KRYPTIK_ROOT}"/tools/release-{manifest,channel}.sh | sha256_of_stdin)" "${LATEST_KEY:+with a statement key}" +step payload s_payload "$(cat "${IMG}"/kernels/{slot-a,slot-b}.signed.efi "${IMG}/root.json" "${KRYPTIK_ROOT}"/tools/release-{manifest,channel}.sh | sha256_of_stdin)$(_hash_file "${IMAGES}/images.manifest.sig")" "${LATEST_KEY:+with a statement key}" step export s_export "$(cat "${IMG}"/kryptik-*.sha256 "${IMG}/payload-${KRYPTIK_VERSION}/manifest" "${IMG}/kryptik-${KRYPTIK_VERSION}.SHA256SUMS.sig" "$ANCHOR" | sha256_of_stdin)" echo ok "Stage 06 finished: ${KRYPTIK_OUT}/kryptik-${KRYPTIK_VERSION}" diff --git a/docs/design/boot-and-updates.md b/docs/design/boot-and-updates.md index 7b25c829..a3834b3a 100644 --- a/docs/design/boot-and-updates.md +++ b/docs/design/boot-and-updates.md @@ -130,14 +130,19 @@ entry left alone. ## Updates 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 -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. +`kryptik-b.efi`, `root.json`, `manifest` and `manifest.sig`, and the +release's software images if it has any: `images.manifest`, its signature +and each image as `NAME.img.zst` ([how software reaches +zones](software-delivery.md)). 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 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. Each software image is +decompressed into the state partition's store, and checked there against +its list, before the slot is written. Versions are ordered as `sort -V` orders them. A production release is numbered MAJOR.MINOR.PATCH, which that order reads as a person does ([release keys](../release-keys.md)). The trust anchor and required role are on the verified root in diff --git a/docs/design/delta-updates.md b/docs/design/delta-updates.md index ea049c7a..0b2c7b91 100644 --- a/docs/design/delta-updates.md +++ b/docs/design/delta-updates.md @@ -10,8 +10,9 @@ whether it is worth doing at today's release sizes. ## Where things stand - **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 + `root.json`, `manifest` and `manifest.sig`, and the release's software + images if it has any. 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)). - **The channel moves whole files.** `update-put` takes the manifest and its signature first. Then it takes only listed files, each from the byte zone 0 diff --git a/docs/design/software-delivery.md b/docs/design/software-delivery.md index ebdac872..cc962930 100644 --- a/docs/design/software-delivery.md +++ b/docs/design/software-delivery.md @@ -222,11 +222,21 @@ up ADR-001 in the zones where the applications run. one for another root. - **Stored** under `/var/lib/kryptik/images//.img`, with the list for each root as `.manifest` and `.manifest.sig`, root - only. Zone 0 keeps the generation the committed root uses and, during a - trial, the one the trial root uses. -- **Fetched** by the same verbs as a release. The statement of what is - current gains one line per image for each base it serves; zone 0 fetches - only images whose hash it does not hold, never past the signed size. + only. Zone 0 keeps the lists of the roots in the two slots and the images + they name, so a rollback finds its images; an image both lists name is + kept once. +- **Shipped** with each release. Its payload carries `images.manifest`, the + signature and each image as `NAME.img.zst`, compressed for the trip as the + root is, all listed in the release manifest, so the channel fetches them + with the rest. `kryptik-update apply` checks, before it writes the slot, + that the list is for the root the payload carries, and decompresses each + image into the store, where the copy is checked against the size and hash + the list signs before it takes its name. Once the trial is armed, the + store drops the release the slot held. +- **Fetched** alone for an image-only fix, by the same verbs as a release. + The statement of what is current gains one line per image for each base + it serves; zone 0 fetches only images whose hash it does not hold, never + past the signed size. - **Mounted** by kryptikd on first use: a read-only loop device, dm-verity with the manifest's root hash, and ext4 mounted read-only, `nosuid,nodev` under `/run/kryptik/images//`, one directory per root hash. Each diff --git a/docs/user-guide.md b/docs/user-guide.md index 8665a84c..118fbe9f 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -394,9 +394,11 @@ the clock". A release brought by hand is a signed payload directory holding exactly `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 +compressed), `kryptik-a.efi`, `kryptik-b.efi` and `root.json`, and the +release's software images if it has any (`images.manifest`, its signature +and `NAME.img.zst` files): 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 +carries. Bring it onto the state partition, for example under `/var/lib/kryptik/updates/`, and as root (the administration login, then `su`): diff --git a/tools/image/update-test.sh b/tools/image/update-test.sh index 2832d52d..f3897ca2 100755 --- a/tools/image/update-test.sh +++ b/tools/image/update-test.sh @@ -194,6 +194,7 @@ drive "expect:KRYPTIK_SMOKE: END" "login:${TUSER}:${TPASS}" \ "grab:v2:grep ^VERSION_ID= /etc/os-release; cat /run/kryptik/boot-identity; cat /var/lib/kryptik/boot/last-result" \ "run:test \"\$(cat /home/${TUSER}/marker)\" = before-update" \ "$(ROOTSH 'sha256sum -c /root/work.sha && kryptik-update status && echo B-OK')" "expect:B-OK" \ + "$(ROOTSH 'bash /usr/lib/kryptik/guest-tests/image-probe.sh')" "expect:IMAGE-PROBE " \ "$(ROOTSH 'kryptikd time status')" \ "$(ROOTSH 'poweroff')" "expect:Power down" "wait-exit" rc=$?; stop_vm @@ -203,6 +204,9 @@ txt | grep -q "KRYPTIK_SMOKE: boot_identity=slot=b" && green "booted slot b" || txt | grep -q "version_id=${VB}" && green "guest reports version ${VB}" || red "guest did not report ${VB}" txt | grep -q "boot-success: committed: BOOTX64.EFI is now slot b" && green "boot-success committed slot b" || red "no commit of slot b" txt | grep -q "committed slot: b" && green "status shows committed slot b" || red "committed slot is not b" +txt | grep -q 'IMAGE-PROBE kryptik-probe 1.0, from the probe image: ' \ + && green "a zone on ${VB} runs the probe from the image apply kept with it" \ + || red "the probe image did not run on ${VB}: $(txt | grep -m1 'IMAGE-PROBE')" txt | grep -qF "floor ${B_FLOOR} UTC (release ${VB}, the newest this machine committed to" \ && green "the clock's floor is ${VB}'s signed date (${B_FLOOR} UTC)" || red "after the commit of ${VB} the floor is not its date: $(txt | grep -E '(floor|release) ' | tail -2 | tr '\n' ' ')" @@ -268,11 +272,15 @@ drive "expect:KRYPTIK_SMOKE: END" "login:${TUSER}:${TPASS}" \ "$(ROOTSH 'reboot')" "expect:Linux version" "expect:KRYPTIK_SMOKE: END" \ "login:${TUSER}:${TPASS}" \ "$(ROOTSH 'cat /run/kryptik/boot-identity; cat /var/lib/kryptik/boot/last-result; echo RB2-OK')" "expect:slot=b" "expect:commit b" "expect:RB2-OK" \ + "$(ROOTSH 'bash /usr/lib/kryptik/guest-tests/image-probe.sh')" "expect:IMAGE-PROBE " \ "$(ROOTSH 'poweroff')" "expect:Power down" "wait-exit" rc=$?; stop_vm [[ "$rc" -eq 0 ]] && green "rollback: slot b armed, booted and committed" || red "step 5 drive failed" stop_unless_ok "$rc" "step 5" txt | grep -q "version_id=${VB}" && green "guest reports ${VB} after rollback" || red "guest did not report ${VB}" +txt | grep -q 'IMAGE-PROBE kryptik-probe 1.0, from the probe image: ' \ + && green "after ${VA} was applied to the other slot, ${VB}'s images were still kept, and its zone runs the probe" \ + || red "${VB}'s probe image did not run after the rollback: $(txt | grep -m1 'IMAGE-PROBE')" # ----------------------------------------------------------------- step 6 -- step "step 6: interruption during the slot write, then after arming" diff --git a/tools/tests/update-images.sh b/tools/tests/update-images.sh new file mode 100755 index 00000000..dc1424c7 --- /dev/null +++ b/tools/tests/update-images.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +# Tests for kryptik-update's stage_images and prune_images, offline: a payload's software images +# reach the store only for the root the payload carries and only as their list signs them, and the +# store keeps the lists of the roots in the two slots and the images those lists name. + +set -uo pipefail + +unset KRYPTIK_SOURCES KRYPTIK_WORK KRYPTIK_LOCK KRYPTIK_OUT KRYPTIK_ROOT + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +UPD="${ROOT}/tools/update/kryptik-update" + +PASS=0 +FAIL=0 +green() { printf '\033[32m PASS\033[0m %s\n' "$1"; PASS=$((PASS + 1)); } +red() { printf '\033[31m FAIL\033[0m %s\n' "$1"; FAIL=$((FAIL + 1)); } + +for t in zstd sha256sum; do + command -v "$t" >/dev/null 2>&1 || { echo "${t} required"; exit 1; } +done + +W="$(mktemp -d)" +trap 'rm -rf "$W"' EXIT +STORE="${W}/store" + +# The two functions, lifted out of the tool as image-list.sh lifts check-images, with what apply +# has set by the time it calls them: the payload's path, its manifest's hashes and the private copy. +{ + printf 'IMAGES=%q\n' "$STORE" + echo 'say() { printf "%s\n" "$*"; }' + echo 'die() { printf "REFUSED: %s\n" "$*"; exit 1; }' + echo 'uncache() { :; }' + echo 'payload_path() { echo "$dir/$1"; }' + echo 'manifest_hash() { sed -n '"'"'/^--$/,$p'"'"' "$m" | sed 1d | awk -v r="$1" '"'"'$3==r {print $1; exit}'"'"'; }' + sed -n '/^stage_images() {/,/^}/p' "$UPD" + sed -n '/^prune_images() {/,/^}/p' "$UPD" +} > "${W}/lib.sh" +grep -q '^stage_images() {' "${W}/lib.sh" && grep -q '^prune_images() {' "${W}/lib.sh" \ + || { echo "FATAL: stage_images or prune_images is missing from ${UPD}" >&2; exit 1; } + +hex() { printf "%064d" 0 | tr 0 "$1"; } +R1="$(hex 1)"; R2="$(hex 2)"; R3="$(hex 3)" + +# A payload in DIR carrying the root BASE (what its list says) and an image NAME of CONTENT, the +# release manifest listing every file; the image's root hash is HASH. +payload() { # payload DIR BASE NAME CONTENT HASH + rm -rf "$1"; mkdir -p "$1" + printf '%s' "$4" > "${W}/raw" + zstd -q -f "${W}/raw" -o "$1/$3.img.zst" + printf 'KRYPTIK-IMAGES-1\nrelease: 1.2.0\nbase: %s\nimage: %s 1 bytes=%s sha256=%s data-blocks=1 root-hash=%s salt=%s\n' \ + "$2" "$3" "$(stat -c %s "${W}/raw")" "$(sha256sum "${W}/raw" | cut -c1-64)" "$5" "$(hex a)" > "$1/images.manifest" + printf 'a signature\n' > "$1/images.manifest.sig" + { + printf 'KRYPTIK-MANIFEST-1\n--\n' + for f in images.manifest images.manifest.sig "$3.img.zst"; do + printf '%s %s %s\n' "$(sha256sum "$1/$f" | cut -c1-64)" "$(stat -c %s "$1/$f")" "$f" + done + } > "$1/manifest" +} +# stage DIR ROOT-HASH: stage_images for that payload, its output and exit in OUT and RC. +stage() { + mkdir -p "${W}/snap" + OUT="$(dir="$1" m="$1/manifest" ROOT_HASH="$2" VERSION=1.2.0 SNAP="${W}/snap" bash -c ". '${W}/lib.sh'; stage_images" 2>&1)"; RC=$? +} + +payload "${W}/p1" "$R1" probe "the probe image" "$(hex b)" +stage "${W}/p1" "$R1" +if [[ "$RC" -eq 0 && "$(cat "${STORE}/probe/$(hex b).img")" == "the probe image" ]] \ + && cmp -s "${W}/p1/images.manifest" "${STORE}/${R1}.manifest" && [[ -f "${STORE}/${R1}.manifest.sig" ]]; then + green "a payload's image and its list are kept in the store, by root hash" +else + red "the payload's image was not kept as its list says (exit ${RC}): ${OUT}" +fi +stage "${W}/p1" "$R1" +[[ "$RC" -eq 0 && "$OUT" == *"image probe: already kept"* ]] && green "an image already kept is not written again" \ + || red "staging the same image twice did not find it kept: ${OUT}" + +stage "${W}/p1" "$R2" +[[ "$RC" -ne 0 && "$OUT" == *"REFUSED: the payload's image list is for another root"* ]] \ + && green "a list for another root than the payload carries is refused" || red "a list for another root was not refused: ${OUT}" + +payload "${W}/p2" "$R1" viewer "what the list signs" "$(hex c)" +printf 'something else' > "${W}/other" +zstd -q -f "${W}/other" -o "${W}/p2/viewer.img.zst" +# The release manifest still vouches for the file, as it would for a payload built wrong. +sed -i "s/^[0-9a-f]* [0-9]* viewer.img.zst\$/$(sha256sum "${W}/p2/viewer.img.zst" | cut -c1-64) $(stat -c %s "${W}/p2/viewer.img.zst") viewer.img.zst/" "${W}/p2/manifest" +stage "${W}/p2" "$R1" +if [[ "$RC" -ne 0 && "$OUT" == *"REFUSED: image viewer does not decompress to the bytes its list signs"* ]] \ + && [[ -z "$(find "$STORE" -name '*.new')" && ! -e "${STORE}/viewer/$(hex c).img" ]]; then + green "an image that does not decompress to its signed bytes is refused, and nothing of it is left" +else + red "a wrong image was kept or left behind (exit ${RC}): ${OUT}; $(find "$STORE" -name '*.new')" +fi + +payload "${W}/p3" "$R1" viewer "an image" "$(hex c)" +rm "${W}/p3/viewer.img.zst" +sed -i '/ viewer.img.zst$/d' "${W}/p3/manifest" +stage "${W}/p3" "$R1" +[[ "$RC" -ne 0 && "$OUT" == *"REFUSED: the list names image viewer, and the payload does not carry viewer.img.zst"* ]] \ + && green "a list naming an image the payload does not carry is refused" || red "a missing image was not refused: ${OUT}" + +payload "${W}/p4" "$R1" viewer "an image" "$(hex c)" +printf '\n' >> "${W}/p4/images.manifest" +stage "${W}/p4" "$R1" +[[ "$RC" -ne 0 && "$OUT" == *"REFUSED: the image list changed after the manifest was checked"* ]] \ + && green "a list that is not the one the release manifest hashed is refused" || red "a changed list was not refused: ${OUT}" + +payload "${W}/p5" "$R1" "../x" "an image" "$(hex c)" +stage "${W}/p5" "$R1" +[[ "$RC" -ne 0 && "$OUT" == *"REFUSED: the image list names '../x'"* ]] \ + && green "an image name that is not a plain name is refused" || red "a name with a path was not refused: ${OUT}" + +# prune: three roots' lists, an image shared by two of them, and a leftover .new. +payload "${W}/q2" "$R2" probe "the probe image" "$(hex b)"; stage "${W}/q2" "$R2" +payload "${W}/q3" "$R3" viewer "only the third root's" "$(hex d)"; stage "${W}/q3" "$R3" +: > "${STORE}/probe/$(hex e).img.new" +( . "${W}/lib.sh"; prune_images "$R1" "$R2" ) +if [[ -f "${STORE}/${R1}.manifest" && -f "${STORE}/${R2}.manifest.sig" && ! -e "${STORE}/${R3}.manifest" && ! -e "${STORE}/${R3}.manifest.sig" ]] \ + && [[ -f "${STORE}/probe/$(hex b).img" && ! -e "${STORE}/viewer/$(hex d).img" && ! -e "${STORE}/probe/$(hex e).img.new" ]]; then + green "pruning keeps the two roots' lists and the images they name, and nothing else" +else + red "pruning kept or removed the wrong files: $(cd "$STORE" && find . -type f | sort | tr '\n' ' ')" +fi + +echo +if [[ "$FAIL" -gt 0 ]]; then + echo "${FAIL} of $((PASS + FAIL)) checks failed." + exit 1 +fi +echo "All ${PASS} checks passed." diff --git a/tools/update/kryptik-update b/tools/update/kryptik-update index adb132f4..27cb62e4 100755 --- a/tools/update/kryptik-update +++ b/tools/update/kryptik-update @@ -16,7 +16,8 @@ # # 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. +# the release's software images if it has any (images.manifest, its .sig and +# NAME.img.zst), and nothing else. set -eu PROG=kryptik-update @@ -37,6 +38,8 @@ B=/var/lib/kryptik/boot ESP_MNT=/run/kryptik/update-esp LOCK=/run/kryptik/update.lock LOG=/var/log/kryptik/update.log +# Software images, by the root they are built for (docs/design/software-delivery.md). +IMAGES=/var/lib/kryptik/images # Where the manifest is copied. Never taken from the environment, since the # exit trap removes it; a variable only so the offline suite can set it. SNAP="" @@ -346,6 +349,74 @@ unlist_slot() { # unlist_slot SLOT umount_esp } +# The payload's software images, before any slot is written: the list must be +# for the root the payload carries, and each image is decompressed into the +# store and checked there, on the copy, against the size and hash the list +# signs. Only once every image has checked is any renamed into place. +stage_images() { + [ -n "$(manifest_hash images.manifest)" ] || return 0 + [ -n "$(manifest_hash images.manifest.sig)" ] || die "the payload lists images.manifest without its signature" + # A copy, read once and checked against the manifest: the payload directory is mutable. + l="$SNAP/images.manifest"; lsig="$SNAP/images.manifest.sig" + cp -- "$(payload_path images.manifest)" "$l" && cp -- "$(payload_path images.manifest.sig)" "$lsig" \ + || die "cannot copy the image list" + [ "$(sha256sum "$l" | cut -c1-64)" = "$(manifest_hash images.manifest)" ] \ + && [ "$(sha256sum "$lsig" | cut -c1-64)" = "$(manifest_hash images.manifest.sig)" ] \ + || die "the image list changed after the manifest was checked" + [ "$(head -1 "$l")" = KRYPTIK-IMAGES-1 ] || die "images.manifest is not a KRYPTIK-IMAGES-1" + [ "$(sed -n 's/^base: //p' "$l")" = "$ROOT_HASH" ] || die "the payload's image list is for another root than the one it carries" + mkdir -p "$IMAGES" && chmod 0700 "$IMAGES" || die "cannot make $IMAGES" + staged="" + while read -r _ name _ bytes sha _ hash _; do + [ -n "$name" ] || continue + bytes="${bytes#bytes=}"; sha="${sha#sha256=}"; hash="${hash#root-hash=}" + case "$name" in ''|*[!a-z0-9-]*) die "the image list names '$name'" ;; esac + case "$hash$sha" in *[!0-9a-f]*) die "image $name: its hashes in the list are not hex" ;; esac + [ "${#hash}" = 64 ] && [ "${#sha}" = 64 ] || die "image $name: its hashes in the list are not 64 digits" + [ -n "$(manifest_hash "$name.img.zst")" ] || die "the list names image $name, and the payload does not carry $name.img.zst" + f="$IMAGES/$name/$hash.img" + if [ -f "$f" ] && [ "$(stat -c %s "$f")" = "$bytes" ] && [ "$(sha256sum "$f" | cut -c1-64)" = "$sha" ]; then + say "image $name: already kept" + continue + fi + mkdir -p "$IMAGES/$name" || die "cannot make $IMAGES/$name" + zstd -dcq < "$(payload_path "$name.img.zst")" > "$f.new" \ + || { rm -f "$f.new"; die "image $name did not decompress into the store (is the state partition full?)"; } + sync -f "$f.new" + uncache "$f.new" + if [ "$(stat -c %s "$f.new")" != "$bytes" ] || [ "$(sha256sum "$f.new" | cut -c1-64)" != "$sha" ]; then + rm -f "$f.new" + die "image $name does not decompress to the bytes its list signs" + fi + staged="$staged $f" + say "image $name: staged and checked ($bytes bytes)" + done </dev/null | sed -n 's/^image: \([a-z0-9-]*\) .* root-hash=\([0-9a-f]*\) .*/\1\/\2.img/p')" + for f in "$IMAGES"/*/*.img "$IMAGES"/*/*.img.new; do + [ -f "$f" ] || continue + printf '%s\n' "$wanted" | grep -Fxq -- "${f#"$IMAGES"/}" || rm -f "$f" + done +} + # Verify everything, then write only the inactive slot, read it back, install # its kernel and arm BootNext. BOOTX64.EFI is left to boot-success, which # commits once the new slot has come up. Zone data is never written. @@ -382,6 +453,7 @@ cmd_apply() { [ -b "$tdev" ] || die "kryptik-$target ($tdev) is not a block device" tsize="$(blockdev --getsize64 "$tdev")" [ "$tsize" -ge "$ROOT_BYTES" ] || die "slot $target is $tsize bytes; the root image needs $ROOT_BYTES" + stage_images # --- writes begin: the inactive slot only ------------------------------- unlist_slot "$target" @@ -418,6 +490,10 @@ cmd_apply() { # --- arm one trial boot -------------------------------------------------- arm_trial "$target" + # The running root's images stay for a rollback; those of the release this slot held go. + # A command line naming no root hash prunes nothing, rather than the running root's list. + running_hash="$(sed -n 's/.*sha256 \([0-9a-f]\{64\}\) .*/\1/p' /proc/cmdline)" + if [ -n "$running_hash" ]; then prune_images "$running_hash" "$ROOT_HASH"; fi printf '%s apply %s -> slot %s\n' "$(date -Iseconds 2>/dev/null)" "$VERSION" "$target" >> "$LOG" say "armed: the next boot tries slot $target ($VERSION) once. Reboot to try it." say "if it does not come up, the firmware falls back to the committed slot ($slot)." From da8c512e5ce1ba6f1d41d360a9e21701b370f749 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 23:23:58 -0700 Subject: [PATCH 009/123] kryptik-update and the installer keep images through one store library build/service-scripts/image-store.sh, installed as /usr/libexec/kryptik/image-store.sh beside esp-records.sh, holds the store: images_keep LIST SIG STORE BASE READER keeps the images a list names for its root, each copy checked against the signed size and hash before it takes its name, and images_prune STORE ROOT-HASH... keeps those roots' lists and the images they name. kryptik-update's staging is what is the payload's own: the list's copy checked against the manifest, every named image carried, and zstd as the reader, which now dies nowhere inside a redirection. boot-check looks for the library; the host test lifts it. --- build/config/image-programs.txt | 1 + build/recipes/boot-check.sh | 1 + build/service-scripts/image-store.sh | 63 +++++++++++++++++++++++++ tools/tests/update-images.sh | 26 ++++++----- tools/update/kryptik-update | 69 +++++----------------------- 5 files changed, 92 insertions(+), 68 deletions(-) create mode 100755 build/service-scripts/image-store.sh diff --git a/build/config/image-programs.txt b/build/config/image-programs.txt index dcfc4d46..9fc24878 100644 --- a/build/config/image-programs.txt +++ b/build/config/image-programs.txt @@ -1102,6 +1102,7 @@ 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/image-store.sh usr/libexec/kryptik/installer-run.sh usr/libexec/kryptik/keyboard.sh usr/libexec/kryptik/kryptikd-check.sh diff --git a/build/recipes/boot-check.sh b/build/recipes/boot-check.sh index 04010f6d..3a75154a 100644 --- a/build/recipes/boot-check.sh +++ b/build/recipes/boot-check.sh @@ -87,6 +87,7 @@ s_boot_check() { chk "zone policies" /usr/lib/kryptik/zones/policy/work.seccomp chk "device helper" /usr/libexec/kryptik/devices.sh x chk "ESP record reader" /usr/libexec/kryptik/esp-records.sh + chk "image store" /usr/libexec/kryptik/image-store.sh chk "boot scripts" /usr/libexec/kryptik/sysinit.sh x chk "test control helper" /usr/libexec/kryptik/testctl.sh chk "keyboard helper" /usr/libexec/kryptik/keyboard.sh diff --git a/build/service-scripts/image-store.sh b/build/service-scripts/image-store.sh new file mode 100755 index 00000000..b255ab35 --- /dev/null +++ b/build/service-scripts/image-store.sh @@ -0,0 +1,63 @@ +#!/bin/sh +# Software images on the state partition (docs/design/software-delivery.md), for kryptik-update (a +# release's payload) and kryptik-install (the medium): each list kept under the root hash it is built +# for, each image it names under its own root hash. Sourced; the caller defines say, die and uncache. + +# images_keep LIST SIG STORE BASE READER: keep in STORE the images LIST names, for the root BASE, which +# LIST must be built for. READER NAME writes image NAME's bytes to stdout. Each copy is checked against +# the size and hash LIST signs before it takes its name, none takes its name until every one has +# checked, and LIST and SIG are kept last. kryptikd checks LIST's signature at every use. +images_keep() { + _l="$1"; _s="$2"; _store="$3"; _base="$4"; _read="$5" + [ "$(head -1 "$_l")" = KRYPTIK-IMAGES-1 ] || die "the image list is not a KRYPTIK-IMAGES-1" + [ "$(sed -n 's/^base: //p' "$_l")" = "$_base" ] || die "the image list is for another root than the one it comes with" + mkdir -p "$_store" && chmod 0700 "$_store" || die "cannot make $_store" + _staged="" + while read -r _ _name _ _bytes _sha _ _hash _; do + [ -n "$_name" ] || continue + _bytes="${_bytes#bytes=}"; _sha="${_sha#sha256=}"; _hash="${_hash#root-hash=}" + case "$_name" in ''|*[!a-z0-9-]*) die "the image list names '$_name'" ;; esac + case "$_bytes" in ''|*[!0-9]*) die "image $_name: its size in the list is not a number" ;; esac + case "$_hash$_sha" in *[!0-9a-f]*) die "image $_name: its hashes in the list are not hex" ;; esac + [ "${#_hash}" = 64 ] && [ "${#_sha}" = 64 ] || die "image $_name: its hashes in the list are not 64 digits" + _f="$_store/$_name/$_hash.img" + if [ -f "$_f" ] && [ "$(stat -c %s "$_f")" = "$_bytes" ] && [ "$(sha256sum "$_f" | cut -c1-64)" = "$_sha" ]; then + say "image $_name: already kept" + continue + fi + mkdir -p "$_store/$_name" || die "cannot make $_store/$_name" + "$_read" "$_name" > "$_f.new" || { rm -f "$_f.new"; die "image $_name could not be read, or written into the store"; } + sync -f "$_f.new" + uncache "$_f.new" + if [ "$(stat -c %s "$_f.new")" != "$_bytes" ] || [ "$(sha256sum "$_f.new" | cut -c1-64)" != "$_sha" ]; then + rm -f "$_f.new" + die "image $_name is not the bytes its list signs" + fi + _staged="$_staged $_f" + say "image $_name: kept and checked ($_bytes bytes)" + done </dev/null | sed -n 's/^image: \([a-z0-9-]*\) .* root-hash=\([0-9a-f]*\) .*/\1\/\2.img/p')" + for _f in "$_store"/*/*.img "$_store"/*/*.img.new; do + [ -f "$_f" ] || continue + printf '%s\n' "$_wanted" | grep -Fxq -- "${_f#"$_store"/}" || rm -f "$_f" + done +} diff --git a/tools/tests/update-images.sh b/tools/tests/update-images.sh index dc1424c7..e8476bc0 100755 --- a/tools/tests/update-images.sh +++ b/tools/tests/update-images.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash -# Tests for kryptik-update's stage_images and prune_images, offline: a payload's software images -# reach the store only for the root the payload carries and only as their list signs them, and the -# store keeps the lists of the roots in the two slots and the images those lists name. +# Tests for the image store (build/service-scripts/image-store.sh) and kryptik-update's staging of a +# payload's images, offline: images reach the store only for the root they come with and only as their +# list signs them, and the store keeps the lists of the roots in the two slots and the images they name. set -uo pipefail @@ -9,6 +9,7 @@ unset KRYPTIK_SOURCES KRYPTIK_WORK KRYPTIK_LOCK KRYPTIK_OUT KRYPTIK_ROOT ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" UPD="${ROOT}/tools/update/kryptik-update" +LIB="${ROOT}/build/service-scripts/image-store.sh" PASS=0 FAIL=0 @@ -23,8 +24,8 @@ W="$(mktemp -d)" trap 'rm -rf "$W"' EXIT STORE="${W}/store" -# The two functions, lifted out of the tool as image-list.sh lifts check-images, with what apply -# has set by the time it calls them: the payload's path, its manifest's hashes and the private copy. +# The store's functions and apply's staging, lifted out as image-list.sh lifts check-images, with +# what apply has set by the time it calls them: the payload's path, its manifest's hashes and the copy. { printf 'IMAGES=%q\n' "$STORE" echo 'say() { printf "%s\n" "$*"; }' @@ -32,11 +33,14 @@ STORE="${W}/store" echo 'uncache() { :; }' echo 'payload_path() { echo "$dir/$1"; }' echo 'manifest_hash() { sed -n '"'"'/^--$/,$p'"'"' "$m" | sed 1d | awk -v r="$1" '"'"'$3==r {print $1; exit}'"'"'; }' + sed -n '/^images_keep() {/,/^}/p' "$LIB" + sed -n '/^images_prune() {/,/^}/p' "$LIB" sed -n '/^stage_images() {/,/^}/p' "$UPD" - sed -n '/^prune_images() {/,/^}/p' "$UPD" + grep '^payload_image() ' "$UPD" } > "${W}/lib.sh" -grep -q '^stage_images() {' "${W}/lib.sh" && grep -q '^prune_images() {' "${W}/lib.sh" \ - || { echo "FATAL: stage_images or prune_images is missing from ${UPD}" >&2; exit 1; } +for fn in images_keep images_prune stage_images payload_image; do + grep -q "^${fn}() " "${W}/lib.sh" || { echo "FATAL: ${fn} is missing from ${LIB} or ${UPD}" >&2; exit 1; } +done hex() { printf "%064d" 0 | tr 0 "$1"; } R1="$(hex 1)"; R2="$(hex 2)"; R3="$(hex 3)" @@ -76,7 +80,7 @@ stage "${W}/p1" "$R1" || red "staging the same image twice did not find it kept: ${OUT}" stage "${W}/p1" "$R2" -[[ "$RC" -ne 0 && "$OUT" == *"REFUSED: the payload's image list is for another root"* ]] \ +[[ "$RC" -ne 0 && "$OUT" == *"REFUSED: the image list is for another root than the one it comes with"* ]] \ && green "a list for another root than the payload carries is refused" || red "a list for another root was not refused: ${OUT}" payload "${W}/p2" "$R1" viewer "what the list signs" "$(hex c)" @@ -85,7 +89,7 @@ zstd -q -f "${W}/other" -o "${W}/p2/viewer.img.zst" # The release manifest still vouches for the file, as it would for a payload built wrong. sed -i "s/^[0-9a-f]* [0-9]* viewer.img.zst\$/$(sha256sum "${W}/p2/viewer.img.zst" | cut -c1-64) $(stat -c %s "${W}/p2/viewer.img.zst") viewer.img.zst/" "${W}/p2/manifest" stage "${W}/p2" "$R1" -if [[ "$RC" -ne 0 && "$OUT" == *"REFUSED: image viewer does not decompress to the bytes its list signs"* ]] \ +if [[ "$RC" -ne 0 && "$OUT" == *"REFUSED: image viewer is not the bytes its list signs"* ]] \ && [[ -z "$(find "$STORE" -name '*.new')" && ! -e "${STORE}/viewer/$(hex c).img" ]]; then green "an image that does not decompress to its signed bytes is refused, and nothing of it is left" else @@ -114,7 +118,7 @@ stage "${W}/p5" "$R1" payload "${W}/q2" "$R2" probe "the probe image" "$(hex b)"; stage "${W}/q2" "$R2" payload "${W}/q3" "$R3" viewer "only the third root's" "$(hex d)"; stage "${W}/q3" "$R3" : > "${STORE}/probe/$(hex e).img.new" -( . "${W}/lib.sh"; prune_images "$R1" "$R2" ) +( . "${W}/lib.sh"; images_prune "$STORE" "$R1" "$R2" ) if [[ -f "${STORE}/${R1}.manifest" && -f "${STORE}/${R2}.manifest.sig" && ! -e "${STORE}/${R3}.manifest" && ! -e "${STORE}/${R3}.manifest.sig" ]] \ && [[ -f "${STORE}/probe/$(hex b).img" && ! -e "${STORE}/viewer/$(hex d).img" && ! -e "${STORE}/probe/$(hex e).img.new" ]]; then green "pruning keeps the two roots' lists and the images they name, and nothing else" diff --git a/tools/update/kryptik-update b/tools/update/kryptik-update index 27cb62e4..896650c6 100755 --- a/tools/update/kryptik-update +++ b/tools/update/kryptik-update @@ -68,7 +68,8 @@ case "${1:-}" in . /usr/libexec/kryptik/devices.sh # 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 ;; + . /usr/libexec/kryptik/esp-records.sh + . /usr/libexec/kryptik/image-store.sh ;; esac running_slot() { sed -n 's/^slot=//p' /run/kryptik/boot-identity 2>/dev/null; } @@ -349,73 +350,27 @@ unlist_slot() { # unlist_slot SLOT umount_esp } -# The payload's software images, before any slot is written: the list must be -# for the root the payload carries, and each image is decompressed into the -# store and checked there, on the copy, against the size and hash the list -# signs. Only once every image has checked is any renamed into place. +# The payload's software images, before any slot is written: a copy of its +# list, read once and checked against the manifest (the payload directory is +# mutable), and each image kept in the store as the list signs it. stage_images() { [ -n "$(manifest_hash images.manifest)" ] || return 0 [ -n "$(manifest_hash images.manifest.sig)" ] || die "the payload lists images.manifest without its signature" - # A copy, read once and checked against the manifest: the payload directory is mutable. l="$SNAP/images.manifest"; lsig="$SNAP/images.manifest.sig" cp -- "$(payload_path images.manifest)" "$l" && cp -- "$(payload_path images.manifest.sig)" "$lsig" \ || die "cannot copy the image list" [ "$(sha256sum "$l" | cut -c1-64)" = "$(manifest_hash images.manifest)" ] \ && [ "$(sha256sum "$lsig" | cut -c1-64)" = "$(manifest_hash images.manifest.sig)" ] \ || die "the image list changed after the manifest was checked" - [ "$(head -1 "$l")" = KRYPTIK-IMAGES-1 ] || die "images.manifest is not a KRYPTIK-IMAGES-1" - [ "$(sed -n 's/^base: //p' "$l")" = "$ROOT_HASH" ] || die "the payload's image list is for another root than the one it carries" - mkdir -p "$IMAGES" && chmod 0700 "$IMAGES" || die "cannot make $IMAGES" - staged="" - while read -r _ name _ bytes sha _ hash _; do - [ -n "$name" ] || continue - bytes="${bytes#bytes=}"; sha="${sha#sha256=}"; hash="${hash#root-hash=}" - case "$name" in ''|*[!a-z0-9-]*) die "the image list names '$name'" ;; esac - case "$hash$sha" in *[!0-9a-f]*) die "image $name: its hashes in the list are not hex" ;; esac - [ "${#hash}" = 64 ] && [ "${#sha}" = 64 ] || die "image $name: its hashes in the list are not 64 digits" - [ -n "$(manifest_hash "$name.img.zst")" ] || die "the list names image $name, and the payload does not carry $name.img.zst" - f="$IMAGES/$name/$hash.img" - if [ -f "$f" ] && [ "$(stat -c %s "$f")" = "$bytes" ] && [ "$(sha256sum "$f" | cut -c1-64)" = "$sha" ]; then - say "image $name: already kept" - continue - fi - mkdir -p "$IMAGES/$name" || die "cannot make $IMAGES/$name" - zstd -dcq < "$(payload_path "$name.img.zst")" > "$f.new" \ - || { rm -f "$f.new"; die "image $name did not decompress into the store (is the state partition full?)"; } - sync -f "$f.new" - uncache "$f.new" - if [ "$(stat -c %s "$f.new")" != "$bytes" ] || [ "$(sha256sum "$f.new" | cut -c1-64)" != "$sha" ]; then - rm -f "$f.new" - die "image $name does not decompress to the bytes its list signs" - fi - staged="$staged $f" - say "image $name: staged and checked ($bytes bytes)" - done </dev/null | sed -n 's/^image: \([a-z0-9-]*\) .* root-hash=\([0-9a-f]*\) .*/\1\/\2.img/p')" - for f in "$IMAGES"/*/*.img "$IMAGES"/*/*.img.new; do - [ -f "$f" ] || continue - printf '%s\n' "$wanted" | grep -Fxq -- "${f#"$IMAGES"/}" || rm -f "$f" - done -} +# Image NAME as the payload carries it, decompressed. +payload_image() { zstd -dcq < "$(payload_path "$1.img.zst")"; } # payload_image NAME # Verify everything, then write only the inactive slot, read it back, install # its kernel and arm BootNext. BOOTX64.EFI is left to boot-success, which @@ -493,7 +448,7 @@ cmd_apply() { # The running root's images stay for a rollback; those of the release this slot held go. # A command line naming no root hash prunes nothing, rather than the running root's list. running_hash="$(sed -n 's/.*sha256 \([0-9a-f]\{64\}\) .*/\1/p' /proc/cmdline)" - if [ -n "$running_hash" ]; then prune_images "$running_hash" "$ROOT_HASH"; fi + if [ -n "$running_hash" ]; then images_prune "$IMAGES" "$running_hash" "$ROOT_HASH"; fi printf '%s apply %s -> slot %s\n' "$(date -Iseconds 2>/dev/null)" "$VERSION" "$target" >> "$LOG" say "armed: the next boot tries slot $target ($VERSION) once. Reboot to try it." say "if it does not come up, the firmware falls back to the committed slot ($slot)." From eae0e1b7f0c18d19a87818b3904ed7692b5ae4f8 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 23:25:35 -0700 Subject: [PATCH 010/123] The installer keeps the medium's software images on the new state partition, for the root it installs The medium carries the signed list and its images in a partition of their own (USB) or under /images (ISO). Before anything is written, kryptik-install requires the list to be signed and built for the root the medium installs; once the state partition is made, it keeps each image through image-store.sh, the copy checked against the size and hash the list signs. The install suite runs the probe from the kept image on the installed system. The design note says what recovery cannot do: kryptik-recover does not open the state partition, so a restored slot finds its images only if the store still holds that release's. --- docs/design/software-delivery.md | 9 ++++++++- docs/user-guide.md | 2 +- tools/image/install-test.sh | 2 ++ tools/install/kryptik-install.sh | 26 ++++++++++++++++++++++++-- 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/docs/design/software-delivery.md b/docs/design/software-delivery.md index 3f5839a5..bba7e914 100644 --- a/docs/design/software-delivery.md +++ b/docs/design/software-delivery.md @@ -236,7 +236,14 @@ up ADR-001 in the zones where the applications run. that the list is for the root the payload carries, and decompresses each image into the store, where the copy is checked against the size and hash the list signs before it takes its name. Once the trial is armed, the - store drops the release the slot held. + store drops the release the slot held. The install medium carries the same + list and images, in a partition of their own on the USB image and under + `/images` on the ISO; the installer checks the list is for the root it + installs before it writes anything, and keeps each image as `apply` does. + `kryptik-recover --restore-slot` does not open the state partition, so a + slot it restores finds its images only if the store still holds that + release's, from the install or a kept update; otherwise a zone that names + one is refused until the next update brings them. - **Fetched** alone for an image-only fix, by the same verbs as a release. The statement of what is current gains one line per image for each base it serves; zone 0 fetches only images whose hash it does not hold, never diff --git a/docs/user-guide.md b/docs/user-guide.md index 118fbe9f..c6bdbb22 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -203,7 +203,7 @@ 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). Its plan names the disk's model and what each of its +volumes, updates, and the medium's software images). 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 diff --git a/tools/image/install-test.sh b/tools/image/install-test.sh index f1881703..95dd1f47 100755 --- a/tools/image/install-test.sh +++ b/tools/image/install-test.sh @@ -113,6 +113,7 @@ drive --record "$REC" \ "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" \ + "su:${RPASS}:bash /usr/lib/kryptik/guest-tests/image-probe.sh" \ "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" \ @@ -145,6 +146,7 @@ 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" 'IMAGE-PROBE kryptik-probe 1.0, from the probe image: ' "a zone runs the probe from the image the installer kept from the medium" want "$P2" 'reboot: Restarting system' "the guest rebooted itself" if [[ "$(grep -c 'Linux version' "$P2")" -ge 2 ]]; then green "two kernel boots in one session (reboot worked)"; else red "expected two kernel boots"; fi want "$P2" 'Power down' "the guest powered off from inside" diff --git a/tools/install/kryptik-install.sh b/tools/install/kryptik-install.sh index 0935493b..83162b4d 100755 --- a/tools/install/kryptik-install.sh +++ b/tools/install/kryptik-install.sh @@ -104,6 +104,7 @@ mounted_on() { # mounted_on DEVS < TABLE: "SOURCE on WHERE" for each source th . /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 +. /usr/libexec/kryptik/image-store.sh [ -z "$KEYBOARD" ] || kb_row "$KEYBOARD" > /dev/null || die "no keyboard layout named ${KEYBOARD}: kryptik keyboard lists them" [ -z "$PRESEED" ] || preseed_ok "$PRESEED" @@ -170,11 +171,11 @@ fi # --- what we are installing, from the medium --------------------------------- media="$(sed -n 's/^media=//p' /run/kryptik/boot-identity 2>/dev/null)" [ -n "$media" ] || die "this is not an install medium (no kryptik.media= on the signed command line)" -mkdir -p "$MNT_BASE/media" "$MNT_BASE/esp" "$MNT_BASE/state" "$MNT_BASE/tesp" +mkdir -p "$MNT_BASE/media" "$MNT_BASE/esp" "$MNT_BASE/state" "$MNT_BASE/tesp" "$MNT_BASE/images" MAPPING=kryptik-install-state cleanup() { [ -t 0 ] && stty echo 2>/dev/null || true - for m in "$MNT_BASE/tesp" "$MNT_BASE/state" "$MNT_BASE/esp" "$MNT_BASE/media"; do + for m in "$MNT_BASE/tesp" "$MNT_BASE/state" "$MNT_BASE/images" "$MNT_BASE/esp" "$MNT_BASE/media"; do mountpoint -q "$m" 2>/dev/null && umount "$m" 2>/dev/null || true rmdir "$m" 2>/dev/null || true done @@ -184,6 +185,8 @@ cleanup() { trap cleanup EXIT INT TERM ESP_SRC="" # a block device or a regular file holding the ESP image +IMG_SRC="" # the medium's software images, if it carries any +medium_image() { cat "$IMG_SRC/$1.img"; } # medium_image NAME ROOT_SRC="" # block device holding the root image at ROOT_OFF ROOT_OFF=0 case "$media" in @@ -195,6 +198,12 @@ case "$media" in [ -b "$ESP_SRC" ] || die "no single kryptik-esp partition on the medium this system booted from" mount -t vfat -o ro "$ESP_SRC" "$MNT_BASE/esp" || die "could not mount the medium's ESP" ROOT_JSON="$MNT_BASE/esp/kryptik/root.json" + # The release's software images, when the medium carries them, in a partition of their own. + IMG_DEV="$(kryptik_part kryptik-images 2>/dev/null)" || IMG_DEV="" + if [ -b "$IMG_DEV" ]; then + mount -t ext4 -o ro,nosuid,nodev,noexec "$IMG_DEV" "$MNT_BASE/images" || die "could not mount the medium's kryptik-images" + IMG_SRC="$MNT_BASE/images" + fi ;; iso) mount -t iso9660 -o ro /dev/sr0 "$MNT_BASE/media" || die "could not mount the medium (/dev/sr0)" @@ -203,6 +212,7 @@ case "$media" in # For its kernels, read before anything is written. mount -t vfat -o ro,loop "$ESP_SRC" "$MNT_BASE/esp" || die "could not mount the medium's ESP image" ROOT_JSON="$MNT_BASE/media/root.json" + if [ -f "$MNT_BASE/media/images/images.manifest" ]; then IMG_SRC="$MNT_BASE/media/images"; fi ROOT_SRC=/dev/sr0 # The signed linear table "0 N linear /dev/sr0 START" puts the root image at sector START. ROOT_OFF="$(sed -n 's/.*linear \/dev\/sr0 \([0-9]*\).*/\1/p' /proc/cmdline | head -1)" @@ -212,6 +222,14 @@ case "$media" in *) die "unknown medium type '${media}'" ;; esac medium_root "$ROOT_JSON" /proc/cmdline +ROOT_HASH="$(json_field "$ROOT_JSON" root_hash)" +# The medium's image list, read before anything is written: for this medium's root, and signed. +# Each image is checked against it as it is copied (image-store.sh); kryptikd checks the signature. +if [ -n "$IMG_SRC" ]; then + [ -f "$IMG_SRC/images.manifest.sig" ] || die "the medium's image list has no signature" + [ "$(sed -n 's/^base: //p' "$IMG_SRC/images.manifest")" = "$ROOT_HASH" ] \ + || die "the medium's image list is for another root than the one it installs" +fi for s in a b; do kernel_names_root "$MNT_BASE/esp/EFI/kryptik/kryptik-$s.efi" \ || die "the medium's kernel for slot $s does not carry the root hash its signed command line names; nothing was written" @@ -385,6 +403,10 @@ 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" mkdir -p "$MNT_BASE/state/lib/kryptik" +if [ -n "$IMG_SRC" ]; then + images_keep "$IMG_SRC/images.manifest" "$IMG_SRC/images.manifest.sig" "$MNT_BASE/state/lib/kryptik/images" "$ROOT_HASH" medium_image + say "images $(grep -c '^image: ' "$IMG_SRC/images.manifest") kept for ${VERSION}" +fi cat > "$MNT_BASE/state/lib/kryptik/install.json" </dev/null || echo unknown)", From 8b59958d1d213cb5b444409dbdc6f8a0bfd2b579 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 23:27:18 -0700 Subject: [PATCH 011/123] The installer's line in the design note points at the medium's bullet instead of repeating it --- docs/design/software-delivery.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/design/software-delivery.md b/docs/design/software-delivery.md index bba7e914..b2fff2ae 100644 --- a/docs/design/software-delivery.md +++ b/docs/design/software-delivery.md @@ -236,10 +236,9 @@ up ADR-001 in the zones where the applications run. that the list is for the root the payload carries, and decompresses each image into the store, where the copy is checked against the size and hash the list signs before it takes its name. Once the trial is armed, the - store drops the release the slot held. The install medium carries the same - list and images, in a partition of their own on the USB image and under - `/images` on the ISO; the installer checks the list is for the root it - installs before it writes anything, and keeps each image as `apply` does. + store drops the release the slot held. The installer takes the medium's + copy (above): it checks the list is for the root it installs before it + writes anything, and keeps each image as `apply` does. `kryptik-recover --restore-slot` does not open the state partition, so a slot it restores finds its images only if the store still holds that release's, from the install or a kept update; otherwise a zone that names From 39d51839d25dd6c01a9a5e9d604dfbd199511916 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 23:30:52 -0700 Subject: [PATCH 012/123] An image list says which images each image is built over, and a zone naming one must name them too A requires line, one per image in image order between the image and needs lines, names the images an image is built over. kryptikd refuses to start a zone that names an image without the images it requires, naming the missing one, and check --images refuses the same at build time, before a browser named without its toolkit could fail at the loader. --- compartments/kryptikd/src/images.rs | 45 +++++++++++++++++++++-- compartments/kryptikd/src/images/tests.rs | 35 ++++++++++++++++++ compartments/kryptikd/src/main.rs | 4 ++ docs/design/software-delivery.md | 8 +++- 4 files changed, 87 insertions(+), 5 deletions(-) diff --git a/compartments/kryptikd/src/images.rs b/compartments/kryptikd/src/images.rs index 50b3968a..1f9facb1 100644 --- a/compartments/kryptikd/src/images.rs +++ b/compartments/kryptikd/src/images.rs @@ -46,6 +46,8 @@ pub struct Manifest { /// The root hash of the root the images were built over. pub base: String, pub images: Vec, + /// The images each image is built over (`requires:`), which a zone naming it must name too. + pub requires: Vec<(String, Vec)>, /// Each image's policy-file lines: what a zone's policy must grant before it gets the image. pub needs: Vec<(String, String)>, } @@ -93,11 +95,12 @@ pub fn parse(text: &str) -> Result { return Err("the image list's base is not a root hash".into()); } let mut images: Vec = Vec::new(); + let mut requires: Vec<(String, Vec)> = Vec::new(); let mut needs: Vec<(String, String)> = Vec::new(); for line in lines { if let Some(rest) = line.strip_prefix("image: ") { - if !needs.is_empty() { - return Err("an image line follows the needs lines".into()); + if !requires.is_empty() || !needs.is_empty() { + return Err("an image line follows the requires or needs lines".into()); } let img = image_line(rest)?; // Sorted, which also keeps one line per image. @@ -105,6 +108,31 @@ pub fn parse(text: &str) -> Result { return Err(format!("image {} is out of order or listed twice", img.name)); } images.push(img); + } else if let Some(rest) = line.strip_prefix("requires: ") { + if !needs.is_empty() { + return Err("a requires line follows the needs lines".into()); + } + let mut words = rest.split(' '); + let name = words.next().unwrap_or_default(); + let reqs: Vec = words.map(str::to_string).collect(); + let at = images + .iter() + .position(|i| i.name == name) + .ok_or_else(|| format!("a requires line names {name:?}, which the list does not hold"))?; + if reqs.is_empty() { + return Err(format!("the requires line for {name} names no image")); + } + for (k, r) in reqs.iter().enumerate() { + if r == name || !images.iter().any(|i| &i.name == r) || reqs[..k].contains(r) { + return Err(format!("image {name} requires {r:?}, which is not another image the list holds")); + } + } + // In image order, one line per image. + let last = requires.last().and_then(|(n, _)| images.iter().position(|i| &i.name == n)); + if last.is_some_and(|l| l >= at) { + return Err(format!("the requires line for {name} is out of order or repeated")); + } + requires.push((name.to_string(), reqs)); } else if let Some(rest) = line.strip_prefix("needs: ") { let (name, what) = rest.split_once(' ').ok_or_else(|| format!("{line:?} names no policy line"))?; let at = images @@ -124,7 +152,7 @@ pub fn parse(text: &str) -> Result { if images.is_empty() { return Err("the image list names no image".into()); } - Ok(Manifest { release, base, images, needs }) + Ok(Manifest { release, base, images, requires, needs }) } fn image_line(rest: &str) -> Result { @@ -188,6 +216,16 @@ pub fn needs_met(m: &Manifest, image: &str, granted: Option<&Policy>) -> Result< Ok(()) } +/// Whether a zone that names `named` also names every image each of them is built over. +pub fn requires_met(m: &Manifest, named: &[String]) -> Result<(), String> { + for (image, reqs) in m.requires.iter().filter(|(i, _)| named.contains(i)) { + if let Some(r) = reqs.iter().find(|r| !named.contains(r)) { + return Err(format!("image {image:?} is built over {r:?}, which the zone does not name")); + } + } + Ok(()) +} + /// Where a set of images is stacked: one directory per set, whichever zone asks for it. pub fn set_dir(images: &[&Image]) -> String { let parts: Vec = images.iter().map(|i| format!("{}-{}", i.name, &i.root_hash[..16])).collect(); @@ -212,6 +250,7 @@ pub fn prepare(zone: &Zone, granted: Option<&Policy>) -> Result } needs_met(&m, name, granted)?; } + requires_met(&m, &zone.images)?; let mut lower: Vec = Vec::new(); for img in &chosen { lower.push(format!("{}/usr", open(img)?)); diff --git a/compartments/kryptikd/src/images/tests.rs b/compartments/kryptikd/src/images/tests.rs index cfd70557..32f2308d 100644 --- a/compartments/kryptikd/src/images/tests.rs +++ b/compartments/kryptikd/src/images/tests.rs @@ -76,6 +76,15 @@ fn refuses_what_stage_06_never_writes() { list(&[&good, &image("viewer"), "needs: viewer allow-syscall seccomp", "needs: browser allow-syscall mlockall"]), ), ("an unknown line", list(&[&good, "signer: someone"])), + ("requires for an image not listed", list(&[&good, &image("toolkit"), "requires: viewer toolkit"])), + ("requires nothing", list(&[&good, &image("toolkit"), "requires: browser"])), + ("requires itself", list(&[&good, &image("toolkit"), "requires: browser browser"])), + ("requires an image not listed", list(&[&good, &image("toolkit"), "requires: browser fonts"])), + ("requires one image twice", list(&[&good, &image("toolkit"), "requires: browser toolkit toolkit"])), + ("two requires lines for one image", list(&[&good, &image("toolkit"), "requires: browser toolkit", "requires: browser toolkit"])), + ("requires out of image order", list(&[&good, &image("fonts"), &image("toolkit"), "requires: toolkit fonts", "requires: browser toolkit"])), + ("requires after needs", list(&[&good, &image("toolkit"), "needs: browser allow-syscall seccomp", "requires: browser toolkit"])), + ("an image after requires", list(&[&good, "requires: browser toolkit", &image("toolkit")])), ] { assert!(parse(&text).is_err(), "{why}: {text:?}"); } @@ -116,6 +125,32 @@ fn needs_must_be_granted_by_the_zone_policy() { assert!(needs_met(&base, "browser", None).is_ok()); } +#[test] +fn an_image_built_over_others_needs_them_named() { + let m = parse(&list(&[ + &image("browser"), + &image("fonts"), + &image("toolkit"), + "requires: browser toolkit fonts", + "requires: toolkit fonts", + "needs: browser allow-syscall seccomp", + ])) + .unwrap(); + assert_eq!( + m.requires, + vec![ + ("browser".to_string(), vec!["toolkit".to_string(), "fonts".to_string()]), + ("toolkit".to_string(), vec!["fonts".to_string()]), + ] + ); + let names = |n: &[&str]| n.iter().map(|s| s.to_string()).collect::>(); + assert!(requires_met(&m, &names(&["browser", "toolkit", "fonts"])).is_ok()); + assert!(requires_met(&m, &names(&["fonts"])).is_ok(), "an image built over nothing"); + let refused = requires_met(&m, &names(&["browser", "toolkit"])).unwrap_err(); + assert!(refused.contains("\"fonts\""), "{refused}"); + assert!(requires_met(&m, &names(&["toolkit"])).is_err()); +} + #[test] fn a_set_is_named_by_its_images_and_their_hashes() { let m = parse(&list(&[&image("browser"), &image("viewer")])).unwrap(); diff --git a/compartments/kryptikd/src/main.rs b/compartments/kryptikd/src/main.rs index 04f54385..e4822fa1 100644 --- a/compartments/kryptikd/src/main.rs +++ b/compartments/kryptikd/src/main.rs @@ -700,6 +700,10 @@ fn cmd_check(dir: &Path, target: bool, image_list: Option<&Path>) -> ExitCode { failed = true; } Some(Ok(m)) => { + if let Err(e) = images::requires_met(m, &z.images) { + eprintln!(" {e}"); + failed = true; + } for name in &z.images { let held = m.images.iter().any(|i| &i.name == name); match images::needs_met(m, name, granted.as_ref()) { diff --git a/docs/design/software-delivery.md b/docs/design/software-delivery.md index 6a655bad..7d00fdf6 100644 --- a/docs/design/software-delivery.md +++ b/docs/design/software-delivery.md @@ -192,6 +192,8 @@ up ADR-001 in the zones where the applications run. release: 1.2.0 base: image: browser 140.4.0 bytes= sha256=<64 hex> data-blocks= root-hash=<64 hex> salt=<64 hex> + image: toolkit 3.24.49 bytes= sha256=<64 hex> data-blocks= root-hash=<64 hex> salt=<64 hex> + requires: browser toolkit needs: browser allow-syscall seccomp ``` @@ -201,8 +203,10 @@ up ADR-001 in the zones where the applications run. from the signed kernel's command line. The verity parameters are the root's (SHA-256, 4096-byte blocks, no superblock, the hash tree after `data-blocks`), so every value kryptikd passes to dm-verity is signed. - Image lines are sorted by name and a needs line uses the policy-file - syntax; anything else in the file refuses it. + Image lines are sorted by name; a requires line, one per image in image + order, names the images it is built over, which a zone naming it must + name too; a needs line uses the policy-file syntax. Anything else in the + file refuses it. - **Stored** under `/var/lib/kryptik/images//.img`, with the list for each root as `.manifest` and `.manifest.sig`, root only. Zone 0 keeps the generation the committed root uses and, during a From dd7516e9a46b7d958173a3de2c91461107878fd3 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 23:31:54 -0700 Subject: [PATCH 013/123] The medium's images partition is a FAT32, like the ESP, not an ext4 Whoever can write the stick can craft that filesystem, and the installer mounts it. FAT is the parser the ESP already needs; ext4's is far larger. An image of 4 GiB or more, past what FAT32 holds, fails the step. --- build/stages/06-iso.sh | 23 +++++++++++++---------- docs/design/boot-and-updates.md | 6 +++--- docs/design/software-delivery.md | 9 ++++++--- 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/build/stages/06-iso.sh b/build/stages/06-iso.sh index 9c1cb916..dd4c2347 100755 --- a/build/stages/06-iso.sh +++ b/build/stages/06-iso.sh @@ -550,19 +550,22 @@ medium_images() { # medium_images DIR install -m 0644 "${IMAGES}"/images.manifest* "${IMAGES}"/*.img "$1/" } -# make_images_fs DIR OUT: DIR as the ext4 of the USB's kryptik-images partition, made as the root's is. +# make_images_fs DIR OUT: DIR as the FAT32 of the USB's kryptik-images partition, as the ESP is made. +# Whoever can write the stick can craft this filesystem, and the installer mounts it: FAT is the +# parser the ESP already needs, where ext4's is far larger. make_images_fs() { + local f + for f in "$1"/*; do + (( $(stat -c %s "$f") < 4 * 1024 * 1024 * 1024 )) || { echo "$(basename "$f") is 4 GiB or more, past what FAT32 holds"; return 1; } + done local bytes; bytes="$(du -sb "$1" | cut -f1)" - local fs_bytes=$(( bytes + bytes / 8 + 16 * 1024 * 1024 )) - fs_bytes=$(( (fs_bytes + 4095) / 4096 * 4096 )) + local mib=$(( (bytes + bytes / 16) / 1048576 + 16 )) + (( mib >= 64 )) || mib=64 local id; id="$(printf 'kryptik-images %s %s' "$KRYPTIK_VERSION" "$(root_json root_hash)" | sha256_of_stdin)" - id="${id:0:8}-${id:8:4}-${id:12:4}-${id:16:4}-${id:20:12}" - make_ext4 "$1" "$2" "$fs_bytes" "$id" kryptik-images - if [[ -n "${SOURCE_DATE_EPOCH:-}" ]]; then - make_ext4 "$1" "${2}.again" "$fs_bytes" "$id" kryptik-images - cmp -s "$2" "${2}.again" || { echo "FAIL: two mke2fs runs over the medium's images wrote different filesystems"; return 1; } - rm -f "${2}.again" - fi + rm -f "$2" + truncate -s $(( mib * 1048576 )) "$2" + mkfs.vfat -F 32 -n KRYPTIKIMG -i "${id:0:8}" "$2" >/dev/null + mcopy -i "$2" "$1"/* ::/ } make_gpt_image() { # make_gpt_image OUT ESP-IMG ROOT-IMG ROOT-LABEL [IMAGES-FS] diff --git a/docs/design/boot-and-updates.md b/docs/design/boot-and-updates.md index a32f360c..13f82ad7 100644 --- a/docs/design/boot-and-updates.md +++ b/docs/design/boot-and-updates.md @@ -35,9 +35,9 @@ target over `/dev/sr0`, verity on top). The root image is byte-identical on the medium, in slot A and in a payload, so the installer copies bytes and signs nothing. A release with [software images](software-delivery.md) also carries them: the USB image in -a third partition, `kryptik-images`, an ext4 made as the root's is, and the -ISO as files under `/images` in its own filesystem. Either holds the signed -list, its signature and each image. +a third partition, `kryptik-images`, a FAT32 like the ESP, and the ISO as +files under `/images` in its own filesystem. Either holds the signed list, +its signature and each image. ## The command line diff --git a/docs/design/software-delivery.md b/docs/design/software-delivery.md index 81e390dd..8f2adcb8 100644 --- a/docs/design/software-delivery.md +++ b/docs/design/software-delivery.md @@ -221,9 +221,12 @@ up ADR-001 in the zones where the applications run. `images-/controls/`: the same list in `kryptik-release`, and one for another root. - **On the medium.** The USB image carries the list, its signature and each - image in a third partition, `kryptik-images`, an ext4 made as the root's - is; the ISO carries the same files under `/images` in its filesystem. A - release without images has neither. The controls stay with the build. + image in a third partition, `kryptik-images`; the ISO carries the same + files under `/images` in its filesystem. A release without images has + neither. The controls stay with the build. Whoever can write the stick + can craft that partition, and the installer mounts it, so it is a FAT32 + like the ESP: the kernel parses no filesystem for it that the medium did + not already need. What it holds is checked as an update's images are. - **Stored** under `/var/lib/kryptik/images//.img`, with the list for each root as `.manifest` and `.manifest.sig`, root only. Zone 0 keeps the generation the committed root uses and, during a From 5832bf3b8b52b5ada5e3babf492be186d4c7e972 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 23:33:24 -0700 Subject: [PATCH 014/123] An image's reader stops one byte past its signed size, and a refusal partway removes the copies already made The payload's .zst files are read again after the manifest checked them, so a file swapped in between could decompress into /var without end before the size check refused it: images_keep now gives the reader the signed size, and kryptik-update's reader stops one byte past it, so an overlong image fails the size check instead of filling the state partition. A refusal partway through images_keep removes the copies of the images before it rather than leaving them until the next update prunes them. --- build/service-scripts/image-store.sh | 30 +++++++++++++++++--------- tools/tests/update-images.sh | 32 ++++++++++++++++++++++++++++ tools/update/kryptik-update | 5 +++-- 3 files changed, 55 insertions(+), 12 deletions(-) diff --git a/build/service-scripts/image-store.sh b/build/service-scripts/image-store.sh index b255ab35..0c25d8f4 100755 --- a/build/service-scripts/image-store.sh +++ b/build/service-scripts/image-store.sh @@ -4,9 +4,11 @@ # for, each image it names under its own root hash. Sourced; the caller defines say, die and uncache. # images_keep LIST SIG STORE BASE READER: keep in STORE the images LIST names, for the root BASE, which -# LIST must be built for. READER NAME writes image NAME's bytes to stdout. Each copy is checked against -# the size and hash LIST signs before it takes its name, none takes its name until every one has -# checked, and LIST and SIG are kept last. kryptikd checks LIST's signature at every use. +# LIST must be built for. READER NAME BYTES writes image NAME's bytes to stdout, and never more than +# BYTES and one, so a source that changed since it was checked cannot fill the state partition. Each +# copy is checked against the size and hash LIST signs before it takes its name, none takes its name +# until every one has checked, a refusal takes the copies already made with it, and LIST and SIG are +# kept last. kryptikd checks LIST's signature at every use. images_keep() { _l="$1"; _s="$2"; _store="$3"; _base="$4"; _read="$5" [ "$(head -1 "$_l")" = KRYPTIK-IMAGES-1 ] || die "the image list is not a KRYPTIK-IMAGES-1" @@ -16,22 +18,23 @@ images_keep() { while read -r _ _name _ _bytes _sha _ _hash _; do [ -n "$_name" ] || continue _bytes="${_bytes#bytes=}"; _sha="${_sha#sha256=}"; _hash="${_hash#root-hash=}" - case "$_name" in ''|*[!a-z0-9-]*) die "the image list names '$_name'" ;; esac - case "$_bytes" in ''|*[!0-9]*) die "image $_name: its size in the list is not a number" ;; esac - case "$_hash$_sha" in *[!0-9a-f]*) die "image $_name: its hashes in the list are not hex" ;; esac - [ "${#_hash}" = 64 ] && [ "${#_sha}" = 64 ] || die "image $_name: its hashes in the list are not 64 digits" + case "$_name" in ''|*[!a-z0-9-]*) _images_fail "the image list names '$_name'" ;; esac + case "$_bytes" in ''|*[!0-9]*) _images_fail "image $_name: its size in the list is not a number" ;; esac + case "$_hash$_sha" in *[!0-9a-f]*) _images_fail "image $_name: its hashes in the list are not hex" ;; esac + [ "${#_hash}" = 64 ] && [ "${#_sha}" = 64 ] || _images_fail "image $_name: its hashes in the list are not 64 digits" _f="$_store/$_name/$_hash.img" if [ -f "$_f" ] && [ "$(stat -c %s "$_f")" = "$_bytes" ] && [ "$(sha256sum "$_f" | cut -c1-64)" = "$_sha" ]; then say "image $_name: already kept" continue fi - mkdir -p "$_store/$_name" || die "cannot make $_store/$_name" - "$_read" "$_name" > "$_f.new" || { rm -f "$_f.new"; die "image $_name could not be read, or written into the store"; } + mkdir -p "$_store/$_name" || _images_fail "cannot make $_store/$_name" + "$_read" "$_name" "$_bytes" > "$_f.new" \ + || { rm -f "$_f.new"; _images_fail "image $_name could not be read, or written into the store"; } sync -f "$_f.new" uncache "$_f.new" if [ "$(stat -c %s "$_f.new")" != "$_bytes" ] || [ "$(sha256sum "$_f.new" | cut -c1-64)" != "$_sha" ]; then rm -f "$_f.new" - die "image $_name is not the bytes its list signs" + _images_fail "image $_name is not the bytes its list signs" fi _staged="$_staged $_f" say "image $_name: kept and checked ($_bytes bytes)" @@ -45,6 +48,13 @@ EOF || die "the image list could not be kept" } +# A refusal partway through images_keep: the copies already made go with it, or a browser's would sit +# on /var until the next update. +_images_fail() { # _images_fail MESSAGE + for _x in $_staged; do rm -f "$_x.new"; done + die "$1" +} + # images_prune STORE ROOT-HASH...: keep the lists for those roots and the images they name; a list for # any other root, an image no kept list names and a copy left half-made all go. images_prune() { diff --git a/tools/tests/update-images.sh b/tools/tests/update-images.sh index e8476bc0..ab13d707 100755 --- a/tools/tests/update-images.sh +++ b/tools/tests/update-images.sh @@ -114,6 +114,38 @@ stage "${W}/p5" "$R1" [[ "$RC" -ne 0 && "$OUT" == *"REFUSED: the image list names '../x'"* ]] \ && green "an image name that is not a plain name is refused" || red "a name with a path was not refused: ${OUT}" +# The reader stops one byte past the signed size, so a file swapped after the manifest's check +# cannot decompress into the store without end. +head -c 300000 /dev/zero > "${W}/big" +zstd -q -f "${W}/big" -o "${W}/p1/huge.img.zst" +got="$(dir="${W}/p1" bash -c ". '${W}/lib.sh'; payload_image huge 1000" | wc -c)" +[[ "$got" -eq 1001 ]] && green "the payload's reader stops one byte past the size the list signs" \ + || red "the payload's reader gave ${got} bytes for an image signed as 1000" + +# A refusal partway takes the copies already made with it. +rm -rf "${W}/p6"; mkdir -p "${W}/p6" +printf 'first' > "${W}/raw-a"; printf 'second' > "${W}/raw-z" +zstd -q -f "${W}/raw-a" -o "${W}/p6/alpha.img.zst"; zstd -q -f "${W}/raw-z" -o "${W}/p6/zeta.img.zst" +{ + printf 'KRYPTIK-IMAGES-1\nrelease: 1.2.0\nbase: %s\n' "$R1" + printf 'image: alpha 1 bytes=5 sha256=%s data-blocks=1 root-hash=%s salt=%s\n' "$(sha256sum "${W}/raw-a" | cut -c1-64)" "$(hex 6)" "$(hex a)" + printf 'image: zeta 1 bytes=6 sha256=%s data-blocks=1 root-hash=%s salt=%s\n' "$(hex 0)" "$(hex 7)" "$(hex a)" +} > "${W}/p6/images.manifest" +printf 'a signature\n' > "${W}/p6/images.manifest.sig" +{ + printf 'KRYPTIK-MANIFEST-1\n--\n' + for f in images.manifest images.manifest.sig alpha.img.zst zeta.img.zst; do + printf '%s %s %s\n' "$(sha256sum "${W}/p6/$f" | cut -c1-64)" "$(stat -c %s "${W}/p6/$f")" "$f" + done +} > "${W}/p6/manifest" +stage "${W}/p6" "$R1" +if [[ "$RC" -ne 0 && "$OUT" == *"REFUSED: image zeta is not the bytes its list signs"* && "$OUT" == *"image alpha: kept and checked"* ]] \ + && [[ -z "$(find "$STORE" -name '*.new')" && ! -e "${STORE}/alpha/$(hex 6).img" ]]; then + green "a refusal partway removes the copies already made, and keeps none of them" +else + red "a refusal partway left copies behind (exit ${RC}): ${OUT}; $(find "$STORE" -name '*.new')" +fi + # prune: three roots' lists, an image shared by two of them, and a leftover .new. payload "${W}/q2" "$R2" probe "the probe image" "$(hex b)"; stage "${W}/q2" "$R2" payload "${W}/q3" "$R3" viewer "only the third root's" "$(hex d)"; stage "${W}/q3" "$R3" diff --git a/tools/update/kryptik-update b/tools/update/kryptik-update index 896650c6..52924882 100755 --- a/tools/update/kryptik-update +++ b/tools/update/kryptik-update @@ -369,8 +369,9 @@ stage_images() { say "the image list for $VERSION kept" } -# Image NAME as the payload carries it, decompressed. -payload_image() { zstd -dcq < "$(payload_path "$1.img.zst")"; } # payload_image NAME +# Image NAME as the payload carries it, decompressed, and never past the size its list signs and one +# byte: the file is read again after the manifest's check, and a changed one must not fill /var. +payload_image() { zstd -dcq < "$(payload_path "$1.img.zst")" | head -c "$(( $2 + 1 ))"; } # payload_image NAME BYTES # Verify everything, then write only the inactive slot, read it back, install # its kernel and arm BootNext. BOOTX64.EFI is left to boot-success, which From e5b87ed7116005493517485a86144f9ca2842c06 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 23:34:03 -0700 Subject: [PATCH 015/123] The installer mounts the medium's FAT32 images partition, and reads an image no further than its signed size The medium's kryptik-images partition is now a FAT32 made like the ESP, a filesystem the installer already mounts, not an ext4 whose crafted images its maintainers do not treat as a boundary: kryptik-install mounts it vfat, read-only, nosuid, nodev and noexec. Its reader stops one byte past the size the list signs, as kryptik-update's does. --- tools/install/kryptik-install.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tools/install/kryptik-install.sh b/tools/install/kryptik-install.sh index 83162b4d..9ddaac17 100755 --- a/tools/install/kryptik-install.sh +++ b/tools/install/kryptik-install.sh @@ -186,7 +186,8 @@ trap cleanup EXIT INT TERM ESP_SRC="" # a block device or a regular file holding the ESP image IMG_SRC="" # the medium's software images, if it carries any -medium_image() { cat "$IMG_SRC/$1.img"; } # medium_image NAME +# Image NAME as the medium carries it, never past the size its list signs and one byte. +medium_image() { head -c "$(( $2 + 1 ))" "$IMG_SRC/$1.img"; } # medium_image NAME BYTES ROOT_SRC="" # block device holding the root image at ROOT_OFF ROOT_OFF=0 case "$media" in @@ -201,7 +202,7 @@ case "$media" in # The release's software images, when the medium carries them, in a partition of their own. IMG_DEV="$(kryptik_part kryptik-images 2>/dev/null)" || IMG_DEV="" if [ -b "$IMG_DEV" ]; then - mount -t ext4 -o ro,nosuid,nodev,noexec "$IMG_DEV" "$MNT_BASE/images" || die "could not mount the medium's kryptik-images" + mount -t vfat -o ro,nosuid,nodev,noexec "$IMG_DEV" "$MNT_BASE/images" || die "could not mount the medium's kryptik-images" IMG_SRC="$MNT_BASE/images" fi ;; From a4a62c70e18623404683f52cecbd66776b8c8f36 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 23:40:25 -0700 Subject: [PATCH 016/123] The store test lifts _images_fail too, and the bounded reader keeps zstd's broken pipe to itself --- tools/tests/update-images.sh | 3 ++- tools/update/kryptik-update | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tools/tests/update-images.sh b/tools/tests/update-images.sh index ab13d707..91468353 100755 --- a/tools/tests/update-images.sh +++ b/tools/tests/update-images.sh @@ -34,11 +34,12 @@ STORE="${W}/store" echo 'payload_path() { echo "$dir/$1"; }' echo 'manifest_hash() { sed -n '"'"'/^--$/,$p'"'"' "$m" | sed 1d | awk -v r="$1" '"'"'$3==r {print $1; exit}'"'"'; }' sed -n '/^images_keep() {/,/^}/p' "$LIB" + sed -n '/^_images_fail() {/,/^}/p' "$LIB" sed -n '/^images_prune() {/,/^}/p' "$LIB" sed -n '/^stage_images() {/,/^}/p' "$UPD" grep '^payload_image() ' "$UPD" } > "${W}/lib.sh" -for fn in images_keep images_prune stage_images payload_image; do +for fn in images_keep _images_fail images_prune stage_images payload_image; do grep -q "^${fn}() " "${W}/lib.sh" || { echo "FATAL: ${fn} is missing from ${LIB} or ${UPD}" >&2; exit 1; } done diff --git a/tools/update/kryptik-update b/tools/update/kryptik-update index 52924882..770d49d4 100755 --- a/tools/update/kryptik-update +++ b/tools/update/kryptik-update @@ -371,7 +371,7 @@ stage_images() { # Image NAME as the payload carries it, decompressed, and never past the size its list signs and one # byte: the file is read again after the manifest's check, and a changed one must not fill /var. -payload_image() { zstd -dcq < "$(payload_path "$1.img.zst")" | head -c "$(( $2 + 1 ))"; } # payload_image NAME BYTES +payload_image() { zstd -dcq < "$(payload_path "$1.img.zst")" 2>/dev/null | head -c "$(( $2 + 1 ))"; } # payload_image NAME BYTES # Verify everything, then write only the inactive slot, read it back, install # its kernel and arm BootNext. BOOTX64.EFI is left to boot-success, which From dd982c4e222cadfe47b1d48a8f4440f2fe731237 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 12 Oct 2026 23:47:59 -0700 Subject: [PATCH 017/123] Stage 06's only "images" is the image stage's array: the medium's filesystem and directory have names of their own --- build/stages/06-iso.sh | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/build/stages/06-iso.sh b/build/stages/06-iso.sh index dd4c2347..25dcd42a 100755 --- a/build/stages/06-iso.sh +++ b/build/stages/06-iso.sh @@ -569,12 +569,12 @@ make_images_fs() { } make_gpt_image() { # make_gpt_image OUT ESP-IMG ROOT-IMG ROOT-LABEL [IMAGES-FS] - local out="$1" esp="$2" root="$3" label="$4" images="${5:-}" + local out="$1" esp="$2" root="$3" label="$4" imgfs="${5:-}" local esp_bytes root_bytes images_sectors=0 esp_bytes="$(stat -c %s "$esp")"; root_bytes="$(stat -c %s "$root")" local esp_sectors=$(( esp_bytes / 512 )) local root_sectors=$(( (root_bytes + 1048575) / 1048576 * 2048 )) - [[ -z "$images" ]] || images_sectors=$(( ($(stat -c %s "$images") + 1048575) / 1048576 * 2048 )) + [[ -z "$imgfs" ]] || images_sectors=$(( ($(stat -c %s "$imgfs") + 1048575) / 1048576 * 2048 )) local esp_start=2048 local root_start=$(( esp_start + esp_sectors )) local images_start=$(( root_start + root_sectors )) @@ -584,14 +584,14 @@ unit: sectors start=${esp_start}, size=${esp_sectors}, type=C12A7328-F81F-11D2-BA4B-00A0C93EC93B, name=\"kryptik-esp\" start=${root_start}, size=${root_sectors}, type=4F68BCE3-E8CD-4DB1-96E7-FBCAF984B709, name=\"${label}\"" # The release's software images, after the root: the installer copies them into the store. - [[ -z "$images" ]] || table+=" + [[ -z "$imgfs" ]] || table+=" start=${images_start}, size=${images_sectors}, type=0FC63DAF-8483-4772-8E79-3D69D8477DE4, name=\"kryptik-images\"" rm -f "$out" truncate -s $(( total * 512 )) "$out" sfdisk --quiet --wipe always "$out" <<< "$table" dd if="$esp" of="$out" bs=512 seek="$esp_start" conv=notrunc,sparse status=none dd if="$root" of="$out" bs=512 seek="$root_start" conv=notrunc,sparse status=none - [[ -z "$images" ]] || dd if="$images" of="$out" bs=512 seek="$images_start" conv=notrunc,sparse status=none + [[ -z "$imgfs" ]] || dd if="$imgfs" of="$out" bs=512 seek="$images_start" conv=notrunc,sparse status=none sfdisk -l "$out" | sed 's/^/ /' } @@ -611,7 +611,7 @@ s_usb() { } make_iso() { # make_iso OUT ESP-IMG [IMAGES-DIR] - local out="$1" esp="$2" images="${3:-}" + local out="$1" esp="$2" imgdir="${3:-}" local t; t="$(mktemp -d)" cp "$SB_CERT" "$t/kryptik-sb.crt" cp "${IMG}/root.json" "$t/" @@ -623,10 +623,10 @@ from a USB stick use the USB image instead: this ISO's kernel looks for /dev/sr0. EOF local graft=(esp.img="$esp" kryptik-sb.crt="$t/kryptik-sb.crt" root.json="$t/root.json" README.txt="$t/README.txt") - if [[ -n "$images" ]]; then + if [[ -n "$imgdir" ]]; then printf '%s\n' "The software images under images/ are named by images.manifest, which the" \ "release key signs (images.manifest.sig); the installer copies them." >> "$t/README.txt" - graft+=(images="$images") + graft+=(images="$imgdir") fi rm -f "$out" # Linux filesystem data, as a GUID: xorriso takes an MBR type byte or a @@ -649,13 +649,13 @@ s_bind_iso() { echo "inputs digest: $1" echo "--- pass 1: layout (a placeholder ESP of the final size) ---" make_esp "${IMG}/esp-iso.img" slot-a .efi - local images="" + local imgdir="" if medium_images "${IMG}/iso-images"; then - images="${IMG}/iso-images" + imgdir="${IMG}/iso-images" # Signed at sign time: one 2048-byte block holds the signature, as it holds this. - [[ -f "${images}/images.manifest.sig" ]] || printf 'placeholder\n' > "${images}/images.manifest.sig" + [[ -f "${imgdir}/images.manifest.sig" ]] || printf 'placeholder\n' > "${imgdir}/images.manifest.sig" fi - make_iso "${IMG}/layout.iso" "${IMG}/esp-iso.img" ${images:+"$images"} + make_iso "${IMG}/layout.iso" "${IMG}/esp-iso.img" ${imgdir:+"$imgdir"} local start; start="$(part2_start "${IMG}/layout.iso")" [[ -n "$start" ]] || { echo "could not read the appended partition's start"; sfdisk -d "${IMG}/layout.iso"; return 1; } echo "appended root partition starts at sector ${start}" @@ -682,12 +682,12 @@ s_iso() { sign_one media-iso echo "--- pass 2: the real ESP and ISO ---" make_esp "${IMG}/esp-iso.img" media-iso - local images="" + local imgdir="" if medium_images "${IMG}/iso-images"; then - images="${IMG}/iso-images" - [[ -f "${images}/images.manifest.sig" ]] || { echo "images.manifest is not signed"; return 1; } + imgdir="${IMG}/iso-images" + [[ -f "${imgdir}/images.manifest.sig" ]] || { echo "images.manifest is not signed"; return 1; } fi - make_iso "$iso" "${IMG}/esp-iso.img" ${images:+"$images"} + make_iso "$iso" "${IMG}/esp-iso.img" ${imgdir:+"$imgdir"} rm -rf "${IMG}/iso-images" local start2; start2="$(part2_start "$iso")" [[ "$start2" == "$start" ]] || { echo "FAIL: the root partition moved from where the ISO kernel was bound (${start} -> ${start2})"; return 1; } From 1279742d7f19cb231fbb01cfe4478812a7294b2b Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 00:01:26 -0700 Subject: [PATCH 018/123] The toolkit image: GTK 3 for Wayland alone, without X11, D-Bus or introspection, over the base system Fourteen sources, pinned in build/images/versions.env and locked in build/images/sources.lock, apart from the root's: they never reach the root, so they are no input of stage 04 and leave its cache standing. fetch-sources.sh lists them per image (--list-image) and the root's alone (--list-root); stage 04's licences are the root's sources only, and the image installs its own, which stage 06 checks. Every check of the lock reads both. GNOME publishes an unsigned .sha256sum named for the stem, a new kind the provenance check reads by file name. gdk-pixbuf reads PNG alone, built in; pcre2 has no JIT; cairo keeps no tracer or script interpreter; fontconfig's and GTK's configuration is under /usr/etc. at-spi2-core is patched to build ATK without libxml2, which only the bridge's tests use. The zones suite runs fc-match and gtk-query-immodules-3.0 in a zone that names the image. --- .github/actions/prepare/action.yml | 4 +- .github/workflows/ci.yml | 23 +-- .github/workflows/distro.yml | 2 +- build/guest-tests/zones-check.sh | 14 ++ build/images/sources.lock | 14 ++ build/images/toolkit.programs | 68 +++++++++ build/images/toolkit.sh | 136 ++++++++++++++++++ build/images/versions.env | 24 ++++ build/lib/common.sh | 15 ++ ...0001-Build-ATK-alone-without-libxml2.patch | 21 +++ build/patches/at-spi2-core-2.62.1/README.md | 12 ++ build/patches/at-spi2-core-2.62.1/SHA256SUMS | 1 + build/recipes/licences.sh | 13 +- build/stages/04-images.sh | 2 +- build/stages/06-iso.sh | 1 + docs/design/browser-and-graphics.md | 34 +++-- docs/design/software-delivery.md | 4 + docs/supply-chain.md | 7 +- tools/acceptance.sh | 6 +- tools/artifact-manifest.sh | 4 +- tools/check-image-licences.sh | 26 +++- tools/currency-rules.tsv | 15 ++ tools/fetch-sources.sh | 94 +++++++++--- tools/image/zones-test.sh | 2 +- tools/provenance-inventory.sh | 20 +-- tools/source-bundle.sh | 14 +- tools/tests/image-licences.sh | 12 ++ tools/verify-provenance.sh | 22 +-- tools/verify-signatures.sh | 6 +- 29 files changed, 528 insertions(+), 88 deletions(-) create mode 100644 build/images/sources.lock create mode 100644 build/images/toolkit.programs create mode 100644 build/images/toolkit.sh create mode 100644 build/images/versions.env create mode 100644 build/patches/at-spi2-core-2.62.1/0001-Build-ATK-alone-without-libxml2.patch create mode 100644 build/patches/at-spi2-core-2.62.1/README.md create mode 100644 build/patches/at-spi2-core-2.62.1/SHA256SUMS diff --git a/.github/actions/prepare/action.yml b/.github/actions/prepare/action.yml index 4ba333e3..676bf4f8 100644 --- a/.github/actions/prepare/action.yml +++ b/.github/actions/prepare/action.yml @@ -40,7 +40,7 @@ runs: uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: /mnt/kryptik/sources - key: sources-v3-${{ hashFiles('sources.lock') }} + key: sources-v3-${{ hashFiles('sources.lock', 'build/images/sources.lock') }} # A changed lock restores the previous set; every file is still hashed against the lock. restore-keys: sources- @@ -60,4 +60,4 @@ runs: uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: /mnt/kryptik/sources - key: sources-v3-${{ hashFiles('sources.lock') }} + key: sources-v3-${{ hashFiles('sources.lock', 'build/images/sources.lock') }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be06ef01..a133c893 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -131,7 +131,7 @@ jobs: uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: /mnt/kryptik/sources - key: sources-v3-${{ hashFiles('sources.lock') }} + key: sources-v3-${{ hashFiles('sources.lock', 'build/images/sources.lock') }} restore-keys: | sources- @@ -205,17 +205,22 @@ jobs: - name: Signatures, strict run: ./tools/verify-signatures.sh --strict + # The root's sources in sources.lock, the software images' in build/images/sources.lock. - name: sources.lock covers the manifest run: | fail=0 - while read -r name ver url _; do - file=$(basename "$url") - if ! grep -q " $file\$" sources.lock; then - echo " MISSING FROM LOCK: $file" - fail=1 - fi - done < <(./tools/fetch-sources.sh --list) - [ $fail -eq 0 ] && echo "sources.lock covers every manifest entry" + check() { + while read -r name ver url _; do + file=$(basename "$url") + if ! grep -q " $file\$" "$1"; then + echo " MISSING FROM $1: $file" + fail=1 + fi + done + } + check sources.lock < <(./tools/fetch-sources.sh --list-root) + check build/images/sources.lock < <(./tools/fetch-sources.sh --list | grep -vxF -f <(./tools/fetch-sources.sh --list-root)) + [ $fail -eq 0 ] && echo "sources.lock and build/images/sources.lock cover every manifest entry" exit $fail compartments: diff --git a/.github/workflows/distro.yml b/.github/workflows/distro.yml index 7f9b4532..e95e518b 100644 --- a/.github/workflows/distro.yml +++ b/.github/workflows/distro.yml @@ -332,7 +332,7 @@ jobs: # After the cache is packed, so no image tree or overlay reaches it; stage 06 packs what this builds. - name: Software images (over the base system, in the chroot) - timeout-minutes: 30 + timeout-minutes: 90 run: make -o system images # The throwaway production pair, then A and B; a production release is only bound here, where upstream build scripts ran as root. diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index e9612f8c..8a26ddaa 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -1666,6 +1666,20 @@ else else fail "image-corrupt" "could not find or flip the probe's first block (${blk:-none})" fi + # The toolkit image over a zone's /usr: its fontconfig finds the root's fonts, and every library + # GTK loads resolves in the zone, with no display to open. + tk_hash="$(sed -n 's/^image: toolkit .* root-hash=\([0-9a-f]\{64\}\) .*/\1/p' "$D/images.manifest")" + if [[ -n "$tk_hash" ]] && install -D -m 0644 "$D/toolkit.img" "$IMGS/toolkit/$tk_hash.img"; then + izone imgtk 4194304 '#0a1b6c' toolkit + irun imgtk sh -c 'fc-match sans; gtk-query-immodules-3.0 | head -1' + if [[ "$ZRC" -eq 0 && "$ZOUT" == *"DejaVu Sans"* && "$ZOUT" == *"Input Method Modules file"* ]]; then + pass "image-toolkit" "a zone naming the toolkit loads GTK and finds its fonts: $(head -1 <<<"$ZOUT")" + else + fail "image-toolkit" "rc ${ZRC}: ${ZOUT:-no output}; $(tail -3 <<<"$ZERR" | tr '\n' ' ')" + fi + else + fail "image-toolkit" "the list names no toolkit image, or it could not be kept (${tk_hash:-none})" + fi umount /run/zt-images fi diff --git a/build/images/sources.lock b/build/images/sources.lock new file mode 100644 index 00000000..70dfeaeb --- /dev/null +++ b/build/images/sources.lock @@ -0,0 +1,14 @@ +cca6b6a86e4389d89b370288761ce2385ddeceb10c4412da78689bc30c88af2d at-spi2-core-2.62.1.tar.xz +1c767308174337a74694da0f3ec069c271452163a1ef4540964c50c301f157d4 cairo-1.18.6.tar.xz +4f7b554a38cdf78c033f666c8871f3749e14a094f65a07f630c91ed0b43d35e3 fontconfig-2.18.3.tar.xz +93cd47eb719c325107f2d3cf087d06299e944c3b48a40b55537302304dea1dd5 freetype-2.15.0.tar.xz +6949dcde27d41cebad1fd741fcafc36d55a1020d2d872d4a6eb3914caabbada2 fribidi-1.0.17.tar.xz +919f529512961a12e81cd4b4b466a48c3933469e7f9a310c6513cd4fb252ba3c gdk-pixbuf-2.44.8.tar.xz +93c941aa17d5eb1d53fe838365f29a8b4e539c222a256d974ec8f30fc413e396 glib-2.90.1.tar.xz +80931fa472a77b9a164f6740e3c0b444fac6770054632d35a7ff9d679e5e7b9f gtk-3.24.52.tar.xz +d07a007327277708a2a73ae437887cdbaf282937f6d03ca5467723e9099af586 harfbuzz-14.6.0.tar.xz +db0e50a80aa3bf64bb45cbca5cf9f75efd9348cf2ac690b907435238c3cf81d7 hicolor-icon-theme-0.18.tar.xz +a7ced37f4102b745ac86d6a70a9da399cc139ff168ba6b8002b4d8d43c900c15 libepoxy-1.5.10.tar.gz +d80dd2a38a37f803cb9b6ac7b14bd6e74ddc3b654780a8380bdf93523fdb4389 libpng-1.6.59.tar.xz +342385b6ca3b7c73455d7c80a13b7dbe4489e00bc3bd4c5bd6ed4dce421e374a pango-1.58.2.tar.xz +53c156e1ba416a20da8e65395daa132da0d80e76910424caca3fcdae7831d384 pcre2-10.49.tar.bz2 diff --git a/build/images/toolkit.programs b/build/images/toolkit.programs new file mode 100644 index 00000000..985a7f69 --- /dev/null +++ b/build/images/toolkit.programs @@ -0,0 +1,68 @@ +# The toolkit image's executable files, as build/config/image-programs.txt names the root's. +usr/bin/fc-cache +usr/bin/fc-cat +usr/bin/fc-conflist +usr/bin/fc-genconf +usr/bin/fc-list +usr/bin/fc-match +usr/bin/fc-pattern +usr/bin/fc-query +usr/bin/fc-scan +usr/bin/fc-validate +usr/bin/gapplication +usr/bin/gdbus +usr/bin/gdbus-codegen +usr/bin/gdk-pixbuf-csource +usr/bin/gdk-pixbuf-pixdata +usr/bin/gdk-pixbuf-query-loaders +usr/bin/gi-compile-repository +usr/bin/gi-decompile-typelib +usr/bin/gi-inspect-typelib +usr/bin/gio +usr/bin/gio-querymodules +usr/bin/glib-compile-resources +usr/bin/glib-compile-schemas +usr/bin/glib-genmarshal +usr/bin/glib-gettextize +usr/bin/glib-mkenums +usr/bin/gobject-query +usr/bin/gresource +usr/bin/gsettings +usr/bin/gtester +usr/bin/gtester-report +usr/bin/gtk-builder-tool +usr/bin/gtk-encode-symbolic-svg +usr/bin/gtk-launch +usr/bin/gtk-query-immodules-3.0 +usr/bin/gtk-query-settings +usr/bin/gtk-update-icon-cache +usr/bin/gtk3-demo +usr/bin/gtk3-demo-application +usr/bin/gtk3-icon-browser +usr/bin/gtk3-widget-factory +usr/libexec/gio-launch-desktop +usr/lib/gtk-3.0/3.0.0/printbackends/libprintbackend-file.so +usr/lib/libatk-1.0.so.0.* +usr/lib/libcairo-gobject.so.2.* +usr/lib/libcairo.so.2.* +usr/lib/libepoxy.so.0.* +usr/lib/libfontconfig.so.1.* +usr/lib/libfreetype.so.6.* +usr/lib/libfribidi.so.0.* +usr/lib/libgailutil-3.so.0.* +usr/lib/libgdk-3.so.0.* +usr/lib/libgdk_pixbuf-2.0.so.0.* +usr/lib/libgio-2.0.so.0.* +usr/lib/libgirepository-2.0.so.0.* +usr/lib/libglib-2.0.so.0.* +usr/lib/libgmodule-2.0.so.0.* +usr/lib/libgobject-2.0.so.0.* +usr/lib/libgthread-2.0.so.0.* +usr/lib/libgtk-3.so.0.* +usr/lib/libharfbuzz.so.0.* +usr/lib/libpango-1.0.so.0.* +usr/lib/libpangocairo-1.0.so.0.* +usr/lib/libpangoft2-1.0.so.0.* +usr/lib/libpcre2-8.so.0.* +usr/lib/libpcre2-posix.so.3.* +usr/lib/libpng16.so.16.* diff --git a/build/images/toolkit.sh b/build/images/toolkit.sh new file mode 100644 index 00000000..e0f01659 --- /dev/null +++ b/build/images/toolkit.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# The toolkit image: GTK 3 for Wayland alone, without X11, D-Bus or GObject introspection, and the +# libraries under it (docs/design/browser-and-graphics.md). The browser image is built over it; its +# demos are the first GUI programs a zone can run. Sources pinned in build/images/versions.env. +# shellcheck disable=SC2034 # read by build/stages/04-images.sh +image_version="${V_GTK3}" +# shellcheck disable=SC2034 +image_rows=( + "pcre2" "s_tk_pcre2" + "glib" "s_tk_glib" + "libpng" "s_tk_libpng" + "freetype" "s_tk_freetype" + "harfbuzz" "s_tk_harfbuzz" + "fontconfig" "s_tk_fontconfig" + "fribidi" "s_tk_fribidi" + "cairo" "s_tk_cairo" + "pango" "s_tk_pango" + "gdk-pixbuf" "s_tk_gdk_pixbuf" + "libepoxy" "s_tk_libepoxy" + "atk" "s_tk_atk" + "hicolor" "s_tk_hicolor" + "gtk3" "s_tk_gtk3" + "licences" "s_tk_licences" +) + +# Only what GLib needs: the 8-bit library, without the JIT, which writes code into memory it runs. +s_tk_pcre2() { + native_build "pcre2-${V_PCRE2}.tar.bz2" "pcre2-${V_PCRE2}" --disable-static --enable-unicode \ + --disable-jit --disable-pcre2-16 --disable-pcre2-32 --disable-pcre2grep-libz \ + --disable-pcre2grep-libbz2 --disable-pcre2test-libreadline + rm -f /usr/bin/pcre2grep /usr/bin/pcre2test /usr/bin/pcre2-config +} + +s_tk_glib() { + meson_build "glib-${V_GLIB}.tar.xz" "glib-${V_GLIB}" \ + -Dselinux=disabled -Dlibmount=disabled -Dlibelf=disabled -Dsysprof=disabled \ + -Dintrospection=disabled -Dman-pages=disabled -Ddocumentation=false -Dtests=false \ + -Dinstalled_tests=false -Ddtrace=disabled -Dsystemtap=disabled -Dglib_debug=disabled +} + +s_tk_libpng() { + native_build "libpng-${V_LIBPNG}.tar.xz" "libpng-${V_LIBPNG}" --disable-static + rm -f /usr/bin/pngfix /usr/bin/png-fix-itxt /usr/bin/libpng-config /usr/bin/libpng16-config +} + +# Without HarfBuzz, which is built on it, and without brotli and bzip2, which no shipped font needs. +s_tk_freetype() { + meson_build "freetype-${V_FREETYPE}.tar.xz" "freetype-${V_FREETYPE}" \ + -Dharfbuzz=disabled -Dbrotli=disabled -Dbzip2=disabled -Dpng=enabled -Dzlib=system -Dtests=disabled +} + +# The shaping library alone: no subsetter, rasteriser, vector or GPU paths, and no utilities. +s_tk_harfbuzz() { + meson_build "harfbuzz-${V_HARFBUZZ}.tar.xz" "harfbuzz-${V_HARFBUZZ}" \ + -Dglib=enabled -Dfreetype=enabled -Dgobject=disabled -Dcairo=disabled -Dchafa=disabled \ + -Dicu=disabled -Dpng=disabled -Dzlib=disabled -Draster=disabled -Dvector=disabled \ + -Dgpu=disabled -Dgpu_demo=disabled -Dsubset=disabled -Dutilities=disabled \ + -Dtests=disabled -Dintrospection=disabled -Ddocs=disabled -Dbenchmark=disabled +} + +# Its configuration under /usr/etc, as an image adds nothing outside /usr. No system cache is made: a +# zone's fontconfig keeps one in the user's home. +s_tk_fontconfig() { + meson_build "fontconfig-${V_FONTCONFIG}.tar.xz" "fontconfig-${V_FONTCONFIG}" --sysconfdir=/usr/etc \ + -Dtools=enabled -Dcache-build=disabled -Ddoc=disabled -Dtests=disabled -Dnls=disabled \ + -Dxml-backend=expat -Dfontations=disabled + rmdir /var/cache/fontconfig +} + +s_tk_fribidi() { + meson_build "fribidi-${V_FRIBIDI}.tar.xz" "fribidi-${V_FRIBIDI}" -Ddocs=false -Dbin=false -Dtests=false +} + +# PDF and PostScript surfaces for GTK's print-to-file; no X11, and no tracer or script interpreter, +# which zlib would otherwise bring. +s_tk_cairo() { + meson_build "cairo-${V_CAIRO}.tar.xz" "cairo-${V_CAIRO}" \ + -Dxlib=disabled -Dxcb=disabled -Dquartz=disabled -Ddwrite=disabled -Dtee=disabled \ + -Dpng=enabled -Dzlib=enabled -Dfreetype=enabled -Dfontconfig=enabled -Dglib=enabled \ + -Dspectre=disabled -Dsymbol-lookup=disabled -Dlzo=disabled -Dtests=disabled + rm -rf /usr/lib/cairo /usr/bin/cairo-trace + rm -f /usr/lib/libcairo-script-interpreter.so* /usr/lib/pkgconfig/cairo-script-interpreter.pc \ + /usr/include/cairo/cairo-script-interpreter.h +} + +s_tk_pango() { + meson_build "pango-${V_PANGO}.tar.xz" "pango-${V_PANGO}" \ + -Dintrospection=disabled -Dbuild-testsuite=false -Dbuild-examples=false -Dfontconfig=enabled \ + -Dfreetype=enabled -Dcairo=enabled -Dlibthai=disabled -Dxft=disabled -Dsysprof=disabled + rm -f /usr/bin/pango-view /usr/bin/pango-list /usr/bin/pango-segmentation +} + +# PNG alone, built in: GTK's theme and icons are PNG, and every other loader is a parser more. +s_tk_gdk_pixbuf() { + meson_build "gdk-pixbuf-${V_GDK_PIXBUF}.tar.xz" "gdk-pixbuf-${V_GDK_PIXBUF}" \ + -Dpng=enabled -Djpeg=disabled -Dtiff=disabled -Dgif=disabled -Dothers=disabled \ + -Dglycin=disabled -Dandroid=disabled -Dbuiltin_loaders=png -Dgio_sniffing=false \ + -Dthumbnailer=disabled -Dintrospection=disabled -Dman=false -Dtests=false -Dinstalled_tests=false +} + +# EGL only, loaded when a program asks for GL; software zones never do. +s_tk_libepoxy() { + meson_build "libepoxy-${V_LIBEPOXY}.tar.gz" "libepoxy-${V_LIBEPOXY}" \ + -Dglx=no -Degl=yes -Dx11=false -Dtests=false -Ddocs=false +} + +# ATK alone: the accessibility bridge talks D-Bus, which no zone has. +s_tk_atk() { + local src; src="$(unpack "at-spi2-core-${V_AT_SPI2_CORE}.tar.xz" "at-spi2-core-${V_AT_SPI2_CORE}")" + cd "$src" + apply_repo_patches "at-spi2-core-${V_AT_SPI2_CORE}" + meson setup build --prefix=/usr --buildtype=plain --wrap-mode=nodownload \ + -Datk_only=true -Dintrospection=disabled -Ddocs=false -Dx11=disabled \ + -Dgtk2_atk_adaptor=false -Duse_systemd=false + ninja -C build + ninja -C build install +} + +s_tk_hicolor() { + meson_build "hicolor-icon-theme-${V_HICOLOR_ICON_THEME}.tar.xz" "hicolor-icon-theme-${V_HICOLOR_ICON_THEME}" +} + +# Wayland alone; input methods built in, so no module cache is read; printing to a file only. +s_tk_gtk3() { + meson_build "gtk-${V_GTK3}.tar.xz" "gtk-${V_GTK3}" --sysconfdir=/usr/etc \ + -Dx11_backend=false -Dwayland_backend=true -Dbroadway_backend=false -Dxinerama=no \ + -Dcloudproviders=false -Dprofiler=false -Dtracker3=false -Dcolord=no -Dprint_backends=file \ + -Dintrospection=false -Dgtk_doc=false -Dman=false -Ddemos=true -Dexamples=false -Dtests=false \ + -Dinstalled_tests=false -Dbuiltin_immodules=all + # Property names for interface builders: no program in a zone shows them. + rm -f /usr/share/locale/*/LC_MESSAGES/gtk30-properties.mo +} + +s_tk_licences() { + echo "$(source_licences < <("${KRYPTIK_ROOT}/tools/fetch-sources.sh" --list-image toolkit)) licence files" +} diff --git a/build/images/versions.env b/build/images/versions.env new file mode 100644 index 00000000..bdd49984 --- /dev/null +++ b/build/images/versions.env @@ -0,0 +1,24 @@ +# Pins of the software images' sources (build/images), apart from the root's in +# build/config/versions.env: they never reach the root, so a change here is no +# input of stage 04 and leaves its cache standing. Their checksums are in +# build/images/sources.lock; tools/fetch-sources.sh lists them per image. + +MIRROR_GNOME="https://download.gnome.org/sources" +MIRROR_ICON_THEME="https://icon-theme.freedesktop.org/releases" + +# --- toolkit: GTK 3 for Wayland alone, without X11 or D-Bus --- +V_PCRE2=10.49 +V_GLIB=2.90.1 +V_LIBPNG=1.6.59 +V_FREETYPE=2.15.0 +V_HARFBUZZ=14.6.0 +V_FONTCONFIG=2.18.3 +V_FRIBIDI=1.0.17 +V_CAIRO=1.18.6 +V_PANGO=1.58.2 +V_GDK_PIXBUF=2.44.8 +V_LIBEPOXY=1.5.10 +# ATK, which GTK 3 requires, is built from here without the D-Bus bridge. +V_AT_SPI2_CORE=2.62.1 +V_HICOLOR_ICON_THEME=0.18 +V_GTK3=3.24.52 diff --git a/build/lib/common.sh b/build/lib/common.sh index 5bdb144c..ad138614 100644 --- a/build/lib/common.sh +++ b/build/lib/common.sh @@ -9,6 +9,9 @@ KRYPTIK_WORK="${KRYPTIK_WORK:-${KRYPTIK_ROOT}/build/work}" KRYPTIK_OUT="${KRYPTIK_OUT:-${KRYPTIK_ROOT}/out}" # shellcheck disable=SC2034 # consumed by tools/fetch-sources.sh KRYPTIK_LOCK="${KRYPTIK_ROOT}/sources.lock" +# The software images' sources, locked apart from the root's: no stage 04 input names them. +# shellcheck disable=SC2034 +KRYPTIK_IMAGES_LOCK="${KRYPTIK_ROOT}/build/images/sources.lock" if [[ -t 1 ]] && [[ -z "${NO_COLOR:-}" ]]; then C_RED=$'\033[31m'; C_GRN=$'\033[32m'; C_YEL=$'\033[33m' @@ -152,6 +155,18 @@ LFS stages that need privilege escalate explicitly and narrowly." load_config() { # shellcheck source=/dev/null source "${KRYPTIK_ROOT}/build/config/versions.env" + # The software images' pins, apart for the same reason as their lock. + # shellcheck source=/dev/null + if [[ -f "${KRYPTIK_ROOT}/build/images/versions.env" ]]; then source "${KRYPTIK_ROOT}/build/images/versions.env"; fi +} + +# lock_entry FILE: FILE's hash in sources.lock or the images' lock, or failure. +lock_entry() { + local l + for l in "$KRYPTIK_LOCK" "$KRYPTIK_IMAGES_LOCK"; do + [[ -f "$l" ]] && awk -v f="$1" '$2 == f { print $1; found=1; exit } END { exit !found }' "$l" && return 0 + done + return 1 } load_hardening() { diff --git a/build/patches/at-spi2-core-2.62.1/0001-Build-ATK-alone-without-libxml2.patch b/build/patches/at-spi2-core-2.62.1/0001-Build-ATK-alone-without-libxml2.patch new file mode 100644 index 00000000..40f0d3b5 --- /dev/null +++ b/build/patches/at-spi2-core-2.62.1/0001-Build-ATK-alone-without-libxml2.patch @@ -0,0 +1,21 @@ +Subject: [PATCH] Build ATK alone without libxml2 + +Only the accessibility bridge's tests use libxml2, and an atk_only build +makes neither the bridge nor its tests, so it need not require it. +--- + meson.build | 3 ++- + 1 file changed, 2 insertions(+), 1 deletion(-) + +diff --git a/meson.build b/meson.build +--- a/meson.build ++++ b/meson.build +@@ -151,7 +151,8 @@ + endif + endif + gmodule_dep = dependency('gmodule-2.0', version: gmodule_req_version) +-libxml_dep = dependency('libxml-2.0', version: libxml_req_version) ++# Only the bridge's tests use it, and atk_only builds neither. ++libxml_dep = dependency('libxml-2.0', version: libxml_req_version, required: not get_option('atk_only')) + + x11_deps = [] + x11_dep = dependency('x11', required: get_option('x11')) diff --git a/build/patches/at-spi2-core-2.62.1/README.md b/build/patches/at-spi2-core-2.62.1/README.md new file mode 100644 index 00000000..d9b09e4a --- /dev/null +++ b/build/patches/at-spi2-core-2.62.1/README.md @@ -0,0 +1,12 @@ +# at-spi2-core 2.62.1: ATK alone, without libxml2 + +Applied by `s_tk_atk` in the toolkit image (`build/images/toolkit.sh`) +through `apply_repo_patches`; `SHA256SUMS` is verified before anything is +applied. + +GTK 3 requires ATK, which now ships in at-spi2-core. Its `atk_only` option +builds ATK without the D-Bus accessibility bridge, which the toolkit leaves +out, but `meson.build` still requires libxml2 at the top level, though only +the bridge's tests use it. The patch makes it optional in an `atk_only` +build, so the image carries no XML parser for nothing. Delete this directory +when the pin moves to a release whose `meson.build` does the same. diff --git a/build/patches/at-spi2-core-2.62.1/SHA256SUMS b/build/patches/at-spi2-core-2.62.1/SHA256SUMS new file mode 100644 index 00000000..4321f4ac --- /dev/null +++ b/build/patches/at-spi2-core-2.62.1/SHA256SUMS @@ -0,0 +1 @@ +e702461395dc2f7dc0ff8380f63c4089ae60ed2ab654009d5866da6982b73439 0001-Build-ATK-alone-without-libxml2.patch diff --git a/build/recipes/licences.sh b/build/recipes/licences.sh index 1400e30c..b8cf17c8 100644 --- a/build/recipes/licences.sh +++ b/build/recipes/licences.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash -# Every source's licence files, from its own tarball, under /usr/share/licenses//. -s_licences() { +# Each listed source's licence files, from its own tarball, under /usr/share/licenses//; +# prints how many. The root's sources for stage 04, an image's for that image. +source_licences() { # source_licences < tools/fetch-sources.sh --list-root|--list-image NAME # Also the microcode's lowercase license, firmware's WHENCE and the kernel's LICENSES/. local more_re='^[^/]+/(license|WHENCE|LICENSES/(preferred|exceptions)/[^/]+)$' local name url f m dir tmp n=0 members @@ -24,7 +25,13 @@ s_licences() { fi done rm -rf "$tmp" - done < <("${KRYPTIK_ROOT}/tools/fetch-sources.sh" --list) + done + echo "$n" +} + +s_licences() { + local f n members + n="$(source_licences < <("${KRYPTIK_ROOT}/tools/fetch-sources.sh" --list-root))" # Texts no tarball carries: the CA bundle's MPL, and Rust std, libc and musl in static binaries. for f in "${KRYPTIK_ROOT}"/build/licences/*/*; do diff --git a/build/stages/04-images.sh b/build/stages/04-images.sh index df473dcd..64c28086 100755 --- a/build/stages/04-images.sh +++ b/build/stages/04-images.sh @@ -20,7 +20,7 @@ image_inputs() { # image_inputs NAME for f in "${DEFS}/${1}.sh" "${DEFS}/${1}"/* "${KRYPTIK_ROOT}/build/lib/recipes.sh" "${KRYPTIK_ROOT}"/build/recipes/*.sh \ "${BASH_SOURCE[0]}" "${KRYPTIK_ROOT}/tools/check-image-tree.sh" "${KRYPTIK_ROOT}/build/config/hardening.env" \ "${KRYPTIK_ROOT}/build/config/hardening-exceptions.txt" "${KRYPTIK_ROOT}/build/config/versions.env" \ - "${KRYPTIK_ROOT}/sources.lock"; do + "${KRYPTIK_ROOT}/sources.lock" "${DEFS}/versions.env" "${DEFS}/sources.lock"; do if [[ -f "$f" ]]; then printf '%s %s\n' "${f#"${KRYPTIK_ROOT}/"}" "$(sha256_of "$f")"; fi done printf 'base %s\n' "$(_stamp_read "${KRYPTIK_WORK}/.stamps/bs-licences")" diff --git a/build/stages/06-iso.sh b/build/stages/06-iso.sh index 2892dc40..f82d39b9 100755 --- a/build/stages/06-iso.sh +++ b/build/stages/06-iso.sh @@ -353,6 +353,7 @@ pack_image() { if find "$stage" -perm /6000 -print | grep . >&2; then echo "FAIL: a setuid or setgid file in image ${name}" >&2; return 1; fi "${KRYPTIK_ROOT}/tools/check-artifact-hardening.sh" --strict "$stage" >&2 "${KRYPTIK_ROOT}/tools/check-image-programs.sh" "$stage" --list "${KRYPTIK_ROOT}/build/images/${name}.programs" >&2 + "${KRYPTIK_ROOT}/tools/check-image-licences.sh" --image "$name" "$stage" >&2 local bytes; bytes="$(du -sb "$stage" | cut -f1)" local fs_bytes=$(( bytes + bytes / 8 + 16 * 1024 * 1024 )) fs_bytes=$(( (fs_bytes + 4095) / 4096 * 4096 )) diff --git a/docs/design/browser-and-graphics.md b/docs/design/browser-and-graphics.md index 6827f230..8ef2a0ff 100644 --- a/docs/design/browser-and-graphics.md +++ b/docs/design/browser-and-graphics.md @@ -106,15 +106,31 @@ browser. ## The toolkit stack GTK 3 for Firefox, built with `-Dx11_backend=false`, so no X11 library -enters the image, and without the accessibility bridge, which talks D-Bus: - -- glib, pango, harfbuzz, fribidi, freetype, fontconfig, cairo (pixman is - already built), gdk-pixbuf with libpng and libjpeg-turbo, libepoxy, ATK - from at-spi2-core; -- shared-mime-info, the hicolor theme and one small icon theme; -- GSettings with its keyfile backend, since dconf needs D-Bus; -- fonts: DejaVu is shipped; Noto for wider scripts costs tens of megabytes, - and CJK fonts a hundred or more, so they are a choice, not a default. +enters the image, and without the accessibility bridge, which talks D-Bus. +It is the `toolkit` software image (`build/images/toolkit.sh`), which the +browser image is built over: + +- glib (with pcre2, without its JIT), pango, harfbuzz, fribidi, freetype, + fontconfig, cairo (pixman is the root's), gdk-pixbuf with libpng, libepoxy + and ATK from at-spi2-core, built alone with a patch that drops its + unused libxml2; +- gdk-pixbuf reads PNG alone, built in: GTK's theme and icons are PNG, and + each other loader is a parser more. JPEG, GIF and the rest come only if a + program the image carries needs them; Firefox decodes its own; +- the hicolor theme. An icon theme with symbolic icons needs librsvg, so + GTK falls back to its built-in icons until one is chosen; +- fontconfig's and GTK's configuration under `/usr/etc`, as an image adds + nothing outside `/usr`; fontconfig keeps its cache in the user's home; +- GSettings with the memory backend by default, or the keyfile backend + (`GSETTINGS_BACKEND=keyfile`), since dconf needs D-Bus; +- printing to a file alone, which GTK requires; no CUPS; +- fonts: DejaVu is the root's; Noto for wider scripts costs tens of + megabytes, and CJK fonts a hundred or more, so they are a choice, not a + default. + +gtk3-demo and gtk3-widget-factory come with it, the first GUI programs a +zone can run. The zones suite runs `fc-match` and `gtk-query-immodules-3.0` +in a zone that names the image, which loads every library GTK needs. Firefox's own copies of NSS, NSPR, libvpx, dav1d and opus stay bundled: they are built from source in its tree either way, and every library made diff --git a/docs/design/software-delivery.md b/docs/design/software-delivery.md index 8d2fd551..4940ae78 100644 --- a/docs/design/software-delivery.md +++ b/docs/design/software-delivery.md @@ -195,6 +195,10 @@ up ADR-001 in the zones where the applications run. bit, or file capability. The probe image (`build/images/probe.sh`), one small program that locks its memory, proves the stage and the zones suite's checks; it ships to no one. + An image's sources are pinned in `build/images/versions.env` and locked in + `build/images/sources.lock`, so a change to them leaves stage 04 alone, + and the image installs their licence files itself, as stage 04 does the + root's; stage 06 refuses an image missing one. - **Packed.** Stage 06 makes `images-/.img` the way it makes the root (ext4 without a journal, then the verity tree, with a salt and UUID derived from the content), runs the root's artifact audit diff --git a/docs/supply-chain.md b/docs/supply-chain.md index ecec9518..42098095 100644 --- a/docs/supply-chain.md +++ b/docs/supply-chain.md @@ -31,7 +31,12 @@ Two build tools that do not ship are special cases: ## Source integrity `sources.lock` pins a SHA-256 for every upstream tarball, and -`tools/fetch-sources.sh` refuses to proceed on a mismatch. +`tools/fetch-sources.sh` refuses to proceed on a mismatch. The software +images' sources are pinned apart, in `build/images/versions.env` and +`build/images/sources.lock`: they never reach the root, so a change to them +is no input of stage 04 and leaves its cache standing. Every check here +reads both, and `fetch-sources.sh --list-root` and `--list-image NAME` give +each set alone. The lock alone is trust on first use: `--lock` records the hash of whatever was downloaded. Before committing a lock file or changing an entry, run diff --git a/tools/acceptance.sh b/tools/acceptance.sh index 43926560..45ce11bc 100755 --- a/tools/acceptance.sh +++ b/tools/acceptance.sh @@ -338,9 +338,9 @@ it_compositor_sources() { return "$ok" } it_sources_lock() { - echo "-- every build input matches sources.lock (offline)" - ( cd "$KRYPTIK_SOURCES" && sha256sum --check --quiet --strict "${ROOT}/sources.lock" ) || return 1 - echo " ok: $(grep -c . "${ROOT}/sources.lock") entries verified" + echo "-- every build input matches sources.lock and the software images' lock (offline)" + ( cd "$KRYPTIK_SOURCES" && sha256sum --check --quiet --strict "${ROOT}/sources.lock" "${ROOT}/build/images/sources.lock" ) || return 1 + echo " ok: $(cat "${ROOT}/sources.lock" "${ROOT}/build/images/sources.lock" | grep -c .) entries verified" } # Check each medium's sidecar against the hash the run took at the start. it_media_hashes() { diff --git a/tools/artifact-manifest.sh b/tools/artifact-manifest.sh index 1be99da9..94090bc8 100755 --- a/tools/artifact-manifest.sh +++ b/tools/artifact-manifest.sh @@ -47,7 +47,9 @@ emit_inputs() { "$KRYPTIK_ROOT"/build/config/hardening.env \ "$KRYPTIK_ROOT"/build/config/hardening-exceptions.txt \ "$KRYPTIK_ROOT"/build/config/kernel/*.fragment \ - "$KRYPTIK_ROOT"/sources.lock; do + "$KRYPTIK_ROOT"/sources.lock \ + "$KRYPTIK_ROOT"/build/images/versions.env \ + "$KRYPTIK_ROOT"/build/images/sources.lock; do [[ -f "$f" ]] || continue rel="${f#"$KRYPTIK_ROOT"/}" printf 'input\trecipe\t%s\t%s\n' "$rel" "$(sha256_of "$f")" diff --git a/tools/check-image-licences.sh b/tools/check-image-licences.sh index 21f01885..9dfd488e 100755 --- a/tools/check-image-licences.sh +++ b/tools/check-image-licences.sh @@ -1,14 +1,21 @@ #!/usr/bin/env bash # Checks ROOT/usr/share/licenses/ holds Kryptik's licence and each source's, or its exception line. # -# ./tools/check-image-licences.sh ROOT +# ./tools/check-image-licences.sh ROOT the root image: the root's sources +# ./tools/check-image-licences.sh --image NAME TREE software image NAME: the sources it is built from source "$(dirname "${BASH_SOURCE[0]}")/../build/lib/common.sh" -ROOT="${1:?usage: check-image-licences.sh ROOT}" +LIST=(--list-root); OF="the root's" +if [[ "${1:-}" == --image ]]; then + LIST=(--list-image "${2:?usage: check-image-licences.sh --image NAME TREE}"); OF="image ${2}'s" + shift 2 +fi +ROOT="${1:?usage: check-image-licences.sh ROOT, or --image NAME TREE}" LIC="${ROOT}/usr/share/licenses" EXC="${KRYPTIK_ROOT}/build/config/licence-exceptions.txt" -[[ -d "$LIC" ]] || die "no ${LIC}" +# An image built from no listed source (the probe) has nothing here to hold. +[[ -d "$LIC" || "${LIST[0]}" == --list-image ]] || die "no ${LIC}" declare -A EXCEPT=() if [[ -f "$EXC" ]]; then @@ -25,12 +32,17 @@ while read -r name _ _; do n=$((n + 1)) [[ -n "${EXCEPT[$name]:-}" ]] && continue has_files "${LIC}/${name}" || missing+=("$name") -done < <("${KRYPTIK_ROOT}/tools/fetch-sources.sh" --list) -[[ "$n" -gt 0 ]] || die "fetch-sources.sh --list named no sources" -[[ -f "${LIC}/kryptik/LICENSE" ]] || missing+=(kryptik) +done < <("${KRYPTIK_ROOT}/tools/fetch-sources.sh" "${LIST[@]}") +if [[ "$n" -eq 0 && "${LIST[0]}" == --list-image ]]; then + ok "image ${LIST[1]} is built from no listed source" + exit 0 +fi +[[ "$n" -gt 0 ]] || die "fetch-sources.sh ${LIST[*]} named no sources" +# Kryptik's own licence is the root's; an image holds only its sources'. +[[ "${LIST[0]}" != --list-root || -f "${LIC}/kryptik/LICENSE" ]] || missing+=(kryptik) if [[ "${#missing[@]}" -gt 0 ]]; then printf ' no licence files: %s\n' "${missing[@]}" die "${#missing[@]} without licence files: install them, or give each a line and its reason in ${EXC}" fi -ok "all ${n} sources have their licence files, and Kryptik has its own" +ok "all ${n} of ${OF} sources have their licence files" diff --git a/tools/currency-rules.tsv b/tools/currency-rules.tsv index 5b8b72d6..7a6a2b2a 100644 --- a/tools/currency-rules.tsv +++ b/tools/currency-rules.tsv @@ -43,3 +43,18 @@ libdisplay-info api https://gitlab.freedesktop.org/api/v4/projects/emersion%2Fli # Each series changes the API dwl uses. wlroots api https://gitlab.freedesktop.org/api/v4/projects/wlroots%2Fwlroots/repository/tags?per_page=100 name series dwl api https://codeberg.org/api/v1/repos/dwl/dwl/tags?limit=50 name - +# The software images' sources (build/images/versions.env). +pcre2 listing https://api.github.com/repos/PCRE2Project/pcre2/tags?per_page=50 "name": "pcre2-([0-9]+\.[0-9]+)" - +# GNOME numbers a development series with an odd minor; each project's cache.json names every release. +glib listing https://download.gnome.org/sources/glib/cache.json glib-([0-9]+\.[0-9]+\.[0-9]+)\.tar\.xz even +# Pango 1.90 and up are the next major's previews. +pango listing https://download.gnome.org/sources/pango/cache.json pango-(1\.[0-8][0-9]?\.[0-9]+)\.tar\.xz even +gdk-pixbuf listing https://download.gnome.org/sources/gdk-pixbuf/cache.json gdk-pixbuf-([0-9]+\.[0-9]+\.[0-9]+)\.tar\.xz even +at-spi2-core listing https://download.gnome.org/sources/at-spi2-core/cache.json at-spi2-core-([0-9]+\.[0-9]+\.[0-9]+)\.tar\.xz even +# GTK 3 is in maintenance as 3.24.x; GTK 4 is another toolkit. +gtk3 listing https://download.gnome.org/sources/gtk/cache.json gtk-(3\.24\.[0-9]+)\.tar\.xz - +# 1.6 is the stable series; the 1.7 tags are betas. +libpng listing https://api.github.com/repos/pnggroup/libpng/tags?per_page=100 "name": "v(1\.6\.[0-9]+)" - +fontconfig api https://gitlab.freedesktop.org/api/v4/projects/890/releases?per_page=20 tag_name drop90 +# An odd minor is a development snapshot. +cairo listing https://www.cairographics.org/releases/ cairo-([0-9]+\.[0-9]+\.[0-9]+)\.tar\.xz even diff --git a/tools/fetch-sources.sh b/tools/fetch-sources.sh index 20010af1..6092a521 100755 --- a/tools/fetch-sources.sh +++ b/tools/fetch-sources.sh @@ -1,21 +1,27 @@ #!/usr/bin/env bash # Fetch and verify upstream source tarballs. # -# ./tools/fetch-sources.sh download and verify against sources.lock -# ./tools/fetch-sources.sh --lock download and WRITE sources.lock -# ./tools/fetch-sources.sh --list print the resolved manifest, download nothing +# ./tools/fetch-sources.sh download and verify against sources.lock +# ./tools/fetch-sources.sh --lock download and WRITE sources.lock +# ./tools/fetch-sources.sh --list print the resolved manifest, download nothing +# ./tools/fetch-sources.sh --list-root only the sources the root is built from +# ./tools/fetch-sources.sh --list-image NAME only those software image NAME is built from # +# The software images' sources are pinned in build/images/versions.env and locked in +# build/images/sources.lock, so a change to them is no input of stage 04. # --lock trusts whatever downloads: audit sources.lock before committing it (docs/supply-chain.md). source "$(dirname "${BASH_SOURCE[0]}")/../build/lib/common.sh" load_config -MODE="verify" +MODE="verify"; IMAGE="" case "${1:-}" in --lock) MODE="lock" ;; --list) MODE="list" ;; + --list-root) MODE="list-root" ;; + --list-image) MODE="list-image"; IMAGE="${2:?--list-image needs an image name}" ;; "") ;; - *) die "unknown argument: $1 (expected --lock, --list, or nothing)" ;; + *) die "unknown argument: $1 (expected --lock, --list, --list-root, --list-image NAME, or nothing)" ;; esac # Test hook: tools/tests/fetch-sources.sh substitutes a manifest of file:// URLs. @@ -35,6 +41,8 @@ fi # probe whichever of .sig, .asc and .sign is published, now or in a later release # sha256 the publisher's .sha256 beside it (tools/verify-provenance.sh) # sha256.txt the same, named .sha256.txt +# sha256sum the same, named .sha256sum +# stem.sha256sum a .sha256sum named without the .tar.* suffix (glib-2.90.1.sha256sum) # tag a signed tag the archive must reproduce (tools/verify-provenance.sh) # none nothing, as beside a generated GitHub, GitLab or sr.ht archive # new: where its newest release is found (tools/check-source-currency.sh) @@ -165,12 +173,46 @@ linux-hardened|${V_LINUX_HARDENED}|${MIRROR_HARDENED}/v${V_LINUX_HARDENED}/linux MANIFEST } -if [[ "$MODE" == "list" ]]; then - manifest | while IFS='|' read -r name ver url sig new; do - printf '%-12s %-10s %s %s %s\n' "$name" "$ver" "$url" "$sig" "$new" - done - exit 0 -fi +# The software images' sources (build/images/versions.env), never installed in the root: the image +# each is built into, then the row as above. Their checksums are in build/images/sources.lock. +image_manifest() { + [[ -z "${KRYPTIK_FETCH_MANIFEST:-}" ]] || return 0 + local gnome="$MIRROR_GNOME" + cat < "${KRYPTIK_LOCK}.new" + # A tree without software images has no directory for their lock. + if [[ -d "${KRYPTIK_IMAGES_LOCK%/*}" ]]; then : > "${KRYPTIK_IMAGES_LOCK}.new"; fi fi total=0; fetched=0; cached=0 -while IFS='|' read -r name ver url _; do +while IFS='|' read -r owner name ver url _; do [[ -z "$name" ]] && continue total=$((total + 1)) file="$(basename "$url")" dest="${KRYPTIK_SOURCES}/${file}" + lock="$KRYPTIK_LOCK"; [[ "$owner" == . ]] || lock="$KRYPTIK_IMAGES_LOCK" was_fetched=no if [[ -f "$dest" ]]; then @@ -254,11 +299,11 @@ while IFS='|' read -r name ver url _; do actual="$(sha256_of "$dest")" if [[ "$MODE" == "lock" ]]; then - printf '%s %s\n' "$actual" "$file" >> "${KRYPTIK_LOCK}.new" + printf '%s %s\n' "$actual" "$file" >> "${lock}.new" ok "${name} ${ver} ${actual:0:16}…" else - if ! expected="$(lookup_hash "$file")"; then - die "no entry for ${file} in sources.lock; run './tools/fetch-sources.sh --lock', then audit it." + if ! expected="$(lookup_hash "$file" "$lock")"; then + die "no entry for ${file} in ${lock#"$KRYPTIK_ROOT"/}; run './tools/fetch-sources.sh --lock', then audit it." fi if [[ "$actual" != "$expected" ]]; then err "CHECKSUM MISMATCH for ${file}" @@ -272,7 +317,7 @@ while IFS='|' read -r name ver url _; do fi ok "${name} ${ver} verified" fi -done < <(manifest) +done < <(all_sources) # Kept in the sources cache for the signature gate, as ftp.gnu.org does not answer every runner. keyring="${KRYPTIK_SOURCES}/.keys/gnu-keyring.gpg" @@ -284,9 +329,12 @@ if [[ ! -s "$keyring" ]]; then fi if [[ "$MODE" == "lock" ]]; then - sort -k2 "${KRYPTIK_LOCK}.new" > "$KRYPTIK_LOCK" - rm -f "${KRYPTIK_LOCK}.new" - ok "wrote $(wc -l < "$KRYPTIK_LOCK") entries to sources.lock" + for lock in "$KRYPTIK_LOCK" "$KRYPTIK_IMAGES_LOCK"; do + [[ -f "${lock}.new" ]] || continue + sort -k2 "${lock}.new" > "$lock" + rm -f "${lock}.new" + ok "wrote $(wc -l < "$lock") entries to ${lock#"$KRYPTIK_ROOT"/}" + done warn "NOT YET AUDITED. Verify against upstream signatures before trusting." else ok "${total} package(s) verified (${fetched} downloaded, ${cached} cached)" diff --git a/tools/image/zones-test.sh b/tools/image/zones-test.sh index 0fa663cb..5295d9fb 100755 --- a/tools/image/zones-test.sh +++ b/tools/image/zones-test.sh @@ -92,7 +92,7 @@ for name in kernel-support policies net-ready net-dns dhcpcd-separated dnsmasq-u 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 \ microcode-notice \ - image-store image-runs image-needs-refused image-hidden image-explained image-list-namespace image-list-other-base image-corrupt; do + image-store image-runs image-needs-refused image-hidden image-explained image-list-namespace image-list-other-base image-corrupt image-toolkit; do grep -q "ZT PASS ${name}" <<<"$T2" && green "guest: ${name}" || red "guest: ${name} (not passed)" done diff --git a/tools/provenance-inventory.sh b/tools/provenance-inventory.sh index 376a1b42..0e035062 100755 --- a/tools/provenance-inventory.sh +++ b/tools/provenance-inventory.sh @@ -270,7 +270,7 @@ else echo fi -export KRYPTIK_LOCK KRYPTIK_SOURCES MD OFFLINE JSON KEYSTATE +export KRYPTIK_LOCK KRYPTIK_IMAGES_LOCK KRYPTIK_SOURCES MD OFFLINE JSON KEYSTATE INV_COMMIT="$(git -C "$KRYPTIK_ROOT" rev-parse HEAD 2>/dev/null || echo unknown)" export INV_COMMIT python3 - "$MANIFEST" "$SIGREP" "$PROVREP" "$IDREP" "$LICREP" "$ARTREP" "$NOTES_TSV" <<'PYEOF' @@ -338,14 +338,16 @@ CLASS_ORDER = {k: i for i, (k, _) in enumerate(CLASSES)} manifest = [(r[0], r[1], r[2]) for r in rows(manifest_path) if len(r) >= 3] lock = {} -try: - with open(lock_path) as fh: - for line in fh: - parts = line.split() - if len(parts) >= 2: - lock[parts[1]] = parts[0] -except OSError: - pass +# The root's lock and the software images'. +for path in (lock_path, os.environ.get("KRYPTIK_IMAGES_LOCK", "")): + try: + with open(path) as fh: + for line in fh: + parts = line.split() + if len(parts) >= 2: + lock[parts[1]] = parts[0] + except OSError: + pass sig = {} for r in rows(sig_path): diff --git a/tools/source-bundle.sh b/tools/source-bundle.sh index 9311fc1b..45f64b6e 100755 --- a/tools/source-bundle.sh +++ b/tools/source-bundle.sh @@ -21,7 +21,9 @@ commit="$(git -C "$KRYPTIK_ROOT" rev-parse HEAD 2>/dev/null)" || die "${KRYPTIK_ OUT="${OUT:-${KRYPTIK_OUT}/source-${commit:0:12}}" [[ ! -e "$OUT" ]] || die "${OUT} already exists" mkdir -p "$OUT/sources" "$OUT/signatures" "$OUT/crates" -LOCK="${KRYPTIK_ROOT}/sources.lock" +# The root's and the software images' locks: the bundle is everything a release is built from. +LOCKS=("$KRYPTIK_LOCK") +[[ ! -f "$KRYPTIK_IMAGES_LOCK" ]] || LOCKS+=("$KRYPTIK_IMAGES_LOCK") log "Source bundle for ${commit} in ${OUT}" # Each tarball is checked against sources.lock as it is copied. @@ -29,8 +31,7 @@ n=0 while read -r name _ver url sig _; do [[ -n "$name" ]] || continue f="${url##*/}" - want="$(awk -v f="$f" '$2 == f { print $1; exit }' "$LOCK")" - [[ -n "$want" ]] || die "${name}: ${f} is not in sources.lock" + want="$(lock_entry "$f")" || die "${name}: ${f} is not in sources.lock or build/images/sources.lock" [[ -f "${KRYPTIK_SOURCES}/${f}" ]] || die "${name}: ${f} is not downloaded; run make sources" [[ "$(sha256_of "${KRYPTIK_SOURCES}/${f}")" == "$want" ]] || die "${name}: ${f} does not match sources.lock" [[ -f "$OUT/sources/${f}" ]] || { cp "${KRYPTIK_SOURCES}/${f}" "$OUT/sources/"; n=$((n + 1)); } @@ -46,9 +47,10 @@ missing=() while read -r hash f; do [[ -z "$hash" || "$hash" == \#* ]] && continue [[ -f "$OUT/sources/${f}" ]] || missing+=("$f") -done < "$LOCK" -[[ "${#missing[@]}" -eq 0 ]] || die "sources.lock names what the bundle lacks: ${missing[*]}" -cp "$LOCK" "$OUT/" +done < <(cat "${LOCKS[@]}") +[[ "${#missing[@]}" -eq 0 ]] || die "the locks name what the bundle lacks: ${missing[*]}" +cp "$KRYPTIK_LOCK" "$OUT/" +[[ ! -f "$KRYPTIK_IMAGES_LOCK" ]] || cp "$KRYPTIK_IMAGES_LOCK" "$OUT/images-sources.lock" ok "${n} tarballs, $(find "$OUT/signatures" -type f | wc -l) signatures" git -C "$KRYPTIK_ROOT" archive --format=tar.gz --prefix="kryptik-${commit:0:12}/" \ diff --git a/tools/tests/image-licences.sh b/tools/tests/image-licences.sh index 51be50bc..9388b856 100755 --- a/tools/tests/image-licences.sh +++ b/tools/tests/image-licences.sh @@ -30,6 +30,18 @@ out="$(check)"; rc=$? rm -r "$I/usr/share/licenses/kryptik" out="$(check)"; rc=$? [[ "$rc" -ne 0 && "$out" == *"no licence files: kryptik"* ]] && ok "Kryptik's own licence is required too" || bad "no kryptik: rc=$rc: $out" +# A software image: its own sources, and no Kryptik licence of its own. +J="$T/toolkit"; mkdir -p "$J/usr/share/licenses/glib"; echo text > "$J/usr/share/licenses/glib/COPYING" +printf '#!/usr/bin/env bash\n[[ "$1 ${2:-}" == "--list-image toolkit" ]] && echo "glib 2.90.1 https://x/glib-2.90.1.tar.xz stem.sha256sum rule"; true\n' > "$R/tools/fetch-sources.sh" +icheck() { KRYPTIK_ROOT="$R" NO_COLOR=1 bash "$R/tools/check-image-licences.sh" --image "$1" "$2" 2>&1; } +out="$(icheck toolkit "$J")"; rc=$? +[[ "$rc" -eq 0 && "$out" == *"all 1 of image toolkit's sources"* ]] && ok "an image's sources covered, without Kryptik's own: it passes" || bad "image covered: rc=$rc: $out" +rm -r "$J/usr/share/licenses/glib" +out="$(icheck toolkit "$J")"; rc=$? +[[ "$rc" -ne 0 && "$out" == *"no licence files: glib"* ]] && ok "an image missing a source's licence fails, by name" || bad "image missing: rc=$rc: $out" +out="$(icheck probe "$T/nothing")"; rc=$? +[[ "$rc" -eq 0 && "$out" == *"built from no listed source"* ]] && ok "an image built from no listed source passes" || bad "no sources: rc=$rc: $out" + printf '#!/usr/bin/env bash\ntrue\n' > "$R/tools/fetch-sources.sh" out="$(check)"; rc=$? [[ "$rc" -ne 0 && "$out" == *"named no sources"* ]] && ok "an empty source list passes nothing" || bad "empty list: rc=$rc: $out" diff --git a/tools/verify-provenance.sh b/tools/verify-provenance.sh index cd2c2ab0..10d83be6 100755 --- a/tools/verify-provenance.sh +++ b/tools/verify-provenance.sh @@ -72,10 +72,7 @@ fail() { err "[$1] $2"; FAIL_N=$((FAIL_N+1)); FAIL_LIST+=("[ unavail() { warn "[$1] UNVERIFIED: $2"; UNAVAIL_N=$((UNAVAIL_N+1)); UNAVAIL_LIST+=("[$1] $2"); report "$1" unverified "$2"; } prereq() { warn "[$1] CANNOT CHECK: $2"; PREREQ_N=$((PREREQ_N+1)); PREREQ_LIST+=("[$1] $2"); report "$1" uncheckable "$2"; } -lock_hash_for() { - [[ -f "$KRYPTIK_LOCK" ]] || return 1 - awk -v f="$1" '$2 == f { print $1; found=1 } END { exit !found }' "$KRYPTIK_LOCK" -} +lock_hash_for() { lock_entry "$1"; } # http_get URL DEST: curl's exit status decides; HTTP_CODE only tells a 404 from no answer. http_get() { @@ -236,10 +233,11 @@ verify_hm_tree() { fi } -# verify_published_sha256 NAME URL SUFFIX: a publisher's checksum, over the same TLS as the tarball. +# verify_published_sha256 NAME URL SUFFIX [SUM-URL]: a publisher's checksum, over the same TLS as the +# tarball, at URL+SUFFIX unless SUM-URL says where. verify_published_sha256() { local name="$1" url="$2" suffix="$3" - local sum="${url}${suffix}" + local sum="${4:-${url}${suffix}}" RSRC="$name" local file; file="$(basename "$url")" local body="${WORK}/${file}${suffix}" @@ -282,8 +280,11 @@ verify_published_sha256() { return fi + # The line naming the file where the list names files (GNOME's lists the news beside it), else + # the first digest. local expected - expected="$(awk 'NF{print $1; exit}' "$body" 2>/dev/null || true)" + expected="$(awk -v f="$file" 'NF >= 2 && ($2 == f || $2 == "*" f) { print $1; exit }' "$body" 2>/dev/null || true)" + [[ -n "$expected" ]] || expected="$(awk 'NF{print $1; exit}' "$body" 2>/dev/null || true)" if [[ ! "$expected" =~ ^[0-9a-fA-F]{64}$ ]]; then fail pub "${name}: ${sum} is not a sha256 digest (got $(head -c 80 "$body" | tr -d '\n' || true))" return @@ -312,7 +313,7 @@ MANIFEST="${WORK}/manifest" "${KRYPTIK_ROOT}/tools/fetch-sources.sh" --list > "$MANIFEST" \ || die "tools/fetch-sources.sh --list failed, so there is nothing to check against." mapfile -t TAG_ROWS < <(awk '$4 == "tag"' "$MANIFEST") -mapfile -t SUM_ROWS < <(awk '$4 == "sha256" || $4 == "sha256.txt"' "$MANIFEST") +mapfile -t SUM_ROWS < <(awk '$4 == "sha256" || $4 == "sha256.txt" || $4 == "sha256sum" || $4 == "stem.sha256sum"' "$MANIFEST") log "Signed tags" for row in "${TAG_ROWS[@]}"; do @@ -338,7 +339,10 @@ echo log "Publisher checksums" for row in "${SUM_ROWS[@]}"; do read -r name _ url sig _ <<< "$row" - verify_published_sha256 "$name" "$url" ".${sig}" + case "$sig" in + stem.sha256sum) verify_published_sha256 "$name" "$url" ".sha256sum" "${url%.tar.*}.sha256sum" ;; + *) verify_published_sha256 "$name" "$url" ".${sig}" ;; + esac done echo diff --git a/tools/verify-signatures.sh b/tools/verify-signatures.sh index 116ca815..441c4da3 100755 --- a/tools/verify-signatures.sh +++ b/tools/verify-signatures.sh @@ -834,7 +834,7 @@ while read -r name _ver url sig _; do # A kind this script does not know fails: skipping it would pass the row. case "$sig" in - gnu|kernel|sig|asc|stem.sig|sums:?*|probe|sha256|sha256.txt|tag|none) ;; + gnu|kernel|sig|asc|stem.sig|sums:?*|probe|sha256|sha256.txt|sha256sum|stem.sha256sum|tag|none) ;; *) err "${name}: the manifest declares no signature kind this script knows ('${sig}')" FAILED=$((FAILED + 1)); FAILED_LIST+=("${name} (unknown signature kind '${sig}')") @@ -859,8 +859,8 @@ while read -r name _ver url sig _; do stem.sig) verify_detached "$name" "${KRYPTIK_SOURCES}/${file}" "${url%.tar.*}.sig" ;; sums:*) verify_sums "$name" "$url" "$file" "${sig#sums:}" ;; probe) verify_any "$name" "$url" "$file" ;; - sha256|sha256.txt|tag) - what="the publisher's .${sig}" + sha256|sha256.txt|sha256sum|stem.sha256sum|tag) + what="the publisher's .${sig#stem.}" [[ "$sig" == tag ]] && what="the signed tag" warn "${name}: no OpenPGP signature upstream; ${what} is verify-provenance's" mark_unsigned "${name} (${what}, see verify-provenance)" From ad7bb340dfc2be785d4b851f6cd8c3911b8a0aaa Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 00:08:10 -0700 Subject: [PATCH 019/123] FreeType comes from Savannah's own copy, and the manifest test checks each set of sources against its own lock The redirector sent the runner to a mirror that lacked 2.15.0. The test knew one lock and the old kinds. --- build/images/versions.env | 2 ++ tools/fetch-sources.sh | 2 +- tools/tests/fetch-sources.sh | 21 +++++++++++++-------- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/build/images/versions.env b/build/images/versions.env index bdd49984..a4dbf71d 100644 --- a/build/images/versions.env +++ b/build/images/versions.env @@ -4,6 +4,8 @@ # build/images/sources.lock; tools/fetch-sources.sh lists them per image. MIRROR_GNOME="https://download.gnome.org/sources" +# Savannah's own copy: the redirector can send a new release to a mirror that lacks it yet. +MIRROR_SAVANNAH_MASTER="https://download-mirror.savannah.gnu.org/releases" MIRROR_ICON_THEME="https://icon-theme.freedesktop.org/releases" # --- toolkit: GTK 3 for Wayland alone, without X11 or D-Bus --- diff --git a/tools/fetch-sources.sh b/tools/fetch-sources.sh index 6092a521..5c407cac 100755 --- a/tools/fetch-sources.sh +++ b/tools/fetch-sources.sh @@ -182,7 +182,7 @@ image_manifest() { toolkit|pcre2|${V_PCRE2}|${MIRROR_GITHUB}/PCRE2Project/pcre2/releases/download/pcre2-${V_PCRE2}/pcre2-${V_PCRE2}.tar.bz2|sig|rule toolkit|glib|${V_GLIB}|${gnome}/glib/${V_GLIB%.*}/glib-${V_GLIB}.tar.xz|stem.sha256sum|rule toolkit|libpng|${V_LIBPNG}|${MIRROR_SOURCEFORGE}/libpng/libpng-${V_LIBPNG}.tar.xz|none|rule -toolkit|freetype|${V_FREETYPE}|${MIRROR_SAVANNAH}/freetype/freetype-${V_FREETYPE}.tar.xz|sig|listing +toolkit|freetype|${V_FREETYPE}|${MIRROR_SAVANNAH_MASTER}/freetype/freetype-${V_FREETYPE}.tar.xz|sig|listing toolkit|harfbuzz|${V_HARFBUZZ}|${MIRROR_GITHUB}/harfbuzz/harfbuzz/releases/download/${V_HARFBUZZ}/harfbuzz-${V_HARFBUZZ}.tar.xz|none|github toolkit|fontconfig|${V_FONTCONFIG}|${MIRROR_FDO_GITLAB}/api/v4/projects/890/packages/generic/fontconfig/${V_FONTCONFIG}/fontconfig-${V_FONTCONFIG}.tar.xz|sha256sum|rule toolkit|fribidi|${V_FRIBIDI}|${MIRROR_GITHUB}/fribidi/fribidi/releases/download/v${V_FRIBIDI}/fribidi-${V_FRIBIDI}.tar.xz|none|github diff --git a/tools/tests/fetch-sources.sh b/tools/tests/fetch-sources.sh index d67566cb..296c8c99 100755 --- a/tools/tests/fetch-sources.sh +++ b/tools/tests/fetch-sources.sh @@ -291,20 +291,25 @@ fi # --- the shipped manifest and the shipped lockfile must agree --------------- # A row with no lock line is refused at fetch time; a lock line with no row is a hash nobody checks. +# The root's rows are in sources.lock, the software images' in build/images/sources.lock. bash "$TOOL" --list > "${W}/live-manifest" 2>/dev/null -awk '{n = $3; sub(/.*\//, "", n); print n}' "${W}/live-manifest" | sort -u > "${W}/mf" -awk '{print $2}' "${ROOT}/sources.lock" | sort -u > "${W}/lk" - -miss="$(comm -23 "${W}/mf" "${W}/lk" | tr '\n' ' ')" +bash "$TOOL" --list-root > "${W}/root-manifest" 2>/dev/null +names() { awk '{n = $3; sub(/.*\//, "", n); print n}' "$1" | sort -u; } +names "${W}/root-manifest" > "${W}/mf-root" +comm -23 <(names "${W}/live-manifest") "${W}/mf-root" > "${W}/mf-images" +awk '{print $2}' "${ROOT}/sources.lock" | sort -u > "${W}/lk-root" +awk '{print $2}' "${ROOT}/build/images/sources.lock" | sort -u > "${W}/lk-images" + +miss="$(comm -23 "${W}/mf-root" "${W}/lk-root" | tr '\n' ' ')$(comm -23 "${W}/mf-images" "${W}/lk-images" | tr '\n' ' ')" if [[ -z "${miss// /}" ]]; then - green "every source in the manifest has a sources.lock entry" + green "every source in the manifest has an entry in its lock" else red "manifest rows with no lock entry: ${miss}" fi -stale="$(comm -13 "${W}/mf" "${W}/lk" | tr '\n' ' ')" +stale="$(comm -13 "${W}/mf-root" "${W}/lk-root" | tr '\n' ' ')$(comm -13 "${W}/mf-images" "${W}/lk-images" | tr '\n' ' ')" if [[ -z "${stale// /}" ]]; then - green "every sources.lock entry corresponds to a manifest row" + green "every lock entry corresponds to a manifest row of its own set" else red "lock entries with no manifest row: ${stale}" fi @@ -321,7 +326,7 @@ fi # A value no tool knows fails there too; here it fails before it ships. odd="$(awk ' - $4 !~ /^(gnu|kernel|sig|asc|stem\.sig|sums:[^\/]+\.(asc|sig)|probe|sha256|sha256\.txt|tag|none)$/ { print $1 " sig=" $4; next } + $4 !~ /^(gnu|kernel|sig|asc|stem\.sig|sums:[^\/]+\.(asc|sig)|probe|sha256|sha256\.txt|sha256sum|stem\.sha256sum|tag|none)$/ { print $1 " sig=" $4; next } $5 !~ /^(gnu|vdir|github|listing|rule|eol|follows:.+)$/ { print $1 " new=" $5 } ' "${W}/live-manifest" | tr '\n' ' ')" if [[ -z "${odd// /}" ]]; then From 86bc347ec81a34fcbd306b6cd282742304c99a50 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 00:10:43 -0700 Subject: [PATCH 020/123] An image can be built over others, and a toolchain layer can be built that ships to no one A definition's image_over names the images whose trees lie under it, above the sysroot, while its rows run; they are built first, and their fingerprints are its inputs. The tree check takes every lower layer and refuses a file any of them has. image_ships=no makes a layer that only builds over it use, which stage 06 neither packs nor lists; a shipped image built over shipped ones gets the list's requires line, in the list's own order. --- build/stages/04-images.sh | 46 +++++++++++++++++++++++++------- build/stages/06-iso.sh | 20 +++++++++++--- docs/design/software-delivery.md | 6 ++++- tools/check-image-tree.sh | 35 +++++++++++++++--------- tools/tests/check-image-tree.sh | 22 +++++++++++++++ 5 files changed, 102 insertions(+), 27 deletions(-) diff --git a/build/stages/04-images.sh b/build/stages/04-images.sh index 64c28086..e89b2cb3 100755 --- a/build/stages/04-images.sh +++ b/build/stages/04-images.sh @@ -4,6 +4,10 @@ # packs each tree as it packs the root. # usage: make images (as root, for the overlay; or 04-images.sh NAME... for some of build/images) # 04-images.sh --inside NAME in the chroot: one image's rows, as stage 04 runs its own +# +# A definition sets image_version and image_rows, and may set image_over, the images it is built over: +# their trees lie under it, above the sysroot, while its rows run. image_ships=no makes a layer that +# only images built over it use, a toolchain, which stage 06 never packs. source "$(dirname "${BASH_SOURCE[0]}")/../lib/common.sh" load_config @@ -14,9 +18,16 @@ CHROOTD="${KRYPTIK_ROOT}/build/stages/03-chroot-prep.sh" # --- on the host: an overlay per image, its rows run inside it --------------- -# What an image is built from: its definition and files, the recipes and flags, the pins, and the base system. +# image_setting NAME VAR: VAR in NAME's definition, an array's words joined by spaces. +image_setting() { + # shellcheck source=/dev/null + ( source "${DEFS}/${1}.sh"; declare -n v="$2"; printf '%s' "${v[*]:-}" ) +} + +# What an image is built from: its definition and files, the recipes and flags, the pins, the base +# system, and the images it is built over. image_inputs() { # image_inputs NAME - local f + local f dep for f in "${DEFS}/${1}.sh" "${DEFS}/${1}"/* "${KRYPTIK_ROOT}/build/lib/recipes.sh" "${KRYPTIK_ROOT}"/build/recipes/*.sh \ "${BASH_SOURCE[0]}" "${KRYPTIK_ROOT}/tools/check-image-tree.sh" "${KRYPTIK_ROOT}/build/config/hardening.env" \ "${KRYPTIK_ROOT}/build/config/hardening-exceptions.txt" "${KRYPTIK_ROOT}/build/config/versions.env" \ @@ -24,6 +35,9 @@ image_inputs() { # image_inputs NAME if [[ -f "$f" ]]; then printf '%s %s\n' "${f#"${KRYPTIK_ROOT}/"}" "$(sha256_of "$f")"; fi done printf 'base %s\n' "$(_stamp_read "${KRYPTIK_WORK}/.stamps/bs-licences")" + for dep in $(image_setting "$1" image_over); do + printf 'over %s %s\n' "$dep" "$(sed -n 's/^fingerprint: //p' "${KRYPTIK_WORK}/image-build/${dep}/record")" + done } # The image that is being built, so an exit takes its mounts down with it. @@ -35,12 +49,26 @@ unmount_image() { # unmount_image TOP if findmnt -rn -o TARGET | grep -F "$1/" >&2; then die "still mounted under $1 (above): nothing there is removed"; fi } +# The images being built, innermost last, so one built over itself is refused. +BUILDING="" build_image() { # build_image NAME - local name="$1" top="${KRYPTIK_WORK}/image-build/${1}" version want d + local name="$1" top="${KRYPTIK_WORK}/image-build/${1}" version want d ships dep lowers="" + local -a over [[ "$name" =~ ^[a-z0-9-]{1,32}$ ]] || die "image name ${name}: a-z, 0-9 and -, at most 32" - # shellcheck source=/dev/null - version="$(source "${DEFS}/${name}.sh"; printf '%s' "${image_version:-}")" + [[ -f "${DEFS}/${name}.sh" ]] || die "no image ${name}: nothing at ${DEFS}/${name}.sh" + [[ " ${BUILDING} " != *" ${name} "* ]] || die "image ${name} is built over itself:${BUILDING} ${name}" + version="$(image_setting "$name" image_version)" [[ "$version" =~ ^[!-~]{1,64}$ ]] || die "image ${name}: version '${version}' is not 1-64 printable characters without a space" + ships="$(image_setting "$name" image_ships)"; ships="${ships:-yes}" + [[ "$ships" == yes || "$ships" == no ]] || die "image ${name}: image_ships is yes or no, not '${ships}'" + read -ra over <<< "$(image_setting "$name" image_over)" + # What it is built over comes first; the nearest layer is named first, as overlayfs takes it. + BUILDING="${BUILDING} ${name}" + for dep in "${over[@]}"; do + build_image "$dep" + lowers+="${KRYPTIK_WORK}/image-build/${dep}/tree:" + done + BUILDING="${BUILDING% "${name}"}" want="$(image_inputs "$name" | sha256_of_stdin)" if [[ -d "${top}/tree/usr" ]] && grep -qx "fingerprint: ${want}" "${top}/record" 2>/dev/null; then dim " skip ${name} ${version} (already built, inputs unchanged)" @@ -56,17 +84,18 @@ build_image() { # build_image NAME # and the real work tree's stamps, logs, build and images are bound into it as usual. for d in .stamps logs build images; do ln -s "${KRYPTIK_WORK}/${d}" "${top}/${d}"; done MOUNTED="$top" - mount -t overlay overlay -o "lowerdir=${SYSROOT},upperdir=${top}/upper,workdir=${top}/overlay-work" "${top}/sysroot" + mount -t overlay overlay -o "lowerdir=${lowers}${SYSROOT},upperdir=${top}/upper,workdir=${top}/overlay-work" "${top}/sysroot" env KRYPTIK_WORK="$top" KRYPTIK_KRYPTIKD_BIN= KRYPTIK_WLPROXY_BIN= \ "$CHROOTD" run /kryptik/build/stages/04-images.sh --inside "$name" unmount_image "$top" MOUNTED="" - "${KRYPTIK_ROOT}/tools/check-image-tree.sh" "$SYSROOT" "${top}/upper" + "${KRYPTIK_ROOT}/tools/check-image-tree.sh" "${lowers}${SYSROOT}" "${top}/upper" # Without extended attributes: overlayfs keeps its own on the upper directory. mkdir -p "${top}/tree" rsync -aH --numeric-ids "${top}/upper/usr" "${top}/tree/" rm -rf "${top}/upper" "${top}/overlay-work" - printf 'name: %s\nversion: %s\nfingerprint: %s\n' "$name" "$version" "$want" > "${top}/record" + printf 'name: %s\nversion: %s\nships: %s\nover: %s\nfingerprint: %s\n' \ + "$name" "$version" "$ships" "${over[*]}" "$want" > "${top}/record" ok "image ${name} ${version}: $(find "${top}/tree" ! -type d | wc -l) files and links" } @@ -81,7 +110,6 @@ build_images() { # build_images [NAME...] fi log "Kryptik software images: ${names[*]}" for n in "${names[@]}"; do - [[ -f "${DEFS}/${n}.sh" ]] || die "no image ${n}: nothing at ${DEFS}/${n}.sh" build_image "$n" done ok "software images built: ${names[*]}" diff --git a/build/stages/06-iso.sh b/build/stages/06-iso.sh index 3ee61e3e..b04a73df 100755 --- a/build/stages/06-iso.sh +++ b/build/stages/06-iso.sh @@ -385,17 +385,28 @@ pack_image() { # Each software image the image stage built (build/stages/04-images.sh), in IMAGES with # images.manifest naming them for this root: the release, the root's hash as their base, an image line -# each by name, then each image's policy lines (build/images/NAME.needs). sign-images signs it. With no -# image built there is no list, as kryptikd refuses one that names none. +# each by name, a requires line for each built over others that ship, then each image's policy lines +# (build/images/NAME.needs). sign-images signs it. A layer that does not ship, a toolchain, is left out. +# With no image built there is no list, as kryptikd refuses one that names none. s_images() { local version="$1" base; base="$(root_json root_hash)" - local rec name ver line needs=() images=() + local rec name ver line dep reqs needs=() images=() requires=() rm -rf "$IMAGES"; mkdir -p "$IMAGES" - for rec in "${KRYPTIK_WORK}"/image-build/*/record; do + # In the list's own order, which its requires and needs lines follow. + for rec in $(printf '%s\n' "${KRYPTIK_WORK}"/image-build/*/record | LC_ALL=C sort); do [[ -f "$rec" ]] || continue name="$(sed -n 's/^name: //p' "$rec")"; ver="$(sed -n 's/^version: //p' "$rec")" + if [[ "$(sed -n 's/^ships: //p' "$rec")" == no ]]; then + echo "--- image ${name} ${ver}: a layer others are built over, not shipped ---" + continue + fi echo "--- image ${name} ${ver} ---" images+=("$(pack_image "$name" "$ver" "$(dirname "$rec")/tree" "$base")") + reqs="" + for dep in $(sed -n 's/^over: //p' "$rec"); do + [[ "$(sed -n 's/^ships: //p' "${KRYPTIK_WORK}/image-build/${dep}/record")" == no ]] || reqs+=" ${dep}" + done + [[ -z "$reqs" ]] || requires+=("requires: ${name}${reqs}") if [[ -f "${KRYPTIK_ROOT}/build/images/${name}.needs" ]]; then while IFS= read -r line; do [[ -z "$line" || "$line" == \#* ]] || needs+=("needs: ${name} ${line}") @@ -410,6 +421,7 @@ s_images() { { printf 'KRYPTIK-IMAGES-1\nrelease: %s\nbase: %s\n' "$version" "$base" printf '%s\n' "${images[@]}" | LC_ALL=C sort + if [[ "${#requires[@]}" -gt 0 ]]; then printf '%s\n' "${requires[@]}"; fi if [[ "${#needs[@]}" -gt 0 ]]; then printf '%s\n' "${needs[@]}"; fi } > "${IMAGES}/images.manifest" echo "--- images.manifest ---" diff --git a/docs/design/software-delivery.md b/docs/design/software-delivery.md index 4940ae78..272cdc78 100644 --- a/docs/design/software-delivery.md +++ b/docs/design/software-delivery.md @@ -198,7 +198,11 @@ up ADR-001 in the zones where the applications run. An image's sources are pinned in `build/images/versions.env` and locked in `build/images/sources.lock`, so a change to them leaves stage 04 alone, and the image installs their licence files itself, as stage 04 does the - root's; stage 06 refuses an image missing one. + root's; stage 06 refuses an image missing one. An image can be built over + others (`image_over`): their trees lie under it, above the root, while its + rows run, the tree check refuses a file any of them has, and the list's + `requires:` line names those that ship. A layer marked `image_ships=no`, a + toolchain only builds use, is never packed or listed. - **Packed.** Stage 06 makes `images-/.img` the way it makes the root (ext4 without a journal, then the verity tree, with a salt and UUID derived from the content), runs the root's artifact audit diff --git a/tools/check-image-tree.sh b/tools/check-image-tree.sh index bd9e1e63..0e988985 100755 --- a/tools/check-image-tree.sh +++ b/tools/check-image-tree.sh @@ -2,8 +2,10 @@ # Fail on what an image's recipes did to the root instead of adding to /usr: the upper directory of the # overlay they ran on, against the sysroot under it (docs/design/software-delivery.md). # -# tools/check-image-tree.sh LOWER UPPER +# tools/check-image-tree.sh LOWER[:LOWER...] UPPER # +# LOWER is the sysroot, after the trees of the images this one is built over when it has any, nearest +# first, as overlayfs takes them: a file any of them has is one the image may not replace. # Refused: a whiteout or an opaque or redirected directory (a root path removed or hidden), a file the # root already has, a directory of the root's under /usr with another mode or owner, a file outside # /usr, a link that leads out of /usr, a device node, a setuid or setgid bit, a file capability. The @@ -12,12 +14,13 @@ 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 -[[ "$#" -eq 2 ]] || die "usage: check-image-tree.sh LOWER UPPER" -[[ -d "$1" && -d "$2" ]] || die "not directories: $1 $2" +[[ "$#" -eq 2 ]] || die "usage: check-image-tree.sh LOWER[:LOWER...] UPPER" +IFS=: read -ra lowers <<< "$1" +for d in "${lowers[@]}" "$2"; do [[ -d "$d" ]] || die "not a directory: $d"; done log "What the image's recipes left in $2, against $1" python3 - "$1" "$2" <<'EOF' import os, stat, sys -lower, upper = sys.argv[1:3] +lowers, upper = sys.argv[1].split(":"), sys.argv[2] # Written by the chroot itself (stage 03), or left by builds; none of it is the image's. SCRATCH = ("tmp/", "var/tmp/", "root/", "run/", "dev/", "proc/", "sys/") MOUNTS = {"kryptik", "kryptik-sources", "kryptik-work", "kryptik-kryptikd", "kryptik-wlproxy"} @@ -44,13 +47,19 @@ for d, subdirs, names in os.walk(upper): if (rel + "/").startswith(SCRATCH) or top in MOUNTS or rel in MARKS: continue st = os.lstat(p) - lp = os.path.join(lower, rel) - try: - lst = os.lstat(lp) - except FileNotFoundError: - lst = None + # The nearest layer that has the path: the last is the root, the others images it is built over. + lst, under = None, False + for i, lower in enumerate(lowers): + try: + lst = os.lstat(os.path.join(lower, rel)) + except FileNotFoundError: + continue + under = i < len(lowers) - 1 + break + what = f"/{rel} of an image under it" if under else f"the root's /{rel}" + dwhat = f"the directory /{rel} of an image under it" if under else f"the root's directory /{rel}" if stat.S_ISCHR(st.st_mode) and st.st_rdev == 0 or xattr(p, "trusted.overlay.whiteout") is not None: - bad.append(f"removes the root's /{rel}") + bad.append(f"removes {what}") continue if xattr(p, "trusted.overlay.redirect") is not None: bad.append(f"moves /{rel} within the root") @@ -59,9 +68,9 @@ for d, subdirs, names in os.walk(upper): if xattr(p, "trusted.overlay.opaque") == b"y": bad.append(f"hides what the root has under /{rel}") elif lst is not None and not stat.S_ISDIR(lst.st_mode): - bad.append(f"replaces the root's /{rel} with a directory") + bad.append(f"replaces {what} with a directory") elif lst is not None and top == "usr" and (stat.S_IMODE(st.st_mode), st.st_uid, st.st_gid) != (stat.S_IMODE(lst.st_mode), lst.st_uid, lst.st_gid): - bad.append(f"changes the root's directory /{rel} " + bad.append(f"changes {dwhat} " f"({oct(stat.S_IMODE(lst.st_mode))} {lst.st_uid}:{lst.st_gid} -> {oct(stat.S_IMODE(st.st_mode))} {st.st_uid}:{st.st_gid})") elif lst is None and top != "usr": bad.append(f"makes /{rel} outside /usr") @@ -69,7 +78,7 @@ for d, subdirs, names in os.walk(upper): dirs += 1 continue if lst is not None: - bad.append(f"replaces the root's /{rel}") + bad.append(f"replaces {what}") elif top != "usr": bad.append(f"installs /{rel} outside /usr") elif not (stat.S_ISREG(st.st_mode) or stat.S_ISLNK(st.st_mode)): diff --git a/tools/tests/check-image-tree.sh b/tools/tests/check-image-tree.sh index 4ed2027a..c0778c2e 100755 --- a/tools/tests/check-image-tree.sh +++ b/tools/tests/check-image-tree.sh @@ -69,6 +69,28 @@ out="$(check)"; rc=$? [[ "$rc" -ne 0 && "$out" == *"installed nothing under /usr"* ]] \ && ok "an image with nothing under /usr fails" || bad "empty image: rc=$rc: $out" +# Built over another image, whose tree lies between: neither its files nor the root's may be replaced. +M="$T/under"; mkdir -p "$M/usr/lib/toolkit" "$M/usr/bin" +printf 'lib\n' > "$M/usr/lib/toolkit/libtk.so" +chmod --reference="$L/usr/bin" "$M/usr/bin" +lcheck() { NO_COLOR=1 bash "$CHECK" "$M:$L" "$U" 2>&1; } +fresh +out="$(lcheck)"; rc=$? +[[ "$rc" -eq 0 && "$out" == *"3 files and links"* ]] && ok "an image built over another adds its own files" || bad "layered clean: rc=$rc: $out" +fresh; mkdir -p "$U/usr/lib/toolkit"; chmod --reference="$M/usr/lib/toolkit" "$U/usr/lib/toolkit" +printf 'other\n' > "$U/usr/lib/toolkit/libtk.so" +out="$(lcheck)"; rc=$? +[[ "$rc" -ne 0 && "$out" == *"replaces /usr/lib/toolkit/libtk.so of an image under it"* ]] \ + && ok "a file of the image it is built over, rewritten, fails" || bad "layered replace: rc=$rc: $out" +fresh; mkdir -p "$U/usr/share/info"; printf 'changed\n' > "$U/usr/share/info/dir" +out="$(lcheck)"; rc=$? +[[ "$rc" -ne 0 && "$out" == *"replaces the root's /usr/share/info/dir"* ]] \ + && ok "the root's files stay protected under another image" || bad "layered root replace: rc=$rc: $out" +fresh; mkdir -p "$U/usr/lib/toolkit"; chmod 0700 "$U/usr/lib/toolkit" +out="$(lcheck)"; rc=$? +[[ "$rc" -ne 0 && "$out" == *"changes the directory /usr/lib/toolkit of an image under it"* ]] \ + && ok "a directory of the image under it with another mode fails" || bad "layered directory mode: rc=$rc: $out" + # A whiteout is a character device 0/0, which only root can make. fresh if mknod "$U/usr/bin/bash" c 0 0 2>/dev/null; then From 1227fc908c259f6f1e2732befba1a4547adef6c1 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 00:12:53 -0700 Subject: [PATCH 021/123] The toolkit's signed sources have their keys: three pinned with what names them, hicolor's through Debian's WKD pcre2's SECURITY.md names its key; cairo's signer serves his under his GNOME account; FreeType publishes only the long id, which the pinned key ends in and which has signed every release since 2.12.0. --- tools/key-provenance.tsv | 1 + tools/verify-signatures.sh | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/tools/key-provenance.tsv b/tools/key-provenance.tsv index 4db38639..e54c5d8a 100644 --- a/tools/key-provenance.tsv +++ b/tools/key-provenance.tsv @@ -104,3 +104,4 @@ B3F2469D78D78BD09D366F37C94035C21B4F2AEB korg https://git.kernel.org/pub/scm 155D3FC500C834486D1EEA677FD9FCCB000BEEEE savannah https://savannah.gnu.org/project/release-gpgkeys.php?group=gzip&download=1 2026-10-02 gzip Jim Meyering 0CF4E8D871593224842832B888DD9E08C5DDACB9 savannah https://savannah.gnu.org/project/release-gpgkeys.php?group=findutils&download=1 2026-10-02 findutils James Youngman B1D2BD1375BECB784CF4F8C4D73CF638C53C06BE savannah https://savannah.gnu.org/project/release-gpgkeys.php?group=inetutils&download=1 2026-10-02 inetutils Simon Josefsson ; signs with subkey A3CC9C870B9D310ABAD4CF2F51722B08FE4745A2; Savannah's copy expired 2022-10-23, the GNU keyring's runs later +D33A3F0CA16B0ACC51A60738494C8A5FBF4DECEB wkd mak@debian.org 2026-10-13 hicolor-icon-theme Matthias Klumpp ; db.debian.org and nm.debian.org show the same fingerprint; signs the 0.18 tarball, the first signed release; expires 2028-07-18 diff --git a/tools/verify-signatures.sh b/tools/verify-signatures.sh index 441c4da3..803ff1d1 100755 --- a/tools/verify-signatures.sh +++ b/tools/verify-signatures.sh @@ -256,6 +256,17 @@ PINNED_FPRS=( # libexpat names no signer: gentoo.org's WKD serves this for sping@gentoo.org (source-notes.tsv) "3176EF7DB2367F1FCA4F306B1F9B0E909AF37285" # Sebastian Pipping, expat + + # The software images' sources (build/images/versions.env). + # https://github.com/PCRE2Project/pcre2/blob/main/SECURITY.md (retrieved 2026-10-13) names it for + # 10.45 on, cross-signed by Philip Hazel's earlier key. It signs with a subkey GitHub's copy lacks. + "A95536204A3BB489715231282A98E77EB6F24CA8" # Nicholas Wilson, pcre2 + # cairographics.org names the signer, not the key; https://gitlab.gnome.org/ebassi.gpg serves this + # one under his GNOME account (retrieved 2026-10-13). It signs the checksum lists since 1.18.0. + "53EF3DC3B63E2899271BD26322E8091EEA11BBB7" # Emmanuele Bassi, cairo + # FreeType publishes only the long id BE6C3AAC63AD8E3F: docs/release in its repository since + # 2.12.0, and each release announcement. This key ends in it and has signed every release since. + "E30674707856409FF1948010BE6C3AAC63AD8E3F" # Werner Lemberg, freetype (long id alone: weaker) ) # Primaries and subkeys: GOODSIG names a subkey, while pins and keys.manifest hold primaries. From 77680f31ebc98f34836589733eb9d7cc4529af4b Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 00:13:26 -0700 Subject: [PATCH 022/123] An image's layers count by what they are built from, so a current image is found so without building them --- build/stages/04-images.sh | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/build/stages/04-images.sh b/build/stages/04-images.sh index e89b2cb3..e6566851 100755 --- a/build/stages/04-images.sh +++ b/build/stages/04-images.sh @@ -25,9 +25,10 @@ image_setting() { } # What an image is built from: its definition and files, the recipes and flags, the pins, the base -# system, and the images it is built over. -image_inputs() { # image_inputs NAME - local f dep +# system, and what the images it is built over are built from, so a cached image needs none of them +# built to be found current. CHAIN is the images it is being read under. +image_inputs() { # image_inputs NAME [CHAIN] + local f dep h chain="${2:-} ${1}" for f in "${DEFS}/${1}.sh" "${DEFS}/${1}"/* "${KRYPTIK_ROOT}/build/lib/recipes.sh" "${KRYPTIK_ROOT}"/build/recipes/*.sh \ "${BASH_SOURCE[0]}" "${KRYPTIK_ROOT}/tools/check-image-tree.sh" "${KRYPTIK_ROOT}/build/config/hardening.env" \ "${KRYPTIK_ROOT}/build/config/hardening-exceptions.txt" "${KRYPTIK_ROOT}/build/config/versions.env" \ @@ -36,7 +37,10 @@ image_inputs() { # image_inputs NAME done printf 'base %s\n' "$(_stamp_read "${KRYPTIK_WORK}/.stamps/bs-licences")" for dep in $(image_setting "$1" image_over); do - printf 'over %s %s\n' "$dep" "$(sed -n 's/^fingerprint: //p' "${KRYPTIK_WORK}/image-build/${dep}/record")" + [[ " ${chain} " != *" ${dep} "* ]] || die "image ${dep} is built over itself:${chain} ${dep}" + [[ -f "${DEFS}/${dep}.sh" ]] || die "image ${1} is built over ${dep}, which has no ${DEFS}/${dep}.sh" + h="$(image_inputs "$dep" "$chain" | sha256_of_stdin)" + printf 'over %s %s\n' "$dep" "$h" done } @@ -49,31 +53,26 @@ unmount_image() { # unmount_image TOP if findmnt -rn -o TARGET | grep -F "$1/" >&2; then die "still mounted under $1 (above): nothing there is removed"; fi } -# The images being built, innermost last, so one built over itself is refused. -BUILDING="" build_image() { # build_image NAME local name="$1" top="${KRYPTIK_WORK}/image-build/${1}" version want d ships dep lowers="" local -a over [[ "$name" =~ ^[a-z0-9-]{1,32}$ ]] || die "image name ${name}: a-z, 0-9 and -, at most 32" [[ -f "${DEFS}/${name}.sh" ]] || die "no image ${name}: nothing at ${DEFS}/${name}.sh" - [[ " ${BUILDING} " != *" ${name} "* ]] || die "image ${name} is built over itself:${BUILDING} ${name}" version="$(image_setting "$name" image_version)" [[ "$version" =~ ^[!-~]{1,64}$ ]] || die "image ${name}: version '${version}' is not 1-64 printable characters without a space" ships="$(image_setting "$name" image_ships)"; ships="${ships:-yes}" [[ "$ships" == yes || "$ships" == no ]] || die "image ${name}: image_ships is yes or no, not '${ships}'" read -ra over <<< "$(image_setting "$name" image_over)" - # What it is built over comes first; the nearest layer is named first, as overlayfs takes it. - BUILDING="${BUILDING} ${name}" - for dep in "${over[@]}"; do - build_image "$dep" - lowers+="${KRYPTIK_WORK}/image-build/${dep}/tree:" - done - BUILDING="${BUILDING% "${name}"}" want="$(image_inputs "$name" | sha256_of_stdin)" if [[ -d "${top}/tree/usr" ]] && grep -qx "fingerprint: ${want}" "${top}/record" 2>/dev/null; then dim " skip ${name} ${version} (already built, inputs unchanged)" return 0 fi + # What it is built over comes first; the nearest layer is named first, as overlayfs takes it. + for dep in "${over[@]}"; do + build_image "$dep" + lowers+="${KRYPTIK_WORK}/image-build/${dep}/tree:" + done log "image ${name} ${version}" unmount_image "$top" rm -rf "$top" From f4a0aeda0d3b65a56a0becc59e9c72b2d18bbc05 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 00:15:35 -0700 Subject: [PATCH 023/123] How the browser image is built: Firefox ESR 153 over the toolkit and build-only toolchain layers --- docs/design/browser-and-graphics.md | 47 +++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/docs/design/browser-and-graphics.md b/docs/design/browser-and-graphics.md index 8ef2a0ff..ce3717e1 100644 --- a/docs/design/browser-and-graphics.md +++ b/docs/design/browser-and-graphics.md @@ -138,6 +138,53 @@ separate is a recipe and a pin review of its own. H.264 needs FFmpeg's decoder, which Firefox loads if it is present; whether to ship it is a licensing question as much as a size one, and is left open. +## How the browser image is built + +Firefox ESR 153 is the `browser` software image, built over the toolkit +image and over build-only layers that never reach a zone (`image_ships=no`, +[software delivery](software-delivery.md)). Stage 04 already treats cmake +this way: a pinned binary the build runs and never installs. ESR 153's own +floors are given with each layer. + +- **Its layers.** + - **`rust`:** the pinned release toolchain for `x86_64-unknown-linux-gnu`, + vouched for by the same Rust key as kryptikd's. ESR 153 needs 1.90 or + newer. + - **`clang`:** LLVM's release build, for libclang. bindgen loads it to + generate the style system's bindings, and its `wasm32` target compiles + the sandboxed libraries. It is about a gigabyte compressed, in the + sources cache. A source build of clang and libclang would instead cost + an hour each time the base system changes. + - **`cbindgen`:** 0.29.4 or newer, built with the `rust` layer. Its crate + and the crates its lock file names are fetched before the build, since + the chroot has no network, and checked against that lock. + - **`nasm`:** for dav1d's AV1 decoder. + - **Node.js** is left out: ESR 153 still takes `--disable-nodejs`. +- **The build.** + - Stage 04's GCC with the hardening flags; an exception each flag needs + goes into `hardening-exceptions.txt` with its reason. + - `--enable-default-toolkit=cairo-gtk3-wayland-only`, `--disable-dbus`, + `--enable-release`, `--disable-tests`, `--disable-crashreporter`, + `--disable-updater` (updates are the image's) and `--disable-eme`. + - `--disable-jemalloc`, as the image preloads hardened_malloc for every + process. + - `--disable-audio-backends` until sound reaches zones. The ESR default + is PulseAudio, and ALSA needs alsa-lib and a sound path designed first. + - The first build is `--without-wasm-sandboxed-libraries`. The next adds + a WASI sysroot to the `clang` layer, since a font or spelling parser + bug should stay inside that sandbox. +- **Time.** + - On four cores with GCC, without LTO or PGO, ESR takes about two and a + half hours. That fits the system job's six hours after a warm stage 04, + but not every run should pay it. + - The trees of the shipped images go into a cache of their own, keyed by + their fingerprints, which include the base system's stamp. A run + restores a tree whose inputs have not changed, so only a change to the + browser, the toolkit or the base system rebuilds it. +- **Updates.** ESR has a security release every four weeks. Its pins are the + images' (`build/images/versions.env`), so a bump rebuilds the browser and + leaves stage 04 and its cache alone. + ## GPU rendering, zone by zone ### What software rendering gives From c0a8b5167b16bd319f902fdae8da67d2dcfe9cf7 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 00:21:42 -0700 Subject: [PATCH 024/123] Stage 06 reads the image records by name, not by path, so the requires and needs lines follow the image lines' order "probe-x/record" sorts before "probe/record", as "-" is below "/", while the image lines put probe first; kryptikd refuses a list whose requires or needs lines are out of image order. --- build/stages/06-iso.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/build/stages/06-iso.sh b/build/stages/06-iso.sh index b04a73df..21a08c37 100755 --- a/build/stages/06-iso.sh +++ b/build/stages/06-iso.sh @@ -392,8 +392,10 @@ s_images() { local version="$1" base; base="$(root_json root_hash)" local rec name ver line dep reqs needs=() images=() requires=() rm -rf "$IMAGES"; mkdir -p "$IMAGES" - # In the list's own order, which its requires and needs lines follow. - for rec in $(printf '%s\n' "${KRYPTIK_WORK}"/image-build/*/record | LC_ALL=C sort); do + # By name in byte order, the list's own, which its requires and needs lines follow. Not by path: + # there "probe-x/record" comes before "probe/record", as "-" sorts below "/". + for name in $(for rec in "${KRYPTIK_WORK}"/image-build/*/record; do basename "${rec%/record}"; done | LC_ALL=C sort); do + rec="${KRYPTIK_WORK}/image-build/${name}/record" [[ -f "$rec" ]] || continue name="$(sed -n 's/^name: //p' "$rec")"; ver="$(sed -n 's/^version: //p' "$rec")" if [[ "$(sed -n 's/^ships: //p' "$rec")" == no ]]; then From e297f177710c6218dc5fd9f580b9d522ec64c9b7 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 00:30:21 -0700 Subject: [PATCH 025/123] An image's mapping and mount are named by its whole root hash, and a stacked set is used again only where it stacks those very mounts kryptikd reused an open dm-verity mapping, its mount and an overlay set whenever one existed under a name that held 64 bits of the root hash. Only kryptikd makes them, from signed lists, but a wrong reuse then needed only two release-signed images sharing a name and a 64-bit hash prefix, not the signature itself. The mapping (at most 111 characters, inside device-mapper's 127) and the mount are now named by the whole root hash. A set's name keeps 64 bits of each hash, as three long names in full would pass NAME_MAX, and a set already mounted is used only if /proc/self/mounts shows it stacking exactly the full-hash mounts it would stack; one over other images is refused. The set's overlay follows no redirect in a lower layer, though no image is built with one. software-delivery.md says so, and that an image-only fetch must keep a list newer than the one held, so that a replayed older signed list cannot bring an image's vulnerable build back. --- compartments/kryptikd/src/images.rs | 30 ++++++++++++++++++++--- compartments/kryptikd/src/images/tests.rs | 16 ++++++++++++ docs/design/software-delivery.md | 25 +++++++++++-------- 3 files changed, 57 insertions(+), 14 deletions(-) diff --git a/compartments/kryptikd/src/images.rs b/compartments/kryptikd/src/images.rs index 1f9facb1..5eda1b31 100644 --- a/compartments/kryptikd/src/images.rs +++ b/compartments/kryptikd/src/images.rs @@ -257,13 +257,34 @@ pub fn prepare(zone: &Zone, granted: Option<&Policy>) -> Result } lower.push("/usr".into()); let set = set_dir(&chosen); - if !crate::volume::is_mountpoint(&set) { + let lowerdir = format!("lowerdir={}", lower.join(":")); + let mounts = fs::read_to_string("/proc/self/mounts").map_err(|e| format!("/proc/self/mounts: {e}"))?; + if !stacked(&mounts, &set, &lowerdir)? { fs::create_dir_all(&set).map_err(|e| format!("{set}: {e}"))?; - mount("kryptik-usr", &set, "overlay", Some(format!("lowerdir={}", lower.join(":")).as_str()))?; + // No redirect in a lower layer is followed, though no image is built with one. + mount("kryptik-usr", &set, "overlay", Some(format!("{lowerdir},redirect_dir=nofollow").as_str()))?; } Ok(set) } +/// Whether the set at `at` is mounted already with exactly `lowerdir` (use it), not at all (stack +/// it), or over other images (refused): a set's name holds 64 bits of each root hash, the mounts +/// in its lowerdir the whole hash. `mounts` is /proc/self/mounts; the topmost mount at `at` counts. +fn stacked(mounts: &str, at: &str, lowerdir: &str) -> Result { + let options = mounts + .lines() + .filter_map(|l| { + let f: Vec<&str> = l.split_whitespace().collect(); + (f.len() >= 4 && f[1] == at).then(|| f[3]) + }) + .last(); + match options { + None => Ok(false), + Some(o) if o.split(',').any(|x| x == lowerdir) => Ok(true), + Some(_) => Err(format!("{at} already stacks other images than these")), + } +} + /// The list for `base`, read once, its signature checked as nobody on a copy of those bytes. fn verified_list(base: &str) -> Result { let list = Path::new(STORE).join(format!("{base}.manifest")); @@ -305,7 +326,8 @@ fn open(img: &Image) -> Result { if md.len() != img.bytes { return Err(format!("image {}: {shown} holds {} bytes, and the list signs {}", img.name, md.len(), img.bytes)); } - let mapper = format!("kryptik-image-{}-{}", img.name, &img.root_hash[..16]); + // The whole root hash: at most 111 characters, inside device-mapper's 127. + let mapper = format!("kryptik-image-{}-{}", img.name, img.root_hash); let dev = format!("/dev/mapper/{mapper}"); if Path::new(&dev).exists() { // dm-verity marks a mapping corrupted at the first block that reads back other than signed. @@ -326,7 +348,7 @@ fn open(img: &Image) -> Result { ]; crate::volume::run("veritysetup open", "veritysetup", &args, None).map_err(|e| format!("image {}: {}", img.name, e.1))?; } - let at = format!("{MOUNTS}/{}/{}", img.name, &img.root_hash[..16]); + let at = format!("{MOUNTS}/{}/{}", img.name, img.root_hash); if !crate::volume::is_mountpoint(&at) { fs::create_dir_all(&at).map_err(|e| format!("{at}: {e}"))?; // A journal is never replayed: the device under it is read-only. diff --git a/compartments/kryptikd/src/images/tests.rs b/compartments/kryptikd/src/images/tests.rs index 32f2308d..05175f54 100644 --- a/compartments/kryptikd/src/images/tests.rs +++ b/compartments/kryptikd/src/images/tests.rs @@ -159,6 +159,22 @@ fn a_set_is_named_by_its_images_and_their_hashes() { assert_eq!(set_dir(&both[..1]), format!("/run/kryptik/usr/browser-{}", &hex('b')[..16])); } +#[test] +fn a_set_is_used_again_only_where_it_stacks_these_very_images() { + let at = format!("/run/kryptik/usr/browser-{}", &hex('b')[..16]); + let want = format!("lowerdir=/run/kryptik/images/browser/{}/usr:/usr", hex('b')); + let other = format!("lowerdir=/run/kryptik/images/browser/{}{}/usr:/usr", &hex('b')[..16], &hex('c')[16..]); + let line = |lowerdir: &str| format!("kryptik-usr {at} overlay ro,nosuid,nodev,relatime,{lowerdir},redirect_dir=nofollow 0 0\n"); + let root = "/dev/vda2 / ext4 ro,relatime 0 0\n"; + assert_eq!(stacked(root, &at, &want), Ok(false), "not mounted: stack it"); + assert_eq!(stacked(&format!("{root}{}", line(&want)), &at, &want), Ok(true), "these very images: use it"); + let refused = stacked(&format!("{root}{}", line(&other)), &at, &want).unwrap_err(); + assert!(refused.contains("other images"), "{refused}"); + // The topmost mount at the path is the one that counts. + assert_eq!(stacked(&format!("{}{}", line(&other), line(&want)), &at, &want), Ok(true)); + assert!(stacked(&format!("{}{}", line(&want), line(&other)), &at, &want).is_err()); +} + #[test] fn names_are_what_a_path_and_a_zone_file_take() { for ok in ["browser", "pdf-viewer", "a", "x1"] { diff --git a/docs/design/software-delivery.md b/docs/design/software-delivery.md index 8d2fd551..729224f2 100644 --- a/docs/design/software-delivery.md +++ b/docs/design/software-delivery.md @@ -252,20 +252,25 @@ up ADR-001 in the zones where the applications run. - **Fetched** alone for an image-only fix, by the same verbs as a release. The statement of what is current gains one line per image for each base it serves; zone 0 fetches only images whose hash it does not hold, never - past the signed size. + past the signed size. A second list for a root whose list the store keeps + replaces it only if the statement names it as newer: a signed list from + before an image's fix, replayed, must not bring the old image back. - **Mounted** by kryptikd on first use: a read-only loop device, dm-verity with the manifest's root hash, and ext4 mounted read-only, `nosuid,nodev` - under `/run/kryptik/images//`, one directory per root hash. Each - start checks the list's signature again, as nobody, on a copy of the bytes - it then reads. A mapping that dm-verity has marked corrupted is not used - again: the next start of a zone that names the image is refused, with the + under `/run/kryptik/images//`, one directory per root hash; the + mapping and the directory are named by the whole hash. Each start checks + the list's signature again, as nobody, on a copy of the bytes it then + reads. A mapping that dm-verity has marked corrupted is not used again: + the next start of a zone that names the image is refused, with the reason, until the image is fetched again. For each distinct set of images a zone names, kryptikd mounts a read-only overlay (no upper directory; the - images' `usr` over the root's `/usr`) at `/run/kryptik/usr/` in the - initial namespace, and the zone's bind of `/usr` takes that instead. Since - zone 0 mounts it and the zone only receives a bind, the hardened kernel's - `OVERLAY_FS_UNPRIVILEGED`, which is off, does not come into it. Loop, - dm-verity and overlayfs are already built in. + images' `usr` over the root's `/usr`; redirects in a layer not followed) + at `/run/kryptik/usr/` in the initial namespace, and the zone's bind + of `/usr` takes that instead. A set already mounted there is used only if + it stacks exactly those mounts, as its name holds 64 bits of each root + hash. Since zone 0 mounts it and the zone only receives a bind, the + hardened kernel's `OVERLAY_FS_UNPRIVILEGED`, which is off, does not come + into it. Loop, dm-verity and overlayfs are already built in. - **Named** in the zone file: ```toml From cd3d681eaa54bcc37dbbc1f637d01c1b7dbf1e92 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 00:32:25 -0700 Subject: [PATCH 026/123] The browser plan names its two open choices: how the Rust tarballs reach the chroot, and where cbindgen is built --- docs/design/browser-and-graphics.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/design/browser-and-graphics.md b/docs/design/browser-and-graphics.md index ce3717e1..9c6ebcc1 100644 --- a/docs/design/browser-and-graphics.md +++ b/docs/design/browser-and-graphics.md @@ -184,6 +184,19 @@ floors are given with each layer. - **Updates.** ESR has a security release every four weeks. Its pins are the images' (`build/images/versions.env`), so a bump rebuilds the browser and leaves stage 04 and its cache alone. +- **Choices still open.** + - **Getting the Rust tarballs into the chroot.** The `rust` layer needs + the tarballs `build/config/rust.lock` pins. Today the Distro workflow + fetches them outside the chroot for kryptikd and nothing else sees + them. Listed as image sources instead, they come under the signature + and currency gates, which then cover kryptikd's compiler too. That is + a bump every six weeks, and a test must hold both locks to one hash. + - **cbindgen built the way kryptikd is.** The option is static for + musl, outside the chroot, by the pinned compiler, its crates checked + by its own lock. That needs stage 03's runner to bind one more binary + into the chroot, and the runner is in stage 04's cache key. The + alternative is an offline build inside the chroot from crates fetched + beforehand. ## GPU rendering, zone by zone From 73e163b215f8be234f424689517f9996518ecaf8 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 00:33:57 -0700 Subject: [PATCH 027/123] Stage 06 audits an image against its own accepted findings, or none, not the root's --strict refuses an accepted entry that matches nothing, and every entry of the root's list names an object of the root's, so the probe's audit failed on all of them. --- build/stages/06-iso.sh | 8 ++++++-- docs/design/software-delivery.md | 4 +++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/build/stages/06-iso.sh b/build/stages/06-iso.sh index a5cfd396..d4ad448d 100755 --- a/build/stages/06-iso.sh +++ b/build/stages/06-iso.sh @@ -351,7 +351,11 @@ pack_image() { rsync -aH --numeric-ids "${tree}/" "${stage}/" strip_root "$stage" >&2 if find "$stage" -perm /6000 -print | grep . >&2; then echo "FAIL: a setuid or setgid file in image ${name}" >&2; return 1; fi - "${KRYPTIK_ROOT}/tools/check-artifact-hardening.sh" --strict "$stage" >&2 + # Against the image's own accepted findings, each with its reason, or none: the root's list + # names the root's objects, and --strict refuses an entry that matches nothing. + local accepted="${KRYPTIK_ROOT}/build/images/${name}.accepted" + if [[ ! -f "$accepted" ]]; then accepted="${stage}.accepted"; : > "$accepted"; fi + "${KRYPTIK_ROOT}/tools/check-artifact-hardening.sh" --strict --accepted "$accepted" "$stage" >&2 "${KRYPTIK_ROOT}/tools/check-image-programs.sh" "$stage" --list "${KRYPTIK_ROOT}/build/images/${name}.programs" >&2 local bytes; bytes="$(du -sb "$stage" | cut -f1)" local fs_bytes=$(( bytes + bytes / 8 + 16 * 1024 * 1024 )) @@ -377,7 +381,7 @@ pack_image() { [[ ${#root_hash} -eq 64 ]] || { echo "image ${name}: bad root hash ${root_hash}" >&2; return 1; } veritysetup verify --no-superblock --hash=sha256 --data-block-size=4096 --hash-block-size=4096 \ --data-blocks="$data_blocks" --hash-offset="$fs_bytes" --salt="$salt" "$img" "$img" "$root_hash" >&2 - rm -rf "$stage" "${stage}.hash" + rm -rf "$stage" "${stage}.hash" "${stage}.accepted" printf 'image: %s %s bytes=%s sha256=%s data-blocks=%s root-hash=%s salt=%s\n' \ "$name" "$version" "$(stat -c %s "$img")" "$(sha256_of "$img")" "$data_blocks" "$root_hash" "$salt" } diff --git a/docs/design/software-delivery.md b/docs/design/software-delivery.md index ebdac872..5feb8aac 100644 --- a/docs/design/software-delivery.md +++ b/docs/design/software-delivery.md @@ -198,7 +198,9 @@ up ADR-001 in the zones where the applications run. - **Packed.** Stage 06 makes `images-/.img` the way it makes the root (ext4 without a journal, then the verity tree, with a salt and UUID derived from the content), runs the root's artifact audit - and the image's own program list over it, and writes a manifest, + over it with the image's own accepted findings + (`build/images/NAME.accepted`, each with its reason) or none, and the + image's own program list, and writes a manifest, `images-/images.manifest`: ```text From 0cfaf095d985591959d5cdd0128979489c7bb32e Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 00:36:46 -0700 Subject: [PATCH 028/123] Stage 06 runs every check of an image before failing it, so one run shows each finding --- build/stages/06-iso.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/build/stages/06-iso.sh b/build/stages/06-iso.sh index d4ad448d..15b9e929 100755 --- a/build/stages/06-iso.sh +++ b/build/stages/06-iso.sh @@ -355,8 +355,11 @@ pack_image() { # names the root's objects, and --strict refuses an entry that matches nothing. local accepted="${KRYPTIK_ROOT}/build/images/${name}.accepted" if [[ ! -f "$accepted" ]]; then accepted="${stage}.accepted"; : > "$accepted"; fi - "${KRYPTIK_ROOT}/tools/check-artifact-hardening.sh" --strict --accepted "$accepted" "$stage" >&2 - "${KRYPTIK_ROOT}/tools/check-image-programs.sh" "$stage" --list "${KRYPTIK_ROOT}/build/images/${name}.programs" >&2 + # Every check runs before the step fails, so one run shows each finding. + local failed=0 + "${KRYPTIK_ROOT}/tools/check-artifact-hardening.sh" --strict --accepted "$accepted" "$stage" >&2 || failed=1 + "${KRYPTIK_ROOT}/tools/check-image-programs.sh" "$stage" --list "${KRYPTIK_ROOT}/build/images/${name}.programs" >&2 || failed=1 + [[ "$failed" -eq 0 ]] || { echo "FAIL: image ${name} did not pass the checks above" >&2; return 1; } local bytes; bytes="$(du -sb "$stage" | cut -f1)" local fs_bytes=$(( bytes + bytes / 8 + 16 * 1024 * 1024 )) fs_bytes=$(( (fs_bytes + 4095) / 4096 * 4096 )) From 55f4202e22111a5b6dfbd5275c24da14697dcce8 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 00:37:45 -0700 Subject: [PATCH 029/123] The browser plan adds what the source research found: two gigabytes of sources, and Mozilla's rotated key --- docs/design/browser-and-graphics.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/design/browser-and-graphics.md b/docs/design/browser-and-graphics.md index 9c6ebcc1..7733003c 100644 --- a/docs/design/browser-and-graphics.md +++ b/docs/design/browser-and-graphics.md @@ -197,6 +197,16 @@ floors are given with each layer. into the chroot, and the runner is in stage 04's cache key. The alternative is an offline build inside the chroot from crates fetched beforehand. + - **Two gigabytes of sources.** Firefox's source is 0.8 GB and LLVM's + build 1.2 GB even as `.tar.zst`. In the one sources cache, every image + pin's bump would copy the root's set beside them, against the + repository's 10 GB. The images' sources need a cache of their own, + keyed by `build/images/sources.lock`. + - **Mozilla's key from Mozilla.** Its signing subkey was replaced in + August 2026 after a copy leaked, and the keyserver's copy carries + neither the new subkey nor the old one's revocation. The key must come + from the `KEY` file beside each release, which needs a provenance kind + for a project's own key file. ## GPU rendering, zone by zone From c21e891cfc14caabc1dbb2dc4bd1b8aa4a14cb1f Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 00:45:54 -0700 Subject: [PATCH 030/123] The Distro run keeps the software images' trees in a cache of their own, keyed by the base system they are built over make images keeps each image whose fingerprint still matches its inputs and builds the rest, so a run that changes neither an image nor the base system builds none; a tag still builds from nothing. --- .github/workflows/distro.yml | 58 +++++++++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/.github/workflows/distro.yml b/.github/workflows/distro.yml index e95e518b..36e88c26 100644 --- a/.github/workflows/distro.yml +++ b/.github/workflows/distro.yml @@ -330,11 +330,67 @@ jobs: if: always() run: rm -f /mnt/kryptik/work-after-05.tar.zst - # After the cache is packed, so no image tree or overlay reaches it; stage 06 packs what this builds. + # The images built over this very base system, from an earlier run: make images keeps each whose + # fingerprint still matches its inputs and builds the rest. Not for a tag, which builds from nothing. + - name: Which base system the images are built over + id: images_key + if: github.ref_type != 'tag' + run: | + base="$(sudo sed -n 's/^fingerprint: //p' "$KRYPTIK_WORK/.stamps/bs-licences" | cut -c1-16)" + if [ -n "$base" ]; then echo "prefix=images-v1-${{ needs.toolchain.outputs.ttfp }}-${base}-" >> "$GITHUB_OUTPUT"; fi + + - name: Software images from an earlier run (cached) + id: images_cache + if: steps.images_key.outputs.prefix != '' + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + with: + path: /mnt/kryptik/images-cache.tar.zst + key: ${{ steps.images_key.outputs.prefix }} + restore-keys: | + ${{ steps.images_key.outputs.prefix }} + + - name: Unpack the cached images + if: steps.images_cache.outputs.cache-matched-key != '' + run: | + cd /mnt/kryptik + sudo tar --zstd -xf images-cache.tar.zst -C work + rm -f images-cache.tar.zst + sudo sed -n 's/^\(name\|fingerprint\): //p' work/image-build/*/record | paste - - || true + + # After the stage 04-05 cache is packed, so no image tree or overlay reaches it; stage 06 packs what this builds. - name: Software images (over the base system, in the chroot) + id: images timeout-minutes: 90 run: make -o system images + # Each image's tree and record, never an overlay's upper or work directory; saved when they changed. + - name: Pack the images for their cache + id: images_pack + if: ${{ !cancelled() && steps.images.outcome == 'success' && steps.images_key.outputs.prefix != '' }} + run: | + d="$(cd "$KRYPTIK_WORK" && sudo cat image-build/*/record 2>/dev/null | sha256sum | cut -c1-16)" + key="${{ steps.images_key.outputs.prefix }}${d}-" + case "${{ steps.images_cache.outputs.cache-matched-key }}" in "$key"*) echo "the images are the cached ones"; exit 0 ;; esac + cd /mnt/kryptik + sudo tar -I 'zstd -T0' -cf images-cache.tar.zst -C work --exclude='image-build/*/upper' \ + --exclude='image-build/*/overlay-work' --exclude='image-build/*/sysroot' image-build + sudo chown "$(id -u):$(id -g)" images-cache.tar.zst + ls -la images-cache.tar.zst + echo "key=${key}${{ github.run_id }}-${{ github.run_attempt }}" >> "$GITHUB_OUTPUT" + + # A cache that cannot be saved costs the next run time, not this one its verdict. + - name: Save the images + if: ${{ !cancelled() && steps.images_pack.outputs.key != '' }} + continue-on-error: true + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + with: + path: /mnt/kryptik/images-cache.tar.zst + key: ${{ steps.images_pack.outputs.key }} + + - name: Drop the images' tarball before the media are built + if: always() + run: rm -f /mnt/kryptik/images-cache.tar.zst + # The throwaway production pair, then A and B; a production release is only bound here, where upstream build scripts ran as root. - name: Stage 06, the media (production 1.0.0 and 1.0.1, release A and the update B) id: media From 4afc0ff163f321870a2ba74a6d71f45e24f5d338 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 01:24:13 -0700 Subject: [PATCH 031/123] The toolkit image takes the Khronos EGL headers from libglvnd, which libepoxy and GTK's Wayland backend compile against, and no EGL library --- build/images/sources.lock | 1 + build/images/toolkit.sh | 10 ++++++++++ build/images/versions.env | 2 ++ docs/design/browser-and-graphics.md | 3 +++ tools/currency-rules.tsv | 1 + tools/fetch-sources.sh | 1 + 6 files changed, 18 insertions(+) diff --git a/build/images/sources.lock b/build/images/sources.lock index 70dfeaeb..03950f5f 100644 --- a/build/images/sources.lock +++ b/build/images/sources.lock @@ -9,6 +9,7 @@ cca6b6a86e4389d89b370288761ce2385ddeceb10c4412da78689bc30c88af2d at-spi2-core-2 d07a007327277708a2a73ae437887cdbaf282937f6d03ca5467723e9099af586 harfbuzz-14.6.0.tar.xz db0e50a80aa3bf64bb45cbca5cf9f75efd9348cf2ac690b907435238c3cf81d7 hicolor-icon-theme-0.18.tar.xz a7ced37f4102b745ac86d6a70a9da399cc139ff168ba6b8002b4d8d43c900c15 libepoxy-1.5.10.tar.gz +2b6e15b06aafb4c0b6e2348124808cbd9b291c647299eaaba2e3202f51ff2f3d libglvnd-v1.7.0.tar.gz d80dd2a38a37f803cb9b6ac7b14bd6e74ddc3b654780a8380bdf93523fdb4389 libpng-1.6.59.tar.xz 342385b6ca3b7c73455d7c80a13b7dbe4489e00bc3bd4c5bd6ed4dce421e374a pango-1.58.2.tar.xz 53c156e1ba416a20da8e65395daa132da0d80e76910424caca3fcdae7831d384 pcre2-10.49.tar.bz2 diff --git a/build/images/toolkit.sh b/build/images/toolkit.sh index e0f01659..2ede046b 100644 --- a/build/images/toolkit.sh +++ b/build/images/toolkit.sh @@ -16,6 +16,7 @@ image_rows=( "cairo" "s_tk_cairo" "pango" "s_tk_pango" "gdk-pixbuf" "s_tk_gdk_pixbuf" + "egl-headers" "s_tk_egl_headers" "libepoxy" "s_tk_libepoxy" "atk" "s_tk_atk" "hicolor" "s_tk_hicolor" @@ -98,6 +99,15 @@ s_tk_gdk_pixbuf() { -Dthumbnailer=disabled -Dintrospection=disabled -Dman=false -Dtests=false -Dinstalled_tests=false } +# The Khronos EGL headers that libepoxy and GTK's Wayland backend compile against, and nothing else of +# libglvnd: no EGL library comes with them, as a zone has no GPU. Its README holds the licences. +s_tk_egl_headers() { + local src; src="$(unpack "libglvnd-v${V_LIBGLVND}.tar.gz" "libglvnd-v${V_LIBGLVND}")" + install -D -m 0644 -t /usr/include/EGL "${src}"/include/EGL/{egl,eglext,eglplatform}.h + install -D -m 0644 -t /usr/include/KHR "${src}/include/KHR/khrplatform.h" + install -D -m 0644 "${src}/README.md" /usr/share/licenses/libglvnd/README.md +} + # EGL only, loaded when a program asks for GL; software zones never do. s_tk_libepoxy() { meson_build "libepoxy-${V_LIBEPOXY}.tar.gz" "libepoxy-${V_LIBEPOXY}" \ diff --git a/build/images/versions.env b/build/images/versions.env index a4dbf71d..34a539cf 100644 --- a/build/images/versions.env +++ b/build/images/versions.env @@ -19,6 +19,8 @@ V_FRIBIDI=1.0.17 V_CAIRO=1.18.6 V_PANGO=1.58.2 V_GDK_PIXBUF=2.44.8 +# Its Khronos EGL headers alone, which libepoxy and GTK compile against; the root has no EGL. +V_LIBGLVND=1.7.0 V_LIBEPOXY=1.5.10 # ATK, which GTK 3 requires, is built from here without the D-Bus bridge. V_AT_SPI2_CORE=2.62.1 diff --git a/docs/design/browser-and-graphics.md b/docs/design/browser-and-graphics.md index 8ef2a0ff..80293036 100644 --- a/docs/design/browser-and-graphics.md +++ b/docs/design/browser-and-graphics.md @@ -114,6 +114,9 @@ browser image is built over: fontconfig, cairo (pixman is the root's), gdk-pixbuf with libpng, libepoxy and ATK from at-spi2-core, built alone with a patch that drops its unused libxml2; +- the Khronos EGL headers from libglvnd, which libepoxy and GTK's Wayland + backend compile against. No EGL library comes with them: a zone has no + GPU, so GL stays unavailable there; - gdk-pixbuf reads PNG alone, built in: GTK's theme and icons are PNG, and each other loader is a parser more. JPEG, GIF and the rest come only if a program the image carries needs them; Firefox decodes its own; diff --git a/tools/currency-rules.tsv b/tools/currency-rules.tsv index 7a6a2b2a..c66bbfbf 100644 --- a/tools/currency-rules.tsv +++ b/tools/currency-rules.tsv @@ -56,5 +56,6 @@ gtk3 listing https://download.gnome.org/sources/gtk/cache.json gtk-(3\.24\.[0-9] # 1.6 is the stable series; the 1.7 tags are betas. libpng listing https://api.github.com/repos/pnggroup/libpng/tags?per_page=100 "name": "v(1\.6\.[0-9]+)" - fontconfig api https://gitlab.freedesktop.org/api/v4/projects/890/releases?per_page=20 tag_name drop90 +libglvnd api https://gitlab.freedesktop.org/api/v4/projects/glvnd%2Flibglvnd/releases?per_page=20 tag_name - # An odd minor is a development snapshot. cairo listing https://www.cairographics.org/releases/ cairo-([0-9]+\.[0-9]+\.[0-9]+)\.tar\.xz even diff --git a/tools/fetch-sources.sh b/tools/fetch-sources.sh index 5c407cac..46b42634 100755 --- a/tools/fetch-sources.sh +++ b/tools/fetch-sources.sh @@ -189,6 +189,7 @@ toolkit|fribidi|${V_FRIBIDI}|${MIRROR_GITHUB}/fribidi/fribidi/releases/download/ toolkit|cairo|${V_CAIRO}|${MIRROR_CAIRO}/cairo-${V_CAIRO}.tar.xz|sums:cairo-${V_CAIRO}.tar.xz.sha256sum.asc|rule toolkit|pango|${V_PANGO}|${gnome}/pango/${V_PANGO%.*}/pango-${V_PANGO}.tar.xz|stem.sha256sum|rule toolkit|gdk-pixbuf|${V_GDK_PIXBUF}|${gnome}/gdk-pixbuf/${V_GDK_PIXBUF%.*}/gdk-pixbuf-${V_GDK_PIXBUF}.tar.xz|stem.sha256sum|rule +toolkit|libglvnd|${V_LIBGLVND}|${MIRROR_FDO_GITLAB}/glvnd/libglvnd/-/archive/v${V_LIBGLVND}/libglvnd-v${V_LIBGLVND}.tar.gz|none|rule toolkit|libepoxy|${V_LIBEPOXY}|${MIRROR_GITHUB}/anholt/libepoxy/archive/${V_LIBEPOXY}/libepoxy-${V_LIBEPOXY}.tar.gz|none|github toolkit|at-spi2-core|${V_AT_SPI2_CORE}|${gnome}/at-spi2-core/${V_AT_SPI2_CORE%.*}/at-spi2-core-${V_AT_SPI2_CORE}.tar.xz|stem.sha256sum|rule toolkit|hicolor-icon-theme|${V_HICOLOR_ICON_THEME}|${MIRROR_ICON_THEME}/hicolor-icon-theme-${V_HICOLOR_ICON_THEME}.tar.xz|asc|listing From 8f03b9e7e239699b46cb4a1d98736e1bd4891c51 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 01:35:42 -0700 Subject: [PATCH 032/123] The browser plan takes its four open choices and names pdf.js as the document viewer --- docs/design/browser-and-graphics.md | 53 ++++++++++++++++------------- 1 file changed, 30 insertions(+), 23 deletions(-) diff --git a/docs/design/browser-and-graphics.md b/docs/design/browser-and-graphics.md index 7733003c..102d0009 100644 --- a/docs/design/browser-and-graphics.md +++ b/docs/design/browser-and-graphics.md @@ -138,6 +138,15 @@ separate is a recipe and a pin review of its own. H.264 needs FFmpeg's decoder, which Firefox loads if it is present; whether to ship it is a licensing question as much as a size one, and is left open. +## The document viewer + +PDFs open in the browser's own viewer, pdf.js, which runs as page script +inside the content process's sandbox. No image of its own is needed until a +workflow needs a format pdf.js does not read. zathura, the light viewer that +was the alternative, moved to GTK 4 in 2026 and now also needs json-glib, +libmagic, sqlite and xxhash. Building it would mean a second toolkit image, +or pinning a GTK 3 branch nobody maintains any more. + ## How the browser image is built Firefox ESR 153 is the `browser` software image, built over the toolkit @@ -184,29 +193,27 @@ floors are given with each layer. - **Updates.** ESR has a security release every four weeks. Its pins are the images' (`build/images/versions.env`), so a bump rebuilds the browser and leaves stage 04 and its cache alone. -- **Choices still open.** - - **Getting the Rust tarballs into the chroot.** The `rust` layer needs - the tarballs `build/config/rust.lock` pins. Today the Distro workflow - fetches them outside the chroot for kryptikd and nothing else sees - them. Listed as image sources instead, they come under the signature - and currency gates, which then cover kryptikd's compiler too. That is - a bump every six weeks, and a test must hold both locks to one hash. - - **cbindgen built the way kryptikd is.** The option is static for - musl, outside the chroot, by the pinned compiler, its crates checked - by its own lock. That needs stage 03's runner to bind one more binary - into the chroot, and the runner is in stage 04's cache key. The - alternative is an offline build inside the chroot from crates fetched - beforehand. - - **Two gigabytes of sources.** Firefox's source is 0.8 GB and LLVM's - build 1.2 GB even as `.tar.zst`. In the one sources cache, every image - pin's bump would copy the root's set beside them, against the - repository's 10 GB. The images' sources need a cache of their own, - keyed by `build/images/sources.lock`. - - **Mozilla's key from Mozilla.** Its signing subkey was replaced in - August 2026 after a copy leaked, and the keyserver's copy carries - neither the new subkey nor the old one's revocation. The key must come - from the `KEY` file beside each release, which needs a provenance kind - for a project's own key file. +- **Choices taken.** + - **The Rust tarballs are image sources.** The `rust` layer lists the + rustc, cargo and rust-std tarballs `build/config/rust.lock` pins, so the + signature and currency gates cover them, and kryptikd's compiler with + them. A CI check holds both locks to the same hashes, so the shipped + daemon and the browser are built by one release, bumped every six weeks. + - **cbindgen is built inside the chroot, offline.** Its crate and the + crates its own lock file names are fetched beforehand and checked + against that lock. In the chroot each is unpacked into a vendored source + directory, and `cargo build --offline --locked` uses the `rust` layer. + Stage 03's runner and stage 04's cache key stay as they are. + - **The images' sources have a cache of their own.** They go under + `sources/images/`, keyed by `build/images/sources.lock`. The root's set + stays keyed by `sources.lock` alone, so a browser bump copies neither set + into the other's entry. + - **Mozilla's key comes from Mozilla.** The fingerprint is pinned like + every other, confirmed by Mozilla's security blog post of August 2026. + The key material is taken from the `KEY` file beside the release rather + than from the keyserver, whose copy lacks the new subkey and the old + one's revocation. It is imported again on every run, so a later + revocation lands. ## GPU rendering, zone by zone From 36a4bfbd40343728416a73c4adb9ccf337bc4f76 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 01:39:48 -0700 Subject: [PATCH 033/123] The image tree check takes a new directory overlayfs marked opaque, as the root has nothing under it to hide --- tools/check-image-tree.sh | 16 +++++++++------- tools/tests/check-image-tree.sh | 14 ++++++++++++++ 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/tools/check-image-tree.sh b/tools/check-image-tree.sh index bd9e1e63..f3fb624c 100755 --- a/tools/check-image-tree.sh +++ b/tools/check-image-tree.sh @@ -4,10 +4,10 @@ # # tools/check-image-tree.sh LOWER UPPER # -# Refused: a whiteout or an opaque or redirected directory (a root path removed or hidden), a file the -# root already has, a directory of the root's under /usr with another mode or owner, a file outside -# /usr, a link that leads out of /usr, a device node, a setuid or setgid bit, a file capability. The -# chroot's own scratch, mount points and marks are let be. +# Refused: a whiteout, a redirected directory, or an opaque one over a directory of the root's (a root +# path removed or hidden), a file the root already has, a directory of the root's under /usr with +# another mode or owner, a file outside /usr, a link that leads out of /usr, a device node, a setuid or +# setgid bit, a file capability. The chroot's own scratch, mount points and marks are let be. 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 @@ -56,10 +56,12 @@ for d, subdirs, names in os.walk(upper): bad.append(f"moves /{rel} within the root") continue if stat.S_ISDIR(st.st_mode): - if xattr(p, "trusted.overlay.opaque") == b"y": - bad.append(f"hides what the root has under /{rel}") - elif lst is not None and not stat.S_ISDIR(lst.st_mode): + # overlayfs marks each directory it makes inside a merged one opaque, to spare later + # lookups: over a path the root lacks that hides nothing, and the tree's copy drops it. + if lst is not None and not stat.S_ISDIR(lst.st_mode): bad.append(f"replaces the root's /{rel} with a directory") + elif lst is not None and xattr(p, "trusted.overlay.opaque") == b"y": + bad.append(f"hides what the root has under /{rel}") elif lst is not None and top == "usr" and (stat.S_IMODE(st.st_mode), st.st_uid, st.st_gid) != (stat.S_IMODE(lst.st_mode), lst.st_uid, lst.st_gid): bad.append(f"changes the root's directory /{rel} " f"({oct(stat.S_IMODE(lst.st_mode))} {lst.st_uid}:{lst.st_gid} -> {oct(stat.S_IMODE(st.st_mode))} {st.st_uid}:{st.st_gid})") diff --git a/tools/tests/check-image-tree.sh b/tools/tests/check-image-tree.sh index 4ed2027a..a0262f25 100755 --- a/tools/tests/check-image-tree.sh +++ b/tools/tests/check-image-tree.sh @@ -79,5 +79,19 @@ else printf ' SKIP a whiteout fails (making one needs root)\n' fi +# overlayfs's opaque mark is a trusted xattr, which only root can set. +opaque() { python3 -c 'import os, sys; os.setxattr(sys.argv[1], "trusted.overlay.opaque", b"y")' "$1" 2>/dev/null; } +fresh +if opaque "$U/usr/share/probe"; then + out="$(check)"; rc=$? + [[ "$rc" -eq 0 ]] && ok "a new directory overlayfs marked opaque passes: the root has nothing there" || bad "new opaque: rc=$rc: $out" + fresh; mkdir -p "$U/usr/share/info"; chmod --reference="$L/usr/share/info" "$U/usr/share/info"; opaque "$U/usr/share/info" + out="$(check)"; rc=$? + [[ "$rc" -ne 0 && "$out" == *"hides what the root has under /usr/share/info"* ]] \ + && ok "an opaque directory over one of the root's fails" || bad "opaque over the root's: rc=$rc: $out" +else + printf ' SKIP opaque directories (setting the mark needs root)\n' +fi + printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" [[ "$FAIL" -eq 0 ]] From ff89037bd637052238967be4cfe0c1daac0ec6d2 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 01:46:09 -0700 Subject: [PATCH 034/123] The software images' sources are kept in sources/images/, a cache of their own keyed by their lock, each named in sources/ by a link --- .github/actions/prepare/action.yml | 40 +++++++++++++++++++----- .github/workflows/ci.yml | 33 ++++++++++++++++---- .github/workflows/distro.yml | 5 +++ docs/design/browser-and-graphics.md | 8 +++-- tools/fetch-sources.sh | 24 ++++++++++++--- tools/prune-sources.sh | 8 +++-- tools/tests/fetch-sources.sh | 47 +++++++++++++++++++++++++++++ 7 files changed, 141 insertions(+), 24 deletions(-) diff --git a/.github/actions/prepare/action.yml b/.github/actions/prepare/action.yml index 676bf4f8..1c7f16c3 100644 --- a/.github/actions/prepare/action.yml +++ b/.github/actions/prepare/action.yml @@ -5,7 +5,10 @@ inputs: description: Also let the runner start VMs (KVM, and virt-fw-vars for the variable stores). default: 'false' sources: - description: The upstream sources, cached by sources.lock. Either fetches what the cache lacks; 'fetch' also saves the set. + description: The root's upstream sources, cached by sources.lock. Either fetches what the cache lacks; 'fetch' also saves the set. + default: '' + image-sources: + description: The software images' sources too, a cache of their own keyed by build/images/sources.lock; 'fetch' or 'restore' as for sources. default: '' runs: using: composite @@ -35,22 +38,35 @@ runs: sudo udevadm trigger --name-match=kvm ls -la /dev/kvm + # Without images/, the software images' sources, which have a cache of their own: a browser bump + # then copies none of the root's set, and a root bump none of theirs. - if: inputs.sources != '' id: sources uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: - path: /mnt/kryptik/sources - key: sources-v3-${{ hashFiles('sources.lock', 'build/images/sources.lock') }} + path: | + /mnt/kryptik/sources + !/mnt/kryptik/sources/images + key: sources-v4-${{ hashFiles('sources.lock') }} # A changed lock restores the previous set; every file is still hashed against the lock. - restore-keys: sources- + restore-keys: sources-v4- + + - if: inputs.image-sources != '' + id: image_sources + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + with: + path: /mnt/kryptik/sources/images + key: image-sources-v1-${{ hashFiles('build/images/sources.lock') }} + restore-keys: image-sources-v1- # A cache evicted since the build job is fetched again, not taken as gone. - - if: inputs.sources != '' && steps.sources.outputs.cache-hit != 'true' + - if: (inputs.sources != '' && steps.sources.outputs.cache-hit != 'true') || (inputs.image-sources != '' && steps.image_sources.outputs.cache-hit != 'true') shell: bash env: MODE: ${{ inputs.sources }} + IMAGES: ${{ inputs.image-sources }} run: | - make sources + if [[ -n "$IMAGES" ]]; then make sources; else ./tools/fetch-sources.sh --root; fi ./tools/prune-sources.sh # Fetches the signatures into the saved set for CI's gate; this pass is not the verdict. [[ "$MODE" != fetch ]] || ./tools/verify-signatures.sh > /dev/null 2>&1 || true @@ -59,5 +75,13 @@ runs: - if: inputs.sources == 'fetch' && steps.sources.outputs.cache-hit != 'true' uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: - path: /mnt/kryptik/sources - key: sources-v3-${{ hashFiles('sources.lock', 'build/images/sources.lock') }} + path: | + /mnt/kryptik/sources + !/mnt/kryptik/sources/images + key: sources-v4-${{ hashFiles('sources.lock') }} + + - if: inputs.image-sources == 'fetch' && steps.image_sources.outputs.cache-hit != 'true' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + with: + path: /mnt/kryptik/sources/images + key: image-sources-v1-${{ hashFiles('build/images/sources.lock') }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a133c893..a6415a7d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -125,19 +125,31 @@ jobs: sudo mkdir -p /mnt/kryptik && sudo chown "$(id -u):$(id -g)" /mnt/kryptik ./tools/fetch-sources.sh --list - # Restores the last set; `make sources` checks it against the lock and fetches what is missing. + # Restores the last sets; `make sources` checks them against the locks and fetches what is missing. + # The software images' sources, under images/, are a cache of their own (the prepare action's). - name: Upstream sources (cached by sources.lock) id: sources uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: - path: /mnt/kryptik/sources - key: sources-v3-${{ hashFiles('sources.lock', 'build/images/sources.lock') }} + path: | + /mnt/kryptik/sources + !/mnt/kryptik/sources/images + key: sources-v4-${{ hashFiles('sources.lock') }} restore-keys: | - sources- + sources-v4- + + - name: The software images' sources (cached by build/images/sources.lock) + id: image_sources + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + with: + path: /mnt/kryptik/sources/images + key: image-sources-v1-${{ hashFiles('build/images/sources.lock') }} + restore-keys: | + image-sources-v1- # Fetches the signatures into the cached set for the gate below; this pass is not the verdict. - name: Fetch and verify the sources - if: steps.sources.outputs.cache-hit != 'true' + if: steps.sources.outputs.cache-hit != 'true' || steps.image_sources.outputs.cache-hit != 'true' run: | make sources ./tools/prune-sources.sh @@ -148,9 +160,18 @@ jobs: if: ${{ github.event_name != 'pull_request' && steps.sources.outputs.cache-hit != 'true' }} uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: - path: /mnt/kryptik/sources + path: | + /mnt/kryptik/sources + !/mnt/kryptik/sources/images key: ${{ steps.sources.outputs.cache-primary-key }} + - name: Save the software images' sources + if: ${{ github.event_name != 'pull_request' && steps.image_sources.outputs.cache-hit != 'true' }} + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + with: + path: /mnt/kryptik/sources/images + key: ${{ steps.image_sources.outputs.cache-primary-key }} + # Informational, as mirrors go down; a one-byte ranged GET, since some GNU mirrors refuse HEAD. - name: Every source URL is reachable continue-on-error: true diff --git a/.github/workflows/distro.yml b/.github/workflows/distro.yml index 36e88c26..b319db92 100644 --- a/.github/workflows/distro.yml +++ b/.github/workflows/distro.yml @@ -76,6 +76,7 @@ jobs: - uses: ./.github/actions/prepare with: sources: fetch + image-sources: ${{ github.ref_type == 'tag' && 'fetch' || '' }} # CI's gates once more, so a tag never outruns them. - name: The release's sources are signed, and their provenance holds @@ -194,6 +195,7 @@ jobs: - uses: ./.github/actions/prepare with: sources: fetch + image-sources: fetch # One root-made tarball, keyed on the toolchain, the pins and stage 03; a change to any restarts stage 04 (about 45 minutes). - name: Stage 04-05 sysroot and kernel (cached) @@ -616,6 +618,8 @@ jobs: with: vm: 'true' sources: restore + # The inputs suite checks every source against both locks; the other parts read only the root's. + image-sources: ${{ contains(matrix.suites, 'inputs') && 'restore' || '' }} - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: @@ -760,6 +764,7 @@ jobs: - uses: ./.github/actions/prepare with: sources: fetch + image-sources: fetch - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: kryptik-release diff --git a/docs/design/browser-and-graphics.md b/docs/design/browser-and-graphics.md index 1c127d72..73d5ec23 100644 --- a/docs/design/browser-and-graphics.md +++ b/docs/design/browser-and-graphics.md @@ -208,9 +208,11 @@ floors are given with each layer. directory, and `cargo build --offline --locked` uses the `rust` layer. Stage 03's runner and stage 04's cache key stay as they are. - **The images' sources have a cache of their own.** They go under - `sources/images/`, keyed by `build/images/sources.lock`. The root's set - stays keyed by `sources.lock` alone, so a browser bump copies neither set - into the other's entry. + `sources/images/`, keyed by `build/images/sources.lock`, each named in + `sources/` by a link, so every tool finds it where it always has. The + root's set stays keyed by `sources.lock` alone, so a browser bump copies + neither set into the other's entry. A job that builds no image fetches + the root's alone (`fetch-sources.sh --root`). - **Mozilla's key comes from Mozilla.** The fingerprint is pinned like every other, confirmed by Mozilla's security blog post of August 2026. The key material is taken from the `KEY` file beside the release rather diff --git a/tools/fetch-sources.sh b/tools/fetch-sources.sh index 46b42634..0446d34e 100755 --- a/tools/fetch-sources.sh +++ b/tools/fetch-sources.sh @@ -2,26 +2,30 @@ # Fetch and verify upstream source tarballs. # # ./tools/fetch-sources.sh download and verify against sources.lock +# ./tools/fetch-sources.sh --root only the root's, for a job that builds no image # ./tools/fetch-sources.sh --lock download and WRITE sources.lock # ./tools/fetch-sources.sh --list print the resolved manifest, download nothing # ./tools/fetch-sources.sh --list-root only the sources the root is built from # ./tools/fetch-sources.sh --list-image NAME only those software image NAME is built from # # The software images' sources are pinned in build/images/versions.env and locked in -# build/images/sources.lock, so a change to them is no input of stage 04. +# build/images/sources.lock, so a change to them is no input of stage 04. They are kept in +# sources/images/, which has a cache of its own, each named in sources/ by a link where every +# reader looks. # --lock trusts whatever downloads: audit sources.lock before committing it (docs/supply-chain.md). source "$(dirname "${BASH_SOURCE[0]}")/../build/lib/common.sh" load_config -MODE="verify"; IMAGE="" +MODE="verify"; IMAGE=""; ROOT_ONLY=0 case "${1:-}" in + --root) ROOT_ONLY=1 ;; --lock) MODE="lock" ;; --list) MODE="list" ;; --list-root) MODE="list-root" ;; --list-image) MODE="list-image"; IMAGE="${2:?--list-image needs an image name}" ;; "") ;; - *) die "unknown argument: $1 (expected --lock, --list, --list-root, --list-image NAME, or nothing)" ;; + *) die "unknown argument: $1 (expected --root, --lock, --list, --list-root, --list-image NAME, or nothing)" ;; esac # Test hook: tools/tests/fetch-sources.sh substitutes a manifest of file:// URLs. @@ -176,7 +180,11 @@ MANIFEST # The software images' sources (build/images/versions.env), never installed in the root: the image # each is built into, then the row as above. Their checksums are in build/images/sources.lock. image_manifest() { - [[ -z "${KRYPTIK_FETCH_MANIFEST:-}" ]] || return 0 + if [[ -n "${KRYPTIK_FETCH_MANIFEST:-}" ]]; then + # A test's images, when it gives any. + [[ -z "${KRYPTIK_FETCH_IMAGE_MANIFEST:-}" ]] || cat "$KRYPTIK_FETCH_IMAGE_MANIFEST" + return 0 + fi local gnome="$MIRROR_GNOME" cat < "${UPSTREAM}/delta-4.0.tar.gz" +IMANIFEST="${W}/image-manifest" +printf 'toolkit|delta|4.0|file://%s/delta-4.0.tar.gz|probe|listing\n' "$UPSTREAM" > "$IMANIFEST" +images_root() { + build_root alpha-1.0:good beta-2.0:good gamma-3.0:good + mkdir -p "${FAKE}/build/images" + printf '%s delta-4.0.tar.gz\n' "$(sha_of "${UPSTREAM}/delta-4.0.tar.gz")" > "${FAKE}/build/images/sources.lock" +} +run_images() { + KRYPTIK_ROOT="$FAKE" KRYPTIK_FETCH_SELFTEST=1 KRYPTIK_FETCH_MANIFEST="$MANIFEST" \ + KRYPTIK_FETCH_IMAGE_MANIFEST="$IMANIFEST" NO_COLOR=1 bash "$TOOL" "$@" > "$OUT" 2>&1 + RC=$? +} + +images_root +run_images +expect_pass "an image's source is fetched and verified against the images' lock" "4 package(s) verified" +if [[ -f "${FAKE}/sources/images/delta-4.0.tar.gz" && ! -L "${FAKE}/sources/images/delta-4.0.tar.gz" \ + && "$(readlink "${FAKE}/sources/delta-4.0.tar.gz")" == images/delta-4.0.tar.gz ]]; then + green "it is kept in sources/images/ and named in sources/ by a relative link" +else + red "the image's source is not in sources/images/ behind a link"; ls -la "${FAKE}/sources" "${FAKE}/sources/images" 2>&1 | sed 's/^/ /' +fi + +images_root +run_images --root +expect_pass "--root fetches the root's sources alone" "3 package(s) verified" +if [[ ! -e "${FAKE}/sources/images/delta-4.0.tar.gz" ]]; then + green "--root leaves the images' sources alone" +else + red "--root fetched an image's source" +fi + +# A set cached before images/ existed has the image's file in sources/ itself. +images_root +cp "${UPSTREAM}/delta-4.0.tar.gz" "${FAKE}/sources/delta-4.0.tar.gz" +run_images +expect_pass "an image's source cached in sources/ itself still verifies" "1 cached" +if [[ -L "${FAKE}/sources/delta-4.0.tar.gz" && -f "${FAKE}/sources/images/delta-4.0.tar.gz" ]]; then + green "it moves into sources/images/, a link left in its place" +else + red "the image's source was not moved into sources/images/" +fi + # --- arguments and the selftest gate ---------------------------------------- run --list From 4376026f5a652d863b3b586f0912e65655ff79bb Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 01:54:34 -0700 Subject: [PATCH 035/123] The toolkit image keeps its seven canary-free objects as findings with reasons, drops libtool's archives, and takes GLib's licences from LICENSES/ --- build/images/toolkit.accepted | 12 ++++++++++++ build/images/toolkit.sh | 8 +++++--- build/recipes/licences.sh | 5 +++-- 3 files changed, 20 insertions(+), 5 deletions(-) create mode 100644 build/images/toolkit.accepted diff --git a/build/images/toolkit.accepted b/build/images/toolkit.accepted new file mode 100644 index 00000000..94c1027c --- /dev/null +++ b/build/images/toolkit.accepted @@ -0,0 +1,12 @@ +# Findings of tools/check-artifact-hardening.sh the toolkit image keeps, in the form of +# build/config/artifact-accepted.txt. Stage 06 audits the image against this list alone. + +# 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/fc-list # fontconfig's small tools +NO-SSP usr/bin/fc-pattern # the same +NO-SSP usr/bin/fc-query # the same +NO-SSP usr/bin/fc-scan # the same +NO-SSP usr/bin/gobject-query # GLib's, the same +NO-SSP usr/lib/libcairo-gobject.so.* # the GType registrations of cairo's types +NO-SSP usr/lib/libgthread-2.0.so.* # a stub kept for old programs, g_thread_init alone diff --git a/build/images/toolkit.sh b/build/images/toolkit.sh index 2ede046b..3dc35c24 100644 --- a/build/images/toolkit.sh +++ b/build/images/toolkit.sh @@ -29,7 +29,7 @@ s_tk_pcre2() { native_build "pcre2-${V_PCRE2}.tar.bz2" "pcre2-${V_PCRE2}" --disable-static --enable-unicode \ --disable-jit --disable-pcre2-16 --disable-pcre2-32 --disable-pcre2grep-libz \ --disable-pcre2grep-libbz2 --disable-pcre2test-libreadline - rm -f /usr/bin/pcre2grep /usr/bin/pcre2test /usr/bin/pcre2-config + rm -f /usr/bin/pcre2grep /usr/bin/pcre2test /usr/bin/pcre2-config /usr/lib/libpcre2-*.la } s_tk_glib() { @@ -41,7 +41,8 @@ s_tk_glib() { s_tk_libpng() { native_build "libpng-${V_LIBPNG}.tar.xz" "libpng-${V_LIBPNG}" --disable-static - rm -f /usr/bin/pngfix /usr/bin/png-fix-itxt /usr/bin/libpng-config /usr/bin/libpng16-config + rm -f /usr/bin/pngfix /usr/bin/png-fix-itxt /usr/bin/libpng-config /usr/bin/libpng16-config \ + /usr/lib/libpng*.la } # Without HarfBuzz, which is built on it, and without brotli and bzip2, which no shipped font needs. @@ -141,6 +142,7 @@ s_tk_gtk3() { rm -f /usr/share/locale/*/LC_MESSAGES/gtk30-properties.mo } +# GLib keeps its texts the REUSE way, in LICENSES/, with no licence file at its top. s_tk_licences() { - echo "$(source_licences < <("${KRYPTIK_ROOT}/tools/fetch-sources.sh" --list-image toolkit)) licence files" + echo "$(source_licences '^[^/]+/LICENSES/[^/]+$' < <("${KRYPTIK_ROOT}/tools/fetch-sources.sh" --list-image toolkit)) licence files" } diff --git a/build/recipes/licences.sh b/build/recipes/licences.sh index b8cf17c8..75a7b2db 100644 --- a/build/recipes/licences.sh +++ b/build/recipes/licences.sh @@ -1,10 +1,11 @@ #!/usr/bin/env bash # Each listed source's licence files, from its own tarball, under /usr/share/licenses//; -# prints how many. The root's sources for stage 04, an image's for that image. -source_licences() { # source_licences < tools/fetch-sources.sh --list-root|--list-image NAME +# prints how many. The root's sources for stage 04, an image's for that image; ERE adds members. +source_licences() { # source_licences [ERE] < tools/fetch-sources.sh --list-root|--list-image NAME # Also the microcode's lowercase license, firmware's WHENCE and the kernel's LICENSES/. local more_re='^[^/]+/(license|WHENCE|LICENSES/(preferred|exceptions)/[^/]+)$' + [[ -z "${1:-}" ]] || more_re="${more_re}|${1}" local name url f m dir tmp n=0 members while read -r name _ url _; do [[ -n "$name" ]] || continue From 9ef8b46274fcba009bbfd7ca41f74ca22c63ccea Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 01:57:43 -0700 Subject: [PATCH 036/123] The browser's sources and the layers it is built over are pinned: Firefox 153.4.0esr, Rust 1.98.1, LLVM 23.1.3, cbindgen and nasm, with Mozilla's, Rust's and LLVM's keys pinned and fetched from their own sites --- build/images/sources.lock | 7 +++++++ build/images/versions.env | 11 +++++++++++ docs/design/browser-and-graphics.md | 6 ++++-- tools/currency-rules.tsv | 6 ++++++ tools/fetch-sources.sh | 7 +++++++ tools/key-provenance.tsv | 14 +++++++++++++- tools/pin-reviews.tsv | 1 + tools/tests/fetch-sources.sh | 10 ++++++++++ tools/tests/verify-signatures.sh | 5 +++++ tools/verify-signatures.sh | 19 ++++++++++++++++++- 10 files changed, 82 insertions(+), 4 deletions(-) diff --git a/build/images/sources.lock b/build/images/sources.lock index 03950f5f..d3acc1e3 100644 --- a/build/images/sources.lock +++ b/build/images/sources.lock @@ -1,5 +1,9 @@ +14d2f701eb68fb799001bdea6231048f3990690fa2f406d563555ff8f744daba LLVM-23.1.3-Linux-X64.tar.zst cca6b6a86e4389d89b370288761ce2385ddeceb10c4412da78689bc30c88af2d at-spi2-core-2.62.1.tar.xz 1c767308174337a74694da0f3ec069c271452163a1ef4540964c50c301f157d4 cairo-1.18.6.tar.xz +ea1de9f9e23107d97ee2b41a72c552f34064a593da503789218387aee59f3ba4 cargo-1.98.1-x86_64-unknown-linux-gnu.tar.xz +2ecb53484c9c167ba674026b656d8a27d7657a58e6066aa902bfb1a4aa00ae20 cbindgen-0.29.4.crate +3082dec68030b4fbb46041c282e362e524c9d6d75e7c45e944d8d0e11c8ea2df firefox-153.4.0esr.source.tar.xz 4f7b554a38cdf78c033f666c8871f3749e14a094f65a07f630c91ed0b43d35e3 fontconfig-2.18.3.tar.xz 93cd47eb719c325107f2d3cf087d06299e944c3b48a40b55537302304dea1dd5 freetype-2.15.0.tar.xz 6949dcde27d41cebad1fd741fcafc36d55a1020d2d872d4a6eb3914caabbada2 fribidi-1.0.17.tar.xz @@ -11,5 +15,8 @@ db0e50a80aa3bf64bb45cbca5cf9f75efd9348cf2ac690b907435238c3cf81d7 hicolor-icon-t a7ced37f4102b745ac86d6a70a9da399cc139ff168ba6b8002b4d8d43c900c15 libepoxy-1.5.10.tar.gz 2b6e15b06aafb4c0b6e2348124808cbd9b291c647299eaaba2e3202f51ff2f3d libglvnd-v1.7.0.tar.gz d80dd2a38a37f803cb9b6ac7b14bd6e74ddc3b654780a8380bdf93523fdb4389 libpng-1.6.59.tar.xz +87336eba53b4acfe917424ab5d500d2b0054d9f5148d35c2273ccf2cfb712f0d nasm-3.02.tar.xz 342385b6ca3b7c73455d7c80a13b7dbe4489e00bc3bd4c5bd6ed4dce421e374a pango-1.58.2.tar.xz 53c156e1ba416a20da8e65395daa132da0d80e76910424caca3fcdae7831d384 pcre2-10.49.tar.bz2 +fa3ff450172a16c026944030230c5069947af93c728d9179971d44e5e0cfb561 rust-std-1.98.1-x86_64-unknown-linux-gnu.tar.xz +e974f036b28565f37c0f3bd92ddefa809bee16c04f9dcf07b9ed96e05aaaf7c4 rustc-1.98.1-x86_64-unknown-linux-gnu.tar.xz diff --git a/build/images/versions.env b/build/images/versions.env index 34a539cf..5f4e04d5 100644 --- a/build/images/versions.env +++ b/build/images/versions.env @@ -26,3 +26,14 @@ V_LIBEPOXY=1.5.10 V_AT_SPI2_CORE=2.62.1 V_HICOLOR_ICON_THEME=0.18 V_GTK3=3.24.52 + +# --- browser: Firefox ESR, over the toolkit and build-only layers no zone gets --- +# The ESR after 140, which ends once the two stop overlapping. Mozilla's key is taken from the KEY file +# beside this release (tools/key-provenance.tsv), so a bump changes both. +V_FIREFOX=153.4.0 +# The compiler kryptikd is built with: build/config/rust.lock holds the same three tarballs. +V_RUST=1.98.1 +# LLVM's release build, for the libclang bindgen loads. +V_LLVM=23.1.3 +V_CBINDGEN=0.29.4 +V_NASM=3.02 diff --git a/docs/design/browser-and-graphics.md b/docs/design/browser-and-graphics.md index 73d5ec23..24bbed78 100644 --- a/docs/design/browser-and-graphics.md +++ b/docs/design/browser-and-graphics.md @@ -217,8 +217,10 @@ floors are given with each layer. every other, confirmed by Mozilla's security blog post of August 2026. The key material is taken from the `KEY` file beside the release rather than from the keyserver, whose copy lacks the new subkey and the old - one's revocation. It is imported again on every run, so a later - revocation lands. + one's revocation. That is an `origin` row in `tools/key-provenance.tsv`, + a kind only a pinned key may have: the pin says who signs, the row only + where the key is fetched from on every run, so a later revocation lands. + Rust's and LLVM's keys come from their own sites the same way. ## GPU rendering, zone by zone diff --git a/tools/currency-rules.tsv b/tools/currency-rules.tsv index c66bbfbf..aaf6cb82 100644 --- a/tools/currency-rules.tsv +++ b/tools/currency-rules.tsv @@ -57,5 +57,11 @@ gtk3 listing https://download.gnome.org/sources/gtk/cache.json gtk-(3\.24\.[0-9] libpng listing https://api.github.com/repos/pnggroup/libpng/tags?per_page=100 "name": "v(1\.6\.[0-9]+)" - fontconfig api https://gitlab.freedesktop.org/api/v4/projects/890/releases?per_page=20 tag_name drop90 libglvnd api https://gitlab.freedesktop.org/api/v4/projects/glvnd%2Flibglvnd/releases?per_page=20 tag_name - +# GitHub's API answers with spaced JSON, which only a listing pattern reads. +rustc listing https://api.github.com/repos/rust-lang/rust/releases?per_page=20 "tag_name": "([0-9]+\.[0-9]+\.[0-9]+)" - +cbindgen listing https://api.github.com/repos/mozilla/cbindgen/releases?per_page=20 "tag_name": "v?([0-9]+\.[0-9]+\.[0-9]+)" - +nasm listing https://www.nasm.us/pub/nasm/releasebuilds/ ([0-9]+\.[0-9]+(\.[0-9]+)?)/ - +# The current ESR and, while two overlap, the next one; the pin's own ESR series is what counts. +firefox listing https://product-details.mozilla.org/1.0/firefox_versions.json "FIREFOX_ESR[_NEXT]*": "([0-9]+\.[0-9]+\.[0-9]+)esr" major # An odd minor is a development snapshot. cairo listing https://www.cairographics.org/releases/ cairo-([0-9]+\.[0-9]+\.[0-9]+)\.tar\.xz even diff --git a/tools/fetch-sources.sh b/tools/fetch-sources.sh index 0446d34e..bc2618b6 100755 --- a/tools/fetch-sources.sh +++ b/tools/fetch-sources.sh @@ -202,6 +202,13 @@ toolkit|libepoxy|${V_LIBEPOXY}|${MIRROR_GITHUB}/anholt/libepoxy/archive/${V_LIBE toolkit|at-spi2-core|${V_AT_SPI2_CORE}|${gnome}/at-spi2-core/${V_AT_SPI2_CORE%.*}/at-spi2-core-${V_AT_SPI2_CORE}.tar.xz|stem.sha256sum|rule toolkit|hicolor-icon-theme|${V_HICOLOR_ICON_THEME}|${MIRROR_ICON_THEME}/hicolor-icon-theme-${V_HICOLOR_ICON_THEME}.tar.xz|asc|listing toolkit|gtk3|${V_GTK3}|${gnome}/gtk/${V_GTK3%.*}/gtk-${V_GTK3}.tar.xz|stem.sha256sum|rule +rust|rustc|${V_RUST}|https://static.rust-lang.org/dist/rustc-${V_RUST}-x86_64-unknown-linux-gnu.tar.xz|asc|rule +rust|cargo|${V_RUST}|https://static.rust-lang.org/dist/cargo-${V_RUST}-x86_64-unknown-linux-gnu.tar.xz|asc|follows:rustc +rust|rust-std|${V_RUST}|https://static.rust-lang.org/dist/rust-std-${V_RUST}-x86_64-unknown-linux-gnu.tar.xz|asc|follows:rustc +clang|llvm|${V_LLVM}|${MIRROR_GITHUB}/llvm/llvm-project/releases/download/llvmorg-${V_LLVM}/LLVM-${V_LLVM}-Linux-X64.tar.zst|sig|github +cbindgen|cbindgen|${V_CBINDGEN}|https://static.crates.io/crates/cbindgen/cbindgen-${V_CBINDGEN}.crate|none|rule +nasm|nasm|${V_NASM}|https://www.nasm.us/pub/nasm/releasebuilds/${V_NASM}/nasm-${V_NASM}.tar.xz|none|rule +browser|firefox|${V_FIREFOX}|https://archive.mozilla.org/pub/firefox/releases/${V_FIREFOX}esr/source/firefox-${V_FIREFOX}esr.source.tar.xz|asc|rule MANIFEST } diff --git a/tools/key-provenance.tsv b/tools/key-provenance.tsv index e54c5d8a..16a1e8bb 100644 --- a/tools/key-provenance.tsv +++ b/tools/key-provenance.tsv @@ -16,11 +16,12 @@ # Columns are whitespace separated; the note runs to the end of the line. # # fingerprint the full 40-character primary key fingerprint, uppercase -# kind korg | wkd | github | savannah +# kind korg | wkd | github | savannah | origin # locator kind korg: the URL the armoured key was fetched from # kind wkd: the email address whose Web Key Directory serves it # kind github: https://github.com/.gpg # kind savannah: the project's release-gpgkeys.php?group=&download=1 +# kind origin: the URL of the key file the project serves on its own site # retrieved the date the confirmation was walked # signs comma-separated source names this key signs, no spaces; the row # speaks for these alone, so a key published per project has a @@ -55,6 +56,14 @@ # fingerprint was not learned from the signature under test - it came # from the account that publishes the releases. # +# origin the key file a project serves on its own site, beside its releases or +# on its release page, for a key ALSO pinned in PINNED_FPRS. The pin +# says who signs; the row says only where the key's material is taken +# from, so a new subkey or a revocation the project publishes lands on +# the next run where a keyserver's copy may lag. A key not pinned has +# no origin row: that would be a fingerprint learned from the site that +# serves the release, and nothing more. +# # NONE IS OUT-OF-BAND CONFIRMATION. All four rest on TLS and the CA system, and # no human here has checked any of these fingerprints through a second channel. # They are strictly better than a keyserver lookup by the id under test, and @@ -105,3 +114,6 @@ B3F2469D78D78BD09D366F37C94035C21B4F2AEB korg https://git.kernel.org/pub/scm 0CF4E8D871593224842832B888DD9E08C5DDACB9 savannah https://savannah.gnu.org/project/release-gpgkeys.php?group=findutils&download=1 2026-10-02 findutils James Youngman B1D2BD1375BECB784CF4F8C4D73CF638C53C06BE savannah https://savannah.gnu.org/project/release-gpgkeys.php?group=inetutils&download=1 2026-10-02 inetutils Simon Josefsson ; signs with subkey A3CC9C870B9D310ABAD4CF2F51722B08FE4745A2; Savannah's copy expired 2022-10-23, the GNU keyring's runs later D33A3F0CA16B0ACC51A60738494C8A5FBF4DECEB wkd mak@debian.org 2026-10-13 hicolor-icon-theme Matthias Klumpp ; db.debian.org and nm.debian.org show the same fingerprint; signs the 0.18 tarball, the first signed release; expires 2028-07-18 +14F26682D0916CDD81E37B6D61B7B526D98F0353 origin https://archive.mozilla.org/pub/firefox/releases/153.4.0esr/KEY 2026-10-13 firefox Mozilla Software Releases ; the file carries the signing subkey 827E658608679618CD349F93678E455D76767AA3 (2026-08-06, to 2028-08-05) and the revocation of the leaked 09BEED63F3462A2DFFAB3B875ECB6497C1A20256, neither of which keyserver.ubuntu.com serves; V_FIREFOX's bump moves this URL +108F66205EAEB0AAA8DD5E1C85AB96E6FA1BE5FE origin https://static.rust-lang.org/rust-key.gpg.ascii 2026-10-13 rustc,cargo,rust-std Rust Language (Tag and Release Signing Key) ; served beside the releases it signs +D574BD5D1D0E98895E3BF90044F2485E45D59042 origin https://releases.llvm.org/release-keys.asc 2026-10-13 llvm Tobias Hieta ; one of the release managers' keys LLVM's site lists, and the one that signs 23.1.3 diff --git a/tools/pin-reviews.tsv b/tools/pin-reviews.tsv index e416380b..eb5ce929 100644 --- a/tools/pin-reviews.tsv +++ b/tools/pin-reviews.tsv @@ -11,6 +11,7 @@ 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-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. +rustc 1.98.1 1.99.0 fine 2026-10-13 Release notes 1.99.0: language, lint, library and Cargo changes; no security fix. The browser's layer and kryptikd (build/config/rust.lock) move together. 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 --------------- diff --git a/tools/tests/fetch-sources.sh b/tools/tests/fetch-sources.sh index 2e60c5e0..2cdd4ae2 100755 --- a/tools/tests/fetch-sources.sh +++ b/tools/tests/fetch-sources.sh @@ -361,6 +361,16 @@ else red "lock entries with no manifest row: ${stale}" fi +# The browser's Rust layer and kryptikd's compiler are one release: both locks hold the same three. +rust_shared="$(awk 'NR == FNR { if ($1 !~ /^#/ && NF == 2) want[$2] = $1; next } + ($2 in want) { n++; if (want[$2] != $1) bad = bad " " $2 } + END { print n + 0 bad }' "${ROOT}/build/config/rust.lock" "${ROOT}/build/images/sources.lock")" +if [[ "$rust_shared" == 3 ]]; then + green "build/config/rust.lock and the images' lock hold the same Rust tarballs" +else + red "the Rust tarballs the two locks share, and those whose hashes differ: ${rust_shared}" +fi + # An unset version leaves a field empty (a URL like gdbm-.tar.gz, a quiet 404) and the row short. blank="$(awk 'NF != 5 {print $1}' "${W}/live-manifest" | tr '\n' ' ')" if [[ -z "${blank// /}" ]]; then diff --git a/tools/tests/verify-signatures.sh b/tools/tests/verify-signatures.sh index 0702ef64..2c3a37a4 100755 --- a/tools/tests/verify-signatures.sh +++ b/tools/tests/verify-signatures.sh @@ -948,6 +948,11 @@ bad_prov "${UNKFPR} savannah https://savannah.example/project/release-gpgkeys. "a savannah locator on another host is refused" bad_prov "${UNKFPR} savannah https://savannah.gnu.org/project/memberlist.php?group=x 2026-09-11 unknown x" \ "a savannah locator that is not the project's release keyring is refused" +# An origin row says where a pinned key's material comes from, and nothing about who signs. +bad_prov "${UNKFPR} origin https://example.test/KEY 2026-10-13 unknown unknown fixture " \ + "an origin row for a key no pin names is refused" +bad_prov "14F26682D0916CDD81E37B6D61B7B526D98F0353 origin http://archive.mozilla.org/KEY 2026-10-13 firefox Mozilla" \ + "an origin locator that is not https is refused" # Shipped github rows need both the endpoint shape and the release-author tie. gh_bad=0 diff --git a/tools/verify-signatures.sh b/tools/verify-signatures.sh index 803ff1d1..61c44d7b 100755 --- a/tools/verify-signatures.sh +++ b/tools/verify-signatures.sh @@ -267,6 +267,13 @@ PINNED_FPRS=( # FreeType publishes only the long id BE6C3AAC63AD8E3F: docs/release in its repository since # 2.12.0, and each release announcement. This key ends in it and has signed every release since. "E30674707856409FF1948010BE6C3AAC63AD8E3F" # Werner Lemberg, freetype (long id alone: weaker) + # Mozilla's security blog of 2026-08-10 states it with its new signing subkey 827E6586...76767AA3, + # as does the KEY file beside each release. key-provenance.tsv takes the key from that file. + "14F26682D0916CDD81E37B6D61B7B526D98F0353" # Mozilla Software Releases, firefox + # https://static.rust-lang.org/rust-key.gpg.ascii (retrieved 2026-10-13), beside the releases. + "108F66205EAEB0AAA8DD5E1C85AB96E6FA1BE5FE" # Rust Language, rustc, cargo and rust-std + # https://releases.llvm.org/release-keys.asc (retrieved 2026-10-13), the release managers' keys. + "D574BD5D1D0E98895E3BF90044F2485E45D59042" # Tobias Hieta, LLVM's release builds ) # Primaries and subkeys: GOODSIG names a subkey, while pins and keys.manifest hold primaries. @@ -349,6 +356,16 @@ load_key_provenance() { fi ;; wkd) [[ "$l" == *@*.* ]] || why="a wkd locator must be an email address" ;; + origin) + if [[ "$l" == https://* ]] || [[ "${KRYPTIK_SIGCHECK_SELFTEST:-0}" == "1" && "$l" == file://* ]]; then + : + else + why="an origin locator must be an https URL" + fi + # It says where the key is taken from, never who signs: the pin says that. + [[ " ${PINNED_FPRS[*]} " == *" ${f} "* ]] \ + || why="an origin row is for a key pinned in PINNED_FPRS" + ;; *) why="unknown kind '${k}'" ;; esac fi @@ -415,7 +432,7 @@ import_provenance_keys_for() { tmp="$(mktemp)" case "${PROV_KIND[$i]}" in - korg|github|savannah) + korg|github|savannah|origin) if ! curl -fsSL --max-time 30 --retry 2 --retry-delay 5 -o "$tmp" "${PROV_LOC[$i]}" 2>/dev/null; then warn "${name}: could not fetch the published key from ${PROV_LOC[$i]}" PROV_MISSED[$i]=1 From 7e9e38b47d27e39da505eb6d8edb5a49ea78449a Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 02:03:22 -0700 Subject: [PATCH 037/123] fetch-sources fetches the crates a crate's own Cargo.lock names, each held to the hash the lock gives, for cbindgen's offline build --- tools/fetch-sources.sh | 46 ++++++++++++++++++++++++++++++++++-- tools/tests/fetch-sources.sh | 39 ++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/tools/fetch-sources.sh b/tools/fetch-sources.sh index bc2618b6..2dc8fd79 100755 --- a/tools/fetch-sources.sh +++ b/tools/fetch-sources.sh @@ -11,7 +11,8 @@ # The software images' sources are pinned in build/images/versions.env and locked in # build/images/sources.lock, so a change to them is no input of stage 04. They are kept in # sources/images/, which has a cache of its own, each named in sources/ by a link where every -# reader looks. +# reader looks. A Rust crate's own Cargo.lock names the crates it is built from, each with its +# hash: they go to sources/images/crates/, for an offline build in the chroot. # --lock trusts whatever downloads: audit sources.lock before committing it (docs/supply-chain.md). source "$(dirname "${BASH_SOURCE[0]}")/../build/lib/common.sh" @@ -28,7 +29,9 @@ case "${1:-}" in *) die "unknown argument: $1 (expected --root, --lock, --list, --list-root, --list-image NAME, or nothing)" ;; esac -# Test hook: tools/tests/fetch-sources.sh substitutes a manifest of file:// URLs. +# Test hook: tools/tests/fetch-sources.sh substitutes a manifest of file:// URLs, and crates.io. +[[ -z "${KRYPTIK_CRATES:-}" || -n "${KRYPTIK_FETCH_MANIFEST:-}" ]] \ + || die "KRYPTIK_CRATES replaces crates.io for tools/tests/fetch-sources.sh alone" if [[ -n "${KRYPTIK_FETCH_MANIFEST:-}" ]]; then [[ "${KRYPTIK_FETCH_SELFTEST:-0}" == "1" ]] \ || die "Refusing to fetch or lock against a substituted manifest without KRYPTIK_FETCH_SELFTEST=1" @@ -279,6 +282,44 @@ fetch_attempt() { return 1 } +# crate_lock CRATE: name, version and hash of each crates.io package the crate's Cargo.lock names. +crate_lock() { + python3 -I - "$1" <<'PY' +import sys, tarfile, tomllib +with tarfile.open(sys.argv[1], "r:gz") as t: + m = next((m for m in t.getmembers() if m.isfile() and m.name.count("/") == 1 and m.name.endswith("/Cargo.lock")), None) + if m is None: + sys.exit(f"{sys.argv[1]} holds no Cargo.lock at its top") + lock = tomllib.loads(t.extractfile(m).read().decode()) +for p in lock.get("package", []): + if p.get("source", "").startswith("registry+") and "checksum" in p: + print(p["name"], p["version"], p["checksum"]) +PY +} + +# fetch_crates CRATE: the crates its Cargo.lock names, each held to the hash the lock gives. The +# crate's own hash, in the images' lock, vouches for its Cargo.lock. +fetch_crates() { + local crate="$1" list name ver sum dest n=0 + list="$(crate_lock "$crate")" || die "$(basename "$crate"): its Cargo.lock could not be read" + mkdir -p "${KRYPTIK_SOURCES}/images/crates" + while read -r name ver sum; do + [[ -n "$name" ]] || continue + dest="${KRYPTIK_SOURCES}/images/crates/${name}-${ver}.crate" + if [[ -f "$dest" ]]; then + [[ "$(sha256_of "$dest")" == "$sum" ]] \ + || die "CHECKSUM MISMATCH for crate ${name} ${ver}: it CHANGED after $(basename "$crate")'s Cargo.lock locked it. Do not delete it before finding out why." + else + fetch_one "${KRYPTIK_CRATES:-https://static.crates.io/crates}/${name}/${name}-${ver}.crate" "$dest" \ + || die "download failed: crate ${name} ${ver}, which $(basename "$crate") builds from" + [[ "$(sha256_of "$dest")" == "$sum" ]] \ + || die "CHECKSUM MISMATCH for crate ${name} ${ver}, downloaded just now: not the hash $(basename "$crate")'s Cargo.lock gives. Remove ${dest} and retry." + fi + n=$((n + 1)) + done <<< "$list" + ok "$(basename "$crate"): the ${n} crates its Cargo.lock names, verified" +} + lookup_hash() { # lookup_hash FILE LOCK [[ -f "$2" ]] || return 1 awk -v f="$1" '$2 == f { print $1; found=1 } END { exit !found }' "$2" @@ -340,6 +381,7 @@ while IFS='|' read -r owner name ver url _; do die "It was already on disk, so it CHANGED after it was locked. Do not delete it before finding out why (docs/supply-chain.md)." fi ok "${name} ${ver} verified" + [[ "$file" != *.crate ]] || fetch_crates "$dest" fi done < <(all_sources) diff --git a/tools/tests/fetch-sources.sh b/tools/tests/fetch-sources.sh index 2cdd4ae2..551b78ea 100755 --- a/tools/tests/fetch-sources.sh +++ b/tools/tests/fetch-sources.sh @@ -317,6 +317,45 @@ else red "the image's source was not moved into sources/images/" fi +# --- a crate's own crates ---------------------------------------------------- + +# The crate's hash is locked; its Cargo.lock gives the hash of each crate it builds from. +CR="${W}/crates"; mkdir -p "${CR}/dep" "${W}/tool-1.0" +printf 'dep crate\n' > "${CR}/dep/dep-0.1.0.crate" +dep_sum="$(sha_of "${CR}/dep/dep-0.1.0.crate")" +crate_with() { # crate_with DEP-HASH: tool-1.0.crate whose Cargo.lock locks dep 0.1.0 to DEP-HASH + printf '[[package]]\nname = "dep"\nversion = "0.1.0"\nsource = "registry+https://github.com/rust-lang/crates.io-index"\nchecksum = "%s"\n\n[[package]]\nname = "tool"\nversion = "1.0"\n' "$1" > "${W}/tool-1.0/Cargo.lock" + tar -czf "${UPSTREAM}/tool-1.0.crate" -C "$W" tool-1.0 +} +CMANIFEST="${W}/crate-manifest" +printf 'tool|tool|1.0|file://%s/tool-1.0.crate|none|listing\n' "$UPSTREAM" > "$CMANIFEST" +crates_root() { + build_root alpha-1.0:good beta-2.0:good gamma-3.0:good + mkdir -p "${FAKE}/build/images" + printf '%s tool-1.0.crate\n' "$(sha_of "${UPSTREAM}/tool-1.0.crate")" > "${FAKE}/build/images/sources.lock" +} +run_crates() { + KRYPTIK_ROOT="$FAKE" KRYPTIK_FETCH_SELFTEST=1 KRYPTIK_FETCH_MANIFEST="$MANIFEST" \ + KRYPTIK_FETCH_IMAGE_MANIFEST="$CMANIFEST" KRYPTIK_CRATES="file://${CR}" NO_COLOR=1 bash "$TOOL" "$@" > "$OUT" 2>&1 + RC=$? +} + +crate_with "$dep_sum"; crates_root +run_crates +expect_pass "a crate's Cargo.lock brings the crates it names, each verified" "the 1 crates its Cargo.lock names, verified" +[[ -f "${FAKE}/sources/images/crates/dep-0.1.0.crate" ]] \ + && green "they land in sources/images/crates/" || red "the named crate is not in sources/images/crates/" + +crate_with 0000000000000000000000000000000000000000000000000000000000000000; crates_root +run_crates +expect_fail "a crate whose bytes are not what the Cargo.lock says is refused" "CHECKSUM MISMATCH for crate dep 0.1.0" + +if KRYPTIK_ROOT="$FAKE" KRYPTIK_CRATES="file://${CR}" NO_COLOR=1 bash "$TOOL" --list > "$OUT" 2>&1; then + red "a crates mirror was taken without the test's manifest" +else + grep -qF "KRYPTIK_CRATES replaces crates.io" "$OUT" && green "a crates mirror is refused outside the test" || { red "the crates mirror refusal did not say why"; show; } +fi + # --- arguments and the selftest gate ---------------------------------------- run --list From 0180469b45f5538e140591f93d8a657d4875be5a Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 02:04:26 -0700 Subject: [PATCH 038/123] shellcheck reads source_licences' optional pattern as an image's --- build/recipes/licences.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/build/recipes/licences.sh b/build/recipes/licences.sh index 75a7b2db..de53fa63 100644 --- a/build/recipes/licences.sh +++ b/build/recipes/licences.sh @@ -2,6 +2,7 @@ # Each listed source's licence files, from its own tarball, under /usr/share/licenses//; # prints how many. The root's sources for stage 04, an image's for that image; ERE adds members. +# shellcheck disable=SC2120 # an image passes the ERE, from its own definition source_licences() { # source_licences [ERE] < tools/fetch-sources.sh --list-root|--list-image NAME # Also the microcode's lowercase license, firmware's WHENCE and the kernel's LICENSES/. local more_re='^[^/]+/(license|WHENCE|LICENSES/(preferred|exceptions)/[^/]+)$' From 109415d8f7a8cd45ba4fc7beccf8578b27993339 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 02:07:14 -0700 Subject: [PATCH 039/123] The root's sources cache keeps the path it always had, so earlier sets restore, and is saved with images/ set aside --- .github/actions/prepare/action.yml | 27 ++++++++++++++++----------- .github/workflows/ci.yml | 23 ++++++++++++++--------- docs/design/browser-and-graphics.md | 6 +++--- tools/fetch-sources.sh | 7 ++++--- tools/tests/fetch-sources.sh | 10 ++++++++++ 5 files changed, 47 insertions(+), 26 deletions(-) diff --git a/.github/actions/prepare/action.yml b/.github/actions/prepare/action.yml index 1c7f16c3..ba2fd588 100644 --- a/.github/actions/prepare/action.yml +++ b/.github/actions/prepare/action.yml @@ -38,18 +38,17 @@ runs: sudo udevadm trigger --name-match=kvm ls -la /dev/kvm - # Without images/, the software images' sources, which have a cache of their own: a browser bump - # then copies none of the root's set, and a root bump none of theirs. + # The root's set, keyed by its lock alone. images/, the software images' sources, has a cache of + # its own and is set aside while this one is saved: a browser bump then copies none of the root's + # set, and a root bump none of theirs. The path is the one it always had, so earlier sets restore. - if: inputs.sources != '' id: sources uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: - path: | - /mnt/kryptik/sources - !/mnt/kryptik/sources/images - key: sources-v4-${{ hashFiles('sources.lock') }} + path: /mnt/kryptik/sources + key: sources-v3-${{ hashFiles('sources.lock') }} # A changed lock restores the previous set; every file is still hashed against the lock. - restore-keys: sources-v4- + restore-keys: sources- - if: inputs.image-sources != '' id: image_sources @@ -72,13 +71,19 @@ runs: [[ "$MODE" != fetch ]] || ./tools/verify-signatures.sh > /dev/null 2>&1 || true # Saved now rather than at the end, so a job that fails later does not refetch the new files. + - if: inputs.sources == 'fetch' && steps.sources.outputs.cache-hit != 'true' + shell: bash + run: if [[ -d /mnt/kryptik/sources/images ]]; then mv /mnt/kryptik/sources/images /mnt/kryptik/images-aside; fi + - if: inputs.sources == 'fetch' && steps.sources.outputs.cache-hit != 'true' uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: - path: | - /mnt/kryptik/sources - !/mnt/kryptik/sources/images - key: sources-v4-${{ hashFiles('sources.lock') }} + path: /mnt/kryptik/sources + key: sources-v3-${{ hashFiles('sources.lock') }} + + - if: always() && inputs.sources == 'fetch' && steps.sources.outputs.cache-hit != 'true' + shell: bash + run: if [[ -d /mnt/kryptik/images-aside ]]; then mv /mnt/kryptik/images-aside /mnt/kryptik/sources/images; fi - if: inputs.image-sources == 'fetch' && steps.image_sources.outputs.cache-hit != 'true' uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6415a7d..165ee2a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -126,17 +126,16 @@ jobs: ./tools/fetch-sources.sh --list # Restores the last sets; `make sources` checks them against the locks and fetches what is missing. - # The software images' sources, under images/, are a cache of their own (the prepare action's). + # The software images' sources, under images/, are a cache of their own (the prepare action's), + # set aside while the root's is saved. - name: Upstream sources (cached by sources.lock) id: sources uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: - path: | - /mnt/kryptik/sources - !/mnt/kryptik/sources/images - key: sources-v4-${{ hashFiles('sources.lock') }} + path: /mnt/kryptik/sources + key: sources-v3-${{ hashFiles('sources.lock') }} restore-keys: | - sources-v4- + sources- - name: The software images' sources (cached by build/images/sources.lock) id: image_sources @@ -156,15 +155,21 @@ jobs: ./tools/verify-signatures.sh > /dev/null 2>&1 || true # Not from a pull request: its cache is scoped to the merge ref and would duplicate the set. + - name: Set the software images' sources aside + if: ${{ github.event_name != 'pull_request' && steps.sources.outputs.cache-hit != 'true' }} + run: if [[ -d /mnt/kryptik/sources/images ]]; then mv /mnt/kryptik/sources/images /mnt/kryptik/images-aside; fi + - name: Save the sources if: ${{ github.event_name != 'pull_request' && steps.sources.outputs.cache-hit != 'true' }} uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: - path: | - /mnt/kryptik/sources - !/mnt/kryptik/sources/images + path: /mnt/kryptik/sources key: ${{ steps.sources.outputs.cache-primary-key }} + - name: The software images' sources back in place + if: always() + run: if [[ -d /mnt/kryptik/images-aside ]]; then mv /mnt/kryptik/images-aside /mnt/kryptik/sources/images; fi + - name: Save the software images' sources if: ${{ github.event_name != 'pull_request' && steps.image_sources.outputs.cache-hit != 'true' }} uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 diff --git a/docs/design/browser-and-graphics.md b/docs/design/browser-and-graphics.md index 73d5ec23..17c90494 100644 --- a/docs/design/browser-and-graphics.md +++ b/docs/design/browser-and-graphics.md @@ -210,9 +210,9 @@ floors are given with each layer. - **The images' sources have a cache of their own.** They go under `sources/images/`, keyed by `build/images/sources.lock`, each named in `sources/` by a link, so every tool finds it where it always has. The - root's set stays keyed by `sources.lock` alone, so a browser bump copies - neither set into the other's entry. A job that builds no image fetches - the root's alone (`fetch-sources.sh --root`). + root's set is keyed by `sources.lock` alone and saved with `images/` set + aside, so a browser bump copies neither set into the other's entry. A job + that builds no image fetches the root's alone (`fetch-sources.sh --root`). - **Mozilla's key comes from Mozilla.** The fingerprint is pinned like every other, confirmed by Mozilla's security blog post of August 2026. The key material is taken from the `KEY` file beside the release rather diff --git a/tools/fetch-sources.sh b/tools/fetch-sources.sh index 0446d34e..206d09ac 100755 --- a/tools/fetch-sources.sh +++ b/tools/fetch-sources.sh @@ -288,18 +288,19 @@ fi total=0; fetched=0; cached=0 while IFS='|' read -r owner name ver url _; do [[ -z "$name" ]] && continue - [[ "$ROOT_ONLY" -eq 0 || "$owner" == . ]] || continue - total=$((total + 1)) file="$(basename "$url")" dest="${KRYPTIK_SOURCES}/${file}" lock="$KRYPTIK_LOCK"; [[ "$owner" == . ]] || lock="$KRYPTIK_IMAGES_LOCK" if [[ "$owner" != . ]]; then mkdir -p "${KRYPTIK_SOURCES}/images" - # One fetched before images/ was made moves in. + # One fetched before images/ was made moves in, under --root too, so the root's set is + # saved without it. if [[ -f "$dest" && ! -L "$dest" ]]; then mv -- "$dest" "${KRYPTIK_SOURCES}/images/${file}"; fi ln -sfn "images/${file}" "$dest" dest="${KRYPTIK_SOURCES}/images/${file}" + [[ "$ROOT_ONLY" -eq 0 ]] || continue fi + total=$((total + 1)) was_fetched=no if [[ -f "$dest" ]]; then diff --git a/tools/tests/fetch-sources.sh b/tools/tests/fetch-sources.sh index 2e60c5e0..0a1c7b45 100755 --- a/tools/tests/fetch-sources.sh +++ b/tools/tests/fetch-sources.sh @@ -306,6 +306,16 @@ else red "--root fetched an image's source" fi +# --root still moves one a cache left at the top, so the root's set is saved without it. +images_root +cp "${UPSTREAM}/delta-4.0.tar.gz" "${FAKE}/sources/delta-4.0.tar.gz" +run_images --root +if [[ "$RC" -eq 0 && -L "${FAKE}/sources/delta-4.0.tar.gz" && -f "${FAKE}/sources/images/delta-4.0.tar.gz" ]]; then + green "--root moves an image's source left at the top into sources/images/" +else + red "--root left an image's source at the top (exit ${RC})"; show +fi + # A set cached before images/ existed has the image's file in sources/ itself. images_root cp "${UPSTREAM}/delta-4.0.tar.gz" "${FAKE}/sources/delta-4.0.tar.gz" From 7c5f9e23fe8b0bbfa472269b67572441ce973925 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 02:12:05 -0700 Subject: [PATCH 040/123] The browser image: Firefox ESR for Wayland alone over the toolkit, built offline with rust, clang, cbindgen and nasm layers that no zone gets --- .github/workflows/distro.yml | 3 +- build/images/browser.programs | 9 ++++++ build/images/browser.sh | 53 +++++++++++++++++++++++++++++++++++ build/images/cbindgen.sh | 34 ++++++++++++++++++++++ build/images/clang.sh | 22 +++++++++++++++ build/images/nasm.sh | 16 +++++++++++ build/images/rust.sh | 24 ++++++++++++++++ 7 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 build/images/browser.programs create mode 100644 build/images/browser.sh create mode 100644 build/images/cbindgen.sh create mode 100644 build/images/clang.sh create mode 100644 build/images/nasm.sh create mode 100644 build/images/rust.sh diff --git a/.github/workflows/distro.yml b/.github/workflows/distro.yml index b319db92..81ddfda7 100644 --- a/.github/workflows/distro.yml +++ b/.github/workflows/distro.yml @@ -360,9 +360,10 @@ jobs: sudo sed -n 's/^\(name\|fingerprint\): //p' work/image-build/*/record | paste - - || true # After the stage 04-05 cache is packed, so no image tree or overlay reaches it; stage 06 packs what this builds. + # The browser takes some three hours when its tree is not cached; the toolkit some minutes. - name: Software images (over the base system, in the chroot) id: images - timeout-minutes: 90 + timeout-minutes: 240 run: make -o system images # Each image's tree and record, never an overlay's upper or work directory; saved when they changed. diff --git a/build/images/browser.programs b/build/images/browser.programs new file mode 100644 index 00000000..12080c59 --- /dev/null +++ b/build/images/browser.programs @@ -0,0 +1,9 @@ +# The browser image's executable files, as build/config/image-programs.txt names the root's. Written +# from Firefox's packaging manifest; the first build checks it. +usr/lib/firefox/firefox +usr/lib/firefox/firefox-bin +usr/lib/firefox/glxtest +usr/lib/firefox/vaapitest +usr/lib/firefox/pingsender +usr/lib/firefox/*.so +usr/lib/firefox/gmp-clearkey/*/libclearkey.so diff --git a/build/images/browser.sh b/build/images/browser.sh new file mode 100644 index 00000000..7f3551e9 --- /dev/null +++ b/build/images/browser.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# The browser image: Firefox ESR for Wayland alone, over the toolkit image, built with the rust, +# clang, cbindgen and nasm layers, which it does not carry (docs/design/browser-and-graphics.md). +# Its own copies of NSS, NSPR, libvpx, dav1d and opus stay bundled. Its PDF viewer is the document +# viewer. Pins in build/images/versions.env. +# shellcheck disable=SC2034 # read by build/stages/04-images.sh +image_version="${V_FIREFOX}esr" +# shellcheck disable=SC2034 +image_over=(toolkit rust clang cbindgen nasm) +# shellcheck disable=SC2034 +image_rows=( + "firefox" "s_browser_firefox" + "licences" "s_browser_licences" +) + +# Stage 04's GCC and hardening flags; libclang only for bindgen. With no network: Firefox's own vendored +# crates and Python packages, no toolchain bootstrap, no Node.js. No D-Bus, which no zone has, no updater +# (updates are the image's), no crash reporter, no EME, no jemalloc (hardened_malloc is preloaded), and +# no sound until sound reaches zones. The first build keeps no wasm sandbox for its libraries. +s_browser_firefox() { + local src llvm="/usr/lib/llvm-${V_LLVM%%.*}" + src="$(unpack "firefox-${V_FIREFOX}esr.source.tar.xz" "firefox-${V_FIREFOX}")" + cd "$src" + cat > mozconfig < "${top}/vendor/${name}/.cargo-checksum.json" + done + mkdir -p "${top}/cbindgen-${V_CBINDGEN}/.cargo" + printf '[source.crates-io]\nreplace-with = "vendored"\n\n[source.vendored]\ndirectory = "%s"\n' "${top}/vendor" \ + > "${top}/cbindgen-${V_CBINDGEN}/.cargo/config.toml" + (cd "${top}/cbindgen-${V_CBINDGEN}" && CARGO_HOME="${top}/cargo-home" cargo build --release --offline --locked) + install -D -m 0755 "${top}/cbindgen-${V_CBINDGEN}/target/release/cbindgen" /usr/bin/cbindgen + rm -rf "$top" + cbindgen --version +} diff --git a/build/images/clang.sh b/build/images/clang.sh new file mode 100644 index 00000000..0719ea6f --- /dev/null +++ b/build/images/clang.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# LLVM's release build, for the libclang bindgen loads to read Firefox's C++ headers: the library, +# clang's builtin headers, and clang, which names where they are. A layer that only builds use; no +# zone gets it (docs/design/browser-and-graphics.md). +# shellcheck disable=SC2034 # read by build/stages/04-images.sh +image_version="${V_LLVM}" +# shellcheck disable=SC2034 +image_ships=no +# shellcheck disable=SC2034 +image_rows=( + "libclang" "s_clang_libclang" +) + +# Some 6 GB unpacked: only what bindgen and Firefox's configure read, under /usr/lib/llvm-MAJOR. +s_clang_libclang() { + local top="LLVM-${V_LLVM}-Linux-X64" dir="/usr/lib/llvm-${V_LLVM%%.*}" + mkdir -p "$dir" + tar --zstd --no-same-owner -xf "${KRYPTIK_SOURCES}/LLVM-${V_LLVM}-Linux-X64.tar.zst" -C "$dir" --strip-components=1 \ + --wildcards "${top}/bin/clang" "${top}/bin/clang-[0-9]*" "${top}/lib/libclang.so*" \ + "${top}/lib/clang/*/include/*" "${top}/LICENSE.TXT" + "${dir}/bin/clang" --version +} diff --git a/build/images/nasm.sh b/build/images/nasm.sh new file mode 100644 index 00000000..4cd30063 --- /dev/null +++ b/build/images/nasm.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# nasm, for the assembly of Firefox's AV1 decoder (dav1d). A layer that only builds use; no zone gets +# it (docs/design/browser-and-graphics.md). +# shellcheck disable=SC2034 # read by build/stages/04-images.sh +image_version="${V_NASM}" +# shellcheck disable=SC2034 +image_ships=no +# shellcheck disable=SC2034 +image_rows=( + "nasm" "s_nasm" +) + +s_nasm() { + native_build "nasm-${V_NASM}.tar.xz" "nasm-${V_NASM}" + nasm -v +} diff --git a/build/images/rust.sh b/build/images/rust.sh new file mode 100644 index 00000000..73b764eb --- /dev/null +++ b/build/images/rust.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# The Rust toolchain the browser is built with: rustc, cargo and the standard library for +# x86_64-unknown-linux-gnu, the very tarballs build/config/rust.lock pins for kryptikd. A layer that +# only builds use; no zone gets it (docs/design/browser-and-graphics.md). +# shellcheck disable=SC2034 # read by build/stages/04-images.sh +image_version="${V_RUST}" +# shellcheck disable=SC2034 +image_ships=no +# shellcheck disable=SC2034 +image_rows=( + "rust" "s_rust_toolchain" +) + +# Each component's own installer, under /usr. +s_rust_toolchain() { + local src + for src in "$(unpack "rustc-${V_RUST}-x86_64-unknown-linux-gnu.tar.xz" "rustc-${V_RUST}-x86_64-unknown-linux-gnu")" \ + "$(unpack "cargo-${V_RUST}-x86_64-unknown-linux-gnu.tar.xz" "cargo-${V_RUST}-x86_64-unknown-linux-gnu")" \ + "$(unpack "rust-std-${V_RUST}-x86_64-unknown-linux-gnu.tar.xz" "rust-std-${V_RUST}-x86_64-unknown-linux-gnu")"; do + "${src}/install.sh" --prefix=/usr --disable-ldconfig + done + rustc --version + cargo --version +} From 37f65de772557e6e4da6c1cdcfbea46845acb96c Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 02:16:08 -0700 Subject: [PATCH 041/123] The zones suite draws a page with Firefox, headless, in a zone that names the browser and the toolkit --- build/guest-tests/zones-check.sh | 14 ++++++++++++++ tools/image/zones-test.sh | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index 8a26ddaa..2575d068 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -1680,6 +1680,20 @@ else else fail "image-toolkit" "the list names no toolkit image, or it could not be kept (${tk_hash:-none})" fi + # The browser image over the toolkit's, which a zone naming it must name too: Firefox draws a + # page of the zone's own with no display, through its content process, its sandbox left on. + ff_hash="$(sed -n 's/^image: browser .* root-hash=\([0-9a-f]\{64\}\) .*/\1/p' "$D/images.manifest")" + if [[ -n "$ff_hash" && -n "$tk_hash" ]] && install -D -m 0644 "$D/browser.img" "$IMGS/browser/$ff_hash.img"; then + izone imgff 4259840 '#1b0a6c' "browser toolkit" + irun imgff sh -c 'printf "

kryptik

" > /tmp/page.html && cd /tmp && HOME=/tmp firefox --headless --screenshot /tmp/shot.png file:///tmp/page.html && test -s /tmp/shot.png && echo SHOT-TAKEN' + if [[ "$ZRC" -eq 0 && "$ZOUT" == *"SHOT-TAKEN"* ]]; then + pass "image-browser" "a zone naming the browser and the toolkit draws a page with Firefox, headless" + else + fail "image-browser" "rc ${ZRC}: $(tail -3 <<<"$ZOUT" | tr '\n' ' '); $(tail -5 <<<"$ZERR" | tr '\n' ' ')" + fi + else + fail "image-browser" "the list names no browser image, or it could not be kept (${ff_hash:-none})" + fi umount /run/zt-images fi diff --git a/tools/image/zones-test.sh b/tools/image/zones-test.sh index 5295d9fb..13f0b7e0 100755 --- a/tools/image/zones-test.sh +++ b/tools/image/zones-test.sh @@ -92,7 +92,7 @@ for name in kernel-support policies net-ready net-dns dhcpcd-separated dnsmasq-u 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 \ microcode-notice \ - image-store image-runs image-needs-refused image-hidden image-explained image-list-namespace image-list-other-base image-corrupt image-toolkit; do + image-store image-runs image-needs-refused image-hidden image-explained image-list-namespace image-list-other-base image-corrupt image-toolkit image-browser; do grep -q "ZT PASS ${name}" <<<"$T2" && green "guest: ${name}" || red "guest: ${name} (not passed)" done From 8e7ca2d5c8d078172f91b8c5861886b77ab4fec7 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 02:19:16 -0700 Subject: [PATCH 042/123] The crates test's runner takes no arguments, which shellcheck asks of a function never given any --- tools/tests/fetch-sources.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/tests/fetch-sources.sh b/tools/tests/fetch-sources.sh index 0f8b579a..01d32b75 100755 --- a/tools/tests/fetch-sources.sh +++ b/tools/tests/fetch-sources.sh @@ -346,7 +346,7 @@ crates_root() { } run_crates() { KRYPTIK_ROOT="$FAKE" KRYPTIK_FETCH_SELFTEST=1 KRYPTIK_FETCH_MANIFEST="$MANIFEST" \ - KRYPTIK_FETCH_IMAGE_MANIFEST="$CMANIFEST" KRYPTIK_CRATES="file://${CR}" NO_COLOR=1 bash "$TOOL" "$@" > "$OUT" 2>&1 + KRYPTIK_FETCH_IMAGE_MANIFEST="$CMANIFEST" KRYPTIK_CRATES="file://${CR}" NO_COLOR=1 bash "$TOOL" > "$OUT" 2>&1 RC=$? } From 0f02f34b8e4b0d3f21a9f9d4cc5a79c2b8663230 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 02:22:20 -0700 Subject: [PATCH 043/123] The browser image asks a zone for seccomp, which Firefox's content sandbox installs a filter with, and the suite's zone grants it --- build/guest-tests/zones-check.sh | 4 +++- build/images/browser.needs | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 build/images/browser.needs diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index 2575d068..7a5ff0a4 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -1684,7 +1684,9 @@ else # page of the zone's own with no display, through its content process, its sandbox left on. ff_hash="$(sed -n 's/^image: browser .* root-hash=\([0-9a-f]\{64\}\) .*/\1/p' "$D/images.manifest")" if [[ -n "$ff_hash" && -n "$tk_hash" ]] && install -D -m 0644 "$D/browser.img" "$IMGS/browser/$ff_hash.img"; then - izone imgff 4259840 '#1b0a6c' "browser toolkit" + # Firefox's content sandbox installs a filter of its own, which the image's needs line asks for. + printf 'allow-syscall seccomp\n' > "$IZ/policy/imgff.seccomp" + izone imgff 4259840 '#1b0a6c' "browser toolkit" policy/imgff.seccomp irun imgff sh -c 'printf "

kryptik

" > /tmp/page.html && cd /tmp && HOME=/tmp firefox --headless --screenshot /tmp/shot.png file:///tmp/page.html && test -s /tmp/shot.png && echo SHOT-TAKEN' if [[ "$ZRC" -eq 0 && "$ZOUT" == *"SHOT-TAKEN"* ]]; then pass "image-browser" "a zone naming the browser and the toolkit draws a page with Firefox, headless" diff --git a/build/images/browser.needs b/build/images/browser.needs new file mode 100644 index 00000000..98496ab9 --- /dev/null +++ b/build/images/browser.needs @@ -0,0 +1 @@ +allow-syscall seccomp From fdd3942bb8a2d88ea41e9705c59d6c08ab237366 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 02:25:45 -0700 Subject: [PATCH 044/123] The status page names what the software-image checks prove, the toolkit's included --- docs/status.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/status.md b/docs/status.md index 23f9b084..98ceca2a 100644 --- a/docs/status.md +++ b/docs/status.md @@ -23,6 +23,7 @@ that ship, under QEMU with OVMF firmware. | Installation and the state partition | **tested**: `make install-test` (install, boot alone, reboot, cold boot, the firmware's entry for slot a naming the ESP by its PARTUUID, no release named for the empty slot b, refusals including an injected I/O error and a disk with 4096-byte logical sectors), `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; `kryptik state passphrase` refusing a wrong current passphrase and a new one under 8 characters, then changing it), 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, a plan naming what each of the disk's partitions holds, ERASE and the passphrase asked before the first write and a passphrase under 8 characters refused, 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 and put back by `kryptik-recover --commit-slot`, a tampered root refused by dm-verity, recovery from the medium with state intact, also when the disk's ESP no longer mounts, and the state partition's header saved, wiped and put back) | | 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 | +| Software images | **tested**: `make images` builds each image over the base system through an overlay, and its tree check refuses a file of the root's, anything outside `/usr`, a setuid bit, a file capability and a link out of `/usr` (`tools/tests/check-image-tree.sh`); stage 06 packs each with the root's artifact audit, strict, against the image's own accepted findings, its program list and its licences, and signs the list for the root it was built over. `make zones-test` keeps that list and its images, and checks a zone granted the probe's call runs it from the image over its `/usr`, one not granted it is refused before it starts, one naming no image does not see it, `explain` names it, a list signed under the release namespace or for another root is refused, a flipped block of a kept image reads as an I/O error in the zone and the next start is refused, and a zone naming the toolkit image loads GTK and finds the root's fonts | | 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); 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`. A trial the firmware never started, which BootNext still set shows, is reported so, with how to start it by hand (`tools/tests/boot-success.sh`). 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/` | From f336ab01c5cad8d4a24f01252f6f4c064a1ffe83 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 02:26:25 -0700 Subject: [PATCH 045/123] The status page names the browser's check --- docs/status.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/status.md b/docs/status.md index 98ceca2a..ab741360 100644 --- a/docs/status.md +++ b/docs/status.md @@ -23,7 +23,7 @@ that ship, under QEMU with OVMF firmware. | Installation and the state partition | **tested**: `make install-test` (install, boot alone, reboot, cold boot, the firmware's entry for slot a naming the ESP by its PARTUUID, no release named for the empty slot b, refusals including an injected I/O error and a disk with 4096-byte logical sectors), `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; `kryptik state passphrase` refusing a wrong current passphrase and a new one under 8 characters, then changing it), 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, a plan naming what each of the disk's partitions holds, ERASE and the passphrase asked before the first write and a passphrase under 8 characters refused, 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 and put back by `kryptik-recover --commit-slot`, a tampered root refused by dm-verity, recovery from the medium with state intact, also when the disk's ESP no longer mounts, and the state partition's header saved, wiped and put back) | | 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 | -| Software images | **tested**: `make images` builds each image over the base system through an overlay, and its tree check refuses a file of the root's, anything outside `/usr`, a setuid bit, a file capability and a link out of `/usr` (`tools/tests/check-image-tree.sh`); stage 06 packs each with the root's artifact audit, strict, against the image's own accepted findings, its program list and its licences, and signs the list for the root it was built over. `make zones-test` keeps that list and its images, and checks a zone granted the probe's call runs it from the image over its `/usr`, one not granted it is refused before it starts, one naming no image does not see it, `explain` names it, a list signed under the release namespace or for another root is refused, a flipped block of a kept image reads as an I/O error in the zone and the next start is refused, and a zone naming the toolkit image loads GTK and finds the root's fonts | +| Software images | **tested**: `make images` builds each image over the base system through an overlay, and its tree check refuses a file of the root's, anything outside `/usr`, a setuid bit, a file capability and a link out of `/usr` (`tools/tests/check-image-tree.sh`); stage 06 packs each with the root's artifact audit, strict, against the image's own accepted findings, its program list and its licences, and signs the list for the root it was built over. `make zones-test` keeps that list and its images, and checks a zone granted the probe's call runs it from the image over its `/usr`, one not granted it is refused before it starts, one naming no image does not see it, `explain` names it, a list signed under the release namespace or for another root is refused, a flipped block of a kept image reads as an I/O error in the zone and the next start is refused, and a zone naming the toolkit image loads GTK and finds the root's fonts, and one naming the browser image and the toolkit draws a page with Firefox, headless, its content sandbox granted the seccomp call it asks for | | 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); 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`. A trial the firmware never started, which BootNext still set shows, is reported so, with how to start it by hand (`tools/tests/boot-success.sh`). 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/` | From 5006d06e46713e02f1f8c0aa3d25cd5259d06394 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 02:39:42 -0700 Subject: [PATCH 046/123] untrusted, personal and work carry the browser image, Alt+Shift+u and the menu's b open Firefox where a zone has it, and the desktop suite opens a page with it --- build/desktop/dwl-config.h | 2 +- build/guest-tests/gui-check.sh | 12 ++++++++++++ compartments/zones/personal.toml | 5 +++++ compartments/zones/policy/personal.seccomp | 2 ++ compartments/zones/policy/untrusted.seccomp | 2 ++ compartments/zones/policy/work.seccomp | 2 ++ compartments/zones/untrusted.toml | 5 +++++ compartments/zones/work.toml | 5 +++++ docs/user-guide.md | 6 ++++-- tools/desktop/kryptik-chrome | 7 +++++-- tools/image/gui-test.sh | 2 +- 11 files changed, 44 insertions(+), 6 deletions(-) diff --git a/build/desktop/dwl-config.h b/build/desktop/dwl-config.h index fcf21f0c..6dad9644 100644 --- a/build/desktop/dwl-config.h +++ b/build/desktop/dwl-config.h @@ -89,7 +89,7 @@ static const enum libinput_config_tap_button_map button_map = LIBINPUT_CONFIG_TA * chrome's menu, a text program in a zone 0 terminal of its own */ static const char *termcmd[] = { "kryptik-launch", "--ask", "work", "--", "havoc", NULL }; static const char *personalcmd[] = { "kryptik-launch", "--ask", "personal", "--", "havoc", NULL }; -static const char *untrustedcmd[] = { "kryptik-launch", "--ask", "untrusted", "--", "havoc", "lynx", NULL }; +static const char *untrustedcmd[] = { "kryptik-launch", "--ask", "untrusted", "--", "firefox", NULL }; static const char *menucmd[] = { "havoc", "/usr/bin/kryptik-chrome", "--menu", NULL }; static const Key keys[] = { diff --git a/build/guest-tests/gui-check.sh b/build/guest-tests/gui-check.sh index effa7e07..c437cbde 100755 --- a/build/guest-tests/gui-check.sh +++ b/build/guest-tests/gui-check.sh @@ -771,6 +771,18 @@ else fail "chrome-wifi-scan" "kryptik-launch --wifi-scan: $(tr '\n' ' ' <<<"$scan_said" | cut -c1-200)" fi +# --- the browser, in a zone that carries it --------------------------------------------- +# untrusted names the browser image, which the installer kept from the medium: Firefox opens a +# page of the zone's own, and its window is drawn as untrusted's. +mark firefox untrusted +launch_plain untrusted "sh -c 'printf \"kryptik-page

kryptik

\" > /tmp/page.html && exec firefox --new-instance file:///tmp/page.html'" > "$LOG/launch-firefox.out" 2>&1 & +if wait_for 120 grep -q '^title=.*kryptik-page' "$RT/kryptik/focus" && grep -qx 'zone=untrusted' "$RT/kryptik/focus"; then + pass "browser-window" "Firefox from the browser image opened a page in untrusted: $(grep '^title=' "$RT/kryptik/focus")" +else + fail "browser-window" "focus: $(tr '\n' ' ' < "$RT/kryptik/focus" 2>/dev/null); $(since_mark firefox untrusted | tail -5 | tr '\n' ' ')" +fi +stop_zone untrusted + # --- 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/compartments/zones/personal.toml b/compartments/zones/personal.toml index 9c7583fd..f9f7c739 100644 --- a/compartments/zones/personal.toml +++ b/compartments/zones/personal.toml @@ -29,3 +29,8 @@ border_color = "#009b79" border_pattern = "dashed" glyph = "●" label = "PERSONAL" + +[software] +# The browser, and the toolkit it is built over (docs/design/software-delivery.md); the call its +# content sandbox needs is granted in the zone's policy. +images = "browser toolkit" diff --git a/compartments/zones/policy/personal.seccomp b/compartments/zones/policy/personal.seccomp index 574a3775..88ff77d2 100644 --- a/compartments/zones/policy/personal.seccomp +++ b/compartments/zones/policy/personal.seccomp @@ -1 +1,3 @@ # personal: the base policy only; the directives are in docs/design/zone-policy-files.md. +# The browser image's need: Firefox's content sandbox installs a seccomp filter of its own. +allow-syscall seccomp diff --git a/compartments/zones/policy/untrusted.seccomp b/compartments/zones/policy/untrusted.seccomp index 267ba684..d207192e 100644 --- a/compartments/zones/policy/untrusted.seccomp +++ b/compartments/zones/policy/untrusted.seccomp @@ -1 +1,3 @@ # untrusted: the base policy only; the directives are in docs/design/zone-policy-files.md. +# The browser image's need: Firefox's content sandbox installs a seccomp filter of its own. +allow-syscall seccomp diff --git a/compartments/zones/policy/work.seccomp b/compartments/zones/policy/work.seccomp index 6101451b..f4876625 100644 --- a/compartments/zones/policy/work.seccomp +++ b/compartments/zones/policy/work.seccomp @@ -1 +1,3 @@ # work: the base policy only; the directives are in docs/design/zone-policy-files.md. +# The browser image's need: Firefox's content sandbox installs a seccomp filter of its own. +allow-syscall seccomp diff --git a/compartments/zones/untrusted.toml b/compartments/zones/untrusted.toml index 2a10d310..ff4783c9 100644 --- a/compartments/zones/untrusted.toml +++ b/compartments/zones/untrusted.toml @@ -35,3 +35,8 @@ border_color = "#ba7c02" border_pattern = "dash-dot" glyph = "▲" label = "UNTRUSTED" + +[software] +# The browser, and the toolkit it is built over (docs/design/software-delivery.md); the call its +# content sandbox needs is granted in the zone's policy. +images = "browser toolkit" diff --git a/compartments/zones/work.toml b/compartments/zones/work.toml index 2ef40fbb..fe087ad3 100644 --- a/compartments/zones/work.toml +++ b/compartments/zones/work.toml @@ -29,3 +29,8 @@ border_color = "#2d60d1" border_pattern = "solid" glyph = "■" label = "WORK" + +[software] +# The browser, and the toolkit it is built over (docs/design/software-delivery.md); the call its +# content sandbox needs is granted in the zone's policy. +images = "browser toolkit" diff --git a/docs/user-guide.md b/docs/user-guide.md index c6bdbb22..49f675a4 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -283,7 +283,7 @@ pointer moving onto it moves the keyboard there. Keys (Alt is the modifier): | 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 | +| Alt+Shift+u | the browser (Firefox) in the untrusted zone | | Alt+e | fullscreen (the window keeps its zone border, so the zone stays visible) | | Alt+Shift+c | close the focused window | | Alt+j / Alt+k | focus next / previous | @@ -294,7 +294,9 @@ pointer moving onto it moves the keyboard there. Keys (Alt is the modifier): 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, +Enter. The browser is Firefox in `untrusted`, `personal` and `work`, which +carry its software image, and lynx in a terminal elsewhere; Firefox opens +PDFs too. A zone with encrypted storage asks for its passphrase when it starts, in the menu's window or, for a key, in a small prompt window; the passphrase never appears on a command line. Under the zones, a Network line says what the net zone reports: whether it is ready, the Wi-Fi network it joined (or diff --git a/tools/desktop/kryptik-chrome b/tools/desktop/kryptik-chrome index 92f5b5c5..4d42c110 100755 --- a/tools/desktop/kryptik-chrome +++ b/tools/desktop/kryptik-chrome @@ -48,6 +48,8 @@ zone_field() { # zone key sed -n "s/^$2[[:space:]]*=[[:space:]]*\"\\(.*\\)\"[[:space:]]*$/\\1/p" "$ZONES/$1.toml" 2>/dev/null | head -1 } zone_is_nic() { grep -q '^mode[[:space:]]*=[[:space:]]*"nic"' "$ZONES/$1.toml" 2>/dev/null; } +# Whether the zone's file names the browser image among its images. +zone_has_browser() { grep -Eq '^images[[:space:]]*=[[:space:]]*"([^"]* )?browser( [^"]*)?"' "$ZONES/$1.toml" 2>/dev/null; } # --- the status stream --------------------------------------------------------- @@ -233,7 +235,7 @@ run_menu() { menu_network menu_update echo - echo "Apps: t terminal e editor (nano) b browser (lynx, needs routed network)" + echo "Apps: t terminal e editor (nano) b browser (Firefox where the zone has it, else lynx)" 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 " w Wi-Fi networks f the last zone window's zone r refresh q close this window" @@ -267,7 +269,8 @@ run_menu() { case "$app" in t) set -- "$TERMINAL" ;; e) set -- "$TERMINAL" /usr/bin/nano ;; - b) set -- "$TERMINAL" /usr/bin/lynx ;; + # Firefox where the zone's file names the browser image, lynx elsewhere. + b) if zone_has_browser "$z"; then set -- firefox; else set -- "$TERMINAL" /usr/bin/lynx; fi ;; esac echo "launching in zone $z: $*" # --ask: a passphrase is read on this zone 0 terminal, never a zone's. diff --git a/tools/image/gui-test.sh b/tools/image/gui-test.sh index ba4256af..11a7125e 100755 --- a/tools/image/gui-test.sh +++ b/tools/image/gui-test.sh @@ -116,7 +116,7 @@ grep 'GT FAIL' <<<"$T" | sed 's/^/ /' for name in session-socket compositor-running chrome-focus-record chrome-window-is-zone0 zone0-sees-capture zone-proxy-path zone-sees-needed zone-hidden-globals zone-bind-refused proxy-logged-refusal \ map-keeps-zone0-focus close-keeps-zone0-focus alone-keeps-no-keyboard alone-reached-by-key focus-shows-zone focus-shows-label title-prefixed last-zone-recorded menu-opens-on-key menu-keeps-last-zone menu-names-stale-statement menu-names-install-age 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 chrome-wifi-add menu-network-line chrome-wifi-add-open chrome-wifi-scan \ + 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 browser-window \ zone0-cursor-set zone0-cursor-shown zone-cursor-asked zone-hears-of-outputs zone-cursor-not-shown compositor-socket-unreached \ zone0-frame-clock zone-window-animates \ zone0-fullscreen-granted zone0-fullscreen-needs-focus fullscreen-by-key zone-child-mapped child-focused parent-focused parent-fullscreen fullscreen-keeps-focus keyboard-stays-on-fullscreen zoom-keeps-keyboard zoom-twice-keeps-keyboard zone0-over-fullscreen-gets-keyboard zone0-window-ends-fullscreen child-ends-fullscreen; do From 1c9fb4c99848e893ab53345ef40afc1fb20d5259 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 02:40:53 -0700 Subject: [PATCH 047/123] The browser image's Firefox reports nothing, joins no study, checks for no update and never asks to be the default --- build/images/browser.sh | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/build/images/browser.sh b/build/images/browser.sh index 7f3551e9..8b4d2207 100644 --- a/build/images/browser.sh +++ b/build/images/browser.sh @@ -46,6 +46,19 @@ EOF MOZ_NOSPAM=1 ./mach --no-interactive build env MOZBUILD_STATE_PATH="${src}/.mozbuild" ./mach --no-interactive install test -x /usr/lib/firefox/firefox + # Enterprise policies: no telemetry or studies, no update checks (updates are the image's), and + # no question of the default browser, which a zone has no other of. + install -d -m 0755 /usr/lib/firefox/distribution + cat > /usr/lib/firefox/distribution/policies.json <<'EOF' +{ + "policies": { + "DisableTelemetry": true, + "DisableFirefoxStudies": true, + "DisableAppUpdate": true, + "DontCheckDefaultBrowser": true + } +} +EOF } s_browser_licences() { From 72e75635d91306d647160ecd2ea36f4dc7e21478 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 02:41:46 -0700 Subject: [PATCH 048/123] The clang layer takes no licence text from LLVM's build, as it ships nowhere and the tarball's top may hold none --- build/images/clang.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/images/clang.sh b/build/images/clang.sh index 0719ea6f..25e32efa 100644 --- a/build/images/clang.sh +++ b/build/images/clang.sh @@ -17,6 +17,6 @@ s_clang_libclang() { mkdir -p "$dir" tar --zstd --no-same-owner -xf "${KRYPTIK_SOURCES}/LLVM-${V_LLVM}-Linux-X64.tar.zst" -C "$dir" --strip-components=1 \ --wildcards "${top}/bin/clang" "${top}/bin/clang-[0-9]*" "${top}/lib/libclang.so*" \ - "${top}/lib/clang/*/include/*" "${top}/LICENSE.TXT" + "${top}/lib/clang/*/include/*" "${dir}/bin/clang" --version } From 916cddb5a1c43cefefdb9fc0eb4511417e134513 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 02:48:34 -0700 Subject: [PATCH 049/123] The toolkit's zone round also reads GTK's compiled settings schemas, which its file chooser needs --- build/guest-tests/zones-check.sh | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index 8a26ddaa..14cb0b93 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -1666,14 +1666,15 @@ else else fail "image-corrupt" "could not find or flip the probe's first block (${blk:-none})" fi - # The toolkit image over a zone's /usr: its fontconfig finds the root's fonts, and every library - # GTK loads resolves in the zone, with no display to open. + # The toolkit image over a zone's /usr: its fontconfig finds the root's fonts, every library GTK + # loads resolves in the zone, with no display to open, and GTK's settings schemas were compiled + # at install, without which its file chooser aborts. tk_hash="$(sed -n 's/^image: toolkit .* root-hash=\([0-9a-f]\{64\}\) .*/\1/p' "$D/images.manifest")" if [[ -n "$tk_hash" ]] && install -D -m 0644 "$D/toolkit.img" "$IMGS/toolkit/$tk_hash.img"; then izone imgtk 4194304 '#0a1b6c' toolkit - irun imgtk sh -c 'fc-match sans; gtk-query-immodules-3.0 | head -1' - if [[ "$ZRC" -eq 0 && "$ZOUT" == *"DejaVu Sans"* && "$ZOUT" == *"Input Method Modules file"* ]]; then - pass "image-toolkit" "a zone naming the toolkit loads GTK and finds its fonts: $(head -1 <<<"$ZOUT")" + irun imgtk sh -c 'fc-match sans; gtk-query-immodules-3.0 | head -1; gsettings list-schemas | grep -x org.gtk.Settings.FileChooser' + if [[ "$ZRC" -eq 0 && "$ZOUT" == *"DejaVu Sans"* && "$ZOUT" == *"Input Method Modules file"* && "$ZOUT" == *"org.gtk.Settings.FileChooser"* ]]; then + pass "image-toolkit" "a zone naming the toolkit loads GTK, finds its fonts and reads its compiled schemas: $(head -1 <<<"$ZOUT")" else fail "image-toolkit" "rc ${ZRC}: ${ZOUT:-no output}; $(tail -3 <<<"$ZERR" | tr '\n' ' ')" fi From 85bb851d8e808630d633d3237e1dc2d1e21af585 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 02:48:54 -0700 Subject: [PATCH 050/123] The design says image_over is not followed through, so a zone names an image's whole chain --- docs/design/software-delivery.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/design/software-delivery.md b/docs/design/software-delivery.md index 2ad61958..f7fe6eea 100644 --- a/docs/design/software-delivery.md +++ b/docs/design/software-delivery.md @@ -202,7 +202,10 @@ up ADR-001 in the zones where the applications run. others (`image_over`): their trees lie under it, above the root, while its rows run, the tree check refuses a file any of them has, and the list's `requires:` line names those that ship. A layer marked `image_ships=no`, a - toolchain only builds use, is never packed or listed. + toolchain only builds use, is never packed or listed. `image_over` is not + followed through: an image built over one that is itself built over others + names those too, and a zone names every image of the chain, as each image's + `requires:` line is checked on its own. - **Packed.** Stage 06 makes `images-/.img` the way it makes the root (ext4 without a journal, then the verity tree, with a salt and UUID derived from the content), runs the root's artifact audit From a6532534f4430ff83f7094cfadf9fe0e1ba4c368 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 02:50:10 -0700 Subject: [PATCH 051/123] The images' sources cache carries the signatures too, so an image's pin moving alone saves them --- .github/actions/prepare/action.yml | 10 ++++++++-- .github/workflows/ci.yml | 10 ++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/.github/actions/prepare/action.yml b/.github/actions/prepare/action.yml index ba2fd588..0a4257d4 100644 --- a/.github/actions/prepare/action.yml +++ b/.github/actions/prepare/action.yml @@ -50,11 +50,15 @@ runs: # A changed lock restores the previous set; every file is still hashed against the lock. restore-keys: sources- + # With the signatures, which verify-signatures keeps beside the root's: when an image's pin alone + # moves, only this cache is saved again. - if: inputs.image-sources != '' id: image_sources uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: - path: /mnt/kryptik/sources/images + path: | + /mnt/kryptik/sources/images + /mnt/kryptik/sources/.signatures key: image-sources-v1-${{ hashFiles('build/images/sources.lock') }} restore-keys: image-sources-v1- @@ -88,5 +92,7 @@ runs: - if: inputs.image-sources == 'fetch' && steps.image_sources.outputs.cache-hit != 'true' uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: - path: /mnt/kryptik/sources/images + path: | + /mnt/kryptik/sources/images + /mnt/kryptik/sources/.signatures key: image-sources-v1-${{ hashFiles('build/images/sources.lock') }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 165ee2a6..b4636a06 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,11 +137,15 @@ jobs: restore-keys: | sources- + # With the signatures, which verify-signatures keeps beside the root's: when an image's pin alone + # moves, only this cache is saved again. - name: The software images' sources (cached by build/images/sources.lock) id: image_sources uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: - path: /mnt/kryptik/sources/images + path: | + /mnt/kryptik/sources/images + /mnt/kryptik/sources/.signatures key: image-sources-v1-${{ hashFiles('build/images/sources.lock') }} restore-keys: | image-sources-v1- @@ -174,7 +178,9 @@ jobs: if: ${{ github.event_name != 'pull_request' && steps.image_sources.outputs.cache-hit != 'true' }} uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: - path: /mnt/kryptik/sources/images + path: | + /mnt/kryptik/sources/images + /mnt/kryptik/sources/.signatures key: ${{ steps.image_sources.outputs.cache-primary-key }} # Informational, as mirrors go down; a one-byte ranged GET, since some GNU mirrors refuse HEAD. From ab7c87d74c10887974348f672e759d7d98abaa24 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 02:52:18 -0700 Subject: [PATCH 052/123] An image's .strip list names what it keeps for the images built over it and never ships, and the toolkit leaves out its headers, pkg-config files and code generators --- build/images/toolkit.programs | 17 +---------------- build/images/toolkit.strip | 26 ++++++++++++++++++++++++++ build/stages/06-iso.sh | 12 ++++++++++++ docs/design/software-delivery.md | 4 +++- 4 files changed, 42 insertions(+), 17 deletions(-) create mode 100644 build/images/toolkit.strip diff --git a/build/images/toolkit.programs b/build/images/toolkit.programs index 985a7f69..d74558ef 100644 --- a/build/images/toolkit.programs +++ b/build/images/toolkit.programs @@ -1,4 +1,4 @@ -# The toolkit image's executable files, as build/config/image-programs.txt names the root's. +# The toolkit image's executable files as it ships, its .strip list's left out, as build/config/image-programs.txt names the root's. usr/bin/fc-cache usr/bin/fc-cat usr/bin/fc-conflist @@ -11,27 +11,12 @@ usr/bin/fc-scan usr/bin/fc-validate usr/bin/gapplication usr/bin/gdbus -usr/bin/gdbus-codegen -usr/bin/gdk-pixbuf-csource -usr/bin/gdk-pixbuf-pixdata -usr/bin/gdk-pixbuf-query-loaders -usr/bin/gi-compile-repository -usr/bin/gi-decompile-typelib -usr/bin/gi-inspect-typelib usr/bin/gio usr/bin/gio-querymodules -usr/bin/glib-compile-resources usr/bin/glib-compile-schemas -usr/bin/glib-genmarshal -usr/bin/glib-gettextize -usr/bin/glib-mkenums usr/bin/gobject-query usr/bin/gresource usr/bin/gsettings -usr/bin/gtester -usr/bin/gtester-report -usr/bin/gtk-builder-tool -usr/bin/gtk-encode-symbolic-svg usr/bin/gtk-launch usr/bin/gtk-query-immodules-3.0 usr/bin/gtk-query-settings diff --git a/build/images/toolkit.strip b/build/images/toolkit.strip new file mode 100644 index 00000000..c08770c3 --- /dev/null +++ b/build/images/toolkit.strip @@ -0,0 +1,26 @@ +# What the toolkit image keeps for the images built over it, the browser's, and never ships to a zone: +# headers and pkg-config files, code generators and test tools, documentation (stage 06's pack_image). +usr/include +usr/lib/glib-2.0/include +usr/lib/pkgconfig +usr/share/aclocal +usr/share/gettext/its +usr/share/glib-2.0/codegen +usr/share/glib-2.0/gdb +usr/share/gdb +usr/share/doc +usr/bin/gdbus-codegen +usr/bin/gdk-pixbuf-csource +usr/bin/gdk-pixbuf-pixdata +usr/bin/gdk-pixbuf-query-loaders +usr/bin/gi-compile-repository +usr/bin/gi-decompile-typelib +usr/bin/gi-inspect-typelib +usr/bin/glib-compile-resources +usr/bin/glib-genmarshal +usr/bin/glib-gettextize +usr/bin/glib-mkenums +usr/bin/gtester +usr/bin/gtester-report +usr/bin/gtk-builder-tool +usr/bin/gtk-encode-symbolic-svg diff --git a/build/stages/06-iso.sh b/build/stages/06-iso.sh index ffa09dd2..dde03228 100755 --- a/build/stages/06-iso.sh +++ b/build/stages/06-iso.sh @@ -349,6 +349,18 @@ pack_image() { local stage; stage="$(mktemp -d "${IMG}/image-stage.XXXXXX")" chmod 0755 "$stage" rsync -aH --numeric-ids "${tree}/" "${stage}/" + # What the image keeps for those built over it and never ships (build/images/NAME.strip, a glob + # a line under its root): an entry that matches nothing is stale, as in its program list. + local strip_list="${KRYPTIK_ROOT}/build/images/${name}.strip" glob gone=() + if [[ -f "$strip_list" ]]; then + while IFS= read -r glob || [[ -n "$glob" ]]; do + [[ -n "$glob" && "$glob" != \#* ]] || continue + [[ "$glob" != /* && "$glob" != *..* ]] || { echo "FAIL: image ${name}: ${glob} is not a path under its root" >&2; return 1; } + mapfile -t gone < <(compgen -G "${stage}/${glob}") + [[ "${#gone[@]}" -gt 0 ]] || { echo "FAIL: image ${name}: ${glob} in ${name}.strip names nothing" >&2; return 1; } + rm -rf -- "${gone[@]}" + done < "$strip_list" + fi strip_root "$stage" >&2 if find "$stage" -perm /6000 -print | grep . >&2; then echo "FAIL: a setuid or setgid file in image ${name}" >&2; return 1; fi # Against the image's own accepted findings, each with its reason, or none: the root's list diff --git a/docs/design/software-delivery.md b/docs/design/software-delivery.md index eb5c5f66..56ea36f0 100644 --- a/docs/design/software-delivery.md +++ b/docs/design/software-delivery.md @@ -204,7 +204,9 @@ up ADR-001 in the zones where the applications run. salt and UUID derived from the content), runs the root's artifact audit over it with the image's own accepted findings (`build/images/NAME.accepted`, each with its reason) or none, and the - image's own program list, and writes a manifest, + image's own program list, after leaving out what `build/images/NAME.strip` + names (headers, pkg-config files, code generators: what only builds over the + image use, which keep them in its build tree), and writes a manifest, `images-/images.manifest`: ```text From 53890b1f24a115fe28d22c6751c6f49257b7a95e Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 02:53:56 -0700 Subject: [PATCH 053/123] The source bundle carries the crates cbindgen is built from, held to its lock, and pruning keeps only the crates the current locks name --- tools/fetch-sources.sh | 10 +++++++++- tools/prune-sources.sh | 20 ++++++++++++++++++++ tools/source-bundle.sh | 16 ++++++++++++++++ tools/tests/fetch-sources.sh | 4 ++++ 4 files changed, 49 insertions(+), 1 deletion(-) diff --git a/tools/fetch-sources.sh b/tools/fetch-sources.sh index 30c0e781..cf029cb0 100755 --- a/tools/fetch-sources.sh +++ b/tools/fetch-sources.sh @@ -7,6 +7,7 @@ # ./tools/fetch-sources.sh --list print the resolved manifest, download nothing # ./tools/fetch-sources.sh --list-root only the sources the root is built from # ./tools/fetch-sources.sh --list-image NAME only those software image NAME is built from +# ./tools/fetch-sources.sh --crates CRATE name, version and hash of the crates CRATE's lock names # # The software images' sources are pinned in build/images/versions.env and locked in # build/images/sources.lock, so a change to them is no input of stage 04. They are kept in @@ -25,8 +26,9 @@ case "${1:-}" in --list) MODE="list" ;; --list-root) MODE="list-root" ;; --list-image) MODE="list-image"; IMAGE="${2:?--list-image needs an image name}" ;; + --crates) MODE="crates"; CRATE="${2:?--crates needs a .crate file}" ;; "") ;; - *) die "unknown argument: $1 (expected --root, --lock, --list, --list-root, --list-image NAME, or nothing)" ;; + *) die "unknown argument: $1 (expected --root, --lock, --list, --list-root, --list-image NAME, --crates CRATE, or nothing)" ;; esac # Test hook: tools/tests/fetch-sources.sh substitutes a manifest of file:// URLs, and crates.io. @@ -320,6 +322,12 @@ fetch_crates() { ok "$(basename "$crate"): the ${n} crates its Cargo.lock names, verified" } +# For the source bundle and the pruning, which keep what a crate's own lock names. +if [[ "$MODE" == crates ]]; then + crate_lock "$CRATE" + exit +fi + lookup_hash() { # lookup_hash FILE LOCK [[ -f "$2" ]] || return 1 awk -v f="$1" '$2 == f { print $1; found=1 } END { exit !found }' "$2" diff --git a/tools/prune-sources.sh b/tools/prune-sources.sh index aea17cd4..643a9439 100755 --- a/tools/prune-sources.sh +++ b/tools/prune-sources.sh @@ -40,3 +40,23 @@ for f in "${KRYPTIK_SOURCES}"/* "${KRYPTIK_SOURCES}"/images/*; do removed=$((removed + 1)) done ok "${#keep[@]} files named by the manifest; ${removed} not named $([[ "$DRY" -eq 1 ]] && echo "would be" || echo "were") removed" + +# images/crates/ keeps what the current crates' own locks name; with a crate missing its lock cannot be +# read, and nothing there is removed. +[[ -d "${KRYPTIK_SOURCES}/images/crates" ]] || exit 0 +declare -A wanted=() +while read -r _ _ url _; do + f="${url##*/}" + [[ "$f" == *.crate ]] || continue + [[ -f "${KRYPTIK_SOURCES}/${f}" ]] || { dim "${f} is not downloaded: images/crates/ is left as it is"; exit 0; } + while read -r cname cver _; do wanted["${cname}-${cver}.crate"]=1; done \ + < <("${KRYPTIK_ROOT}/tools/fetch-sources.sh" --crates "${KRYPTIK_SOURCES}/${f}") +done < <("${KRYPTIK_ROOT}/tools/fetch-sources.sh" --list) +dropped=0 +for f in "${KRYPTIK_SOURCES}"/images/crates/*.crate; do + [[ -f "$f" ]] || continue + [[ -n "${wanted[$(basename "$f")]:-}" ]] && continue + if [[ "$DRY" -eq 1 ]]; then echo "would remove crates/$(basename "$f")"; else rm -f -- "$f"; echo "removed crates/$(basename "$f")"; fi + dropped=$((dropped + 1)) +done +ok "${#wanted[@]} crates named by the crates' locks; ${dropped} not named $([[ "$DRY" -eq 1 ]] && echo "would be" || echo "were") removed" diff --git a/tools/source-bundle.sh b/tools/source-bundle.sh index 45f64b6e..e19ec8ce 100755 --- a/tools/source-bundle.sh +++ b/tools/source-bundle.sh @@ -64,6 +64,22 @@ for ws in compartments/kryptikd compositor; do done ok "the crates of both Rust workspaces" +# A crate a software image is built with, cbindgen for the browser, comes with the crates its own lock +# names, which fetch-sources.sh fetched into sources/images/crates/: each, held to that lock's hash. +nc=0 +while read -r _ _ url _; do + f="${url##*/}" + [[ "$f" == *.crate ]] || continue + while read -r cname cver csum; do + c="${KRYPTIK_SOURCES}/images/crates/${cname}-${cver}.crate" + [[ -f "$c" ]] || die "crate ${cname} ${cver}, which ${f} is built from, is not downloaded; run make sources" + [[ "$(sha256_of "$c")" == "$csum" ]] || die "crate ${cname} ${cver} does not match ${f}'s Cargo.lock" + mkdir -p "$OUT/crates/images" + [[ -f "$OUT/crates/images/${c##*/}" ]] || { cp "$c" "$OUT/crates/images/"; nc=$((nc + 1)); } + done < <("${KRYPTIK_ROOT}/tools/fetch-sources.sh" --crates "${KRYPTIK_SOURCES}/${f}") +done < <("${KRYPTIK_ROOT}/tools/fetch-sources.sh" --list) +ok "${nc} crates the software images' crates are built from" + (cd "$OUT" && { printf '# Kryptik corresponding source for commit %s\n' "$commit" find . -type f ! -name MANIFEST -print0 | LC_ALL=C sort -z | xargs -0 sha256sum diff --git a/tools/tests/fetch-sources.sh b/tools/tests/fetch-sources.sh index 01d32b75..1a6ab9d7 100755 --- a/tools/tests/fetch-sources.sh +++ b/tools/tests/fetch-sources.sh @@ -360,6 +360,10 @@ crate_with 0000000000000000000000000000000000000000000000000000000000000000; cra run_crates expect_fail "a crate whose bytes are not what the Cargo.lock says is refused" "CHECKSUM MISMATCH for crate dep 0.1.0" +KRYPTIK_ROOT="$FAKE" NO_COLOR=1 bash "$TOOL" --crates "${UPSTREAM}/tool-1.0.crate" > "$OUT" 2>&1 +[[ "$(cat "$OUT")" == "dep 0.1.0 0000000000000000000000000000000000000000000000000000000000000000" ]] \ + && green "--crates prints what a crate's lock names" || { red "--crates printed something else"; show; } + if KRYPTIK_ROOT="$FAKE" KRYPTIK_CRATES="file://${CR}" NO_COLOR=1 bash "$TOOL" --list > "$OUT" 2>&1; then red "a crates mirror was taken without the test's manifest" else From 0d4005768020003071e1e9ea2c7a5d8286a2f407 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 02:54:12 -0700 Subject: [PATCH 054/123] fetch-sources' test holds Mozilla's key row to the KEY file beside the pinned Firefox --- tools/tests/fetch-sources.sh | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tools/tests/fetch-sources.sh b/tools/tests/fetch-sources.sh index 3d85af43..20bfba70 100755 --- a/tools/tests/fetch-sources.sh +++ b/tools/tests/fetch-sources.sh @@ -371,6 +371,15 @@ else red "lock entries with no manifest row: ${stale}" fi +# Mozilla's key is taken from the KEY file beside the pinned release; a Firefox bump that left the row +# behind would keep importing a file that never learns a later revocation. +vf="$(sed -n 's/^V_FIREFOX=//p' "${ROOT}/build/images/versions.env")" +if [[ -n "$vf" ]] && grep -Eq "^14F26682D0916CDD81E37B6D61B7B526D98F0353[[:space:]]+origin[[:space:]]+https://archive[.]mozilla[.]org/pub/firefox/releases/${vf//./[.]}esr/KEY[[:space:]]" "${ROOT}/tools/key-provenance.tsv"; then + green "Mozilla's key row names the KEY file beside the pinned Firefox, ${vf}esr" +else + red "tools/key-provenance.tsv's Mozilla row does not name releases/${vf:-?}esr/KEY: a Firefox bump moves it too" +fi + # The browser's Rust layer and kryptikd's compiler are one release: both locks hold the same three. rust_shared="$(awk 'NR == FNR { if ($1 !~ /^#/ && NF == 2) want[$2] = $1; next } ($2 in want) { n++; if (want[$2] != $1) bad = bad " " $2 } From a48c160bcff0c359b3d5301275ab40e458c5d488 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 03:01:32 -0700 Subject: [PATCH 055/123] A zone set without network needs no NIC zone: one is required only where a zone is routed --- compartments/kryptikd/src/zone.rs | 7 +++++-- compartments/kryptikd/src/zone/tests.rs | 7 ++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/compartments/kryptikd/src/zone.rs b/compartments/kryptikd/src/zone.rs index b999716e..2e1f31f8 100644 --- a/compartments/kryptikd/src/zone.rs +++ b/compartments/kryptikd/src/zone.rs @@ -15,7 +15,7 @@ pub enum NetworkMode { None, /// veth into the bridge owned by the `nic` zone. Routed, - /// Holds the physical interface. Exactly one zone may have this. + /// Holds the physical interface. At most one zone has this, and one must where a zone is routed. Nic, } @@ -664,15 +664,18 @@ pub fn check_invariants(zones: &[Zone]) -> Result<(), ZoneError> { } } } - // Exactly one zone holds the NIC: the single path to the network. + /* At most one zone holds the NIC, the single path to the network, and one must wherever a + * routed zone needs that path. A set of zones without network, as a test's, needs none. */ let nic: Vec<&str> = zones .iter() .filter(|z| z.network == NetworkMode::Nic) .map(|z| z.name.as_str()) .collect(); + let routed = zones.iter().any(|z| z.network == NetworkMode::Routed); match nic.len() { 1 => {} + 0 if !routed => {} 0 => { return Err(ZoneError::Invalid( "no zone holds the physical NIC; routed zones would have no path out" diff --git a/compartments/kryptikd/src/zone/tests.rs b/compartments/kryptikd/src/zone/tests.rs index 2895a627..476b56dc 100644 --- a/compartments/kryptikd/src/zone/tests.rs +++ b/compartments/kryptikd/src/zone/tests.rs @@ -357,7 +357,12 @@ fn exactly_one_nic_zone() { assert!(format!("{err}").contains("claim the physical NIC"), "got: {err}"); let none = vec![zone_with("work", "routed", "#111111")]; - assert!(check_invariants(&none).is_err()); + let err = check_invariants(&none).unwrap_err(); + assert!(format!("{err}").contains("routed zones would have no path out"), "got: {err}"); + + // Zones without network need no path out, so no NIC zone. + let offline = vec![zone_with("work", "none", "#111111"), zone_with("vault", "none", "#222222")]; + assert!(check_invariants(&offline).is_ok()); let one = vec![ zone_with("net", "nic", "#111111"), From 870159cd520dfabd3035ec0876a25668a0dd82d4 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 03:01:58 -0700 Subject: [PATCH 056/123] The zones suite's images disk is labelled within ext4's 16 bytes, so the guest finds it by the label it was given --- build/guest-tests/zones-check.sh | 5 +++-- tools/image/zones-test.sh | 5 ++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index e9612f8c..45194ac3 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -1581,10 +1581,11 @@ fi # reads; then zones of this check's own: one granted the probe's call, one not, # and one that names no image. IMGS=/var/lib/kryptik/images; IZ=/root/zt/image-zones -img_dev="$(blkid -L kryptik-zt-images 2>/dev/null)" +# The label tools/image/zones-test.sh gives the disk, within ext4's 16 bytes. +img_dev="$(blkid -L kryptik-zt-img 2>/dev/null)" mkdir -p /run/zt-images "$IZ/policy" if [[ -z "$img_dev" ]] || ! mount -o ro "$img_dev" /run/zt-images; then - fail "image-store" "no kryptik-zt-images disk holds the signed images: ${img_dev:-none attached}" + fail "image-store" "no kryptik-zt-img disk holds the signed images: ${img_dev:-none attached}" else D=/run/zt-images this_root="$(sed -n 's/.*sha256 \([0-9a-f]\{64\}\) .*/\1/p' /proc/cmdline)" diff --git a/tools/image/zones-test.sh b/tools/image/zones-test.sh index 0fa663cb..d669d655 100755 --- a/tools/image/zones-test.sh +++ b/tools/image/zones-test.sh @@ -54,10 +54,13 @@ step "step 2: the guest-side zone, network and storage checks (as root)" MVER="$(basename "$USB")"; MVER="${MVER#kryptik-}"; MVER="${MVER%-usb.img}" IMAGES="$(dirname "$USB")/images-${MVER}" IMGDISK="${VMDIR}/zones-images.img" +# The label the guest finds the disk by: ext4 keeps 16 bytes of one, and cuts a longer one short. +IMGLABEL=kryptik-zt-img +(( ${#IMGLABEL} <= 16 )) || { red "the images disk's label ${IMGLABEL} is over ext4's 16 bytes"; exit 1; } IMGARGS=() if [[ -f "${IMAGES}/images.manifest.sig" ]] && rm -f "$IMGDISK" \ && truncate -s "$(( $(du -sm "$IMAGES" | cut -f1) + 64 ))M" "$IMGDISK" \ - && mkfs.ext4 -q -F -L kryptik-zt-images -d "$IMAGES" "$IMGDISK"; then + && mkfs.ext4 -q -F -L "$IMGLABEL" -d "$IMAGES" "$IMGDISK"; then IMGARGS=(--disk "$IMGDISK") else red "no signed software images for ${MVER} at ${IMAGES}: the guest's image checks have nothing to keep" From a09807f9f9efd97e511c9cafde30cc44d878ce4c Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 03:03:08 -0700 Subject: [PATCH 057/123] The update suite lists the staged release with its image list, and holds each image the list names to it, whatever images a build carries --- tools/image/update-test.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tools/image/update-test.sh b/tools/image/update-test.sh index f3897ca2..e243f0c1 100755 --- a/tools/image/update-test.sh +++ b/tools/image/update-test.sh @@ -437,6 +437,10 @@ else # the user, automatic fetching brings the release whole without a fetch # (one asked for as well changes nothing), then apply, trial boot and # commit, and the setting outlasts the update. + # The staged release: the root's files and the image list with its signature, listed, and each + # image the list names, left out of the listing and held to the list instead. No single quote: + # vm-drive's su wraps the command in them. + staged="cd /var/lib/kryptik/update/incoming/${VB} && "'sed -n "s/^image: \([a-z0-9-]*\) .*/\1.img.zst/p" images.manifest > /tmp/staged-images; ls | grep -vxF -f /tmp/staged-images | sort | tr "\n" " "; echo LISTED; w=0; while read -r f; do [ -s "$f" ] || w=1; done < /tmp/staged-images; echo IMAGES-WHOLE=$w' drive "${UP_TO_STATEMENT[@]}" \ "$(ROOTSH 'sleep 70; echo STAGED-UNASKED=$(ls /var/lib/kryptik/update/incoming 2>/dev/null | wc -l)')" "expect:STAGED-UNASKED=0" \ "run:kryptik update status | grep -q 'fetching *only when asked'" \ @@ -445,7 +449,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.zst manifest manifest.sig root.json LISTED" \ + "$(ROOTSH "$staged")" "expect:images.manifest images.manifest.sig kryptik-a.efi kryptik-b.efi kryptik-root.img.zst manifest manifest.sig root.json LISTED" "expect:IMAGES-WHOLE=0" \ "run:kryptik update apply" "expect:armed: the next boot tries slot b" \ "$(ROOTSH 'reboot')" "expect:Linux version" "expect:KRYPTIK_SMOKE: END" \ "login:${TUSER}:${TPASS}" \ From dbe2c316f67f8fe459ca64f6e9ca19689aa031e5 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 03:06:55 -0700 Subject: [PATCH 058/123] The browser's zone round may take three minutes, as Firefox's first start makes its profile under software rendering --- build/guest-tests/zones-check.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index 9a2f6a9d..83ae3b7d 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -1613,9 +1613,9 @@ else izone imgok 3932160 '#0a1b2c' probe policy/imgok.seccomp izone imgbare 3997696 '#0a1b3c' probe izone imgnone 4063232 '#0a1b4c' - irun() { # irun ZONE CMD...: sets ZRC, ZOUT and ZERR + irun() { # irun ZONE CMD...: sets ZRC, ZOUT and ZERR; IRUN_SECS bounds it (60 s) local zone="$1"; shift - timeout -k 5 60 "$KD" run "$zone" --zones "$IZ" --rootfs "$R" -- "$@" > "$LOG/$zone.out" 2> "$LOG/$zone.err" + timeout -k 5 "${IRUN_SECS:-60}" "$KD" run "$zone" --zones "$IZ" --rootfs "$R" -- "$@" > "$LOG/$zone.out" 2> "$LOG/$zone.err" ZRC=$?; ZOUT="$(cat "$LOG/$zone.out")"; ZERR="$(cat "$LOG/$zone.err")" } irun imgok /usr/bin/kryptik-probe @@ -1689,7 +1689,7 @@ else # Firefox's content sandbox installs a filter of its own, which the image's needs line asks for. printf 'allow-syscall seccomp\n' > "$IZ/policy/imgff.seccomp" izone imgff 4259840 '#1b0a6c' "browser toolkit" policy/imgff.seccomp - irun imgff sh -c 'printf "

kryptik

" > /tmp/page.html && cd /tmp && HOME=/tmp firefox --headless --screenshot /tmp/shot.png file:///tmp/page.html && test -s /tmp/shot.png && echo SHOT-TAKEN' + IRUN_SECS=180 irun imgff sh -c 'printf "

kryptik

" > /tmp/page.html && cd /tmp && HOME=/tmp firefox --headless --screenshot /tmp/shot.png file:///tmp/page.html && test -s /tmp/shot.png && echo SHOT-TAKEN' if [[ "$ZRC" -eq 0 && "$ZOUT" == *"SHOT-TAKEN"* ]]; then pass "image-browser" "a zone naming the browser and the toolkit draws a page with Firefox, headless" else From c1a5e28bae24679c8445dff62e4de33b50845b7b Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 03:22:59 -0700 Subject: [PATCH 059/123] The clang layer reads LLVM's zstd with the 1 GiB window it was made with, and the browser builds without debug symbols, which stage 06 strips --- build/images/browser.sh | 4 +++- build/images/clang.sh | 8 +++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/build/images/browser.sh b/build/images/browser.sh index 8b4d2207..b8f02c66 100644 --- a/build/images/browser.sh +++ b/build/images/browser.sh @@ -16,7 +16,8 @@ image_rows=( # Stage 04's GCC and hardening flags; libclang only for bindgen. With no network: Firefox's own vendored # crates and Python packages, no toolchain bootstrap, no Node.js. No D-Bus, which no zone has, no updater # (updates are the image's), no crash reporter, no EME, no jemalloc (hardened_malloc is preloaded), and -# no sound until sound reaches zones. The first build keeps no wasm sandbox for its libraries. +# no sound until sound reaches zones. The first build keeps no wasm sandbox for its libraries. No debug +# symbols: stage 06 strips them anyway, and linking libxul with them would need more than the runner has. s_browser_firefox() { local src llvm="/usr/lib/llvm-${V_LLVM%%.*}" src="$(unpack "firefox-${V_FIREFOX}esr.source.tar.xz" "firefox-${V_FIREFOX}")" @@ -27,6 +28,7 @@ ac_add_options --enable-application=browser ac_add_options --enable-default-toolkit=cairo-gtk3-wayland-only ac_add_options --enable-release ac_add_options --disable-debug +ac_add_options --disable-debug-symbols ac_add_options --disable-tests ac_add_options --disable-bootstrap ac_add_options --disable-nodejs diff --git a/build/images/clang.sh b/build/images/clang.sh index 25e32efa..1ce2db4e 100644 --- a/build/images/clang.sh +++ b/build/images/clang.sh @@ -15,8 +15,10 @@ image_rows=( s_clang_libclang() { local top="LLVM-${V_LLVM}-Linux-X64" dir="/usr/lib/llvm-${V_LLVM%%.*}" mkdir -p "$dir" - tar --zstd --no-same-owner -xf "${KRYPTIK_SOURCES}/LLVM-${V_LLVM}-Linux-X64.tar.zst" -C "$dir" --strip-components=1 \ - --wildcards "${top}/bin/clang" "${top}/bin/clang-[0-9]*" "${top}/lib/libclang.so*" \ - "${top}/lib/clang/*/include/*" + # LLVM compresses with a 1 GiB window, past zstd's default limit of 128 MiB. + zstd -d --long=31 -c "${KRYPTIK_SOURCES}/LLVM-${V_LLVM}-Linux-X64.tar.zst" \ + | tar --no-same-owner -xf - -C "$dir" --strip-components=1 \ + --wildcards "${top}/bin/clang" "${top}/bin/clang-[0-9]*" "${top}/lib/libclang.so*" \ + "${top}/lib/clang/*/include/*" "${dir}/bin/clang" --version } From f1ecf7a374739a639e158c1eab5242896acdeee1 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 03:39:18 -0700 Subject: [PATCH 060/123] Image sources are linked into sources/ by every run that asks for them, as a root set restored from main's cache holds no links --- .github/actions/prepare/action.yml | 12 +++++++----- .github/workflows/ci.yml | 9 ++++++--- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/.github/actions/prepare/action.yml b/.github/actions/prepare/action.yml index 0a4257d4..df45bdbc 100644 --- a/.github/actions/prepare/action.yml +++ b/.github/actions/prepare/action.yml @@ -62,17 +62,19 @@ runs: key: image-sources-v1-${{ hashFiles('build/images/sources.lock') }} restore-keys: image-sources-v1- - # A cache evicted since the build job is fetched again, not taken as gone. - - if: (inputs.sources != '' && steps.sources.outputs.cache-hit != 'true') || (inputs.image-sources != '' && steps.image_sources.outputs.cache-hit != 'true') + # A cache evicted since the build job is fetched again, not taken as gone. With the images' sources + # this runs on two hits as well: the root's set restored may be one saved without the links that + # name each image source in sources/ (main's, or an older set's), and only this pass makes them. + - if: (inputs.sources != '' && steps.sources.outputs.cache-hit != 'true') || inputs.image-sources != '' shell: bash env: - MODE: ${{ inputs.sources }} IMAGES: ${{ inputs.image-sources }} + SAVES: ${{ (inputs.sources == 'fetch' && steps.sources.outputs.cache-hit != 'true') || (inputs.image-sources == 'fetch' && steps.image_sources.outputs.cache-hit != 'true') }} run: | if [[ -n "$IMAGES" ]]; then make sources; else ./tools/fetch-sources.sh --root; fi ./tools/prune-sources.sh - # Fetches the signatures into the saved set for CI's gate; this pass is not the verdict. - [[ "$MODE" != fetch ]] || ./tools/verify-signatures.sh > /dev/null 2>&1 || true + # Fetches the signatures into the set saved below for CI's gate; this pass is not the verdict. + [[ "$SAVES" != true ]] || ./tools/verify-signatures.sh > /dev/null 2>&1 || true # Saved now rather than at the end, so a job that fails later does not refetch the new files. - if: inputs.sources == 'fetch' && steps.sources.outputs.cache-hit != 'true' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4636a06..f451a810 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -150,13 +150,16 @@ jobs: restore-keys: | image-sources-v1- - # Fetches the signatures into the cached set for the gate below; this pass is not the verdict. + # Runs on two hits as well: the root's set restored may be one saved without the links that name + # each image source in sources/ (an older set's), and only this pass makes them. On a miss it + # fetches the signatures into the cached set for the gate below; that pass is not the verdict. - name: Fetch and verify the sources - if: steps.sources.outputs.cache-hit != 'true' || steps.image_sources.outputs.cache-hit != 'true' + env: + MISSED: ${{ steps.sources.outputs.cache-hit != 'true' || steps.image_sources.outputs.cache-hit != 'true' }} run: | make sources ./tools/prune-sources.sh - ./tools/verify-signatures.sh > /dev/null 2>&1 || true + [[ "$MISSED" != true ]] || ./tools/verify-signatures.sh > /dev/null 2>&1 || true # Not from a pull request: its cache is scoped to the merge ref and would duplicate the set. - name: Set the software images' sources aside From 3fe566b09eab8a153cdd4a5cbfbaeac0677f7b28 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 03:56:56 -0700 Subject: [PATCH 061/123] The clang layer gives zstd LLVM's tarball on standard input, as zstd skips a file named by a link --- build/images/clang.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/build/images/clang.sh b/build/images/clang.sh index 1ce2db4e..91c682d8 100644 --- a/build/images/clang.sh +++ b/build/images/clang.sh @@ -15,8 +15,9 @@ image_rows=( s_clang_libclang() { local top="LLVM-${V_LLVM}-Linux-X64" dir="/usr/lib/llvm-${V_LLVM%%.*}" mkdir -p "$dir" - # LLVM compresses with a 1 GiB window, past zstd's default limit of 128 MiB. - zstd -d --long=31 -c "${KRYPTIK_SOURCES}/LLVM-${V_LLVM}-Linux-X64.tar.zst" \ + # LLVM compresses with a 1 GiB window, past zstd's default limit of 128 MiB. On standard input, + # as zstd skips a file named by a link, and sources/ names an image's sources by links. + zstd -d --long=31 -c < "${KRYPTIK_SOURCES}/LLVM-${V_LLVM}-Linux-X64.tar.zst" \ | tar --no-same-owner -xf - -C "$dir" --strip-components=1 \ --wildcards "${top}/bin/clang" "${top}/bin/clang-[0-9]*" "${top}/lib/libclang.so*" \ "${top}/lib/clang/*/include/*" From 853957a7e55804fb35b45b47bd28ae40167c9b02 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 04:02:34 -0700 Subject: [PATCH 062/123] Software images are built against the base system up to wlroots, so zone 0's own programs rebuild no image --- .github/workflows/distro.yml | 8 +++--- build/stages/04-base-system.sh | 3 +++ build/stages/04-images.sh | 41 +++++++++++++++++++++++------ docs/design/browser-and-graphics.md | 8 +++--- docs/design/software-delivery.md | 9 ++++++- 5 files changed, 54 insertions(+), 15 deletions(-) diff --git a/.github/workflows/distro.yml b/.github/workflows/distro.yml index 81ddfda7..574960bb 100644 --- a/.github/workflows/distro.yml +++ b/.github/workflows/distro.yml @@ -332,13 +332,15 @@ jobs: if: always() run: rm -f /mnt/kryptik/work-after-05.tar.zst - # The images built over this very base system, from an earlier run: make images keeps each whose - # fingerprint still matches its inputs and builds the rest. Not for a tag, which builds from nothing. + # The images built over this base system, up to the last step they build against (IMAGES_BASE in + # 04-images.sh), from an earlier run: make images keeps each whose fingerprint still matches its + # inputs and builds the rest. Not for a tag, which builds from nothing. - name: Which base system the images are built over id: images_key if: github.ref_type != 'tag' run: | - base="$(sudo sed -n 's/^fingerprint: //p' "$KRYPTIK_WORK/.stamps/bs-licences" | cut -c1-16)" + step="$(sed -n 's/^IMAGES_BASE="\([a-z0-9-]*\)"$/\1/p' build/stages/04-images.sh)" + base="$(sudo sed -n 's/^fingerprint: //p' "$KRYPTIK_WORK/.stamps/bs-${step:-none}" | cut -c1-16)" if [ -n "$base" ]; then echo "prefix=images-v1-${{ needs.toolchain.outputs.ttfp }}-${base}-" >> "$GITHUB_OUTPUT"; fi - name: Software images from an earlier run (cached) diff --git a/build/stages/04-base-system.sh b/build/stages/04-base-system.sh index 7857c92a..f5a84c79 100755 --- a/build/stages/04-base-system.sh +++ b/build/stages/04-base-system.sh @@ -178,6 +178,9 @@ PACKAGES=( "hwdata" "s_hwdata" "libdisplay-info" "meson_build libdisplay-info-${V_LIBDISPLAY_INFO}.tar.xz libdisplay-info-${V_LIBDISPLAY_INFO}" "wlroots" "s_wlroots" + # Software images build against the base system up to here (IMAGES_BASE in 04-images.sh). What + # follows is zone 0's own, which no image links or runs while it builds, so changing it rebuilds + # no image: a library an image needs goes above this line. # dwl's three inputs are digests, so editing any of them rebuilds it. "dwl" "s_dwl $(sha256_of "${KRYPTIK_ROOT}/build/desktop/dwl-config.h" 2>/dev/null || echo none) $(sha256_of "${KRYPTIK_ROOT}/build/desktop/zone-colours.h" 2>/dev/null || echo none) $(sha256_of "${KRYPTIK_ROOT}/tools/desktop/dwl-zone-borders.py" 2>/dev/null || echo none)" "havoc" "s_havoc" diff --git a/build/stages/04-images.sh b/build/stages/04-images.sh index e6566851..6a56b27d 100755 --- a/build/stages/04-images.sh +++ b/build/stages/04-images.sh @@ -13,6 +13,10 @@ source "$(dirname "${BASH_SOURCE[0]}")/../lib/common.sh" load_config DEFS="${KRYPTIK_ROOT}/build/images" +# The last stage 04 step an image builds against. The base system up to it is in every image's +# inputs; the steps after it, zone 0's own programs and applications, which no image links or runs +# while it builds, are not, so changing them rebuilds no image. +IMAGES_BASE="wlroots" SYSROOT="${KRYPTIK_WORK}/sysroot" CHROOTD="${KRYPTIK_ROOT}/build/stages/03-chroot-prep.sh" @@ -24,18 +28,35 @@ image_setting() { ( source "${DEFS}/${1}.sh"; declare -n v="$2"; printf '%s' "${v[*]:-}" ) } -# What an image is built from: its definition and files, the recipes and flags, the pins, the base -# system, and what the images it is built over are built from, so a cached image needs none of them -# built to be found current. CHAIN is the images it is being read under. +# Each row's recipe as stage 04 fingerprints a step: its code and every helper it reaches, wherever +# that is defined, its arguments, the sources it names and the V_* it reads. +image_rows_of() { # image_rows_of NAME + ( + # shellcheck source=/dev/null + source "${KRYPTIK_ROOT}/build/lib/recipes.sh" + # shellcheck source=/dev/null + source "${DEFS}/${1}.sh" + # shellcheck disable=SC2154 # image_rows comes from the image's definition + for ((i = 0; i < ${#image_rows[@]}; i += 2)); do + # shellcheck disable=SC2086 # a recipe is a word-split command, as the chroot runs it + printf 'row %s %s\n' "${image_rows[i]}" "$(recipe_fingerprint ${image_rows[i+1]})" + done + ) +} + +# What an image is built from: its definition and files, the shared recipe helpers and the flags, +# its pins, its rows' recipes, the base system up to IMAGES_BASE, and what the images it is built +# over are built from, so a cached image needs none of them built to be found current. CHAIN is the +# images it is being read under. image_inputs() { # image_inputs NAME [CHAIN] local f dep h chain="${2:-} ${1}" - for f in "${DEFS}/${1}.sh" "${DEFS}/${1}"/* "${KRYPTIK_ROOT}/build/lib/recipes.sh" "${KRYPTIK_ROOT}"/build/recipes/*.sh \ + for f in "${DEFS}/${1}.sh" "${DEFS}/${1}"/* "${KRYPTIK_ROOT}/build/lib/recipes.sh" \ "${BASH_SOURCE[0]}" "${KRYPTIK_ROOT}/tools/check-image-tree.sh" "${KRYPTIK_ROOT}/build/config/hardening.env" \ - "${KRYPTIK_ROOT}/build/config/hardening-exceptions.txt" "${KRYPTIK_ROOT}/build/config/versions.env" \ - "${KRYPTIK_ROOT}/sources.lock" "${DEFS}/versions.env" "${DEFS}/sources.lock"; do + "${KRYPTIK_ROOT}/build/config/hardening-exceptions.txt" "${DEFS}/versions.env" "${DEFS}/sources.lock"; do if [[ -f "$f" ]]; then printf '%s %s\n' "${f#"${KRYPTIK_ROOT}/"}" "$(sha256_of "$f")"; fi done - printf 'base %s\n' "$(_stamp_read "${KRYPTIK_WORK}/.stamps/bs-licences")" + image_rows_of "$1" + printf 'base %s %s\n' "$IMAGES_BASE" "$(_stamp_read "${KRYPTIK_WORK}/.stamps/bs-${IMAGES_BASE}")" for dep in $(image_setting "$1" image_over); do [[ " ${chain} " != *" ${dep} "* ]] || die "image ${dep} is built over itself:${chain} ${dep}" [[ -f "${DEFS}/${dep}.sh" ]] || die "image ${1} is built over ${dep}, which has no ${DEFS}/${dep}.sh" @@ -65,6 +86,8 @@ build_image() { # build_image NAME read -ra over <<< "$(image_setting "$name" image_over)" want="$(image_inputs "$name" | sha256_of_stdin)" if [[ -d "${top}/tree/usr" ]] && grep -qx "fingerprint: ${want}" "${top}/record" 2>/dev/null; then + # The root past IMAGES_BASE can change under a kept tree, which still may hold no file of the root's. + "${KRYPTIK_ROOT}/tools/check-image-tree.sh" "$SYSROOT" "${top}/tree" dim " skip ${name} ${version} (already built, inputs unchanged)" return 0 fi @@ -102,6 +125,7 @@ build_images() { # build_images [NAME...] [[ "$EUID" -eq 0 ]] || die "the image stage mounts an overlay over the sysroot: run it as root (make images)" require_outside_chroot "the image stage" [[ -f "${KRYPTIK_WORK}/.stamps/bs-licences" ]] || die "stage 04 has not finished: make system first" + [[ -f "${KRYPTIK_WORK}/.stamps/bs-${IMAGES_BASE}" ]] || die "stage 04 has no step ${IMAGES_BASE}, the last one images build against (IMAGES_BASE)" trap '[[ -z "$MOUNTED" ]] || unmount_image "$MOUNTED"' EXIT local names=("$@") d n if [[ "${#names[@]}" -eq 0 ]]; then @@ -139,7 +163,8 @@ umask 022 source "${KRYPTIK_ROOT}/build/lib/recipes.sh" # shellcheck source=/dev/null source "${DEFS}/${name}.sh" -# Stage 04's last build step: a rebuilt base system rebuilds every image. +# Stage 04 finished: the rows run over the whole base system. Whether an image is built again at all +# is image_inputs' call, on the host. stage_depends_on "bs-" licences # shellcheck disable=SC2154 # image_rows comes from the image's definition for ((i = 0; i < ${#image_rows[@]}; i += 2)); do diff --git a/docs/design/browser-and-graphics.md b/docs/design/browser-and-graphics.md index d7a5ad46..cfc352ae 100644 --- a/docs/design/browser-and-graphics.md +++ b/docs/design/browser-and-graphics.md @@ -190,9 +190,11 @@ floors are given with each layer. half hours. That fits the system job's six hours after a warm stage 04, but not every run should pay it. - The trees of the shipped images go into a cache of their own, keyed by - their fingerprints, which include the base system's stamp. A run - restores a tree whose inputs have not changed, so only a change to the - browser, the toolkit or the base system rebuilds it. + their fingerprints, which include the base system's stamp up to + `wlroots`, the last step an image builds against. A run restores a tree + whose inputs have not changed, so only a change to the browser, the + toolkit or the base system up to `wlroots` rebuilds it; zone 0's own + programs, which stage 04 builds after it, do not. - **Updates.** ESR has a security release every four weeks. Its pins are the images' (`build/images/versions.env`), so a bump rebuilds the browser and leaves stage 04 and its cache alone. diff --git a/docs/design/software-delivery.md b/docs/design/software-delivery.md index 0eb6b136..29a33bae 100644 --- a/docs/design/software-delivery.md +++ b/docs/design/software-delivery.md @@ -205,7 +205,14 @@ up ADR-001 in the zones where the applications run. toolchain only builds use, is never packed or listed. `image_over` is not followed through: an image built over one that is itself built over others names those too, and a zone names every image of the chain, as each image's - `requires:` line is checked on its own. + `requires:` line is checked on its own. An image is built again when what + it is built from changes: its definition, the shared recipe helpers and + flags, its rows' recipes as stage 04 fingerprints a step, the images under + it, and the base system up to `wlroots`, the last step an image builds + against (`IMAGES_BASE`). Zone 0's own programs and applications come after + it in stage 04, so changing them rebuilds no image, and a kept image's tree + is checked against the root again. A release builds every image from + nothing. - **Packed.** Stage 06 makes `images-/.img` the way it makes the root (ext4 without a journal, then the verity tree, with a salt and UUID derived from the content), runs the root's artifact audit From b5aa8176e87c481d0f0668a5c33b0fcf62223fe5 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 04:10:40 -0700 Subject: [PATCH 063/123] An image's inputs name the sysroot's headers, libraries and pkg-config files, and the manifest rows its licences are read by --- build/stages/04-base-system.sh | 3 ++- build/stages/04-images.sh | 18 +++++++++++++++--- docs/design/software-delivery.md | 11 ++++++----- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/build/stages/04-base-system.sh b/build/stages/04-base-system.sh index f5a84c79..78e89703 100755 --- a/build/stages/04-base-system.sh +++ b/build/stages/04-base-system.sh @@ -180,7 +180,8 @@ PACKAGES=( "wlroots" "s_wlroots" # Software images build against the base system up to here (IMAGES_BASE in 04-images.sh). What # follows is zone 0's own, which no image links or runs while it builds, so changing it rebuilds - # no image: a library an image needs goes above this line. + # no image. A library, header or pkg-config file goes above this line: one added below rebuilds + # every image all the same, as the images' inputs list those the sysroot has. # dwl's three inputs are digests, so editing any of them rebuilds it. "dwl" "s_dwl $(sha256_of "${KRYPTIK_ROOT}/build/desktop/dwl-config.h" 2>/dev/null || echo none) $(sha256_of "${KRYPTIK_ROOT}/build/desktop/zone-colours.h" 2>/dev/null || echo none) $(sha256_of "${KRYPTIK_ROOT}/tools/desktop/dwl-zone-borders.py" 2>/dev/null || echo none)" "havoc" "s_havoc" diff --git a/build/stages/04-images.sh b/build/stages/04-images.sh index 6a56b27d..dd828f96 100755 --- a/build/stages/04-images.sh +++ b/build/stages/04-images.sh @@ -44,10 +44,20 @@ image_rows_of() { # image_rows_of NAME ) } +# The names of what a build can find in the sysroot: headers, libraries and pkg-config files. A +# configure check or a meson feature left on auto finds one a step past IMAGES_BASE added as surely +# as one before it, so adding or removing one rebuilds every image; the base stamp covers the rest. +link_surface() { + ( cd "$SYSROOT" && find usr/include usr/lib usr/share/pkgconfig \( -type f -o -type l \) \ + \( -path 'usr/include/*' -o -name '*.so' -o -name '*.so.*' -o -name '*.a' -o -name '*.pc' \) 2>/dev/null ) \ + | LC_ALL=C sort | sha256_of_stdin +} + # What an image is built from: its definition and files, the shared recipe helpers and the flags, -# its pins, its rows' recipes, the base system up to IMAGES_BASE, and what the images it is built -# over are built from, so a cached image needs none of them built to be found current. CHAIN is the -# images it is being read under. +# its pins and the manifest rows its licences are read by, its rows' recipes, the base system up to +# IMAGES_BASE and the sysroot's headers and libraries, and what the images it is built over are +# built from, so a cached image needs none of them built to be found current. CHAIN is the images it +# is being read under. image_inputs() { # image_inputs NAME [CHAIN] local f dep h chain="${2:-} ${1}" for f in "${DEFS}/${1}.sh" "${DEFS}/${1}"/* "${KRYPTIK_ROOT}/build/lib/recipes.sh" \ @@ -55,8 +65,10 @@ image_inputs() { # image_inputs NAME [CHAIN] "${KRYPTIK_ROOT}/build/config/hardening-exceptions.txt" "${DEFS}/versions.env" "${DEFS}/sources.lock"; do if [[ -f "$f" ]]; then printf '%s %s\n' "${f#"${KRYPTIK_ROOT}/"}" "$(sha256_of "$f")"; fi done + printf 'sources %s\n' "$("${KRYPTIK_ROOT}/tools/fetch-sources.sh" --list-image "$1" | sha256_of_stdin)" image_rows_of "$1" printf 'base %s %s\n' "$IMAGES_BASE" "$(_stamp_read "${KRYPTIK_WORK}/.stamps/bs-${IMAGES_BASE}")" + printf 'link surface %s\n' "$(link_surface)" for dep in $(image_setting "$1" image_over); do [[ " ${chain} " != *" ${dep} "* ]] || die "image ${dep} is built over itself:${chain} ${dep}" [[ -f "${DEFS}/${dep}.sh" ]] || die "image ${1} is built over ${dep}, which has no ${DEFS}/${dep}.sh" diff --git a/docs/design/software-delivery.md b/docs/design/software-delivery.md index 29a33bae..a592839d 100644 --- a/docs/design/software-delivery.md +++ b/docs/design/software-delivery.md @@ -208,11 +208,12 @@ up ADR-001 in the zones where the applications run. `requires:` line is checked on its own. An image is built again when what it is built from changes: its definition, the shared recipe helpers and flags, its rows' recipes as stage 04 fingerprints a step, the images under - it, and the base system up to `wlroots`, the last step an image builds - against (`IMAGES_BASE`). Zone 0's own programs and applications come after - it in stage 04, so changing them rebuilds no image, and a kept image's tree - is checked against the root again. A release builds every image from - nothing. + it, the base system up to `wlroots`, the last step an image builds against + (`IMAGES_BASE`), and the names of the headers, libraries and pkg-config + files the sysroot has, which a build's checks can find wherever stage 04 + put them. Zone 0's own programs and applications come after `wlroots` in + stage 04, so changing them rebuilds no image, and a kept image's tree is + checked against the root again. A release builds every image from nothing. - **Packed.** Stage 06 makes `images-/.img` the way it makes the root (ext4 without a journal, then the verity tree, with a salt and UUID derived from the content), runs the root's artifact audit From 2b5d0f42b98ef9c437bc78c440f4f2765e1a0fe2 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 04:15:29 -0700 Subject: [PATCH 064/123] Firefox's configure finds llvm-objdump beside clang, and an unzip of Python's zipfile, the two programs it requires that the build had not --- build/images/browser.sh | 16 ++++++++++++++++ build/images/clang.sh | 11 +++++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/build/images/browser.sh b/build/images/browser.sh index b8f02c66..f12ab059 100644 --- a/build/images/browser.sh +++ b/build/images/browser.sh @@ -22,6 +22,21 @@ s_browser_firefox() { local src llvm="/usr/lib/llvm-${V_LLVM%%.*}" src="$(unpack "firefox-${V_FIREFOX}esr.source.tar.xz" "firefox-${V_FIREFOX}")" cd "$src" + # Configure requires unzip, which only packaging runs (make_unzip.py: `unzip ARCHIVE`, into the + # current directory) and the base system does not have: Python's zipfile does that, here alone. + cat > kryptik-unzip <<'EOF' +#!/usr/bin/env python3 +import sys, zipfile +args = [a for a in sys.argv[1:] if a not in ("-q", "-o", "-qo", "-oq")] +dest = "." +if len(args) == 3 and args[1] == "-d": + dest = args.pop(2) + args.pop(1) +if len(args) != 1: + sys.exit("kryptik-unzip: only ARCHIVE [-d DIR]: " + " ".join(sys.argv[1:])) +zipfile.ZipFile(args[0]).extractall(dest) +EOF + chmod 0755 kryptik-unzip cat > mozconfig < Date: Tue, 13 Oct 2026 04:16:14 -0700 Subject: [PATCH 065/123] link_surface lists what is there when one of its directories is not --- build/stages/04-images.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/stages/04-images.sh b/build/stages/04-images.sh index dd828f96..c2ec93bc 100755 --- a/build/stages/04-images.sh +++ b/build/stages/04-images.sh @@ -49,7 +49,7 @@ image_rows_of() { # image_rows_of NAME # as one before it, so adding or removing one rebuilds every image; the base stamp covers the rest. link_surface() { ( cd "$SYSROOT" && find usr/include usr/lib usr/share/pkgconfig \( -type f -o -type l \) \ - \( -path 'usr/include/*' -o -name '*.so' -o -name '*.so.*' -o -name '*.a' -o -name '*.pc' \) 2>/dev/null ) \ + \( -path 'usr/include/*' -o -name '*.so' -o -name '*.so.*' -o -name '*.a' -o -name '*.pc' \) 2>/dev/null || true ) \ | LC_ALL=C sort | sha256_of_stdin } From 9c2e9a9528e8fce940f79a610e96c3ed0fd379e1 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 04:21:18 -0700 Subject: [PATCH 066/123] The browser design names its sandbox in a zone as it now is: the namespace probe answered EPERM, and seccomp granted to the zones that run it --- docs/design/browser-and-graphics.md | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/design/browser-and-graphics.md b/docs/design/browser-and-graphics.md index d7a5ad46..5be579b6 100644 --- a/docs/design/browser-and-graphics.md +++ b/docs/design/browser-and-graphics.md @@ -68,21 +68,21 @@ root. The proposed decision is ADR-016 in [decisions](../decisions.md). - **Its sandbox inside a zone.** Firefox confines its content processes with seccomp-bpf, installed with `seccomp(2)` and `SECCOMP_FILTER_FLAG_TSYNC`, and adds a namespace layer (chroot, no network) where it can create user - namespaces. Two things stop it in a zone today: + namespaces. - At start it probes for user namespaces by calling - `clone(CLONE_NEWUSER)` in its own process (`SandboxInfo.cpp`), unless - `MOZ_ASSUME_USER_NS` says the answer. The zone filter kills that call, - and the browser with it. - - `seccomp(2)` is not in the base list, so the call is killed too. - - The fix for the first is general: answer `clone` and `unshare` with - namespace flags with `EPERM`, as the kernel itself answers an unprivileged - caller, instead of killing. Chromium, bubblewrap and Firefox all probe this - way, and an `EPERM` grants nothing. The second is a policy line, - `allow-syscall seccomp`, in the zones that run the browser. Firefox's - filters then stack under the zone's, and the stricter answer wins. Its - namespace layer stays off, the price of no nested user namespaces - (`hardened.fragment`). + `clone(CLONE_NEWUSER)` in its own process (`SandboxInfo.cpp`). The zone + filter answers `clone` and `unshare` with namespace flags with `EPERM`, + as the kernel answers an unprivileged caller, so the probe fails and the + browser starts; Chromium and bubblewrap probe the same way, and an + `EPERM` grants nothing. + - `seccomp(2)` is not in the base list. The browser image needs it + (`build/images/browser.needs`), and the zones that run the browser, + `untrusted`, `personal` and `work`, grant it with + `allow-syscall seccomp`, which `kryptikd check` holds them to. + + Firefox's filters then stack under the zone's, and the stricter answer + wins. Its namespace layer stays off, the price of no nested user + namespaces (`hardened.fragment`). - **Updates.** The extended-support release (ESR) has a security release every four weeks, like the rapid channel, with fewer feature changes. From d1d628b636b33b185c9012d8f2ad297f6723ba82 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 04:31:49 -0700 Subject: [PATCH 067/123] Firefox's configure is given llvm-objdump by name, as it asks only a clang compiler where it is --- build/images/browser.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build/images/browser.sh b/build/images/browser.sh index f12ab059..8698bd0d 100644 --- a/build/images/browser.sh +++ b/build/images/browser.sh @@ -24,6 +24,7 @@ s_browser_firefox() { cd "$src" # Configure requires unzip, which only packaging runs (make_unzip.py: `unzip ARCHIVE`, into the # current directory) and the base system does not have: Python's zipfile does that, here alone. + # And llvm-objdump, which it finds by asking the compiler when that is clang: GCC's, it is named. cat > kryptik-unzip <<'EOF' #!/usr/bin/env python3 import sys, zipfile @@ -59,6 +60,7 @@ ac_add_options --with-libclang-path=${llvm}/lib ac_add_options --with-clang-path=${llvm}/bin/clang mk_add_options MOZ_OBJDIR=@TOPSRCDIR@/obj export UNZIP=${src}/kryptik-unzip +export LLVM_OBJDUMP=${llvm}/bin/llvm-objdump EOF env CC=gcc CXX=g++ MOZBUILD_STATE_PATH="${src}/.mozbuild" MACH_BUILD_PYTHON_NATIVE_PACKAGE_SOURCE=none \ MOZ_NOSPAM=1 ./mach --no-interactive build From d5dc0b23dcef57e909ec48552fb67fbc52c2df30 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 04:33:33 -0700 Subject: [PATCH 068/123] bindgen's libclang is told where GCC is installed, which its own search misses under Kryptik's triple, and a C++ header is parsed with it before the build --- build/images/browser.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/build/images/browser.sh b/build/images/browser.sh index 8698bd0d..d5934ccd 100644 --- a/build/images/browser.sh +++ b/build/images/browser.sh @@ -19,7 +19,11 @@ image_rows=( # no sound until sound reaches zones. The first build keeps no wasm sandbox for its libraries. No debug # symbols: stage 06 strips them anyway, and linking libxul with them would need more than the runner has. s_browser_firefox() { - local src llvm="/usr/lib/llvm-${V_LLVM%%.*}" + local src llvm="/usr/lib/llvm-${V_LLVM%%.*}" gccdir + # bindgen's libclang finds GCC's C++ headers only under triples it knows, and this GCC's is + # x86_64-kryptik-linux-gnu: it is named, and a header parsed here first, not an hour into the build. + gccdir="$(dirname "$(gcc -print-libgcc-file-name)")" + echo '#include ' | "${llvm}/bin/clang" -x c++ -fsyntax-only --gcc-install-dir="$gccdir" - src="$(unpack "firefox-${V_FIREFOX}esr.source.tar.xz" "firefox-${V_FIREFOX}")" cd "$src" # Configure requires unzip, which only packaging runs (make_unzip.py: `unzip ARCHIVE`, into the @@ -61,6 +65,7 @@ ac_add_options --with-clang-path=${llvm}/bin/clang mk_add_options MOZ_OBJDIR=@TOPSRCDIR@/obj export UNZIP=${src}/kryptik-unzip export LLVM_OBJDUMP=${llvm}/bin/llvm-objdump +export BINDGEN_CFLAGS="--gcc-install-dir=${gccdir}" EOF env CC=gcc CXX=g++ MOZBUILD_STATE_PATH="${src}/.mozbuild" MACH_BUILD_PYTHON_NATIVE_PACKAGE_SOURCE=none \ MOZ_NOSPAM=1 ./mach --no-interactive build From b7a81bec0ecde365782053ab98684684ed24dd70 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 04:38:50 -0700 Subject: [PATCH 069/123] The browser's program list follows Firefox 153's packaging manifest, and its telemetry ping sender is left out of what ships --- build/images/browser.programs | 9 +++++---- build/images/browser.strip | 3 +++ 2 files changed, 8 insertions(+), 4 deletions(-) create mode 100644 build/images/browser.strip diff --git a/build/images/browser.programs b/build/images/browser.programs index 12080c59..14b1530a 100644 --- a/build/images/browser.programs +++ b/build/images/browser.programs @@ -1,9 +1,10 @@ -# The browser image's executable files, as build/config/image-programs.txt names the root's. Written -# from Firefox's packaging manifest; the first build checks it. +# The browser image's executable files as it ships, its .strip list's left out, as +# build/config/image-programs.txt names the root's: from Firefox 153's packaging manifest for a +# GTK build on Linux, which has no plugin-container (content processes start from firefox) and no +# ClearKey module. usr/lib/firefox/firefox usr/lib/firefox/firefox-bin usr/lib/firefox/glxtest usr/lib/firefox/vaapitest -usr/lib/firefox/pingsender +usr/lib/firefox/vulkantest usr/lib/firefox/*.so -usr/lib/firefox/gmp-clearkey/*/libclearkey.so diff --git a/build/images/browser.strip b/build/images/browser.strip new file mode 100644 index 00000000..9884fe72 --- /dev/null +++ b/build/images/browser.strip @@ -0,0 +1,3 @@ +# What the browser image never ships to a zone (stage 06's pack_image): the telemetry ping sender, +# which the policies' DisableTelemetry leaves nothing to send. +usr/lib/firefox/pingsender From c587095d23c64859257e3688bbf8771328a34c3d Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 04:39:15 -0700 Subject: [PATCH 070/123] The browser's program list says only what the packaging manifest lists --- build/images/browser.programs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/build/images/browser.programs b/build/images/browser.programs index 14b1530a..01fbbaaf 100644 --- a/build/images/browser.programs +++ b/build/images/browser.programs @@ -1,7 +1,6 @@ # The browser image's executable files as it ships, its .strip list's left out, as -# build/config/image-programs.txt names the root's: from Firefox 153's packaging manifest for a -# GTK build on Linux, which has no plugin-container (content processes start from firefox) and no -# ClearKey module. +# build/config/image-programs.txt names the root's: from Firefox 153's packaging manifest, which +# for a GTK build on Linux lists no plugin-container and no ClearKey module. usr/lib/firefox/firefox usr/lib/firefox/firefox-bin usr/lib/firefox/glxtest From c52e9ff2b0c5b4a12d0542ecdb468c35013bf3b4 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 04:42:51 -0700 Subject: [PATCH 071/123] The roadmap says what of the applications and of software delivery is built: the toolkit, Firefox in three zones, and signed images --- docs/roadmap.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 207f612a..40335907 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -188,11 +188,16 @@ work in zones from a terminal and a text browser, and keep it up to date. A desktop someone can live in, built the same way. -- **Applications.** A graphical browser and the toolkit stack under it, per - zone, with GPU rendering decided zone by zone; a mail client, a document - viewer, a file manager that understands transfers between zones. -- **Software from somewhere.** A package manager and a signed binary - repository, or zones that carry their own userland. An ADR first. +- **Applications.** Built: the GTK toolkit and Firefox ESR over it, in + `untrusted`, `personal` and `work`, its PDF viewer the document viewer + ([browser and graphics](design/browser-and-graphics.md)). Still to come: + GPU rendering decided zone by zone, a mail client, and a file manager that + understands transfers between zones. +- **Software from somewhere.** Built: signed software images (ADR-015), + built over the root and signed with each release, carried in its update + payload and named by the zones that use them + ([software delivery](design/software-delivery.md)). Not decided: a package + manager, or images released apart from the root. - **The laptop.** Per-zone sound brokered like the clipboard, Bluetooth, suspend and resume with volume keys dropped across it, power management, hotplug and multiple monitors, input methods and a second keyboard layout From bba32d05ef80ea77f37fc62e159449381d3c74c7 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 04:44:07 -0700 Subject: [PATCH 072/123] ADR-015 and ADR-016 are accepted: applications ship as signed images, and Firefox runs in zones rendering in software --- docs/decisions.md | 4 ++-- docs/design/browser-and-graphics.md | 8 ++++---- docs/design/installer-choices.md | 4 ++-- docs/design/software-delivery.md | 8 ++++---- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/decisions.md b/docs/decisions.md index 7b40635b..3a4478f2 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -259,7 +259,7 @@ firmware's boot entries and a judged trial, not a loader's menu. file; an initramfs inside the signed image, measured but a second userland to keep small and right. -## ADR-015 (proposed): Applications ship as signed images beside the root +## ADR-015: Applications ship as signed images beside the root The root keeps the base system: what zone 0 runs and what every zone needs. Applications (the browser and its toolkit, a mail client, a document viewer, @@ -292,7 +292,7 @@ ephemeral zones); another distribution's userland in a zone (gives up ADR-001 where the applications run); everything on the root (does not fit the slots of machines already installed). -## ADR-016 (proposed): Firefox in zones, rendering in software; the GPU and X11 by zone +## ADR-016: Firefox in zones, rendering in software; the GPU and X11 by zone The browser is Firefox ESR, built by stage 04's GCC for Wayland alone, on GTK 3 without X11 or D-Bus, and run with its own seccomp sandbox inside the diff --git a/docs/design/browser-and-graphics.md b/docs/design/browser-and-graphics.md index 5be579b6..212d892f 100644 --- a/docs/design/browser-and-graphics.md +++ b/docs/design/browser-and-graphics.md @@ -6,7 +6,7 @@ Today a zone gets a terminal and a text browser, drawn into shared memory and composited on the CPU. This document chooses the browser, the toolkit under it, how a zone is given the GPU and what the proxy must then carry, how Xwayland runs inside a zone (ADR-004), and what all of it costs the -root. The proposed decision is ADR-016 in [decisions](../decisions.md). +root. The decision is ADR-016 in [decisions](../decisions.md). ## Where things stand @@ -45,8 +45,8 @@ root. The proposed decision is ADR-016 in [decisions](../decisions.md). - **ADR-001 and ADR-010.** Built from source here; Rust comes from the pinned release toolchain, as for kryptikd. - **The slot size** of machines already installed (below), and the software - images proposed as ADR-015 in `docs/design/software-delivery.md`, beside - this design. + images of ADR-015 (`docs/design/software-delivery.md`), beside this + design. ## The browser @@ -366,7 +366,7 @@ filesystem and the hash tree. 400 MiB left. Mesa and libLLVM leave about 200 MiB; a mail client after that does not fit, and the release that crosses the line needs a reinstall: a major version under [releases](../releases.md). -- As a software image (ADR-015, proposed), the browser costs the root +- As a software image (ADR-015), the browser costs the root nothing, and the state partition about twice the image while an update is in flight. - **Build time.** Firefox is one of the largest builds there is, and it diff --git a/docs/design/installer-choices.md b/docs/design/installer-choices.md index 505c8345..7a418497 100644 --- a/docs/design/installer-choices.md +++ b/docs/design/installer-choices.md @@ -52,8 +52,8 @@ often a major version happens. The proposed decision is ADR-023 in `--slot-size` covers the user who knows. The default is the question. Image plus half again was right for a root that grew slowly. Version 2 puts more in the root: firmware, Mesa if the compositor moves to the GPU, and the -browser stack itself unless applications ship as images (ADR-015, proposed -in `docs/design/software-delivery.md`). The browser design works out that the +browser stack itself unless applications ship as images (ADR-015, +`docs/design/software-delivery.md`). The browser design works out that the software-rendered browser in the root alone leaves about 400 MiB of today's slot. diff --git a/docs/design/software-delivery.md b/docs/design/software-delivery.md index 0eb6b136..4fe56eb5 100644 --- a/docs/design/software-delivery.md +++ b/docs/design/software-delivery.md @@ -7,8 +7,8 @@ ships whole in every release. Version 2's applications (a graphical browser, a mail client, a document viewer) do not fit that model for long. This document chooses how they arrive: a package manager with a signed binary repository, zones that carry their own userland, or something between. The -[roadmap](../roadmap.md) asks for an ADR first; the proposed text is -ADR-015 in [decisions](../decisions.md). +[roadmap](../roadmap.md) asked for an ADR first: it is ADR-015 in +[decisions](../decisions.md). ## Where things stand @@ -80,8 +80,8 @@ its files are in the root image. - **Policies** are the zone files', as now. - **The slot limit** decides it. The browser and its toolkit stack alone take most of the 800 MiB of headroom a 1.0 install has (the browser and - graphics design, `docs/design/browser-and-graphics.md`, proposed beside - this one, works it out); a mail client, a document viewer and Mesa do not + graphics design, `docs/design/browser-and-graphics.md`, beside this one, + works it out); a mail client, a document viewer and Mesa do not fit after it. - **Other costs:** every build carries every application; every zone sees every application's files, including zones that never run them. From f777ef6587783848312678fe5be84d50fde18878 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 04:45:19 -0700 Subject: [PATCH 073/123] The roadmap's software delivery names the install media and what is enforced when a zone runs: dm-verity, read-only over its /usr --- docs/roadmap.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 40335907..ae44a3c3 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -194,8 +194,9 @@ A desktop someone can live in, built the same way. GPU rendering decided zone by zone, a mail client, and a file manager that understands transfers between zones. - **Software from somewhere.** Built: signed software images (ADR-015), - built over the root and signed with each release, carried in its update - payload and named by the zones that use them + built over the root and signed with each release, carried on its install + media and in its update payload, and opened with dm-verity and stacked + read-only over the `/usr` of the zones that name them ([software delivery](design/software-delivery.md)). Not decided: a package manager, or images released apart from the root. - **The laptop.** Per-zone sound brokered like the clipboard, Bluetooth, From cb5a3db761fef0311eb584431994801d2589889b Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 04:50:21 -0700 Subject: [PATCH 074/123] The build's stages list make images, which a medium needs before make media now that three zones name the browser --- docs/building.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/building.md b/docs/building.md index 9601e9f3..9f1fdad1 100644 --- a/docs/building.md +++ b/docs/building.md @@ -43,9 +43,16 @@ make toolchain # stage 01 (unprivileged) make temp-tools # stage 02 (unprivileged) make system # stage 04, inside the chroot (root for the mounts) make kernel # stage 05, inside the chroot +make images # the software images over stage 04: the toolkit, the browser (hours) make media # stage 06: USB image, ISO, signed release payload ``` +`untrusted`, `personal` and `work` name the browser and toolkit images, and a +zone whose images its root's signed list lacks does not start, so a medium for +use needs `make images` before `make media`. Firefox alone takes some three +hours on four cores; a rebuilt stage 04 rebuilds it only when a step up to +`wlroots` changed ([software delivery](design/software-delivery.md)). + `make paths` prints where everything goes; `KRYPTIK_WORK` moves the work tree. Each step is stamped, so a rerun resumes where it stopped and a changed recipe rebuilds from that step on. From adaf430b96f427bdf04b2572edaa86342ca4c629 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 04:51:17 -0700 Subject: [PATCH 075/123] building.md says when a zone naming an image starts, with or without a list, and the link surface among what rebuilds Firefox --- docs/building.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/building.md b/docs/building.md index 9f1fdad1..ecaa223b 100644 --- a/docs/building.md +++ b/docs/building.md @@ -48,10 +48,11 @@ make media # stage 06: USB image, ISO, signed release payload ``` `untrusted`, `personal` and `work` name the browser and toolkit images, and a -zone whose images its root's signed list lacks does not start, so a medium for -use needs `make images` before `make media`. Firefox alone takes some three -hours on four cores; a rebuilt stage 04 rebuilds it only when a step up to -`wlroots` changed ([software delivery](design/software-delivery.md)). +zone naming an image does not start unless its root has a signed list that +holds it, so a medium for use needs `make images` before `make media`. Firefox +alone takes some three hours on four cores; a rebuilt stage 04 rebuilds it +only when a step up to `wlroots` changed, or the headers and libraries a build +can find ([software delivery](design/software-delivery.md)). `make paths` prints where everything goes; `KRYPTIK_WORK` moves the work tree. Each step is stamped, so a rerun resumes where it stopped and a changed From 95d269e1a32bacc38cf3393ec47624561278b480 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 05:52:38 -0700 Subject: [PATCH 076/123] Firefox plays through ALSA, which its Web MIDI needs anyway, and its policies leave out first-run pages, recommendations and sponsored tiles --- build/images/browser.sh | 33 +++++++++++++++++++++++------ docs/design/browser-and-graphics.md | 7 ++++-- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/build/images/browser.sh b/build/images/browser.sh index d5934ccd..99ae2757 100644 --- a/build/images/browser.sh +++ b/build/images/browser.sh @@ -15,9 +15,11 @@ image_rows=( # Stage 04's GCC and hardening flags; libclang only for bindgen. With no network: Firefox's own vendored # crates and Python packages, no toolchain bootstrap, no Node.js. No D-Bus, which no zone has, no updater -# (updates are the image's), no crash reporter, no EME, no jemalloc (hardened_malloc is preloaded), and -# no sound until sound reaches zones. The first build keeps no wasm sandbox for its libraries. No debug -# symbols: stage 06 strips them anyway, and linking libxul with them would need more than the runner has. +# (updates are the image's), no crash reporter, no EME, no jemalloc (hardened_malloc is preloaded). Sound +# through ALSA, whose default device in a zone is zone 0's mixer; Web MIDI links ALSA's sequencer, which +# reaches no device, as the kernel builds none. The first build keeps no wasm sandbox for its libraries. +# No debug symbols: stage 06 strips them anyway, and linking libxul with them would need more than the +# runner has. s_browser_firefox() { local src llvm="/usr/lib/llvm-${V_LLVM%%.*}" gccdir # bindgen's libclang finds GCC's C++ headers only under triples it knows, and this GCC's is @@ -58,7 +60,7 @@ ac_add_options --disable-updater ac_add_options --disable-crashreporter ac_add_options --disable-eme ac_add_options --disable-jemalloc -ac_add_options --disable-audio-backends +ac_add_options --enable-audio-backends=alsa ac_add_options --without-wasm-sandboxed-libraries ac_add_options --with-libclang-path=${llvm}/lib ac_add_options --with-clang-path=${llvm}/bin/clang @@ -71,8 +73,10 @@ EOF MOZ_NOSPAM=1 ./mach --no-interactive build env MOZBUILD_STATE_PATH="${src}/.mozbuild" ./mach --no-interactive install test -x /usr/lib/firefox/firefox - # Enterprise policies: no telemetry or studies, no update checks (updates are the image's), and - # no question of the default browser, which a zone has no other of. + # Enterprise policies: no telemetry or studies, no update checks (updates are the image's), no + # question of the default browser, which a zone has no other of, and none of the first-run pages, + # recommendations or sponsored tiles that a profile made afresh at each start, as untrusted's is, + # would show every time. install -d -m 0755 /usr/lib/firefox/distribution cat > /usr/lib/firefox/distribution/policies.json <<'EOF' { @@ -80,7 +84,22 @@ EOF "DisableTelemetry": true, "DisableFirefoxStudies": true, "DisableAppUpdate": true, - "DontCheckDefaultBrowser": true + "DontCheckDefaultBrowser": true, + "OverrideFirstRunPage": "", + "OverridePostUpdatePage": "", + "NoDefaultBookmarks": true, + "UserMessaging": { + "ExtensionRecommendations": false, + "FeatureRecommendations": false, + "UrlbarInterventions": false, + "SkipOnboarding": true, + "MoreFromMozilla": false, + "FirefoxLabs": false + }, + "FirefoxHome": { + "SponsoredTopSites": false, + "SponsoredStories": false + } } } EOF diff --git a/docs/design/browser-and-graphics.md b/docs/design/browser-and-graphics.md index d7a5ad46..3e226e34 100644 --- a/docs/design/browser-and-graphics.md +++ b/docs/design/browser-and-graphics.md @@ -180,8 +180,11 @@ floors are given with each layer. `--disable-updater` (updates are the image's) and `--disable-eme`. - `--disable-jemalloc`, as the image preloads hardened_malloc for every process. - - `--disable-audio-backends` until sound reaches zones. The ESR default - is PulseAudio, and ALSA needs alsa-lib and a sound path designed first. + - `--enable-audio-backends=alsa`: a zone's default ALSA device is zone + 0's mixer (the laptop design's sound path). Firefox needs alsa-lib in + any case, as Web MIDI on Linux links ALSA's sequencer API; with no + sequencer in the kernel and no `/dev/snd` in a zone, it reaches no + device. - The first build is `--without-wasm-sandboxed-libraries`. The next adds a WASI sysroot to the `clang` layer, since a font or spelling parser bug should stay inside that sandbox. From d72580fa964ef440977052d27708ef279402601c Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 05:56:05 -0700 Subject: [PATCH 077/123] The pinned keys keep a blank line between the root's and the images' groups --- tools/verify-signatures.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/verify-signatures.sh b/tools/verify-signatures.sh index 8104e301..d00509fa 100755 --- a/tools/verify-signatures.sh +++ b/tools/verify-signatures.sh @@ -260,6 +260,7 @@ PINNED_FPRS=( # https://www.alsa-project.org/wiki/Download (retrieved 2026-10-13) states this fingerprint # and links the key, https://www.alsa-project.org/files/pub/gpg-release-key-v1.txt. "F04DF50737AC1A884C4B3D718380596DA6E59C91" # ALSA Release Team, alsa-lib + # The software images' sources (build/images/versions.env). # https://github.com/PCRE2Project/pcre2/blob/main/SECURITY.md (retrieved 2026-10-13) names it for # 10.45 on, cross-signed by Philip Hazel's earlier key. It signs with a subkey GitHub's copy lacks. From fe1d62968de9f5657ccb173724f282d36571be7f Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 07:22:53 -0700 Subject: [PATCH 078/123] The zones that carry the browser allow it the threads it runs A cgroup's pids.max counts threads as well as processes, and Firefox runs a content process for each site open, each with dozens of threads, beside a parent with about a hundred: 512 in untrusted and 1024 in personal and work run out at a few tens of tabs, and a thread Firefox cannot start crashes what asked for it. untrusted goes to 2048, still under the 3000 forks the zones suite's storm makes, and personal and work to 4096; memory_max still bounds what the tasks hold. --- compartments/zones/personal.toml | 3 ++- compartments/zones/untrusted.toml | 3 ++- compartments/zones/work.toml | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/compartments/zones/personal.toml b/compartments/zones/personal.toml index f9f7c739..5e1ece5c 100644 --- a/compartments/zones/personal.toml +++ b/compartments/zones/personal.toml @@ -16,7 +16,8 @@ seccomp = "policy/personal.seccomp" [limits] memory_max = "8G" -pids_max = 1024 +# Threads count too: Firefox runs a process for each site, each with dozens of threads. +pids_max = 4096 [identity] # Root inside maps to host uid/gid 262144, nobody to 327678; fixed, never derived from zone order. diff --git a/compartments/zones/untrusted.toml b/compartments/zones/untrusted.toml index ff4783c9..e7628150 100644 --- a/compartments/zones/untrusted.toml +++ b/compartments/zones/untrusted.toml @@ -20,7 +20,8 @@ seccomp = "policy/untrusted.seccomp" [limits] memory_max = "4G" -pids_max = 512 +# Threads count too: Firefox runs a process for each site, each with dozens of threads. +pids_max = 2048 # Two CPUs' worth of time: what runs here cannot take the whole machine. cpu_max = "200%" diff --git a/compartments/zones/work.toml b/compartments/zones/work.toml index fe087ad3..4416a76e 100644 --- a/compartments/zones/work.toml +++ b/compartments/zones/work.toml @@ -16,7 +16,8 @@ seccomp = "policy/work.seccomp" [limits] memory_max = "8G" -pids_max = 1024 +# Threads count too: Firefox runs a process for each site, each with dozens of threads. +pids_max = 4096 [identity] # Root inside maps to host uid/gid 458752, nobody to 524286; fixed, never derived from zone order. From 07cd4f486afeac857a3f3037d7d7f8a9e57dbc57 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 07:34:14 -0700 Subject: [PATCH 079/123] Firefox's build has Python's sqlite3 module, which its Glean step needs and the base system's Python lacks The build's Glean step caches the metrics it parses with diskcache, which keeps them in SQLite through Python's sqlite3, and the base system's Python is built without it, as zone 0 has no SQLite. The module is compiled from that Python's own source and the SQLite Firefox carries, into the build root alone; browser.strip keeps it out of what ships. --- build/images/browser.sh | 12 ++++++++++++ build/images/browser.strip | 2 ++ 2 files changed, 14 insertions(+) diff --git a/build/images/browser.sh b/build/images/browser.sh index 99ae2757..900734ee 100644 --- a/build/images/browser.sh +++ b/build/images/browser.sh @@ -28,6 +28,18 @@ s_browser_firefox() { echo '#include ' | "${llvm}/bin/clang" -x c++ -fsyntax-only --gcc-install-dir="$gccdir" - src="$(unpack "firefox-${V_FIREFOX}esr.source.tar.xz" "firefox-${V_FIREFOX}")" cd "$src" + # The build's Glean step caches what it parses through Python's sqlite3, and the base system's + # Python has none, as zone 0 has no SQLite: the module is built from that Python's own source + # and Firefox's copy of SQLite, for this build alone (browser.strip keeps it out of the image). + local py pysrc + py="$(python3 -c 'import platform; print(platform.python_version())')" + pysrc="$(unpack "Python-${py}.tar.xz" "Python-${py}")" + gcc -O2 -fPIC -shared -DSQLITE_OMIT_LOAD_EXTENSION \ + -I"$(python3 -c 'import sysconfig; print(sysconfig.get_path("include"))')" -I"${src}/third_party/sqlite3/src" \ + "${pysrc}"/Modules/_sqlite/*.c "${src}/third_party/sqlite3/src/sqlite3.c" \ + -o "$(python3 -c 'import sysconfig; print(sysconfig.get_path("platstdlib") + "/lib-dynload/_sqlite3" + sysconfig.get_config_var("EXT_SUFFIX"))')" + rm -rf "$pysrc" + python3 -c 'import sqlite3; sqlite3.connect(":memory:").execute("create table t (x)")' # Configure requires unzip, which only packaging runs (make_unzip.py: `unzip ARCHIVE`, into the # current directory) and the base system does not have: Python's zipfile does that, here alone. # And llvm-objdump, which it finds by asking the compiler when that is clang: GCC's, it is named. diff --git a/build/images/browser.strip b/build/images/browser.strip index 9884fe72..41e58a12 100644 --- a/build/images/browser.strip +++ b/build/images/browser.strip @@ -1,3 +1,5 @@ # What the browser image never ships to a zone (stage 06's pack_image): the telemetry ping sender, # which the policies' DisableTelemetry leaves nothing to send. usr/lib/firefox/pingsender +# Python's sqlite3 module, which the build's Glean step alone needs (browser.sh). +usr/lib/python3*/lib-dynload/_sqlite3* From e841905be00112bcb2ef8133ea5bf0256211c9a2 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 07:50:04 -0700 Subject: [PATCH 080/123] The software images build in a job of their own, between stages 04-05 and stage 06 A runner gives a job six hours. Firefox alone takes three or more on four cores, so beside a stage 04 built from nothing (a tag's, or a run after a new pin) and stage 06 it would not fit the one job they shared. Stages 04-05 hand their tree to the next jobs as an artifact, as their cache keeps it and never with the module signing key, which stage 05 makes anew in the stage 06 job, as after a cache restore. The images job hands each image's tree and record on. Stage 06 keeps the job id the signing, acceptance and verdict jobs name, and no longer passes the Rust binaries, which only stage 04's chroot binds. The images step's own limit goes from 240 to 330 minutes. --- .github/workflows/distro.yml | 141 ++++++++++++++++++++++++++++++++--- README.md | 7 +- 2 files changed, 133 insertions(+), 15 deletions(-) diff --git a/.github/workflows/distro.yml b/.github/workflows/distro.yml index 574960bb..1a0ac4a6 100644 --- a/.github/workflows/distro.yml +++ b/.github/workflows/distro.yml @@ -185,8 +185,8 @@ jobs: retention-days: 7 if-no-files-found: ignore - system: - name: Stages 04-06 (base system, kernel, media) + base: + name: Stages 04-05 (base system, kernel) needs: toolchain runs-on: ubuntu-24.04 timeout-minutes: 360 @@ -195,7 +195,6 @@ jobs: - uses: ./.github/actions/prepare with: sources: fetch - image-sources: fetch # One root-made tarball, keyed on the toolchain, the pins and stage 03; a change to any restarts stage 04 (about 45 minutes). - name: Stage 04-05 sysroot and kernel (cached) @@ -328,10 +327,68 @@ jobs: path: /mnt/kryptik/work-after-05.tar.zst key: ${{ steps.progress05.outputs.prefix }}${{ github.run_id }}-${{ github.run_attempt }} - - name: Drop the cache tarball before the media are built + - name: Drop the cache tarball if: always() run: rm -f /mnt/kryptik/work-after-05.tar.zst + # The next jobs' tree, as the cache keeps it, with the logs. Never the module signing key, as anyone + # who can read the repository can read an artifact: the stage 06 job's stage 05 makes a new one. + - name: Pack the stage 04-05 tree for the next jobs + run: | + cd /mnt/kryptik + members=(.stamps sysroot logs) + kernel_tree="build/linux-$(sed -n 's/^V_LINUX=//p' "$GITHUB_WORKSPACE/build/config/versions.env")" + [ -d "work/$kernel_tree" ] && members+=("$kernel_tree") + sudo tar -I 'zstd -T0' -cf work-after-05.tar.zst -C work --anchored \ + --exclude='sysroot/proc/*' --exclude='sysroot/sys/*' --exclude='sysroot/dev/*' \ + --exclude='sysroot/run/*' --exclude='sysroot/kryptik*/*' \ + --exclude="$kernel_tree/certs/signing_key.pem" "${members[@]}" + if sudo tar -I zstd -tf work-after-05.tar.zst | grep -E 'certs/signing_key\.pem$|keys/release/'; then echo "a private key is in the tarball"; exit 1; fi + sudo chown "$(id -u):$(id -g)" work-after-05.tar.zst + ls -la work-after-05.tar.zst + + - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: work-after-05 + path: /mnt/kryptik/work-after-05.tar.zst + retention-days: 1 + compression-level: 0 + + - name: Stage logs + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: logs-base + path: /mnt/kryptik/work/logs + retention-days: 7 + if-no-files-found: ignore + + # The software images in a job of their own: Firefox alone takes hours, which beside a stage 04 built + # from nothing (a tag's, or a new pin's) and stage 06 would not fit one job's six. + images: + name: Software images (the toolkit, the browser) + needs: [toolchain, base] + runs-on: ubuntu-24.04 + timeout-minutes: 360 + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + - uses: ./.github/actions/prepare + with: + sources: fetch + image-sources: fetch + + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: work-after-05 + path: /mnt/kryptik + + - name: Unpack the stage 04-05 tree + run: | + cd /mnt/kryptik + sudo tar --zstd -xf work-after-05.tar.zst -C work + rm -f work-after-05.tar.zst + ls work; sudo du -sh work/sysroot + # The images built over this base system, up to the last step they build against (IMAGES_BASE in # 04-images.sh), from an earlier run: make images keeps each whose fingerprint still matches its # inputs and builds the rest. Not for a tag, which builds from nothing. @@ -361,11 +418,11 @@ jobs: rm -f images-cache.tar.zst sudo sed -n 's/^\(name\|fingerprint\): //p' work/image-build/*/record | paste - - || true - # After the stage 04-05 cache is packed, so no image tree or overlay reaches it; stage 06 packs what this builds. - # The browser takes some three hours when its tree is not cached; the toolkit some minutes. + # Stage 06 packs what this builds. The browser takes some three hours or more when its tree is not + # cached, the toolkit some minutes. - name: Software images (over the base system, in the chroot) id: images - timeout-minutes: 240 + timeout-minutes: 330 run: make -o system images # Each image's tree and record, never an overlay's upper or work directory; saved when they changed. @@ -392,17 +449,77 @@ jobs: path: /mnt/kryptik/images-cache.tar.zst key: ${{ steps.images_pack.outputs.key }} - - name: Drop the images' tarball before the media are built + - name: Drop the images' tarball if: always() run: rm -f /mnt/kryptik/images-cache.tar.zst + # Each image's tree and record for stage 06, cached or not, and the stamps and logs the build left. + - name: Pack the images for the next job + run: | + cd /mnt/kryptik + sudo tar -I 'zstd -T0' -cf images-tree.tar.zst -C work --exclude='image-build/*/upper' \ + --exclude='image-build/*/overlay-work' --exclude='image-build/*/sysroot' image-build .stamps logs + sudo chown "$(id -u):$(id -g)" images-tree.tar.zst + ls -la images-tree.tar.zst + + - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: images-tree + path: /mnt/kryptik/images-tree.tar.zst + retention-days: 1 + compression-level: 0 + + - name: Stage logs + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: logs-images + path: /mnt/kryptik/work/logs + retention-days: 7 + if-no-files-found: ignore + + system: + name: Stage 06 (the media) + needs: [base, images] + runs-on: ubuntu-24.04 + timeout-minutes: 360 + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + - uses: ./.github/actions/prepare + with: + sources: restore + image-sources: restore + + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: work-after-05 + path: /mnt/kryptik + + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: images-tree + path: /mnt/kryptik + + - name: Unpack the stage 04-05 tree and the images + run: | + cd /mnt/kryptik + sudo tar --zstd -xf work-after-05.tar.zst -C work + sudo tar --zstd -xf images-tree.tar.zst -C work + rm -f work-after-05.tar.zst images-tree.tar.zst + ls work; sudo du -sh work/sysroot work/image-build + + # The tree came without its module signing key, so stage 05 builds and signs again with a new one, + # as after a cache restore; stage 06 relinks the kernel from that tree. + - name: Stage 05, keyed afresh + timeout-minutes: 90 + run: make -o system kernel + # The throwaway production pair, then A and B; a production release is only bound here, where upstream build scripts ran as root. - name: Stage 06, the media (production 1.0.0 and 1.0.1, release A and the update B) id: media timeout-minutes: 90 run: | - bins=(KRYPTIK_KRYPTIKD_BIN=/mnt/kryptik/kryptikd-musl KRYPTIK_WLPROXY_BIN=/mnt/kryptik/kryptik-wlproxy-musl) - make production-pair "${bins[@]}" + make production-pair ls -la "$KRYPTIK_WORK/images-production" # A tag's version decides its role; elsewhere the dispatch input does. ROLE=development @@ -417,7 +534,7 @@ jobs: echo "a ${ROLE} release: ${VER_B}, updating from ${VER_A}" echo "role=${ROLE}" >> "$GITHUB_OUTPUT" if [ "$ROLE" = development ]; then - for v in "$VER_A" "$VER_B"; do make media KRYPTIK_VERSION="$v" "${bins[@]}"; done + for v in "$VER_A" "$VER_B"; do make media KRYPTIK_VERSION="$v"; done ls -la "$KRYPTIK_WORK/images" | grep -E 'usb.img|\.iso|payload' exit 0 fi @@ -436,7 +553,7 @@ jobs: sudo rm -rf "$KRYPTIK_WORK/images" sudo find "$KRYPTIK_WORK/.stamps" -maxdepth 1 -name 'img-*' -delete make media KRYPTIK_MEDIA_PHASE=bind KRYPTIK_ROLE=production KRYPTIK_KEYS="$pub" \ - KRYPTIK_VERSION="$v" KRYPTIK_CHANNEL="$channel" "${bins[@]}" + KRYPTIK_VERSION="$v" KRYPTIK_CHANNEL="$channel" sudo mkdir -p "$KRYPTIK_WORK/bound/$v/stamps" sudo mv "$KRYPTIK_WORK/images" "$KRYPTIK_WORK/bound/$v/images" sudo cp -a "$KRYPTIK_WORK/.stamps"/img-* "$KRYPTIK_WORK/bound/$v/stamps/" diff --git a/README.md b/README.md index 924591d2..2df92970 100644 --- a/README.md +++ b/README.md @@ -325,9 +325,10 @@ docs/ Architecture, threat model, decisions, status ## Building The `Distro` workflow (`.github/workflows/distro.yml`) builds and tests -everything on GitHub's runners: stages 01–02, then stages 04–06, then -`make acceptance` under KVM, uploading the acceptance report and the tested -images. A tag `v` builds that version from nothing, tests it and +everything on GitHub's runners: stages 01–02, stages 04–05, the software +images and stage 06, each a job of its own (a runner gives a job six hours, +and Firefox alone takes several), then `make acceptance` under KVM, +uploading the acceptance report and the tested images. A tag `v` builds that version from nothing, tests it and drafts it on the Releases page; from `v1` on, the build signs with the release keys once the maintainer approves it. To build locally, on Linux: From 751f30b78b309264181dfe4c3ac6c361198f00b1 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 07:53:34 -0700 Subject: [PATCH 081/123] The browser's design says the images build in a job of their own, and why the build makes Python's sqlite3 --- docs/design/browser-and-graphics.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/design/browser-and-graphics.md b/docs/design/browser-and-graphics.md index 0c234797..5e0b124c 100644 --- a/docs/design/browser-and-graphics.md +++ b/docs/design/browser-and-graphics.md @@ -188,10 +188,17 @@ floors are given with each layer. - The first build is `--without-wasm-sandboxed-libraries`. The next adds a WASI sysroot to the `clang` layer, since a font or spelling parser bug should stay inside that sandbox. + - The build's Glean step caches what it parses in SQLite, through + Python's `sqlite3`, which the base system's Python lacks, as zone 0 has + no SQLite. The recipe compiles that module from the base system's own + Python source and the SQLite Firefox carries, into the build's root + alone, and `browser.strip` keeps it out of the image. - **Time.** - - On four cores with GCC, without LTO or PGO, ESR takes about two and a - half hours. That fits the system job's six hours after a warm stage 04, - but not every run should pay it. + - On four cores with GCC, without LTO or PGO, ESR takes some hours. The + images build in a job of their own, between stages 04-05 and stage 06, + since a runner gives a job six hours and a stage 04 built from nothing + (a tag's, or a run after a new pin) beside the browser and stage 06 + would not fit one job. Not every run should pay it. - The trees of the shipped images go into a cache of their own, keyed by their fingerprints, which include the base system's stamp up to `wlroots`, the last step an image builds against. A run restores a tree From a37348ee10c9c93ac4d0157a168d9e6a0c81d0cd Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 08:10:56 -0700 Subject: [PATCH 082/123] The handed-on trees keep a week on a tag, the build-only layers' trees stay out, and stage 06 restores no image sources A tag's stage 06 job may be re-run after its owner's approval, a day or more later, so its two artifacts keep a week as work-bound does. Stage 06 reads only the record of a layer that does not ship, so the images job leaves those trees out of what it hands on. Stage 06 reads no source file, so its job restores the root's sources alone. --- .github/workflows/distro.yml | 13 +++++++++---- README.md | 7 ++++--- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/.github/workflows/distro.yml b/.github/workflows/distro.yml index 1a0ac4a6..bd70db3f 100644 --- a/.github/workflows/distro.yml +++ b/.github/workflows/distro.yml @@ -351,7 +351,8 @@ jobs: with: name: work-after-05 path: /mnt/kryptik/work-after-05.tar.zst - retention-days: 1 + # A tag's stage 06 job may be re-run after its owner's approval, so it keeps a week. + retention-days: ${{ github.ref_type == 'tag' && 7 || 1 }} compression-level: 0 - name: Stage logs @@ -454,11 +455,16 @@ jobs: run: rm -f /mnt/kryptik/images-cache.tar.zst # Each image's tree and record for stage 06, cached or not, and the stamps and logs the build left. + # A layer that does not ship keeps only its record: stage 06 reads no more of it. - name: Pack the images for the next job run: | cd /mnt/kryptik + layers=() + for rec in work/image-build/*/record; do + if [ "$(sudo sed -n 's/^ships: //p' "$rec")" = no ]; then layers+=(--exclude="image-build/$(basename "$(dirname "$rec")")/tree"); fi + done sudo tar -I 'zstd -T0' -cf images-tree.tar.zst -C work --exclude='image-build/*/upper' \ - --exclude='image-build/*/overlay-work' --exclude='image-build/*/sysroot' image-build .stamps logs + --exclude='image-build/*/overlay-work' --exclude='image-build/*/sysroot' "${layers[@]}" image-build .stamps logs sudo chown "$(id -u):$(id -g)" images-tree.tar.zst ls -la images-tree.tar.zst @@ -466,7 +472,7 @@ jobs: with: name: images-tree path: /mnt/kryptik/images-tree.tar.zst - retention-days: 1 + retention-days: ${{ github.ref_type == 'tag' && 7 || 1 }} compression-level: 0 - name: Stage logs @@ -488,7 +494,6 @@ jobs: - uses: ./.github/actions/prepare with: sources: restore - image-sources: restore - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: diff --git a/README.md b/README.md index 2df92970..4a89dc21 100644 --- a/README.md +++ b/README.md @@ -328,9 +328,10 @@ The `Distro` workflow (`.github/workflows/distro.yml`) builds and tests everything on GitHub's runners: stages 01–02, stages 04–05, the software images and stage 06, each a job of its own (a runner gives a job six hours, and Firefox alone takes several), then `make acceptance` under KVM, -uploading the acceptance report and the tested images. A tag `v` builds that version from nothing, tests it and -drafts it on the Releases page; from `v1` on, the build signs with the -release keys once the maintainer approves it. To build locally, on Linux: +uploading the acceptance report and the tested images. A tag `v` +builds that version from nothing, tests it and drafts it on the Releases +page; from `v1` on, the build signs with the release keys once the +maintainer approves it. To build locally, on Linux: ```sh make check # what the host is missing From 3bec07c5d2465b84c6f633f4dadb2e544f7f2254 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 08:42:21 -0700 Subject: [PATCH 083/123] Firefox builds with GCC 14: without -fno-delete-null-pointer-checks, and with the three changes a GCC build needs Under -fno-delete-null-pointer-checks GCC 14 cannot fold an object's address compared with null in a constant expression, and SpiderMonkey's static_asserts in SharedIntlData.cpp need just that, so the flag is dropped for Firefox alone, with its reason: CONFIG_DEFAULT_MMAP_MIN_ADDR=65536 keeps page 0 unmappable, so a null check the compiler deletes gains an attacker nothing. The patch set build/patches/firefox-153.4.0 carries what Gentoo's 153 patch set carries for a GCC build: features.h left unwrapped so FORTIFY takes effect, ffvpx's mismatched pointer types no longer an error under GCC 14 as they already were under clang, and two missing includes. --- build/config/hardening-exceptions.txt | 1 + build/images/browser.sh | 2 ++ ...001-features-h-unwrapped-for-fortify.patch | 10 +++++++++ ...ffvpx-gcc-incompatible-pointer-types.patch | 15 +++++++++++++ .../0003-cstdint-includes.patch | 21 +++++++++++++++++++ build/patches/firefox-153.4.0/README.md | 20 ++++++++++++++++++ build/patches/firefox-153.4.0/SHA256SUMS | 3 +++ docs/design/browser-and-graphics.md | 5 ++++- docs/hardening.md | 10 ++++++--- 9 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 build/patches/firefox-153.4.0/0001-features-h-unwrapped-for-fortify.patch create mode 100644 build/patches/firefox-153.4.0/0002-ffvpx-gcc-incompatible-pointer-types.patch create mode 100644 build/patches/firefox-153.4.0/0003-cstdint-includes.patch create mode 100644 build/patches/firefox-153.4.0/README.md create mode 100644 build/patches/firefox-153.4.0/SHA256SUMS diff --git a/build/config/hardening-exceptions.txt b/build/config/hardening-exceptions.txt index 8607b16e..459ced95 100644 --- a/build/config/hardening-exceptions.txt +++ b/build/config/hardening-exceptions.txt @@ -2,3 +2,4 @@ # # what breaks, and what was tried glibc -D_FORTIFY_SOURCE=3 # glibc implements FORTIFY, so --enable-fortify-source=3 applies it to all but its loader, startup and checking code +firefox -fno-delete-null-pointer-checks # under it GCC 14 cannot fold an object's address compared with null in a constant expression, which SpiderMonkey's static_asserts need (SharedIntlData.cpp); CONFIG_DEFAULT_MMAP_MIN_ADDR=65536 keeps page 0 unmappable, so a null check GCC deletes gains an attacker nothing diff --git a/build/images/browser.sh b/build/images/browser.sh index 900734ee..6272c2ab 100644 --- a/build/images/browser.sh +++ b/build/images/browser.sh @@ -28,6 +28,8 @@ s_browser_firefox() { echo '#include ' | "${llvm}/bin/clang" -x c++ -fsyntax-only --gcc-install-dir="$gccdir" - src="$(unpack "firefox-${V_FIREFOX}esr.source.tar.xz" "firefox-${V_FIREFOX}")" cd "$src" + # What a GCC 14 build of this release needs (build/patches/firefox-*/README.md). + apply_repo_patches "firefox-${V_FIREFOX}" # The build's Glean step caches what it parses through Python's sqlite3, and the base system's # Python has none, as zone 0 has no SQLite: the module is built from that Python's own source # and Firefox's copy of SQLite, for this build alone (browser.strip keeps it out of the image). diff --git a/build/patches/firefox-153.4.0/0001-features-h-unwrapped-for-fortify.patch b/build/patches/firefox-153.4.0/0001-features-h-unwrapped-for-fortify.patch new file mode 100644 index 00000000..bbfaff12 --- /dev/null +++ b/build/patches/firefox-153.4.0/0001-features-h-unwrapped-for-fortify.patch @@ -0,0 +1,10 @@ +--- a/config/system-headers.mozbuild ++++ b/config/system-headers.mozbuild +@@ -222,7 +222,6 @@ system_headers = [ + "exe386.h", + "extras.h", + "fcntl.h", +- "features.h", + "fenv.h", + "ffi.h", + "fibdef.h", diff --git a/build/patches/firefox-153.4.0/0002-ffvpx-gcc-incompatible-pointer-types.patch b/build/patches/firefox-153.4.0/0002-ffvpx-gcc-incompatible-pointer-types.patch new file mode 100644 index 00000000..636f5628 --- /dev/null +++ b/build/patches/firefox-153.4.0/0002-ffvpx-gcc-incompatible-pointer-types.patch @@ -0,0 +1,15 @@ +--- a/media/ffvpx/ffvpxcommon.mozbuild ++++ b/media/ffvpx/ffvpxcommon.mozbuild +@@ -35,11 +35,11 @@ + '-Wno-unused-function', + # XXX This does not seem to have any effect on some versions of GCC. + '-Wno-deprecated-declarations', ++ '-Wno-incompatible-pointer-types', + ] + if CONFIG['CC_TYPE'] in ('clang', 'clang-cl'): + CFLAGS += [ + '-Wno-absolute-value', +- '-Wno-incompatible-pointer-types', + '-Wno-string-conversion', + '-Wno-visibility', + ] diff --git a/build/patches/firefox-153.4.0/0003-cstdint-includes.patch b/build/patches/firefox-153.4.0/0003-cstdint-includes.patch new file mode 100644 index 00000000..7ff91480 --- /dev/null +++ b/build/patches/firefox-153.4.0/0003-cstdint-includes.patch @@ -0,0 +1,21 @@ +--- a/ipc/glue/SharedMemoryHandle.h ++++ b/ipc/glue/SharedMemoryHandle.h +@@ -5,6 +5,7 @@ + #ifndef mozilla_ipc_SharedMemoryHandle_h + #define mozilla_ipc_SharedMemoryHandle_h + ++#include + #include + + #include "mozilla/UniquePtrExtensions.h" +--- a/modules/woff2/include/woff2/output.h ++++ b/modules/woff2/include/woff2/output.h +@@ -10,6 +10,7 @@ + #define WOFF2_WOFF2_OUT_H_ + + #include ++#include + #include + #include + #include + diff --git a/build/patches/firefox-153.4.0/README.md b/build/patches/firefox-153.4.0/README.md new file mode 100644 index 00000000..d3a27229 --- /dev/null +++ b/build/patches/firefox-153.4.0/README.md @@ -0,0 +1,20 @@ +# Firefox 153.4.0 ESR: building with GCC 14 + +Applied by `s_browser_firefox` in the browser image through `apply_repo_patches`; +`SHA256SUMS` is verified before anything is applied. + +Mozilla builds Firefox with clang; Kryptik builds it with stage 04's GCC 14, as +every other program is built. These changes are the ones Gentoo's Firefox 153 +patch set carries for a GCC build, each a few lines: + +- `0001`: `features.h` leaves the list of system headers Firefox wraps. Wrapped, + glibc's `_FORTIFY_SOURCE` checks do not take effect, which stage 06's audit + would report as objects without FORTIFY. +- `0002`: ffvpx, Firefox's copy of FFmpeg, passes pointers of mismatched types in + places, which GCC 14 makes an error by default; the warning was already + silenced for clang, and is for GCC too. +- `0003`: two headers use fixed-width integer types without including + ``, which newer libstdc++ headers no longer bring in on the way. + +The flag stage 04 adds that this build cannot take, `-fno-delete-null-pointer-checks`, +is dropped for Firefox alone in `build/config/hardening-exceptions.txt`. diff --git a/build/patches/firefox-153.4.0/SHA256SUMS b/build/patches/firefox-153.4.0/SHA256SUMS new file mode 100644 index 00000000..5734b0c7 --- /dev/null +++ b/build/patches/firefox-153.4.0/SHA256SUMS @@ -0,0 +1,3 @@ +25e3af89f5cd1cb9fb97492b8c254c1d122c1f2bd83003af59577288098cce02 0001-features-h-unwrapped-for-fortify.patch +d8473ec1e442e5ff0559a08868ed7345024ae898961cffb5b9bb64de29fcb160 0002-ffvpx-gcc-incompatible-pointer-types.patch +692345659a387cda546bf9aa2a8b19cefce60f3545634607e6a35d526145fd90 0003-cstdint-includes.patch diff --git a/docs/design/browser-and-graphics.md b/docs/design/browser-and-graphics.md index 3e226e34..22c6cdbb 100644 --- a/docs/design/browser-and-graphics.md +++ b/docs/design/browser-and-graphics.md @@ -174,7 +174,10 @@ floors are given with each layer. - **Node.js** is left out: ESR 153 still takes `--disable-nodejs`. - **The build.** - Stage 04's GCC with the hardening flags; an exception each flag needs - goes into `hardening-exceptions.txt` with its reason. + goes into `hardening-exceptions.txt` with its reason. Firefox drops + `-fno-delete-null-pointer-checks`, under which GCC 14 cannot evaluate + SpiderMonkey's compile-time checks, and carries the few changes a GCC + build needs (`build/patches/firefox-153.4.0`). - `--enable-default-toolkit=cairo-gtk3-wayland-only`, `--disable-dbus`, `--enable-release`, `--disable-tests`, `--disable-crashreporter`, `--disable-updater` (updates are the image's) and `--disable-eme`. diff --git a/docs/hardening.md b/docs/hardening.md index f2cc62d9..f4ee8657 100644 --- a/docs/hardening.md +++ b/docs/hardening.md @@ -34,12 +34,16 @@ 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`: glibc implements FORTIFY, and its own +`build/lib/common.sh` fails the build on an entry without a reason. There are +two. 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. +accepts by name. And Firefox's `-fno-delete-null-pointer-checks`, in the +browser image: under it GCC 14 cannot evaluate SpiderMonkey's compile-time +checks, which compare an object's address with null, and with +`CONFIG_DEFAULT_MMAP_MIN_ADDR=65536` no process can map page 0, so a null check +the compiler deletes gains an attacker nothing. ### What the audit finds From f67958867f00bde511b9accbc0297314fdd7d554 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 13 Oct 2026 08:58:00 -0700 Subject: [PATCH 084/123] Firefox's null-check exception says what the low 64 KiB covers and that Mozilla's builds go without the flag too CONFIG_DEFAULT_MMAP_MIN_ADDR=65536 keeps the first 64 KiB unmappable, which covers a null pointer dereferenced at a small offset, not every deleted check; Mozilla's own builds delete these checks as well. The ffvpx patch's added line takes its neighbours' indent. --- build/config/hardening-exceptions.txt | 2 +- .../0002-ffvpx-gcc-incompatible-pointer-types.patch | 2 +- build/patches/firefox-153.4.0/SHA256SUMS | 2 +- docs/design/browser-and-graphics.md | 3 ++- docs/hardening.md | 7 ++++--- 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/build/config/hardening-exceptions.txt b/build/config/hardening-exceptions.txt index 459ced95..f9eed066 100644 --- a/build/config/hardening-exceptions.txt +++ b/build/config/hardening-exceptions.txt @@ -2,4 +2,4 @@ # # what breaks, and what was tried glibc -D_FORTIFY_SOURCE=3 # glibc implements FORTIFY, so --enable-fortify-source=3 applies it to all but its loader, startup and checking code -firefox -fno-delete-null-pointer-checks # under it GCC 14 cannot fold an object's address compared with null in a constant expression, which SpiderMonkey's static_asserts need (SharedIntlData.cpp); CONFIG_DEFAULT_MMAP_MIN_ADDR=65536 keeps page 0 unmappable, so a null check GCC deletes gains an attacker nothing +firefox -fno-delete-null-pointer-checks # under it GCC 14 cannot fold an object's address compared with null in a constant expression, which SpiderMonkey's static_asserts need (SharedIntlData.cpp); CONFIG_DEFAULT_MMAP_MIN_ADDR=65536 keeps the first 64 KiB unmappable, and Mozilla's own builds delete these checks as well diff --git a/build/patches/firefox-153.4.0/0002-ffvpx-gcc-incompatible-pointer-types.patch b/build/patches/firefox-153.4.0/0002-ffvpx-gcc-incompatible-pointer-types.patch index 636f5628..1b371c25 100644 --- a/build/patches/firefox-153.4.0/0002-ffvpx-gcc-incompatible-pointer-types.patch +++ b/build/patches/firefox-153.4.0/0002-ffvpx-gcc-incompatible-pointer-types.patch @@ -4,7 +4,7 @@ '-Wno-unused-function', # XXX This does not seem to have any effect on some versions of GCC. '-Wno-deprecated-declarations', -+ '-Wno-incompatible-pointer-types', ++ '-Wno-incompatible-pointer-types', ] if CONFIG['CC_TYPE'] in ('clang', 'clang-cl'): CFLAGS += [ diff --git a/build/patches/firefox-153.4.0/SHA256SUMS b/build/patches/firefox-153.4.0/SHA256SUMS index 5734b0c7..77608aa6 100644 --- a/build/patches/firefox-153.4.0/SHA256SUMS +++ b/build/patches/firefox-153.4.0/SHA256SUMS @@ -1,3 +1,3 @@ 25e3af89f5cd1cb9fb97492b8c254c1d122c1f2bd83003af59577288098cce02 0001-features-h-unwrapped-for-fortify.patch -d8473ec1e442e5ff0559a08868ed7345024ae898961cffb5b9bb64de29fcb160 0002-ffvpx-gcc-incompatible-pointer-types.patch +b9bc94714ef801b3450dddfba1a35a7139ed48c9a958378dbda9016a4403ee14 0002-ffvpx-gcc-incompatible-pointer-types.patch 692345659a387cda546bf9aa2a8b19cefce60f3545634607e6a35d526145fd90 0003-cstdint-includes.patch diff --git a/docs/design/browser-and-graphics.md b/docs/design/browser-and-graphics.md index 22c6cdbb..9414d958 100644 --- a/docs/design/browser-and-graphics.md +++ b/docs/design/browser-and-graphics.md @@ -176,7 +176,8 @@ floors are given with each layer. - Stage 04's GCC with the hardening flags; an exception each flag needs goes into `hardening-exceptions.txt` with its reason. Firefox drops `-fno-delete-null-pointer-checks`, under which GCC 14 cannot evaluate - SpiderMonkey's compile-time checks, and carries the few changes a GCC + SpiderMonkey's compile-time checks (Mozilla's own builds go without it + too), and carries the few changes a GCC build needs (`build/patches/firefox-153.4.0`). - `--enable-default-toolkit=cairo-gtk3-wayland-only`, `--disable-dbus`, `--enable-release`, `--disable-tests`, `--disable-crashreporter`, diff --git a/docs/hardening.md b/docs/hardening.md index f4ee8657..e2bfc6d7 100644 --- a/docs/hardening.md +++ b/docs/hardening.md @@ -41,9 +41,10 @@ and checking code (its `elf`, `dlfcn`, `csu` and `debug` directories), whose programs `pldd`, `sprof`, `pcprofiledump` and `sotruss-lib.so` the audit accepts by name. And Firefox's `-fno-delete-null-pointer-checks`, in the browser image: under it GCC 14 cannot evaluate SpiderMonkey's compile-time -checks, which compare an object's address with null, and with -`CONFIG_DEFAULT_MMAP_MIN_ADDR=65536` no process can map page 0, so a null check -the compiler deletes gains an attacker nothing. +checks, which compare an object's address with null. With +`CONFIG_DEFAULT_MMAP_MIN_ADDR=65536` no process can map the first 64 KiB, which +covers a null pointer dereferenced at a small offset, and Mozilla's own builds +delete these checks as well. ### What the audit finds From 47327c74456dce2dcdf318aa26cb2df39c71b2d8 Mon Sep 17 00:00:00 2001 From: DevomB Date: Wed, 14 Oct 2026 17:14:43 -0700 Subject: [PATCH 085/123] The image stage makes the work tree's stamps, logs, build and images directories before linking them Since the images run in a job of their own, their work tree is stage 05's hand-off, which has no images directory: the link to it dangled, and stage 03's layout step in the first image's chroot failed on it (browser round 38427424503, 7 s into the step). --- build/stages/04-images.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/build/stages/04-images.sh b/build/stages/04-images.sh index c2ec93bc..2a9b44c0 100755 --- a/build/stages/04-images.sh +++ b/build/stages/04-images.sh @@ -115,8 +115,12 @@ build_image() { # build_image NAME rm -f "${KRYPTIK_WORK}/.stamps/image-${name}-"* mkdir -p "${top}/upper" "${top}/overlay-work" "${top}/sysroot" # Stage 03 takes the chroot's root from KRYPTIK_WORK, so the overlay is that work tree's sysroot, - # and the real work tree's stamps, logs, build and images are bound into it as usual. - for d in .stamps logs build images; do ln -s "${KRYPTIK_WORK}/${d}" "${top}/${d}"; done + # and the real work tree's stamps, logs, build and images are bound into it as usual. They are + # made here: the images job's work tree is stage 05's hand-off, which has no images directory. + for d in .stamps logs build images; do + mkdir -p "${KRYPTIK_WORK}/${d}" + ln -s "${KRYPTIK_WORK}/${d}" "${top}/${d}" + done MOUNTED="$top" mount -t overlay overlay -o "lowerdir=${lowers}${SYSROOT},upperdir=${top}/upper,workdir=${top}/overlay-work" "${top}/sysroot" env KRYPTIK_WORK="$top" KRYPTIK_KRYPTIKD_BIN= KRYPTIK_WLPROXY_BIN= \ From f583baa97db87577a66290311f7d2c7e18b3bf6d Mon Sep 17 00:00:00 2001 From: DevomB Date: Wed, 14 Oct 2026 17:28:29 -0700 Subject: [PATCH 086/123] Firefox ESR 153.5.0, for MFSA 2026-108's security fixes 153.5.0esr (2026-10-13) fixes 61 CVEs, 30 rated high, among them sandbox escapes in Networking and Graphics and use-after-frees in DOM, SVG, XML, Canvas2D and WebGL. The tarball's hash is Mozilla's own SHA256SUMS line; the KEY file is byte for byte 153.4.0esr's, so only its row's URL moves. The four files the GCC patches touch are identical in both releases' tags, so the patch set applies unchanged and moves to the new version's directory. --- build/images/sources.lock | 2 +- build/images/versions.env | 2 +- .../0001-features-h-unwrapped-for-fortify.patch | 0 .../0002-ffvpx-gcc-incompatible-pointer-types.patch | 0 .../0003-cstdint-includes.patch | 0 build/patches/{firefox-153.4.0 => firefox-153.5.0}/README.md | 2 +- build/patches/{firefox-153.4.0 => firefox-153.5.0}/SHA256SUMS | 0 docs/design/browser-and-graphics.md | 2 +- tools/key-provenance.tsv | 2 +- 9 files changed, 5 insertions(+), 5 deletions(-) rename build/patches/{firefox-153.4.0 => firefox-153.5.0}/0001-features-h-unwrapped-for-fortify.patch (100%) rename build/patches/{firefox-153.4.0 => firefox-153.5.0}/0002-ffvpx-gcc-incompatible-pointer-types.patch (100%) rename build/patches/{firefox-153.4.0 => firefox-153.5.0}/0003-cstdint-includes.patch (100%) rename build/patches/{firefox-153.4.0 => firefox-153.5.0}/README.md (96%) rename build/patches/{firefox-153.4.0 => firefox-153.5.0}/SHA256SUMS (100%) diff --git a/build/images/sources.lock b/build/images/sources.lock index d3acc1e3..fb451ed6 100644 --- a/build/images/sources.lock +++ b/build/images/sources.lock @@ -3,7 +3,7 @@ cca6b6a86e4389d89b370288761ce2385ddeceb10c4412da78689bc30c88af2d at-spi2-core-2 1c767308174337a74694da0f3ec069c271452163a1ef4540964c50c301f157d4 cairo-1.18.6.tar.xz ea1de9f9e23107d97ee2b41a72c552f34064a593da503789218387aee59f3ba4 cargo-1.98.1-x86_64-unknown-linux-gnu.tar.xz 2ecb53484c9c167ba674026b656d8a27d7657a58e6066aa902bfb1a4aa00ae20 cbindgen-0.29.4.crate -3082dec68030b4fbb46041c282e362e524c9d6d75e7c45e944d8d0e11c8ea2df firefox-153.4.0esr.source.tar.xz +28714a7d4f9b38c7aafbe38022de7775a3d9cb3c1ebf19a567c26eb761e3410b firefox-153.5.0esr.source.tar.xz 4f7b554a38cdf78c033f666c8871f3749e14a094f65a07f630c91ed0b43d35e3 fontconfig-2.18.3.tar.xz 93cd47eb719c325107f2d3cf087d06299e944c3b48a40b55537302304dea1dd5 freetype-2.15.0.tar.xz 6949dcde27d41cebad1fd741fcafc36d55a1020d2d872d4a6eb3914caabbada2 fribidi-1.0.17.tar.xz diff --git a/build/images/versions.env b/build/images/versions.env index 5f4e04d5..a89ff200 100644 --- a/build/images/versions.env +++ b/build/images/versions.env @@ -30,7 +30,7 @@ V_GTK3=3.24.52 # --- browser: Firefox ESR, over the toolkit and build-only layers no zone gets --- # The ESR after 140, which ends once the two stop overlapping. Mozilla's key is taken from the KEY file # beside this release (tools/key-provenance.tsv), so a bump changes both. -V_FIREFOX=153.4.0 +V_FIREFOX=153.5.0 # The compiler kryptikd is built with: build/config/rust.lock holds the same three tarballs. V_RUST=1.98.1 # LLVM's release build, for the libclang bindgen loads. diff --git a/build/patches/firefox-153.4.0/0001-features-h-unwrapped-for-fortify.patch b/build/patches/firefox-153.5.0/0001-features-h-unwrapped-for-fortify.patch similarity index 100% rename from build/patches/firefox-153.4.0/0001-features-h-unwrapped-for-fortify.patch rename to build/patches/firefox-153.5.0/0001-features-h-unwrapped-for-fortify.patch diff --git a/build/patches/firefox-153.4.0/0002-ffvpx-gcc-incompatible-pointer-types.patch b/build/patches/firefox-153.5.0/0002-ffvpx-gcc-incompatible-pointer-types.patch similarity index 100% rename from build/patches/firefox-153.4.0/0002-ffvpx-gcc-incompatible-pointer-types.patch rename to build/patches/firefox-153.5.0/0002-ffvpx-gcc-incompatible-pointer-types.patch diff --git a/build/patches/firefox-153.4.0/0003-cstdint-includes.patch b/build/patches/firefox-153.5.0/0003-cstdint-includes.patch similarity index 100% rename from build/patches/firefox-153.4.0/0003-cstdint-includes.patch rename to build/patches/firefox-153.5.0/0003-cstdint-includes.patch diff --git a/build/patches/firefox-153.4.0/README.md b/build/patches/firefox-153.5.0/README.md similarity index 96% rename from build/patches/firefox-153.4.0/README.md rename to build/patches/firefox-153.5.0/README.md index d3a27229..f23a7ec5 100644 --- a/build/patches/firefox-153.4.0/README.md +++ b/build/patches/firefox-153.5.0/README.md @@ -1,4 +1,4 @@ -# Firefox 153.4.0 ESR: building with GCC 14 +# Firefox 153.5.0 ESR: building with GCC 14 Applied by `s_browser_firefox` in the browser image through `apply_repo_patches`; `SHA256SUMS` is verified before anything is applied. diff --git a/build/patches/firefox-153.4.0/SHA256SUMS b/build/patches/firefox-153.5.0/SHA256SUMS similarity index 100% rename from build/patches/firefox-153.4.0/SHA256SUMS rename to build/patches/firefox-153.5.0/SHA256SUMS diff --git a/docs/design/browser-and-graphics.md b/docs/design/browser-and-graphics.md index 963ea6a3..f594a962 100644 --- a/docs/design/browser-and-graphics.md +++ b/docs/design/browser-and-graphics.md @@ -181,7 +181,7 @@ floors are given with each layer. `-fno-delete-null-pointer-checks`, under which GCC 14 cannot evaluate SpiderMonkey's compile-time checks (Mozilla's own builds go without it too), and carries the few changes a GCC - build needs (`build/patches/firefox-153.4.0`). + build needs (`build/patches/firefox-153.5.0`). - `--enable-default-toolkit=cairo-gtk3-wayland-only`, `--disable-dbus`, `--enable-release`, `--disable-tests`, `--disable-crashreporter`, `--disable-updater` (updates are the image's) and `--disable-eme`. diff --git a/tools/key-provenance.tsv b/tools/key-provenance.tsv index 16a1e8bb..d1995337 100644 --- a/tools/key-provenance.tsv +++ b/tools/key-provenance.tsv @@ -114,6 +114,6 @@ B3F2469D78D78BD09D366F37C94035C21B4F2AEB korg https://git.kernel.org/pub/scm 0CF4E8D871593224842832B888DD9E08C5DDACB9 savannah https://savannah.gnu.org/project/release-gpgkeys.php?group=findutils&download=1 2026-10-02 findutils James Youngman B1D2BD1375BECB784CF4F8C4D73CF638C53C06BE savannah https://savannah.gnu.org/project/release-gpgkeys.php?group=inetutils&download=1 2026-10-02 inetutils Simon Josefsson ; signs with subkey A3CC9C870B9D310ABAD4CF2F51722B08FE4745A2; Savannah's copy expired 2022-10-23, the GNU keyring's runs later D33A3F0CA16B0ACC51A60738494C8A5FBF4DECEB wkd mak@debian.org 2026-10-13 hicolor-icon-theme Matthias Klumpp ; db.debian.org and nm.debian.org show the same fingerprint; signs the 0.18 tarball, the first signed release; expires 2028-07-18 -14F26682D0916CDD81E37B6D61B7B526D98F0353 origin https://archive.mozilla.org/pub/firefox/releases/153.4.0esr/KEY 2026-10-13 firefox Mozilla Software Releases ; the file carries the signing subkey 827E658608679618CD349F93678E455D76767AA3 (2026-08-06, to 2028-08-05) and the revocation of the leaked 09BEED63F3462A2DFFAB3B875ECB6497C1A20256, neither of which keyserver.ubuntu.com serves; V_FIREFOX's bump moves this URL +14F26682D0916CDD81E37B6D61B7B526D98F0353 origin https://archive.mozilla.org/pub/firefox/releases/153.5.0esr/KEY 2026-10-15 firefox Mozilla Software Releases ; the file carries the signing subkey 827E658608679618CD349F93678E455D76767AA3 (2026-08-06, to 2028-08-05) and the revocation of the leaked 09BEED63F3462A2DFFAB3B875ECB6497C1A20256, neither of which keyserver.ubuntu.com serves; V_FIREFOX's bump moves this URL 108F66205EAEB0AAA8DD5E1C85AB96E6FA1BE5FE origin https://static.rust-lang.org/rust-key.gpg.ascii 2026-10-13 rustc,cargo,rust-std Rust Language (Tag and Release Signing Key) ; served beside the releases it signs D574BD5D1D0E98895E3BF90044F2485E45D59042 origin https://releases.llvm.org/release-keys.asc 2026-10-13 llvm Tobias Hieta ; one of the release managers' keys LLVM's site lists, and the one that signs 23.1.3 From dab7888dbb1f3bfc485a82fecdee3c65d21a07d9 Mon Sep 17 00:00:00 2001 From: DevomB Date: Wed, 14 Oct 2026 21:06:21 -0700 Subject: [PATCH 087/123] The nasm currency rule reads the release directories' links, not the page's DOCTYPE nasm.us's index page declares HTML 4.01, and the rule's pattern took "4.01//" for a release: the pin gate reported nasm 3.02 -> 4.01, a release that does not exist (no 4.01 directory; 3.02 is the newest). The pattern now matches href="X.Y[.Z]/" alone. The survey's test serves that page's DOCTYPE with an rc directory and expects 3.02 current. --- tools/currency-rules.tsv | 3 ++- tools/tests/check-source-currency.sh | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/tools/currency-rules.tsv b/tools/currency-rules.tsv index aaf6cb82..bd5bc936 100644 --- a/tools/currency-rules.tsv +++ b/tools/currency-rules.tsv @@ -60,7 +60,8 @@ libglvnd api https://gitlab.freedesktop.org/api/v4/projects/glvnd%2Flibglvnd/rel # GitHub's API answers with spaced JSON, which only a listing pattern reads. rustc listing https://api.github.com/repos/rust-lang/rust/releases?per_page=20 "tag_name": "([0-9]+\.[0-9]+\.[0-9]+)" - cbindgen listing https://api.github.com/repos/mozilla/cbindgen/releases?per_page=20 "tag_name": "v?([0-9]+\.[0-9]+\.[0-9]+)" - -nasm listing https://www.nasm.us/pub/nasm/releasebuilds/ ([0-9]+\.[0-9]+(\.[0-9]+)?)/ - +# The directory links alone: the page's DOCTYPE names HTML 4.01, and "4.01//" is no release. +nasm listing https://www.nasm.us/pub/nasm/releasebuilds/ href="([0-9]+\.[0-9]+(\.[0-9]+)?)/" - # The current ESR and, while two overlap, the next one; the pin's own ESR series is what counts. firefox listing https://product-details.mozilla.org/1.0/firefox_versions.json "FIREFOX_ESR[_NEXT]*": "([0-9]+\.[0-9]+\.[0-9]+)esr" major # An odd minor is a development snapshot. diff --git a/tools/tests/check-source-currency.sh b/tools/tests/check-source-currency.sh index 441ab9f6..d477ae9e 100755 --- a/tools/tests/check-source-currency.sh +++ b/tools/tests/check-source-currency.sh @@ -116,6 +116,12 @@ page "ftp.openbsd.org/pub/OpenBSD/OpenSSH/portable" \ page "openssl-library.org/source" \ "openssl-3.3.1.tar.gz" "openssl-3.6.2.tar.gz" "openssl-4.0.0.tar.gz" +# nasm: the index page's DOCTYPE names HTML 4.01, which is no release, and an rc directory is none either. +mkdir -p "${SERVE}/www.nasm.us/pub/nasm/releasebuilds" +printf '%s\n' '' \ + '3.01/ 3.02rc13/ 3.02/' \ + > "${SERVE}/www.nasm.us/pub/nasm/releasebuilds/index.html" + # glibc's release branch, read by git over HTTP from a plain info/refs, past the patch set's commit. refs() { mkdir -p "${SERVE}/sourceware.org/git/glibc.git/info"; printf '%s\trefs/heads/release/2.40/master\n' "$1" > "${SERVE}/sourceware.org/git/glibc.git/info/refs"; } refs 1111111111111111111111111111111111111111 @@ -187,6 +193,7 @@ openssh 10.5p1 https://ftp.openbsd.org/pub/OpenBSD/OpenSSH/portable/ope kernel-hardening-checker 0.6.17.1 https://github.com/a13xp0p0v/kernel-hardening-checker/archive/refs/tags/v0.6.17.1.tar.gz probe rule glibc-fhs-patch 2.40 https://www.linuxfromscratch.org/patches/lfs/12.2/glibc-2.40-fhs-1.patch none follows:glibc openssl 3.3.1 https://www.openssl.org/source/openssl-3.3.1.tar.gz probe rule +nasm 3.02 https://www.nasm.us/pub/nasm/releasebuilds/3.02/nasm-3.02.tar.xz none rule oddity 1.0 https://example.invalid/oddity/oddity-1.0.tar.gz probe telepathy norule 1.0 https://example.invalid/norule/norule-1.0.tar.gz probe rule ROWS @@ -253,6 +260,7 @@ expect_row kernel-hardening-checker 0.6.17.1 current # tags only, no releases expect_row glibc-fhs-patch "" deferred # follows the glibc pin expect_row glibc-branch 111111111111 BEHIND # the branch moved past the patch set's commit expect_row openssl 3.6.2 BEHIND # the pinned major line, not 4.0.0 +expect_row nasm 3.02 current # not the 4.01 of the page's DOCTYPE # A row the script cannot look up is UNKNOWN, never skipped. expect_row oddity "" UNKNOWN # a way to look that the script does not know From eb6f2e76dbe8ec257ce5ce1a52026ce217f876a8 Mon Sep 17 00:00:00 2001 From: DevomB Date: Wed, 14 Oct 2026 21:32:33 -0700 Subject: [PATCH 088/123] Firefox builds its remote server and client with X11 only, so a build with neither D-Bus nor X11 has none Without D-Bus, toolkit/components/remote builds the X11 remote (nsGTKRemoteServer, nsXRemoteClient), which includes gdk/gdkx.h, and the toolkit image's GTK has no X11 backend: the browser round 38659525807 stopped there after 111 minutes. The patch takes those sources only under MOZ_X11, and nsRemoteService falls to the path a platform without remoting already takes (the client answers NS_ERROR_NOT_AVAILABLE, and startup then goes on as normal; no server). Nothing outside the remote directory names the X11 classes. Checked with GNU patch -Np1 -F0 against FIREFOX_153_5_0esr_RELEASE's two files. --- .../0004-remote-needs-dbus-or-x11.patch | 46 +++++++++++++++++++ build/patches/firefox-153.5.0/README.md | 10 +++- build/patches/firefox-153.5.0/SHA256SUMS | 1 + docs/design/browser-and-graphics.md | 3 ++ 4 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 build/patches/firefox-153.5.0/0004-remote-needs-dbus-or-x11.patch diff --git a/build/patches/firefox-153.5.0/0004-remote-needs-dbus-or-x11.patch b/build/patches/firefox-153.5.0/0004-remote-needs-dbus-or-x11.patch new file mode 100644 index 00000000..5d10e18e --- /dev/null +++ b/build/patches/firefox-153.5.0/0004-remote-needs-dbus-or-x11.patch @@ -0,0 +1,46 @@ +--- a/toolkit/components/remote/moz.build ++++ b/toolkit/components/remote/moz.build +@@ -36,7 +36,7 @@ + "RemoteUtils.h", + ] + CXXFLAGS += CONFIG["MOZ_DBUS_CFLAGS"] +- else: ++ elif CONFIG["MOZ_X11"]: + SOURCES += [ + "nsGTKRemoteServer.cpp", + "nsXRemoteClient.cpp", +--- a/toolkit/components/remote/nsRemoteService.cpp ++++ b/toolkit/components/remote/nsRemoteService.cpp +@@ -7,7 +7,7 @@ + # ifdef MOZ_ENABLE_DBUS + # include "nsDBusRemoteServer.h" + # include "nsDBusRemoteClient.h" +-# else ++# elif defined(MOZ_X11) + # include "nsGTKRemoteServer.h" + # include "nsXRemoteClient.h" + # endif +@@ -195,8 +195,10 @@ + #ifdef MOZ_WIDGET_GTK + # if defined(MOZ_ENABLE_DBUS) + client = MakeUnique(mStartupToken); ++# elif defined(MOZ_X11) ++ client = MakeUnique(mStartupToken); + # else +- client = MakeUnique(mStartupToken); ++ return NS_ERROR_NOT_AVAILABLE; + # endif + #elif defined(XP_WIN) + client = MakeUnique(); +@@ -257,8 +259,10 @@ + #ifdef MOZ_WIDGET_GTK + # if defined(MOZ_ENABLE_DBUS) + mRemoteServer = MakeUnique(); ++# elif defined(MOZ_X11) ++ mRemoteServer = MakeUnique(); + # else +- mRemoteServer = MakeUnique(); ++ return; + # endif + #elif defined(XP_WIN) + mRemoteServer = MakeUnique(); diff --git a/build/patches/firefox-153.5.0/README.md b/build/patches/firefox-153.5.0/README.md index f23a7ec5..84977a34 100644 --- a/build/patches/firefox-153.5.0/README.md +++ b/build/patches/firefox-153.5.0/README.md @@ -4,8 +4,8 @@ Applied by `s_browser_firefox` in the browser image through `apply_repo_patches` `SHA256SUMS` is verified before anything is applied. Mozilla builds Firefox with clang; Kryptik builds it with stage 04's GCC 14, as -every other program is built. These changes are the ones Gentoo's Firefox 153 -patch set carries for a GCC build, each a few lines: +every other program is built. The first three are the changes Gentoo's Firefox +153 patch set carries for a GCC build, each a few lines; the fourth is Kryptik's: - `0001`: `features.h` leaves the list of system headers Firefox wraps. Wrapped, glibc's `_FORTIFY_SOURCE` checks do not take effect, which stage 06's audit @@ -15,6 +15,12 @@ patch set carries for a GCC build, each a few lines: silenced for clang, and is for GCC too. - `0003`: two headers use fixed-width integer types without including ``, which newer libstdc++ headers no longer bring in on the way. +- `0004`: without D-Bus, the GTK build takes the X11 remote server and client + (`nsGTKRemoteServer`, `nsXRemoteClient`), which include `gdk/gdkx.h`, and the + toolkit image's GTK has no X11 backend. They are built with X11 only, and a + build with neither gets no remote at all, as on a platform without one: a + second `firefox` with the same profile is not handed to the first, and says + that Firefox is already running. The flag stage 04 adds that this build cannot take, `-fno-delete-null-pointer-checks`, is dropped for Firefox alone in `build/config/hardening-exceptions.txt`. diff --git a/build/patches/firefox-153.5.0/SHA256SUMS b/build/patches/firefox-153.5.0/SHA256SUMS index 77608aa6..915dce98 100644 --- a/build/patches/firefox-153.5.0/SHA256SUMS +++ b/build/patches/firefox-153.5.0/SHA256SUMS @@ -1,3 +1,4 @@ 25e3af89f5cd1cb9fb97492b8c254c1d122c1f2bd83003af59577288098cce02 0001-features-h-unwrapped-for-fortify.patch b9bc94714ef801b3450dddfba1a35a7139ed48c9a958378dbda9016a4403ee14 0002-ffvpx-gcc-incompatible-pointer-types.patch 692345659a387cda546bf9aa2a8b19cefce60f3545634607e6a35d526145fd90 0003-cstdint-includes.patch +d68bb973e17677582d8988053518549d8261fe20934b732c12a4138bf9ec8dfb 0004-remote-needs-dbus-or-x11.patch diff --git a/docs/design/browser-and-graphics.md b/docs/design/browser-and-graphics.md index f594a962..4434eb8d 100644 --- a/docs/design/browser-and-graphics.md +++ b/docs/design/browser-and-graphics.md @@ -185,6 +185,9 @@ floors are given with each layer. - `--enable-default-toolkit=cairo-gtk3-wayland-only`, `--disable-dbus`, `--enable-release`, `--disable-tests`, `--disable-crashreporter`, `--disable-updater` (updates are the image's) and `--disable-eme`. + With neither D-Bus nor X11 Firefox has no remote: a second launch in a + zone whose browser is open is not handed to it, and says that Firefox is + already running (`0004` in the patch set). - `--disable-jemalloc`, as the image preloads hardened_malloc for every process. - `--enable-audio-backends=alsa`: a zone's default ALSA device is zone From e1c681545817a04de2480d6bdd88e91257340b06 Mon Sep 17 00:00:00 2001 From: DevomB Date: Wed, 14 Oct 2026 22:20:59 -0700 Subject: [PATCH 089/123] The browser round starts Firefox on a tag nothing is on, so its window takes the keyboard as it maps dwl puts a zone's window mapped while another zone's has the keyboard at the back of the focus order, unpicked, and focustop passes over it, so the chrome's focus record, which the round reads, never shows it. The launcher has the keyboard when the round starts. On a tag with no window nothing has the keyboard, and the window takes it when it maps, however long Firefox takes to start. --- build/guest-tests/gui-check.sh | 8 +++++++- tools/image/gui-test.sh | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/build/guest-tests/gui-check.sh b/build/guest-tests/gui-check.sh index ba9a19ab..9f06d108 100755 --- a/build/guest-tests/gui-check.sh +++ b/build/guest-tests/gui-check.sh @@ -35,6 +35,7 @@ # 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 KEY-VIEW-BROWSER, GT KEY-BROWSER-BACK press Alt+3 (a tag nothing is on), then Alt+1 # GT END # Verdicts: "GT PASS|FAIL|INFO name - detail". set -u @@ -1307,7 +1308,11 @@ fi # --- the browser, in a zone that carries it --------------------------------------------- # untrusted names the browser image, which the installer kept from the medium: Firefox opens a -# page of the zone's own, and its window is drawn as untrusted's. +# page of the zone's own, and its window is drawn as untrusted's. It starts on a tag nothing is +# on: mapped beside the launcher, which has the keyboard, the window would wait to be picked, +# and no key pressed at a set time can wait out a start that takes Firefox many seconds. +echo "GT KEY-VIEW-BROWSER" +wait_for 10 grep -qx 'zone=-' "$RT/kryptik/focus" || info "browser: a window still shows after Alt+3: $(tr '\n' ' ' < "$RT/kryptik/focus")" mark firefox untrusted launch_plain untrusted "sh -c 'printf \"kryptik-page

kryptik

\" > /tmp/page.html && exec firefox --new-instance file:///tmp/page.html'" > "$LOG/launch-firefox.out" 2>&1 & if wait_for 120 grep -q '^title=.*kryptik-page' "$RT/kryptik/focus" && grep -qx 'zone=untrusted' "$RT/kryptik/focus"; then @@ -1316,6 +1321,7 @@ else fail "browser-window" "focus: $(tr '\n' ' ' < "$RT/kryptik/focus" 2>/dev/null); $(since_mark firefox untrusted | tail -5 | tr '\n' ' ')" fi stop_zone untrusted +echo "GT KEY-BROWSER-BACK" # --- teardown ------------------------------------------------------------------------------ # The runtime tmpfs does not survive power-off: copy the session log and the diff --git a/tools/image/gui-test.sh b/tools/image/gui-test.sh index bfa6c4f9..2bafac25 100755 --- a/tools/image/gui-test.sh +++ b/tools/image/gui-test.sh @@ -131,6 +131,7 @@ DRIVE_TIMEOUT=600 drive --qmp "$QMP" \ "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 KEY-VIEW-BROWSER" "key:alt+3" "expect:GT KEY-BROWSER-BACK" "key:alt+1" \ "expect:GT END" "expect:GCHECK-DONE" \ "send:su - root -c 'poweroff'" "expect:Password: ?" "send:${RPASS}" \ "expect:Power down" "wait-exit" From 9d8062c72622d601029509a5acab5cc399585f81 Mon Sep 17 00:00:00 2001 From: DevomB Date: Wed, 14 Oct 2026 22:33:35 -0700 Subject: [PATCH 090/123] The guide says a zone runs one Firefox, and a second window comes from the open one Built with neither D-Bus nor X11, Firefox has no remote (patch 0004): a second launch in a zone whose browser is open is not handed to it and says that Firefox is already running. --- docs/user-guide.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/user-guide.md b/docs/user-guide.md index 3459523b..d9cbeedb 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -312,7 +312,9 @@ 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. The browser is Firefox in `untrusted`, `personal` and `work`, which carry its software image, and lynx in a terminal elsewhere; Firefox opens -PDFs too. A zone with encrypted storage asks for its passphrase when it starts, +PDFs too. A zone runs one Firefox: asked for again while it is open there, +it says that Firefox is already running, and a new window comes from the +open one (Ctrl+N). A zone with encrypted storage asks for its passphrase when it starts, in the menu's window or, for a key, in a small prompt window; the passphrase never appears on a command line. Under the zones, a Network line says what the net zone reports: whether it is ready, the Wi-Fi network it joined (or From 537364704219960e420442b75a5685db483ed666 Mon Sep 17 00:00:00 2001 From: DevomB Date: Wed, 14 Oct 2026 22:36:31 -0700 Subject: [PATCH 091/123] The browser round checks that Firefox's window is still there 90 s after its page showed Its idle startup tasks and its startup cache's first write come after the page does, the write at most 60 s after the cache's last entry. A call the zone's filter kills at that point ends Firefox after browser-window has passed, so nothing would fail. The failure line carries the kernel's seccomp lines when there are any. --- build/guest-tests/gui-check.sh | 12 +++++++++++- tools/image/gui-test.sh | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/build/guest-tests/gui-check.sh b/build/guest-tests/gui-check.sh index 9f06d108..23fc5553 100755 --- a/build/guest-tests/gui-check.sh +++ b/build/guest-tests/gui-check.sh @@ -1315,8 +1315,18 @@ echo "GT KEY-VIEW-BROWSER" wait_for 10 grep -qx 'zone=-' "$RT/kryptik/focus" || info "browser: a window still shows after Alt+3: $(tr '\n' ' ' < "$RT/kryptik/focus")" mark firefox untrusted launch_plain untrusted "sh -c 'printf \"kryptik-page

kryptik

\" > /tmp/page.html && exec firefox --new-instance file:///tmp/page.html'" > "$LOG/launch-firefox.out" 2>&1 & -if wait_for 120 grep -q '^title=.*kryptik-page' "$RT/kryptik/focus" && grep -qx 'zone=untrusted' "$RT/kryptik/focus"; then +ff_page() { grep -q '^title=.*kryptik-page' "$RT/kryptik/focus" && grep -qx 'zone=untrusted' "$RT/kryptik/focus"; } +if wait_for 120 ff_page; then pass "browser-window" "Firefox from the browser image opened a page in untrusted: $(grep '^title=' "$RT/kryptik/focus")" + # Its idle startup tasks and its startup cache's first write come later, the write at most + # 60 s after the cache's last entry: a call the zone's filter kills there ends Firefox after + # its window has shown. + sleep 90 + if ff_page; then + pass "browser-stays-up" "Firefox's window is still untrusted's 90 s after its page showed" + else + fail "browser-stays-up" "focus: $(tr '\n' ' ' < "$RT/kryptik/focus" 2>/dev/null); $(since_mark firefox untrusted | tail -5 | tr '\n' ' '); kernel: $(dmesg 2>/dev/null | grep -E 'type=1326|seccomp' | tail -2 | tr '\n' ' ')" + fi else fail "browser-window" "focus: $(tr '\n' ' ' < "$RT/kryptik/focus" 2>/dev/null); $(since_mark firefox untrusted | tail -5 | tr '\n' ' ')" fi diff --git a/tools/image/gui-test.sh b/tools/image/gui-test.sh index 2bafac25..b0834a5a 100755 --- a/tools/image/gui-test.sh +++ b/tools/image/gui-test.sh @@ -150,7 +150,7 @@ for name in session-socket compositor-running chrome-focus-record chrome-window- 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 zone-copy-pastes-in-zone zone-copy-stays-in-zone zone-copy-not-in-zone0 \ move-door-unreached move-crosses-zones move-leaves-nothing-behind move-in-no-log move-door-refuses-others \ transfer-policy no-question-for-policy-refusal consent-code-shown transfer-approved transfer-landed plain-y-refused denied-file-absent \ - second-head-appears chrome-follows-head second-head-zone-window second-head-names-zone second-head-gone compositor-survives-unplug zone-survives-unplug chrome-back-on-first-head chrome-wifi-add menu-network-line chrome-wifi-add-open chrome-wifi-scan browser-window \ + 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 browser-window browser-stays-up \ zone-popup-drawn zone-popup-yields-keyboard zone-popup-ends-on-own-focus \ password-check-pauses lock-takes-keyboard lock-refuses-wrong-password lock-survives-its-death lock-unlocks-with-password lock-keeps-keys-from-zone-grab \ idle-lock-armed idle-locks idle-blanks key-wakes-screens idle-locks-again \ From 445fb2de17acc881ca076183fa2f43b087a9b744 Mon Sep 17 00:00:00 2001 From: DevomB Date: Wed, 14 Oct 2026 22:50:31 -0700 Subject: [PATCH 092/123] The proxy's bounds fit a browser on two 4K heads: 1 GiB of pools a connection and a zone, 32 windows a connection Passing a bound ends the client's connection, and Firefox draws every window over one: at 128 MiB a connection and 256 MiB a zone one 4K window ended it, and so did a ninth window. Pools may now hold 128 MiB each (a 6K frame), 1 GiB a connection and a zone, 128 and 256 of them; a connection may open 32 windows. The resource-limits design says what each bound holds against: the count, dwl's mappings; the bytes, what dwl maps and, for pages it reads before the zone writes them, what it is charged for while the pools live. A zone that keeps a pool's descriptor keeps those pages after, which no bound on live pools closes; punching each pool's range once it is gone would, and is not built yet. --- compositor/wlproxy/src/policy.rs | 21 ++++++--- compositor/wlproxy/src/session/tests.rs | 31 +++++++++++-- docs/audit-scope.md | 5 +- docs/design/browser-and-graphics.md | 7 +-- .../resource-limits-and-ephemeral-zones.md | 46 +++++++++++++++++++ 5 files changed, 95 insertions(+), 15 deletions(-) diff --git a/compositor/wlproxy/src/policy.rs b/compositor/wlproxy/src/policy.rs index 77878a90..efb4b5f6 100644 --- a/compositor/wlproxy/src/policy.rs +++ b/compositor/wlproxy/src/policy.rs @@ -80,21 +80,28 @@ pub fn app_id_for(zone: &str, claimed: &str) -> String { format!("kryptik.{zone}.{}", if cleaned.is_empty() { "app".to_string() } else { cleaned }) } -// Resource bounds per client connection and across a zone's connections. +/* Resource bounds per client connection and across a zone's connections. Passing one ends the + * connection, as Wayland answers a pool or a window it will not make only with a protocol error, + * and a browser draws all its windows over one connection: the bounds sit above what Firefox + * needs on two 4K heads (docs/design/resource-limits-and-ephemeral-zones.md). */ pub const MAX_OBJECTS: usize = 4096; /// Id slots per range: libwayland reuses freed ids, so only a client that never does reaches this. pub const MAX_ID_SLOTS: usize = 2 * MAX_OBJECTS; pub const MAX_PENDING_BYTES: usize = 1 << 20; // per direction pub const MAX_PENDING_FDS: usize = 64; -pub const MAX_TOPLEVELS_PER_SESSION: usize = 8; +/// A browser's windows, dialogs and picture-in-picture all count. +pub const MAX_TOPLEVELS_PER_SESSION: usize = 32; pub const MAX_TOPLEVELS_PER_ZONE: usize = 64; /// Menus and their submenus, tooltips: the compositor keeps each inside its window. pub const MAX_POPUPS_PER_SESSION: usize = 16; -pub const MAX_SHM_POOLS_PER_SESSION: usize = 32; -pub const MAX_SHM_POOLS_PER_ZONE: usize = 128; -pub const MAX_SHM_POOL_BYTES: usize = 64 << 20; // room for a 4K RGBA frame and stride padding -pub const MAX_SHM_BYTES_PER_SESSION: usize = 128 << 20; -pub const MAX_SHM_BYTES_PER_ZONE: usize = 256 << 20; +/// Each pool is a mapping in dwl: far below vm.max_map_count, where dwl's own mappings would fail. +pub const MAX_SHM_POOLS_PER_SESSION: usize = 128; +pub const MAX_SHM_POOLS_PER_ZONE: usize = 256; +pub const MAX_SHM_POOL_BYTES: usize = 128 << 20; // room for a 6K RGBA frame and stride padding +/// What dwl maps for a zone at one time. A page dwl reads before the zone wrote it is charged to +/// dwl, not to the zone's memory_max; these bound that while the pools live, not after. +pub const MAX_SHM_BYTES_PER_SESSION: usize = 1 << 30; +pub const MAX_SHM_BYTES_PER_ZONE: usize = 1 << 30; /// A pool a surface may still show stays charged, so these bound what one session can hold. pub const MAX_SUBSURFACES_PER_SESSION: usize = 256; pub const MAX_HELD_PER_SURFACE: usize = 64; diff --git a/compositor/wlproxy/src/session/tests.rs b/compositor/wlproxy/src/session/tests.rs index 213157f6..84f3cb57 100644 --- a/compositor/wlproxy/src/session/tests.rs +++ b/compositor/wlproxy/src/session/tests.rs @@ -285,9 +285,9 @@ fn shm_pool_limits() { for id in 4..4 + policy::MAX_SHM_POOLS_PER_SESSION as u32 { send_with_fd(c.as_raw_fd(), &create(id, 4096), fd.as_raw_fd()); pump_all(&mut s).unwrap(); + read_all(&mut sv); } assert_eq!(s.shm_pool_count, policy::MAX_SHM_POOLS_PER_SESSION); - read_all(&mut sv); let denied = 4 + policy::MAX_SHM_POOLS_PER_SESSION as u32; send_with_fd(c.as_raw_fd(), &create(denied, 4096), fd.as_raw_fd()); assert!(matches!(pump_all(&mut s), Err(SessionError::ResourceLimit(_)))); @@ -296,17 +296,40 @@ fn shm_pool_limits() { let (mut s, mut c, mut sv) = make(); s.objects.place(3, (protocol::find("wl_shm").unwrap(), 1)); - for id in 4..6 { + let full = (policy::MAX_SHM_BYTES_PER_SESSION / policy::MAX_SHM_POOL_BYTES) as u32; + for id in 4..4 + full { send_with_fd(c.as_raw_fd(), &create(id, policy::MAX_SHM_POOL_BYTES as i32), fd.as_raw_fd()); pump_all(&mut s).unwrap(); + read_all(&mut sv); } assert_eq!(s.shm_pool_bytes, policy::MAX_SHM_BYTES_PER_SESSION); - read_all(&mut sv); - send_with_fd(c.as_raw_fd(), &create(6, 4096), fd.as_raw_fd()); + send_with_fd(c.as_raw_fd(), &create(4 + full, 4096), fd.as_raw_fd()); assert!(matches!(pump_all(&mut s), Err(SessionError::ResourceLimit(_)))); assert!(read_all(&mut sv).is_empty()); } +#[test] +fn a_browser_on_two_4k_heads_fits() { + // Firefox draws each window into pools of its own, a front buffer, the one it draws and up to + // three spare, and GTK holds two more for the window: two 4K windows and developer tools. + let (mut s, mut c, mut sv) = make(); + s.objects.place(3, (protocol::find("wl_shm").unwrap(), 1)); + let (fd, _) = UnixStream::pair().unwrap(); + let frame = 3840 * 2160 * 4; + for id in 4..4 + 3 * 7 { + send_with_fd(c.as_raw_fd(), &MessageWriter::new(3, 0).u32(id).i32(frame).finish().unwrap(), fd.as_raw_fd()); + pump_all(&mut s).unwrap(); + read_all(&mut sv); + } + assert_eq!(s.shm_pool_bytes, 3 * 7 * frame as usize); + s.objects.place(30, (protocol::find("xdg_surface").unwrap(), 1)); + for id in 31..31 + 12 { + c.write_all(&MessageWriter::new(30, 1).u32(id).finish().unwrap()).unwrap(); + pump_all(&mut s).unwrap(); + } + assert_eq!(s.toplevels, 12); +} + #[test] fn pool_budget_follows_buffers() { let (mut s, mut c, mut sv) = make(); diff --git a/docs/audit-scope.md b/docs/audit-scope.md index a77a2720..fc6aa330 100644 --- a/docs/audit-scope.md +++ b/docs/audit-scope.md @@ -121,7 +121,10 @@ the author, with their findings fixed: across zones, and pools are bounded per connection and per zone. Whether the compositor's share of that memory is charged to the zone's cgroup is still a measurement to make - ([resource limits](design/resource-limits-and-ephemeral-zones.md#tests)). + ([resource limits](design/resource-limits-and-ephemeral-zones.md#tests)), + and the bounds hold it only while the pools live: a zone that keeps a + pool's descriptor keeps what the compositor faulted + ([Wayland shared memory](design/resource-limits-and-ephemeral-zones.md#wayland-shared-memory)). The broker's request parser and the proxy's wire parser have seeded mutation tests with corpora in the tree. Coverage-guided fuzzing is not run. diff --git a/docs/design/browser-and-graphics.md b/docs/design/browser-and-graphics.md index b495aeb1..962adb56 100644 --- a/docs/design/browser-and-graphics.md +++ b/docs/design/browser-and-graphics.md @@ -22,9 +22,10 @@ root. The proposed decision is ADR-016 in [decisions](../decisions.md). (below). A cursor named through `wp_cursor_shape_manager_v1` is drawn by the compositor from its own theme, while an image a zone draws is never taken. It disconnects a client - that binds anything else, and bounds shared-memory pools at 64 MiB each, - 128 MiB per connection and 256 MiB per zone - (`compositor/wlproxy/src/policy.rs`). + that binds anything else, and bounds shared-memory pools at 128 MiB each + and 1 GiB per connection and per zone, which Firefox on two 4K heads fits + (`compositor/wlproxy/src/policy.rs`, + [resource limits](resource-limits-and-ephemeral-zones.md#wayland-shared-memory)). - **A zone has no GPU device.** Its `/dev` holds `null`, `zero`, `full`, `random`, `urandom`, `tty`, its own `/dev/shm` and a devpts instance (`DEVICES` in `compartments/kryptikd/src/rootfs.rs`). diff --git a/docs/design/resource-limits-and-ephemeral-zones.md b/docs/design/resource-limits-and-ephemeral-zones.md index a1df3355..7bd141b9 100755 --- a/docs/design/resource-limits-and-ephemeral-zones.md +++ b/docs/design/resource-limits-and-ephemeral-zones.md @@ -108,6 +108,52 @@ buffers are deleted and no surface shows one of them, counting each buffer a synchronized subsurface committed until wlroots applies that subsurface's cache. +## Wayland shared memory + +A zone's windows reach dwl as shared memory: a client hands dwl a descriptor +(`wl_shm.create_pool`), which dwl maps and draws from. The proxy bounds what +a zone hands over (`compositor/wlproxy/src/policy.rs`): + +| Bound | Per connection | Per zone | What it holds | +| --- | --- | --- | --- | +| Pools | 128 | 256 | dwl's mappings, one a pool, far below `vm.max_map_count`, past which dwl's own mappings fail | +| Bytes in pools | 1 GiB | 1 GiB | what dwl maps for a zone at one time, at most 128 MiB a pool (a 6K frame) | +| Windows | 32 | 64 | how many windows a zone puts on the screen | + +Passing one ends the client's connection, as Wayland answers a pool or a +window it will not make only with a protocol error. Firefox draws all its +windows over one connection, so a breach closes every one of them at once. + +**What a browser needs.** Firefox renders each window into pools of its own: +the buffer on screen, the one it draws and up to three spare +(`widget/gtk/WindowSurfaceWaylandMultiBuffer.cpp`), and GTK holds one or two +for the window itself. At 3840×2160 a frame is 33 MB, so a window takes 100 +to 230 MB. Two windows on two 4K heads, developer tools and a +picture-in-picture window come to about 0.7 GB in about 30 pools. The bounds +before these, 128 MiB a connection and 256 MiB a zone, ended Firefox with one +4K window open, and a ninth window under the earlier limit of 8. + +**What the zone pays for, and what dwl does.** A page the zone writes is +charged to the zone's cgroup and held by its `memory_max`. A page dwl reads +before the zone has written it is allocated by dwl's fault and charged to +dwl. The byte bounds limit that while the pools live: a zone can make dwl +hold at most 1 GiB this way at one time. They do not limit it after. A zone +that keeps its own descriptor once a pool is gone keeps the pages dwl +faulted, still charged to dwl, and can do it again with a new pool, a +screen's worth of pages a frame. No bound on live pools closes that, at any +size. + +**Closing it, not built yet.** The proxy would keep a duplicate of each +pool's descriptor and punch the pool's range (`fallocate` with +`FALLOC_FL_PUNCH_HOLE`) once the pool and its buffers are gone and no surface +shows one, which frees what dwl faulted. It would refuse a pool it could not +punch: a read-only descriptor, a write seal, a file that is not shared +memory. What dwl holds for a zone is then bounded by the per-zone bound, and +a `memory.max` on the session's cgroup contains the rest. Checking at a +commit that a buffer's pages are resident is not enough, as the zone can +punch them itself between the check and dwl's read. The measurement under +Tests confirms the charge on the installed kernel. + ## Files `cgroup.rs`, `spawn.rs` (cgroup setup before the intermediate unshares; From c0e0266c74046f4fb5a468cabcda10707134a300 Mon Sep 17 00:00:00 2001 From: DevomB Date: Wed, 14 Oct 2026 22:54:15 -0700 Subject: [PATCH 093/123] The roadmap names the memory a zone can make the compositor hold, and the work that bounds it A page dwl reads before the zone writes it is charged to dwl, and a zone that keeps a pool's descriptor keeps it after the pool is gone, which the proxy's bounds on live pools do not hold. The item orders the measurement first, then punching each pool's range once gone and a memory.max on the session's cgroup. --- docs/roadmap.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/roadmap.md b/docs/roadmap.md index 1593a132..27f6f1c1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -183,6 +183,15 @@ work in zones from a terminal and a text browser, and keep it up to date. - [x] **A destroy verb.** `kryptikd volume destroy NAME` erases a stopped zone's key slots and deletes its container; it refuses an open volume ([design](design/encrypted-volumes.md)). The zone's definition stays. +- [ ] **Memory a zone makes the compositor hold.** A page of a zone's + shared-memory pool that dwl reads before the zone writes it is charged + to dwl, not to the zone's `memory_max`, and a zone that keeps the + pool's descriptor keeps that page after the pool is gone; the proxy's + bounds hold it only while the pools live + ([Wayland shared memory](design/resource-limits-and-ephemeral-zones.md#wayland-shared-memory)). + First the measurement (`wlprobe charge`), then the proxy punching each + pool's range once the pool and its buffers are gone, refusing a pool it + cannot punch, and a `memory.max` on the session's cgroup. ## Version 2 From 31576d7f534c6ca49665e4bb17c6aa7161a6edea Mon Sep 17 00:00:00 2001 From: DevomB Date: Thu, 15 Oct 2026 01:14:02 -0700 Subject: [PATCH 094/123] The browser design says how the browser key and the menu would bring a zone's open Firefox forward instead of starting another --- docs/design/browser-and-graphics.md | 49 +++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/docs/design/browser-and-graphics.md b/docs/design/browser-and-graphics.md index 4483fffc..b6f061f5 100644 --- a/docs/design/browser-and-graphics.md +++ b/docs/design/browser-and-graphics.md @@ -90,6 +90,55 @@ root. The decision is ADR-016 in [decisions](../decisions.md). - **Updates.** The extended-support release (ESR) has a security release every four weeks, like the rapid channel, with fewer feature changes. +### A second launch where Firefox is open + +With no remote, a second `firefox` in a zone whose browser is open finds +its profile locked and says that Firefox is already running. The browser +key (Alt+Shift+u) and the menu's `b` should instead put that zone's open +Firefox window in front, and start Firefox only when the zone has none. A +design, not yet built: + +- **Who knows the window.** dwl alone. For every window it holds the zone, + taken from the socket the client came through and never from the client, + the `app_id` Firefox sets, its tags and monitor, and the focus order. The + `app_id` is the client's own word, but a match is only sought among one + zone's windows, so a zone that claims it can bring forward only its own + window. The chrome sees only the focused window of each output, from + dwl's status. The launch daemon sees the zone's processes, not its + windows. +- **The key.** A dwl action in place of `spawn`, focus-or-launch, given the + zone, the `app_id` and the launch command. It takes the first window in + the focus order whose zone and `app_id` match, so the one used last. If + that window is not shown, dwl first views its first tag on its monitor, + as Alt and the tag's number would, then gives it the keyboard. With no + such window, it starts the command as the key does now. No channel to + dwl is needed. +- **The menu.** The chrome keeps the launch, as the zone's passphrase is + asked on its terminal. It needs two things from dwl: + - dwl's status gains a line naming each zone's open `app_id`s. The + chrome's status reader records it beside the focus record, in the + session's runtime directory, and `b` looks there first. + - When the browser is open, the chrome writes a one-line request, the + zone and that `app_id`, to a file in that directory. dwl watches it the + way it watches the microphone and camera marks: inotify on the + directory, and a file that must be the session user's own and not + writable by group or others. dwl reads it, removes it and acts on it as + the key does. The runtime directory is the session user's alone, mode + 0700, so no zone can make the request. +- **On another tag or monitor.** dwl views the window's tag on the monitor + it is on, and the keyboard follows it there. A window is never moved + between tags, so the vault keeps its tag. +- **A waiting zone.** The window of a zone frozen after a sleep, its volume + waiting for the passphrase, comes forward all the same and does not + answer. The menu already says the zone is waiting and asks with `p`. +- **Not chosen.** Firefox's remote over a D-Bus session bus private to the + zone would hand the second launch to the open browser, but it puts D-Bus + back into the image and the zone, which the build leaves out on purpose. + A Wayland protocol for the chrome, wlroots' foreign-toplevel management + or one of Kryptik's own, is more code than a status line and a watched + file, and foreign-toplevel management carries no zone: the chrome would + have to trust the titles. + ### Chromium Google builds and supports Chromium with clang alone, and it needs Rust and From de5cf5894d4685c569c9964e93f21ccc73757c6d Mon Sep 17 00:00:00 2001 From: DevomB Date: Thu, 15 Oct 2026 02:00:05 -0700 Subject: [PATCH 095/123] Firefox is built with Node.js's release build, a layer no zone gets ESR 153's configure takes --disable-nodejs, but its build runs Node.js for the devtools debugger's modules all the same, so the browser's build stopped there. The node layer unpacks the program alone from Node.js's linux-x64 build, whose checksum list a releaser's pinned key signs, and the browser's mozconfig names it. verify_sums fetches a cached checksum list again when it does not name the file: Node.js names every release's list SHASUMS256.txt, so the one cached for a release would refuse the next. --- build/images/browser.sh | 10 ++++++---- build/images/node.sh | 20 ++++++++++++++++++++ build/images/sources.lock | 1 + build/images/versions.env | 2 ++ docs/design/browser-and-graphics.md | 12 ++++++++---- tools/currency-rules.tsv | 2 ++ tools/fetch-sources.sh | 1 + tools/key-provenance.tsv | 1 + tools/tests/verify-signatures.sh | 17 +++++++++++++++++ tools/verify-signatures.sh | 8 ++++++++ 10 files changed, 66 insertions(+), 8 deletions(-) create mode 100644 build/images/node.sh diff --git a/build/images/browser.sh b/build/images/browser.sh index 6272c2ab..7546d4a0 100644 --- a/build/images/browser.sh +++ b/build/images/browser.sh @@ -1,12 +1,12 @@ #!/usr/bin/env bash # The browser image: Firefox ESR for Wayland alone, over the toolkit image, built with the rust, -# clang, cbindgen and nasm layers, which it does not carry (docs/design/browser-and-graphics.md). +# clang, cbindgen, nasm and node layers, which it does not carry (docs/design/browser-and-graphics.md). # Its own copies of NSS, NSPR, libvpx, dav1d and opus stay bundled. Its PDF viewer is the document # viewer. Pins in build/images/versions.env. # shellcheck disable=SC2034 # read by build/stages/04-images.sh image_version="${V_FIREFOX}esr" # shellcheck disable=SC2034 -image_over=(toolkit rust clang cbindgen nasm) +image_over=(toolkit rust clang cbindgen nasm node) # shellcheck disable=SC2034 image_rows=( "firefox" "s_browser_firefox" @@ -14,7 +14,7 @@ image_rows=( ) # Stage 04's GCC and hardening flags; libclang only for bindgen. With no network: Firefox's own vendored -# crates and Python packages, no toolchain bootstrap, no Node.js. No D-Bus, which no zone has, no updater +# crates and Python packages, and no toolchain bootstrap. No D-Bus, which no zone has, no updater # (updates are the image's), no crash reporter, no EME, no jemalloc (hardened_malloc is preloaded). Sound # through ALSA, whose default device in a zone is zone 0's mixer; Web MIDI links ALSA's sequencer, which # reaches no device, as the kernel builds none. The first build keeps no wasm sandbox for its libraries. @@ -45,6 +45,8 @@ s_browser_firefox() { # Configure requires unzip, which only packaging runs (make_unzip.py: `unzip ARCHIVE`, into the # current directory) and the base system does not have: Python's zipfile does that, here alone. # And llvm-objdump, which it finds by asking the compiler when that is clang: GCC's, it is named. + # And Node.js, which the build runs for the devtools debugger's modules whatever --disable-nodejs + # says: not on the PATH, it is named, and configure stops at once if it is wrong. cat > kryptik-unzip <<'EOF' #!/usr/bin/env python3 import sys, zipfile @@ -67,7 +69,6 @@ ac_add_options --disable-debug ac_add_options --disable-debug-symbols ac_add_options --disable-tests ac_add_options --disable-bootstrap -ac_add_options --disable-nodejs ac_add_options --disable-dbus ac_add_options --disable-necko-wifi ac_add_options --disable-updater @@ -82,6 +83,7 @@ mk_add_options MOZ_OBJDIR=@TOPSRCDIR@/obj export UNZIP=${src}/kryptik-unzip export LLVM_OBJDUMP=${llvm}/bin/llvm-objdump export BINDGEN_CFLAGS="--gcc-install-dir=${gccdir}" +export NODEJS=/usr/lib/node-${V_NODE%%.*}/bin/node EOF env CC=gcc CXX=g++ MOZBUILD_STATE_PATH="${src}/.mozbuild" MACH_BUILD_PYTHON_NATIVE_PACKAGE_SOURCE=none \ MOZ_NOSPAM=1 ./mach --no-interactive build diff --git a/build/images/node.sh b/build/images/node.sh new file mode 100644 index 00000000..628b4afe --- /dev/null +++ b/build/images/node.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Node.js's release build, which Firefox's build runs to generate its devtools debugger's modules. A +# layer that only builds use; no zone gets it (docs/design/browser-and-graphics.md). +# shellcheck disable=SC2034 # read by build/stages/04-images.sh +image_version="${V_NODE}" +# shellcheck disable=SC2034 +image_ships=no +# shellcheck disable=SC2034 +image_rows=( + "node" "s_node" +) + +# The program alone, under /usr/lib/node-MAJOR: the build needs neither npm nor the headers. +s_node() { + local top="node-v${V_NODE}-linux-x64" dir="/usr/lib/node-${V_NODE%%.*}" + mkdir -p "$dir" + tar --no-same-owner -xJf "${KRYPTIK_SOURCES}/${top}.tar.xz" -C "$dir" --strip-components=1 "${top}/bin/node" + # A script, not --version, which returns before the engine starts. + "${dir}/bin/node" -e 'console.log(process.version)' +} diff --git a/build/images/sources.lock b/build/images/sources.lock index fb451ed6..c7b30d00 100644 --- a/build/images/sources.lock +++ b/build/images/sources.lock @@ -16,6 +16,7 @@ a7ced37f4102b745ac86d6a70a9da399cc139ff168ba6b8002b4d8d43c900c15 libepoxy-1.5.1 2b6e15b06aafb4c0b6e2348124808cbd9b291c647299eaaba2e3202f51ff2f3d libglvnd-v1.7.0.tar.gz d80dd2a38a37f803cb9b6ac7b14bd6e74ddc3b654780a8380bdf93523fdb4389 libpng-1.6.59.tar.xz 87336eba53b4acfe917424ab5d500d2b0054d9f5148d35c2273ccf2cfb712f0d nasm-3.02.tar.xz +cc2f601cee82d9a5a233302fe307abc600727035fd7d2c3a3571755989e95008 node-v24.22.0-linux-x64.tar.xz 342385b6ca3b7c73455d7c80a13b7dbe4489e00bc3bd4c5bd6ed4dce421e374a pango-1.58.2.tar.xz 53c156e1ba416a20da8e65395daa132da0d80e76910424caca3fcdae7831d384 pcre2-10.49.tar.bz2 fa3ff450172a16c026944030230c5069947af93c728d9179971d44e5e0cfb561 rust-std-1.98.1-x86_64-unknown-linux-gnu.tar.xz diff --git a/build/images/versions.env b/build/images/versions.env index a89ff200..39a15e67 100644 --- a/build/images/versions.env +++ b/build/images/versions.env @@ -37,3 +37,5 @@ V_RUST=1.98.1 V_LLVM=23.1.3 V_CBINDGEN=0.29.4 V_NASM=3.02 +# Node.js's release build, which Firefox's build runs for its devtools: an LTS line. +V_NODE=24.22.0 diff --git a/docs/design/browser-and-graphics.md b/docs/design/browser-and-graphics.md index b6f061f5..3b7207ee 100644 --- a/docs/design/browser-and-graphics.md +++ b/docs/design/browser-and-graphics.md @@ -59,9 +59,9 @@ root. The decision is ADR-016 in [decisions](../decisions.md). - **Build.** Firefox builds with GCC 11.1 or newer on Linux, so stage 04's GCC 14 can compile it. It still needs clang and libclang, since bindgen generates its style system's bindings through libclang whatever the - compiler; the pinned Rust toolchain and cbindgen; Node.js, unless the - build passes `--disable-nodejs`, which upstream warns will become an - error; Python 3; and nasm for its codecs' SIMD. Its wasm-sandboxed + compiler; the pinned Rust toolchain and cbindgen; Node.js, which the + build runs for the devtools debugger's modules, `--disable-nodejs` or + not; Python 3; and nasm for its codecs' SIMD. Its wasm-sandboxed libraries (RLBox: graphite, hunspell, expat, ogg, woff2 and others run as WebAssembly compiled back to native code) need clang's `wasm32` target and a WASI sysroot. They are worth keeping: a font or spelling parser bug stays @@ -224,7 +224,11 @@ floors are given with each layer. and the crates its lock file names are fetched before the build, since the chroot has no network, and checked against that lock. - **`nasm`:** for dav1d's AV1 decoder. - - **Node.js** is left out: ESR 153 still takes `--disable-nodejs`. + - **`node`:** Node.js's release build, the program alone, which the build + runs for the devtools debugger's modules. ESR 153's configure still + takes `--disable-nodejs`, but the build then stops at those modules. + 12.22.12 or newer; the pin keeps to an LTS line, and one of the + releasers whose keys Node.js lists signs its checksum list. - **The build.** - Stage 04's GCC with the hardening flags; an exception each flag needs goes into `hardening-exceptions.txt` with its reason. Firefox drops diff --git a/tools/currency-rules.tsv b/tools/currency-rules.tsv index bd5bc936..72ca3d19 100644 --- a/tools/currency-rules.tsv +++ b/tools/currency-rules.tsv @@ -62,6 +62,8 @@ rustc listing https://api.github.com/repos/rust-lang/rust/releases?per_page=20 " cbindgen listing https://api.github.com/repos/mozilla/cbindgen/releases?per_page=20 "tag_name": "v?([0-9]+\.[0-9]+\.[0-9]+)" - # The directory links alone: the page's DOCTYPE names HTML 4.01, and "4.01//" is no release. nasm listing https://www.nasm.us/pub/nasm/releasebuilds/ href="([0-9]+\.[0-9]+(\.[0-9]+)?)/" - +# Every release in one index; a new major is a new LTS line, not a currency fix. +node listing https://nodejs.org/dist/index.json "version":"v([0-9]+\.[0-9]+\.[0-9]+)" major # The current ESR and, while two overlap, the next one; the pin's own ESR series is what counts. firefox listing https://product-details.mozilla.org/1.0/firefox_versions.json "FIREFOX_ESR[_NEXT]*": "([0-9]+\.[0-9]+\.[0-9]+)esr" major # An odd minor is a development snapshot. diff --git a/tools/fetch-sources.sh b/tools/fetch-sources.sh index 181e8451..40ef95a8 100755 --- a/tools/fetch-sources.sh +++ b/tools/fetch-sources.sh @@ -214,6 +214,7 @@ rust|rust-std|${V_RUST}|https://static.rust-lang.org/dist/rust-std-${V_RUST}-x86 clang|llvm|${V_LLVM}|${MIRROR_GITHUB}/llvm/llvm-project/releases/download/llvmorg-${V_LLVM}/LLVM-${V_LLVM}-Linux-X64.tar.zst|sig|github cbindgen|cbindgen|${V_CBINDGEN}|https://static.crates.io/crates/cbindgen/cbindgen-${V_CBINDGEN}.crate|none|rule nasm|nasm|${V_NASM}|https://www.nasm.us/pub/nasm/releasebuilds/${V_NASM}/nasm-${V_NASM}.tar.xz|none|rule +node|node|${V_NODE}|https://nodejs.org/dist/v${V_NODE}/node-v${V_NODE}-linux-x64.tar.xz|sums:SHASUMS256.txt.sig|rule browser|firefox|${V_FIREFOX}|https://archive.mozilla.org/pub/firefox/releases/${V_FIREFOX}esr/source/firefox-${V_FIREFOX}esr.source.tar.xz|asc|rule MANIFEST } diff --git a/tools/key-provenance.tsv b/tools/key-provenance.tsv index d1995337..4f7c565f 100644 --- a/tools/key-provenance.tsv +++ b/tools/key-provenance.tsv @@ -117,3 +117,4 @@ D33A3F0CA16B0ACC51A60738494C8A5FBF4DECEB wkd mak@debian.org 14F26682D0916CDD81E37B6D61B7B526D98F0353 origin https://archive.mozilla.org/pub/firefox/releases/153.5.0esr/KEY 2026-10-15 firefox Mozilla Software Releases ; the file carries the signing subkey 827E658608679618CD349F93678E455D76767AA3 (2026-08-06, to 2028-08-05) and the revocation of the leaked 09BEED63F3462A2DFFAB3B875ECB6497C1A20256, neither of which keyserver.ubuntu.com serves; V_FIREFOX's bump moves this URL 108F66205EAEB0AAA8DD5E1C85AB96E6FA1BE5FE origin https://static.rust-lang.org/rust-key.gpg.ascii 2026-10-13 rustc,cargo,rust-std Rust Language (Tag and Release Signing Key) ; served beside the releases it signs D574BD5D1D0E98895E3BF90044F2485E45D59042 origin https://releases.llvm.org/release-keys.asc 2026-10-13 llvm Tobias Hieta ; one of the release managers' keys LLVM's site lists, and the one that signs 23.1.3 +C82FA3AE1CBEDC6BE46B9360C43CEC45C17AB93C origin https://raw.githubusercontent.com/nodejs/release-keys/HEAD/keys/C82FA3AE1CBEDC6BE46B9360C43CEC45C17AB93C.asc 2026-10-15 node Richard Lau ; one of the releasers' keys in Node.js's release-keys repository, and the one that signs 24.22.0 diff --git a/tools/tests/verify-signatures.sh b/tools/tests/verify-signatures.sh index 2c3a37a4..d39f06b9 100755 --- a/tools/tests/verify-signatures.sh +++ b/tools/tests/verify-signatures.sh @@ -476,6 +476,23 @@ fresh_root; run expect_fail "a file the signed list does not name fails" \ "does not match a digest in summed-SHA-256.txt" +# Like Node.js's SHASUMS256.txt: every release's list has the same name, so the one cached can be an +# earlier release's, and its signature with it. +for r in first second; do printf 'fixture payload for %s\n' "$r" > "${SRC}/${r}.tar.gz"; done +list_release() { # list_release NAME: upstream's list names NAME's file alone, signed afresh + sha256sum "${SRC}/$1.tar.gz" | sed "s# .*# $1.tar.gz#" > "${SRC}/SHASUMS256.txt" + fixgpg --yes --local-user good@example.test \ + --detach-sign -o "${SRC}/SHASUMS256.txt.sig" "${SRC}/SHASUMS256.txt" >/dev/null 2>&1 +} +list_release first +: > "${W}/manifest"; add_row first sums:SHASUMS256.txt.sig +fresh_root; run --strict +expect_pass "a list named alike for every release verifies" "verified: 1" +list_release second +: > "${W}/manifest"; add_row second sums:SHASUMS256.txt.sig +fresh_root; run --strict +expect_pass "an earlier release's cached list is fetched again, with its signature" "verified: 1" + # --- unaudited imported keys ------------------------------------------------ write_manifest unknown diff --git a/tools/verify-signatures.sh b/tools/verify-signatures.sh index d00509fa..fc261077 100755 --- a/tools/verify-signatures.sh +++ b/tools/verify-signatures.sh @@ -278,6 +278,9 @@ PINNED_FPRS=( "108F66205EAEB0AAA8DD5E1C85AB96E6FA1BE5FE" # Rust Language, rustc, cargo and rust-std # https://releases.llvm.org/release-keys.asc (retrieved 2026-10-13), the release managers' keys. "D574BD5D1D0E98895E3BF90044F2485E45D59042" # Tobias Hieta, LLVM's release builds + # https://github.com/nodejs/node#release-keys (retrieved 2026-10-15) lists the releasers' keys, which + # its release-keys repository serves. Each release is signed by one releaser; this one signs 24.22.0. + "C82FA3AE1CBEDC6BE46B9360C43CEC45C17AB93C" # Richard Lau, Node.js ) # Primaries and subkeys: GOODSIG names a subkey, while pins and keys.manifest hold primaries. @@ -786,6 +789,11 @@ verify_sums() { local listname="${signame%.*}" local list="${SIGDIR}/${listname}" want got + # A list named alike for every release (Node.js's SHASUMS256.txt) may be cached from an earlier + # one: if it does not name FILE, it and its signature are fetched again. + if [[ -s "$list" && -z "$(listed_digest "$list" "$file")" ]]; then + rm -f "$list" "${SIGDIR}/${signame}" + fi if [[ ! -s "$list" ]] && ! quiet_fetch "${url%/*}/${listname}" "$list"; then rm -f "$list" warn "${name}: no ${listname} published upstream" From d4dfe2f168cf24c950b2802c6eeeff571e78322a Mon Sep 17 00:00:00 2001 From: DevomB Date: Thu, 15 Oct 2026 02:08:05 -0700 Subject: [PATCH 096/123] The browser's install step takes the build's Python package source ./mach install ran without MACH_BUILD_PYTHON_NATIVE_PACKAGE_SOURCE=none, which the build step sets. Left unset, mach makes virtualenvs of its own and runs pip for its optional packages, in a chroot with no network: it carries on when they fail, but the step's environment was not the build's. --- build/images/browser.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/build/images/browser.sh b/build/images/browser.sh index 6272c2ab..62ecf46a 100644 --- a/build/images/browser.sh +++ b/build/images/browser.sh @@ -85,7 +85,10 @@ export BINDGEN_CFLAGS="--gcc-install-dir=${gccdir}" EOF env CC=gcc CXX=g++ MOZBUILD_STATE_PATH="${src}/.mozbuild" MACH_BUILD_PYTHON_NATIVE_PACKAGE_SOURCE=none \ MOZ_NOSPAM=1 ./mach --no-interactive build - env MOZBUILD_STATE_PATH="${src}/.mozbuild" ./mach --no-interactive install + # The build's package source too: left unset, mach makes virtualenvs of its own and asks pip, which + # has no network here, for its optional packages. + env MOZBUILD_STATE_PATH="${src}/.mozbuild" MACH_BUILD_PYTHON_NATIVE_PACKAGE_SOURCE=none \ + ./mach --no-interactive install test -x /usr/lib/firefox/firefox # Enterprise policies: no telemetry or studies, no update checks (updates are the image's), no # question of the default browser, which a zone has no other of, and none of the first-run pages, From 2af1d7bf766fdfd77dcf0db89b9d452458d547d8 Mon Sep 17 00:00:00 2001 From: DevomB Date: Thu, 15 Oct 2026 02:33:05 -0700 Subject: [PATCH 097/123] Stage 01 fetches the root's sources alone make toolchain runs make sources, which fetches every software image's sources from upstream as well. The Stages 01-02 job restores only the root's set, so it downloaded the images' every run, and failed when one of their hosts was down (icon-theme.freedesktop.org timing out, with no copy of hicolor-icon-theme 0.18 at Gentoo's mirror). No stage there reads them; the images' job restores their cache and fetches what it lacks. --- .github/workflows/distro.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/distro.yml b/.github/workflows/distro.yml index dec003fb..4878f673 100644 --- a/.github/workflows/distro.yml +++ b/.github/workflows/distro.yml @@ -121,7 +121,11 @@ jobs: - name: Stage 01, the cross toolchain (unprivileged) timeout-minutes: 200 - run: make toolchain + # The root's sources alone: make's own sources would fetch every image's from upstream too, + # which no stage here reads and whose cache only the images' jobs restore. + run: | + ./tools/fetch-sources.sh --root + make -o sources toolchain - name: Stage 02, the temporary tools (unprivileged) id: stage02 From 0f7518a7c6a8666039a54a30c5b9d3265bf7d692 Mon Sep 17 00:00:00 2001 From: DevomB Date: Thu, 15 Oct 2026 03:47:38 -0700 Subject: [PATCH 098/123] The installer has kryptikd check the medium's signed image list and images before it writes anything The installer's comment called the medium's image list signed, but it only checked that a .sig was there and that the list's base line named the root it installs. images_keep then copied the sizes the unverified list named onto the new state partition, after the slots and ESP were written, and a malformed line ended the install halfway. kryptikd check-images DIR BASE now checks a medium's list as a kept one is checked: its signature as nobody on a copy (kryptik-update check-images, so in the image namespace, by the release key), then images::parse, the root it names, and every image it names present in DIR at the size it signs. medium-root.sh's medium_images_ok runs it for the root the signed command line names, before the partition table is written, and kryptikd joins the tools checked up front. Each image's hash is still checked as it is copied. verified_list and the new check share the signature step. The design says a corrupted mapping is refused until the machine restarts, as the code does. --- build/service-scripts/medium-root.sh | 11 ++++++ compartments/kryptikd/src/images.rs | 41 ++++++++++++++++--- compartments/kryptikd/src/images/tests.rs | 44 +++++++++++++++++++++ compartments/kryptikd/src/main.rs | 20 ++++++++++ docs/design/software-delivery.md | 12 ++++-- tools/install/kryptik-install.sh | 11 ++---- tools/tests/installer.sh | 48 +++++++++++++++++++++++ 7 files changed, 170 insertions(+), 17 deletions(-) diff --git a/build/service-scripts/medium-root.sh b/build/service-scripts/medium-root.sh index df506c65..e0968c0e 100755 --- a/build/service-scripts/medium-root.sh +++ b/build/service-scripts/medium-root.sh @@ -10,6 +10,8 @@ # V_HASH V_SALT # kernel_names_root EFI the kernel's command line carries V_HASH # root_verifies DEV DEV holds the root V_HASH names +# medium_images_ok DIR the image list in DIR is signed, for V_HASH, and +# every image it names is in DIR at its size decimal_field() { # decimal_field NAME VALUE: a bounded decimal, or die case "$2" in ''|*[!0-9]*) die "root.json: $1 is not a number" ;; esac @@ -59,6 +61,15 @@ kernel_names_root() { # kernel_names_root EFI [ -f "$1" ] && grep -a -q -F "$V_HASH" "$1" } +# The software images a medium carries, before anything is written: a list +# signed as one, for the root the signed command line names, naming only images +# DIR holds at the sizes it signs (kryptikd check-images). Each image's hash is +# checked as it is copied (image-store.sh). +medium_images_ok() { # medium_images_ok DIR + _said="$(kryptikd check-images "$1" "$V_HASH" 2>&1)" \ + || die "the medium's software images are refused: ${_said#kryptikd: check-images: }" +} + # The record can lie about itself; the signed kernel's root hash cannot. root_verifies() { # root_verifies DEV veritysetup verify --no-superblock --hash=sha256 --data-block-size=4096 --hash-block-size=4096 \ diff --git a/compartments/kryptikd/src/images.rs b/compartments/kryptikd/src/images.rs index 5eda1b31..161e7319 100644 --- a/compartments/kryptikd/src/images.rs +++ b/compartments/kryptikd/src/images.rs @@ -291,18 +291,49 @@ fn verified_list(base: &str) -> Result { let text = read_bounded(&list, LIST_MAX) .map_err(|e| format!("no software images are kept for this root ({}...): {e}", &base[..12]))?; let sig = read_bounded(&list.with_extension("manifest.sig"), SIG_MAX)?; + signed(&text, &sig).map_err(|e| format!("the image list for this root is refused: {e}"))?; + let m = parse(std::str::from_utf8(&text).map_err(|_| "the image list is not text".to_string())?)?; + if m.base != base { + return Err(format!("the image list kept for this root is for another root ({})", m.release)); + } + Ok(m) +} + +/// Whether `sig` signs `text` as an image list, checked as nobody on a copy of these bytes. +fn signed(text: &[u8], sig: &[u8]) -> Result<(), String> { let dir = crate::update::make_scratch(Path::new("/run/kryptik"))?; - let checked = fs::write(dir.join("manifest"), &text) - .and_then(|_| fs::write(dir.join("manifest.sig"), &sig)) + let checked = fs::write(dir.join("manifest"), text) + .and_then(|_| fs::write(dir.join("manifest.sig"), sig)) .map_err(|e| format!("{}: {e}", dir.display())) .and_then(|_| crate::update::check_images(&dir)); let _ = fs::remove_dir_all(&dir); - checked.map_err(|e| format!("the image list for this root is refused: {e}"))?; + checked.map(|_| ()) +} + +/// A medium's images.manifest and images.manifest.sig in `dir`, checked as the installer has them +/// before it writes anything: signed as an image list, read as a kept list is, for the root `base` +/// it installs, and naming only images `dir` holds as NAME.img at the size the list signs. Each +/// image's hash is checked as it is copied (image-store.sh). +pub fn check_medium(dir: &Path, base: &str) -> Result { + check_medium_with(dir, base, &signed) +} + +fn check_medium_with(dir: &Path, base: &str, check: &dyn Fn(&[u8], &[u8]) -> Result<(), String>) -> Result { + let text = read_bounded(&dir.join("images.manifest"), LIST_MAX)?; + let sig = read_bounded(&dir.join("images.manifest.sig"), SIG_MAX)?; + check(&text, &sig).map_err(|e| format!("the medium's image list is refused: {e}"))?; let m = parse(std::str::from_utf8(&text).map_err(|_| "the image list is not text".to_string())?)?; if m.base != base { - return Err(format!("the image list kept for this root is for another root ({})", m.release)); + return Err(format!("the medium's image list ({}) is for another root than the one it installs", m.release)); } - Ok(m) + for img in &m.images { + let file = dir.join(format!("{}.img", img.name)); + let md = fs::metadata(&file).map_err(|e| format!("image {}: {}: {e}", img.name, file.display()))?; + if !md.is_file() || md.len() != img.bytes { + return Err(format!("image {}: {} holds {} bytes, and the list signs {}", img.name, file.display(), md.len(), img.bytes)); + } + } + Ok(format!("{} images of release {} for this root\n", m.images.len(), m.release)) } fn read_bounded(path: &Path, max: u64) -> Result, String> { diff --git a/compartments/kryptikd/src/images/tests.rs b/compartments/kryptikd/src/images/tests.rs index 05175f54..58b68179 100644 --- a/compartments/kryptikd/src/images/tests.rs +++ b/compartments/kryptikd/src/images/tests.rs @@ -185,3 +185,47 @@ fn names_are_what_a_path_and_a_zone_file_take() { assert!(!valid_name(bad), "{bad:?}"); } } + +/// What the installer has checked before it writes anything: the medium's list is signed, for the +/// root it installs, and each image it names is there at the size it signs. +#[test] +fn a_medium_is_checked_before_anything_is_written() { + let d = std::env::temp_dir().join(format!("kryptik-images-test-{}-medium", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + std::fs::write(d.join("images.manifest"), list(&[&image("browser")])).unwrap(); + std::fs::write(d.join("images.manifest.sig"), b"signature").unwrap(); + std::fs::write(d.join("browser.img"), vec![0u8; 12288]).unwrap(); + let yes = |_: &[u8], _: &[u8]| -> Result<(), String> { Ok(()) }; + let no = |_: &[u8], _: &[u8]| -> Result<(), String> { Err("not signed by the release key as an image list".into()) }; + + let said = check_medium_with(&d, &hex('1'), &yes).unwrap(); + assert!(said.contains("1 images of release 1.2.0"), "{said}"); + + let refused = check_medium_with(&d, &hex('1'), &no).unwrap_err(); + assert!(refused.contains("the medium's image list is refused: not signed"), "{refused}"); + + let refused = check_medium_with(&d, &hex('2'), &yes).unwrap_err(); + assert!(refused.contains("another root"), "{refused}"); + + std::fs::write(d.join("browser.img"), vec![0u8; 4096]).unwrap(); + let refused = check_medium_with(&d, &hex('1'), &yes).unwrap_err(); + assert!(refused.contains("holds 4096 bytes, and the list signs 12288"), "{refused}"); + + std::fs::remove_file(d.join("browser.img")).unwrap(); + let refused = check_medium_with(&d, &hex('1'), &yes).unwrap_err(); + assert!(refused.starts_with("image browser:"), "{refused}"); + + // A directory where an image should be is no image. + std::fs::create_dir(d.join("browser.img")).unwrap(); + assert!(check_medium_with(&d, &hex('1'), &yes).is_err()); + + std::fs::remove_file(d.join("images.manifest.sig")).unwrap(); + assert!(check_medium_with(&d, &hex('1'), &yes).unwrap_err().contains("images.manifest.sig")); + + // A list that does not parse is refused after its signature, as a kept one is. + std::fs::write(d.join("images.manifest.sig"), b"signature").unwrap(); + std::fs::write(d.join("images.manifest"), "KRYPTIK-IMAGES-1\nrelease: 1.2.0\n").unwrap(); + assert!(check_medium_with(&d, &hex('1'), &yes).is_err()); + let _ = std::fs::remove_dir_all(&d); +} diff --git a/compartments/kryptikd/src/main.rs b/compartments/kryptikd/src/main.rs index aba4d22b..57e9bfbd 100644 --- a/compartments/kryptikd/src/main.rs +++ b/compartments/kryptikd/src/main.rs @@ -114,6 +114,10 @@ USAGE: kryptikd check-release DIR whether DIR's manifest and manifest.sig verify, checked as nobody on copies: how kryptik-update apply, which runs as root, has a release checked + kryptikd check-images DIR BASE a medium's images.manifest and its signature in + DIR, checked as a kept list is, for root BASE, + and each image it names there at its size: the + installer's check before it writes anything --rootfs DIR base directory for zone data (default: /var/lib/kryptik/zones); the zone sees its own directory as /home/NAME @@ -295,6 +299,22 @@ fn main() -> ExitCode { } }, "time" => cmd_time(&args), + "check-images" => match args.as_slice() { + [_, dir, base] => match images::check_medium(Path::new(dir), base) { + Ok(said) => { + print!("{said}"); + ExitCode::SUCCESS + } + Err(why) => { + eprintln!("kryptikd: check-images: {why}"); + ExitCode::FAILURE + } + }, + _ => { + eprintln!("usage: kryptikd check-images DIR BASE"); + ExitCode::from(2) + } + }, "check-release" => match args.as_slice() { [_, dir] => match update::check_release(Path::new(dir)) { Ok(said) => { diff --git a/docs/design/software-delivery.md b/docs/design/software-delivery.md index 4f321f10..a52cc035 100644 --- a/docs/design/software-delivery.md +++ b/docs/design/software-delivery.md @@ -252,7 +252,11 @@ up ADR-001 in the zones where the applications run. neither. The controls stay with the build. Whoever can write the stick can craft that partition, and the installer mounts it, so it is a FAT32 like the ESP: the kernel parses no filesystem for it that the medium did - not already need. What it holds is checked as an update's images are. + not already need. Before the installer writes anything, `kryptikd + check-images` checks the list as a kept one is checked, its signature as + nobody on a copy, for the root the signed command line names, and finds + every image it names there at the size it signs; each image's hash is + checked as it is copied, as an update's are. - **Stored** under `/var/lib/kryptik/images//.img`, with the list for each root as `.manifest` and `.manifest.sig`, root only. Zone 0 keeps the lists of the roots in the two slots and the images @@ -266,8 +270,8 @@ up ADR-001 in the zones where the applications run. image into the store, where the copy is checked against the size and hash the list signs before it takes its name. Once the trial is armed, the store drops the release the slot held. The installer takes the medium's - copy (above): it checks the list is for the root it installs before it - writes anything, and keeps each image as `apply` does. + copy (above): kryptikd checks the list and the images' sizes before it + writes anything, and it keeps each image as `apply` does. `kryptik-recover --restore-slot` does not open the state partition, so a slot it restores finds its images only if the store still holds that release's, from the install or a kept update; otherwise a zone that names @@ -285,7 +289,7 @@ up ADR-001 in the zones where the applications run. the list's signature again, as nobody, on a copy of the bytes it then reads. A mapping that dm-verity has marked corrupted is not used again: the next start of a zone that names the image is refused, with the - reason, until the image is fetched again. For each distinct set of images + reason, until this machine restarts. For each distinct set of images a zone names, kryptikd mounts a read-only overlay (no upper directory; the images' `usr` over the root's `/usr`; redirects in a layer not followed) at `/run/kryptik/usr/` in the initial namespace, and the zone's bind diff --git a/tools/install/kryptik-install.sh b/tools/install/kryptik-install.sh index 3eb4989c..616787b2 100755 --- a/tools/install/kryptik-install.sh +++ b/tools/install/kryptik-install.sh @@ -71,7 +71,7 @@ done missing="" for tool in sfdisk 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 kryptik-efiboot; do + loadkeys chattr kryptik-efiboot kryptikd; do command -v "$tool" >/dev/null 2>&1 || missing="${missing} ${tool}" done [ -z "$missing" ] || die "this system is missing:${missing} @@ -237,13 +237,8 @@ case "$media" in esac medium_root "$ROOT_JSON" /proc/cmdline ROOT_HASH="$(json_field "$ROOT_JSON" root_hash)" -# The medium's image list, read before anything is written: for this medium's root, and signed. -# Each image is checked against it as it is copied (image-store.sh); kryptikd checks the signature. -if [ -n "$IMG_SRC" ]; then - [ -f "$IMG_SRC/images.manifest.sig" ] || die "the medium's image list has no signature" - [ "$(sed -n 's/^base: //p' "$IMG_SRC/images.manifest")" = "$ROOT_HASH" ] \ - || die "the medium's image list is for another root than the one it installs" -fi +# The medium's software images, checked before anything is written (medium-root.sh). +[ -z "$IMG_SRC" ] || medium_images_ok "$IMG_SRC" for s in a b; do kernel_names_root "$MNT_BASE/esp/EFI/kryptik/kryptik-$s.efi" \ || die "the medium's kernel for slot $s does not carry the root hash its signed command line names; nothing was written" diff --git a/tools/tests/installer.sh b/tools/tests/installer.sh index 6039909c..2ac48b11 100755 --- a/tools/tests/installer.sh +++ b/tools/tests/installer.sh @@ -191,6 +191,54 @@ else rm -f "$tmp" "$rec" fi +echo +echo "-- the medium's software images go through kryptikd before anything is written" +# A kryptikd that answers check-images as the medium in DIR has it: DIR/answer is "ok" or the refusal. +# What it checks is images.rs's (check_medium) and kryptik-update's (tools/tests/image-list.sh): an +# unsigned list, one signed in kryptik-release, a missing or short image. This is how the installer +# takes the answer, and where. +if ! declare -F medium_images_ok >/dev/null; then + red "could not read medium_images_ok from ${MEDIUM_ROOT}" +else + stub="$(mktemp -d)" + cat > "${stub}/kryptikd" <<'STUB' +#!/bin/sh +[ "$1" = check-images ] && [ "$#" -eq 3 ] || { echo "usage: kryptikd check-images DIR BASE" >&2; exit 2; } +printf '%s\n' "$3" > "$2/base-asked" +a="$(cat "$2/answer")" +[ "$a" = ok ] && { echo "1 images of release 1.0.0 for this root"; exit 0; } +echo "kryptikd: check-images: $a" >&2 +exit 1 +STUB + chmod 0755 "${stub}/kryptikd" + med="$(mktemp -d)" + V_HASH="$(printf 'c%.0s' $(seq 64))" + echo ok > "${med}/answer" + ( PATH="${stub}:$PATH"; medium_images_ok "$med" ) >/dev/null 2>&1 \ + && [[ "$(cat "${med}/base-asked" 2>/dev/null)" == "$V_HASH" ]] \ + && green "a medium kryptikd accepts is taken, checked for the root the signed command line names" \ + || red "a medium kryptikd accepts was refused, or checked for another root" + for why in "the medium's image list is refused: not signed as an image list" \ + "image browser: /media/images/browser.img holds 4096 bytes, and the list signs 12288"; do + echo "$why" > "${med}/answer" + out="$( (PATH="${stub}:$PATH"; medium_images_ok "$med") 2>&1 )"; rc=$? + [[ "$rc" -ne 0 && "$out" == *"software images are refused: ${why}"* ]] \ + && green "a refusal stops the install with kryptikd's reason (${why%%:*})" \ + || red "a refusal was not passed on: rc=${rc} ${out}" + done + rm -rf "$stub" "$med" +fi +if awk '/medium_images_ok "\$IMG_SRC"/ && !c { c = NR } + /^sfdisk / && !w { w = NR } + END { exit !(c && w && c < w) }' "$INSTALLER"; then + green "the medium's images are checked before the partition table, the first write" +else + red "the medium's image check is not before the first write" +fi +grep -qE 'kryptik-efiboot kryptikd; do$' "$INSTALLER" \ + && green "kryptikd is among the tools checked up front" \ + || red "kryptikd is not among the tools checked up front" + echo echo "-- --slot-size is a number of MiB, no less than a slot needs" eval "$(sed -n '/^slot_size_ok()/,/^}/p' "$INSTALLER")" From fca8ffa0ecd9f2db0e45dd27ae6593be8ae12552 Mon Sep 17 00:00:00 2001 From: DevomB Date: Thu, 15 Oct 2026 03:50:11 -0700 Subject: [PATCH 099/123] A medium's image list and images are taken only as regular files, never through a link or a FIFO An ISO's Rock Ridge can carry links, FIFOs and device nodes. read_bounded now opens with O_NONBLOCK and O_NOFOLLOW and requires a regular file, so a FIFO named images.manifest is refused at once instead of blocking the installer; check_medium looks at an image with symlink_metadata, as the design's "a regular file" and open()'s own rule say. --- compartments/kryptikd/src/images.rs | 13 +++++++++++-- compartments/kryptikd/src/images/tests.rs | 18 +++++++++++++++++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/compartments/kryptikd/src/images.rs b/compartments/kryptikd/src/images.rs index 161e7319..b8feb95e 100644 --- a/compartments/kryptikd/src/images.rs +++ b/compartments/kryptikd/src/images.rs @@ -328,7 +328,7 @@ fn check_medium_with(dir: &Path, base: &str, check: &dyn Fn(&[u8], &[u8]) -> Res } for img in &m.images { let file = dir.join(format!("{}.img", img.name)); - let md = fs::metadata(&file).map_err(|e| format!("image {}: {}: {e}", img.name, file.display()))?; + let md = fs::symlink_metadata(&file).map_err(|e| format!("image {}: {}: {e}", img.name, file.display()))?; if !md.is_file() || md.len() != img.bytes { return Err(format!("image {}: {} holds {} bytes, and the list signs {}", img.name, file.display(), md.len(), img.bytes)); } @@ -337,7 +337,16 @@ fn check_medium_with(dir: &Path, base: &str, check: &dyn Fn(&[u8], &[u8]) -> Res } fn read_bounded(path: &Path, max: u64) -> Result, String> { - let f = fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?; + use std::os::unix::fs::OpenOptionsExt; + // A regular file alone: a medium's ISO can carry a link or a FIFO, and a FIFO would block the read. + let f = fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW) + .open(path) + .map_err(|e| format!("{}: {e}", path.display()))?; + if !f.metadata().map_err(|e| format!("{}: {e}", path.display()))?.is_file() { + return Err(format!("{} is not a regular file", path.display())); + } let mut bytes = Vec::new(); f.take(max + 1).read_to_end(&mut bytes).map_err(|e| format!("{}: {e}", path.display()))?; if bytes.len() as u64 > max { diff --git a/compartments/kryptikd/src/images/tests.rs b/compartments/kryptikd/src/images/tests.rs index 58b68179..bd935f8b 100644 --- a/compartments/kryptikd/src/images/tests.rs +++ b/compartments/kryptikd/src/images/tests.rs @@ -216,9 +216,25 @@ fn a_medium_is_checked_before_anything_is_written() { let refused = check_medium_with(&d, &hex('1'), &yes).unwrap_err(); assert!(refused.starts_with("image browser:"), "{refused}"); - // A directory where an image should be is no image. + // A directory where an image should be is no image, nor is a link to one. std::fs::create_dir(d.join("browser.img")).unwrap(); assert!(check_medium_with(&d, &hex('1'), &yes).is_err()); + std::fs::remove_dir(d.join("browser.img")).unwrap(); + std::fs::write(d.join("elsewhere"), vec![0u8; 12288]).unwrap(); + std::os::unix::fs::symlink(d.join("elsewhere"), d.join("browser.img")).unwrap(); + assert!(check_medium_with(&d, &hex('1'), &yes).unwrap_err().contains("holds"), "a link is not the image"); + std::fs::remove_file(d.join("browser.img")).unwrap(); + std::fs::rename(d.join("elsewhere"), d.join("browser.img")).unwrap(); + + // A FIFO in the list's place is refused at once, never waited on. + let list_text = std::fs::read(d.join("images.manifest")).unwrap(); + std::fs::remove_file(d.join("images.manifest")).unwrap(); + let fifo = std::ffi::CString::new(d.join("images.manifest").to_str().unwrap()).unwrap(); + assert_eq!(unsafe { libc::mkfifo(fifo.as_ptr(), 0o600) }, 0); + assert!(check_medium_with(&d, &hex('1'), &yes).unwrap_err().contains("not a regular file")); + std::fs::remove_file(d.join("images.manifest")).unwrap(); + std::fs::write(d.join("images.manifest"), &list_text).unwrap(); + assert!(check_medium_with(&d, &hex('1'), &yes).is_ok()); std::fs::remove_file(d.join("images.manifest.sig")).unwrap(); assert!(check_medium_with(&d, &hex('1'), &yes).unwrap_err().contains("images.manifest.sig")); From 9859ddd6e2e3aa5dcbed7388890346316477ebbb Mon Sep 17 00:00:00 2001 From: DevomB Date: Thu, 15 Oct 2026 03:54:16 -0700 Subject: [PATCH 100/123] A payload's images are read through the descriptors they were hashed on, and a mapping is used again only if it is the image's The rest of the non-author review of image delivery: - kryptik-update pins each NAME.img.zst the manifest lists, as the root's four files are, and payload_path names its descriptor, so stage_images decompresses the bytes verify_payload hashed even if the payload directory changes in between. - kryptikd uses /dev/mapper/kryptik-image-NAME-HASH again only if veritysetup status shows it is dm-verity with the root hash the list signs, besides not corrupted. - An image's usr must be its own directory (symlink_metadata), not a link that would stack something else over /usr. - apply reads the running root's hash from the kroot table alone. - The ISO is mounted nosuid,nodev,noexec, as the stick's images partition is, and read_bounded looks at a path before it opens it, so no device node there is ever opened. --- compartments/kryptikd/src/images.rs | 27 +++++++++++++++++++---- compartments/kryptikd/src/images/tests.rs | 25 +++++++++++++++++++++ tools/install/kryptik-install.sh | 3 ++- tools/tests/update-manifest-snapshot.sh | 25 +++++++++++++++++++++ tools/update/kryptik-update | 18 +++++++++++++-- 5 files changed, 91 insertions(+), 7 deletions(-) diff --git a/compartments/kryptikd/src/images.rs b/compartments/kryptikd/src/images.rs index b8feb95e..4e62c843 100644 --- a/compartments/kryptikd/src/images.rs +++ b/compartments/kryptikd/src/images.rs @@ -338,7 +338,12 @@ fn check_medium_with(dir: &Path, base: &str, check: &dyn Fn(&[u8], &[u8]) -> Res fn read_bounded(path: &Path, max: u64) -> Result, String> { use std::os::unix::fs::OpenOptionsExt; - // A regular file alone: a medium's ISO can carry a link or a FIFO, and a FIFO would block the read. + /* A regular file alone, looked at before it is opened: a medium's ISO can carry a link, a FIFO, + * which would block the read, or a device node, some of which act on an open. Checked again + * once open, should it have been swapped in between. */ + if !fs::symlink_metadata(path).map_err(|e| format!("{}: {e}", path.display()))?.is_file() { + return Err(format!("{} is not a regular file", path.display())); + } let f = fs::OpenOptions::new() .read(true) .custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW) @@ -370,9 +375,12 @@ fn open(img: &Image) -> Result { let mapper = format!("kryptik-image-{}-{}", img.name, img.root_hash); let dev = format!("/dev/mapper/{mapper}"); if Path::new(&dev).exists() { - // dm-verity marks a mapping corrupted at the first block that reads back other than signed. let status = crate::volume::run("veritysetup status", "veritysetup", &["status", mapper.as_str()], None).map_err(|e| e.1)?; - if status.lines().any(|l| l.split_whitespace().eq(["status:", "corrupted"])) { + let (its, corrupted) = status_of(&status, &img.root_hash); + if !its { + return Err(format!("image {}: {dev} is there, and is not this image's dm-verity mapping", img.name)); + } + if corrupted { return Err(format!( "image {} read back other than signed while in use, so it is not used again until this machine restarts", img.name @@ -394,12 +402,23 @@ fn open(img: &Image) -> Result { // A journal is never replayed: the device under it is read-only. mount(&dev, &at, "ext4", Some("noload"))?; } - if !Path::new(&at).join("usr").is_dir() { + // Its own directory: a link there would stack whatever it names over /usr. + if !fs::symlink_metadata(Path::new(&at).join("usr")).is_ok_and(|m| m.is_dir()) { return Err(format!("image {} holds no usr directory to stack over /usr", img.name)); } Ok(at) } +/// What `veritysetup status` says of a mapping found under an image's name: whether it is that +/// image's, dm-verity with the root hash its list signs as read from the active table, and whether +/// dm-verity has marked it corrupted, at the first block that read back other than signed. +fn status_of(status: &str, root_hash: &str) -> (bool, bool) { + let field = |key: &str| status.lines().find_map(|l| l.trim().strip_prefix(key)).map(str::trim); + let its = field("type:") == Some("VERITY") && field("root hash:") == Some(root_hash); + let corrupted = status.lines().any(|l| l.split_whitespace().eq(["status:", "corrupted"])); + (its, corrupted) +} + /// Read-only, with no set-id programs and no device files, whatever the image holds. fn mount(src: &str, at: &str, fstype: &str, data: Option<&str>) -> Result<(), String> { let c = |s: &str| std::ffi::CString::new(s).map_err(|_| format!("{s:?} holds a NUL")); diff --git a/compartments/kryptikd/src/images/tests.rs b/compartments/kryptikd/src/images/tests.rs index bd935f8b..d0050007 100644 --- a/compartments/kryptikd/src/images/tests.rs +++ b/compartments/kryptikd/src/images/tests.rs @@ -186,6 +186,26 @@ fn names_are_what_a_path_and_a_zone_file_take() { } } +/// A mapping under an image's name is used again only if veritysetup shows it is that image's, as +/// cryptsetup 2.8 prints its status. +#[test] +fn a_mapping_is_used_again_only_if_it_is_this_images() { + let status = |kind: &str, state: &str, root: &str| { + format!( + "/dev/mapper/kryptik-image-browser-{root} is active and is in use.\n type: {kind}\n status: {state}\n \ + hash type: 1\n data block: 4096 [bytes]\n hash block: 4096 [bytes]\n hash name: sha256\n \ + salt: {}\n data device: /dev/loop0\n mode: readonly\n root hash: {root}\n", + hex('c') + ) + }; + let b = hex('b'); + assert_eq!(status_of(&status("VERITY", "verified", &b), &b), (true, false)); + assert_eq!(status_of(&status("VERITY", "corrupted", &b), &b), (true, true)); + assert_eq!(status_of(&status("VERITY", "verified", &hex('d')), &b), (false, false), "another root hash"); + assert_eq!(status_of(&status("LUKS2", "verified", &b), &b).0, false, "not dm-verity"); + assert_eq!(status_of(&status("VERITY", "verified", &b).replace(" root hash:", " root:"), &b).0, false, "no root hash shown"); +} + /// What the installer has checked before it writes anything: the medium's list is signed, for the /// root it installs, and each image it names is there at the size it signs. #[test] @@ -233,6 +253,11 @@ fn a_medium_is_checked_before_anything_is_written() { assert_eq!(unsafe { libc::mkfifo(fifo.as_ptr(), 0o600) }, 0); assert!(check_medium_with(&d, &hex('1'), &yes).unwrap_err().contains("not a regular file")); std::fs::remove_file(d.join("images.manifest")).unwrap(); + // Nor is a link to the list followed. + std::fs::write(d.join("list-elsewhere"), &list_text).unwrap(); + std::os::unix::fs::symlink(d.join("list-elsewhere"), d.join("images.manifest")).unwrap(); + assert!(check_medium_with(&d, &hex('1'), &yes).unwrap_err().contains("not a regular file")); + std::fs::remove_file(d.join("images.manifest")).unwrap(); std::fs::write(d.join("images.manifest"), &list_text).unwrap(); assert!(check_medium_with(&d, &hex('1'), &yes).is_ok()); diff --git a/tools/install/kryptik-install.sh b/tools/install/kryptik-install.sh index 616787b2..850f9d34 100755 --- a/tools/install/kryptik-install.sh +++ b/tools/install/kryptik-install.sh @@ -220,7 +220,8 @@ case "$media" in fi ;; iso) - mount -t iso9660 -o ro /dev/sr0 "$MNT_BASE/media" || die "could not mount the medium (/dev/sr0)" + # As the stick's images partition is: Rock Ridge can carry device nodes and set-id files. + mount -t iso9660 -o ro,nosuid,nodev,noexec /dev/sr0 "$MNT_BASE/media" || die "could not mount the medium (/dev/sr0)" ESP_SRC="$MNT_BASE/media/esp.img" [ -f "$ESP_SRC" ] || die "no esp.img on the medium" # For its kernels, read before anything is written. diff --git a/tools/tests/update-manifest-snapshot.sh b/tools/tests/update-manifest-snapshot.sh index 48edbf34..e6ca101e 100755 --- a/tools/tests/update-manifest-snapshot.sh +++ b/tools/tests/update-manifest-snapshot.sh @@ -363,6 +363,31 @@ out="$(asroot refused verify_manifest "$T/signed" 0)" && ok "as root, kryptikd's refusal is apply's, in the checker's words" \ || bad "verify_manifest as root, refused: $(tail -2 <<<"$out" | tr '\n' ' ')" +# A software image the manifest lists is pinned as the root's files are: what stage_images +# decompresses after the check is the file that was hashed, whatever the directory holds by then. +mkpayload pinned 2 +printf 'the signed image\n' | zstd -q -c > pinned/browser.img.zst +{ sed -n '1,/^--$/p' pinned/manifest | sed 's/^files: 4$/files: 5/' + sed -n '/^--$/,$p' pinned/manifest | sed 1d + printf '%s %s %s\n' "$(sha256sum pinned/browser.img.zst | cut -c1-64)" "$(stat -c %s pinned/browser.img.zst)" browser.img.zst +} > pinned/manifest.new && mv pinned/manifest.new pinned/manifest +ssh-keygen -Y sign -f key -n kryptik-release pinned/manifest >/dev/null 2>&1 +mkdir -p "$T/snap-pinned" +cat > "$T/case-pinned.sh" < /dev/null && echo VERIFIED +printf 'swapped in after the check\n' | zstd -q -c > $T/pinned/browser.img.zst.new +mv $T/pinned/browser.img.zst.new $T/pinned/browser.img.zst +zstd -dcq < "\$(payload_path browser.img.zst)" +EOF +out="$(bash "$T/case-pinned.sh" 2>&1)" +if [[ "$out" == *VERIFIED* && "$out" == *"the signed image"* && "$out" != *swapped* ]]; then + ok "an image the manifest lists is read through the descriptor it was hashed on, not by its name" +else + bad "an image swapped after the check was read, or the payload did not verify: $(tr '\n' ' ' <<<"$out")" +fi + # The tool itself: the checks need neither root nor this system's disks. if [[ "$(id -u)" != 0 ]]; then out="$(sh "$TOOL" check-pointer "$T/ptr/latest" "$T/ptr/latest.sig" 2>&1)" diff --git a/tools/update/kryptik-update b/tools/update/kryptik-update index 770d49d4..1ab66378 100755 --- a/tools/update/kryptik-update +++ b/tools/update/kryptik-update @@ -138,7 +138,12 @@ payload_path() { # NAME -> where to read it case "$1" in 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" ;; + *) + # An image verify_payload pinned (NAME=FD in PINNED), else the payload's file. + case " ${PINNED:-} " in + *" $1="*) _fd="${PINNED#* $1=}"; echo "/proc/self/fd/${_fd%% *}" ;; + *) echo "$dir/$1" ;; + esac ;; esac } @@ -146,6 +151,15 @@ payload_path() { # NAME -> where to read it verify_payload() { # verify_payload DIR RECOVERY -> sets VERSION ROOT_HASH verify_manifest "$1" "$2" pin 3 kryptik-root.img.zst; pin 4 kryptik-a.efi; pin 5 kryptik-b.efi; pin 6 root.json + # And each software image it lists, so the bytes hashed below are those stage_images + # decompresses. A name with a directory in it is refused below, before anything is read. + PINNED=""; fd=7 + for n in $(sed -n '/^--$/,$p' "$m" | sed '1d' | awk '$3 ~ /\.img\.zst$/ && $3 != "kryptik-root.img.zst" { print $3 }'); do + case "$n" in */*|..*) continue ;; esac + pin "$fd" "$n" + PINNED="$PINNED $n=$fd" + fd=$((fd + 1)) + done # Nothing unlisted (a directory listing) before hashing every listed file # (gigabytes). Names are compared whole, as text, dotfiles included. @@ -448,7 +462,7 @@ cmd_apply() { arm_trial "$target" # The running root's images stay for a rollback; those of the release this slot held go. # A command line naming no root hash prunes nothing, rather than the running root's list. - running_hash="$(sed -n 's/.*sha256 \([0-9a-f]\{64\}\) .*/\1/p' /proc/cmdline)" + running_hash="$(sed -n 's/.*"kroot,[^"]* sha256 \([0-9a-f]\{64\}\) .*/\1/p' /proc/cmdline)" if [ -n "$running_hash" ]; then images_prune "$IMAGES" "$running_hash" "$ROOT_HASH"; fi printf '%s apply %s -> slot %s\n' "$(date -Iseconds 2>/dev/null)" "$VERSION" "$target" >> "$LOG" say "armed: the next boot tries slot $target ($VERSION) once. Reboot to try it." From 4463c980a31a736993cc52835fbda7ebc65e54fd Mon Sep 17 00:00:00 2001 From: DevomB Date: Thu, 15 Oct 2026 03:59:09 -0700 Subject: [PATCH 101/123] The images' pins start past the update lock, and the installer keeps the image list kryptikd checked Pinning each listed image from descriptor 7 put the third on 9, the descriptor apply and rollback hold the update lock on; reopening it released the flock for the rest of the apply. The pins now start at 10, and the snapshot test pins three images under a lock on 9 and finds the lock still there. The installer read the medium's list and signature again for images_keep after kryptikd had checked them, and a medium can answer a second read with other bytes. kryptikd check-images DIR BASE OUT now writes the bytes it checked into OUT, the installer's private directory, and images_keep and the count read them from there. --- build/service-scripts/medium-root.sh | 12 ++++---- compartments/kryptikd/src/images.rs | 19 +++++++++--- compartments/kryptikd/src/images/tests.rs | 35 +++++++++++++++-------- compartments/kryptikd/src/main.rs | 10 ++++--- docs/design/software-delivery.md | 5 ++-- tools/install/kryptik-install.sh | 13 ++++++--- tools/tests/installer.sh | 22 ++++++++------ tools/tests/update-manifest-snapshot.sh | 19 +++++++++--- tools/update/kryptik-update | 3 +- 9 files changed, 93 insertions(+), 45 deletions(-) diff --git a/build/service-scripts/medium-root.sh b/build/service-scripts/medium-root.sh index e0968c0e..64db95c1 100755 --- a/build/service-scripts/medium-root.sh +++ b/build/service-scripts/medium-root.sh @@ -10,8 +10,9 @@ # V_HASH V_SALT # kernel_names_root EFI the kernel's command line carries V_HASH # root_verifies DEV DEV holds the root V_HASH names -# medium_images_ok DIR the image list in DIR is signed, for V_HASH, and -# every image it names is in DIR at its size +# medium_images_ok DIR OUT the image list in DIR is signed, for V_HASH, and +# every image it names is in DIR at its size; the +# list and signature as checked are put in OUT decimal_field() { # decimal_field NAME VALUE: a bounded decimal, or die case "$2" in ''|*[!0-9]*) die "root.json: $1 is not a number" ;; esac @@ -64,9 +65,10 @@ kernel_names_root() { # kernel_names_root EFI # The software images a medium carries, before anything is written: a list # signed as one, for the root the signed command line names, naming only images # DIR holds at the sizes it signs (kryptikd check-images). Each image's hash is -# checked as it is copied (image-store.sh). -medium_images_ok() { # medium_images_ok DIR - _said="$(kryptikd check-images "$1" "$V_HASH" 2>&1)" \ +# checked as it is copied (image-store.sh). The list kept is the one checked, from +# OUT: a medium read a second time could answer with other bytes. +medium_images_ok() { # medium_images_ok DIR OUT + _said="$(kryptikd check-images "$1" "$V_HASH" "$2" 2>&1)" \ || die "the medium's software images are refused: ${_said#kryptikd: check-images: }" } diff --git a/compartments/kryptikd/src/images.rs b/compartments/kryptikd/src/images.rs index 4e62c843..29ce35db 100644 --- a/compartments/kryptikd/src/images.rs +++ b/compartments/kryptikd/src/images.rs @@ -313,12 +313,18 @@ fn signed(text: &[u8], sig: &[u8]) -> Result<(), String> { /// A medium's images.manifest and images.manifest.sig in `dir`, checked as the installer has them /// before it writes anything: signed as an image list, read as a kept list is, for the root `base` /// it installs, and naming only images `dir` holds as NAME.img at the size the list signs. Each -/// image's hash is checked as it is copied (image-store.sh). -pub fn check_medium(dir: &Path, base: &str) -> Result { - check_medium_with(dir, base, &signed) +/// image's hash is checked as it is copied (image-store.sh). With `out`, the very bytes checked are +/// written there for the installer to keep: a medium read a second time could answer otherwise. +pub fn check_medium(dir: &Path, base: &str, out: Option<&Path>) -> Result { + check_medium_with(dir, base, out, &signed) } -fn check_medium_with(dir: &Path, base: &str, check: &dyn Fn(&[u8], &[u8]) -> Result<(), String>) -> Result { +fn check_medium_with( + dir: &Path, + base: &str, + out: Option<&Path>, + check: &dyn Fn(&[u8], &[u8]) -> Result<(), String>, +) -> Result { let text = read_bounded(&dir.join("images.manifest"), LIST_MAX)?; let sig = read_bounded(&dir.join("images.manifest.sig"), SIG_MAX)?; check(&text, &sig).map_err(|e| format!("the medium's image list is refused: {e}"))?; @@ -333,6 +339,11 @@ fn check_medium_with(dir: &Path, base: &str, check: &dyn Fn(&[u8], &[u8]) -> Res return Err(format!("image {}: {} holds {} bytes, and the list signs {}", img.name, file.display(), md.len(), img.bytes)); } } + if let Some(out) = out { + crate::files::write_atomic(&out.join("images.manifest"), &[&text[..]], 0o600, None) + .and_then(|_| crate::files::write_atomic(&out.join("images.manifest.sig"), &[&sig[..]], 0o600, None)) + .map_err(|e| format!("{}: {e}", out.display()))?; + } Ok(format!("{} images of release {} for this root\n", m.images.len(), m.release)) } diff --git a/compartments/kryptikd/src/images/tests.rs b/compartments/kryptikd/src/images/tests.rs index d0050007..1fd6c33e 100644 --- a/compartments/kryptikd/src/images/tests.rs +++ b/compartments/kryptikd/src/images/tests.rs @@ -219,30 +219,41 @@ fn a_medium_is_checked_before_anything_is_written() { let yes = |_: &[u8], _: &[u8]| -> Result<(), String> { Ok(()) }; let no = |_: &[u8], _: &[u8]| -> Result<(), String> { Err("not signed by the release key as an image list".into()) }; - let said = check_medium_with(&d, &hex('1'), &yes).unwrap(); + let said = check_medium_with(&d, &hex('1'), None, &yes).unwrap(); assert!(said.contains("1 images of release 1.2.0"), "{said}"); + // The bytes checked are the ones handed on, and a refusal hands on nothing. + let out = d.join("checked"); + std::fs::create_dir(&out).unwrap(); + check_medium_with(&d, &hex('1'), Some(&out), &yes).unwrap(); + assert_eq!(std::fs::read(out.join("images.manifest")).unwrap(), std::fs::read(d.join("images.manifest")).unwrap()); + assert_eq!(std::fs::read(out.join("images.manifest.sig")).unwrap(), b"signature"); + std::fs::remove_dir_all(&out).unwrap(); + std::fs::create_dir(&out).unwrap(); + assert!(check_medium_with(&d, &hex('2'), Some(&out), &yes).is_err()); + assert_eq!(std::fs::read_dir(&out).unwrap().count(), 0, "a refused list is not handed on"); + std::fs::remove_dir_all(&out).unwrap(); - let refused = check_medium_with(&d, &hex('1'), &no).unwrap_err(); + let refused = check_medium_with(&d, &hex('1'), None, &no).unwrap_err(); assert!(refused.contains("the medium's image list is refused: not signed"), "{refused}"); - let refused = check_medium_with(&d, &hex('2'), &yes).unwrap_err(); + let refused = check_medium_with(&d, &hex('2'), None, &yes).unwrap_err(); assert!(refused.contains("another root"), "{refused}"); std::fs::write(d.join("browser.img"), vec![0u8; 4096]).unwrap(); - let refused = check_medium_with(&d, &hex('1'), &yes).unwrap_err(); + let refused = check_medium_with(&d, &hex('1'), None, &yes).unwrap_err(); assert!(refused.contains("holds 4096 bytes, and the list signs 12288"), "{refused}"); std::fs::remove_file(d.join("browser.img")).unwrap(); - let refused = check_medium_with(&d, &hex('1'), &yes).unwrap_err(); + let refused = check_medium_with(&d, &hex('1'), None, &yes).unwrap_err(); assert!(refused.starts_with("image browser:"), "{refused}"); // A directory where an image should be is no image, nor is a link to one. std::fs::create_dir(d.join("browser.img")).unwrap(); - assert!(check_medium_with(&d, &hex('1'), &yes).is_err()); + assert!(check_medium_with(&d, &hex('1'), None, &yes).is_err()); std::fs::remove_dir(d.join("browser.img")).unwrap(); std::fs::write(d.join("elsewhere"), vec![0u8; 12288]).unwrap(); std::os::unix::fs::symlink(d.join("elsewhere"), d.join("browser.img")).unwrap(); - assert!(check_medium_with(&d, &hex('1'), &yes).unwrap_err().contains("holds"), "a link is not the image"); + assert!(check_medium_with(&d, &hex('1'), None, &yes).unwrap_err().contains("holds"), "a link is not the image"); std::fs::remove_file(d.join("browser.img")).unwrap(); std::fs::rename(d.join("elsewhere"), d.join("browser.img")).unwrap(); @@ -251,22 +262,22 @@ fn a_medium_is_checked_before_anything_is_written() { std::fs::remove_file(d.join("images.manifest")).unwrap(); let fifo = std::ffi::CString::new(d.join("images.manifest").to_str().unwrap()).unwrap(); assert_eq!(unsafe { libc::mkfifo(fifo.as_ptr(), 0o600) }, 0); - assert!(check_medium_with(&d, &hex('1'), &yes).unwrap_err().contains("not a regular file")); + assert!(check_medium_with(&d, &hex('1'), None, &yes).unwrap_err().contains("not a regular file")); std::fs::remove_file(d.join("images.manifest")).unwrap(); // Nor is a link to the list followed. std::fs::write(d.join("list-elsewhere"), &list_text).unwrap(); std::os::unix::fs::symlink(d.join("list-elsewhere"), d.join("images.manifest")).unwrap(); - assert!(check_medium_with(&d, &hex('1'), &yes).unwrap_err().contains("not a regular file")); + assert!(check_medium_with(&d, &hex('1'), None, &yes).unwrap_err().contains("not a regular file")); std::fs::remove_file(d.join("images.manifest")).unwrap(); std::fs::write(d.join("images.manifest"), &list_text).unwrap(); - assert!(check_medium_with(&d, &hex('1'), &yes).is_ok()); + assert!(check_medium_with(&d, &hex('1'), None, &yes).is_ok()); std::fs::remove_file(d.join("images.manifest.sig")).unwrap(); - assert!(check_medium_with(&d, &hex('1'), &yes).unwrap_err().contains("images.manifest.sig")); + assert!(check_medium_with(&d, &hex('1'), None, &yes).unwrap_err().contains("images.manifest.sig")); // A list that does not parse is refused after its signature, as a kept one is. std::fs::write(d.join("images.manifest.sig"), b"signature").unwrap(); std::fs::write(d.join("images.manifest"), "KRYPTIK-IMAGES-1\nrelease: 1.2.0\n").unwrap(); - assert!(check_medium_with(&d, &hex('1'), &yes).is_err()); + assert!(check_medium_with(&d, &hex('1'), None, &yes).is_err()); let _ = std::fs::remove_dir_all(&d); } diff --git a/compartments/kryptikd/src/main.rs b/compartments/kryptikd/src/main.rs index 57e9bfbd..7df7b406 100644 --- a/compartments/kryptikd/src/main.rs +++ b/compartments/kryptikd/src/main.rs @@ -114,10 +114,12 @@ USAGE: kryptikd check-release DIR whether DIR's manifest and manifest.sig verify, checked as nobody on copies: how kryptik-update apply, which runs as root, has a release checked - kryptikd check-images DIR BASE a medium's images.manifest and its signature in + kryptikd check-images DIR BASE [OUT] + a medium's images.manifest and its signature in DIR, checked as a kept list is, for root BASE, and each image it names there at its size: the - installer's check before it writes anything + installer's check before it writes anything; + with OUT, the two files as checked, written there --rootfs DIR base directory for zone data (default: /var/lib/kryptik/zones); the zone sees its own directory as /home/NAME @@ -300,7 +302,7 @@ fn main() -> ExitCode { }, "time" => cmd_time(&args), "check-images" => match args.as_slice() { - [_, dir, base] => match images::check_medium(Path::new(dir), base) { + [_, dir, base] | [_, dir, base, _] => match images::check_medium(Path::new(dir), base, args.get(3).map(Path::new)) { Ok(said) => { print!("{said}"); ExitCode::SUCCESS @@ -311,7 +313,7 @@ fn main() -> ExitCode { } }, _ => { - eprintln!("usage: kryptikd check-images DIR BASE"); + eprintln!("usage: kryptikd check-images DIR BASE [OUT]"); ExitCode::from(2) } }, diff --git a/docs/design/software-delivery.md b/docs/design/software-delivery.md index a52cc035..a84de69d 100644 --- a/docs/design/software-delivery.md +++ b/docs/design/software-delivery.md @@ -255,8 +255,9 @@ up ADR-001 in the zones where the applications run. not already need. Before the installer writes anything, `kryptikd check-images` checks the list as a kept one is checked, its signature as nobody on a copy, for the root the signed command line names, and finds - every image it names there at the size it signs; each image's hash is - checked as it is copied, as an update's are. + every image it names there at the size it signs, and hands on the list + and signature it checked, which are what the installer keeps; each + image's hash is checked as it is copied, as an update's are. - **Stored** under `/var/lib/kryptik/images//.img`, with the list for each root as `.manifest` and `.manifest.sig`, root only. Zone 0 keeps the lists of the roots in the two slots and the images diff --git a/tools/install/kryptik-install.sh b/tools/install/kryptik-install.sh index 850f9d34..c6c9169b 100755 --- a/tools/install/kryptik-install.sh +++ b/tools/install/kryptik-install.sh @@ -238,8 +238,13 @@ case "$media" in esac medium_root "$ROOT_JSON" /proc/cmdline ROOT_HASH="$(json_field "$ROOT_JSON" root_hash)" -# The medium's software images, checked before anything is written (medium-root.sh). -[ -z "$IMG_SRC" ] || medium_images_ok "$IMG_SRC" +# The medium's software images, checked before anything is written (medium-root.sh); the list +# and signature kept later are the ones checked, from IMG_CHECKED. +IMG_CHECKED="$MNT_BASE/images-checked" +if [ -n "$IMG_SRC" ]; then + rm -rf "$IMG_CHECKED" && mkdir -m 0700 "$IMG_CHECKED" || die "cannot make $IMG_CHECKED" + medium_images_ok "$IMG_SRC" "$IMG_CHECKED" +fi for s in a b; do kernel_names_root "$MNT_BASE/esp/EFI/kryptik/kryptik-$s.efi" \ || die "the medium's kernel for slot $s does not carry the root hash its signed command line names; nothing was written" @@ -414,8 +419,8 @@ fi mount -t ext4 -o rw "/dev/mapper/$MAPPING" "$MNT_BASE/state" || die "could not mount kryptik-state" mkdir -p "$MNT_BASE/state/lib/kryptik" if [ -n "$IMG_SRC" ]; then - images_keep "$IMG_SRC/images.manifest" "$IMG_SRC/images.manifest.sig" "$MNT_BASE/state/lib/kryptik/images" "$ROOT_HASH" medium_image - say "images $(grep -c '^image: ' "$IMG_SRC/images.manifest") kept for ${VERSION}" + images_keep "$IMG_CHECKED/images.manifest" "$IMG_CHECKED/images.manifest.sig" "$MNT_BASE/state/lib/kryptik/images" "$ROOT_HASH" medium_image + say "images $(grep -c '^image: ' "$IMG_CHECKED/images.manifest") kept for ${VERSION}" fi cat > "$MNT_BASE/state/lib/kryptik/install.json" < "${stub}/kryptikd" <<'STUB' #!/bin/sh -[ "$1" = check-images ] && [ "$#" -eq 3 ] || { echo "usage: kryptikd check-images DIR BASE" >&2; exit 2; } +[ "$1" = check-images ] && [ "$#" -eq 4 ] || { echo "usage: kryptikd check-images DIR BASE OUT" >&2; exit 2; } printf '%s\n' "$3" > "$2/base-asked" a="$(cat "$2/answer")" -[ "$a" = ok ] && { echo "1 images of release 1.0.0 for this root"; exit 0; } +[ "$a" = ok ] && { echo checked > "$4/images.manifest"; echo "1 images of release 1.0.0 for this root"; exit 0; } echo "kryptikd: check-images: $a" >&2 exit 1 STUB @@ -214,21 +214,25 @@ STUB med="$(mktemp -d)" V_HASH="$(printf 'c%.0s' $(seq 64))" echo ok > "${med}/answer" - ( PATH="${stub}:$PATH"; medium_images_ok "$med" ) >/dev/null 2>&1 \ - && [[ "$(cat "${med}/base-asked" 2>/dev/null)" == "$V_HASH" ]] \ - && green "a medium kryptikd accepts is taken, checked for the root the signed command line names" \ - || red "a medium kryptikd accepts was refused, or checked for another root" + chk="$(mktemp -d)" + ( PATH="${stub}:$PATH"; medium_images_ok "$med" "$chk" ) >/dev/null 2>&1 \ + && [[ "$(cat "${med}/base-asked" 2>/dev/null)" == "$V_HASH" && "$(cat "${chk}/images.manifest" 2>/dev/null)" == checked ]] \ + && green "a medium kryptikd accepts is taken, checked for the signed root, its checked list handed on" \ + || red "a medium kryptikd accepts was refused, checked for another root, or its list not handed on" for why in "the medium's image list is refused: not signed as an image list" \ "image browser: /media/images/browser.img holds 4096 bytes, and the list signs 12288"; do echo "$why" > "${med}/answer" - out="$( (PATH="${stub}:$PATH"; medium_images_ok "$med") 2>&1 )"; rc=$? + out="$( (PATH="${stub}:$PATH"; medium_images_ok "$med" "$chk") 2>&1 )"; rc=$? [[ "$rc" -ne 0 && "$out" == *"software images are refused: ${why}"* ]] \ && green "a refusal stops the install with kryptikd's reason (${why%%:*})" \ || red "a refusal was not passed on: rc=${rc} ${out}" done - rm -rf "$stub" "$med" + rm -rf "$stub" "$med" "$chk" fi -if awk '/medium_images_ok "\$IMG_SRC"/ && !c { c = NR } +grep -qF 'images_keep "$IMG_CHECKED/images.manifest" "$IMG_CHECKED/images.manifest.sig"' "$INSTALLER" \ + && green "the list kept is the one kryptikd checked, not the medium's read again" \ + || red "images_keep reads the list from the medium a second time" +if awk '/medium_images_ok "\$IMG_SRC" "\$IMG_CHECKED"/ && !c { c = NR } /^sfdisk / && !w { w = NR } END { exit !(c && w && c < w) }' "$INSTALLER"; then green "the medium's images are checked before the partition table, the first write" diff --git a/tools/tests/update-manifest-snapshot.sh b/tools/tests/update-manifest-snapshot.sh index e6ca101e..50f5ed2a 100755 --- a/tools/tests/update-manifest-snapshot.sh +++ b/tools/tests/update-manifest-snapshot.sh @@ -365,28 +365,39 @@ out="$(asroot refused verify_manifest "$T/signed" 0)" # A software image the manifest lists is pinned as the root's files are: what stage_images # decompresses after the check is the file that was hashed, whatever the directory holds by then. +# Three of them, as a release carries: with the update lock on fd 9, the third pin must not reopen 9. mkpayload pinned 2 -printf 'the signed image\n' | zstd -q -c > pinned/browser.img.zst -{ sed -n '1,/^--$/p' pinned/manifest | sed 's/^files: 4$/files: 5/' +for i in browser probe toolkit; do printf 'the signed %s\n' "$i" | zstd -q -c > "pinned/$i.img.zst"; done +{ sed -n '1,/^--$/p' pinned/manifest | sed 's/^files: 4$/files: 7/' sed -n '/^--$/,$p' pinned/manifest | sed 1d - printf '%s %s %s\n' "$(sha256sum pinned/browser.img.zst | cut -c1-64)" "$(stat -c %s pinned/browser.img.zst)" browser.img.zst + for i in browser probe toolkit; do + printf '%s %s %s\n' "$(sha256sum "pinned/$i.img.zst" | cut -c1-64)" "$(stat -c %s "pinned/$i.img.zst")" "$i.img.zst" + done } > pinned/manifest.new && mv pinned/manifest.new pinned/manifest ssh-keygen -Y sign -f key -n kryptik-release pinned/manifest >/dev/null 2>&1 mkdir -p "$T/snap-pinned" cat > "$T/case-pinned.sh" <$T/update.lock +flock -n 9 && echo LOCKED verify_payload $T/pinned 0 > /dev/null && echo VERIFIED +[ "\$(readlink /proc/self/fd/9)" = "$T/update.lock" ] && echo LOCK-KEPT printf 'swapped in after the check\n' | zstd -q -c > $T/pinned/browser.img.zst.new mv $T/pinned/browser.img.zst.new $T/pinned/browser.img.zst zstd -dcq < "\$(payload_path browser.img.zst)" EOF out="$(bash "$T/case-pinned.sh" 2>&1)" -if [[ "$out" == *VERIFIED* && "$out" == *"the signed image"* && "$out" != *swapped* ]]; then +if [[ "$out" == *VERIFIED* && "$out" == *"the signed browser"* && "$out" != *swapped* ]]; then ok "an image the manifest lists is read through the descriptor it was hashed on, not by its name" else bad "an image swapped after the check was read, or the payload did not verify: $(tr '\n' ' ' <<<"$out")" fi +if [[ "$out" == *LOCKED* && "$out" == *LOCK-KEPT* ]]; then + ok "three pinned images leave the update lock's descriptor where it was" +else + bad "pinning the images reopened the update lock's descriptor: $(tr '\n' ' ' <<<"$out")" +fi # The tool itself: the checks need neither root nor this system's disks. if [[ "$(id -u)" != 0 ]]; then diff --git a/tools/update/kryptik-update b/tools/update/kryptik-update index 1ab66378..dbd77cc9 100755 --- a/tools/update/kryptik-update +++ b/tools/update/kryptik-update @@ -153,7 +153,8 @@ verify_payload() { # verify_payload DIR RECOVERY -> sets VERSION ROOT_HASH pin 3 kryptik-root.img.zst; pin 4 kryptik-a.efi; pin 5 kryptik-b.efi; pin 6 root.json # And each software image it lists, so the bytes hashed below are those stage_images # decompresses. A name with a directory in it is refused below, before anything is read. - PINNED=""; fd=7 + # From 10 up: 3-6 hold the root's files and 9 the update lock, which a reopen would release. + PINNED=""; fd=10 for n in $(sed -n '/^--$/,$p' "$m" | sed '1d' | awk '$3 ~ /\.img\.zst$/ && $3 != "kryptik-root.img.zst" { print $3 }'); do case "$n" in */*|..*) continue ;; esac pin "$fd" "$n" From d139f807ed22632ee00284091557bf70da8b459b Mon Sep 17 00:00:00 2001 From: DevomB Date: Thu, 15 Oct 2026 04:00:20 -0700 Subject: [PATCH 102/123] The installer's cleanup removes the checked image list, so its base directory goes as before --- tools/install/kryptik-install.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/install/kryptik-install.sh b/tools/install/kryptik-install.sh index c6c9169b..9e493c53 100755 --- a/tools/install/kryptik-install.sh +++ b/tools/install/kryptik-install.sh @@ -193,6 +193,7 @@ cleanup() { rmdir "$m" 2>/dev/null || true done [ -b "/dev/mapper/$MAPPING" ] && cryptsetup close "$MAPPING" 2>/dev/null || true + rm -rf "$MNT_BASE/images-checked" rmdir "$MNT_BASE" 2>/dev/null || true } trap cleanup EXIT INT TERM From 645230e475262671303eaef23299dbba76360bb4 Mon Sep 17 00:00:00 2001 From: DevomB Date: Thu, 15 Oct 2026 04:11:24 -0700 Subject: [PATCH 103/123] The images' descriptors are bash's to pick, so a redirected call cannot leave one unreadable While a call's output is redirected, bash keeps its own copy of the output on descriptor 10, close-on-exec. A pin opened on a fixed 10 then took that place and stayed close-on-exec, so stat, sha256sum and zstd could not read it, as the snapshot test's redirected verify_payload showed. exec {fd}< takes the lowest free descriptor from 10 up, past any bash holds. --- tools/tests/update-manifest-snapshot.sh | 2 ++ tools/update/kryptik-update | 14 +++++++------- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/tools/tests/update-manifest-snapshot.sh b/tools/tests/update-manifest-snapshot.sh index 50f5ed2a..5e45cb8e 100755 --- a/tools/tests/update-manifest-snapshot.sh +++ b/tools/tests/update-manifest-snapshot.sh @@ -366,6 +366,8 @@ out="$(asroot refused verify_manifest "$T/signed" 0)" # A software image the manifest lists is pinned as the root's files are: what stage_images # decompresses after the check is the file that was hashed, whatever the directory holds by then. # Three of them, as a release carries: with the update lock on fd 9, the third pin must not reopen 9. +# The call's output is redirected, so bash holds its own copy of it on 10 meanwhile, close-on-exec: +# a pin there would be one stat and zstd cannot read. mkpayload pinned 2 for i in browser probe toolkit; do printf 'the signed %s\n' "$i" | zstd -q -c > "pinned/$i.img.zst"; done { sed -n '1,/^--$/p' pinned/manifest | sed 's/^files: 4$/files: 7/' diff --git a/tools/update/kryptik-update b/tools/update/kryptik-update index dbd77cc9..d1fd9789 100755 --- a/tools/update/kryptik-update +++ b/tools/update/kryptik-update @@ -128,11 +128,11 @@ cmd_status() { # The release's four files are opened once and read only through those # descriptors, so what is hashed is what is written even if the directory # changes. Links are refused, as is a name replaced while being opened. -pin() { # pin FD NAME +pin() { # pin FD NAME, or pin - NAME for a descriptor bash picks; either is left in fd f="$dir/$2" { [ -f "$f" ] && [ ! -L "$f" ]; } || die "payload lacks $2, or it is not a regular file" - eval "exec $1< \"\$f\"" || die "cannot open $2" - [ "$(stat -c %d:%i "$f")" = "$(stat -L -c %d:%i "/proc/self/fd/$1")" ] || die "$2 changed while it was being opened" + if [ "$1" = - ]; then eval 'exec {fd}< "$f"'; else fd="$1"; eval "exec $fd< \"\$f\""; fi || die "cannot open $2" + [ "$(stat -c %d:%i "$f")" = "$(stat -L -c %d:%i "/proc/self/fd/$fd")" ] || die "$2 changed while it was being opened" } payload_path() { # NAME -> where to read it case "$1" in @@ -153,13 +153,13 @@ verify_payload() { # verify_payload DIR RECOVERY -> sets VERSION ROOT_HASH pin 3 kryptik-root.img.zst; pin 4 kryptik-a.efi; pin 5 kryptik-b.efi; pin 6 root.json # And each software image it lists, so the bytes hashed below are those stage_images # decompresses. A name with a directory in it is refused below, before anything is read. - # From 10 up: 3-6 hold the root's files and 9 the update lock, which a reopen would release. - PINNED=""; fd=10 + # bash (sh here) picks each descriptor: from 10 up, past the root's and the update lock's, + # and never one it holds itself, as it holds a redirected call's own output while it runs. + PINNED="" for n in $(sed -n '/^--$/,$p' "$m" | sed '1d' | awk '$3 ~ /\.img\.zst$/ && $3 != "kryptik-root.img.zst" { print $3 }'); do case "$n" in */*|..*) continue ;; esac - pin "$fd" "$n" + pin - "$n" PINNED="$PINNED $n=$fd" - fd=$((fd + 1)) done # Nothing unlisted (a directory listing) before hashing every listed file From 8d38c6a83889c29275b7add3f9cd3c0b880428f7 Mon Sep 17 00:00:00 2001 From: DevomB Date: Thu, 15 Oct 2026 05:23:55 -0700 Subject: [PATCH 104/123] The browser image keeps four findings without a CET note and three without a canary, each with its reason Stage 06 refused the image on these seven alone: the Rust in libxul, NSPR's, freebl's and the codecs' assembly carry no CET note, and three small libraries have no function -fstack-protector-strong guards. --- build/images/browser.accepted | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 build/images/browser.accepted diff --git a/build/images/browser.accepted b/build/images/browser.accepted new file mode 100644 index 00000000..c24e9269 --- /dev/null +++ b/build/images/browser.accepted @@ -0,0 +1,15 @@ +# Findings of tools/check-artifact-hardening.sh the browser image keeps, in the form of +# build/config/artifact-accepted.txt. Stage 06 audits the image against this list alone. + +# No CET note: the linker marks a library for IBT and SHSTK only when every object in it is marked. +NO-CET usr/lib/firefox/libxul.so # its Rust code: stable rustc marks nothing for CET, nor does the std it links +NO-CET usr/lib/firefox/libnspr4.so # NSPR's hand-written x86-64 assembly (os_Linux_x86_64.s) carries no CET note +NO-CET usr/lib/firefox/libfreeblpriv3.so # freebl's hand-written x86-64 assembly (arcfour-amd64-gas.s, mpi_amd64_common.S) carries no CET note +NO-CET usr/lib/firefox/libgkcodecs.so # libvpx's and libaom's NASM assembly, whose x86inc.asm writes no CET note + +# No canary anywhere in the object's code. -fstack-protector-strong (in Firefox's own hardening +# flags and stage 04's CFLAGS) guards only functions with a local array or an address-taken +# local; these have none. +NO-SSP usr/lib/firefox/libmozgtk.so # one function, which only names GTK for the linker +NO-SSP usr/lib/firefox/libmozwayland.so # stubs of libwayland-client's functions, each returning at once +NO-SSP usr/lib/firefox/libplc4.so # NSPR's string, base64, option and error helpers From cf0d249d3a382b97b60447098c2e80e18f00fb75 Mon Sep 17 00:00:00 2001 From: DevomB Date: Thu, 15 Oct 2026 05:33:34 -0700 Subject: [PATCH 105/123] hicolor-icon-theme and cairo have mirrors while their own hosts do not answer: Debian's pool with upstream's signature and Alpine's v3.22 distfiles for the one, the BLFS mirror for the other --- tools/source-mirrors.tsv | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tools/source-mirrors.tsv b/tools/source-mirrors.tsv index 05967e69..dc91495d 100644 --- a/tools/source-mirrors.tsv +++ b/tools/source-mirrors.tsv @@ -18,3 +18,6 @@ wayland-protocols https://deb.debian.org/debian/pool/main/w/wayland-protocols/w wayland-protocols https://distfiles.alpinelinux.org/distfiles/edge/wayland-protocols-{VER}.tar.xz - xkeyboard-config https://deb.debian.org/debian/pool/main/x/xkeyboard-config/xkeyboard-config_{VER}.orig.tar.xz .asc pixman https://deb.debian.org/debian/pool/main/p/pixman/pixman_{VER}.orig.tar.gz - +hicolor-icon-theme https://deb.debian.org/debian/pool/main/h/hicolor-icon-theme/hicolor-icon-theme_{VER}.orig.tar.xz .asc +hicolor-icon-theme https://distfiles.alpinelinux.org/distfiles/v3.22/hicolor-icon-theme-{VER}.tar.xz - +cairo https://ftp2.osuosl.org/pub/blfs/conglomeration/cairo/cairo-{VER}.tar.xz - From be2aa22ef146501d934a22073976aedfbd1910ef Mon Sep 17 00:00:00 2001 From: DevomB Date: Thu, 15 Oct 2026 08:26:15 -0700 Subject: [PATCH 106/123] Every image stage 06 packs has 4096-byte blocks, the size dm-verity reads it in mke2fs.conf gives a file system under 512 MiB blocks of 1024, and ext4 cannot mount a block smaller than the dm-verity device's own, so a zone could not start on the probe image (mount: EINVAL) after an install or an update. make_ext4 asks for 4096, and pack_image refuses an image of any other block size. --- build/stages/06-iso.sh | 6 +++++- docs/design/software-delivery.md | 5 +++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/build/stages/06-iso.sh b/build/stages/06-iso.sh index ba5f2ae5..7ca361a4 100755 --- a/build/stages/06-iso.sh +++ b/build/stages/06-iso.sh @@ -332,10 +332,12 @@ EOF # make_ext4 TREE IMAGE BYTES UUID [LABEL]: 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/. +# Blocks of 4096 bytes, the size dm-verity reads every image in: ext4 refuses a smaller block on +# that device (EINVAL), and mke2fs.conf gives a file system under 512 MiB blocks of 1024. 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 "${5:-kryptik-root}" -O '^has_journal' -U "$4" \ + "$CHROOTD" run /usr/sbin/mke2fs -q -F -t ext4 -b 4096 -L "${5:-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 } @@ -386,6 +388,8 @@ pack_image() { cmp -s "$img" "${img}.again" || { echo "FAIL: two mke2fs runs over image ${name} wrote different images" >&2; return 1; } rm -f "${img}.again" fi + [[ "$(dumpe2fs -h "$img" 2>/dev/null | sed -n 's/^Block size: *//p')" == 4096 ]] \ + || { echo "FAIL: image ${name} is not of 4096-byte blocks, which dm-verity reads it in" >&2; return 1; } local data_blocks=$(( fs_bytes / 4096 )) local salt; salt="$(sha256_of "$img")" local total=$(( fs_bytes + fs_bytes / 64 + 4 * 1024 * 1024 )) diff --git a/docs/design/software-delivery.md b/docs/design/software-delivery.md index a84de69d..3296efd8 100644 --- a/docs/design/software-delivery.md +++ b/docs/design/software-delivery.md @@ -215,8 +215,9 @@ up ADR-001 in the zones where the applications run. stage 04, so changing them rebuilds no image, and a kept image's tree is checked against the root again. A release builds every image from nothing. - **Packed.** Stage 06 makes `images-/.img` the way it - makes the root (ext4 without a journal, then the verity tree, with a - salt and UUID derived from the content), runs the root's artifact audit + makes the root (ext4 without a journal in 4096-byte blocks, the size + dm-verity reads it in, then the verity tree, with a salt and UUID derived + from the content), runs the root's artifact audit over it with the image's own accepted findings (`build/images/NAME.accepted`, each with its reason) or none, and the image's own program list, after leaving out what `build/images/NAME.strip` From a63d37cf15136f6691f7d64f8406f4341e412e0f Mon Sep 17 00:00:00 2001 From: DevomB Date: Thu, 15 Oct 2026 10:19:49 -0700 Subject: [PATCH 107/123] A zone binds its images' /usr through a descriptor opened as host root, as it does the broker's socket The set is stacked under /run/kryptik, root's alone, and pid 1 binds it after taking the zone's ids, so looking up the bind's source failed with EACCES and no zone that names images started. intermediate_main opens the set O_PATH beside the broker's and the Wayland socket's descriptors, after the mount namespace is unshared and before the id switch, and pivot_into binds /proc/self/fd/N. --- compartments/kryptikd/src/spawn.rs | 17 ++++++++++++++++- docs/design/software-delivery.md | 4 +++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/compartments/kryptikd/src/spawn.rs b/compartments/kryptikd/src/spawn.rs index 3347e553..a6ce17be 100644 --- a/compartments/kryptikd/src/spawn.rs +++ b/compartments/kryptikd/src/spawn.rs @@ -1251,6 +1251,21 @@ fn intermediate_main( Some(format!("/proc/self/fd/{raw}")) } }; + /* The images' /usr too: its set is stacked under /run/kryptik (images.rs), which the zone's + * mapped root may not walk. Like the broker's, the descriptor dies in pid 1's sweep. */ + let usr_in_zone = if usr == "/usr" { + usr.to_string() + } else { + let c = match std::ffi::CString::new(usr) { + Ok(c) => c, + Err(_) => bail!("the images' /usr path contains a NUL"), + }; + let fd = unsafe { libc::open(c.as_ptr(), libc::O_PATH | libc::O_DIRECTORY | libc::O_NOFOLLOW) }; + if fd < 0 { + bail!("the images' /usr at {}: {}", usr, io::Error::last_os_error()); + } + format!("/proc/self/fd/{fd}") + }; // Until the parent writes our id maps we are nobody (65534) and cannot become root. ready.signal(); @@ -1312,7 +1327,7 @@ fn intermediate_main( if inner == 0 { alive.close_write(); let service = maps_service(id.privileged, zone_policy); - let rc = zone_init(zone, rootfs, argv, flags, zone_policy, fs_rules, plumbed, &broker_in_zone, wayland_in_zone.as_deref(), wifi_conf, sound_dir, camera_dir, usr, service, &alive); + let rc = zone_init(zone, rootfs, argv, flags, zone_policy, fs_rules, plumbed, &broker_in_zone, wayland_in_zone.as_deref(), wifi_conf, sound_dir, camera_dir, &usr_in_zone, service, &alive); unsafe { libc::_exit(rc) }; } alive.close_read(); diff --git a/docs/design/software-delivery.md b/docs/design/software-delivery.md index 3296efd8..07eefbf3 100644 --- a/docs/design/software-delivery.md +++ b/docs/design/software-delivery.md @@ -295,7 +295,9 @@ up ADR-001 in the zones where the applications run. a zone names, kryptikd mounts a read-only overlay (no upper directory; the images' `usr` over the root's `/usr`; redirects in a layer not followed) at `/run/kryptik/usr/` in the initial namespace, and the zone's bind - of `/usr` takes that instead. A set already mounted there is used only if + of `/usr` takes that instead, through a descriptor kryptikd opens before + it takes the zone's ids: `/run/kryptik` is root's alone, and the zone's + mapped root may not walk it. A set already mounted there is used only if it stacks exactly those mounts, as its name holds 64 bits of each root hash. Since zone 0 mounts it and the zone only receives a bind, the hardened kernel's `OVERLAY_FS_UNPRIVILEGED`, which is off, does not come From b399ce7ad52338b60a3d90012a707d43cd8ad593 Mon Sep 17 00:00:00 2001 From: DevomB Date: Thu, 15 Oct 2026 21:26:14 -0700 Subject: [PATCH 108/123] The installer holds the target disk locked, so eudev's own re-read cannot drop the new partitions Hardware proof 38774278436's suspend suite failed at its install: "the kernel does not name /dev/vda2 kryptik-a", straight after reread_table had re-read the table and both partitions had appeared. When sfdisk closes the disk it wrote, eudev 3.2.15 (synthesize_change) takes flock(LOCK_EX|LOCK_NB) on it and does a BLKRRPART of its own; handled after ours, that one drops every partition and adds it again, and the name check fell in between. kryptik-install takes an exclusive flock on the whole disk before sfdisk and keeps it until it exits, as udev's block-device locking protocol asks of whatever writes a partition table. eudev's re-read then gives way, and its workers, which take LOCK_SH|LOCK_NB on the disk, skip probing its partitions while the install writes them. The lock is on an open file and dies with the process. sfdisk, mkfs.ext4, cryptsetup and veritysetup take none on the disk, so nothing waits on it; tools/tests/installer.sh holds the order (opened, locked, then written) and fails any --lock or LOCK_BLOCK_DEVICE in the installer. --- tools/install/kryptik-install.sh | 12 +++++++++--- tools/tests/installer.sh | 9 +++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/tools/install/kryptik-install.sh b/tools/install/kryptik-install.sh index b1085aa6..3f2afd58 100755 --- a/tools/install/kryptik-install.sh +++ b/tools/install/kryptik-install.sh @@ -69,7 +69,7 @@ done # --- every external tool, checked before the first write ------------------- missing="" -for tool in sfdisk blockdev blkid cryptsetup veritysetup stty mkfs.ext4 dd sha256sum mount umount sync awk sed od \ +for tool in sfdisk blockdev flock 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 kryptik-efiboot; do command -v "$tool" >/dev/null 2>&1 || missing="${missing} ${tool}" @@ -80,8 +80,8 @@ touches a disk, not at the moment it is first called." # Only a table the kernel reads whole (BLKRRPART) gives each partition its name (PARTNAME), # which kryptik_named looks up. Partitions added one at a time, as partx adds them and as sfdisk -# does when its own re-read finds the disk busy, carry none. udev's probe of the new table keeps -# the disk busy for a moment. +# does when its own re-read finds the disk busy, carry none. A program that still has an old +# partition open keeps the disk busy for a moment. reread_table() { # DISK n=0 until blockdev --rereadpt "$1" 2>/dev/null; do @@ -329,6 +329,12 @@ kb_store "$LAYOUT" || die "the firmware did not take the keyboard layout ${LAYOU # --- partition ------------------------------------------------------------- say "partitioning (sfdisk, GPT: kryptik-esp, kryptik-a, kryptik-b, kryptik-state)" +# The disk stays locked until the install ends, as udev asks of whatever writes a partition +# table. When sfdisk closes the disk, eudev re-reads its table unless the disk is locked, and a +# re-read after ours drops the new partitions while they are named and written. The lock dies +# with this process; sfdisk and the tools after it take none of their own. +exec 7<"$TARGET_REAL" +flock -w 30 -x 7 || die "another program held ${TARGET} locked for 30 seconds; nothing was written" P1="$(part_dev "$TARGET_REAL" 1)"; P2="$(part_dev "$TARGET_REAL" 2)" P3="$(part_dev "$TARGET_REAL" 3)"; P4="$(part_dev "$TARGET_REAL" 4)" GPT_ESP=C12A7328-F81F-11D2-BA4B-00A0C93EC93B diff --git a/tools/tests/installer.sh b/tools/tests/installer.sh index 6039909c..25abbe51 100755 --- a/tools/tests/installer.sh +++ b/tools/tests/installer.sh @@ -275,6 +275,15 @@ else fi grep -v '^ *#' "$INSTALLER" | grep -qw partx && red "the installer adds partitions with partx, which leaves them nameless" \ || green "the installer has no partx, whose partitions the kernel leaves nameless" +locked="$(grep -n '^flock -w [0-9]* -x 7 || die' "$INSTALLER" | cut -d: -f1)" +opened="$(grep -n '^exec 7<"\$TARGET_REAL"$' "$INSTALLER" | cut -d: -f1)" +[[ "$opened" =~ ^[0-9]+$ && "$locked" =~ ^[0-9]+$ && "$opened" -lt "$locked" && "$locked" -lt "$written" ]] \ + && green "the whole disk is locked before its table is written, so eudev's own re-read gives way" \ + || red "the disk is opened at line ${opened:-none} and locked at ${locked:-none}; the table is written from ${written:-none}" +# A tool that waited for the lock this process holds would never get it. +grep -v '^ *#' "$INSTALLER" | grep -qE -- '--lock|LOCK_BLOCK_DEVICE' \ + && red "a tool the installer runs asks for a lock on the disk it holds" \ + || green "no tool the installer runs asks for a lock on the disk it holds" grep -q '^kryptik_named kryptik-a | grep -qx "\$P2" || die' "$INSTALLER" \ && green "the written table is checked by the name the kernel gives, as the system will find it" \ || red "the installer does not check partition 2's name through kryptik_named" From b786fe2c77f3a88400339775975e7e12dc636989 Mon Sep 17 00:00:00 2001 From: DevomB Date: Thu, 15 Oct 2026 22:43:16 -0700 Subject: [PATCH 109/123] A browser round that shows no window says what the proxy refused, what the zone's filter killed and where Firefox waits Round 5 failed browser-window with nothing from the zone but the sandbox's expected EPERM. The host prints FAIL lines alone, so the failure line now carries the proxy log's tail, the kernel's seccomp and segfault lines, each firefox process with its state, wait channel and age, and the launch's output. --- build/guest-tests/gui-check.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/build/guest-tests/gui-check.sh b/build/guest-tests/gui-check.sh index 267c2b6b..5c52ea72 100755 --- a/build/guest-tests/gui-check.sh +++ b/build/guest-tests/gui-check.sh @@ -1539,7 +1539,10 @@ if wait_for 120 ff_page; then fail "browser-stays-up" "focus: $(tr '\n' ' ' < "$RT/kryptik/focus" 2>/dev/null); $(since_mark firefox untrusted | tail -5 | tr '\n' ' '); kernel: $(dmesg 2>/dev/null | grep -E 'type=1326|seccomp' | tail -2 | tr '\n' ' ')" fi else - fail "browser-window" "focus: $(tr '\n' ' ' < "$RT/kryptik/focus" 2>/dev/null); $(since_mark firefox untrusted | tail -5 | tr '\n' ' ')" + # A start that shows nothing prints nothing in the zone either. The host prints FAIL lines + # alone, so this one carries what the proxy refused, what the zone's filter killed, and + # whether Firefox still runs and where it waits. + fail "browser-window" "focus: $(tr '\n' ' ' < "$RT/kryptik/focus" 2>/dev/null); zone: $(since_mark firefox untrusted | tail -5 | tr '\n' ' '); proxy: $(tail -4 "$plog" 2>/dev/null | tr '\n' ' '); kernel: $(dmesg 2>/dev/null | grep -E 'type=1326|seccomp|segfault|traps:' | tail -4 | tr '\n' ' '); firefox: $(ps -eo pid=,stat=,wchan:24=,etime=,args= 2>/dev/null | grep '[f]irefox' | head -8 | cut -c1-120 | tr '\n' ';'); launch: $(tail -3 "$LOG/launch-firefox.out" 2>/dev/null | tr '\n' ' ')" fi stop_zone untrusted echo "GT KEY-BROWSER-BACK" From f7aa4a5a3b1c88ffda01f022ff97e1d7e0558f1c Mon Sep 17 00:00:00 2001 From: DevomB Date: Thu, 15 Oct 2026 22:49:53 -0700 Subject: [PATCH 110/123] The chrome asks for a waiting zone's passphrase once the state partition is back, not while it is being suspended Round 5 (38849928400) failed the suspend suite's nothing-else-waits on a havoc that started in the middle of `kryptik suspend --awake`. The chrome's watcher opened its --waiting window whenever, at a look, kryptik-state was not suspended and a kryptik-zone-* device was. kryptikd suspends each zone's volume before the state partition, whose /var holds the volumes, so a look in between found work waiting with the state partition still up. The window's terminal then started reading the user's home as the state partition went, and stopped there in D. Main's suspend was fast enough between the two that the once-a-second look missed it; the batch's, with the images' devices, is not. The watcher now opens the window on the state partition's return from a suspension it saw: it notes the partition suspended, and on its next look with the partition up asks for whichever zones still wait. That is "once the state partition is back after a sleep", which the loop's comment already said. A second sleep that leaves a zone waiting asks again, where the old test of the waiting set's change did not. --- tools/desktop/kryptik-chrome | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/tools/desktop/kryptik-chrome b/tools/desktop/kryptik-chrome index 0447cc25..b23f4c4e 100755 --- a/tools/desktop/kryptik-chrome +++ b/tools/desktop/kryptik-chrome @@ -184,7 +184,7 @@ run_status() { # as the battery reaches 10% and again 5%, a window warns of the power-off at 3%; and once # the state partition is back after a sleep, a window asks for each waiting zone's passphrase. ( - shown="" filming="" warned="" waited="" blocks="" seen="" + shown="" filming="" warned="" suspended="" blocks="" seen="" while :; do mic="$("$SOUND" --control status 2>/dev/null | sed -n 's/^zone \([^ ]*\) .* granted.*/\1/p' | head -1)" if [ -n "$mic" ] && [ "$mic" != "$shown" ]; then @@ -201,12 +201,14 @@ run_status() { "$TERMINAL" /usr/bin/kryptik-chrome --battery & fi warned="$w" - if ! dm_suspended kryptik-state; then - wz="$(waiting_zones | tr '\n' ' ')" - if [ -n "$wz" ] && [ "$wz" != "$waited" ]; then - "$TERMINAL" /usr/bin/kryptik-chrome --waiting & - fi - waited="$wz" + # Opened when the state partition comes back from a suspension seen here: kryptikd + # suspends each zone's volume first, and a look in between would find a zone waiting + # with the state partition up and start a terminal that then stops on it. + if dm_suspended kryptik-state; then + suspended=1 + elif [ -n "$suspended" ]; then + suspended="" + [ -z "$(waiting_zones)" ] || "$TERMINAL" /usr/bin/kryptik-chrome --waiting & fi # A stick plugged in opens the window that asks which zone gets it. set -- "$SYS_BLOCK"/* From 0b60d08419168bb874402370dfd6ef478e7c7f25 Mon Sep 17 00:00:00 2001 From: DevomB Date: Thu, 15 Oct 2026 23:03:03 -0700 Subject: [PATCH 111/123] The zones suite's pids-limit check starts threads to untrusted's limit, not forked Pythons Round 5 (38849928400) never finished its zones suite: the serial line stops after the time checks, at the pids-limit storm, which forked Pythons in untrusted until pids.max refused. fe1d6296 raised untrusted's pids_max from 512 to 2048 for the browser's threads; 2048 sleeping interpreters fill the 3 GiB VM, untrusted's memory_max of 4G bounds nothing below that, and with no swap the guest stalled past zrun's 60 s. The storm is now one Python starting daemon threads with 64 KiB stacks until the start fails: pids.max counts threads as it counts processes, and threads are what the raise was for. That is about 128 MiB at 2048, and every thread ends as the interpreter exits. The check is unchanged: at most the limit, at least half of it, and the zone survives. clone3 gets ENOSYS from the zone's filter, so glibc's threads come by clone, which it allows. --- build/guest-tests/zones-check.sh | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index 3da423d8..0b601ede 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -1497,27 +1497,28 @@ else fi # --- zones: resource limits and lifecycle ----------------------------------- -STORM='import os, time +# Threads, which pids.max counts as it counts processes: as many forked Pythons as the browser's +# limit would fill the VM's memory, which untrusted's memory_max does not bound below. Daemon +# threads, so every one ends as the interpreter exits after its line. +STORM='import threading, time +threading.stack_size(65536) n = 0 -for i in range(3000): +for i in range(10000): try: - p = os.fork() - except BlockingIOError: + threading.Thread(target=time.sleep, args=(300,), daemon=True).start() + except RuntimeError: break - if p == 0: - time.sleep(300); os._exit(0) n += 1 -print("FORKED=%d" % n) +print("STARTED=%d" % n) print("LIMIT-SURVIVED")' -# Python, as bash retries a failed fork for about 15 s and would not reach pids.max in time. zrun untrusted 60 -- /usr/bin/python3 -c "$STORM" if [[ "$ZOUT" == *LIMIT-SURVIVED* ]]; then - forked="$(grep -o 'FORKED=[0-9]*' "$LOG/untrusted.out" | cut -d= -f2)" + started="$(grep -o 'STARTED=[0-9]*' "$LOG/untrusted.out" | cut -d= -f2)" limit="$(sed -n 's/^pids_max *= *\([0-9]*\).*/\1/p' "$Z/untrusted.toml")" # At most the limit, and at least half of it, or the check proves nothing. - [[ -n "$forked" && "$forked" -le "${limit:-1024}" && "$forked" -ge $(( ${limit:-1024} / 2 )) ]] && pass "pids-limit" "bounded at pids_max=$limit (forked $forked before EAGAIN), zone survived" || fail "pids-limit" "forked=$forked limit=$limit" + [[ -n "$started" && "$started" -le "${limit:-1024}" && "$started" -ge $(( ${limit:-1024} / 2 )) ]] && pass "pids-limit" "bounded at pids_max=$limit (started $started threads before EAGAIN), zone survived" || fail "pids-limit" "started=$started limit=$limit" else - fail "pids-limit" "the zone did not survive the fork storm: $(tail -2 "$LOG/untrusted.err" | tr '\n' ' ')" + fail "pids-limit" "the zone did not survive the thread storm: $(tail -2 "$LOG/untrusted.err" | tr '\n' ' ')" fi # dd's own status and words: a tmpfs that took all 3000 MiB is not bounded. zrun untrusted 60 -- sh -c 'out=$(dd if=/dev/zero of=$HOME/big bs=1M count=3000 2>&1); echo "DD-RC=$?"; echo "$out" | grep -o "No space left on device" | head -1; rm -f $HOME/big; echo TMPFS-SURVIVED' From 415f072e52ddceae63cccde6541434f7bffc109a Mon Sep 17 00:00:00 2001 From: DevomB Date: Thu, 15 Oct 2026 23:21:33 -0700 Subject: [PATCH 112/123] gui-check: name every call untrusted's filter refuses Firefox, after a browser check fails A kill's audit line names one call, so a second kill would take another round. After the last key step, a failed browser-window or browser-stays-up runs Firefox as the user in zone 0 under kryptikd seccomp-trace --zone untrusted, in a mount and pid namespace of its own with untrusted's /usr set bound over /usr and no render or sound node. The filter notifies instead of killing: a soft refusal gets the zone's errno, a kill ENOSYS, and Firefox goes on. The counts of each refused call go on a FAIL line, browser-window-trace, which the host prints. --- build/guest-tests/gui-check.sh | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/build/guest-tests/gui-check.sh b/build/guest-tests/gui-check.sh index 5c52ea72..08c58517 100755 --- a/build/guest-tests/gui-check.sh +++ b/build/guest-tests/gui-check.sh @@ -1527,6 +1527,7 @@ wait_for 10 grep -qx 'zone=-' "$RT/kryptik/focus" || info "browser: a window sti mark firefox untrusted launch_plain untrusted "sh -c 'printf \"kryptik-page

kryptik

\" > /tmp/page.html && exec firefox --new-instance file:///tmp/page.html'" > "$LOG/launch-firefox.out" 2>&1 & ff_page() { grep -q '^title=.*kryptik-page' "$RT/kryptik/focus" && grep -qx 'zone=untrusted' "$RT/kryptik/focus"; } +ff_broke="" if wait_for 120 ff_page; then pass "browser-window" "Firefox from the browser image opened a page in untrusted: $(grep '^title=' "$RT/kryptik/focus")" # Its idle startup tasks and its startup cache's first write come later, the write at most @@ -1536,12 +1537,14 @@ if wait_for 120 ff_page; then if ff_page; then pass "browser-stays-up" "Firefox's window is still untrusted's 90 s after its page showed" else + ff_broke="browser-stays-up" fail "browser-stays-up" "focus: $(tr '\n' ' ' < "$RT/kryptik/focus" 2>/dev/null); $(since_mark firefox untrusted | tail -5 | tr '\n' ' '); kernel: $(dmesg 2>/dev/null | grep -E 'type=1326|seccomp' | tail -2 | tr '\n' ' ')" fi else # A start that shows nothing prints nothing in the zone either. The host prints FAIL lines # alone, so this one carries what the proxy refused, what the zone's filter killed, and # whether Firefox still runs and where it waits. + ff_broke="browser-window" fail "browser-window" "focus: $(tr '\n' ' ' < "$RT/kryptik/focus" 2>/dev/null); zone: $(since_mark firefox untrusted | tail -5 | tr '\n' ' '); proxy: $(tail -4 "$plog" 2>/dev/null | tr '\n' ' '); kernel: $(dmesg 2>/dev/null | grep -E 'type=1326|seccomp|segfault|traps:' | tail -4 | tr '\n' ' '); firefox: $(ps -eo pid=,stat=,wchan:24=,etime=,args= 2>/dev/null | grep '[f]irefox' | head -8 | cut -c1-120 | tr '\n' ';'); launch: $(tail -3 "$LOG/launch-firefox.out" 2>/dev/null | tr '\n' ' ')" fi stop_zone untrusted @@ -1574,6 +1577,27 @@ else fi pkill -n -u "$USER_NAME" -f 'havoc /usr/bin/kryptik-chrome --menu' 2>/dev/null +# --- what untrusted's filter refuses Firefox, after a browser check failed ------------------ +# A kill's audit line names one call. Here the filter notifies instead: Firefox runs as the user +# in zone 0 under `kryptikd seccomp-trace --zone untrusted`, in a mount and pid namespace of its +# own with untrusted's /usr set bound over /usr and no render or sound node, so one round names +# every call the filter refuses. A soft refusal gets the errno a zone gets, a kill ENOSYS, and +# Firefox goes on. After the last key step: the window it maps could take a key meant for a check. +if [[ -n "$ff_broke" ]]; then + ffset="$(awk '$1 == "kryptik-usr" && $2 ~ /browser-/ { print $2 }' /proc/self/mounts | tail -1)" + ff_calls="" + if [[ -n "$ffset" ]]; then + ff_calls="$(FFSET="$ffset" FFUSER="$USER_NAME" FFRT="$RT" timeout -k 5 120 \ + unshare -m -p -f --mount-proc --kill-child sh -c ' + mount --bind "$FFSET" /usr || exit 1 + for d in /dev/dri /dev/snd; do [ -d "$d" ] && mount -t tmpfs -o mode=0755 none "$d"; done + exec su -s /bin/bash "$FFUSER" -c "h=\$(mktemp -d) && cd \$h && printf \"kryptik-page

kryptik

\" > page.html && HOME=\$h XDG_RUNTIME_DIR=$FFRT WAYLAND_DISPLAY=wayland-0 timeout -k 5 60 /usr/bin/kryptikd seccomp-trace --zones /usr/lib/kryptik/zones --zone untrusted -- firefox --new-instance file://\$h/page.html"' 2>&1 \ + | sed -n 's/^.*KRYPTIK_SECCOMP_DENIED //p' | sort | uniq -c | sort -rn \ + | awk '{ n = $1; $1 = ""; printf "%s x%d;", $0, n }')" + fi + fail "browser-window-trace" "${ff_broke} failed; untrusted's filter, notifying instead of killing, refused Firefox in zone 0 over ${ffset:-no browser /usr set}:${ff_calls:- nothing}" +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. From 862e93a6b2189cbe60a9bba1e8821b623ae9db3d Mon Sep 17 00:00:00 2001 From: DevomB Date: Thu, 15 Oct 2026 23:28:50 -0700 Subject: [PATCH 113/123] A sleep that fails after a zone volume's key went counts itself, so the chrome still asks for that zone's passphrase #538 opens the chrome's waiting window on the state partition's return from a suspension. A seal that wipes a zone volume's key and then fails, at another volume or at the state partition, never suspends the state partition: seal() thaws the zones whose volume still has its key, and the others keep waiting with no return to see, so no window opened for them, only the menu's WAITING and p. seal() now counts such a failure in /run/kryptik-launch/left-waiting, which group kryptik reads beside the launch socket, writing the next count and renaming it into place. The chrome's watcher takes a new count as it takes the state partition's return, having read the count it starts with, so an old one opens nothing. A seal that wiped nothing before failing counts nothing. kryptikd's unit test counts twice, restarts from a count it cannot read, leaves no temporary file, and does nothing where the directory is missing. laptop.md's zone-volume step says it. --- compartments/kryptikd/src/suspend.rs | 22 ++++++++++++++++++++++ compartments/kryptikd/src/suspend/tests.rs | 19 +++++++++++++++++++ docs/design/laptop.md | 4 +++- tools/desktop/kryptik-chrome | 11 ++++++++--- 4 files changed, 52 insertions(+), 4 deletions(-) diff --git a/compartments/kryptikd/src/suspend.rs b/compartments/kryptikd/src/suspend.rs index d1412487..7e4fc9f5 100644 --- a/compartments/kryptikd/src/suspend.rs +++ b/compartments/kryptikd/src/suspend.rs @@ -35,6 +35,10 @@ const FREEZE_WITHIN: Duration = Duration::from_secs(10); /// How long the state partition's resume may take, its key derivation included. const RESUME_WITHIN: Duration = Duration::from_secs(60); const PATH: &str = "/usr/sbin:/usr/bin:/sbin:/bin"; +/// A seal that failed after wiping a zone volume's key counts itself here, beside the launch +/// socket, where group kryptik reads it: that zone waits with the state partition up, which the +/// chrome cannot tell from a seal under way. +const LEFT_WAITING: &str = "/run/kryptik-launch/left-waiting"; /// Whether the device-mapper device `name` is suspended, as `sys_block` says, or None if no /// device has that name. @@ -192,22 +196,40 @@ pub fn seal() -> Result, String> { // asks for neither skip_lockfs nor noflush), so no zone and no kernel thread, ext4's lazy // init or its journal, writes to a volume without its key; the resume thaws it. One asleep // since an earlier sleep, its passphrase not given yet, stays as it is. + let mut wiped = false; for zone in crate::volume::mappings() { if suspended(&crate::volume::mapper_name(&zone)) { continue; } if let Err(e) = crate::volume::suspend_mapping(&crate::volume::mapper_name(&zone)) { thaw(); + if wiped { + count_left_waiting(Path::new(LEFT_WAITING)); + } return Err(format!("zone {zone:?}'s volume did not suspend: {e}")); } + wiped = true; } if let Err(e) = crate::volume::suspend_mapping(STATE_MAPPER) { thaw(); + if wiped { + count_left_waiting(Path::new(LEFT_WAITING)); + } return Err(format!("the state partition did not suspend: {e}")); } Ok(zones) } +/// One more seal that stopped with a zone volume's key gone, counted at `path`. Best effort: the +/// count only opens the chrome's window, and its menu names a waiting zone either way. +fn count_left_waiting(path: &Path) { + let n: u64 = fs::read_to_string(path).ok().and_then(|s| s.trim().parse().ok()).unwrap_or(0); + let tmp = path.with_extension("new"); + if fs::write(&tmp, format!("{}\n", n + 1)).is_ok() { + let _ = fs::rename(&tmp, path); + } +} + /// Put the machine to sleep (suspend to RAM); returns once it has woken, or with why it did not /// sleep. Nothing is read or started on the way, as the state partition is suspended by now. pub fn sleep() -> Result<(), String> { diff --git a/compartments/kryptikd/src/suspend/tests.rs b/compartments/kryptikd/src/suspend/tests.rs index 236c421e..79cb3c6d 100644 --- a/compartments/kryptikd/src/suspend/tests.rs +++ b/compartments/kryptikd/src/suspend/tests.rs @@ -66,3 +66,22 @@ fn an_update_holding_its_lock_keeps_the_machine_awake() { assert!(!locked_out(&p), "released with its descriptor"); let _ = fs::remove_file(&p); } + +#[test] +fn a_seal_that_left_a_zone_waiting_counts_itself() { + let dir = std::env::temp_dir().join(format!("kryptikd-left-waiting-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let f = dir.join("left-waiting"); + count_left_waiting(&f); + assert_eq!(fs::read_to_string(&f).unwrap(), "1\n"); + count_left_waiting(&f); + assert_eq!(fs::read_to_string(&f).unwrap(), "2\n", "each such seal counts once more"); + fs::write(&f, "garbage").unwrap(); + count_left_waiting(&f); + assert_eq!(fs::read_to_string(&f).unwrap(), "1\n", "a count it cannot read starts again"); + assert!(!dir.join("left-waiting.new").exists(), "the count is renamed into place"); + // No directory to write in: nothing, and no panic. + count_left_waiting(&dir.join("absent").join("left-waiting")); + let _ = fs::remove_dir_all(&dir); +} diff --git a/docs/design/laptop.md b/docs/design/laptop.md index 183b5ae3..d566a9a0 100644 --- a/docs/design/laptop.md +++ b/docs/design/laptop.md @@ -278,7 +278,9 @@ to resume can run from the root while the state partition is suspended. `kryptikd volume resume` as root); the daemon's `info` says `waiting yes` meanwhile, and a launch into it is told why. Once the state partition is back the chrome opens a window that asks for each waiting zone's - passphrase in turn (`kryptik-launch --resume`, on its own terminal); + passphrase in turn (`kryptik-launch --resume`, on its own terminal), as + it does when a sleep fails after a zone volume's key went, which the + daemon counts in `/run/kryptik-launch/left-waiting`; the menu shows the zone WAITING and takes it with `p`, and `kryptik-launch --ask`, as the zone keys run it, asks for it instead of starting the zone again. Stopped without its diff --git a/tools/desktop/kryptik-chrome b/tools/desktop/kryptik-chrome index b23f4c4e..00d53a1c 100755 --- a/tools/desktop/kryptik-chrome +++ b/tools/desktop/kryptik-chrome @@ -61,6 +61,8 @@ SYS_BLOCK=/sys/class/block # kryptikd's record of the camera's grant (camera.rs), and its note that the driver stayed loaded. CAMERA=/run/kryptik-camera/grant CAMERA_STAYED=/run/kryptik-camera/stayed +# kryptikd's count of sleeps that failed after a zone volume's key went (suspend.rs). +LEFT=/run/kryptik-launch/left-waiting die() { echo "kryptik-chrome: $*" >&2; exit 1; } @@ -185,6 +187,7 @@ run_status() { # the state partition is back after a sleep, a window asks for each waiting zone's passphrase. ( shown="" filming="" warned="" suspended="" blocks="" seen="" + left="$(cat "$LEFT" 2>/dev/null)" while :; do mic="$("$SOUND" --control status 2>/dev/null | sed -n 's/^zone \([^ ]*\) .* granted.*/\1/p' | head -1)" if [ -n "$mic" ] && [ "$mic" != "$shown" ]; then @@ -203,11 +206,13 @@ run_status() { warned="$w" # Opened when the state partition comes back from a suspension seen here: kryptikd # suspends each zone's volume first, and a look in between would find a zone waiting - # with the state partition up and start a terminal that then stops on it. + # with the state partition up and start a terminal that then stops on it. A sleep that + # failed after a volume's key went, the state partition never suspended, counts itself. + l="$(cat "$LEFT" 2>/dev/null)" if dm_suspended kryptik-state; then suspended=1 - elif [ -n "$suspended" ]; then - suspended="" + elif [ -n "$suspended" ] || [ "$l" != "$left" ]; then + suspended="" left="$l" [ -z "$(waiting_zones)" ] || "$TERMINAL" /usr/bin/kryptik-chrome --waiting & fi # A stick plugged in opens the window that asks which zone gets it. From acf92b6f38dc06ec4aefd6c9d9f932c3db158209 Mon Sep 17 00:00:00 2001 From: DevomB Date: Thu, 15 Oct 2026 23:31:48 -0700 Subject: [PATCH 114/123] The seal's count is written 0644 through write_atomic, whatever the daemon's umask fs::write left the file's mode to the serve process's umask; a 077 one would hide the count from the session, which reads it as group kryptik. write_atomic gives it its mode, an O_NOFOLLOW temporary named for the process and a sync, as kryptikd writes its other such files; the test checks the mode. --- compartments/kryptikd/src/suspend.rs | 10 ++++------ compartments/kryptikd/src/suspend/tests.rs | 4 +++- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/compartments/kryptikd/src/suspend.rs b/compartments/kryptikd/src/suspend.rs index 7e4fc9f5..9402d3d8 100644 --- a/compartments/kryptikd/src/suspend.rs +++ b/compartments/kryptikd/src/suspend.rs @@ -220,14 +220,12 @@ pub fn seal() -> Result, String> { Ok(zones) } -/// One more seal that stopped with a zone volume's key gone, counted at `path`. Best effort: the -/// count only opens the chrome's window, and its menu names a waiting zone either way. +/// One more seal that stopped with a zone volume's key gone, counted at `path`, 0644 whatever the +/// daemon's umask, as the session reads it. Best effort: the count only opens the chrome's window, +/// and its menu names a waiting zone either way. fn count_left_waiting(path: &Path) { let n: u64 = fs::read_to_string(path).ok().and_then(|s| s.trim().parse().ok()).unwrap_or(0); - let tmp = path.with_extension("new"); - if fs::write(&tmp, format!("{}\n", n + 1)).is_ok() { - let _ = fs::rename(&tmp, path); - } + let _ = crate::files::write_atomic(path, &[format!("{}\n", n + 1).as_bytes()], 0o644, None); } /// Put the machine to sleep (suspend to RAM); returns once it has woken, or with why it did not diff --git a/compartments/kryptikd/src/suspend/tests.rs b/compartments/kryptikd/src/suspend/tests.rs index 79cb3c6d..803c72e9 100644 --- a/compartments/kryptikd/src/suspend/tests.rs +++ b/compartments/kryptikd/src/suspend/tests.rs @@ -1,4 +1,5 @@ use super::*; +use std::os::unix::fs::PermissionsExt; /// A /sys/block of the kernel's shape: device-mapper devices with a name and a suspended flag. fn sys_block(dir: &Path, devices: &[(&str, &str, &str)]) { @@ -80,7 +81,8 @@ fn a_seal_that_left_a_zone_waiting_counts_itself() { fs::write(&f, "garbage").unwrap(); count_left_waiting(&f); assert_eq!(fs::read_to_string(&f).unwrap(), "1\n", "a count it cannot read starts again"); - assert!(!dir.join("left-waiting.new").exists(), "the count is renamed into place"); + assert!(!dir.join(format!(".left-waiting.{}", std::process::id())).exists(), "the count is renamed into place"); + assert_eq!(fs::metadata(&f).unwrap().permissions().mode() & 0o777, 0o644, "the session's group reads it"); // No directory to write in: nothing, and no panic. count_left_waiting(&dir.join("absent").join("left-waiting")); let _ = fs::remove_dir_all(&dir); From 9a6d45d308f18b52ed2a254f88976dcc176463a4 Mon Sep 17 00:00:00 2001 From: DevomB Date: Thu, 15 Oct 2026 23:35:53 -0700 Subject: [PATCH 115/123] software-delivery.md: a corrupted image is refused until the machine restarts The check list said a zone naming a corrupted image is refused until the image is fetched again. images.rs refuses a mapping dm-verity has marked corrupted until the machine restarts, as the description above it says. What a fetch does is the store's: an update that carries the image replaces the stored copy, since images_keep keeps one only while its size and SHA-256 match the list's. --- docs/design/software-delivery.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/design/software-delivery.md b/docs/design/software-delivery.md index 07eefbf3..8e54b3db 100644 --- a/docs/design/software-delivery.md +++ b/docs/design/software-delivery.md @@ -291,7 +291,9 @@ up ADR-001 in the zones where the applications run. the list's signature again, as nobody, on a copy of the bytes it then reads. A mapping that dm-verity has marked corrupted is not used again: the next start of a zone that names the image is refused, with the - reason, until this machine restarts. For each distinct set of images + reason, until this machine restarts. An update that carries the image + replaces the stored copy, since the store keeps an image only while its + size and SHA-256 match the list's. For each distinct set of images a zone names, kryptikd mounts a read-only overlay (no upper directory; the images' `usr` over the root's `/usr`; redirects in a layer not followed) at `/run/kryptik/usr/` in the initial namespace, and the zone's bind @@ -344,7 +346,7 @@ image-only update from the channel: zone already running keeps the old image until it stops; - a flipped block in a stored image reads as an I/O error in the zone, the machine keeps running, and the next start of a zone that names the image is - refused with the reason until it is fetched again; + refused with the reason until the machine restarts; - an image built for another base, an image manifest signed in `kryptik-release`, and a root manifest signed in `kryptik-image` are each refused; From fc7c034512f976ce026cf61a9cab592b38085908 Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 16 Oct 2026 00:00:12 -0700 Subject: [PATCH 116/123] libglvnd has a mirror while gitlab.freedesktop.org does not answer: Void's source mirror, which keeps the same archive libglvnd 1.7.0 came from gitlab.freedesktop.org's archive alone, and main's first run after the browser fetches the toolkit's sources cold, as freedesktop.org's hosts have failed such fetches before. Void Linux's source mirror keeps the very archive: libglvnd-v1.7.0.tar.gz at sources.voidlinux.org/libglvnd-1.7.0/, 839,820 bytes, SHA-256 2b6e15b0...f2f3d, the lock's. Debian's orig tarball (073e7292...) and Fedora's lookaside copy (8797914f...) are other archives of the tag and would not pass the lock; Alpine and BLFS keep none. The row goes after upstream's host and before Gentoo's distfiles, as each mirror row does, and names no signature, as upstream publishes none. --- tools/source-mirrors.tsv | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/source-mirrors.tsv b/tools/source-mirrors.tsv index dc91495d..630b5724 100644 --- a/tools/source-mirrors.tsv +++ b/tools/source-mirrors.tsv @@ -21,3 +21,4 @@ pixman https://deb.debian.org/debian/pool/main/p/pixman/pixman_{VER} hicolor-icon-theme https://deb.debian.org/debian/pool/main/h/hicolor-icon-theme/hicolor-icon-theme_{VER}.orig.tar.xz .asc hicolor-icon-theme https://distfiles.alpinelinux.org/distfiles/v3.22/hicolor-icon-theme-{VER}.tar.xz - cairo https://ftp2.osuosl.org/pub/blfs/conglomeration/cairo/cairo-{VER}.tar.xz - +libglvnd https://sources.voidlinux.org/libglvnd-{VER}/libglvnd-v{VER}.tar.gz - From 7950ae8974b8dc04bfb5e5100cdd63d30638c55c Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 16 Oct 2026 00:20:35 -0700 Subject: [PATCH 117/123] The pin reviews reach rustc 1.99.1 and pkgconf 3.0.9, the releases upstream made since Kernel currency's gate failed on both the branch's dispatched runs: rustc was reviewed to 1.99.0 and pkgconf to 3.0.8, and each upstream has released since. On a pull request the gate only informs; on main's push it fails, so the browser push would turn main's Kernel currency red. rustc 1.99.1 (2026-10-15) fixes three things: a miscompilation 1.99.0 brought in on x86-64, where a function returning bool to C left the upper bits set because rustc stopped marking the return zeroext (rust-lang/rust#163911); wcslen on UEFI and embedded targets; and rust-lld's rpath on macOS. 1.98.1, the pin, still masks the bool. The row now says never 1.99.0. pkgconf 3.0.9 (2026-10-15) is three commits: the Autotools build runs spdxtool's utility tests, NEWS, and the version. No security advisory is published for either. --- tools/pin-reviews.tsv | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/pin-reviews.tsv b/tools/pin-reviews.tsv index 96cdc4f9..3decfab9 100644 --- a/tools/pin-reviews.tsv +++ b/tools/pin-reviews.tsv @@ -12,7 +12,7 @@ binutils 2.43.1 2.47 fine 2026-09-19 NEWS 2.44 to 2.47 and the Debi 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-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. glibc-branch cdaa5d6db08e 9bc3c99bffba fine 2026-10-15 build/patches/glibc-2.40's 0001 carries release/2.40/master to cdaa5d6d. The branch's one commit since, 9bc3c99b (2026-10-13), fixes an off-by-one in the AT_HWCAP4 loop of sysdeps/powerpc/dl-procinfo.h, reached only on PowerPC: nothing in it builds for x86-64. Move the patch to the branch head when the branch next fixes generic or x86-64 code. -rustc 1.98.1 1.99.0 fine 2026-10-13 Release notes 1.99.0: language, lint, library and Cargo changes; no security fix. The browser's layer and kryptikd (build/config/rust.lock) move together. +rustc 1.98.1 1.99.1 fine 2026-10-16 Release notes 1.99.0: language, lint, library and Cargo changes; no security fix. 1.99.1 fixes a miscompilation that 1.99.0 brought in on x86-64, a bool returned to C with its upper bits left set (rust-lang/rust#163911: rustc stopped marking the return zeroext), and wcslen on UEFI and rust-lld's rpath on macOS; 1.98.1 still masks the bool. Never 1.99.0. The browser's layer and kryptikd (build/config/rust.lock) move together. 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 --------------- @@ -31,7 +31,7 @@ gettext 0.22.5 1.0 fine 2026-09-19 NEWS 0.23 to 1.0: no security texinfo 7.1 7.3 fine 2026-09-19 NEWS 7.1.1 to 7.3: no security fix. libtool 2.4.7 2.6.2 fine 2026-09-19 NEWS 2.5.x and 2.6.x: no security fix. gperf 3.1 3.3 fine 2026-09-19 NEWS 3.2 and 3.3: no security fix. -pkgconf 2.3.0 3.0.8 fine 2026-10-09 NEWS to 3.0.8: no security fix. 3.0 breaks the libpkgconf ABI, moves to meson and changed quoting and sysroot handling twice; 3.0.8 (2026-10-09) percent-encodes spdxtool URLs, uses the platform path separator in the built-in path variables, and fixes documentation. +pkgconf 2.3.0 3.0.9 fine 2026-10-16 NEWS to 3.0.9: no security fix. 3.0 breaks the libpkgconf ABI, moves to meson and changed quoting and sysroot handling twice; 3.0.8 (2026-10-09) percent-encodes spdxtool URLs, uses the platform path separator in the built-in path variables, and fixes documentation; 3.0.9 (2026-10-15) only adds spdxtool's utility tests to the Autotools build (d71cd7a364). intel-microcode 20260812 20260925 fine 2026-09-27 Release notes 20260925: one functional update, Meteor Lake C0 (06-aa-04, 0x28 to 0x2b), and no security advisory. ca-bundle 2026-08-13 2026-09-25 fine 2026-09-27 The 2026-09-25 bundle holds the same 121 certificates, byte for byte; only its header's date and certdata.txt digest differ. groff 1.23.0 1.24.2 fine 2026-09-29 NEWS 1.24.0 to 1.24.2: 1.24.2 fixes command injection (CWE-78) in mmroff, pdfmom and pre-grohtml, which the image does not install (build/recipes/groff.sh); man renders for a terminal, never HTML. 1.24.0 has incompatible request-syntax changes; never that one. From 9b042a66b13cadc388cf8b9aef3823f36a2bbc02 Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 16 Oct 2026 00:26:44 -0700 Subject: [PATCH 118/123] wlroots and libdrm have a mirror while freedesktop.org's hosts do not answer: Void's and BLFS's copies of the same archives Source reachability names wlroots 0.19.3 and libdrm 2.4.129 as served by their upstream alone, gitlab.freedesktop.org's release download and dri.freedesktop.org, hosts that have failed cold fetches before; Gentoo's distfiles keep neither file. A cold build stops at them. Void Linux's source mirror keeps wlroots-0.19.3.tar.gz, 671,529 bytes with the lock's 5d026931..ccc5, and BLFS's conglomeration on OSUOSL keeps libdrm-2.4.129.tar.xz, 433,392 bytes with the lock's 5978183f..77dc. Alpine's wlroots-0.19.3.tar.gz is another archive (a6ff89b6..), and Debian, Alpine and Void keep no libdrm 2.4.129. Neither mirror keeps upstream's signature, so both rows name none, as the Alpine wayland row does. Void names its package after the series (wlroots0.19), which a comment says, as the row has to move with a series change. libinput 1.30.4 stays its upstream's alone: no distribution keeps the GitLab archive of 1.30.4. --- tools/source-mirrors.tsv | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tools/source-mirrors.tsv b/tools/source-mirrors.tsv index 630b5724..5f331597 100644 --- a/tools/source-mirrors.tsv +++ b/tools/source-mirrors.tsv @@ -18,6 +18,9 @@ wayland-protocols https://deb.debian.org/debian/pool/main/w/wayland-protocols/w wayland-protocols https://distfiles.alpinelinux.org/distfiles/edge/wayland-protocols-{VER}.tar.xz - xkeyboard-config https://deb.debian.org/debian/pool/main/x/xkeyboard-config/xkeyboard-config_{VER}.orig.tar.xz .asc pixman https://deb.debian.org/debian/pool/main/p/pixman/pixman_{VER}.orig.tar.gz - +libdrm https://ftp2.osuosl.org/pub/blfs/conglomeration/libdrm/libdrm-{VER}.tar.xz - +# Void names its package after the series, so the row moves with a series change. +wlroots https://sources.voidlinux.org/wlroots0.19-{VER}/wlroots-{VER}.tar.gz - hicolor-icon-theme https://deb.debian.org/debian/pool/main/h/hicolor-icon-theme/hicolor-icon-theme_{VER}.orig.tar.xz .asc hicolor-icon-theme https://distfiles.alpinelinux.org/distfiles/v3.22/hicolor-icon-theme-{VER}.tar.xz - cairo https://ftp2.osuosl.org/pub/blfs/conglomeration/cairo/cairo-{VER}.tar.xz - From d00298cba9180b50c66ba55e7acdac19a707a11c Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 16 Oct 2026 00:27:12 -0700 Subject: [PATCH 119/123] The wlroots and libdrm mirror rows go at the table's end, after libglvnd So they merge cleanly beside the block of mirror rows that goes in above the wayland rows. --- tools/source-mirrors.tsv | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/source-mirrors.tsv b/tools/source-mirrors.tsv index 5f331597..81ec27cc 100644 --- a/tools/source-mirrors.tsv +++ b/tools/source-mirrors.tsv @@ -18,10 +18,10 @@ wayland-protocols https://deb.debian.org/debian/pool/main/w/wayland-protocols/w wayland-protocols https://distfiles.alpinelinux.org/distfiles/edge/wayland-protocols-{VER}.tar.xz - xkeyboard-config https://deb.debian.org/debian/pool/main/x/xkeyboard-config/xkeyboard-config_{VER}.orig.tar.xz .asc pixman https://deb.debian.org/debian/pool/main/p/pixman/pixman_{VER}.orig.tar.gz - -libdrm https://ftp2.osuosl.org/pub/blfs/conglomeration/libdrm/libdrm-{VER}.tar.xz - -# Void names its package after the series, so the row moves with a series change. -wlroots https://sources.voidlinux.org/wlroots0.19-{VER}/wlroots-{VER}.tar.gz - hicolor-icon-theme https://deb.debian.org/debian/pool/main/h/hicolor-icon-theme/hicolor-icon-theme_{VER}.orig.tar.xz .asc hicolor-icon-theme https://distfiles.alpinelinux.org/distfiles/v3.22/hicolor-icon-theme-{VER}.tar.xz - cairo https://ftp2.osuosl.org/pub/blfs/conglomeration/cairo/cairo-{VER}.tar.xz - libglvnd https://sources.voidlinux.org/libglvnd-{VER}/libglvnd-v{VER}.tar.gz - +libdrm https://ftp2.osuosl.org/pub/blfs/conglomeration/libdrm/libdrm-{VER}.tar.xz - +# Void names its package after the series, so the row moves with a series change. +wlroots https://sources.voidlinux.org/wlroots0.19-{VER}/wlroots-{VER}.tar.gz - From ceb7b855492f6af5bc63ec782ffa535d7bd96849 Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 16 Oct 2026 00:27:41 -0700 Subject: [PATCH 120/123] Twenty-five sources that only their own host served get a second place, each checked against the lock The probe of every place each source is kept found 51 sources that one place alone serves. These rows give 25 of them another: the LFS 12.2 package set and BLFS's archive at OSUOSL, kernel.org's and sourceware's mirrors, a nongnu mirror, metacpan and OSUOSL's CPAN, Kitware's GitHub releases, and Debian's, Alpine's and Void's copies. Each file was downloaded on 2026-10-16 and has the lock's SHA-256. No row claims upstream's signature. libaio's own host, releases.pagure.org, answers 404 for its tarball today; only Gentoo's distfiles served it. A row in a fixed directory, LFS 12.2's set or util-linux's v2.42, lapses when the pin moves past it, as a distribution's row does, and the fetch asks the next place. --- tools/source-mirrors.tsv | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tools/source-mirrors.tsv b/tools/source-mirrors.tsv index 630b5724..46cf0545 100644 --- a/tools/source-mirrors.tsv +++ b/tools/source-mirrors.tsv @@ -12,6 +12,43 @@ # # Each row was checked when added: the mirror's file has the lock's hash, and its signature was # made by the key upstream's own is made by. +file https://ftp.osuosl.org/pub/lfs/lfs-packages/12.2/file-{VER}.tar.gz - +attr https://mirror.csclub.uwaterloo.ca/nongnu/attr/attr-{VER}.tar.gz - +attr https://sources.voidlinux.org/attr-{VER}/attr-{VER}.tar.gz - +pkgconf https://ftp.osuosl.org/pub/lfs/lfs-packages/12.2/pkgconf-{VER}.tar.xz - +pkgconf https://distfiles.alpinelinux.org/distfiles/v3.21/pkgconf-{VER}.tar.xz - +util-linux https://mirrors.mit.edu/kernel/linux/utils/util-linux/v2.42/util-linux-{VER}.tar.xz - +e2fsprogs https://mirror.math.princeton.edu/pub/kernel/linux/kernel/people/tytso/e2fsprogs/v{VER}/e2fsprogs-{VER}.tar.gz - +procps-ng https://master.dl.sourceforge.net/project/procps-ng/Production/procps-ng-{VER}.tar.xz - +procps-ng https://distfiles.alpinelinux.org/distfiles/edge/procps-ng-{VER}.tar.xz - +libpipeline https://mirror.csclub.uwaterloo.ca/nongnu/libpipeline/libpipeline-{VER}.tar.gz - +libpipeline https://ftp.osuosl.org/pub/lfs/lfs-packages/12.2/libpipeline-{VER}.tar.gz - +man-db https://mirror.csclub.uwaterloo.ca/nongnu/man-db/man-db-{VER}.tar.xz - +man-db https://ftp.osuosl.org/pub/lfs/lfs-packages/12.2/man-db-{VER}.tar.xz - +elfutils https://mirrors.kernel.org/sourceware/elfutils/{VER}/elfutils-{VER}.tar.bz2 - +elfutils https://ftp.osuosl.org/pub/lfs/lfs-packages/12.2/elfutils-{VER}.tar.bz2 - +perl https://cpan.metacpan.org/src/5.0/perl-{VER}.tar.xz - +perl https://ftp.osuosl.org/pub/CPAN/src/5.0/perl-{VER}.tar.xz - +skalibs https://distfiles.alpinelinux.org/distfiles/edge/skalibs-{VER}.tar.gz - +execline https://distfiles.alpinelinux.org/distfiles/edge/execline-{VER}.tar.gz - +s6 https://distfiles.alpinelinux.org/distfiles/edge/s6-{VER}.tar.gz - +s6-rc https://distfiles.alpinelinux.org/distfiles/edge/s6-rc-{VER}.tar.gz - +s6-linux-init https://distfiles.alpinelinux.org/distfiles/edge/s6-linux-init-{VER}.tar.gz - +cmake https://github.com/Kitware/CMake/releases/download/v{VER}/cmake-{VER}.tar.gz - +cmake-bin https://github.com/Kitware/CMake/releases/download/v{VER}/cmake-{VER}-linux-x86_64.tar.gz - +libaio https://ftp2.osuosl.org/pub/blfs/conglomeration/libaio/libaio-{VER}.tar.gz - +libaio https://deb.debian.org/debian/pool/main/liba/libaio/libaio_{VER}.orig.tar.gz - +lvm2 https://mirrors.kernel.org/sourceware/lvm2/LVM2.{VER}.tgz - +lvm2 https://ftp2.osuosl.org/pub/blfs/conglomeration/lvm2/LVM2.{VER}.tgz - +seatd https://ftp2.osuosl.org/pub/blfs/conglomeration/seatd/seatd-{VER}.tar.gz - +seatd https://deb.debian.org/debian/pool/main/s/seatd/seatd_{VER}.orig.tar.gz - +dwl https://distfiles.alpinelinux.org/distfiles/edge/dwl-v{VER}.tar.gz - +lynx https://invisible-island.net/archives/lynx/tarballs/lynx{VER}.tar.bz2 - +lynx https://ftp2.osuosl.org/pub/blfs/conglomeration/lynx/lynx{VER}.tar.bz2 - +glibc-fhs-patch https://ftp.osuosl.org/pub/lfs/lfs-packages/12.2/glibc-{VER}-fhs-1.patch - +glibc-fhs-patch https://ftp.lfs-matrix.net/pub/lfs/lfs-packages/12.2/glibc-{VER}-fhs-1.patch - +glib https://ftp2.osuosl.org/pub/blfs/conglomeration/glib/glib-{VER}.tar.xz - +at-spi2-core https://ftp2.osuosl.org/pub/blfs/conglomeration/at-spi2-core/at-spi2-core-{VER}.tar.xz - wayland https://deb.debian.org/debian/pool/main/w/wayland/wayland_{VER}.orig.tar.xz .asc wayland https://distfiles.alpinelinux.org/distfiles/edge/wayland-{VER}.tar.xz - wayland-protocols https://deb.debian.org/debian/pool/main/w/wayland-protocols/wayland-protocols_{VER}.orig.tar.xz .asc From 812815ba3501246adf73248383d19ea16653fa69 Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 16 Oct 2026 00:39:41 -0700 Subject: [PATCH 121/123] zones-check: image-corrupt takes EIO in musl's words too Round 6's image-corrupt failed on wording alone: the flipped block did read as EIO, and kryptikd, static musl, reported its exec of the probe as "I/O error (os error 5)", while the check looked for glibc's "Input/output error". The next start was refused as signed. --- build/guest-tests/zones-check.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index 0b601ede..c9381a0d 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -1816,7 +1816,10 @@ else irun imgok /usr/bin/kryptik-probe rc1="$ZRC"; said1="$ZOUT $ZERR" irun imgok /usr/bin/kryptik-probe - if [[ "$rc1" -ne 0 && "$said1" == *"Input/output error"* && "$ZRC" -ne 0 && "$ZERR" == *"read back other than signed"* ]]; then + # EIO in either C library's words: kryptikd is static musl ("I/O error"), the image's + # programs are glibc's ("Input/output error"). + if [[ "$rc1" -ne 0 && ( "$said1" == *"Input/output error"* || "$said1" == *"I/O error"* ) \ + && "$ZRC" -ne 0 && "$ZERR" == *"read back other than signed"* ]]; then pass "image-corrupt" "a flipped block reads as an I/O error in the zone, and the next start is refused" else fail "image-corrupt" "first rc ${rc1}: $(tail -2 <<<"$said1" | tr '\n' ' '); then rc ${ZRC}: $(tail -2 <<<"$ZERR" | tr '\n' ' ')" From 83d6fb9dc4f00588ce430c4f863794f5e730bf9f Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 16 Oct 2026 00:41:43 -0700 Subject: [PATCH 122/123] A zone's quotactl fails with EPERM instead of killing the caller Firefox's nsLocalFile::GetDiskSpaceAvailable asks the user's quota on the profile's device (quotactl Q_GETQUOTA) from its network cache's thread, Cache2 I/O, a moment after it starts. The zone filter killed it for that before a window showed: the audit line in the desktop suite's browser-window failure reads syscall=179, comm "Cache2 I/O", exe /usr/lib/firefox/firefox. On any failure Firefox keeps the free space statfs gave, so EPERM costs it nothing. quotactl stays in the denied list, so no policy opens it: setting a quota is its other use. A unit test holds both, and zone-policy-files.md says why. --- compartments/kryptikd/src/seccomp.rs | 3 +++ compartments/kryptikd/src/seccomp/tests.rs | 8 ++++++++ docs/design/zone-policy-files.md | 5 +++++ 3 files changed, 16 insertions(+) diff --git a/compartments/kryptikd/src/seccomp.rs b/compartments/kryptikd/src/seccomp.rs index 3e9c3f9d..bf9bdfc5 100644 --- a/compartments/kryptikd/src/seccomp.rs +++ b/compartments/kryptikd/src/seccomp.rs @@ -428,6 +428,9 @@ pub const REFUSED_SOFTLY: &[(libc::c_long, u32)] = &[ // Firefox lowers the I/O priority of the thread writing its startup cache and writes at the // normal one when refused; killed, the browser ends seconds after its window shows. (libc::SYS_ioprio_set, EPERM), + // Denied, but Firefox's disk-space check reads the user's quota from its network cache's + // thread at start and keeps statfs's answer when refused; killed, no window ever shows. + (libc::SYS_quotactl, EPERM), ]; const fn errno_action(e: u32) -> u32 { diff --git a/compartments/kryptikd/src/seccomp/tests.rs b/compartments/kryptikd/src/seccomp/tests.rs index 9e0e46a7..b0b25265 100644 --- a/compartments/kryptikd/src/seccomp/tests.rs +++ b/compartments/kryptikd/src/seccomp/tests.rs @@ -306,6 +306,14 @@ fn thread_scheduling_refused_softly() { assert_eq!(evaluate(&p, X86, libc::SYS_sched_setscheduler as u32), SECCOMP_RET_ALLOW, "a policy opens it"); } +#[test] +fn quota_read_refused_softly() { + // Reading a quota hears EPERM, not a kill; quotactl stays denied, so no policy opens it. + let p = build_program(BASE_ALLOWLIST).unwrap(); + assert_eq!(evaluate(&p, X86, libc::SYS_quotactl as u32), errno_action(EPERM)); + assert!(is_denied(libc::SYS_quotactl), "quotactl must stay denied"); +} + #[test] fn io_priority_refused_softly() { // Lowering a thread's I/O priority hears EPERM, not a kill; reading it stays allowed. diff --git a/docs/design/zone-policy-files.md b/docs/design/zone-policy-files.md index cd677a19..b524a075 100755 --- a/docs/design/zone-policy-files.md +++ b/docs/design/zone-policy-files.md @@ -77,6 +77,11 @@ a kill, is printed with `soft` after its name and gets the same errno here: and again on a timer, and writes at the normal priority when refused, where a kill would end the browser. A policy may allow it, and the kernel still refuses the real-time class without `CAP_SYS_NICE`. +- `quotactl` fails with EPERM: Firefox's disk-space check reads the user's + quota on the profile's device from its network cache's thread, a moment + after it starts, and keeps the free space `statfs` gave when refused, + where a kill ended the browser before its window showed. It stays denied: + no policy opens it, as setting a quota is the call's other use. A printed name can go on an `allow-syscall` line unless it is on the denied list; a call refused for its arguments (namespace flags to `clone`, From 2da7c6c25a667a17e739b43e6b868c4eca15333e Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 16 Oct 2026 00:42:52 -0700 Subject: [PATCH 123/123] The zones suite's passphrase search leaves out the signed images mounted under /run/kryptik, and names any file it finds Round 6 failed no-passphrase-leak with no passphrase anywhere: the search is grep -r over /run/kryptik, and since personal and untrusted carry the browser and the toolkit, kryptikd keeps their images mounted at /run/kryptik/images and their stacked sets at /run/kryptik/usr. The grep walked Firefox's and GTK's whole trees, where a "dev-pass" in any file is a match. A leaks helper searches the files under /run/kryptik with those two mounts pruned, and every command line, through xargs and grep -l, so the check's failure names the files that matched, never what they hold. The zones' roots are in mount namespaces of their own, so the host's /run/kryptik holds the registry and the two image mounts alone: the search over the registry and the command lines is unchanged. --- build/guest-tests/zones-check.sh | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index 0b601ede..97ed8745 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -494,9 +494,17 @@ grep -q PERSONAL-UP "$LOG/personal-bg.out" && pass "encrypted-zone-start" "perso # with the storage checks). It must find that launcher's command line, or # finding no passphrase says nothing. The [e] and [s] keep a grep's own # command line from matching. -pp_seen=0; pp_leak=0 +# leaks PATTERN: the files under /run/kryptik and the command lines that hold +# it. The signed images a zone stacks are mounted under /run/kryptik/images and +# /run/kryptik/usr (images.rs) and hold only what was signed, a browser's whole +# tree among it, so they are not searched. +leaks() { + { find /run/kryptik \( -path /run/kryptik/images -o -path /run/kryptik/usr \) -prune -o -type f -print0 2>/dev/null + printf '%s\0' /proc/[0-9]*/cmdline; } | xargs -0r grep -ls -- "$1" 2>/dev/null +} +pp_seen=0 grep -qs 'passphrase-fil[e]' /proc/[0-9]*/cmdline && pp_seen=1 -grep -rqs 'personal-pas[s]' /run/kryptik /proc/[0-9]*/cmdline && pp_leak=1 +pp_leak="$(leaks 'personal-pas[s]')" # An echo that gets no answer shows separation only while personal holds the # address that was tried, and untrusted has a path: it reaches the bridge. per_init="$(cut -d' ' -f1 /run/kryptik/zones/personal/init.pid 2>/dev/null)" @@ -1661,10 +1669,11 @@ grep -qE 'VAULT-PING rc=2 ' <<<"$ZOUT" && pass "vault-ping" "ping in an offline [[ "$ZOUT" == *VAULT-ISOLATED* && "$ZOUT" == *VAULT-WROTE* && "$ZOUT" == *LINKS=0* ]] && pass "vault-offline" "vault has loopback only, no path to the bridge, and keeps data" || fail "vault-offline" "$(tr '\n' ' ' <<<"$ZOUT") $(tail -1 "$LOG/vault.err")" # No passphrase on any command line or in the registry: while personal ran # (above), and now that every zone that took one has stopped. +pp_late="$(leaks 'personal-pas[s]\|vault-pas[s]\|dev-pas[s]')" if [[ "$pp_seen" != 1 ]]; then fail "no-passphrase-leak" "the search did not see personal's launcher while it ran, so it proves nothing" -elif [[ "$pp_leak" = 1 ]] || grep -rqs 'personal-pas[s]\|vault-pas[s]\|dev-pas[s]' /run/kryptik /proc/[0-9]*/cmdline; then - fail "no-passphrase-leak" "a passphrase appeared in the registry or a command line" +elif [[ -n "$pp_leak$pp_late" ]]; then + fail "no-passphrase-leak" "a passphrase appeared in the registry or a command line: $(printf '%s\n' $pp_leak $pp_late | sort -u | tr '\n' ' ')" else pass "no-passphrase-leak" "no passphrase in /run/kryptik or any command line, while personal ran and after the zones stopped" fi