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