Watch
0
0
Fork
You've already forked hyperhive
0

hive-priv: fetch the swarm signature from attic before verifying a locally valid closure

This commit is contained in:
atlas 2026-11-08 11:45:20 +01:00
commit c25cf3d48a
2 changed files with 393 additions and 51 deletions

View file

@ -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

View file

@ -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<i32>, 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<String> {
[
"--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<String> {
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:?}"
);
}
}