Skip to main content

ares_store/
fleet_secrets.rs

1//! Fleet-wide, tenant-agnostic provider API key & config storage.
2//!
3//! Stores encrypted-at-rest provider overrides in PostgreSQL (loaded by
4//! `ares-store::fleet_provider_secrets`), decrypts into an in-memory
5//! `Arc<ArcSwap<FleetSecrets>>` map for lock-free hot-swap reads.
6//!
7//! Encryption: AES-256-GCM (RustCrypto `aes-gcm`) with a 96-bit nonce from
8//! `OsRng` per encryption. Master key is SHA-256 of `FLEET_SECRETS_KEY` env
9//! var, wrapped in `Zeroizing` so it is zeroed on drop.
10//!
11//! If `FLEET_SECRETS_KEY` is unset the module logs a single warning and
12//! treats all getters as returning `None` — the service does NOT refuse to
13//! start (OSS deployments without UI never set the master key).
14
15use 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/// Fixed-length error type for the fleet-secrets module.
28#[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/// In-memory decrypted view of a single provider override row.
43///
44/// `None` fields mean "no override" — falls back to env-var / config defaults.
45#[derive(Debug, Clone, Default, Serialize, Deserialize)]
46pub struct ProviderOverride {
47    /// Decrypted API key (raw bytes interpreted as UTF-8).
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub api_key: Option<String>,
50    /// Override `api_base` (e.g. swap OpenAI endpoint).
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub api_base: Option<String>,
53    /// Override `default_model` (e.g. switch to a different model id).
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub default_model: Option<String>,
56    /// Requests per minute limit.
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub rate_limit_rpm: Option<i32>,
59    /// Tokens per minute limit.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub rate_limit_tpm: Option<i32>,
62    /// Fallback provider names to try if this provider fails.
63    #[serde(default, skip_serializing_if = "Vec::is_empty")]
64    pub fallback_providers: Vec<String>,
65    /// Unix seconds; set by the loader when the row is hydrated.
66    #[serde(default)]
67    pub updated_at: i64,
68    /// Admin identity that last wrote the row.
69    #[serde(default)]
70    pub updated_by: String,
71}
72
73/// Decrypted, in-memory fleet secrets state. Cheap to clone (it's an
74/// `Arc<ArcSwap<...>>` under the hood), so callers can hand the wrapper
75/// directly to handlers.
76#[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    /// Construct an empty FleetSecrets wrapper.
88    pub fn new() -> Self {
89        Self::default()
90    }
91
92    /// Construct from a prebuilt map (used by the DB loader).
93    pub fn from_providers(providers: HashMap<String, ProviderOverride>) -> Self {
94        Self {
95            inner: Arc::new(ArcSwap::from_pointee(FleetSecretsInner { providers })),
96        }
97    }
98
99    /// Atomic swap to a new map. Reads continue to see the old map; new
100    /// readers see the new map.
101    pub fn store(&self, providers: HashMap<String, ProviderOverride>) {
102        self.inner.store(Arc::new(FleetSecretsInner { providers }));
103    }
104
105    /// Look up an override entry by provider name.
106    pub fn get(&self, provider_name: &str) -> Option<ProviderOverride> {
107        self.inner.load().providers.get(provider_name).cloned()
108    }
109
110    /// Return all overrides (cloned).
111    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    /// Total entries.
121    pub fn len(&self) -> usize {
122        self.inner.load().providers.len()
123    }
124
125    /// True if no entries.
126    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/// AES-256-GCM ciphertext with its 96-bit nonce. Stored side-by-side so the
144/// loader does not have to track nonces separately.
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct EncryptedPayload {
147    /// 96-bit nonce, unique per encryption.
148    pub nonce: Vec<u8>,
149    /// AEAD ciphertext (includes GCM auth tag at the end).
150    pub ciphertext: Vec<u8>,
151}
152
153/// Master-key wrapper that zeroes itself on drop. Cheap to clone via Arc.
154#[derive(Debug, Clone)]
155pub struct MasterKey {
156    /// SHA-256(env) → 32 bytes. Wrapped in `Zeroizing` to scrub on drop.
157    bytes: Arc<Zeroizing<[u8; 32]>>,
158}
159
160impl MasterKey {
161    /// Resolve the master key from `FLEET_SECRETS_KEY` env var. Returns
162    /// `None` and logs a single warning if the env var is unset/empty.
163    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    /// Build a MasterKey from an arbitrary string (for tests).
178    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    /// Borrow the raw 32-byte key. Do not copy or log.
190    pub fn as_bytes(&self) -> &[u8; 32] {
191        &self.bytes
192    }
193}
194
195/// Encrypt a UTF-8 plaintext API key. Returns the ciphertext + nonce.
196pub 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
223/// Decrypt a previously-encrypted payload back to the plaintext API key.
224pub 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
254/// Return the last `n` chars of a key, prefixed with `…`, for safe display.
255/// Returns `None` for empty input.
256pub 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/// Hex-encode a 32-byte key for diagnostic output (test-only).
269#[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        // Flip a byte in the middle of the ciphertext (away from the auth tag).
303        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], // wrong length
314            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        // "nvapi-abc12345XYZ" has 18 chars; last 4 = "5XYZ"
342        assert_eq!(
343            last_n_visible("nvapi-abc12345XYZ", 4),
344            Some("…5XYZ".to_string())
345        );
346        // 9 chars, n=8: 9>8, return "…" + last 8 = "…vapi-abc"
347        assert_eq!(
348            last_n_visible("nvapi-abc", 8),
349            Some("…vapi-abc".to_string())
350        );
351        // 9 chars, n=10: 9<=10, return full
352        assert_eq!(
353            last_n_visible("nvapi-abc", 10),
354            Some("nvapi-abc".to_string())
355        );
356        // Empty: None.
357        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        // Replace with empty map.
385        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        // SAFETY: tests in this module are not run in parallel, so mutating
393        // the env var is safe.
394        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        // Verify the master key produces consistent SHA-256.
407        let expected = MasterKey::from_secret("test-only-secret-not-real");
408        assert_eq!(m.as_bytes(), expected.as_bytes());
409    }
410}