use std::collections::HashMap;
use std::path::Path;
use std::sync::Mutex;
use serde::{Deserialize, Serialize};
use crate::acme::jws::{self, Jwk};
use crate::error::CertmeshError;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Account {
pub id: String,
pub jwk: Jwk,
#[serde(default)]
pub contacts: Vec<String>,
pub status: AccountStatus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AccountStatus {
Valid,
Deactivated,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct AccountDb {
accounts: Vec<Account>,
}
pub struct AccountStore {
accounts: Mutex<HashMap<String, Account>>,
path: std::path::PathBuf,
}
impl AccountStore {
pub fn load(path: &Path) -> Self {
let map = match std::fs::read_to_string(path) {
Ok(json) => match serde_json::from_str::<AccountDb>(&json) {
Ok(db) => db.accounts.into_iter().map(|a| (a.id.clone(), a)).collect(),
Err(e) => {
tracing::warn!(error = %e, "ACME accounts.json parse failed; starting empty");
HashMap::new()
}
},
Err(_) => HashMap::new(),
};
Self {
accounts: Mutex::new(map),
path: path.to_path_buf(),
}
}
pub fn get(&self, id: &str) -> Option<Account> {
self.accounts
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(id)
.cloned()
}
pub fn register(
&self,
jwk: Jwk,
contacts: Vec<String>,
) -> Result<(Account, bool), CertmeshError> {
let id = jws::jwk_thumbprint(&jwk);
let mut map = self.accounts.lock().unwrap_or_else(|e| e.into_inner());
if let Some(existing) = map.get(&id) {
return Ok((existing.clone(), false));
}
let account = Account {
id: id.clone(),
jwk,
contacts,
status: AccountStatus::Valid,
};
map.insert(id, account.clone());
let snapshot: Vec<Account> = map.values().cloned().collect();
drop(map);
self.persist(&snapshot)?;
Ok((account, true))
}
fn persist(&self, accounts: &[Account]) -> Result<(), CertmeshError> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?;
}
let db = AccountDb {
accounts: accounts.to_vec(),
};
koi_common::persist::write_json_pretty(&self.path, &db).map_err(CertmeshError::Io)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine;
use p256::ecdsa::SigningKey;
fn b64() -> base64::engine::general_purpose::GeneralPurpose {
base64::engine::general_purpose::URL_SAFE_NO_PAD
}
fn random_jwk() -> Jwk {
let sk = SigningKey::random(&mut p256::elliptic_curve::rand_core::OsRng);
let point = sk.verifying_key().to_encoded_point(false);
Jwk {
kty: "EC".into(),
crv: "P-256".into(),
x: b64().encode(point.x().unwrap()),
y: b64().encode(point.y().unwrap()),
}
}
#[test]
fn register_is_idempotent_on_key() {
let dir = std::env::temp_dir().join("koi-acme-acct-test-1");
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("accounts.json");
let store = AccountStore::load(&path);
let jwk = random_jwk();
let (a1, created1) = store.register(jwk.clone(), vec![]).unwrap();
assert!(created1);
let (a2, created2) = store.register(jwk, vec![]).unwrap();
assert!(!created2, "same key must NOT create a second account");
assert_eq!(a1.id, a2.id);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn accounts_persist_across_reload() {
let dir = std::env::temp_dir().join("koi-acme-acct-test-2");
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("accounts.json");
let id = {
let store = AccountStore::load(&path);
let (a, _) = store.register(random_jwk(), vec![]).unwrap();
a.id
};
let store2 = AccountStore::load(&path);
assert!(
store2.get(&id).is_some(),
"account must survive a reload (daemon restart)"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn unknown_account_is_none() {
let dir = std::env::temp_dir().join("koi-acme-acct-test-3");
let _ = std::fs::remove_dir_all(&dir);
let store = AccountStore::load(&dir.join("accounts.json"));
assert!(store.get("nonexistent-thumbprint").is_none());
let _ = std::fs::remove_dir_all(&dir);
}
}