diff --git a/Cargo.lock b/Cargo.lock index cc037e8b5dc..6023b0c32d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4385,6 +4385,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" @@ -4416,6 +4425,7 @@ name = "toyos-ssh" version = "0.1.0" dependencies = [ "ring", + "toyos-update", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index daaac56ffae..134540e77f3 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..8c5acffeff3 100644 --- a/build/publish.rs +++ b/build/publish.rs @@ -2,24 +2,33 @@ //! 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, 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. //! -//! **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 +39,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::RepoKey; 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 +77,52 @@ 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`. -pub fn publish(manifest: &Path, dir: &Path, key: &Key, now: u64) -> Result { +/// 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: &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(".")); 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 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 +157,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,43 +173,93 @@ 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 { +fn signed(body: String, role: Role, key: &RepoKey) -> Vec { let line = key.sign_document(role, body.as_bytes()); (body + &line).into_bytes() } -/// The newest root `dir` holds, walked from root 1 as the client walks. -fn current_root(dir: &Path) -> Result)>, String> { +/// `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; 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(); + 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) + )); + } + } + Ok(out) +} + +/// 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 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())), - } + 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 +347,30 @@ 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"; + const ROWS: &[(&str, u64, &str, &[u8])] = &[("gbae", 3, "gbae-v1.tar.gz", b"gbae bytes")]; + + fn key() -> RepoKey { + RepoKey::from_seed([3; 32]) + } - fn key() -> Key { - Key::throwaway_from([3; 32]) + /// Root 1 as the ceremony signs it, granting `signing`. + fn first_root(signing: &RepoKey) -> 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 +382,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 +400,146 @@ 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); + /// 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_nothing_is_written() { + let src = TempDir::new("publish-src"); + let out = TempDir::new("publish-repo"); + 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_publish_that_would_move_anything_back_is_refused_by_name() { + 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"); - 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 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!(listing(out.path()), Vec::::new(), "a refused publish wrote nothing"); + } - // 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 why = repo::Refused::Sequence { name: name.clone(), target: target.clone(), sequence: 2, floor: 3, holder }; + /// A received root past a gap in `roots/` is refused by name, before + /// anything is written. + #[test] + 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, ROWS), out.path(), &key(), NOW).unwrap(); + let before = listing(out.path()); + (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"); + } + + /// 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: "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:?}"); - let same = publish(&manifest(src.path(), &[("gbae", 3, "gbae-v1b.tar.gz", b"other")]), out.path(), &key(), NOW); - let why = repo::Refused::Reissued { name, target, sequence: 3, holder }; + 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: "gbae".into(), target: TRIPLE.into(), sequence: 3, holder: repo::Holder::Machine }; 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:?}"); + 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")); - let bad_name = publish(&manifest(src.path(), &[("Gbae", 1, "g.tar.gz", b"g")]), out.path(), &key(), NOW); + 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:?}"); + left_as_it_was(out.path(), &before, ×tamp); + } - 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"); + /// 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:?}"); + left_as_it_was(out.path(), &before, ×tamp); } /// A timestamp on disk naming targets 2^64 − 1 leaves no next version: @@ -413,7 +548,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 +567,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..7dcdc843a02 --- /dev/null +++ b/build/root/Cargo.toml @@ -0,0 +1,20 @@ +# A member of the host workspace (root `Cargo.toml`), built for the offline +# machine's Unix triple and run there alone. + +[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..ab2d951ab92 --- /dev/null +++ b/build/root/src/main.rs @@ -0,0 +1,268 @@ +//! `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, `0600`, into a file that must not exist yet: its +//! 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 +//! 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. 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, 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 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` +//! (`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 +//! 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 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. 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`, and +//! `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. +//! 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::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +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 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))) +} + +/// 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 = 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()))?; + 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"); + fill(create_new(out, 0o644)?, 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) + )) +} + +/// 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()), + }) +} + +/// `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())) +} + +#[cfg(test)] +mod tests { + use super::*; + use toyos_tmpdir::TempDir; + + /// 2026-10-09T00:00:00Z. + const NOW: u64 = 1_791_504_000; + const SIGNING: [u8; 32] = [3; 32]; + + /// 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, + } + + 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"); + std::fs::write(&signing, format!("{}\n", repo::public_line(&toyos_update::sig::public_of(&SIGNING)))).unwrap(); + Ceremony { _dir: dir, key, signing, out, said } + } + + /// The root key is minted `0600`. + #[test] + fn the_root_key_is_minted_0600() { + let c = ceremony(); + 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() { + 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 63906308eb6..0fd2b9c4426 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,13 @@ //! 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**, 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; @@ -46,6 +53,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 +102,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 @@ -105,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 { @@ -155,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) { @@ -166,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> { @@ -210,6 +200,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 +228,89 @@ fn read_owner_key(path: &Path) -> Result { )); } } - 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()))) + 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()?; - 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); + 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)) +} + +/// 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 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 { + refuse_wide(path)?; + let text = std::fs::read_to_string(path).map_err(|e| { + format!( + "the repository's signing key is not at {} ({e}). Mint one with `cargo run -- --repo-signing-key-new`", + path.display() + ) + })?; + 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. +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 +356,46 @@ mod tests { assert!(checkout_throwaway(dir.path()).err().unwrap().contains("is not 64 hex digits")); } + /// The repository's signing key is minted `0600`, with its directory. + #[test] + 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"); + mint_repo_key(&path).expect("minted"); + assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); + } + + /// 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(); + 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 /// contents are read; the same key `0600` reads. #[test] 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 db0fd2274c6..b147e1123f1 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,72 @@ mod tests { assert_eq!(parse_time(bad), None, "{bad}"); } } + + /// Root 1 as the ceremony signs it is self-signed. + #[test] + 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), .. } + )); + } + + /// 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. + #[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_line_that_is_not_an_ed25519_public_line_is_refused() { + let line = public_line(&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 d6f6f0de95d..8203b71505a 100644 --- a/lib/update/tests/repo_oracle.rs +++ b/lib/update/tests/repo_oracle.rs @@ -97,3 +97,26 @@ fn a_repository_ssh_keygen_signed_is_accepted_and_a_bent_one_is_not() { "{walked}" ); } + +/// The root OpenSSH signed is self-signed. +#[test] +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}"); +} + +/// 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"); +}