1use aes_gcm::aead::{Aead, KeyInit, Payload};
16use aes_gcm::{Aes256Gcm, Nonce};
17use arc_swap::ArcSwap;
18use rand::Rng;
19use serde::{Deserialize, Serialize};
20use sha2::{Digest, Sha256};
21use std::collections::HashMap;
22use std::sync::Arc;
23use thiserror::Error;
24use tracing::warn;
25use zeroize::Zeroizing;
26
27#[derive(Debug, Error)]
29pub enum FleetSecretsError {
30 #[error("master key derivation failed: {0}")]
31 MasterKey(String),
32 #[error("encryption failed: {0}")]
33 Encrypt(String),
34 #[error("decryption failed: {0}")]
35 Decrypt(String),
36 #[error("ciphertext is malformed: {0}")]
37 Malformed(String),
38 #[error("master key is not configured; fleet-secrets lookups return None")]
39 MasterKeyUnset,
40}
41
42#[derive(Debug, Clone, Default, Serialize, Deserialize)]
46pub struct ProviderOverride {
47 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub api_key: Option<String>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub api_base: Option<String>,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub default_model: Option<String>,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub rate_limit_rpm: Option<i32>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub rate_limit_tpm: Option<i32>,
62 #[serde(default, skip_serializing_if = "Vec::is_empty")]
64 pub fallback_providers: Vec<String>,
65 #[serde(default)]
67 pub updated_at: i64,
68 #[serde(default)]
70 pub updated_by: String,
71}
72
73#[derive(Debug, Clone, Default)]
77pub struct FleetSecrets {
78 inner: Arc<ArcSwap<FleetSecretsInner>>,
79}
80
81#[derive(Debug, Default)]
82struct FleetSecretsInner {
83 providers: HashMap<String, ProviderOverride>,
84}
85
86impl FleetSecrets {
87 pub fn new() -> Self {
89 Self::default()
90 }
91
92 pub fn from_providers(providers: HashMap<String, ProviderOverride>) -> Self {
94 Self {
95 inner: Arc::new(ArcSwap::from_pointee(FleetSecretsInner { providers })),
96 }
97 }
98
99 pub fn store(&self, providers: HashMap<String, ProviderOverride>) {
102 self.inner.store(Arc::new(FleetSecretsInner { providers }));
103 }
104
105 pub fn get(&self, provider_name: &str) -> Option<ProviderOverride> {
107 self.inner.load().providers.get(provider_name).cloned()
108 }
109
110 pub fn list(&self) -> Vec<(String, ProviderOverride)> {
112 self.inner
113 .load()
114 .providers
115 .iter()
116 .map(|(k, v)| (k.clone(), v.clone()))
117 .collect()
118 }
119
120 pub fn len(&self) -> usize {
122 self.inner.load().providers.len()
123 }
124
125 pub fn is_empty(&self) -> bool {
127 self.inner.load().providers.is_empty()
128 }
129}
130
131impl cordis::Service for FleetSecrets {
132 fn name(&self) -> &'static str {
133 "fleet_secrets"
134 }
135 fn init(&self, _ctx: &std::sync::Arc<cordis::Context>) -> cordis::ServiceInitFuture<'_> {
136 Box::pin(async { Ok(None) })
137 }
138 fn check(&self) -> bool {
139 true
140 }
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct EncryptedPayload {
147 pub nonce: Vec<u8>,
149 pub ciphertext: Vec<u8>,
151}
152
153#[derive(Debug, Clone)]
155pub struct MasterKey {
156 bytes: Arc<Zeroizing<[u8; 32]>>,
158}
159
160impl MasterKey {
161 pub fn from_env() -> Option<Self> {
164 match std::env::var("FLEET_SECRETS_KEY") {
165 Ok(raw) if !raw.is_empty() => Some(Self::from_secret(&raw)),
166 _ => {
167 warn!(
168 "FLEET_SECRETS_KEY is not set; fleet provider secrets will be disabled. \
169 Set it to a >=32-char random string in /etc/dirmacs/fleet-secrets.env \
170 and reload ares.service to enable encrypted provider overrides."
171 );
172 None
173 }
174 }
175 }
176
177 pub fn from_secret(secret: &str) -> Self {
179 let mut hasher = Sha256::new();
180 hasher.update(secret.as_bytes());
181 let digest = hasher.finalize();
182 let mut bytes = [0u8; 32];
183 bytes.copy_from_slice(&digest);
184 Self {
185 bytes: Arc::new(Zeroizing::new(bytes)),
186 }
187 }
188
189 pub fn as_bytes(&self) -> &[u8; 32] {
191 &self.bytes
192 }
193}
194
195pub fn encrypt_api_key(
197 plaintext: &str,
198 master: &MasterKey,
199) -> Result<EncryptedPayload, FleetSecretsError> {
200 let cipher = Aes256Gcm::new_from_slice(master.as_bytes())
201 .map_err(|e| FleetSecretsError::Encrypt(e.to_string()))?;
202
203 let mut nonce_bytes = [0u8; 12];
204 rand::rng().fill(&mut nonce_bytes);
205 let nonce = Nonce::from_slice(&nonce_bytes);
206
207 let ciphertext = cipher
208 .encrypt(
209 nonce,
210 Payload {
211 msg: plaintext.as_bytes(),
212 aad: b"ares.fleet_secrets.v1",
213 },
214 )
215 .map_err(|e| FleetSecretsError::Encrypt(e.to_string()))?;
216
217 Ok(EncryptedPayload {
218 nonce: nonce_bytes.to_vec(),
219 ciphertext,
220 })
221}
222
223pub fn decrypt_api_key(
225 payload: &EncryptedPayload,
226 master: &MasterKey,
227) -> Result<String, FleetSecretsError> {
228 if payload.nonce.len() != 12 {
229 return Err(FleetSecretsError::Malformed(format!(
230 "nonce must be 12 bytes, got {}",
231 payload.nonce.len()
232 )));
233 }
234 if payload.ciphertext.is_empty() {
235 return Err(FleetSecretsError::Malformed("ciphertext is empty".into()));
236 }
237 let cipher = Aes256Gcm::new_from_slice(master.as_bytes())
238 .map_err(|e| FleetSecretsError::Decrypt(e.to_string()))?;
239 let nonce = Nonce::from_slice(&payload.nonce);
240
241 let plaintext = cipher
242 .decrypt(
243 nonce,
244 Payload {
245 msg: &payload.ciphertext,
246 aad: b"ares.fleet_secrets.v1",
247 },
248 )
249 .map_err(|e| FleetSecretsError::Decrypt(e.to_string()))?;
250
251 String::from_utf8(plaintext).map_err(|e| FleetSecretsError::Decrypt(e.to_string()))
252}
253
254pub fn last_n_visible(key: &str, n: usize) -> Option<String> {
257 if key.is_empty() {
258 return None;
259 }
260 let len = key.chars().count();
261 if len <= n {
262 return Some(key.to_string());
263 }
264 let start_byte = key.char_indices().nth(len - n).map(|(i, _)| i).unwrap_or(0);
265 Some(format!("…{}", &key[start_byte..]))
266}
267
268#[cfg(test)]
270pub fn hex_key(master: &MasterKey) -> String {
271 hex::encode(master.as_bytes().as_slice())
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277
278 #[test]
279 fn round_trip_encrypt_decrypt() {
280 let master = MasterKey::from_secret("test-secret");
281 let plaintext = "nvapi-abc123-XYZ";
282 let payload = encrypt_api_key(plaintext, &master).expect("encrypt");
283 assert_eq!(payload.nonce.len(), 12);
284 assert!(!payload.ciphertext.is_empty());
285 let decrypted = decrypt_api_key(&payload, &master).expect("decrypt");
286 assert_eq!(decrypted, plaintext);
287 }
288
289 #[test]
290 fn wrong_key_fails_decrypt() {
291 let a = MasterKey::from_secret("key-a");
292 let b = MasterKey::from_secret("key-b");
293 let payload = encrypt_api_key("secret", &a).expect("encrypt");
294 let result = decrypt_api_key(&payload, &b);
295 assert!(result.is_err(), "wrong master key must fail to decrypt");
296 }
297
298 #[test]
299 fn tampered_ciphertext_fails() {
300 let master = MasterKey::from_secret("key");
301 let mut payload = encrypt_api_key("secret", &master).expect("encrypt");
302 let mid = payload.ciphertext.len() / 2;
304 payload.ciphertext[mid] ^= 0xFF;
305 let result = decrypt_api_key(&payload, &master);
306 assert!(result.is_err(), "tampered ciphertext must fail to decrypt");
307 }
308
309 #[test]
310 fn malformed_nonce_rejected() {
311 let master = MasterKey::from_secret("key");
312 let payload = EncryptedPayload {
313 nonce: vec![0; 8], ciphertext: vec![1, 2, 3],
315 };
316 let result = decrypt_api_key(&payload, &master);
317 assert!(result.is_err());
318 }
319
320 #[test]
321 fn empty_ciphertext_rejected() {
322 let master = MasterKey::from_secret("key");
323 let payload = EncryptedPayload {
324 nonce: vec![0; 12],
325 ciphertext: vec![],
326 };
327 let result = decrypt_api_key(&payload, &master);
328 assert!(result.is_err());
329 }
330
331 #[test]
332 fn unique_nonce_per_encryption() {
333 let master = MasterKey::from_secret("key");
334 let a = encrypt_api_key("same", &master).expect("a");
335 let b = encrypt_api_key("same", &master).expect("b");
336 assert_ne!(a.nonce, b.nonce, "nonces must be unique");
337 }
338
339 #[test]
340 fn last_n_visible_truncates() {
341 assert_eq!(
343 last_n_visible("nvapi-abc12345XYZ", 4),
344 Some("…5XYZ".to_string())
345 );
346 assert_eq!(
348 last_n_visible("nvapi-abc", 8),
349 Some("…vapi-abc".to_string())
350 );
351 assert_eq!(
353 last_n_visible("nvapi-abc", 10),
354 Some("nvapi-abc".to_string())
355 );
356 assert_eq!(last_n_visible("", 4), None);
358 }
359
360 #[test]
361 fn fleet_secrets_swap_is_visible_to_readers() {
362 let secrets = FleetSecrets::new();
363 assert!(secrets.get("nvidia").is_none());
364 assert!(secrets.is_empty());
365
366 let mut map = HashMap::new();
367 map.insert(
368 "nvidia".to_string(),
369 ProviderOverride {
370 api_key: Some("nvapi-X".into()),
371 api_base: None,
372 default_model: Some("meta/llama-3.3-70b-instruct".into()),
373 updated_at: 1,
374 updated_by: "admin".into(),
375 ..Default::default()
376 },
377 );
378 secrets.store(map);
379
380 let entry = secrets.get("nvidia").expect("entry present");
381 assert_eq!(entry.api_key.as_deref(), Some("nvapi-X"));
382 assert_eq!(secrets.len(), 1);
383
384 secrets.store(HashMap::new());
386 assert!(secrets.get("nvidia").is_none());
387 assert!(secrets.is_empty());
388 }
389
390 #[test]
391 fn from_env_returns_none_when_unset() {
392 let prev = std::env::var("FLEET_SECRETS_KEY").ok();
395 std::env::remove_var("FLEET_SECRETS_KEY");
396 assert!(MasterKey::from_env().is_none());
397 if let Some(p) = prev {
398 std::env::set_var("FLEET_SECRETS_KEY", p);
399 }
400 }
401
402 #[test]
403 fn from_env_resolves_when_set() {
404 std::env::set_var("FLEET_SECRETS_KEY", "test-only-secret-not-real");
405 let m = MasterKey::from_env().expect("key resolves");
406 let expected = MasterKey::from_secret("test-only-secret-not-real");
408 assert_eq!(m.as_bytes(), expected.as_bytes());
409 }
410}