From 8218fd584e4053848738a310258893e14c4ee10e Mon Sep 17 00:00:00 2001 From: salmonik Date: Mon, 24 Aug 2026 15:27:06 +0200 Subject: TOFU host trust: SPKI fingerprint pinning, tests, forget-host --- tests/tofu.rs | 139 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 tests/tofu.rs (limited to 'tests/tofu.rs') diff --git a/tests/tofu.rs b/tests/tofu.rs new file mode 100644 index 0000000..260e1b6 --- /dev/null +++ b/tests/tofu.rs @@ -0,0 +1,139 @@ +//! 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); +} -- cgit v1.2.3