Proof of concept mechanical port of ircnet/ircd to Rust as part of a bit about C being insecure for network services
0

Configure Feed

Select the types of activity you want to include in your feed.

ircd.rs / leveva-iauth / src / ident.rs
8.8 kB 252 lines
1//! The ident (RFC 1413, originally RFC 931) authentication module. 2//! 3//! Connects back to the client's IP on the ident port, asks `<their-port> , 4//! <our-port>`, and reads the one-line reply. A well-formed `USERID` answer yields 5//! the client's claimed username; an `OTHER` OS type marks it untrusted (rendered 6//! with a leading `~` by the caller). Anything else — a refused connection, a 7//! timeout, a malformed or `ERROR` reply — is [`ModuleOutcome::NoOpinion`], and the 8//! username falls back to the `USER`-supplied one. 9//! 10//! Ident **never denies**: the worst it does is decline to confirm a username. 11 12use async_trait::async_trait; 13use tokio::io::{AsyncReadExt, AsyncWriteExt}; 14use tokio::net::TcpStream; 15 16use crate::module::{Module, ModuleCtx, ModuleOutcome}; 17 18/// The standard ident port. 19pub const IDENT_PORT: u16 = 113; 20 21/// The RFC 1413 ident lookup module. 22/// 23/// `port` is the TCP port to connect to on the client's host — [`IDENT_PORT`] in 24/// production; tests point it at a local fake identd on an ephemeral port. (A plain 25/// field rather than a `#[cfg(test)]` hook so the override is ordinary code.) 26pub struct Ident { 27 pub port: u16, 28} 29 30impl Default for Ident { 31 fn default() -> Self { 32 Ident { port: IDENT_PORT } 33 } 34} 35 36impl Ident { 37 /// An ident module talking to the standard port 113. 38 pub fn new() -> Self { 39 Self::default() 40 } 41} 42 43#[async_trait] 44impl Module for Ident { 45 fn name(&self) -> &'static str { 46 "ident" 47 } 48 49 async fn run(&self, ctx: &ModuleCtx) -> ModuleOutcome { 50 let mut stream = match TcpStream::connect((ctx.itsip.as_str(), self.port)).await { 51 Ok(s) => s, 52 // No identd listening (or unreachable): decline. This is the common case 53 // on the modern internet and produces the `~user` fallback. 54 Err(_) => return ModuleOutcome::NoOpinion, 55 }; 56 let query = format!("{} , {}\r\n", ctx.itsport, ctx.ourport); 57 if stream.write_all(query.as_bytes()).await.is_err() { 58 return ModuleOutcome::NoOpinion; 59 } 60 // Read until CR (the reply terminator) or EOF, with a hard byte cap so a 61 // chatty/hostile server can't make us buffer forever. (The wall-clock bound 62 // is the pipeline's per-module timeout.) 63 let mut buf = Vec::with_capacity(128); 64 let mut tmp = [0u8; 256]; 65 loop { 66 match stream.read(&mut tmp).await { 67 Ok(0) => break, 68 Ok(n) => { 69 buf.extend_from_slice(&tmp[..n]); 70 if buf.contains(&b'\r') || buf.len() > 1024 { 71 break; 72 } 73 } 74 Err(_) => break, 75 } 76 } 77 let reply = String::from_utf8_lossy(&buf); 78 let line = reply.split(['\r', '\n']).next().unwrap_or(""); 79 match parse_ident_reply(line, ctx.itsport, ctx.ourport) { 80 Some((name, other)) => ModuleOutcome::Username { 81 name, 82 usable: !other, 83 }, 84 None => ModuleOutcome::NoOpinion, 85 } 86 } 87} 88 89/// Parse an RFC 1413 reply line of the form 90/// `<their-port> , <our-port> : USERID : <os> : <username>`. 91/// 92/// Returns `(username, is_other)` where `is_other` is `true` when the OS field is 93/// `OTHER` (an untrusted reply). The port pair must echo the query's ports exactly 94/// (a stray reply for a different connection is rejected), the response type must be 95/// `USERID` (an `ERROR` reply yields `None`), and the username must be non-empty. 96/// 97/// Mirrors the field-walk of the faithful `iauth-rs` port (its 98/// `modules::rfc931::parse_ident_reply`), against which it is differentially pinned. 99pub fn parse_ident_reply(line: &str, itsport: u16, ourport: u16) -> Option<(String, bool)> { 100 let rest = line.trim_start(); 101 // "<their-port> , <our-port> : ..." 102 let mut ports = rest.splitn(2, ','); 103 let lhs = ports.next()?.trim(); 104 if lhs.parse::<u16>().ok()? != itsport { 105 return None; 106 } 107 let rhs = ports.next()?; 108 let mut after_lp = rhs.splitn(2, ':'); 109 let lp = after_lp.next()?.trim(); 110 if lp.parse::<u16>().ok()? != ourport { 111 return None; 112 } 113 let resp = after_lp.next()?.trim_start(); 114 if !resp.starts_with("USERID") { 115 return None; 116 } 117 // After the "USERID" token comes ": <os> : <username>". The OS field tells us 118 // trusted (UNIX/etc.) vs OTHER; the username is everything after the last ':'. 119 let after_userid = resp.split_once(':')?.1.trim_start(); 120 let other = after_userid.starts_with("OTHER"); 121 let user = after_userid.rsplit(':').next()?.trim(); 122 if user.is_empty() { 123 return None; 124 } 125 Some((user.to_string(), other)) 126} 127 128#[cfg(test)] 129mod tests { 130 use super::*; 131 use tokio::io::{AsyncReadExt, AsyncWriteExt}; 132 use tokio::net::TcpListener; 133 134 // --- pure parser: happy paths + the inverses (every rejection branch) --- 135 136 #[test] 137 fn parses_a_unix_userid_reply() { 138 assert_eq!( 139 parse_ident_reply("6667 , 12345 : USERID : UNIX : alice", 6667, 12345), 140 Some(("alice".to_string(), false)) 141 ); 142 } 143 144 #[test] 145 fn other_os_type_is_marked_untrusted() { 146 assert_eq!( 147 parse_ident_reply("6667 , 12345 : USERID : OTHER : bob", 6667, 12345), 148 Some(("bob".to_string(), true)) 149 ); 150 } 151 152 #[test] 153 fn wrong_remote_port_is_rejected() { 154 // A reply whose first port doesn't match our query is for someone else. 155 assert_eq!( 156 parse_ident_reply("9999 , 12345 : USERID : UNIX : alice", 6667, 12345), 157 None 158 ); 159 } 160 161 #[test] 162 fn wrong_local_port_is_rejected() { 163 assert_eq!( 164 parse_ident_reply("6667 , 22222 : USERID : UNIX : alice", 6667, 12345), 165 None 166 ); 167 } 168 169 #[test] 170 fn an_error_reply_yields_nothing() { 171 assert_eq!( 172 parse_ident_reply("6667 , 12345 : ERROR : NO-USER", 6667, 12345), 173 None 174 ); 175 } 176 177 #[test] 178 fn an_empty_username_is_rejected() { 179 assert_eq!( 180 parse_ident_reply("6667 , 12345 : USERID : UNIX : ", 6667, 12345), 181 None 182 ); 183 } 184 185 #[test] 186 fn garbage_and_empty_lines_yield_nothing() { 187 for junk in ["", "not an ident reply", "6667", "6667 ,", ":::::"] { 188 assert_eq!(parse_ident_reply(junk, 6667, 12345), None, "junk={junk:?}"); 189 } 190 } 191 192 // --- the async module against a fake identd --- 193 194 /// Bind a loopback listener that answers the first connection with `reply`, and 195 /// hand back its port (used as the ident `port` override). 196 async fn fake_identd(reply: &'static str) -> u16 { 197 let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); 198 let port = l.local_addr().unwrap().port(); 199 tokio::spawn(async move { 200 if let Ok((mut s, _)) = l.accept().await { 201 let mut buf = [0u8; 64]; 202 let _ = s.read(&mut buf).await; 203 let _ = s.write_all(reply.as_bytes()).await; 204 } 205 }); 206 port 207 } 208 209 #[tokio::test] 210 async fn module_confirms_a_unix_username() { 211 let port = fake_identd("6667 , 12345 : USERID : UNIX : alice\r\n").await; 212 let ctx = ModuleCtx::new("127.0.0.1", 6667, "127.0.0.1", 12345); 213 let outcome = Ident { port }.run(&ctx).await; 214 assert_eq!( 215 outcome, 216 ModuleOutcome::Username { 217 name: "alice".into(), 218 usable: true 219 } 220 ); 221 } 222 223 #[tokio::test] 224 async fn module_marks_an_other_username_untrusted() { 225 let port = fake_identd("6667 , 12345 : USERID : OTHER : bob\r\n").await; 226 let ctx = ModuleCtx::new("127.0.0.1", 6667, "127.0.0.1", 12345); 227 assert_eq!( 228 Ident { port }.run(&ctx).await, 229 ModuleOutcome::Username { 230 name: "bob".into(), 231 usable: false 232 } 233 ); 234 } 235 236 #[tokio::test] 237 async fn no_identd_listening_declines() { 238 // Reserve a port, then drop the listener so the connect is refused. 239 let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); 240 let port = l.local_addr().unwrap().port(); 241 drop(l); 242 let ctx = ModuleCtx::new("127.0.0.1", 6667, "127.0.0.1", 12345); 243 assert_eq!(Ident { port }.run(&ctx).await, ModuleOutcome::NoOpinion); 244 } 245 246 #[tokio::test] 247 async fn a_malformed_reply_declines() { 248 let port = fake_identd("complete nonsense\r\n").await; 249 let ctx = ModuleCtx::new("127.0.0.1", 6667, "127.0.0.1", 12345); 250 assert_eq!(Ident { port }.run(&ctx).await, ModuleOutcome::NoOpinion); 251 } 252}