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