1use 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
36const TOKEN_REFRESH_SKEW: Duration = Duration::from_secs(10);
38
39const DEFAULT_LEASE_SECS: u64 = 300;
41
42#[derive(Debug, Clone)]
44pub struct SpiffeConfig {
45 pub vault_addr: String,
47 pub transit_mount: String,
49 pub jwt_auth_mount: String,
51 pub role: String,
53 pub spiffe_id: String,
55 pub audience: String,
57 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 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 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 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 #[must_use]
137 pub fn public_key_pem(&self) -> &str {
138 self.minter.public_key_pem()
139 }
140
141 #[must_use]
143 pub fn spiffe_id(&self) -> &str {
144 self.minter.spiffe_id()
145 }
146
147 async fn token(&self) -> Result<Zeroizing<String>, BackendError> {
150 let mut guard = self.cached.lock().await;
151 if let Some(c) = guard.as_ref()
153 && 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 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 Ok(CachedToken {
197 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 #[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 assert_eq!(backend.addr, "http://127.0.0.1:8200");
430 }
431
432 #[test]
433 fn from_signer_rejects_invalid_pem() {
434 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}