aede_accounts/
keys.rs

1//! Independently revocable client credentials, without recoverable passwords.
2
3use std::collections::BTreeMap;
4
5use super::*;
6
7/// Maximum number of persistent API keys in one installation.
8pub const MAX_API_KEYS: usize = 512;
9/// Maximum number of persistent API keys belonging to one account.
10pub const MAX_API_KEYS_PER_ACCOUNT: usize = 8;
11/// Maximum UTF-8 label length, measured before any presentation escaping.
12pub const MAX_API_KEY_LABEL_BYTES: usize = 128;
13
14/// Safe public metadata for an independently revocable API credential.
15/// The owner, credential generation and salted verifier stay private.
16#[derive(Clone, PartialEq, Eq)]
17pub struct ApiKey {
18    /// Opaque public identifier; this alone never authenticates a client.
19    pub id: String,
20    /// User-supplied description, preserving its original spelling.
21    pub label: String,
22    /// Creation time in Unix seconds.
23    pub created_at: u64,
24    owner: String,
25    revision: u64,
26    secret_hash: String,
27}
28
29impl std::fmt::Debug for ApiKey {
30    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        formatter
32            .debug_struct("ApiKey")
33            .field("id", &self.id)
34            .field("label", &self.label)
35            .field("created_at", &self.created_at)
36            .finish_non_exhaustive()
37    }
38}
39
40impl Accounts {
41    /// List one account's keys in creation order, without secrets or verifiers.
42    /// Disabled accounts may still be inspected by the local administrator.
43    pub fn api_keys(&self, username: &str) -> Result<Vec<&ApiKey>, String> {
44        let account = self
45            .find(username)
46            .ok_or("no account has this login name")?;
47        Ok(self
48            .api_keys
49            .iter()
50            .filter(|key| key.owner == account.id)
51            .collect())
52    }
53
54    /// Issue an OS-random `id.secret` credential and retain only its salted verifier.
55    /// Both components contain 256 random bits encoded as lowercase hexadecimal.
56    /// The returned token can be shown once, after the caller successfully saves
57    /// the store under its writer lock; it cannot be recovered by later reads.
58    /// Password work must run on a bounded blocking worker for HTTP callers.
59    pub fn create_api_key(
60        &mut self,
61        username: &str,
62        label: &str,
63        now: u64,
64    ) -> Result<(ApiKey, String), String> {
65        let account = self
66            .find(username)
67            .ok_or("no account has this login name")?;
68        if !account.enabled {
69            return Err("disabled accounts cannot receive API keys".into());
70        }
71        if !valid_label(label) {
72            return Err(
73                "API key labels need non-whitespace text and at most 128 UTF-8 bytes".into(),
74            );
75        }
76        if self.api_keys.len() >= MAX_API_KEYS {
77            return Err("the installation already has 512 API keys".into());
78        }
79        if self
80            .api_keys
81            .iter()
82            .filter(|key| key.owner == account.id)
83            .count()
84            >= MAX_API_KEYS_PER_ACCOUNT
85        {
86            return Err("this account already has eight API keys".into());
87        }
88        if now > MAX_REVISION {
89            return Err("API key creation time is too large".into());
90        }
91        let id = random_token()?;
92        if self.api_keys.iter().any(|key| key.id == id) {
93            return Err("could not generate a unique API key identifier; retry".into());
94        }
95        let secret = random_token()?;
96        let key = ApiKey {
97            id,
98            label: label.into(),
99            created_at: now,
100            owner: account.id.clone(),
101            revision: account.revision,
102            secret_hash: hash_password(&secret)?,
103        };
104        let token = format!("{}.{}", key.id, secret);
105        self.api_keys.push(key.clone());
106        Ok((key, token))
107    }
108
109    /// Revoke exactly one named account's key, leaving other keys and sessions valid.
110    pub fn revoke_api_key(&mut self, username: &str, key_id: &str) -> Result<(), String> {
111        let account = self
112            .find(username)
113            .ok_or("no account has this login name")?;
114        let index = self
115            .api_keys
116            .iter()
117            .position(|key| key.owner == account.id && key.id == key_id)
118            .ok_or("no API key with this identifier belongs to the account")?;
119        self.api_keys.remove(index);
120        Ok(())
121    }
122
123    /// Verify an exact `id.secret` credential and return its enabled account and key.
124    /// Well-formed unknown keys perform the same bounded Argon2 work as known
125    /// keys. HTTP callers must schedule verification on a bounded worker and
126    /// apply admission/rate limits; these persistent tokens are not sessions.
127    pub fn authenticate_api_key(&self, token: &str) -> Option<(&Account, &ApiKey)> {
128        if token.len() != 129 {
129            return None;
130        }
131        let (id, secret) = token.split_once('.')?;
132        if !valid_token(id) || !valid_token(secret) {
133            return None;
134        }
135        let key = self.api_keys.iter().find(|key| key.id == id);
136        let hash = key.map_or(DUMMY_HASH, |key| key.secret_hash.as_str());
137        let parsed = PasswordHash::new(hash).ok()?;
138        let matches = hasher()
139            .ok()?
140            .verify_password(secret.as_bytes(), &parsed)
141            .is_ok();
142        let key = key.filter(|_| matches)?;
143        let account = self.api_key_account(&key.id, &key.owner, &self.epoch, key.revision)?;
144        Some((account, key))
145    }
146
147    /// Recheck a previously authenticated key after reloading the current store.
148    /// This checks presence, owner, enabled state, epoch and generation without
149    /// password work. The caller must already have verified the complete token;
150    /// possessing a public key ID or account metadata is never authentication.
151    pub fn api_key_account(
152        &self,
153        key_id: &str,
154        owner: &str,
155        epoch: &str,
156        revision: u64,
157    ) -> Option<&Account> {
158        let account = self.session_account(owner, epoch, revision)?;
159        self.api_keys
160            .iter()
161            .any(|key| key.id == key_id && key.owner == owner && key.revision == revision)
162            .then_some(account)
163    }
164
165    pub(super) fn purge_api_keys(&mut self, owner: &str) {
166        self.api_keys.retain(|key| key.owner != owner);
167    }
168}
169
170fn valid_label(label: &str) -> bool {
171    label.len() <= MAX_API_KEY_LABEL_BYTES && !label.trim().is_empty()
172}
173
174pub(super) fn to_json(keys: &[ApiKey]) -> Json {
175    Json::Array(
176        keys.iter()
177            .map(|key| {
178                serde_json::json!({
179                    "id": key.id,
180                    "label": key.label,
181                    "created_at": key.created_at,
182                    "owner": key.owner,
183                    "revision": key.revision,
184                    "secret_hash": key.secret_hash,
185                })
186            })
187            .collect(),
188    )
189}
190
191pub(super) fn from_json(
192    value: Option<&Json>,
193    accounts: &[Account],
194) -> Result<Vec<ApiKey>, InvalidStore> {
195    let Some(value) = value else {
196        return Ok(Vec::new());
197    };
198    let invalid = || InvalidStore::new("malformed or excessive API key records");
199    let rows = value
200        .as_array()
201        .filter(|rows| rows.len() <= MAX_API_KEYS)
202        .ok_or_else(invalid)?;
203    let mut keys = Vec::with_capacity(rows.len());
204    let mut ids = BTreeSet::new();
205    let mut counts = BTreeMap::<&str, usize>::new();
206    for row in rows {
207        let string = |field| row.get(field).and_then(Json::as_str).ok_or_else(invalid);
208        let number = |field| {
209            row.get(field)
210                .and_then(json_u64)
211                .filter(|value| *value <= MAX_REVISION)
212                .ok_or_else(invalid)
213        };
214        let id = string("id")?;
215        let label = string("label")?;
216        let owner = string("owner")?;
217        let secret_hash = string("secret_hash")?;
218        let revision = number("revision")?;
219        if !valid_token(id)
220            || !ids.insert(id)
221            || !valid_label(label)
222            || !valid_hash(secret_hash)
223            || !accounts.iter().any(|account| {
224                account.id == owner && account.enabled && account.revision == revision
225            })
226        {
227            return Err(invalid());
228        }
229        let count = counts.entry(owner).or_default();
230        *count += 1;
231        if *count > MAX_API_KEYS_PER_ACCOUNT {
232            return Err(invalid());
233        }
234        keys.push(ApiKey {
235            id: id.into(),
236            label: label.into(),
237            created_at: number("created_at")?,
238            owner: owner.into(),
239            revision,
240            secret_hash: secret_hash.into(),
241        });
242    }
243    Ok(keys)
244}
245
246#[cfg(test)]
247#[path = "keys_tests.rs"]
248mod tests;