summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-10-29 22:22:27 +0100
committersalmonik <salmonik@tuta.io>2026-10-29 22:22:27 +0100
commitf30b1a519771ac49e2b03640e742fdb0a246bd81 (patch)
treeb9a78039ba55a364e73df5dc174320dc8222f5c3
parentformatting (diff)
downloadzagan-bet-f30b1a519771ac49e2b03640e742fdb0a246bd81.tar.gz
zagan-bet-f30b1a519771ac49e2b03640e742fdb0a246bd81.zip
v2.4.0: key stored credentials by certificate fingerprint
-rw-r--r--AGENTS.md6
-rw-r--r--Cargo.toml2
-rw-r--r--docs/SERVER_SETUP.md10
-rw-r--r--docs/TESTING.md14
-rw-r--r--src/bin/client/connection.rs34
-rw-r--r--src/bin/client/repl.rs57
-rw-r--r--src/client/database.rs116
-rw-r--r--src/client/ui.rs4
-rw-r--r--tests/storage.rs113
9 files changed, 307 insertions, 49 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 3f96b7b..2a70ba2 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -74,6 +74,12 @@
74## TLS / TOFU 74## TLS / TOFU
75- See `src/tofu.rs` for the trust model. The fingerprint is over the 75- See `src/tofu.rs` for the trust model. The fingerprint is over the
76 certificate's SPKI, not the whole certificate. 76 certificate's SPKI, not the whole certificate.
77- Credentials are keyed by the SPKI fingerprint, not by address: one login
78 covers every address that presents the same key. Never look credentials up
79 by host.
80- `forget_host` removes one address. Drop the credential only when no address
81 with that fingerprint remains, or two addresses for one server would stop
82 sharing a login.
77- `save_host` must not wipe the fingerprint column. Use `ON CONFLICT ... 83- `save_host` must not wipe the fingerprint column. Use `ON CONFLICT ...
78 DO UPDATE SET last_used` — never `INSERT OR REPLACE`. 84 DO UPDATE SET last_used` — never `INSERT OR REPLACE`.
79 85
diff --git a/Cargo.toml b/Cargo.toml
index c064cc3..e0e17a7 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
1[package] 1[package]
2name = "zagan-bet" 2name = "zagan-bet"
3version = "2.3.2" 3version = "2.4.0"
4edition = "2024" 4edition = "2024"
5 5
6[dependencies] 6[dependencies]
diff --git a/docs/SERVER_SETUP.md b/docs/SERVER_SETUP.md
index 62d1a9c..37d55b1 100644
--- a/docs/SERVER_SETUP.md
+++ b/docs/SERVER_SETUP.md
@@ -69,11 +69,19 @@ On first contact the client prints the SPKI fingerprint and pins it. If you
69regenerate the server identity, the client will reject the new key; run 69regenerate the server identity, the client will reject the new key; run
70`forget-host` in the client if the change is intentional. 70`forget-host` in the client if the change is intentional.
71 71
72Credentials are stored against the pinned fingerprint, not the address. Log in
73once through `127.0.0.1:11525` and connecting through the machine's LAN address
74will authenticate automatically, because both addresses present the same key.
75`forget-host` removes one address; the stored login is only dropped when the
76last address for that key is forgotten.
77
72## Database 78## Database
73 79
74The server creates `server.db` in the current directory on first use. It holds 80The server creates `server.db` in the current directory on first use. It holds
75users, balances, and the per-game `plays` audit. The client keeps its own 81users, balances, and the per-game `plays` audit. The client keeps its own
76`client.db` for host fingerprints and stored credentials. 82`client.db` for host fingerprints and stored credentials. Databases from before
83the fingerprint-keyed credentials are migrated on startup; rows whose host was
84already forgotten cannot be re-keyed and require one fresh login.
77 85
78During development, delete both files to reset users, balances, history, and 86During development, delete both files to reset users, balances, history, and
79trust: 87trust:
diff --git a/docs/TESTING.md b/docs/TESTING.md
index 15b060c..fe302a9 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -124,6 +124,20 @@ Use an 80-column terminal because several output lines are width-sensitive.
124 one row with `winnings = 0`, `details.result = "abandoned"` and 124 one row with `winnings = 0`, `details.result = "abandoned"` and
125 `details.reason = "server_restart"`. 125 `details.reason = "server_restart"`.
126 126
127## Manual identity / credential checklist
128
1291. Connect through one address, `register` and `login`. `hosts` shows the
130 fingerprint.
1312. `disconnect`, then `connect` through another address that reaches the same
132 server (for example `10.0.0.100` when you first used `127.0.0.1`). The client
133 must print `Authenticated as <user>` without asking for a password.
1343. `hosts` must list both addresses on one line under one fingerprint.
1354. `forget-host <first address>` must say the credentials were kept and name
136 the remaining address. Connecting through the remaining address still
137 authenticates automatically.
1385. `forget-host <last address>` must say trust and credentials were cleared.
139 The next connect is a first contact and does not authenticate.
140
127## Manual server checks 141## Manual server checks
128 142
129- Register, log out, log in again (bcrypt path). 143- Register, log out, log in again (bcrypt path).
diff --git a/src/bin/client/connection.rs b/src/bin/client/connection.rs
index 44cfea9..ec5642c 100644
--- a/src/bin/client/connection.rs
+++ b/src/bin/client/connection.rs
@@ -56,10 +56,10 @@ pub fn connect(
56 56
57 // TOFU: persist the fingerprint on first contact. 57 // TOFU: persist the fingerprint on first contact.
58 if !unsafe_mode { 58 if !unsafe_mode {
59 if let Some(fp) = observed { 59 if let Some(fp) = observed.as_deref() {
60 if database::get_fingerprint(conn, addr).is_none() { 60 if database::get_fingerprint(conn, addr).is_none() {
61 database::save_fingerprint(conn, addr, &fp)?; 61 database::save_fingerprint(conn, addr, fp)?;
62 for line in first_contact_lines(addr, &fp) { 62 for line in first_contact_lines(addr, fp) {
63 println!("{}", line); 63 println!("{}", line);
64 } 64 }
65 } 65 }
@@ -80,21 +80,25 @@ pub fn connect(
80 elapsed.as_secs_f64() * 1000.0 80 elapsed.as_secs_f64() * 1000.0
81 ); 81 );
82 82
83 if let Some((username, password)) = database::get_credentials(conn, addr) { 83 // Credentials are stored per server identity, so any address presenting
84 let auth_msg = format!("AUTH {} {}", username, password); 84 // this certificate key can log in with the same stored account.
85 tls.write_line(&auth_msg)?; 85 let identity = observed
86 let mut auth_response = String::new(); 86 .clone()
87 tls.read_line(&mut auth_response)?; 87 .or_else(|| database::get_fingerprint(conn, addr));
88 if auth_response.trim().starts_with("OK") { 88 if let Some(identity) = identity {
89 println!("Authenticated as {}", username); 89 if let Some((username, password)) = database::get_credentials(conn, &identity) {
90 Ok((tls, Some(username))) 90 let auth_msg = format!("AUTH {} {}", username, password);
91 } else { 91 tls.write_line(&auth_msg)?;
92 let mut auth_response = String::new();
93 tls.read_line(&mut auth_response)?;
94 if auth_response.trim().starts_with("OK") {
95 println!("Authenticated as {}", username);
96 return Ok((tls, Some(username)));
97 }
92 println!("Authentication failed: {}", auth_response.trim()); 98 println!("Authentication failed: {}", auth_response.trim());
93 Ok((tls, None))
94 } 99 }
95 } else {
96 Ok((tls, None))
97 } 100 }
101 Ok((tls, None))
98} 102}
99 103
100/// Wrapper so TLS handshake failures surface as errors with their message 104/// Wrapper so TLS handshake failures surface as errors with their message
diff --git a/src/bin/client/repl.rs b/src/bin/client/repl.rs
index 9af4ea9..d973596 100644
--- a/src/bin/client/repl.rs
+++ b/src/bin/client/repl.rs
@@ -245,7 +245,19 @@ impl ClientState {
245 Effect::ForgetHost(target) => { 245 Effect::ForgetHost(target) => {
246 self.commit_prompt_line(); 246 self.commit_prompt_line();
247 match database::forget_host(&self.conn, &target) { 247 match database::forget_host(&self.conn, &target) {
248 Ok(_) => println!("Cleared trust and stored credentials for {}.", target), 248 Ok(outcome) if outcome.credentials_removed => println!(
249 "Cleared trust and stored credentials for {}.",
250 target
251 ),
252 Ok(outcome) => {
253 println!("Cleared trust for {}.", target);
254 if !outcome.siblings.is_empty() {
255 println!(
256 "Stored credentials kept: this server is still reachable as {}.",
257 outcome.siblings.join(", ")
258 );
259 }
260 }
249 Err(error) => println!("Failed to forget host: {}", error), 261 Err(error) => println!("Failed to forget host: {}", error),
250 } 262 }
251 } 263 }
@@ -506,12 +518,17 @@ impl ClientState {
506 let response = response.trim(); 518 let response = response.trim();
507 519
508 if response.starts_with("OK") { 520 if response.starts_with("OK") {
509 if let Some(addr) = &self.addr { 521 match self.trusted_identity() {
510 if let Err(error) = 522 Some(identity) => {
511 database::save_credentials(&self.conn, addr, &username, &hashed) 523 if let Err(error) =
512 { 524 database::save_credentials(&self.conn, &identity, &username, &hashed)
513 println!("Failed to save credentials: {}", error); 525 {
526 println!("Failed to save credentials: {}", error);
527 }
514 } 528 }
529 None => println!(
530 "Credentials not saved: no verified certificate identity for this host."
531 ),
515 } 532 }
516 self.core.set_logged_in(Some(username.clone())); 533 self.core.set_logged_in(Some(username.clone()));
517 println!("Logged in as {}", username); 534 println!("Logged in as {}", username);
@@ -567,14 +584,27 @@ impl ClientState {
567 return; 584 return;
568 } 585 }
569 let hashed = format!("{:x}", Sha256::digest(password.as_bytes())); 586 let hashed = format!("{:x}", Sha256::digest(password.as_bytes()));
570 if let Some(addr) = &self.addr { 587 let Some(identity) = self.trusted_identity() else {
571 match database::save_credentials(&self.conn, addr, &username, &hashed) { 588 println!(
572 Ok(_) => println!("Credentials saved for {}", addr), 589 "No verified certificate for this host. Connect first so its key can be trusted."
573 Err(error) => println!("Failed to save credentials: {}", error), 590 );
574 } 591 return;
592 };
593 match database::save_credentials(&self.conn, &identity, &username, &hashed) {
594 Ok(_) => println!(
595 "Credentials saved for server {} (every address with this key).",
596 short_fingerprint(&identity)
597 ),
598 Err(error) => println!("Failed to save credentials: {}", error),
575 } 599 }
576 } 600 }
577 601
602 /// Certificate identity of the current address, once it has been verified.
603 fn trusted_identity(&self) -> Option<String> {
604 let addr = self.addr.as_deref()?;
605 database::get_fingerprint(&self.conn, addr)
606 }
607
578 fn list_hosts(&mut self) { 608 fn list_hosts(&mut self) {
579 let hosts = database::get_all_hosts(&self.conn); 609 let hosts = database::get_all_hosts(&self.conn);
580 for line in host_lines(&hosts) { 610 for line in host_lines(&hosts) {
@@ -583,6 +613,11 @@ impl ClientState {
583 } 613 }
584} 614}
585 615
616/// First few hex characters of a fingerprint, enough to tell servers apart.
617fn short_fingerprint(fingerprint: &str) -> String {
618 fingerprint.chars().take(16).collect()
619}
620
586fn style_roulette_number(text: &str, number: u8) -> String { 621fn style_roulette_number(text: &str, number: u8) -> String {
587 match roulette_color(number) { 622 match roulette_color(number) {
588 "red" => text.red().to_string(), 623 "red" => text.red().to_string(),
diff --git a/src/client/database.rs b/src/client/database.rs
index e1cedaf..04a271a 100644
--- a/src/client/database.rs
+++ b/src/client/database.rs
@@ -1,5 +1,15 @@
1use rusqlite::Connection; 1use rusqlite::Connection;
2 2
3/// What `forget_host` did with the stored credential.
4#[derive(Debug, Default, PartialEq, Eq)]
5pub struct ForgetOutcome {
6 /// Addresses that still present the same certificate key.
7 pub siblings: Vec<String>,
8 /// True when no address for that server remained, so the credential was
9 /// dropped with it.
10 pub credentials_removed: bool,
11}
12
3pub fn init_db(conn: &Connection) { 13pub fn init_db(conn: &Connection) {
4 conn.execute( 14 conn.execute(
5 "CREATE TABLE IF NOT EXISTS hosts ( 15 "CREATE TABLE IF NOT EXISTS hosts (
@@ -12,9 +22,15 @@ pub fn init_db(conn: &Connection) {
12 .unwrap(); 22 .unwrap();
13 // Migration for databases created before TOFU trust. 23 // Migration for databases created before TOFU trust.
14 let _ = conn.execute("ALTER TABLE hosts ADD COLUMN fingerprint TEXT", []); 24 let _ = conn.execute("ALTER TABLE hosts ADD COLUMN fingerprint TEXT", []);
25
26 // Credentials belong to a server identity (the certificate key), not to a
27 // single address, so every address that presents the same key shares them.
28 if has_column(conn, "credentials", "host") {
29 migrate_credentials_to_fingerprints(conn);
30 }
15 conn.execute( 31 conn.execute(
16 "CREATE TABLE IF NOT EXISTS credentials ( 32 "CREATE TABLE IF NOT EXISTS credentials (
17 host TEXT PRIMARY KEY, 33 fingerprint TEXT PRIMARY KEY,
18 username TEXT NOT NULL, 34 username TEXT NOT NULL,
19 password TEXT NOT NULL 35 password TEXT NOT NULL
20 )", 36 )",
@@ -23,6 +39,42 @@ pub fn init_db(conn: &Connection) {
23 .unwrap(); 39 .unwrap();
24} 40}
25 41
42fn has_column(conn: &Connection, table: &str, column: &str) -> bool {
43 let Ok(mut statement) = conn.prepare(&format!("PRAGMA table_info({table})")) else {
44 return false;
45 };
46 let Ok(rows) = statement.query_map([], |row| row.get::<_, String>(1)) else {
47 return false;
48 };
49 rows.flatten().any(|name| name == column)
50}
51
52/// Re-key host-bound credentials by the fingerprint of their host.
53///
54/// Rows whose host was already forgotten have no identity to attach to and are
55/// dropped; those addresses have to re-authenticate once.
56fn migrate_credentials_to_fingerprints(conn: &Connection) {
57 let tx = conn.unchecked_transaction().unwrap();
58 tx.execute_batch(
59 "DROP TABLE IF EXISTS credentials_by_identity;
60 CREATE TABLE credentials_by_identity (
61 fingerprint TEXT PRIMARY KEY,
62 username TEXT NOT NULL,
63 password TEXT NOT NULL
64 );
65 INSERT OR REPLACE INTO credentials_by_identity (fingerprint, username, password)
66 SELECT h.fingerprint, c.username, c.password
67 FROM credentials c
68 JOIN hosts h ON h.host = c.host
69 WHERE h.fingerprint IS NOT NULL
70 ORDER BY h.last_used ASC, h.host ASC;
71 DROP TABLE credentials;
72 ALTER TABLE credentials_by_identity RENAME TO credentials;",
73 )
74 .unwrap();
75 tx.commit().unwrap();
76}
77
26pub fn get_last_host(conn: &Connection) -> Option<String> { 78pub fn get_last_host(conn: &Connection) -> Option<String> {
27 let mut stmt = conn 79 let mut stmt = conn
28 .prepare("SELECT host FROM hosts ORDER BY last_used DESC LIMIT 1") 80 .prepare("SELECT host FROM hosts ORDER BY last_used DESC LIMIT 1")
@@ -44,24 +96,27 @@ pub fn save_host(conn: &Connection, host: &str) {
44 .unwrap(); 96 .unwrap();
45} 97}
46 98
99/// Store credentials for a server identity (certificate fingerprint).
47pub fn save_credentials( 100pub fn save_credentials(
48 conn: &Connection, 101 conn: &Connection,
49 host: &str, 102 fingerprint: &str,
50 username: &str, 103 username: &str,
51 password: &str, 104 password: &str,
52) -> Result<(), Box<dyn std::error::Error>> { 105) -> Result<(), Box<dyn std::error::Error>> {
53 conn.execute( 106 conn.execute(
54 "INSERT OR REPLACE INTO credentials (host, username, password) VALUES (?1, ?2, ?3)", 107 "INSERT OR REPLACE INTO credentials (fingerprint, username, password)
55 (host, username, password), 108 VALUES (?1, ?2, ?3)",
109 (fingerprint, username, password),
56 )?; 110 )?;
57 Ok(()) 111 Ok(())
58} 112}
59 113
60pub fn get_credentials(conn: &Connection, host: &str) -> Option<(String, String)> { 114/// Credentials for a server identity, regardless of which address reached it.
115pub fn get_credentials(conn: &Connection, fingerprint: &str) -> Option<(String, String)> {
61 let mut stmt = conn 116 let mut stmt = conn
62 .prepare("SELECT username, password FROM credentials WHERE host = ?1") 117 .prepare("SELECT username, password FROM credentials WHERE fingerprint = ?1")
63 .unwrap(); 118 .unwrap();
64 let mut rows = stmt.query([host]).unwrap(); 119 let mut rows = stmt.query([fingerprint]).unwrap();
65 if let Ok(Some(row)) = rows.next() { 120 if let Ok(Some(row)) = rows.next() {
66 let username: String = row.get(0).unwrap(); 121 let username: String = row.get(0).unwrap();
67 let password: String = row.get(1).unwrap(); 122 let password: String = row.get(1).unwrap();
@@ -98,11 +153,43 @@ pub fn save_fingerprint(
98 Ok(()) 153 Ok(())
99} 154}
100 155
101/// Remove all trust and credentials for a host ("forget this host"). 156/// Addresses that present `fingerprint`, most recently used first.
102pub fn forget_host(conn: &Connection, host: &str) -> Result<(), rusqlite::Error> { 157pub fn hosts_with_fingerprint(conn: &Connection, fingerprint: &str) -> Vec<String> {
158 let mut stmt = conn
159 .prepare("SELECT host FROM hosts WHERE fingerprint = ?1 ORDER BY last_used DESC")
160 .unwrap();
161 let rows = stmt.query_map([fingerprint], |row| row.get::<_, String>(0)).unwrap();
162 rows.filter_map(|row| row.ok()).collect()
163}
164
165/// Remove trust for one address ("forget this host").
166///
167/// Credentials are shared by every address with the same certificate key, so
168/// they are only dropped once the last such address is forgotten.
169pub fn forget_host(conn: &Connection, host: &str) -> Result<ForgetOutcome, rusqlite::Error> {
170 let fingerprint = get_fingerprint(conn, host);
103 conn.execute("DELETE FROM hosts WHERE host = ?1", [host])?; 171 conn.execute("DELETE FROM hosts WHERE host = ?1", [host])?;
104 conn.execute("DELETE FROM credentials WHERE host = ?1", [host])?; 172
105 Ok(()) 173 let Some(fingerprint) = fingerprint else {
174 return Ok(ForgetOutcome::default());
175 };
176
177 let siblings = hosts_with_fingerprint(conn, &fingerprint);
178 if siblings.is_empty() {
179 conn.execute(
180 "DELETE FROM credentials WHERE fingerprint = ?1",
181 [&fingerprint],
182 )?;
183 return Ok(ForgetOutcome {
184 siblings,
185 credentials_removed: true,
186 });
187 }
188
189 Ok(ForgetOutcome {
190 siblings,
191 credentials_removed: false,
192 })
106} 193}
107 194
108/// List all remembered hosts with their fingerprints. 195/// List all remembered hosts with their fingerprints.
@@ -110,8 +197,9 @@ pub fn get_all_hosts(conn: &Connection) -> Vec<(String, Option<String>)> {
110 let mut stmt = conn 197 let mut stmt = conn
111 .prepare("SELECT host, fingerprint FROM hosts ORDER BY last_used DESC") 198 .prepare("SELECT host, fingerprint FROM hosts ORDER BY last_used DESC")
112 .unwrap(); 199 .unwrap();
113 let rows = stmt 200 let rows = stmt.query_map([], |row| {
114 .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) 201 Ok((row.get(0)?, row.get(1)?))
115 .unwrap(); 202 })
203 .unwrap();
116 rows.filter_map(|r| r.ok()).collect() 204 rows.filter_map(|r| r.ok()).collect()
117} 205}
diff --git a/src/client/ui.rs b/src/client/ui.rs
index 4ab0759..9d65087 100644
--- a/src/client/ui.rs
+++ b/src/client/ui.rs
@@ -43,7 +43,7 @@ const COMMANDS: &[CommandHelp] = &[
43 CommandHelp { 43 CommandHelp {
44 name: "setcred", 44 name: "setcred",
45 usage: "setcred", 45 usage: "setcred",
46 description: "set credentials for a host", 46 description: "set credentials for the server's certificate key",
47 }, 47 },
48 CommandHelp { 48 CommandHelp {
49 name: "logout", 49 name: "logout",
@@ -73,7 +73,7 @@ const COMMANDS: &[CommandHelp] = &[
73 CommandHelp { 73 CommandHelp {
74 name: "forget-host", 74 name: "forget-host",
75 usage: "forget-host [ip:port]", 75 usage: "forget-host [ip:port]",
76 description: "clear saved trust fingerprint and credentials", 76 description: "clear trust for one address (credentials stay while the key is in use)",
77 }, 77 },
78 CommandHelp { 78 CommandHelp {
79 name: "hosts", 79 name: "hosts",
diff --git a/tests/storage.rs b/tests/storage.rs
index b6e1369..19936c2 100644
--- a/tests/storage.rs
+++ b/tests/storage.rs
@@ -20,21 +20,124 @@ fn client_credentials_roundtrip() {
20 let (_, conn) = tmp_db("creds"); 20 let (_, conn) = tmp_db("creds");
21 client_database::init_db(&conn); 21 client_database::init_db(&conn);
22 22
23 assert_eq!(client_database::get_credentials(&conn, "h1"), None); 23 let identity = "5a15c712230831cb";
24 client_database::save_credentials(&conn, "h1", "alice", "pw").unwrap(); 24 assert_eq!(client_database::get_credentials(&conn, identity), None);
25 client_database::save_credentials(&conn, identity, "alice", "pw").unwrap();
25 assert_eq!( 26 assert_eq!(
26 client_database::get_credentials(&conn, "h1"), 27 client_database::get_credentials(&conn, identity),
27 Some(("alice".to_string(), "pw".to_string())) 28 Some(("alice".to_string(), "pw".to_string()))
28 ); 29 );
29 // Overwrite works. 30 // Overwrite works.
30 client_database::save_credentials(&conn, "h1", "alice", "pw2").unwrap(); 31 client_database::save_credentials(&conn, identity, "alice", "pw2").unwrap();
31 assert_eq!( 32 assert_eq!(
32 client_database::get_credentials(&conn, "h1"), 33 client_database::get_credentials(&conn, identity),
33 Some(("alice".to_string(), "pw2".to_string())) 34 Some(("alice".to_string(), "pw2".to_string()))
34 ); 35 );
35} 36}
36 37
37#[test] 38#[test]
39fn credentials_belong_to_the_certificate_not_the_address() {
40 let (_, conn) = tmp_db("creds-identity");
41 client_database::init_db(&conn);
42
43 // Two addresses for one server, one address for another.
44 client_database::save_host(&conn, "10.0.0.100:11525");
45 client_database::save_host(&conn, "127.0.0.1:11525");
46 client_database::save_host(&conn, "other.example:11525");
47 client_database::save_fingerprint(&conn, "10.0.0.100:11525", "server-a").unwrap();
48 client_database::save_fingerprint(&conn, "127.0.0.1:11525", "server-a").unwrap();
49 client_database::save_fingerprint(&conn, "other.example:11525", "server-b").unwrap();
50
51 client_database::save_credentials(&conn, "server-a", "alice", "pw").unwrap();
52
53 assert_eq!(
54 client_database::get_credentials(&conn, "server-a"),
55 Some(("alice".to_string(), "pw".to_string())),
56 "one login covers every address with the same key"
57 );
58 assert_eq!(
59 client_database::get_credentials(&conn, "server-b"),
60 None,
61 "a different key must not inherit the credentials"
62 );
63}
64
65#[test]
66fn forgetting_one_address_keeps_the_shared_credentials() {
67 let (_, conn) = tmp_db("creds-forget");
68 client_database::init_db(&conn);
69
70 client_database::save_host(&conn, "10.0.0.100:11525");
71 client_database::save_host(&conn, "127.0.0.1:11525");
72 client_database::save_fingerprint(&conn, "10.0.0.100:11525", "server-a").unwrap();
73 client_database::save_fingerprint(&conn, "127.0.0.1:11525", "server-a").unwrap();
74 client_database::save_credentials(&conn, "server-a", "alice", "pw").unwrap();
75
76 let outcome = client_database::forget_host(&conn, "10.0.0.100:11525").unwrap();
77 assert!(!outcome.credentials_removed);
78 assert_eq!(outcome.siblings, vec!["127.0.0.1:11525".to_string()]);
79 assert_eq!(
80 client_database::get_credentials(&conn, "server-a"),
81 Some(("alice".to_string(), "pw".to_string())),
82 "the server is still reachable, so the login is still useful"
83 );
84
85 let outcome = client_database::forget_host(&conn, "127.0.0.1:11525").unwrap();
86 assert!(outcome.credentials_removed);
87 assert!(outcome.siblings.is_empty());
88 assert_eq!(client_database::get_credentials(&conn, "server-a"), None);
89}
90
91#[test]
92fn legacy_host_keyed_credentials_are_rekeyed_by_fingerprint() {
93 let (_, conn) = tmp_db("creds-migrate");
94 conn.execute_batch(
95 "CREATE TABLE hosts (
96 host TEXT PRIMARY KEY,
97 last_used DATETIME DEFAULT CURRENT_TIMESTAMP,
98 fingerprint TEXT
99 );
100 CREATE TABLE credentials (
101 host TEXT PRIMARY KEY,
102 username TEXT NOT NULL,
103 password TEXT NOT NULL
104 );
105 INSERT INTO hosts (host, last_used, fingerprint)
106 VALUES ('a:1', '2020-01-01 00:00:00', 'server-a');
107 INSERT INTO hosts (host, last_used, fingerprint)
108 VALUES ('b:2', '2021-01-01 00:00:00', 'server-a');
109 INSERT INTO hosts (host, last_used, fingerprint)
110 VALUES ('c:3', '2022-01-01 00:00:00', NULL);
111 INSERT INTO credentials VALUES ('a:1', 'alice', 'old');
112 INSERT INTO credentials VALUES ('b:2', 'alice', 'recent');
113 INSERT INTO credentials VALUES ('c:3', 'carol', 'untrusted');",
114 )
115 .unwrap();
116
117 client_database::init_db(&conn);
118
119 assert_eq!(
120 client_database::get_credentials(&conn, "server-a"),
121 Some(("alice".to_string(), "recent".to_string())),
122 "the most recently used address wins when legacy rows disagree"
123 );
124 assert_eq!(
125 client_database::get_credentials(&conn, "carol"),
126 None,
127 "a host with no trusted key cannot be re-keyed"
128 );
129
130 // The old table is gone: the column set is the fingerprint schema.
131 let mut statement = conn.prepare("PRAGMA table_info(credentials)").unwrap();
132 let columns: Vec<String> = statement
133 .query_map([], |row| row.get(1))
134 .unwrap()
135 .filter_map(|row| row.ok())
136 .collect();
137 assert_eq!(columns, vec!["fingerprint", "username", "password"]);
138}
139
140#[test]
38fn client_last_host_tracking() { 141fn client_last_host_tracking() {
39 let (_, conn) = tmp_db("hosts"); 142 let (_, conn) = tmp_db("hosts");
40 client_database::init_db(&conn); 143 client_database::init_db(&conn);