use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
use serde::{Deserialize, Serialize};
use tracing::{debug, info};
use crate::{Result, Secret, SecretsError, SecretsStore};
const CREDENTIALS_SCOPE: &str = "credentials";
#[derive(Debug, Clone, Serialize, Deserialize)]
struct StoredCredential {
hash: String,
roles: Vec<String>,
}
pub struct CredentialStore<S: SecretsStore> {
store: S,
}
impl<S: SecretsStore> std::fmt::Debug for CredentialStore<S> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CredentialStore")
.field("store", &"<secrets store>")
.finish()
}
}
impl<S: SecretsStore> CredentialStore<S> {
pub fn new(store: S) -> Self {
Self { store }
}
#[must_use]
pub fn store(&self) -> &S {
&self.store
}
pub async fn validate(&self, api_key: &str, api_secret: &str) -> Result<Option<Vec<String>>> {
let secret = match self.store.get_secret(CREDENTIALS_SCOPE, api_key).await {
Ok(s) => s,
Err(SecretsError::NotFound { .. }) => {
debug!(api_key = %api_key, "Credential not found");
return Ok(None);
}
Err(e) => return Err(e),
};
let stored: StoredCredential = serde_json::from_str(secret.expose()).map_err(|e| {
SecretsError::Storage(format!("corrupt credential record for '{api_key}': {e}"))
})?;
let parsed_hash = PasswordHash::new(&stored.hash).map_err(|e| {
SecretsError::Storage(format!("invalid password hash for '{api_key}': {e}"))
})?;
let argon2 = Argon2::default();
if argon2
.verify_password(api_secret.as_bytes(), &parsed_hash)
.is_ok()
{
debug!(api_key = %api_key, "Credential validated successfully");
Ok(Some(stored.roles))
} else {
debug!(api_key = %api_key, "Invalid password");
Ok(None)
}
}
pub async fn create_api_key(
&self,
api_key: &str,
password: &str,
roles: &[&str],
) -> Result<()> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let hash = argon2
.hash_password(password.as_bytes(), &salt)
.map_err(|e| SecretsError::Encryption(format!("failed to hash password: {e}")))?
.to_string();
let credential = StoredCredential {
hash,
roles: roles.iter().map(|r| (*r).to_string()).collect(),
};
let json = serde_json::to_string(&credential)
.map_err(|e| SecretsError::Storage(format!("failed to serialise credential: {e}")))?;
self.store
.set_secret(CREDENTIALS_SCOPE, api_key, &Secret::new(json))
.await?;
info!(api_key = %api_key, roles = ?roles, "Created API key credential");
Ok(())
}
pub async fn delete_api_key(&self, api_key: &str) -> Result<()> {
self.store.delete_secret(CREDENTIALS_SCOPE, api_key).await?;
info!(api_key = %api_key, "Deleted API key credential");
Ok(())
}
pub async fn exists(&self, api_key: &str) -> Result<bool> {
self.store.exists(CREDENTIALS_SCOPE, api_key).await
}
pub async fn set_roles(&self, api_key: &str, roles: &[&str]) -> Result<()> {
let secret = self.store.get_secret(CREDENTIALS_SCOPE, api_key).await?;
let mut stored: StoredCredential = serde_json::from_str(secret.expose()).map_err(|e| {
SecretsError::Storage(format!("corrupt credential record for '{api_key}': {e}"))
})?;
stored.roles = roles.iter().map(|r| (*r).to_string()).collect();
let json = serde_json::to_string(&stored)
.map_err(|e| SecretsError::Storage(format!("failed to serialise credential: {e}")))?;
self.store
.set_secret(CREDENTIALS_SCOPE, api_key, &Secret::new(json))
.await?;
info!(api_key = %api_key, roles = ?roles, "Updated credential roles");
Ok(())
}
pub async fn ensure_admin(&self, api_key: &str, password: &str) -> Result<bool> {
if self.exists(api_key).await? {
debug!(api_key = %api_key, "Admin credential already exists");
return Ok(false);
}
self.create_api_key(api_key, password, &["admin"]).await?;
Ok(true)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{EncryptionKey, PersistentSecretsStore};
use zlayer_paths::ZLayerDirs;
async fn create_test_store() -> (PersistentSecretsStore, zlayer_types::Scratch) {
let temp_dir = ZLayerDirs::system_default()
.scratch_dir("create-test-store-")
.unwrap();
let db_path = temp_dir.path().join("test_creds.sqlite");
let key = EncryptionKey::generate();
let store = PersistentSecretsStore::open(&db_path, key).await.unwrap();
(store, temp_dir)
}
#[tokio::test]
async fn test_create_and_validate() {
let (store, _temp) = create_test_store().await;
let cred_store = CredentialStore::new(store);
cred_store
.create_api_key("test-key", "test-secret", &["admin", "reader"])
.await
.unwrap();
let roles = cred_store
.validate("test-key", "test-secret")
.await
.unwrap();
assert!(roles.is_some());
let roles = roles.unwrap();
assert!(roles.contains(&"admin".to_string()));
assert!(roles.contains(&"reader".to_string()));
}
#[tokio::test]
async fn test_validate_wrong_password() {
let (store, _temp) = create_test_store().await;
let cred_store = CredentialStore::new(store);
cred_store
.create_api_key("test-key", "correct-password", &["admin"])
.await
.unwrap();
let roles = cred_store
.validate("test-key", "wrong-password")
.await
.unwrap();
assert!(roles.is_none());
}
#[tokio::test]
async fn test_validate_nonexistent_key() {
let (store, _temp) = create_test_store().await;
let cred_store = CredentialStore::new(store);
let roles = cred_store
.validate("nonexistent", "password")
.await
.unwrap();
assert!(roles.is_none());
}
#[tokio::test]
async fn test_exists() {
let (store, _temp) = create_test_store().await;
let cred_store = CredentialStore::new(store);
assert!(!cred_store.exists("test-key").await.unwrap());
cred_store
.create_api_key("test-key", "password", &["admin"])
.await
.unwrap();
assert!(cred_store.exists("test-key").await.unwrap());
}
#[tokio::test]
async fn test_delete_api_key() {
let (store, _temp) = create_test_store().await;
let cred_store = CredentialStore::new(store);
cred_store
.create_api_key("delete-me", "password", &["admin"])
.await
.unwrap();
assert!(cred_store.exists("delete-me").await.unwrap());
cred_store.delete_api_key("delete-me").await.unwrap();
assert!(!cred_store.exists("delete-me").await.unwrap());
}
#[tokio::test]
async fn test_set_roles_preserves_hash() {
let (store, _temp) = create_test_store().await;
let cred_store = CredentialStore::new(store);
cred_store
.create_api_key("alice@example.com", "hunter2hunter2", &["user"])
.await
.unwrap();
cred_store
.set_roles("alice@example.com", &["admin"])
.await
.unwrap();
let roles = cred_store
.validate("alice@example.com", "hunter2hunter2")
.await
.unwrap()
.expect("should validate");
assert_eq!(roles, vec!["admin".to_string()]);
}
#[tokio::test]
async fn test_set_roles_missing_errors() {
let (store, _temp) = create_test_store().await;
let cred_store = CredentialStore::new(store);
let err = cred_store
.set_roles("nonexistent", &["admin"])
.await
.unwrap_err();
assert!(
matches!(err, SecretsError::NotFound { .. }),
"unexpected: {err}"
);
}
#[tokio::test]
async fn test_ensure_admin_creates() {
let (store, _temp) = create_test_store().await;
let cred_store = CredentialStore::new(store);
let created = cred_store
.ensure_admin("admin", "admin-password")
.await
.unwrap();
assert!(created);
let roles = cred_store
.validate("admin", "admin-password")
.await
.unwrap();
assert!(roles.is_some());
assert!(roles.unwrap().contains(&"admin".to_string()));
}
#[tokio::test]
async fn test_ensure_admin_skips_existing() {
let (store, _temp) = create_test_store().await;
let cred_store = CredentialStore::new(store);
cred_store
.create_api_key("admin", "original-password", &["admin"])
.await
.unwrap();
let created = cred_store
.ensure_admin("admin", "new-password")
.await
.unwrap();
assert!(!created);
let roles = cred_store
.validate("admin", "original-password")
.await
.unwrap();
assert!(roles.is_some());
let roles = cred_store.validate("admin", "new-password").await.unwrap();
assert!(roles.is_none());
}
#[tokio::test]
async fn test_overwrite_credential() {
let (store, _temp) = create_test_store().await;
let cred_store = CredentialStore::new(store);
cred_store
.create_api_key("key", "password1", &["reader"])
.await
.unwrap();
cred_store
.create_api_key("key", "password2", &["admin"])
.await
.unwrap();
let roles = cred_store.validate("key", "password1").await.unwrap();
assert!(roles.is_none());
let roles = cred_store.validate("key", "password2").await.unwrap();
assert!(roles.is_some());
let roles = roles.unwrap();
assert!(roles.contains(&"admin".to_string()));
assert!(!roles.contains(&"reader".to_string()));
}
}