Skip to main content

solti_model/
auth.rs

1//! # Authentication credentials.
2//!
3//! [`Token`] is a shared bearer secret used in **both** directions of agent ⇄ control-plane communication:
4//!   - agent → CP discovery: the agent presents it (see `solti-discover`);
5//!   - CP → agent API: the agent verifies inbound calls against it (see `solti-api`).
6//!
7//! One secret per agent enables auth symmetrically with a single config knob.
8
9use std::fmt;
10use std::path::Path;
11
12use base64::Engine;
13use base64::engine::general_purpose::URL_SAFE_NO_PAD;
14use subtle::ConstantTimeEq;
15
16use crate::error::{ModelError, ModelResult};
17
18/// Prefix on generated tokens:
19/// aids secret-scanners and log redaction, and makes an auto-generated token visually distinguishable from a user-supplied one.
20const GENERATED_PREFIX: &str = "solti_agt_";
21
22/// Entropy of a generated token, in bytes (256 bits).
23const GENERATED_ENTROPY_BYTES: usize = 32;
24
25/// A bearer token shared between an agent and the control plane.
26#[derive(Clone)]
27pub struct Token(String);
28
29impl Token {
30    /// Wrap a raw token. Surrounding whitespace is trimmed.
31    ///
32    /// Prefer [`Token::from_env`] / [`Token::from_file`] in production so the secret is not baked into the binary or argv.
33    pub fn new(token: impl Into<String>) -> Self {
34        Self(token.into().trim().to_string())
35    }
36
37    /// Generate a fresh random token (256 bits of OS entropy, base64url-encoded, prefixed `solti_agt_`).
38    ///
39    /// This is a pure value: it performs **no** persistence.
40    /// The SDK is a library and does not decide *where* the secret lives: the agent binary owns that (file, vault, k8s secret, …).
41    pub fn generate() -> Self {
42        let mut buf = [0u8; GENERATED_ENTROPY_BYTES];
43        getrandom::fill(&mut buf).expect("getrandom: OS entropy source unavailable");
44        Self(format!("{GENERATED_PREFIX}{}", URL_SAFE_NO_PAD.encode(buf)))
45    }
46
47    /// Read the token from an environment variable.
48    ///
49    /// Returns [`ModelError::Invalid`] if the variable is unset or empty.
50    pub fn from_env(var: &str) -> ModelResult<Self> {
51        let raw = std::env::var(var)
52            .map_err(|_| ModelError::Invalid(format!("token env var `{var}` is not set").into()))?;
53        Self::checked(raw)
54    }
55
56    /// Read the token from a file (trailing newline / whitespace trimmed).
57    ///
58    /// Returns [`ModelError::Invalid`] if the file cannot be read or is empty.
59    pub fn from_file(path: impl AsRef<Path>) -> ModelResult<Self> {
60        let path = path.as_ref();
61        let raw = std::fs::read_to_string(path).map_err(|e| {
62            ModelError::Invalid(format!("read token file `{}`: {e}", path.display()).into())
63        })?;
64        Self::checked(raw)
65    }
66
67    fn checked(raw: String) -> ModelResult<Self> {
68        let trimmed = raw.trim();
69        if trimmed.is_empty() {
70            return Err(ModelError::Invalid("token must not be empty".into()));
71        }
72        Ok(Self(trimmed.to_string()))
73    }
74
75    /// Borrow the raw token for outbound header construction (the sending side, e.g. `solti-discover`).
76    /// Inbound verification should use [`Token::verify`].
77    pub fn expose(&self) -> &str {
78        &self.0
79    }
80
81    /// Whether the token is empty (after trimming) — used by config validation.
82    pub fn is_empty(&self) -> bool {
83        self.0.is_empty()
84    }
85
86    /// Constant-time comparison against a candidate presented by a caller.
87    ///
88    /// Used by the inbound verification path (`solti-api`), a timing side-channel cannot be used to recover the secret byte-by-byte.
89    /// A length mismatch returns `false` (token length is not secret);
90    /// equal-length content comparison stays constant-time.
91    pub fn verify(&self, candidate: &str) -> bool {
92        self.0.as_bytes().ct_eq(candidate.as_bytes()).into()
93    }
94}
95
96impl fmt::Debug for Token {
97    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98        f.write_str("Token(***redacted***)")
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn new_trims_whitespace() {
108        assert_eq!(Token::new("  abc\n").expose(), "abc");
109    }
110
111    #[test]
112    fn checked_rejects_empty() {
113        assert!(Token::from_env("__definitely_unset_var__").is_err());
114    }
115
116    #[test]
117    fn verify_matches_only_exact() {
118        let t = Token::new("s3cr3t-value");
119        assert!(t.verify("s3cr3t-value"));
120        assert!(!t.verify("s3cr3t-valuE"));
121        assert!(!t.verify("s3cr3t"));
122        assert!(!t.verify("s3cr3t-value-extra"));
123    }
124
125    #[test]
126    fn debug_is_redacted() {
127        let t = Token::new("super-secret");
128        assert_eq!(format!("{t:?}"), "Token(***redacted***)");
129        assert!(!format!("{t:?}").contains("super-secret"));
130    }
131
132    #[test]
133    fn generate_is_prefixed_unique_and_self_verifying() {
134        let a = Token::generate();
135        let b = Token::generate();
136        assert!(a.expose().starts_with("solti_agt_"));
137        assert_ne!(a.expose(), b.expose(), "two generated tokens must differ");
138        assert!(a.verify(a.expose()));
139        assert!(!a.verify(b.expose()));
140        assert_eq!(a.expose().len(), "solti_agt_".len() + 43);
141    }
142}