Skip to main content

basil_core/service/
spiffe.rs

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