diff --git a/cli/src/main.rs b/cli/src/main.rs index e3f0ee8..a4e202d 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -170,8 +170,9 @@ fn main() { /// reports the distinction; only the CLI knows the command. fn hint(error: Option<&fumi::error::Error>) { match error { - Some(fumi::error::Error::NotPinned { host }) => eprintln!( - "obtain the key from the operator through a trusted channel, then:\n fumi trust {host} " + Some(fumi::error::Error::NotPinned { host, port }) => eprintln!( + "obtain the key from the operator through a trusted channel, then:\n fumi trust {} ", + fumi::address::trust_label(host, *port) ), Some(fumi::error::Error::KeyChanged { address, offered, .. }) => { if let Ok(addr) = Address::parse(address) { @@ -358,21 +359,30 @@ fn trust(store: &Store, host: &str, key_b32: &str, force: bool) -> Result<()> { v.len() )) })?; - // A pin is per server, and the reference client strips any :port the - // same way its address grammar does: at the first colon. - let host = host.split(':').next().unwrap_or(host); - match store.server_pin(host)? { + // A pin is scoped per server and port (address::trust_label): the port + // splits from the right so a bare host means the default port, and an + // explicit default port normalizes onto the same bare row existing + // stores already hold. + let (host, port) = match host.rsplit_once(':') { + Some((h, p)) => match p.parse::() { + Ok(port) => (h, port), + Err(_) => (host, fumi::address::DEFAULT_PORT), + }, + None => (host, fumi::address::DEFAULT_PORT), + }; + match store.server_pin(host, port)? { Some(old) if old != key && !force => { return Err(anyhow!(format!( - "{host} is already pinned to {}; use --force to replace", + "{} is already pinned to {}; use --force to replace", + fumi::address::trust_label(host, port), b32(&old) )) .into()); } _ => {} } - store.pin_server(host, &key)?; - println!("pinned {host} {}", b32(&key)); + store.pin_server(host, port, &key)?; + println!("pinned {} {}", fumi::address::trust_label(host, port), b32(&key)); Ok(()) } diff --git a/core/src/address.rs b/core/src/address.rs index 97aaf04..c48ab0e 100644 --- a/core/src/address.rs +++ b/core/src/address.rs @@ -5,6 +5,22 @@ use crate::crypto::{b32, unb32, KEY_LEN}; use crate::error::Error; pub const DEFAULT_PORT: u16 = 1961; + +/// The trust label of a host: the bare host on the default port, `host:port` +/// on any other. One rule for the store's pin keys and the trust errors, so +/// a pin and its error always name the same trust object. The fallback is +/// asymmetric by design: the default port inherits the legacy bare-host pin, +/// and a non-default port never falls back to it — a second domain on one +/// host earns its own trust on first use instead of a mismatch against its +/// sibling's key. +pub fn trust_label(host: &str, port: u16) -> String { + if port == DEFAULT_PORT { + host.to_string() + } else { + format!("{host}:{port}") + } +} + /// A Reticulum destination hash, as printed by every RNS tool: 32 lowercase /// hexadecimal characters (RNS.md sec 13.2). pub const RNS_HASH_HEX: usize = 32; diff --git a/core/src/client.rs b/core/src/client.rs index 9d58c85..ce65554 100644 --- a/core/src/client.rs +++ b/core/src/client.rs @@ -79,10 +79,11 @@ pub fn connect( ) -> Result { match addr.scheme { Scheme::Tcp => { - let pinned = store.server_pin(&addr.host)?; + let pinned = store.server_pin(&addr.host, addr.port)?; if pinned.is_none() && require_pin { return Err(Error::NotPinned { host: addr.host.clone(), + port: addr.port, }); } // The dial hint lets a host that resolves DNS itself (an @@ -94,6 +95,19 @@ pub fn connect( pinned, timeout, )?; + // Pin enforcement lives here, one layer above the transport: the + // identity host names the trust object even when a dial hint + // routed the packets elsewhere (sec 4). + if let Some(pinned) = pinned { + if !ct_eq(&pinned, transport.server_static()) { + return Err(Error::PinMismatch { + host: addr.host.clone(), + port: addr.port, + pinned, + presented: *transport.server_static(), + }); + } + } let unpinned_static = if pinned.is_none() { Some(*transport.server_static()) } else { diff --git a/core/src/error.rs b/core/src/error.rs index 4270e37..5ef7904 100644 --- a/core/src/error.rs +++ b/core/src/error.rs @@ -26,12 +26,14 @@ pub enum Error { UnknownStatus(u8, String), /// The host has no pinned server key, so the session cannot be /// authenticated (sec 4). A GUI routes this to pinning; an auth failure - /// is terminal. - NotPinned { host: String }, + /// is terminal. The port scopes the pin (address::trust_label). + NotPinned { host: String, port: u16 }, /// A pinned server presented a different key (sec 4): a hard abort, the - /// pin is the entire trust model. + /// pin is the entire trust model. The port scopes the pin + /// (address::trust_label). PinMismatch { host: String, + port: u16, pinned: [u8; KEY_LEN], presented: [u8; KEY_LEN], }, @@ -89,14 +91,20 @@ impl fmt::Display for Error { Self::UnknownStatus(status, what) => { write!(f, "{what} failed: unknown status {status}") } - Self::NotPinned { host } => write!(f, "no pinned key for {host}"), + Self::NotPinned { host, port } => write!( + f, + "no pinned key for {}", + crate::address::trust_label(host, *port) + ), Self::PinMismatch { host, + port, pinned, presented, } => write!( f, - "{host} presented a different key than the one pinned\n pinned: {}\n presented: {}", + "{} presented a different key than the one pinned\n pinned: {}\n presented: {}", + crate::address::trust_label(host, *port), b32(pinned), b32(presented) ), diff --git a/core/src/export.rs b/core/src/export.rs index 645acac..9fd7b59 100644 --- a/core/src/export.rs +++ b/core/src/export.rs @@ -249,11 +249,21 @@ pub fn import(store: &Store, master: &[u8; KEY_LEN], text: &str) -> Result = unb32(key).ok().and_then(|k| k.try_into().ok()); + // A backup pin key is a bare host (the default port) or host:port, + // scoping exactly as the live store does; the port splits from the + // right, so a host without a valid port suffix stays whole. + let (host, port) = match host.rsplit_once(':') { + Some((h, p)) => match p.parse::() { + Ok(port) => (h.to_string(), port), + Err(_) => (host.clone(), crate::address::DEFAULT_PORT), + }, + None => (host.clone(), crate::address::DEFAULT_PORT), + }; match key { None => summary.malformed += 1, - Some(key) => match store.server_pin(host)? { + Some(key) => match store.server_pin(&host, port)? { None => { - store.pin_server(host, &key)?; + store.pin_server(&host, port, &key)?; summary.pins_added += 1; } Some(existing) if existing != key => summary.pins_conflicted += 1, @@ -337,7 +347,7 @@ mod tests { fn seeded_store() -> (Store, [u8; KEY_LEN]) { let store = Store::open_in_memory().unwrap(); let master = [7u8; KEY_LEN]; - store.pin_server("example.org", &[1u8; KEY_LEN]).unwrap(); + store.pin_server("example.org", crate::address::DEFAULT_PORT, &[1u8; KEY_LEN]).unwrap(); store .save_contact("alice@example.org", &[2u8; KEY_LEN], true) .unwrap(); @@ -366,7 +376,7 @@ mod tests { assert_eq!(summary.contacts_added, 1); assert_eq!(summary.malformed, 0); - assert_eq!(fresh.server_pin("example.org").unwrap(), Some([1u8; KEY_LEN])); + assert_eq!(fresh.server_pin("example.org", crate::address::DEFAULT_PORT).unwrap(), Some([1u8; KEY_LEN])); let (key, verified) = fresh.contact("alice@example.org").unwrap().unwrap(); assert_eq!(key, [2u8; KEY_LEN]); assert!(verified); @@ -395,13 +405,13 @@ mod tests { let file = export(&store, &master).unwrap(); let mine = Store::open_in_memory().unwrap(); - mine.pin_server("example.org", &[6u8; KEY_LEN]).unwrap(); + mine.pin_server("example.org", crate::address::DEFAULT_PORT, &[6u8; KEY_LEN]).unwrap(); mine.save_contact("alice@example.org", &[7u8; KEY_LEN], false) .unwrap(); let summary = import(&mine, &master, &file).unwrap(); assert_eq!(summary.pins_conflicted, 1); assert_eq!(summary.contacts_conflicted, 1); - assert_eq!(mine.server_pin("example.org").unwrap(), Some([6u8; KEY_LEN])); + assert_eq!(mine.server_pin("example.org", crate::address::DEFAULT_PORT).unwrap(), Some([6u8; KEY_LEN])); let (key, _) = mine.contact("alice@example.org").unwrap().unwrap(); assert_eq!(key, [7u8; KEY_LEN]); } diff --git a/core/src/store.rs b/core/src/store.rs index a13258c..08ed98b 100644 --- a/core/src/store.rs +++ b/core/src/store.rs @@ -276,9 +276,12 @@ impl Store { Ok(()) } - pub fn server_pin(&self, host: &str) -> Result, Error> { + pub fn server_pin(&self, host: &str, port: u16) -> Result, Error> { Ok(self - .one::>>("SELECT static FROM servers WHERE host = ?1", &[&host])? + .one::>>( + "SELECT static FROM servers WHERE host = ?1", + &[&crate::address::trust_label(host, port)], + )? .flatten() .and_then(|k| k.try_into().ok())) } @@ -286,9 +289,11 @@ impl Store { /// Removes a pin: the settings screen's unpin, the inverse of /// `pin_server`. The next session against that host is trust on first /// use again (sec 4, sec 8). - pub fn unpin_server(&self, host: &str) -> Result<(), Error> { - self.db() - .execute("DELETE FROM servers WHERE host = ?1", params![host])?; + pub fn unpin_server(&self, host: &str, port: u16) -> Result<(), Error> { + self.db().execute( + "DELETE FROM servers WHERE host = ?1", + params![&crate::address::trust_label(host, port)], + )?; Ok(()) } @@ -307,12 +312,20 @@ impl Store { .collect()) } - pub fn pin_server(&self, host: &str, key: &[u8; KEY_LEN]) -> Result<(), Error> { + /// Pins a server key, scoped by port (address::trust_label): the default + /// port keeps the bare-host row existing stores already hold, and any + /// other port earns its own row. + pub fn pin_server( + &self, + host: &str, + port: u16, + key: &[u8; KEY_LEN], + ) -> Result<(), Error> { self.db() .execute( "INSERT INTO servers (host, static, pinned_at) VALUES (?1, ?2, ?3) \ ON CONFLICT (host) DO UPDATE SET static = ?2, pinned_at = ?3", - params![host, key, now()], + params![&crate::address::trust_label(host, port), key, now()], ) .map(|_| ())?; Ok(()) @@ -659,6 +672,7 @@ fn row_stored(row: &rusqlite::Row<'_>) -> rusqlite::Result { mod tests { use super::*; use crate::account::{identity_seed, Account}; + use crate::address::DEFAULT_PORT; fn master() -> [u8; 32] { core::array::from_fn(|i| i as u8) @@ -798,11 +812,11 @@ mod tests { #[test] fn pins_and_contacts_round_trip() { let store = temp_store("pins"); - assert!(store.server_pin("example.org").unwrap().is_none()); - store.pin_server("example.org", &[5u8; 32]).unwrap(); - assert_eq!(store.server_pin("example.org").unwrap(), Some([5u8; 32])); - store.pin_server("example.org", &[6u8; 32]).unwrap(); - assert_eq!(store.server_pin("example.org").unwrap(), Some([6u8; 32])); + assert!(store.server_pin("example.org", DEFAULT_PORT).unwrap().is_none()); + store.pin_server("example.org", DEFAULT_PORT, &[5u8; 32]).unwrap(); + assert_eq!(store.server_pin("example.org", DEFAULT_PORT).unwrap(), Some([5u8; 32])); + store.pin_server("example.org", DEFAULT_PORT, &[6u8; 32]).unwrap(); + assert_eq!(store.server_pin("example.org", DEFAULT_PORT).unwrap(), Some([6u8; 32])); let key = Account::new(master(), 1).unwrap().me().pk(); assert!(store.contact("bob@example.org").unwrap().is_none()); @@ -913,14 +927,48 @@ mod tests { assert_eq!(store.accepted_state("a@example.org").unwrap(), Some(false)); } + /// Pins scope by port with an asymmetric fallback: the default port + /// inherits the bare-host row, and any other port never falls back to + /// it — a second server on one host earns its own trust on first use, + /// not a mismatch against its sibling's key. + #[test] + fn pins_scope_by_port_asymmetrically() { + let store = temp_store("pin-scope"); + store + .pin_server("example.org", DEFAULT_PORT, &[1u8; KEY_LEN]) + .unwrap(); + store + .pin_server("example.org", 1962, &[2u8; KEY_LEN]) + .unwrap(); + // The default port reads the legacy bare-host row. + assert_eq!( + store.server_pin("example.org", DEFAULT_PORT).unwrap(), + Some([1u8; KEY_LEN]) + ); + // A non-default port reads only its own scoped row. + assert_eq!( + store.server_pin("example.org", 1962).unwrap(), + Some([2u8; KEY_LEN]) + ); + // The rule: no fallback to the bare row from another port. + assert_eq!(store.server_pin("example.org", 1963).unwrap(), None); + // Unpin is scoped too; the default-port row survives it. + store.unpin_server("example.org", 1962).unwrap(); + assert_eq!(store.server_pin("example.org", 1962).unwrap(), None); + assert_eq!( + store.server_pin("example.org", DEFAULT_PORT).unwrap(), + Some([1u8; KEY_LEN]) + ); + } + #[test] fn unpin_returns_to_trust_on_first_use() { let store = temp_store("unpin"); - store.pin_server("example.org", &[1u8; KEY_LEN]).unwrap(); - store.unpin_server("example.org").unwrap(); - assert_eq!(store.server_pin("example.org").unwrap(), None); + store.pin_server("example.org", DEFAULT_PORT, &[1u8; KEY_LEN]).unwrap(); + store.unpin_server("example.org", DEFAULT_PORT).unwrap(); + assert_eq!(store.server_pin("example.org", DEFAULT_PORT).unwrap(), None); // Unpinning what was never pinned is not an error. - store.unpin_server("never.example.org").unwrap(); + store.unpin_server("never.example.org", DEFAULT_PORT).unwrap(); } #[test] diff --git a/core/src/tcp.rs b/core/src/tcp.rs index 3a88846..63f17fe 100644 --- a/core/src/tcp.rs +++ b/core/src/tcp.rs @@ -7,7 +7,7 @@ use std::time::Duration; use snow::{Builder, TransportState}; -use crate::crypto::{ct_eq, KEY_LEN}; +use crate::crypto::KEY_LEN; use crate::error::Error; use crate::transport::{ Response, Transport, TransportBindValues, MAX_FRAME, NOISE_PARAMS, NOISE_PAYLOAD, PROLOGUE, @@ -40,16 +40,11 @@ impl TcpTransport { let mut refused = None; for attempt in 1..=HANDSHAKE_ATTEMPTS { match handshake(host, port, timeout) { - Ok((stream, noise, server_static, bind)) => { - if let Some(pinned) = pinned { - if !ct_eq(&pinned, &server_static) { - return Err(Error::PinMismatch { - host: host.to_string(), - pinned, - presented: server_static, - }); - } - } + Ok((stream, noise, _server_static, bind)) => { + // Pin enforcement lives one layer up, in client::connect: + // it knows the identity host, which a dial hint may have + // routed away from, so the mismatch error must name the + // trust object and not wherever the packets went. return Ok(TcpTransport { stream, noise,