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    /// Borrow the underlying secrets store.
76    ///
77    /// Useful for constructing sibling typed stores (e.g.
78    /// [`crate::RegistryCredentialStore`] / [`crate::GitCredentialStore`]) that
79    /// share the same concrete backing store without re-opening it. When `S` is
80    /// an `Arc<_>` (the common case), call `.clone()` on the returned reference
81    /// for a cheap refcount bump.
82    #[must_use]
83    pub fn store(&self) -> &S {
84        &self.store
85    }
86
87    /// Validate an API key and secret pair.
88    ///
89    /// Returns `Some(roles)` if the credentials are valid, `None` if invalid.
90    ///
91    /// # Arguments
92    /// * `api_key` - The API key (used as the secret name in the store)
93    /// * `api_secret` - The password/secret to verify against the stored hash
94    ///
95    /// # Errors
96    /// Returns a `SecretsError` if there is a storage or decryption error
97    /// (NOT for invalid credentials -- that returns `Ok(None)`).
98    pub async fn validate(&self, api_key: &str, api_secret: &str) -> Result<Option<Vec<String>>> {
99        // Look up the credential by API key
100        let secret = match self.store.get_secret(CREDENTIALS_SCOPE, api_key).await {
101            Ok(s) => s,
102            Err(SecretsError::NotFound { .. }) => {
103                debug!(api_key = %api_key, "Credential not found");
104                return Ok(None);
105            }
106            Err(e) => return Err(e),
107        };
108
109        // Deserialise the stored credential
110        let stored: StoredCredential = serde_json::from_str(secret.expose()).map_err(|e| {
111            SecretsError::Storage(format!("corrupt credential record for '{api_key}': {e}"))
112        })?;
113
114        // Verify the password against the stored argon2id hash
115        let parsed_hash = PasswordHash::new(&stored.hash).map_err(|e| {
116            SecretsError::Storage(format!("invalid password hash for '{api_key}': {e}"))
117        })?;
118
119        let argon2 = Argon2::default();
120        if argon2
121            .verify_password(api_secret.as_bytes(), &parsed_hash)
122            .is_ok()
123        {
124            debug!(api_key = %api_key, "Credential validated successfully");
125            Ok(Some(stored.roles))
126        } else {
127            debug!(api_key = %api_key, "Invalid password");
128            Ok(None)
129        }
130    }
131
132    /// Create a new API key credential.
133    ///
134    /// The password is hashed with Argon2id before storage. If a credential
135    /// with the same key already exists, it will be overwritten.
136    ///
137    /// # Arguments
138    /// * `api_key` - The API key identifier
139    /// * `password` - The password/secret to hash and store
140    /// * `roles` - Roles assigned to this credential
141    ///
142    /// # Errors
143    /// Returns a `SecretsError` if hashing or storage fails.
144    pub async fn create_api_key(
145        &self,
146        api_key: &str,
147        password: &str,
148        roles: &[&str],
149    ) -> Result<()> {
150        // Hash the password with Argon2id
151        let salt = SaltString::generate(&mut OsRng);
152        let argon2 = Argon2::default();
153        let hash = argon2
154            .hash_password(password.as_bytes(), &salt)
155            .map_err(|e| SecretsError::Encryption(format!("failed to hash password: {e}")))?
156            .to_string();
157
158        let credential = StoredCredential {
159            hash,
160            roles: roles.iter().map(|r| (*r).to_string()).collect(),
161        };
162
163        let json = serde_json::to_string(&credential)
164            .map_err(|e| SecretsError::Storage(format!("failed to serialise credential: {e}")))?;
165
166        self.store
167            .set_secret(CREDENTIALS_SCOPE, api_key, &Secret::new(json))
168            .await?;
169
170        info!(api_key = %api_key, roles = ?roles, "Created API key credential");
171        Ok(())
172    }
173
174    /// Delete an API key credential.
175    ///
176    /// # Arguments
177    /// * `api_key` - The API key to delete
178    ///
179    /// # Errors
180    /// Returns `SecretsError::NotFound` if the credential doesn't exist.
181    pub async fn delete_api_key(&self, api_key: &str) -> Result<()> {
182        self.store.delete_secret(CREDENTIALS_SCOPE, api_key).await?;
183        info!(api_key = %api_key, "Deleted API key credential");
184        Ok(())
185    }
186
187    /// Check if an API key credential exists.
188    ///
189    /// # Errors
190    /// Returns a `SecretsError` if there is a storage or decryption error.
191    pub async fn exists(&self, api_key: &str) -> Result<bool> {
192        self.store.exists(CREDENTIALS_SCOPE, api_key).await
193    }
194
195    /// Overwrite the roles array on an existing credential, preserving the
196    /// password hash. Use this to keep credential roles in sync with the
197    /// authoritative user-store role when an admin changes a user's role.
198    ///
199    /// # Arguments
200    /// * `api_key` - The API key whose roles should be updated
201    /// * `roles` - The new role list (replaces existing)
202    ///
203    /// # Errors
204    /// Returns `SecretsError::NotFound` if the credential doesn't exist, or a
205    /// `SecretsError` if the storage or (de)serialisation fails.
206    pub async fn set_roles(&self, api_key: &str, roles: &[&str]) -> Result<()> {
207        let secret = self.store.get_secret(CREDENTIALS_SCOPE, api_key).await?;
208        let mut stored: StoredCredential = serde_json::from_str(secret.expose()).map_err(|e| {
209            SecretsError::Storage(format!("corrupt credential record for '{api_key}': {e}"))
210        })?;
211
212        stored.roles = roles.iter().map(|r| (*r).to_string()).collect();
213
214        let json = serde_json::to_string(&stored)
215            .map_err(|e| SecretsError::Storage(format!("failed to serialise credential: {e}")))?;
216
217        self.store
218            .set_secret(CREDENTIALS_SCOPE, api_key, &Secret::new(json))
219            .await?;
220
221        info!(api_key = %api_key, roles = ?roles, "Updated credential roles");
222        Ok(())
223    }
224
225    /// Ensure a default admin credential exists.
226    ///
227    /// If no credential with the given `api_key` exists, one is created with
228    /// the provided password and `["admin"]` role. Returns the password that
229    /// was set (either the provided one or the existing one if already present).
230    ///
231    /// # Arguments
232    /// * `api_key` - The admin API key name (e.g. "admin")
233    /// * `password` - The password to use if the credential doesn't exist
234    ///
235    /// # Returns
236    /// `true` if a new credential was created, `false` if one already existed.
237    ///
238    /// # Errors
239    /// Returns a `SecretsError` if hashing or storage fails during creation.
240    pub async fn ensure_admin(&self, api_key: &str, password: &str) -> Result<bool> {
241        if self.exists(api_key).await? {
242            debug!(api_key = %api_key, "Admin credential already exists");
243            return Ok(false);
244        }
245
246        self.create_api_key(api_key, password, &["admin"]).await?;
247        Ok(true)
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use crate::{EncryptionKey, PersistentSecretsStore};
255    use zlayer_paths::ZLayerDirs;
256
257    async fn create_test_store() -> (PersistentSecretsStore, zlayer_types::Scratch) {
258        let temp_dir = ZLayerDirs::system_default()
259            .scratch_dir("create-test-store-")
260            .unwrap();
261        let db_path = temp_dir.path().join("test_creds.sqlite");
262        let key = EncryptionKey::generate();
263        let store = PersistentSecretsStore::open(&db_path, key).await.unwrap();
264        (store, temp_dir)
265    }
266
267    #[tokio::test]
268    async fn test_create_and_validate() {
269        let (store, _temp) = create_test_store().await;
270        let cred_store = CredentialStore::new(store);
271
272        cred_store
273            .create_api_key("test-key", "test-secret", &["admin", "reader"])
274            .await
275            .unwrap();
276
277        // Valid credentials
278        let roles = cred_store
279            .validate("test-key", "test-secret")
280            .await
281            .unwrap();
282        assert!(roles.is_some());
283        let roles = roles.unwrap();
284        assert!(roles.contains(&"admin".to_string()));
285        assert!(roles.contains(&"reader".to_string()));
286    }
287
288    #[tokio::test]
289    async fn test_validate_wrong_password() {
290        let (store, _temp) = create_test_store().await;
291        let cred_store = CredentialStore::new(store);
292
293        cred_store
294            .create_api_key("test-key", "correct-password", &["admin"])
295            .await
296            .unwrap();
297
298        let roles = cred_store
299            .validate("test-key", "wrong-password")
300            .await
301            .unwrap();
302        assert!(roles.is_none());
303    }
304
305    #[tokio::test]
306    async fn test_validate_nonexistent_key() {
307        let (store, _temp) = create_test_store().await;
308        let cred_store = CredentialStore::new(store);
309
310        let roles = cred_store
311            .validate("nonexistent", "password")
312            .await
313            .unwrap();
314        assert!(roles.is_none());
315    }
316
317    #[tokio::test]
318    async fn test_exists() {
319        let (store, _temp) = create_test_store().await;
320        let cred_store = CredentialStore::new(store);
321
322        assert!(!cred_store.exists("test-key").await.unwrap());
323
324        cred_store
325            .create_api_key("test-key", "password", &["admin"])
326            .await
327            .unwrap();
328
329        assert!(cred_store.exists("test-key").await.unwrap());
330    }
331
332    #[tokio::test]
333    async fn test_delete_api_key() {
334        let (store, _temp) = create_test_store().await;
335        let cred_store = CredentialStore::new(store);
336
337        cred_store
338            .create_api_key("delete-me", "password", &["admin"])
339            .await
340            .unwrap();
341        assert!(cred_store.exists("delete-me").await.unwrap());
342
343        cred_store.delete_api_key("delete-me").await.unwrap();
344        assert!(!cred_store.exists("delete-me").await.unwrap());
345    }
346
347    #[tokio::test]
348    async fn test_set_roles_preserves_hash() {
349        let (store, _temp) = create_test_store().await;
350        let cred_store = CredentialStore::new(store);
351
352        cred_store
353            .create_api_key("alice@example.com", "hunter2hunter2", &["user"])
354            .await
355            .unwrap();
356
357        cred_store
358            .set_roles("alice@example.com", &["admin"])
359            .await
360            .unwrap();
361
362        // Old password still works (hash preserved)
363        let roles = cred_store
364            .validate("alice@example.com", "hunter2hunter2")
365            .await
366            .unwrap()
367            .expect("should validate");
368        assert_eq!(roles, vec!["admin".to_string()]);
369    }
370
371    #[tokio::test]
372    async fn test_set_roles_missing_errors() {
373        let (store, _temp) = create_test_store().await;
374        let cred_store = CredentialStore::new(store);
375
376        let err = cred_store
377            .set_roles("nonexistent", &["admin"])
378            .await
379            .unwrap_err();
380        assert!(
381            matches!(err, SecretsError::NotFound { .. }),
382            "unexpected: {err}"
383        );
384    }
385
386    #[tokio::test]
387    async fn test_ensure_admin_creates() {
388        let (store, _temp) = create_test_store().await;
389        let cred_store = CredentialStore::new(store);
390
391        let created = cred_store
392            .ensure_admin("admin", "admin-password")
393            .await
394            .unwrap();
395        assert!(created);
396
397        // Should be able to validate
398        let roles = cred_store
399            .validate("admin", "admin-password")
400            .await
401            .unwrap();
402        assert!(roles.is_some());
403        assert!(roles.unwrap().contains(&"admin".to_string()));
404    }
405
406    #[tokio::test]
407    async fn test_ensure_admin_skips_existing() {
408        let (store, _temp) = create_test_store().await;
409        let cred_store = CredentialStore::new(store);
410
411        // Create first
412        cred_store
413            .create_api_key("admin", "original-password", &["admin"])
414            .await
415            .unwrap();
416
417        // ensure_admin should not overwrite
418        let created = cred_store
419            .ensure_admin("admin", "new-password")
420            .await
421            .unwrap();
422        assert!(!created);
423
424        // Original password should still work
425        let roles = cred_store
426            .validate("admin", "original-password")
427            .await
428            .unwrap();
429        assert!(roles.is_some());
430
431        // New password should NOT work
432        let roles = cred_store.validate("admin", "new-password").await.unwrap();
433        assert!(roles.is_none());
434    }
435
436    #[tokio::test]
437    async fn test_overwrite_credential() {
438        let (store, _temp) = create_test_store().await;
439        let cred_store = CredentialStore::new(store);
440
441        cred_store
442            .create_api_key("key", "password1", &["reader"])
443            .await
444            .unwrap();
445
446        // Overwrite with new password and roles
447        cred_store
448            .create_api_key("key", "password2", &["admin"])
449            .await
450            .unwrap();
451
452        // Old password should NOT work
453        let roles = cred_store.validate("key", "password1").await.unwrap();
454        assert!(roles.is_none());
455
456        // New password should work with new roles
457        let roles = cred_store.validate("key", "password2").await.unwrap();
458        assert!(roles.is_some());
459        let roles = roles.unwrap();
460        assert!(roles.contains(&"admin".to_string()));
461        assert!(!roles.contains(&"reader".to_string()));
462    }
463}