Skip to main content

basil_core/service/
spiffe.rs

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