summaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-08-24 15:27:06 +0200
committersalmonik <salmonik@tuta.io>2026-08-24 15:27:06 +0200
commit8218fd584e4053848738a310258893e14c4ee10e (patch)
tree2c92997ba0b6aa260fe72c88190f5f8b590457cb /tests
parentTLS transport and games framework on client and server (diff)
downloadzagan-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.rs61
-rw-r--r--tests/tofu.rs139
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
3use std::path::PathBuf;
4
5use zagan_bet::client_database;
6use zagan_bet::server_database;
7
8fn 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]
19fn 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]
38fn 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]
51fn 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
7use std::path::PathBuf;
8use std::process::Command;
9
10use zagan_bet::client_database;
11use zagan_bet::tofu::{decide, spki_fingerprint, TrustDecision};
12
13/// Generate a self-signed certificate under target/ and return its DER bytes.
14fn 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.
59fn 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]
75fn 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]
85fn fingerprint_rejects_garbage() {
86 assert!(spki_fingerprint(b"not a certificate").is_err());
87}
88
89#[test]
90fn first_contact_trusts_and_records() {
91 let observed = "ab12cd34";
92 assert_eq!(decide(None, observed), TrustDecision::TrustNew);
93}
94
95#[test]
96fn matching_key_is_trusted() {
97 assert_eq!(
98 decide(Some("ab12cd34"), "ab12cd34"),
99 TrustDecision::Trusted
100 );
101}
102
103#[test]
104fn 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]
117fn 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}