Skip to main content

ullun/
security.rs

1//! Small security helpers: secure random tokens and constant-time comparison
2//!
3//! ```
4//! use ullun::security::{constant_time_eq, random_token};
5//!
6//! let token = random_token(16).unwrap();          // 128 bits as 32 hex characters
7//! assert_eq!(token.len(), 32);
8//! assert!(constant_time_eq(token.as_bytes(), token.as_bytes()));
9//! ```
10
11use crate::error::{Error, Result};
12use std::net::{IpAddr, Ipv4Addr, SocketAddr};
13
14/// Compare two byte strings without returning early on the first difference,
15/// so the time taken does not reveal how much of a secret was guessed right.
16/// Different lengths compare unequal (the length itself is not hidden).
17pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
18    let mut diff = (a.len() ^ b.len()) as u8;
19    for i in 0..a.len().max(b.len()) {
20        diff |= a.get(i).copied().unwrap_or(0) ^ b.get(i).copied().unwrap_or(0);
21    }
22    std::hint::black_box(diff) == 0
23}
24
25/// `len` bytes from the operating system's CSPRNG
26pub fn random_bytes(len: usize) -> Result<Vec<u8>> {
27    let mut buf = vec![0u8; len];
28    getrandom::fill(&mut buf)
29        .map_err(|e| Error::internal(format!("Random number generator unavailable: {}", e)))?;
30    Ok(buf)
31}
32
33/// Unguessable token: `bytes` random bytes as lowercase hex (`2 * bytes`
34/// characters). Use at least 16 bytes for session ids and secrets.
35pub fn random_token(bytes: usize) -> Result<String> {
36    let mut out = String::with_capacity(bytes * 2);
37    for b in random_bytes(bytes)? {
38        out.push(char::from_digit(u32::from(b >> 4), 16).unwrap_or('0'));
39        out.push(char::from_digit(u32::from(b & 0xf), 16).unwrap_or('0'));
40    }
41    Ok(out)
42}
43
44/// Random string of `len` characters from `[A-Za-z0-9]`, uniformly distributed
45/// (rejection sampling). Handy for short public ids; for secrets prefer
46/// [`random_token`].
47pub fn random_alphanumeric(len: usize) -> Result<String> {
48    const ALPHABET: &[u8; 62] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
49    let mut out = String::with_capacity(len);
50    while out.len() < len {
51        for b in random_bytes(len.max(8))? {
52            // 248 = 4 * 62: values above would skew the distribution
53            if b < 248 && out.len() < len {
54                out.push(ALPHABET[usize::from(b) % 62] as char);
55            }
56        }
57    }
58    Ok(out)
59}
60
61/// Whether `ip` is an address on the public internet. Loopback, private
62/// (RFC 1918, unique local), link-local, CGNAT, multicast, documentation,
63/// benchmarking and reserved ranges are not, and neither are IPv6 addresses
64/// that embed such an IPv4 address (IPv4-mapped, NAT64, 6to4).
65pub fn is_public_ip(ip: IpAddr) -> bool {
66    fn public_v4(v4: Ipv4Addr) -> bool {
67        let o = v4.octets();
68        !(v4.is_loopback()
69            || v4.is_private()
70            || v4.is_link_local()
71            || v4.is_unspecified()
72            || v4.is_broadcast()
73            || v4.is_multicast()
74            || v4.is_documentation()
75            || o[0] == 0                                  // 0.0.0.0/8
76            || o[0] >= 240                                // reserved
77            || (o[0] == 100 && (64..128).contains(&o[1])) // CGNAT 100.64.0.0/10
78            || (o[0] == 198 && (o[1] == 18 || o[1] == 19)) // benchmarking
79            || (o[0] == 192 && o[1] == 0 && o[2] == 0)) // IETF protocol assignments
80    }
81    match ip {
82        IpAddr::V4(v4) => public_v4(v4),
83        IpAddr::V6(v6) => {
84            let seg = v6.segments();
85            let embedded =
86                |a: u16, b: u16| Ipv4Addr::new((a >> 8) as u8, a as u8, (b >> 8) as u8, b as u8);
87            if let Some(v4) = v6.to_ipv4_mapped() {
88                return public_v4(v4);
89            }
90            // NAT64 64:ff9b::/96 carries an IPv4 address in the last 32 bits
91            if seg[..6] == [0x64, 0xff9b, 0, 0, 0, 0] {
92                return public_v4(embedded(seg[6], seg[7]));
93            }
94            // 6to4 2002::/16 carries it right after the prefix
95            if seg[0] == 0x2002 {
96                return public_v4(embedded(seg[1], seg[2]));
97            }
98            // deprecated IPv4-compatible ::a.b.c.d
99            if seg[..6] == [0, 0, 0, 0, 0, 0] {
100                return false;
101            }
102            !(v6.is_loopback()
103                || v6.is_unspecified()
104                || v6.is_multicast()
105                || (seg[0] & 0xfe00) == 0xfc00            // unique local fc00::/7
106                || (seg[0] & 0xffc0) == 0xfe80            // link local fe80::/10
107                || (seg[0] == 0x2001 && seg[1] == 0x0db8)) // documentation
108        }
109    }
110}
111
112/// Why [`resolve_public`] refused
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub enum ResolveError {
115    /// The name did not resolve to any address
116    Resolution,
117    /// At least one answer is not a public address
118    NotPublic(IpAddr),
119}
120
121impl std::fmt::Display for ResolveError {
122    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123        match self {
124            ResolveError::Resolution => f.write_str("name resolution failed"),
125            ResolveError::NotPublic(ip) => write!(f, "target address {} is not public", ip),
126        }
127    }
128}
129
130impl std::error::Error for ResolveError {}
131
132/// Resolve `host` and vet the answers before connecting (SSRF protection)
133///
134/// Every address the name resolves to must be public; with `allow_private`
135/// the check is skipped (internal tools, tests). Connect to the returned
136/// address itself, not to the name again, so a DNS answer that changes between
137/// check and use cannot redirect the connection (DNS rebinding).
138///
139/// ```no_run
140/// # async fn run() {
141/// use ullun::security::resolve_public;
142/// let addr = resolve_public("example.com", 443, false).await.unwrap();
143/// let _stream = tokio::net::TcpStream::connect(addr).await.unwrap();
144/// # }
145/// ```
146pub async fn resolve_public(
147    host: &str,
148    port: u16,
149    allow_private: bool,
150) -> std::result::Result<SocketAddr, ResolveError> {
151    let addrs: Vec<SocketAddr> = tokio::net::lookup_host((host, port))
152        .await
153        .map_err(|_| ResolveError::Resolution)?
154        .collect();
155    let first = addrs.first().copied().ok_or(ResolveError::Resolution)?;
156    if !allow_private {
157        if let Some(bad) = addrs.iter().find(|a| !is_public_ip(a.ip())) {
158            return Err(ResolveError::NotPublic(bad.ip()));
159        }
160    }
161    Ok(first)
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use std::collections::HashSet;
168
169    #[test]
170    fn constant_time_eq_semantics() {
171        assert!(constant_time_eq(b"", b""));
172        assert!(constant_time_eq(b"secret", b"secret"));
173        assert!(!constant_time_eq(b"secret", b"secreT"));
174        assert!(!constant_time_eq(b"secret", b"secre"));
175        assert!(!constant_time_eq(b"", b"x"));
176        // a differing length must not be masked by a matching prefix
177        assert!(!constant_time_eq(b"abc", b"abc\0"));
178    }
179
180    #[test]
181    fn public_and_non_public_addresses() {
182        let ip = |s: &str| s.parse::<IpAddr>().unwrap();
183        for public in [
184            "8.8.8.8",
185            "1.1.1.1",
186            "93.184.216.34",
187            "172.32.0.1",
188            "100.63.255.255",
189            "100.128.0.1",
190            "198.17.0.1",
191            "198.20.0.1",
192            "2606:4700:4700::1111",
193            "2a00:1450:4001::1",
194            "64:ff9b::808:808",
195            "2002:0808:0808::1",
196            "::ffff:8.8.8.8",
197        ] {
198            assert!(is_public_ip(ip(public)), "{public} should be public");
199        }
200        for private in [
201            "127.0.0.1",
202            "127.255.255.254",
203            "10.0.0.1",
204            "172.16.0.1",
205            "172.31.255.255",
206            "192.168.1.1",
207            "169.254.169.254",
208            "0.0.0.0",
209            "0.1.2.3",
210            "255.255.255.255",
211            "100.64.0.1",
212            "100.127.255.255",
213            "224.0.0.1",
214            "239.255.255.255",
215            "240.0.0.1",
216            "192.0.2.1",
217            "198.51.100.1",
218            "203.0.113.1",
219            "198.18.0.1",
220            "198.19.255.255",
221            "192.0.0.1",
222            "::1",
223            "::",
224            "::1234",
225            "fe80::1",
226            "fd00::1",
227            "fc00::1",
228            "ff02::1",
229            "2001:db8::1",
230            "::ffff:127.0.0.1",
231            "::ffff:10.0.0.1",
232            "::ffff:169.254.169.254",
233            "64:ff9b::7f00:1",
234            "64:ff9b::a00:1",
235            "2002:7f00:1::1",
236            "2002:c0a8:101::1",
237        ] {
238            assert!(!is_public_ip(ip(private)), "{private} should not be public");
239        }
240    }
241
242    #[tokio::test]
243    async fn resolve_public_vets_literals_and_names() {
244        assert_eq!(
245            resolve_public("93.184.216.34", 443, false).await,
246            Ok("93.184.216.34:443".parse().unwrap())
247        );
248        assert_eq!(
249            resolve_public("127.0.0.1", 80, false).await,
250            Err(ResolveError::NotPublic("127.0.0.1".parse().unwrap()))
251        );
252        assert_eq!(
253            resolve_public("169.254.169.254", 80, false)
254                .await
255                .unwrap_err()
256                .to_string(),
257            "target address 169.254.169.254 is not public"
258        );
259        // bracketed literals are not hostnames: resolvers disagree whether they parse
260        // (Windows accepts "[::1]"), but none of them may yield a usable address
261        assert!(resolve_public("[::1]", 80, false).await.is_err());
262        assert_eq!(
263            resolve_public("::1", 80, false).await,
264            Err(ResolveError::NotPublic("::1".parse().unwrap()))
265        );
266        // the escape hatch for internal use
267        assert_eq!(
268            resolve_public("127.0.0.1", 80, true).await,
269            Ok("127.0.0.1:80".parse().unwrap())
270        );
271        // names that do not resolve
272        assert_eq!(
273            resolve_public("no-such-host.invalid", 80, false).await,
274            Err(ResolveError::Resolution)
275        );
276        // "localhost" resolves to loopback, which is not public
277        assert!(matches!(
278            resolve_public("localhost", 80, false).await,
279            Err(ResolveError::NotPublic(_))
280        ));
281    }
282
283    #[test]
284    fn token_shape_and_uniqueness() {
285        let tokens: HashSet<String> = (0..200).map(|_| random_token(16).unwrap()).collect();
286        assert_eq!(tokens.len(), 200);
287        assert!(tokens.iter().all(|t| t.len() == 32
288            && t.bytes()
289                .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())));
290        assert_eq!(random_token(0).unwrap(), "");
291    }
292
293    #[test]
294    fn alphanumeric_shape_and_coverage() {
295        let s = random_alphanumeric(2000).unwrap();
296        assert_eq!(s.len(), 2000);
297        assert!(s.bytes().all(|b| b.is_ascii_alphanumeric()));
298        // all 62 symbols should appear in 2000 draws (miss chance ~ 62 * e^-32)
299        let distinct: HashSet<u8> = s.bytes().collect();
300        assert_eq!(distinct.len(), 62);
301        assert_eq!(random_alphanumeric(0).unwrap(), "");
302    }
303}