From 423f6fda73fca7256ab47b1a8bdc3c5131312f73 Mon Sep 17 00:00:00 2001 From: japabu Date: Tue, 13 Oct 2026 04:06:50 +0200 Subject: [PATCH 1/5] The package repository's keys: toyos-root signs root 1 offline, and the publisher signs under it with the repository's signing key alone Stage 1 of the design accepted on #892: apps leave the tree as packages that `pkg` installs from a repository CI signs. This stage builds the keys. - `toyos-root` (`build/root`), a new crate depending only on toyos-update with `sign` and on getrandom, so it compiles for any triple without a C toolchain. `new` mints the root key as a 64-hex-digit seed file; `sign` writes root 1, which gives the root key the root role alone and the signing key the timestamp and targets roles, each at threshold 1, expiring a year from the machine's clock, read back as self-signed before it is written. Its module header carries the ceremony's steps. Signing root N+1 (`--previous`), renewal and rotation are migration stage 9's. - `cargo run -- --repo-signing-key-new` mints the repository's signing key at `~/.config/toyos/repo-signing-key` (0600), distinct from the image key, prints its fingerprint on stderr and its public line on stdout, so the line can be redirected onto the stick the ceremony carries. - `build/publish.rs` loses root minting, root renewal and `one_key`. It receives roots under `roots/` beside its manifest, copies those the repository lacks and never rewrites one it holds, holds root 1 to `repo::self_signed`, and refuses by name a newest root that does not grant its key the targets and timestamp roles. It signs with the repository's signing key only. Every name is flat (`--.tar.gz`, no `archives/`), which a GitHub release's asset namespace keeps. After a publish leaving targets M it prunes every targets below M-1 and every archive neither M nor M-1 names, and never a root. - `toyos_update::repo` gains `self_signed`, `fingerprint`, `public_line` and its parser, and `render::first_root`, the one definition of root 1 that toyos-root signs and the publisher's tests use. The fingerprint is held to OpenSSH's (`ssh-keygen -l` over the fixture's key). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy --- Cargo.lock | 9 + Cargo.toml | 1 + build/flags.rs | 9 +- build/main.rs | 35 ++- build/publish.rs | 376 ++++++++++++++++++++------------ build/root/Cargo.toml | 21 ++ build/root/src/main.rs | 215 ++++++++++++++++++ build/signing.rs | 119 +++++++--- lib/update/src/repo.rs | 110 +++++++++- lib/update/tests/repo_oracle.rs | 13 ++ 10 files changed, 728 insertions(+), 180 deletions(-) create mode 100644 build/root/Cargo.toml create mode 100644 build/root/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index e139e2ed673..3b7c09d51e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4565,6 +4565,15 @@ version = "0.1.0" name = "toyos-random" version = "0.1.0" +[[package]] +name = "toyos-root" +version = "0.1.0" +dependencies = [ + "getrandom 0.3.4", + "toyos-tmpdir", + "toyos-update", +] + [[package]] name = "toyos-rootimage" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 333021bd1b0..22415840edc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ members = [ "apps/paint", "apps/snake", "apps/toyfetch", + "build/root", "build/ssh", "kernel", "kernel/cpuvuln", diff --git a/build/flags.rs b/build/flags.rs index 4daadd9765d..9c2e0c7c808 100644 --- a/build/flags.rs +++ b/build/flags.rs @@ -79,8 +79,13 @@ declare_flags!(pub CARGO_RUN = { /// Write the image `ssh update` takes to this path, signed with /// the owner's key. pub UPDATE_IMAGE = "--update-image", Next; - /// Publish the packages this `packages.toml` lists into the package - /// repository of the key this run signs with (`build/publish.rs`). + /// Mint the package repository's signing key where + /// `signing::repo_key_path` says, refusing to replace one, and print its + /// public line. + pub REPO_SIGNING_KEY_NEW = "--repo-signing-key-new", None; + /// Publish the packages this `packages.toml` lists, under the roots in + /// `roots/` beside it, into the package repository under `$HOME`, signed + /// with the repository's signing key (`build/publish.rs`). pub PUBLISH = "--publish", Next; }); diff --git a/build/main.rs b/build/main.rs index cd6d31acc95..8d39c0292af 100644 --- a/build/main.rs +++ b/build/main.rs @@ -123,6 +123,23 @@ fn main() { } return; } + // Writes one file outside the checkout and builds nothing. The public + // line alone goes to stdout, so it can be redirected onto the stick the + // root-key ceremony carries it on. + if asked(&flags::REPO_SIGNING_KEY_NEW) { + let minted = toyos_build::signing::repo_key_path().and_then(|path| toyos_build::signing::mint_repo_key(&path).map(|key| (path, key))); + match minted { + Ok((path, key)) => { + eprintln!("The repository's signing key is at {} ({}).", path.display(), key.fingerprint()); + println!("{}", toyos_update::repo::public_line(&key.public())); + } + Err(why) => { + eprintln!("Error: {why}"); + std::process::exit(1); + } + } + return; + } // Before anything is built, so a missing key is refused before any lock // and no image this run writes is signed by two keys. let update_image = CARGO_RUN.value(&args, &flags::UPDATE_IMAGE).map(PathBuf::from); @@ -135,23 +152,27 @@ fn main() { } } } - // Writes the key's package repository and builds nothing. + // Writes the package repository and builds nothing. if let Some(manifest) = CARGO_RUN.value(&args, &flags::PUBLISH) { - let key = toyos_build::signing::key(); let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("a clock after 1970") .as_secs(); - let published = toyos_build::publish::repository(&root, key) - .and_then(|dir| toyos_build::publish::publish(Path::new(manifest), &dir, key, now).map(|p| (dir, p))); + let published = toyos_build::signing::repo_key_path() + .and_then(|path| toyos_build::signing::read_repo_key(&path)) + .and_then(|key| { + let home = env::var_os("HOME").ok_or("no $HOME, which the repository is under")?; + let dir = PathBuf::from(home).join(toyos_build::publish::REPOSITORY_DIR); + toyos_build::publish::publish(Path::new(manifest), &dir, &key, now).map(|p| (dir, p, key.fingerprint())) + }); match published { - Ok((dir, p)) => println!( - "Published into {}: root {}, targets {}, timestamp {}, signed by {}.", + Ok((dir, p, fingerprint)) => println!( + "Published into {}: root {}, targets {}, timestamp {}, signed by {fingerprint}; pruned {}.", dir.display(), p.root, p.targets, p.timestamp, - key.fingerprint() + p.pruned.len() ), Err(why) => { eprintln!("Error: {why}"); diff --git a/build/publish.rs b/build/publish.rs index 2ce96585bbf..60e02e1fea9 100644 --- a/build/publish.rs +++ b/build/publish.rs @@ -2,24 +2,32 @@ //! repository out, in the format `toyos_update::repo` reads and nothing else //! writes. //! -//! **Which repository is decided by which key, as for an image** -//! (`build/signing.rs`): this checkout's throwaway key publishes into -//! [`THROWAWAY_DIR`] under its `target/`, the owner's into [`OWNER_DIR`] under -//! `$HOME`, beside his key and outside every checkout. Stage one fills every -//! role with that one key at threshold one; the root names every role and its -//! threshold, so separating the keys is a new root and not a new client. +//! **It signs with the repository's signing key and nothing else, and mints no +//! root.** Every root is the ceremony's (`build/root`): the publisher receives +//! them as `roots/root..txt` beside its manifest, copies each the repository +//! lacks, never rewrites one it holds, and refuses by name a newest root that +//! does not grant its key the targets and timestamp roles. Root 1 is held to +//! [`repo::self_signed`], since a machine trusts the root it pins unchecked. //! -//! **A publish only moves forward.** Root `N` stays until it is within -//! [`RENEW`] of its expiry, and then root `N+1` carries the same keys; the -//! targets and the timestamp each take their next version; an item's sequence -//! never falls, and an equal one names the same archive; an archive under -//! `archives/` is never rewritten. Every file lands by rename, synced with its -//! directory, the timestamp last, so a copy of the directory taken mid-publish -//! or after a power loss names only what it holds. **What is written has first -//! been read back through the client** ([`repo::refresh`]), by a machine -//! pinning root 1 and holding the directory as it was, every archive streamed -//! through [`repo::Archive`]: the client's floors are the publisher's, and a -//! repository this writes is one such a machine accepts. +//! **Every file sits at the repository's top level**, under a name of `a-z 0-9 +//! . _ -`, which a GitHub release's flat asset namespace keeps as it is: an +//! archive is `--.tar.gz`, so its name changes whenever +//! its bytes may. +//! +//! **A publish only moves forward.** The targets and the timestamp each take +//! their next version; an item's sequence never falls, and an equal one names +//! the same archive; an archive is never rewritten. Every file lands by +//! rename, synced with its directory, the timestamp last, so a copy of the +//! directory taken mid-publish or after a power loss names only what it holds. +//! **What is written has first been read back through the client** +//! ([`repo::refresh`]), by a machine pinning root 1 and holding the directory +//! as it was, every archive streamed through [`repo::Archive`]: the client's +//! floors are the publisher's, and a repository this writes is one such a +//! machine accepts. +//! +//! **Then it prunes** ([`pruned`]): with targets M published, every targets +//! below M−1 and every archive neither M nor M−1 names goes, and never a root, +//! so a machine mid-install that holds M−1 still finds what it names. //! //! ```toml //! [[package]] @@ -30,32 +38,26 @@ //! archive = "gbae-v0.2.0-toyos-x86_64.tar.gz" # beside this file //! ``` -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::io::Read; use std::path::{Path, PathBuf}; use serde::Deserialize; -use toyos_update::repo::{self, render, Grant, Held, Item, Mirror, Role, Root, Snapshot, Targets, Timestamp}; +use toyos_update::repo::{self, render, Held, Item, Mirror, Role, Root, Snapshot, Targets, Timestamp}; -use crate::signing::{Key, Whose}; +use crate::signing::Key; const DAY: u64 = 86_400; -/// How long a root is good for. -pub const ROOT_LIFE: u64 = 365 * DAY; /// How long a targets is good for: the owner signs one at least this often. pub const TARGETS_LIFE: u64 = 90 * DAY; /// How long a timestamp is good for: a freeze lasts no longer. pub const TIMESTAMP_LIFE: u64 = 7 * DAY; -/// How close to its expiry a root is renewed. -pub const RENEW: u64 = 30 * DAY; -/// The throwaway key's repository, under the checkout. -pub const THROWAWAY_DIR: &str = "target/pkg-repository"; -/// The owner's repository, under `$HOME`. -pub const OWNER_DIR: &str = ".config/toyos/pkg-repository"; -/// Where archives sit in a repository. -const ARCHIVES: &str = "archives"; +/// The repository `--publish` writes, under `$HOME`. +pub const REPOSITORY_DIR: &str = ".config/toyos/pkg-repository"; +/// Where the roots a publish receives are, beside its manifest. +pub const ROOTS: &str = "roots"; #[derive(Deserialize)] #[serde(deny_unknown_fields)] @@ -74,61 +76,60 @@ struct Row { archive: PathBuf, } -/// The versions one publish left the repository at. +/// The versions one publish left the repository at, and the files it pruned. #[derive(Debug, PartialEq, Eq)] pub struct Published { pub root: u64, pub targets: u64, pub timestamp: u64, + pub pruned: Vec, } -/// The repository `key` publishes into. -pub fn repository(checkout: &Path, key: &Key) -> Result { - match key.whose() { - Whose::Throwaway => Ok(checkout.join(THROWAWAY_DIR)), - Whose::Owner(_) => { - let home = std::env::var_os("HOME").ok_or("no $HOME, which the owner's repository is under")?; - Ok(PathBuf::from(home).join(OWNER_DIR)) - } - } -} - -/// Publish what `manifest` lists into `dir`, signed by `key`, at `now`. +/// Publish what `manifest` lists into `dir`, signed by `key`, at `now`, with +/// the roots under [`ROOTS`] beside `manifest`. pub fn publish(manifest: &Path, dir: &Path, key: &Key, now: u64) -> Result { let text = fs::read_to_string(manifest).map_err(|e| format!("{}: {e}", manifest.display()))?; let rows: Manifest = toml::from_str(&text).map_err(|e| format!("{}: {e}", manifest.display()))?; let beside = manifest.parent().unwrap_or(Path::new(".")); let mut new: BTreeMap> = BTreeMap::new(); - let current = current_root(dir)?; - let root = match ¤t { - Some((r, _)) if !one_key(r, key) => { - return Err(format!( - "{}'s root {} is not {}'s alone, and a publish rotates no key", - dir.display(), - r.version, - key.fingerprint() - )); - } - Some((r, _)) if r.expires >= now + RENEW => r.version, - _ => { - let version = current.as_ref().map_or(1, |(r, _)| r.version + 1); - let bytes = signed(render::root(&minted(version, now + ROOT_LIFE, key)), Role::Root, key); - new.insert(repo::root_file(version), bytes); - version + let received = beside.join(ROOTS); + for (version, bytes) in roots(&received)? { + let name = repo::root_file(version); + match read_if_there(&dir.join(&name))? { + Some(held) if held != bytes => { + return Err(format!("{} holds another {name} than {}, and a root is never rewritten", dir.display(), received.display())); + } + Some(_) => {} + None => { + new.insert(name, bytes); + } } + } + let first = match new.get(&repo::root_file(1)) { + Some(bytes) => bytes.clone(), + None => read_if_there(&dir.join(repo::root_file(1)))? + .ok_or_else(|| format!("{} holds no root.1.txt, which the ceremony signs", received.display()))?, }; + repo::self_signed(&first).map_err(|why| format!("root 1 is not self-signed: {why}"))?; + let (newest, _) = newest_root(&mut Overlay { dir, new: &new })?.expect("root 1, read above"); + let id = repo::key_id(&key.public()); + if ![Role::Targets, Role::Timestamp].iter().all(|&role| newest.grant(role).keys.contains(&id)) { + return Err(format!( + "root {} does not grant {} the targets and timestamp roles, and a publish signs with no other key", + newest.version, + key.fingerprint() + )); + } let mut items = Vec::new(); for row in &rows.package { let path = beside.join(&row.archive); let bytes = fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))?; - let file = row.archive.file_name().and_then(|f| f.to_str()).ok_or_else(|| format!("{} names no file", path.display()))?; - let url = format!("{ARCHIVES}/{file}"); - // An archive already published, or listed by an earlier row, is never - // rewritten: the read-back's stream refuses one with other bytes. - let there = dir.join(&url).try_exists().map_err(|e| format!("{}: {e}", dir.join(&url).display()))?; - if !there && !new.contains_key(&url) { + let url = format!("{}-{}-{}.tar.gz", row.name, row.sequence, row.target); + // An archive already published is never rewritten: the read-back's + // stream refuses one with other bytes. + if read_if_there(&dir.join(&url))?.is_none() { new.insert(url.clone(), bytes.clone()); } items.push(Item { @@ -163,11 +164,9 @@ pub fn publish(manifest: &Path, dir: &Path, key: &Key, now: u64) -> Result bytes.clone(), - None => fs::read(dir.join(repo::root_file(1))).map_err(|e| format!("{}: {e}", dir.display()))?, - }; - let machine = current.as_ref().map(|(_, root)| Held { + let none = BTreeMap::new(); + let held_root = newest_root(&mut Overlay { dir, new: &none })?; + let machine = held_root.as_ref().map(|(_, root)| Held { root, timestamp: held.as_ref().map(|p| p.timestamp.as_slice()), targets: held.as_ref().map(|p| p.targets.as_slice()), @@ -181,23 +180,42 @@ pub fn publish(manifest: &Path, dir: &Path, key: &Key, now: u64) -> Result, Vec<_>) = new.iter().partition(|(name, _)| *name == repo::TIMESTAMP_FILE); rest.append(&mut last); for (name, bytes) in rest { land(&dir.join(name), bytes)?; } - Ok(Published { root, targets: targets.version, timestamp: timestamp.version }) -} -/// Whether `root` gives every role to `key` alone, at threshold one. -fn one_key(root: &Root, key: &Key) -> bool { - root.keys == [key.public()] && root.grants.iter().all(|g| g.threshold == 1) + let before = held.map(|p| Targets::parse(&p.targets)).transpose().map_err(|why| format!("{}: {why}", dir.display()))?; + let mut files = Vec::new(); + for entry in fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))? { + files.push(entry.map_err(|e| format!("{}: {e}", dir.display()))?.file_name().to_string_lossy().into_owned()); + } + let gone = pruned(&files, &targets, before.as_ref()); + for name in &gone { + fs::remove_file(dir.join(name)).map_err(|e| format!("{}: {e}", dir.join(name).display()))?; + } + fs::File::open(dir).and_then(|d| d.sync_all()).map_err(|e| format!("{}: {e}", dir.display()))?; + Ok(Published { root: newest.version, targets: targets.version, timestamp: timestamp.version, pruned: gone }) } -fn minted(version: u64, expires: u64, key: &Key) -> Root { - let grant = Grant { threshold: 1, keys: vec![repo::key_id(&key.public())] }; - Root { version, expires, keys: vec![key.public()], grants: [grant.clone(), grant.clone(), grant] } +/// What a publish that left targets `newest` deletes of `files`, given the +/// `previous` targets it replaced: every targets below `newest`'s previous +/// version, and every archive neither names. Never a root. +pub fn pruned(files: &[String], newest: &Targets, previous: Option<&Targets>) -> Vec { + let named: BTreeSet<&str> = newest.items.iter().chain(previous.iter().flat_map(|t| &t.items)).map(|i| i.url.as_str()).collect(); + let keep_from = newest.version - 1; + let mut gone: Vec = files + .iter() + .filter(|name| match name.strip_prefix("targets.").and_then(|v| v.strip_suffix(".txt")) { + Some(version) => version.parse::().is_ok_and(|v| v < keep_from), + None => name.ends_with(".tar.gz") && !named.contains(name.as_str()), + }) + .cloned() + .collect(); + gone.sort(); + gone } fn signed(body: String, role: Role, key: &Key) -> Vec { @@ -205,20 +223,37 @@ fn signed(body: String, role: Role, key: &Key) -> Vec { (body + &line).into_bytes() } -/// The newest root `dir` holds, walked from root 1 as the client walks. -fn current_root(dir: &Path) -> Result)>, String> { - let mut found = None; +/// `path`'s bytes, or `None` where there is no such file. +fn read_if_there(path: &Path) -> Result>, String> { + match fs::read(path) { + Ok(bytes) => Ok(Some(bytes)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(format!("{}: {e}", path.display())), + } +} + +/// Every root under `dir`, from root 1 while the next is there. +fn roots(dir: &Path) -> Result)>, String> { + let mut out = Vec::new(); for version in 1.. { - let path = dir.join(repo::root_file(version)); - match fs::read(&path) { - Ok(bytes) => { - let root = Root::parse(&bytes).map_err(|why| format!("{}: {why}", path.display()))?; - found = Some((root, bytes)); - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => break, - Err(e) => return Err(format!("{}: {e}", path.display())), + match read_if_there(&dir.join(repo::root_file(version)))? { + Some(bytes) => out.push((version, bytes)), + None => return Ok(out), } } + unreachable!("a walk that ends at the first absent root") +} + +/// The newest root `overlay` holds, walked from root 1 as the client walks, +/// and its bytes. +fn newest_root(overlay: &mut Overlay<'_>) -> Result)>, String> { + let mut found = None; + for version in 1.. { + let name = repo::root_file(version); + let Some(bytes) = overlay.fetch(&name, repo::ROOT_CAP)? else { break }; + let root = Root::parse(&bytes).map_err(|why| format!("{name}: {why}"))?; + found = Some((root, bytes)); + } Ok(found) } @@ -305,20 +340,29 @@ mod tests { /// 2026-10-09T00:00:00Z. const NOW: u64 = 1_791_504_000; + /// The ceremony's root key, for these tests. + const ROOT_KEY: [u8; 32] = [9; 32]; + const TRIPLE: &str = "x86_64-unknown-toyos"; fn key() -> Key { Key::throwaway_from([3; 32]) } + /// Root 1 as the ceremony signs it, granting `signing`. + fn first_root(signing: &Key) -> Vec { + render::first_root(&ROOT_KEY, &signing.public(), NOW + 365 * DAY) + } + /// A manifest listing `rows` of `(name, sequence, archive file, bytes)`, - /// written beside its archives. - fn manifest(dir: &Path, rows: &[(&str, u64, &str, &[u8])]) -> PathBuf { + /// written beside its archives and `root` as its root 1. + fn manifest(dir: &Path, root: &[u8], rows: &[(&str, u64, &str, &[u8])]) -> PathBuf { + fs::create_dir_all(dir.join(ROOTS)).unwrap(); + fs::write(dir.join(ROOTS).join("root.1.txt"), root).unwrap(); let mut text = String::new(); for (name, sequence, file, bytes) in rows { - fs::create_dir_all(dir.join(file).parent().unwrap()).unwrap(); fs::write(dir.join(file), bytes).unwrap(); text += &format!( - "[[package]]\nname = {name:?}\ntarget = \"x86_64-unknown-toyos\"\nsequence = {sequence}\nversion = \"1.0\"\narchive = {file:?}\n" + "[[package]]\nname = {name:?}\ntarget = {TRIPLE:?}\nsequence = {sequence}\nversion = \"1.0\"\narchive = {file:?}\n" ); } let path = dir.join("packages.toml"); @@ -330,6 +374,12 @@ mod tests { fs::read(dir.join(name)).unwrap_or_else(|e| panic!("{name}: {e}")) } + fn listing(dir: &Path) -> Vec { + let mut names: Vec = dir.read_dir().unwrap().map(|e| e.unwrap().file_name().into_string().unwrap()).collect(); + names.sort(); + names + } + fn gbae(targets: &Targets) -> &Item { targets.items.iter().find(|i| i.name == "gbae").expect("gbae") } @@ -342,69 +392,84 @@ mod tests { } #[test] - fn a_first_publish_mints_root_one_and_a_machine_accepts_it() { + fn a_first_publish_carries_the_received_root_and_a_machine_accepts_it() { let src = TempDir::new("publish-src"); let out = TempDir::new("publish-repo"); - let m = manifest(src.path(), &[("snake", 1, "snake-v1.tar.gz", b"snake bytes"), ("gbae", 3, "gbae-v1.tar.gz", b"gbae bytes")]); - assert_eq!(publish(&m, out.path(), &key(), NOW), Ok(Published { root: 1, targets: 1, timestamp: 1 })); + let root = first_root(&key()); + let m = manifest(src.path(), &root, &[("snake", 1, "snake-v1.tar.gz", b"snake bytes"), ("gbae", 3, "gbae-v1.tar.gz", b"gbae bytes")]); + assert_eq!(publish(&m, out.path(), &key(), NOW), Ok(Published { root: 1, targets: 1, timestamp: 1, pruned: vec![] })); let fresh = client(out.path(), NOW, None).expect("what was published"); let names: Vec<&str> = fresh.targets.items.iter().map(|i| i.name.as_str()).collect(); assert_eq!(names, ["gbae", "snake"]); assert_eq!(read(out.path(), &gbae(&fresh.targets).url), b"gbae bytes"); - let root = Root::parse(&fresh.root).unwrap(); - assert_eq!((root.keys.as_slice(), root.expires), ([key().public()].as_slice(), NOW + ROOT_LIFE)); - assert!(!out.path().read_dir().unwrap().any(|e| e.unwrap().file_name().to_string_lossy().contains("staged"))); + assert_eq!(fresh.root, root, "the root received, and no other"); + assert!(!listing(out.path()).iter().any(|name| name.contains("staged"))); } - /// The second publish moves every version forward, keeps the root, and a - /// machine holding the first accepts it; one inside the root's last - /// [`RENEW`] adds root 2, which the machine walks to. + /// The second publish moves every version forward and keeps the root, + /// and a machine holding the first accepts it. #[test] - fn a_publish_moves_forward_and_renews_a_root_near_its_expiry() { + fn a_publish_moves_forward_past_a_machines_floors() { let src = TempDir::new("publish-src"); let out = TempDir::new("publish-repo"); - let m = manifest(src.path(), &[("gbae", 3, "gbae-v1.tar.gz", b"gbae bytes")]); - publish(&m, out.path(), &key(), NOW).unwrap(); + let root = first_root(&key()); + publish(&manifest(src.path(), &root, &[("gbae", 3, "gbae-v1.tar.gz", b"gbae bytes")]), out.path(), &key(), NOW).unwrap(); let first = client(out.path(), NOW, None).unwrap(); - let m = manifest(src.path(), &[("gbae", 4, "gbae-v2.tar.gz", b"gbae two")]); - assert_eq!(publish(&m, out.path(), &key(), NOW + DAY), Ok(Published { root: 1, targets: 2, timestamp: 2 })); + let m = manifest(src.path(), &root, &[("gbae", 4, "gbae-v2.tar.gz", b"gbae two")]); + assert_eq!(publish(&m, out.path(), &key(), NOW + DAY), Ok(Published { root: 1, targets: 2, timestamp: 2, pruned: vec![] })); let held = Held { root: &first.root, timestamp: Some(&first.timestamp), targets: Some(&first.targets_bytes) }; let second = client(out.path(), NOW + DAY, Some(held)).expect("the next publish, past the first's floors"); assert_eq!(gbae(&second.targets).sequence, 4); + } - let late = NOW + ROOT_LIFE - RENEW + 1; - assert_eq!(publish(&m, out.path(), &key(), late), Ok(Published { root: 2, targets: 3, timestamp: 3 })); - let renewed = client(out.path(), late, None).expect("root 2, walked from root 1"); - assert_eq!(Root::parse(&renewed.root).unwrap().version, 2); + /// **The publisher signs only under a root that grants its key.** A + /// received root that grants another key the targets and timestamp roles, + /// or one its own root key did not sign, is refused by name, and nothing + /// is written: no root, no archive. + #[test] + fn a_root_that_does_not_grant_the_key_is_refused_and_no_root_is_written() { + let src = TempDir::new("publish-src"); + let out = TempDir::new("publish-repo"); + let rows = [("gbae", 3, "gbae-v1.tar.gz", &b"gbae bytes"[..])]; + let other = first_root(&Key::throwaway_from([4; 32])); + let refused = publish(&manifest(src.path(), &other, &rows), out.path(), &key(), NOW); + let why = format!("root 1 does not grant {} the targets and timestamp roles", key().fingerprint()); + assert!(refused.as_ref().unwrap_err().starts_with(&why), "{refused:?}"); + + let mut forged = render::root(&Root::parse(&first_root(&key())).unwrap()); + forged += &key().sign_document(Role::Root, forged.as_bytes()); + let refused = publish(&manifest(src.path(), forged.as_bytes(), &rows), out.path(), &key(), NOW); + assert!(refused.as_ref().unwrap_err().starts_with("root 1 is not self-signed: "), "{refused:?}"); + assert_eq!(fs::read_dir(out.path()).unwrap().count(), 0, "a refused publish wrote nothing"); } #[test] fn a_publish_that_would_move_anything_back_is_refused_by_name() { let src = TempDir::new("publish-src"); let out = TempDir::new("publish-repo"); - publish(&manifest(src.path(), &[("gbae", 3, "gbae-v1.tar.gz", b"gbae bytes")]), out.path(), &key(), NOW).unwrap(); - let before = read(out.path(), "timestamp.txt"); + let root = first_root(&key()); + publish(&manifest(src.path(), &root, &[("gbae", 3, "gbae-v1.tar.gz", b"gbae bytes")]), out.path(), &key(), NOW).unwrap(); + let before = listing(out.path()); + let timestamp = read(out.path(), "timestamp.txt"); // The client's floors, held as the directory holds them. - let (name, target, holder) = (String::from("gbae"), String::from("x86_64-unknown-toyos"), repo::Holder::Machine); - let lower = publish(&manifest(src.path(), &[("gbae", 2, "gbae-v0.tar.gz", b"old")]), out.path(), &key(), NOW); + let (name, target, holder) = (String::from("gbae"), String::from(TRIPLE), repo::Holder::Machine); + let lower = publish(&manifest(src.path(), &root, &[("gbae", 2, "gbae-v0.tar.gz", b"old")]), out.path(), &key(), NOW); let why = repo::Refused::Sequence { name: name.clone(), target: target.clone(), sequence: 2, floor: 3, holder }; assert!(lower.as_ref().unwrap_err().ends_with(&why.to_string()), "{lower:?}"); - let same = publish(&manifest(src.path(), &[("gbae", 3, "gbae-v1b.tar.gz", b"other")]), out.path(), &key(), NOW); + let same = publish(&manifest(src.path(), &root, &[("gbae", 3, "gbae-v1b.tar.gz", b"other")]), out.path(), &key(), NOW); let why = repo::Refused::Reissued { name, target, sequence: 3, holder }; assert!(same.as_ref().unwrap_err().ends_with(&why.to_string()), "{same:?}"); - let rewritten = publish(&manifest(src.path(), &[("gbae", 4, "gbae-v1.tar.gz", b"rewritten")]), out.path(), &key(), NOW); - assert!(rewritten.as_ref().unwrap_err().contains("refuses archives/gbae-v1.tar.gz: "), "{rewritten:?}"); - let other = publish(&manifest(src.path(), &[("gbae", 4, "gbae-v4.tar.gz", b"new")]), out.path(), &Key::throwaway_from([4; 32]), NOW); - assert!(other.as_ref().unwrap_err().contains("a publish rotates no key"), "{other:?}"); let unknown = src.path().join("unknown.toml"); fs::write(&unknown, "[[package]]\nname = \"gbae\"\nowner = \"me\"\n").unwrap(); assert!(publish(&unknown, out.path(), &key(), NOW).unwrap_err().contains("owner")); - let bad_name = publish(&manifest(src.path(), &[("Gbae", 1, "g.tar.gz", b"g")]), out.path(), &key(), NOW); + let bad_name = publish(&manifest(src.path(), &root, &[("Gbae", 1, "g.tar.gz", b"g")]), out.path(), &key(), NOW); assert!(bad_name.as_ref().unwrap_err().contains("refuses what this publish would leave"), "{bad_name:?}"); + let bent = manifest(src.path(), &first_root(&Key::throwaway_from([4; 32])), &[("gbae", 4, "gbae-v4.tar.gz", b"new")]); + let rewritten = publish(&bent, out.path(), &key(), NOW); + assert!(rewritten.as_ref().unwrap_err().contains("holds another root.1.txt than"), "{rewritten:?}"); - assert_eq!(read(out.path(), "timestamp.txt"), before, "a refused publish wrote nothing"); - assert!(!out.path().join("archives/gbae-v0.tar.gz").exists(), "a refused publish wrote no archive"); + assert_eq!((listing(out.path()), read(out.path(), "timestamp.txt")), (before, timestamp), "a refused publish wrote nothing"); } /// A timestamp on disk naming targets 2^64 − 1 leaves no next version: @@ -413,7 +478,7 @@ mod tests { fn a_timestamp_naming_the_last_targets_version_is_refused() { let src = TempDir::new("publish-src"); let out = TempDir::new("publish-repo"); - let m = manifest(src.path(), &[("gbae", 3, "gbae-v1.tar.gz", b"gbae bytes")]); + let m = manifest(src.path(), &first_root(&key()), &[("gbae", 3, "gbae-v1.tar.gz", b"gbae bytes")]); publish(&m, out.path(), &key(), NOW).unwrap(); let mut held = Timestamp::parse(&read(out.path(), "timestamp.txt")).unwrap(); held.targets.version = u64::MAX; @@ -432,20 +497,57 @@ mod tests { assert_eq!(read(out.path(), "timestamp.txt"), last, "a refused publish wrote nothing"); } - /// Two rows whose archives share a file name share its url: one archive, - /// or a refusal, never the second row's bytes under the first's SHA-256. + /// **Pruning keeps targets M and M−1 and what they name.** Three + /// publishes of gbae at sequences 1, 2 and 3 leave targets 2 and 3 and + /// sequences 2 and 3's archives, so a machine that holds targets 2 still + /// finds its archive; targets 1 and sequence 1's archive go, and the root + /// stays. #[test] - fn two_rows_naming_one_archive_with_other_bytes_are_refused() { + fn pruning_keeps_targets_m_and_m_minus_one_and_what_they_name() { let src = TempDir::new("publish-src"); let out = TempDir::new("publish-repo"); - let rows = [("gbae", 1, "a/x.tar.gz", &b"gbae bytes"[..]), ("snake", 1, "b/x.tar.gz", b"snake bytes")]; - let clash = publish(&manifest(src.path(), &rows), out.path(), &key(), NOW); - let why = repo::Refused::ArchiveShort { length: 11, got: 10 }; - assert!(clash.as_ref().unwrap_err().ends_with(&format!("refuses archives/x.tar.gz: {why}")), "{clash:?}"); - assert!(!out.path().join("timestamp.txt").exists(), "a refused publish wrote nothing"); - - let rows = [("gbae", 1, "a/x.tar.gz", &b"one archive"[..]), ("snake", 1, "b/x.tar.gz", b"one archive")]; - publish(&manifest(src.path(), &rows), out.path(), &key(), NOW).expect("one archive, named twice"); - assert_eq!(read(out.path(), "archives/x.tar.gz"), b"one archive"); + let root = first_root(&key()); + for sequence in 1..=2 { + let m = manifest(src.path(), &root, &[("gbae", sequence, "gbae.tar.gz", format!("gbae {sequence}").as_bytes())]); + publish(&m, out.path(), &key(), NOW).unwrap(); + } + let m = manifest(src.path(), &root, &[("gbae", 3, "gbae.tar.gz", b"gbae 3")]); + let published = publish(&m, out.path(), &key(), NOW).unwrap(); + let one = format!("gbae-1-{TRIPLE}.tar.gz"); + assert_eq!(published.pruned, [one, "targets.1.txt".into()]); + let mut kept = vec![ + format!("gbae-2-{TRIPLE}.tar.gz"), + format!("gbae-3-{TRIPLE}.tar.gz"), + "root.1.txt".into(), + "targets.2.txt".into(), + "targets.3.txt".into(), + "timestamp.txt".into(), + ]; + kept.sort(); + assert_eq!(listing(out.path()), kept); + let two = Targets::parse(&read(out.path(), "targets.2.txt")).unwrap(); + assert_eq!(read(out.path(), &gbae(&two).url), b"gbae 2"); + } + + /// **Every name is flat**: what a publish writes for both ToyOS triples + /// sits at the repository's top level, named in `a-z 0-9 . _ -`, which a + /// GitHub release keeps unrenamed. + #[test] + fn every_file_a_publish_writes_has_a_flat_name() { + let src = TempDir::new("publish-src"); + let out = TempDir::new("publish-repo"); + fs::create_dir_all(src.path().join("aarch64")).unwrap(); + let m = manifest(src.path(), &first_root(&key()), &[("gbae", 3, "gbae.tar.gz", b"x86-64 bytes")]); + fs::write(src.path().join("aarch64/gbae.tar.gz"), b"aarch64 bytes").unwrap(); + let mut text = fs::read_to_string(&m).unwrap(); + text += "[[package]]\nname = \"gbae\"\ntarget = \"aarch64-unknown-toyos\"\nsequence = 3\nversion = \"1.0\"\narchive = \"aarch64/gbae.tar.gz\"\n"; + fs::write(&m, text).unwrap(); + publish(&m, out.path(), &key(), NOW).unwrap(); + let names = listing(out.path()); + assert_eq!(names.len(), 5, "{names:?}"); + for name in names { + assert!(out.path().join(&name).is_file(), "{name} is a file"); + assert!(name.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"._-".contains(&b)), "{name}"); + } } } diff --git a/build/root/Cargo.toml b/build/root/Cargo.toml new file mode 100644 index 00000000000..715dd89a9b4 --- /dev/null +++ b/build/root/Cargo.toml @@ -0,0 +1,21 @@ +# A member of the host workspace (root `Cargo.toml`), built for the offline +# machine's triple and run there alone: no C, so any triple Rust targets builds +# it without a C toolchain. + +[package] +name = "toyos-root" +description = "The package repository's offline root-key ceremony: mints the root key and signs root 1, and nothing else." +version = "0.1.0" +edition = "2024" +license = "MIT OR Apache-2.0" +publish = false + +[dependencies] +# The repository's format, its renderer and its signer: what root 1 is written +# in and read back through. +toyos-update = { path = "../../lib/update", features = ["sign"] } +# The operating system's randomness, for the root key's seed. +getrandom = "0.3" + +[dev-dependencies] +toyos-tmpdir = { path = "../../lib/tmpdir" } diff --git a/build/root/src/main.rs b/build/root/src/main.rs new file mode 100644 index 00000000000..de2dc093724 --- /dev/null +++ b/build/root/src/main.rs @@ -0,0 +1,215 @@ +//! `toyos-root`: the package repository's root-key ceremony, run on a machine +//! that has no network and will never have one, and the only ToyOS program +//! that machine runs. +//! +//! ```text +//! toyos-root new +//! toyos-root sign --key --signing --out +//! ``` +//! +//! `new` mints the root key into a file that must not exist yet: its seed in +//! 64 hex digits and a newline, as the build's other key files hold one. It +//! prints the key's fingerprint and public line. `sign` writes root 1 +//! (`toyos_update::repo::render::first_root`): the root key alone holds the +//! root role and the signing key, named by the public line +//! `cargo run -- --repo-signing-key-new` printed, the timestamp and targets +//! roles, each at threshold 1, expiring a year from the machine's clock. It +//! reads root 1 back as self-signed before it writes it, and prints it with +//! both fingerprints. The key file's mode is not checked: what guards it is +//! the stick's custody, and a check would bind the tool to one kind of +//! filesystem and host. +//! +//! **The ceremony.** The owner brings a machine that is offline for good, two +//! USB sticks A and B for the root key, formatted on that machine, and a third, +//! C, that carries files between it and the online machine. +//! +//! 1. Online, before. +//! 1. `cargo run -q -- --repo-signing-key-new > /signing.pub` mints the +//! signing key at `~/.config/toyos/repo-signing-key` (`0600`), prints +//! its fingerprint, and writes its public line to the stick. Write the +//! fingerprint on paper. +//! 2. `cargo build --release -p toyos-root --target `, and copy `target//release/toyos-root` to C. +//! 2. Offline. +//! 1. `date -u`: the clock must say today, since root 1 expires a year from +//! it. +//! 2. `toyos-root new /root-key`. Write its fingerprint on the paper. +//! 3. `cp /root-key /root-key && cmp /root-key /root-key`. +//! 4. `toyos-root sign --key /root-key --signing /signing.pub --out +//! /root.1.txt`. Both fingerprints it prints must be the paper's, +//! and its expiry a year from today. +//! 5. Unmount and unplug A and B, and keep them apart, offline, each in a +//! place of its own. Only `/root.1.txt` goes back. +//! 3. Online, after. +//! 1. `root.1.txt` is committed as `roots/root.1.txt` in `ToyOSOrg/packages` +//! and as `images/pkg/root.1.txt` in ToyOS, each read back through +//! `toyos_update::repo::self_signed` and the publisher. +//! 2. The signing key file's contents become the `REPO_SIGNING_KEY` secret +//! of `packages`'s `publish` environment, and the file is deleted. +//! +//! Signing root N+1 from root N, renewal and rotation are not built: a +//! machine holding root 1 is held to its keys until they are. + +use std::io::Write; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +use toyos_update::repo::{self, PublicKey}; + +/// How long root 1 is good for. +const LIFE: u64 = 365 * 86_400; + +const USAGE: &str = "usage: toyos-root new \n \ + toyos-root sign --key --signing --out "; + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + let said = match args.iter().map(String::as_str).collect::>()[..] { + ["new", key] => new(Path::new(key)), + ["sign", "--key", key, "--signing", signing, "--out", out] => { + let now = SystemTime::now().duration_since(UNIX_EPOCH).expect("a clock after 1970").as_secs(); + sign(Path::new(key), Path::new(signing), Path::new(out), now) + } + _ => Err(USAGE.into()), + }; + match said { + Ok(text) => print!("{text}"), + Err(why) => { + eprintln!("toyos-root: {why}"); + std::process::exit(1); + } + } +} + +/// Mint the root key into `path`, which must not exist. +fn new(path: &Path) -> Result { + let mut seed = [0u8; 32]; + getrandom::fill(&mut seed).map_err(|e| format!("the operating system's randomness: {e}"))?; + let hex: String = seed.iter().map(|b| format!("{b:02x}")).collect(); + write_new(path, format!("{hex}\n").as_bytes())?; + let public = toyos_update::sig::public_of(&seed); + Ok(format!("root key {}\n{}\n", repo::fingerprint(&public), repo::public_line(&public))) +} + +/// Sign root 1 with the root key at `key`, granting the key `signing` names, +/// into `out`, which must not exist. +fn sign(key: &Path, signing: &Path, out: &Path, now: u64) -> Result { + let text = std::fs::read_to_string(key).map_err(|e| format!("{}: {e}", key.display()))?; + let seed = seed_of_hex(text.strip_suffix('\n').unwrap_or(&text)) + .ok_or_else(|| format!("{} is not 64 hex digits, which `toyos-root new` writes", key.display()))?; + let line = std::fs::read_to_string(signing).map_err(|e| format!("{}: {e}", signing.display()))?; + let signing_key: PublicKey = repo::parse_public_line(line.strip_suffix('\n').unwrap_or(&line)) + .ok_or_else(|| format!("{} is not one `ssh-ed25519 ` line", signing.display()))?; + let root_key = toyos_update::sig::public_of(&seed); + if root_key == signing_key { + return Err(format!("{} names the root key, which holds the root role alone", signing.display())); + } + let bytes = repo::render::first_root(&seed, &signing_key, now + LIFE); + let root = repo::self_signed(&bytes).expect("the root this signed is self-signed"); + write_new(out, &bytes)?; + Ok(format!( + "{}root key {}\nsigning key {}\nexpires {}\n", + String::from_utf8(bytes).expect("a root is ASCII"), + repo::fingerprint(&root_key), + repo::fingerprint(&signing_key), + repo::time_text(root.expires) + )) +} + +/// `bytes` written to `path` and synced, where no file is there. +fn write_new(path: &Path, bytes: &[u8]) -> Result<(), String> { + let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(path).map_err(|e| match e.kind() { + std::io::ErrorKind::AlreadyExists => format!("{} already exists, and it is not replaced", path.display()), + _ => format!("{}: {e}", path.display()), + })?; + file.write_all(bytes).and_then(|()| file.sync_all()).map_err(|e| format!("{}: {e}", path.display())) +} + +fn seed_of_hex(text: &str) -> Option<[u8; 32]> { + if text.len() != 64 || !text.is_ascii() { + return None; + } + let mut seed = [0u8; 32]; + for (i, byte) in seed.iter_mut().enumerate() { + *byte = u8::from_str_radix(&text[2 * i..2 * i + 2], 16).ok()?; + } + Some(seed) +} + +#[cfg(test)] +mod tests { + use super::*; + use repo::{render, Held, Item, Mirror, Role, Snapshot, Targets, Timestamp}; + use toyos_tmpdir::TempDir; + + /// 2026-10-09T00:00:00Z. + const NOW: u64 = 1_791_504_000; + const SIGNING: [u8; 32] = [3; 32]; + + struct Repo(Vec<(String, Vec)>); + + impl Mirror for Repo { + fn fetch(&mut self, name: &str, cap: usize) -> Result>, String> { + Ok(self.0.iter().find(|(n, _)| n == name).map(|(_, b)| b[..b.len().min(cap + 1)].to_vec())) + } + } + + /// A targets and the timestamp naming it, both signed by `seed`. + fn repository(seed: &[u8; 32]) -> Repo { + let sha256 = toyos_update::sha256(b"archive"); + let item = Item { + name: "gbae".into(), + target: "x86_64-unknown-toyos".into(), + sequence: 1, + version: "1.0".into(), + url: "gbae-1-x86_64-unknown-toyos.tar.gz".into(), + length: 7, + sha256, + }; + let body = render::targets(&Targets { version: 1, expires: NOW + 86_400, items: vec![item] }); + let targets = (body.clone() + &render::signature(seed, Role::Targets, body.as_bytes())).into_bytes(); + let snapshot = Snapshot { version: 1, length: targets.len() as u64, sha256: toyos_update::sha256(&targets) }; + let body = render::timestamp(&Timestamp { version: 1, expires: NOW + 86_400, targets: snapshot }); + let timestamp = (body.clone() + &render::signature(seed, Role::Timestamp, body.as_bytes())).into_bytes(); + Repo(vec![("targets.1.txt".into(), targets), ("timestamp.txt".into(), timestamp)]) + } + + /// **The ceremony's output is a root 1 a machine can pin**: minted and + /// signed through the two commands, it reads back self-signed under its + /// root key, and a repository the signing key signs is one a machine + /// pinning it accepts, where one the root key signs is not. + #[test] + fn a_root_one_it_signs_is_self_signed_and_grants_the_signing_key() { + let dir = TempDir::new("toyos-root"); + let (key, signing, out) = (dir.path().join("root-key"), dir.path().join("signing.pub"), dir.path().join("root.1.txt")); + let said = new(&key).expect("minted"); + assert!(new(&key).unwrap_err().ends_with("already exists, and it is not replaced")); + std::fs::write(&signing, format!("{}\n", repo::public_line(&toyos_update::sig::public_of(&SIGNING)))).unwrap(); + let printed = sign(&key, &signing, &out, NOW).expect("signed"); + let bytes = std::fs::read(&out).unwrap(); + let root = repo::self_signed(&bytes).expect("self-signed"); + assert_eq!((root.version, root.expires), (1, NOW + LIFE)); + let root_fingerprint = said.lines().next().unwrap().strip_prefix("root key ").unwrap(); + assert!(printed.contains(&format!("root key {root_fingerprint}\n")), "{printed}"); + assert!(sign(&key, &signing, &out, NOW).unwrap_err().ends_with("already exists, and it is not replaced")); + + let image = Held { root: &bytes, timestamp: None, targets: None }; + repo::refresh(&mut repository(&SIGNING), image, None, NOW).expect("what the signing key signs"); + let seed = seed_of_hex(std::fs::read_to_string(&key).unwrap().trim_end()).unwrap(); + assert!(repo::refresh(&mut repository(&seed), image, None, NOW).is_err(), "the root key signs roots alone"); + } + + /// A signing line that is not one Ed25519 public line, or that names the + /// root key itself, is refused, and no root is written. + #[test] + fn a_signing_line_that_is_not_another_key_is_refused() { + let dir = TempDir::new("toyos-root"); + let (key, signing, out) = (dir.path().join("root-key"), dir.path().join("signing.pub"), dir.path().join("root.1.txt")); + let said = new(&key).unwrap(); + std::fs::write(&signing, said.lines().nth(1).unwrap()).unwrap(); + assert!(sign(&key, &signing, &out, NOW).unwrap_err().ends_with("names the root key, which holds the root role alone")); + std::fs::write(&signing, "ssh-ed25519 AAAA comment\n").unwrap(); + assert!(sign(&key, &signing, &out, NOW).unwrap_err().ends_with("is not one `ssh-ed25519 ` line")); + assert!(!out.exists(), "a refused sign wrote no root"); + } +} diff --git a/build/signing.rs b/build/signing.rs index 63906308eb6..bf2a7d2da72 100644 --- a/build/signing.rs +++ b/build/signing.rs @@ -1,5 +1,5 @@ -//! The key an image and the package repository are signed with -//! (`build/publish.rs`), and the one place a private key is held. +//! The keys an image and the package repository are signed with, and the one +//! place a private key is held. //! //! **Two keys, chosen by what the image is for.** An image for a QEMU guest //! of `cargo run` or `cargo test`, a CI run or a metal-loop stick is signed @@ -20,6 +20,12 @@ //! signer, and only the public key's fingerprint is ever said. The file is an //! OpenSSH Ed25519 private key without a passphrase, so `ssh-keygen -Y sign -n //! toyos-image` over an image's header makes the same signature this does. +//! +//! **The package repository's signing key is a third**, never the image key: +//! the root the owner signs offline grants it the targets and timestamp roles +//! (`build/root`), and the publisher signs with it alone (`build/publish.rs`). +//! [`mint_repo_key`] writes it at [`REPO_KEY`] under `$HOME` as the seed in 64 +//! hex digits, the form it is pasted into CI's secret in. use std::path::{Path, PathBuf}; use std::sync::OnceLock; @@ -46,6 +52,10 @@ pub const OWNER_KEY_ENV: &str = "TOYOS_SIGNING_KEY"; /// Where the owner's key is by default: outside every checkout. pub const DEFAULT_OWNER_KEY: &str = ".config/toyos/image-signing-key"; +/// Where the package repository's signing key is, under `$HOME`: outside every +/// checkout. +pub const REPO_KEY: &str = ".config/toyos/repo-signing-key"; + /// A key this process signs with. pub struct Key { seed: [u8; 32], @@ -91,7 +101,7 @@ impl Key { /// `SHA256:` of the OpenSSH public key blob, as `ssh-keygen -l` /// prints it: the one name for this key a line may carry. pub fn fingerprint(&self) -> String { - toyos_ssh::hostkey::fingerprint(&self.public) + toyos_update::repo::fingerprint(&self.public) } /// The header naming these sections, and its signature: a slot's signed @@ -210,6 +220,22 @@ pub fn owner_key_path() -> Result { /// The owner's key at `path`, refused by name where it is not one — or where /// anyone but its owner may read it, as `ssh` refuses such a key. fn read_owner_key(path: &Path) -> Result { + refuse_wide(path)?; + let text = std::fs::read_to_string(path).map_err(|e| { + format!( + "the owner's image-signing key is not at {} ({e}). Mint one with \ + `cargo run -- --signing-key-new` (it is written there and never into a checkout), \ + or point {OWNER_KEY_ENV} at an OpenSSH Ed25519 key without a passphrase", + path.display() + ) + })?; + let seed = toyos_ssh::hostkey::openssh_seed(&text).map_err(|why| format!("{}: {why}", path.display()))?; + Ok(Key::from_seed(seed, Whose::Owner(path.to_path_buf()))) +} + +/// Refused by name where anyone but its owner may read the key at `path`, as +/// `ssh` refuses such a key. +fn refuse_wide(path: &Path) -> Result<(), String> { use std::os::unix::fs::PermissionsExt; if let Ok(meta) = std::fs::metadata(path) { let mode = meta.permissions().mode() & 0o777; @@ -222,40 +248,59 @@ fn read_owner_key(path: &Path) -> Result { )); } } + Ok(()) +} + +/// Mint the owner's key at [`owner_key_path`], refusing to replace one: a key +/// replaced is every installed machine refusing the next update. +pub fn mint_owner_key() -> Result<(PathBuf, String), String> { + let path = owner_key_path()?; + let key = Key::mint(); + write_secret(&path, &openssh_text(&key.seed))?; + Ok((path, key.fingerprint())) +} + +/// Where the package repository's signing key is: [`REPO_KEY`] under `$HOME`. +pub fn repo_key_path() -> Result { + let home = std::env::var_os("HOME").ok_or("no $HOME, which the repository's signing key is under")?; + Ok(PathBuf::from(home).join(REPO_KEY)) +} + +/// Mint the package repository's signing key at `path`, refusing to replace +/// one: a key replaced is one no root grants. +pub fn mint_repo_key(path: &Path) -> Result { + let key = Key::mint(); + write_secret(path, &format!("{}\n", hex_of(&key.seed)))?; + Ok(Key { whose: Whose::Owner(path.to_path_buf()), ..key }) +} + +/// The package repository's signing key at `path`, refused by name where it +/// is not one or others may read it. +pub fn read_repo_key(path: &Path) -> Result { + refuse_wide(path)?; let text = std::fs::read_to_string(path).map_err(|e| { format!( - "the owner's image-signing key is not at {} ({e}). Mint one with \ - `cargo run -- --signing-key-new` (it is written there and never into a checkout), \ - or point {OWNER_KEY_ENV} at an OpenSSH Ed25519 key without a passphrase", + "the repository's signing key is not at {} ({e}). Mint one with `cargo run -- --repo-signing-key-new`", path.display() ) })?; - let seed = toyos_ssh::hostkey::openssh_seed(&text).map_err(|why| format!("{}: {why}", path.display()))?; + let seed = seed_of_hex(text.trim()).ok_or_else(|| format!("{} is not 64 hex digits", path.display()))?; Ok(Key::from_seed(seed, Whose::Owner(path.to_path_buf()))) } -/// Mint the owner's key at [`owner_key_path`], refusing to replace one: a key -/// replaced is every installed machine refusing the next update. -pub fn mint_owner_key() -> Result<(PathBuf, String), String> { - let path = owner_key_path()?; - if path.exists() { - return Err(format!("{} already holds a key, and it is not replaced", path.display())); - } - let key = Key::mint(); - let dir = path.parent().ok_or_else(|| format!("{} has no directory", path.display()))?; - std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; - let text = openssh_text(&key.seed); +/// `text` written to `path`, `0600` and synced, where no file is there. +fn write_secret(path: &Path, text: &str) -> Result<(), String> { use std::io::Write; use std::os::unix::fs::OpenOptionsExt; - let mut file = std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .mode(0o600) - .open(&path) - .map_err(|e| format!("{}: {e}", path.display()))?; - file.write_all(text.as_bytes()).map_err(|e| format!("{}: {e}", path.display()))?; - file.sync_all().map_err(|e| format!("{}: {e}", path.display()))?; - Ok((path, key.fingerprint())) + let dir = path.parent().ok_or_else(|| format!("{} has no directory", path.display()))?; + std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + let mut file = std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(path).map_err(|e| { + match e.kind() { + std::io::ErrorKind::AlreadyExists => format!("{} already holds a key, and it is not replaced", path.display()), + _ => format!("{}: {e}", path.display()), + } + })?; + file.write_all(text.as_bytes()).and_then(|()| file.sync_all()).map_err(|e| format!("{}: {e}", path.display())) } /// An unencrypted `openssh-key-v1` file for `seed`, which OpenSSH reads. @@ -301,6 +346,26 @@ mod tests { assert!(checkout_throwaway(dir.path()).err().unwrap().contains("is not 64 hex digits")); } + /// **The repository's signing key is minted `0600` once and read back**: + /// a second mint is refused rather than replacing it, a bent file is + /// refused, and its fingerprint is the one OpenSSH's own implementation in + /// this tree prints, so the paper the ceremony compares holds one name. + #[test] + fn a_repo_key_is_minted_once_and_read_back() { + use std::os::unix::fs::PermissionsExt; + let dir = toyos_tmpdir::TempDir::new("repo-key"); + let path = dir.path().join("config/repo-signing-key"); + let minted = mint_repo_key(&path).expect("minted"); + assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); + let read = read_repo_key(&path).expect("read back"); + assert_eq!((read.public, read.whose()), (minted.public, &Whose::Owner(path.clone()))); + assert_eq!(read.fingerprint(), toyos_ssh::hostkey::fingerprint(&read.public)); + assert!(mint_repo_key(&path).err().unwrap().contains("already holds a key, and it is not replaced")); + assert_eq!(read_repo_key(&path).map(|k| k.public), Ok(minted.public), "the refused mint left the key"); + std::fs::write(&path, "not a seed\n").unwrap(); + assert!(read_repo_key(&path).err().unwrap().contains("is not 64 hex digits")); + } + /// An owner's key anyone else may read is refused by its mode, before its /// contents are read; the same key `0600` reads. #[test] diff --git a/lib/update/src/repo.rs b/lib/update/src/repo.rs index db0fd2274c6..f6d63671071 100644 --- a/lib/update/src/repo.rs +++ b/lib/update/src/repo.rs @@ -31,7 +31,8 @@ //! bytes, never a lower sequence for an item. A held floor counts only while //! the final root gives its role the keys the root held beside it did, which is //! how a rotation recovers from a stolen key's fast-forward. [`Archive`] checks -//! the archive's bytes as they stream. +//! the archive's bytes as they stream. The root a machine pins is trusted as +//! pinned; [`self_signed`] is what a root 1 is held to before it is. use alloc::format; use alloc::string::String; @@ -288,6 +289,29 @@ pub fn key_id(key: &PublicKey) -> Digest { sha256(&public_blob(key)) } +/// `SHA256:` of the key's ID, unpadded: what `ssh-keygen -l` prints, +/// and the one name a person compares a key by. +pub fn fingerprint(key: &PublicKey) -> String { + format!("SHA256:{}", base64_encode(&key_id(key)).trim_end_matches('=')) +} + +/// `ssh-ed25519 `: a public key as an OpenSSH `.pub` file and an +/// `allowed_signers` line carry it, and as a root declares one. +pub fn public_line(key: &PublicKey) -> String { + format!("ssh-ed25519 {}", base64_encode(&public_blob(key))) +} + +/// The key a [`public_line`] names, and nothing else, or `None`. +pub fn parse_public_line(text: &str) -> Option { + let [kind, blob] = text.split(' ').collect::>()[..] else { return None }; + public_key(kind, blob) +} + +fn public_key(kind: &str, blob: &str) -> Option { + let blob = base64_decode(blob).filter(|_| kind == "ssh-ed25519")?; + (blob.len() == 51 && blob[..19] == public_blob(&[0; 32])[..19]).then(|| blob[19..].try_into().expect("32 bytes")) +} + /// One role's signers in a root. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Grant { @@ -371,11 +395,7 @@ impl Root { return Err(c.last("a key is not `key ssh-ed25519 `")); }; let id = hex32(id).ok_or_else(|| c.last("a key ID that is not 64 lowercase hex digits"))?; - let blob = base64_decode(blob).filter(|_| kind == "ssh-ed25519"); - let key: PublicKey = blob - .filter(|b| b.len() == 51 && b[..19] == public_blob(&[0; 32])[..19]) - .map(|b| b[19..].try_into().expect("32 bytes")) - .ok_or_else(|| c.last("a key that is not an ssh-ed25519 public key"))?; + let key = public_key(kind, blob).ok_or_else(|| c.last("a key that is not an ssh-ed25519 public key"))?; if key_id(&key) != id { return Err(c.last("a key ID that is not its key's SHA-256")); } @@ -418,6 +438,16 @@ impl Root { } } +/// `bytes` as a root its own keys vouch for at its own root threshold: what a +/// root 1 is held to before an image pins it, since [`refresh`] trusts the +/// root it is handed as pinned. +pub fn self_signed(bytes: &[u8]) -> Result { + let doc = document(bytes, Role::Root)?; + let root = Root::of(&doc)?; + vouched(&doc, &root)?; + Ok(root) +} + impl Timestamp { pub fn parse(bytes: &[u8]) -> Result { Self::of(&document(bytes, Role::Timestamp)?) @@ -1009,6 +1039,7 @@ pub fn base64_decode(text: &str) -> Option> { #[cfg(any(test, feature = "sign"))] pub mod render { use super::*; + use alloc::vec; /// `root`'s signed bytes, unsigned. pub fn root(root: &Root) -> String { @@ -1016,7 +1047,7 @@ pub mod render { let mut keys: Vec<(Digest, &PublicKey)> = root.keys.iter().map(|k| (key_id(k), k)).collect(); keys.sort(); for (id, key) in keys { - out += &format!("key {} ssh-ed25519 {}\n", hex(&id), base64_encode(&public_blob(key))); + out += &format!("key {} {}\n", hex(&id), public_line(key)); } for role in Role::ALL { let grant = root.grant(role); @@ -1029,6 +1060,21 @@ pub mod render { out } + /// Root 1 as the root-key ceremony signs it: `root`'s key alone holds + /// the root role and `signing` the timestamp and targets roles, each at + /// threshold 1, signed by `root`. + pub fn first_root(root: &[u8; 32], signing: &PublicKey, expires: u64) -> Vec { + let public = sig::public_of(root); + assert_ne!(&public, signing, "the root key holds the root role alone"); + let grant = |key: &PublicKey| Grant { threshold: 1, keys: vec![key_id(key)] }; + let mut keys = vec![public, *signing]; + keys.sort_by_key(key_id); + let first = Root { version: 1, expires, keys, grants: [grant(&public), grant(signing), grant(signing)] }; + let body = self::root(&first); + let line = signature(root, Role::Root, body.as_bytes()); + (body + &line).into_bytes() + } + pub fn timestamp(timestamp: &Timestamp) -> String { let t = timestamp.targets; header(Role::Timestamp, timestamp.version, timestamp.expires) @@ -1615,4 +1661,54 @@ mod tests { assert_eq!(parse_time(bad), None, "{bad}"); } } + + /// Root 1 as the ceremony signs it is self-signed and grants the signing + /// key the targets and timestamp roles: a repository that key signs is one + /// a machine pinning that root accepts. + #[test] + fn the_ceremonys_root_is_self_signed_and_grants_the_signing_key() { + let first = render::first_root(&seed(1), &public(2), NOW + 365 * DAY); + assert_eq!(self_signed(&first).map(|r| r.version), Ok(1)); + let mut repo = Repo::default(); + repo.publish(1, &targets(1, vec![item(3, ARCHIVE)]), 2); + let image = Held { root: &first, timestamp: None, targets: None }; + refresh(&mut repo, image, None, NOW).expect("targets and timestamp the signing key signed"); + let mut by_root = Repo::default(); + by_root.publish(1, &targets(1, vec![item(3, ARCHIVE)]), 1); + assert!(matches!( + refused(refresh(&mut by_root, image, None, NOW)), + Refused::Threshold { role: Role::Timestamp, first: Some(SigRefused::NotTheRolesKey), .. } + )); + } + + /// A root signed by a key it does not give the root role is not + /// self-signed, however well-formed. + #[test] + fn a_root_its_own_root_key_did_not_sign_is_refused() { + let body = render::root(&root(1, [(1, &[1]), (1, &[2]), (1, &[2])])); + let by_signing_key = signed(body, Role::Root, &[2]); + assert_eq!( + self_signed(&by_signing_key), + Err(Refused::Threshold { + role: Role::Root, + version: 1, + root: 1, + valid: 0, + needed: 1, + first: Some(SigRefused::NotTheRolesKey) + }) + ); + } + + /// A public line reads back as its key, and nothing but the two words of + /// an Ed25519 one does. + #[test] + fn a_public_line_names_its_key_and_nothing_else_reads() { + let line = public_line(&public(1)); + assert_eq!(parse_public_line(&line), Some(public(1))); + let blob = line.split(' ').nth(1).unwrap(); + for bent in [format!("{line} comment"), format!("ssh-rsa {blob}"), format!(" {line}"), blob.to_string()] { + assert_eq!(parse_public_line(&bent), None, "{bent:?}"); + } + } } diff --git a/lib/update/tests/repo_oracle.rs b/lib/update/tests/repo_oracle.rs index d6f6f0de95d..d07be511037 100644 --- a/lib/update/tests/repo_oracle.rs +++ b/lib/update/tests/repo_oracle.rs @@ -97,3 +97,16 @@ fn a_repository_ssh_keygen_signed_is_accepted_and_a_bent_one_is_not() { "{walked}" ); } + +/// The root OpenSSH signed is self-signed, and its key's public line and +/// fingerprint are OpenSSH's: the line is the fixture's own words, and the +/// fingerprint is what `ssh-keygen -l` printed over that line. +#[test] +fn the_fixtures_root_is_self_signed_and_its_key_reads_as_openssh_names_it() { + let root = repo::self_signed(ROOT).expect("the root OpenSSH signed"); + let [key] = root.keys[..] else { panic!("the fixture declares one key") }; + let line = repo::public_line(&key); + assert!(String::from_utf8_lossy(ROOT).contains(&format!(" {line}\n")), "{line}"); + assert_eq!(repo::parse_public_line(&line), Some(key)); + assert_eq!(repo::fingerprint(&key), "SHA256:bdWoF+LpZIGawveHnzj/Kd4UofKmRTiHbB9Dk4KaAxc"); +} From ad6f61c49c6ff9b5e034b2c0fb49a1be550cc339 Mon Sep 17 00:00:00 2001 From: japabu Date: Tue, 13 Oct 2026 04:33:06 +0200 Subject: [PATCH 2/5] Stage 1's review: the root key is minted 0600, the keys' text and names have one home each, and the repository key is its own type The build review of #892's stage 1 and the owner's rulings on its NOTEs. - `toyos-root new` mints the root key 0600, as the accepted ceremony says; root 1 is written 0644. The crate is built for a Unix triple, and the ceremony names the one measured path: `--target aarch64-apple-darwin` on this Mac, for an offline Mac of the same macOS and architecture. Any other offline triple is stated as unmeasured. - A seed's key-file text has one writer and one reader, `toyos_update::sig::{seed_text, seed_of_text}` behind `sign`: 64 lowercase hex digits and a newline, and nothing else reads. The checkout's throwaway key, the repository's signing key and the root key all go through it, and the two `seed_of_hex` copies and `hex_of` go. - The `SHA256:` fingerprint has one home, `toyos_update::repo::fingerprint`: toyos-ssh calls it and loses its own, with its fixture, and the assert holding the two equal goes. - `signing::RepoKey` is the repository's signing key: it signs documents and has no way to sign an image header or name a floor. `Key` loses `sign_document`. - The publisher's grant check goes: the read-back refuses a root that does not grant the key before anything is written. - A file in `roots/` the walk from root 1 does not reach, a root past a gap among them, is refused by name. - One claim per test: the tests the review named are split, and toyos-root's repetition of `repo`'s grant check is cut; toyos-root's test holds the written root to `render::first_root` byte for byte instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy --- Cargo.lock | 1 + build/publish.rs | 162 +++++++++++----- build/root/Cargo.toml | 3 +- build/root/src/main.rs | 177 +++++++++--------- build/signing.rs | 120 +++++++----- build/ssh/Cargo.toml | 3 + build/ssh/src/auth.rs | 2 +- build/ssh/src/base64.rs | 3 +- build/ssh/src/hostkey.rs | 16 +- .../tests/fixtures/host_ed25519.fingerprint | 1 - build/ssh/tests/refusals.rs | 2 +- build/ssh/tests/replay.rs | 2 +- lib/update/src/repo.rs | 34 +++- lib/update/src/sig.rs | 44 +++++ lib/update/tests/repo_oracle.rs | 24 ++- 15 files changed, 383 insertions(+), 211 deletions(-) delete mode 100644 build/ssh/tests/fixtures/host_ed25519.fingerprint diff --git a/Cargo.lock b/Cargo.lock index dc0913a60cf..6023b0c32d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4425,6 +4425,7 @@ name = "toyos-ssh" version = "0.1.0" dependencies = [ "ring", + "toyos-update", ] [[package]] diff --git a/build/publish.rs b/build/publish.rs index 60e02e1fea9..8c5acffeff3 100644 --- a/build/publish.rs +++ b/build/publish.rs @@ -4,10 +4,11 @@ //! //! **It signs with the repository's signing key and nothing else, and mints no //! root.** Every root is the ceremony's (`build/root`): the publisher receives -//! them as `roots/root..txt` beside its manifest, copies each the repository -//! lacks, never rewrites one it holds, and refuses by name a newest root that -//! does not grant its key the targets and timestamp roles. Root 1 is held to -//! [`repo::self_signed`], since a machine trusts the root it pins unchecked. +//! them as `roots/root..txt` beside its manifest, from root 1 with no gap +//! and nothing else there, copies each the repository lacks, and never +//! rewrites one it holds. Root 1 is held to [`repo::self_signed`], since a +//! machine trusts the root it pins unchecked; a root that does not grant the +//! key is refused by the read-back. //! //! **Every file sits at the repository's top level**, under a name of `a-z 0-9 //! . _ -`, which a GitHub release's flat asset namespace keeps as it is: an @@ -46,7 +47,7 @@ use std::path::{Path, PathBuf}; use serde::Deserialize; use toyos_update::repo::{self, render, Held, Item, Mirror, Role, Root, Snapshot, Targets, Timestamp}; -use crate::signing::Key; +use crate::signing::RepoKey; const DAY: u64 = 86_400; /// How long a targets is good for: the owner signs one at least this often. @@ -87,7 +88,7 @@ pub struct Published { /// Publish what `manifest` lists into `dir`, signed by `key`, at `now`, with /// the roots under [`ROOTS`] beside `manifest`. -pub fn publish(manifest: &Path, dir: &Path, key: &Key, now: u64) -> Result { +pub fn publish(manifest: &Path, dir: &Path, key: &RepoKey, now: u64) -> Result { let text = fs::read_to_string(manifest).map_err(|e| format!("{}: {e}", manifest.display()))?; let rows: Manifest = toml::from_str(&text).map_err(|e| format!("{}: {e}", manifest.display()))?; let beside = manifest.parent().unwrap_or(Path::new(".")); @@ -113,14 +114,6 @@ pub fn publish(manifest: &Path, dir: &Path, key: &Key, now: u64) -> Result) -> gone } -fn signed(body: String, role: Role, key: &Key) -> Vec { +fn signed(body: String, role: Role, key: &RepoKey) -> Vec { let line = key.sign_document(role, body.as_bytes()); (body + &line).into_bytes() } @@ -232,16 +225,30 @@ fn read_if_there(path: &Path) -> Result>, String> { } } -/// Every root under `dir`, from root 1 while the next is there. +/// Every root under `dir`, from root 1 while the next is there; any other +/// file there, a root past a gap among them, is refused by name. fn roots(dir: &Path) -> Result)>, String> { let mut out = Vec::new(); - for version in 1.. { - match read_if_there(&dir.join(repo::root_file(version)))? { - Some(bytes) => out.push((version, bytes)), - None => return Ok(out), + while let Some(bytes) = read_if_there(&dir.join(repo::root_file(out.len() as u64 + 1)))? { + out.push((out.len() as u64 + 1, bytes)); + } + let entries = match fs::read_dir(dir) { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out), + Err(e) => return Err(format!("{}: {e}", dir.display())), + }; + for entry in entries { + let name = entry.map_err(|e| format!("{}: {e}", dir.display()))?.file_name(); + if !(1..=out.len() as u64).any(|version| name == *repo::root_file(version)) { + return Err(format!( + "{} holds {}, which is not a root the walk from root 1 reaches: it stops before {}", + dir.display(), + name.to_string_lossy(), + repo::root_file(out.len() as u64 + 1) + )); } } - unreachable!("a walk that ends at the first absent root") + Ok(out) } /// The newest root `overlay` holds, walked from root 1 as the client walks, @@ -343,13 +350,14 @@ mod tests { /// The ceremony's root key, for these tests. const ROOT_KEY: [u8; 32] = [9; 32]; const TRIPLE: &str = "x86_64-unknown-toyos"; + const ROWS: &[(&str, u64, &str, &[u8])] = &[("gbae", 3, "gbae-v1.tar.gz", b"gbae bytes")]; - fn key() -> Key { - Key::throwaway_from([3; 32]) + fn key() -> RepoKey { + RepoKey::from_seed([3; 32]) } /// Root 1 as the ceremony signs it, granting `signing`. - fn first_root(signing: &Key) -> Vec { + fn first_root(signing: &RepoKey) -> Vec { render::first_root(&ROOT_KEY, &signing.public(), NOW + 365 * DAY) } @@ -422,54 +430,116 @@ mod tests { assert_eq!(gbae(&second.targets).sequence, 4); } - /// **The publisher signs only under a root that grants its key.** A - /// received root that grants another key the targets and timestamp roles, - /// or one its own root key did not sign, is refused by name, and nothing - /// is written: no root, no archive. + /// A received root 1 that grants another key the targets and timestamp + /// roles is refused by the read-back, before anything is written. #[test] - fn a_root_that_does_not_grant_the_key_is_refused_and_no_root_is_written() { + fn a_root_that_does_not_grant_the_key_is_refused_and_nothing_is_written() { let src = TempDir::new("publish-src"); let out = TempDir::new("publish-repo"); - let rows = [("gbae", 3, "gbae-v1.tar.gz", &b"gbae bytes"[..])]; - let other = first_root(&Key::throwaway_from([4; 32])); - let refused = publish(&manifest(src.path(), &other, &rows), out.path(), &key(), NOW); - let why = format!("root 1 does not grant {} the targets and timestamp roles", key().fingerprint()); - assert!(refused.as_ref().unwrap_err().starts_with(&why), "{refused:?}"); + let other = first_root(&RepoKey::from_seed([4; 32])); + let refused = publish(&manifest(src.path(), &other, ROWS), out.path(), &key(), NOW); + assert!(refused.as_ref().unwrap_err().contains("refuses what this publish would leave"), "{refused:?}"); + assert_eq!(listing(out.path()), Vec::::new(), "a refused publish wrote nothing"); + } + /// A received root 1 its own root key did not sign is refused by name, + /// before anything is written. + #[test] + fn a_root_one_that_is_not_self_signed_is_refused_and_nothing_is_written() { + let src = TempDir::new("publish-src"); + let out = TempDir::new("publish-repo"); let mut forged = render::root(&Root::parse(&first_root(&key())).unwrap()); forged += &key().sign_document(Role::Root, forged.as_bytes()); - let refused = publish(&manifest(src.path(), forged.as_bytes(), &rows), out.path(), &key(), NOW); + let refused = publish(&manifest(src.path(), forged.as_bytes(), ROWS), out.path(), &key(), NOW); assert!(refused.as_ref().unwrap_err().starts_with("root 1 is not self-signed: "), "{refused:?}"); - assert_eq!(fs::read_dir(out.path()).unwrap().count(), 0, "a refused publish wrote nothing"); + assert_eq!(listing(out.path()), Vec::::new(), "a refused publish wrote nothing"); } + /// A received root past a gap in `roots/` is refused by name, before + /// anything is written. #[test] - fn a_publish_that_would_move_anything_back_is_refused_by_name() { + fn a_root_past_a_gap_in_roots_is_refused_and_nothing_is_written() { + let src = TempDir::new("publish-src"); + let out = TempDir::new("publish-repo"); + let m = manifest(src.path(), &first_root(&key()), ROWS); + fs::write(src.path().join(ROOTS).join("root.3.txt"), first_root(&key())).unwrap(); + let refused = publish(&m, out.path(), &key(), NOW); + assert!(refused.as_ref().unwrap_err().ends_with("holds root.3.txt, which is not a root the walk from root 1 reaches: it stops before root.2.txt"), "{refused:?}"); + assert_eq!(listing(out.path()), Vec::::new(), "a refused publish wrote nothing"); + } + + /// One publish of [`ROWS`] under root 1, and what it left. + fn published_once() -> (TempDir, TempDir, Vec, Vec) { let src = TempDir::new("publish-src"); let out = TempDir::new("publish-repo"); let root = first_root(&key()); - publish(&manifest(src.path(), &root, &[("gbae", 3, "gbae-v1.tar.gz", b"gbae bytes")]), out.path(), &key(), NOW).unwrap(); + publish(&manifest(src.path(), &root, ROWS), out.path(), &key(), NOW).unwrap(); let before = listing(out.path()); - let timestamp = read(out.path(), "timestamp.txt"); + (src, out, root, before) + } + + /// A refused publish left `out` holding what it held, timestamp included. + fn left_as_it_was(out: &Path, before: &[String], timestamp: &[u8]) { + assert_eq!((listing(out), read(out, "timestamp.txt")), (before.to_vec(), timestamp.to_vec()), "a refused publish wrote nothing"); + } - // The client's floors, held as the directory holds them. - let (name, target, holder) = (String::from("gbae"), String::from(TRIPLE), repo::Holder::Machine); + /// A sequence below the floor a machine holds is refused by the + /// client's name for it, and nothing is written. + #[test] + fn a_lower_sequence_is_refused_by_name() { + let (src, out, root, before) = published_once(); + let timestamp = read(out.path(), "timestamp.txt"); let lower = publish(&manifest(src.path(), &root, &[("gbae", 2, "gbae-v0.tar.gz", b"old")]), out.path(), &key(), NOW); - let why = repo::Refused::Sequence { name: name.clone(), target: target.clone(), sequence: 2, floor: 3, holder }; + let why = repo::Refused::Sequence { name: "gbae".into(), target: TRIPLE.into(), sequence: 2, floor: 3, holder: repo::Holder::Machine }; assert!(lower.as_ref().unwrap_err().ends_with(&why.to_string()), "{lower:?}"); + left_as_it_was(out.path(), &before, ×tamp); + } + + /// An equal sequence naming other bytes is refused by the client's name + /// for it, and nothing is written. + #[test] + fn an_equal_sequence_with_other_bytes_is_refused_by_name() { + let (src, out, root, before) = published_once(); + let timestamp = read(out.path(), "timestamp.txt"); let same = publish(&manifest(src.path(), &root, &[("gbae", 3, "gbae-v1b.tar.gz", b"other")]), out.path(), &key(), NOW); - let why = repo::Refused::Reissued { name, target, sequence: 3, holder }; + let why = repo::Refused::Reissued { name: "gbae".into(), target: TRIPLE.into(), sequence: 3, holder: repo::Holder::Machine }; assert!(same.as_ref().unwrap_err().ends_with(&why.to_string()), "{same:?}"); + left_as_it_was(out.path(), &before, ×tamp); + } + + /// A manifest row with a field the format does not name is refused by + /// that field's name, and nothing is written. + #[test] + fn a_manifest_field_the_format_does_not_name_is_refused() { + let (src, out, _, before) = published_once(); + let timestamp = read(out.path(), "timestamp.txt"); let unknown = src.path().join("unknown.toml"); fs::write(&unknown, "[[package]]\nname = \"gbae\"\nowner = \"me\"\n").unwrap(); assert!(publish(&unknown, out.path(), &key(), NOW).unwrap_err().contains("owner")); + left_as_it_was(out.path(), &before, ×tamp); + } + + /// A package name the client refuses is refused by the read-back, and + /// nothing is written. + #[test] + fn a_name_the_client_refuses_is_refused_by_the_read_back() { + let (src, out, root, before) = published_once(); + let timestamp = read(out.path(), "timestamp.txt"); let bad_name = publish(&manifest(src.path(), &root, &[("Gbae", 1, "g.tar.gz", b"g")]), out.path(), &key(), NOW); assert!(bad_name.as_ref().unwrap_err().contains("refuses what this publish would leave"), "{bad_name:?}"); - let bent = manifest(src.path(), &first_root(&Key::throwaway_from([4; 32])), &[("gbae", 4, "gbae-v4.tar.gz", b"new")]); + left_as_it_was(out.path(), &before, ×tamp); + } + + /// A received root 1 other than the one the repository holds is refused + /// by name, and nothing is written. + #[test] + fn a_received_root_that_would_rewrite_a_held_one_is_refused() { + let (src, out, _, before) = published_once(); + let timestamp = read(out.path(), "timestamp.txt"); + let bent = manifest(src.path(), &first_root(&RepoKey::from_seed([4; 32])), &[("gbae", 4, "gbae-v4.tar.gz", b"new")]); let rewritten = publish(&bent, out.path(), &key(), NOW); assert!(rewritten.as_ref().unwrap_err().contains("holds another root.1.txt than"), "{rewritten:?}"); - - assert_eq!((listing(out.path()), read(out.path(), "timestamp.txt")), (before, timestamp), "a refused publish wrote nothing"); + left_as_it_was(out.path(), &before, ×tamp); } /// A timestamp on disk naming targets 2^64 − 1 leaves no next version: diff --git a/build/root/Cargo.toml b/build/root/Cargo.toml index 715dd89a9b4..7dcdc843a02 100644 --- a/build/root/Cargo.toml +++ b/build/root/Cargo.toml @@ -1,6 +1,5 @@ # A member of the host workspace (root `Cargo.toml`), built for the offline -# machine's triple and run there alone: no C, so any triple Rust targets builds -# it without a C toolchain. +# machine's Unix triple and run there alone. [package] name = "toyos-root" diff --git a/build/root/src/main.rs b/build/root/src/main.rs index de2dc093724..5ec4c7f594a 100644 --- a/build/root/src/main.rs +++ b/build/root/src/main.rs @@ -7,17 +7,16 @@ //! toyos-root sign --key --signing --out //! ``` //! -//! `new` mints the root key into a file that must not exist yet: its seed in -//! 64 hex digits and a newline, as the build's other key files hold one. It -//! prints the key's fingerprint and public line. `sign` writes root 1 +//! `new` mints the root key, `0600`, into a file that must not exist yet: its +//! seed as `toyos_update::sig::seed_text` writes one. It prints the key's +//! fingerprint and public line. `sign` writes root 1 //! (`toyos_update::repo::render::first_root`): the root key alone holds the //! root role and the signing key, named by the public line //! `cargo run -- --repo-signing-key-new` printed, the timestamp and targets //! roles, each at threshold 1, expiring a year from the machine's clock. It //! reads root 1 back as self-signed before it writes it, and prints it with -//! both fingerprints. The key file's mode is not checked: what guards it is -//! the stick's custody, and a check would bind the tool to one kind of -//! filesystem and host. +//! both fingerprints. It builds for a Unix triple, which mints the key +//! `0600`. //! //! **The ceremony.** The owner brings a machine that is offline for good, two //! USB sticks A and B for the root key, formatted on that machine, and a third, @@ -28,12 +27,16 @@ //! signing key at `~/.config/toyos/repo-signing-key` (`0600`), prints //! its fingerprint, and writes its public line to the stick. Write the //! fingerprint on paper. -//! 2. `cargo build --release -p toyos-root --target `, and copy `target//release/toyos-root` to C. +//! 2. `cargo build --release -p toyos-root --target aarch64-apple-darwin` +//! on this Mac, for an offline Mac of the same macOS and architecture, +//! and copy `target/aarch64-apple-darwin/release/toyos-root` to C. That +//! path is the measured one; a build for any other offline triple is +//! unmeasured. //! 2. Offline. //! 1. `date -u`: the clock must say today, since root 1 expires a year from //! it. -//! 2. `toyos-root new /root-key`. Write its fingerprint on the paper. +//! 2. `toyos-root new /root-key` mints the root key `0600`. Write its +//! fingerprint on the paper. //! 3. `cp /root-key /root-key && cmp /root-key /root-key`. //! 4. `toyos-root sign --key /root-key --signing /signing.pub --out //! /root.1.txt`. Both fingerprints it prints must be the paper's, @@ -51,6 +54,7 @@ //! machine holding root 1 is held to its keys until they are. use std::io::Write; +use std::os::unix::fs::OpenOptionsExt; use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; @@ -85,8 +89,7 @@ fn main() { fn new(path: &Path) -> Result { let mut seed = [0u8; 32]; getrandom::fill(&mut seed).map_err(|e| format!("the operating system's randomness: {e}"))?; - let hex: String = seed.iter().map(|b| format!("{b:02x}")).collect(); - write_new(path, format!("{hex}\n").as_bytes())?; + write_new(path, toyos_update::sig::seed_text(&seed).as_bytes(), 0o600)?; let public = toyos_update::sig::public_of(&seed); Ok(format!("root key {}\n{}\n", repo::fingerprint(&public), repo::public_line(&public))) } @@ -95,8 +98,8 @@ fn new(path: &Path) -> Result { /// into `out`, which must not exist. fn sign(key: &Path, signing: &Path, out: &Path, now: u64) -> Result { let text = std::fs::read_to_string(key).map_err(|e| format!("{}: {e}", key.display()))?; - let seed = seed_of_hex(text.strip_suffix('\n').unwrap_or(&text)) - .ok_or_else(|| format!("{} is not 64 hex digits, which `toyos-root new` writes", key.display()))?; + let seed = toyos_update::sig::seed_of_text(&text) + .ok_or_else(|| format!("{} is not the 64 hex digits and newline `toyos-root new` writes", key.display()))?; let line = std::fs::read_to_string(signing).map_err(|e| format!("{}: {e}", signing.display()))?; let signing_key: PublicKey = repo::parse_public_line(line.strip_suffix('\n').unwrap_or(&line)) .ok_or_else(|| format!("{} is not one `ssh-ed25519 ` line", signing.display()))?; @@ -106,7 +109,7 @@ fn sign(key: &Path, signing: &Path, out: &Path, now: u64) -> Result Result Result<(), String> { - let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(path).map_err(|e| match e.kind() { +/// `bytes` written to `path` at `mode` and synced, where no file is there. +fn write_new(path: &Path, bytes: &[u8], mode: u32) -> Result<(), String> { + let mut file = std::fs::OpenOptions::new().write(true).create_new(true).mode(mode).open(path).map_err(|e| match e.kind() { std::io::ErrorKind::AlreadyExists => format!("{} already exists, and it is not replaced", path.display()), _ => format!("{}: {e}", path.display()), })?; file.write_all(bytes).and_then(|()| file.sync_all()).map_err(|e| format!("{}: {e}", path.display())) } -fn seed_of_hex(text: &str) -> Option<[u8; 32]> { - if text.len() != 64 || !text.is_ascii() { - return None; - } - let mut seed = [0u8; 32]; - for (i, byte) in seed.iter_mut().enumerate() { - *byte = u8::from_str_radix(&text[2 * i..2 * i + 2], 16).ok()?; - } - Some(seed) -} - #[cfg(test)] mod tests { use super::*; - use repo::{render, Held, Item, Mirror, Role, Snapshot, Targets, Timestamp}; + use std::os::unix::fs::PermissionsExt; use toyos_tmpdir::TempDir; /// 2026-10-09T00:00:00Z. const NOW: u64 = 1_791_504_000; const SIGNING: [u8; 32] = [3; 32]; - struct Repo(Vec<(String, Vec)>); - - impl Mirror for Repo { - fn fetch(&mut self, name: &str, cap: usize) -> Result>, String> { - Ok(self.0.iter().find(|(n, _)| n == name).map(|(_, b)| b[..b.len().min(cap + 1)].to_vec())) - } + /// A minted root key, a signing line naming [`SIGNING`], and where root 1 + /// goes, with what `new` printed. + struct Ceremony { + _dir: TempDir, + key: std::path::PathBuf, + signing: std::path::PathBuf, + out: std::path::PathBuf, + said: String, } - /// A targets and the timestamp naming it, both signed by `seed`. - fn repository(seed: &[u8; 32]) -> Repo { - let sha256 = toyos_update::sha256(b"archive"); - let item = Item { - name: "gbae".into(), - target: "x86_64-unknown-toyos".into(), - sequence: 1, - version: "1.0".into(), - url: "gbae-1-x86_64-unknown-toyos.tar.gz".into(), - length: 7, - sha256, - }; - let body = render::targets(&Targets { version: 1, expires: NOW + 86_400, items: vec![item] }); - let targets = (body.clone() + &render::signature(seed, Role::Targets, body.as_bytes())).into_bytes(); - let snapshot = Snapshot { version: 1, length: targets.len() as u64, sha256: toyos_update::sha256(&targets) }; - let body = render::timestamp(&Timestamp { version: 1, expires: NOW + 86_400, targets: snapshot }); - let timestamp = (body.clone() + &render::signature(seed, Role::Timestamp, body.as_bytes())).into_bytes(); - Repo(vec![("targets.1.txt".into(), targets), ("timestamp.txt".into(), timestamp)]) - } - - /// **The ceremony's output is a root 1 a machine can pin**: minted and - /// signed through the two commands, it reads back self-signed under its - /// root key, and a repository the signing key signs is one a machine - /// pinning it accepts, where one the root key signs is not. - #[test] - fn a_root_one_it_signs_is_self_signed_and_grants_the_signing_key() { + fn ceremony() -> Ceremony { let dir = TempDir::new("toyos-root"); let (key, signing, out) = (dir.path().join("root-key"), dir.path().join("signing.pub"), dir.path().join("root.1.txt")); let said = new(&key).expect("minted"); - assert!(new(&key).unwrap_err().ends_with("already exists, and it is not replaced")); std::fs::write(&signing, format!("{}\n", repo::public_line(&toyos_update::sig::public_of(&SIGNING)))).unwrap(); - let printed = sign(&key, &signing, &out, NOW).expect("signed"); - let bytes = std::fs::read(&out).unwrap(); - let root = repo::self_signed(&bytes).expect("self-signed"); - assert_eq!((root.version, root.expires), (1, NOW + LIFE)); - let root_fingerprint = said.lines().next().unwrap().strip_prefix("root key ").unwrap(); - assert!(printed.contains(&format!("root key {root_fingerprint}\n")), "{printed}"); - assert!(sign(&key, &signing, &out, NOW).unwrap_err().ends_with("already exists, and it is not replaced")); - - let image = Held { root: &bytes, timestamp: None, targets: None }; - repo::refresh(&mut repository(&SIGNING), image, None, NOW).expect("what the signing key signs"); - let seed = seed_of_hex(std::fs::read_to_string(&key).unwrap().trim_end()).unwrap(); - assert!(repo::refresh(&mut repository(&seed), image, None, NOW).is_err(), "the root key signs roots alone"); + Ceremony { _dir: dir, key, signing, out, said } } - /// A signing line that is not one Ed25519 public line, or that names the - /// root key itself, is refused, and no root is written. + /// The root key is minted `0600`. #[test] - fn a_signing_line_that_is_not_another_key_is_refused() { - let dir = TempDir::new("toyos-root"); - let (key, signing, out) = (dir.path().join("root-key"), dir.path().join("signing.pub"), dir.path().join("root.1.txt")); - let said = new(&key).unwrap(); - std::fs::write(&signing, said.lines().nth(1).unwrap()).unwrap(); - assert!(sign(&key, &signing, &out, NOW).unwrap_err().ends_with("names the root key, which holds the root role alone")); - std::fs::write(&signing, "ssh-ed25519 AAAA comment\n").unwrap(); - assert!(sign(&key, &signing, &out, NOW).unwrap_err().ends_with("is not one `ssh-ed25519 ` line")); - assert!(!out.exists(), "a refused sign wrote no root"); + fn the_root_key_is_minted_0600() { + let c = ceremony(); + assert_eq!(std::fs::metadata(&c.key).unwrap().permissions().mode() & 0o777, 0o600); + } + + /// A second `new` over the key is refused, and the key is left. + #[test] + fn new_over_a_key_is_refused_and_leaves_it() { + let c = ceremony(); + let before = std::fs::read(&c.key).unwrap(); + assert!(new(&c.key).unwrap_err().ends_with("already exists, and it is not replaced")); + assert_eq!(std::fs::read(&c.key).unwrap(), before); + } + + /// `sign` writes the ceremony's root 1 for the minted key and the signing + /// line, expiring a year from the clock it is given. + #[test] + fn sign_writes_the_ceremonys_root_one_expiring_a_year_on() { + let c = ceremony(); + sign(&c.key, &c.signing, &c.out, NOW).expect("signed"); + let seed = toyos_update::sig::seed_of_text(&std::fs::read_to_string(&c.key).unwrap()).unwrap(); + let expected = repo::render::first_root(&seed, &toyos_update::sig::public_of(&SIGNING), NOW + LIFE); + assert_eq!(std::fs::read(&c.out).unwrap(), expected); + } + + /// The root key fingerprint `sign` prints is the one `new` printed. + #[test] + fn sign_prints_the_fingerprint_new_printed() { + let c = ceremony(); + let printed = sign(&c.key, &c.signing, &c.out, NOW).expect("signed"); + let minted = c.said.lines().next().unwrap().strip_prefix("root key ").unwrap(); + assert!(printed.contains(&format!("\nroot key {minted}\n")), "{printed}"); + } + + /// `sign` over a root 1 already written is refused. + #[test] + fn sign_over_an_existing_root_is_refused() { + let c = ceremony(); + std::fs::write(&c.out, b"held").unwrap(); + assert!(sign(&c.key, &c.signing, &c.out, NOW).unwrap_err().ends_with("already exists, and it is not replaced")); + } + + /// A signing line naming the root key itself is refused, and no root is + /// written. + #[test] + fn a_signing_line_naming_the_root_key_is_refused() { + let c = ceremony(); + std::fs::write(&c.signing, c.said.lines().nth(1).unwrap()).unwrap(); + assert!(sign(&c.key, &c.signing, &c.out, NOW).unwrap_err().ends_with("names the root key, which holds the root role alone")); + assert!(!c.out.exists(), "a refused sign wrote no root"); + } + + /// A signing line that is not one Ed25519 public line is refused, and no + /// root is written. + #[test] + fn a_signing_line_that_is_not_one_public_line_is_refused() { + let c = ceremony(); + std::fs::write(&c.signing, "ssh-ed25519 AAAA comment\n").unwrap(); + assert!(sign(&c.key, &c.signing, &c.out, NOW).unwrap_err().ends_with("is not one `ssh-ed25519 ` line")); + assert!(!c.out.exists(), "a refused sign wrote no root"); } } diff --git a/build/signing.rs b/build/signing.rs index bf2a7d2da72..0fd2b9c4426 100644 --- a/build/signing.rs +++ b/build/signing.rs @@ -21,11 +21,12 @@ //! OpenSSH Ed25519 private key without a passphrase, so `ssh-keygen -Y sign -n //! toyos-image` over an image's header makes the same signature this does. //! -//! **The package repository's signing key is a third**, never the image key: -//! the root the owner signs offline grants it the targets and timestamp roles -//! (`build/root`), and the publisher signs with it alone (`build/publish.rs`). -//! [`mint_repo_key`] writes it at [`REPO_KEY`] under `$HOME` as the seed in 64 -//! hex digits, the form it is pasted into CI's secret in. +//! **The package repository's signing key is a third**, a [`RepoKey`], which +//! signs documents and no image header: the root the owner signs offline +//! grants it the targets and timestamp roles (`build/root`), and the +//! publisher signs with it alone (`build/publish.rs`). [`mint_repo_key`] +//! writes it at [`REPO_KEY`] under `$HOME` as `toyos_update::sig::seed_text`, +//! the form it is pasted into CI's secret in. use std::path::{Path, PathBuf}; use std::sync::OnceLock; @@ -115,12 +116,6 @@ impl Key { out } - /// The `sig` line this key vouches for a package repository document's - /// `body` with, as `role` (`build/publish.rs`). - pub fn sign_document(&self, role: Role, body: &[u8]) -> String { - toyos_update::repo::render::signature(&self.seed, role, body) - } - /// A key from a seed the caller chose, for a test that needs a second, /// wrong key or a fixed one. pub fn throwaway_from(seed: [u8; 32]) -> Self { @@ -165,7 +160,7 @@ fn checkout_throwaway(root: &Path) -> Result { .mode(0o600) .open(&mine) .and_then(|mut file| { - file.write_all(format!("{}\n", hex_of(&key.seed)).as_bytes())?; + file.write_all(toyos_update::sig::seed_text(&key.seed).as_bytes())?; file.sync_all() }); let linked = written.and_then(|()| match std::fs::hard_link(&mine, &path) { @@ -176,26 +171,11 @@ fn checkout_throwaway(root: &Path) -> Result { linked.map_err(|e| format!("{}: {e}", path.display()))?; } let text = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?; - let seed = seed_of_hex(text.trim()) + let seed = toyos_update::sig::seed_of_text(&text) .ok_or_else(|| format!("{} is not 64 hex digits; delete it and the next build mints another", path.display()))?; Ok(Key::throwaway_from(seed)) } -fn hex_of(bytes: &[u8]) -> String { - bytes.iter().map(|b| format!("{b:02x}")).collect() -} - -fn seed_of_hex(text: &str) -> Option<[u8; 32]> { - if text.len() != 64 || !text.is_ascii() { - return None; - } - let mut seed = [0u8; 32]; - for (i, byte) in seed.iter_mut().enumerate() { - *byte = u8::from_str_radix(&text[2 * i..2 * i + 2], 16).ok()?; - } - Some(seed) -} - /// Sign everything this process builds with the owner's key, or say why not. /// Asked before anything is built, so no image is signed by two keys. pub fn use_owner() -> Result<&'static Key, String> { @@ -266,17 +246,46 @@ pub fn repo_key_path() -> Result { Ok(PathBuf::from(home).join(REPO_KEY)) } +/// The package repository's signing key: it signs the documents its root +/// grants it, and no image header. +pub struct RepoKey { + seed: [u8; 32], + public: [u8; 32], +} + +impl RepoKey { + pub(crate) fn from_seed(seed: [u8; 32]) -> Self { + Self { public: toyos_update::sig::public_of(&seed), seed } + } + + pub fn public(&self) -> [u8; 32] { + self.public + } + + /// `SHA256:`, as `ssh-keygen -l` prints it. + pub fn fingerprint(&self) -> String { + toyos_update::repo::fingerprint(&self.public) + } + + /// The `sig` line this key vouches for a package repository document's + /// `body` with, as `role` (`build/publish.rs`). + pub fn sign_document(&self, role: Role, body: &[u8]) -> String { + toyos_update::repo::render::signature(&self.seed, role, body) + } +} + /// Mint the package repository's signing key at `path`, refusing to replace /// one: a key replaced is one no root grants. -pub fn mint_repo_key(path: &Path) -> Result { - let key = Key::mint(); - write_secret(path, &format!("{}\n", hex_of(&key.seed)))?; - Ok(Key { whose: Whose::Owner(path.to_path_buf()), ..key }) +pub fn mint_repo_key(path: &Path) -> Result { + let mut seed = [0u8; 32]; + getrandom::fill(&mut seed).expect("the operating system's randomness"); + write_secret(path, &toyos_update::sig::seed_text(&seed))?; + Ok(RepoKey::from_seed(seed)) } /// The package repository's signing key at `path`, refused by name where it /// is not one or others may read it. -pub fn read_repo_key(path: &Path) -> Result { +pub fn read_repo_key(path: &Path) -> Result { refuse_wide(path)?; let text = std::fs::read_to_string(path).map_err(|e| { format!( @@ -284,8 +293,9 @@ pub fn read_repo_key(path: &Path) -> Result { path.display() ) })?; - let seed = seed_of_hex(text.trim()).ok_or_else(|| format!("{} is not 64 hex digits", path.display()))?; - Ok(Key::from_seed(seed, Whose::Owner(path.to_path_buf()))) + let seed = toyos_update::sig::seed_of_text(&text) + .ok_or_else(|| format!("{} is not 64 lowercase hex digits and a newline", path.display()))?; + Ok(RepoKey::from_seed(seed)) } /// `text` written to `path`, `0600` and synced, where no file is there. @@ -346,24 +356,44 @@ mod tests { assert!(checkout_throwaway(dir.path()).err().unwrap().contains("is not 64 hex digits")); } - /// **The repository's signing key is minted `0600` once and read back**: - /// a second mint is refused rather than replacing it, a bent file is - /// refused, and its fingerprint is the one OpenSSH's own implementation in - /// this tree prints, so the paper the ceremony compares holds one name. + /// The repository's signing key is minted `0600`, with its directory. #[test] - fn a_repo_key_is_minted_once_and_read_back() { + fn a_repo_key_is_minted_0600() { use std::os::unix::fs::PermissionsExt; let dir = toyos_tmpdir::TempDir::new("repo-key"); let path = dir.path().join("config/repo-signing-key"); - let minted = mint_repo_key(&path).expect("minted"); + mint_repo_key(&path).expect("minted"); assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); - let read = read_repo_key(&path).expect("read back"); - assert_eq!((read.public, read.whose()), (minted.public, &Whose::Owner(path.clone()))); - assert_eq!(read.fingerprint(), toyos_ssh::hostkey::fingerprint(&read.public)); + } + + /// The repository's signing key reads back as the key minted. + #[test] + fn a_repo_key_reads_back_as_minted() { + let dir = toyos_tmpdir::TempDir::new("repo-key"); + let path = dir.path().join("repo-signing-key"); + let minted = mint_repo_key(&path).expect("minted"); + assert_eq!(read_repo_key(&path).map(|k| k.public), Ok(minted.public)); + } + + /// A second mint is refused rather than replacing the key. + #[test] + fn a_second_repo_key_mint_is_refused_and_leaves_the_key() { + let dir = toyos_tmpdir::TempDir::new("repo-key"); + let path = dir.path().join("repo-signing-key"); + let minted = mint_repo_key(&path).expect("minted"); assert!(mint_repo_key(&path).err().unwrap().contains("already holds a key, and it is not replaced")); assert_eq!(read_repo_key(&path).map(|k| k.public), Ok(minted.public), "the refused mint left the key"); + } + + /// A repository key file that is not a seed's text is refused by name. + #[test] + fn a_repo_key_file_that_is_not_a_seed_is_refused() { + use std::os::unix::fs::PermissionsExt; + let dir = toyos_tmpdir::TempDir::new("repo-key"); + let path = dir.path().join("repo-signing-key"); std::fs::write(&path, "not a seed\n").unwrap(); - assert!(read_repo_key(&path).err().unwrap().contains("is not 64 hex digits")); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + assert!(read_repo_key(&path).err().unwrap().contains("is not 64 lowercase hex digits and a newline")); } /// An owner's key anyone else may read is refused by its mode, before its diff --git a/build/ssh/Cargo.toml b/build/ssh/Cargo.toml index 92ead4ab2ec..d849a1c21e8 100644 --- a/build/ssh/Cargo.toml +++ b/build/ssh/Cargo.toml @@ -16,3 +16,6 @@ publish = false # verify, `chacha20_poly1305_openssh` (made for exactly this packet layout), # SHA-256 and the system's randomness. ring = "0.17" +# The one `SHA256:` fingerprint a key is named by, the one the package +# repository's keys are named by too. +toyos-update = { path = "../../lib/update" } diff --git a/build/ssh/src/auth.rs b/build/ssh/src/auth.rs index bebd08b28ed..b10238c8cc0 100644 --- a/build/ssh/src/auth.rs +++ b/build/ssh/src/auth.rs @@ -117,7 +117,7 @@ impl PreAuth { } else { let key = ed25519_key(blob)?; if !authorizer.authorizes(user, &key) { - Declined::Key { user: user.to_string(), fingerprint: hostkey::fingerprint(&key) } + Declined::Key { user: user.to_string(), fingerprint: toyos_update::repo::fingerprint(&key) } } else if let Some(signature) = signature { let signature = ed25519_signature(signature)?; let mut data = Vec::new(); diff --git a/build/ssh/src/base64.rs b/build/ssh/src/base64.rs index 767349e1e42..ddaf6234570 100644 --- a/build/ssh/src/base64.rs +++ b/build/ssh/src/base64.rs @@ -1,5 +1,4 @@ -//! RFC 4648 base64, the armour of an OpenSSH key file and the digits of a -//! fingerprint. +//! RFC 4648 base64, the armour of an OpenSSH key file. const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; diff --git a/build/ssh/src/hostkey.rs b/build/ssh/src/hostkey.rs index 0e3378405bc..80c6f970ab0 100644 --- a/build/ssh/src/hostkey.rs +++ b/build/ssh/src/hostkey.rs @@ -1,7 +1,6 @@ //! An Ed25519 key in OpenSSH's unencrypted `openssh-key-v1` file -//! (`PROTOCOL.key`), read and written, and its `SHA256:` fingerprint: the -//! server's host key, and the one parser the build's image-signing key is read -//! with too. +//! (`PROTOCOL.key`), read and written: the server's host key, and the one +//! parser the build's image-signing key is read with too. use ring::signature::{Ed25519KeyPair, KeyPair}; @@ -49,12 +48,6 @@ impl HostKey { } } -/// `SHA256:` of the public key blob, as `ssh-keygen -l` prints it. -pub fn fingerprint(public: &[u8; 32]) -> String { - let digest = ring::digest::digest(&ring::digest::SHA256, &public_blob(public)); - format!("SHA256:{}", base64::encode(digest.as_ref()).trim_end_matches('=')) -} - /// `string ssh-ed25519 | string key`, the OpenSSH public key blob (RFC 8709 §4). pub(crate) fn public_blob(public: &[u8; 32]) -> Vec { let mut out = Vec::new(); @@ -221,14 +214,13 @@ mod tests { } /// A file `ssh-keygen -t ed25519` wrote reads, and makes the public key - /// and fingerprint `ssh-keygen -l` printed for it. + /// its `.pub` names. #[test] - fn a_key_ssh_keygen_minted_reads_with_its_fingerprint() { + fn a_key_ssh_keygen_minted_reads() { let text = include_str!("../tests/fixtures/host_ed25519"); let public = include_str!("../tests/fixtures/host_ed25519.pub"); let key = HostKey::from_openssh(text).expect("ssh-keygen's key"); let blob = base64::decode(public.split(' ').nth(1).unwrap()).unwrap(); assert_eq!(blob, key.blob()); - assert_eq!(fingerprint(&key.public()), include_str!("../tests/fixtures/host_ed25519.fingerprint").trim()); } } diff --git a/build/ssh/tests/fixtures/host_ed25519.fingerprint b/build/ssh/tests/fixtures/host_ed25519.fingerprint deleted file mode 100644 index c8f08866d18..00000000000 --- a/build/ssh/tests/fixtures/host_ed25519.fingerprint +++ /dev/null @@ -1 +0,0 @@ -SHA256:/pmWZjHhAna2q7Gk9gEeMqi486MLZ1/znPh7aLSALNQ diff --git a/build/ssh/tests/refusals.rs b/build/ssh/tests/refusals.rs index 18c65760c66..51f647b6c51 100644 --- a/build/ssh/tests/refusals.rs +++ b/build/ssh/tests/refusals.rs @@ -131,7 +131,7 @@ fn an_unlisted_key_is_refused_at_the_offer_and_signed() { assert_eq!(c.recv(), [failure()]); c.sign_in(USER, &stranger).unwrap(); assert_eq!(c.recv(), [failure()]); - let declined = Event::Declined(Declined::Key { user: USER.into(), fingerprint: toyos_ssh::hostkey::fingerprint(&public) }); + let declined = Event::Declined(Declined::Key { user: USER.into(), fingerprint: toyos_update::repo::fingerprint(&public) }); assert_eq!(c.events, [declined.clone(), declined]); // The authorizer is asked for the user too: another user's key is not // this one's. diff --git a/build/ssh/tests/replay.rs b/build/ssh/tests/replay.rs index 3579a43a9c7..c48e29cc194 100644 --- a/build/ssh/tests/replay.rs +++ b/build/ssh/tests/replay.rs @@ -117,7 +117,7 @@ fn a_session_the_client_rekeyed_three_times_replays() { #[test] fn a_key_the_server_does_not_name_is_refused_at_the_offer() { let events = replay(STRANGER); - let stranger = toyos_ssh::hostkey::fingerprint(&public_key(include_str!("fixtures/stranger_ed25519.pub"))); + let stranger = toyos_update::repo::fingerprint(&public_key(include_str!("fixtures/stranger_ed25519.pub"))); assert_eq!(events, [Event::Declined(Declined::Key { user: USER.into(), fingerprint: stranger })]); } diff --git a/lib/update/src/repo.rs b/lib/update/src/repo.rs index f6d63671071..b147e1123f1 100644 --- a/lib/update/src/repo.rs +++ b/lib/update/src/repo.rs @@ -1662,19 +1662,33 @@ mod tests { } } - /// Root 1 as the ceremony signs it is self-signed and grants the signing - /// key the targets and timestamp roles: a repository that key signs is one - /// a machine pinning that root accepts. + /// Root 1 as the ceremony signs it is self-signed. #[test] - fn the_ceremonys_root_is_self_signed_and_grants_the_signing_key() { + fn the_ceremonys_root_is_self_signed() { let first = render::first_root(&seed(1), &public(2), NOW + 365 * DAY); assert_eq!(self_signed(&first).map(|r| r.version), Ok(1)); + } + + /// Root 1 as the ceremony signs it grants the signing key the targets and + /// timestamp roles: a repository that key signs is one a machine pinning + /// that root accepts. + #[test] + fn the_ceremonys_root_accepts_what_the_signing_key_signs() { + let first = render::first_root(&seed(1), &public(2), NOW + 365 * DAY); let mut repo = Repo::default(); repo.publish(1, &targets(1, vec![item(3, ARCHIVE)]), 2); let image = Held { root: &first, timestamp: None, targets: None }; refresh(&mut repo, image, None, NOW).expect("targets and timestamp the signing key signed"); + } + + /// Root 1 as the ceremony signs it gives its root key the root role + /// alone: a timestamp that key signs is refused. + #[test] + fn the_ceremonys_root_refuses_a_timestamp_the_root_key_signs() { + let first = render::first_root(&seed(1), &public(2), NOW + 365 * DAY); let mut by_root = Repo::default(); by_root.publish(1, &targets(1, vec![item(3, ARCHIVE)]), 1); + let image = Held { root: &first, timestamp: None, targets: None }; assert!(matches!( refused(refresh(&mut by_root, image, None, NOW)), Refused::Threshold { role: Role::Timestamp, first: Some(SigRefused::NotTheRolesKey), .. } @@ -1700,12 +1714,16 @@ mod tests { ); } - /// A public line reads back as its key, and nothing but the two words of - /// an Ed25519 one does. + /// A public line reads back as its key. + #[test] + fn a_public_line_reads_back_as_its_key() { + assert_eq!(parse_public_line(&public_line(&public(1))), Some(public(1))); + } + + /// Nothing but the two words of an Ed25519 public line reads as a key. #[test] - fn a_public_line_names_its_key_and_nothing_else_reads() { + fn a_line_that_is_not_an_ed25519_public_line_is_refused() { let line = public_line(&public(1)); - assert_eq!(parse_public_line(&line), Some(public(1))); let blob = line.split(' ').nth(1).unwrap(); for bent in [format!("{line} comment"), format!("ssh-rsa {blob}"), format!(" {line}"), blob.to_string()] { assert_eq!(parse_public_line(&bent), None, "{bent:?}"); diff --git a/lib/update/src/sig.rs b/lib/update/src/sig.rs index d9ebd2b475f..06c7c40a234 100644 --- a/lib/update/src/sig.rs +++ b/lib/update/src/sig.rs @@ -120,6 +120,24 @@ pub fn sign(seed: &[u8; 32], signed: &S) -> [u8; SIGNATURE_B ed25519_dalek::SigningKey::from_bytes(seed).sign(message(signed).as_bytes()).to_bytes() } +/// A seed as a key file holds it: 64 lowercase hex digits and a newline. +#[cfg(any(test, feature = "sign"))] +pub fn seed_text(seed: &[u8; 32]) -> alloc::string::String { + let mut digits = [0u8; 64]; + alloc::format!("{}\n", crate::hex(seed, &mut digits)) +} + +/// The seed a [`seed_text`] holds, and `None` for any other text. +#[cfg(any(test, feature = "sign"))] +pub fn seed_of_text(text: &str) -> Option<[u8; 32]> { + let digits = text.strip_suffix('\n').filter(|d| d.len() == 64 && d.is_ascii())?; + let mut seed = [0u8; 32]; + for (i, byte) in seed.iter_mut().enumerate() { + *byte = u8::from_str_radix(&digits[2 * i..2 * i + 2], 16).ok()?; + } + (seed_text(&seed) == text).then_some(seed) +} + /// A 32-byte key from 64 hex digits, at compile time: how the loader and the /// updater take the key the build embeds. Anything else fails the build. pub const fn key_from_hex(text: &str) -> [u8; 32] { @@ -229,4 +247,30 @@ mod tests { assert_eq!(&m[35..39], &64u32.to_be_bytes()); assert_eq!(&m[39..], &Sha512::digest(header)[..]); } + + /// A seed's key-file text reads back as the seed. + #[test] + fn a_seed_text_reads_back_as_its_seed() { + let seed: [u8; 32] = core::array::from_fn(|i| (i * 37) as u8); + assert_eq!(seed_of_text(&seed_text(&seed)), Some(seed)); + } + + /// A text that is not exactly 64 lowercase hex digits and one newline is + /// no seed, however near. + #[test] + fn a_text_not_in_the_seed_texts_form_is_refused() { + let text = seed_text(&[0xab; 32]); + let digits = text.trim_end(); + for bent in [ + digits.to_string(), + text.to_uppercase(), + format!("{digits}\n\n"), + format!("{digits} \n"), + format!(" {text}"), + format!("+b{}\n", &digits[2..]), + format!("{}\n", &digits[2..]), + ] { + assert_eq!(seed_of_text(&bent), None, "{bent:?}"); + } + } } diff --git a/lib/update/tests/repo_oracle.rs b/lib/update/tests/repo_oracle.rs index d07be511037..8203b71505a 100644 --- a/lib/update/tests/repo_oracle.rs +++ b/lib/update/tests/repo_oracle.rs @@ -98,15 +98,25 @@ fn a_repository_ssh_keygen_signed_is_accepted_and_a_bent_one_is_not() { ); } -/// The root OpenSSH signed is self-signed, and its key's public line and -/// fingerprint are OpenSSH's: the line is the fixture's own words, and the -/// fingerprint is what `ssh-keygen -l` printed over that line. +/// The root OpenSSH signed is self-signed. #[test] -fn the_fixtures_root_is_self_signed_and_its_key_reads_as_openssh_names_it() { - let root = repo::self_signed(ROOT).expect("the root OpenSSH signed"); - let [key] = root.keys[..] else { panic!("the fixture declares one key") }; +fn the_fixtures_root_is_self_signed() { + assert_eq!(repo::self_signed(ROOT).map(|r| r.version), Ok(1)); +} + +/// The fixture's key renders as the public line the fixture's own words +/// carry. +#[test] +fn the_fixtures_key_renders_as_the_line_it_carries() { + let [key] = repo::Root::parse(ROOT).unwrap().keys[..] else { panic!("the fixture declares one key") }; let line = repo::public_line(&key); assert!(String::from_utf8_lossy(ROOT).contains(&format!(" {line}\n")), "{line}"); - assert_eq!(repo::parse_public_line(&line), Some(key)); +} + +/// The fixture's key's fingerprint is what `ssh-keygen -l` printed over its +/// public line. +#[test] +fn the_fixtures_key_has_the_fingerprint_ssh_keygen_printed() { + let [key] = repo::Root::parse(ROOT).unwrap().keys[..] else { panic!("the fixture declares one key") }; assert_eq!(repo::fingerprint(&key), "SHA256:bdWoF+LpZIGawveHnzj/Kd4UofKmRTiHbB9Dk4KaAxc"); } From 72caf9c0d6bc85511ec88566d4c14fa8a0eacc76 Mon Sep 17 00:00:00 2001 From: japabu Date: Tue, 13 Oct 2026 04:50:48 +0200 Subject: [PATCH 3/5] Stage 1's review, round 2: toyos-root new refuses a filesystem that keeps no Unix mode, and the ceremony names APFS The 0600 the header promised the root key rested on a filesystem the ceremony never named. On exFAT and FAT32, the formats USB sticks come in, open(O_CREAT, 0600) succeeds and every file reads back the mount's one mode: measured 0700 on both, attached as disk images and erased by diskutil. `new` printed its fingerprint as though it had minted 0600. - `new` reads the mode back from the file it created, before the seed is written, and where it is not 0600 removes the empty file and refuses by name. `sign`'s root 1 to stick C takes no read-back: it is public. - The header names A and B as APFS, erased with `diskutil eraseDisk APFS GPT `, and says why. - Step 2.1 names `sudo date -u ` for a wrong clock; steps 2.2 and 2.4 run `/toyos-root`, which is on no PATH offline; step 2.3 reads the B copy's mode back. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy --- build/root/src/main.rs | 53 ++++++++++++++++++++++++++++-------------- 1 file changed, 36 insertions(+), 17 deletions(-) diff --git a/build/root/src/main.rs b/build/root/src/main.rs index 5ec4c7f594a..bc3355c8d81 100644 --- a/build/root/src/main.rs +++ b/build/root/src/main.rs @@ -8,8 +8,10 @@ //! ``` //! //! `new` mints the root key, `0600`, into a file that must not exist yet: its -//! seed as `toyos_update::sig::seed_text` writes one. It prints the key's -//! fingerprint and public line. `sign` writes root 1 +//! seed as `toyos_update::sig::seed_text` writes one. It reads the new file's +//! mode back before it writes the seed, and where the filesystem keeps no Unix +//! mode and reads back anything else, it removes the file and refuses. It +//! prints the key's fingerprint and public line. `sign` writes root 1 //! (`toyos_update::repo::render::first_root`): the root key alone holds the //! root role and the signing key, named by the public line //! `cargo run -- --repo-signing-key-new` printed, the timestamp and targets @@ -19,8 +21,11 @@ //! `0600`. //! //! **The ceremony.** The owner brings a machine that is offline for good, two -//! USB sticks A and B for the root key, formatted on that machine, and a third, -//! C, that carries files between it and the online machine. +//! USB sticks A and B for the root key, and a third, C, that carries files +//! between it and the online machine. A and B are erased to APFS on the +//! offline machine, `diskutil eraseDisk APFS GPT `, with `` +//! the stick's whole disk as `diskutil list` names it: exFAT and FAT32, the +//! formats sticks come in, keep no Unix mode, and `new` refuses them. //! //! 1. Online, before. //! 1. `cargo run -q -- --repo-signing-key-new > /signing.pub` mints the @@ -34,12 +39,14 @@ //! unmeasured. //! 2. Offline. //! 1. `date -u`: the clock must say today, since root 1 expires a year from -//! it. -//! 2. `toyos-root new /root-key` mints the root key `0600`. Write its +//! it. Where it does not, set it by hand, `sudo date -u `, +//! and read `date -u` again. +//! 2. `/toyos-root new /root-key` mints the root key `0600`. Write its //! fingerprint on the paper. -//! 3. `cp /root-key /root-key && cmp /root-key /root-key`. -//! 4. `toyos-root sign --key /root-key --signing /signing.pub --out -//! /root.1.txt`. Both fingerprints it prints must be the paper's, +//! 3. `cp /root-key /root-key && cmp /root-key /root-key`, and +//! `stat -f %Sp /root-key` reads `-rw-------`. +//! 4. `/toyos-root sign --key /root-key --signing /signing.pub +//! --out /root.1.txt`. Both fingerprints it prints must be the paper's, //! and its expiry a year from today. //! 5. Unmount and unplug A and B, and keep them apart, offline, each in a //! place of its own. Only `/root.1.txt` goes back. @@ -54,7 +61,7 @@ //! machine holding root 1 is held to its keys until they are. use std::io::Write; -use std::os::unix::fs::OpenOptionsExt; +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; @@ -89,7 +96,16 @@ fn main() { fn new(path: &Path) -> Result { let mut seed = [0u8; 32]; getrandom::fill(&mut seed).map_err(|e| format!("the operating system's randomness: {e}"))?; - write_new(path, toyos_update::sig::seed_text(&seed).as_bytes(), 0o600)?; + let file = create_new(path, 0o600)?; + let mode = file.metadata().map_err(|e| format!("{}: {e}", path.display()))?.permissions().mode() & 0o777; + if mode != 0o600 { + let removed = std::fs::remove_file(path).map_or_else(|e| format!(", and removing it failed: {e}"), |()| String::new()); + return Err(format!( + "{} reads back {mode:04o}, not 0600: its filesystem keeps no Unix mode, so the root key is not written there{removed}", + path.display() + )); + } + fill(file, path, toyos_update::sig::seed_text(&seed).as_bytes())?; let public = toyos_update::sig::public_of(&seed); Ok(format!("root key {}\n{}\n", repo::fingerprint(&public), repo::public_line(&public))) } @@ -109,7 +125,7 @@ fn sign(key: &Path, signing: &Path, out: &Path, now: u64) -> Result Result Result<(), String> { - let mut file = std::fs::OpenOptions::new().write(true).create_new(true).mode(mode).open(path).map_err(|e| match e.kind() { +/// A file created at `path` asking for `mode`, where no file is there. +fn create_new(path: &Path, mode: u32) -> Result { + std::fs::OpenOptions::new().write(true).create_new(true).mode(mode).open(path).map_err(|e| match e.kind() { std::io::ErrorKind::AlreadyExists => format!("{} already exists, and it is not replaced", path.display()), _ => format!("{}: {e}", path.display()), - })?; + }) +} + +/// `bytes` written to `file`, the one at `path`, and synced. +fn fill(mut file: std::fs::File, path: &Path, bytes: &[u8]) -> Result<(), String> { file.write_all(bytes).and_then(|()| file.sync_all()).map_err(|e| format!("{}: {e}", path.display())) } #[cfg(test)] mod tests { use super::*; - use std::os::unix::fs::PermissionsExt; use toyos_tmpdir::TempDir; /// 2026-10-09T00:00:00Z. From c1e06aa01e4071a89e5f225ae0e143a435486e03 Mon Sep 17 00:00:00 2001 From: japabu Date: Tue, 13 Oct 2026 05:00:48 +0200 Subject: [PATCH 4/5] Stage 1's review, round 3: the mode read-back is a function a host test holds, and the ceremony enables ownership on A and B `new`'s refusal of a key file that reads back other than 0600 moves into `read_back_0600`, which `new` calls with the open file; a host test creates a 0644 file in a tempdir and asserts the refusal and the removal, so the refusal no longer rests on a disk-image script alone. The ceremony names the sticks ROOTA and ROOTB, takes `` only from an `(external, physical)` entry, enables ownership on both with `sudo diskutil enableOwnership`, since an APFS stick mounts `noowners`, and reads owner and mode back on both copies. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy --- build/root/src/main.rs | 51 +++++++++++++++++++++++++++++++----------- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/build/root/src/main.rs b/build/root/src/main.rs index bc3355c8d81..a0aa7bcdd84 100644 --- a/build/root/src/main.rs +++ b/build/root/src/main.rs @@ -23,9 +23,15 @@ //! **The ceremony.** The owner brings a machine that is offline for good, two //! USB sticks A and B for the root key, and a third, C, that carries files //! between it and the online machine. A and B are erased to APFS on the -//! offline machine, `diskutil eraseDisk APFS GPT `, with `` -//! the stick's whole disk as `diskutil list` names it: exFAT and FAT32, the -//! formats sticks come in, keep no Unix mode, and `new` refuses them. +//! offline machine, `diskutil eraseDisk APFS ROOTA GPT ` and the same +//! for `ROOTB`, with `` the stick's whole disk taken only from an entry +//! `diskutil list` marks `(external, physical)`: any other is the machine's +//! own. exFAT and FAT32, the formats sticks come in, keep no Unix mode, and +//! `new` refuses them. Ownership is then enabled on each, +//! `sudo diskutil enableOwnership /Volumes/ROOTA` and the same for `ROOTB`, +//! and `mount` must list neither as `noowners`: a volume that ignores +//! ownership treats every user as the owner `0600` names. Below, `` is +//! `/Volumes/ROOTA`, `` is `/Volumes/ROOTB`, and `` is where C mounts. //! //! 1. Online, before. //! 1. `cargo run -q -- --repo-signing-key-new > /signing.pub` mints the @@ -44,7 +50,8 @@ //! 2. `/toyos-root new /root-key` mints the root key `0600`. Write its //! fingerprint on the paper. //! 3. `cp /root-key /root-key && cmp /root-key /root-key`, and -//! `stat -f %Sp /root-key` reads `-rw-------`. +//! `stat -f '%Su %Sp' /root-key /root-key` reads the user that ran +//! `new` and `-rw-------`, twice. //! 4. `/toyos-root sign --key /root-key --signing /signing.pub //! --out /root.1.txt`. Both fingerprints it prints must be the paper's, //! and its expiry a year from today. @@ -96,15 +103,7 @@ fn main() { fn new(path: &Path) -> Result { let mut seed = [0u8; 32]; getrandom::fill(&mut seed).map_err(|e| format!("the operating system's randomness: {e}"))?; - let file = create_new(path, 0o600)?; - let mode = file.metadata().map_err(|e| format!("{}: {e}", path.display()))?.permissions().mode() & 0o777; - if mode != 0o600 { - let removed = std::fs::remove_file(path).map_or_else(|e| format!(", and removing it failed: {e}"), |()| String::new()); - return Err(format!( - "{} reads back {mode:04o}, not 0600: its filesystem keeps no Unix mode, so the root key is not written there{removed}", - path.display() - )); - } + let file = read_back_0600(create_new(path, 0o600)?, path)?; fill(file, path, toyos_update::sig::seed_text(&seed).as_bytes())?; let public = toyos_update::sig::public_of(&seed); Ok(format!("root key {}\n{}\n", repo::fingerprint(&public), repo::public_line(&public))) @@ -143,6 +142,20 @@ fn create_new(path: &Path, mode: u32) -> Result { }) } +/// `file`, just created at `path` and still empty, where its mode reads back +/// `0600`; otherwise the file is removed and refused. +fn read_back_0600(file: std::fs::File, path: &Path) -> Result { + let mode = file.metadata().map_err(|e| format!("{}: {e}", path.display()))?.permissions().mode() & 0o777; + if mode == 0o600 { + return Ok(file); + } + let removed = std::fs::remove_file(path).map_or_else(|e| format!(", and removing it failed: {e}"), |()| String::new()); + Err(format!( + "{} reads back {mode:04o}, not 0600: its filesystem keeps no Unix mode, so the root key is not written there{removed}", + path.display() + )) +} + /// `bytes` written to `file`, the one at `path`, and synced. fn fill(mut file: std::fs::File, path: &Path, bytes: &[u8]) -> Result<(), String> { file.write_all(bytes).and_then(|()| file.sync_all()).map_err(|e| format!("{}: {e}", path.display())) @@ -182,6 +195,18 @@ mod tests { assert_eq!(std::fs::metadata(&c.key).unwrap().permissions().mode() & 0o777, 0o600); } + /// A key file that reads back other than `0600` is refused and removed. + #[test] + fn a_key_file_that_reads_back_other_than_0600_is_refused_and_removed() { + let dir = TempDir::new("toyos-root"); + let path = dir.path().join("root-key"); + let file = create_new(&path, 0o644).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + let why = read_back_0600(file, &path).unwrap_err(); + assert!(why.ends_with("reads back 0644, not 0600: its filesystem keeps no Unix mode, so the root key is not written there"), "{why}"); + assert!(!path.exists(), "the refused file is removed"); + } + /// A second `new` over the key is refused, and the key is left. #[test] fn new_over_a_key_is_refused_and_leaves_it() { From 41bbac4f5916d2830d66a3d66991c56eb29fc1b5 Mon Sep 17 00:00:00 2001 From: japabu Date: Tue, 13 Oct 2026 05:09:45 +0200 Subject: [PATCH 5/5] Stage 1's review, round 4: the ownership step cites diskutil(8) and its scope, and step 2.3 reads the mode alone diskutil(8) enableOwnership writes the setting to the running OS's volume database, not to the target disk, so 0600 on A and B binds only on the offline machine; elsewhere the sticks' guard is custody. The %Su check in step 2.3 could not fail (noowners reports the current user, and with ownership enabled the on-disk owner is that same user), so it is cut back to %Sp; the mount check is the discriminating one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy --- build/root/src/main.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/build/root/src/main.rs b/build/root/src/main.rs index a0aa7bcdd84..ab2d951ab92 100644 --- a/build/root/src/main.rs +++ b/build/root/src/main.rs @@ -28,10 +28,13 @@ //! `diskutil list` marks `(external, physical)`: any other is the machine's //! own. exFAT and FAT32, the formats sticks come in, keep no Unix mode, and //! `new` refuses them. Ownership is then enabled on each, -//! `sudo diskutil enableOwnership /Volumes/ROOTA` and the same for `ROOTB`, -//! and `mount` must list neither as `noowners`: a volume that ignores -//! ownership treats every user as the owner `0600` names. Below, `` is -//! `/Volumes/ROOTA`, `` is `/Volumes/ROOTB`, and `` is where C mounts. +//! `sudo diskutil enableOwnership /Volumes/ROOTA` and the same for `ROOTB` +//! (`diskutil(8)`, `enableOwnership`), and `mount` must list neither as +//! `noowners`: a volume that ignores ownership treats every user as the owner +//! `0600` names. The setting lives in the offline machine's volume database, +//! not on the stick, so anywhere else A and B are guarded by custody alone +//! (step 2.5). Below, `` is `/Volumes/ROOTA`, `` is `/Volumes/ROOTB`, +//! and `` is where C mounts. //! //! 1. Online, before. //! 1. `cargo run -q -- --repo-signing-key-new > /signing.pub` mints the @@ -50,8 +53,7 @@ //! 2. `/toyos-root new /root-key` mints the root key `0600`. Write its //! fingerprint on the paper. //! 3. `cp /root-key /root-key && cmp /root-key /root-key`, and -//! `stat -f '%Su %Sp' /root-key /root-key` reads the user that ran -//! `new` and `-rw-------`, twice. +//! `stat -f %Sp /root-key /root-key` reads `-rw-------` twice. //! 4. `/toyos-root sign --key /root-key --signing /signing.pub //! --out /root.1.txt`. Both fingerprints it prints must be the paper's, //! and its expiry a year from today.