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