use std::fs;
use std::io;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use sha2::{Digest, Sha256};
use uuid::Uuid;
pub const ADMIN_TOKEN_PREFIX: &str = "la_admin_";
pub const CLAIM_FILE_NAME: &str = "admin-claim.json";
pub const DEFAULT_CANDIDATE_TTL_SECS: u64 = 120;
#[derive(Debug, Clone)]
pub struct AdminUiConfig {
pub enabled: bool,
pub listen_addr: SocketAddr,
pub candidate_ttl: Duration,
}
impl Default for AdminUiConfig {
fn default() -> Self {
Self {
enabled: false,
listen_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
candidate_ttl: Duration::from_secs(DEFAULT_CANDIDATE_TTL_SECS),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClaimError {
AlreadyClaimed,
ProvisionedByEnvironment,
NoCandidate,
ClaimIdMismatch,
TokenMismatch,
Storage,
}
impl std::fmt::Display for ClaimError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let msg = match self {
Self::AlreadyClaimed => "admin is already claimed; present the admin token",
Self::ProvisionedByEnvironment => {
"an admin credential is provisioned by environment; bootstrap is disabled"
}
Self::NoCandidate => "no outstanding bootstrap candidate; start over with a new mint",
Self::ClaimIdMismatch => "claim_id does not match the outstanding candidate",
Self::TokenMismatch => "the presented token does not match the outstanding candidate",
Self::Storage => "failed to persist the admin claim",
};
f.write_str(msg)
}
}
impl std::error::Error for ClaimError {}
#[derive(Debug, Clone)]
pub struct Candidate {
pub claim_id: String,
pub token: String,
pub expires_in_secs: u64,
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone, serde::Serialize)]
pub struct AdminStatus {
pub claimed: bool,
pub bootstrap_open: bool,
pub provisioned_by_environment: bool,
pub candidate_pending: bool,
pub claimed_at: Option<u64>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
struct ClaimFile {
token_sha256: String,
claimed_at: u64,
}
#[derive(Debug, Default)]
struct ClaimState {
active_sha256: Option<String>,
claimed_at: Option<u64>,
candidate: Option<PendingCandidate>,
}
#[derive(Debug, Clone)]
struct PendingCandidate {
claim_id: String,
token_sha256: String,
expires_at: SystemTime,
}
#[derive(Debug)]
pub struct AdminClaim {
env_key: Option<String>,
claim_path: Option<PathBuf>,
candidate_ttl: Duration,
state: Mutex<ClaimState>,
}
impl AdminClaim {
#[must_use]
pub fn load(env_key: Option<String>, data_dir: &Path, candidate_ttl: Duration) -> Self {
let claim_path = data_dir.join(CLAIM_FILE_NAME);
let stored = fs::read_to_string(&claim_path)
.ok()
.and_then(|raw| serde_json::from_str::<ClaimFile>(&raw).ok())
.filter(|file| !file.token_sha256.is_empty());
let state = ClaimState {
claimed_at: stored.as_ref().map(|file| file.claimed_at),
active_sha256: stored.map(|file| file.token_sha256),
candidate: None,
};
Self {
env_key: env_key.filter(|key| !key.is_empty()),
claim_path: Some(claim_path),
candidate_ttl,
state: Mutex::new(state),
}
}
#[must_use]
pub fn in_memory(env_key: Option<String>, candidate_ttl: Duration) -> Self {
Self {
env_key: env_key.filter(|key| !key.is_empty()),
claim_path: None,
candidate_ttl,
state: Mutex::new(ClaimState::default()),
}
}
#[must_use]
pub const fn provisioned_by_environment(&self) -> bool {
self.env_key.is_some()
}
#[must_use]
pub fn is_claimed(&self) -> bool {
self.env_key.is_some() || self.locked().active_sha256.is_some()
}
#[must_use]
pub fn status(&self) -> AdminStatus {
let mut state = self.locked();
Self::expire_candidate(&mut state);
let provisioned = self.env_key.is_some();
let claimed = provisioned || state.active_sha256.is_some();
AdminStatus {
claimed,
bootstrap_open: !claimed,
provisioned_by_environment: provisioned,
candidate_pending: state.candidate.is_some(),
claimed_at: state.claimed_at,
}
}
#[must_use]
pub fn verify(&self, presented: &str) -> bool {
if presented.is_empty() {
return false;
}
if let Some(env_key) = self.env_key.as_deref()
&& constant_time_eq(env_key.as_bytes(), presented.as_bytes())
{
return true;
}
let state = self.locked();
state.active_sha256.as_deref().is_some_and(|digest| {
constant_time_eq(digest.as_bytes(), sha256_hex(presented).as_bytes())
})
}
#[allow(clippy::significant_drop_tightening)]
pub fn begin(&self) -> Result<Candidate, ClaimError> {
if self.env_key.is_some() {
return Err(ClaimError::ProvisionedByEnvironment);
}
let mut state = self.locked();
if state.active_sha256.is_some() {
return Err(ClaimError::AlreadyClaimed);
}
let claim_id = Uuid::new_v4().to_string();
let token = mint_admin_token();
state.candidate = Some(PendingCandidate {
claim_id: claim_id.clone(),
token_sha256: sha256_hex(&token),
expires_at: SystemTime::now() + self.candidate_ttl,
});
Ok(Candidate {
claim_id,
token,
expires_in_secs: self.candidate_ttl.as_secs(),
})
}
#[allow(clippy::significant_drop_tightening)]
pub fn confirm(&self, claim_id: &str, presented: &str) -> Result<(), ClaimError> {
if self.env_key.is_some() {
return Err(ClaimError::ProvisionedByEnvironment);
}
let mut state = self.locked();
if state.active_sha256.is_some() {
return Err(ClaimError::AlreadyClaimed);
}
Self::expire_candidate(&mut state);
let candidate = state.candidate.clone().ok_or(ClaimError::NoCandidate)?;
if !constant_time_eq(candidate.claim_id.as_bytes(), claim_id.as_bytes()) {
return Err(ClaimError::ClaimIdMismatch);
}
let digest = sha256_hex(presented);
if !constant_time_eq(candidate.token_sha256.as_bytes(), digest.as_bytes()) {
return Err(ClaimError::TokenMismatch);
}
let claimed_at = unix_secs();
self.persist(&digest, claimed_at)
.map_err(|_| ClaimError::Storage)?;
state.active_sha256 = Some(digest);
state.claimed_at = Some(claimed_at);
state.candidate = None;
Ok(())
}
#[allow(clippy::significant_drop_tightening)]
pub fn rotate(&self) -> Result<String, ClaimError> {
if self.env_key.is_some() {
return Err(ClaimError::ProvisionedByEnvironment);
}
let mut state = self.locked();
if state.active_sha256.is_none() {
return Err(ClaimError::NoCandidate);
}
let token = mint_admin_token();
let digest = sha256_hex(&token);
let claimed_at = unix_secs();
self.persist(&digest, claimed_at)
.map_err(|_| ClaimError::Storage)?;
state.active_sha256 = Some(digest);
state.claimed_at = Some(claimed_at);
state.candidate = None;
Ok(token)
}
fn persist(&self, token_sha256: &str, claimed_at: u64) -> io::Result<()> {
let Some(path) = self.claim_path.as_ref() else {
return Ok(());
};
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let file = ClaimFile {
token_sha256: token_sha256.to_string(),
claimed_at,
};
let body = serde_json::to_string_pretty(&file)?;
let tmp = path.with_extension("json.tmp");
fs::write(&tmp, body)?;
fs::rename(&tmp, path)
}
fn expire_candidate(state: &mut ClaimState) {
let expired = state
.candidate
.as_ref()
.is_some_and(|candidate| candidate.expires_at <= SystemTime::now());
if expired {
state.candidate = None;
}
}
fn locked(&self) -> std::sync::MutexGuard<'_, ClaimState> {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
}
fn mint_admin_token() -> String {
let high = Uuid::new_v4().simple().to_string();
let low = Uuid::new_v4().simple().to_string();
format!("{ADMIN_TOKEN_PREFIX}{high}{low}")
}
#[must_use]
pub fn sha256_hex(value: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(value.as_bytes());
hex::encode(hasher.finalize())
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
a.iter()
.zip(b.iter())
.fold(0u8, |acc, (x, y)| acc | (x ^ y))
== 0
}
fn unix_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs())
}
#[cfg(test)]
mod tests {
use super::*;
fn claim() -> AdminClaim {
AdminClaim::in_memory(None, Duration::from_secs(120))
}
#[test]
fn unclaimed_by_default() {
let admin = claim();
assert!(!admin.is_claimed());
assert!(admin.status().bootstrap_open);
}
#[test]
fn candidate_alone_does_not_authorise() {
let admin = claim();
let candidate = admin.begin().expect("mint");
assert!(!admin.verify(&candidate.token));
assert!(!admin.is_claimed());
assert!(admin.status().bootstrap_open);
}
#[test]
fn confirm_activates_and_closes_bootstrap() {
let admin = claim();
let candidate = admin.begin().expect("mint");
admin
.confirm(&candidate.claim_id, &candidate.token)
.expect("confirm");
assert!(admin.verify(&candidate.token));
assert!(admin.is_claimed());
assert!(!admin.status().bootstrap_open);
assert_eq!(admin.begin().unwrap_err(), ClaimError::AlreadyClaimed);
}
#[test]
fn unconfirmed_mint_is_always_recoverable() {
let admin = claim();
let abandoned = admin.begin().expect("first mint");
let second = admin.begin().expect("second mint stays allowed");
assert_eq!(
admin.confirm(&abandoned.claim_id, &abandoned.token),
Err(ClaimError::ClaimIdMismatch)
);
admin
.confirm(&second.claim_id, &second.token)
.expect("second confirms");
assert!(admin.verify(&second.token));
}
#[test]
fn expired_candidate_leaves_system_unclaimed() {
let admin = AdminClaim::in_memory(None, Duration::from_secs(0));
let candidate = admin.begin().expect("mint");
assert_eq!(
admin.confirm(&candidate.claim_id, &candidate.token),
Err(ClaimError::NoCandidate)
);
assert!(!admin.is_claimed());
assert!(admin.status().bootstrap_open);
}
#[test]
fn wrong_token_is_rejected() {
let admin = claim();
let candidate = admin.begin().expect("mint");
assert_eq!(
admin.confirm(&candidate.claim_id, "la_admin_nope"),
Err(ClaimError::TokenMismatch)
);
assert!(!admin.is_claimed());
}
#[test]
fn environment_key_disables_bootstrap() {
let admin = AdminClaim::in_memory(Some("env-key".into()), Duration::from_secs(60));
assert!(admin.is_claimed());
assert!(admin.verify("env-key"));
assert!(!admin.verify("other"));
assert_eq!(
admin.begin().unwrap_err(),
ClaimError::ProvisionedByEnvironment
);
}
#[test]
fn rotate_replaces_the_credential() {
let admin = claim();
let candidate = admin.begin().expect("mint");
admin
.confirm(&candidate.claim_id, &candidate.token)
.expect("confirm");
let replacement = admin.rotate().expect("rotate");
assert!(admin.verify(&replacement));
assert!(!admin.verify(&candidate.token));
}
#[test]
fn rotate_requires_an_existing_claim() {
assert_eq!(claim().rotate().unwrap_err(), ClaimError::NoCandidate);
}
#[test]
fn claim_survives_restart() {
let dir = tempfile::tempdir().expect("tempdir");
let ttl = Duration::from_secs(60);
let admin = AdminClaim::load(None, dir.path(), ttl);
let candidate = admin.begin().expect("mint");
admin
.confirm(&candidate.claim_id, &candidate.token)
.expect("confirm");
let reloaded = AdminClaim::load(None, dir.path(), ttl);
assert!(reloaded.is_claimed());
assert!(reloaded.verify(&candidate.token));
assert_eq!(reloaded.begin().unwrap_err(), ClaimError::AlreadyClaimed);
}
#[test]
fn mint_never_persists_anything() {
let dir = tempfile::tempdir().expect("tempdir");
let ttl = Duration::from_secs(60);
let admin = AdminClaim::load(None, dir.path(), ttl);
let _candidate = admin.begin().expect("mint");
assert!(!dir.path().join(CLAIM_FILE_NAME).exists());
assert!(!AdminClaim::load(None, dir.path(), ttl).is_claimed());
}
}