Skip to main content

basil_core/core/backend/
spiffe.rs

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