use rand::RngCore;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::RwLock;
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct ApiKey {
pub id: String,
pub name: String,
pub created_at: u64,
}
pub struct KeyStore {
keys: Vec<ApiKey>,
last_used: RwLock<HashMap<String, u64>>,
path: Option<PathBuf>,
}
impl KeyStore {
pub fn load() -> Self {
let path = config_path();
let keys = path
.as_ref()
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|s| serde_json::from_str::<Vec<ApiKey>>(&s).ok())
.unwrap_or_default();
Self {
keys,
last_used: RwLock::new(HashMap::new()),
path,
}
}
pub fn is_enabled(&self) -> bool {
!self.keys.is_empty()
}
pub fn validate(&self, provided: &str) -> Option<String> {
let _ = parse_name(provided)?;
let key = self.keys.iter().find(|k| k.id == provided)?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
self.last_used
.write()
.unwrap()
.insert(provided.to_string(), now);
Some(key.name.clone())
}
pub fn list_redacted(&self) -> Vec<RedactedKey> {
let last = self.last_used.read().unwrap();
self.keys
.iter()
.map(|k| RedactedKey {
name: k.name.clone(),
prefix: k.id.chars().take(12).collect::<String>() + "…",
created_at: k.created_at,
last_used_at: last.get(&k.id).copied(),
})
.collect()
}
pub fn create(&mut self, name: &str) -> Result<String, String> {
validate_name(name)?;
if self.keys.iter().any(|k| k.name == name) {
return Err(format!("key with name '{}' already exists", name));
}
let mut bytes = [0u8; 32];
rand::rng().fill_bytes(&mut bytes);
let id = format!("mr_{}_{}", name, hex::encode(bytes));
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
self.keys.push(ApiKey {
id: id.clone(),
name: name.to_string(),
created_at: now,
});
self.save()?;
Ok(id)
}
pub fn revoke(&mut self, name: &str) -> Result<(), String> {
let before = self.keys.len();
self.keys.retain(|k| k.name != name);
if self.keys.len() == before {
return Err(format!("key '{}' not found", name));
}
self.save()?;
Ok(())
}
fn save(&self) -> Result<(), String> {
let Some(ref p) = self.path else {
return Ok(());
};
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let json = serde_json::to_string_pretty(&self.keys).map_err(|e| e.to_string())?;
std::fs::write(p, json).map_err(|e| e.to_string())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o600));
}
Ok(())
}
}
#[derive(serde::Serialize)]
pub struct RedactedKey {
pub name: String,
pub prefix: String,
pub created_at: u64,
pub last_used_at: Option<u64>,
}
fn config_path() -> Option<PathBuf> {
directories::ProjectDirs::from("sh", "gladius", "microresolve")
.map(|pd| pd.config_dir().join("keys.json"))
}
fn validate_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("name must not be empty".into());
}
if name.len() > 31 {
return Err(format!("name '{}' exceeds 31 chars", name));
}
if !name.chars().next().unwrap().is_ascii_alphanumeric() {
return Err(format!("name '{}' must start with a letter or digit", name));
}
if !name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(format!(
"name '{}' may only contain lowercase letters, digits, and '-'",
name
));
}
Ok(())
}
fn parse_name(provided: &str) -> Option<String> {
let rest = provided.strip_prefix("mr_")?;
let underscore = rest.find('_')?;
let (name, tail) = rest.split_at(underscore);
let hex = tail.strip_prefix('_')?;
if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
if validate_name(name).is_err() {
return None;
}
Some(name.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn key_round_trip() {
let mut s = KeyStore {
keys: Vec::new(),
last_used: RwLock::new(HashMap::new()),
path: None,
};
let id = s.create("alex-laptop").unwrap();
assert!(id.starts_with("mr_alex-laptop_"));
assert_eq!(parse_name(&id).as_deref(), Some("alex-laptop"));
assert_eq!(s.validate(&id).as_deref(), Some("alex-laptop"));
}
#[test]
fn rejects_bad_names() {
let mut s = KeyStore {
keys: Vec::new(),
last_used: RwLock::new(HashMap::new()),
path: None,
};
assert!(s.create("HasUpper").is_err());
assert!(s.create("under_score").is_err());
assert!(s.create("").is_err());
assert!(s.create("-leading-dash").is_err());
}
#[test]
fn rejects_malformed_keys() {
assert!(parse_name("mr_alex_short").is_none());
assert!(parse_name("not_a_key_at_all").is_none());
assert!(parse_name("").is_none());
}
}