//! Air-gapped bundle assembly and offline verification. //! //! A bundle is a directory of artifacts (image tarballs, compose files, //! migrations, models) plus `manifest.json`, `checksums.txt` (sha256 per //! artifact), and `checksums.sig` — a detached ed25519 signature over the //! checksum file. Verification needs only the public key: no network, no //! transparency log (keyless signing is internet-dependent and therefore //! disqualified for this target). use std::fs; use std::path::{Path, PathBuf}; use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; use sha2::{Digest, Sha256}; #[derive(Debug, thiserror::Error)] pub enum BundleError { #[error("io: {0}")] Io(String), #[error("bad key material: {0}")] Key(String), #[error("signature verification failed")] BadSignature, #[error("checksum mismatch for {0}")] ChecksumMismatch(String), #[error("bundle is missing {0}")] Missing(String), } fn io_err(e: std::io::Error) -> BundleError { BundleError::Io(e.to_string()) } /// Generates a signing keypair; the private key stays with the release /// pipeline, the public key ships to customers out of band. pub fn generate_keypair(private_path: &Path, public_path: &Path) -> Result<(), BundleError> { let signing = SigningKey::generate(&mut rand_core::OsRng); fs::write(private_path, hex::encode(signing.to_bytes())).map_err(io_err)?; fs::write(public_path, hex::encode(signing.verifying_key().to_bytes())).map_err(io_err)?; Ok(()) } /// Re-derives the public key from a signing key (release pipelines store /// only the private half in secrets). pub fn derive_public_key(private_path: &Path, public_path: &Path) -> Result<(), BundleError> { let signing = load_signing_key(private_path)?; fs::write(public_path, hex::encode(signing.verifying_key().to_bytes())).map_err(io_err)?; Ok(()) } fn load_signing_key(path: &Path) -> Result { let bytes: [u8; 32] = hex::decode(fs::read_to_string(path).map_err(io_err)?.trim()) .map_err(|e| BundleError::Key(e.to_string()))? .try_into() .map_err(|_| BundleError::Key("private key must be 32 bytes".into()))?; Ok(SigningKey::from_bytes(&bytes)) } fn load_verifying_key(path: &Path) -> Result { let bytes: [u8; 32] = hex::decode(fs::read_to_string(path).map_err(io_err)?.trim()) .map_err(|e| BundleError::Key(e.to_string()))? .try_into() .map_err(|_| BundleError::Key("public key must be 32 bytes".into()))?; VerifyingKey::from_bytes(&bytes).map_err(|e| BundleError::Key(e.to_string())) } fn sha256_file(path: &Path) -> Result { let bytes = fs::read(path).map_err(io_err)?; Ok(hex::encode(Sha256::digest(&bytes))) } /// Copies artifacts into `out`, writes the manifest, checksums every file, /// and signs the checksum list. `artifacts` are (source, bundle-relative /// destination) pairs. pub fn assemble( out: &Path, version: &str, artifacts: &[(PathBuf, String)], private_key: &Path, ) -> Result<(), BundleError> { fs::create_dir_all(out).map_err(io_err)?; let mut entries: Vec<(String, String)> = Vec::new(); for (source, destination) in artifacts { let target = out.join(destination); if let Some(parent) = target.parent() { fs::create_dir_all(parent).map_err(io_err)?; } fs::copy(source, &target).map_err(io_err)?; entries.push((destination.clone(), sha256_file(&target)?)); } let manifest = serde_json::json!({ "name": "clawmates", "version": version, "artifacts": entries.iter().map(|(p, h)| { serde_json::json!({"path": p, "sha256": h}) }).collect::>(), }); let manifest_path = out.join("manifest.json"); fs::write( &manifest_path, serde_json::to_vec_pretty(&manifest).unwrap(), ) .map_err(io_err)?; entries.push(("manifest.json".into(), sha256_file(&manifest_path)?)); let checksums: String = entries .iter() .map(|(path, hash)| format!("{hash} {path}\n")) .collect(); fs::write(out.join("checksums.txt"), &checksums).map_err(io_err)?; let key = load_signing_key(private_key)?; let signature: Signature = key.sign(checksums.as_bytes()); fs::write(out.join("checksums.sig"), hex::encode(signature.to_bytes())).map_err(io_err)?; Ok(()) } /// Fully offline verification: the signature over `checksums.txt`, then /// every listed artifact hash. This is what `install.sh` runs before any /// `docker load`. pub fn verify(bundle: &Path, public_key: &Path) -> Result { let checksums_path = bundle.join("checksums.txt"); if !checksums_path.is_file() { return Err(BundleError::Missing("checksums.txt".into())); } let checksums = fs::read_to_string(&checksums_path).map_err(io_err)?; let signature_hex = fs::read_to_string(bundle.join("checksums.sig")) .map_err(|_| BundleError::Missing("checksums.sig".into()))?; let signature_bytes: [u8; 64] = hex::decode(signature_hex.trim()) .map_err(|e| BundleError::Key(e.to_string()))? .try_into() .map_err(|_| BundleError::Key("signature must be 64 bytes".into()))?; let key = load_verifying_key(public_key)?; key.verify( checksums.as_bytes(), &Signature::from_bytes(&signature_bytes), ) .map_err(|_| BundleError::BadSignature)?; let mut verified = 0; for line in checksums.lines() { let Some((expected, path)) = line.split_once(" ") else { continue; }; let actual = sha256_file(&bundle.join(path)).map_err(|_| BundleError::Missing(path.to_owned()))?; if actual != expected { return Err(BundleError::ChecksumMismatch(path.to_owned())); } verified += 1; } Ok(verified) }