use std::collections::HashMap;
use std::sync::Mutex;
use koi_common::id::generate_short_id;
use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum OrderStatus {
Pending,
Ready,
Processing,
Valid,
Invalid,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum AuthzStatus {
Pending,
Valid,
Invalid,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ChallengeStatus {
Pending,
Processing,
Valid,
Invalid,
}
#[derive(Debug, Clone)]
pub struct Challenge {
pub id: String,
pub token: String,
pub status: ChallengeStatus,
}
#[derive(Debug, Clone)]
pub struct Authz {
pub id: String,
pub identifier: String,
pub wildcard: bool,
pub status: AuthzStatus,
pub challenge: Challenge,
pub account_id: String,
}
#[derive(Debug, Clone)]
pub struct Order {
pub id: String,
pub account_id: String,
pub identifiers: Vec<String>,
pub status: OrderStatus,
pub authz_ids: Vec<String>,
pub certificate_id: Option<String>,
}
impl Order {
pub fn authorized_names(&self) -> &[String] {
&self.identifiers
}
}
#[derive(Debug, Clone)]
pub struct IssuedCertificate {
pub id: String,
pub chain_pem: String,
}
#[derive(Default)]
pub struct OrderStore {
orders: Mutex<HashMap<String, Order>>,
authzs: Mutex<HashMap<String, Authz>>,
certs: Mutex<HashMap<String, IssuedCertificate>>,
}
impl OrderStore {
pub fn new() -> Self {
Self::default()
}
pub fn create_order(&self, account_id: &str, identifiers: Vec<String>) -> Order {
let order_id = generate_short_id();
let mut authz_ids = Vec::with_capacity(identifiers.len());
{
let mut authzs = self.authzs.lock().unwrap_or_else(|e| e.into_inner());
for ident in &identifiers {
let (name, wildcard) = match ident.strip_prefix("*.") {
Some(base) => (base.to_string(), true),
None => (ident.clone(), false),
};
let authz_id = generate_short_id();
let challenge = Challenge {
id: generate_short_id(),
token: generate_token(),
status: ChallengeStatus::Pending,
};
authzs.insert(
authz_id.clone(),
Authz {
id: authz_id.clone(),
identifier: name,
wildcard,
status: AuthzStatus::Pending,
challenge,
account_id: account_id.to_string(),
},
);
authz_ids.push(authz_id);
}
}
let order = Order {
id: order_id.clone(),
account_id: account_id.to_string(),
identifiers,
status: OrderStatus::Pending,
authz_ids,
certificate_id: None,
};
self.orders
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(order_id, order.clone());
order
}
pub fn get_order(&self, id: &str) -> Option<Order> {
self.orders
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(id)
.cloned()
}
pub fn get_authz(&self, id: &str) -> Option<Authz> {
self.authzs
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(id)
.cloned()
}
pub fn authz_by_challenge(&self, challenge_id: &str) -> Option<Authz> {
self.authzs
.lock()
.unwrap_or_else(|e| e.into_inner())
.values()
.find(|a| a.challenge.id == challenge_id)
.cloned()
}
pub fn mark_challenge_valid(&self, authz_id: &str) {
let order_id = {
let mut authzs = self.authzs.lock().unwrap_or_else(|e| e.into_inner());
let Some(authz) = authzs.get_mut(authz_id) else {
return;
};
authz.status = AuthzStatus::Valid;
authz.challenge.status = ChallengeStatus::Valid;
let orders = self.orders.lock().unwrap_or_else(|e| e.into_inner());
orders
.values()
.find(|o| o.authz_ids.contains(&authz_id.to_string()))
.map(|o| o.id.clone())
};
if let Some(order_id) = order_id {
self.recompute_order_status(&order_id);
}
}
pub fn mark_challenge_invalid(&self, authz_id: &str) {
let mut authzs = self.authzs.lock().unwrap_or_else(|e| e.into_inner());
if let Some(authz) = authzs.get_mut(authz_id) {
authz.status = AuthzStatus::Invalid;
authz.challenge.status = ChallengeStatus::Invalid;
let order_id = {
let orders = self.orders.lock().unwrap_or_else(|e| e.into_inner());
orders
.values()
.find(|o| o.authz_ids.contains(&authz_id.to_string()))
.map(|o| o.id.clone())
};
drop(authzs);
if let Some(order_id) = order_id {
if let Some(o) = self
.orders
.lock()
.unwrap_or_else(|e| e.into_inner())
.get_mut(&order_id)
{
o.status = OrderStatus::Invalid;
}
}
}
}
fn recompute_order_status(&self, order_id: &str) {
let authzs = self.authzs.lock().unwrap_or_else(|e| e.into_inner());
let mut orders = self.orders.lock().unwrap_or_else(|e| e.into_inner());
let Some(order) = orders.get_mut(order_id) else {
return;
};
if matches!(order.status, OrderStatus::Valid | OrderStatus::Invalid) {
return;
}
let all_valid = order.authz_ids.iter().all(|id| {
authzs
.get(id)
.map(|a| a.status == AuthzStatus::Valid)
.unwrap_or(false)
});
if all_valid {
order.status = OrderStatus::Ready;
}
}
pub fn record_certificate(&self, order_id: &str, chain_pem: String) -> String {
let cert_id = generate_short_id();
self.certs.lock().unwrap_or_else(|e| e.into_inner()).insert(
cert_id.clone(),
IssuedCertificate {
id: cert_id.clone(),
chain_pem,
},
);
if let Some(order) = self
.orders
.lock()
.unwrap_or_else(|e| e.into_inner())
.get_mut(order_id)
{
order.certificate_id = Some(cert_id.clone());
order.status = OrderStatus::Valid;
}
cert_id
}
pub fn get_certificate(&self, cert_id: &str) -> Option<IssuedCertificate> {
self.certs
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(cert_id)
.cloned()
}
}
fn generate_token() -> String {
use base64::Engine;
use rand::RngCore;
let mut bytes = [0u8; 32];
rand::rng().fill_bytes(&mut bytes);
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn order_creates_one_authz_per_identifier() {
let store = OrderStore::new();
let order = store.create_order("acct-1", vec!["a.lan".into(), "b.lan".into()]);
assert_eq!(order.authz_ids.len(), 2);
assert_eq!(order.status, OrderStatus::Pending);
for id in &order.authz_ids {
let authz = store.get_authz(id).unwrap();
assert_eq!(authz.status, AuthzStatus::Pending);
assert_eq!(authz.challenge.status, ChallengeStatus::Pending);
assert!(!authz.challenge.token.is_empty());
}
}
#[test]
fn wildcard_identifier_strips_prefix_and_flags() {
let store = OrderStore::new();
let order = store.create_order("acct-1", vec!["*.lan".into()]);
let authz = store.get_authz(&order.authz_ids[0]).unwrap();
assert_eq!(authz.identifier, "lan");
assert!(authz.wildcard);
}
#[test]
fn order_becomes_ready_when_all_authz_valid() {
let store = OrderStore::new();
let order = store.create_order("acct-1", vec!["a.lan".into(), "b.lan".into()]);
store.mark_challenge_valid(&order.authz_ids[0]);
assert_eq!(
store.get_order(&order.id).unwrap().status,
OrderStatus::Pending,
"still pending while one authz is unvalidated"
);
store.mark_challenge_valid(&order.authz_ids[1]);
assert_eq!(
store.get_order(&order.id).unwrap().status,
OrderStatus::Ready,
"ready once every authz is valid"
);
}
#[test]
fn invalid_challenge_invalidates_order() {
let store = OrderStore::new();
let order = store.create_order("acct-1", vec!["a.lan".into()]);
store.mark_challenge_invalid(&order.authz_ids[0]);
assert_eq!(
store.get_order(&order.id).unwrap().status,
OrderStatus::Invalid
);
}
#[test]
fn authz_by_challenge_finds_it() {
let store = OrderStore::new();
let order = store.create_order("acct-1", vec!["a.lan".into()]);
let authz = store.get_authz(&order.authz_ids[0]).unwrap();
let found = store.authz_by_challenge(&authz.challenge.id).unwrap();
assert_eq!(found.id, authz.id);
}
#[test]
fn record_certificate_makes_order_valid() {
let store = OrderStore::new();
let order = store.create_order("acct-1", vec!["a.lan".into()]);
let cert_id = store.record_certificate(&order.id, "PEM".into());
let o = store.get_order(&order.id).unwrap();
assert_eq!(o.status, OrderStatus::Valid);
assert_eq!(o.certificate_id.as_deref(), Some(cert_id.as_str()));
assert_eq!(store.get_certificate(&cert_id).unwrap().chain_pem, "PEM");
}
}