Skip to main content

zlayer_secrets/
credentials.rs

1//! Credential store for API authentication.
2//!
3//! Built on top of [`PersistentSecretsStore`], this module provides API-key
4//! based authentication with Argon2id password hashing.
5//!
6//! Credentials are stored in the `credentials` scope of the secrets store.
7//! Each credential is a JSON object containing the argon2id hash of the
8//! API secret and an array of roles.
9//!
10//! # Example
11//!
12//! ```rust,ignore
13//! use zlayer_secrets::credentials::CredentialStore;
14//! use zlayer_secrets::{EncryptionKey, PersistentSecretsStore};
15//!
16//! # async fn example() -> zlayer_secrets::Result<()> {
17//! let key = EncryptionKey::generate();
18//! let secrets_dir = zlayer_paths::ZLayerDirs::system_default().secrets();
19//! let store = PersistentSecretsStore::open(&secrets_dir, key).await?;
20//! let cred_store = CredentialStore::new(store);
21//!
22//! // Create an API key
23//! cred_store.create_api_key("admin", "super-secret-password", &["admin"]).await?;
24//!
25//! // Validate credentials
26//! let roles = cred_store.validate("admin", "super-secret-password").await?;
27//! assert!(roles.is_some());
28//! # Ok(())
29//! # }
30//! ```
31
32use argon2::{
33    password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
34    Argon2,
35};
36use serde::{Deserialize, Serialize};
37use tracing::{debug, info};
38
39use crate::{Result, Secret, SecretsError, SecretsStore};
40
41/// The scope used for storing API credentials in the secrets store.
42const CREDENTIALS_SCOPE: &str = "credentials";
43
44/// Stored credential record (JSON-serialised inside the encrypted secret).
45#[derive(Debug, Clone, Serialize, Deserialize)]
46struct StoredCredential {
47    /// Argon2id hash of the API secret / password.
48    hash: String,
49    /// Roles assigned to this credential (e.g. `["admin"]`).
50    roles: Vec<String>,
51}
52
53/// Credential store for API key authentication.
54///
55/// Wraps a [`SecretsStore`] implementation and stores credentials as encrypted
56/// JSON blobs keyed by API key name under the `credentials` scope.
57pub struct CredentialStore<S: SecretsStore> {
58    store: S,
59}
60
61impl<S: SecretsStore> std::fmt::Debug for CredentialStore<S> {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        f.debug_struct("CredentialStore")
64            .field("store", &"<secrets store>")
65            .finish()
66    }
67}
68
69impl<S: SecretsStore> CredentialStore<S> {
70    /// Create a new credential store backed by the provided secrets store.
71    pub fn new(store: S) -> Self {
72        Self { store }
73    }
74
75    /// Validate an API key and secret pair.
76    ///
77    /// Returns `Some(roles)` if the credentials are valid, `None` if invalid.
78    ///
79    /// # Arguments
80    /// * `api_key` - The API key (used as the secret name in the store)
81    /// * `api_secret` - The password/secret to verify against the stored hash
82    ///
83    /// # Errors
84    /// Returns a `SecretsError` if there is a storage or decryption error
85    /// (NOT for invalid credentials -- that returns `Ok(None)`).
86    pub async fn validate(&self, api_key: &str, api_secret: &str) -> Result<Option<Vec<String>>> {
87        // Look up the credential by API key
88        let secret = match self.store.get_secret(CREDENTIALS_SCOPE, api_key).await {
89            Ok(s) => s,
90            Err(SecretsError::NotFound { .. }) => {
91                debug!(api_key = %api_key, "Credential not found");
92                return Ok(None);
93            }
94            Err(e) => return Err(e),
95        };
96
97        // Deserialise the stored credential
98        let stored: StoredCredential = serde_json::from_str(secret.expose()).map_err(|e| {
99            SecretsError::Storage(format!("corrupt credential record for '{api_key}': {e}"))
100        })?;
101
102        // Verify the password against the stored argon2id hash
103        let parsed_hash = PasswordHash::new(&stored.hash).map_err(|e| {
104            SecretsError::Storage(format!("invalid password hash for '{api_key}': {e}"))
105        })?;
106
107        let argon2 = Argon2::default();
108        if argon2
109            .verify_password(api_secret.as_bytes(), &parsed_hash)
110            .is_ok()
111        {
112            debug!(api_key = %api_key, "Credential validated successfully");
113            Ok(Some(stored.roles))
114        } else {
115            debug!(api_key = %api_key, "Invalid password");
116            Ok(None)
117        }
118    }
119
120    /// Create a new API key credential.
121    ///
122    /// The password is hashed with Argon2id before storage. If a credential
123    /// with the same key already exists, it will be overwritten.
124    ///
125    /// # Arguments
126    /// * `api_key` - The API key identifier
127    /// * `password` - The password/secret to hash and store
128    /// * `roles` - Roles assigned to this credential
129    ///
130    /// # Errors
131    /// Returns a `SecretsError` if hashing or storage fails.
132    pub async fn create_api_key(
133        &self,
134        api_key: &str,
135        password: &str,
136        roles: &[&str],
137    ) -> Result<()> {
138        // Hash the password with Argon2id
139        let salt = SaltString::generate(&mut OsRng);
140        let argon2 = Argon2::default();
141        let hash = argon2
142            .hash_password(password.as_bytes(), &salt)
143            .map_err(|e| SecretsError::Encryption(format!("failed to hash password: {e}")))?
144            .to_string();
145
146        let credential = StoredCredential {
147            hash,
148            roles: roles.iter().map(|r| (*r).to_string()).collect(),
149        };
150
151        let json = serde_json::to_string(&credential)
152            .map_err(|e| SecretsError::Storage(format!("failed to serialise credential: {e}")))?;
153
154        self.store
155            .set_secret(CREDENTIALS_SCOPE, api_key, &Secret::new(json))
156            .await?;
157
158        info!(api_key = %api_key, roles = ?roles, "Created API key credential");
159        Ok(())
160    }
161
162    /// Delete an API key credential.
163    ///
164    /// # Arguments
165    /// * `api_key` - The API key to delete
166    ///
167    /// # Errors
168    /// Returns `SecretsError::NotFound` if the credential doesn't exist.
169    pub async fn delete_api_key(&self, api_key: &str) -> Result<()> {
170        self.store.delete_secret(CREDENTIALS_SCOPE, api_key).await?;
171        info!(api_key = %api_key, "Deleted API key credential");
172        Ok(())
173    }
174
175    /// Check if an API key credential exists.
176    ///
177    /// # Errors
178    /// Returns a `SecretsError` if there is a storage or decryption error.
179    pub async fn exists(&self, api_key: &str) -> Result<bool> {
180        self.store.exists(CREDENTIALS_SCOPE, api_key).await
181    }
182
183    /// Overwrite the roles array on an existing credential, preserving the
184    /// password hash. Use this to keep credential roles in sync with the
185    /// authoritative user-store role when an admin changes a user's role.
186    ///
187    /// # Arguments
188    /// * `api_key` - The API key whose roles should be updated
189    /// * `roles` - The new role list (replaces existing)
190    ///
191    /// # Errors
192    /// Returns `SecretsError::NotFound` if the credential doesn't exist, or a
193    /// `SecretsError` if the storage or (de)serialisation fails.
194    pub async fn set_roles(&self, api_key: &str, roles: &[&str]) -> Result<()> {
195        let secret = self.store.get_secret(CREDENTIALS_SCOPE, api_key).await?;
196        let mut stored: StoredCredential = serde_json::from_str(secret.expose()).map_err(|e| {
197            SecretsError::Storage(format!("corrupt credential record for '{api_key}': {e}"))
198        })?;
199
200        stored.roles = roles.iter().map(|r| (*r).to_string()).collect();
201
202        let json = serde_json::to_string(&stored)
203            .map_err(|e| SecretsError::Storage(format!("failed to serialise credential: {e}")))?;
204
205        self.store
206            .set_secret(CREDENTIALS_SCOPE, api_key, &Secret::new(json))
207            .await?;
208
209        info!(api_key = %api_key, roles = ?roles, "Updated credential roles");
210        Ok(())
211    }
212
213    /// Ensure a default admin credential exists.
214    ///
215    /// If no credential with the given `api_key` exists, one is created with
216    /// the provided password and `["admin"]` role. Returns the password that
217    /// was set (either the provided one or the existing one if already present).
218    ///
219    /// # Arguments
220    /// * `api_key` - The admin API key name (e.g. "admin")
221    /// * `password` - The password to use if the credential doesn't exist
222    ///
223    /// # Returns
224    /// `true` if a new credential was created, `false` if one already existed.
225    ///
226    /// # Errors
227    /// Returns a `SecretsError` if hashing or storage fails during creation.
228    pub async fn ensure_admin(&self, api_key: &str, password: &str) -> Result<bool> {
229        if self.exists(api_key).await? {
230            debug!(api_key = %api_key, "Admin credential already exists");
231            return Ok(false);
232        }
233
234        self.create_api_key(api_key, password, &["admin"]).await?;
235        Ok(true)
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use crate::{EncryptionKey, PersistentSecretsStore};
243
244    async fn create_test_store() -> (PersistentSecretsStore, tempfile::TempDir) {
245        let temp_dir = tempfile::tempdir().unwrap();
246        let db_path = temp_dir.path().join("test_creds.sqlite");
247        let key = EncryptionKey::generate();
248        let store = PersistentSecretsStore::open(&db_path, key).await.unwrap();
249        (store, temp_dir)
250    }
251
252    #[tokio::test]
253    async fn test_create_and_validate() {
254        let (store, _temp) = create_test_store().await;
255        let cred_store = CredentialStore::new(store);
256
257        cred_store
258            .create_api_key("test-key", "test-secret", &["admin", "reader"])
259            .await
260            .unwrap();
261
262        // Valid credentials
263        let roles = cred_store
264            .validate("test-key", "test-secret")
265            .await
266            .unwrap();
267        assert!(roles.is_some());
268        let roles = roles.unwrap();
269        assert!(roles.contains(&"admin".to_string()));
270        assert!(roles.contains(&"reader".to_string()));
271    }
272
273    #[tokio::test]
274    async fn test_validate_wrong_password() {
275        let (store, _temp) = create_test_store().await;
276        let cred_store = CredentialStore::new(store);
277
278        cred_store
279            .create_api_key("test-key", "correct-password", &["admin"])
280            .await
281            .unwrap();
282
283        let roles = cred_store
284            .validate("test-key", "wrong-password")
285            .await
286            .unwrap();
287        assert!(roles.is_none());
288    }
289
290    #[tokio::test]
291    async fn test_validate_nonexistent_key() {
292        let (store, _temp) = create_test_store().await;
293        let cred_store = CredentialStore::new(store);
294
295        let roles = cred_store
296            .validate("nonexistent", "password")
297            .await
298            .unwrap();
299        assert!(roles.is_none());
300    }
301
302    #[tokio::test]
303    async fn test_exists() {
304        let (store, _temp) = create_test_store().await;
305        let cred_store = CredentialStore::new(store);
306
307        assert!(!cred_store.exists("test-key").await.unwrap());
308
309        cred_store
310            .create_api_key("test-key", "password", &["admin"])
311            .await
312            .unwrap();
313
314        assert!(cred_store.exists("test-key").await.unwrap());
315    }
316
317    #[tokio::test]
318    async fn test_delete_api_key() {
319        let (store, _temp) = create_test_store().await;
320        let cred_store = CredentialStore::new(store);
321
322        cred_store
323            .create_api_key("delete-me", "password", &["admin"])
324            .await
325            .unwrap();
326        assert!(cred_store.exists("delete-me").await.unwrap());
327
328        cred_store.delete_api_key("delete-me").await.unwrap();
329        assert!(!cred_store.exists("delete-me").await.unwrap());
330    }
331
332    #[tokio::test]
333    async fn test_set_roles_preserves_hash() {
334        let (store, _temp) = create_test_store().await;
335        let cred_store = CredentialStore::new(store);
336
337        cred_store
338            .create_api_key("alice@example.com", "hunter2hunter2", &["user"])
339            .await
340            .unwrap();
341
342        cred_store
343            .set_roles("alice@example.com", &["admin"])
344            .await
345            .unwrap();
346
347        // Old password still works (hash preserved)
348        let roles = cred_store
349            .validate("alice@example.com", "hunter2hunter2")
350            .await
351            .unwrap()
352            .expect("should validate");
353        assert_eq!(roles, vec!["admin".to_string()]);
354    }
355
356    #[tokio::test]
357    async fn test_set_roles_missing_errors() {
358        let (store, _temp) = create_test_store().await;
359        let cred_store = CredentialStore::new(store);
360
361        let err = cred_store
362            .set_roles("nonexistent", &["admin"])
363            .await
364            .unwrap_err();
365        assert!(
366            matches!(err, SecretsError::NotFound { .. }),
367            "unexpected: {err}"
368        );
369    }
370
371    #[tokio::test]
372    async fn test_ensure_admin_creates() {
373        let (store, _temp) = create_test_store().await;
374        let cred_store = CredentialStore::new(store);
375
376        let created = cred_store
377            .ensure_admin("admin", "admin-password")
378            .await
379            .unwrap();
380        assert!(created);
381
382        // Should be able to validate
383        let roles = cred_store
384            .validate("admin", "admin-password")
385            .await
386            .unwrap();
387        assert!(roles.is_some());
388        assert!(roles.unwrap().contains(&"admin".to_string()));
389    }
390
391    #[tokio::test]
392    async fn test_ensure_admin_skips_existing() {
393        let (store, _temp) = create_test_store().await;
394        let cred_store = CredentialStore::new(store);
395
396        // Create first
397        cred_store
398            .create_api_key("admin", "original-password", &["admin"])
399            .await
400            .unwrap();
401
402        // ensure_admin should not overwrite
403        let created = cred_store
404            .ensure_admin("admin", "new-password")
405            .await
406            .unwrap();
407        assert!(!created);
408
409        // Original password should still work
410        let roles = cred_store
411            .validate("admin", "original-password")
412            .await
413            .unwrap();
414        assert!(roles.is_some());
415
416        // New password should NOT work
417        let roles = cred_store.validate("admin", "new-password").await.unwrap();
418        assert!(roles.is_none());
419    }
420
421    #[tokio::test]
422    async fn test_overwrite_credential() {
423        let (store, _temp) = create_test_store().await;
424        let cred_store = CredentialStore::new(store);
425
426        cred_store
427            .create_api_key("key", "password1", &["reader"])
428            .await
429            .unwrap();
430
431        // Overwrite with new password and roles
432        cred_store
433            .create_api_key("key", "password2", &["admin"])
434            .await
435            .unwrap();
436
437        // Old password should NOT work
438        let roles = cred_store.validate("key", "password1").await.unwrap();
439        assert!(roles.is_none());
440
441        // New password should work with new roles
442        let roles = cred_store.validate("key", "password2").await.unwrap();
443        assert!(roles.is_some());
444        let roles = roles.unwrap();
445        assert!(roles.contains(&"admin".to_string()));
446        assert!(!roles.contains(&"reader".to_string()));
447    }
448}