From f30b1a519771ac49e2b03640e742fdb0a246bd81 Mon Sep 17 00:00:00 2001 From: salmonik Date: Thu, 29 Oct 2026 22:22:27 +0100 Subject: v2.4.0: key stored credentials by certificate fingerprint --- src/bin/client/connection.rs | 34 +++++++------ src/bin/client/repl.rs | 57 +++++++++++++++++---- src/client/database.rs | 116 +++++++++++++++++++++++++++++++++++++------ src/client/ui.rs | 4 +- 4 files changed, 169 insertions(+), 42 deletions(-) (limited to 'src') 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( // TOFU: persist the fingerprint on first contact. if !unsafe_mode { - if let Some(fp) = observed { + if let Some(fp) = observed.as_deref() { if database::get_fingerprint(conn, addr).is_none() { - database::save_fingerprint(conn, addr, &fp)?; - for line in first_contact_lines(addr, &fp) { + database::save_fingerprint(conn, addr, fp)?; + for line in first_contact_lines(addr, fp) { println!("{}", line); } } @@ -80,21 +80,25 @@ pub fn connect( elapsed.as_secs_f64() * 1000.0 ); - if let Some((username, password)) = database::get_credentials(conn, addr) { - let auth_msg = format!("AUTH {} {}", username, password); - tls.write_line(&auth_msg)?; - let mut auth_response = String::new(); - tls.read_line(&mut auth_response)?; - if auth_response.trim().starts_with("OK") { - println!("Authenticated as {}", username); - Ok((tls, Some(username))) - } else { + // Credentials are stored per server identity, so any address presenting + // this certificate key can log in with the same stored account. + let identity = observed + .clone() + .or_else(|| database::get_fingerprint(conn, addr)); + if let Some(identity) = identity { + if let Some((username, password)) = database::get_credentials(conn, &identity) { + let auth_msg = format!("AUTH {} {}", username, password); + tls.write_line(&auth_msg)?; + let mut auth_response = String::new(); + tls.read_line(&mut auth_response)?; + if auth_response.trim().starts_with("OK") { + println!("Authenticated as {}", username); + return Ok((tls, Some(username))); + } println!("Authentication failed: {}", auth_response.trim()); - Ok((tls, None)) } - } else { - Ok((tls, None)) } + Ok((tls, None)) } /// 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 { Effect::ForgetHost(target) => { self.commit_prompt_line(); match database::forget_host(&self.conn, &target) { - Ok(_) => println!("Cleared trust and stored credentials for {}.", target), + Ok(outcome) if outcome.credentials_removed => println!( + "Cleared trust and stored credentials for {}.", + target + ), + Ok(outcome) => { + println!("Cleared trust for {}.", target); + if !outcome.siblings.is_empty() { + println!( + "Stored credentials kept: this server is still reachable as {}.", + outcome.siblings.join(", ") + ); + } + } Err(error) => println!("Failed to forget host: {}", error), } } @@ -506,12 +518,17 @@ impl ClientState { let response = response.trim(); if response.starts_with("OK") { - if let Some(addr) = &self.addr { - if let Err(error) = - database::save_credentials(&self.conn, addr, &username, &hashed) - { - println!("Failed to save credentials: {}", error); + match self.trusted_identity() { + Some(identity) => { + if let Err(error) = + database::save_credentials(&self.conn, &identity, &username, &hashed) + { + println!("Failed to save credentials: {}", error); + } } + None => println!( + "Credentials not saved: no verified certificate identity for this host." + ), } self.core.set_logged_in(Some(username.clone())); println!("Logged in as {}", username); @@ -567,14 +584,27 @@ impl ClientState { return; } let hashed = format!("{:x}", Sha256::digest(password.as_bytes())); - if let Some(addr) = &self.addr { - match database::save_credentials(&self.conn, addr, &username, &hashed) { - Ok(_) => println!("Credentials saved for {}", addr), - Err(error) => println!("Failed to save credentials: {}", error), - } + let Some(identity) = self.trusted_identity() else { + println!( + "No verified certificate for this host. Connect first so its key can be trusted." + ); + return; + }; + match database::save_credentials(&self.conn, &identity, &username, &hashed) { + Ok(_) => println!( + "Credentials saved for server {} (every address with this key).", + short_fingerprint(&identity) + ), + Err(error) => println!("Failed to save credentials: {}", error), } } + /// Certificate identity of the current address, once it has been verified. + fn trusted_identity(&self) -> Option { + let addr = self.addr.as_deref()?; + database::get_fingerprint(&self.conn, addr) + } + fn list_hosts(&mut self) { let hosts = database::get_all_hosts(&self.conn); for line in host_lines(&hosts) { @@ -583,6 +613,11 @@ impl ClientState { } } +/// First few hex characters of a fingerprint, enough to tell servers apart. +fn short_fingerprint(fingerprint: &str) -> String { + fingerprint.chars().take(16).collect() +} + fn style_roulette_number(text: &str, number: u8) -> String { match roulette_color(number) { "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 @@ use rusqlite::Connection; +/// What `forget_host` did with the stored credential. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct ForgetOutcome { + /// Addresses that still present the same certificate key. + pub siblings: Vec, + /// True when no address for that server remained, so the credential was + /// dropped with it. + pub credentials_removed: bool, +} + pub fn init_db(conn: &Connection) { conn.execute( "CREATE TABLE IF NOT EXISTS hosts ( @@ -12,9 +22,15 @@ pub fn init_db(conn: &Connection) { .unwrap(); // Migration for databases created before TOFU trust. let _ = conn.execute("ALTER TABLE hosts ADD COLUMN fingerprint TEXT", []); + + // Credentials belong to a server identity (the certificate key), not to a + // single address, so every address that presents the same key shares them. + if has_column(conn, "credentials", "host") { + migrate_credentials_to_fingerprints(conn); + } conn.execute( "CREATE TABLE IF NOT EXISTS credentials ( - host TEXT PRIMARY KEY, + fingerprint TEXT PRIMARY KEY, username TEXT NOT NULL, password TEXT NOT NULL )", @@ -23,6 +39,42 @@ pub fn init_db(conn: &Connection) { .unwrap(); } +fn has_column(conn: &Connection, table: &str, column: &str) -> bool { + let Ok(mut statement) = conn.prepare(&format!("PRAGMA table_info({table})")) else { + return false; + }; + let Ok(rows) = statement.query_map([], |row| row.get::<_, String>(1)) else { + return false; + }; + rows.flatten().any(|name| name == column) +} + +/// Re-key host-bound credentials by the fingerprint of their host. +/// +/// Rows whose host was already forgotten have no identity to attach to and are +/// dropped; those addresses have to re-authenticate once. +fn migrate_credentials_to_fingerprints(conn: &Connection) { + let tx = conn.unchecked_transaction().unwrap(); + tx.execute_batch( + "DROP TABLE IF EXISTS credentials_by_identity; + CREATE TABLE credentials_by_identity ( + fingerprint TEXT PRIMARY KEY, + username TEXT NOT NULL, + password TEXT NOT NULL + ); + INSERT OR REPLACE INTO credentials_by_identity (fingerprint, username, password) + SELECT h.fingerprint, c.username, c.password + FROM credentials c + JOIN hosts h ON h.host = c.host + WHERE h.fingerprint IS NOT NULL + ORDER BY h.last_used ASC, h.host ASC; + DROP TABLE credentials; + ALTER TABLE credentials_by_identity RENAME TO credentials;", + ) + .unwrap(); + tx.commit().unwrap(); +} + pub fn get_last_host(conn: &Connection) -> Option { let mut stmt = conn .prepare("SELECT host FROM hosts ORDER BY last_used DESC LIMIT 1") @@ -44,24 +96,27 @@ pub fn save_host(conn: &Connection, host: &str) { .unwrap(); } +/// Store credentials for a server identity (certificate fingerprint). pub fn save_credentials( conn: &Connection, - host: &str, + fingerprint: &str, username: &str, password: &str, ) -> Result<(), Box> { conn.execute( - "INSERT OR REPLACE INTO credentials (host, username, password) VALUES (?1, ?2, ?3)", - (host, username, password), + "INSERT OR REPLACE INTO credentials (fingerprint, username, password) + VALUES (?1, ?2, ?3)", + (fingerprint, username, password), )?; Ok(()) } -pub fn get_credentials(conn: &Connection, host: &str) -> Option<(String, String)> { +/// Credentials for a server identity, regardless of which address reached it. +pub fn get_credentials(conn: &Connection, fingerprint: &str) -> Option<(String, String)> { let mut stmt = conn - .prepare("SELECT username, password FROM credentials WHERE host = ?1") + .prepare("SELECT username, password FROM credentials WHERE fingerprint = ?1") .unwrap(); - let mut rows = stmt.query([host]).unwrap(); + let mut rows = stmt.query([fingerprint]).unwrap(); if let Ok(Some(row)) = rows.next() { let username: String = row.get(0).unwrap(); let password: String = row.get(1).unwrap(); @@ -98,11 +153,43 @@ pub fn save_fingerprint( Ok(()) } -/// Remove all trust and credentials for a host ("forget this host"). -pub fn forget_host(conn: &Connection, host: &str) -> Result<(), rusqlite::Error> { +/// Addresses that present `fingerprint`, most recently used first. +pub fn hosts_with_fingerprint(conn: &Connection, fingerprint: &str) -> Vec { + let mut stmt = conn + .prepare("SELECT host FROM hosts WHERE fingerprint = ?1 ORDER BY last_used DESC") + .unwrap(); + let rows = stmt.query_map([fingerprint], |row| row.get::<_, String>(0)).unwrap(); + rows.filter_map(|row| row.ok()).collect() +} + +/// Remove trust for one address ("forget this host"). +/// +/// Credentials are shared by every address with the same certificate key, so +/// they are only dropped once the last such address is forgotten. +pub fn forget_host(conn: &Connection, host: &str) -> Result { + let fingerprint = get_fingerprint(conn, host); conn.execute("DELETE FROM hosts WHERE host = ?1", [host])?; - conn.execute("DELETE FROM credentials WHERE host = ?1", [host])?; - Ok(()) + + let Some(fingerprint) = fingerprint else { + return Ok(ForgetOutcome::default()); + }; + + let siblings = hosts_with_fingerprint(conn, &fingerprint); + if siblings.is_empty() { + conn.execute( + "DELETE FROM credentials WHERE fingerprint = ?1", + [&fingerprint], + )?; + return Ok(ForgetOutcome { + siblings, + credentials_removed: true, + }); + } + + Ok(ForgetOutcome { + siblings, + credentials_removed: false, + }) } /// List all remembered hosts with their fingerprints. @@ -110,8 +197,9 @@ pub fn get_all_hosts(conn: &Connection) -> Vec<(String, Option)> { let mut stmt = conn .prepare("SELECT host, fingerprint FROM hosts ORDER BY last_used DESC") .unwrap(); - let rows = stmt - .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) - .unwrap(); + let rows = stmt.query_map([], |row| { + Ok((row.get(0)?, row.get(1)?)) + }) + .unwrap(); rows.filter_map(|r| r.ok()).collect() } 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] = &[ CommandHelp { name: "setcred", usage: "setcred", - description: "set credentials for a host", + description: "set credentials for the server's certificate key", }, CommandHelp { name: "logout", @@ -73,7 +73,7 @@ const COMMANDS: &[CommandHelp] = &[ CommandHelp { name: "forget-host", usage: "forget-host [ip:port]", - description: "clear saved trust fingerprint and credentials", + description: "clear trust for one address (credentials stay while the key is in use)", }, CommandHelp { name: "hosts", -- cgit v1.2.3