Skip to main content

basil_core/core/
crypto_provider.rs

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