Skip to main content

basil_core/core/backend/
spiffe.rs

1//! Vault-compatible (Vault or `OpenBao`) `transit` backend authenticated with a **self-minted
2//! JWT-SVID** instead of a static token.
3//!
4//! On demand the backend mints a JWT-SVID ([`SvidMinter`]), exchanges it at
5//! `auth/<mount>/login` for a short-lived Vault token, caches that token until
6//! it nears expiry, and then runs the identical transit operations as the
7//! static-token backend. "Same server, different protocol": the wire calls to
8//! transit are unchanged. Only the authentication handshake differs.
9//!
10//! Vault maps the SPIFFE id (the JWT `sub`) to a role and policy, so the
11//! *authorization* of what this broker may do lives in vault policy, which is
12//! where a future per-client ACL layer will plug in (choosing which SPIFFE id
13//! to assume per caller).
14
15use std::time::{Duration, Instant};
16
17use async_trait::async_trait;
18use serde_json::{Value, json};
19use tokio::sync::Mutex;
20use tracing::{debug, info};
21use zeroize::Zeroizing;
22
23use basil_proto::{AeadAlgorithm, CiphertextEnvelope, KeyMaterial, KeyType};
24
25use super::svid::SvidMinter;
26use super::transit::{TransitClient, read_body, transit_aead_type};
27use super::{Backend, BackendError, KeyMetadata, KvValue, NewKey, PublicKey, SignOptions};
28
29/// Re-login this long before the cached token actually expires.
30const TOKEN_REFRESH_SKEW: Duration = Duration::from_secs(10);
31
32/// Fallback cache lifetime if vault reports a non-expiring (`0`) lease.
33const DEFAULT_LEASE_SECS: u64 = 300;
34
35/// Configuration for the SPIFFE/JWT login exchange.
36#[derive(Debug, Clone)]
37pub struct SpiffeConfig {
38    /// Vault address, e.g. `http://127.0.0.1:8200`.
39    pub vault_addr: String,
40    /// Transit engine mount path, e.g. `transit`.
41    pub transit_mount: String,
42    /// JWT auth method mount path, e.g. `jwt`.
43    pub jwt_auth_mount: String,
44    /// Vault jwt role bound to this broker's SPIFFE id.
45    pub role: String,
46    /// SPIFFE id stamped into the SVID `sub`.
47    pub spiffe_id: String,
48    /// Audience (`aud`) the vault role expects (`bound_audiences`).
49    pub audience: String,
50    /// Lifetime of each minted SVID.
51    pub svid_ttl: Duration,
52}
53
54struct CachedToken {
55    token: String,
56    expires_at: Instant,
57}
58
59pub struct SpiffeVaultBackend {
60    http: reqwest::Client,
61    addr: String,
62    auth_mount: String,
63    role: String,
64    transit: TransitClient,
65    minter: SvidMinter,
66    cached: Mutex<Option<CachedToken>>,
67}
68
69impl SpiffeVaultBackend {
70    /// Build a backend that **self-generates** a fresh RSA issuer key. Useful for
71    /// tests and ephemeral setups; production boots from a sealed-bundle signer
72    /// cred via [`Self::from_signer`].
73    pub fn new(cfg: SpiffeConfig) -> Result<Self, BackendError> {
74        let minter =
75            SvidMinter::generate(cfg.spiffe_id.clone(), cfg.audience.clone(), cfg.svid_ttl)?;
76        Self::assemble(cfg, minter)
77    }
78
79    /// Build a backend from a sealed-bundle [`super::super::seal::BackendCred::SpiffeSigner`]:
80    /// an existing PEM private signing key (`PKCS#1` or `PKCS#8`) the broker uses
81    /// to self-issue its JWT-SVID, plus the deployment-supplied [`SpiffeConfig`].
82    ///
83    /// The cred's SPIFFE id (`cfg.spiffe_id`) is what the minter stamps into the
84    /// SVID `sub`; the key material never leaves this process.
85    pub fn from_signer(key_pem: &str, cfg: SpiffeConfig) -> Result<Self, BackendError> {
86        let minter = SvidMinter::from_pem(
87            key_pem,
88            cfg.spiffe_id.clone(),
89            cfg.audience.clone(),
90            cfg.svid_ttl,
91        )?;
92        Self::assemble(cfg, minter)
93    }
94
95    /// Shared wiring: build the HTTP/transit clients and assemble the backend
96    /// around an already-constructed [`SvidMinter`].
97    fn assemble(cfg: SpiffeConfig, minter: SvidMinter) -> Result<Self, BackendError> {
98        crate::ensure_crypto_provider();
99        let http = reqwest::Client::builder()
100            .build()
101            .map_err(|e| BackendError::Transport(e.to_string()))?;
102        let addr = cfg.vault_addr.trim_end_matches('/').to_string();
103        let transit = TransitClient::new(http.clone(), &addr, &cfg.transit_mount);
104        Ok(Self {
105            http,
106            addr,
107            auth_mount: cfg.jwt_auth_mount,
108            role: cfg.role,
109            transit,
110            minter,
111            cached: Mutex::new(None),
112        })
113    }
114
115    /// The broker's JWT-SVID validation public key (SPKI PEM). Register this
116    /// with Vault's jwt auth via `jwt_validation_pubkeys`.
117    #[must_use]
118    pub fn public_key_pem(&self) -> &str {
119        self.minter.public_key_pem()
120    }
121
122    /// The SPIFFE id this broker presents.
123    #[must_use]
124    pub fn spiffe_id(&self) -> &str {
125        self.minter.spiffe_id()
126    }
127
128    /// Return a valid Vault token, re-running the SVID login when the cached
129    /// token is missing or within [`TOKEN_REFRESH_SKEW`] of expiry.
130    async fn token(&self) -> Result<String, BackendError> {
131        let mut guard = self.cached.lock().await;
132        /* ubs:ignore false positive: time _not_ used as source of randomness. */
133        if let Some(c) = guard.as_ref()
134            /* ubs:ignore */
135            && c.expires_at > Instant::now() + TOKEN_REFRESH_SKEW
136        {
137            return Ok(c.token.clone());
138        }
139        let fresh = self.login().await?;
140        let token = fresh.token.clone();
141        *guard = Some(fresh);
142        drop(guard);
143        Ok(token)
144    }
145
146    /// Mint a fresh JWT-SVID and exchange it at `auth/<mount>/login`.
147    async fn login(&self) -> Result<CachedToken, BackendError> {
148        let jwt = self.minter.mint()?;
149        let url = format!("{}/v1/auth/{}/login", self.addr, self.auth_mount);
150        debug!(role = %self.role, spiffe_id = %self.minter.spiffe_id(), "exchanging JWT-SVID for vault token");
151
152        let resp = self
153            .http
154            .post(url)
155            .json(&json!({ "role": self.role, "jwt": jwt }))
156            .send()
157            .await
158            .map_err(|e| BackendError::Transport(e.to_string()))?;
159        let body = read_body(resp)
160            .await?
161            .ok_or_else(|| BackendError::Protocol("empty login response".into()))?;
162
163        let auth = body
164            .get("auth")
165            .ok_or_else(|| BackendError::Backend("login response has no auth block".into()))?;
166        let token = auth
167            .get("client_token")
168            .and_then(Value::as_str)
169            .ok_or_else(|| BackendError::Protocol("no client_token in login response".into()))?
170            .to_string();
171        let lease = auth
172            .get("lease_duration")
173            .and_then(Value::as_u64)
174            .filter(|&l| l > 0)
175            .unwrap_or(DEFAULT_LEASE_SECS);
176
177        info!(lease_seconds = lease, "obtained vault token via JWT-SVID");
178        /* ubs:ignore false positive: time is _not_ used as source of randomness. */
179        Ok(CachedToken {
180            /* ubs:ignore */
181            token,
182            expires_at: Instant::now() + Duration::from_secs(lease),
183        })
184    }
185}
186
187#[async_trait]
188impl Backend for SpiffeVaultBackend {
189    fn kind(&self) -> &'static str {
190        "spiffe-vault"
191    }
192
193    async fn new_key(&self, key_type: KeyType) -> Result<NewKey, BackendError> {
194        let token = self.token().await?;
195        self.transit.new_key(&token, key_type).await
196    }
197
198    async fn create_named_key(
199        &self,
200        key_id: &str,
201        key_type: KeyType,
202    ) -> Result<NewKey, BackendError> {
203        let token = self.token().await?;
204        self.transit
205            .create_named_key(&token, key_id, key_type)
206            .await
207    }
208
209    async fn create_named_aead(
210        &self,
211        key_id: &str,
212        aead: AeadAlgorithm,
213    ) -> Result<(), BackendError> {
214        let token = self.token().await?;
215        self.transit
216            .create_named_aead(&token, key_id, transit_aead_type(aead))
217            .await
218    }
219
220    async fn public_key(&self, key_id: &str) -> Result<Vec<u8>, BackendError> {
221        let token = self.token().await?;
222        self.transit.read_public_key(&token, key_id).await
223    }
224
225    async fn public_key_with_meta(&self, key_id: &str) -> Result<PublicKey, BackendError> {
226        let token = self.token().await?;
227        self.transit.read_public_key_with_meta(&token, key_id).await
228    }
229
230    async fn key_metadata(&self, key_id: &str) -> Result<KeyMetadata, BackendError> {
231        let token = self.token().await?;
232        self.transit.read_key_metadata(&token, key_id).await
233    }
234
235    async fn public_keys(
236        &self,
237        key_id: &str,
238    ) -> Result<std::collections::BTreeMap<u32, Vec<u8>>, BackendError> {
239        let token = self.token().await?;
240        self.transit.read_public_keys(&token, key_id).await
241    }
242
243    async fn import(
244        &self,
245        key_id: &str,
246        key_type: KeyType,
247        material: &KeyMaterial,
248    ) -> Result<NewKey, BackendError> {
249        let token = self.token().await?;
250        self.transit
251            .import(&token, key_id, key_type, material)
252            .await
253    }
254
255    async fn sign(&self, key_id: &str, message: &[u8]) -> Result<Vec<u8>, BackendError> {
256        let token = self.token().await?;
257        self.transit.sign(&token, key_id, message).await
258    }
259
260    async fn sign_with_options(
261        &self,
262        key_id: &str,
263        message: &[u8],
264        options: SignOptions,
265    ) -> Result<Vec<u8>, BackendError> {
266        let token = self.token().await?;
267        self.transit
268            .sign_with_options(&token, key_id, message, options)
269            .await
270    }
271
272    async fn verify(
273        &self,
274        key_id: &str,
275        message: &[u8],
276        signature: &[u8],
277    ) -> Result<bool, BackendError> {
278        let token = self.token().await?;
279        self.transit
280            .verify(&token, key_id, message, signature)
281            .await
282    }
283
284    async fn verify_with_options(
285        &self,
286        key_id: &str,
287        message: &[u8],
288        signature: &[u8],
289        options: SignOptions,
290    ) -> Result<bool, BackendError> {
291        let token = self.token().await?;
292        self.transit
293            .verify_with_options(&token, key_id, message, signature, options)
294            .await
295    }
296
297    async fn encrypt(
298        &self,
299        key_id: &str,
300        algorithm: AeadAlgorithm,
301        plaintext: &[u8],
302        aad: Option<&[u8]>,
303    ) -> Result<CiphertextEnvelope, BackendError> {
304        let token = self.token().await?;
305        self.transit
306            .encrypt(&token, key_id, algorithm, plaintext, aad)
307            .await
308    }
309
310    async fn decrypt(
311        &self,
312        key_id: &str,
313        envelope: &CiphertextEnvelope,
314        aad: Option<&[u8]>,
315    ) -> Result<Vec<u8>, BackendError> {
316        let token = self.token().await?;
317        self.transit.decrypt(&token, key_id, envelope, aad).await
318    }
319
320    async fn rotate(&self, key_id: &str) -> Result<u32, BackendError> {
321        let token = self.token().await?;
322        self.transit.rotate(&token, key_id).await
323    }
324
325    async fn kv_get(&self, key_id: &str, version: Option<u32>) -> Result<KvValue, BackendError> {
326        let token = self.token().await?;
327        self.transit.kv_get(&token, key_id, version).await
328    }
329
330    async fn kv_get_secret(
331        &self,
332        key_id: &str,
333        version: Option<u32>,
334    ) -> Result<Zeroizing<Vec<u8>>, BackendError> {
335        let token = self.token().await?;
336        self.transit.kv_get_secret(&token, key_id, version).await
337    }
338
339    async fn kv_put(&self, key_id: &str, value: &[u8]) -> Result<u32, BackendError> {
340        let token = self.token().await?;
341        self.transit.kv_put(&token, key_id, value).await
342    }
343
344    async fn configure_versions(
345        &self,
346        key_id: &str,
347        min_decryption_version: Option<u32>,
348        min_available_version: Option<u32>,
349    ) -> Result<(), BackendError> {
350        let token = self.token().await?;
351        self.transit
352            .configure_versions(
353                &token,
354                key_id,
355                min_decryption_version,
356                min_available_version,
357            )
358            .await
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    use super::{Backend, Duration, SpiffeConfig, SpiffeVaultBackend};
365    use rsa::RsaPrivateKey;
366    use rsa::pkcs8::{EncodePrivateKey, LineEnding};
367
368    fn config() -> SpiffeConfig {
369        SpiffeConfig {
370            vault_addr: "http://127.0.0.1:8200/".to_string(),
371            transit_mount: "transit".to_string(),
372            jwt_auth_mount: "jwt".to_string(),
373            role: "basil".to_string(),
374            spiffe_id: "spiffe://example.test/basil".to_string(),
375            audience: "openbao".to_string(),
376            svid_ttl: Duration::from_mins(2),
377        }
378    }
379
380    /// `from_signer` boots a backend from an existing PEM signing key (the sealed
381    /// `SpiffeSigner` cred path) and stamps the cred's SPIFFE id into the SVID.
382    #[test]
383    fn from_signer_builds_from_bundle_pem() {
384        let mut rng = rand::thread_rng();
385        let key = RsaPrivateKey::new(&mut rng, 1024).expect("rsa keygen");
386        let pem = key.to_pkcs8_pem(LineEnding::LF).expect("pkcs8 pem");
387
388        let backend = SpiffeVaultBackend::from_signer(&pem, config())
389            .expect("construct backend from signer cred");
390        assert_eq!(backend.kind(), "spiffe-vault");
391        assert_eq!(backend.spiffe_id(), "spiffe://example.test/basil");
392        assert!(backend.public_key_pem().contains("BEGIN PUBLIC KEY"));
393        // Trailing slash on the configured addr is normalized away.
394        assert_eq!(backend.addr, "http://127.0.0.1:8200");
395    }
396
397    #[test]
398    fn from_signer_rejects_invalid_pem() {
399        // `SpiffeVaultBackend` is not `Debug`, so match rather than `expect_err`.
400        match SpiffeVaultBackend::from_signer("garbage", config()) {
401            Err(super::BackendError::Backend(_)) => {}
402            Err(other) => panic!("wrong error: {other}"),
403            Ok(_) => panic!("invalid pem must be rejected"),
404        }
405    }
406}