Skip to main content

basil_core/service/
spiffe.rs

1// SPDX-FileCopyrightText: 2026 OpenBasil Contributors
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! SPIFFE Workload API service shell.
6//!
7//! Each method first enforces SPIFFE's `workload.spiffe.io=true` metadata
8//! marker before serving SVID and trust-bundle material.
9
10#![allow(clippy::result_large_err)]
11
12use std::pin::Pin;
13use std::sync::Arc;
14use std::time::Duration;
15
16use base64::Engine as _;
17use base64::engine::general_purpose::URL_SAFE_NO_PAD;
18use basil_proto::spiffe::spiffe_workload_api_server::SpiffeWorkloadApi;
19use basil_proto::spiffe::{
20    JwtBundlesRequest, JwtBundlesResponse, Jwtsvid, JwtsvidRequest, JwtsvidResponse,
21    ValidateJwtsvidRequest, ValidateJwtsvidResponse, X509BundlesRequest, X509BundlesResponse,
22    X509svid, X509svidRequest, X509svidResponse,
23};
24use futures::Stream;
25use std::collections::HashMap;
26use tonic::{Code, Request, Response, Status};
27
28use crate::catalog::policy::Op;
29use crate::catalog::{Class, Decision, DenyReason, KeyAlgorithm, KeyEntry};
30use crate::decision::DecisionRecord;
31use crate::event::BrokerEventKind;
32use crate::state::{BrokerState, Generation};
33use crate::transport::peer_from_request;
34
35type WorkloadResult<T> = Result<Response<T>, Status>;
36type BoxStream<T> = Pin<Box<dyn Stream<Item = Result<T, Status>> + Send + 'static>>;
37
38/// SPIFFE Workload API adapter.
39#[derive(Debug, Clone)]
40pub struct SpiffeWorkloadGrpc {
41    state: Arc<BrokerState>,
42}
43
44impl SpiffeWorkloadGrpc {
45    /// Build a Workload API service adapter.
46    #[must_use]
47    pub const fn new(state: Arc<BrokerState>) -> Self {
48        Self { state }
49    }
50}
51
52#[tonic::async_trait]
53impl SpiffeWorkloadApi for SpiffeWorkloadGrpc {
54    type FetchX509SVIDStream = BoxStream<X509svidResponse>;
55    type FetchX509BundlesStream = BoxStream<X509BundlesResponse>;
56    type FetchJWTBundlesStream = BoxStream<JwtBundlesResponse>;
57
58    async fn fetch_x509svid(
59        &self,
60        request: Request<X509svidRequest>,
61    ) -> WorkloadResult<Self::FetchX509SVIDStream> {
62        require_workload_header(&request)?;
63        let peer = peer_from_request(&request);
64        let uid = peer.uid.ok_or_else(|| {
65            Status::new(
66                Code::Unauthenticated,
67                "missing peer credentials for FetchX509SVID",
68            )
69        })?;
70        let plan = self.x509_issue_plan(uid)?;
71        let state = Arc::clone(&self.state);
72        let rx = state.events().subscribe();
73        let stream = futures::stream::unfold(
74            (state, rx, plan, uid, false),
75            |(state, mut rx, plan, uid, emitted)| async move {
76                if !emitted {
77                    let response = issue_x509_response(&state, uid, &plan).await;
78                    return Some((response, (state, rx, plan, uid, true)));
79                }
80
81                let refresh =
82                    tokio::time::sleep(Duration::from_secs(x509_refresh_after_secs(&plan)));
83                tokio::pin!(refresh);
84                loop {
85                    tokio::select! {
86                        () = &mut refresh => break,
87                        event = rx.recv() => match event {
88                            Ok(event) if x509_refresh_event(&plan, &event.kind) => break,
89                            Ok(_) | Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
90                            Err(tokio::sync::broadcast::error::RecvError::Closed) => return None,
91                        }
92                    }
93                }
94
95                let response = issue_x509_response(&state, uid, &plan).await;
96                Some((response, (state, rx, plan, uid, true)))
97            },
98        );
99        Ok(Response::new(Box::pin(stream)))
100    }
101
102    async fn fetch_x509_bundles(
103        &self,
104        request: Request<X509BundlesRequest>,
105    ) -> WorkloadResult<Self::FetchX509BundlesStream> {
106        require_workload_header(&request)?;
107        let plan = self.x509_bundle_plan()?;
108        let state = Arc::clone(&self.state);
109        let rx = state.events().subscribe();
110        let stream = futures::stream::unfold(
111            (state, rx, plan, false),
112            |(state, mut rx, plan, emitted)| async move {
113                if !emitted {
114                    let response = x509_bundles_response(&state, &plan).await;
115                    return Some((response, (state, rx, plan, true)));
116                }
117
118                loop {
119                    match rx.recv().await {
120                        Ok(event) if x509_bundle_refresh_event(&plan, &event.kind) => break,
121                        Ok(_) | Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
122                        Err(tokio::sync::broadcast::error::RecvError::Closed) => return None,
123                    }
124                }
125
126                let response = x509_bundles_response(&state, &plan).await;
127                Some((response, (state, rx, plan, true)))
128            },
129        );
130        Ok(Response::new(Box::pin(stream)))
131    }
132
133    async fn fetch_jwtsvid(
134        &self,
135        request: Request<JwtsvidRequest>,
136    ) -> WorkloadResult<JwtsvidResponse> {
137        require_workload_header(&request)?;
138        let peer = peer_from_request(&request);
139        let uid = peer.uid.ok_or_else(|| {
140            Status::new(
141                Code::Unauthenticated,
142                "missing peer credentials for FetchJWTSVID",
143            )
144        })?;
145        let body = request.get_ref();
146        if body.audience.is_empty() || body.audience.iter().any(|aud| aud.trim().is_empty()) {
147            return Err(invalid_argument(
148                "FetchJWTSVID requires a non-empty audience",
149            ));
150        }
151        if !body.spiffe_id.is_empty() && !is_spiffe_id(body.spiffe_id.as_str()) {
152            return Err(invalid_argument("requested SPIFFE ID is malformed"));
153        }
154
155        // Pin ONE generation for the whole RPC (basil-gymz): the issuer choice +
156        // mint authorization AND the templated SPIFFE id are rendered against the
157        // same catalog/config snapshot. A concurrent reload between the two loads
158        // could otherwise template the id against a different generation than the
159        // one that authorized the mint. One pin keeps them coherent by construction.
160        let generation = Arc::clone(&self.state.load_generation());
161        let issuer = self.jwt_issuer(&generation, uid, body.spiffe_id.as_str())?;
162        let spiffe_id = requested_or_templated_spiffe_id(
163            &generation,
164            uid,
165            body.spiffe_id.as_str(),
166            issuer.entry,
167        )?;
168        let issuer_id = issuer
169            .entry
170            .labels
171            .get("spiffe_id")
172            .unwrap_or(issuer.name.as_str());
173        let alg = svid_alg(issuer.entry.key_type)?;
174
175        let mut svids = Vec::with_capacity(body.audience.len());
176        for audience in &body.audience {
177            let token = crate::minter::mint_svid(
178                issuer.backend,
179                &issuer.path,
180                issuer_id,
181                alg,
182                &spiffe_id,
183                audience,
184                Some(DEFAULT_JWT_SVID_TTL_SECS),
185                &serde_json::Value::Null,
186            )
187            .await
188            .map_err(|e| mint_status(&e))?;
189            svids.push(Jwtsvid {
190                spiffe_id: spiffe_id.clone(),
191                svid: token,
192                hint: String::new(),
193            });
194        }
195        Ok(Response::new(JwtsvidResponse { svids }))
196    }
197
198    async fn fetch_jwt_bundles(
199        &self,
200        request: Request<JwtBundlesRequest>,
201    ) -> WorkloadResult<Self::FetchJWTBundlesStream> {
202        require_workload_header(&request)?;
203        let plan = self.jwt_bundle_plan()?;
204        let state = Arc::clone(&self.state);
205        let rx = state.events().subscribe();
206        let stream = futures::stream::unfold(
207            (state, rx, plan, false),
208            |(state, mut rx, plan, emitted)| async move {
209                if !emitted {
210                    let response = jwt_bundles_response(&state, &plan).await;
211                    return Some((response, (state, rx, plan, true)));
212                }
213
214                loop {
215                    match rx.recv().await {
216                        Ok(event) if jwt_bundle_refresh_event(&plan, &event.kind) => break,
217                        Ok(_) | Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
218                        Err(tokio::sync::broadcast::error::RecvError::Closed) => return None,
219                    }
220                }
221
222                let response = jwt_bundles_response(&state, &plan).await;
223                Some((response, (state, rx, plan, true)))
224            },
225        );
226        Ok(Response::new(Box::pin(stream)))
227    }
228
229    async fn validate_jwtsvid(
230        &self,
231        request: Request<ValidateJwtsvidRequest>,
232    ) -> WorkloadResult<ValidateJwtsvidResponse> {
233        require_workload_header(&request)?;
234        let peer = peer_from_request(&request);
235        let uid = peer.uid.ok_or_else(|| {
236            Status::new(
237                Code::Unauthenticated,
238                "missing peer credentials for ValidateJWTSVID",
239            )
240        })?;
241        let body = request.get_ref();
242        if body.audience.trim().is_empty() || body.svid.trim().is_empty() {
243            return Err(invalid_argument(
244                "ValidateJWTSVID requires a non-empty audience and SVID",
245            ));
246        }
247
248        let generation = Arc::clone(&self.state.load_generation());
249        let unverified = unverified_jwt_svid_claims(&body.svid)?;
250        let issuer = self.jwt_validation_issuer(&generation, uid, &unverified)?;
251        let validation = validate_jwt_svid(
252            issuer.backend,
253            &issuer.path,
254            issuer.entry,
255            &issuer.issuer_id,
256            &body.audience,
257            &body.svid,
258        )
259        .await?;
260        let trust_domain = issuer
261            .entry
262            .labels
263            .get("trust_domain")
264            .ok_or_else(validation_failed)?;
265        reject_revoked_jwtsvid(
266            self.state.jwt_revocations(),
267            trust_domain,
268            &validation.claims,
269        )?;
270        Ok(Response::new(ValidateJwtsvidResponse {
271            spiffe_id: validation.spiffe_id,
272            claims: Some(json_struct(validation.claims)),
273        }))
274    }
275}
276
277fn require_workload_header<T>(request: &Request<T>) -> Result<(), Status> {
278    let mut values = request.metadata().get_all("workload.spiffe.io").iter();
279    let valid = values
280        .next()
281        .and_then(|value| value.to_str().ok())
282        .is_some_and(|value| value == "true")
283        && values.next().is_none();
284    if valid {
285        return Ok(());
286    }
287
288    Err(Status::new(
289        Code::InvalidArgument,
290        "SPIFFE Workload API requests require workload.spiffe.io=true",
291    ))
292}
293
294const DEFAULT_JWT_SVID_TTL_SECS: u64 = 300;
295
296struct JwtIssuer<'a> {
297    name: String,
298    path: String,
299    entry: &'a KeyEntry,
300    backend: &'a dyn crate::backend::Backend,
301}
302
303struct JwtValidationIssuer<'a> {
304    path: String,
305    issuer_id: String,
306    entry: &'a KeyEntry,
307    backend: &'a dyn crate::backend::Backend,
308}
309
310struct ValidJwtSvid {
311    spiffe_id: String,
312    claims: serde_json::Value,
313}
314
315#[derive(Debug, Clone)]
316struct X509IssuePlan {
317    key_name: String,
318    spiffe_id: String,
319    trust_domain: String,
320    ttl_seconds: u64,
321}
322
323#[derive(Debug, Clone)]
324struct X509BundlePlan {
325    key_name: String,
326    trust_domain: String,
327}
328
329#[derive(Debug, Clone)]
330struct JwtBundlePlan {
331    key_name: String,
332    trust_domain: String,
333}
334
335impl SpiffeWorkloadGrpc {
336    fn x509_issue_plan(&self, uid: u32) -> Result<X509IssuePlan, Status> {
337        // Pin one generation for the whole plan: every PDP decision and the
338        // templated SPIFFE-ID rendering draw from the same coherent snapshot.
339        let generation = self.state.load_generation();
340        let actor = generation.pdp().resolve_unix_actor(uid).map_err(|_| {
341            Status::new(
342                Code::PermissionDenied,
343                "no configured subject for FetchX509SVID caller",
344            )
345        })?;
346        let mut saw_candidate = false;
347        for (name, entry) in &generation.catalog().keys {
348            if !is_x509_svid_issuer(entry) {
349                continue;
350            }
351            saw_candidate = true;
352            let decision = generation.pdp().decide(&actor, Op::Mint, name);
353            self.state
354                .record_decision(&DecisionRecord::from_actor_decision(
355                    generation.id(),
356                    &actor,
357                    Op::Mint,
358                    name,
359                    &decision,
360                ));
361            if decision.is_deny() {
362                continue;
363            }
364            let spiffe_id = requested_or_templated_spiffe_id(&generation, uid, "", entry)?;
365            let trust_domain = entry
366                .labels
367                .get("trust_domain")
368                .ok_or_else(|| {
369                    Status::new(
370                        Code::Internal,
371                        "X.509-SVID issuer has no trust_domain label",
372                    )
373                })?
374                .to_string();
375            return Ok(X509IssuePlan {
376                key_name: name.clone(),
377                spiffe_id,
378                trust_domain,
379                ttl_seconds: self.state.limits().svid_ttl_secs.max(1),
380            });
381        }
382
383        let reason = if saw_candidate {
384            "not authorized to mint an X.509-SVID"
385        } else {
386            "no X.509-SVID issuer is configured"
387        };
388        self.state
389            .record_decision(&DecisionRecord::from_actor_decision(
390                generation.id(),
391                &actor,
392                Op::Mint,
393                "spiffe.x509_svid",
394                &Decision::Deny {
395                    reason: DenyReason::NotPermitted,
396                },
397            ));
398        Err(Status::new(Code::PermissionDenied, reason))
399    }
400
401    /// Find the JWT-SVID issuer for `requested_spiffe_id` and authorize the mint,
402    /// deciding against the **caller-pinned** `generation` so the whole
403    /// `fetch_jwtsvid` RPC stays coherent across a concurrent reload (basil-gymz).
404    fn jwt_issuer<'a>(
405        &'a self,
406        generation: &'a Generation,
407        uid: u32,
408        requested_spiffe_id: &str,
409    ) -> Result<JwtIssuer<'a>, Status> {
410        let actor = generation.pdp().resolve_unix_actor(uid).map_err(|_| {
411            Status::new(
412                Code::PermissionDenied,
413                "no configured subject for FetchJWTSVID caller",
414            )
415        })?;
416        let mut saw_candidate = false;
417        for (name, entry) in &generation.catalog().keys {
418            if !is_jwt_svid_issuer(entry) {
419                continue;
420            }
421            if !requested_spiffe_id.is_empty()
422                && !spiffe_id_matches_trust_domain(requested_spiffe_id, entry)
423            {
424                continue;
425            }
426            saw_candidate = true;
427            let decision = generation.pdp().decide(&actor, Op::Mint, name);
428            self.state
429                .record_decision(&DecisionRecord::from_actor_decision(
430                    generation.id(),
431                    &actor,
432                    Op::Mint,
433                    name,
434                    &decision,
435                ));
436            if decision.is_deny() {
437                continue;
438            }
439            let routed = self
440                .state
441                .manager()
442                .resolve(name)
443                .map_err(|e| Status::new(Code::Internal, e.to_string()))?;
444            return Ok(JwtIssuer {
445                name: name.clone(),
446                path: routed.path().to_string(),
447                entry,
448                backend: routed.backend,
449            });
450        }
451
452        let reason = if saw_candidate {
453            "not authorized to mint a JWT-SVID"
454        } else {
455            "no JWT-SVID issuer matches the requested SPIFFE ID"
456        };
457        self.state
458            .record_decision(&DecisionRecord::from_actor_decision(
459                generation.id(),
460                &actor,
461                Op::Mint,
462                "spiffe.jwt_svid",
463                &Decision::Deny {
464                    reason: DenyReason::NotPermitted,
465                },
466            ));
467        Err(Status::new(Code::PermissionDenied, reason))
468    }
469
470    fn jwt_validation_issuer<'a>(
471        &'a self,
472        generation: &'a Generation,
473        uid: u32,
474        claims: &serde_json::Value,
475    ) -> Result<JwtValidationIssuer<'a>, Status> {
476        let actor = generation.pdp().resolve_unix_actor(uid).map_err(|_| {
477            Status::new(
478                Code::PermissionDenied,
479                "no configured subject for ValidateJWTSVID caller",
480            )
481        })?;
482        let iss = claims
483            .get("iss")
484            .and_then(serde_json::Value::as_str)
485            .ok_or_else(validation_failed)?;
486        let sub = claims
487            .get("sub")
488            .and_then(serde_json::Value::as_str)
489            .ok_or_else(validation_failed)?;
490        if !is_spiffe_id(sub) {
491            return Err(validation_failed());
492        }
493
494        let mut saw_candidate = false;
495        for (name, entry) in &generation.catalog().keys {
496            if !is_jwt_svid_issuer(entry) || !spiffe_id_matches_trust_domain(sub, entry) {
497                continue;
498            }
499            let issuer_id = entry
500                .labels
501                .get("spiffe_id")
502                .map_or_else(|| name.as_str(), |value| value);
503            /* ubs constant time equality check is not needed for the iss field */
504            /* ubs:ignore */
505            if issuer_id != iss {
506                continue;
507            }
508            saw_candidate = true;
509            let decision = generation.pdp().decide(&actor, Op::Validate, name);
510            self.state
511                .record_decision(&DecisionRecord::from_actor_decision(
512                    generation.id(),
513                    &actor,
514                    Op::Validate,
515                    name,
516                    &decision,
517                ));
518            if decision.is_deny() {
519                continue;
520            }
521            let routed = self
522                .state
523                .manager()
524                .resolve(name)
525                .map_err(|e| Status::new(Code::Internal, e.to_string()))?;
526            return Ok(JwtValidationIssuer {
527                path: routed.path().to_string(),
528                issuer_id: issuer_id.to_string(),
529                entry,
530                backend: routed.backend,
531            });
532        }
533
534        if saw_candidate {
535            return Err(Status::new(
536                Code::PermissionDenied,
537                "not authorized to validate a JWT-SVID",
538            ));
539        }
540        Err(validation_failed())
541    }
542
543    fn x509_bundle_plan(&self) -> Result<Vec<X509BundlePlan>, Status> {
544        let generation = self.state.load_generation();
545        let plans: Vec<_> = generation
546            .catalog()
547            .keys
548            .iter()
549            .filter(|(_, entry)| is_x509_svid_issuer(entry))
550            .filter_map(|(name, entry)| {
551                entry
552                    .labels
553                    .get("trust_domain")
554                    .map(|trust_domain| X509BundlePlan {
555                        key_name: name.clone(),
556                        trust_domain: trust_domain.to_string(),
557                    })
558            })
559            .collect();
560        if plans.is_empty() {
561            Err(Status::new(
562                Code::FailedPrecondition,
563                "no X.509 bundle publisher is configured",
564            ))
565        } else {
566            Ok(plans)
567        }
568    }
569
570    fn jwt_bundle_plan(&self) -> Result<Vec<JwtBundlePlan>, Status> {
571        let generation = self.state.load_generation();
572        let plans: Vec<_> = generation
573            .catalog()
574            .keys
575            .iter()
576            .filter(|(_, entry)| is_jwt_svid_issuer(entry))
577            .filter_map(|(name, entry)| {
578                entry
579                    .labels
580                    .get("trust_domain")
581                    .map(|trust_domain| JwtBundlePlan {
582                        key_name: name.clone(),
583                        trust_domain: trust_domain.to_string(),
584                    })
585            })
586            .collect();
587        if plans.is_empty() {
588            Err(Status::new(
589                Code::FailedPrecondition,
590                "no JWT bundle publisher is configured",
591            ))
592        } else {
593            Ok(plans)
594        }
595    }
596}
597
598async fn issue_x509_response(
599    state: &BrokerState,
600    _uid: u32,
601    plan: &X509IssuePlan,
602) -> Result<X509svidResponse, Status> {
603    let mut issued = state
604        .manager()
605        .issue_x509_svid(&plan.key_name, &plan.spiffe_id, plan.ttl_seconds)
606        .await
607        .map_err(|err| x509_issue_status(&err))?;
608    Ok(X509svidResponse {
609        svids: vec![X509svid {
610            spiffe_id: plan.spiffe_id.clone(),
611            x509_svid: issued.cert_chain_der.concat(),
612            // Move (never copy) the leaf key out of its `Zeroizing` buffer:
613            // the proto field is then the only plain copy, and `X509svid`
614            // zeroizes it on drop after tonic encodes the response.
615            x509_svid_key: std::mem::take(&mut *issued.leaf_private_key_der),
616            bundle: issued.bundle_der.concat(),
617            hint: String::new(),
618        }],
619        crl: Vec::new(),
620        federated_bundles: std::collections::HashMap::default(),
621    })
622}
623
624fn x509_refresh_after_secs(plan: &X509IssuePlan) -> u64 {
625    (plan.ttl_seconds / 2).max(1)
626}
627
628fn x509_refresh_event(plan: &X509IssuePlan, kind: &BrokerEventKind) -> bool {
629    match kind {
630        /* ubs: constant time equality check is not needed for checking key name */
631        BrokerEventKind::KeyRotated { key_id, .. } => {
632            /* ubs:ignore */
633            key_id == &plan.key_name
634        }
635        BrokerEventKind::BundleChanged { trust_domain }
636        | BrokerEventKind::Revoked { trust_domain, .. } => {
637            /* ubs:ignore */
638            trust_domain == &plan.trust_domain
639        }
640    }
641}
642
643fn x509_bundle_refresh_event(plans: &[X509BundlePlan], kind: &BrokerEventKind) -> bool {
644    plans.iter().any(|plan| match kind {
645        BrokerEventKind::KeyRotated { key_id, .. } => {
646            /* ubs:ignore */
647            key_id == &plan.key_name
648        }
649        BrokerEventKind::BundleChanged { trust_domain }
650        | BrokerEventKind::Revoked { trust_domain, .. } => {
651            /* ubs:ignore */
652            trust_domain == &plan.trust_domain
653        }
654    })
655}
656
657fn jwt_bundle_refresh_event(plans: &[JwtBundlePlan], kind: &BrokerEventKind) -> bool {
658    plans.iter().any(|plan| match kind {
659        BrokerEventKind::KeyRotated { key_id, .. } => {
660            /* ubs:ignore */
661            key_id == &plan.key_name
662        }
663        BrokerEventKind::BundleChanged { trust_domain }
664        | BrokerEventKind::Revoked { trust_domain, .. } => {
665            /* ubs:ignore */
666            trust_domain == &plan.trust_domain
667        }
668    })
669}
670
671async fn x509_bundles_response(
672    state: &BrokerState,
673    plans: &[X509BundlePlan],
674) -> Result<X509BundlesResponse, Status> {
675    let mut bundles = HashMap::new();
676    let mut crl = Vec::new();
677    for plan in plans {
678        let routed = state
679            .manager()
680            .resolve(&plan.key_name)
681            .map_err(|e| Status::new(Code::Internal, e.to_string()))?;
682        let bundle = routed
683            .backend
684            .x509_bundle(routed.path())
685            .await
686            .map_err(|_| upstream_unavailable())?;
687        bundles.insert(
688            format!("spiffe://{}", plan.trust_domain),
689            bundle.bundle_der.concat(),
690        );
691        if !bundle.crl_der.is_empty() {
692            crl.push(bundle.crl_der);
693        }
694    }
695    Ok(X509BundlesResponse { crl, bundles })
696}
697
698async fn jwt_bundles_response(
699    state: &BrokerState,
700    plans: &[JwtBundlePlan],
701) -> Result<JwtBundlesResponse, Status> {
702    let mut bundles = HashMap::new();
703    for plan in plans {
704        let routed = state
705            .manager()
706            .resolve(&plan.key_name)
707            .map_err(|e| Status::new(Code::Internal, e.to_string()))?;
708        let alg = svid_alg(routed.entry.key_type)?;
709        // Reflect the rotation grace window: publish every issuer version still
710        // inside `[grace_floor ..= latest]` so a verifier can validate a token
711        // signed by a recently rotated-away version. Same generator the HTTP JWKS
712        // uses (`basil-uce.2`), so the two surfaces never diverge.
713        let limits = state.limits();
714        let jwks =
715            crate::minter::jwt_svid_jwks_grace(routed.backend, routed.path(), alg, |latest| {
716                limits.grace_floor(latest)
717            })
718            .await
719            .map_err(|_| upstream_unavailable())?;
720        bundles.insert(format!("spiffe://{}", plan.trust_domain), jwks);
721    }
722    Ok(JwtBundlesResponse { bundles })
723}
724
725async fn validate_jwt_svid(
726    backend: &dyn crate::backend::Backend,
727    key_path: &str,
728    entry: &KeyEntry,
729    issuer_id: &str,
730    audience: &str,
731    token: &str,
732) -> Result<ValidJwtSvid, Status> {
733    let alg = svid_alg(entry.key_type)?;
734    let public_key = backend
735        .public_key(key_path)
736        .await
737        .map_err(|_| validation_failed())?;
738    let decoding_key = decoding_key(&public_key, alg)?;
739    let algorithm = jwt_algorithm(alg);
740    let mut validation = jsonwebtoken::Validation::new(algorithm);
741    validation.set_required_spec_claims(&["exp", "iss", "sub", "aud"]);
742    validation.set_issuer(&[issuer_id]);
743    validation.set_audience(&[audience]);
744    let token_data = jsonwebtoken::decode::<serde_json::Value>(token, &decoding_key, &validation)
745        .map_err(|_| validation_failed())?;
746    let spiffe_id = token_data
747        .claims
748        .get("sub")
749        .and_then(serde_json::Value::as_str)
750        .filter(|sub| is_spiffe_id(sub) && spiffe_id_matches_trust_domain(sub, entry))
751        .ok_or_else(validation_failed)?
752        .to_string();
753    Ok(ValidJwtSvid {
754        spiffe_id,
755        claims: token_data.claims,
756    })
757}
758
759fn unverified_jwt_svid_claims(token: &str) -> Result<serde_json::Value, Status> {
760    let mut parts = token.split('.');
761    let _header = parts.next().ok_or_else(validation_failed)?;
762    let claims = parts.next().ok_or_else(validation_failed)?;
763    let _signature = parts.next().ok_or_else(validation_failed)?;
764    if parts.next().is_some() {
765        return Err(validation_failed());
766    }
767    let bytes = URL_SAFE_NO_PAD
768        .decode(claims)
769        .map_err(|_| validation_failed())?;
770    serde_json::from_slice(&bytes).map_err(|_| validation_failed())
771}
772
773fn reject_revoked_jwtsvid(
774    store: &crate::revocation::JwtRevocationStore,
775    trust_domain: &str,
776    claims: &serde_json::Value,
777) -> Result<(), Status> {
778    // Revocation is `jti`-scoped and the broker minter stamps `jti`
779    // unconditionally (`mint_svid`): a token without one can never be revoked,
780    // so accepting it would make revocation unenforceable. Fail closed.
781    let jti = claims
782        .get("jti")
783        .and_then(serde_json::Value::as_str)
784        .ok_or_else(validation_failed)?;
785    if store.is_revoked(trust_domain, jti) {
786        return Err(validation_failed());
787    }
788    Ok(())
789}
790
791fn decoding_key(
792    public_key: &[u8],
793    alg: crate::minter::SvidAlg,
794) -> Result<jsonwebtoken::DecodingKey, Status> {
795    match alg {
796        crate::minter::SvidAlg::EdDsa if public_key.len() == 32 => {
797            Ok(jsonwebtoken::DecodingKey::from_ed_der(public_key))
798        }
799        crate::minter::SvidAlg::EdDsa => Err(validation_failed()),
800        crate::minter::SvidAlg::Rs256 => {
801            if let Ok(pem) = std::str::from_utf8(public_key)
802                && pem.trim_start().starts_with("-----BEGIN ")
803            {
804                return jsonwebtoken::DecodingKey::from_rsa_pem(public_key)
805                    .map_err(|_| validation_failed());
806            }
807            Ok(jsonwebtoken::DecodingKey::from_rsa_der(public_key))
808        }
809        crate::minter::SvidAlg::Es256 => {
810            if let Ok(pem) = std::str::from_utf8(public_key)
811                && pem.trim_start().starts_with("-----BEGIN ")
812            {
813                return jsonwebtoken::DecodingKey::from_ec_pem(public_key)
814                    .map_err(|_| validation_failed());
815            }
816            Ok(jsonwebtoken::DecodingKey::from_ec_der(public_key))
817        }
818        crate::minter::SvidAlg::Es384 => {
819            if let Ok(pem) = std::str::from_utf8(public_key)
820                && pem.trim_start().starts_with("-----BEGIN ")
821            {
822                return jsonwebtoken::DecodingKey::from_ec_pem(public_key)
823                    .map_err(|_| validation_failed());
824            }
825            Ok(jsonwebtoken::DecodingKey::from_ec_der(public_key))
826        }
827    }
828}
829
830const fn jwt_algorithm(alg: crate::minter::SvidAlg) -> jsonwebtoken::Algorithm {
831    match alg {
832        crate::minter::SvidAlg::EdDsa => jsonwebtoken::Algorithm::EdDSA,
833        crate::minter::SvidAlg::Rs256 => jsonwebtoken::Algorithm::RS256,
834        crate::minter::SvidAlg::Es256 => jsonwebtoken::Algorithm::ES256,
835        crate::minter::SvidAlg::Es384 => jsonwebtoken::Algorithm::ES384,
836    }
837}
838
839fn is_x509_svid_issuer(entry: &KeyEntry) -> bool {
840    entry.class == Class::Asymmetric
841        && entry.labels.get("svid_kind") == Some("x509")
842        && entry.labels.get("trust_domain").is_some()
843}
844
845fn is_jwt_svid_issuer(entry: &KeyEntry) -> bool {
846    /* ubs false positive: do not need constant time equality checks here */
847    entry.class == Class::Asymmetric
848        /* ubs:ignore */
849        && entry.labels.get("svid_kind") == Some("jwt")
850        && entry.labels.get("trust_domain").is_some()
851        // Only a SPIFFE JWT-SVID profile algorithm (rsa-*/ec-* → RS*/ES*/PS*)
852        // is a valid issuer: an EdDSA/ed25519 token is rejected by conforming
853        // SPIFFE clients. The catalog loader fails closed on such a misconfig at
854        // boot/check (`validate_jwt_svid_issuer_alg`); this keeps the runtime
855        // predicate consistent (defense in depth) so a bypassed load can never
856        // select a non-profile issuer.
857        && entry
858            .key_type
859            .is_some_and(KeyAlgorithm::is_spiffe_jwt_svid_profile)
860}
861
862fn requested_or_templated_spiffe_id(
863    generation: &Generation,
864    uid: u32,
865    requested: &str,
866    entry: &KeyEntry,
867) -> Result<String, Status> {
868    let trust_domain = entry
869        .labels
870        .get("trust_domain")
871        .ok_or_else(|| Status::new(Code::Internal, "JWT-SVID issuer has no trust_domain label"))?;
872    let segment = generation
873        .config()
874        .names
875        .users
876        .get(&uid)
877        .map_or_else(|| uid.to_string(), std::string::ToString::to_string);
878    let id = format!("spiffe://{trust_domain}/{segment}");
879    if !is_spiffe_id(&id) {
880        return Err(Status::new(
881            Code::Internal,
882            "templated SPIFFE ID is malformed",
883        ));
884    }
885
886    if requested.is_empty() || requested == id {
887        Ok(id)
888    } else if is_spiffe_id(requested) && spiffe_id_matches_trust_domain(requested, entry) {
889        Err(Status::new(
890            Code::PermissionDenied,
891            "requested SPIFFE ID is outside the caller identity",
892        ))
893    } else {
894        Err(invalid_argument(
895            "requested SPIFFE ID is malformed or out of trust domain",
896        ))
897    }
898}
899
900fn spiffe_id_matches_trust_domain(spiffe_id: &str, entry: &KeyEntry) -> bool {
901    entry
902        .labels
903        .get("trust_domain")
904        .is_some_and(|trust_domain| {
905            spiffe_id
906                .strip_prefix("spiffe://")
907                .and_then(|rest| rest.split_once('/'))
908                /* ubs constant time equality check is not needed here */
909                /* ubs:ignore */
910                .is_some_and(|(td, path)| td == trust_domain && !path.is_empty())
911        })
912}
913
914/// Pick the JWS `alg` for a JWT-SVID issuer from its key type.
915///
916/// A JWT-SVID issuer must use a SPIFFE JWT-SVID profile signing algorithm
917/// (`RS256` for `rsa-*`; `ES256` for P-256). The catalog loader
918/// fails closed on any non-profile `svid_kind=jwt` issuer at boot/check
919/// ([`validate_jwt_svid_issuer_alg`](crate::catalog::loader)) and the runtime
920/// predicate [`is_jwt_svid_issuer`] re-checks [`KeyAlgorithm::is_spiffe_jwt_svid_profile`],
921/// so an `EdDSA`/Ed25519 issuer can never reach this function. The non-profile
922/// arms therefore fail closed with `FailedPrecondition` (never a panic) as a
923/// defense-in-depth backstop rather than asserting unreachability.
924fn svid_alg(key_type: Option<KeyAlgorithm>) -> Result<crate::minter::SvidAlg, Status> {
925    match key_type {
926        Some(KeyAlgorithm::Rsa2048) => Ok(crate::minter::SvidAlg::Rs256),
927        Some(KeyAlgorithm::EcdsaP256) => Ok(crate::minter::SvidAlg::Es256),
928        Some(KeyAlgorithm::EcdsaP384) => Ok(crate::minter::SvidAlg::Es384),
929        // Ed25519/Ed25519Nkey (`EdDSA`) is not a SPIFFE JWT-SVID profile alg and
930        // is rejected at load by the fail-closed guardrail; this arm is the
931        // runtime backstop for a bypassed load. AEAD/KEM key types likewise
932        // cannot sign JWT-SVIDs.
933        _ => Err(Status::new(
934            Code::FailedPrecondition,
935            "JWT-SVID issuer key cannot sign JWT-SVIDs",
936        )),
937    }
938}
939
940fn is_spiffe_id(id: &str) -> bool {
941    let Some(rest) = id.strip_prefix("spiffe://") else {
942        return false;
943    };
944    let Some((trust_domain, path)) = rest.split_once('/') else {
945        return false;
946    };
947    is_valid_spiffe_part(trust_domain) && is_valid_spiffe_part(path)
948}
949
950fn is_valid_spiffe_part(part: &str) -> bool {
951    !part.is_empty() && !part.chars().any(char::is_whitespace)
952}
953
954fn invalid_argument(message: &'static str) -> Status {
955    Status::new(Code::InvalidArgument, message)
956}
957
958fn validation_failed() -> Status {
959    invalid_argument("JWT-SVID validation failed")
960}
961
962fn upstream_unavailable() -> Status {
963    Status::new(Code::Unavailable, "backend unavailable")
964}
965
966fn json_struct(value: serde_json::Value) -> prost_types::Struct {
967    let serde_json::Value::Object(fields) = value else {
968        return prost_types::Struct::default();
969    };
970    prost_types::Struct {
971        fields: fields
972            .into_iter()
973            .map(|(key, value)| (key, json_value(value)))
974            .collect(),
975    }
976}
977
978fn json_value(value: serde_json::Value) -> prost_types::Value {
979    let kind = match value {
980        serde_json::Value::Null => prost_types::value::Kind::NullValue(0),
981        serde_json::Value::Bool(value) => prost_types::value::Kind::BoolValue(value),
982        serde_json::Value::Number(value) => {
983            prost_types::value::Kind::NumberValue(value.as_f64().unwrap_or(0.0))
984        }
985        serde_json::Value::String(value) => prost_types::value::Kind::StringValue(value),
986        serde_json::Value::Array(values) => {
987            prost_types::value::Kind::ListValue(prost_types::ListValue {
988                values: values.into_iter().map(json_value).collect(),
989            })
990        }
991        serde_json::Value::Object(_) => prost_types::value::Kind::StructValue(json_struct(value)),
992    };
993    prost_types::Value { kind: Some(kind) }
994}
995
996fn mint_status(err: &crate::minter::GenericMintError) -> Status {
997    match err {
998        crate::minter::GenericMintError::Reserved(e) => {
999            Status::new(Code::InvalidArgument, e.to_string())
1000        }
1001        crate::minter::GenericMintError::Backend(_) => upstream_unavailable(),
1002    }
1003}
1004
1005fn x509_issue_status(err: &crate::manager::ManagerError) -> Status {
1006    match err {
1007        crate::manager::ManagerError::UnknownKey(_) => {
1008            Status::new(Code::PermissionDenied, "not authorized")
1009        }
1010        crate::manager::ManagerError::Unsupported(_)
1011        | crate::manager::ManagerError::OpNotValidForClass { .. }
1012        | crate::manager::ManagerError::UnsupportedKeyType { .. } => {
1013            Status::new(Code::FailedPrecondition, err.to_string())
1014        }
1015        crate::manager::ManagerError::Backend(_) => upstream_unavailable(),
1016        crate::manager::ManagerError::UnknownBackend { .. }
1017        | crate::manager::ManagerError::AlgorithmMismatch { .. }
1018        | crate::manager::ManagerError::KemAlgorithmMismatch { .. }
1019        | crate::manager::ManagerError::ValueRotateNeedsSet(_)
1020        // Neither a sealing nor a materialize-to-sign error (nor a missing
1021        // public_path, nor a provider-dispatch ML-DSA error) can arise on the
1022        // X.509 issuance path (a PKI issuer is asymmetric+pki, not sealing, kv2,
1023        // or ML-DSA software custody); treat it as an internal invariant breach.
1024        | crate::manager::ManagerError::Sealing(_)
1025        | crate::manager::ManagerError::Signing(_)
1026        | crate::manager::ManagerError::Provider(_)
1027        // A COSE unseal-context pin (basil-2rqj) applies only to the sealing
1028        // UnsealCose path, never X.509 issuance; an internal invariant breach here.
1029        | crate::manager::ManagerError::UnsealContextNotPermitted(_)
1030        | crate::manager::ManagerError::MissingPublicPath(_) => {
1031            Status::new(Code::Internal, err.to_string())
1032        }
1033    }
1034}
1035
1036#[cfg(test)]
1037mod tests {
1038    use super::*;
1039    use std::collections::BTreeMap;
1040
1041    use async_trait::async_trait;
1042    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
1043
1044    use crate::backend::{Backend, BackendError, NewKey, X509Bundle, X509Svid};
1045    use crate::catalog::loader::load;
1046    use crate::manager::BackendManager;
1047    use crate::peer::PeerInfo;
1048    use crate::state::{BrokerLimits, DEFAULT_SVID_TTL_SECS};
1049
1050    const CATALOG: &str = r#"{
1051      "schemaVersion": 1,
1052      "backends": { "bao": { "kind": "vault", "addr": "https://127.0.0.1:8200" } },
1053      "keys": {
1054        "spire.jwt": {
1055          "class": "asymmetric", "keyType": "rsa-2048", "backend": "bao",
1056          "path": "jwt-issuer", "writable": false, "missing": "error",
1057          "labels": ["svid_kind=jwt", "trust_domain=example.org", "spiffe_id=spiffe://example.org/basil"],
1058          "description": "JWT-SVID issuer"
1059        },
1060        "spire.x509": {
1061          "class": "asymmetric", "keyType": "ed25519", "backend": "bao",
1062          "engine": "pki", "path": "pki/issue/workload", "writable": false, "missing": "error",
1063          "labels": ["svid_kind=x509", "trust_domain=example.org"],
1064          "description": "X.509-SVID issuer"
1065        }
1066      }
1067    }"#;
1068
1069    const POLICY: &str = r#"{
1070      "schemaVersion": 2,
1071      "subjects": {
1072        "svc.api": { "allOf": [ { "kind": "unix", "uid": 9100 } ] }
1073      },
1074      "roles": { "minter": ["mint"], "validator": ["validate"] },
1075      "rules": [
1076        { "id": "allow-svc-jwt", "subjects": ["svc.api"], "action": ["role:minter"], "target": ["spire.jwt"] },
1077        { "id": "allow-svc-x509", "subjects": ["svc.api"], "action": ["role:minter"], "target": ["spire.x509"] },
1078        { "id": "allow-svc-validate", "subjects": ["svc.api"], "action": ["role:validator"], "target": ["spire.jwt"] }
1079      ],
1080      "config": {
1081        "names": { "users": { "9100": "svc-api" }, "groups": {} },
1082        "memberships": {}
1083      }
1084    }"#;
1085
1086    #[derive(Default)]
1087    struct JwtBackend {
1088        sign_calls: std::sync::atomic::AtomicUsize,
1089        x509_calls: std::sync::Mutex<Vec<String>>,
1090        expected_x509_ttl: std::sync::atomic::AtomicU64,
1091    }
1092
1093    #[async_trait]
1094    impl Backend for JwtBackend {
1095        fn kind(&self) -> &'static str {
1096            "jwt-test"
1097        }
1098
1099        async fn new_key(&self, key_type: basil_proto::KeyType) -> Result<NewKey, BackendError> {
1100            let _ = key_type;
1101            Err(BackendError::Unsupported("new_key"))
1102        }
1103
1104        async fn public_key(&self, key_id: &str) -> Result<Vec<u8>, BackendError> {
1105            let _ = key_id;
1106            Ok(test_public_key())
1107        }
1108
1109        async fn sign(&self, key_id: &str, message: &[u8]) -> Result<Vec<u8>, BackendError> {
1110            let _ = key_id;
1111            self.sign_calls
1112                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1113            test_rs256_sign(message)
1114        }
1115
1116        async fn sign_with_options(
1117            &self,
1118            key_id: &str,
1119            message: &[u8],
1120            options: crate::backend::SignOptions,
1121        ) -> Result<Vec<u8>, BackendError> {
1122            let _ = key_id;
1123            // The fixture issuer is rsa-2048 → the minter requests RS256.
1124            if options != crate::backend::SignOptions::Rs256Pkcs1v15Sha256 {
1125                return Err(BackendError::Unsupported("jwt-test sign options"));
1126            }
1127            self.sign_calls
1128                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1129            test_rs256_sign(message)
1130        }
1131
1132        async fn verify(
1133            &self,
1134            key_id: &str,
1135            message: &[u8],
1136            signature: &[u8],
1137        ) -> Result<bool, BackendError> {
1138            let _ = (key_id, message, signature);
1139            Err(BackendError::Unsupported("verify"))
1140        }
1141
1142        async fn issue_x509_svid(
1143            &self,
1144            key_id: &str,
1145            spiffe_id: &str,
1146            ttl_seconds: u64,
1147        ) -> Result<X509Svid, BackendError> {
1148            assert_eq!(key_id, "pki/issue/workload");
1149            assert_eq!(
1150                ttl_seconds,
1151                self.expected_x509_ttl
1152                    .load(std::sync::atomic::Ordering::SeqCst)
1153            );
1154            self.x509_calls
1155                .lock()
1156                .expect("calls lock")
1157                .push(spiffe_id.to_string());
1158            Ok(X509Svid {
1159                cert_chain_der: vec![b"leaf".to_vec(), b"issuer".to_vec()],
1160                leaf_private_key_der: zeroize::Zeroizing::new(b"private-key".to_vec()),
1161                bundle_der: vec![b"bundle".to_vec()],
1162            })
1163        }
1164
1165        async fn x509_bundle(&self, key_id: &str) -> Result<X509Bundle, BackendError> {
1166            assert_eq!(key_id, "pki/issue/workload");
1167            Ok(X509Bundle {
1168                bundle_der: vec![b"bundle".to_vec(), b"issuer".to_vec()],
1169                crl_der: b"crl".to_vec(),
1170            })
1171        }
1172    }
1173
1174    fn service() -> (SpiffeWorkloadGrpc, Arc<JwtBackend>) {
1175        service_with_limits(BrokerLimits::default())
1176    }
1177
1178    fn service_with_limits(limits: BrokerLimits) -> (SpiffeWorkloadGrpc, Arc<JwtBackend>) {
1179        let (catalog, policy, config, warnings) = load(CATALOG, POLICY).expect("fixture loads");
1180        assert!(warnings.is_empty());
1181        let backend = Arc::new(JwtBackend::default());
1182        backend.expected_x509_ttl.store(
1183            limits.svid_ttl_secs.max(1),
1184            std::sync::atomic::Ordering::SeqCst,
1185        );
1186        let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
1187        backends.insert("bao".into(), Box::new(TestBackend(backend.clone())));
1188        let manager = BackendManager::new(catalog.clone(), backends).expect("manager builds");
1189        let state = Arc::new(BrokerState::with_limits(
1190            catalog, policy, config, manager, "jwt-test", limits,
1191        ));
1192        (SpiffeWorkloadGrpc::new(state), backend)
1193    }
1194
1195    struct TestBackend(Arc<JwtBackend>);
1196
1197    #[async_trait]
1198    impl Backend for TestBackend {
1199        fn kind(&self) -> &'static str {
1200            self.0.kind()
1201        }
1202
1203        async fn new_key(&self, key_type: basil_proto::KeyType) -> Result<NewKey, BackendError> {
1204            self.0.new_key(key_type).await
1205        }
1206
1207        async fn public_key(&self, key_id: &str) -> Result<Vec<u8>, BackendError> {
1208            self.0.public_key(key_id).await
1209        }
1210
1211        async fn sign(&self, key_id: &str, message: &[u8]) -> Result<Vec<u8>, BackendError> {
1212            self.0.sign(key_id, message).await
1213        }
1214
1215        async fn sign_with_options(
1216            &self,
1217            key_id: &str,
1218            message: &[u8],
1219            options: crate::backend::SignOptions,
1220        ) -> Result<Vec<u8>, BackendError> {
1221            self.0.sign_with_options(key_id, message, options).await
1222        }
1223
1224        async fn verify(
1225            &self,
1226            key_id: &str,
1227            message: &[u8],
1228            signature: &[u8],
1229        ) -> Result<bool, BackendError> {
1230            self.0.verify(key_id, message, signature).await
1231        }
1232
1233        async fn issue_x509_svid(
1234            &self,
1235            key_id: &str,
1236            spiffe_id: &str,
1237            ttl_seconds: u64,
1238        ) -> Result<X509Svid, BackendError> {
1239            self.0.issue_x509_svid(key_id, spiffe_id, ttl_seconds).await
1240        }
1241
1242        async fn x509_bundle(&self, key_id: &str) -> Result<X509Bundle, BackendError> {
1243            self.0.x509_bundle(key_id).await
1244        }
1245    }
1246
1247    fn jwt_request(uid: u32, spiffe_id: &str, audience: Vec<&str>) -> Request<JwtsvidRequest> {
1248        let mut request = Request::new(JwtsvidRequest {
1249            audience: audience.into_iter().map(str::to_string).collect(),
1250            spiffe_id: spiffe_id.to_string(),
1251        });
1252        request
1253            .metadata_mut()
1254            .insert("workload.spiffe.io", "true".parse().expect("metadata"));
1255        request.extensions_mut().insert(PeerInfo {
1256            uid: Some(uid),
1257            ..PeerInfo::default()
1258        });
1259        request
1260    }
1261
1262    fn x509_request(uid: u32) -> Request<X509svidRequest> {
1263        let mut request = Request::new(X509svidRequest {});
1264        request
1265            .metadata_mut()
1266            .insert("workload.spiffe.io", "true".parse().expect("metadata"));
1267        request.extensions_mut().insert(PeerInfo {
1268            uid: Some(uid),
1269            ..PeerInfo::default()
1270        });
1271        request
1272    }
1273
1274    fn x509_bundles_request() -> Request<X509BundlesRequest> {
1275        let mut request = Request::new(X509BundlesRequest {});
1276        request
1277            .metadata_mut()
1278            .insert("workload.spiffe.io", "true".parse().expect("metadata"));
1279        request
1280    }
1281
1282    fn jwt_bundles_request() -> Request<JwtBundlesRequest> {
1283        let mut request = Request::new(JwtBundlesRequest {});
1284        request
1285            .metadata_mut()
1286            .insert("workload.spiffe.io", "true".parse().expect("metadata"));
1287        request
1288    }
1289
1290    fn validate_request(uid: u32, audience: &str, svid: String) -> Request<ValidateJwtsvidRequest> {
1291        let mut request = Request::new(ValidateJwtsvidRequest {
1292            audience: audience.to_string(),
1293            svid,
1294        });
1295        request
1296            .metadata_mut()
1297            .insert("workload.spiffe.io", "true".parse().expect("metadata"));
1298        request.extensions_mut().insert(PeerInfo {
1299            uid: Some(uid),
1300            ..PeerInfo::default()
1301        });
1302        request
1303    }
1304
1305    fn token_claims(token: &str) -> serde_json::Value {
1306        let mut parts = token.split('.');
1307        let _header = parts.next().expect("header");
1308        let claims = parts.next().expect("claims");
1309        let bytes = URL_SAFE_NO_PAD.decode(claims).expect("claims b64");
1310        serde_json::from_slice(&bytes).expect("claims json")
1311    }
1312
1313    /// The fixture's JWT-SVID issuer is `rsa-2048` (the SPIFFE JWT-SVID profile;
1314    /// an `ed25519` issuer is now rejected at catalog load, basil-6o4). One RSA
1315    /// keypair backs both the mock backend's published public key and the
1316    /// `valid_jwt_svid` signer so validation round-trips under `RS256`.
1317    fn test_issuer_key() -> &'static rsa::RsaPrivateKey {
1318        use std::sync::LazyLock;
1319        static KEY: LazyLock<rsa::RsaPrivateKey> = LazyLock::new(|| {
1320            let mut rng = rand::thread_rng();
1321            rsa::RsaPrivateKey::new(&mut rng, 2048).expect("test rsa keygen")
1322        });
1323        &KEY
1324    }
1325
1326    /// The issuer's public half as SPKI PEM: what the JWT-SVID JWKS builder and
1327    /// the validation `decoding_key` (RS256) consume (both accept SPKI PEM).
1328    fn test_public_key() -> Vec<u8> {
1329        use rsa::pkcs8::{EncodePublicKey, LineEnding};
1330        rsa::RsaPublicKey::from(test_issuer_key())
1331            .to_public_key_pem(LineEnding::LF)
1332            .expect("spki pem")
1333            .into_bytes()
1334    }
1335
1336    /// A real `RS256` signature over `input` with the fixture issuer key, as the
1337    /// raw signature bytes the minter re-encodes into the JWS. (`jsonwebtoken`
1338    /// returns a base64url string; decode it back to bytes.)
1339    fn test_rs256_sign(input: &[u8]) -> Result<Vec<u8>, BackendError> {
1340        use rsa::pkcs1::EncodeRsaPrivateKey;
1341        let private_pem = test_issuer_key()
1342            .to_pkcs1_pem(rsa::pkcs8::LineEnding::LF)
1343            .expect("pkcs1 pem");
1344        let encoding_key =
1345            jsonwebtoken::EncodingKey::from_rsa_pem(private_pem.as_bytes()).expect("encoding key");
1346        let b64 = jsonwebtoken::crypto::sign(input, &encoding_key, jsonwebtoken::Algorithm::RS256)
1347            .map_err(|e| BackendError::Backend(e.to_string()))?;
1348        URL_SAFE_NO_PAD
1349            .decode(b64)
1350            .map_err(|e| BackendError::Backend(e.to_string()))
1351    }
1352
1353    fn valid_jwt_svid(audience: &str, expires_at: u64) -> String {
1354        use rsa::pkcs1::EncodeRsaPrivateKey;
1355        let claims = serde_json::json!({
1356            "iss": "spiffe://example.org/basil",
1357            "sub": "spiffe://example.org/svc-api",
1358            "aud": audience,
1359            "iat": expires_at.saturating_sub(60),
1360            "exp": expires_at,
1361            "jti": "test-jti",
1362            "role": "api",
1363        });
1364        let private_pem = test_issuer_key()
1365            .to_pkcs1_pem(rsa::pkcs8::LineEnding::LF)
1366            .expect("pkcs1 pem");
1367        jsonwebtoken::encode(
1368            &jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256),
1369            &claims,
1370            &jsonwebtoken::EncodingKey::from_rsa_pem(private_pem.as_bytes()).expect("private key"),
1371        )
1372        .expect("token signs")
1373    }
1374
1375    #[test]
1376    fn workload_header_requires_true() {
1377        let request = Request::new(X509svidRequest {});
1378        let status = require_workload_header(&request).expect_err("missing header is rejected");
1379        assert_eq!(status.code(), Code::InvalidArgument);
1380
1381        let mut request = Request::new(X509svidRequest {});
1382        request.metadata_mut().insert(
1383            "workload.spiffe.io",
1384            "false".parse().expect("valid metadata"),
1385        );
1386        let status = require_workload_header(&request).expect_err("false header is rejected");
1387        assert_eq!(status.code(), Code::InvalidArgument);
1388
1389        request.metadata_mut().insert(
1390            "workload.spiffe.io",
1391            "true".parse().expect("valid metadata"),
1392        );
1393        require_workload_header(&request).expect("true header is accepted");
1394    }
1395
1396    #[test]
1397    fn workload_header_rejects_duplicates_binary_and_malformed_values() {
1398        fn request_with_values(values: &[&str]) -> Request<X509svidRequest> {
1399            let mut request = Request::new(X509svidRequest {});
1400            for value in values {
1401                request.metadata_mut().append(
1402                    "workload.spiffe.io",
1403                    value.parse().expect("valid metadata value"),
1404                );
1405            }
1406            request
1407        }
1408
1409        const SECRET_METADATA: &str = "Authorization: Bearer vault-token-s.123";
1410        for values in [
1411            &["true", "true"][..],
1412            &["false", "true"],
1413            &["true", "false"],
1414            &["TRUE"],
1415            &["True"],
1416            &[" true"],
1417            &["true "],
1418            &["\ttrue"],
1419            &[SECRET_METADATA],
1420            &[&"a".repeat(16 * 1024)],
1421        ] {
1422            let status = require_workload_header(&request_with_values(values))
1423                .expect_err("malformed workload header is rejected");
1424            assert_eq!(status.code(), Code::InvalidArgument);
1425            assert_eq!(
1426                status.message(),
1427                "SPIFFE Workload API requests require workload.spiffe.io=true"
1428            );
1429            assert!(!status.message().contains("vault-token-s.123"));
1430        }
1431
1432        let mut binary_only = Request::new(X509svidRequest {});
1433        binary_only.metadata_mut().insert_bin(
1434            "workload.spiffe.io-bin",
1435            tonic::metadata::MetadataValue::from_bytes(b"true"),
1436        );
1437        let status = require_workload_header(&binary_only)
1438            .expect_err("binary metadata key does not satisfy string gate");
1439        assert_eq!(status.code(), Code::InvalidArgument);
1440    }
1441
1442    #[tokio::test]
1443    async fn workload_api_methods_reject_missing_header_consistently() {
1444        let (service, _backend) = service();
1445
1446        let status = service
1447            .fetch_x509svid(Request::new(X509svidRequest {}))
1448            .await
1449            .err()
1450            .expect("missing x509 svid header rejected");
1451        assert_eq!(status.code(), Code::InvalidArgument);
1452
1453        let status = service
1454            .fetch_x509_bundles(Request::new(X509BundlesRequest {}))
1455            .await
1456            .err()
1457            .expect("missing x509 bundles header rejected");
1458        assert_eq!(status.code(), Code::InvalidArgument);
1459
1460        let status = service
1461            .fetch_jwtsvid(Request::new(JwtsvidRequest {
1462                audience: vec!["vault".to_string()],
1463                spiffe_id: String::new(),
1464            }))
1465            .await
1466            .expect_err("missing jwt svid header rejected");
1467        assert_eq!(status.code(), Code::InvalidArgument);
1468
1469        let status = service
1470            .fetch_jwt_bundles(Request::new(JwtBundlesRequest {}))
1471            .await
1472            .err()
1473            .expect("missing jwt bundles header rejected");
1474        assert_eq!(status.code(), Code::InvalidArgument);
1475
1476        let status = service
1477            .validate_jwtsvid(Request::new(ValidateJwtsvidRequest {
1478                audience: "vault".to_string(),
1479                svid: valid_jwt_svid(
1480                    "vault",
1481                    jsonwebtoken::get_current_timestamp().saturating_add(300),
1482                ),
1483            }))
1484            .await
1485            .expect_err("missing validate header rejected");
1486        assert_eq!(status.code(), Code::InvalidArgument);
1487    }
1488
1489    #[tokio::test]
1490    async fn fetch_jwtsvid_templates_default_spiffe_id() {
1491        let (service, backend) = service();
1492        let response = service
1493            .fetch_jwtsvid(jwt_request(9100, "", vec!["vault"]))
1494            .await
1495            .expect("fetch jwt-svid")
1496            .into_inner();
1497        assert_eq!(response.svids.len(), 1);
1498        let svid = response.svids.first().expect("one svid");
1499        assert_eq!(svid.spiffe_id, "spiffe://example.org/svc-api");
1500        let claims = token_claims(&svid.svid);
1501        assert_eq!(claims["iss"], "spiffe://example.org/basil");
1502        assert_eq!(claims["sub"], "spiffe://example.org/svc-api");
1503        assert_eq!(claims["aud"], "vault");
1504        assert_eq!(
1505            backend.sign_calls.load(std::sync::atomic::Ordering::SeqCst),
1506            1
1507        );
1508    }
1509
1510    /// basil-gymz coherence: `fetch_jwtsvid` pins ONE generation for the whole
1511    /// RPC: the issuer/mint authorization AND the templated SPIFFE id render
1512    /// against the same snapshot. After a reload swaps the generation (here, the
1513    /// config renames uid 9100), a later RPC templates the id against the NEW
1514    /// generation; the swap is observed via the single pin, and the issuer choice
1515    /// and templated id stay coherent (one snapshot, never a mix). Before the fix
1516    /// `requested_or_templated_spiffe_id` loaded a SECOND fresh snapshot, so a
1517    /// reload between the two loads could template against a different generation.
1518    #[tokio::test]
1519    async fn fetch_jwtsvid_templates_against_reloaded_generation_coherently() {
1520        // Reload policy: same catalog (routing shape unchanged), config renames
1521        // uid 9100 → "renamed".
1522        const RENAMED_POLICY: &str = r#"{
1523          "schemaVersion": 2,
1524          "subjects": {
1525            "svc.api": { "allOf": [ { "kind": "unix", "uid": 9100 } ] }
1526          },
1527          "roles": { "minter": ["mint"], "validator": ["validate"] },
1528          "rules": [
1529            { "id": "allow-svc-jwt", "subjects": ["svc.api"], "action": ["role:minter"], "target": ["spire.jwt"] },
1530            { "id": "allow-svc-x509", "subjects": ["svc.api"], "action": ["role:minter"], "target": ["spire.x509"] },
1531            { "id": "allow-svc-validate", "subjects": ["svc.api"], "action": ["role:validator"], "target": ["spire.jwt"] }
1532          ],
1533          "config": { "names": { "users": { "9100": "renamed" }, "groups": {} }, "memberships": {} }
1534        }"#;
1535
1536        let (service, _backend) = service();
1537
1538        // Generation 1: uid 9100 → "svc-api".
1539        let before = service
1540            .fetch_jwtsvid(jwt_request(9100, "", vec!["vault"]))
1541            .await
1542            .expect("gen 1 fetch jwt-svid")
1543            .into_inner();
1544        assert_eq!(
1545            before.svids.first().expect("svid").spiffe_id,
1546            "spiffe://example.org/svc-api"
1547        );
1548
1549        // Reload: build the new generation from the renamed-config policy and
1550        // swap it in (the routing shape is unchanged, so this is reloadable).
1551        let (cat, pol, cfg, _) = load(CATALOG, RENAMED_POLICY).expect("reload fixture loads");
1552        let next = crate::state::Generation::new(2, std::sync::Arc::new(cat), pol, cfg);
1553        service.state.swap_generation(std::sync::Arc::new(next));
1554        assert_eq!(service.state.active_generation_id(), 2);
1555
1556        // Generation 2: the same RPC now templates against the reloaded config.
1557        let after = service
1558            .fetch_jwtsvid(jwt_request(9100, "", vec!["vault"]))
1559            .await
1560            .expect("gen 2 fetch jwt-svid")
1561            .into_inner();
1562        let svid = after.svids.first().expect("svid");
1563        assert_eq!(svid.spiffe_id, "spiffe://example.org/renamed");
1564        // The minted token's subject matches the templated id, so issuer + id are
1565        // coherent within the one pinned generation.
1566        let claims = token_claims(&svid.svid);
1567        assert_eq!(claims["sub"], "spiffe://example.org/renamed");
1568        assert_eq!(claims["iss"], "spiffe://example.org/basil");
1569    }
1570
1571    /// basil-zq6w: after a reload, `BackendManager` still owns the startup
1572    /// catalog, but SPIFFE issuer discovery must follow the serving generation's
1573    /// labels. Routing through `manager.resolve()` stays correct because the
1574    /// reload guard keeps backend/path/engine/keyType stable; candidate discovery
1575    /// is the label-sensitive part.
1576    #[tokio::test]
1577    async fn fetch_jwtsvid_discovers_issuer_from_reloaded_generation_catalog() {
1578        let (service, _backend) = service();
1579        assert_eq!(service.state.active_generation_id(), 1);
1580
1581        let reloaded_catalog = CATALOG
1582            .replace("trust_domain=example.org", "trust_domain=other.org")
1583            .replace(
1584                "spiffe_id=spiffe://example.org/basil",
1585                "spiffe_id=spiffe://other.org/basil",
1586            );
1587        let (cat, pol, cfg, warnings) =
1588            load(&reloaded_catalog, POLICY).expect("label-only reload fixture loads");
1589        assert!(warnings.is_empty());
1590        let next = crate::state::Generation::new(2, std::sync::Arc::new(cat), pol, cfg);
1591        service.state.swap_generation(std::sync::Arc::new(next));
1592
1593        let response = service
1594            .fetch_jwtsvid(jwt_request(
1595                9100,
1596                "spiffe://other.org/svc-api",
1597                vec!["vault"],
1598            ))
1599            .await
1600            .expect("reloaded trust-domain issuer is discoverable")
1601            .into_inner();
1602        let svid = response.svids.first().expect("svid");
1603        assert_eq!(svid.spiffe_id, "spiffe://other.org/svc-api");
1604        let claims = token_claims(&svid.svid);
1605        assert_eq!(claims["iss"], "spiffe://other.org/basil");
1606        assert_eq!(claims["sub"], "spiffe://other.org/svc-api");
1607    }
1608
1609    #[tokio::test]
1610    async fn fetch_x509svid_streams_initial_svid_set() {
1611        use futures::StreamExt as _;
1612
1613        let (service, backend) = service();
1614        let mut stream = service
1615            .fetch_x509svid(x509_request(9100))
1616            .await
1617            .expect("fetch x509-svid")
1618            .into_inner();
1619        let response = stream
1620            .next()
1621            .await
1622            .expect("initial response")
1623            .expect("initial response ok");
1624        assert_eq!(response.svids.len(), 1);
1625        let svid = response.svids.first().expect("one svid");
1626        assert_eq!(svid.spiffe_id, "spiffe://example.org/svc-api");
1627        assert_eq!(svid.x509_svid, b"leafissuer");
1628        assert_eq!(svid.x509_svid_key, b"private-key");
1629        assert_eq!(svid.bundle, b"bundle");
1630        assert_eq!(
1631            backend.x509_calls.lock().expect("calls lock").as_slice(),
1632            ["spiffe://example.org/svc-api"]
1633        );
1634    }
1635
1636    #[tokio::test]
1637    async fn fetch_x509svid_honors_configured_ttl() {
1638        use futures::StreamExt as _;
1639
1640        let (service, backend) = service_with_limits(BrokerLimits {
1641            svid_ttl_secs: 4,
1642            ..BrokerLimits::default()
1643        });
1644        let mut stream = service
1645            .fetch_x509svid(x509_request(9100))
1646            .await
1647            .expect("fetch x509-svid")
1648            .into_inner();
1649        let response = stream
1650            .next()
1651            .await
1652            .expect("initial response")
1653            .expect("initial response ok");
1654        assert_eq!(response.svids.len(), 1);
1655        assert_eq!(
1656            backend
1657                .expected_x509_ttl
1658                .load(std::sync::atomic::Ordering::SeqCst),
1659            4
1660        );
1661    }
1662
1663    #[test]
1664    fn x509_refresh_interval_is_half_ttl_with_floor() {
1665        let plan = X509IssuePlan {
1666            key_name: "pki/issue/workload".to_string(),
1667            spiffe_id: "spiffe://example.org/svc-api".to_string(),
1668            trust_domain: "example.org".to_string(),
1669            ttl_seconds: DEFAULT_SVID_TTL_SECS,
1670        };
1671        assert_eq!(x509_refresh_after_secs(&plan), DEFAULT_SVID_TTL_SECS / 2);
1672
1673        let short_plan = X509IssuePlan {
1674            ttl_seconds: 1,
1675            ..plan
1676        };
1677        assert_eq!(x509_refresh_after_secs(&short_plan), 1);
1678    }
1679
1680    #[tokio::test]
1681    async fn fetch_x509svid_denies_unauthorized_uid_before_issuing() {
1682        let (service, backend) = service();
1683        let Err(status) = service.fetch_x509svid(x509_request(7777)).await else {
1684            panic!("unauthorized uid accepted");
1685        };
1686        assert_eq!(status.code(), Code::PermissionDenied);
1687        assert!(backend.x509_calls.lock().expect("calls lock").is_empty());
1688    }
1689
1690    #[tokio::test]
1691    async fn fetch_x509_bundles_streams_initial_bundle_map() {
1692        use futures::StreamExt as _;
1693
1694        let (service, _backend) = service();
1695        let mut stream = service
1696            .fetch_x509_bundles(x509_bundles_request())
1697            .await
1698            .expect("fetch x509 bundles")
1699            .into_inner();
1700        let response = stream
1701            .next()
1702            .await
1703            .expect("initial response")
1704            .expect("initial response ok");
1705        assert_eq!(
1706            response.bundles.get("spiffe://example.org"),
1707            Some(&b"bundleissuer".to_vec())
1708        );
1709        assert_eq!(response.crl, vec![b"crl".to_vec()]);
1710    }
1711
1712    #[tokio::test]
1713    async fn fetch_x509_bundles_pushes_on_bundle_change() {
1714        use futures::StreamExt as _;
1715
1716        let (service, _backend) = service();
1717        let events = service.state.events().clone();
1718        let mut stream = service
1719            .fetch_x509_bundles(x509_bundles_request())
1720            .await
1721            .expect("fetch x509 bundles")
1722            .into_inner();
1723        let _initial = stream.next().await.expect("initial response");
1724        events.bundle_changed("example.org");
1725        let response = tokio::time::timeout(Duration::from_secs(1), stream.next())
1726            .await
1727            .expect("refresh response")
1728            .expect("stream item")
1729            .expect("refresh response ok");
1730        assert!(response.bundles.contains_key("spiffe://example.org"));
1731    }
1732
1733    #[tokio::test]
1734    async fn fetch_jwt_bundles_streams_initial_jwks_map() {
1735        use futures::StreamExt as _;
1736
1737        let (service, _backend) = service();
1738        let mut stream = service
1739            .fetch_jwt_bundles(jwt_bundles_request())
1740            .await
1741            .expect("fetch jwt bundles")
1742            .into_inner();
1743        let response = stream
1744            .next()
1745            .await
1746            .expect("initial response")
1747            .expect("initial response ok");
1748        let jwks = response
1749            .bundles
1750            .get("spiffe://example.org")
1751            .expect("jwt bundle");
1752        let jwks: serde_json::Value = serde_json::from_slice(jwks).expect("jwks json");
1753        // SPIFFE JWT-SVID profile issuer: an RSA key published as RS256 (the
1754        // catalog rejects an EdDSA/ed25519 JWT-SVID issuer at load, basil-6o4).
1755        assert_eq!(jwks["keys"][0]["kty"], "RSA");
1756        assert_eq!(jwks["keys"][0]["alg"], "RS256");
1757        assert!(jwks["keys"][0]["n"].is_string());
1758        assert!(jwks["keys"][0]["e"].is_string());
1759    }
1760
1761    #[tokio::test]
1762    async fn fetch_jwt_bundles_pushes_on_issuer_rotation() {
1763        use futures::StreamExt as _;
1764
1765        let (service, _backend) = service();
1766        let events = service.state.events().clone();
1767        let mut stream = service
1768            .fetch_jwt_bundles(jwt_bundles_request())
1769            .await
1770            .expect("fetch jwt bundles")
1771            .into_inner();
1772        let _initial = stream.next().await.expect("initial response");
1773        events.key_rotated("spire.jwt", 2);
1774        let response = tokio::time::timeout(Duration::from_secs(1), stream.next())
1775            .await
1776            .expect("refresh response")
1777            .expect("stream item")
1778            .expect("refresh response ok");
1779        assert!(response.bundles.contains_key("spiffe://example.org"));
1780    }
1781
1782    #[tokio::test]
1783    async fn validate_jwtsvid_returns_spiffe_id_and_claims() {
1784        let (service, _backend) = service();
1785        let token = valid_jwt_svid(
1786            "vault",
1787            jsonwebtoken::get_current_timestamp().saturating_add(300),
1788        );
1789        let response = service
1790            .validate_jwtsvid(validate_request(9100, "vault", token))
1791            .await
1792            .expect("valid token")
1793            .into_inner();
1794        assert_eq!(response.spiffe_id, "spiffe://example.org/svc-api");
1795        let claims = response.claims.expect("claims");
1796        assert_eq!(
1797            claims
1798                .fields
1799                .get("role")
1800                .and_then(|value| value.kind.as_ref()),
1801            Some(&prost_types::value::Kind::StringValue("api".to_string()))
1802        );
1803    }
1804
1805    #[tokio::test]
1806    async fn validate_jwtsvid_rejects_active_revoked_jti() {
1807        let (service, _backend) = service();
1808        service
1809            .state
1810            .revoke_jwt_svid(
1811                "example.org",
1812                "test-jti",
1813                jsonwebtoken::get_current_timestamp().saturating_add(300),
1814            )
1815            .await
1816            .expect("revoked jti stored");
1817        let token = valid_jwt_svid(
1818            "vault",
1819            jsonwebtoken::get_current_timestamp().saturating_add(300),
1820        );
1821        let status = service
1822            .validate_jwtsvid(validate_request(9100, "vault", token))
1823            .await
1824            .expect_err("revoked jti rejected");
1825        assert_eq!(status.code(), Code::InvalidArgument);
1826        assert_eq!(status.message(), "JWT-SVID validation failed");
1827    }
1828
1829    #[tokio::test]
1830    async fn validate_jwtsvid_allows_expired_revoked_jti_entry() {
1831        let (service, _backend) = service();
1832        service
1833            .state
1834            .revoke_jwt_svid(
1835                "example.org",
1836                "test-jti",
1837                jsonwebtoken::get_current_timestamp().saturating_sub(1),
1838            )
1839            .await
1840            .expect("expired jti ignored");
1841        let token = valid_jwt_svid(
1842            "vault",
1843            jsonwebtoken::get_current_timestamp().saturating_add(300),
1844        );
1845        let response = service
1846            .validate_jwtsvid(validate_request(9100, "vault", token))
1847            .await
1848            .expect("expired deny-list entry does not reject")
1849            .into_inner();
1850        assert_eq!(response.spiffe_id, "spiffe://example.org/svc-api");
1851    }
1852
1853    #[tokio::test]
1854    async fn validate_jwtsvid_rejects_token_without_jti() {
1855        use rsa::pkcs1::EncodeRsaPrivateKey;
1856        let (service, _backend) = service();
1857        // A well-signed, in-audience token minus `jti`: revocation is
1858        // jti-scoped, so a jti-less token could never be revoked and must be
1859        // rejected outright (finding 20).
1860        let expires_at = jsonwebtoken::get_current_timestamp().saturating_add(300);
1861        let claims = serde_json::json!({
1862            "iss": "spiffe://example.org/basil",
1863            "sub": "spiffe://example.org/svc-api",
1864            "aud": "vault",
1865            "iat": expires_at.saturating_sub(60),
1866            "exp": expires_at,
1867        });
1868        let private_pem = test_issuer_key()
1869            .to_pkcs1_pem(rsa::pkcs8::LineEnding::LF)
1870            .expect("pkcs1 pem");
1871        let token = jsonwebtoken::encode(
1872            &jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256),
1873            &claims,
1874            &jsonwebtoken::EncodingKey::from_rsa_pem(private_pem.as_bytes()).expect("private key"),
1875        )
1876        .expect("token signs");
1877        let status = service
1878            .validate_jwtsvid(validate_request(9100, "vault", token))
1879            .await
1880            .expect_err("jti-less token rejected");
1881        assert_eq!(status.code(), Code::InvalidArgument);
1882        assert_eq!(status.message(), "JWT-SVID validation failed");
1883    }
1884
1885    #[tokio::test]
1886    async fn validate_jwtsvid_is_policy_gated() {
1887        let (service, _backend) = service();
1888        let token = valid_jwt_svid(
1889            "vault",
1890            jsonwebtoken::get_current_timestamp().saturating_add(300),
1891        );
1892        let status = service
1893            .validate_jwtsvid(validate_request(7777, "vault", token))
1894            .await
1895            .expect_err("unauthorized validator rejected");
1896        assert_eq!(status.code(), Code::PermissionDenied);
1897    }
1898
1899    #[tokio::test]
1900    async fn validate_jwtsvid_rejects_wrong_audience() {
1901        let (service, _backend) = service();
1902        let token = valid_jwt_svid(
1903            "vault",
1904            jsonwebtoken::get_current_timestamp().saturating_add(300),
1905        );
1906        let status = service
1907            .validate_jwtsvid(validate_request(9100, "other", token))
1908            .await
1909            .expect_err("wrong audience rejected");
1910        assert_eq!(status.code(), Code::InvalidArgument);
1911        assert_eq!(status.message(), "JWT-SVID validation failed");
1912    }
1913
1914    #[tokio::test]
1915    async fn validate_jwtsvid_rejects_expired_token() {
1916        let (service, _backend) = service();
1917        let token = valid_jwt_svid(
1918            "vault",
1919            jsonwebtoken::get_current_timestamp().saturating_sub(120),
1920        );
1921        let status = service
1922            .validate_jwtsvid(validate_request(9100, "vault", token))
1923            .await
1924            .expect_err("expired token rejected");
1925        assert_eq!(status.code(), Code::InvalidArgument);
1926        assert_eq!(status.message(), "JWT-SVID validation failed");
1927    }
1928
1929    #[tokio::test]
1930    async fn validate_jwtsvid_rejects_bad_signature() {
1931        let (service, _backend) = service();
1932        let mut token = valid_jwt_svid(
1933            "vault",
1934            jsonwebtoken::get_current_timestamp().saturating_add(300),
1935        );
1936        token.push('x');
1937        let status = service
1938            .validate_jwtsvid(validate_request(9100, "vault", token))
1939            .await
1940            .expect_err("bad signature rejected");
1941        assert_eq!(status.code(), Code::InvalidArgument);
1942        assert_eq!(status.message(), "JWT-SVID validation failed");
1943    }
1944
1945    #[tokio::test]
1946    async fn fetch_jwtsvid_rejects_malformed_spiffe_ids_and_audiences() {
1947        let (service, _backend) = service();
1948        for requested in [
1949            "example.org/no-scheme",
1950            "spiffe://example.org",
1951            "spiffe://example.org/",
1952            "spiffe://example.org/svc api",
1953        ] {
1954            let status = service
1955                .fetch_jwtsvid(jwt_request(9100, requested, vec!["vault"]))
1956                .await
1957                .expect_err("malformed requested SPIFFE ID rejected");
1958            assert_eq!(status.code(), Code::InvalidArgument);
1959        }
1960
1961        let status = service
1962            .fetch_jwtsvid(jwt_request(
1963                9100,
1964                "spiffe://other.org/svc-api",
1965                vec!["vault"],
1966            ))
1967            .await
1968            .expect_err("out-of-domain requested SPIFFE ID rejected");
1969        assert_eq!(status.code(), Code::PermissionDenied);
1970
1971        for audience in ["", " ", "\t", "\n"] {
1972            let status = service
1973                .fetch_jwtsvid(jwt_request(9100, "", vec![audience]))
1974                .await
1975                .expect_err("blank audience rejected");
1976            assert_eq!(status.code(), Code::InvalidArgument);
1977            assert_eq!(
1978                status.message(),
1979                "FetchJWTSVID requires a non-empty audience"
1980            );
1981        }
1982    }
1983
1984    #[tokio::test]
1985    async fn validate_jwtsvid_rejects_malformed_inputs_consistently() {
1986        let (service, _backend) = service();
1987        for (audience, token) in [
1988            ("", valid_jwt_svid("vault", 300)),
1989            (" ", valid_jwt_svid("vault", 300)),
1990            ("vault", String::new()),
1991            ("vault", "not.a.jwt".to_string()),
1992        ] {
1993            let status = service
1994                .validate_jwtsvid(validate_request(9100, audience, token))
1995                .await
1996                .expect_err("malformed validation input rejected");
1997            assert_eq!(status.code(), Code::InvalidArgument);
1998        }
1999    }
2000
2001    #[test]
2002    fn workload_api_upstream_errors_omit_secret_bearing_details() {
2003        let canaries = [
2004            "vault-token-s.123",
2005            "Authorization: Bearer secret",
2006            "/run/credentials/basil/passphrase",
2007            "-----BEGIN PRIVATE KEY-----",
2008            "upstream-response-body-with-credential",
2009        ];
2010        let statuses = [
2011            mint_status(&crate::minter::GenericMintError::Backend(
2012                crate::backend::BackendError::Backend(canaries[4].to_string()),
2013            )),
2014            x509_issue_status(&crate::manager::ManagerError::Backend(
2015                crate::backend::BackendError::Transport(canaries[1].to_string()),
2016            )),
2017            upstream_unavailable(),
2018        ];
2019        for status in statuses {
2020            assert_eq!(status.code(), Code::Unavailable);
2021            for canary in canaries {
2022                assert!(
2023                    !status.message().contains(canary),
2024                    "Workload API status leaked secret canary `{canary}`"
2025                );
2026            }
2027        }
2028    }
2029
2030    #[tokio::test]
2031    async fn fetch_jwtsvid_accepts_explicit_caller_identity() {
2032        let (service, _backend) = service();
2033        let response = service
2034            .fetch_jwtsvid(jwt_request(
2035                9100,
2036                "spiffe://example.org/svc-api",
2037                vec!["vault", "nats"],
2038            ))
2039            .await
2040            .expect("fetch jwt-svid")
2041            .into_inner();
2042        assert_eq!(response.svids.len(), 2);
2043        assert!(
2044            response
2045                .svids
2046                .iter()
2047                /* ubs constant time equality check is not needed here */
2048                /* ubs:ignore */
2049                .all(|svid| svid.spiffe_id == "spiffe://example.org/svc-api")
2050        );
2051    }
2052
2053    #[tokio::test]
2054    async fn fetch_jwtsvid_rejects_explicit_same_domain_impersonation() {
2055        let (service, _backend) = service();
2056        let status = service
2057            .fetch_jwtsvid(jwt_request(
2058                9100,
2059                "spiffe://example.org/custom",
2060                vec!["vault", "nats"],
2061            ))
2062            .await
2063            .expect_err("impersonation denied");
2064        assert_eq!(status.code(), Code::PermissionDenied);
2065    }
2066
2067    #[tokio::test]
2068    async fn fetch_jwtsvid_requires_audience() {
2069        let (service, _backend) = service();
2070        let status = service
2071            .fetch_jwtsvid(jwt_request(9100, "", vec![]))
2072            .await
2073            .expect_err("missing audience rejected");
2074        assert_eq!(status.code(), Code::InvalidArgument);
2075    }
2076
2077    #[tokio::test]
2078    async fn fetch_jwtsvid_denies_unauthorized_uid_before_signing() {
2079        let (service, backend) = service();
2080        let status = service
2081            .fetch_jwtsvid(jwt_request(7777, "", vec!["vault"]))
2082            .await
2083            .expect_err("unauthorized uid rejected");
2084        assert_eq!(status.code(), Code::PermissionDenied);
2085        assert_eq!(
2086            backend.sign_calls.load(std::sync::atomic::Ordering::SeqCst),
2087            0
2088        );
2089    }
2090
2091    #[test]
2092    fn svid_alg_selects_profile_algs_and_fails_closed_otherwise() {
2093        assert_eq!(
2094            svid_alg(Some(KeyAlgorithm::Rsa2048)).expect("rsa is a JWT-SVID alg"),
2095            crate::minter::SvidAlg::Rs256
2096        );
2097        assert_eq!(
2098            svid_alg(Some(KeyAlgorithm::EcdsaP256)).expect("p256 is a JWT-SVID alg"),
2099            crate::minter::SvidAlg::Es256
2100        );
2101        assert_eq!(
2102            svid_alg(Some(KeyAlgorithm::EcdsaP384)).expect("p384 is a JWT-SVID alg"),
2103            crate::minter::SvidAlg::Es384
2104        );
2105        // EdDSA/Ed25519 is rejected at catalog load, so this is the runtime
2106        // backstop: it must fail closed with FailedPrecondition (never panic).
2107        for key_type in [
2108            None,
2109            Some(KeyAlgorithm::Ed25519),
2110            Some(KeyAlgorithm::Ed25519Nkey),
2111            Some(KeyAlgorithm::EcdsaP521),
2112            Some(KeyAlgorithm::Aes256Gcm),
2113            Some(KeyAlgorithm::X25519),
2114        ] {
2115            let status = svid_alg(key_type).expect_err("non-profile alg is rejected");
2116            assert_eq!(status.code(), Code::FailedPrecondition);
2117        }
2118    }
2119}