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(key_id, backend_path, storage_key, "get_public_key", algorithm)
1079            .await?;
1080        Ok(material.public_key)
1081    }
1082
1083    /// Seal a freshly generated `seed` under the catalog `storage_key` AEAD key
1084    /// (binding the custody AAD), build the [`SoftwareCustodyKeyRecord`], and write
1085    /// it to KV at `backend_path`. Shared by the ML-DSA and ML-KEM generate paths;
1086    /// the seed stays in caller-owned [`Zeroizing`] storage and never leaves.
1087    /// Returns only the non-secret public half.
1088    async fn seal_and_store(
1089        &self,
1090        params: SealParams<'_>,
1091        seed: &[u8],
1092        public_key: Vec<u8>,
1093    ) -> Result<NewKey, ProviderError> {
1094        // The fresh record is the key's first version; the use path validates the
1095        // KV record version equals this on read.
1096        const RECORD_VERSION: u32 = 1;
1097
1098        let custody = SoftwareCustodyCatalog {
1099            key_id: params.key_id,
1100            algorithm: params.algorithm_token,
1101            provider: CryptoProviderId::LocalSoftware.token(),
1102            provider_version: self.provider_version,
1103            custody: CustodyMode::SoftwareEncrypted.token(),
1104            storage_key: params.storage_key,
1105        };
1106        let aad = custody.aad(RECORD_VERSION);
1107        let envelope = self
1108            .backend
1109            .encrypt(
1110                params.storage_key,
1111                AeadAlgorithm::Aes256Gcm,
1112                seed,
1113                Some(&aad),
1114            )
1115            .await?;
1116        let record = SoftwareCustodyKeyRecord {
1117            schema_version: SoftwareCustodyKeyRecord::SCHEMA_VERSION,
1118            key_id: params.key_id.to_string(),
1119            key_version: RECORD_VERSION,
1120            public_key: B64.encode(&public_key),
1121            algorithm: params.algorithm_token.to_string(),
1122            provider: CryptoProviderId::LocalSoftware.token().to_string(),
1123            provider_version: self.provider_version.to_string(),
1124            custody: CustodyMode::SoftwareEncrypted.token().to_string(),
1125            encrypted_private_key: EncryptedPrivateKey {
1126                wrapping_key: params.storage_key.to_string(),
1127                algorithm: aead_token(envelope.alg).to_string(),
1128                key_version: envelope.key_version,
1129                nonce: B64.encode(&envelope.nonce),
1130                ciphertext: B64.encode(&envelope.ciphertext),
1131            },
1132        };
1133        let bytes = serde_json::to_vec(&record).map_err(|_| {
1134            crypto_failed(
1135                CryptoProviderId::LocalSoftware,
1136                params.op,
1137                params.algorithm_token,
1138                "software-custody record serialization failed",
1139            )
1140        })?;
1141        self.backend.kv_put(params.backend_path, &bytes).await?;
1142        Ok(NewKey {
1143            key_id: params.key_id.to_string(),
1144            public_key,
1145        })
1146    }
1147}
1148
1149/// Inputs to [`LocalSoftwareProvider::seal_and_store`] for one software-custody
1150/// provisioning write (grouped to keep the seal path's argument count small).
1151struct SealParams<'a> {
1152    /// Audit/error op token (`generate_key` or `generate_sealing_key`).
1153    op: &'static str,
1154    /// Catalog key id.
1155    key_id: &'a str,
1156    /// KV path the custody record is written to.
1157    backend_path: &'a str,
1158    /// Algorithm token recorded in the custody record and AAD.
1159    algorithm_token: &'static str,
1160    /// Backend AEAD key name that wraps the seed.
1161    storage_key: &'a str,
1162}
1163#[async_trait]
1164impl CryptoProvider for LocalSoftwareProvider<'_> {
1165    fn provider_id(&self) -> CryptoProviderId {
1166        CryptoProviderId::LocalSoftware
1167    }
1168
1169    async fn generate_key(&self, request: GenerateKey<'_>) -> Result<NewKey, ProviderError> {
1170        use rand::RngCore as _;
1171        use rand::rngs::OsRng;
1172
1173        let algorithm = ml_dsa_algorithm(request.algorithm).ok_or_else(|| {
1174            unsupported(
1175                CryptoProviderId::LocalSoftware,
1176                "generate_key",
1177                signature_algorithm_name(request.algorithm),
1178            )
1179        })?;
1180        let algorithm_token = algorithm.token();
1181        let storage_key = request.storage_key.ok_or_else(|| {
1182            crypto_failed(
1183                CryptoProviderId::LocalSoftware,
1184                "generate_key",
1185                algorithm_token,
1186                "software-custody key is missing its storage AEAD key",
1187            )
1188        })?;
1189
1190        // Fresh seed = the private key; it stays in a zeroizing buffer that wipes
1191        // on drop and is never returned, logged, or audited.
1192        let mut seed = Zeroizing::new([0u8; ml_dsa_sign::SEED_LEN]);
1193        OsRng.fill_bytes(seed.as_mut_slice());
1194        let public_key =
1195            ml_dsa_sign::public_from_seed(algorithm, seed.as_slice()).map_err(|_| {
1196                crypto_failed(
1197                    CryptoProviderId::LocalSoftware,
1198                    "generate_key",
1199                    algorithm_token,
1200                    "ml-dsa key generation failed",
1201                )
1202            })?;
1203
1204        self.seal_and_store(
1205            SealParams {
1206                op: "generate_key",
1207                key_id: request.key_id,
1208                backend_path: request.backend_path,
1209                algorithm_token,
1210                storage_key,
1211            },
1212            seed.as_slice(),
1213            public_key,
1214        )
1215        .await
1216    }
1217
1218    async fn generate_sealing_key(
1219        &self,
1220        request: GenerateSealingKey<'_>,
1221    ) -> Result<NewKey, ProviderError> {
1222        use rand::RngCore as _;
1223        use rand::rngs::OsRng;
1224
1225        let kem = request.algorithm;
1226        let algorithm_token = kem.token();
1227        let storage_key = request.storage_key.ok_or_else(|| {
1228            crypto_failed(
1229                CryptoProviderId::LocalSoftware,
1230                "generate_sealing_key",
1231                algorithm_token,
1232                "software-custody key is missing its storage AEAD key",
1233            )
1234        })?;
1235
1236        // ML-KEM seeds are 64 bytes (`d ‖ z`, FIPS 203). The seed is the private
1237        // key and stays in a zeroizing buffer; only the derived encapsulation
1238        // (public) key is returned.
1239        let mut seed = Zeroizing::new([0u8; ml_kem_envelope::SEED_LEN]);
1240        OsRng.fill_bytes(seed.as_mut_slice());
1241        let public_key = ml_kem_envelope::public_from_seed(seed.as_slice(), kem_to_core(kem))
1242            .map_err(|_| {
1243                crypto_failed(
1244                    CryptoProviderId::LocalSoftware,
1245                    "generate_sealing_key",
1246                    algorithm_token,
1247                    "ml-kem key generation failed",
1248                )
1249            })?;
1250
1251        self.seal_and_store(
1252            SealParams {
1253                op: "generate_sealing_key",
1254                key_id: request.key_id,
1255                backend_path: request.backend_path,
1256                algorithm_token,
1257                storage_key,
1258            },
1259            seed.as_slice(),
1260            public_key,
1261        )
1262        .await
1263    }
1264
1265    async fn import_key(&self, request: ImportKey<'_>) -> Result<NewKey, ProviderError> {
1266        Err(unsupported(
1267            CryptoProviderId::LocalSoftware,
1268            "import_key",
1269            signature_algorithm_name(request.algorithm),
1270        ))
1271    }
1272
1273    async fn sign(&self, request: SignRequest<'_>) -> Result<Vec<u8>, ProviderError> {
1274        let algorithm = ml_dsa_algorithm(request.algorithm).ok_or_else(|| {
1275            unsupported(
1276                CryptoProviderId::LocalSoftware,
1277                "sign",
1278                signature_algorithm_name(request.algorithm),
1279            )
1280        })?;
1281        let material = self
1282            .material(
1283                request.key_id,
1284                request.backend_path,
1285                request.storage_key,
1286                "sign",
1287                algorithm.token(),
1288            )
1289            .await?;
1290        let seed = self.materialize_seed(&material).await?;
1291        ml_dsa_sign::sign(algorithm, &seed, request.message).map_err(|_| {
1292            crypto_failed(
1293                CryptoProviderId::LocalSoftware,
1294                "sign",
1295                algorithm.token(),
1296                "ml-dsa signing failed",
1297            )
1298        })
1299    }
1300
1301    async fn verify(&self, request: VerifyRequest<'_>) -> Result<bool, ProviderError> {
1302        let algorithm = ml_dsa_algorithm(request.algorithm).ok_or_else(|| {
1303            unsupported(
1304                CryptoProviderId::LocalSoftware,
1305                "verify",
1306                signature_algorithm_name(request.algorithm),
1307            )
1308        })?;
1309        // Verification needs only the published public key. No seed materialized.
1310        let material = self
1311            .material(
1312                request.key_id,
1313                request.backend_path,
1314                request.storage_key,
1315                "verify",
1316                algorithm.token(),
1317            )
1318            .await?;
1319        ml_dsa_sign::verify(
1320            algorithm,
1321            &material.public_key,
1322            request.message,
1323            request.signature,
1324        )
1325        .map_err(|_| {
1326            crypto_failed(
1327                CryptoProviderId::LocalSoftware,
1328                "verify",
1329                algorithm.token(),
1330                "malformed verification input",
1331            )
1332        })
1333    }
1334
1335    async fn encapsulate(
1336        &self,
1337        request: EncapsulateRequest<'_>,
1338    ) -> Result<Encapsulation, ProviderError> {
1339        let material = self
1340            .material(
1341                request.key_id,
1342                request.backend_path,
1343                request.storage_key,
1344                "encapsulate",
1345                kem_algorithm_name(request.algorithm),
1346            )
1347            .await?;
1348        let seed = self.materialize_seed(&material).await?;
1349        let (encapsulated_key, shared_secret) =
1350            ml_kem_envelope::encapsulate(&seed, kem_to_core(request.algorithm)).map_err(|_| {
1351                crypto_failed(
1352                    CryptoProviderId::LocalSoftware,
1353                    "encapsulate",
1354                    kem_algorithm_name(request.algorithm),
1355                    "ml-kem encapsulation failed",
1356                )
1357            })?;
1358        Ok(Encapsulation {
1359            encapsulated_key,
1360            shared_secret,
1361        })
1362    }
1363
1364    async fn decapsulate(
1365        &self,
1366        request: DecapsulateRequest<'_>,
1367    ) -> Result<Zeroizing<Vec<u8>>, ProviderError> {
1368        let material = self
1369            .material(
1370                request.key_id,
1371                request.backend_path,
1372                request.storage_key,
1373                "decapsulate",
1374                kem_algorithm_name(request.algorithm),
1375            )
1376            .await?;
1377        let seed = self.materialize_seed(&material).await?;
1378        ml_kem_envelope::decapsulate(
1379            &seed,
1380            kem_to_core(request.algorithm),
1381            request.encapsulated_key,
1382        )
1383        .map_err(|_| {
1384            crypto_failed(
1385                CryptoProviderId::LocalSoftware,
1386                "decapsulate",
1387                kem_algorithm_name(request.algorithm),
1388                "ml-kem decapsulation failed",
1389            )
1390        })
1391    }
1392
1393    async fn wrap_envelope(
1394        &self,
1395        request: WrapEnvelopeRequest<'_>,
1396    ) -> Result<Envelope, ProviderError> {
1397        let material = self
1398            .material(
1399                request.key_id,
1400                request.backend_path,
1401                request.storage_key,
1402                "wrap_envelope",
1403                kem_algorithm_name(request.kem_algorithm),
1404            )
1405            .await?;
1406        let seed = self.materialize_seed(&material).await?;
1407        let sealed = ml_kem_envelope::seal(
1408            &seed,
1409            kem_to_core(request.kem_algorithm),
1410            envelope_to_core(request.envelope_algorithm),
1411            request.plaintext,
1412            request.aad.unwrap_or_default(),
1413        )
1414        .map_err(|_| {
1415            crypto_failed(
1416                CryptoProviderId::LocalSoftware,
1417                "wrap_envelope",
1418                kem_algorithm_name(request.kem_algorithm),
1419                "ml-kem envelope seal failed",
1420            )
1421        })?;
1422        Ok(Envelope {
1423            kem_algorithm: request.kem_algorithm,
1424            envelope_algorithm: request.envelope_algorithm,
1425            key_version: material.key_version,
1426            encapsulated_key: sealed.encapsulated_key,
1427            nonce: sealed.nonce.to_vec(),
1428            ciphertext: sealed.ciphertext,
1429        })
1430    }
1431
1432    async fn unwrap_envelope(
1433        &self,
1434        request: UnwrapEnvelopeRequest<'_>,
1435    ) -> Result<Vec<u8>, ProviderError> {
1436        let material = self
1437            .material(
1438                request.key_id,
1439                request.backend_path,
1440                request.storage_key,
1441                "unwrap_envelope",
1442                kem_algorithm_name(request.kem_algorithm),
1443            )
1444            .await?;
1445        let seed = self.materialize_seed(&material).await?;
1446        let envelope = ml_kem_envelope::envelope_from_parts(
1447            kem_to_core(request.kem_algorithm),
1448            envelope_to_core(request.envelope_algorithm),
1449            request.encapsulated_key,
1450            request.nonce,
1451            request.ciphertext,
1452        )
1453        .map_err(|_| {
1454            crypto_failed(
1455                CryptoProviderId::LocalSoftware,
1456                "unwrap_envelope",
1457                kem_algorithm_name(request.kem_algorithm),
1458                "malformed ml-kem envelope",
1459            )
1460        })?;
1461        let plaintext = ml_kem_envelope::open(&seed, &envelope, request.aad.unwrap_or_default())
1462            .map_err(|_| {
1463                crypto_failed(
1464                    CryptoProviderId::LocalSoftware,
1465                    "unwrap_envelope",
1466                    kem_algorithm_name(request.kem_algorithm),
1467                    "ml-kem envelope open failed",
1468                )
1469            })?;
1470        Ok(plaintext.to_vec())
1471    }
1472}
1473const fn ml_dsa_algorithm(algorithm: SignatureAlgorithm) -> Option<ml_dsa_sign::MlDsaAlgorithm> {
1474    match algorithm {
1475        SignatureAlgorithm::MlDsa44 => Some(ml_dsa_sign::MlDsaAlgorithm::MlDsa44),
1476        SignatureAlgorithm::MlDsa65 => Some(ml_dsa_sign::MlDsaAlgorithm::MlDsa65),
1477        SignatureAlgorithm::MlDsa87 => Some(ml_dsa_sign::MlDsaAlgorithm::MlDsa87),
1478        SignatureAlgorithm::Ed25519
1479        | SignatureAlgorithm::Ed25519Nkey
1480        | SignatureAlgorithm::Rs256
1481        | SignatureAlgorithm::Es256 => None,
1482    }
1483}
1484const fn kem_to_core(algorithm: KemAlgorithm) -> ml_kem_envelope::KemAlgorithm {
1485    match algorithm {
1486        KemAlgorithm::MlKem512 => ml_kem_envelope::KemAlgorithm::MlKem512,
1487        KemAlgorithm::MlKem768 => ml_kem_envelope::KemAlgorithm::MlKem768,
1488        KemAlgorithm::MlKem1024 => ml_kem_envelope::KemAlgorithm::MlKem1024,
1489    }
1490}
1491const fn envelope_to_core(algorithm: EnvelopeAlgorithm) -> ml_kem_envelope::EnvelopeAlgorithm {
1492    match algorithm {
1493        EnvelopeAlgorithm::Aes256Gcm => ml_kem_envelope::EnvelopeAlgorithm::Aes256Gcm,
1494        EnvelopeAlgorithm::ChaCha20Poly1305 => ml_kem_envelope::EnvelopeAlgorithm::ChaCha20Poly1305,
1495    }
1496}
1497
1498/// Select a provider from catalog metadata and backend capability.
1499///
1500/// `backend_native_supported` is the caller's capability probe for the requested
1501/// algorithm and operation.
1502pub fn select_provider(
1503    metadata: ProviderMetadata,
1504    backend_native_supported: bool,
1505    local_software_allowed: bool,
1506    op: &'static str,
1507) -> Result<CryptoProviderId, ProviderError> {
1508    match metadata.policy {
1509        ProviderPolicy::BackendRequired => select_backend_required(backend_native_supported, op),
1510        ProviderPolicy::BackendPreferred => select_backend_preferred(
1511            metadata.custody,
1512            backend_native_supported,
1513            local_software_allowed,
1514            op,
1515        ),
1516        ProviderPolicy::LocalSoftware => {
1517            select_local_software(metadata.custody, local_software_allowed, op)
1518        }
1519    }
1520}
1521
1522const fn select_backend_required(
1523    backend_native_supported: bool,
1524    op: &'static str,
1525) -> Result<CryptoProviderId, ProviderError> {
1526    if backend_native_supported {
1527        return Ok(CryptoProviderId::VaultTransit);
1528    }
1529    Err(unsupported(
1530        CryptoProviderId::VaultTransit,
1531        op,
1532        "backend-native",
1533    ))
1534}
1535
1536fn select_backend_preferred(
1537    custody: Option<CustodyMode>,
1538    backend_native_supported: bool,
1539    local_software_allowed: bool,
1540    op: &'static str,
1541) -> Result<CryptoProviderId, ProviderError> {
1542    // A key already provisioned under software custody stays software-custodied:
1543    // its private seed lives software-encrypted in KV, so a capability probe that
1544    // newly reports native backend support must NOT re-route the key to the
1545    // backend (which holds no material for it). Migrating a software key to
1546    // backend-native is an explicit re-key (`basil-wuj.10`), never an implicit
1547    // side effect of the probe flipping. This pin also honors an operator who
1548    // explicitly declared software custody on a `backend-preferred` key.
1549    if custody == Some(CustodyMode::SoftwareEncrypted) {
1550        require_local_software_allowed(local_software_allowed, op)?;
1551        return Ok(CryptoProviderId::LocalSoftware);
1552    }
1553    if backend_native_supported {
1554        return Ok(CryptoProviderId::VaultTransit);
1555    }
1556    require_local_software_allowed(local_software_allowed, op)?;
1557    require_software_custody(
1558        custody,
1559        op,
1560        "local software fallback requires software-encrypted custody",
1561    )?;
1562    Ok(CryptoProviderId::LocalSoftware)
1563}
1564
1565fn select_local_software(
1566    custody: Option<CustodyMode>,
1567    local_software_allowed: bool,
1568    op: &'static str,
1569) -> Result<CryptoProviderId, ProviderError> {
1570    require_local_software_allowed(local_software_allowed, op)?;
1571    require_software_custody(
1572        custody,
1573        op,
1574        "local software provider requires software-encrypted custody",
1575    )?;
1576    Ok(CryptoProviderId::LocalSoftware)
1577}
1578
1579const fn require_local_software_allowed(
1580    local_software_allowed: bool,
1581    op: &'static str,
1582) -> Result<(), ProviderError> {
1583    if local_software_allowed {
1584        return Ok(());
1585    }
1586    Err(ProviderError::PolicyDenied {
1587        op,
1588        reason: "local software custody requires caller policy grant",
1589    })
1590}
1591
1592fn require_software_custody(
1593    custody: Option<CustodyMode>,
1594    op: &'static str,
1595    reason: &'static str,
1596) -> Result<(), ProviderError> {
1597    if custody == Some(CustodyMode::SoftwareEncrypted) {
1598        return Ok(());
1599    }
1600    Err(ProviderError::PolicyDenied { op, reason })
1601}
1602
1603const fn key_type_for_signature(algorithm: SignatureAlgorithm) -> Option<KeyType> {
1604    match algorithm {
1605        SignatureAlgorithm::Ed25519 => Some(KeyType::Ed25519),
1606        SignatureAlgorithm::Ed25519Nkey => Some(KeyType::Ed25519Nkey),
1607        SignatureAlgorithm::Rs256 => Some(KeyType::Rsa2048),
1608        SignatureAlgorithm::Es256 => Some(KeyType::EcdsaP256),
1609        SignatureAlgorithm::MlDsa44 | SignatureAlgorithm::MlDsa65 | SignatureAlgorithm::MlDsa87 => {
1610            None
1611        }
1612    }
1613}
1614
1615const fn sign_options(algorithm: SignatureAlgorithm) -> Option<SignOptions> {
1616    match algorithm {
1617        SignatureAlgorithm::Ed25519 | SignatureAlgorithm::Ed25519Nkey => Some(SignOptions::Default),
1618        SignatureAlgorithm::Rs256 => Some(SignOptions::Rs256Pkcs1v15Sha256),
1619        SignatureAlgorithm::Es256 => Some(SignOptions::Es256),
1620        SignatureAlgorithm::MlDsa44 | SignatureAlgorithm::MlDsa65 | SignatureAlgorithm::MlDsa87 => {
1621            None
1622        }
1623    }
1624}
1625
1626const fn signature_algorithm_name(algorithm: SignatureAlgorithm) -> &'static str {
1627    match algorithm {
1628        SignatureAlgorithm::Ed25519 => "ed25519",
1629        SignatureAlgorithm::Ed25519Nkey => "ed25519-nkey",
1630        SignatureAlgorithm::Rs256 => "rs256",
1631        SignatureAlgorithm::Es256 => "es256",
1632        SignatureAlgorithm::MlDsa44 => "ml-dsa-44",
1633        SignatureAlgorithm::MlDsa65 => "ml-dsa-65",
1634        SignatureAlgorithm::MlDsa87 => "ml-dsa-87",
1635    }
1636}
1637
1638const fn kem_algorithm_name(algorithm: KemAlgorithm) -> &'static str {
1639    algorithm.token()
1640}
1641
1642const fn unsupported(
1643    provider: CryptoProviderId,
1644    op: &'static str,
1645    algorithm: &'static str,
1646) -> ProviderError {
1647    ProviderError::Unsupported {
1648        provider,
1649        op,
1650        algorithm,
1651    }
1652}
1653const fn crypto_failed(
1654    provider: CryptoProviderId,
1655    op: &'static str,
1656    algorithm: &'static str,
1657    reason: &'static str,
1658) -> ProviderError {
1659    ProviderError::CryptoFailed {
1660        provider,
1661        op,
1662        algorithm,
1663        reason,
1664    }
1665}
1666
1667/// The storage-AEAD token written into a software-custody record. The inverse of
1668/// [`parse_storage_aead`], so a generated record round-trips on read.
1669const fn aead_token(alg: AeadAlgorithm) -> &'static str {
1670    match alg {
1671        AeadAlgorithm::Aes256Gcm => "aes-256-gcm",
1672        AeadAlgorithm::Chacha20Poly1305 => "chacha20-poly1305",
1673    }
1674}
1675
1676#[cfg(test)]
1677mod tests {
1678    use async_trait::async_trait;
1679
1680    use super::*;
1681    use crate::backend::{BackendError, NewKey};
1682    use crate::catalog::schema::Labels;
1683
1684    struct RecordingBackend;
1685
1686    #[async_trait]
1687    impl Backend for RecordingBackend {
1688        fn kind(&self) -> &'static str {
1689            "recording"
1690        }
1691
1692        async fn new_key(&self, key_type: KeyType) -> Result<NewKey, BackendError> {
1693            let _ = key_type;
1694            Err(BackendError::Unsupported("new_key"))
1695        }
1696
1697        async fn create_named_key(
1698            &self,
1699            key_id: &str,
1700            key_type: KeyType,
1701        ) -> Result<NewKey, BackendError> {
1702            Ok(NewKey {
1703                key_id: format!("{key_id}:{key_type}"),
1704                public_key: vec![1, 2, 3],
1705            })
1706        }
1707
1708        async fn public_key(&self, key_id: &str) -> Result<Vec<u8>, BackendError> {
1709            let _ = key_id;
1710            Ok(vec![1, 2, 3])
1711        }
1712
1713        async fn sign(&self, key_id: &str, message: &[u8]) -> Result<Vec<u8>, BackendError> {
1714            Ok([key_id.as_bytes(), message].concat())
1715        }
1716
1717        async fn sign_with_options(
1718            &self,
1719            key_id: &str,
1720            message: &[u8],
1721            options: SignOptions,
1722        ) -> Result<Vec<u8>, BackendError> {
1723            let mut out = self.sign(key_id, message).await?;
1724            // ubs false positive: options is not secret material
1725            /* ubs:ignore */
1726            if options == SignOptions::Rs256Pkcs1v15Sha256 {
1727                out.extend_from_slice(b":rs256");
1728            } else if options == SignOptions::Es256 {
1729                out.extend_from_slice(b":es256");
1730            }
1731            Ok(out)
1732        }
1733
1734        async fn verify(
1735            &self,
1736            key_id: &str,
1737            message: &[u8],
1738            signature: &[u8],
1739        ) -> Result<bool, BackendError> {
1740            // ubs false positive: test, unrealistic comparison
1741            /* ubs:ignore */
1742            Ok(signature == [key_id.as_bytes(), message].concat())
1743        }
1744    }
1745
1746    fn key_entry_with_labels(labels: &[&str]) -> KeyEntry {
1747        KeyEntry {
1748            class: crate::catalog::schema::Class::Asymmetric,
1749            key_type: Some(crate::catalog::schema::KeyAlgorithm::Ed25519),
1750            backend: "bao".to_string(),
1751            engine: None,
1752            path: "issuer".to_string(),
1753            public_path: None,
1754            writable: true,
1755            missing: crate::catalog::schema::MissingPolicy::Error,
1756            generate: None,
1757            sealing_pin: None,
1758            labels: Labels(labels.iter().map(ToString::to_string).collect()),
1759            description: "issuer".to_string(),
1760        }
1761    }
1762
1763    #[test]
1764    fn provider_metadata_defaults_to_backend_required() {
1765        let entry = key_entry_with_labels(&[]);
1766        assert_eq!(
1767            ProviderMetadata::from_key(&entry),
1768            ProviderMetadata {
1769                provider: None,
1770                policy: ProviderPolicy::BackendRequired,
1771                custody: None
1772            }
1773        );
1774    }
1775
1776    #[test]
1777    fn provider_metadata_parses_reserved_labels() {
1778        let entry = key_entry_with_labels(&[
1779            "crypto_provider=local-software",
1780            "crypto_provider_policy=backend-preferred",
1781            "pqc_custody=software-encrypted",
1782        ]);
1783        assert_eq!(
1784            ProviderMetadata::from_key(&entry),
1785            ProviderMetadata {
1786                provider: Some(CryptoProviderId::LocalSoftware),
1787                policy: ProviderPolicy::BackendPreferred,
1788                custody: Some(CustodyMode::SoftwareEncrypted)
1789            }
1790        );
1791    }
1792
1793    #[test]
1794    fn provider_selection_honors_backend_required() {
1795        let selected = select_provider(
1796            ProviderMetadata {
1797                provider: None,
1798                policy: ProviderPolicy::BackendRequired,
1799                custody: None,
1800            },
1801            true,
1802            false,
1803            "sign",
1804        )
1805        .expect("backend-native selected");
1806        assert_eq!(selected, CryptoProviderId::VaultTransit);
1807
1808        assert!(matches!(
1809            select_provider(
1810                ProviderMetadata {
1811                    provider: None,
1812                    policy: ProviderPolicy::BackendRequired,
1813                    custody: None,
1814                },
1815                false,
1816                false,
1817                "sign",
1818            ),
1819            Err(ProviderError::Unsupported { .. })
1820        ));
1821    }
1822
1823    #[test]
1824    fn provider_selection_honors_local_software_custody_gate() {
1825        let denied = select_provider(
1826            ProviderMetadata {
1827                provider: None,
1828                policy: ProviderPolicy::BackendPreferred,
1829                custody: Some(CustodyMode::BackendNative),
1830            },
1831            false,
1832            true,
1833            "sign",
1834        )
1835        .expect_err("backend fallback needs software custody");
1836        assert!(matches!(denied, ProviderError::PolicyDenied { .. }));
1837
1838        let denied = select_provider(
1839            ProviderMetadata {
1840                provider: None,
1841                policy: ProviderPolicy::BackendPreferred,
1842                custody: Some(CustodyMode::SoftwareEncrypted),
1843            },
1844            false,
1845            false,
1846            "sign",
1847        )
1848        .expect_err("local software needs caller policy");
1849        assert!(matches!(
1850            denied,
1851            ProviderError::PolicyDenied {
1852                reason: "local software custody requires caller policy grant",
1853                ..
1854            }
1855        ));
1856
1857        let selected = select_provider(
1858            ProviderMetadata {
1859                provider: None,
1860                policy: ProviderPolicy::BackendPreferred,
1861                custody: Some(CustodyMode::SoftwareEncrypted),
1862            },
1863            false,
1864            true,
1865            "sign",
1866        )
1867        .expect("local fallback selected");
1868        assert_eq!(selected, CryptoProviderId::LocalSoftware);
1869    }
1870
1871    #[test]
1872    fn backend_preferred_software_custody_pins_despite_native_support() {
1873        // The migration invariant (`basil-wuj.10`): a backend-preferred key already
1874        // under software custody stays on the local-software provider even when the
1875        // backend NOW reports native support: the probe must not silently re-route
1876        // an already-provisioned software key to the backend.
1877        let pinned = select_provider(
1878            ProviderMetadata {
1879                provider: None,
1880                policy: ProviderPolicy::BackendPreferred,
1881                custody: Some(CustodyMode::SoftwareEncrypted),
1882            },
1883            true,
1884            true,
1885            "sign",
1886        )
1887        .expect("software-custodied key stays local-software");
1888        assert_eq!(pinned, CryptoProviderId::LocalSoftware);
1889
1890        // A backend-preferred key with NO recorded software custody DOES take the
1891        // backend natively when the probe reports support (the new-key path).
1892        let native = select_provider(
1893            ProviderMetadata {
1894                provider: None,
1895                policy: ProviderPolicy::BackendPreferred,
1896                custody: None,
1897            },
1898            true,
1899            true,
1900            "sign",
1901        )
1902        .expect("no-custody preferred key routes to native");
1903        assert_eq!(native, CryptoProviderId::VaultTransit);
1904
1905        // The pin still honors the caller policy grant: a software-custodied key
1906        // with native support but no grant is denied, never routed to the backend.
1907        let denied = select_provider(
1908            ProviderMetadata {
1909                provider: None,
1910                policy: ProviderPolicy::BackendPreferred,
1911                custody: Some(CustodyMode::SoftwareEncrypted),
1912            },
1913            true,
1914            false,
1915            "sign",
1916        )
1917        .expect_err("local software still needs the caller grant");
1918        assert!(matches!(denied, ProviderError::PolicyDenied { .. }));
1919    }
1920
1921    #[test]
1922    fn provider_selection_honors_local_software_policy_mode() {
1923        let selected = select_provider(
1924            ProviderMetadata {
1925                provider: Some(CryptoProviderId::LocalSoftware),
1926                policy: ProviderPolicy::LocalSoftware,
1927                custody: Some(CustodyMode::SoftwareEncrypted),
1928            },
1929            true,
1930            true,
1931            "decapsulate",
1932        )
1933        .expect("explicit local software selected");
1934        assert_eq!(selected, CryptoProviderId::LocalSoftware);
1935
1936        let denied = select_provider(
1937            ProviderMetadata {
1938                provider: Some(CryptoProviderId::LocalSoftware),
1939                policy: ProviderPolicy::LocalSoftware,
1940                custody: Some(CustodyMode::SoftwareEncrypted),
1941            },
1942            true,
1943            false,
1944            "decapsulate",
1945        )
1946        .expect_err("caller grant required");
1947        assert!(matches!(
1948            denied,
1949            ProviderError::PolicyDenied {
1950                reason: "local software custody requires caller policy grant",
1951                ..
1952            }
1953        ));
1954    }
1955
1956    #[test]
1957    fn provider_audit_event_is_secret_free() {
1958        let event = ProviderAuditEvent {
1959            op: "decapsulate",
1960            key_id: "pqc.kem",
1961            key_version: Some(7),
1962            algorithm: "ml-kem-768",
1963            provider: CryptoProviderId::LocalSoftware,
1964            custody: CustodyMode::SoftwareEncrypted,
1965            caller_uid: 9100,
1966            outcome: ProviderAuditOutcome::Success,
1967            reason: "ok",
1968        };
1969        let value = event.to_json_value();
1970        assert_eq!(value["event"]["kind"], "basil.audit.provider_operation");
1971        assert_eq!(value["event"]["version"], 1);
1972        assert_eq!(value["actor"]["kind"], "unix_uid");
1973        assert_eq!(value["actor"]["id"], "9100");
1974        assert_eq!(value["target"]["kind"], "catalog_key");
1975        assert_eq!(value["target"]["id"], "pqc.kem");
1976        assert_eq!(value["target"]["version"], 7);
1977        assert!(value["occurred_at"].as_str().is_some());
1978        assert_eq!(value["op"], "decapsulate");
1979        assert_eq!(value["key_version"], 7);
1980        assert_eq!(value["algorithm"], "ml-kem-768");
1981        assert_eq!(value["provider"], "local-software");
1982        assert_eq!(value["custody"], "software-encrypted");
1983        assert_eq!(value["caller_uid"], 9100);
1984        assert_eq!(value["outcome"], "success");
1985        assert!(value.get("private_key").is_none());
1986        assert!(value.get("plaintext").is_none());
1987        assert!(value.get("ciphertext").is_none());
1988        assert!(value.get("signature").is_none());
1989        assert!(value.get("shared_secret").is_none());
1990    }
1991
1992    #[tokio::test]
1993    async fn backend_provider_delegates_legacy_signing() {
1994        let backend = RecordingBackend;
1995        let provider = BackendCryptoProvider::new(&backend);
1996        let signature = provider
1997            .sign(SignRequest {
1998                key_id: "catalog.issuer",
1999                backend_path: "issuer",
2000                algorithm: SignatureAlgorithm::Rs256,
2001                message: b"digest",
2002                storage_key: None,
2003            })
2004            .await
2005            .expect("signs");
2006        assert_eq!(signature, b"issuerdigest:rs256");
2007    }
2008
2009    #[tokio::test]
2010    async fn backend_provider_rejects_pqc_without_native_support() {
2011        // ML-DSA now dispatches to the backend's native PQC seam. A backend that
2012        // does not implement it (the default `sign_pqc`) fails closed with a
2013        // backend `Unsupported`, so a `backend-required` ML-DSA key over a
2014        // non-native backend still cannot sign.
2015        let backend = RecordingBackend;
2016        let provider = BackendCryptoProvider::new(&backend);
2017        assert!(!backend.supports_native_algorithm(NativeAlgorithm::MlDsa65));
2018        let err = provider
2019            .sign(SignRequest {
2020                key_id: "catalog.issuer",
2021                backend_path: "issuer",
2022                algorithm: SignatureAlgorithm::MlDsa65,
2023                message: b"digest",
2024                storage_key: None,
2025            })
2026            .await
2027            .expect_err("ML-DSA unsupported");
2028        assert!(matches!(
2029            err,
2030            ProviderError::Backend(BackendError::Unsupported("sign_pqc"))
2031        ));
2032    }
2033}
2034
2035#[cfg(test)]
2036mod pqc_provider_tests {
2037    use std::collections::HashMap;
2038
2039    use async_trait::async_trait;
2040    use basil_proto::{CiphertextEnvelope, KeyType};
2041
2042    use super::*;
2043    use crate::backend::{Backend, BackendError, KvValue, NewKey, SignOptions};
2044    use crate::core::{ml_dsa_sign, ml_kem_envelope};
2045
2046    const STORAGE_KEY: &str = "pqc-storage-aead";
2047    const KEY_ID: &str = "pqc.example";
2048    const PATH: &str = "kv/pqc/example";
2049
2050    /// In-memory backend simulating out-of-band custody provisioning: it serves a
2051    /// pre-written `SoftwareCustodyKeyRecord` from `kv_get` and returns the raw
2052    /// seed from `decrypt` (the test does not exercise the real AEAD).
2053    struct CustodyBackend {
2054        records: HashMap<String, Vec<u8>>,
2055        secrets: HashMap<String, Vec<u8>>,
2056    }
2057
2058    impl CustodyBackend {
2059        fn single(record: Vec<u8>, seed: &[u8]) -> Self {
2060            Self {
2061                records: HashMap::from([(PATH.to_string(), record)]),
2062                secrets: HashMap::from([(STORAGE_KEY.to_string(), seed.to_vec())]),
2063            }
2064        }
2065    }
2066
2067    #[async_trait]
2068    impl Backend for CustodyBackend {
2069        fn kind(&self) -> &'static str {
2070            "custody-test"
2071        }
2072
2073        async fn new_key(&self, _key_type: KeyType) -> Result<NewKey, BackendError> {
2074            Err(BackendError::Unsupported("new_key"))
2075        }
2076
2077        async fn create_named_key(
2078            &self,
2079            _key_id: &str,
2080            _key_type: KeyType,
2081        ) -> Result<NewKey, BackendError> {
2082            Err(BackendError::Unsupported("create_named_key"))
2083        }
2084
2085        async fn public_key(&self, _key_id: &str) -> Result<Vec<u8>, BackendError> {
2086            Err(BackendError::Unsupported("public_key"))
2087        }
2088
2089        async fn sign(&self, _key_id: &str, _message: &[u8]) -> Result<Vec<u8>, BackendError> {
2090            Err(BackendError::Unsupported("sign"))
2091        }
2092
2093        async fn sign_with_options(
2094            &self,
2095            _key_id: &str,
2096            _message: &[u8],
2097            _options: SignOptions,
2098        ) -> Result<Vec<u8>, BackendError> {
2099            Err(BackendError::Unsupported("sign_with_options"))
2100        }
2101
2102        async fn verify(
2103            &self,
2104            _key_id: &str,
2105            _message: &[u8],
2106            _signature: &[u8],
2107        ) -> Result<bool, BackendError> {
2108            Err(BackendError::Unsupported("verify"))
2109        }
2110
2111        async fn kv_get(
2112            &self,
2113            key_id: &str,
2114            _version: Option<u32>,
2115        ) -> Result<KvValue, BackendError> {
2116            let value = self
2117                .records
2118                .get(key_id)
2119                .cloned()
2120                .ok_or(BackendError::Unsupported("kv_get"))?;
2121            Ok(KvValue { value, version: 1 })
2122        }
2123
2124        async fn decrypt(
2125            &self,
2126            key_id: &str,
2127            _envelope: &CiphertextEnvelope,
2128            _aad: Option<&[u8]>,
2129        ) -> Result<Vec<u8>, BackendError> {
2130            self.secrets
2131                .get(key_id)
2132                .cloned()
2133                .ok_or(BackendError::Unsupported("decrypt"))
2134        }
2135    }
2136
2137    /// Build a valid software-custody record JSON for one key.
2138    fn custody_record(algorithm: &str, public_key: &[u8], provider_version: &str) -> Vec<u8> {
2139        let record = SoftwareCustodyKeyRecord {
2140            schema_version: SoftwareCustodyKeyRecord::SCHEMA_VERSION,
2141            key_id: KEY_ID.to_string(),
2142            key_version: 1,
2143            public_key: encode_record_bytes(public_key),
2144            algorithm: algorithm.to_string(),
2145            provider: CryptoProviderId::LocalSoftware.token().to_string(),
2146            provider_version: provider_version.to_string(),
2147            custody: CustodyMode::SoftwareEncrypted.token().to_string(),
2148            encrypted_private_key: EncryptedPrivateKey {
2149                wrapping_key: STORAGE_KEY.to_string(),
2150                algorithm: "aes-256-gcm".to_string(),
2151                key_version: 1,
2152                nonce: encode_record_bytes(&[0u8; 12]),
2153                ciphertext: encode_record_bytes(&[0u8; 16]),
2154            },
2155        };
2156        serde_json::to_vec(&record).expect("serialize record")
2157    }
2158
2159    const DSA_LEVELS: [(SignatureAlgorithm, ml_dsa_sign::MlDsaAlgorithm); 3] = [
2160        (
2161            SignatureAlgorithm::MlDsa44,
2162            ml_dsa_sign::MlDsaAlgorithm::MlDsa44,
2163        ),
2164        (
2165            SignatureAlgorithm::MlDsa65,
2166            ml_dsa_sign::MlDsaAlgorithm::MlDsa65,
2167        ),
2168        (
2169            SignatureAlgorithm::MlDsa87,
2170            ml_dsa_sign::MlDsaAlgorithm::MlDsa87,
2171        ),
2172    ];
2173
2174    const KEM_LEVELS: [KemAlgorithm; 3] = [
2175        KemAlgorithm::MlKem512,
2176        KemAlgorithm::MlKem768,
2177        KemAlgorithm::MlKem1024,
2178    ];
2179
2180    #[tokio::test]
2181    async fn signs_and_verifies_every_ml_dsa_level() {
2182        for (sig_algorithm, dsa_algorithm) in DSA_LEVELS {
2183            let seed = [0x11u8; ml_dsa_sign::SEED_LEN];
2184            let public = ml_dsa_sign::public_from_seed(dsa_algorithm, &seed).expect("public");
2185            let record = custody_record(
2186                dsa_algorithm.token(),
2187                &public,
2188                LocalSoftwareProvider::PROVIDER_VERSION,
2189            );
2190            let backend = CustodyBackend::single(record, &seed);
2191            let provider = LocalSoftwareProvider::new(&backend);
2192
2193            let signature = provider
2194                .sign(SignRequest {
2195                    key_id: KEY_ID,
2196                    backend_path: PATH,
2197                    algorithm: sig_algorithm,
2198                    message: b"basil pqc payload",
2199                    storage_key: Some(STORAGE_KEY),
2200                })
2201                .await
2202                .expect("sign");
2203            assert!(
2204                provider
2205                    .verify(VerifyRequest {
2206                        key_id: KEY_ID,
2207                        backend_path: PATH,
2208                        algorithm: sig_algorithm,
2209                        message: b"basil pqc payload",
2210                        signature: &signature,
2211                        storage_key: Some(STORAGE_KEY),
2212                    })
2213                    .await
2214                    .expect("verify"),
2215                "{} verifies",
2216                dsa_algorithm.token()
2217            );
2218            assert!(
2219                !provider
2220                    .verify(VerifyRequest {
2221                        key_id: KEY_ID,
2222                        backend_path: PATH,
2223                        algorithm: sig_algorithm,
2224                        message: b"tampered payload",
2225                        signature: &signature,
2226                        storage_key: Some(STORAGE_KEY),
2227                    })
2228                    .await
2229                    .expect("verify"),
2230                "{} rejects wrong message",
2231                dsa_algorithm.token()
2232            );
2233        }
2234    }
2235
2236    #[tokio::test]
2237    async fn encapsulate_decapsulate_every_ml_kem_level() {
2238        for kem in KEM_LEVELS {
2239            let seed = [0x42u8; ml_kem_envelope::SEED_LEN];
2240            let record = custody_record(
2241                kem_algorithm_name(kem),
2242                &[7u8; 32],
2243                LocalSoftwareProvider::PROVIDER_VERSION,
2244            );
2245            let backend = CustodyBackend::single(record, &seed);
2246            let provider = LocalSoftwareProvider::new(&backend);
2247
2248            let encapsulation = provider
2249                .encapsulate(EncapsulateRequest {
2250                    key_id: KEY_ID,
2251                    backend_path: PATH,
2252                    algorithm: kem,
2253                    storage_key: Some(STORAGE_KEY),
2254                })
2255                .await
2256                .expect("encapsulate");
2257            let shared = provider
2258                .decapsulate(DecapsulateRequest {
2259                    key_id: KEY_ID,
2260                    backend_path: PATH,
2261                    algorithm: kem,
2262                    encapsulated_key: &encapsulation.encapsulated_key,
2263                    storage_key: Some(STORAGE_KEY),
2264                })
2265                .await
2266                .expect("decapsulate");
2267            assert_eq!(
2268                encapsulation.shared_secret.as_slice(),
2269                shared.as_slice(),
2270                "{} shared secret matches",
2271                kem_algorithm_name(kem)
2272            );
2273        }
2274    }
2275
2276    #[tokio::test]
2277    async fn wrap_unwrap_envelope_every_ml_kem_level() {
2278        for kem in KEM_LEVELS {
2279            for envelope_algorithm in [
2280                EnvelopeAlgorithm::Aes256Gcm,
2281                EnvelopeAlgorithm::ChaCha20Poly1305,
2282            ] {
2283                let seed = [0x42u8; ml_kem_envelope::SEED_LEN];
2284                let record = custody_record(
2285                    kem_algorithm_name(kem),
2286                    &[7u8; 32],
2287                    LocalSoftwareProvider::PROVIDER_VERSION,
2288                );
2289                let backend = CustodyBackend::single(record, &seed);
2290                let provider = LocalSoftwareProvider::new(&backend);
2291
2292                let envelope = provider
2293                    .wrap_envelope(WrapEnvelopeRequest {
2294                        key_id: KEY_ID,
2295                        backend_path: PATH,
2296                        kem_algorithm: kem,
2297                        envelope_algorithm,
2298                        plaintext: b"top secret",
2299                        aad: Some(b"context"),
2300                        storage_key: Some(STORAGE_KEY),
2301                    })
2302                    .await
2303                    .expect("wrap");
2304                assert_eq!(envelope.key_version, 1);
2305                let plaintext = provider
2306                    .unwrap_envelope(UnwrapEnvelopeRequest {
2307                        key_id: KEY_ID,
2308                        backend_path: PATH,
2309                        kem_algorithm: kem,
2310                        envelope_algorithm,
2311                        encapsulated_key: &envelope.encapsulated_key,
2312                        nonce: &envelope.nonce,
2313                        ciphertext: &envelope.ciphertext,
2314                        aad: Some(b"context"),
2315                        storage_key: Some(STORAGE_KEY),
2316                    })
2317                    .await
2318                    .expect("unwrap");
2319                assert_eq!(plaintext, b"top secret");
2320
2321                let wrong_aad = provider
2322                    .unwrap_envelope(UnwrapEnvelopeRequest {
2323                        key_id: KEY_ID,
2324                        backend_path: PATH,
2325                        kem_algorithm: kem,
2326                        envelope_algorithm,
2327                        encapsulated_key: &envelope.encapsulated_key,
2328                        nonce: &envelope.nonce,
2329                        ciphertext: &envelope.ciphertext,
2330                        aad: Some(b"wrong"),
2331                        storage_key: Some(STORAGE_KEY),
2332                    })
2333                    .await
2334                    .expect_err("wrong aad fails");
2335                assert!(matches!(
2336                    wrong_aad,
2337                    ProviderError::CryptoFailed {
2338                        op: "unwrap_envelope",
2339                        ..
2340                    }
2341                ));
2342            }
2343        }
2344    }
2345
2346    #[tokio::test]
2347    async fn rejects_non_pqc_signature_algorithm() {
2348        let backend = CustodyBackend::single(Vec::new(), &[]);
2349        let provider = LocalSoftwareProvider::new(&backend);
2350        let err = provider
2351            .sign(SignRequest {
2352                key_id: KEY_ID,
2353                backend_path: PATH,
2354                algorithm: SignatureAlgorithm::Ed25519,
2355                message: b"m",
2356                storage_key: Some(STORAGE_KEY),
2357            })
2358            .await
2359            .expect_err("non-pqc rejected");
2360        assert!(matches!(
2361            err,
2362            ProviderError::Unsupported {
2363                provider: CryptoProviderId::LocalSoftware,
2364                op: "sign",
2365                algorithm: "ed25519"
2366            }
2367        ));
2368    }
2369
2370    #[tokio::test]
2371    async fn malformed_record_fails_closed() {
2372        let backend = CustodyBackend::single(b"not a record".to_vec(), &[0u8; 32]);
2373        let provider = LocalSoftwareProvider::new(&backend);
2374        let err = provider
2375            .sign(SignRequest {
2376                key_id: KEY_ID,
2377                backend_path: PATH,
2378                algorithm: SignatureAlgorithm::MlDsa44,
2379                message: b"m",
2380                storage_key: Some(STORAGE_KEY),
2381            })
2382            .await
2383            .expect_err("malformed record");
2384        assert!(matches!(
2385            err,
2386            ProviderError::CryptoFailed {
2387                provider: CryptoProviderId::LocalSoftware,
2388                op: "sign",
2389                algorithm: "ml-dsa-44",
2390                reason: "malformed software-custody record"
2391            }
2392        ));
2393    }
2394
2395    #[tokio::test]
2396    async fn wrong_provider_version_record_fails_closed() {
2397        let seed = [0x11u8; ml_dsa_sign::SEED_LEN];
2398        let public =
2399            ml_dsa_sign::public_from_seed(ml_dsa_sign::MlDsaAlgorithm::MlDsa65, &seed).expect("pk");
2400        // Record provisioned for a different provider version than the provider
2401        // advertises: metadata cross-check must reject it before any decrypt.
2402        let record = custody_record("ml-dsa-65", &public, "99");
2403        let backend = CustodyBackend::single(record, &seed);
2404        let provider = LocalSoftwareProvider::new(&backend);
2405        let err = provider
2406            .sign(SignRequest {
2407                key_id: KEY_ID,
2408                backend_path: PATH,
2409                algorithm: SignatureAlgorithm::MlDsa65,
2410                message: b"m",
2411                storage_key: Some(STORAGE_KEY),
2412            })
2413            .await
2414            .expect_err("version mismatch");
2415        assert!(matches!(err, ProviderError::CryptoFailed { .. }));
2416    }
2417
2418    #[tokio::test]
2419    async fn record_wrapping_key_must_match_the_catalog_storage_key() {
2420        let seed = [0x11u8; ml_dsa_sign::SEED_LEN];
2421        let public =
2422            ml_dsa_sign::public_from_seed(ml_dsa_sign::MlDsaAlgorithm::MlDsa65, &seed).expect("pk");
2423        let record = custody_record("ml-dsa-65", &public, LocalSoftwareProvider::PROVIDER_VERSION);
2424        let backend = CustodyBackend::single(record, &seed);
2425        let provider = LocalSoftwareProvider::new(&backend);
2426        // The record self-declares STORAGE_KEY; the catalog declares another
2427        // AEAD key. The cross-check must reject the record before any unwrap,
2428        // so a swapped/re-wrapped record cannot pick its own wrapping key.
2429        let err = provider
2430            .sign(SignRequest {
2431                key_id: KEY_ID,
2432                backend_path: PATH,
2433                algorithm: SignatureAlgorithm::MlDsa65,
2434                message: b"m",
2435                storage_key: Some("other-storage-aead"),
2436            })
2437            .await
2438            .expect_err("wrapping-key mismatch");
2439        assert!(matches!(
2440            err,
2441            ProviderError::CryptoFailed {
2442                op: "sign",
2443                reason: "malformed software-custody record",
2444                ..
2445            }
2446        ));
2447    }
2448
2449    #[tokio::test]
2450    async fn use_path_without_a_catalog_storage_key_fails_closed() {
2451        let seed = [0x11u8; ml_dsa_sign::SEED_LEN];
2452        let public =
2453            ml_dsa_sign::public_from_seed(ml_dsa_sign::MlDsaAlgorithm::MlDsa65, &seed).expect("pk");
2454        let record = custody_record("ml-dsa-65", &public, LocalSoftwareProvider::PROVIDER_VERSION);
2455        let backend = CustodyBackend::single(record, &seed);
2456        let provider = LocalSoftwareProvider::new(&backend);
2457        // Without the catalog-declared storage key there is no trust anchor to
2458        // validate the record's wrapping key against: refuse, exactly like the
2459        // generate path.
2460        let err = provider
2461            .sign(SignRequest {
2462                key_id: KEY_ID,
2463                backend_path: PATH,
2464                algorithm: SignatureAlgorithm::MlDsa65,
2465                message: b"m",
2466                storage_key: None,
2467            })
2468            .await
2469            .expect_err("missing storage key");
2470        assert!(matches!(
2471            err,
2472            ProviderError::CryptoFailed {
2473                op: "sign",
2474                reason: "software-custody key is missing its storage AEAD key",
2475                ..
2476            }
2477        ));
2478    }
2479
2480    #[tokio::test]
2481    async fn generate_without_storage_key_and_import_fail_closed() {
2482        let backend = CustodyBackend::single(Vec::new(), &[]);
2483        let provider = LocalSoftwareProvider::new(&backend);
2484        let material = basil_proto::KeyMaterial::Ed25519Seed(vec![0u8; 32]);
2485        // Generate is now supported, but it must have a storage AEAD key to seal
2486        // the seed; without one it fails closed before touching the backend.
2487        assert!(matches!(
2488            provider
2489                .generate_key(GenerateKey {
2490                    key_id: KEY_ID,
2491                    backend_path: PATH,
2492                    algorithm: SignatureAlgorithm::MlDsa87,
2493                    storage_key: None,
2494                })
2495                .await,
2496            Err(ProviderError::CryptoFailed {
2497                op: "generate_key",
2498                ..
2499            })
2500        ));
2501        // Import (BYOK) is still unsupported for software custody.
2502        assert!(matches!(
2503            provider
2504                .import_key(ImportKey {
2505                    key_id: KEY_ID,
2506                    backend_path: PATH,
2507                    algorithm: SignatureAlgorithm::MlDsa87,
2508                    material: &material,
2509                })
2510                .await,
2511            Err(ProviderError::Unsupported {
2512                op: "import_key",
2513                ..
2514            })
2515        ));
2516    }
2517
2518    /// A stateful in-memory backend that performs a faithful generate → seal →
2519    /// write → read → unseal → sign round trip: `encrypt` keeps the seed bytes
2520    /// (identity envelope, the test exercises the provider wiring, not the AEAD),
2521    /// `kv_put` stores the record, `kv_get` serves it at version 1, and `decrypt`
2522    /// returns the sealed bytes.
2523    struct RoundTripBackend {
2524        records: std::sync::Mutex<HashMap<String, Vec<u8>>>,
2525    }
2526
2527    impl RoundTripBackend {
2528        fn new() -> Self {
2529            Self {
2530                records: std::sync::Mutex::new(HashMap::new()),
2531            }
2532        }
2533    }
2534
2535    #[async_trait]
2536    impl Backend for RoundTripBackend {
2537        fn kind(&self) -> &'static str {
2538            "round-trip-custody-test"
2539        }
2540
2541        async fn new_key(&self, _key_type: KeyType) -> Result<NewKey, BackendError> {
2542            Err(BackendError::Unsupported("new_key"))
2543        }
2544
2545        async fn public_key(&self, _key_id: &str) -> Result<Vec<u8>, BackendError> {
2546            Err(BackendError::Unsupported("public_key"))
2547        }
2548
2549        async fn sign(&self, _key_id: &str, _message: &[u8]) -> Result<Vec<u8>, BackendError> {
2550            Err(BackendError::Unsupported("sign"))
2551        }
2552
2553        async fn verify(
2554            &self,
2555            _key_id: &str,
2556            _message: &[u8],
2557            _signature: &[u8],
2558        ) -> Result<bool, BackendError> {
2559            Err(BackendError::Unsupported("verify"))
2560        }
2561
2562        async fn encrypt(
2563            &self,
2564            _key_id: &str,
2565            algorithm: basil_proto::AeadAlgorithm,
2566            plaintext: &[u8],
2567            _aad: Option<&[u8]>,
2568        ) -> Result<CiphertextEnvelope, BackendError> {
2569            Ok(CiphertextEnvelope {
2570                alg: algorithm,
2571                key_version: 1,
2572                nonce: vec![0u8; 12],
2573                ciphertext: plaintext.to_vec(),
2574            })
2575        }
2576
2577        async fn decrypt(
2578            &self,
2579            _key_id: &str,
2580            envelope: &CiphertextEnvelope,
2581            _aad: Option<&[u8]>,
2582        ) -> Result<Vec<u8>, BackendError> {
2583            Ok(envelope.ciphertext.clone())
2584        }
2585
2586        async fn kv_put(&self, key_id: &str, value: &[u8]) -> Result<u32, BackendError> {
2587            self.records
2588                .lock()
2589                .map_err(|_| BackendError::Unsupported("kv_put"))?
2590                .insert(key_id.to_string(), value.to_vec());
2591            Ok(1)
2592        }
2593
2594        async fn kv_get(
2595            &self,
2596            key_id: &str,
2597            _version: Option<u32>,
2598        ) -> Result<KvValue, BackendError> {
2599            let value = self
2600                .records
2601                .lock()
2602                .map_err(|_| BackendError::Unsupported("kv_get"))?
2603                .get(key_id)
2604                .cloned();
2605            value
2606                .map(|value| KvValue { value, version: 1 })
2607                .ok_or(BackendError::Unsupported("kv_get"))
2608        }
2609    }
2610
2611    #[tokio::test]
2612    async fn generate_then_sign_verify_round_trip_every_level() {
2613        for (sig_algorithm, dsa_algorithm) in DSA_LEVELS {
2614            let backend = RoundTripBackend::new();
2615            let provider = LocalSoftwareProvider::new(&backend);
2616            let created = provider
2617                .generate_key(GenerateKey {
2618                    key_id: KEY_ID,
2619                    backend_path: PATH,
2620                    algorithm: sig_algorithm,
2621                    storage_key: Some(STORAGE_KEY),
2622                })
2623                .await
2624                .expect("generate");
2625            // The returned public matches what the seed derives.
2626            assert!(
2627                !created.public_key.is_empty(),
2628                "{} public",
2629                dsa_algorithm.token()
2630            );
2631
2632            let signature = provider
2633                .sign(SignRequest {
2634                    key_id: KEY_ID,
2635                    backend_path: PATH,
2636                    algorithm: sig_algorithm,
2637                    message: b"provisioned payload",
2638                    storage_key: Some(STORAGE_KEY),
2639                })
2640                .await
2641                .expect("sign");
2642            assert!(
2643                provider
2644                    .verify(VerifyRequest {
2645                        key_id: KEY_ID,
2646                        backend_path: PATH,
2647                        algorithm: sig_algorithm,
2648                        message: b"provisioned payload",
2649                        signature: &signature,
2650                        storage_key: Some(STORAGE_KEY),
2651                    })
2652                    .await
2653                    .expect("verify"),
2654                "{} round trip verifies",
2655                dsa_algorithm.token()
2656            );
2657            assert!(
2658                ml_dsa_sign::verify(
2659                    dsa_algorithm,
2660                    &created.public_key,
2661                    b"provisioned payload",
2662                    &signature,
2663                )
2664                .expect("core verify"),
2665                "{} verifies under returned public",
2666                dsa_algorithm.token()
2667            );
2668        }
2669    }
2670}