Proof of concept mechanical port of ircnet/ircd to Rust as part of a bit about C being insecure for network services
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}