From c25cf3d48a09d4f1f93dc3b4da1834bb424c1c68 Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 8 Nov 2026 11:45:20 +0100 Subject: [PATCH] hive-priv: fetch the swarm signature from attic before verifying a locally valid closure --- docs/4-implementation/swarm/builder.md | 7 +- .../trust-boundary/hive-c0re-privilege.md | 64 ++-- hive-priv/README.md | 4 +- hive-priv/src/closure.rs | 321 +++++++++++++++++- nix/script-tests/cache-pull-substitute.nix | 4 +- nix/script-tests/cache-pull-substitute.sh | 44 +++ 6 files changed, 393 insertions(+), 51 deletions(-) diff --git a/docs/4-implementation/swarm/builder.md b/docs/4-implementation/swarm/builder.md index b3761c82..39ed6639 100644 --- a/docs/4-implementation/swarm/builder.md +++ b/docs/4-implementation/swarm/builder.md @@ -609,7 +609,12 @@ refusal and a path no substituter has each fail with their own message. `Swap` then does the same lookup and pull, a no-op once the path is in the store, and gives `hive-priv` the store path to apply. `hive-priv` applies it only -when `nix store verify --sigs-needed 1` finds the swarm key's signature on it. A stopped agent skips +when `nix store verify --sigs-needed 1` finds the swarm key's signature on it. +A path that was already in the store, such as one the builder built through the +host's daemon, carries no signature there, and the pull leaves it so, so +`hive-priv` first runs `nix store copy-sigs` for that one path against the +cache in `/run/hyperhive-nix/cache.conf` (netrc `/run/hyperhive-nix/netrc`). The +verify still needs the swarm key's signature; a failed copy only gets logged. A stopped agent skips `Prebuild`, so `Swap` downloads it. A first deploy's `Create` node does the same and creates the container from that path. diff --git a/docs/4-implementation/trust-boundary/hive-c0re-privilege.md b/docs/4-implementation/trust-boundary/hive-c0re-privilege.md index 964eb0ba..73113a23 100644 --- a/docs/4-implementation/trust-boundary/hive-c0re-privilege.md +++ b/docs/4-implementation/trust-boundary/hive-c0re-privilege.md @@ -28,36 +28,36 @@ for every operation that genuinely requires root. **Narrow interface** — `PrivRequest` variants map 1:1 to specific known operations; there is no arbitrary command pass-through: -| Operation | What it runs | -| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `StartContainer` / `StopContainer` | `nixos-container start/stop ` | -| `KillContainer` | `machinectl kill --signal=SIGKILL` (`nixos-container` has no kill verb) | -| `StartFailureLog` | `journalctl _SYSTEMD_UNIT=container@h-.service --since @ -n 30 -o cat --no-pager`, fixed argv, 5 s timeout, output capped at 8 KiB; hive-priv clamps `` to the last hour. hive-c0re appends the lines to the failed start's build log, which only the dashboard serves. | -| `CreateContainer` / `UpdateContainer` | `nixos-container create --system-path `; for an update, `nix-env -p --set ` and a reload of the unit. `` must already be in the store with a signature by the swarm cache's key: hive-priv runs `nix store verify --no-contents --sigs-needed 1` with `trusted-public-keys` set to the key in root-owned `/run/hyperhive-priv/attic/public-key`, and refuses a path that's absent or that the key didn't sign. hive-priv builds nothing. The check doesn't bind the path to the agent whose pull produced it: hive-c0re could already pull any swarm-signed path and apply it to any agent, so the check bounds the same risk (one agent gets another's closure), not an arbitrary-path one | -| `RealiseClosure` | Kept for one release, for an older hive-c0re that still asks hive-priv to download. Downloads nothing: answers `Ok` when the path is in the store and signed by the swarm key (what `CreateContainer` needs), else `not available: `. The path must be `/nix/store/<32 chars>-` and the agent name valid | -| `RefreshCacheFacts` | `systemctl start hive-priv-cache-facts.service`, waited for up to 60 s. The request names no unit and has no field. hive-c0re asks for it when the fragment is missing or lacks a setting | -| `DestroyContainer` | `nixos-container destroy ` | -| `ListContainers` | `nixos-container list` | -| `ReloadGatewayNginx` | `systemctl reload/start/reset-failed nginx` (host unit; the unit name is hard-coded, not a parameter) | -| `WriteNspawnFlags` | write `/etc/nixos-containers/.conf` (bind-mount list, network isolation vars, and `--ephemeral` after the root's salvage). hive-priv first copies a stopped container's root to the agent's `work/`, then renames it aside (it deletes nothing), following no link the container could have planted; for an agent whose closure record sets `private_users` it also adds `--private-users=pick`, mounts every bind `owneridmap`, and once chowns the agent's `state`, `harness`, `work` and `claude` dirs (never `config`, following no link) to the data owner | -| `EnsureAgentSocketDir` | `mkdirat` `/run/hive-agent/` `0751 root:root` under an `O_NOFOLLOW` parent fd; leaves an existing directory alone, refuses anything else; for an agent whose closure record sets `private_users` it then chowns the dir to the data owner | -| `WriteResourceLimits` | write `CPUQuota=`/`MemoryMax=`/`CPUWeight=`/`IOWeight=` systemd drop-in for agent container, with an `ExecStartPre=` that runs `hive-priv check-rootfs ` (no-follow check of the root paths the start script touches; see persistence.md), and a GC root on the build it names | -| `RemoveServiceDropin` | remove `container@.service.d/` drop-in on destroy, and the GC root of the hive-priv build it named | -| `DaemonReload` | `systemctl daemon-reload` | -| `ControlInfraContainer` | `systemctl nginx.service` for the gateway — the `InfraContainer` enum is the allowlist, and serde rejects unknown names at the wire boundary (`hive-c0re`, the forge and the matrix have no variant, so no request can name them) | -| `SyncAgentTmpfiles` | unlink `/etc/tmpfiles.d/hyperhive-agents.conf` and return `Ok` | -| `SetAgentPaused` | create / remove the `//harness/paused` marker that parks an agent's turn loop | -| `EnsureAgentSubvolume` | `btrfs subvolume create /` for a new agent — no-op when the path exists or the filesystem isn't btrfs | -| `UpgradeAgentSubvolume` | migrate an existing plain state dir into a subvolume: create, `cp -a --reflink=auto`, atomic swap. Operator opt-in, and the caller stops the agent first | -| `DeleteAgentSubvolume` | `btrfs subvolume delete /` — purge path only, never a plain destroy | -| `EnsureBtrfsQuota` | `btrfs quota enable `. Operator opt-in — enabling forces a full rescan | -| `ReadSubvolumeUsage` | `btrfs qgroup show -f --raw /` | -| `SetSubvolumeQuota` | `btrfs qgroup limit /` | -| `MeasureAgentDisk` | `du -s -x -b -c -P` over `//state`, `//work` and `/var/lib/nixos-containers/h-`, killed after 10 min. hive-priv derives the paths from the name; the request carries none | -| `SnapshotAgentSubvolume` | `btrfs subvolume snapshot -r ` — the frozen point-in-time copy a migration streams from | -| `DeleteAgentSnapshot` | `btrfs subvolume delete ` | -| `SendAgentSnapshotToFile` | `btrfs send [-p ] ` into a bare filename under `MIGRATE_STAGING_ROOT`; refuses to overwrite an existing export | -| `SendAgentSnapshotToFd` | `btrfs send [-p ] ` into a **file descriptor passed with the request** (`SCM_RIGHTS`). hive-c0re connects to the peer and hands over the connected socket, so hive-priv never learns an address or protocol. hive-priv requires exactly one descriptor here, and refuses one that arrives alongside any other operation | +| Operation | What it runs | +| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `StartContainer` / `StopContainer` | `nixos-container start/stop ` | +| `KillContainer` | `machinectl kill --signal=SIGKILL` (`nixos-container` has no kill verb) | +| `StartFailureLog` | `journalctl _SYSTEMD_UNIT=container@h-.service --since @ -n 30 -o cat --no-pager`, fixed argv, 5 s timeout, output capped at 8 KiB; hive-priv clamps `` to the last hour. hive-c0re appends the lines to the failed start's build log, which only the dashboard serves. | +| `CreateContainer` / `UpdateContainer` | `nixos-container create --system-path `; for an update, `nix-env -p --set ` and a reload of the unit. `` must already be in the store with a signature by the swarm cache's key: hive-priv first runs `nix store copy-sigs --store daemon` for that path from the cache named in `/run/hyperhive-nix/cache.conf`, with the netrc beside it, which imports the cache's signature for a path that was already in the store (on a failure it logs a warning and the check goes on); it then runs `nix store verify --no-contents --sigs-needed 1` with `trusted-public-keys` set to the key in root-owned `/run/hyperhive-priv/attic/public-key`, and refuses a path that's absent or that the key didn't sign. hive-priv builds nothing. The check doesn't bind the path to the agent whose pull produced it: hive-c0re could already pull any swarm-signed path and apply it to any agent, so the check bounds the same risk (one agent gets another's closure), not an arbitrary-path one | +| `RealiseClosure` | Kept for one release, for an older hive-c0re that still asks hive-priv to download. Downloads nothing: answers `Ok` when the path is in the store and signed by the swarm key (what `CreateContainer` needs), else `not available: `. The path must be `/nix/store/<32 chars>-` and the agent name valid | +| `RefreshCacheFacts` | `systemctl start hive-priv-cache-facts.service`, waited for up to 60 s. The request names no unit and has no field. hive-c0re asks for it when the fragment is missing or lacks a setting | +| `DestroyContainer` | `nixos-container destroy ` | +| `ListContainers` | `nixos-container list` | +| `ReloadGatewayNginx` | `systemctl reload/start/reset-failed nginx` (host unit; the unit name is hard-coded, not a parameter) | +| `WriteNspawnFlags` | write `/etc/nixos-containers/.conf` (bind-mount list, network isolation vars, and `--ephemeral` after the root's salvage). hive-priv first copies a stopped container's root to the agent's `work/`, then renames it aside (it deletes nothing), following no link the container could have planted; for an agent whose closure record sets `private_users` it also adds `--private-users=pick`, mounts every bind `owneridmap`, and once chowns the agent's `state`, `harness`, `work` and `claude` dirs (never `config`, following no link) to the data owner | +| `EnsureAgentSocketDir` | `mkdirat` `/run/hive-agent/` `0751 root:root` under an `O_NOFOLLOW` parent fd; leaves an existing directory alone, refuses anything else; for an agent whose closure record sets `private_users` it then chowns the dir to the data owner | +| `WriteResourceLimits` | write `CPUQuota=`/`MemoryMax=`/`CPUWeight=`/`IOWeight=` systemd drop-in for agent container, with an `ExecStartPre=` that runs `hive-priv check-rootfs ` (no-follow check of the root paths the start script touches; see persistence.md), and a GC root on the build it names | +| `RemoveServiceDropin` | remove `container@.service.d/` drop-in on destroy, and the GC root of the hive-priv build it named | +| `DaemonReload` | `systemctl daemon-reload` | +| `ControlInfraContainer` | `systemctl nginx.service` for the gateway — the `InfraContainer` enum is the allowlist, and serde rejects unknown names at the wire boundary (`hive-c0re`, the forge and the matrix have no variant, so no request can name them) | +| `SyncAgentTmpfiles` | unlink `/etc/tmpfiles.d/hyperhive-agents.conf` and return `Ok` | +| `SetAgentPaused` | create / remove the `//harness/paused` marker that parks an agent's turn loop | +| `EnsureAgentSubvolume` | `btrfs subvolume create /` for a new agent — no-op when the path exists or the filesystem isn't btrfs | +| `UpgradeAgentSubvolume` | migrate an existing plain state dir into a subvolume: create, `cp -a --reflink=auto`, atomic swap. Operator opt-in, and the caller stops the agent first | +| `DeleteAgentSubvolume` | `btrfs subvolume delete /` — purge path only, never a plain destroy | +| `EnsureBtrfsQuota` | `btrfs quota enable `. Operator opt-in — enabling forces a full rescan | +| `ReadSubvolumeUsage` | `btrfs qgroup show -f --raw /` | +| `SetSubvolumeQuota` | `btrfs qgroup limit /` | +| `MeasureAgentDisk` | `du -s -x -b -c -P` over `//state`, `//work` and `/var/lib/nixos-containers/h-`, killed after 10 min. hive-priv derives the paths from the name; the request carries none | +| `SnapshotAgentSubvolume` | `btrfs subvolume snapshot -r ` — the frozen point-in-time copy a migration streams from | +| `DeleteAgentSnapshot` | `btrfs subvolume delete ` | +| `SendAgentSnapshotToFile` | `btrfs send [-p ] ` into a bare filename under `MIGRATE_STAGING_ROOT`; refuses to overwrite an existing export | +| `SendAgentSnapshotToFd` | `btrfs send [-p ] ` into a **file descriptor passed with the request** (`SCM_RIGHTS`). hive-c0re connects to the peer and hands over the connected socket, so hive-priv never learns an address or protocol. hive-priv requires exactly one descriptor here, and refuses one that arrives alongside any other operation | **Strings that reach root-loaded config** — `WriteResourceLimits` and `WriteNspawnFlags` copy request strings into a systemd drop-in and an env @@ -106,7 +106,9 @@ the daemon accepts: substituters. `--max-jobs 0` forbids a build, so a path no substituter has fails. - `hive-priv` verifies. Before `CreateContainer` or `UpdateContainer` applies a - toplevel, it runs `nix store verify --no-contents --sigs-needed 1` with + toplevel, it first imports the cache's signature for that path with + `nix store copy-sigs` (a path the host built carries none), then runs + `nix store verify --no-contents --sigs-needed 1` with `trusted-public-keys` set to the key in the root file, and refuses a path that is absent or that the key didn't sign. The daemon's trusted keys also include the host's other caches, so this check is the one that counts only the swarm's. diff --git a/hive-priv/README.md b/hive-priv/README.md index 970f7099..a93b10b4 100644 --- a/hive-priv/README.md +++ b/hive-priv/README.md @@ -47,7 +47,9 @@ Each module's `//!` header lists its ops. `container_toplevel_action` (in `src/container.rs`) applies a closure that is already in the store; hive-priv builds nothing. hive-c0re names the closure (`toplevel` on `CreateContainer` / `UpdateContainer`), pulled beforehand by -hive-c0re through the nix daemon. hive-priv applies a path only when +hive-c0re through the nix daemon. hive-priv first imports the cache's signature for the path with +`nix store copy-sigs` (from `/run/hyperhive-nix/cache.conf`; a failure logs a +warning), then applies it only when `nix store verify --no-contents --sigs-needed 1`, with the key in root-owned `/run/hyperhive-priv/attic/public-key`, finds the swarm cache's signature on it. It refuses a path that is absent, unsigned, signed by another key or not a store diff --git a/hive-priv/src/closure.rs b/hive-priv/src/closure.rs index 7fcb8a7c..e3512839 100644 --- a/hive-priv/src/closure.rs +++ b/hive-priv/src/closure.rs @@ -7,6 +7,11 @@ //! key, which `nix store verify` checks against the key in [`ROOT_FACTS_DIR`]. //! The key is a file a root unit keeps; nothing in the request, and nothing //! hive-c0re writes, chooses it. +//! +//! A path already in the store from a local build has no signature in the +//! store database, and the daemon's pull leaves it so. Before the check, +//! [`fetch_signature`] imports the signature the swarm cache holds for it; the +//! check still decides. use std::os::unix::fs::MetadataExt as _; use std::path::Path; @@ -24,6 +29,13 @@ const ROOT_FACTS_DIR: &str = "/run/hyperhive-priv/attic"; /// The root unit that reads this hive's cache facts from the store. const FACTS_UNIT: &str = "hive-priv-cache-facts.service"; +/// Where the facts unit writes `cache.conf` (the cache's address) and `netrc` +/// (the pull token), both root-only to write. +const CONF_DIR: &str = "/run/hyperhive-nix"; + +/// Wall-clock bound on one `nix store copy-sigs`, which fetches one narinfo. +const COPY_SIGS_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(1); + /// Wall-clock bound on one `nix store verify`, which reads a path's metadata. const VERIFY_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(1); @@ -116,12 +128,95 @@ fn verdict(code: Option, toplevel: &str, stderr: &str) -> Result<()> { } } -/// Whether `toplevel` is in the store with a signature by the key in `dir`. -async fn check_signed(spawn: &impl Spawn, dir: &Path, owner: u32, toplevel: &str) -> Result<()> { +/// The swarm cache's address in a `cache.conf` fragment: the value of its +/// `extra-substituters` line, when that is one word. +fn cache_url(fragment: &str) -> Option<&str> { + fragment + .lines() + .filter_map(|line| line.split_once('=')) + .find(|(name, _)| name.trim() == "extra-substituters") + .map(|(_, value)| value.trim()) + .filter(|url| !url.is_empty() && !url.contains(char::is_whitespace)) +} + +/// The `nix` arguments that import the signatures the cache at `url` holds for +/// `toplevel` into the daemon's store database, authenticating with `netrc`. +/// +/// - `--store daemon` is the store the verify reads, whatever the environment. +/// - No `--recursive`: only the path that is verified is touched. +fn copy_sigs_args(toplevel: &str, url: &str, netrc: &Path) -> Vec { + [ + "--extra-experimental-features", + "nix-command", + "store", + "copy-sigs", + "--store", + "daemon", + "--substituter", + url, + "--option", + "netrc-file", + &netrc.to_string_lossy(), + toplevel, + ] + .map(str::to_owned) + .into() +} + +/// Import the signature the swarm cache holds for `toplevel`, so that a path +/// already in the store without it can pass the verify. Nothing is trusted +/// here: the verify still needs the swarm key's signature. +/// +/// A failure (no `cache.conf`, the cache unreachable, no narinfo for the path) +/// is logged and the verify runs anyway: the path may already carry the +/// signature. +async fn fetch_signature(spawn: &impl Spawn, conf_dir: &Path, toplevel: &str) { + let conf = conf_dir.join("cache.conf"); + let fragment = match std::fs::read_to_string(&conf) { + Ok(text) => text, + Err(e) => { + tracing::warn!(path = %conf.display(), error = %e, "not fetching the swarm signature: cannot read the cache address"); + return; + } + }; + let Some(url) = cache_url(&fragment) else { + tracing::warn!(path = %conf.display(), "not fetching the swarm signature: no single extra-substituters address"); + return; + }; + let mut cmd = spawn.command("nix"); + cmd.args(copy_sigs_args(toplevel, url, &conf_dir.join("netrc"))); + match run_bounded(cmd, COPY_SIGS_TIMEOUT).await { + Ok(BoundedRun::Exited { status, .. }) if status.success() => {} + Ok(BoundedRun::Exited { status, stderr }) => tracing::warn!( + %toplevel, %status, stderr = stderr.lines().last().unwrap_or("").trim(), + "nix store copy-sigs failed; verifying without it" + ), + Ok(BoundedRun::TimedOut) => tracing::warn!( + %toplevel, + "nix store copy-sigs timed out after {}s; verifying without it", + COPY_SIGS_TIMEOUT.as_secs() + ), + Err(e) => tracing::warn!( + %toplevel, error = %format!("{e:#}"), + "could not run nix store copy-sigs; verifying without it" + ), + } +} + +/// Whether `toplevel` is in the store with a signature by the key in `dir`, +/// after importing the cache's signature for it ([`fetch_signature`]). +async fn check_signed( + spawn: &impl Spawn, + dir: &Path, + conf_dir: &Path, + owner: u32, + toplevel: &str, +) -> Result<()> { if !valid_closure_path(toplevel) { bail!("{toplevel:?} is not a store path"); } let key = swarm_key(dir, owner)?; + fetch_signature(spawn, conf_dir, toplevel).await; let mut cmd = spawn.command("nix"); cmd.args(verify_args(toplevel, &key)); match run_bounded(cmd, VERIFY_TIMEOUT) @@ -148,17 +243,26 @@ async fn check_signed(spawn: &impl Spawn, dir: &Path, owner: u32, toplevel: &str /// `toplevel` is not a store path, is not in the store, or carries no /// signature by the swarm cache's key, or the key cannot be read. pub(crate) async fn applicable_for<'a>(agent: &str, toplevel: &'a str) -> Result<&'a str> { - applicable_with(&RealSpawn, Path::new(ROOT_FACTS_DIR), 0, agent, toplevel).await + applicable_with( + &RealSpawn, + Path::new(ROOT_FACTS_DIR), + Path::new(CONF_DIR), + 0, + agent, + toplevel, + ) + .await } async fn applicable_with<'a>( spawn: &impl Spawn, dir: &Path, + conf_dir: &Path, owner: u32, agent: &str, toplevel: &'a str, ) -> Result<&'a str> { - match check_signed(spawn, dir, owner, toplevel).await { + match check_signed(spawn, dir, conf_dir, owner, toplevel).await { Ok(()) => Ok(toplevel), Err(e) => { tracing::error!( @@ -180,7 +284,15 @@ pub(crate) async fn realise_closure(agent: &str, store_path: &str) -> Result<(St if !valid_closure_path(store_path) { bail!("{store_path:?} is not a store path"); } - match check_signed(&RealSpawn, Path::new(ROOT_FACTS_DIR), 0, store_path).await { + match check_signed( + &RealSpawn, + Path::new(ROOT_FACTS_DIR), + Path::new(CONF_DIR), + 0, + store_path, + ) + .await + { Ok(()) => Ok(("present".to_owned(), String::new())), Err(e) => bail!( "not available: {e:#}; hive-priv no longer downloads, hive-c0re pulls through the nix daemon" @@ -254,26 +366,66 @@ mod tests { dir } - /// A `nix` that behaves like `nix store verify --sigs-needed 1` over a store - /// holding `PATH`, signed by `KEY` only: exit 0 for that path under that key, - /// 2 for that path under any other key, 4 for any other path. It exits 99 - /// for anything but the verify invocation, and logs its arguments. - fn nix(log: &Path) -> ScriptSpawn { + const URL: &str = "https://cache.example/swarm"; + + /// A `cache.conf` directory as the facts unit leaves it. + fn conf() -> PathBuf { + let dir = scratch(); + put( + &dir.join("cache.conf"), + &format!( + "extra-substituters = {URL}\nextra-trusted-public-keys = {KEY}\nnetrc-file = {}/netrc\n", + dir.display() + ), + 0o644, + ); + dir + } + + /// A `nix` over a store holding `PATH`, which `nix store verify + /// --sigs-needed 1` finds signed by `KEY` only: exit 0 for that path under + /// that key, 2 for that path under any other key, 4 for any other path. It + /// exits 99 for anything but the verify and copy-sigs invocations, and logs + /// its arguments. + /// + /// `signed` is whether `PATH` carries the signature before anything runs; + /// otherwise the verify exits 2 until a copy-sigs has exited 0, which + /// imports it. `copy_exit` is that copy-sigs' exit status. + fn nix_with(log: &Path, signed: bool, copy_exit: i32) -> ScriptSpawn { + let marker = log.with_extension("signed"); + if signed { + std::fs::write(&marker, "").expect("marker"); + } ScriptSpawn(format!( r#"echo "$@" >>{log} +if [ "$3 $4" = "store copy-sigs" ]; then + [ "$5 $6 $7" = "--store daemon --substituter" ] || exit 99 + [ "$8" = {URL} ] || exit 99 + [ "$9 ${{10}}" = "--option netrc-file" ] || exit 99 + [ "${{12}}" = {PATH} ] || exit 99 + [ {copy_exit} = 0 ] && touch {marker} + exit {copy_exit} +fi [ "$1 $2 $3 $4 $5" = "--extra-experimental-features nix-command store verify --no-contents" ] || exit 99 [ "$6 $7 $8 $9" = "--sigs-needed 1 --option trusted-public-keys" ] || exit 99 [ "${{11}}" = {PATH} ] || exit 4 [ "${{10}}" = {KEY} ] || exit 2 +[ -e {marker} ] || exit 2 exit 0"#, - log = log.display() + log = log.display(), + marker = marker.display() )) } + /// A `nix` whose path is signed already and whose copy-sigs succeeds. + fn nix(log: &Path) -> ScriptSpawn { + nix_with(log, true, 0) + } + #[tokio::test] async fn a_toplevel_signed_by_the_swarm_key_is_applied() { let log = scratch().join("calls"); - let got = applicable_with(&nix(&log), &facts(KEY), me(), "atlas", PATH) + let got = applicable_with(&nix(&log), &facts(KEY), &conf(), me(), "atlas", PATH) .await .expect("signed"); assert_eq!(got, PATH); @@ -300,7 +452,7 @@ exit 0"#, "has no signature by the swarm cache's key", ), ] { - let err = applicable_with(&nix(&log), &dir, me(), "atlas", path) + let err = applicable_with(&nix(&log), &dir, &conf(), me(), "atlas", path) .await .expect_err(case); let msg = format!("{err:#}"); @@ -316,7 +468,7 @@ exit 0"#, #[tokio::test] async fn a_failing_verify_refuses() { let spawn = ScriptSpawn("echo 'error: cannot connect' >&2; exit 1".to_owned()); - let err = applicable_with(&spawn, &facts(KEY), me(), "atlas", PATH) + let err = applicable_with(&spawn, &facts(KEY), &conf(), me(), "atlas", PATH) .await .expect_err("refused"); assert!(format!("{err:#}").contains("cannot connect"), "{err:#}"); @@ -333,14 +485,14 @@ exit 0"#, "/nix/store/x", &format!("{PATH}/bin"), ] { - let err = applicable_with(&yes, &facts(KEY), me(), "atlas", bad) + let err = applicable_with(&yes, &facts(KEY), &conf(), me(), "atlas", bad) .await .expect_err(bad); assert!(format!("{err:#}").contains("not a store path"), "{bad}"); } // Control: the same stand-in accepts a store path. assert!( - applicable_with(&yes, &facts(KEY), me(), "atlas", PATH) + applicable_with(&yes, &facts(KEY), &conf(), me(), "atlas", PATH) .await .is_ok() ); @@ -352,7 +504,7 @@ exit 0"#, async fn without_the_root_key_nothing_is_applied() { let log = scratch().join("calls"); let nowhere = scratch().join("nowhere"); - let err = applicable_with(&nix(&log), &nowhere, 0, "atlas", PATH) + let err = applicable_with(&nix(&log), &nowhere, &conf(), 0, "atlas", PATH) .await .expect_err("no key"); assert!(format!("{err:#}").contains("missing"), "{err:#}"); @@ -457,4 +609,139 @@ exit 0"#, .expect_err("timed out"); assert!(format!("{err:#}").contains("did not finish"), "{err:#}"); } + + fn calls(log: &Path) -> Vec { + std::fs::read_to_string(log) + .unwrap_or_default() + .lines() + .map(str::to_owned) + .collect() + } + + /// A path in the store without the swarm signature passes once the cache's + /// signature is imported, and the import runs before the verify, with the + /// cache's address and netrc from the facts unit's directory. + #[tokio::test] + async fn the_cache_signature_is_imported_before_the_verify() { + let log = scratch().join("calls"); + let conf = conf(); + applicable_with( + &nix_with(&log, false, 0), + &facts(KEY), + &conf, + me(), + "atlas", + PATH, + ) + .await + .expect("signed after the import"); + let calls = calls(&log); + assert_eq!(calls.len(), 2, "{calls:?}"); + assert_eq!( + calls[0], + format!( + "--extra-experimental-features nix-command store copy-sigs --store daemon --substituter {URL} --option netrc-file {}/netrc {PATH}", + conf.display() + ) + ); + assert!(calls[1].contains("store verify"), "{calls:?}"); + } + + /// A failed import is not the verdict: the verify runs and decides, so an + /// unsigned path is refused for its missing signature and a path that + /// already carries it is applied. + #[tokio::test] + async fn a_failed_import_still_runs_the_verify() { + let log = scratch().join("calls"); + let err = applicable_with( + &nix_with(&log, false, 1), + &facts(KEY), + &conf(), + me(), + "atlas", + PATH, + ) + .await + .expect_err("still unsigned"); + assert!( + format!("{err:#}").contains("has no signature by the swarm cache's key"), + "{err:#}" + ); + let calls = calls(&log); + assert!( + calls.len() == 2 && calls[0].contains("copy-sigs") && calls[1].contains("store verify"), + "{calls:?}" + ); + // Control: the same failing import leaves a signed path applicable. + let log = scratch().join("calls"); + applicable_with( + &nix_with(&log, true, 1), + &facts(KEY), + &conf(), + me(), + "atlas", + PATH, + ) + .await + .expect("signed already"); + } + + /// Without a readable `cache.conf` naming one address there is no import, + /// and the verify decides as before. + #[tokio::test] + async fn without_a_cache_address_the_verify_runs_alone() { + for (case, text) in [ + ("no file", None), + ("no setting", Some("netrc-file = /x\n")), + ("empty", Some("extra-substituters =\n")), + ( + "two addresses", + Some("extra-substituters = https://a https://b\n"), + ), + ] { + let conf = scratch(); + if let Some(text) = text { + put(&conf.join("cache.conf"), text, 0o644); + } + let log = scratch().join("calls"); + let err = applicable_with( + &nix_with(&log, false, 0), + &facts(KEY), + &conf, + me(), + "atlas", + PATH, + ) + .await + .expect_err(case); + assert!(format!("{err:#}").contains("has no signature"), "{case}"); + let calls = calls(&log); + assert!( + calls.len() == 1 && calls[0].contains("store verify"), + "{case}: {calls:?}" + ); + } + } + + #[test] + fn the_cache_address_is_the_extra_substituters_value() { + assert_eq!( + cache_url("extra-substituters = https://c.example/swarm\nnetrc-file = /n\n"), + Some("https://c.example/swarm") + ); + // `substituters` is the host's own list, not the swarm cache. + assert_eq!(cache_url("substituters = https://c.example\n"), None); + assert_eq!(cache_url(""), None); + } + + #[test] + fn the_import_is_for_the_one_path_and_never_trusts_a_key() { + let args = copy_sigs_args(PATH, URL, Path::new("/run/hyperhive-nix/netrc")); + assert_eq!(args.last().map(String::as_str), Some(PATH)); + assert!(!args.iter().any(|a| a == "--recursive" || a == "-r")); + assert!( + !args.iter().any(|a| a.contains("trusted-public-keys")), + "{args:?}" + ); + } } diff --git a/nix/script-tests/cache-pull-substitute.nix b/nix/script-tests/cache-pull-substitute.nix index cb8d7bd5..b4502010 100644 --- a/nix/script-tests/cache-pull-substitute.nix +++ b/nix/script-tests/cache-pull-substitute.nix @@ -2,7 +2,8 @@ # (`nix-store --realise --max-jobs 0`, hive-c0re/src/cache_pull.rs) # against two file caches: a swarm cache that has the top path and lacks a # dependency, and the host's own substituter that has the dependency. The cases -# are in ./cache-pull-substitute.sh. +# are in ./cache-pull-substitute.sh, which also runs hive-priv's +# `nix store copy-sigs` step against a path built in a store. { pkgs, ... }: pkgs.runCommand "hyperhive-script-test-cache-pull-substitute" { @@ -11,6 +12,7 @@ pkgs.runCommand "hyperhive-script-test-cache-pull-substitute" pkgs.coreutils pkgs.gnugrep ]; + HIVE_TEST_BASH = "${pkgs.bash}/bin/bash"; } '' ${pkgs.bash}/bin/bash ${./cache-pull-substitute.sh} diff --git a/nix/script-tests/cache-pull-substitute.sh b/nix/script-tests/cache-pull-substitute.sh index dab372d4..2fb51f62 100644 --- a/nix/script-tests/cache-pull-substitute.sh +++ b/nix/script-tests/cache-pull-substitute.sh @@ -8,6 +8,11 @@ # holds the dependency. The stores are chroot stores, so the realise runs in # this process where a host runs it in the daemon; the substitution logic is # the same. +# +# The last cases are hive-priv's step before its `nix store verify` +# (hive-priv/src/closure.rs): a path the host built itself is valid in its store +# without the swarm signature, the pull leaves it so, and `nix store copy-sigs` +# imports the signature from the cache. set -uo pipefail failures=0 @@ -91,5 +96,44 @@ conf plain "" if pull plain d2; then fail "realised without the swarm cache"; fi expect "no top path" "$(valid d2 "$top")" no +# A path built on the host is input-addressed and so, unlike the toFile paths +# above, has no content address that lets `nix store verify` pass it unsigned. +# The stores here share one store directory (`store=`), as cache and host do. +case="setup: a path built in the host store, and the swarm cache holding it signed" +: "${HIVE_TEST_BASH:=$(command -v bash)}" +host="local?store=$T/hs&state=$T/hs-state&log=$T/hs-log" +built=$(nix build --store "$host" --no-link --print-out-paths --impure --expr "derivation { name = \"agent-closure\"; system = builtins.currentSystem; builder = \"$HIVE_TEST_BASH\"; args = [ \"-c\" \"echo agent > \$out\" ]; }") +nix copy --from "$host" --to "file://$T/signed?store=$T/hs&secret-key=$T/swarm.sec" "$built" +swarm_key=$(cat "$T/swarm.pub") +other_key=$(cat "$T/up.pub") + +# verify : hive-priv's check, against the host store. +verify() { + nix store verify --store "$host" --no-contents --sigs-needed 1 --option trusted-public-keys "$1" "$built" >/dev/null 2>&1 + echo $? +} +# copy_sigs : the exact arguments of copy_sigs_args, bar the store. +copy_sigs() { + nix store copy-sigs --store "$host" --substituter "$1" --option netrc-file "$T/netrc" "$built" >/dev/null 2>"$T/err" +} + +case="control: the path is valid in the host store and the cache serves it signed" +expect "valid" "$(nix-store --store "$host" --check-validity "$built" && echo yes || echo no)" yes +expect "the cache has its narinfo" "$(if [ -e "$T/signed/$(basename "$built" | cut -c1-32).narinfo" ]; then echo yes; else echo no; fi)" yes + +case="a path valid in the host store without the signature fails the verify" +expect "verify exit" "$(verify "$swarm_key")" 2 + +case="a cache without the path imports nothing and the verify still refuses" +copy_sigs "file://$T/empty-cache?store=$T/hs" +expect "copy-sigs exit" $? 0 +expect "verify exit" "$(verify "$swarm_key")" 2 + +case="the cache serving the path imports the signature and the verify passes" +copy_sigs "file://$T/signed?store=$T/hs" +expect "copy-sigs exit" $? 0 +expect "verify exit" "$(verify "$swarm_key")" 0 +expect "another key still fails" "$(verify "$other_key")" 2 + echo "$count checks, $failures failed" [ "$failures" = 0 ]