Skip to main content

basil_core/core/
crypto_provider.rs

1// SPDX-FileCopyrightText: 2026 OpenBasil Contributors
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! Provider-neutral crypto operation dispatch.
6//!
7//! This layer sits below `service::*` and above concrete backends. It describes
8//! algorithm-provider capabilities without exposing provider internals or private
9//! key material to callers.
10
11use super::{ml_dsa_sign, ml_kem_envelope};
12use crate::audit::timestamp;
13use crate::backend::{Backend, BackendError, NativeAlgorithm, NewKey, SignOptions};
14use crate::catalog::schema::KeyEntry;
15use async_trait::async_trait;
16use base64::Engine;
17use base64::engine::general_purpose::STANDARD as B64;
18use basil_proto::{AeadAlgorithm, CiphertextEnvelope};
19use basil_proto::{KeyMaterial, KeyType};
20use serde::{Deserialize, Serialize};
21use serde_json::json;
22use zeroize::Zeroizing;
23
24/// Supported signing algorithm families for provider dispatch.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum SignatureAlgorithm {
27    /// Plain Ed25519.
28    Ed25519,
29    /// Ed25519 used as a NATS `NKey`.
30    Ed25519Nkey,
31    /// JWS `RS256`.
32    Rs256,
33    /// JWS `ES256`.
34    Es256,
35    /// ML-DSA parameter level 44.
36    MlDsa44,
37    /// ML-DSA parameter level 65.
38    MlDsa65,
39    /// ML-DSA parameter level 87.
40    MlDsa87,
41}
42
43impl SignatureAlgorithm {
44    /// Whether this is a post-quantum ML-DSA signing algorithm (the family routed
45    /// through the local-software crypto provider).
46    #[must_use]
47    pub const fn is_ml_dsa(self) -> bool {
48        matches!(self, Self::MlDsa44 | Self::MlDsa65 | Self::MlDsa87)
49    }
50
51    /// The stable kebab-case token for this algorithm (used in audit + diagnostics).
52    #[must_use]
53    pub const fn token(self) -> &'static str {
54        signature_algorithm_name(self)
55    }
56
57    /// The backend-native [`NativeAlgorithm`] this signing algorithm maps to, for
58    /// the capability probe ([`Backend::supports_native_algorithm`]). `Some` only
59    /// for the ML-DSA family: classical signing algorithms always custody in
60    /// place and never consult the PQC native probe.
61    #[must_use]
62    pub const fn native_algorithm(self) -> Option<NativeAlgorithm> {
63        match self {
64            Self::MlDsa44 => Some(NativeAlgorithm::MlDsa44),
65            Self::MlDsa65 => Some(NativeAlgorithm::MlDsa65),
66            Self::MlDsa87 => Some(NativeAlgorithm::MlDsa87),
67            Self::Ed25519 | Self::Ed25519Nkey | Self::Rs256 | Self::Es256 => None,
68        }
69    }
70}
71
72/// Map a catalog signing [`KeyAlgorithm`](crate::catalog::KeyAlgorithm) to the
73/// provider [`SignatureAlgorithm`], returning `Some` only for the ML-DSA family.
74///
75/// This is the routing signal the manager uses to send a key down the
76/// provider-dispatch path: a `Some` result means the key is an ML-DSA signing key
77/// whose private is software-custodied, so `sign`/`verify`/`new_key` go through
78/// [`select_provider`] and a [`CryptoProvider`] rather than the in-place backend
79/// signing path. Classical signing algorithms return `None`.
80#[must_use]
81pub const fn ml_dsa_signature_algorithm(
82    key_type: crate::catalog::KeyAlgorithm,
83) -> Option<SignatureAlgorithm> {
84    use crate::catalog::KeyAlgorithm;
85    match key_type {
86        KeyAlgorithm::MlDsa44 => Some(SignatureAlgorithm::MlDsa44),
87        KeyAlgorithm::MlDsa65 => Some(SignatureAlgorithm::MlDsa65),
88        KeyAlgorithm::MlDsa87 => Some(SignatureAlgorithm::MlDsa87),
89        KeyAlgorithm::Ed25519
90        | KeyAlgorithm::Ed25519Nkey
91        | KeyAlgorithm::Rsa2048
92        | KeyAlgorithm::EcdsaP256
93        | KeyAlgorithm::EcdsaP384
94        | KeyAlgorithm::EcdsaP521
95        | KeyAlgorithm::Aes256Gcm
96        | KeyAlgorithm::ChaCha20Poly1305
97        | KeyAlgorithm::X25519
98        | KeyAlgorithm::MlKem512
99        | KeyAlgorithm::MlKem768
100        | KeyAlgorithm::MlKem1024 => None,
101    }
102}
103
104/// Supported KEM algorithm families for provider dispatch.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum KemAlgorithm {
107    /// ML-KEM parameter level 512.
108    MlKem512,
109    /// ML-KEM parameter level 768.
110    MlKem768,
111    /// ML-KEM parameter level 1024.
112    MlKem1024,
113}
114
115impl KemAlgorithm {
116    /// The kebab-case token used in catalog labels, audit records, and provider
117    /// dispatch (`ml-kem-512` / `ml-kem-768` / `ml-kem-1024`).
118    #[must_use]
119    pub const fn token(self) -> &'static str {
120        match self {
121            Self::MlKem512 => "ml-kem-512",
122            Self::MlKem768 => "ml-kem-768",
123            Self::MlKem1024 => "ml-kem-1024",
124        }
125    }
126
127    /// The backend-native [`NativeAlgorithm`] this KEM maps to, for the capability
128    /// probe. Always `None`: no shipping backend exposes native ML-KEM transit, so
129    /// ML-KEM keys always remain software-custodied and never route to a
130    /// backend-native provider. When a backend gains native ML-KEM, add the
131    /// variants both here and to [`BackendCryptoProvider`]'s envelope dispatch.
132    #[must_use]
133    pub const fn native_algorithm(self) -> Option<NativeAlgorithm> {
134        match self {
135            Self::MlKem512 | Self::MlKem768 | Self::MlKem1024 => None,
136        }
137    }
138}
139
140/// Symmetric envelope algorithm used after KEM shared-secret derivation.
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub enum EnvelopeAlgorithm {
143    /// AES-256-GCM envelope encryption.
144    Aes256Gcm,
145    /// ChaCha20-Poly1305 envelope encryption.
146    ChaCha20Poly1305,
147}
148
149/// Provider implementation selected for a key operation.
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub enum CryptoProviderId {
152    /// Backend-native Vault transit support (`HashiCorp` Vault or `OpenBao`).
153    VaultTransit,
154    /// Basil local software provider for software-custodied PQC keys.
155    LocalSoftware,
156}
157
158/// Catalog provider policy for one key.
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160pub enum ProviderPolicy {
161    /// Backend-native support is mandatory.
162    BackendRequired,
163    /// Backend-native support wins; local software is an explicit fallback.
164    BackendPreferred,
165    /// Local software is mandatory.
166    LocalSoftware,
167}
168
169/// Custody mode recorded in key metadata.
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171pub enum CustodyMode {
172    /// Private material stays backend-native.
173    BackendNative,
174    /// Private material is stored encrypted and unwrapped locally per operation.
175    SoftwareEncrypted,
176}
177
178impl CustodyMode {
179    const fn token(self) -> &'static str {
180        match self {
181            Self::BackendNative => "backend-native",
182            Self::SoftwareEncrypted => "software-encrypted",
183        }
184    }
185}
186
187impl CryptoProviderId {
188    const fn token(self) -> &'static str {
189        match self {
190            Self::VaultTransit => "vault-transit",
191            Self::LocalSoftware => "local-software",
192        }
193    }
194
195    /// The key-custody mode implied by this provider: backend-native keeps the
196    /// private in place; the local-software provider unwraps a software-encrypted
197    /// seed per operation.
198    #[must_use]
199    pub const fn custody_mode(self) -> CustodyMode {
200        match self {
201            Self::VaultTransit => CustodyMode::BackendNative,
202            Self::LocalSoftware => CustodyMode::SoftwareEncrypted,
203        }
204    }
205}
206
207/// Provider metadata derived from reserved catalog labels.
208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
209pub struct ProviderMetadata {
210    /// Explicit provider, if the catalog labels name one.
211    pub provider: Option<CryptoProviderId>,
212    /// Provider policy, defaulting to backend-required.
213    pub policy: ProviderPolicy,
214    /// Custody mode, if declared.
215    pub custody: Option<CustodyMode>,
216}
217
218impl ProviderMetadata {
219    /// Parse provider metadata from validated catalog labels.
220    #[must_use]
221    pub fn from_key(entry: &KeyEntry) -> Self {
222        let provider = match entry.labels.get("crypto_provider") {
223            Some("vault-transit") => Some(CryptoProviderId::VaultTransit),
224            Some("local-software") => Some(CryptoProviderId::LocalSoftware),
225            _ => None,
226        };
227        let policy = match entry.labels.get("crypto_provider_policy") {
228            Some("backend-preferred") => ProviderPolicy::BackendPreferred,
229            Some("local-software") => ProviderPolicy::LocalSoftware,
230            _ => ProviderPolicy::BackendRequired,
231        };
232        let custody = match entry.labels.get("pqc_custody") {
233            Some("backend-native") => Some(CustodyMode::BackendNative),
234            Some("software-encrypted") => Some(CustodyMode::SoftwareEncrypted),
235            _ => None,
236        };
237        Self {
238            provider,
239            policy,
240            custody,
241        }
242    }
243}
244
245/// Errors raised before or inside a provider operation.
246#[derive(Debug, thiserror::Error)]
247pub enum ProviderError {
248    /// The selected provider does not implement this operation/algorithm.
249    #[error("provider {provider:?} does not support {op} for {algorithm}")]
250    Unsupported {
251        /// Provider that rejected the operation.
252        provider: CryptoProviderId,
253        /// Operation name.
254        op: &'static str,
255        /// Algorithm name.
256        algorithm: &'static str,
257    },
258
259    /// Policy or custody labels forbid the requested provider.
260    #[error("provider policy denied {op}: {reason}")]
261    PolicyDenied {
262        /// Operation name.
263        op: &'static str,
264        /// Stable reason.
265        reason: &'static str,
266    },
267
268    /// A software-custody crypto or record operation failed: a malformed
269    /// custody record, wrong key material, or a decapsulation/verification/seal
270    /// failure. The message is opaque: it never contains seed, key, plaintext,
271    /// signature, or shared-secret bytes (no decrypt oracle).
272    #[error("provider {provider:?} {op} failed for {algorithm}: {reason}")]
273    CryptoFailed {
274        /// Provider that failed.
275        provider: CryptoProviderId,
276        /// Operation name.
277        op: &'static str,
278        /// Algorithm name.
279        algorithm: &'static str,
280        /// Stable, secret-free reason token.
281        reason: &'static str,
282    },
283
284    /// Concrete backend error.
285    #[error(transparent)]
286    Backend(#[from] BackendError),
287}
288
289/// Stable metadata copied from a software-custodied key's catalog labels.
290#[derive(Debug, Clone, Copy, PartialEq, Eq)]
291pub(crate) struct SoftwareCustodyCatalog<'a> {
292    /// Catalog key id.
293    pub key_id: &'a str,
294    /// PQC algorithm token.
295    pub algorithm: &'a str,
296    /// Provider id token.
297    pub provider: &'a str,
298    /// Provider implementation version token.
299    pub provider_version: &'a str,
300    /// Custody mode token.
301    pub custody: &'a str,
302    /// Backend AEAD key used to unwrap the encrypted private record.
303    pub storage_key: &'a str,
304}
305impl SoftwareCustodyCatalog<'_> {
306    pub(crate) fn aad(&self, key_version: u32) -> Vec<u8> {
307        format!(
308            "basil:pqc-software-custody:v1\nkey_id={}\nkey_version={key_version}\nalgorithm={}\nprovider={}\nprovider_version={}\ncustody={}\nstorage_key={}\n",
309            self.key_id,
310            self.algorithm,
311            self.provider,
312            self.provider_version,
313            self.custody,
314            self.storage_key,
315        )
316        .into_bytes()
317    }
318}
319
320/// An encrypted local-software private key record stored in KV.
321#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
322#[serde(rename_all = "camelCase", deny_unknown_fields)]
323pub(crate) struct SoftwareCustodyKeyRecord {
324    schema_version: u32,
325    key_id: String,
326    key_version: u32,
327    public_key: String,
328    algorithm: String,
329    provider: String,
330    provider_version: String,
331    custody: String,
332    encrypted_private_key: EncryptedPrivateKey,
333}
334#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
335#[serde(rename_all = "camelCase", deny_unknown_fields)]
336struct EncryptedPrivateKey {
337    wrapping_key: String,
338    algorithm: String,
339    key_version: u32,
340    nonce: String,
341    ciphertext: String,
342}
343impl SoftwareCustodyKeyRecord {
344    const SCHEMA_VERSION: u32 = 1;
345
346    /// The AAD that must be supplied to the backend AEAD decrypt operation.
347    #[must_use]
348    pub(crate) fn aad(&self, catalog: &SoftwareCustodyCatalog<'_>) -> Vec<u8> {
349        catalog.aad(self.key_version)
350    }
351
352    /// Convert the encrypted private-key fields to the backend ciphertext type.
353    pub(crate) fn encrypted_private_key(
354        &self,
355    ) -> Result<CiphertextEnvelope, SoftwareCustodyRecordError> {
356        Ok(CiphertextEnvelope {
357            alg: parse_storage_aead(&self.encrypted_private_key.algorithm)?,
358            key_version: self.encrypted_private_key.key_version,
359            nonce: decode_b64(&self.encrypted_private_key.nonce)?,
360            ciphertext: decode_b64(&self.encrypted_private_key.ciphertext)?,
361        })
362    }
363
364    fn validate_metadata(
365        &self,
366        catalog: &SoftwareCustodyCatalog<'_>,
367        kv_version: u32,
368    ) -> Result<(), SoftwareCustodyRecordError> {
369        if self.schema_version != Self::SCHEMA_VERSION {
370            return Err(SoftwareCustodyRecordError::MetadataMismatch(
371                "schema_version",
372            ));
373        }
374        if self.key_id != catalog.key_id {
375            return Err(SoftwareCustodyRecordError::MetadataMismatch("key_id"));
376        }
377        if self.key_version != kv_version {
378            return Err(SoftwareCustodyRecordError::MetadataMismatch("key_version"));
379        }
380        if self.algorithm != catalog.algorithm {
381            return Err(SoftwareCustodyRecordError::MetadataMismatch("algorithm"));
382        }
383        if self.provider != catalog.provider {
384            return Err(SoftwareCustodyRecordError::MetadataMismatch("provider"));
385        }
386        if self.provider_version != catalog.provider_version {
387            return Err(SoftwareCustodyRecordError::MetadataMismatch(
388                "provider_version",
389            ));
390        }
391        if self.custody != catalog.custody {
392            return Err(SoftwareCustodyRecordError::MetadataMismatch("custody"));
393        }
394        if self.public_key.is_empty() || decode_b64(&self.public_key).is_err() {
395            return Err(SoftwareCustodyRecordError::Malformed);
396        }
397        if self.encrypted_private_key.wrapping_key != catalog.storage_key {
398            return Err(SoftwareCustodyRecordError::MetadataMismatch("wrapping_key"));
399        }
400        if self.encrypted_private_key.key_version == 0 {
401            return Err(SoftwareCustodyRecordError::MetadataMismatch(
402                "encrypted_private_key.key_version",
403            ));
404        }
405        Ok(())
406    }
407}
408
409/// Materialized, validated software-custody fields the local-software provider
410/// needs for one operation: the AEAD ciphertext + AAD to decrypt the private
411/// seed, the backend AEAD key name, the published public key, and the version.
412pub(crate) struct LocalSoftwareMaterial {
413    /// AEAD-wrapped private seed to hand to [`Backend::decrypt`].
414    pub(crate) ciphertext: CiphertextEnvelope,
415    /// Associated data bound to the wrapped seed.
416    pub(crate) aad: Vec<u8>,
417    /// Backend AEAD key name that unwraps the seed.
418    pub(crate) storage_key: String,
419    /// Published public-key bytes (verifying key / encapsulation key).
420    pub(crate) public_key: Vec<u8>,
421    /// KV record version (also the envelope key version).
422    pub(crate) key_version: u32,
423}
424impl SoftwareCustodyKeyRecord {
425    /// Parse and validate a software-custody record for the local-software
426    /// provider, cross-checking it against the requested key id, algorithm, the
427    /// provider's own version, and the **catalog-declared** storage AEAD key
428    /// (`storage_key`, the `pqc_storage_key` label): a record whose
429    /// self-declared `wrapping_key` disagrees is rejected, so a swapped or
430    /// re-wrapped record cannot redirect the seed unwrap to an AEAD key the
431    /// catalog never authorized. The returned material carries the catalog key,
432    /// and the AAD binds it.
433    ///
434    /// # Errors
435    ///
436    /// [`SoftwareCustodyRecordError`] for malformed JSON, a metadata mismatch,
437    /// or a bad base64 field.
438    pub(crate) fn parse_for_local_software(
439        bytes: &[u8],
440        key_id: &str,
441        algorithm: &str,
442        provider_version: &str,
443        kv_version: u32,
444        storage_key: &str,
445    ) -> Result<LocalSoftwareMaterial, SoftwareCustodyRecordError> {
446        let record: Self =
447            serde_json::from_slice(bytes).map_err(|_| SoftwareCustodyRecordError::Malformed)?;
448        let catalog = SoftwareCustodyCatalog {
449            key_id,
450            algorithm,
451            provider: CryptoProviderId::LocalSoftware.token(),
452            provider_version,
453            custody: CustodyMode::SoftwareEncrypted.token(),
454            storage_key,
455        };
456        record.validate_metadata(&catalog, kv_version)?;
457        let ciphertext = record.encrypted_private_key()?;
458        let aad = record.aad(&catalog);
459        let public_key = decode_b64(&record.public_key)?;
460        Ok(LocalSoftwareMaterial {
461            ciphertext,
462            aad,
463            storage_key: storage_key.to_string(),
464            public_key,
465            key_version: kv_version,
466        })
467    }
468}
469
470/// Errors from encrypted software-custody record parsing and validation.
471#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
472pub(crate) enum SoftwareCustodyRecordError {
473    /// The record is not valid JSON, has unknown fields, or has malformed base64.
474    #[error("malformed software-custody key record")]
475    Malformed,
476    /// Record metadata disagrees with catalog/request metadata.
477    #[error("software-custody key record metadata mismatch: {0}")]
478    MetadataMismatch(&'static str),
479    /// The record names an unsupported storage AEAD.
480    #[error("unsupported software-custody storage AEAD")]
481    UnsupportedStorageAead,
482}
483fn parse_storage_aead(token: &str) -> Result<AeadAlgorithm, SoftwareCustodyRecordError> {
484    match token {
485        "aes-256-gcm" => Ok(AeadAlgorithm::Aes256Gcm),
486        "chacha20-poly1305" => Ok(AeadAlgorithm::Chacha20Poly1305),
487        _ => Err(SoftwareCustodyRecordError::UnsupportedStorageAead),
488    }
489}
490fn decode_b64(value: &str) -> Result<Vec<u8>, SoftwareCustodyRecordError> {
491    B64.decode(value)
492        .map_err(|_| SoftwareCustodyRecordError::Malformed)
493}
494
495/// Encode bytes for a software-custody JSON record.
496#[cfg(test)]
497pub(crate) fn encode_record_bytes(bytes: &[u8]) -> String {
498    B64.encode(bytes)
499}
500
501/// Outcome for a PQC provider audit event.
502#[derive(Debug, Clone, Copy, PartialEq, Eq)]
503pub enum ProviderAuditOutcome {
504    /// Provider operation was permitted.
505    Allow,
506    /// Provider operation was denied before private material use.
507    Deny,
508    /// Provider operation completed successfully.
509    Success,
510    /// Provider operation failed.
511    Failure,
512}
513
514impl ProviderAuditOutcome {
515    const fn token(self) -> &'static str {
516        match self {
517            Self::Allow => "allow",
518            Self::Deny => "deny",
519            Self::Success => "success",
520            Self::Failure => "failure",
521        }
522    }
523}
524
525/// Secret-free audit event for software-custodied PQC provider operations.
526#[derive(Debug, Clone, PartialEq, Eq)]
527pub struct ProviderAuditEvent<'a> {
528    /// Operation name.
529    pub op: &'static str,
530    /// Catalog key id.
531    pub key_id: &'a str,
532    /// Catalog/backend key version, when known.
533    pub key_version: Option<u32>,
534    /// Algorithm token.
535    pub algorithm: &'static str,
536    /// Selected provider.
537    pub provider: CryptoProviderId,
538    /// Key custody mode.
539    pub custody: CustodyMode,
540    /// Kernel-attested caller uid.
541    pub caller_uid: u32,
542    /// Outcome.
543    pub outcome: ProviderAuditOutcome,
544    /// Stable reason token.
545    pub reason: &'static str,
546}
547
548impl ProviderAuditEvent<'_> {
549    /// Convert to JSON without including payloads, keys, signatures, ciphertexts,
550    /// encapsulated keys, shared secrets, or any other secret bytes.
551    #[must_use]
552    pub fn to_json_value(&self) -> serde_json::Value {
553        json!({
554            "event": {
555                "kind": "basil.audit.provider_operation",
556                "version": 1,
557            },
558            "occurred_at": timestamp(),
559            "op": self.op,
560            "actor": {
561                "kind": "unix_uid",
562                "id": self.caller_uid.to_string(),
563            },
564            "target": {
565                "kind": "catalog_key",
566                "id": self.key_id,
567                "version": self.key_version,
568            },
569            "key_version": self.key_version,
570            "algorithm": self.algorithm,
571            "provider": self.provider.token(),
572            "custody": self.custody.token(),
573            "caller_uid": self.caller_uid,
574            "outcome": self.outcome.token(),
575            "reason": self.reason,
576        })
577    }
578}
579
580/// Generate-key request.
581pub struct GenerateKey<'a> {
582    /// Catalog key id.
583    pub key_id: &'a str,
584    /// Backend-native locator (transit key name, or, for a software-custodied
585    /// key, the KV path the encrypted custody record is written to).
586    pub backend_path: &'a str,
587    /// Algorithm to generate.
588    pub algorithm: SignatureAlgorithm,
589    /// Backend AEAD key name that wraps a software-custodied private seed.
590    /// Required by the local-software provider; ignored by backend-native
591    /// providers, which custody the private in place.
592    pub storage_key: Option<&'a str>,
593}
594
595/// Generate-sealing-key request: provision a software-custodied ML-KEM recipient.
596///
597/// Unlike [`GenerateKey`] (signing), a sealing key has no signing semantics; the
598/// published half is the KEM encapsulation key derived from the seed.
599pub struct GenerateSealingKey<'a> {
600    /// Catalog key id.
601    pub key_id: &'a str,
602    /// KV path the encrypted custody record is written to.
603    pub backend_path: &'a str,
604    /// KEM parameter set to generate.
605    pub algorithm: KemAlgorithm,
606    /// Backend AEAD key name that wraps the software-custodied private seed.
607    /// Required by the local-software provider.
608    pub storage_key: Option<&'a str>,
609}
610
611/// Import-key request.
612pub struct ImportKey<'a> {
613    /// Catalog key id.
614    pub key_id: &'a str,
615    /// Backend-native locator.
616    pub backend_path: &'a str,
617    /// Algorithm to import.
618    pub algorithm: SignatureAlgorithm,
619    /// Caller-provided material.
620    pub material: &'a KeyMaterial,
621}
622
623/// Sign request.
624pub struct SignRequest<'a> {
625    /// Catalog key id.
626    pub key_id: &'a str,
627    /// Backend-native locator.
628    pub backend_path: &'a str,
629    /// Signing algorithm.
630    pub algorithm: SignatureAlgorithm,
631    /// The raw message to sign (signed as-is, not a precomputed digest).
632    pub message: &'a [u8],
633    /// Catalog-declared AEAD key that wraps a software-custodied private seed;
634    /// the custody record must agree. Required by the local-software provider;
635    /// ignored by backend-native providers.
636    pub storage_key: Option<&'a str>,
637}
638
639/// Verify request.
640pub struct VerifyRequest<'a> {
641    /// Catalog key id.
642    pub key_id: &'a str,
643    /// Backend-native locator.
644    pub backend_path: &'a str,
645    /// Signing algorithm.
646    pub algorithm: SignatureAlgorithm,
647    /// The raw signed message (the same bytes passed to sign).
648    pub message: &'a [u8],
649    /// Signature bytes.
650    pub signature: &'a [u8],
651    /// Catalog-declared AEAD key that wraps a software-custodied private seed;
652    /// the custody record must agree. Required by the local-software provider;
653    /// ignored by backend-native providers.
654    pub storage_key: Option<&'a str>,
655}
656
657/// Encapsulate request.
658pub struct EncapsulateRequest<'a> {
659    /// Catalog key id.
660    pub key_id: &'a str,
661    /// Backend-native locator.
662    pub backend_path: &'a str,
663    /// KEM algorithm.
664    pub algorithm: KemAlgorithm,
665    /// Catalog-declared AEAD key that wraps a software-custodied private seed;
666    /// the custody record must agree. Required by the local-software provider;
667    /// ignored by backend-native providers.
668    pub storage_key: Option<&'a str>,
669}
670
671/// Decapsulate request.
672pub struct DecapsulateRequest<'a> {
673    /// Catalog key id.
674    pub key_id: &'a str,
675    /// Backend-native locator.
676    pub backend_path: &'a str,
677    /// KEM algorithm.
678    pub algorithm: KemAlgorithm,
679    /// Encapsulated shared-secret material.
680    pub encapsulated_key: &'a [u8],
681    /// Catalog-declared AEAD key that wraps a software-custodied private seed;
682    /// the custody record must agree. Required by the local-software provider;
683    /// ignored by backend-native providers.
684    pub storage_key: Option<&'a str>,
685}
686
687/// KEM ciphertext and shared-secret output.
688pub struct Encapsulation {
689    /// Encapsulated key bytes safe to return to a caller.
690    pub encapsulated_key: Vec<u8>,
691    /// Shared secret retained for immediate envelope encryption.
692    pub shared_secret: Zeroizing<Vec<u8>>,
693}
694
695/// KEM/envelope wrap request.
696pub struct WrapEnvelopeRequest<'a> {
697    /// Catalog key id.
698    pub key_id: &'a str,
699    /// Backend-native locator.
700    pub backend_path: &'a str,
701    /// KEM algorithm.
702    pub kem_algorithm: KemAlgorithm,
703    /// Envelope AEAD algorithm.
704    pub envelope_algorithm: EnvelopeAlgorithm,
705    /// Plaintext to wrap.
706    pub plaintext: &'a [u8],
707    /// Optional associated data.
708    pub aad: Option<&'a [u8]>,
709    /// Catalog-declared AEAD key that wraps a software-custodied private seed;
710    /// the custody record must agree. Required by the local-software provider;
711    /// ignored by backend-native providers.
712    pub storage_key: Option<&'a str>,
713}
714
715/// KEM/envelope unwrap request.
716pub struct UnwrapEnvelopeRequest<'a> {
717    /// Catalog key id.
718    pub key_id: &'a str,
719    /// Backend-native locator.
720    pub backend_path: &'a str,
721    /// KEM algorithm.
722    pub kem_algorithm: KemAlgorithm,
723    /// Envelope AEAD algorithm.
724    pub envelope_algorithm: EnvelopeAlgorithm,
725    /// Encapsulated key bytes.
726    pub encapsulated_key: &'a [u8],
727    /// Nonce bytes.
728    pub nonce: &'a [u8],
729    /// Ciphertext bytes.
730    pub ciphertext: &'a [u8],
731    /// Optional associated data.
732    pub aad: Option<&'a [u8]>,
733    /// Catalog-declared AEAD key that wraps a software-custodied private seed;
734    /// the custody record must agree. Required by the local-software provider;
735    /// ignored by backend-native providers.
736    pub storage_key: Option<&'a str>,
737}
738
739/// KEM/envelope output.
740#[derive(Debug, Clone, PartialEq, Eq)]
741pub struct Envelope {
742    /// KEM algorithm.
743    pub kem_algorithm: KemAlgorithm,
744    /// Envelope AEAD algorithm.
745    pub envelope_algorithm: EnvelopeAlgorithm,
746    /// Key version used by the provider.
747    pub key_version: u32,
748    /// Encapsulated key bytes.
749    pub encapsulated_key: Vec<u8>,
750    /// Broker/provider-owned nonce bytes.
751    pub nonce: Vec<u8>,
752    /// Ciphertext bytes.
753    pub ciphertext: Vec<u8>,
754}
755
756/// Provider-neutral crypto operations.
757#[async_trait]
758pub trait CryptoProvider: Send + Sync {
759    /// Stable provider identifier.
760    fn provider_id(&self) -> CryptoProviderId;
761
762    /// Generate a new provider-native signing key.
763    async fn generate_key(&self, request: GenerateKey<'_>) -> Result<NewKey, ProviderError>;
764
765    /// Generate a new provider-native KEM **sealing** (recipient) key.
766    async fn generate_sealing_key(
767        &self,
768        request: GenerateSealingKey<'_>,
769    ) -> Result<NewKey, ProviderError>;
770
771    /// Import caller-provided key material.
772    async fn import_key(&self, request: ImportKey<'_>) -> Result<NewKey, ProviderError>;
773
774    /// Sign in place.
775    async fn sign(&self, request: SignRequest<'_>) -> Result<Vec<u8>, ProviderError>;
776
777    /// Verify with provider-held public material.
778    async fn verify(&self, request: VerifyRequest<'_>) -> Result<bool, ProviderError>;
779
780    /// KEM encapsulation.
781    async fn encapsulate(
782        &self,
783        request: EncapsulateRequest<'_>,
784    ) -> Result<Encapsulation, ProviderError>;
785
786    /// KEM decapsulation.
787    async fn decapsulate(
788        &self,
789        request: DecapsulateRequest<'_>,
790    ) -> Result<Zeroizing<Vec<u8>>, ProviderError>;
791
792    /// KEM plus envelope encryption.
793    async fn wrap_envelope(
794        &self,
795        request: WrapEnvelopeRequest<'_>,
796    ) -> Result<Envelope, ProviderError>;
797
798    /// KEM plus envelope decryption.
799    async fn unwrap_envelope(
800        &self,
801        request: UnwrapEnvelopeRequest<'_>,
802    ) -> Result<Vec<u8>, ProviderError>;
803}
804
805/// Adapter exposing an existing backend as the backend-native crypto provider.
806pub struct BackendCryptoProvider<'a> {
807    backend: &'a dyn Backend,
808}
809
810impl<'a> BackendCryptoProvider<'a> {
811    /// Build a provider adapter over an existing backend.
812    #[must_use]
813    pub const fn new(backend: &'a dyn Backend) -> Self {
814        Self { backend }
815    }
816}
817
818#[async_trait]
819impl CryptoProvider for BackendCryptoProvider<'_> {
820    fn provider_id(&self) -> CryptoProviderId {
821        CryptoProviderId::VaultTransit
822    }
823
824    async fn generate_key(&self, request: GenerateKey<'_>) -> Result<NewKey, ProviderError> {
825        // ML-DSA provisions through the backend's native PQC seam; the seed is
826        // generated and custodied in place and only the public half returns.
827        if let Some(native) = request.algorithm.native_algorithm() {
828            return self
829                .backend
830                .create_named_pqc_key(request.backend_path, native)
831                .await
832                .map_err(ProviderError::Backend);
833        }
834        let key_type = key_type_for_signature(request.algorithm).ok_or_else(|| {
835            unsupported(
836                self.provider_id(),
837                "generate_key",
838                signature_algorithm_name(request.algorithm),
839            )
840        })?;
841        self.backend
842            .create_named_key(request.backend_path, key_type)
843            .await
844            .map_err(ProviderError::Backend)
845    }
846
847    async fn generate_sealing_key(
848        &self,
849        request: GenerateSealingKey<'_>,
850    ) -> Result<NewKey, ProviderError> {
851        // No backend natively generates an ML-KEM recipient key; sealing keys are
852        // software-custodied. Fail closed.
853        Err(unsupported(
854            self.provider_id(),
855            "generate_sealing_key",
856            kem_algorithm_name(request.algorithm),
857        ))
858    }
859
860    async fn import_key(&self, request: ImportKey<'_>) -> Result<NewKey, ProviderError> {
861        let key_type = key_type_for_signature(request.algorithm).ok_or_else(|| {
862            unsupported(
863                self.provider_id(),
864                "import_key",
865                signature_algorithm_name(request.algorithm),
866            )
867        })?;
868        self.backend
869            .import(request.backend_path, key_type, request.material)
870            .await
871            .map_err(ProviderError::Backend)
872    }
873
874    async fn sign(&self, request: SignRequest<'_>) -> Result<Vec<u8>, ProviderError> {
875        // ML-DSA signs in place through the backend's native PQC seam.
876        if let Some(native) = request.algorithm.native_algorithm() {
877            return self
878                .backend
879                .sign_pqc(request.backend_path, request.message, native)
880                .await
881                .map_err(ProviderError::Backend);
882        }
883        let options = sign_options(request.algorithm).ok_or_else(|| {
884            unsupported(
885                self.provider_id(),
886                "sign",
887                signature_algorithm_name(request.algorithm),
888            )
889        })?;
890        self.backend
891            .sign_with_options(request.backend_path, request.message, options)
892            .await
893            .map_err(ProviderError::Backend)
894    }
895
896    async fn verify(&self, request: VerifyRequest<'_>) -> Result<bool, ProviderError> {
897        // ML-DSA verifies through the backend's native PQC seam.
898        if let Some(native) = request.algorithm.native_algorithm() {
899            return self
900                .backend
901                .verify_pqc(
902                    request.backend_path,
903                    request.message,
904                    request.signature,
905                    native,
906                )
907                .await
908                .map_err(ProviderError::Backend);
909        }
910        let options = sign_options(request.algorithm).ok_or_else(|| {
911            unsupported(
912                self.provider_id(),
913                "verify",
914                signature_algorithm_name(request.algorithm),
915            )
916        })?;
917        self.backend
918            .verify_with_options(
919                request.backend_path,
920                request.message,
921                request.signature,
922                options,
923            )
924            .await
925            .map_err(ProviderError::Backend)
926    }
927
928    async fn encapsulate(
929        &self,
930        request: EncapsulateRequest<'_>,
931    ) -> Result<Encapsulation, ProviderError> {
932        Err(unsupported(
933            self.provider_id(),
934            "encapsulate",
935            kem_algorithm_name(request.algorithm),
936        ))
937    }
938
939    async fn decapsulate(
940        &self,
941        request: DecapsulateRequest<'_>,
942    ) -> Result<Zeroizing<Vec<u8>>, ProviderError> {
943        Err(unsupported(
944            self.provider_id(),
945            "decapsulate",
946            kem_algorithm_name(request.algorithm),
947        ))
948    }
949
950    async fn wrap_envelope(
951        &self,
952        request: WrapEnvelopeRequest<'_>,
953    ) -> Result<Envelope, ProviderError> {
954        Err(unsupported(
955            self.provider_id(),
956            "wrap_envelope",
957            kem_algorithm_name(request.kem_algorithm),
958        ))
959    }
960
961    async fn unwrap_envelope(
962        &self,
963        request: UnwrapEnvelopeRequest<'_>,
964    ) -> Result<Vec<u8>, ProviderError> {
965        Err(unsupported(
966            self.provider_id(),
967            "unwrap_envelope",
968            kem_algorithm_name(request.kem_algorithm),
969        ))
970    }
971}
972
973/// Basil's local-software PQC provider: ML-DSA signing/verification and ML-KEM
974/// encapsulation/decapsulation/envelope over software-custodied keys.
975///
976/// Private keys are custodied as encrypted [`SoftwareCustodyKeyRecord`]s in the
977/// backend KV store. For each private-key operation the provider reads the
978/// record, validates its non-secret metadata, AEAD-decrypts the seed into a
979/// [`Zeroizing`] buffer, runs the PQC math, and drops the seed: it never holds
980/// standing private material. ML-DSA math lives in [`ml_dsa_sign`]; ML-KEM math
981/// and the envelope sealing live in [`ml_kem_envelope`].
982///
983/// Provisioning ([`Self::generate_key`] for ML-DSA, [`Self::generate_sealing_key`]
984/// for ML-KEM) generates a fresh seed, seals it under the catalog `storage_key`
985/// AEAD key with the custody-binding AAD, and writes the record; the seed never
986/// leaves a [`Zeroizing`] buffer and only the public half is returned. BYOK
987/// [`Self::import_key`] remains unsupported (custody records are broker-sealed).
988pub struct LocalSoftwareProvider<'a> {
989    backend: &'a dyn Backend,
990    provider_version: &'static str,
991}
992impl<'a> LocalSoftwareProvider<'a> {
993    /// Stable provider-implementation version token. It is recorded in the
994    /// `crypto_provider_version` catalog label and bound into the custody-record
995    /// AAD, so a record provisioned for a different version fails closed.
996    pub const PROVIDER_VERSION: &'static str = "1";
997
998    /// Build a provider over the backend that custodies the encrypted records.
999    #[must_use]
1000    pub const fn new(backend: &'a dyn Backend) -> Self {
1001        Self {
1002            backend,
1003            provider_version: Self::PROVIDER_VERSION,
1004        }
1005    }
1006
1007    /// Read + validate the custody record for one operation.
1008    ///
1009    /// `storage_key` is the catalog-declared AEAD key (the `pqc_storage_key`
1010    /// label): it is the trust anchor the record's self-declared `wrapping_key`
1011    /// is checked against, so it is required on every use path exactly as on
1012    /// the generate path.
1013    async fn material(
1014        &self,
1015        key_id: &str,
1016        backend_path: &str,
1017        storage_key: Option<&str>,
1018        op: &'static str,
1019        algorithm: &'static str,
1020    ) -> Result<LocalSoftwareMaterial, ProviderError> {
1021        let storage_key = storage_key.ok_or_else(|| {
1022            crypto_failed(
1023                CryptoProviderId::LocalSoftware,
1024                op,
1025                algorithm,
1026                "software-custody key is missing its storage AEAD key",
1027            )
1028        })?;
1029        let kv = self.backend.kv_get(backend_path, None).await?;
1030        SoftwareCustodyKeyRecord::parse_for_local_software(
1031            &kv.value,
1032            key_id,
1033            algorithm,
1034            self.provider_version,
1035            kv.version,
1036            storage_key,
1037        )
1038        .map_err(|_| {
1039            crypto_failed(
1040                CryptoProviderId::LocalSoftware,
1041                op,
1042                algorithm,
1043                "malformed software-custody record",
1044            )
1045        })
1046    }
1047
1048    /// Materialize the private seed: AEAD-decrypt it into a [`Zeroizing`] buffer
1049    /// that wipes on drop. Reachable only after [`Self::material`] validated the
1050    /// record's metadata.
1051    async fn materialize_seed(
1052        &self,
1053        material: &LocalSoftwareMaterial,
1054    ) -> Result<Zeroizing<Vec<u8>>, ProviderError> {
1055        let plaintext = self
1056            .backend
1057            .decrypt(
1058                &material.storage_key,
1059                &material.ciphertext,
1060                Some(&material.aad),
1061            )
1062            .await?;
1063        Ok(Zeroizing::new(plaintext))
1064    }
1065
1066    /// Read the published public half (verifying / encapsulation key) from the
1067    /// custody record **without** materializing the private seed: the public-key
1068    /// read path for `get_public_key`. The record's public half was derived from
1069    /// the seed and recorded at provisioning time.
1070    pub(crate) async fn public_key(
1071        &self,
1072        key_id: &str,
1073        backend_path: &str,
1074        storage_key: Option<&str>,
1075        algorithm: &'static str,
1076    ) -> Result<Vec<u8>, ProviderError> {
1077        let material = self
1078            .material(
1079                key_id,
1080                backend_path,
1081                storage_key,
1082                "get_public_key",
1083                algorithm,
1084            )
1085            .await?;
1086        Ok(material.public_key)
1087    }
1088
1089    /// Seal a freshly generated `seed` under the catalog `storage_key` AEAD key
1090    /// (binding the custody AAD), build the [`SoftwareCustodyKeyRecord`], and write
1091    /// it to KV at `backend_path`. Shared by the ML-DSA and ML-KEM generate paths;
1092    /// the seed stays in caller-owned [`Zeroizing`] storage and never leaves.
1093    /// Returns only the non-secret public half.
1094    async fn seal_and_store(
1095        &self,
1096        params: SealParams<'_>,
1097        seed: &[u8],
1098        public_key: Vec<u8>,
1099    ) -> Result<NewKey, ProviderError> {
1100        // The fresh record is the key's first version; the use path validates the
1101        // KV record version equals this on read.
1102        const RECORD_VERSION: u32 = 1;
1103
1104        let custody = SoftwareCustodyCatalog {
1105            key_id: params.key_id,
1106            algorithm: params.algorithm_token,
1107            provider: CryptoProviderId::LocalSoftware.token(),
1108            provider_version: self.provider_version,
1109            custody: CustodyMode::SoftwareEncrypted.token(),
1110            storage_key: params.storage_key,
1111        };
1112        let aad = custody.aad(RECORD_VERSION);
1113        let envelope = self
1114            .backend
1115            .encrypt(
1116                params.storage_key,
1117                AeadAlgorithm::Aes256Gcm,
1118                seed,
1119                Some(&aad),
1120            )
1121            .await?;
1122        let record = SoftwareCustodyKeyRecord {
1123            schema_version: SoftwareCustodyKeyRecord::SCHEMA_VERSION,
1124            key_id: params.key_id.to_string(),
1125            key_version: RECORD_VERSION,
1126            public_key: B64.encode(&public_key),
1127            algorithm: params.algorithm_token.to_string(),
1128            provider: CryptoProviderId::LocalSoftware.token().to_string(),
1129            provider_version: self.provider_version.to_string(),
1130            custody: CustodyMode::SoftwareEncrypted.token().to_string(),
1131            encrypted_private_key: EncryptedPrivateKey {
1132                wrapping_key: params.storage_key.to_string(),
1133                algorithm: aead_token(envelope.alg).to_string(),
1134                key_version: envelope.key_version,
1135                nonce: B64.encode(&envelope.nonce),
1136                ciphertext: B64.encode(&envelope.ciphertext),
1137            },
1138        };
1139        let bytes = serde_json::to_vec(&record).map_err(|_| {
1140            crypto_failed(
1141                CryptoProviderId::LocalSoftware,
1142                params.op,
1143                params.algorithm_token,
1144                "software-custody record serialization failed",
1145            )
1146        })?;
1147        self.backend.kv_put(params.backend_path, &bytes).await?;
1148        Ok(NewKey {
1149            key_id: params.key_id.to_string(),
1150            public_key,
1151        })
1152    }
1153}
1154
1155/// Inputs to [`LocalSoftwareProvider::seal_and_store`] for one software-custody
1156/// provisioning write (grouped to keep the seal path's argument count small).
1157struct SealParams<'a> {
1158    /// Audit/error op token (`generate_key` or `generate_sealing_key`).
1159    op: &'static str,
1160    /// Catalog key id.
1161    key_id: &'a str,
1162    /// KV path the custody record is written to.
1163    backend_path: &'a str,
1164    /// Algorithm token recorded in the custody record and AAD.
1165    algorithm_token: &'static str,
1166    /// Backend AEAD key name that wraps the seed.
1167    storage_key: &'a str,
1168}
1169#[async_trait]
1170impl CryptoProvider for LocalSoftwareProvider<'_> {
1171    fn provider_id(&self) -> CryptoProviderId {
1172        CryptoProviderId::LocalSoftware
1173    }
1174
1175    async fn generate_key(&self, request: GenerateKey<'_>) -> Result<NewKey, ProviderError> {
1176        use rand::RngCore as _;
1177        use rand::rngs::OsRng;
1178
1179        let algorithm = ml_dsa_algorithm(request.algorithm).ok_or_else(|| {
1180            unsupported(
1181                CryptoProviderId::LocalSoftware,
1182                "generate_key",
1183                signature_algorithm_name(request.algorithm),
1184            )
1185        })?;
1186        let algorithm_token = algorithm.token();
1187        let storage_key = request.storage_key.ok_or_else(|| {
1188            crypto_failed(
1189                CryptoProviderId::LocalSoftware,
1190                "generate_key",
1191                algorithm_token,
1192                "software-custody key is missing its storage AEAD key",
1193            )
1194        })?;
1195
1196        // Fresh seed = the private key; it stays in a zeroizing buffer that wipes
1197        // on drop and is never returned, logged, or audited.
1198        let mut seed = Zeroizing::new([0u8; ml_dsa_sign::SEED_LEN]);
1199        OsRng.fill_bytes(seed.as_mut_slice());
1200        let public_key =
1201            ml_dsa_sign::public_from_seed(algorithm, seed.as_slice()).map_err(|_| {
1202                crypto_failed(
1203                    CryptoProviderId::LocalSoftware,
1204                    "generate_key",
1205                    algorithm_token,
1206                    "ml-dsa key generation failed",
1207                )
1208            })?;
1209
1210        self.seal_and_store(
1211            SealParams {
1212                op: "generate_key",
1213                key_id: request.key_id,
1214                backend_path: request.backend_path,
1215                algorithm_token,
1216                storage_key,
1217            },
1218            seed.as_slice(),
1219            public_key,
1220        )
1221        .await
1222    }
1223
1224    async fn generate_sealing_key(
1225        &self,
1226        request: GenerateSealingKey<'_>,
1227    ) -> Result<NewKey, ProviderError> {
1228        use rand::RngCore as _;
1229        use rand::rngs::OsRng;
1230
1231        let kem = request.algorithm;
1232        let algorithm_token = kem.token();
1233        let storage_key = request.storage_key.ok_or_else(|| {
1234            crypto_failed(
1235                CryptoProviderId::LocalSoftware,
1236                "generate_sealing_key",
1237                algorithm_token,
1238                "software-custody key is missing its storage AEAD key",
1239            )
1240        })?;
1241
1242        // ML-KEM seeds are 64 bytes (`d ‖ z`, FIPS 203). The seed is the private
1243        // key and stays in a zeroizing buffer; only the derived encapsulation
1244        // (public) key is returned.
1245        let mut seed = Zeroizing::new([0u8; ml_kem_envelope::SEED_LEN]);
1246        OsRng.fill_bytes(seed.as_mut_slice());
1247        let public_key = ml_kem_envelope::public_from_seed(seed.as_slice(), kem_to_core(kem))
1248            .map_err(|_| {
1249                crypto_failed(
1250                    CryptoProviderId::LocalSoftware,
1251                    "generate_sealing_key",
1252                    algorithm_token,
1253                    "ml-kem key generation failed",
1254                )
1255            })?;
1256
1257        self.seal_and_store(
1258            SealParams {
1259                op: "generate_sealing_key",
1260                key_id: request.key_id,
1261                backend_path: request.backend_path,
1262                algorithm_token,
1263                storage_key,
1264            },
1265            seed.as_slice(),
1266            public_key,
1267        )
1268        .await
1269    }
1270
1271    async fn import_key(&self, request: ImportKey<'_>) -> Result<NewKey, ProviderError> {
1272        Err(unsupported(
1273            CryptoProviderId::LocalSoftware,
1274            "import_key",
1275            signature_algorithm_name(request.algorithm),
1276        ))
1277    }
1278
1279    async fn sign(&self, request: SignRequest<'_>) -> Result<Vec<u8>, ProviderError> {
1280        let algorithm = ml_dsa_algorithm(request.algorithm).ok_or_else(|| {
1281            unsupported(
1282                CryptoProviderId::LocalSoftware,
1283                "sign",
1284                signature_algorithm_name(request.algorithm),
1285            )
1286        })?;
1287        let material = self
1288            .material(
1289                request.key_id,
1290                request.backend_path,
1291                request.storage_key,
1292                "sign",
1293                algorithm.token(),
1294            )
1295            .await?;
1296        let seed = self.materialize_seed(&material).await?;
1297        ml_dsa_sign::sign(algorithm, &seed, request.message).map_err(|_| {
1298            crypto_failed(
1299                CryptoProviderId::LocalSoftware,
1300                "sign",
1301                algorithm.token(),
1302                "ml-dsa signing failed",
1303            )
1304        })
1305    }
1306
1307    async fn verify(&self, request: VerifyRequest<'_>) -> Result<bool, ProviderError> {
1308        let algorithm = ml_dsa_algorithm(request.algorithm).ok_or_else(|| {
1309            unsupported(
1310                CryptoProviderId::LocalSoftware,
1311                "verify",
1312                signature_algorithm_name(request.algorithm),
1313            )
1314        })?;
1315        // Verification needs only the published public key. No seed materialized.
1316        let material = self
1317            .material(
1318                request.key_id,
1319                request.backend_path,
1320                request.storage_key,
1321                "verify",
1322                algorithm.token(),
1323            )
1324            .await?;
1325        ml_dsa_sign::verify(
1326            algorithm,
1327            &material.public_key,
1328            request.message,
1329            request.signature,
1330        )
1331        .map_err(|_| {
1332            crypto_failed(
1333                CryptoProviderId::LocalSoftware,
1334                "verify",
1335                algorithm.token(),
1336                "malformed verification input",
1337            )
1338        })
1339    }
1340
1341    async fn encapsulate(
1342        &self,
1343        request: EncapsulateRequest<'_>,
1344    ) -> Result<Encapsulation, ProviderError> {
1345        let material = self
1346            .material(
1347                request.key_id,
1348                request.backend_path,
1349                request.storage_key,
1350                "encapsulate",
1351                kem_algorithm_name(request.algorithm),
1352            )
1353            .await?;
1354        let seed = self.materialize_seed(&material).await?;
1355        let (encapsulated_key, shared_secret) =
1356            ml_kem_envelope::encapsulate(&seed, kem_to_core(request.algorithm)).map_err(|_| {
1357                crypto_failed(
1358                    CryptoProviderId::LocalSoftware,
1359                    "encapsulate",
1360                    kem_algorithm_name(request.algorithm),
1361                    "ml-kem encapsulation failed",
1362                )
1363            })?;
1364        Ok(Encapsulation {
1365            encapsulated_key,
1366            shared_secret,
1367        })
1368    }
1369
1370    async fn decapsulate(
1371        &self,
1372        request: DecapsulateRequest<'_>,
1373    ) -> Result<Zeroizing<Vec<u8>>, ProviderError> {
1374        let material = self
1375            .material(
1376                request.key_id,
1377                request.backend_path,
1378                request.storage_key,
1379                "decapsulate",
1380                kem_algorithm_name(request.algorithm),
1381            )
1382            .await?;
1383        let seed = self.materialize_seed(&material).await?;
1384        ml_kem_envelope::decapsulate(
1385            &seed,
1386            kem_to_core(request.algorithm),
1387            request.encapsulated_key,
1388        )
1389        .map_err(|_| {
1390            crypto_failed(
1391                CryptoProviderId::LocalSoftware,
1392                "decapsulate",
1393                kem_algorithm_name(request.algorithm),
1394                "ml-kem decapsulation failed",
1395            )
1396        })
1397    }
1398
1399    async fn wrap_envelope(
1400        &self,
1401        request: WrapEnvelopeRequest<'_>,
1402    ) -> Result<Envelope, ProviderError> {
1403        let material = self
1404            .material(
1405                request.key_id,
1406                request.backend_path,
1407                request.storage_key,
1408                "wrap_envelope",
1409                kem_algorithm_name(request.kem_algorithm),
1410            )
1411            .await?;
1412        let seed = self.materialize_seed(&material).await?;
1413        let sealed = ml_kem_envelope::seal(
1414            &seed,
1415            kem_to_core(request.kem_algorithm),
1416            envelope_to_core(request.envelope_algorithm),
1417            request.plaintext,
1418            request.aad.unwrap_or_default(),
1419        )
1420        .map_err(|_| {
1421            crypto_failed(
1422                CryptoProviderId::LocalSoftware,
1423                "wrap_envelope",
1424                kem_algorithm_name(request.kem_algorithm),
1425                "ml-kem envelope seal failed",
1426            )
1427        })?;
1428        Ok(Envelope {
1429            kem_algorithm: request.kem_algorithm,
1430            envelope_algorithm: request.envelope_algorithm,
1431            key_version: material.key_version,
1432            encapsulated_key: sealed.encapsulated_key,
1433            nonce: sealed.nonce.to_vec(),
1434            ciphertext: sealed.ciphertext,
1435        })
1436    }
1437
1438    async fn unwrap_envelope(
1439        &self,
1440        request: UnwrapEnvelopeRequest<'_>,
1441    ) -> Result<Vec<u8>, ProviderError> {
1442        let material = self
1443            .material(
1444                request.key_id,
1445                request.backend_path,
1446                request.storage_key,
1447                "unwrap_envelope",
1448                kem_algorithm_name(request.kem_algorithm),
1449            )
1450            .await?;
1451        let seed = self.materialize_seed(&material).await?;
1452        let envelope = ml_kem_envelope::envelope_from_parts(
1453            kem_to_core(request.kem_algorithm),
1454            envelope_to_core(request.envelope_algorithm),
1455            request.encapsulated_key,
1456            request.nonce,
1457            request.ciphertext,
1458        )
1459        .map_err(|_| {
1460            crypto_failed(
1461                CryptoProviderId::LocalSoftware,
1462                "unwrap_envelope",
1463                kem_algorithm_name(request.kem_algorithm),
1464                "malformed ml-kem envelope",
1465            )
1466        })?;
1467        let plaintext = ml_kem_envelope::open(&seed, &envelope, request.aad.unwrap_or_default())
1468            .map_err(|_| {
1469                crypto_failed(
1470                    CryptoProviderId::LocalSoftware,
1471                    "unwrap_envelope",
1472                    kem_algorithm_name(request.kem_algorithm),
1473                    "ml-kem envelope open failed",
1474                )
1475            })?;
1476        Ok(plaintext.to_vec())
1477    }
1478}
1479const fn ml_dsa_algorithm(algorithm: SignatureAlgorithm) -> Option<ml_dsa_sign::MlDsaAlgorithm> {
1480    match algorithm {
1481        SignatureAlgorithm::MlDsa44 => Some(ml_dsa_sign::MlDsaAlgorithm::MlDsa44),
1482        SignatureAlgorithm::MlDsa65 => Some(ml_dsa_sign::MlDsaAlgorithm::MlDsa65),
1483        SignatureAlgorithm::MlDsa87 => Some(ml_dsa_sign::MlDsaAlgorithm::MlDsa87),
1484        SignatureAlgorithm::Ed25519
1485        | SignatureAlgorithm::Ed25519Nkey
1486        | SignatureAlgorithm::Rs256
1487        | SignatureAlgorithm::Es256 => None,
1488    }
1489}
1490const fn kem_to_core(algorithm: KemAlgorithm) -> ml_kem_envelope::KemAlgorithm {
1491    match algorithm {
1492        KemAlgorithm::MlKem512 => ml_kem_envelope::KemAlgorithm::MlKem512,
1493        KemAlgorithm::MlKem768 => ml_kem_envelope::KemAlgorithm::MlKem768,
1494        KemAlgorithm::MlKem1024 => ml_kem_envelope::KemAlgorithm::MlKem1024,
1495    }
1496}
1497const fn envelope_to_core(algorithm: EnvelopeAlgorithm) -> ml_kem_envelope::EnvelopeAlgorithm {
1498    match algorithm {
1499        EnvelopeAlgorithm::Aes256Gcm => ml_kem_envelope::EnvelopeAlgorithm::Aes256Gcm,
1500        EnvelopeAlgorithm::ChaCha20Poly1305 => ml_kem_envelope::EnvelopeAlgorithm::ChaCha20Poly1305,
1501    }
1502}
1503
1504/// Select a provider from catalog metadata and backend capability.
1505///
1506/// `backend_native_supported` is the caller's capability probe for the requested
1507/// algorithm and operation.
1508pub fn select_provider(
1509    metadata: ProviderMetadata,
1510    backend_native_supported: bool,
1511    local_software_allowed: bool,
1512    op: &'static str,
1513) -> Result<CryptoProviderId, ProviderError> {
1514    match metadata.policy {
1515        ProviderPolicy::BackendRequired => select_backend_required(backend_native_supported, op),
1516        ProviderPolicy::BackendPreferred => select_backend_preferred(
1517            metadata.custody,
1518            backend_native_supported,
1519            local_software_allowed,
1520            op,
1521        ),
1522        ProviderPolicy::LocalSoftware => {
1523            select_local_software(metadata.custody, local_software_allowed, op)
1524        }
1525    }
1526}
1527
1528const fn select_backend_required(
1529    backend_native_supported: bool,
1530    op: &'static str,
1531) -> Result<CryptoProviderId, ProviderError> {
1532    if backend_native_supported {
1533        return Ok(CryptoProviderId::VaultTransit);
1534    }
1535    Err(unsupported(
1536        CryptoProviderId::VaultTransit,
1537        op,
1538        "backend-native",
1539    ))
1540}
1541
1542fn select_backend_preferred(
1543    custody: Option<CustodyMode>,
1544    backend_native_supported: bool,
1545    local_software_allowed: bool,
1546    op: &'static str,
1547) -> Result<CryptoProviderId, ProviderError> {
1548    // A key already provisioned under software custody stays software-custodied:
1549    // its private seed lives software-encrypted in KV, so a capability probe that
1550    // newly reports native backend support must NOT re-route the key to the
1551    // backend (which holds no material for it). Migrating a software key to
1552    // backend-native is an explicit re-key (`basil-wuj.10`), never an implicit
1553    // side effect of the probe flipping. This pin also honors an operator who
1554    // explicitly declared software custody on a `backend-preferred` key.
1555    if custody == Some(CustodyMode::SoftwareEncrypted) {
1556        require_local_software_allowed(local_software_allowed, op)?;
1557        return Ok(CryptoProviderId::LocalSoftware);
1558    }
1559    if backend_native_supported {
1560        return Ok(CryptoProviderId::VaultTransit);
1561    }
1562    require_local_software_allowed(local_software_allowed, op)?;
1563    require_software_custody(
1564        custody,
1565        op,
1566        "local software fallback requires software-encrypted custody",
1567    )?;
1568    Ok(CryptoProviderId::LocalSoftware)
1569}
1570
1571fn select_local_software(
1572    custody: Option<CustodyMode>,
1573    local_software_allowed: bool,
1574    op: &'static str,
1575) -> Result<CryptoProviderId, ProviderError> {
1576    require_local_software_allowed(local_software_allowed, op)?;
1577    require_software_custody(
1578        custody,
1579        op,
1580        "local software provider requires software-encrypted custody",
1581    )?;
1582    Ok(CryptoProviderId::LocalSoftware)
1583}
1584
1585const fn require_local_software_allowed(
1586    local_software_allowed: bool,
1587    op: &'static str,
1588) -> Result<(), ProviderError> {
1589    if local_software_allowed {
1590        return Ok(());
1591    }
1592    Err(ProviderError::PolicyDenied {
1593        op,
1594        reason: "local software custody requires caller policy grant",
1595    })
1596}
1597
1598fn require_software_custody(
1599    custody: Option<CustodyMode>,
1600    op: &'static str,
1601    reason: &'static str,
1602) -> Result<(), ProviderError> {
1603    if custody == Some(CustodyMode::SoftwareEncrypted) {
1604        return Ok(());
1605    }
1606    Err(ProviderError::PolicyDenied { op, reason })
1607}
1608
1609const fn key_type_for_signature(algorithm: SignatureAlgorithm) -> Option<KeyType> {
1610    match algorithm {
1611        SignatureAlgorithm::Ed25519 => Some(KeyType::Ed25519),
1612        SignatureAlgorithm::Ed25519Nkey => Some(KeyType::Ed25519Nkey),
1613        SignatureAlgorithm::Rs256 => Some(KeyType::Rsa2048),
1614        SignatureAlgorithm::Es256 => Some(KeyType::EcdsaP256),
1615        SignatureAlgorithm::MlDsa44 | SignatureAlgorithm::MlDsa65 | SignatureAlgorithm::MlDsa87 => {
1616            None
1617        }
1618    }
1619}
1620
1621const fn sign_options(algorithm: SignatureAlgorithm) -> Option<SignOptions> {
1622    match algorithm {
1623        SignatureAlgorithm::Ed25519 | SignatureAlgorithm::Ed25519Nkey => Some(SignOptions::Default),
1624        SignatureAlgorithm::Rs256 => Some(SignOptions::Rs256Pkcs1v15Sha256),
1625        SignatureAlgorithm::Es256 => Some(SignOptions::Es256),
1626        SignatureAlgorithm::MlDsa44 | SignatureAlgorithm::MlDsa65 | SignatureAlgorithm::MlDsa87 => {
1627            None
1628        }
1629    }
1630}
1631
1632const fn signature_algorithm_name(algorithm: SignatureAlgorithm) -> &'static str {
1633    match algorithm {
1634        SignatureAlgorithm::Ed25519 => "ed25519",
1635        SignatureAlgorithm::Ed25519Nkey => "ed25519-nkey",
1636        SignatureAlgorithm::Rs256 => "rs256",
1637        SignatureAlgorithm::Es256 => "es256",
1638        SignatureAlgorithm::MlDsa44 => "ml-dsa-44",
1639        SignatureAlgorithm::MlDsa65 => "ml-dsa-65",
1640        SignatureAlgorithm::MlDsa87 => "ml-dsa-87",
1641    }
1642}
1643
1644const fn kem_algorithm_name(algorithm: KemAlgorithm) -> &'static str {
1645    algorithm.token()
1646}
1647
1648const fn unsupported(
1649    provider: CryptoProviderId,
1650    op: &'static str,
1651    algorithm: &'static str,
1652) -> ProviderError {
1653    ProviderError::Unsupported {
1654        provider,
1655        op,
1656        algorithm,
1657    }
1658}
1659const fn crypto_failed(
1660    provider: CryptoProviderId,
1661    op: &'static str,
1662    algorithm: &'static str,
1663    reason: &'static str,
1664) -> ProviderError {
1665    ProviderError::CryptoFailed {
1666        provider,
1667        op,
1668        algorithm,
1669        reason,
1670    }
1671}
1672
1673/// The storage-AEAD token written into a software-custody record. The inverse of
1674/// [`parse_storage_aead`], so a generated record round-trips on read.
1675const fn aead_token(alg: AeadAlgorithm) -> &'static str {
1676    match alg {
1677        AeadAlgorithm::Aes256Gcm => "aes-256-gcm",
1678        AeadAlgorithm::Chacha20Poly1305 => "chacha20-poly1305",
1679    }
1680}
1681
1682#[cfg(test)]
1683mod tests {
1684    use async_trait::async_trait;
1685
1686    use super::*;
1687    use crate::backend::{BackendError, NewKey};
1688    use crate::catalog::schema::Labels;
1689
1690    struct RecordingBackend;
1691
1692    #[async_trait]
1693    impl Backend for RecordingBackend {
1694        fn kind(&self) -> &'static str {
1695            "recording"
1696        }
1697
1698        async fn new_key(&self, key_type: KeyType) -> Result<NewKey, BackendError> {
1699            let _ = key_type;
1700            Err(BackendError::Unsupported("new_key"))
1701        }
1702
1703        async fn create_named_key(
1704            &self,
1705            key_id: &str,
1706            key_type: KeyType,
1707        ) -> Result<NewKey, BackendError> {
1708            Ok(NewKey {
1709                key_id: format!("{key_id}:{key_type}"),
1710                public_key: vec![1, 2, 3],
1711            })
1712        }
1713
1714        async fn public_key(&self, key_id: &str) -> Result<Vec<u8>, BackendError> {
1715            let _ = key_id;
1716            Ok(vec![1, 2, 3])
1717        }
1718
1719        async fn sign(&self, key_id: &str, message: &[u8]) -> Result<Vec<u8>, BackendError> {
1720            Ok([key_id.as_bytes(), message].concat())
1721        }
1722
1723        async fn sign_with_options(
1724            &self,
1725            key_id: &str,
1726            message: &[u8],
1727            options: SignOptions,
1728        ) -> Result<Vec<u8>, BackendError> {
1729            let mut out = self.sign(key_id, message).await?;
1730            // ubs false positive: options is not secret material
1731            /* ubs:ignore */
1732            if options == SignOptions::Rs256Pkcs1v15Sha256 {
1733                out.extend_from_slice(b":rs256");
1734            } else if options == SignOptions::Es256 {
1735                out.extend_from_slice(b":es256");
1736            }
1737            Ok(out)
1738        }
1739
1740        async fn verify(
1741            &self,
1742            key_id: &str,
1743            message: &[u8],
1744            signature: &[u8],
1745        ) -> Result<bool, BackendError> {
1746            // ubs false positive: test, unrealistic comparison
1747            /* ubs:ignore */
1748            Ok(signature == [key_id.as_bytes(), message].concat())
1749        }
1750    }
1751
1752    fn key_entry_with_labels(labels: &[&str]) -> KeyEntry {
1753        KeyEntry {
1754            class: crate::catalog::schema::Class::Asymmetric,
1755            key_type: Some(crate::catalog::schema::KeyAlgorithm::Ed25519),
1756            backend: "bao".to_string(),
1757            engine: None,
1758            path: "issuer".to_string(),
1759            public_path: None,
1760            writable: true,
1761            missing: crate::catalog::schema::MissingPolicy::Error,
1762            generate: None,
1763            sealing_pin: None,
1764            labels: Labels(labels.iter().map(ToString::to_string).collect()),
1765            description: "issuer".to_string(),
1766        }
1767    }
1768
1769    #[test]
1770    fn provider_metadata_defaults_to_backend_required() {
1771        let entry = key_entry_with_labels(&[]);
1772        assert_eq!(
1773            ProviderMetadata::from_key(&entry),
1774            ProviderMetadata {
1775                provider: None,
1776                policy: ProviderPolicy::BackendRequired,
1777                custody: None
1778            }
1779        );
1780    }
1781
1782    #[test]
1783    fn provider_metadata_parses_reserved_labels() {
1784        let entry = key_entry_with_labels(&[
1785            "crypto_provider=local-software",
1786            "crypto_provider_policy=backend-preferred",
1787            "pqc_custody=software-encrypted",
1788        ]);
1789        assert_eq!(
1790            ProviderMetadata::from_key(&entry),
1791            ProviderMetadata {
1792                provider: Some(CryptoProviderId::LocalSoftware),
1793                policy: ProviderPolicy::BackendPreferred,
1794                custody: Some(CustodyMode::SoftwareEncrypted)
1795            }
1796        );
1797    }
1798
1799    #[test]
1800    fn provider_selection_honors_backend_required() {
1801        let selected = select_provider(
1802            ProviderMetadata {
1803                provider: None,
1804                policy: ProviderPolicy::BackendRequired,
1805                custody: None,
1806            },
1807            true,
1808            false,
1809            "sign",
1810        )
1811        .expect("backend-native selected");
1812        assert_eq!(selected, CryptoProviderId::VaultTransit);
1813
1814        assert!(matches!(
1815            select_provider(
1816                ProviderMetadata {
1817                    provider: None,
1818                    policy: ProviderPolicy::BackendRequired,
1819                    custody: None,
1820                },
1821                false,
1822                false,
1823                "sign",
1824            ),
1825            Err(ProviderError::Unsupported { .. })
1826        ));
1827    }
1828
1829    #[test]
1830    fn provider_selection_honors_local_software_custody_gate() {
1831        let denied = select_provider(
1832            ProviderMetadata {
1833                provider: None,
1834                policy: ProviderPolicy::BackendPreferred,
1835                custody: Some(CustodyMode::BackendNative),
1836            },
1837            false,
1838            true,
1839            "sign",
1840        )
1841        .expect_err("backend fallback needs software custody");
1842        assert!(matches!(denied, ProviderError::PolicyDenied { .. }));
1843
1844        let denied = select_provider(
1845            ProviderMetadata {
1846                provider: None,
1847                policy: ProviderPolicy::BackendPreferred,
1848                custody: Some(CustodyMode::SoftwareEncrypted),
1849            },
1850            false,
1851            false,
1852            "sign",
1853        )
1854        .expect_err("local software needs caller policy");
1855        assert!(matches!(
1856            denied,
1857            ProviderError::PolicyDenied {
1858                reason: "local software custody requires caller policy grant",
1859                ..
1860            }
1861        ));
1862
1863        let selected = select_provider(
1864            ProviderMetadata {
1865                provider: None,
1866                policy: ProviderPolicy::BackendPreferred,
1867                custody: Some(CustodyMode::SoftwareEncrypted),
1868            },
1869            false,
1870            true,
1871            "sign",
1872        )
1873        .expect("local fallback selected");
1874        assert_eq!(selected, CryptoProviderId::LocalSoftware);
1875    }
1876
1877    #[test]
1878    fn backend_preferred_software_custody_pins_despite_native_support() {
1879        // The migration invariant (`basil-wuj.10`): a backend-preferred key already
1880        // under software custody stays on the local-software provider even when the
1881        // backend NOW reports native support: the probe must not silently re-route
1882        // an already-provisioned software key to the backend.
1883        let pinned = select_provider(
1884            ProviderMetadata {
1885                provider: None,
1886                policy: ProviderPolicy::BackendPreferred,
1887                custody: Some(CustodyMode::SoftwareEncrypted),
1888            },
1889            true,
1890            true,
1891            "sign",
1892        )
1893        .expect("software-custodied key stays local-software");
1894        assert_eq!(pinned, CryptoProviderId::LocalSoftware);
1895
1896        // A backend-preferred key with NO recorded software custody DOES take the
1897        // backend natively when the probe reports support (the new-key path).
1898        let native = select_provider(
1899            ProviderMetadata {
1900                provider: None,
1901                policy: ProviderPolicy::BackendPreferred,
1902                custody: None,
1903            },
1904            true,
1905            true,
1906            "sign",
1907        )
1908        .expect("no-custody preferred key routes to native");
1909        assert_eq!(native, CryptoProviderId::VaultTransit);
1910
1911        // The pin still honors the caller policy grant: a software-custodied key
1912        // with native support but no grant is denied, never routed to the backend.
1913        let denied = select_provider(
1914            ProviderMetadata {
1915                provider: None,
1916                policy: ProviderPolicy::BackendPreferred,
1917                custody: Some(CustodyMode::SoftwareEncrypted),
1918            },
1919            true,
1920            false,
1921            "sign",
1922        )
1923        .expect_err("local software still needs the caller grant");
1924        assert!(matches!(denied, ProviderError::PolicyDenied { .. }));
1925    }
1926
1927    #[test]
1928    fn provider_selection_honors_local_software_policy_mode() {
1929        let selected = select_provider(
1930            ProviderMetadata {
1931                provider: Some(CryptoProviderId::LocalSoftware),
1932                policy: ProviderPolicy::LocalSoftware,
1933                custody: Some(CustodyMode::SoftwareEncrypted),
1934            },
1935            true,
1936            true,
1937            "decapsulate",
1938        )
1939        .expect("explicit local software selected");
1940        assert_eq!(selected, CryptoProviderId::LocalSoftware);
1941
1942        let denied = select_provider(
1943            ProviderMetadata {
1944                provider: Some(CryptoProviderId::LocalSoftware),
1945                policy: ProviderPolicy::LocalSoftware,
1946                custody: Some(CustodyMode::SoftwareEncrypted),
1947            },
1948            true,
1949            false,
1950            "decapsulate",
1951        )
1952        .expect_err("caller grant required");
1953        assert!(matches!(
1954            denied,
1955            ProviderError::PolicyDenied {
1956                reason: "local software custody requires caller policy grant",
1957                ..
1958            }
1959        ));
1960    }
1961
1962    #[test]
1963    fn provider_audit_event_is_secret_free() {
1964        let event = ProviderAuditEvent {
1965            op: "decapsulate",
1966            key_id: "pqc.kem",
1967            key_version: Some(7),
1968            algorithm: "ml-kem-768",
1969            provider: CryptoProviderId::LocalSoftware,
1970            custody: CustodyMode::SoftwareEncrypted,
1971            caller_uid: 9100,
1972            outcome: ProviderAuditOutcome::Success,
1973            reason: "ok",
1974        };
1975        let value = event.to_json_value();
1976        assert_eq!(value["event"]["kind"], "basil.audit.provider_operation");
1977        assert_eq!(value["event"]["version"], 1);
1978        assert_eq!(value["actor"]["kind"], "unix_uid");
1979        assert_eq!(value["actor"]["id"], "9100");
1980        assert_eq!(value["target"]["kind"], "catalog_key");
1981        assert_eq!(value["target"]["id"], "pqc.kem");
1982        assert_eq!(value["target"]["version"], 7);
1983        assert!(value["occurred_at"].as_str().is_some());
1984        assert_eq!(value["op"], "decapsulate");
1985        assert_eq!(value["key_version"], 7);
1986        assert_eq!(value["algorithm"], "ml-kem-768");
1987        assert_eq!(value["provider"], "local-software");
1988        assert_eq!(value["custody"], "software-encrypted");
1989        assert_eq!(value["caller_uid"], 9100);
1990        assert_eq!(value["outcome"], "success");
1991        assert!(value.get("private_key").is_none());
1992        assert!(value.get("plaintext").is_none());
1993        assert!(value.get("ciphertext").is_none());
1994        assert!(value.get("signature").is_none());
1995        assert!(value.get("shared_secret").is_none());
1996    }
1997
1998    #[tokio::test]
1999    async fn backend_provider_delegates_legacy_signing() {
2000        let backend = RecordingBackend;
2001        let provider = BackendCryptoProvider::new(&backend);
2002        let signature = provider
2003            .sign(SignRequest {
2004                key_id: "catalog.issuer",
2005                backend_path: "issuer",
2006                algorithm: SignatureAlgorithm::Rs256,
2007                message: b"digest",
2008                storage_key: None,
2009            })
2010            .await
2011            .expect("signs");
2012        assert_eq!(signature, b"issuerdigest:rs256");
2013    }
2014
2015    #[tokio::test]
2016    async fn backend_provider_rejects_pqc_without_native_support() {
2017        // ML-DSA now dispatches to the backend's native PQC seam. A backend that
2018        // does not implement it (the default `sign_pqc`) fails closed with a
2019        // backend `Unsupported`, so a `backend-required` ML-DSA key over a
2020        // non-native backend still cannot sign.
2021        let backend = RecordingBackend;
2022        let provider = BackendCryptoProvider::new(&backend);
2023        assert!(!backend.supports_native_algorithm(NativeAlgorithm::MlDsa65));
2024        let err = provider
2025            .sign(SignRequest {
2026                key_id: "catalog.issuer",
2027                backend_path: "issuer",
2028                algorithm: SignatureAlgorithm::MlDsa65,
2029                message: b"digest",
2030                storage_key: None,
2031            })
2032            .await
2033            .expect_err("ML-DSA unsupported");
2034        assert!(matches!(
2035            err,
2036            ProviderError::Backend(BackendError::Unsupported("sign_pqc"))
2037        ));
2038    }
2039}
2040
2041#[cfg(test)]
2042mod pqc_provider_tests {
2043    use std::collections::HashMap;
2044
2045    use async_trait::async_trait;
2046    use basil_proto::{CiphertextEnvelope, KeyType};
2047
2048    use super::*;
2049    use crate::backend::{Backend, BackendError, KvValue, NewKey, SignOptions};
2050    use crate::core::{ml_dsa_sign, ml_kem_envelope};
2051
2052    const STORAGE_KEY: &str = "pqc-storage-aead";
2053    const KEY_ID: &str = "pqc.example";
2054    const PATH: &str = "kv/pqc/example";
2055
2056    /// In-memory backend simulating out-of-band custody provisioning: it serves a
2057    /// pre-written `SoftwareCustodyKeyRecord` from `kv_get` and returns the raw
2058    /// seed from `decrypt` (the test does not exercise the real AEAD).
2059    struct CustodyBackend {
2060        records: HashMap<String, Vec<u8>>,
2061        secrets: HashMap<String, Vec<u8>>,
2062    }
2063
2064    impl CustodyBackend {
2065        fn single(record: Vec<u8>, seed: &[u8]) -> Self {
2066            Self {
2067                records: HashMap::from([(PATH.to_string(), record)]),
2068                secrets: HashMap::from([(STORAGE_KEY.to_string(), seed.to_vec())]),
2069            }
2070        }
2071    }
2072
2073    #[async_trait]
2074    impl Backend for CustodyBackend {
2075        fn kind(&self) -> &'static str {
2076            "custody-test"
2077        }
2078
2079        async fn new_key(&self, _key_type: KeyType) -> Result<NewKey, BackendError> {
2080            Err(BackendError::Unsupported("new_key"))
2081        }
2082
2083        async fn create_named_key(
2084            &self,
2085            _key_id: &str,
2086            _key_type: KeyType,
2087        ) -> Result<NewKey, BackendError> {
2088            Err(BackendError::Unsupported("create_named_key"))
2089        }
2090
2091        async fn public_key(&self, _key_id: &str) -> Result<Vec<u8>, BackendError> {
2092            Err(BackendError::Unsupported("public_key"))
2093        }
2094
2095        async fn sign(&self, _key_id: &str, _message: &[u8]) -> Result<Vec<u8>, BackendError> {
2096            Err(BackendError::Unsupported("sign"))
2097        }
2098
2099        async fn sign_with_options(
2100            &self,
2101            _key_id: &str,
2102            _message: &[u8],
2103            _options: SignOptions,
2104        ) -> Result<Vec<u8>, BackendError> {
2105            Err(BackendError::Unsupported("sign_with_options"))
2106        }
2107
2108        async fn verify(
2109            &self,
2110            _key_id: &str,
2111            _message: &[u8],
2112            _signature: &[u8],
2113        ) -> Result<bool, BackendError> {
2114            Err(BackendError::Unsupported("verify"))
2115        }
2116
2117        async fn kv_get(
2118            &self,
2119            key_id: &str,
2120            _version: Option<u32>,
2121        ) -> Result<KvValue, BackendError> {
2122            let value = self
2123                .records
2124                .get(key_id)
2125                .cloned()
2126                .ok_or(BackendError::Unsupported("kv_get"))?;
2127            Ok(KvValue { value, version: 1 })
2128        }
2129
2130        async fn decrypt(
2131            &self,
2132            key_id: &str,
2133            _envelope: &CiphertextEnvelope,
2134            _aad: Option<&[u8]>,
2135        ) -> Result<Vec<u8>, BackendError> {
2136            self.secrets
2137                .get(key_id)
2138                .cloned()
2139                .ok_or(BackendError::Unsupported("decrypt"))
2140        }
2141    }
2142
2143    /// Build a valid software-custody record JSON for one key.
2144    fn custody_record(algorithm: &str, public_key: &[u8], provider_version: &str) -> Vec<u8> {
2145        let record = SoftwareCustodyKeyRecord {
2146            schema_version: SoftwareCustodyKeyRecord::SCHEMA_VERSION,
2147            key_id: KEY_ID.to_string(),
2148            key_version: 1,
2149            public_key: encode_record_bytes(public_key),
2150            algorithm: algorithm.to_string(),
2151            provider: CryptoProviderId::LocalSoftware.token().to_string(),
2152            provider_version: provider_version.to_string(),
2153            custody: CustodyMode::SoftwareEncrypted.token().to_string(),
2154            encrypted_private_key: EncryptedPrivateKey {
2155                wrapping_key: STORAGE_KEY.to_string(),
2156                algorithm: "aes-256-gcm".to_string(),
2157                key_version: 1,
2158                nonce: encode_record_bytes(&[0u8; 12]),
2159                ciphertext: encode_record_bytes(&[0u8; 16]),
2160            },
2161        };
2162        serde_json::to_vec(&record).expect("serialize record")
2163    }
2164
2165    const DSA_LEVELS: [(SignatureAlgorithm, ml_dsa_sign::MlDsaAlgorithm); 3] = [
2166        (
2167            SignatureAlgorithm::MlDsa44,
2168            ml_dsa_sign::MlDsaAlgorithm::MlDsa44,
2169        ),
2170        (
2171            SignatureAlgorithm::MlDsa65,
2172            ml_dsa_sign::MlDsaAlgorithm::MlDsa65,
2173        ),
2174        (
2175            SignatureAlgorithm::MlDsa87,
2176            ml_dsa_sign::MlDsaAlgorithm::MlDsa87,
2177        ),
2178    ];
2179
2180    const KEM_LEVELS: [KemAlgorithm; 3] = [
2181        KemAlgorithm::MlKem512,
2182        KemAlgorithm::MlKem768,
2183        KemAlgorithm::MlKem1024,
2184    ];
2185
2186    #[tokio::test]
2187    async fn signs_and_verifies_every_ml_dsa_level() {
2188        for (sig_algorithm, dsa_algorithm) in DSA_LEVELS {
2189            let seed = [0x11u8; ml_dsa_sign::SEED_LEN];
2190            let public = ml_dsa_sign::public_from_seed(dsa_algorithm, &seed).expect("public");
2191            let record = custody_record(
2192                dsa_algorithm.token(),
2193                &public,
2194                LocalSoftwareProvider::PROVIDER_VERSION,
2195            );
2196            let backend = CustodyBackend::single(record, &seed);
2197            let provider = LocalSoftwareProvider::new(&backend);
2198
2199            let signature = provider
2200                .sign(SignRequest {
2201                    key_id: KEY_ID,
2202                    backend_path: PATH,
2203                    algorithm: sig_algorithm,
2204                    message: b"basil pqc payload",
2205                    storage_key: Some(STORAGE_KEY),
2206                })
2207                .await
2208                .expect("sign");
2209            assert!(
2210                provider
2211                    .verify(VerifyRequest {
2212                        key_id: KEY_ID,
2213                        backend_path: PATH,
2214                        algorithm: sig_algorithm,
2215                        message: b"basil pqc payload",
2216                        signature: &signature,
2217                        storage_key: Some(STORAGE_KEY),
2218                    })
2219                    .await
2220                    .expect("verify"),
2221                "{} verifies",
2222                dsa_algorithm.token()
2223            );
2224            assert!(
2225                !provider
2226                    .verify(VerifyRequest {
2227                        key_id: KEY_ID,
2228                        backend_path: PATH,
2229                        algorithm: sig_algorithm,
2230                        message: b"tampered payload",
2231                        signature: &signature,
2232                        storage_key: Some(STORAGE_KEY),
2233                    })
2234                    .await
2235                    .expect("verify"),
2236                "{} rejects wrong message",
2237                dsa_algorithm.token()
2238            );
2239        }
2240    }
2241
2242    #[tokio::test]
2243    async fn encapsulate_decapsulate_every_ml_kem_level() {
2244        for kem in KEM_LEVELS {
2245            let seed = [0x42u8; ml_kem_envelope::SEED_LEN];
2246            let record = custody_record(
2247                kem_algorithm_name(kem),
2248                &[7u8; 32],
2249                LocalSoftwareProvider::PROVIDER_VERSION,
2250            );
2251            let backend = CustodyBackend::single(record, &seed);
2252            let provider = LocalSoftwareProvider::new(&backend);
2253
2254            let encapsulation = provider
2255                .encapsulate(EncapsulateRequest {
2256                    key_id: KEY_ID,
2257                    backend_path: PATH,
2258                    algorithm: kem,
2259                    storage_key: Some(STORAGE_KEY),
2260                })
2261                .await
2262                .expect("encapsulate");
2263            let shared = provider
2264                .decapsulate(DecapsulateRequest {
2265                    key_id: KEY_ID,
2266                    backend_path: PATH,
2267                    algorithm: kem,
2268                    encapsulated_key: &encapsulation.encapsulated_key,
2269                    storage_key: Some(STORAGE_KEY),
2270                })
2271                .await
2272                .expect("decapsulate");
2273            assert_eq!(
2274                encapsulation.shared_secret.as_slice(),
2275                shared.as_slice(),
2276                "{} shared secret matches",
2277                kem_algorithm_name(kem)
2278            );
2279        }
2280    }
2281
2282    #[tokio::test]
2283    async fn wrap_unwrap_envelope_every_ml_kem_level() {
2284        for kem in KEM_LEVELS {
2285            for envelope_algorithm in [
2286                EnvelopeAlgorithm::Aes256Gcm,
2287                EnvelopeAlgorithm::ChaCha20Poly1305,
2288            ] {
2289                let seed = [0x42u8; ml_kem_envelope::SEED_LEN];
2290                let record = custody_record(
2291                    kem_algorithm_name(kem),
2292                    &[7u8; 32],
2293                    LocalSoftwareProvider::PROVIDER_VERSION,
2294                );
2295                let backend = CustodyBackend::single(record, &seed);
2296                let provider = LocalSoftwareProvider::new(&backend);
2297
2298                let envelope = provider
2299                    .wrap_envelope(WrapEnvelopeRequest {
2300                        key_id: KEY_ID,
2301                        backend_path: PATH,
2302                        kem_algorithm: kem,
2303                        envelope_algorithm,
2304                        plaintext: b"top secret",
2305                        aad: Some(b"context"),
2306                        storage_key: Some(STORAGE_KEY),
2307                    })
2308                    .await
2309                    .expect("wrap");
2310                assert_eq!(envelope.key_version, 1);
2311                let plaintext = provider
2312                    .unwrap_envelope(UnwrapEnvelopeRequest {
2313                        key_id: KEY_ID,
2314                        backend_path: PATH,
2315                        kem_algorithm: kem,
2316                        envelope_algorithm,
2317                        encapsulated_key: &envelope.encapsulated_key,
2318                        nonce: &envelope.nonce,
2319                        ciphertext: &envelope.ciphertext,
2320                        aad: Some(b"context"),
2321                        storage_key: Some(STORAGE_KEY),
2322                    })
2323                    .await
2324                    .expect("unwrap");
2325                assert_eq!(plaintext, b"top secret");
2326
2327                let wrong_aad = provider
2328                    .unwrap_envelope(UnwrapEnvelopeRequest {
2329                        key_id: KEY_ID,
2330                        backend_path: PATH,
2331                        kem_algorithm: kem,
2332                        envelope_algorithm,
2333                        encapsulated_key: &envelope.encapsulated_key,
2334                        nonce: &envelope.nonce,
2335                        ciphertext: &envelope.ciphertext,
2336                        aad: Some(b"wrong"),
2337                        storage_key: Some(STORAGE_KEY),
2338                    })
2339                    .await
2340                    .expect_err("wrong aad fails");
2341                assert!(matches!(
2342                    wrong_aad,
2343                    ProviderError::CryptoFailed {
2344                        op: "unwrap_envelope",
2345                        ..
2346                    }
2347                ));
2348            }
2349        }
2350    }
2351
2352    #[tokio::test]
2353    async fn rejects_non_pqc_signature_algorithm() {
2354        let backend = CustodyBackend::single(Vec::new(), &[]);
2355        let provider = LocalSoftwareProvider::new(&backend);
2356        let err = provider
2357            .sign(SignRequest {
2358                key_id: KEY_ID,
2359                backend_path: PATH,
2360                algorithm: SignatureAlgorithm::Ed25519,
2361                message: b"m",
2362                storage_key: Some(STORAGE_KEY),
2363            })
2364            .await
2365            .expect_err("non-pqc rejected");
2366        assert!(matches!(
2367            err,
2368            ProviderError::Unsupported {
2369                provider: CryptoProviderId::LocalSoftware,
2370                op: "sign",
2371                algorithm: "ed25519"
2372            }
2373        ));
2374    }
2375
2376    #[tokio::test]
2377    async fn malformed_record_fails_closed() {
2378        let backend = CustodyBackend::single(b"not a record".to_vec(), &[0u8; 32]);
2379        let provider = LocalSoftwareProvider::new(&backend);
2380        let err = provider
2381            .sign(SignRequest {
2382                key_id: KEY_ID,
2383                backend_path: PATH,
2384                algorithm: SignatureAlgorithm::MlDsa44,
2385                message: b"m",
2386                storage_key: Some(STORAGE_KEY),
2387            })
2388            .await
2389            .expect_err("malformed record");
2390        assert!(matches!(
2391            err,
2392            ProviderError::CryptoFailed {
2393                provider: CryptoProviderId::LocalSoftware,
2394                op: "sign",
2395                algorithm: "ml-dsa-44",
2396                reason: "malformed software-custody record"
2397            }
2398        ));
2399    }
2400
2401    #[tokio::test]
2402    async fn wrong_provider_version_record_fails_closed() {
2403        let seed = [0x11u8; ml_dsa_sign::SEED_LEN];
2404        let public =
2405            ml_dsa_sign::public_from_seed(ml_dsa_sign::MlDsaAlgorithm::MlDsa65, &seed).expect("pk");
2406        // Record provisioned for a different provider version than the provider
2407        // advertises: metadata cross-check must reject it before any decrypt.
2408        let record = custody_record("ml-dsa-65", &public, "99");
2409        let backend = CustodyBackend::single(record, &seed);
2410        let provider = LocalSoftwareProvider::new(&backend);
2411        let err = provider
2412            .sign(SignRequest {
2413                key_id: KEY_ID,
2414                backend_path: PATH,
2415                algorithm: SignatureAlgorithm::MlDsa65,
2416                message: b"m",
2417                storage_key: Some(STORAGE_KEY),
2418            })
2419            .await
2420            .expect_err("version mismatch");
2421        assert!(matches!(err, ProviderError::CryptoFailed { .. }));
2422    }
2423
2424    #[tokio::test]
2425    async fn record_wrapping_key_must_match_the_catalog_storage_key() {
2426        let seed = [0x11u8; ml_dsa_sign::SEED_LEN];
2427        let public =
2428            ml_dsa_sign::public_from_seed(ml_dsa_sign::MlDsaAlgorithm::MlDsa65, &seed).expect("pk");
2429        let record = custody_record(
2430            "ml-dsa-65",
2431            &public,
2432            LocalSoftwareProvider::PROVIDER_VERSION,
2433        );
2434        let backend = CustodyBackend::single(record, &seed);
2435        let provider = LocalSoftwareProvider::new(&backend);
2436        // The record self-declares STORAGE_KEY; the catalog declares another
2437        // AEAD key. The cross-check must reject the record before any unwrap,
2438        // so a swapped/re-wrapped record cannot pick its own wrapping key.
2439        let err = provider
2440            .sign(SignRequest {
2441                key_id: KEY_ID,
2442                backend_path: PATH,
2443                algorithm: SignatureAlgorithm::MlDsa65,
2444                message: b"m",
2445                storage_key: Some("other-storage-aead"),
2446            })
2447            .await
2448            .expect_err("wrapping-key mismatch");
2449        assert!(matches!(
2450            err,
2451            ProviderError::CryptoFailed {
2452                op: "sign",
2453                reason: "malformed software-custody record",
2454                ..
2455            }
2456        ));
2457    }
2458
2459    #[tokio::test]
2460    async fn use_path_without_a_catalog_storage_key_fails_closed() {
2461        let seed = [0x11u8; ml_dsa_sign::SEED_LEN];
2462        let public =
2463            ml_dsa_sign::public_from_seed(ml_dsa_sign::MlDsaAlgorithm::MlDsa65, &seed).expect("pk");
2464        let record = custody_record(
2465            "ml-dsa-65",
2466            &public,
2467            LocalSoftwareProvider::PROVIDER_VERSION,
2468        );
2469        let backend = CustodyBackend::single(record, &seed);
2470        let provider = LocalSoftwareProvider::new(&backend);
2471        // Without the catalog-declared storage key there is no trust anchor to
2472        // validate the record's wrapping key against: refuse, exactly like the
2473        // generate path.
2474        let err = provider
2475            .sign(SignRequest {
2476                key_id: KEY_ID,
2477                backend_path: PATH,
2478                algorithm: SignatureAlgorithm::MlDsa65,
2479                message: b"m",
2480                storage_key: None,
2481            })
2482            .await
2483            .expect_err("missing storage key");
2484        assert!(matches!(
2485            err,
2486            ProviderError::CryptoFailed {
2487                op: "sign",
2488                reason: "software-custody key is missing its storage AEAD key",
2489                ..
2490            }
2491        ));
2492    }
2493
2494    #[tokio::test]
2495    async fn generate_without_storage_key_and_import_fail_closed() {
2496        let backend = CustodyBackend::single(Vec::new(), &[]);
2497        let provider = LocalSoftwareProvider::new(&backend);
2498        let material = basil_proto::KeyMaterial::Ed25519Seed(vec![0u8; 32]);
2499        // Generate is now supported, but it must have a storage AEAD key to seal
2500        // the seed; without one it fails closed before touching the backend.
2501        assert!(matches!(
2502            provider
2503                .generate_key(GenerateKey {
2504                    key_id: KEY_ID,
2505                    backend_path: PATH,
2506                    algorithm: SignatureAlgorithm::MlDsa87,
2507                    storage_key: None,
2508                })
2509                .await,
2510            Err(ProviderError::CryptoFailed {
2511                op: "generate_key",
2512                ..
2513            })
2514        ));
2515        // Import (BYOK) is still unsupported for software custody.
2516        assert!(matches!(
2517            provider
2518                .import_key(ImportKey {
2519                    key_id: KEY_ID,
2520                    backend_path: PATH,
2521                    algorithm: SignatureAlgorithm::MlDsa87,
2522                    material: &material,
2523                })
2524                .await,
2525            Err(ProviderError::Unsupported {
2526                op: "import_key",
2527                ..
2528            })
2529        ));
2530    }
2531
2532    /// A stateful in-memory backend that performs a faithful generate → seal →
2533    /// write → read → unseal → sign round trip: `encrypt` keeps the seed bytes
2534    /// (identity envelope, the test exercises the provider wiring, not the AEAD),
2535    /// `kv_put` stores the record, `kv_get` serves it at version 1, and `decrypt`
2536    /// returns the sealed bytes.
2537    struct RoundTripBackend {
2538        records: std::sync::Mutex<HashMap<String, Vec<u8>>>,
2539    }
2540
2541    impl RoundTripBackend {
2542        fn new() -> Self {
2543            Self {
2544                records: std::sync::Mutex::new(HashMap::new()),
2545            }
2546        }
2547    }
2548
2549    #[async_trait]
2550    impl Backend for RoundTripBackend {
2551        fn kind(&self) -> &'static str {
2552            "round-trip-custody-test"
2553        }
2554
2555        async fn new_key(&self, _key_type: KeyType) -> Result<NewKey, BackendError> {
2556            Err(BackendError::Unsupported("new_key"))
2557        }
2558
2559        async fn public_key(&self, _key_id: &str) -> Result<Vec<u8>, BackendError> {
2560            Err(BackendError::Unsupported("public_key"))
2561        }
2562
2563        async fn sign(&self, _key_id: &str, _message: &[u8]) -> Result<Vec<u8>, BackendError> {
2564            Err(BackendError::Unsupported("sign"))
2565        }
2566
2567        async fn verify(
2568            &self,
2569            _key_id: &str,
2570            _message: &[u8],
2571            _signature: &[u8],
2572        ) -> Result<bool, BackendError> {
2573            Err(BackendError::Unsupported("verify"))
2574        }
2575
2576        async fn encrypt(
2577            &self,
2578            _key_id: &str,
2579            algorithm: basil_proto::AeadAlgorithm,
2580            plaintext: &[u8],
2581            _aad: Option<&[u8]>,
2582        ) -> Result<CiphertextEnvelope, BackendError> {
2583            Ok(CiphertextEnvelope {
2584                alg: algorithm,
2585                key_version: 1,
2586                nonce: vec![0u8; 12],
2587                ciphertext: plaintext.to_vec(),
2588            })
2589        }
2590
2591        async fn decrypt(
2592            &self,
2593            _key_id: &str,
2594            envelope: &CiphertextEnvelope,
2595            _aad: Option<&[u8]>,
2596        ) -> Result<Vec<u8>, BackendError> {
2597            Ok(envelope.ciphertext.clone())
2598        }
2599
2600        async fn kv_put(&self, key_id: &str, value: &[u8]) -> Result<u32, BackendError> {
2601            self.records
2602                .lock()
2603                .map_err(|_| BackendError::Unsupported("kv_put"))?
2604                .insert(key_id.to_string(), value.to_vec());
2605            Ok(1)
2606        }
2607
2608        async fn kv_get(
2609            &self,
2610            key_id: &str,
2611            _version: Option<u32>,
2612        ) -> Result<KvValue, BackendError> {
2613            let value = self
2614                .records
2615                .lock()
2616                .map_err(|_| BackendError::Unsupported("kv_get"))?
2617                .get(key_id)
2618                .cloned();
2619            value
2620                .map(|value| KvValue { value, version: 1 })
2621                .ok_or(BackendError::Unsupported("kv_get"))
2622        }
2623    }
2624
2625    #[tokio::test]
2626    async fn generate_then_sign_verify_round_trip_every_level() {
2627        for (sig_algorithm, dsa_algorithm) in DSA_LEVELS {
2628            let backend = RoundTripBackend::new();
2629            let provider = LocalSoftwareProvider::new(&backend);
2630            let created = provider
2631                .generate_key(GenerateKey {
2632                    key_id: KEY_ID,
2633                    backend_path: PATH,
2634                    algorithm: sig_algorithm,
2635                    storage_key: Some(STORAGE_KEY),
2636                })
2637                .await
2638                .expect("generate");
2639            // The returned public matches what the seed derives.
2640            assert!(
2641                !created.public_key.is_empty(),
2642                "{} public",
2643                dsa_algorithm.token()
2644            );
2645
2646            let signature = provider
2647                .sign(SignRequest {
2648                    key_id: KEY_ID,
2649                    backend_path: PATH,
2650                    algorithm: sig_algorithm,
2651                    message: b"provisioned payload",
2652                    storage_key: Some(STORAGE_KEY),
2653                })
2654                .await
2655                .expect("sign");
2656            assert!(
2657                provider
2658                    .verify(VerifyRequest {
2659                        key_id: KEY_ID,
2660                        backend_path: PATH,
2661                        algorithm: sig_algorithm,
2662                        message: b"provisioned payload",
2663                        signature: &signature,
2664                        storage_key: Some(STORAGE_KEY),
2665                    })
2666                    .await
2667                    .expect("verify"),
2668                "{} round trip verifies",
2669                dsa_algorithm.token()
2670            );
2671            assert!(
2672                ml_dsa_sign::verify(
2673                    dsa_algorithm,
2674                    &created.public_key,
2675                    b"provisioned payload",
2676                    &signature,
2677                )
2678                .expect("core verify"),
2679                "{} verifies under returned public",
2680                dsa_algorithm.token()
2681            );
2682        }
2683    }
2684}