feat: scope server pins by port, default port inherits (agreed with bunshin)

This commit is contained in:
randogoth 2026-09-30 13:46:05 +03:00
parent 96a558da16
commit 13c624f2b0
7 changed files with 149 additions and 48 deletions

View file

@ -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} <key>"
Some(fumi::error::Error::NotPinned { host, port }) => eprintln!(
"obtain the key from the operator through a trusted channel, then:\n fumi trust {} <key>",
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::<u16>() {
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(())
}

View file

@ -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;

View file

@ -79,10 +79,11 @@ pub fn connect(
) -> Result<Session, Error> {
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 {

View file

@ -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)
),

View file

@ -249,11 +249,21 @@ pub fn import(store: &Store, master: &[u8; KEY_LEN], text: &str) -> Result<Impor
for (host, key) in &payload.servers {
let key: Option<[u8; KEY_LEN]> = 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::<u16>() {
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]);
}

View file

@ -276,9 +276,12 @@ impl Store {
Ok(())
}
pub fn server_pin(&self, host: &str) -> Result<Option<[u8; KEY_LEN]>, Error> {
pub fn server_pin(&self, host: &str, port: u16) -> Result<Option<[u8; KEY_LEN]>, Error> {
Ok(self
.one::<Option<Vec<u8>>>("SELECT static FROM servers WHERE host = ?1", &[&host])?
.one::<Option<Vec<u8>>>(
"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<Stored> {
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]

View file

@ -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,