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/client/database.rs | 116 +++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 102 insertions(+), 14 deletions(-) (limited to 'src/client/database.rs') 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() } -- cgit v1.2.3