diff options
| author | salmonik <salmonik@tuta.io> | 2026-08-24 15:27:06 +0200 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-08-24 15:27:06 +0200 |
| commit | 8218fd584e4053848738a310258893e14c4ee10e (patch) | |
| tree | 2c92997ba0b6aa260fe72c88190f5f8b590457cb /tests | |
| parent | TLS transport and games framework on client and server (diff) | |
| download | zagan-bet-8218fd584e4053848738a310258893e14c4ee10e.tar.gz zagan-bet-8218fd584e4053848738a310258893e14c4ee10e.zip | |
TOFU host trust: SPKI fingerprint pinning, tests, forget-host
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/storage.rs | 61 | ||||
| -rw-r--r-- | tests/tofu.rs | 139 |
2 files changed, 200 insertions, 0 deletions
diff --git a/tests/storage.rs b/tests/storage.rs new file mode 100644 index 0000000..275d47d --- /dev/null +++ b/tests/storage.rs | |||
| @@ -0,0 +1,61 @@ | |||
| 1 | //! Tests for storage layers that already exist. These must PASS. | ||
| 2 | |||
| 3 | use std::path::PathBuf; | ||
| 4 | |||
| 5 | use zagan_bet::client_database; | ||
| 6 | use zagan_bet::server_database; | ||
| 7 | |||
| 8 | fn tmp_db(name: &str) -> (String, rusqlite::Connection) { | ||
| 9 | let path = PathBuf::from(env!("CARGO_TARGET_TMPDIR")) | ||
| 10 | .join(format!("storage-{name}.db")) | ||
| 11 | .to_string_lossy() | ||
| 12 | .into_owned(); | ||
| 13 | let _ = std::fs::remove_file(&path); | ||
| 14 | let conn = rusqlite::Connection::open(&path).unwrap(); | ||
| 15 | (path, conn) | ||
| 16 | } | ||
| 17 | |||
| 18 | #[test] | ||
| 19 | fn client_credentials_roundtrip() { | ||
| 20 | let (_, conn) = tmp_db("creds"); | ||
| 21 | client_database::init_db(&conn); | ||
| 22 | |||
| 23 | assert_eq!(client_database::get_credentials(&conn, "h1"), None); | ||
| 24 | client_database::save_credentials(&conn, "h1", "alice", "pw").unwrap(); | ||
| 25 | assert_eq!( | ||
| 26 | client_database::get_credentials(&conn, "h1"), | ||
| 27 | Some(("alice".to_string(), "pw".to_string())) | ||
| 28 | ); | ||
| 29 | // Overwrite works. | ||
| 30 | client_database::save_credentials(&conn, "h1", "alice", "pw2").unwrap(); | ||
| 31 | assert_eq!( | ||
| 32 | client_database::get_credentials(&conn, "h1"), | ||
| 33 | Some(("alice".to_string(), "pw2".to_string())) | ||
| 34 | ); | ||
| 35 | } | ||
| 36 | |||
| 37 | #[test] | ||
| 38 | fn client_last_host_tracking() { | ||
| 39 | let (_, conn) = tmp_db("hosts"); | ||
| 40 | client_database::init_db(&conn); | ||
| 41 | |||
| 42 | assert_eq!(client_database::get_last_host(&conn), None); | ||
| 43 | client_database::save_host(&conn, "example.com:9000"); | ||
| 44 | assert_eq!( | ||
| 45 | client_database::get_last_host(&conn), | ||
| 46 | Some("example.com:9000".to_string()) | ||
| 47 | ); | ||
| 48 | } | ||
| 49 | |||
| 50 | #[test] | ||
| 51 | fn server_user_registration_and_auth_lookup() { | ||
| 52 | let (_, conn) = tmp_db("users"); | ||
| 53 | server_database::init_db(&conn); | ||
| 54 | |||
| 55 | server_database::add_user(&conn, "bob", "cafebabe").unwrap(); | ||
| 56 | assert_eq!( | ||
| 57 | server_database::get_hashed_password(&conn, "bob"), | ||
| 58 | Some("cafebabe".to_string()) | ||
| 59 | ); | ||
| 60 | assert_eq!(server_database::get_hashed_password(&conn, "nobody"), None); | ||
| 61 | } | ||
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 @@ | |||
| 1 | //! TOFU trust tests. | ||
| 2 | //! | ||
| 3 | //! These pin the not-yet-implemented TOFU behavior: every test here is | ||
| 4 | //! expected to FAIL until the corresponding feature lands in `src/tofu.rs` | ||
| 5 | //! and the client storage layer. | ||
| 6 | |||
| 7 | use std::path::PathBuf; | ||
| 8 | use std::process::Command; | ||
| 9 | |||
| 10 | use zagan_bet::client_database; | ||
| 11 | use zagan_bet::tofu::{decide, spki_fingerprint, TrustDecision}; | ||
| 12 | |||
| 13 | /// Generate a self-signed certificate under target/ and return its DER bytes. | ||
| 14 | fn make_cert(tag: &str) -> Vec<u8> { | ||
| 15 | let dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("tofu-certs"); | ||
| 16 | std::fs::create_dir_all(&dir).unwrap(); | ||
| 17 | let key = dir.join(format!("{tag}.key")); | ||
| 18 | let pem = dir.join(format!("{tag}.pem")); | ||
| 19 | if !pem.exists() { | ||
| 20 | let status = Command::new("openssl") | ||
| 21 | .args([ | ||
| 22 | "req", | ||
| 23 | "-x509", | ||
| 24 | "-newkey", | ||
| 25 | "rsa:2048", | ||
| 26 | "-keyout", | ||
| 27 | key.to_str().unwrap(), | ||
| 28 | "-out", | ||
| 29 | pem.to_str().unwrap(), | ||
| 30 | "-days", | ||
| 31 | "365", | ||
| 32 | "-nodes", | ||
| 33 | "-subj", | ||
| 34 | "/CN=zagan-test", | ||
| 35 | ]) | ||
| 36 | .status() | ||
| 37 | .expect("openssl must be available for tests"); | ||
| 38 | assert!(status.success(), "openssl failed"); | ||
| 39 | } | ||
| 40 | // Convert PEM to DER. | ||
| 41 | let status = Command::new("openssl") | ||
| 42 | .args([ | ||
| 43 | "x509", | ||
| 44 | "-in", | ||
| 45 | pem.to_str().unwrap(), | ||
| 46 | "-outform", | ||
| 47 | "DER", | ||
| 48 | "-out", | ||
| 49 | dir.join(format!("{tag}.der")).to_str().unwrap(), | ||
| 50 | ]) | ||
| 51 | .status() | ||
| 52 | .unwrap(); | ||
| 53 | assert!(status.success()); | ||
| 54 | std::fs::read(dir.join(format!("{tag}.der"))).unwrap() | ||
| 55 | } | ||
| 56 | |||
| 57 | /// Independent expected value computed with openssl, not with our code: | ||
| 58 | /// SHA-256 over the SPKI DER of the certificate. | ||
| 59 | fn openssl_spki_sha256(pem_path: &str) -> String { | ||
| 60 | let out = Command::new("sh") | ||
| 61 | .arg("-c") | ||
| 62 | .arg(format!( | ||
| 63 | "openssl x509 -in {pem} -pubkey -noout | \ | ||
| 64 | openssl pkey -pubin -outform DER | sha256sum", | ||
| 65 | pem = pem_path | ||
| 66 | )) | ||
| 67 | .output() | ||
| 68 | .unwrap(); | ||
| 69 | assert!(out.status.success()); | ||
| 70 | let s = String::from_utf8(out.stdout).unwrap(); | ||
| 71 | s.split_whitespace().next().unwrap().to_string() | ||
| 72 | } | ||
| 73 | |||
| 74 | #[test] | ||
| 75 | fn fingerprint_is_sha256_of_spki() { | ||
| 76 | let der = make_cert("primary"); | ||
| 77 | let dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("tofu-certs"); | ||
| 78 | let expected = openssl_spki_sha256(dir.join("primary.pem").to_str().unwrap()); | ||
| 79 | |||
| 80 | let fp = spki_fingerprint(&der).expect("must parse the test certificate"); | ||
| 81 | assert_eq!(fp, expected, "fingerprint must equal openssl's SPKI SHA-256"); | ||
| 82 | } | ||
| 83 | |||
| 84 | #[test] | ||
| 85 | fn fingerprint_rejects_garbage() { | ||
| 86 | assert!(spki_fingerprint(b"not a certificate").is_err()); | ||
| 87 | } | ||
| 88 | |||
| 89 | #[test] | ||
| 90 | fn first_contact_trusts_and_records() { | ||
| 91 | let observed = "ab12cd34"; | ||
| 92 | assert_eq!(decide(None, observed), TrustDecision::TrustNew); | ||
| 93 | } | ||
| 94 | |||
| 95 | #[test] | ||
| 96 | fn matching_key_is_trusted() { | ||
| 97 | assert_eq!( | ||
| 98 | decide(Some("ab12cd34"), "ab12cd34"), | ||
| 99 | TrustDecision::Trusted | ||
| 100 | ); | ||
| 101 | } | ||
| 102 | |||
| 103 | #[test] | ||
| 104 | fn changed_key_is_mismatch_with_details() { | ||
| 105 | let d = decide(Some("ab12cd34"), "ef56ab78"); | ||
| 106 | match d { | ||
| 107 | TrustDecision::Mismatch { expected, actual } => { | ||
| 108 | assert_eq!(expected, "ab12cd34"); | ||
| 109 | assert_eq!(actual, "ef56ab78"); | ||
| 110 | } | ||
| 111 | other => panic!("expected Mismatch, got {:?}", other), | ||
| 112 | } | ||
| 113 | } | ||
| 114 | |||
| 115 | /// Fingerprint persistence roundtrip in the client database. | ||
| 116 | #[test] | ||
| 117 | fn fingerprint_roundtrip_in_client_db() { | ||
| 118 | let db_path = PathBuf::from(env!("CARGO_TARGET_TMPDIR")) | ||
| 119 | .join("tofu-roundtrip.db") | ||
| 120 | .to_string_lossy() | ||
| 121 | .into_owned(); | ||
| 122 | let _ = std::fs::remove_file(&db_path); | ||
| 123 | let conn = rusqlite::Connection::open(&db_path).unwrap(); | ||
| 124 | client_database::init_db(&conn); | ||
| 125 | |||
| 126 | // Host starts unknown. | ||
| 127 | assert_eq!(client_database::get_fingerprint(&conn, "host1"), None); | ||
| 128 | |||
| 129 | // Save, read back. | ||
| 130 | client_database::save_fingerprint(&conn, "host1", "deadbeef").unwrap(); | ||
| 131 | assert_eq!( | ||
| 132 | client_database::get_fingerprint(&conn, "host1"), | ||
| 133 | Some("deadbeef".to_string()) | ||
| 134 | ); | ||
| 135 | |||
| 136 | // Forget clears it. | ||
| 137 | client_database::forget_host(&conn, "host1").unwrap(); | ||
| 138 | assert_eq!(client_database::get_fingerprint(&conn, "host1"), None); | ||
| 139 | } | ||
