Skip to main content

micromegas_auth/
api_key.rs

1use crate::ip_allowlist::IpAllowlist;
2use crate::types::{AuthContext, AuthProvider, AuthType};
3use anyhow::{Result, anyhow};
4use serde::Deserialize;
5use std::{collections::HashMap, fmt::Display};
6use subtle::ConstantTimeEq;
7
8/// Represents a key in the keyring.
9#[derive(Hash, Eq, PartialEq)]
10pub struct Key {
11    /// The key value
12    pub value: String,
13}
14
15impl Key {
16    /// Creates a new `Key` from a string value.
17    pub fn new(value: String) -> Self {
18        Self { value }
19    }
20}
21
22impl From<String> for Key {
23    fn from(value: String) -> Self {
24        Self { value }
25    }
26}
27
28impl Display for Key {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        write!(f, "<sensitive key>")
31    }
32}
33
34/// Deserializes a string into a `Key`.
35fn key_from_string<'de, D>(deserializer: D) -> Result<Key, D::Error>
36where
37    D: serde::Deserializer<'de>,
38{
39    let s: String = Deserialize::deserialize(deserializer)?;
40    Ok(Key::new(s))
41}
42
43/// Represents an entry in the keyring, mapping a key to a name.
44#[derive(Deserialize)]
45pub struct KeyRingEntry {
46    /// The name associated with the key
47    pub name: String,
48    /// The key
49    #[serde(deserialize_with = "key_from_string")]
50    pub key: Key,
51    /// CIDR ranges or bare IPs this key may be used from. Absent/empty = unrestricted.
52    #[serde(default)]
53    pub allowed_cidrs: Vec<String>,
54}
55
56/// A keyring entry's name plus its parsed IP allowlist -- the parsed allowlist travels with the
57/// name so [`ApiKeyAuthProvider::validate_request`] never re-parses CIDR strings on the hot path.
58pub struct KeyRingValue {
59    /// The name associated with the key.
60    pub name: String,
61    /// The parsed IP allowlist -- empty means unrestricted.
62    pub allowlist: IpAllowlist,
63}
64
65/// A map from `Key` to [`KeyRingValue`] (name + parsed allowlist).
66pub type KeyRing = HashMap<Key, KeyRingValue>;
67
68/// Parses a JSON string into a `KeyRing`.
69///
70/// The JSON string is expected to be an array of objects, each with a `name` and `key` field,
71/// and an optional `allowed_cidrs` array. Fails the whole parse (fail-fast at startup, same as
72/// every other keyring-shape error) on a malformed `allowed_cidrs` entry.
73pub fn parse_key_ring(json: &str) -> Result<KeyRing> {
74    let entries: Vec<KeyRingEntry> = serde_json::from_str(json)?;
75    let mut ring = KeyRing::new();
76    for entry in entries {
77        let allowlist = IpAllowlist::parse(&entry.allowed_cidrs)?;
78        ring.insert(
79            entry.key,
80            KeyRingValue {
81                name: entry.name,
82                allowlist,
83            },
84        );
85    }
86    Ok(ring)
87}
88
89/// API key authentication provider
90pub struct ApiKeyAuthProvider {
91    keyring: KeyRing,
92}
93
94impl ApiKeyAuthProvider {
95    /// Create a new API key authentication provider
96    pub fn new(keyring: KeyRing) -> Self {
97        Self { keyring }
98    }
99}
100
101#[async_trait::async_trait]
102impl AuthProvider for ApiKeyAuthProvider {
103    /// Validate an API key request using constant-time comparison
104    ///
105    /// This implementation protects against timing attacks by:
106    /// 1. Comparing the provided token against ALL keys in the keyring
107    /// 2. Using constant-time comparison from the `subtle` crate
108    /// 3. Always iterating through all keys regardless of match status
109    ///
110    /// This ensures the operation takes the same amount of time whether:
111    /// - The key is found early in the iteration
112    /// - The key is found late in the iteration
113    /// - The key is not found at all
114    async fn validate_request(
115        &self,
116        parts: &dyn crate::types::RequestParts,
117    ) -> Result<AuthContext> {
118        let token = parts
119            .bearer_token()
120            .ok_or_else(|| anyhow!("missing bearer token"))?;
121
122        let token_bytes = token.as_bytes();
123        let mut found: Option<(AuthContext, &IpAllowlist)> = None;
124
125        // Compare against all keys in constant time
126        // IMPORTANT: We iterate through ALL keys, even if we find a match,
127        // to ensure constant-time operation
128        for (stored_key, value) in &self.keyring {
129            let stored_bytes = stored_key.value.as_bytes();
130
131            // Constant-time comparison
132            // Returns 1 if equal, 0 if not equal
133            let matches = token_bytes.ct_eq(stored_bytes).unwrap_u8() == 1;
134
135            // Conditionally set the result without branching on the match
136            // If matches is true, we set found; if matches is false, found stays as-is
137            if matches {
138                found = Some((
139                    AuthContext {
140                        subject: value.name.clone(),
141                        email: None,
142                        issuer: "api_key".to_string(),
143                        audience: None,
144                        expires_at: None,
145                        auth_type: AuthType::ApiKey,
146                        // SECURITY: API keys CAN delegate (act on behalf of users)
147                        allow_delegation: true,
148                        // Env-configured keys carry no Stage 4/4b grant.
149                        bound_audience: None,
150                        read_audiences: vec![],
151                        // API keys carry no email for a `user:` member to match, so only a
152                        // `MembershipProvider` wrapping this provider over a wildcard-admin group
153                        // could ever make one admin -- see the migration v10 module doc comment.
154                        memberships: std::sync::Arc::from([]),
155                    },
156                    &value.allowlist,
157                ));
158            }
159            // Note: We do NOT break or return early - we continue checking all keys
160        }
161
162        // The allowlist check runs once, after the constant-time scan, on `found` only -- it
163        // checks public data (the request's own resolved IP), so there is no timing side-channel
164        // to protect for it specifically, and it leaves the loop's constant-time property over
165        // the *key comparison* untouched.
166        let (context, allowlist) = found.ok_or_else(|| anyhow!("invalid API token"))?;
167        if !allowlist.allows(parts.client_ip()) {
168            micromegas_tracing::warn!(
169                "env api key rejected by allowlist: name={} client_ip={:?}",
170                context.subject,
171                parts.client_ip()
172            );
173            anyhow::bail!("invalid API token: source IP not permitted");
174        }
175        Ok(context)
176    }
177}