src/fetch.rs
//! AIRGAP3 (transfer half) — move + **verify** the bundle across the gap.
//!
//! Design (`install-system.md` "verify on receive" + the airgap law): every byte
//! that crosses the gap is verified before it is trusted. The transfer itself
//! (USB / one-time relay / age-encrypted courier) is out-of-band — on the target
//! the bundle simply *appears* at a local path. [`verify_on_receipt`](crate::fetch::verify_on_receipt) is the
//! gate: it recomputes the bundle's content hashes against the
//! [`crate::archive::BundleManifest`] (**integrity**) and — when a
//! [`crate::archive::TrustRoot`] is pinned out-of-band — verifies the detached
//! [`crate::archive::BundleSignature`] (**authenticity**) against that root,
//! refusing to proceed on any mismatch.
//!
//! Integrity vs authenticity: recomputing `root_sha256` only proves the bundle is
//! internally consistent — an attacker who swaps the payload AND regenerates the
//! manifest passes it. Authenticity (the ed25519 signature over `root_sha256`,
//! checked against a PINNED CA key) is what proves *who* packed it. When a trust
//! root is configured the signature check is **mandatory** and a failure is a hard
//! refuse-to-install (mirroring the integrity bail); with no trust root the gate is
//! integrity-only (the backward-compatible mode).
//!
//! Crucially this does **zero network IO** — the airgap target never reaches out.
use std::path::Path;
use anyhow::{Context, Result};
use crate::archive::{Archive, Bundle, BundleManifest, TrustRoot, load_manifest, sha256_file};
/// The verification verdict for a received bundle.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifyReport {
/// The bundle's recomputed file hash matched the sidecar (whole-archive).
pub bundle_hash_ok: bool,
/// The manifest's internal root hash recomputes (no tampering of the list).
pub manifest_root_ok: bool,
/// Per-entry digests that did NOT match after extraction (empty = all good).
pub bad_entries: Vec<String>,
/// **Authenticity** verdict: `Some(true)`/`Some(false)` when a [`TrustRoot`] was
/// pinned (the signature verified / did not), or `None` when no trust root was
/// configured (integrity-only, backward-compatible mode — authenticity not
/// checked). A `Some(false)` fails the gate.
pub signature_ok: Option<bool>,
}
impl VerifyReport {
pub fn is_ok(&self) -> bool {
self.bundle_hash_ok
&& self.manifest_root_ok
&& self.bad_entries.is_empty()
// Authenticity: only enforced when a trust root was pinned. `None`
// (not configured) passes; `Some(false)` (pinned but failed) fails.
&& self.signature_ok != Some(false)
}
}
/// Verify a bundle's detached authenticity signature against a **pinned** trust
/// root. Every failure mode returns `false` (never a silent pass):
/// - no `<bundle>.sig.json` sidecar → an **unsigned** bundle under a configured
/// trust root is REJECTED;
/// - the signature attests a different `root_sha256` than this manifest → REJECTED
/// (content swapped + manifest regenerated but not re-signed);
/// - the embedded signer key does not MATCH the pinned key → REJECTED (signed by an
/// untrusted key);
/// - the ed25519 `verify_strict` under the pinned key fails → REJECTED;
/// - the `pki` feature is off (cannot run the crypto) → REJECTED.
fn signature_verifies(bundle: &Path, manifest: &BundleManifest, trust_root: &TrustRoot) -> bool {
let sig = match crate::archive::load_signature(bundle) {
Ok(s) => s,
Err(_) => return false,
};
// Bind the signature to THIS content (the integrity root it attests).
if sig.root_sha256 != manifest.root_sha256 {
return false;
}
// Pin: the bundle's embedded signer key must equal the out-of-band trust root.
if sig.signer_pubkey_hex != trust_root.ca_pubkey_hex {
return false;
}
// Cryptographic check under the PINNED key (never the embedded one).
#[cfg(feature = "pki")]
{
crate::pki::verify_bundle_signature(&sig, trust_root)
}
// Without `pki` we cannot verify ed25519 → refuse rather than silently pass.
#[cfg(not(feature = "pki"))]
{
let _ = (&sig, trust_root);
false
}
}
/// Verify a received bundle against its sidecar manifest. Recomputes:
/// 1. the manifest's own root hash (the entry list wasn't tampered),
/// 2. each entry's content hash by streaming it out of the archive.
///
/// `expected_bundle_sha` is the out-of-band-communicated whole-file SHA-256 (e.g.
/// printed on the USB label / sent over the secure channel); when supplied we
/// also confirm the archive file itself is byte-identical.
///
/// `trust_root` pins the CA whose signature makes the bundle **authentic**. When
/// `Some`, the detached [`crate::archive::BundleSignature`] is checked against it
/// (see `signature_verifies`) and the verdict lands in `signature_ok` — a failure
/// (including an *unsigned* bundle) makes the report not-ok. When `None`,
/// authenticity is not checked (integrity-only, backward-compatible).
pub fn verify_on_receipt(
bundle: &Path,
archive: &dyn Archive,
expected_bundle_sha: Option<&str>,
trust_root: Option<&TrustRoot>,
) -> Result<VerifyReport> {
let manifest: BundleManifest = match load_manifest(bundle) {
Ok(m) => m,
Err(e) => {
// SURFACE: fetch — a missing/unreadable sidecar manifest is an honest
// RED (a corrupt or truncated transfer never installs silently).
crate::surface_status!(
"skidbladnir/fetch",
"verify_on_receipt",
false,
"load sidecar manifest for {} failed: {e:#}",
bundle.display()
);
return Err(e).context("load bundle sidecar manifest");
}
};
let manifest_root_ok = manifest.verify_root();
let bundle_hash_ok = match expected_bundle_sha {
Some(expect) => sha256_file(bundle)? == expect,
// No out-of-band hash supplied → we can only attest the internal chain.
None => true,
};
// Re-derive each entry's hash from the actual archive contents in a SINGLE
// streaming pass (TarZstd decompresses the tar once) — never the old
// O(entries × total-bytes) per-entry full-archive rescan.
let bad = archive
.verify_entries(bundle, &manifest)
.context("verify bundle entries")?;
// Authenticity: only when a trust root is pinned (out-of-band). `None` → the
// report leaves `signature_ok = None` (integrity-only, backward-compatible).
let signature_ok = trust_root.map(|tr| signature_verifies(bundle, &manifest, tr));
let report = VerifyReport {
bundle_hash_ok,
manifest_root_ok,
bad_entries: bad,
signature_ok,
};
// SURFACE: fetch — the verify-on-receipt verdict, verbatim. `is_ok()` false is
// the RED the matrix reads when integrity OR authenticity fails; the detail
// carries which check tripped (root/hash/signature/bad-entry count).
crate::surface_status!(
"skidbladnir/fetch",
"verify_on_receipt",
report.is_ok(),
"bundle={} manifest_root_ok={} bundle_hash_ok={} signature_ok={:?} bad_entries={}",
bundle.display(),
report.manifest_root_ok,
report.bundle_hash_ok,
report.signature_ok,
report.bad_entries.len()
);
Ok(report)
}
/// Convenience: verify, and on success return the [`Bundle`] handle; on failure
/// an error naming what was wrong (so a partial/corrupt transfer never installs).
///
/// `trust_root` is threaded through to [`verify_on_receipt`]: pass `Some` to make
/// signature verification MANDATORY (a hard refuse-to-install on any authenticity
/// failure), `None` for the integrity-only backward-compatible gate.
pub fn fetch_and_verify(
bundle: &Path,
archive: &dyn Archive,
trust_root: Option<&TrustRoot>,
) -> Result<Bundle> {
let report = verify_on_receipt(bundle, archive, None, trust_root)?;
if !report.is_ok() {
anyhow::bail!(
"bundle verify FAILED (manifest_root_ok={}, bundle_hash_ok={}, signature_ok={:?}, \
bad_entries={:?}) — refusing to install a corrupt or unauthentic airgap bundle",
report.manifest_root_ok,
report.bundle_hash_ok,
report.signature_ok,
report.bad_entries
);
}
Ok(Bundle::new(bundle))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::archive::{Archive, EntryDigest, TarZstd, manifest_path};
use std::io::Write as _;
fn make_bundle() -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::tempdir().unwrap();
let staging = dir.path().join("stage");
std::fs::create_dir_all(staging.join("linux-x86_64")).unwrap();
let mut f = std::fs::File::create(staging.join("linux-x86_64/holger")).unwrap();
f.write_all(b"holger-bytes").unwrap();
let bundle = dir.path().join("lake.tar.zst");
TarZstd::new().pack(&staging, "20250505", &bundle).unwrap();
(dir, bundle)
}
/// LAW 1 (inject-assert): a faithfully-packed bundle verifies clean, and the
/// whole-file SHA matches the out-of-band value.
#[test]
fn faithful_bundle_verifies_on_receipt() {
let (_d, bundle) = make_bundle();
let eng = TarZstd::new();
let expected = sha256_file(&bundle).unwrap();
let report = verify_on_receipt(&bundle, &eng, Some(&expected), None).unwrap();
assert!(report.is_ok(), "{report:?}");
assert!(report.bundle_hash_ok && report.manifest_root_ok);
assert!(report.bad_entries.is_empty());
// No trust root pinned → authenticity not checked (integrity-only mode).
assert_eq!(report.signature_ok, None);
// fetch_and_verify returns the handle.
assert_eq!(fetch_and_verify(&bundle, &eng, None).unwrap().path, bundle);
}
/// A tampered sidecar manifest is REJECTED — install refuses to proceed.
#[test]
fn tampered_manifest_is_rejected() {
let (_d, bundle) = make_bundle();
// Corrupt the recorded digest in the sidecar.
let mut m = load_manifest(&bundle).unwrap();
m.entries[0].sha256 = "ff".repeat(32);
std::fs::write(manifest_path(&bundle), serde_json::to_vec(&m).unwrap()).unwrap();
let eng = TarZstd::new();
let report = verify_on_receipt(&bundle, &eng, None, None).unwrap();
assert!(!report.is_ok(), "tamper must be caught");
// Either the root no longer matches or the entry hash mismatches.
assert!(!report.manifest_root_ok || !report.bad_entries.is_empty());
assert!(fetch_and_verify(&bundle, &eng, None).is_err());
}
/// A WRONG out-of-band SHA fails the whole-file check (`bundle_hash_ok=false`)
/// while the internal chain stays intact — so the report is NOT ok. (The
/// no-OOB `fetch_and_verify` still passes; it only attests the internal chain.)
#[test]
fn wrong_out_of_band_sha_is_rejected() {
let (_d, bundle) = make_bundle();
let eng = TarZstd::new();
let report = verify_on_receipt(&bundle, &eng, Some(&"00".repeat(32)), None).unwrap();
assert!(!report.bundle_hash_ok, "wrong OOB hash rejected");
assert!(report.manifest_root_ok, "internal chain still intact");
assert!(report.bad_entries.is_empty());
assert!(!report.is_ok(), "a wrong OOB hash makes the report not-ok");
assert!(
fetch_and_verify(&bundle, &eng, None).is_ok(),
"fetch_and_verify (no OOB) attests only the internal chain"
);
}
/// `VerifyReport::is_ok` is the AND of all three checks — any single failing
/// field makes it false.
#[test]
fn verify_report_is_ok_logic() {
let ok = VerifyReport {
bundle_hash_ok: true,
manifest_root_ok: true,
bad_entries: vec![],
signature_ok: None,
};
assert!(ok.is_ok());
// A pinned-and-verified signature still passes.
assert!(
VerifyReport {
signature_ok: Some(true),
..ok.clone()
}
.is_ok()
);
for bad in [
VerifyReport {
bundle_hash_ok: false,
manifest_root_ok: true,
bad_entries: vec![],
signature_ok: None,
},
VerifyReport {
bundle_hash_ok: true,
manifest_root_ok: false,
bad_entries: vec![],
signature_ok: None,
},
VerifyReport {
bundle_hash_ok: true,
manifest_root_ok: true,
bad_entries: vec!["e".into()],
signature_ok: None,
},
// A pinned trust root whose signature FAILED fails the gate.
VerifyReport {
bundle_hash_ok: true,
manifest_root_ok: true,
bad_entries: vec![],
signature_ok: Some(false),
},
] {
assert!(!bad.is_ok(), "{bad:?}");
}
}
/// SINGLE-PASS verify: a manifest entry that is ABSENT from the bundle is
/// reported in `bad_entries` (a truncated/partial transfer) rather than
/// aborting the whole verify — the streaming pass walks the archive once and
/// flags whatever the manifest claimed but the bundle never delivered.
#[test]
fn manifest_entry_absent_from_bundle_is_reported_bad() {
let (_d, bundle) = make_bundle();
// Add a phantom entry the bundle does not actually contain.
let mut m = load_manifest(&bundle).unwrap();
m.entries.push(EntryDigest {
path: "linux-x86_64/ghost".into(),
sha256: "00".repeat(32),
len: 0,
});
std::fs::write(manifest_path(&bundle), serde_json::to_vec(&m).unwrap()).unwrap();
let eng = TarZstd::new();
// Single pass returns a report (not Err) that flags exactly the missing path.
let report = verify_on_receipt(&bundle, &eng, None, None).unwrap();
assert!(
report
.bad_entries
.contains(&"linux-x86_64/ghost".to_string()),
"phantom entry flagged: {report:?}"
);
assert!(!report.is_ok(), "a missing entry fails the gate");
assert!(fetch_and_verify(&bundle, &eng, None).is_err());
}
/// A missing sidecar manifest is an honest error — never a silent pass.
#[test]
fn missing_manifest_errors() {
let (_d, bundle) = make_bundle();
std::fs::remove_file(manifest_path(&bundle)).unwrap();
let eng = TarZstd::new();
assert!(verify_on_receipt(&bundle, &eng, None, None).is_err());
assert!(fetch_and_verify(&bundle, &eng, None).is_err());
}
// ─────────────────── AUTHENTICITY (signature) tests ───────────────────
// Gated on `pki` (default-on): signing needs the ed25519 CA. These are the
// provenance tests — integrity alone (above) does NOT prove who packed a bundle.
/// (a) A bundle SIGNED by the trusted CA verifies + would install: pinning the
/// CA's public key as the trust root yields `signature_ok = Some(true)`, the
/// report is ok, and `fetch_and_verify` (the gate the installer calls) returns
/// the handle.
#[cfg(feature = "pki")]
#[test]
fn signed_bundle_with_pinned_trust_root_verifies_and_installs() {
use crate::archive::TrustRoot;
use crate::pki::{MachineCa, sign_bundle};
let (_d, bundle) = make_bundle();
let eng = TarZstd::new();
let ca = MachineCa::from_seed("packer", [42u8; 32]);
sign_bundle(&bundle, &ca).unwrap();
// The installer pins the CA's key OUT-OF-BAND (not from the bundle).
let trust = TrustRoot::from_pubkey_bytes(&ca.verifying_key().to_bytes());
let report = verify_on_receipt(&bundle, &eng, None, Some(&trust)).unwrap();
assert_eq!(
report.signature_ok,
Some(true),
"trusted signature verifies"
);
assert!(report.is_ok(), "{report:?}");
assert_eq!(
fetch_and_verify(&bundle, &eng, Some(&trust)).unwrap().path,
bundle
);
}
/// (b) Content SWAPPED and the manifest REGENERATED (new `root_sha256`) but NOT
/// re-signed by the trusted key is REJECTED: the stale signature attests the old
/// root, which no longer matches — forging a fresh one needs the CA private key.
#[cfg(feature = "pki")]
#[test]
fn swapped_content_with_regenerated_manifest_but_stale_signature_is_rejected() {
use crate::archive::{Archive, TrustRoot};
use crate::pki::{MachineCa, sign_bundle};
use std::io::Write as _;
// Original bundle, signed by the trusted CA.
let (_d, bundle) = make_bundle();
let eng = TarZstd::new();
let ca = MachineCa::from_seed("packer", [7u8; 32]);
sign_bundle(&bundle, &ca).unwrap();
let trust = TrustRoot::from_pubkey_bytes(&ca.verifying_key().to_bytes());
// Attacker swaps the payload AND regenerates the manifest (new root) by
// repacking different content over the SAME bundle path — but cannot re-sign
// (no trusted private key), so the old `.sig.json` sidecar stays in place.
let evil = _d.path().join("evil");
std::fs::create_dir_all(evil.join("linux-x86_64")).unwrap();
let mut f = std::fs::File::create(evil.join("linux-x86_64/holger")).unwrap();
f.write_all(b"EVIL-payload-bytes").unwrap();
eng.pack(&evil, "20250505", &bundle).unwrap(); // rewrites bundle + manifest
// Integrity now recomputes fine (manifest regenerated), but authenticity fails.
let report = verify_on_receipt(&bundle, &eng, None, Some(&trust)).unwrap();
assert!(
report.manifest_root_ok,
"attacker regenerated a consistent manifest"
);
assert!(
report.bad_entries.is_empty(),
"content matches its (new) manifest"
);
assert_eq!(
report.signature_ok,
Some(false),
"stale signature no longer attests"
);
assert!(!report.is_ok(), "unauthentic bundle fails the gate");
assert!(fetch_and_verify(&bundle, &eng, Some(&trust)).is_err());
}
/// (c) A bundle signed by a DIFFERENT (untrusted) key is REJECTED: the embedded
/// signer key does not match the pinned trust root (and its signature would not
/// verify under the pin anyway).
#[cfg(feature = "pki")]
#[test]
fn bundle_signed_by_untrusted_key_is_rejected() {
use crate::archive::TrustRoot;
use crate::pki::{MachineCa, sign_bundle};
let (_d, bundle) = make_bundle();
let eng = TarZstd::new();
// The attacker signs with THEIR own CA…
let attacker = MachineCa::from_seed("attacker", [9u8; 32]);
sign_bundle(&bundle, &attacker).unwrap();
// …but the installer pins the LEGIT CA's key.
let legit = MachineCa::from_seed("packer", [1u8; 32]);
let trust = TrustRoot::from_pubkey_bytes(&legit.verifying_key().to_bytes());
let report = verify_on_receipt(&bundle, &eng, None, Some(&trust)).unwrap();
assert_eq!(
report.signature_ok,
Some(false),
"untrusted signer rejected"
);
assert!(!report.is_ok());
assert!(fetch_and_verify(&bundle, &eng, Some(&trust)).is_err());
}
/// (d) BACKWARD-COMPAT / policy: an UNSIGNED bundle is REJECTED when a trust root
/// is configured (no `.sig.json` → `signature_ok = Some(false)`), but PASSES when
/// no trust root is pinned (integrity-only, the backward-compatible mode). This
/// documents the explicit decision: a configured trust root makes signatures
/// mandatory.
#[cfg(feature = "pki")]
#[test]
fn unsigned_bundle_rejected_under_trust_root_but_ok_without() {
use crate::archive::{TrustRoot, signature_path};
use crate::pki::MachineCa;
let (_d, bundle) = make_bundle();
let eng = TarZstd::new();
assert!(!signature_path(&bundle).exists(), "bundle is unsigned");
// No trust root pinned → integrity-only gate passes (back-compat).
let no_pin = verify_on_receipt(&bundle, &eng, None, None).unwrap();
assert_eq!(no_pin.signature_ok, None);
assert!(no_pin.is_ok());
assert!(fetch_and_verify(&bundle, &eng, None).is_ok());
// A trust root pinned → an unsigned bundle is a hard refuse.
let ca = MachineCa::from_seed("packer", [5u8; 32]);
let trust = TrustRoot::from_pubkey_bytes(&ca.verifying_key().to_bytes());
let pinned = verify_on_receipt(&bundle, &eng, None, Some(&trust)).unwrap();
assert_eq!(
pinned.signature_ok,
Some(false),
"unsigned under a pin is rejected"
);
assert!(!pinned.is_ok());
assert!(fetch_and_verify(&bundle, &eng, Some(&trust)).is_err());
}
}