Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/Skidbladnir

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());
    }
}