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}