use rand::Rng;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::RwLock;
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum KeyScope {
Admin,
Library,
}
fn default_scope() -> KeyScope {
KeyScope::Admin
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct ApiKey {
pub id: String,
pub name: String,
pub created_at: u64,
#[serde(default = "default_scope")]
pub scope: KeyScope,
}
pub struct KeyStore {
keys: Vec<ApiKey>,
last_used: RwLock<HashMap<String, u64>>,
path: Option<PathBuf>,
}
impl KeyStore {
pub fn load() -> Self {
Self::load_from(config_path())
}
pub fn load_from(path: Option<PathBuf>) -> Self {
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 is_empty(&self) -> bool {
self.keys.is_empty()
}
pub fn validate(&self, provided: &str) -> Option<(String, KeyScope)> {
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(), key.scope))
}
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>() + "…",
scope: k.scope,
created_at: k.created_at,
last_used_at: last.get(&k.id).copied(),
})
.collect()
}
pub fn create(&mut self, name: &str, scope: KeyScope) -> 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,
scope,
});
self.save()?;
Ok(id)
}
pub fn bootstrap_if_empty(&mut self) -> Result<Option<String>, String> {
if !self.is_empty() {
return Ok(None);
}
let id = self.create("studio-admin", KeyScope::Admin)?;
if let Some(ref p) = self.path {
if let Some(parent) = p.parent() {
let admin_path = parent.join("admin-key.txt");
if let Err(e) = std::fs::write(&admin_path, &id) {
eprintln!(
"[key_store] could not persist admin-key.txt: {} ({})",
admin_path.display(),
e
);
} else {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(
&admin_path,
std::fs::Permissions::from_mode(0o600),
);
}
}
}
}
Ok(Some(id))
}
pub fn admin_key_path(&self) -> Option<PathBuf> {
self.path
.as_ref()
.and_then(|p| p.parent().map(|d| d.join("admin-key.txt")))
}
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 scope: KeyScope,
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", KeyScope::Library).unwrap();
assert!(id.starts_with("mr_alex-laptop_"));
assert_eq!(parse_name(&id).as_deref(), Some("alex-laptop"));
let validated = s.validate(&id).expect("validates");
assert_eq!(validated.0, "alex-laptop");
assert_eq!(validated.1, KeyScope::Library);
}
#[test]
fn rejects_bad_names() {
let mut s = KeyStore {
keys: Vec::new(),
last_used: RwLock::new(HashMap::new()),
path: None,
};
assert!(s.create("HasUpper", KeyScope::Library).is_err());
assert!(s.create("under_score", KeyScope::Library).is_err());
assert!(s.create("", KeyScope::Library).is_err());
assert!(s.create("-leading-dash", KeyScope::Library).is_err());
}
#[test]
fn bootstrap_creates_admin_key_when_empty() {
let mut s = KeyStore {
keys: Vec::new(),
last_used: RwLock::new(HashMap::new()),
path: None,
};
let key = s.bootstrap_if_empty().unwrap();
let key = key.expect("returns the new key");
assert!(key.starts_with("mr_studio-admin_"));
let validated = s.validate(&key).expect("the bootstrap key validates");
assert_eq!(validated.0, "studio-admin");
assert_eq!(validated.1, KeyScope::Admin);
assert!(s.bootstrap_if_empty().unwrap().is_none());
}
#[test]
fn legacy_keys_default_to_admin() {
let json = r#"[{"id":"mr_legacy_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","name":"legacy","created_at":1700000000}]"#;
let keys: Vec<ApiKey> = serde_json::from_str(json).unwrap();
assert_eq!(keys[0].scope, KeyScope::Admin);
}
#[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());
}
}