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/// A credential record exported for propagation (e.g. node-join admin
54/// propagation), carrying the **pre-computed** argon2id hash and the roles
55/// array. Importing this record stores the hash verbatim — the plaintext
56/// password is never seen or re-hashed.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct ExportedCredential {
59    /// Argon2id PHC-string hash of the API secret / password.
60    pub hash: String,
61    /// Roles assigned to this credential (e.g. `["admin"]`).
62    pub roles: Vec<String>,
63}
64
65/// Credential store for API key authentication.
66///
67/// Wraps a [`SecretsStore`] implementation and stores credentials as encrypted
68/// JSON blobs keyed by API key name under the `credentials` scope.
69pub struct CredentialStore<S: SecretsStore> {
70    store: S,
71}
72
73impl<S: SecretsStore> std::fmt::Debug for CredentialStore<S> {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        f.debug_struct("CredentialStore")
76            .field("store", &"<secrets store>")
77            .finish()
78    }
79}
80
81impl<S: SecretsStore> CredentialStore<S> {
82    /// Create a new credential store backed by the provided secrets store.
83    pub fn new(store: S) -> Self {
84        Self { store }
85    }
86
87    /// Borrow the underlying secrets store.
88    ///
89    /// Useful for constructing sibling typed stores (e.g.
90    /// [`crate::RegistryCredentialStore`] / [`crate::GitCredentialStore`]) that
91    /// share the same concrete backing store without re-opening it. When `S` is
92    /// an `Arc<_>` (the common case), call `.clone()` on the returned reference
93    /// for a cheap refcount bump.
94    #[must_use]
95    pub fn store(&self) -> &S {
96        &self.store
97    }
98
99    /// Validate an API key and secret pair.
100    ///
101    /// Returns `Some(roles)` if the credentials are valid, `None` if invalid.
102    ///
103    /// # Arguments
104    /// * `api_key` - The API key (used as the secret name in the store)
105    /// * `api_secret` - The password/secret to verify against the stored hash
106    ///
107    /// # Errors
108    /// Returns a `SecretsError` if there is a storage or decryption error
109    /// (NOT for invalid credentials -- that returns `Ok(None)`).
110    pub async fn validate(&self, api_key: &str, api_secret: &str) -> Result<Option<Vec<String>>> {
111        // Look up the credential by API key
112        let secret = match self.store.get_secret(CREDENTIALS_SCOPE, api_key).await {
113            Ok(s) => s,
114            Err(SecretsError::NotFound { .. }) => {
115                debug!(api_key = %api_key, "Credential not found");
116                return Ok(None);
117            }
118            Err(e) => return Err(e),
119        };
120
121        // Deserialise the stored credential
122        let stored: StoredCredential = serde_json::from_str(secret.expose()).map_err(|e| {
123            SecretsError::Storage(format!("corrupt credential record for '{api_key}': {e}"))
124        })?;
125
126        // Verify the password against the stored argon2id hash
127        let parsed_hash = PasswordHash::new(&stored.hash).map_err(|e| {
128            SecretsError::Storage(format!("invalid password hash for '{api_key}': {e}"))
129        })?;
130
131        let argon2 = Argon2::default();
132        if argon2
133            .verify_password(api_secret.as_bytes(), &parsed_hash)
134            .is_ok()
135        {
136            debug!(api_key = %api_key, "Credential validated successfully");
137            Ok(Some(stored.roles))
138        } else {
139            debug!(api_key = %api_key, "Invalid password");
140            Ok(None)
141        }
142    }
143
144    /// Create a new API key credential.
145    ///
146    /// The password is hashed with Argon2id before storage. If a credential
147    /// with the same key already exists, it will be overwritten.
148    ///
149    /// # Arguments
150    /// * `api_key` - The API key identifier
151    /// * `password` - The password/secret to hash and store
152    /// * `roles` - Roles assigned to this credential
153    ///
154    /// # Errors
155    /// Returns a `SecretsError` if hashing or storage fails.
156    pub async fn create_api_key(
157        &self,
158        api_key: &str,
159        password: &str,
160        roles: &[&str],
161    ) -> Result<()> {
162        // Hash the password with Argon2id
163        let salt = SaltString::generate(&mut OsRng);
164        let argon2 = Argon2::default();
165        let hash = argon2
166            .hash_password(password.as_bytes(), &salt)
167            .map_err(|e| SecretsError::Encryption(format!("failed to hash password: {e}")))?
168            .to_string();
169
170        let credential = StoredCredential {
171            hash,
172            roles: roles.iter().map(|r| (*r).to_string()).collect(),
173        };
174
175        let json = serde_json::to_string(&credential)
176            .map_err(|e| SecretsError::Storage(format!("failed to serialise credential: {e}")))?;
177
178        self.store
179            .set_secret(CREDENTIALS_SCOPE, api_key, &Secret::new(json))
180            .await?;
181
182        info!(api_key = %api_key, roles = ?roles, "Created API key credential");
183        Ok(())
184    }
185
186    /// Export a credential's raw record (argon2id hash + roles) for
187    /// propagation, WITHOUT decrypting any password.
188    ///
189    /// Returns `Ok(None)` when no credential exists for `api_key`. The hash in
190    /// the returned [`ExportedCredential`] is the verbatim PHC string; pairing
191    /// it with [`Self::import_record`] on another node reproduces a credential
192    /// that validates the same password without re-hashing.
193    ///
194    /// # Errors
195    /// Returns a `SecretsError` on storage/decryption errors or a corrupt
196    /// record (but NOT for a missing key — that yields `Ok(None)`).
197    pub async fn export_record(&self, api_key: &str) -> Result<Option<ExportedCredential>> {
198        let secret = match self.store.get_secret(CREDENTIALS_SCOPE, api_key).await {
199            Ok(s) => s,
200            Err(SecretsError::NotFound { .. }) => return Ok(None),
201            Err(e) => return Err(e),
202        };
203        let stored: StoredCredential = serde_json::from_str(secret.expose()).map_err(|e| {
204            SecretsError::Storage(format!("corrupt credential record for '{api_key}': {e}"))
205        })?;
206        Ok(Some(ExportedCredential {
207            hash: stored.hash,
208            roles: stored.roles,
209        }))
210    }
211
212    /// Import a pre-hashed credential record (as produced by
213    /// [`Self::export_record`]) under `api_key`, storing the argon2id hash
214    /// AS-IS. No password is supplied and nothing is re-hashed, so the imported
215    /// credential validates exactly the password the original was created with.
216    ///
217    /// If a credential with the same key already exists it is overwritten.
218    ///
219    /// # Errors
220    /// Returns a `SecretsError` if serialisation or storage fails.
221    pub async fn import_record(&self, api_key: &str, record: &ExportedCredential) -> Result<()> {
222        let credential = StoredCredential {
223            hash: record.hash.clone(),
224            roles: record.roles.clone(),
225        };
226        let json = serde_json::to_string(&credential)
227            .map_err(|e| SecretsError::Storage(format!("failed to serialise credential: {e}")))?;
228        self.store
229            .set_secret(CREDENTIALS_SCOPE, api_key, &Secret::new(json))
230            .await?;
231        info!(api_key = %api_key, roles = ?record.roles, "Imported pre-hashed credential record");
232        Ok(())
233    }
234
235    /// Delete an API key credential.
236    ///
237    /// # Arguments
238    /// * `api_key` - The API key to delete
239    ///
240    /// # Errors
241    /// Returns `SecretsError::NotFound` if the credential doesn't exist.
242    pub async fn delete_api_key(&self, api_key: &str) -> Result<()> {
243        self.store.delete_secret(CREDENTIALS_SCOPE, api_key).await?;
244        info!(api_key = %api_key, "Deleted API key credential");
245        Ok(())
246    }
247
248    /// Check if an API key credential exists.
249    ///
250    /// # Errors
251    /// Returns a `SecretsError` if there is a storage or decryption error.
252    pub async fn exists(&self, api_key: &str) -> Result<bool> {
253        self.store.exists(CREDENTIALS_SCOPE, api_key).await
254    }
255
256    /// Overwrite the roles array on an existing credential, preserving the
257    /// password hash. Use this to keep credential roles in sync with the
258    /// authoritative user-store role when an admin changes a user's role.
259    ///
260    /// # Arguments
261    /// * `api_key` - The API key whose roles should be updated
262    /// * `roles` - The new role list (replaces existing)
263    ///
264    /// # Errors
265    /// Returns `SecretsError::NotFound` if the credential doesn't exist, or a
266    /// `SecretsError` if the storage or (de)serialisation fails.
267    pub async fn set_roles(&self, api_key: &str, roles: &[&str]) -> Result<()> {
268        let secret = self.store.get_secret(CREDENTIALS_SCOPE, api_key).await?;
269        let mut stored: StoredCredential = serde_json::from_str(secret.expose()).map_err(|e| {
270            SecretsError::Storage(format!("corrupt credential record for '{api_key}': {e}"))
271        })?;
272
273        stored.roles = roles.iter().map(|r| (*r).to_string()).collect();
274
275        let json = serde_json::to_string(&stored)
276            .map_err(|e| SecretsError::Storage(format!("failed to serialise credential: {e}")))?;
277
278        self.store
279            .set_secret(CREDENTIALS_SCOPE, api_key, &Secret::new(json))
280            .await?;
281
282        info!(api_key = %api_key, roles = ?roles, "Updated credential roles");
283        Ok(())
284    }
285
286    /// Ensure a default admin credential exists.
287    ///
288    /// If no credential with the given `api_key` exists, one is created with
289    /// the provided password and `["admin"]` role. Returns the password that
290    /// was set (either the provided one or the existing one if already present).
291    ///
292    /// # Arguments
293    /// * `api_key` - The admin API key name (e.g. "admin")
294    /// * `password` - The password to use if the credential doesn't exist
295    ///
296    /// # Returns
297    /// `true` if a new credential was created, `false` if one already existed.
298    ///
299    /// # Errors
300    /// Returns a `SecretsError` if hashing or storage fails during creation.
301    pub async fn ensure_admin(&self, api_key: &str, password: &str) -> Result<bool> {
302        if self.exists(api_key).await? {
303            debug!(api_key = %api_key, "Admin credential already exists");
304            return Ok(false);
305        }
306
307        self.create_api_key(api_key, password, &["admin"]).await?;
308        Ok(true)
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use crate::{EncryptionKey, PersistentSecretsStore};
316    use zlayer_paths::ZLayerDirs;
317
318    async fn create_test_store() -> (PersistentSecretsStore, zlayer_types::Scratch) {
319        let temp_dir = ZLayerDirs::system_default()
320            .scratch_dir("create-test-store-")
321            .unwrap();
322        let db_path = temp_dir.path().join("test_creds.sqlite");
323        let key = EncryptionKey::generate();
324        let store = PersistentSecretsStore::open(&db_path, key).await.unwrap();
325        (store, temp_dir)
326    }
327
328    #[tokio::test]
329    async fn test_create_and_validate() {
330        let (store, _temp) = create_test_store().await;
331        let cred_store = CredentialStore::new(store);
332
333        cred_store
334            .create_api_key("test-key", "test-secret", &["admin", "reader"])
335            .await
336            .unwrap();
337
338        // Valid credentials
339        let roles = cred_store
340            .validate("test-key", "test-secret")
341            .await
342            .unwrap();
343        assert!(roles.is_some());
344        let roles = roles.unwrap();
345        assert!(roles.contains(&"admin".to_string()));
346        assert!(roles.contains(&"reader".to_string()));
347    }
348
349    #[tokio::test]
350    async fn test_validate_wrong_password() {
351        let (store, _temp) = create_test_store().await;
352        let cred_store = CredentialStore::new(store);
353
354        cred_store
355            .create_api_key("test-key", "correct-password", &["admin"])
356            .await
357            .unwrap();
358
359        let roles = cred_store
360            .validate("test-key", "wrong-password")
361            .await
362            .unwrap();
363        assert!(roles.is_none());
364    }
365
366    #[tokio::test]
367    async fn test_validate_nonexistent_key() {
368        let (store, _temp) = create_test_store().await;
369        let cred_store = CredentialStore::new(store);
370
371        let roles = cred_store
372            .validate("nonexistent", "password")
373            .await
374            .unwrap();
375        assert!(roles.is_none());
376    }
377
378    #[tokio::test]
379    async fn test_exists() {
380        let (store, _temp) = create_test_store().await;
381        let cred_store = CredentialStore::new(store);
382
383        assert!(!cred_store.exists("test-key").await.unwrap());
384
385        cred_store
386            .create_api_key("test-key", "password", &["admin"])
387            .await
388            .unwrap();
389
390        assert!(cred_store.exists("test-key").await.unwrap());
391    }
392
393    #[tokio::test]
394    async fn test_delete_api_key() {
395        let (store, _temp) = create_test_store().await;
396        let cred_store = CredentialStore::new(store);
397
398        cred_store
399            .create_api_key("delete-me", "password", &["admin"])
400            .await
401            .unwrap();
402        assert!(cred_store.exists("delete-me").await.unwrap());
403
404        cred_store.delete_api_key("delete-me").await.unwrap();
405        assert!(!cred_store.exists("delete-me").await.unwrap());
406    }
407
408    #[tokio::test]
409    async fn test_set_roles_preserves_hash() {
410        let (store, _temp) = create_test_store().await;
411        let cred_store = CredentialStore::new(store);
412
413        cred_store
414            .create_api_key("alice@example.com", "hunter2hunter2", &["user"])
415            .await
416            .unwrap();
417
418        cred_store
419            .set_roles("alice@example.com", &["admin"])
420            .await
421            .unwrap();
422
423        // Old password still works (hash preserved)
424        let roles = cred_store
425            .validate("alice@example.com", "hunter2hunter2")
426            .await
427            .unwrap()
428            .expect("should validate");
429        assert_eq!(roles, vec!["admin".to_string()]);
430    }
431
432    #[tokio::test]
433    async fn test_set_roles_missing_errors() {
434        let (store, _temp) = create_test_store().await;
435        let cred_store = CredentialStore::new(store);
436
437        let err = cred_store
438            .set_roles("nonexistent", &["admin"])
439            .await
440            .unwrap_err();
441        assert!(
442            matches!(err, SecretsError::NotFound { .. }),
443            "unexpected: {err}"
444        );
445    }
446
447    #[tokio::test]
448    async fn test_ensure_admin_creates() {
449        let (store, _temp) = create_test_store().await;
450        let cred_store = CredentialStore::new(store);
451
452        let created = cred_store
453            .ensure_admin("admin", "admin-password")
454            .await
455            .unwrap();
456        assert!(created);
457
458        // Should be able to validate
459        let roles = cred_store
460            .validate("admin", "admin-password")
461            .await
462            .unwrap();
463        assert!(roles.is_some());
464        assert!(roles.unwrap().contains(&"admin".to_string()));
465    }
466
467    #[tokio::test]
468    async fn test_ensure_admin_skips_existing() {
469        let (store, _temp) = create_test_store().await;
470        let cred_store = CredentialStore::new(store);
471
472        // Create first
473        cred_store
474            .create_api_key("admin", "original-password", &["admin"])
475            .await
476            .unwrap();
477
478        // ensure_admin should not overwrite
479        let created = cred_store
480            .ensure_admin("admin", "new-password")
481            .await
482            .unwrap();
483        assert!(!created);
484
485        // Original password should still work
486        let roles = cred_store
487            .validate("admin", "original-password")
488            .await
489            .unwrap();
490        assert!(roles.is_some());
491
492        // New password should NOT work
493        let roles = cred_store.validate("admin", "new-password").await.unwrap();
494        assert!(roles.is_none());
495    }
496
497    #[tokio::test]
498    async fn test_export_then_import_preserves_hash() {
499        // Create a credential on "node A".
500        let (store_a, _temp_a) = create_test_store().await;
501        let cred_a = CredentialStore::new(store_a);
502        cred_a
503            .create_api_key("admin@example.com", "correcthorsebatterystaple", &["admin"])
504            .await
505            .unwrap();
506
507        // Export the raw record (hash + roles); no plaintext password involved.
508        let exported = cred_a
509            .export_record("admin@example.com")
510            .await
511            .unwrap()
512            .expect("record present");
513        assert_eq!(exported.roles, vec!["admin".to_string()]);
514        assert!(exported.hash.starts_with("$argon2"), "PHC hash expected");
515
516        // Import it verbatim into a SEPARATE store ("node B"). No re-hash.
517        let (store_b, _temp_b) = create_test_store().await;
518        let cred_b = CredentialStore::new(store_b);
519        cred_b
520            .import_record("admin@example.com", &exported)
521            .await
522            .unwrap();
523
524        // The same password validates on node B against the imported hash.
525        let roles = cred_b
526            .validate("admin@example.com", "correcthorsebatterystaple")
527            .await
528            .unwrap()
529            .expect("imported credential must validate");
530        assert_eq!(roles, vec!["admin".to_string()]);
531
532        // A wrong password is still rejected.
533        let none = cred_b
534            .validate("admin@example.com", "wrong-password")
535            .await
536            .unwrap();
537        assert!(none.is_none());
538    }
539
540    #[tokio::test]
541    async fn test_export_missing_returns_none() {
542        let (store, _temp) = create_test_store().await;
543        let cred = CredentialStore::new(store);
544        assert!(cred.export_record("nope").await.unwrap().is_none());
545    }
546
547    #[tokio::test]
548    async fn test_overwrite_credential() {
549        let (store, _temp) = create_test_store().await;
550        let cred_store = CredentialStore::new(store);
551
552        cred_store
553            .create_api_key("key", "password1", &["reader"])
554            .await
555            .unwrap();
556
557        // Overwrite with new password and roles
558        cred_store
559            .create_api_key("key", "password2", &["admin"])
560            .await
561            .unwrap();
562
563        // Old password should NOT work
564        let roles = cred_store.validate("key", "password1").await.unwrap();
565        assert!(roles.is_none());
566
567        // New password should work with new roles
568        let roles = cred_store.validate("key", "password2").await.unwrap();
569        assert!(roles.is_some());
570        let roles = roles.unwrap();
571        assert!(roles.contains(&"admin".to_string()));
572        assert!(!roles.contains(&"reader".to_string()));
573    }
574}