Skip to main content

basil_core/core/backend/
mod.rs

1//! Pluggable signing / key-store backends.
2//!
3//! The agent is a *proxy*: incoming `NEW_KEY` / `SIGN` / `VERIFY` messages are
4//! dispatched to a [`Backend`] trait object. v1 ships a single implementation,
5//! [`vault::VaultBackend`] (a Vault-compatible transit engine: `HashiCorp` Vault or `OpenBao`),
6//! but the trait is deliberately backend-agnostic so additional stores
7//! (db-keystore, 1Password, cloud KMS, an internal TPM-backed signer, …) can be
8//! added later without touching the protocol or the connection handler.
9
10use async_trait::async_trait;
11use basil_proto::{AeadAlgorithm, CiphertextEnvelope, KeyMaterial, KeyType};
12use zeroize::Zeroizing;
13
14#[cfg(feature = "aws-kms")]
15pub mod aws_kms;
16#[cfg(feature = "gcp-kms")]
17pub mod gcp_kms;
18#[cfg(feature = "keystore-backend")]
19pub mod keystore;
20pub mod spiffe;
21pub mod vault;
22
23mod kms_common;
24mod pki;
25mod svid;
26mod transit;
27
28/// A newly created (or imported) key.
29#[derive(Debug, Clone)]
30pub struct NewKey {
31    /// Backend-assigned identifier for the key.
32    pub key_id: String,
33    /// Raw public key bytes (for Ed25519, the 32-byte public key).
34    pub public_key: Vec<u8>,
35}
36
37/// A KV-v2 value read: the stored bytes plus the version they came from.
38///
39/// Returned by [`Backend::kv_get`] for a `value`/`public`-class key. The `value`
40/// is the opaque byte string the broker stored under the `value` field (the
41/// lossless round-trip half of [`Backend::kv_put`]); `version` is the KV-v2
42/// version actually read (the requested one, or the latest when none was asked).
43#[derive(Debug, Clone)]
44pub struct KvValue {
45    /// The raw stored value bytes (never key-crypto material).
46    pub value: Vec<u8>,
47    /// The KV-v2 version these bytes were read from.
48    pub version: u32,
49}
50
51/// A post-quantum algorithm a [`Backend`] may natively custody and operate on
52/// **in place**: the private seed never leaves the backend.
53///
54/// This is the type the capability probe ([`Backend::supports_native_algorithm`])
55/// and the native PQC operation methods speak. It lives in the backend layer (not
56/// the provider layer) so the [`Backend`] trait carries no dependency on
57/// [`crypto_provider`](crate::core::crypto_provider): the provider layer maps its
58/// `SignatureAlgorithm` onto this enum, never the reverse. Only the ML-DSA family
59/// is modelled today: no shipping backend exposes native ML-KEM transit, so
60/// ML-KEM keys always remain software-custodied.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum NativeAlgorithm {
63    /// ML-DSA (FIPS 204) signature parameter level 44.
64    MlDsa44,
65    /// ML-DSA (FIPS 204) signature parameter level 65.
66    MlDsa65,
67    /// ML-DSA (FIPS 204) signature parameter level 87.
68    MlDsa87,
69}
70
71/// A key's public material plus the metadata `get_public_key` echoes back.
72///
73/// This is what a `get_public_key` reply needs beyond the raw bytes: the real
74/// algorithm and the current key version (so the handler no longer hardcodes
75/// `ed25519` / `version 1`, the `vault-k3w` OFI).
76#[derive(Debug, Clone)]
77pub struct PublicKey {
78    /// Raw public key bytes (for Ed25519, the 32-byte public key).
79    pub public_key: Vec<u8>,
80    /// The key's algorithm, as the wire reports it.
81    pub key_type: KeyType,
82    /// The latest key version (transit version count).
83    pub version: u32,
84}
85
86/// Value-free metadata for one key, returned by `list`.
87///
88/// Leak-proof: the algorithm and a version *count* only, never the public or
89/// private key bytes (use `get_public_key` for the public half explicitly).
90#[derive(Debug, Clone)]
91pub struct KeyMetadata {
92    /// The key's algorithm, if the backend reports one.
93    pub key_type: Option<KeyType>,
94    /// The latest key version (transit version count).
95    pub latest_version: u32,
96}
97
98/// Backend signing mode for operations whose wire format fixes an algorithm.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
100pub enum SignOptions {
101    /// Backend default signing behavior.
102    #[default]
103    Default,
104    /// JWS `RS256`: RSASSA-PKCS1-v1_5 over SHA-256.
105    Rs256Pkcs1v15Sha256,
106    /// JWS `ES256`: ECDSA P-256 over SHA-256.
107    Es256,
108    /// JWS `ES384`: ECDSA P-384 over SHA-384.
109    Es384,
110    /// JWS `ES512`: ECDSA P-521 over SHA-512.
111    Es512,
112}
113
114/// An issued X.509-SVID leaf set from a PKI backend.
115#[derive(Debug, Clone)]
116pub struct X509Svid {
117    /// DER-encoded leaf certificate followed by any issuer certificates.
118    pub cert_chain_der: Vec<Vec<u8>>,
119    /// DER-encoded unencrypted PKCS#8 leaf private key.
120    pub leaf_private_key_der: Zeroizing<Vec<u8>>,
121    /// DER-encoded trust-domain bundle certificates.
122    pub bundle_der: Vec<Vec<u8>>,
123}
124
125/// Parameters for a DNS/IP-SAN X.509 leaf issuance (a TLS cert, not a SPIFFE
126/// SVID). Mirrors [`Backend::issue_x509_svid`] but binds DNS/IP SANs and a common
127/// name instead of a SPIFFE URI SAN.
128#[derive(Debug, Clone, Default)]
129pub struct X509CertRequest {
130    /// Certificate common name (and implicit first DNS SAN, per the PKI role).
131    pub common_name: String,
132    /// Additional DNS subject alternative names.
133    pub dns_sans: Vec<String>,
134    /// IP subject alternative names.
135    pub ip_sans: Vec<String>,
136    /// Requested validity in seconds.
137    pub ttl_seconds: u64,
138}
139
140/// Trust-domain X.509 bundle material, ready for Workload API response assembly.
141#[derive(Debug, Clone, Default, PartialEq, Eq)]
142pub struct X509Bundle {
143    /// DER-encoded trust-domain CA certificates.
144    pub bundle_der: Vec<Vec<u8>>,
145    /// DER-encoded CRL bytes, empty when the backend has no CRL to publish.
146    pub crl_der: Vec<u8>,
147}
148
149/// Errors a backend may return. Service adapters map these to canonical gRPC
150/// statuses.
151#[derive(Debug, thiserror::Error)]
152pub enum BackendError {
153    #[error("unsupported key type: {0}")]
154    UnsupportedKeyType(KeyType),
155
156    #[error("key not found: {0}")]
157    KeyNotFound(String),
158
159    /// The op (e.g. `import`/`list`/`key_metadata`) is not supported by this
160    /// backend kind. Distinct from [`BackendError::UnsupportedKeyType`]: the
161    /// *operation* itself has no implementation here.
162    #[error("operation not supported by this backend: {0}")]
163    Unsupported(&'static str),
164
165    /// AEAD authentication failed on `decrypt`: a wrong tag, a wrong key
166    /// version, or mismatched AAD. Deliberately **opaque**: it carries no detail
167    /// distinguishing the cause (no padding/AAD oracle), and the handler maps it
168    /// to the single `decrypt_failed` wire code.
169    #[error("decrypt failed")]
170    DecryptFailed,
171
172    /// The request's algorithm does not match the key's catalog `keyType`
173    /// (e.g. a `chacha20-poly1305` request against an `aes-256-gcm` key), or the
174    /// AEAD suite is otherwise not usable for this key. Maps to the wire
175    /// `unsupported_algorithm` / `invalid_request` per the call site.
176    #[error("unsupported AEAD algorithm: {0}")]
177    UnsupportedAlgorithm(AeadAlgorithm),
178
179    /// Transport / HTTP failure talking to the backend.
180    #[error("backend transport error: {0}")]
181    Transport(String),
182
183    /// The backend was reachable but rejected or failed the operation.
184    #[error("backend error: {0}")]
185    Backend(String),
186
187    /// A response from the backend could not be understood.
188    #[error("malformed backend response: {0}")]
189    Protocol(String),
190}
191
192/// A pluggable key-store + signing backend.
193///
194/// Implementations must be cheap to share across connections (the agent holds
195/// a single `Arc<dyn Backend>` and clones it into every spawned handler).
196#[async_trait]
197pub trait Backend: Send + Sync {
198    /// Short, stable name for this backend (e.g. `"vault"`), used in `STATUS`.
199    fn kind(&self) -> &'static str;
200
201    /// `NEW_KEY` creates a new key of `key_type` and returns its id + public key.
202    async fn new_key(&self, key_type: KeyType) -> Result<NewKey, BackendError>;
203
204    /// Create an **asymmetric** crypto key at a **named path** (`key_id` = the
205    /// catalog transit key name) rather than the server-assigned id [`new_key`]
206    /// uses. This is the startup-reconcile (`vault-zrg`) `generate` path: the key
207    /// must exist at the exact catalog `path` so a later `sign`/`get_public_key`
208    /// resolves to it.
209    ///
210    /// The default returns [`BackendError::Unsupported`]; [`vault`] overrides it.
211    async fn create_named_key(
212        &self,
213        key_id: &str,
214        key_type: KeyType,
215    ) -> Result<NewKey, BackendError> {
216        let _ = (key_id, key_type);
217        Err(BackendError::Unsupported("create_named_key"))
218    }
219
220    /// Create a **symmetric AEAD** crypto key at a **named path**. AEAD suites are
221    /// not wire [`KeyType`]s, so the reconcile `generate` path passes `aead`, the
222    /// catalog algorithm. There is no public half to return; `Ok(())` means the
223    /// key now exists at `key_id`.
224    ///
225    /// The default returns [`BackendError::Unsupported`]; [`vault`] overrides it.
226    async fn create_named_aead(
227        &self,
228        key_id: &str,
229        aead: AeadAlgorithm,
230    ) -> Result<(), BackendError> {
231        let _ = (key_id, aead);
232        Err(BackendError::Unsupported("create_named_aead"))
233    }
234
235    /// Read the raw public key bytes for `key_id` (for Ed25519, 32 bytes).
236    /// Used by credential minters (e.g. to derive a NATS issuer `NKey`).
237    async fn public_key(&self, key_id: &str) -> Result<Vec<u8>, BackendError>;
238
239    /// `GET_PUBLIC_KEY`: read the public half **plus** its metadata (algorithm +
240    /// current version) for `key_id`.
241    ///
242    /// The default returns [`BackendError::Unsupported`] so a backend that only
243    /// signs need not implement metadata; [`vault`] overrides it.
244    async fn public_key_with_meta(&self, key_id: &str) -> Result<PublicKey, BackendError> {
245        let _ = key_id;
246        Err(BackendError::Unsupported("get_public_key"))
247    }
248
249    /// Read value-free metadata (algorithm + latest version) for `key_id`.
250    /// Used by `list` to populate each [`basil_proto::KeyEntry`] without
251    /// ever reading key material.
252    ///
253    /// The default returns [`BackendError::Unsupported`]; [`vault`] overrides it.
254    async fn key_metadata(&self, key_id: &str) -> Result<KeyMetadata, BackendError> {
255        let _ = key_id;
256        Err(BackendError::Unsupported("list"))
257    }
258
259    /// Read **every live version's public key** for `key_id`, keyed by version
260    /// number. For a transit key this is the `data.keys` map, each archived (and
261    /// the latest) version's public half, which is the natural multi-version
262    /// source for a rotation/grace-aware JWKS (`basil-uce.2`): the shared
263    /// generator emits one JWK per version still inside the grace window.
264    ///
265    /// **Public material only**, never any private/secret bytes (the same
266    /// guarantee as [`Backend::public_key`]). The default degrades safely to the
267    /// single latest version (so non-transit backends that only know "the current
268    /// public key" still publish a valid one-key set); [`vault`]/[`spiffe`]
269    /// override it to return the whole version map.
270    async fn public_keys(
271        &self,
272        key_id: &str,
273    ) -> Result<std::collections::BTreeMap<u32, Vec<u8>>, BackendError> {
274        // Fall back to the single latest public key. Probe `key_metadata` for the
275        // version, but a backend that does not implement it (only signs) still
276        // reports a valid set at version 1 rather than failing closed.
277        let version = match self.key_metadata(key_id).await {
278            Ok(meta) => meta.latest_version,
279            Err(BackendError::Unsupported(_)) => 1,
280            Err(e) => return Err(e),
281        };
282        let public = self.public_key(key_id).await?;
283        Ok(std::collections::BTreeMap::from([(version, public)]))
284    }
285
286    /// `IMPORT` (BYOK) creates the key `key_id` from caller-supplied `material`.
287    ///
288    /// Write-only: the private material is consumed to provision the key and the
289    /// reply carries only the public half (never the seed/private bytes). The
290    /// material variant must agree with `key_type`.
291    ///
292    /// The default returns [`BackendError::Unsupported`]; [`vault`] overrides it.
293    async fn import(
294        &self,
295        key_id: &str,
296        key_type: KeyType,
297        material: &KeyMaterial,
298    ) -> Result<NewKey, BackendError> {
299        let _ = (key_id, key_type, material);
300        Err(BackendError::Unsupported("import"))
301    }
302
303    /// `SIGN` signs `message` with `key_id`, returning the raw signature bytes.
304    ///
305    /// For Ed25519 / ed25519-nkey keys the argument is the **raw message**, signed
306    /// directly (`EdDSA` is not pre-hashed): the backend MUST NOT pre-hash it. This is
307    /// what lets a NATS client hand Basil the server-issued nonce verbatim and use
308    /// the returned signature as its connect response (an `async-nats` remote-signer
309    /// callback), so the user seed never leaves the vault.
310    async fn sign(&self, key_id: &str, message: &[u8]) -> Result<Vec<u8>, BackendError>;
311
312    /// `SIGN` with backend-specific algorithm options.
313    ///
314    /// The default mode preserves the normal [`Backend::sign`] behavior. Non-
315    /// default modes fail closed unless a backend explicitly implements them.
316    async fn sign_with_options(
317        &self,
318        key_id: &str,
319        message: &[u8],
320        options: SignOptions,
321    ) -> Result<Vec<u8>, BackendError> {
322        match options {
323            SignOptions::Default => self.sign(key_id, message).await,
324            SignOptions::Rs256Pkcs1v15Sha256 => {
325                let _ = (key_id, message);
326                Err(BackendError::Unsupported("sign rs256_pkcs1v15_sha256"))
327            }
328            SignOptions::Es256 => {
329                let _ = (key_id, message);
330                Err(BackendError::Unsupported("sign es256"))
331            }
332            SignOptions::Es384 => {
333                let _ = (key_id, message);
334                Err(BackendError::Unsupported("sign es384"))
335            }
336            SignOptions::Es512 => {
337                let _ = (key_id, message);
338                Err(BackendError::Unsupported("sign es512"))
339            }
340        }
341    }
342
343    /// `VERIFY` verifies `signature` over `message` with `key_id`.
344    async fn verify(
345        &self,
346        key_id: &str,
347        message: &[u8],
348        signature: &[u8],
349    ) -> Result<bool, BackendError>;
350
351    /// `VERIFY` with backend-specific algorithm options.
352    ///
353    /// The default mode preserves the normal [`Backend::verify`] behavior. Non-
354    /// default modes fail closed unless a backend explicitly implements them.
355    async fn verify_with_options(
356        &self,
357        key_id: &str,
358        message: &[u8],
359        signature: &[u8],
360        options: SignOptions,
361    ) -> Result<bool, BackendError> {
362        match options {
363            SignOptions::Default => self.verify(key_id, message, signature).await,
364            SignOptions::Rs256Pkcs1v15Sha256 => {
365                let _ = (key_id, message, signature);
366                Err(BackendError::Unsupported("verify rs256_pkcs1v15_sha256"))
367            }
368            SignOptions::Es256 => {
369                let _ = (key_id, message, signature);
370                Err(BackendError::Unsupported("verify es256"))
371            }
372            SignOptions::Es384 => {
373                let _ = (key_id, message, signature);
374                Err(BackendError::Unsupported("verify es384"))
375            }
376            SignOptions::Es512 => {
377                let _ = (key_id, message, signature);
378                Err(BackendError::Unsupported("verify es512"))
379            }
380        }
381    }
382
383    /// Whether this backend can perform `algorithm` **natively in place**:
384    /// custody the private key inside the backend and run the operation without
385    /// ever materializing the seed locally.
386    ///
387    /// This is the capability probe behind the `backend-preferred` /
388    /// `backend-required` provider policy (`basil-wuj.10`). It is **cheap,
389    /// synchronous, and fails closed**: the default, and any backend that does
390    /// not override it (every shipping Vault/OpenBao transit engine today, none
391    /// of which has ML-DSA transit), returns `false`, so an unknown or
392    /// unsupported capability never routes key material to a backend that cannot
393    /// custody it. A backend that returns `true` here MUST implement the matching
394    /// native operation methods ([`Self::sign_pqc`], [`Self::verify_pqc`],
395    /// [`Self::create_named_pqc_key`]).
396    fn supports_native_algorithm(&self, algorithm: NativeAlgorithm) -> bool {
397        let _ = algorithm;
398        false
399    }
400
401    /// Provision a new **backend-native** ML-DSA key at the named `key_id`,
402    /// returning only its public half. The private seed is generated and kept
403    /// inside the backend and is never returned. Invoked by the backend-native
404    /// crypto provider when [`Self::supports_native_algorithm`] reports support.
405    ///
406    /// The default returns [`BackendError::Unsupported`]; a backend with native
407    /// ML-DSA transit overrides it.
408    async fn create_named_pqc_key(
409        &self,
410        key_id: &str,
411        algorithm: NativeAlgorithm,
412    ) -> Result<NewKey, BackendError> {
413        let _ = (key_id, algorithm);
414        Err(BackendError::Unsupported("create_named_pqc_key"))
415    }
416
417    /// `SIGN` `message` with a **backend-native** ML-DSA key, returning the raw
418    /// signature bytes. The private seed never leaves the backend.
419    ///
420    /// The default returns [`BackendError::Unsupported`]; a backend with native
421    /// ML-DSA transit overrides it.
422    async fn sign_pqc(
423        &self,
424        key_id: &str,
425        message: &[u8],
426        algorithm: NativeAlgorithm,
427    ) -> Result<Vec<u8>, BackendError> {
428        let _ = (key_id, message, algorithm);
429        Err(BackendError::Unsupported("sign_pqc"))
430    }
431
432    /// `VERIFY` `signature` over `message` with a **backend-native** ML-DSA key,
433    /// using only the public half.
434    ///
435    /// The default returns [`BackendError::Unsupported`]; a backend with native
436    /// ML-DSA transit overrides it.
437    async fn verify_pqc(
438        &self,
439        key_id: &str,
440        message: &[u8],
441        signature: &[u8],
442        algorithm: NativeAlgorithm,
443    ) -> Result<bool, BackendError> {
444        let _ = (key_id, message, signature, algorithm);
445        Err(BackendError::Unsupported("verify_pqc"))
446    }
447
448    /// `ENCRYPT`: AEAD-encrypt `plaintext` under `key_id`'s **latest** version,
449    /// binding `aad` if present, and return a normalized [`CiphertextEnvelope`].
450    ///
451    /// The broker **never** takes a caller nonce: the backend (transit) generates
452    /// a fresh nonce per call. `algorithm` must match the key's catalog `keyType`
453    /// (the manager enforces that before dispatch); the envelope echoes it.
454    ///
455    /// The default returns [`BackendError::Unsupported`]; [`transit`] overrides it.
456    async fn encrypt(
457        &self,
458        key_id: &str,
459        algorithm: AeadAlgorithm,
460        plaintext: &[u8],
461        aad: Option<&[u8]>,
462    ) -> Result<CiphertextEnvelope, BackendError> {
463        let _ = (key_id, algorithm, plaintext, aad);
464        Err(BackendError::Unsupported("encrypt"))
465    }
466
467    /// `DECRYPT`: AEAD-decrypt `envelope` under `key_id`, binding `aad` if
468    /// present, and return the recovered plaintext.
469    ///
470    /// The envelope's `key_version` targets the version that produced it (so a
471    /// ciphertext made before a rotation still decrypts during the grace window).
472    /// A tag/AAD/version mismatch is [`BackendError::DecryptFailed`]: opaque, no
473    /// oracle.
474    ///
475    /// The default returns [`BackendError::Unsupported`]; [`transit`] overrides it.
476    async fn decrypt(
477        &self,
478        key_id: &str,
479        envelope: &CiphertextEnvelope,
480        aad: Option<&[u8]>,
481    ) -> Result<Vec<u8>, BackendError> {
482        let _ = (key_id, envelope, aad);
483        Err(BackendError::Unsupported("decrypt"))
484    }
485
486    /// `ROTATE`: bump `key_id` to a fresh **transit key version**, returning the
487    /// new (now-latest) version number. New encrypt/sign always uses the newest
488    /// version; older versions remain decryptable/verifiable within the grace
489    /// window (see [`Backend::configure_versions`]).
490    ///
491    /// The default returns [`BackendError::Unsupported`]; [`transit`] overrides it.
492    async fn rotate(&self, key_id: &str) -> Result<u32, BackendError> {
493        let _ = key_id;
494        Err(BackendError::Unsupported("rotate"))
495    }
496
497    /// Read a **KV-v2 value** for `key_id`: the stored bytes plus the version they
498    /// came from. `version = None` reads the latest version; `Some(v)` reads that
499    /// specific version. This is the residual value-returning `get` path (§7) and
500    /// is only ever routed to a `value`/`public`-class key by the manager. It
501    /// **never** reads transit (crypto-key) material.
502    ///
503    /// The default returns [`BackendError::Unsupported`]; [`transit`] overrides it.
504    async fn kv_get(&self, key_id: &str, version: Option<u32>) -> Result<KvValue, BackendError> {
505        let _ = (key_id, version);
506        Err(BackendError::Unsupported("get"))
507    }
508
509    /// Read a **KV-v2 value as a SECRET**: the stored bytes wrapped in
510    /// [`Zeroizing`] end-to-end, never landing in a non-zeroizing owner that drops
511    /// un-wiped. Distinct from [`Backend::kv_get`], which returns a plain
512    /// [`KvValue`] for value/public reads, this path is used **only** by the
513    /// sealing materialize (`materialize_sealing_private`), where the bytes are an
514    /// X25519 private key. The returned buffer wipes on drop.
515    ///
516    /// The default returns [`BackendError::Unsupported`]; [`transit`] overrides it.
517    async fn kv_get_secret(
518        &self,
519        key_id: &str,
520        version: Option<u32>,
521    ) -> Result<Zeroizing<Vec<u8>>, BackendError> {
522        let _ = (key_id, version);
523        Err(BackendError::Unsupported("kv_get_secret"))
524    }
525
526    /// Write `value` as a fresh **KV-v2 version** of `key_id`, returning the new
527    /// version number (never the value). Used to rotate a *value* key that has a
528    /// catalog `generate` recipe: the broker generates a fresh value and stores it
529    /// as the next version (the `vault-a2p` decision), and to back the `set` op.
530    ///
531    /// The default returns [`BackendError::Unsupported`]; [`transit`] overrides it.
532    async fn kv_put(&self, key_id: &str, value: &[u8]) -> Result<u32, BackendError> {
533        let _ = (key_id, value);
534        Err(BackendError::Unsupported("rotate"))
535    }
536
537    /// Set the transit version window for `key_id`: `min_decryption_version`
538    /// bounds the **grace period** (the oldest version `decrypt`/`verify` may
539    /// still target: `0`/`1` honors all live versions, a higher value rejects
540    /// pre-window ciphertexts), and `min_available_version` is the **retention**
541    /// floor (transit deletes archived key material below it, irreversibly
542    /// pruning expired versions). Both are optional; `None` leaves that field
543    /// untouched.
544    ///
545    /// The default returns [`BackendError::Unsupported`]; [`transit`] overrides it.
546    async fn configure_versions(
547        &self,
548        key_id: &str,
549        min_decryption_version: Option<u32>,
550        min_available_version: Option<u32>,
551    ) -> Result<(), BackendError> {
552        let _ = (key_id, min_decryption_version, min_available_version);
553        Err(BackendError::Unsupported("configure_versions"))
554    }
555
556    /// Issue an X.509-SVID leaf from a provider-native PKI role.
557    ///
558    /// `key_id` is the backend-native issue endpoint from the catalog. For
559    /// Vault PKI this is an absolute path such as `pki/issue/web`, not a
560    /// transit key name. The private key is scoped to this operation and carried
561    /// in a zeroizing buffer until the Workload API response is assembled.
562    async fn issue_x509_svid(
563        &self,
564        key_id: &str,
565        spiffe_id: &str,
566        ttl_seconds: u64,
567    ) -> Result<X509Svid, BackendError> {
568        let _ = (key_id, spiffe_id, ttl_seconds);
569        Err(BackendError::Unsupported("issue_x509_svid"))
570    }
571
572    /// Issue a DNS/IP-SAN X.509 leaf (a TLS cert) from a provider-native PKI role.
573    ///
574    /// `key_id` is the backend-native issue endpoint from the catalog (for
575    /// Vault PKI, an absolute path such as `pki/issue/web`). Like
576    /// [`Backend::issue_x509_svid`] the issuing CA key never leaves the backend;
577    /// unlike an SVID the leaf is bound to DNS/IP SANs, and the leaf private key is
578    /// returned to the caller (a TLS server needs it) in a zeroizing buffer.
579    async fn issue_x509_cert(
580        &self,
581        key_id: &str,
582        request: &X509CertRequest,
583    ) -> Result<X509Svid, BackendError> {
584        let _ = (key_id, request);
585        Err(BackendError::Unsupported("issue_x509_cert"))
586    }
587
588    /// Read trust-domain X.509 bundle material from a provider-native PKI
589    /// issuer path.
590    ///
591    /// `key_id` is the same backend-native locator used by
592    /// [`Backend::issue_x509_svid`]. For Vault PKI this is an issue path such
593    /// as `pki/issue/web`; the backend derives the PKI mount and reads that
594    /// mount's CA bundle and CRL.
595    async fn x509_bundle(&self, key_id: &str) -> Result<X509Bundle, BackendError> {
596        let _ = key_id;
597        Err(BackendError::Unsupported("x509_bundle"))
598    }
599}