Skip to main content

inferd_daemon/
auth.rs

1//! TCP API-key authentication. THREAT_MODEL F-8.
2//!
3//! UDS and named-pipe transports use kernel-attested peer credentials
4//! (F-7). Loopback TCP cannot — anything on the host can bind 127.0.0.1
5//! — so when the operator enables `--tcp` they may also set
6//! `--api-key`. Clients that connect over TCP must then send an auth
7//! frame as the first NDJSON object on the connection:
8//!
9//! ```json
10//! {"type":"auth","key":"<the configured value>"}
11//! ```
12//!
13//! The daemon parses this, compares the key with
14//! `subtle::ConstantTimeEq`, and then proceeds to normal request
15//! handling. A missing or wrong key closes the connection with no
16//! error frame written (we don't want to confirm the endpoint exists
17//! to anonymous probers).
18
19use serde::Deserialize;
20use subtle::ConstantTimeEq;
21
22/// Auth-frame shape. `type` discriminator + `key` payload.
23#[derive(Debug, Deserialize)]
24pub struct AuthFrame {
25    /// Must be the literal `"auth"`.
26    #[serde(rename = "type")]
27    pub kind: String,
28    /// Pre-shared key value.
29    pub key: String,
30}
31
32impl AuthFrame {
33    /// Parse a single line of NDJSON into an `AuthFrame`. Returns
34    /// `None` if the line decodes but `type != "auth"` so the caller
35    /// can distinguish "well-formed but wrong" from "garbage."
36    pub fn from_json(bytes: &[u8]) -> Option<Self> {
37        let frame: AuthFrame = serde_json::from_slice(bytes).ok()?;
38        if frame.kind != "auth" {
39            return None;
40        }
41        Some(frame)
42    }
43}
44
45/// Constant-time comparison of two API-key strings. Identical to
46/// `==` semantically but doesn't leak how many leading bytes match.
47pub fn key_matches(presented: &str, expected: &str) -> bool {
48    presented.as_bytes().ct_eq(expected.as_bytes()).into()
49}
50
51#[cfg(test)]
52mod tests {
53    use super::*;
54
55    #[test]
56    fn parses_auth_frame() {
57        let frame = AuthFrame::from_json(br#"{"type":"auth","key":"secret"}"#).unwrap();
58        assert_eq!(frame.key, "secret");
59    }
60
61    #[test]
62    fn rejects_non_auth_type() {
63        let frame = AuthFrame::from_json(br#"{"type":"request","messages":[]}"#);
64        assert!(frame.is_none());
65    }
66
67    #[test]
68    fn rejects_garbage() {
69        assert!(AuthFrame::from_json(b"not json").is_none());
70    }
71
72    #[test]
73    fn key_matches_equal() {
74        assert!(key_matches("hello", "hello"));
75    }
76
77    #[test]
78    fn key_matches_different() {
79        assert!(!key_matches("hello", "helLo"));
80        assert!(!key_matches("short", "longer-string"));
81    }
82}