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