use std::{path::Path, sync::Arc};
use axum::http::{HeaderMap, header::AUTHORIZATION};
use miette::{Result, miette};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tokio::sync::Mutex;
use crate::{
daat_locus_paths::daat_locus_paths,
persistence::{PersistenceFileMode, write_bytes_atomic},
};
const BEARER_PREFIX: &str = "Bearer ";
const LOCAL_CLI_TOKEN_ID: &str = "local-cli";
const MIN_DAEMON_TOKEN_LEN: usize = 64;
const MAX_TOKEN_NAME_LEN: usize = 64;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DaemonAuthToken(String);
impl DaemonAuthToken {
fn generate() -> Self {
let mut token = String::with_capacity(128);
for _ in 0..4 {
token.push_str(&uuid::Uuid::new_v4().simple().to_string());
}
Self(token)
}
fn parse(token: &str) -> Result<Self> {
if token.len() < MIN_DAEMON_TOKEN_LEN {
return Err(miette!("daemon auth token is too short"));
}
if token
.bytes()
.any(|byte| !byte.is_ascii_graphic() || byte.is_ascii_whitespace())
{
return Err(miette!("daemon auth token contains invalid characters"));
}
Ok(Self(token.to_string()))
}
pub(crate) fn as_str(&self) -> &str {
&self.0
}
pub(crate) fn bearer_value(&self) -> String {
format!("{BEARER_PREFIX}{}", self.as_str())
}
}
#[derive(Clone)]
pub struct DaemonTokenRegistryHandle {
path: std::path::PathBuf,
local_token_path: std::path::PathBuf,
write_lock: Arc<Mutex<()>>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CreatedDaemonToken {
pub id: String,
pub name: String,
pub token: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DaemonTokenListEntry {
pub id: String,
pub name: String,
pub created_at_ms: i64,
pub last_used_at_ms: Option<i64>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
struct DaemonTokenRegistry {
#[serde(default)]
tokens: Vec<DaemonTokenRecord>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
struct DaemonTokenRecord {
id: String,
name: String,
created_at_ms: i64,
#[serde(default)]
last_used_at_ms: Option<i64>,
token_hash: String,
}
impl DaemonTokenRegistryHandle {
pub async fn load_or_create() -> Result<Self> {
let paths = daat_locus_paths().await;
let handle = Self {
path: paths.daemon_token_registry_file(),
local_token_path: paths.daemon_token_file(),
write_lock: Arc::new(Mutex::new(())),
};
handle.ensure_registry().await?;
Ok(handle)
}
pub async fn authorize_headers(&self, headers: &HeaderMap) -> bool {
let Some(value) = headers.get(AUTHORIZATION) else {
return false;
};
let Ok(value) = value.to_str() else {
return false;
};
let Some(token) = value.strip_prefix(BEARER_PREFIX) else {
return false;
};
self.authorize_token(token).await
}
pub async fn authorize_token(&self, token: &str) -> bool {
match self.authorize_token_record(token).await {
Ok(authorized) => authorized,
Err(err) => {
tracing::warn!("daemon token authorization failed: {err}");
false
}
}
}
async fn authorize_token_record(&self, token: &str) -> Result<bool> {
let _guard = self.write_lock.lock().await;
let mut registry = read_registry(&self.path).await?;
let token_hash = hash_token(token);
let Some(record) = registry
.tokens
.iter_mut()
.find(|record| constant_time_eq(&record.token_hash, &token_hash))
else {
return Ok(false);
};
record.last_used_at_ms = Some(now_ms());
write_registry(&self.path, ®istry).await?;
Ok(true)
}
async fn ensure_registry(&self) -> Result<()> {
let _guard = self.write_lock.lock().await;
let mut registry = read_registry(&self.path).await?;
let local_token = load_or_create_local_daemon_auth_token_at(&self.local_token_path).await?;
let changed = ensure_local_cli_record(&mut registry, &local_token);
if changed || !self.path.exists() {
write_registry(&self.path, ®istry).await?;
} else {
harden_private_file_permissions(&self.path)?;
}
Ok(())
}
pub async fn create_token(&self, name: &str) -> Result<CreatedDaemonToken> {
let name = normalize_token_name(name)?;
if name == LOCAL_CLI_TOKEN_ID {
return Err(miette!("token name `{LOCAL_CLI_TOKEN_ID}` is reserved"));
}
let _guard = self.write_lock.lock().await;
let mut registry = read_registry(&self.path).await?;
reject_duplicate_name(®istry, &name)?;
let token = DaemonAuthToken::generate();
let record = DaemonTokenRecord {
id: uuid::Uuid::new_v4().to_string(),
name,
created_at_ms: now_ms(),
last_used_at_ms: None,
token_hash: hash_token(token.as_str()),
};
let created = CreatedDaemonToken {
id: record.id.clone(),
name: record.name.clone(),
token: token.as_str().to_string(),
};
registry.tokens.push(record);
sort_registry(&mut registry);
write_registry(&self.path, ®istry).await?;
Ok(created)
}
pub async fn list_tokens(&self) -> Result<Vec<DaemonTokenListEntry>> {
let _guard = self.write_lock.lock().await;
let registry = read_registry(&self.path).await?;
Ok(registry.tokens.into_iter().map(Into::into).collect())
}
pub async fn revoke_token(&self, selector: &str) -> Result<DaemonTokenListEntry> {
let _guard = self.write_lock.lock().await;
let mut registry = read_registry(&self.path).await?;
let index = find_token_index(®istry, selector)?;
if registry.tokens[index].id == LOCAL_CLI_TOKEN_ID {
return Err(miette!(
"`{LOCAL_CLI_TOKEN_ID}` is required for local CLI access; rotate it instead"
));
}
let removed = registry.tokens.remove(index);
write_registry(&self.path, ®istry).await?;
Ok(removed.into())
}
pub async fn rotate_token(&self, selector: &str) -> Result<CreatedDaemonToken> {
let _guard = self.write_lock.lock().await;
let mut registry = read_registry(&self.path).await?;
let index = find_token_index(®istry, selector)?;
let token = DaemonAuthToken::generate();
registry.tokens[index].token_hash = hash_token(token.as_str());
registry.tokens[index].last_used_at_ms = None;
let rotated = CreatedDaemonToken {
id: registry.tokens[index].id.clone(),
name: registry.tokens[index].name.clone(),
token: token.as_str().to_string(),
};
if registry.tokens[index].id == LOCAL_CLI_TOKEN_ID {
write_local_daemon_auth_token_at(&self.local_token_path, &token).await?;
}
write_registry(&self.path, ®istry).await?;
Ok(rotated)
}
}
impl From<DaemonTokenRecord> for DaemonTokenListEntry {
fn from(record: DaemonTokenRecord) -> Self {
Self {
id: record.id,
name: record.name,
created_at_ms: record.created_at_ms,
last_used_at_ms: record.last_used_at_ms,
}
}
}
pub async fn load_daemon_auth_token() -> Result<DaemonAuthToken> {
let handle = DaemonTokenRegistryHandle::load_or_create().await?;
load_local_daemon_auth_token_at(&handle.local_token_path).await
}
pub async fn load_or_create_daemon_token_registry() -> Result<DaemonTokenRegistryHandle> {
DaemonTokenRegistryHandle::load_or_create().await
}
pub async fn create_daemon_token(name: &str) -> Result<CreatedDaemonToken> {
DaemonTokenRegistryHandle::load_or_create()
.await?
.create_token(name)
.await
}
pub async fn list_daemon_tokens() -> Result<Vec<DaemonTokenListEntry>> {
DaemonTokenRegistryHandle::load_or_create()
.await?
.list_tokens()
.await
}
pub async fn revoke_daemon_token(selector: &str) -> Result<DaemonTokenListEntry> {
DaemonTokenRegistryHandle::load_or_create()
.await?
.revoke_token(selector)
.await
}
pub async fn rotate_daemon_token(selector: &str) -> Result<CreatedDaemonToken> {
DaemonTokenRegistryHandle::load_or_create()
.await?
.rotate_token(selector)
.await
}
fn ensure_local_cli_record(registry: &mut DaemonTokenRegistry, token: &DaemonAuthToken) -> bool {
let token_hash = hash_token(token.as_str());
let now = now_ms();
let mut changed = false;
if let Some(record) = registry
.tokens
.iter_mut()
.find(|record| record.id == LOCAL_CLI_TOKEN_ID)
{
if record.name != LOCAL_CLI_TOKEN_ID {
record.name = LOCAL_CLI_TOKEN_ID.to_string();
changed = true;
}
if record.token_hash != token_hash {
record.token_hash = token_hash;
record.last_used_at_ms = None;
changed = true;
}
} else {
registry.tokens.push(DaemonTokenRecord {
id: LOCAL_CLI_TOKEN_ID.to_string(),
name: LOCAL_CLI_TOKEN_ID.to_string(),
created_at_ms: now,
last_used_at_ms: None,
token_hash,
});
changed = true;
}
sort_registry(registry);
changed
}
async fn read_registry(path: &Path) -> Result<DaemonTokenRegistry> {
let raw = match tokio::fs::read_to_string(path).await {
Ok(raw) => raw,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Ok(DaemonTokenRegistry::default());
}
Err(err) => {
return Err(miette!(
"read daemon token registry {} failed: {err}",
path.display()
));
}
};
serde_json::from_str(&raw).map_err(|err| {
miette!(
"parse daemon token registry {} failed: {err}",
path.display()
)
})
}
async fn write_registry(path: &Path, registry: &DaemonTokenRegistry) -> Result<()> {
let bytes = serde_json::to_vec_pretty(registry)
.map_err(|err| miette!("serialize daemon token registry failed: {err}"))?;
write_private_file(path, &bytes).await
}
async fn load_or_create_local_daemon_auth_token_at(path: &Path) -> Result<DaemonAuthToken> {
match tokio::fs::try_exists(path).await {
Ok(true) => load_local_daemon_auth_token_at(path).await,
Ok(false) => {
let token = DaemonAuthToken::generate();
write_local_daemon_auth_token_at(path, &token).await?;
Ok(token)
}
Err(err) => Err(miette!(
"check daemon auth token {} failed: {err}",
path.display()
)),
}
}
async fn load_local_daemon_auth_token_at(path: &Path) -> Result<DaemonAuthToken> {
let raw = tokio::fs::read_to_string(path)
.await
.map_err(|err| miette!("read daemon auth token {} failed: {err}", path.display()))?;
harden_private_file_permissions(path)?;
DaemonAuthToken::parse(raw.trim())
.map_err(|err| miette!("invalid daemon auth token at {}: {err}", path.display()))
}
async fn write_local_daemon_auth_token_at(path: &Path, token: &DaemonAuthToken) -> Result<()> {
let mut bytes = token.as_str().as_bytes().to_vec();
bytes.push(b'\n');
write_private_file(path, &bytes).await
}
async fn write_private_file(path: &Path, bytes: &[u8]) -> Result<()> {
write_bytes_atomic(
path.to_path_buf(),
bytes.to_vec(),
PersistenceFileMode::Private,
)
.await
.map_err(|err| miette!("write daemon token file {} failed: {err}", path.display()))
}
#[cfg(unix)]
fn harden_private_file_permissions(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|err| {
miette!(
"set daemon token file permissions {} failed: {err}",
path.display()
)
})
}
#[cfg(not(unix))]
const fn harden_private_file_permissions(_path: &Path) -> Result<()> {
Ok(())
}
fn normalize_token_name(name: &str) -> Result<String> {
let name = name.trim();
if name.is_empty() {
return Err(miette!("daemon token name cannot be empty"));
}
if name.len() > MAX_TOKEN_NAME_LEN {
return Err(miette!(
"daemon token name cannot exceed {MAX_TOKEN_NAME_LEN} bytes"
));
}
if !name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
{
return Err(miette!(
"daemon token name can only contain ASCII letters, digits, '-', '_', or '.'"
));
}
Ok(name.to_string())
}
fn reject_duplicate_name(registry: &DaemonTokenRegistry, name: &str) -> Result<()> {
if registry.tokens.iter().any(|record| record.name == name) {
return Err(miette!("daemon token name `{name}` already exists"));
}
Ok(())
}
fn find_token_index(registry: &DaemonTokenRegistry, selector: &str) -> Result<usize> {
let selector = selector.trim();
let matches: Vec<_> = registry
.tokens
.iter()
.enumerate()
.filter(|(_, record)| record.id == selector || record.name == selector)
.map(|(index, _)| index)
.collect();
match matches.as_slice() {
[index] => Ok(*index),
[] => Err(miette!("daemon token `{selector}` not found")),
_ => Err(miette!("daemon token selector `{selector}` is ambiguous")),
}
}
fn sort_registry(registry: &mut DaemonTokenRegistry) {
registry.tokens.sort_by(|left, right| {
(left.id != LOCAL_CLI_TOKEN_ID)
.cmp(&(right.id != LOCAL_CLI_TOKEN_ID))
.then_with(|| left.name.cmp(&right.name))
.then_with(|| left.id.cmp(&right.id))
});
}
fn hash_token(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
format!("sha256:{}", hex_encode(&hasher.finalize()))
}
fn hex_encode(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push(HEX[(byte >> 4) as usize] as char);
out.push(HEX[(byte & 0x0f) as usize] as char);
}
out
}
fn constant_time_eq(left: &str, right: &str) -> bool {
if left.len() != right.len() {
return false;
}
left.as_bytes()
.iter()
.zip(right.as_bytes())
.fold(0_u8, |acc, (left, right)| acc | (left ^ right))
== 0
}
fn now_ms() -> i64 {
chrono::Utc::now().timestamp_millis()
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn registry_creates_private_local_cli_token_and_hash_record() {
let temp = tempfile::tempdir().unwrap();
let handle = DaemonTokenRegistryHandle {
path: temp.path().join("runtime").join("daemon_tokens.json"),
local_token_path: temp.path().join("runtime").join("daemon.token"),
write_lock: Arc::new(Mutex::new(())),
};
handle.ensure_registry().await.unwrap();
let token = load_local_daemon_auth_token_at(&handle.local_token_path)
.await
.unwrap();
let registry = read_registry(&handle.path).await.unwrap();
assert_eq!(registry.tokens.len(), 1);
assert_eq!(registry.tokens[0].id, LOCAL_CLI_TOKEN_ID);
assert_eq!(registry.tokens[0].token_hash, hash_token(token.as_str()));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let token_mode = std::fs::metadata(&handle.local_token_path)
.unwrap()
.permissions()
.mode()
& 0o777;
let registry_mode = std::fs::metadata(&handle.path)
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(token_mode, 0o600);
assert_eq!(registry_mode, 0o600);
}
}
#[tokio::test]
async fn registry_authorizes_created_tokens_and_updates_last_used() {
let temp = tempfile::tempdir().unwrap();
let handle = DaemonTokenRegistryHandle {
path: temp.path().join("runtime").join("daemon_tokens.json"),
local_token_path: temp.path().join("runtime").join("daemon.token"),
write_lock: Arc::new(Mutex::new(())),
};
handle.ensure_registry().await.unwrap();
let created = handle.create_token("web").await.unwrap();
let mut headers = HeaderMap::new();
headers.insert(
AUTHORIZATION,
axum::http::HeaderValue::from_str(&format!("Bearer {}", created.token)).unwrap(),
);
assert!(handle.authorize_headers(&headers).await);
let registry = read_registry(&handle.path).await.unwrap();
let web = registry
.tokens
.iter()
.find(|record| record.id == created.id)
.unwrap();
assert!(web.last_used_at_ms.is_some());
assert!(
!registry
.tokens
.iter()
.any(|record| record.token_hash == created.token)
);
}
#[tokio::test]
async fn registry_revokes_and_rotates_named_tokens() {
let temp = tempfile::tempdir().unwrap();
let handle = DaemonTokenRegistryHandle {
path: temp.path().join("runtime").join("daemon_tokens.json"),
local_token_path: temp.path().join("runtime").join("daemon.token"),
write_lock: Arc::new(Mutex::new(())),
};
handle.ensure_registry().await.unwrap();
let created = handle.create_token("web").await.unwrap();
let rotated = handle.rotate_token("web").await.unwrap();
assert_eq!(created.id, rotated.id);
assert_ne!(created.token, rotated.token);
let revoked = handle.revoke_token("web").await.unwrap();
assert_eq!(revoked.id, created.id);
assert!(handle.revoke_token(LOCAL_CLI_TOKEN_ID).await.is_err());
}
}