use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::path::PathBuf;
use std::sync::OnceLock;
use std::time::{SystemTime, UNIX_EPOCH};
static ACTIVE_API_KEY: OnceLock<String> = OnceLock::new();
static JWT_SIGNING_SECRET: OnceLock<String> = OnceLock::new();
const JWT_LIFETIME_SECS: u64 = 3600;
pub const BOOTSTRAP_KEY_ID: &str = "key_bootstrap";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Role {
#[serde(alias = "admin")]
Owner,
Operator,
Inference,
Viewer,
}
impl Role {
fn rank(self) -> u8 {
match self {
Role::Viewer => 0,
Role::Inference => 1,
Role::Operator => 2,
Role::Owner => 3,
}
}
pub fn satisfies(self, required: Role) -> bool {
self.rank() >= required.rank()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiKeyRecord {
pub id: String,
pub name: String,
pub role: Role,
pub key_hash: String,
pub key_preview: String,
pub created_at: String,
pub last_used_at: Option<String>,
}
#[derive(Debug, Clone)]
pub struct AuthContext {
pub key_id: String,
pub name: String,
pub role: Role,
}
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
sub: String,
exp: u64,
iat: u64,
}
fn api_key_path() -> PathBuf {
std::env::var("GHOSTLINK_API_KEY_PATH")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("api_key.txt"))
}
fn jwt_secret_path() -> PathBuf {
std::env::var("GHOSTLINK_JWT_SECRET_PATH")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("jwt_secret.txt"))
}
fn generate_secret() -> String {
let bytes: [u8; 32] = rand::random();
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn generate_api_key() -> String {
generate_secret()
}
fn hash_key(raw: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(raw.as_bytes());
hasher
.finalize()
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
fn key_preview(raw: &str) -> String {
let len = raw.len();
if len <= 4 {
raw.to_string()
} else {
raw[len - 4..].to_string()
}
}
fn load_or_create_api_key() -> String {
let path = api_key_path();
if let Ok(existing) = std::fs::read_to_string(&path) {
let trimmed = existing.trim().to_string();
if !trimmed.is_empty() {
return trimmed;
}
}
let key = generate_api_key();
if let Err(err) = std::fs::write(&path, &key) {
eprintln!(
"warning: failed to persist API key to {}: {err} (a new one will be generated next restart)",
path.display()
);
}
println!("=======================================================================");
println!(
"Generated a new Ghostlink API key and saved it to {}.",
path.display()
);
println!("Read the key from that file before using it.");
println!("Send it as a bearer token on every API request, or exchange it for");
println!("a short-lived token via POST /api/security/jwt/refresh.");
println!("This key is now the 'bootstrap' Owner entry in your key store — see");
println!("GET /api/security/keys to create additional, more narrowly-scoped keys.");
println!("=======================================================================");
key
}
pub fn active_api_key() -> &'static str {
ACTIVE_API_KEY.get_or_init(load_or_create_api_key)
}
fn jwt_signing_secret() -> &'static str {
JWT_SIGNING_SECRET.get_or_init(|| {
let path = jwt_secret_path();
if let Ok(existing) = std::fs::read_to_string(&path) {
let trimmed = existing.trim().to_string();
if !trimmed.is_empty() {
return trimmed;
}
}
let secret = generate_secret();
if let Err(err) = std::fs::write(&path, &secret) {
eprintln!(
"warning: failed to persist JWT signing secret to {}: {err} (a new one will be generated next restart, invalidating outstanding JWTs)",
path.display()
);
}
secret
})
}
pub fn bootstrap_key_record() -> ApiKeyRecord {
let raw = active_api_key();
ApiKeyRecord {
id: BOOTSTRAP_KEY_ID.to_string(),
name: "bootstrap".to_string(),
role: Role::Owner,
key_hash: hash_key(raw),
key_preview: key_preview(raw),
created_at: chrono::Utc::now().to_rfc3339(),
last_used_at: None,
}
}
pub fn create_key(name: String, role: Role) -> (ApiKeyRecord, String) {
let raw = generate_api_key();
let id = format!(
"key_{}",
rand::random::<[u8; 6]>()
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>()
);
let record = ApiKeyRecord {
id,
name,
role,
key_hash: hash_key(&raw),
key_preview: key_preview(&raw),
created_at: chrono::Utc::now().to_rfc3339(),
last_used_at: None,
};
(record, raw)
}
pub fn issue_jwt(key_id: &str) -> Result<String, String> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| format!("system clock error: {e}"))?
.as_secs();
let claims = Claims {
sub: key_id.to_string(),
iat: now,
exp: now + JWT_LIFETIME_SECS,
};
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(jwt_signing_secret().as_bytes()),
)
.map_err(|e| format!("failed to sign JWT: {e}"))
}
fn verify_jwt(token: &str) -> Option<String> {
decode::<Claims>(
token,
&DecodingKey::from_secret(jwt_signing_secret().as_bytes()),
&Validation::default(),
)
.ok()
.map(|data| data.claims.sub)
}
pub fn authenticate(token: &str, keys: &[ApiKeyRecord]) -> Option<AuthContext> {
let token = token.trim();
if token.is_empty() {
return None;
}
let hashed = hash_key(token);
if let Some(record) = keys.iter().find(|k| k.key_hash == hashed) {
return Some(AuthContext {
key_id: record.id.clone(),
name: record.name.clone(),
role: record.role,
});
}
let subject = verify_jwt(token)?;
keys.iter()
.find(|k| k.id == subject)
.map(|record| AuthContext {
key_id: record.id.clone(),
name: record.name.clone(),
role: record.role,
})
}
pub fn extract_bearer_token(header_value: Option<&str>) -> Option<&str> {
header_value?.strip_prefix("Bearer ").map(str::trim)
}
pub fn percent_decode(input: &str) -> String {
let mut bytes = Vec::with_capacity(input.len());
let mut chars = input.bytes();
while let Some(b) = chars.next() {
if b == b'%' {
let h1 = chars.next();
let h2 = chars.next();
if let (Some(h1), Some(h2)) = (h1, h2) {
if let Ok(val) = u8::from_str_radix(&format!("{}{}", h1 as char, h2 as char), 16) {
bytes.push(val);
continue;
}
}
} else if b == b'+' {
bytes.push(b' ');
continue;
}
bytes.push(b);
}
String::from_utf8_lossy(&bytes).into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
#[test]
fn generate_api_key_produces_64_hex_chars_of_real_entropy() {
let a = generate_api_key();
let b = generate_api_key();
assert_eq!(a.len(), 64);
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
assert_ne!(a, b, "two generated keys should not collide");
}
#[test]
fn load_or_create_api_key_persists_and_reuses_across_calls() {
let _guard = env_lock().lock().expect("env lock poisoned");
let path =
std::env::temp_dir().join(format!("ghostlink-test-api-key-{}.txt", std::process::id()));
let _ = std::fs::remove_file(&path);
std::env::set_var("GHOSTLINK_API_KEY_PATH", &path);
let first = load_or_create_api_key();
assert!(path.exists());
let second = load_or_create_api_key();
assert_eq!(
first, second,
"second call should reuse the persisted key, not regenerate"
);
std::env::remove_var("GHOSTLINK_API_KEY_PATH");
let _ = std::fs::remove_file(&path);
}
#[test]
fn percent_decode_decodes_url_encoded_strings() {
assert_eq!(percent_decode("hello%20world"), "hello world");
assert_eq!(percent_decode("key%2B123"), "key+123");
assert_eq!(percent_decode("plain_key"), "plain_key");
}
#[test]
fn extract_bearer_token_parses_well_formed_header_only() {
assert_eq!(extract_bearer_token(Some("Bearer abc123")), Some("abc123"));
assert_eq!(extract_bearer_token(Some("bearer abc123")), None);
assert_eq!(extract_bearer_token(Some("abc123")), None);
assert_eq!(extract_bearer_token(None), None);
}
#[test]
fn role_satisfies_is_a_strict_hierarchy() {
assert!(Role::Owner.satisfies(Role::Owner));
assert!(Role::Owner.satisfies(Role::Operator));
assert!(Role::Owner.satisfies(Role::Viewer));
assert!(Role::Operator.satisfies(Role::Operator));
assert!(Role::Operator.satisfies(Role::Viewer));
assert!(!Role::Operator.satisfies(Role::Owner));
assert!(Role::Viewer.satisfies(Role::Viewer));
assert!(!Role::Viewer.satisfies(Role::Operator));
assert!(!Role::Viewer.satisfies(Role::Owner));
}
#[test]
fn hash_key_is_deterministic_and_distinct_per_input() {
assert_eq!(hash_key("abc"), hash_key("abc"));
assert_ne!(hash_key("abc"), hash_key("abd"));
assert_eq!(hash_key("abc").len(), 64);
}
#[test]
fn key_preview_never_exposes_more_than_the_last_four_chars() {
assert_eq!(key_preview("abcdefgh"), "efgh");
assert_eq!(key_preview("ab"), "ab");
}
#[test]
fn create_key_returns_a_record_whose_hash_matches_the_raw_value_and_never_stores_it() {
let (record, raw) = create_key("ci-bot".to_string(), Role::Operator);
assert_eq!(record.name, "ci-bot");
assert_eq!(record.role, Role::Operator);
assert_eq!(record.key_hash, hash_key(&raw));
assert_eq!(record.key_preview, key_preview(&raw));
assert!(record.id.starts_with("key_"));
}
#[test]
fn authenticate_matches_raw_key_and_rejects_unknown_or_empty_tokens() {
let (record, raw) = create_key("tester".to_string(), Role::Owner);
let keys = vec![record.clone()];
let ctx = authenticate(&raw, &keys).expect("raw key should authenticate");
assert_eq!(ctx.key_id, record.id);
assert_eq!(ctx.role, Role::Owner);
assert!(authenticate("not-a-real-key", &keys).is_none());
assert!(authenticate("", &keys).is_none());
}
#[test]
fn authenticate_honors_a_jwt_only_while_its_key_id_still_exists_in_the_store() {
let _guard = env_lock().lock().expect("env lock poisoned");
let path = std::env::temp_dir().join(format!(
"ghostlink-test-jwt-secret-{}.txt",
std::process::id()
));
let _ = std::fs::remove_file(&path);
std::env::set_var("GHOSTLINK_JWT_SECRET_PATH", &path);
let (record, _raw) = create_key("jwt-holder".to_string(), Role::Operator);
let jwt = issue_jwt(&record.id).expect("issue_jwt should succeed");
let keys_with_record = vec![record.clone()];
let ctx =
authenticate(&jwt, &keys_with_record).expect("JWT for a live key should authenticate");
assert_eq!(ctx.key_id, record.id);
assert_eq!(ctx.role, Role::Operator);
let keys_without_record: Vec<ApiKeyRecord> = vec![];
assert!(
authenticate(&jwt, &keys_without_record).is_none(),
"a JWT for a revoked key must be rejected even though it hasn't expired"
);
assert!(authenticate(&format!("{jwt}tampered"), &keys_with_record).is_none());
std::env::remove_var("GHOSTLINK_JWT_SECRET_PATH");
let _ = std::fs::remove_file(&path);
}
#[test]
fn authenticate_reflects_a_live_role_change_not_a_stale_jwt_claim() {
let _guard = env_lock().lock().expect("env lock poisoned");
let path = std::env::temp_dir().join(format!(
"ghostlink-test-jwt-secret-role-{}.txt",
std::process::id()
));
let _ = std::fs::remove_file(&path);
std::env::set_var("GHOSTLINK_JWT_SECRET_PATH", &path);
let (mut record, _raw) = create_key("role-change".to_string(), Role::Viewer);
let jwt = issue_jwt(&record.id).expect("issue_jwt should succeed");
record.role = Role::Owner;
let keys = vec![record];
let ctx =
authenticate(&jwt, &keys).expect("JWT should still authenticate after a role change");
assert_eq!(
ctx.role,
Role::Owner,
"role must be read fresh from the store, not from the JWT claims"
);
std::env::remove_var("GHOSTLINK_JWT_SECRET_PATH");
let _ = std::fs::remove_file(&path);
}
}
#[test]
fn role_deserializes_admin_alias_as_owner() {
let json = r#""admin""#;
let role: Role = serde_json::from_str(json).unwrap();
assert_eq!(role, Role::Owner);
let json_owner = r#""owner""#;
let role_owner: Role = serde_json::from_str(json_owner).unwrap();
assert_eq!(role_owner, Role::Owner);
let json_inf = r#""inference""#;
let role_inf: Role = serde_json::from_str(json_inf).unwrap();
assert_eq!(role_inf, Role::Inference);
}