systemprompt_users/services/
api_key_service.rs1use chrono::{DateTime, Utc};
2use rand::RngCore;
3use sha2::{Digest, Sha256};
4use subtle::ConstantTimeEq;
5use systemprompt_database::DbPool;
6use systemprompt_identifiers::{ApiKeyId, UserId};
7
8use crate::error::{Result, UserError};
9use crate::models::{NewApiKey, UserApiKey};
10use crate::repository::{CreateApiKeyParams, UserRepository};
11
12pub const API_KEY_PREFIX: &str = "sp-live-";
13const SECRET_BYTES: usize = 32;
14const PREFIX_ID_BYTES: usize = 6;
15
16#[derive(Debug, Clone)]
17pub struct IssueApiKeyParams<'a> {
18 pub user_id: &'a UserId,
19 pub name: &'a str,
20 pub expires_at: Option<DateTime<Utc>>,
21}
22
23#[derive(Debug, Clone)]
24pub struct ApiKeyService {
25 repository: UserRepository,
26}
27
28impl ApiKeyService {
29 pub fn new(db: &DbPool) -> anyhow::Result<Self> {
30 Ok(Self {
31 repository: UserRepository::new(db)?,
32 })
33 }
34
35 pub async fn issue(&self, params: IssueApiKeyParams<'_>) -> Result<NewApiKey> {
36 let trimmed = params.name.trim();
37 if trimmed.is_empty() {
38 return Err(UserError::Validation(
39 "api key name must not be empty".into(),
40 ));
41 }
42
43 let id = ApiKeyId::generate();
44 let (secret, key_prefix, key_hash) = generate_secret();
45
46 let record = self
47 .repository
48 .create_api_key(CreateApiKeyParams {
49 id: &id,
50 user_id: params.user_id,
51 name: trimmed,
52 key_prefix: &key_prefix,
53 key_hash: &key_hash,
54 expires_at: params.expires_at,
55 })
56 .await?;
57
58 Ok(NewApiKey { record, secret })
59 }
60
61 pub async fn verify(&self, presented_secret: &str) -> Result<Option<UserApiKey>> {
62 let Some(key_prefix) = extract_prefix(presented_secret) else {
63 return Ok(None);
64 };
65
66 let Some(record) = self
67 .repository
68 .find_active_api_key_by_prefix(&key_prefix)
69 .await?
70 else {
71 return Ok(None);
72 };
73
74 if !record.is_active(Utc::now()) {
75 return Ok(None);
76 }
77
78 let presented_hash = hash_secret(presented_secret);
79 if presented_hash
80 .as_bytes()
81 .ct_eq(record.key_hash.as_bytes())
82 .into()
83 {
84 self.repository.touch_api_key_usage(&record.id).await?;
85 Ok(Some(record))
86 } else {
87 Ok(None)
88 }
89 }
90
91 pub async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<UserApiKey>> {
92 self.repository.list_api_keys_for_user(user_id).await
93 }
94
95 pub async fn revoke(&self, id: &ApiKeyId, user_id: &UserId) -> Result<bool> {
96 self.repository.revoke_api_key(id, user_id).await
97 }
98}
99
100fn generate_secret() -> (String, String, String) {
101 let mut raw = [0u8; SECRET_BYTES];
102 rand::rng().fill_bytes(&mut raw);
103 let encoded = hex::encode(raw);
104 let key_prefix = format!("{API_KEY_PREFIX}{}", &encoded[..PREFIX_ID_BYTES * 2]);
105 let secret = format!("{key_prefix}.{}", &encoded[PREFIX_ID_BYTES * 2..]);
106 let key_hash = hash_secret(&secret);
107 (secret, key_prefix, key_hash)
108}
109
110fn hash_secret(secret: &str) -> String {
111 let mut hasher = Sha256::new();
112 hasher.update(secret.as_bytes());
113 hex::encode(hasher.finalize())
114}
115
116fn extract_prefix(presented: &str) -> Option<String> {
117 if !presented.starts_with(API_KEY_PREFIX) {
118 return None;
119 }
120 let dot = presented.find('.')?;
121 Some(presented[..dot].to_string())
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127
128 #[test]
129 fn generate_secret_formats_correctly() {
130 let (secret, prefix, hash) = generate_secret();
131 assert!(secret.starts_with(API_KEY_PREFIX));
132 assert!(secret.starts_with(&prefix));
133 assert!(secret.contains('.'));
134 assert_eq!(hash.len(), 64);
135 }
136
137 #[test]
138 fn extract_prefix_matches_issued_format() {
139 let (secret, prefix, _hash) = generate_secret();
140 assert_eq!(extract_prefix(&secret), Some(prefix));
141 }
142
143 #[test]
144 fn extract_prefix_rejects_foreign_keys() {
145 assert_eq!(extract_prefix("nope"), None);
146 assert_eq!(extract_prefix("sk-live-no-dot-separator"), None);
147 }
148}