1use 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
41const CREDENTIALS_SCOPE: &str = "credentials";
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
46struct StoredCredential {
47 hash: String,
49 roles: Vec<String>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct ExportedCredential {
59 pub hash: String,
61 pub roles: Vec<String>,
63}
64
65pub 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 pub fn new(store: S) -> Self {
84 Self { store }
85 }
86
87 #[must_use]
95 pub fn store(&self) -> &S {
96 &self.store
97 }
98
99 pub async fn validate(&self, api_key: &str, api_secret: &str) -> Result<Option<Vec<String>>> {
111 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 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 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 pub async fn create_api_key(
157 &self,
158 api_key: &str,
159 password: &str,
160 roles: &[&str],
161 ) -> Result<()> {
162 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 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 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 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 pub async fn exists(&self, api_key: &str) -> Result<bool> {
253 self.store.exists(CREDENTIALS_SCOPE, api_key).await
254 }
255
256 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 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 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 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 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 cred_store
474 .create_api_key("admin", "original-password", &["admin"])
475 .await
476 .unwrap();
477
478 let created = cred_store
480 .ensure_admin("admin", "new-password")
481 .await
482 .unwrap();
483 assert!(!created);
484
485 let roles = cred_store
487 .validate("admin", "original-password")
488 .await
489 .unwrap();
490 assert!(roles.is_some());
491
492 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 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 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 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 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 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 cred_store
559 .create_api_key("key", "password2", &["admin"])
560 .await
561 .unwrap();
562
563 let roles = cred_store.validate("key", "password1").await.unwrap();
565 assert!(roles.is_none());
566
567 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}