//! TOFU trust tests. //! //! These pin the not-yet-implemented TOFU behavior: every test here is //! expected to FAIL until the corresponding feature lands in `src/tofu.rs` //! and the client storage layer. use std::path::PathBuf; use std::process::Command; use zagan_bet::client_database; use zagan_bet::tofu::{decide, spki_fingerprint, TrustDecision}; /// Generate a self-signed certificate under target/ and return its DER bytes. fn make_cert(tag: &str) -> Vec { let dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("tofu-certs"); std::fs::create_dir_all(&dir).unwrap(); let key = dir.join(format!("{tag}.key")); let pem = dir.join(format!("{tag}.pem")); if !pem.exists() { let status = Command::new("openssl") .args([ "req", "-x509", "-newkey", "rsa:2048", "-keyout", key.to_str().unwrap(), "-out", pem.to_str().unwrap(), "-days", "365", "-nodes", "-subj", "/CN=zagan-test", ]) .status() .expect("openssl must be available for tests"); assert!(status.success(), "openssl failed"); } // Convert PEM to DER. let status = Command::new("openssl") .args([ "x509", "-in", pem.to_str().unwrap(), "-outform", "DER", "-out", dir.join(format!("{tag}.der")).to_str().unwrap(), ]) .status() .unwrap(); assert!(status.success()); std::fs::read(dir.join(format!("{tag}.der"))).unwrap() } /// Independent expected value computed with openssl, not with our code: /// SHA-256 over the SPKI DER of the certificate. fn openssl_spki_sha256(pem_path: &str) -> String { let out = Command::new("sh") .arg("-c") .arg(format!( "openssl x509 -in {pem} -pubkey -noout | \ openssl pkey -pubin -outform DER | sha256sum", pem = pem_path )) .output() .unwrap(); assert!(out.status.success()); let s = String::from_utf8(out.stdout).unwrap(); s.split_whitespace().next().unwrap().to_string() } #[test] fn fingerprint_is_sha256_of_spki() { let der = make_cert("primary"); let dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("tofu-certs"); let expected = openssl_spki_sha256(dir.join("primary.pem").to_str().unwrap()); let fp = spki_fingerprint(&der).expect("must parse the test certificate"); assert_eq!(fp, expected, "fingerprint must equal openssl's SPKI SHA-256"); } #[test] fn fingerprint_rejects_garbage() { assert!(spki_fingerprint(b"not a certificate").is_err()); } #[test] fn first_contact_trusts_and_records() { let observed = "ab12cd34"; assert_eq!(decide(None, observed), TrustDecision::TrustNew); } #[test] fn matching_key_is_trusted() { assert_eq!( decide(Some("ab12cd34"), "ab12cd34"), TrustDecision::Trusted ); } #[test] fn changed_key_is_mismatch_with_details() { let d = decide(Some("ab12cd34"), "ef56ab78"); match d { TrustDecision::Mismatch { expected, actual } => { assert_eq!(expected, "ab12cd34"); assert_eq!(actual, "ef56ab78"); } other => panic!("expected Mismatch, got {:?}", other), } } /// Fingerprint persistence roundtrip in the client database. #[test] fn fingerprint_roundtrip_in_client_db() { let db_path = PathBuf::from(env!("CARGO_TARGET_TMPDIR")) .join("tofu-roundtrip.db") .to_string_lossy() .into_owned(); let _ = std::fs::remove_file(&db_path); let conn = rusqlite::Connection::open(&db_path).unwrap(); client_database::init_db(&conn); // Host starts unknown. assert_eq!(client_database::get_fingerprint(&conn, "host1"), None); // Save, read back. client_database::save_fingerprint(&conn, "host1", "deadbeef").unwrap(); assert_eq!( client_database::get_fingerprint(&conn, "host1"), Some("deadbeef".to_string()) ); // Forget clears it. client_database::forget_host(&conn, "host1").unwrap(); assert_eq!(client_database::get_fingerprint(&conn, "host1"), None); }