fix: deletable self-sent mail, honest refused-handshake reporting, registration remedy

This commit is contained in:
randogoth 2026-09-29 09:35:46 +03:00
parent 4a780ed648
commit 39c4c4d6a5
3 changed files with 211 additions and 55 deletions

View file

@ -174,7 +174,9 @@ fn hint(error: Option<&fumi::error::Error>) {
}
}
Some(fumi::error::Error::NotRegistered) => {
eprintln!("run: fumi register <user@host>")
eprintln!(
"for a new mailbox:\n fumi register <user@host>\nfor an existing one, pin the server and restore:\n fumi trust <host> <key>\n fumi restore <user@host>"
)
}
_ => {}
}
@ -675,11 +677,17 @@ fn resolve_id_prefixes(store: &Store, ids: &[String]) -> Result<Vec<[u8; ID_LEN]
let mut full = Vec::with_capacity(ids.len());
for id in ids {
let prefix = id.to_ascii_lowercase();
let matches: Vec<[u8; ID_LEN]> = stored
let mut matches: Vec<[u8; ID_LEN]> = stored
.iter()
.filter(|m| hex(m).starts_with(&prefix))
.copied()
.collect();
// A self-addressed message is stored once as received and once as
// sent with the same id; identical ids across folders are one
// message by construction, so dedupe before requiring more
// specificity.
matches.sort_unstable();
matches.dedup();
match matches.len() {
0 => return Err(anyhow!(format!("no message matching {id:?}"))),
1 => full.push(matches[0]),
@ -887,4 +895,30 @@ mod tests {
assert_eq!(format_utc(951_782_400), "2000-02-29 00:00"); // leap day
assert_eq!(format_utc(-1), "1969-12-31 23:59");
}
/// A self-addressed message sits in the inbox and the sent folder with
/// the same id; resolving it must yield one message, not an ambiguity.
#[test]
fn id_resolution_treats_a_self_addressed_message_as_one() {
let store = Store::open_in_memory().unwrap();
let id = [7u8; ID_LEN];
store.store_inbox(&id, b"envelope", 1, false, false).unwrap();
store.store_sent(&id, "fumi@localhost", b"envelope", 1).unwrap();
let full = resolve_id_prefixes(&store, &[hex(&id)]).unwrap();
assert_eq!(full, vec![id]);
}
/// Genuinely distinct messages sharing a prefix must still demand a
/// more specific id.
#[test]
fn id_resolution_rejects_an_ambiguous_prefix() {
let store = Store::open_in_memory().unwrap();
let a = [7u8; ID_LEN];
let mut b = [7u8; ID_LEN];
b[31] = 8;
store.store_inbox(&a, b"a", 1, false, false).unwrap();
store.store_inbox(&b, b"b", 2, false, false).unwrap();
assert!(resolve_id_prefixes(&store, &["0707".into()]).is_err());
assert_eq!(resolve_id_prefixes(&store, &[hex(&a)]).unwrap(), vec![a]);
}
}

View file

@ -55,6 +55,16 @@ pub enum Error {
port: u16,
source: std::io::Error,
},
/// The server dropped the connection mid-handshake after bounded
/// retries. A rate limiter drops connections without a reply, so this
/// is indistinguishable from a dying server; the decision it supports
/// is "retry later", not "treat the server as dead".
HandshakeRefused {
host: String,
port: u16,
source: std::io::Error,
attempts: usize,
},
#[cfg(feature = "store")]
Storage(rusqlite::Error),
Noise(snow::Error),
@ -100,7 +110,10 @@ impl fmt::Display for Error {
b32(known),
b32(offered)
),
Self::NotRegistered => write!(f, "not registered"),
Self::NotRegistered => write!(
f,
"not registered; this store has no account (registering or restoring creates one)"
),
Self::ChainLimit { index } => write!(
f,
"rotation index {index} exceeds the chain limit of {MAX_CHAIN}"
@ -112,6 +125,16 @@ impl fmt::Display for Error {
Self::Unreachable { host, port, source } => {
write!(f, "cannot reach {host}:{port}: {source}")
}
Self::HandshakeRefused {
host,
port,
source,
attempts,
} => write!(
f,
"{host}:{port} refused the connection mid-handshake after {attempts} attempt(s) ({source})\n \
possibly rate limited; a limiter drops connections without a reply, so a dying server looks the same"
),
#[cfg(feature = "store")]
Self::Storage(e) => write!(f, "storage error: {e}"),
Self::Noise(e) => write!(f, "noise error: {e}"),
@ -124,7 +147,9 @@ impl fmt::Display for Error {
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Unreachable { source, .. } => Some(source),
Self::Unreachable { source, .. } | Self::HandshakeRefused { source, .. } => {
Some(source)
}
#[cfg(feature = "store")]
Self::Storage(e) => Some(e),
Self::Noise(e) => Some(e),

View file

@ -25,66 +25,54 @@ pub struct TcpTransport {
}
impl TcpTransport {
/// Runs the Noise_NX handshake as the initiator. The NX pattern has the
/// server transmit its static key during the handshake, so pinning is a
/// single code path: an existing pin must match, an absent one is
/// accepted but reported as unpinned by `pinned()`.
/// Runs the Noise_NX handshake as the initiator, retrying a bounded
/// number of times when the connection is dropped mid-handshake: a rate
/// limiter drops connections without a reply, so a refusal there is
/// indistinguishable from a dying server and earns both a retry and an
/// honest report. Drops during the session proper are not retried; only
/// the handshake is idempotent.
pub fn connect(
host: &str,
port: u16,
pinned: Option<[u8; KEY_LEN]>,
timeout: u64,
) -> Result<TcpTransport, Error> {
let stream = TcpStream::connect((host, port)).map_err(|source| Error::Unreachable {
host: host.to_string(),
port,
source,
})?;
stream.set_read_timeout(Some(Duration::from_secs(timeout)))?;
stream.set_write_timeout(Some(Duration::from_secs(timeout)))?;
let mut stream = stream;
let params: snow::params::NoiseParams = NOISE_PARAMS.parse()?;
let mut noise = Builder::new(params).prologue(PROLOGUE).build_initiator()?;
let mut buf = [0u8; 65535];
let len = noise.write_message(&[], &mut buf)?;
write_u16_len(&mut stream, &buf[..len])?;
let len = read_u16_len(&mut stream)?;
let mut message = vec![0u8; len];
stream.read_exact(&mut message)?;
noise.read_message(&message, &mut buf)?;
if !noise.is_handshake_finished() {
return Err(Error::Other("handshake did not complete".into()));
}
let server_static: [u8; KEY_LEN] = noise
.get_remote_static()
.ok_or_else(|| Error::Other("server sent no static key".into()))?
.try_into()
.map_err(|_| Error::Other("server static key is not 32 bytes".into()))?;
let handshake_hash = noise.get_handshake_hash().to_vec();
let bind = TransportBindValues::tcp(&handshake_hash, &server_static)?;
let transport = noise.into_transport_mode()?;
if let Some(pinned) = pinned {
if !ct_eq(&pinned, &server_static) {
return Err(Error::PinMismatch {
host: host.to_string(),
pinned,
presented: server_static,
});
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,
});
}
}
return Ok(TcpTransport {
stream,
noise,
buf: Vec::new(),
bind,
pinned: pinned.is_some(),
host: host.to_string(),
});
}
Err(Error::Io(source)) if dropped_mid_handshake(&source) => {
if let Some(backoff_ms) = HANDSHAKE_BACKOFF_MS.get(attempt - 1) {
std::thread::sleep(Duration::from_millis(*backoff_ms));
}
refused = Some(source);
}
Err(e) => return Err(e),
}
}
Ok(TcpTransport {
stream,
noise: transport,
buf: Vec::new(),
bind,
pinned: pinned.is_some(),
Err(Error::HandshakeRefused {
host: host.to_string(),
port,
source: refused.expect("a drop was seen on every failed attempt"),
attempts: HANDSHAKE_ATTEMPTS,
})
}
@ -117,6 +105,70 @@ impl TcpTransport {
}
}
/// Attempts a dropped handshake gets before giving up, and the backoff
/// between them (none after the last).
const HANDSHAKE_ATTEMPTS: usize = 3;
const HANDSHAKE_BACKOFF_MS: [u64; 2] = [500, 1500];
/// Whether an io failure during the handshake means the server dropped the
/// connection rather than spoke something wrong. Only these kinds are
/// retried and reported as a refusal; a malformed reply is a different
/// failure and surfaces immediately.
fn dropped_mid_handshake(e: &std::io::Error) -> bool {
matches!(
e.kind(),
std::io::ErrorKind::UnexpectedEof
| std::io::ErrorKind::ConnectionReset
| std::io::ErrorKind::ConnectionAborted
| std::io::ErrorKind::BrokenPipe
| std::io::ErrorKind::NotConnected
)
}
/// One Noise_NX handshake attempt: dial, exchange the two handshake
/// messages, extract the server's static key and the bind values. The NX
/// pattern has the server transmit its static key during the handshake, so
/// pinning is a single code path in the caller: an existing pin must match,
/// an absent one is accepted but reported as unpinned.
fn handshake(
host: &str,
port: u16,
timeout: u64,
) -> Result<(TcpStream, TransportState, [u8; KEY_LEN], TransportBindValues), Error> {
let mut stream = TcpStream::connect((host, port)).map_err(|source| Error::Unreachable {
host: host.to_string(),
port,
source,
})?;
stream.set_read_timeout(Some(Duration::from_secs(timeout)))?;
stream.set_write_timeout(Some(Duration::from_secs(timeout)))?;
let params: snow::params::NoiseParams = NOISE_PARAMS.parse()?;
let mut noise = Builder::new(params).prologue(PROLOGUE).build_initiator()?;
let mut buf = [0u8; 65535];
let len = noise.write_message(&[], &mut buf)?;
write_u16_len(&mut stream, &buf[..len])?;
let len = read_u16_len(&mut stream)?;
let mut message = vec![0u8; len];
stream.read_exact(&mut message)?;
noise.read_message(&message, &mut buf)?;
if !noise.is_handshake_finished() {
return Err(Error::Other("handshake did not complete".into()));
}
let server_static: [u8; KEY_LEN] = noise
.get_remote_static()
.ok_or_else(|| Error::Other("server sent no static key".into()))?
.try_into()
.map_err(|_| Error::Other("server static key is not 32 bytes".into()))?;
let handshake_hash = noise.get_handshake_hash().to_vec();
let bind = TransportBindValues::tcp(&handshake_hash, &server_static)?;
let noise = noise.into_transport_mode()?;
Ok((stream, noise, server_static, bind))
}
impl Transport for TcpTransport {
/// Sends one application frame (u32 length || op || body, split across
/// as many Noise messages as it needs) and reads the response frame,
@ -177,3 +229,48 @@ fn write_u16_len(stream: &mut TcpStream, packet: &[u8]) -> std::io::Result<()> {
stream.write_all(&(packet.len() as u16).to_be_bytes())?;
stream.write_all(packet)
}
#[cfg(test)]
mod tests {
use super::*;
/// A server that accepts and drops every connection mid-handshake is
/// what a rate limiter looks like from the client side: the handshake
/// is retried, then reported honestly as a refusal.
#[test]
fn a_dropped_handshake_is_retried_then_reported() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let dropper = std::thread::spawn(move || {
for _ in 0..HANDSHAKE_ATTEMPTS {
if let Ok((stream, _)) = listener.accept() {
drop(stream);
}
}
});
let error = TcpTransport::connect("127.0.0.1", port, None, 5)
.err()
.expect("expected HandshakeRefused, got a connection");
match error {
Error::HandshakeRefused { attempts, .. } => assert_eq!(attempts, HANDSHAKE_ATTEMPTS),
other => panic!("expected HandshakeRefused, got {other:?}"),
}
dropper.join().unwrap();
}
/// A TCP-level refusal is not a mid-handshake drop: it surfaces
/// immediately as unreachable, without burning retries.
#[test]
fn a_closed_port_is_unreachable_without_retry() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
let error = TcpTransport::connect("127.0.0.1", port, None, 1)
.err()
.expect("expected Unreachable, got a connection");
match error {
Error::Unreachable { .. } => {}
other => panic!("expected Unreachable, got {other:?}"),
}
}
}