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}