Skip to main content

basil_core/core/
manager.rs

1//! Multi-backend manager + per-key routing (design §2.2, §17.7).
2//!
3//! The [`BackendManager`] is the layer between the gRPC service adapters and the
4//! [`Backend`] implementations.
5//! Given a **dotted catalog name** (the wire `key_id`), it resolves the key's
6//! [`KeyEntry`] in the catalog routing table, picks the named [`Backend`]
7//! instance, and dispatches the op against the key's backend-native `path`.
8//!
9//! A down backend fails only the ops routed to it. Each key's resolution is
10//! independent, and a backend error is surfaced as [`ManagerError::Backend`]
11//! without affecting any other key.
12//!
13//! # Op surface
14//!
15//! The manager routes `new_key`, `sign`, `verify`, `get_public_key` (with real
16//! algorithm + version metadata), `import` (BYOK), `encrypt`/`decrypt`, `rotate`,
17//! `get`/`set` (KV-v2 value read/write), and `list` (value-free key metadata),
18//! after checking the op is valid for the key's [`Class`] (e.g. `sign` only on
19//! `asymmetric`, `get` only on `value`/`public`, `set` only on `value`).
20
21use std::collections::BTreeMap;
22use std::path::Path;
23use std::process::Command;
24use std::sync::Arc;
25
26use age::secrecy::ExposeSecret as _;
27use basil_cose::Recipient as _;
28use basil_proto::{
29    AeadAlgorithm, CatalogEntry as WireCatalogEntry, CatalogKind, CiphertextEnvelope, KeyMaterial,
30    KeyType,
31};
32use rand::RngCore;
33use uuid::Uuid;
34
35use zeroize::Zeroizing;
36
37use crate::backend::{
38    Backend, BackendError, KvValue, NativeAlgorithm, NewKey, PublicKey, SignOptions,
39    X509CertRequest, X509Svid,
40};
41use crate::catalog::{BackendRef, Catalog, Class, Engine, GenerateSpec, KeyAlgorithm, KeyEntry};
42use crate::core::crypto_provider::LocalSoftwareProvider;
43use crate::core::crypto_provider::{
44    BackendCryptoProvider, CryptoProvider, CryptoProviderId, CustodyMode,
45    Envelope as ProviderEnvelope, EnvelopeAlgorithm as ProviderEnvelopeAlgorithm,
46    GenerateKey as ProviderGenerateKey, GenerateSealingKey as ProviderGenerateSealingKey,
47    KemAlgorithm as ProviderKemAlgorithm, ProviderError, ProviderMetadata, ProviderPolicy,
48    SignRequest, SignatureAlgorithm, UnwrapEnvelopeRequest as ProviderUnwrapEnvelopeRequest,
49    VerifyRequest, WrapEnvelopeRequest as ProviderWrapEnvelopeRequest, ml_dsa_signature_algorithm,
50    select_provider,
51};
52use crate::ed25519_sign::{self, SignError};
53use crate::state::BrokerLimits;
54use crate::x25519_seal::{self, SealError, SealedEnvelope};
55
56/// An error from resolving or routing a catalog key. Fails closed; never panics.
57#[derive(Debug, thiserror::Error)]
58pub enum ManagerError {
59    /// The dotted name is not in the catalog's key inventory.
60    #[error("unknown key: {0}")]
61    UnknownKey(String),
62
63    /// A key entry names a backend instance that was not provided at construction.
64    /// Caught at [`BackendManager::new`]; never surfaces at request time.
65    #[error("key `{key}` references unknown backend `{backend}`")]
66    UnknownBackend {
67        /// The offending key name.
68        key: String,
69        /// The backend name the key references.
70        backend: String,
71    },
72
73    /// The requested op is not valid for the resolved key's [`Class`]
74    /// (e.g. `sign` on a `value` key).
75    #[error("op `{op}` is not valid for key `{key}` (class {class:?})")]
76    OpNotValidForClass {
77        /// The op that was attempted.
78        op: &'static str,
79        /// The key it was attempted on.
80        key: String,
81        /// The key's class.
82        class: Class,
83    },
84
85    /// The backend's static capability preset does not declare support for this
86    /// key type on a mint/import/generate path.
87    #[error("backend `{backend}` does not declare support for {op} key type `{key_type}`")]
88    UnsupportedKeyType {
89        /// Backend catalog name.
90        backend: String,
91        /// Operation that required native key-type support.
92        op: &'static str,
93        /// Requested wire key type.
94        key_type: KeyType,
95    },
96
97    /// The op is recognized but its [`Backend`] method does not exist yet
98    /// (a later per-op issue + `Backend`-trait expansion backs it).
99    #[error("op `{0}` is recognized but not yet backed by a Backend method")]
100    Unsupported(&'static str),
101
102    /// An `encrypt` `algorithm` that does not match the key's catalog `keyType`
103    /// (e.g. `chacha20-poly1305` against an `aes-256-gcm` key), or an `encrypt`
104    /// on a key whose catalog `keyType` is not an AEAD suite. Maps to the wire
105    /// `invalid_request` (§4.2).
106    #[error("algorithm `{requested}` does not match key `{key}` (catalog type {actual})")]
107    AlgorithmMismatch {
108        /// The key whose catalog type was consulted.
109        key: String,
110        /// The requested AEAD suite.
111        requested: AeadAlgorithm,
112        /// The key's catalog AEAD type (or a note that it is not symmetric).
113        actual: &'static str,
114    },
115
116    /// A KEM envelope requests an algorithm that does not match the sealing key's
117    /// catalog `keyType`.
118    #[error("KEM algorithm `{requested}` does not match key `{key}` (catalog type {actual})")]
119    KemAlgorithmMismatch {
120        /// The key whose catalog type was consulted.
121        key: String,
122        /// Requested KEM algorithm token.
123        requested: &'static str,
124        /// Catalog algorithm token.
125        actual: &'static str,
126    },
127
128    /// `rotate` on a `value` key that has **no** `generate` recipe: there is no
129    /// fresh value to write, so rotation is `invalid_request`: use `set` with
130    /// out-of-band material instead (§7 / `vault-a2p`).
131    #[error("value key `{0}` has no generate recipe; rotate via `set` instead")]
132    ValueRotateNeedsSet(String),
133
134    /// A sealing op (wrap/unwrap/`get_public_key`) failed: a malformed
135    /// materialized private key, a malformed envelope, or an AEAD authentication
136    /// failure. The open-side failure is opaque (no oracle).
137    #[error("sealing op failed: {0}")]
138    Sealing(SealingFailure),
139
140    /// The caller holds `op:decrypt` on this sealing key, but the key pins an
141    /// allowed COSE unseal context (KDF party identities and/or `external_aad`)
142    /// that this envelope is not bound to (`basil-2rqj`). A least-privilege
143    /// refusal: the grant authorizes only the pinned contexts, never any envelope
144    /// addressed to the key. Fails closed as permission-denied. Carries **no**
145    /// secret material: the party identities are cleartext header values and the
146    /// `external_aad` is caller-supplied.
147    #[error("key `{0}` does not authorize this unseal context")]
148    UnsealContextNotPermitted(String),
149
150    /// A value-store (`engine=kv2`) Ed25519 materialize-to-sign op
151    /// (`sign`/`verify`/`get_public_key`) failed because the materialized seed or a
152    /// verify input was malformed (wrong length). Carries no secret material.
153    #[error("materialize-to-sign op failed: {0}")]
154    Signing(SigningFailure),
155
156    /// A materialize-to-use key (`sealing` / `asymmetric`+`engine=kv2`) was asked
157    /// for its public half but carries no `public_path`. A catalog loaded through
158    /// [`crate::catalog::load`] always has one (loader guardrail, basil-o86); this
159    /// fails closed when the manager was built from an unvalidated catalog rather
160    /// than re-deriving the public from the private (which the op surface forbids).
161    #[error("key `{0}` has no public_path; its public half cannot be resolved")]
162    MissingPublicPath(String),
163
164    /// A provider-dispatched (ML-DSA software-custody) operation failed: an
165    /// unsupported algorithm/provider combination, a policy denial of the
166    /// local-software provider, or an opaque software-custody crypto failure.
167    /// Carries no secret material.
168    #[error("provider error: {0}")]
169    Provider(#[from] ProviderError),
170
171    /// The resolved backend returned an error for this op.
172    #[error("backend error: {0}")]
173    Backend(#[from] BackendError),
174}
175
176/// Why a sealing operation failed.
177///
178/// Maps the crypto-core errors onto the manager surface, distinguishing a
179/// configuration/input fault (malformed key or envelope) from an authentication
180/// failure on open (which stays opaque, no oracle about *why* it failed).
181#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
182pub enum SealingFailure {
183    /// The materialized private key or an envelope field was the wrong length, or
184    /// key derivation/seal failed on otherwise valid inputs.
185    #[error("malformed sealing material or envelope")]
186    Malformed,
187
188    /// AEAD authentication failed on unwrap: a wrong key, a tampered envelope, or
189    /// a mismatched `aad`. Opaque on purpose.
190    #[error("unseal authentication failed")]
191    OpenFailed,
192}
193
194impl SealingFailure {
195    /// Project a crypto-core [`SealError`] onto the coarse manager surface,
196    /// collapsing every non-authentication fault to [`SealingFailure::Malformed`]
197    /// and the AEAD failure to [`SealingFailure::OpenFailed`].
198    const fn from_seal(err: SealError) -> Self {
199        match err {
200            SealError::OpenFailed => Self::OpenFailed,
201            SealError::BadKeyLength { .. }
202            | SealError::BadNonceLength { .. }
203            | SealError::KdfFailed
204            | SealError::SealFailed => Self::Malformed,
205        }
206    }
207
208    const fn from_cose_open(err: &basil_cose::OpenError) -> Self {
209        match err {
210            basil_cose::OpenError::OpenFailed => Self::OpenFailed,
211            basil_cose::OpenError::Decode(_)
212            | basil_cose::OpenError::RecipientKeyMismatch
213            | basil_cose::OpenError::PartyMismatch
214            | basil_cose::OpenError::Provider { .. } => Self::Malformed,
215        }
216    }
217}
218
219const fn nats_curve_error(err: &basil_nats::Error) -> ManagerError {
220    let failure = match err {
221        basil_nats::Error::XKeyOpenFailed => SealingFailure::OpenFailed,
222        basil_nats::Error::BadPublicKeyLen(_)
223        | basil_nats::Error::UnsupportedPrefix(_)
224        | basil_nats::Error::UnexpectedPrefix { .. }
225        | basil_nats::Error::UnexpectedXKeyPrefix(_)
226        | basil_nats::Error::BadXKeyVersion
227        | basil_nats::Error::BadXKeyCiphertextLen(_)
228        | basil_nats::Error::XKeySealFailed
229        | basil_nats::Error::Json(_)
230        | basil_nats::Error::InvalidClaims(_)
231        | basil_nats::Error::MalformedJwt(_)
232        | basil_nats::Error::BadSignatureLen(_) => SealingFailure::Malformed,
233    };
234    ManagerError::Sealing(failure)
235}
236
237/// Why a value-store Ed25519 materialize-to-sign op failed.
238///
239/// The only failure mode is a length/format fault on the materialized seed (a
240/// misprovisioned KV value) or on a verify input (`public`/`signature` length).
241/// Carries **no** secret material, never a byte of the seed.
242#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
243pub enum SigningFailure {
244    /// The materialized seed was not 32 bytes, or a verify input was the wrong
245    /// length. Maps from the crypto-core [`SignError`].
246    #[error("malformed signing seed or verify input")]
247    Malformed,
248}
249
250impl SigningFailure {
251    /// Project a crypto-core [`SignError`] onto the manager surface. Every
252    /// `SignError` is a fixed-length validation fault, so all collapse to
253    /// [`SigningFailure::Malformed`].
254    const fn from_sign(err: SignError) -> Self {
255        match err {
256            SignError::BadSeedLength { .. } | SignError::BadFieldLength { .. } => Self::Malformed,
257        }
258    }
259}
260
261/// Caller-scoped policy inputs the provider-dispatch (ML-DSA) path needs beyond
262/// the catalog labels.
263///
264/// `local_software_allowed` is the resolved `op:use_software_custody` PDP grant
265/// (decided in the service layer from the kernel-attested caller). It feeds
266/// [`select_provider`](crate::core::crypto_provider::select_provider): the
267/// local-software crypto provider is selectable only when policy explicitly
268/// grants its use, so a software-custodied key fails closed for a caller that
269/// holds only the bare `op:sign` grant.
270#[derive(Debug, Clone, Copy)]
271pub struct ProviderGate {
272    /// Whether policy grants this caller use of the local-software provider.
273    pub local_software_allowed: bool,
274}
275
276/// What a provider-dispatched operation selected, surfaced so the service layer
277/// can record a [`ProviderAuditEvent`](crate::core::crypto_provider::ProviderAuditEvent)
278/// carrying the provider and algorithm.
279#[derive(Debug, Clone, Copy)]
280pub struct ProviderDispatch {
281    /// The provider that executed the operation.
282    pub provider: CryptoProviderId,
283    /// The algorithm token (e.g. `ml-dsa-65`).
284    pub algorithm: &'static str,
285    /// The key's custody mode.
286    pub custody: CustodyMode,
287}
288
289/// Admin-observable provider metadata for one catalog key (`basil-wuj.10`).
290///
291/// The read side of provider/custody observability: which provider policy and
292/// custody mode a key is provisioned under, the recorded provider version, and
293/// whether its backend now natively supports the key's algorithm (i.e. a
294/// backend-native migration is available). It is built from reserved catalog
295/// labels plus the cheap capability probe and carries **no key material**.
296#[derive(Debug, Clone, PartialEq, Eq)]
297pub struct KeyProviderDescriptor {
298    /// The key's declared provider policy (`crypto_provider_policy`, defaulting to
299    /// backend-required).
300    pub policy: ProviderPolicy,
301    /// The provider named by the `crypto_provider` label, if any.
302    pub provider: Option<CryptoProviderId>,
303    /// The recorded custody mode (`pqc_custody` label), if any: the **active**
304    /// custody for a provisioned key.
305    pub custody: Option<CustodyMode>,
306    /// The `crypto_provider_version` label, if recorded.
307    pub version: Option<String>,
308    /// Whether the key's backend now reports native support for its algorithm:
309    /// `true` means a backend-native migration is available. Always `false` for a
310    /// non-PQC key or a backend without native support.
311    pub backend_native_available: bool,
312}
313
314/// The wire byte fields of an ML-KEM envelope to unwrap.
315///
316/// Grouped so the provider-dispatched
317/// [`BackendManager::provider_unwrap_envelope`] takes one self-describing input
318/// instead of three positional slices.
319#[derive(Debug, Clone, Copy)]
320pub struct MlKemEnvelopeParts<'a> {
321    /// ML-KEM ciphertext / encapsulated shared secret.
322    pub encapsulated_key: &'a [u8],
323    /// Broker/provider-owned AEAD nonce.
324    pub nonce: &'a [u8],
325    /// AEAD ciphertext including the authentication tag.
326    pub ciphertext: &'a [u8],
327}
328
329/// A resolved route: the [`Backend`] instance plus the key's catalog metadata.
330///
331/// Borrows the [`BackendManager`]; the `path` is the backend-native locator the
332/// op is dispatched against (transit key name / KV path), and `engine` is the
333/// effective engine (inferred from `class` when the catalog omits it, §2.2).
334pub struct Routed<'a> {
335    /// The backend instance this key routes to.
336    pub backend: &'a dyn Backend,
337    /// The key's catalog entry (class, `key_type`, path, …).
338    pub entry: &'a KeyEntry,
339    /// The backend catalog declaration this key routes through.
340    pub backend_ref: &'a BackendRef,
341    /// The effective sub-engine (catalog value, or inferred from `class`).
342    pub engine: Engine,
343}
344
345// `Backend` is not `Debug` (it's a trait object), so `Routed` can't derive it.
346// We report the backend by its stable `kind()` name instead.
347impl std::fmt::Debug for Routed<'_> {
348    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
349        f.debug_struct("Routed")
350            .field("backend", &self.backend.kind())
351            .field("entry", &self.entry)
352            .field("backend_ref", &self.backend_ref)
353            .field("engine", &self.engine)
354            .finish()
355    }
356}
357
358impl Routed<'_> {
359    /// The backend-native locator (transit key name / KV path) for this key.
360    #[must_use]
361    pub fn path(&self) -> &str {
362        &self.entry.path
363    }
364
365    /// The KV path holding this key's **public** half, when it is a
366    /// materialize-to-use key whose public is provisioned out of band (basil-o86).
367    /// `None` for every key that uses its backend in place.
368    #[must_use]
369    pub fn public_path(&self) -> Option<&str> {
370        self.entry.public_path.as_deref()
371    }
372
373    /// The key's class.
374    #[must_use]
375    pub const fn class(&self) -> Class {
376        self.entry.class
377    }
378
379    /// The key's crypto algorithm, if any (absent for `value` keys).
380    #[must_use]
381    pub const fn key_type(&self) -> Option<KeyAlgorithm> {
382        self.entry.key_type
383    }
384
385    const fn backend_declares_provides(&self) -> bool {
386        !(self.backend_ref.engines.is_empty()
387            && self.backend_ref.capabilities.is_empty()
388            && self.backend_ref.mint_key_types.is_empty())
389    }
390
391    pub(crate) fn require_mint_key_type(
392        &self,
393        op: &'static str,
394        key_type: KeyType,
395    ) -> Result<(), ManagerError> {
396        let algorithm = KeyAlgorithm::from_wire_key_type(key_type);
397        if !self.backend_declares_provides() || self.backend_ref.mint_key_types.contains(&algorithm)
398        {
399            return Ok(());
400        }
401        Err(ManagerError::UnsupportedKeyType {
402            backend: self.entry.backend.clone(),
403            op,
404            key_type,
405        })
406    }
407}
408
409/// Routes catalog keys to their declared backend instances.
410///
411/// Constructed from a validated [`Catalog`] plus a map of already-built backend
412/// instances keyed by the catalog `backends` names. Constructing the backends
413/// from credentials is out of scope (that is `vault-vh1` / the bin); this layer
414/// only routes.
415pub struct BackendManager {
416    catalog: Arc<Catalog>,
417    backends: BTreeMap<String, Box<dyn Backend>>,
418}
419
420// `Box<dyn Backend>` is not `Debug`; report the routing table by name only.
421impl std::fmt::Debug for BackendManager {
422    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
423        f.debug_struct("BackendManager")
424            .field("backends", &self.backends.keys().collect::<Vec<_>>())
425            .field("keys", &self.catalog.keys.keys().collect::<Vec<_>>())
426            .finish()
427    }
428}
429
430impl BackendManager {
431    /// Build a manager from a catalog + the named, already-constructed backends.
432    ///
433    /// Validates that every `catalog.keys[*].backend` names a present backend
434    /// instance, failing closed with [`ManagerError::UnknownBackend`] otherwise.
435    ///
436    /// # Errors
437    ///
438    /// Returns [`ManagerError::UnknownBackend`] if a key references a backend
439    /// name absent from `backends`.
440    pub fn new(
441        catalog: Catalog,
442        backends: BTreeMap<String, Box<dyn Backend>>,
443    ) -> Result<Self, ManagerError> {
444        for (name, entry) in &catalog.keys {
445            if !backends.contains_key(&entry.backend) {
446                return Err(ManagerError::UnknownBackend {
447                    key: name.clone(),
448                    backend: entry.backend.clone(),
449                });
450            }
451        }
452        Ok(Self {
453            catalog: Arc::new(catalog),
454            backends,
455        })
456    }
457
458    /// Shared immutable catalog this manager routes against.
459    #[must_use]
460    pub fn catalog(&self) -> Arc<Catalog> {
461        Arc::clone(&self.catalog)
462    }
463
464    /// Resolve a dotted catalog name to its backend instance + metadata.
465    ///
466    /// # Errors
467    ///
468    /// - [`ManagerError::UnknownKey`] if `key_id` is not in the catalog.
469    /// - [`ManagerError::UnknownBackend`] if the key's backend is missing
470    ///   (impossible after [`BackendManager::new`] validation, but checked so
471    ///   resolution never panics on a missing instance).
472    pub fn resolve(&self, key_id: &str) -> Result<Routed<'_>, ManagerError> {
473        let entry = self
474            .catalog
475            .keys
476            .get(key_id)
477            .ok_or_else(|| ManagerError::UnknownKey(key_id.to_string()))?;
478        let backend_ref = self.catalog.backends.get(&entry.backend).ok_or_else(|| {
479            ManagerError::UnknownBackend {
480                key: key_id.to_string(),
481                backend: entry.backend.clone(),
482            }
483        })?;
484        let backend =
485            self.backends
486                .get(&entry.backend)
487                .ok_or_else(|| ManagerError::UnknownBackend {
488                    key: key_id.to_string(),
489                    backend: entry.backend.clone(),
490                })?;
491        Ok(Routed {
492            backend: backend.as_ref(),
493            entry,
494            backend_ref,
495            engine: effective_engine(entry),
496        })
497    }
498
499    /// Iterate the catalog's `(dotted name, entry)` pairs, in name order.
500    ///
501    /// Used by startup reconcile (`vault-zrg`) to walk every key and apply its
502    /// `missing` policy; kept `pub(crate)` so reconcile lives in its own module
503    /// without exposing the routing table publicly.
504    pub(crate) fn keys(&self) -> impl Iterator<Item = (&String, &KeyEntry)> {
505        self.catalog.keys.iter()
506    }
507
508    /// `new_key` creates a key under catalog name `key_id`.
509    ///
510    /// Valid for crypto keys (`asymmetric` / `symmetric`). This is the request-time
511    /// op, where the backend assigns the on-backend id. Startup reconcile
512    /// (`crate::reconcile`) instead creates a `generate`-policy key **at its catalog
513    /// `path`** via [`Backend::create_named_key`] / [`Backend::create_named_aead`]
514    /// so the named material exists before any op resolves to it.
515    ///
516    /// # Errors
517    ///
518    /// [`ManagerError::UnknownKey`], [`ManagerError::OpNotValidForClass`] (for a
519    /// `value` / `public` key), or [`ManagerError::Backend`].
520    pub async fn new_key(&self, key_id: &str, key_type: KeyType) -> Result<NewKey, ManagerError> {
521        let routed = self.resolve(key_id)?;
522        require_class(
523            "new_key",
524            key_id,
525            routed.class(),
526            &[Class::Asymmetric, Class::Symmetric],
527        )?;
528        if routed.class() == Class::Asymmetric {
529            routed.require_mint_key_type("new_key", key_type)?;
530        }
531        Ok(routed.backend.new_key(key_type).await?)
532    }
533
534    /// `sign` signs `message` with `key_id`.
535    ///
536    /// Valid only for `asymmetric` keys. Two custody arms by effective engine:
537    ///
538    /// - **transit** (the default): the backend signs *in place*; the private
539    ///   never leaves the vault. This is the strong key-never-leaves guarantee.
540    /// - **`kv2`** (an explicit `engine=kv2` Ed25519 signing key): the materialize-
541    ///   to-sign arm (design §17.7, `vault-iiz`): the 32-byte Ed25519 seed is
542    ///   materialized from KV (`Zeroizing` end-to-end), used for exactly one
543    ///   signature, then zeroized. The key-never-leaves guarantee holds for transit
544    ///   **only**; the `kv2` arm is the sanctioned trade-off for a backend with no
545    ///   in-place sign primitive (e.g. a RAM-constrained device that can't run
546    ///   transit). The seed is never returned, logged, or audited.
547    ///
548    /// # Errors
549    ///
550    /// [`ManagerError::UnknownKey`], [`ManagerError::OpNotValidForClass`],
551    /// [`ManagerError::Signing`] (a malformed materialized seed on the `kv2` arm),
552    /// or [`ManagerError::Backend`].
553    pub async fn sign(&self, key_id: &str, message: &[u8]) -> Result<Vec<u8>, ManagerError> {
554        let routed = self.resolve(key_id)?;
555        require_class("sign", key_id, routed.class(), &[Class::Asymmetric])?;
556        if routed.engine == Engine::Kv2 {
557            // Materialize-to-sign: the seed lives only in `Zeroizing` and the
558            // ed25519-dalek `SigningKey` (ZeroizeOnDrop) inside this scope.
559            let seed = self.materialize_signing_seed(key_id, "sign").await?;
560            return Ok(ed25519_sign::sign(&seed, message).to_vec());
561        }
562        Ok(routed
563            .backend
564            .sign_with_options(
565                routed.path(),
566                message,
567                sign_options_for_key(routed.key_type()),
568            )
569            .await?)
570    }
571
572    /// `verify` verifies `signature` over `message` with `key_id`.
573    ///
574    /// Valid for `asymmetric` and `public` keys (the public half verifies). For an
575    /// `engine=kv2` Ed25519 signing key (`vault-iiz`), verification is a public op:
576    /// the public half is derived from the materialized seed and the signature is
577    /// checked in-process (the seed is zeroized before this returns).
578    ///
579    /// # Errors
580    ///
581    /// [`ManagerError::UnknownKey`], [`ManagerError::OpNotValidForClass`],
582    /// [`ManagerError::Signing`] (a malformed seed or `signature` length on the
583    /// `kv2` arm), or [`ManagerError::Backend`].
584    pub async fn verify(
585        &self,
586        key_id: &str,
587        message: &[u8],
588        signature: &[u8],
589    ) -> Result<bool, ManagerError> {
590        let routed = self.resolve(key_id)?;
591        require_class(
592            "verify",
593            key_id,
594            routed.class(),
595            &[Class::Asymmetric, Class::Public],
596        )?;
597        if routed.class() == Class::Asymmetric && routed.engine == Engine::Kv2 {
598            // Public op (basil-o86): read the out-of-band-provisioned public from
599            // KV and verify in-process. The seed is NEVER materialized for a
600            // verify, only `sign` (the private op) materializes it.
601            let public_bytes = self
602                .read_public_half(key_id, "verify", &[Class::Asymmetric])
603                .await?;
604            let public = ed25519_sign::public_from_slice(&public_bytes)
605                .map_err(|e| ManagerError::Signing(SigningFailure::from_sign(e)))?;
606            return ed25519_sign::verify(&public, message, signature)
607                .map_err(|e| ManagerError::Signing(SigningFailure::from_sign(e)));
608        }
609        Ok(routed
610            .backend
611            .verify_with_options(
612                routed.path(),
613                message,
614                signature,
615                sign_options_for_key(routed.key_type()),
616            )
617            .await?)
618    }
619
620    /// `get_public_key` reads the public half **plus** metadata (real algorithm
621    /// + current version) for `key_id`.
622    ///
623    /// Valid for `asymmetric` and `public` keys, plus **software-custody ML-KEM
624    /// sealing keys** (basil-4ybx): for those the public *encapsulation* key was
625    /// derived at provisioning time and recorded in the custody record, and is
626    /// returned WITHOUT materializing the decapsulation seed (the seed is touched
627    /// only by `unwrap`). The returned [`PublicKey`] is what the handler echoes
628    /// verbatim, replacing the previous hardcoded `ed25519` / `version 1`
629    /// (`vault-k3w`).
630    ///
631    /// # Errors
632    ///
633    /// [`ManagerError::UnknownKey`], [`ManagerError::OpNotValidForClass`],
634    /// or [`ManagerError::Backend`].
635    pub async fn get_public_key(&self, key_id: &str) -> Result<PublicKey, ManagerError> {
636        let routed = self.resolve(key_id)?;
637        // ML-KEM sealing key (basil-4ybx): a software-custody sealing key whose
638        // public *encapsulation* key was derived from the seed at provisioning
639        // time and recorded (non-secret) in the custody record. Return it WITHOUT
640        // materializing the decapsulation seed: the sealing-class dual of the
641        // ML-DSA software-custody read below, and the read path senders use to
642        // seal payloads to this recipient. Handled before the asymmetric/public
643        // class gate, which is scoped to the signing/value read paths and would
644        // otherwise reject a sealing key.
645        if routed.class() == Class::Sealing
646            && ProviderMetadata::from_key(routed.entry).custody
647                == Some(CustodyMode::SoftwareEncrypted)
648            && let Some(kem) = routed.key_type().and_then(ml_kem_provider_algorithm)
649        {
650            let public_key = LocalSoftwareProvider::new(routed.backend)
651                .public_key(key_id, routed.path(), kem.token())
652                .await?;
653            return Ok(PublicKey {
654                public_key,
655                key_type: ml_kem_wire_key_type(kem),
656                // A software-custody record is a single fixed version.
657                version: 1,
658            });
659        }
660        require_class(
661            "get_public_key",
662            key_id,
663            routed.class(),
664            &[Class::Asymmetric, Class::Public],
665        )?;
666        // ML-DSA software-custody signing key (basil-a36l): the verifying key was
667        // derived from the seed at provisioning time and recorded (non-secret) in
668        // the custody record. Return it WITHOUT materializing the private seed: a
669        // pure public read, consistent with the `verify` path that also reads the
670        // recorded public. The classical transit metadata read below would return
671        // garbage for a KV-custodied ML-DSA key (its `path` is a KV path, not a
672        // transit key name).
673        if ProviderMetadata::from_key(routed.entry).custody == Some(CustodyMode::SoftwareEncrypted)
674            && let Some(algorithm) = routed.key_type().and_then(ml_dsa_signature_algorithm)
675        {
676            let public_key = LocalSoftwareProvider::new(routed.backend)
677                .public_key(key_id, routed.path(), algorithm.token())
678                .await?;
679            return Ok(PublicKey {
680                public_key,
681                key_type: ml_dsa_wire_key_type(algorithm),
682                // A software-custody record is a single fixed version.
683                version: 1,
684            });
685        }
686        if routed.class() == Class::Asymmetric && routed.engine == Engine::Kv2 {
687            // Public op (basil-o86): read the out-of-band-provisioned public from
688            // KV; the seed is NEVER materialized (only `sign` materializes it).
689            let public_bytes = self
690                .read_public_half(key_id, "get_public_key", &[Class::Asymmetric])
691                .await?;
692            let public = ed25519_sign::public_from_slice(&public_bytes)
693                .map_err(|e| ManagerError::Signing(SigningFailure::from_sign(e)))?;
694            return Ok(PublicKey {
695                public_key: public.to_vec(),
696                key_type: KeyType::Ed25519,
697                // A KV-stored seed is a single fixed version (no transit version
698                // counter); report version 1.
699                version: 1,
700            });
701        }
702        Ok(routed.backend.public_key_with_meta(routed.path()).await?)
703    }
704
705    /// The provider [`SignatureAlgorithm`] for an ML-DSA signing key, or `None`
706    /// for an unknown key or a classical signing key (which uses the in-place
707    /// backend signing path).
708    ///
709    /// This is the routing signal the signing service uses to choose between the
710    /// classical [`Self::sign`]/[`Self::verify`] path and the provider-dispatch
711    /// [`Self::provider_sign`]/[`Self::provider_verify`] path.
712    #[must_use]
713    pub fn ml_dsa_algorithm_for(&self, key_id: &str) -> Option<SignatureAlgorithm> {
714        let routed = self.resolve(key_id).ok()?;
715        ml_dsa_signature_algorithm(routed.key_type()?)
716    }
717
718    /// The provider [`ProviderKemAlgorithm`] for an ML-KEM **sealing** key, or
719    /// `None` for an unknown key or any non-ML-KEM key (an X25519 sealing key signs
720    /// through the classical sealing path, not provider provisioning).
721    ///
722    /// The routing signal the signing service uses to dispatch a `new_key` for an
723    /// ML-KEM sealing key through [`Self::provider_generate_sealing`].
724    #[must_use]
725    pub fn ml_kem_algorithm_for(&self, key_id: &str) -> Option<ProviderKemAlgorithm> {
726        let routed = self.resolve(key_id).ok()?;
727        ml_kem_provider_algorithm(routed.key_type()?)
728    }
729
730    /// Describe the provider/custody/version a key is provisioned under, plus
731    /// whether its backend now natively supports the key's algorithm: the admin
732    /// read seam for observing the **active** custody mode and whether a
733    /// backend-native migration is available (`basil-wuj.10`).
734    ///
735    /// A pure read: it resolves the key, parses its reserved catalog labels, and
736    /// runs the cheap capability probe. It never performs a crypto operation,
737    /// reads key material, or materializes a seed, so it needs no caller gate.
738    ///
739    /// # Errors
740    ///
741    /// [`ManagerError::UnknownKey`] if `key_id` is not in the catalog.
742    pub fn describe_provider(&self, key_id: &str) -> Result<KeyProviderDescriptor, ManagerError> {
743        let routed = self.resolve(key_id)?;
744        let metadata = ProviderMetadata::from_key(routed.entry);
745        let native = routed
746            .key_type()
747            .and_then(ml_dsa_signature_algorithm)
748            .and_then(SignatureAlgorithm::native_algorithm);
749        let backend_native_available =
750            native.is_some_and(|algorithm| routed.backend.supports_native_algorithm(algorithm));
751        Ok(KeyProviderDescriptor {
752            policy: metadata.policy,
753            provider: metadata.provider,
754            custody: metadata.custody,
755            version: routed
756                .entry
757                .labels
758                .get("crypto_provider_version")
759                .map(str::to_owned),
760            backend_native_available,
761        })
762    }
763
764    /// `sign` for an ML-DSA software-custodied key, dispatched through the
765    /// provider that [`select_provider`] chooses from the key's catalog
766    /// policy/custody labels and the caller's `gate`.
767    ///
768    /// The signature is produced by the selected [`CryptoProvider`]; the private
769    /// seed is materialized **inside the provider** for exactly one signature and
770    /// zeroized. It is never returned, logged, or audited. Returns the signature
771    /// plus a [`ProviderDispatch`] describing the selected provider/algorithm for
772    /// the audit trail.
773    ///
774    /// # Errors
775    ///
776    /// [`ManagerError::UnknownKey`], [`ManagerError::OpNotValidForClass`] (a
777    /// non-asymmetric key), [`ManagerError::Unsupported`] (not an ML-DSA key), or
778    /// [`ManagerError::Provider`] (an unsupported algorithm/provider combination,
779    /// a policy denial, or an opaque software-custody crypto failure).
780    pub async fn provider_sign(
781        &self,
782        key_id: &str,
783        message: &[u8],
784        gate: ProviderGate,
785    ) -> Result<(Vec<u8>, ProviderDispatch), ManagerError> {
786        let routed = self.resolve(key_id)?;
787        require_class("sign", key_id, routed.class(), &[Class::Asymmetric])?;
788        let algorithm = require_ml_dsa(key_id, &routed)?;
789        let provider = select_provider_for(&routed, algorithm.native_algorithm(), gate, "sign")?;
790        let request = SignRequest {
791            key_id,
792            backend_path: routed.path(),
793            algorithm,
794            message,
795        };
796        let signature = match provider {
797            CryptoProviderId::VaultTransit => {
798                BackendCryptoProvider::new(routed.backend)
799                    .sign(request)
800                    .await?
801            }
802            CryptoProviderId::LocalSoftware => {
803                LocalSoftwareProvider::new(routed.backend)
804                    .sign(request)
805                    .await?
806            }
807        };
808        Ok((signature, provider_dispatch(provider, algorithm)))
809    }
810
811    /// `verify` for an ML-DSA software-custodied key, dispatched through the
812    /// selected provider. Verification needs only the published public key (no
813    /// seed is materialized). Returns the validity plus the [`ProviderDispatch`].
814    ///
815    /// # Errors
816    ///
817    /// As [`Self::provider_sign`].
818    pub async fn provider_verify(
819        &self,
820        key_id: &str,
821        message: &[u8],
822        signature: &[u8],
823        gate: ProviderGate,
824    ) -> Result<(bool, ProviderDispatch), ManagerError> {
825        let routed = self.resolve(key_id)?;
826        require_class(
827            "verify",
828            key_id,
829            routed.class(),
830            &[Class::Asymmetric, Class::Public],
831        )?;
832        let algorithm = require_ml_dsa(key_id, &routed)?;
833        let provider = select_provider_for(&routed, algorithm.native_algorithm(), gate, "verify")?;
834        let request = VerifyRequest {
835            key_id,
836            backend_path: routed.path(),
837            algorithm,
838            message,
839            signature,
840        };
841        let valid = match provider {
842            CryptoProviderId::VaultTransit => {
843                BackendCryptoProvider::new(routed.backend)
844                    .verify(request)
845                    .await?
846            }
847            CryptoProviderId::LocalSoftware => {
848                LocalSoftwareProvider::new(routed.backend)
849                    .verify(request)
850                    .await?
851            }
852        };
853        Ok((valid, provider_dispatch(provider, algorithm)))
854    }
855
856    /// `new_key` for an ML-DSA software-custodied key: generate a keypair, seal
857    /// the private seed into an encrypted custody record, and write it to KV, all
858    /// inside the selected provider, so the private is never exposed to the
859    /// caller. Returns the new key (public half only) plus the [`ProviderDispatch`].
860    ///
861    /// # Errors
862    ///
863    /// As [`Self::provider_sign`].
864    pub async fn provider_generate(
865        &self,
866        key_id: &str,
867        gate: ProviderGate,
868    ) -> Result<(NewKey, ProviderDispatch), ManagerError> {
869        let routed = self.resolve(key_id)?;
870        require_class("new_key", key_id, routed.class(), &[Class::Asymmetric])?;
871        let algorithm = require_ml_dsa(key_id, &routed)?;
872        let provider =
873            select_provider_for(&routed, algorithm.native_algorithm(), gate, "generate")?;
874        let request = ProviderGenerateKey {
875            key_id,
876            backend_path: routed.path(),
877            algorithm,
878            storage_key: routed.entry.labels.get("pqc_storage_key"),
879        };
880        let created = match provider {
881            CryptoProviderId::VaultTransit => {
882                BackendCryptoProvider::new(routed.backend)
883                    .generate_key(request)
884                    .await?
885            }
886            CryptoProviderId::LocalSoftware => {
887                LocalSoftwareProvider::new(routed.backend)
888                    .generate_key(request)
889                    .await?
890            }
891        };
892        Ok((created, provider_dispatch(provider, algorithm)))
893    }
894
895    /// `new_key` for an ML-KEM software-custodied **sealing** key, dispatched
896    /// through the crypto provider that [`select_provider`] chooses from the key's
897    /// catalog policy/custody labels and the caller's `gate`.
898    ///
899    /// The provider generates a fresh 64-byte ML-KEM seed, seals it under the
900    /// catalog `pqc_storage_key` AEAD key, writes the custody record, and returns
901    /// the derived encapsulation (public) key plus a [`ProviderDispatch`] for the
902    /// audit trail. The seed never leaves the provider; no private material is
903    /// returned. The requested `kem` must match the key's catalog `keyType`.
904    ///
905    /// # Errors
906    ///
907    /// [`ManagerError::UnknownKey`], [`ManagerError::OpNotValidForClass`] (a
908    /// non-sealing key), [`ManagerError::KemAlgorithmMismatch`] (the catalog
909    /// `keyType` is not the requested ML-KEM level), or
910    /// [`ManagerError::Provider`] (a policy denial or an opaque software-custody
911    /// crypto failure).
912    pub async fn provider_generate_sealing(
913        &self,
914        key_id: &str,
915        kem: ProviderKemAlgorithm,
916        gate: ProviderGate,
917    ) -> Result<(NewKey, ProviderDispatch), ManagerError> {
918        let routed = self.resolve(key_id)?;
919        require_class("new_key", key_id, routed.class(), &[Class::Sealing])?;
920        require_ml_kem_sealing_key(key_id, routed.key_type(), kem)?;
921        let provider = select_provider_for(&routed, kem.native_algorithm(), gate, "generate")?;
922        let request = ProviderGenerateSealingKey {
923            key_id,
924            backend_path: routed.path(),
925            algorithm: kem,
926            storage_key: routed.entry.labels.get("pqc_storage_key"),
927        };
928        let created = match provider {
929            CryptoProviderId::VaultTransit => {
930                BackendCryptoProvider::new(routed.backend)
931                    .generate_sealing_key(request)
932                    .await?
933            }
934            CryptoProviderId::LocalSoftware => {
935                LocalSoftwareProvider::new(routed.backend)
936                    .generate_sealing_key(request)
937                    .await?
938            }
939        };
940        Ok((created, kem_provider_dispatch(provider, kem)))
941    }
942
943    /// `import` (BYOK): provision the key `key_id` from caller-supplied material.
944    ///
945    /// Valid for crypto keys (`asymmetric` / `symmetric`). Write-only: the reply
946    /// carries only the public half; the private material is never echoed.
947    ///
948    /// # Errors
949    ///
950    /// [`ManagerError::UnknownKey`], [`ManagerError::OpNotValidForClass`], or
951    /// [`ManagerError::Backend`] (including an unsupported material variant).
952    pub async fn import(
953        &self,
954        key_id: &str,
955        key_type: KeyType,
956        material: &KeyMaterial,
957    ) -> Result<NewKey, ManagerError> {
958        let routed = self.resolve(key_id)?;
959        require_class(
960            "import",
961            key_id,
962            routed.class(),
963            &[Class::Asymmetric, Class::Symmetric],
964        )?;
965        // A value-store (`engine=kv2`) crypto key's private is provisioned
966        // out-of-band as raw KV bytes, not BYOK-imported through transit. Refuse
967        // explicitly rather than let the transit `import` incidentally fail on a
968        // KV path.
969        if routed.engine == Engine::Kv2 {
970            return Err(ManagerError::Unsupported(
971                "import: a value-store (kv2) crypto key is provisioned out-of-band, not imported via the broker",
972            ));
973        }
974        if routed.class() == Class::Asymmetric {
975            routed.require_mint_key_type("import", key_type)?;
976        }
977        // Import provisions the key under its catalog backend path, not the
978        // backend-assigned id `new_key` uses.
979        Ok(routed
980            .backend
981            .import(routed.path(), key_type, material)
982            .await?)
983    }
984
985    /// `list` returns value-free key metadata across the catalog (§7).
986    ///
987    /// Projects the catalog (name + kind + algorithm) into the wire
988    /// [`WireCatalogEntry`] shape, filtered by `prefix` and by the `visible`
989    /// predicate (the handler passes a PDP-backed closure so a caller only sees
990    /// keys it may list/read). The latest version is read from each key's backend
991    /// via [`Backend::key_metadata`]; a key whose backend errors is reported with
992    /// `latest_version = 0` rather than failing the whole list (one down backend
993    /// must not blind the caller to every other key). Never returns key bytes.
994    ///
995    /// # Errors
996    ///
997    /// Infallible at the manager layer today: per-key backend failures degrade
998    /// to `latest_version = 0`; the [`Result`] is kept for forward-compat.
999    pub async fn list(
1000        &self,
1001        prefix: Option<&str>,
1002        visible: impl Fn(&str) -> bool,
1003    ) -> Result<Vec<WireCatalogEntry>, ManagerError> {
1004        let mut out = Vec::new();
1005        for (name, entry) in &self.catalog.keys {
1006            if let Some(p) = prefix
1007                && !name.starts_with(p)
1008            {
1009                continue;
1010            }
1011            if !visible(name) {
1012                continue;
1013            }
1014            // Best-effort version + algorithm from the backend; a resolve or
1015            // backend failure degrades to the catalog's declared type + version 0
1016            // rather than failing the whole list.
1017            let meta = match self.resolve(name) {
1018                Ok(routed) => routed.backend.key_metadata(routed.path()).await.ok(),
1019                Err(_) => None,
1020            };
1021            let (key_type, latest_version) = meta.map_or_else(
1022                || (wire_key_type(entry), 0),
1023                |m| (m.key_type, m.latest_version),
1024            );
1025            out.push(WireCatalogEntry {
1026                name: name.clone(),
1027                kind: key_kind(entry),
1028                key_type,
1029                latest_version,
1030            });
1031        }
1032        Ok(out)
1033    }
1034
1035    /// `encrypt` AEAD-encrypts `plaintext` under `key_id`'s latest version.
1036    ///
1037    /// Valid only for `symmetric` keys. `algorithm` must match the key's catalog
1038    /// `keyType` ([`ManagerError::AlgorithmMismatch`] otherwise). The backend owns
1039    /// the nonce and returns a normalized [`CiphertextEnvelope`].
1040    ///
1041    /// # Errors
1042    ///
1043    /// [`ManagerError::UnknownKey`], [`ManagerError::OpNotValidForClass`],
1044    /// [`ManagerError::AlgorithmMismatch`], or [`ManagerError::Backend`].
1045    pub async fn encrypt(
1046        &self,
1047        key_id: &str,
1048        algorithm: AeadAlgorithm,
1049        plaintext: &[u8],
1050        aad: Option<&[u8]>,
1051    ) -> Result<CiphertextEnvelope, ManagerError> {
1052        let routed = self.resolve(key_id)?;
1053        require_class("encrypt", key_id, routed.class(), &[Class::Symmetric])?;
1054        require_aead_match(key_id, routed.key_type(), algorithm)?;
1055        Ok(routed
1056            .backend
1057            .encrypt(routed.path(), algorithm, plaintext, aad)
1058            .await?)
1059    }
1060
1061    /// `decrypt` AEAD-decrypts `envelope` under `key_id`, targeting the version
1062    /// the envelope names (rotation grace). The envelope `alg` must match the
1063    /// key's catalog `keyType`.
1064    ///
1065    /// Valid only for `symmetric` keys. A tag/AAD/version mismatch surfaces as
1066    /// [`BackendError::DecryptFailed`] (opaque).
1067    ///
1068    /// # Errors
1069    ///
1070    /// [`ManagerError::UnknownKey`], [`ManagerError::OpNotValidForClass`],
1071    /// [`ManagerError::AlgorithmMismatch`], or [`ManagerError::Backend`]
1072    /// (including the opaque [`BackendError::DecryptFailed`]).
1073    pub async fn decrypt(
1074        &self,
1075        key_id: &str,
1076        envelope: &CiphertextEnvelope,
1077        aad: Option<&[u8]>,
1078    ) -> Result<Vec<u8>, ManagerError> {
1079        let routed = self.resolve(key_id)?;
1080        require_class("decrypt", key_id, routed.class(), &[Class::Symmetric])?;
1081        require_aead_match(key_id, routed.key_type(), envelope.alg)?;
1082        Ok(routed.backend.decrypt(routed.path(), envelope, aad).await?)
1083    }
1084
1085    /// `wrap_envelope` seals `plaintext` to a sealing key as an X25519 sealed box.
1086    ///
1087    /// Valid **only** for `sealing` keys. Sealing needs the recipient public key,
1088    /// which the broker reads from the key's **out-of-band-provisioned**
1089    /// `public_path` (a non-secret `kv_get`, basil-o86), so wrap is a pure public
1090    /// op that **never** materializes the long-lived private (that happens only on
1091    /// `unwrap`, the op that performs the ECDH). A caller can wrap without first
1092    /// fetching the public half. The returned [`SealedEnvelope`] carries the
1093    /// ephemeral public (`encapsulated_key`), the nonce, and the ciphertext; `aad`
1094    /// is bound as AEAD associated data.
1095    ///
1096    /// # Errors
1097    ///
1098    /// [`ManagerError::UnknownKey`], [`ManagerError::OpNotValidForClass`] (a
1099    /// non-sealing key), [`ManagerError::MissingPublicPath`] (no `public_path`
1100    /// configured), [`ManagerError::Sealing`] (malformed stored public / crypto
1101    /// failure), or [`ManagerError::Backend`].
1102    pub async fn wrap_envelope(
1103        &self,
1104        key_id: &str,
1105        plaintext: &[u8],
1106        aad: &[u8],
1107    ) -> Result<SealedEnvelope, ManagerError> {
1108        let routed = self.resolve(key_id)?;
1109        require_class("wrap_envelope", key_id, routed.class(), &[Class::Sealing])?;
1110        require_x25519_sealing_key(key_id, routed.key_type())?;
1111        // Read the recipient public from its out-of-band path; the private is
1112        // never touched on the wrap path. Wrap uses only the public.
1113        let public_bytes = self
1114            .read_public_half(key_id, "wrap_envelope", &[Class::Sealing])
1115            .await?;
1116        let recipient_pub = x25519_seal::public_from_slice(&public_bytes)
1117            .map_err(|e| ManagerError::Sealing(SealingFailure::from_seal(e)))?;
1118        x25519_seal::seal(&recipient_pub, plaintext, aad)
1119            .map_err(|e| ManagerError::Sealing(SealingFailure::from_seal(e)))
1120    }
1121
1122    /// `unwrap_envelope` opens an X25519 sealed box addressed to a sealing key.
1123    ///
1124    /// Valid **only** for `sealing` keys. The X25519 private is materialized from
1125    /// KV by an **internal** read (NOT the public `get` op, which stays denied for
1126    /// a sealing key), used for exactly one `ECDH`, and zeroized on every path. A
1127    /// wrong key, tampered envelope, or mismatched `aad` returns the opaque
1128    /// [`SealingFailure::OpenFailed`] (no oracle). The recovered plaintext is
1129    /// returned in a zeroizing buffer.
1130    ///
1131    /// **Confidentiality only, NOT sender authentication.** A sealed box is
1132    /// anonymous: a successful unwrap proves only that the envelope was sealed to
1133    /// this recipient, never *who* sealed it. Callers MUST NOT treat a successful
1134    /// unwrap as proof of sender identity. (A low-order `encapsulated_key` is
1135    /// rejected by the crypto core, but that is not authentication.)
1136    ///
1137    /// # Errors
1138    ///
1139    /// [`ManagerError::UnknownKey`], [`ManagerError::OpNotValidForClass`] (a
1140    /// non-sealing key), [`ManagerError::Sealing`] (malformed materialized key,
1141    /// malformed envelope, or AEAD authentication failure), or
1142    /// [`ManagerError::Backend`].
1143    pub async fn unwrap_envelope(
1144        &self,
1145        key_id: &str,
1146        envelope: &SealedEnvelope,
1147        aad: &[u8],
1148    ) -> Result<Zeroizing<Vec<u8>>, ManagerError> {
1149        let private = self
1150            .materialize_sealing_private(key_id, "unwrap_envelope")
1151            .await?;
1152        x25519_seal::open(&private, envelope, aad)
1153            .map_err(|e| ManagerError::Sealing(SealingFailure::from_seal(e)))
1154    }
1155
1156    /// Open a strict-profile `COSE_Encrypt` with a custodied X25519 sealing key.
1157    ///
1158    /// The exact tagged `COSE_Encrypt` bytes are passed to `basil-cose`
1159    /// verbatim. The `Enc_structure` AAD covers the serialized protected header
1160    /// bytes, so callers must not parse and re-encode before this point.
1161    ///
1162    /// # Errors
1163    ///
1164    /// [`ManagerError::UnknownKey`], [`ManagerError::OpNotValidForClass`],
1165    /// [`ManagerError::UnsealContextNotPermitted`] when the key pins an allowed
1166    /// COSE unseal context (KDF parties / `external_aad`, `basil-2rqj`) this
1167    /// envelope is not bound to, [`ManagerError::Sealing`] for malformed
1168    /// material/profile input or AEAD authentication failure, or
1169    /// [`ManagerError::Backend`].
1170    pub async fn unseal_cose(
1171        &self,
1172        key_id: &str,
1173        cose_encrypt: &[u8],
1174        external_aad: &[u8],
1175    ) -> Result<Zeroizing<Vec<u8>>, ManagerError> {
1176        // Catalog pinning (basil-2rqj): when the sealing key pins an allowed
1177        // unseal context, an `op:decrypt` grant authorizes ONLY envelopes bound to
1178        // it, not any envelope addressed to the key. The `external_aad` facet is
1179        // enforced here (it is caller-supplied, never embedded), fail-closed before
1180        // the private is ever materialized; the KDF-party facet rides into the one
1181        // COSE open implementation below as `expected_parties`.
1182        let expected_parties = {
1183            let routed = self.resolve(key_id)?;
1184            match routed.entry.sealing_pin.as_ref() {
1185                None => None,
1186                Some(pin) => {
1187                    if !pin.external_aad_allowed(external_aad) {
1188                        return Err(ManagerError::UnsealContextNotPermitted(key_id.to_string()));
1189                    }
1190                    match pin.parties.as_ref() {
1191                        None => None,
1192                        Some(parties) => Some(
1193                            parties
1194                                .to_kdf_parties()
1195                                .map_err(|_| ManagerError::Sealing(SealingFailure::Malformed))?,
1196                        ),
1197                    }
1198                }
1199            }
1200        };
1201
1202        let private = self
1203            .materialize_sealing_private(key_id, "unseal_cose")
1204            .await?;
1205        let cose_key_id = basil_cose::KeyId::from_text(key_id)
1206            .map_err(|_| ManagerError::Sealing(SealingFailure::Malformed))?;
1207        let recipient = basil_cose::X25519Recipient::new(cose_key_id, private);
1208        let aad = basil_cose::ExternalAad::from_bytes(external_aad.to_vec());
1209        let request = basil_cose::OpenRequest {
1210            cose_encrypt,
1211            external_aad: &aad,
1212            expected_parties: expected_parties.as_ref(),
1213        };
1214        recipient.open(&request).await.map_err(|e| match e {
1215            // A pinned-party mismatch is a least-privilege authorization refusal
1216            // (the parties are cleartext header values, not a secret), surfaced as
1217            // permission-denied, distinct from the opaque decrypt-failed posture
1218            // an authentication failure keeps.
1219            basil_cose::OpenError::PartyMismatch => {
1220                ManagerError::UnsealContextNotPermitted(key_id.to_string())
1221            }
1222            other => ManagerError::Sealing(SealingFailure::from_cose_open(&other)),
1223        })
1224    }
1225
1226    /// Encrypt with a custodied NATS curve xkey.
1227    ///
1228    /// This is a materialize-to-use operation: the sender private xkey is read
1229    /// through the secret KV path, used for one NaCl-compatible authenticated box,
1230    /// then zeroized. The peer key is a public `X...` nkey supplied by the caller.
1231    pub async fn encrypt_nats_curve(
1232        &self,
1233        key_id: &str,
1234        recipient_public_xkey: &str,
1235        plaintext: &[u8],
1236    ) -> Result<Vec<u8>, ManagerError> {
1237        let private = self
1238            .materialize_sealing_private(key_id, "encrypt_nats_curve")
1239            .await?;
1240        basil_nats::seal_nats_curve(&private, recipient_public_xkey, plaintext)
1241            .map_err(|e| nats_curve_error(&e))
1242    }
1243
1244    /// Decrypt a NATS curve xkey authenticated box with a custodied recipient key.
1245    ///
1246    /// A wrong key, wrong sender public key, or tampered ciphertext maps to the
1247    /// same opaque sealing open failure used by the envelope decrypt path.
1248    pub async fn decrypt_nats_curve(
1249        &self,
1250        key_id: &str,
1251        sender_public_xkey: &str,
1252        ciphertext: &[u8],
1253    ) -> Result<Zeroizing<Vec<u8>>, ManagerError> {
1254        let private = self
1255            .materialize_sealing_private(key_id, "decrypt_nats_curve")
1256            .await?;
1257        basil_nats::open_nats_curve(&private, sender_public_xkey, ciphertext)
1258            .map_err(|e| nats_curve_error(&e))
1259    }
1260
1261    /// `wrap_envelope` for an ML-KEM **sealing** key, dispatched through the crypto
1262    /// provider (software custody).
1263    ///
1264    /// Self-sealing: the provider materializes the custodied 64-byte ML-KEM seed,
1265    /// derives its public encapsulation key, encapsulates to it, and AEAD-seals the
1266    /// plaintext under the KEM-derived key, so the broker needs no separately
1267    /// published encapsulation key. The requested `kem` must name the same ML-KEM
1268    /// level the key's catalog `keyType` declares; the local-software provider is
1269    /// selectable only when `gate` carries the caller's explicit
1270    /// `op:use_software_custody` grant. Returns the self-describing
1271    /// [`ProviderEnvelope`] (KEM/envelope algorithm, key version, encapsulated key,
1272    /// nonce, ciphertext) plus a [`ProviderDispatch`] for the audit trail.
1273    ///
1274    /// # Errors
1275    ///
1276    /// [`ManagerError::UnknownKey`], [`ManagerError::OpNotValidForClass`] (a
1277    /// non-sealing key), [`ManagerError::KemAlgorithmMismatch`] (a wrong ML-KEM
1278    /// level), or [`ManagerError::Provider`] (a policy denial or an opaque
1279    /// software-custody crypto failure).
1280    pub async fn provider_wrap_envelope(
1281        &self,
1282        key_id: &str,
1283        kem: ProviderKemAlgorithm,
1284        envelope_algorithm: ProviderEnvelopeAlgorithm,
1285        plaintext: &[u8],
1286        aad: &[u8],
1287        gate: ProviderGate,
1288    ) -> Result<(ProviderEnvelope, ProviderDispatch), ManagerError> {
1289        let routed = self.resolve(key_id)?;
1290        require_class("wrap_envelope", key_id, routed.class(), &[Class::Sealing])?;
1291        require_ml_kem_sealing_key(key_id, routed.key_type(), kem)?;
1292        let provider = select_provider_for(&routed, kem.native_algorithm(), gate, "wrap_envelope")?;
1293        let request = ProviderWrapEnvelopeRequest {
1294            key_id,
1295            backend_path: routed.path(),
1296            kem_algorithm: kem,
1297            envelope_algorithm,
1298            plaintext,
1299            aad: Some(aad),
1300        };
1301        let envelope = match provider {
1302            CryptoProviderId::VaultTransit => {
1303                BackendCryptoProvider::new(routed.backend)
1304                    .wrap_envelope(request)
1305                    .await?
1306            }
1307            CryptoProviderId::LocalSoftware => {
1308                LocalSoftwareProvider::new(routed.backend)
1309                    .wrap_envelope(request)
1310                    .await?
1311            }
1312        };
1313        Ok((envelope, kem_provider_dispatch(provider, kem)))
1314    }
1315
1316    /// `unwrap_envelope` for an ML-KEM **sealing** key, dispatched through the
1317    /// crypto provider (software custody).
1318    ///
1319    /// The provider materializes the custodied 64-byte ML-KEM seed for exactly one
1320    /// decapsulation, derives the AEAD key, opens the envelope, and zeroizes the
1321    /// seed on every path. The requested `kem` must match the key's catalog
1322    /// `keyType`; the local-software provider requires the caller's explicit
1323    /// `op:use_software_custody` grant in `gate`. A wrong key, tampered envelope,
1324    /// or mismatched `aad` returns the opaque
1325    /// [`ProviderError::CryptoFailed`](crate::core::crypto_provider::ProviderError::CryptoFailed)
1326    /// (no decrypt oracle). Returns the recovered plaintext plus a
1327    /// [`ProviderDispatch`] for the audit trail.
1328    ///
1329    /// **Confidentiality only, NOT sender authentication.** A successful unwrap
1330    /// proves only that the payload was sealed to this recipient, never *who*
1331    /// sealed it.
1332    ///
1333    /// # Errors
1334    ///
1335    /// As [`Self::provider_wrap_envelope`].
1336    pub async fn provider_unwrap_envelope(
1337        &self,
1338        key_id: &str,
1339        kem: ProviderKemAlgorithm,
1340        envelope_algorithm: ProviderEnvelopeAlgorithm,
1341        parts: MlKemEnvelopeParts<'_>,
1342        aad: &[u8],
1343        gate: ProviderGate,
1344    ) -> Result<(Vec<u8>, ProviderDispatch), ManagerError> {
1345        let routed = self.resolve(key_id)?;
1346        require_class("unwrap_envelope", key_id, routed.class(), &[Class::Sealing])?;
1347        require_ml_kem_sealing_key(key_id, routed.key_type(), kem)?;
1348        let provider =
1349            select_provider_for(&routed, kem.native_algorithm(), gate, "unwrap_envelope")?;
1350        let request = ProviderUnwrapEnvelopeRequest {
1351            key_id,
1352            backend_path: routed.path(),
1353            kem_algorithm: kem,
1354            envelope_algorithm,
1355            encapsulated_key: parts.encapsulated_key,
1356            nonce: parts.nonce,
1357            ciphertext: parts.ciphertext,
1358            aad: Some(aad),
1359        };
1360        let plaintext = match provider {
1361            CryptoProviderId::VaultTransit => {
1362                BackendCryptoProvider::new(routed.backend)
1363                    .unwrap_envelope(request)
1364                    .await?
1365            }
1366            CryptoProviderId::LocalSoftware => {
1367                LocalSoftwareProvider::new(routed.backend)
1368                    .unwrap_envelope(request)
1369                    .await?
1370            }
1371        };
1372        Ok((plaintext, kem_provider_dispatch(provider, kem)))
1373    }
1374
1375    /// `get_public_key` for a **sealing** key: read and return the X25519 public
1376    /// half from the key's **out-of-band-provisioned** `public_path` (basil-o86).
1377    /// A pure public op: the long-lived private is **never** materialized here
1378    /// (that happens only on `unwrap`). Senders use the returned public to seal
1379    /// enrollment payloads to this recipient.
1380    ///
1381    /// # Errors
1382    ///
1383    /// [`ManagerError::UnknownKey`], [`ManagerError::OpNotValidForClass`] (a
1384    /// non-sealing key), [`ManagerError::MissingPublicPath`] (no `public_path`
1385    /// configured), [`ManagerError::Sealing`] (malformed stored public), or
1386    /// [`ManagerError::Backend`].
1387    pub async fn sealing_public_key(
1388        &self,
1389        key_id: &str,
1390    ) -> Result<[u8; x25519_seal::PUBLIC_KEY_LEN], ManagerError> {
1391        let routed = self.resolve(key_id)?;
1392        require_class("get_public_key", key_id, routed.class(), &[Class::Sealing])?;
1393        require_x25519_sealing_key(key_id, routed.key_type())?;
1394        let public_bytes = self
1395            .read_public_half(key_id, "get_public_key", &[Class::Sealing])
1396            .await?;
1397        x25519_seal::public_from_slice(&public_bytes)
1398            .map_err(|e| ManagerError::Sealing(SealingFailure::from_seal(e)))
1399    }
1400
1401    /// Read and validate a sealing key's X25519 private from KV (the internal
1402    /// materialize). This is a `pub(crate)`-free private helper: it is reachable
1403    /// **only** through the sealing `unwrap` path (the one op that performs the
1404    /// ECDH), never via the public `get` op (which `require_class` rejects for a
1405    /// sealing key) and, since basil-o86, never for `wrap`/`get_public_key`
1406    /// either (those read the out-of-band public via [`Self::read_public_half`]).
1407    async fn materialize_sealing_private(
1408        &self,
1409        key_id: &str,
1410        op: &'static str,
1411    ) -> Result<Zeroizing<[u8; x25519_seal::PRIVATE_KEY_LEN]>, ManagerError> {
1412        let routed = self.resolve(key_id)?;
1413        require_class(op, key_id, routed.class(), &[Class::Sealing])?;
1414        require_x25519_sealing_key(key_id, routed.key_type())?;
1415        // SECRET read: the X25519 private stays in `Zeroizing` end-to-end (the
1416        // `Zeroizing<Vec<u8>>` wipes on drop), never the plain `KvValue`/`Vec`
1417        // path used for value/public reads.
1418        let secret = routed.backend.kv_get_secret(routed.path(), None).await?;
1419        // The stored value is raw X25519 private bytes; copy into a fixed,
1420        // zeroizing array (fails closed on a wrong length, never indexes). The
1421        // source `Zeroizing<Vec>` wipes when it drops at the end of this scope.
1422        x25519_seal::private_from_slice(&secret)
1423            .map_err(|e| ManagerError::Sealing(SealingFailure::from_seal(e)))
1424    }
1425
1426    /// Read the **public** half of a materialize-to-use key from its
1427    /// out-of-band-provisioned `public_path` (basil-o86) via a non-secret
1428    /// `kv_get`, enforcing `op`'s class gate (`allowed`). This is the seam that
1429    /// lets every public op (`wrap`/`get_public_key`/`verify`) resolve the public
1430    /// **without** materializing the private: the private is touched only on the
1431    /// op that performs the private crypto (`unwrap`/`sign`).
1432    ///
1433    /// The public carries no key material, so the plain `kv_get`/`KvValue` path is
1434    /// correct here (NOT `kv_get_secret`, which is reserved for the private). The
1435    /// returned bytes are the raw public; the caller validates the fixed length
1436    /// through its crypto core (`x25519_seal`/`ed25519_sign`).
1437    async fn read_public_half(
1438        &self,
1439        key_id: &str,
1440        op: &'static str,
1441        allowed: &[Class],
1442    ) -> Result<Vec<u8>, ManagerError> {
1443        let routed = self.resolve(key_id)?;
1444        require_class(op, key_id, routed.class(), allowed)?;
1445        let public_path = routed
1446            .public_path()
1447            .ok_or_else(|| ManagerError::MissingPublicPath(key_id.to_string()))?;
1448        let kv = routed.backend.kv_get(public_path, None).await?;
1449        Ok(kv.value)
1450    }
1451
1452    /// Read and validate an `engine=kv2` Ed25519 signing key's 32-byte seed from KV
1453    /// (the materialize-to-sign read, `vault-iiz`). The sibling of
1454    /// [`Self::materialize_sealing_private`]: a `pub(crate)`-free private helper
1455    /// reachable **only** through the `sign` Kv2 branch (the one op that performs
1456    /// the private crypto; since basil-o86 `verify`/`get_public_key` read the
1457    /// out-of-band public via [`Self::read_public_half`] instead), never the public
1458    /// `get` op (`require_class` rejects `get` for an asymmetric key, so the seed is
1459    /// structurally un-gettable).
1460    ///
1461    /// The returned [`Zeroizing`] seed and any `SigningKey` built from it are wiped
1462    /// on drop; the bytes never enter a non-zeroizing owner, an error string, a
1463    /// `Debug`/`Display`, a log, or the audit record.
1464    async fn materialize_signing_seed(
1465        &self,
1466        key_id: &str,
1467        op: &'static str,
1468    ) -> Result<Zeroizing<[u8; ed25519_sign::SEED_LEN]>, ManagerError> {
1469        let routed = self.resolve(key_id)?;
1470        require_class(op, key_id, routed.class(), &[Class::Asymmetric])?;
1471        // SECRET read: the Ed25519 seed stays in `Zeroizing` end-to-end (the
1472        // `Zeroizing<Vec<u8>>` wipes on drop), never the plain `KvValue`/`Vec`
1473        // path used for value/public reads (the t9a-review leak).
1474        let secret = routed.backend.kv_get_secret(routed.path(), None).await?;
1475        // The stored value is the raw 32-byte Ed25519 seed; copy into a fixed,
1476        // zeroizing array (fails closed on a wrong length, never indexes). The
1477        // source `Zeroizing<Vec>` wipes when it drops at the end of this scope.
1478        ed25519_sign::seed_from_slice(&secret)
1479            .map_err(|e| ManagerError::Signing(SigningFailure::from_sign(e)))
1480    }
1481
1482    /// `get` reads the latest (or a specific) KV-v2 value for `key_id`,
1483    /// returning the value bytes + the version they came from (§7).
1484    ///
1485    /// Valid **only** for `value` and `public` keys. A crypto key (`asymmetric` /
1486    /// `symmetric`) is rejected with [`ManagerError::OpNotValidForClass`] *before*
1487    /// any backend call: the broker never hands out signing/encryption key
1488    /// material through `get` (that op-class gate is the security invariant here).
1489    /// `version = None` reads the latest version.
1490    ///
1491    /// # Errors
1492    ///
1493    /// [`ManagerError::UnknownKey`], [`ManagerError::OpNotValidForClass`], or
1494    /// [`ManagerError::Backend`].
1495    pub async fn get(&self, key_id: &str, version: Option<u32>) -> Result<KvValue, ManagerError> {
1496        let routed = self.resolve(key_id)?;
1497        require_class(
1498            "get",
1499            key_id,
1500            routed.class(),
1501            &[Class::Value, Class::Public],
1502        )?;
1503        Ok(routed.backend.kv_get(routed.path(), version).await?)
1504    }
1505
1506    /// `set` writes `value` as a fresh KV-v2 version of `key_id`, returning the
1507    /// new version number (never the value, §7).
1508    ///
1509    /// Valid **only** for `value` keys (a `public` key is read-only material, a
1510    /// crypto key is `import`/`rotate` territory). Rejected with
1511    /// [`ManagerError::OpNotValidForClass`] otherwise, before any backend call.
1512    ///
1513    /// # Errors
1514    ///
1515    /// [`ManagerError::UnknownKey`], [`ManagerError::OpNotValidForClass`], or
1516    /// [`ManagerError::Backend`].
1517    pub async fn set(&self, key_id: &str, value: &[u8]) -> Result<u32, ManagerError> {
1518        let routed = self.resolve(key_id)?;
1519        require_class("set", key_id, routed.class(), &[Class::Value])?;
1520        Ok(routed.backend.kv_put(routed.path(), value).await?)
1521    }
1522
1523    /// `rotate` bumps `key_id` to a fresh version, returning the new version.
1524    ///
1525    /// - **Crypto (transit) keys** (`asymmetric`/`symmetric`): a transit key
1526    ///   version bump. New sign/encrypt uses the newest version; older versions
1527    ///   stay usable within the grace window, which this method then applies via
1528    ///   [`Backend::configure_versions`] (raising `min_decryption_version` to
1529    ///   [`BrokerLimits::grace_floor`]). On compromise, set `grace_versions = 0`
1530    ///   so only the newest version is honored.
1531    /// - **Value keys**: a key with a catalog `generate` recipe regenerates a
1532    ///   fresh value as a new KV-v2 version (`vault-a2p`); a value key *without* a
1533    ///   recipe is [`ManagerError::ValueRotateNeedsSet`] (use `set`).
1534    ///
1535    /// # Errors
1536    ///
1537    /// [`ManagerError::UnknownKey`], [`ManagerError::OpNotValidForClass`],
1538    /// [`ManagerError::ValueRotateNeedsSet`], or [`ManagerError::Backend`].
1539    pub async fn rotate(&self, key_id: &str, limits: BrokerLimits) -> Result<u32, ManagerError> {
1540        let routed = self.resolve(key_id)?;
1541        require_class(
1542            "rotate",
1543            key_id,
1544            routed.class(),
1545            &[Class::Asymmetric, Class::Symmetric, Class::Value],
1546        )?;
1547
1548        // A value-store (`engine=kv2`) crypto key holds its private as raw KV
1549        // bytes provisioned out-of-band; it is never transit-rotated (mirrors a
1550        // sealing key, which has no `rotate`). Refuse explicitly rather than let
1551        // the transit `rotate` incidentally 404 on a KV path. `Value`+kv2 is the
1552        // normal value key (rotated via its `generate` recipe below), exempt it.
1553        if routed.engine == Engine::Kv2 && routed.class() != Class::Value {
1554            return Err(ManagerError::Unsupported(
1555                "rotate: a value-store (kv2) crypto key is re-provisioned out-of-band, not rotated via the broker",
1556            ));
1557        }
1558
1559        if routed.class() == Class::Value {
1560            // A value key rotates by generating a fresh value and writing it as a
1561            // new KV-v2 version. No recipe → nothing to generate → use `set`.
1562            let Some(spec) = routed.entry.generate.as_ref() else {
1563                return Err(ManagerError::ValueRotateNeedsSet(key_id.to_string()));
1564            };
1565            let writes = self.generated_writes_for_key(key_id, spec).await?;
1566            let mut rotated_version = None;
1567            for write in writes {
1568                let write_route = self.resolve(&write.key_id)?;
1569                require_class(
1570                    "rotate",
1571                    &write.key_id,
1572                    write_route.class(),
1573                    &[Class::Value, Class::Public],
1574                )?;
1575                let version = write_route
1576                    .backend
1577                    .kv_put(write_route.path(), &write.value)
1578                    .await?;
1579                if write.key_id == key_id {
1580                    rotated_version = Some(version);
1581                }
1582            }
1583            return rotated_version.ok_or_else(|| {
1584                ManagerError::Backend(BackendError::Backend(
1585                    "generate recipe produced no primary value".into(),
1586                ))
1587            });
1588        }
1589
1590        // Crypto key: transit version bump, then apply the grace floor so
1591        // pre-window versions stop decrypting/verifying.
1592        let new_version = routed.backend.rotate(routed.path()).await?;
1593        let floor = limits.grace_floor(new_version);
1594        // Best-effort grace application: a backend that does not support the
1595        // version-config endpoint (default Unsupported) must not fail the rotate
1596        // itself: the version bump already succeeded.
1597        if let Err(e) = routed
1598            .backend
1599            .configure_versions(routed.path(), Some(floor), None)
1600            .await
1601            && !matches!(e, BackendError::Unsupported(_))
1602        {
1603            return Err(ManagerError::Backend(e));
1604        }
1605        Ok(new_version)
1606    }
1607
1608    /// **Retention sweep** for one crypto key: raise the backend's
1609    /// `min_available_version` to [`BrokerLimits::retention_floor`], irreversibly
1610    /// pruning archived key material below the retention window. A no-op when
1611    /// retention is disabled (`retain_versions = None`) or the key has too few
1612    /// versions to prune.
1613    ///
1614    /// This is the sweep *primitive*. Call it after a rotate, or from the
1615    /// binary's periodic catalog sweep task, to enforce retention.
1616    ///
1617    /// # Errors
1618    ///
1619    /// [`ManagerError::UnknownKey`], [`ManagerError::OpNotValidForClass`], or
1620    /// [`ManagerError::Backend`].
1621    pub async fn sweep_retention(
1622        &self,
1623        key_id: &str,
1624        limits: BrokerLimits,
1625    ) -> Result<(), ManagerError> {
1626        let routed = self.resolve(key_id)?;
1627        require_class(
1628            "rotate",
1629            key_id,
1630            routed.class(),
1631            &[Class::Asymmetric, Class::Symmetric],
1632        )?;
1633        let latest = routed
1634            .backend
1635            .key_metadata(routed.path())
1636            .await?
1637            .latest_version;
1638        let Some(floor) = limits.retention_floor(latest) else {
1639            return Ok(()); // retention disabled
1640        };
1641        routed
1642            .backend
1643            .configure_versions(routed.path(), None, Some(floor))
1644            .await?;
1645        Ok(())
1646    }
1647
1648    /// Apply the configured retention floor to every crypto key in catalog order.
1649    ///
1650    /// Value/public keys have no backend key-version retention in Basil and are
1651    /// skipped. A disabled retention policy is a no-op.
1652    ///
1653    /// # Errors
1654    ///
1655    /// Returns the first routing/backend error encountered while sweeping.
1656    pub async fn sweep_all_retention(&self, limits: BrokerLimits) -> Result<(), ManagerError> {
1657        if limits.retain_versions.is_none() {
1658            return Ok(());
1659        }
1660        let key_ids: Vec<String> = self
1661            .keys()
1662            .filter(|(_, entry)| matches!(entry.class, Class::Asymmetric | Class::Symmetric))
1663            .map(|(name, _)| name.clone())
1664            .collect();
1665        for key_id in key_ids {
1666            self.sweep_retention(&key_id, limits).await?;
1667        }
1668        Ok(())
1669    }
1670
1671    /// Issue an X.509-SVID from a catalog `engine=pki` issuer key.
1672    ///
1673    /// Valid only for asymmetric keys with the explicit PKI engine. The backend
1674    /// path is a Vault issue endpoint (`pki/issue/<role>`), and the backend
1675    /// returns DER-normalized certificate chain, leaf key, and bundle material.
1676    ///
1677    /// # Errors
1678    ///
1679    /// [`ManagerError::UnknownKey`], [`ManagerError::OpNotValidForClass`],
1680    /// [`ManagerError::Unsupported`] for a non-PKI catalog entry, or
1681    /// [`ManagerError::Backend`].
1682    pub async fn issue_x509_svid(
1683        &self,
1684        key_id: &str,
1685        spiffe_id: &str,
1686        ttl_seconds: u64,
1687    ) -> Result<X509Svid, ManagerError> {
1688        let routed = self.resolve(key_id)?;
1689        require_class(
1690            "issue_x509_svid",
1691            key_id,
1692            routed.class(),
1693            &[Class::Asymmetric],
1694        )?;
1695        if routed.engine != Engine::Pki {
1696            return Err(ManagerError::Unsupported("issue_x509_svid"));
1697        }
1698        Ok(routed
1699            .backend
1700            .issue_x509_svid(routed.path(), spiffe_id, ttl_seconds)
1701            .await?)
1702    }
1703
1704    /// Issue a DNS/IP-SAN X.509 leaf (TLS cert) from a catalog `engine=pki` issuer.
1705    ///
1706    /// The same issuer-key constraints as [`BackendManager::issue_x509_svid`]
1707    /// (asymmetric class, explicit PKI engine), but binds DNS/IP SANs instead of a
1708    /// SPIFFE URI. The issuing CA key stays in the backend; the leaf private key is
1709    /// returned to the caller.
1710    ///
1711    /// # Errors
1712    ///
1713    /// [`ManagerError::UnknownKey`], [`ManagerError::OpNotValidForClass`],
1714    /// [`ManagerError::Unsupported`] for a non-PKI catalog entry, or
1715    /// [`ManagerError::Backend`].
1716    pub async fn issue_x509_cert(
1717        &self,
1718        key_id: &str,
1719        request: &X509CertRequest,
1720    ) -> Result<X509Svid, ManagerError> {
1721        let routed = self.resolve(key_id)?;
1722        require_class(
1723            "issue_x509_cert",
1724            key_id,
1725            routed.class(),
1726            &[Class::Asymmetric],
1727        )?;
1728        if routed.engine != Engine::Pki {
1729            return Err(ManagerError::Unsupported("issue_x509_cert"));
1730        }
1731        Ok(routed
1732            .backend
1733            .issue_x509_cert(routed.path(), request)
1734            .await?)
1735    }
1736
1737    /// Generate one or more KV writes for a value/public catalog recipe.
1738    ///
1739    /// Most recipes produce one write for `key_id`. The test/dev TLS pair recipes
1740    /// deliberately produce coordinated writes so a certificate and its private
1741    /// key cannot drift apart during reconcile or value rotation.
1742    pub(crate) async fn generated_writes_for_key(
1743        &self,
1744        key_id: &str,
1745        spec: &GenerateSpec,
1746    ) -> Result<Vec<GeneratedWrite>, ManagerError> {
1747        match spec {
1748            GenerateSpec::SelfSignedTls {
1749                common_name,
1750                validity,
1751            } => {
1752                if let Some(pair_key) = self.tls_pair_key_for_cert(key_id) {
1753                    let existing_key = self.try_existing_value(&pair_key).await?;
1754                    let material =
1755                        generate_self_signed_tls(common_name, validity, existing_key.as_deref())?;
1756                    let mut writes = Vec::new();
1757                    if existing_key.is_none() {
1758                        writes.push(GeneratedWrite {
1759                            key_id: pair_key,
1760                            value: material.private_key_pem,
1761                        });
1762                    }
1763                    writes.push(GeneratedWrite {
1764                        key_id: key_id.to_string(),
1765                        value: material.cert_pem,
1766                    });
1767                    Ok(writes)
1768                } else {
1769                    Ok(vec![GeneratedWrite {
1770                        key_id: key_id.to_string(),
1771                        value: generate_self_signed_tls(common_name, validity, None)?.cert_pem,
1772                    }])
1773                }
1774            }
1775            GenerateSpec::SelfSignedTlsPairOf { pair_of } => {
1776                let (common_name, validity) = self.tls_cert_recipe(pair_of)?;
1777                let material = generate_self_signed_tls(common_name, validity, None)?;
1778                Ok(vec![
1779                    GeneratedWrite {
1780                        key_id: key_id.to_string(),
1781                        value: material.private_key_pem,
1782                    },
1783                    GeneratedWrite {
1784                        key_id: pair_of.clone(),
1785                        value: material.cert_pem,
1786                    },
1787                ])
1788            }
1789            GenerateSpec::AsciiPrintable { .. }
1790            | GenerateSpec::Base64 { .. }
1791            | GenerateSpec::Hex { .. }
1792            | GenerateSpec::AgeX25519 => Ok(vec![GeneratedWrite {
1793                key_id: key_id.to_string(),
1794                value: generate_value(spec)?,
1795            }]),
1796        }
1797    }
1798
1799    fn tls_pair_key_for_cert(&self, cert_key: &str) -> Option<String> {
1800        self.catalog.keys.iter().find_map(|(name, entry)| {
1801            matches!(
1802                entry.generate.as_ref(),
1803                // ubs false positive: cert_key and pair_of are catalog key names, not secret material
1804                /* ubs:ignore */
1805                Some(GenerateSpec::SelfSignedTlsPairOf { pair_of }) if pair_of == cert_key
1806            )
1807            .then(|| name.clone())
1808        })
1809    }
1810
1811    fn tls_cert_recipe(&self, cert_key: &str) -> Result<(&str, &str), ManagerError> {
1812        let entry = self
1813            .catalog
1814            .keys
1815            .get(cert_key)
1816            .ok_or_else(|| ManagerError::UnknownKey(cert_key.to_string()))?;
1817        match entry.generate.as_ref() {
1818            Some(GenerateSpec::SelfSignedTls {
1819                common_name,
1820                validity,
1821            }) => Ok((common_name, validity)),
1822            _ => Err(ManagerError::Backend(BackendError::Backend(format!(
1823                "self-signed-tls-pair-of references non-certificate key `{cert_key}`"
1824            )))),
1825        }
1826    }
1827
1828    async fn try_existing_value(&self, key_id: &str) -> Result<Option<Vec<u8>>, ManagerError> {
1829        let routed = self.resolve(key_id)?;
1830        require_class(
1831            "generate",
1832            key_id,
1833            routed.class(),
1834            &[Class::Value, Class::Public],
1835        )?;
1836        match routed.backend.kv_get(routed.path(), None).await {
1837            Ok(value) => Ok(Some(value.value)),
1838            Err(BackendError::KeyNotFound(_)) => Ok(None),
1839            Err(e) => Err(ManagerError::Backend(e)),
1840        }
1841    }
1842}
1843
1844/// One generated KV write. `key_id` is the catalog key, not the backend path.
1845pub(crate) struct GeneratedWrite {
1846    pub(crate) key_id: String,
1847    pub(crate) value: Vec<u8>,
1848}
1849
1850/// Check that `algorithm` matches the key's catalog AEAD `keyType`, returning
1851/// [`ManagerError::AlgorithmMismatch`] otherwise. A symmetric key always carries
1852/// an AEAD `KeyAlgorithm`; a key with a non-AEAD (or absent) type cannot encrypt.
1853fn require_aead_match(
1854    key_id: &str,
1855    key_type: Option<KeyAlgorithm>,
1856    algorithm: AeadAlgorithm,
1857) -> Result<(), ManagerError> {
1858    let actual = match key_type {
1859        Some(KeyAlgorithm::Aes256Gcm) => AeadAlgorithm::Aes256Gcm,
1860        Some(KeyAlgorithm::ChaCha20Poly1305) => AeadAlgorithm::Chacha20Poly1305,
1861        // Symmetric keys are always AEAD; a signing/value type here is a
1862        // misconfigured catalog (or a non-symmetric key slipping past the class
1863        // check), fail closed as a mismatch.
1864        _ => {
1865            return Err(ManagerError::AlgorithmMismatch {
1866                key: key_id.to_string(),
1867                requested: algorithm,
1868                actual: "non-aead",
1869            });
1870        }
1871    };
1872    if actual == algorithm {
1873        Ok(())
1874    } else {
1875        Err(ManagerError::AlgorithmMismatch {
1876            key: key_id.to_string(),
1877            requested: algorithm,
1878            actual: match actual {
1879                AeadAlgorithm::Aes256Gcm => "aes-256-gcm",
1880                AeadAlgorithm::Chacha20Poly1305 => "chacha20-poly1305",
1881            },
1882        })
1883    }
1884}
1885
1886const fn sign_options_for_key(key_type: Option<KeyAlgorithm>) -> SignOptions {
1887    match key_type {
1888        Some(KeyAlgorithm::Rsa2048) => SignOptions::Rs256Pkcs1v15Sha256,
1889        Some(KeyAlgorithm::EcdsaP256) => SignOptions::Es256,
1890        Some(KeyAlgorithm::EcdsaP384) => SignOptions::Es384,
1891        Some(KeyAlgorithm::EcdsaP521) => SignOptions::Es512,
1892        _ => SignOptions::Default,
1893    }
1894}
1895
1896/// Resolve the ML-DSA [`SignatureAlgorithm`] for a routed key, failing closed if
1897/// the key is not an ML-DSA signing key (a routing invariant: the service only
1898/// dispatches ML-DSA keys here).
1899fn require_ml_dsa(key_id: &str, routed: &Routed<'_>) -> Result<SignatureAlgorithm, ManagerError> {
1900    routed
1901        .key_type()
1902        .and_then(ml_dsa_signature_algorithm)
1903        .ok_or_else(|| ManagerError::OpNotValidForClass {
1904            op: "provider_dispatch",
1905            key: key_id.to_string(),
1906            class: routed.class(),
1907        })
1908}
1909
1910/// Choose the crypto provider for a routed PQC key from its catalog
1911/// policy/custody labels, the caller's gate, and a live backend capability probe
1912/// (`basil-wuj.10`).
1913///
1914/// `native` is the key's backend-native [`NativeAlgorithm`], if any. The backend
1915/// is asked (cheaply, fail-closed) whether it natively supports that
1916/// algorithm; the answer drives [`select_provider`]: a `backend-required` key
1917/// needs native support (else it fails closed), and a `backend-preferred` key
1918/// uses the backend natively when supported but otherwise falls back to the
1919/// local-software provider (policy + custody permitting). A key with no
1920/// `native` mapping (e.g. ML-KEM, which has no native backend target) probes as
1921/// unsupported and so always routes to the local-software provider.
1922fn select_provider_for(
1923    routed: &Routed<'_>,
1924    native: Option<NativeAlgorithm>,
1925    gate: ProviderGate,
1926    op: &'static str,
1927) -> Result<CryptoProviderId, ProviderError> {
1928    let metadata = ProviderMetadata::from_key(routed.entry);
1929    let backend_native_supported =
1930        native.is_some_and(|algorithm| routed.backend.supports_native_algorithm(algorithm));
1931    select_provider(
1932        metadata,
1933        backend_native_supported,
1934        gate.local_software_allowed,
1935        op,
1936    )
1937}
1938
1939/// Build the audit-facing [`ProviderDispatch`] for a completed provider op.
1940const fn provider_dispatch(
1941    provider: CryptoProviderId,
1942    algorithm: SignatureAlgorithm,
1943) -> ProviderDispatch {
1944    ProviderDispatch {
1945        provider,
1946        algorithm: algorithm.token(),
1947        custody: provider.custody_mode(),
1948    }
1949}
1950
1951/// Build the audit-facing [`ProviderDispatch`] for a completed ML-KEM
1952/// envelope (`wrap`/`unwrap`) op.
1953const fn kem_provider_dispatch(
1954    provider: CryptoProviderId,
1955    kem: ProviderKemAlgorithm,
1956) -> ProviderDispatch {
1957    ProviderDispatch {
1958        provider,
1959        algorithm: kem.token(),
1960        custody: provider.custody_mode(),
1961    }
1962}
1963
1964/// The printable ASCII alphabet for `ascii-printable` generation: `!`..=`~`
1965/// (the 94 visible, non-space ASCII characters).
1966const ASCII_PRINTABLE: &[u8] = b"!\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~";
1967
1968/// Generate a fresh `value` from its catalog `generate` recipe (`vault-a2p`
1969/// rotation path). Implements byte-string formats and in-process age identities.
1970pub(crate) fn generate_value(spec: &GenerateSpec) -> Result<Vec<u8>, ManagerError> {
1971    use base64::Engine as _;
1972    use std::fmt::Write as _;
1973
1974    let mut rng = rand::thread_rng();
1975    match spec {
1976        GenerateSpec::AsciiPrintable { bytes } => {
1977            let mut out = Vec::with_capacity(*bytes as usize);
1978            for _ in 0..*bytes {
1979                // Rejection-free index: ASCII_PRINTABLE has 94 entries; modulo
1980                // bias over a u32 is negligible and acceptable for a password.
1981                let idx = (rng.next_u32() as usize) % ASCII_PRINTABLE.len();
1982                out.push(ASCII_PRINTABLE.get(idx).copied().unwrap_or(b'!'));
1983            }
1984            Ok(out)
1985        }
1986        GenerateSpec::Base64 { bytes } => {
1987            let mut raw = vec![0u8; *bytes as usize];
1988            rng.fill_bytes(&mut raw);
1989            Ok(base64::engine::general_purpose::STANDARD
1990                .encode(&raw)
1991                .into_bytes())
1992        }
1993        GenerateSpec::Hex { bytes } => {
1994            let mut raw = vec![0u8; *bytes as usize];
1995            rng.fill_bytes(&mut raw);
1996            let mut out = String::with_capacity(*bytes as usize * 2);
1997            for b in &raw {
1998                let _ = write!(out, "{b:02x}");
1999            }
2000            Ok(out.into_bytes())
2001        }
2002        GenerateSpec::AgeX25519 => Ok(age::x25519::Identity::generate()
2003            .to_string()
2004            .expose_secret()
2005            .as_bytes()
2006            .to_vec()),
2007        GenerateSpec::SelfSignedTls {
2008            common_name,
2009            validity,
2010        } => Ok(generate_self_signed_tls(common_name, validity, None)?.cert_pem),
2011        GenerateSpec::SelfSignedTlsPairOf { .. } => Err(ManagerError::Backend(
2012            BackendError::Backend("self-signed-tls-pair-of needs catalog pair context".into()),
2013        )),
2014    }
2015}
2016
2017struct TlsMaterial {
2018    cert_pem: Vec<u8>,
2019    private_key_pem: Vec<u8>,
2020}
2021
2022fn generate_self_signed_tls(
2023    common_name: &str,
2024    validity: &str,
2025    existing_key_pem: Option<&[u8]>,
2026) -> Result<TlsMaterial, ManagerError> {
2027    let dir = std::env::temp_dir().join(format!("basil-self-signed-tls-{}", Uuid::new_v4()));
2028    std::fs::create_dir(&dir).map_err(|e| {
2029        ManagerError::Backend(BackendError::Backend(format!(
2030            "self-signed-tls tempdir create: {e}"
2031        )))
2032    })?;
2033
2034    let result = generate_self_signed_tls_in_dir(&dir, common_name, validity, existing_key_pem);
2035    let cleanup_result = std::fs::remove_dir_all(&dir);
2036    match (result, cleanup_result) {
2037        (Ok(material), Ok(()) | Err(_)) => Ok(material),
2038        (Err(err), _) => Err(err),
2039    }
2040}
2041
2042fn generate_self_signed_tls_in_dir(
2043    dir: &Path,
2044    common_name: &str,
2045    validity: &str,
2046    existing_key_pem: Option<&[u8]>,
2047) -> Result<TlsMaterial, ManagerError> {
2048    let cert_path = dir.join("cert.pem");
2049    let key_path = dir.join("key.pem");
2050    if let Some(key) = existing_key_pem {
2051        write_secret_file(&key_path, key)?;
2052    }
2053
2054    let mut command = Command::new("step");
2055    command
2056        .arg("certificate")
2057        .arg("create")
2058        .arg(common_name)
2059        .arg(&cert_path);
2060    if existing_key_pem.is_none() {
2061        command.arg(&key_path);
2062    }
2063    command
2064        .arg("--profile")
2065        .arg("self-signed")
2066        .arg("--subtle")
2067        .arg("--not-after")
2068        .arg(validity)
2069        .arg("--force");
2070    if existing_key_pem.is_some() {
2071        command.arg("--key").arg(&key_path);
2072    } else {
2073        command.arg("--no-password").arg("--insecure");
2074    }
2075
2076    let output = command.output().map_err(|e| {
2077        ManagerError::Backend(BackendError::Backend(format!(
2078            "self-signed-tls step execution failed: {e}"
2079        )))
2080    })?;
2081    if !output.status.success() {
2082        let detail = String::from_utf8_lossy(&output.stderr);
2083        return Err(ManagerError::Backend(BackendError::Backend(format!(
2084            "self-signed-tls step failed: {}",
2085            detail.trim()
2086        ))));
2087    }
2088
2089    let cert_pem = std::fs::read(&cert_path).map_err(|e| {
2090        ManagerError::Backend(BackendError::Backend(format!(
2091            "self-signed-tls read cert: {e}"
2092        )))
2093    })?;
2094    let private_key_pem = std::fs::read(&key_path).map_err(|e| {
2095        ManagerError::Backend(BackendError::Backend(format!(
2096            "self-signed-tls read key: {e}"
2097        )))
2098    })?;
2099    Ok(TlsMaterial {
2100        cert_pem,
2101        private_key_pem,
2102    })
2103}
2104
2105fn write_secret_file(path: &Path, contents: &[u8]) -> Result<(), ManagerError> {
2106    use std::io::Write as _;
2107    #[cfg(unix)]
2108    use std::os::unix::fs::OpenOptionsExt as _;
2109
2110    let mut options = std::fs::OpenOptions::new();
2111    options.write(true).create_new(true);
2112    #[cfg(unix)]
2113    options.mode(0o600);
2114    let mut file = options.open(path).map_err(|e| {
2115        ManagerError::Backend(BackendError::Backend(format!(
2116            "self-signed-tls write key: {e}"
2117        )))
2118    })?;
2119    file.write_all(contents).map_err(|e| {
2120        ManagerError::Backend(BackendError::Backend(format!(
2121            "self-signed-tls write key: {e}"
2122        )))
2123    })
2124}
2125
2126/// Map a catalog [`KeyEntry`] onto the wire [`CatalogKind`] reported by `list` (§7):
2127/// `asymmetric`/`public` → `signing`, `symmetric` → `encryption`, `value` →
2128/// `value`.
2129const fn key_kind(entry: &KeyEntry) -> CatalogKind {
2130    match entry.class {
2131        Class::Asymmetric | Class::Public => CatalogKind::Signing,
2132        // A sealing key is a KEM recipient (encryption-shaped) for `list`.
2133        Class::Symmetric | Class::Sealing => CatalogKind::Encryption,
2134        Class::Value => CatalogKind::Value,
2135    }
2136}
2137
2138/// The wire [`KeyType`] for a catalog key's algorithm, used as the `list`
2139/// fallback when the backend is unreachable (the catalog's declared type). Value
2140/// keys (and symmetric AEAD keys, which have no wire `KeyType`) yield `None`.
2141const fn wire_key_type(entry: &KeyEntry) -> Option<KeyType> {
2142    match entry.key_type {
2143        Some(KeyAlgorithm::Ed25519) => Some(KeyType::Ed25519),
2144        Some(KeyAlgorithm::Ed25519Nkey) => Some(KeyType::Ed25519Nkey),
2145        Some(KeyAlgorithm::Rsa2048) => Some(KeyType::Rsa2048),
2146        Some(KeyAlgorithm::EcdsaP256) => Some(KeyType::EcdsaP256),
2147        Some(KeyAlgorithm::EcdsaP384) => Some(KeyType::EcdsaP384),
2148        Some(KeyAlgorithm::EcdsaP521) => Some(KeyType::EcdsaP521),
2149        // ML-DSA software-custody signing keys carry a wire `KeyType` so `list`
2150        // reports their parameter level.
2151        Some(KeyAlgorithm::MlDsa44) => Some(KeyType::MlDsa44),
2152        Some(KeyAlgorithm::MlDsa65) => Some(KeyType::MlDsa65),
2153        Some(KeyAlgorithm::MlDsa87) => Some(KeyType::MlDsa87),
2154        // AEAD suites are an AeadAlgorithm on the wire, not a KeyType; sealing
2155        // keys (X25519 and ML-KEM alike) have no wire `KeyType`: `list` reports
2156        // them via the catalog `kind`, not a `KeyType`.
2157        Some(
2158            KeyAlgorithm::Aes256Gcm
2159            | KeyAlgorithm::ChaCha20Poly1305
2160            | KeyAlgorithm::X25519
2161            | KeyAlgorithm::MlKem512
2162            | KeyAlgorithm::MlKem768
2163            | KeyAlgorithm::MlKem1024,
2164        )
2165        | None => None,
2166    }
2167}
2168
2169/// The effective engine for a key: the catalog value, or the §2.2 inference from
2170/// the key's [`Class`] when the catalog omits it (crypto → transit, stored → kv2).
2171fn effective_engine(entry: &KeyEntry) -> Engine {
2172    entry.effective_engine()
2173}
2174
2175/// Check that `op` is valid for `class`, returning [`ManagerError::OpNotValidForClass`]
2176/// otherwise. `allowed` is the set of classes the op is defined on.
2177fn require_class(
2178    op: &'static str,
2179    key_id: &str,
2180    class: Class,
2181    allowed: &[Class],
2182) -> Result<(), ManagerError> {
2183    if allowed.contains(&class) {
2184        Ok(())
2185    } else {
2186        Err(ManagerError::OpNotValidForClass {
2187            op,
2188            key: key_id.to_string(),
2189            class,
2190        })
2191    }
2192}
2193
2194fn require_x25519_sealing_key(
2195    key_id: &str,
2196    actual: Option<KeyAlgorithm>,
2197) -> Result<(), ManagerError> {
2198    match actual {
2199        Some(KeyAlgorithm::X25519) => Ok(()),
2200        Some(other) => Err(ManagerError::KemAlgorithmMismatch {
2201            key: key_id.to_string(),
2202            requested: "x25519",
2203            actual: other.token(),
2204        }),
2205        None => Err(ManagerError::KemAlgorithmMismatch {
2206            key: key_id.to_string(),
2207            requested: "x25519",
2208            actual: "none",
2209        }),
2210    }
2211}
2212
2213/// The wire [`KeyType`] for an ML-DSA signing algorithm. Only ever called for an
2214/// ML-DSA algorithm (the caller filters via [`ml_dsa_signature_algorithm`]); the
2215/// classical arms are unreachable and present only for exhaustiveness.
2216const fn ml_dsa_wire_key_type(algorithm: SignatureAlgorithm) -> KeyType {
2217    match algorithm {
2218        SignatureAlgorithm::MlDsa44 => KeyType::MlDsa44,
2219        SignatureAlgorithm::MlDsa65 => KeyType::MlDsa65,
2220        SignatureAlgorithm::MlDsa87 => KeyType::MlDsa87,
2221        SignatureAlgorithm::Ed25519
2222        | SignatureAlgorithm::Ed25519Nkey
2223        | SignatureAlgorithm::Rs256
2224        | SignatureAlgorithm::Es256 => KeyType::Ed25519,
2225    }
2226}
2227
2228/// The wire [`KeyType`] for an ML-KEM sealing algorithm: the parameter level a
2229/// `get_public_key` on a software-custody ML-KEM sealing key reports alongside
2230/// the encapsulation key (basil-4ybx). The dual of [`ml_dsa_wire_key_type`].
2231const fn ml_kem_wire_key_type(kem: ProviderKemAlgorithm) -> KeyType {
2232    match kem {
2233        ProviderKemAlgorithm::MlKem512 => KeyType::MlKem512,
2234        ProviderKemAlgorithm::MlKem768 => KeyType::MlKem768,
2235        ProviderKemAlgorithm::MlKem1024 => KeyType::MlKem1024,
2236    }
2237}
2238
2239/// Map a catalog [`KeyAlgorithm`] to its provider ML-KEM parameter set, or `None`
2240/// for any non-ML-KEM algorithm. The routing signal for `new_key` on a sealing
2241/// key (the dual of [`ml_dsa_signature_algorithm`] for signing keys).
2242const fn ml_kem_provider_algorithm(algorithm: KeyAlgorithm) -> Option<ProviderKemAlgorithm> {
2243    match algorithm {
2244        KeyAlgorithm::MlKem512 => Some(ProviderKemAlgorithm::MlKem512),
2245        KeyAlgorithm::MlKem768 => Some(ProviderKemAlgorithm::MlKem768),
2246        KeyAlgorithm::MlKem1024 => Some(ProviderKemAlgorithm::MlKem1024),
2247        KeyAlgorithm::Ed25519
2248        | KeyAlgorithm::Ed25519Nkey
2249        | KeyAlgorithm::Rsa2048
2250        | KeyAlgorithm::EcdsaP256
2251        | KeyAlgorithm::EcdsaP384
2252        | KeyAlgorithm::EcdsaP521
2253        | KeyAlgorithm::Aes256Gcm
2254        | KeyAlgorithm::ChaCha20Poly1305
2255        | KeyAlgorithm::X25519
2256        | KeyAlgorithm::MlDsa44
2257        | KeyAlgorithm::MlDsa65
2258        | KeyAlgorithm::MlDsa87 => None,
2259    }
2260}
2261
2262fn require_ml_kem_sealing_key(
2263    key_id: &str,
2264    actual: Option<KeyAlgorithm>,
2265    requested: ProviderKemAlgorithm,
2266) -> Result<(), ManagerError> {
2267    let expected = match requested {
2268        ProviderKemAlgorithm::MlKem512 => KeyAlgorithm::MlKem512,
2269        ProviderKemAlgorithm::MlKem768 => KeyAlgorithm::MlKem768,
2270        ProviderKemAlgorithm::MlKem1024 => KeyAlgorithm::MlKem1024,
2271    };
2272    match actual {
2273        Some(found) if found == expected => Ok(()),
2274        Some(found) => Err(ManagerError::KemAlgorithmMismatch {
2275            key: key_id.to_string(),
2276            requested: requested.token(),
2277            actual: found.token(),
2278        }),
2279        None => Err(ManagerError::KemAlgorithmMismatch {
2280            key: key_id.to_string(),
2281            requested: requested.token(),
2282            actual: "none",
2283        }),
2284    }
2285}
2286
2287#[cfg(test)]
2288mod tests {
2289    use super::*;
2290    use crate::backend::{KeyMetadata, KvValue};
2291    use crate::core::crypto_provider::{SoftwareCustodyCatalog, encode_record_bytes};
2292    use crate::ml_kem_envelope;
2293    use async_trait::async_trait;
2294    use std::sync::Arc;
2295    use std::sync::atomic::{AtomicUsize, Ordering};
2296
2297    /// A mock backend that records the last `(op, path)` it was dispatched with,
2298    /// so a test can assert routing reached the right instance + path.
2299    #[derive(Default)]
2300    struct MockBackend {
2301        name: &'static str,
2302        last_path: std::sync::Mutex<Option<String>>,
2303        last_spiffe_id: std::sync::Mutex<Option<String>>,
2304        new_key_calls: AtomicUsize,
2305        rotate_calls: AtomicUsize,
2306        kv_put_calls: AtomicUsize,
2307        /// The `latest_version` the mock reports + bumps on rotate.
2308        latest_version: AtomicUsize,
2309        /// The most recent `(min_decryption_version, min_available_version)`
2310        /// passed to `configure_versions`, so a test can assert the grace/
2311        /// retention floor the manager computed.
2312        last_versions_config: std::sync::Mutex<Option<(Option<u32>, Option<u32>)>>,
2313        /// The last value written via `kv_put` (the regenerated value on rotate).
2314        last_kv_value: std::sync::Mutex<Option<Vec<u8>>>,
2315        last_sign_options: std::sync::Mutex<Option<SignOptions>>,
2316        last_verify_options: std::sync::Mutex<Option<SignOptions>>,
2317        last_new_key_type: std::sync::Mutex<Option<KeyType>>,
2318        /// Path-keyed KV store so a test can seed distinct values at distinct
2319        /// paths (e.g. a sealing key's private at `path` and its out-of-band
2320        /// public at `public_path`). A `kv_get`/`kv_get_secret` returns the value
2321        /// stored at the requested path; absent a path-specific seed it falls back
2322        /// to `last_kv_value` (so the many tests that only set `last_kv_value`
2323        /// keep working unchanged).
2324        kv_store: std::sync::Mutex<BTreeMap<String, Vec<u8>>>,
2325        kv_put_log: std::sync::Mutex<Vec<(String, Vec<u8>)>>,
2326    }
2327
2328    impl MockBackend {
2329        fn new(name: &'static str) -> Arc<Self> {
2330            Arc::new(Self {
2331                name,
2332                last_path: std::sync::Mutex::new(None),
2333                last_spiffe_id: std::sync::Mutex::new(None),
2334                new_key_calls: AtomicUsize::new(0),
2335                rotate_calls: AtomicUsize::new(0),
2336                kv_put_calls: AtomicUsize::new(0),
2337                latest_version: AtomicUsize::new(5),
2338                last_versions_config: std::sync::Mutex::new(None),
2339                last_kv_value: std::sync::Mutex::new(None),
2340                last_sign_options: std::sync::Mutex::new(None),
2341                last_verify_options: std::sync::Mutex::new(None),
2342                last_new_key_type: std::sync::Mutex::new(None),
2343                kv_store: std::sync::Mutex::new(BTreeMap::new()),
2344                kv_put_log: std::sync::Mutex::new(Vec::new()),
2345            })
2346        }
2347
2348        /// Seed a specific KV path with a value (used to provision a sealing
2349        /// key's out-of-band public separately from its private).
2350        fn seed_kv(&self, path: &str, value: Vec<u8>) {
2351            self.kv_store
2352                .lock()
2353                .unwrap()
2354                .insert(path.to_string(), value);
2355        }
2356
2357        /// Resolve a KV read: a path-seeded value if present, else the last
2358        /// `kv_put` value, else a default. Mirrors the read path for both
2359        /// `kv_get` and `kv_get_secret`.
2360        fn kv_lookup(&self, path: &str) -> Vec<u8> {
2361            if let Some(v) = self.kv_store.lock().unwrap().get(path) {
2362                return v.clone();
2363            }
2364            self.last_kv_value
2365                .lock()
2366                .unwrap()
2367                .clone()
2368                .unwrap_or_else(|| b"stored-value".to_vec())
2369        }
2370
2371        fn last_path(&self) -> Option<String> {
2372            self.last_path.lock().unwrap().clone()
2373        }
2374
2375        fn last_versions_config(&self) -> Option<(Option<u32>, Option<u32>)> {
2376            *self.last_versions_config.lock().unwrap()
2377        }
2378
2379        fn last_kv_value(&self) -> Option<Vec<u8>> {
2380            self.last_kv_value.lock().unwrap().clone()
2381        }
2382
2383        fn last_spiffe_id(&self) -> Option<String> {
2384            self.last_spiffe_id.lock().unwrap().clone()
2385        }
2386
2387        fn kv_put_log(&self) -> Vec<(String, Vec<u8>)> {
2388            self.kv_put_log.lock().unwrap().clone()
2389        }
2390
2391        fn last_sign_options(&self) -> Option<SignOptions> {
2392            *self.last_sign_options.lock().unwrap()
2393        }
2394
2395        fn last_verify_options(&self) -> Option<SignOptions> {
2396            *self.last_verify_options.lock().unwrap()
2397        }
2398
2399        fn last_new_key_type(&self) -> Option<KeyType> {
2400            *self.last_new_key_type.lock().unwrap()
2401        }
2402    }
2403
2404    /// A thin newtype so the same `Arc<MockBackend>` can be both inspected by the
2405    /// test and boxed into the manager's `Box<dyn Backend>` map.
2406    struct MockHandle(Arc<MockBackend>);
2407
2408    #[async_trait]
2409    impl Backend for MockHandle {
2410        fn kind(&self) -> &'static str {
2411            self.0.name
2412        }
2413
2414        async fn new_key(&self, key_type: KeyType) -> Result<NewKey, BackendError> {
2415            self.0.new_key_calls.fetch_add(1, Ordering::SeqCst);
2416            *self.0.last_new_key_type.lock().unwrap() = Some(key_type);
2417            Ok(NewKey {
2418                key_id: format!("{}-newkey", self.0.name),
2419                public_key: vec![1, 2, 3],
2420            })
2421        }
2422
2423        async fn public_key(&self, key_id: &str) -> Result<Vec<u8>, BackendError> {
2424            *self.0.last_path.lock().unwrap() = Some(key_id.to_string());
2425            Ok(vec![9, 9, 9])
2426        }
2427
2428        async fn public_key_with_meta(&self, key_id: &str) -> Result<PublicKey, BackendError> {
2429            *self.0.last_path.lock().unwrap() = Some(key_id.to_string());
2430            Ok(PublicKey {
2431                public_key: vec![9, 9, 9],
2432                // A non-default type+version so a test proves they are plumbed
2433                // (not the old hardcoded ed25519/1).
2434                key_type: KeyType::Ed25519Nkey,
2435                version: 7,
2436            })
2437        }
2438
2439        async fn key_metadata(&self, key_id: &str) -> Result<KeyMetadata, BackendError> {
2440            *self.0.last_path.lock().unwrap() = Some(key_id.to_string());
2441            Ok(KeyMetadata {
2442                key_type: Some(KeyType::Ed25519),
2443                latest_version: u32::try_from(self.0.latest_version.load(Ordering::SeqCst))
2444                    .unwrap_or(u32::MAX),
2445            })
2446        }
2447
2448        async fn import(
2449            &self,
2450            key_id: &str,
2451            _key_type: KeyType,
2452            _material: &KeyMaterial,
2453        ) -> Result<NewKey, BackendError> {
2454            *self.0.last_path.lock().unwrap() = Some(key_id.to_string());
2455            Ok(NewKey {
2456                key_id: key_id.to_string(),
2457                public_key: vec![0xEE, 0xEE],
2458            })
2459        }
2460
2461        async fn sign(&self, key_id: &str, _message: &[u8]) -> Result<Vec<u8>, BackendError> {
2462            *self.0.last_path.lock().unwrap() = Some(key_id.to_string());
2463            *self.0.last_sign_options.lock().unwrap() = Some(SignOptions::Default);
2464            Ok(vec![0xAB, 0xCD])
2465        }
2466
2467        async fn sign_with_options(
2468            &self,
2469            key_id: &str,
2470            message: &[u8],
2471            options: SignOptions,
2472        ) -> Result<Vec<u8>, BackendError> {
2473            let mut signature = self.sign(key_id, message).await?;
2474            *self.0.last_sign_options.lock().unwrap() = Some(options);
2475            if options == SignOptions::Rs256Pkcs1v15Sha256 {
2476                signature.push(0x52);
2477            } else if options == SignOptions::Es256 {
2478                signature.push(0x45);
2479            } else if options == SignOptions::Es384 {
2480                signature.push(0x46);
2481            } else if options == SignOptions::Es512 {
2482                signature.push(0x47);
2483            }
2484            Ok(signature)
2485        }
2486
2487        async fn verify(
2488            &self,
2489            key_id: &str,
2490            _message: &[u8],
2491            _signature: &[u8],
2492        ) -> Result<bool, BackendError> {
2493            *self.0.last_path.lock().unwrap() = Some(key_id.to_string());
2494            *self.0.last_verify_options.lock().unwrap() = Some(SignOptions::Default);
2495            Ok(true)
2496        }
2497
2498        async fn verify_with_options(
2499            &self,
2500            key_id: &str,
2501            message: &[u8],
2502            signature: &[u8],
2503            options: SignOptions,
2504        ) -> Result<bool, BackendError> {
2505            let valid = self.verify(key_id, message, signature).await?;
2506            *self.0.last_verify_options.lock().unwrap() = Some(options);
2507            Ok(valid)
2508        }
2509
2510        /// A deterministic stand-in for transit AEAD: the "ciphertext" is the
2511        /// plaintext X- prefixed with the AAD length + AAD, so `decrypt` can
2512        /// recover the plaintext and detect an AAD mismatch (returning the opaque
2513        /// `DecryptFailed`). The broker owns the nonce (here empty, as transit
2514        /// embeds it). `key_version` is the current latest.
2515        async fn encrypt(
2516            &self,
2517            key_id: &str,
2518            algorithm: AeadAlgorithm,
2519            plaintext: &[u8],
2520            aad: Option<&[u8]>,
2521        ) -> Result<CiphertextEnvelope, BackendError> {
2522            *self.0.last_path.lock().unwrap() = Some(key_id.to_string());
2523            let aad = aad.unwrap_or(&[]);
2524            let mut ciphertext = Vec::new();
2525            ciphertext.push(u8::try_from(aad.len()).unwrap_or(u8::MAX));
2526            ciphertext.extend_from_slice(aad);
2527            ciphertext.extend_from_slice(plaintext);
2528            Ok(CiphertextEnvelope {
2529                alg: algorithm,
2530                key_version: u32::try_from(self.0.latest_version.load(Ordering::SeqCst))
2531                    .unwrap_or(u32::MAX),
2532                nonce: Vec::new(),
2533                ciphertext,
2534            })
2535        }
2536
2537        async fn decrypt(
2538            &self,
2539            key_id: &str,
2540            envelope: &CiphertextEnvelope,
2541            aad: Option<&[u8]>,
2542        ) -> Result<Vec<u8>, BackendError> {
2543            *self.0.last_path.lock().unwrap() = Some(key_id.to_string());
2544            let aad = aad.unwrap_or(&[]);
2545            let ct = &envelope.ciphertext;
2546            let aad_len = *ct.first().ok_or(BackendError::DecryptFailed)? as usize;
2547            let bound = ct.get(1..1 + aad_len).ok_or(BackendError::DecryptFailed)?;
2548            // AAD mismatch -> opaque DecryptFailed (no oracle).
2549            if bound != aad {
2550                return Err(BackendError::DecryptFailed);
2551            }
2552            Ok(ct
2553                .get(1 + aad_len..)
2554                .ok_or(BackendError::DecryptFailed)?
2555                .to_vec())
2556        }
2557
2558        async fn rotate(&self, key_id: &str) -> Result<u32, BackendError> {
2559            *self.0.last_path.lock().unwrap() = Some(key_id.to_string());
2560            self.0.rotate_calls.fetch_add(1, Ordering::SeqCst);
2561            let v = self.0.latest_version.fetch_add(1, Ordering::SeqCst) + 1;
2562            Ok(u32::try_from(v).unwrap_or(u32::MAX))
2563        }
2564
2565        async fn kv_get(
2566            &self,
2567            key_id: &str,
2568            version: Option<u32>,
2569        ) -> Result<KvValue, BackendError> {
2570            *self.0.last_path.lock().unwrap() = Some(key_id.to_string());
2571            // A path-seeded value wins (distinct private/public paths); otherwise
2572            // fall back to the last `kv_put` value (or a default). The version is
2573            // the requested one, or the current latest when none was asked.
2574            let value = self.0.kv_lookup(key_id);
2575            let version = version.unwrap_or_else(|| {
2576                u32::try_from(self.0.latest_version.load(Ordering::SeqCst)).unwrap_or(u32::MAX)
2577            });
2578            Ok(KvValue { value, version })
2579        }
2580
2581        async fn kv_get_secret(
2582            &self,
2583            key_id: &str,
2584            _version: Option<u32>,
2585        ) -> Result<Zeroizing<Vec<u8>>, BackendError> {
2586            *self.0.last_path.lock().unwrap() = Some(key_id.to_string());
2587            Ok(Zeroizing::new(self.0.kv_lookup(key_id)))
2588        }
2589
2590        async fn kv_put(&self, key_id: &str, value: &[u8]) -> Result<u32, BackendError> {
2591            *self.0.last_path.lock().unwrap() = Some(key_id.to_string());
2592            self.0.kv_put_calls.fetch_add(1, Ordering::SeqCst);
2593            *self.0.last_kv_value.lock().unwrap() = Some(value.to_vec());
2594            self.0
2595                .kv_store
2596                .lock()
2597                .unwrap()
2598                .insert(key_id.to_string(), value.to_vec());
2599            self.0
2600                .kv_put_log
2601                .lock()
2602                .unwrap()
2603                .push((key_id.to_string(), value.to_vec()));
2604            let v = self.0.latest_version.fetch_add(1, Ordering::SeqCst) + 1;
2605            Ok(u32::try_from(v).unwrap_or(u32::MAX))
2606        }
2607
2608        async fn configure_versions(
2609            &self,
2610            key_id: &str,
2611            min_decryption_version: Option<u32>,
2612            min_available_version: Option<u32>,
2613        ) -> Result<(), BackendError> {
2614            *self.0.last_path.lock().unwrap() = Some(key_id.to_string());
2615            *self.0.last_versions_config.lock().unwrap() =
2616                Some((min_decryption_version, min_available_version));
2617            Ok(())
2618        }
2619
2620        async fn issue_x509_svid(
2621            &self,
2622            key_id: &str,
2623            spiffe_id: &str,
2624            _ttl_seconds: u64,
2625        ) -> Result<X509Svid, BackendError> {
2626            *self.0.last_path.lock().unwrap() = Some(key_id.to_string());
2627            *self.0.last_spiffe_id.lock().unwrap() = Some(spiffe_id.to_string());
2628            Ok(X509Svid {
2629                cert_chain_der: vec![vec![1, 2, 3]],
2630                leaf_private_key_der: zeroize::Zeroizing::new(vec![4, 5, 6]),
2631                bundle_der: vec![vec![7, 8, 9]],
2632            })
2633        }
2634
2635        async fn issue_x509_cert(
2636            &self,
2637            key_id: &str,
2638            request: &X509CertRequest,
2639        ) -> Result<X509Svid, BackendError> {
2640            *self.0.last_path.lock().unwrap() = Some(key_id.to_string());
2641            // Reuse the spiffe-id recording slot to capture the cert subject.
2642            *self.0.last_spiffe_id.lock().unwrap() = Some(request.common_name.clone());
2643            Ok(X509Svid {
2644                cert_chain_der: vec![vec![0xCE, 0x27]],
2645                leaf_private_key_der: zeroize::Zeroizing::new(vec![0xDE, 0xAD]),
2646                bundle_der: vec![vec![0xCA, 0xFE]],
2647            })
2648        }
2649    }
2650
2651    /// Two backends + keys routed to each, exercising every class.
2652    ///   asym.signer  -> backend "primary", transit key name "signer"
2653    ///   sym.box      -> backend "secondary", transit key name "box"
2654    ///   web.value    -> backend "primary",  kv path "secret/data/web/value"
2655    ///   web.cert     -> backend "secondary", public, kv path "secret/data/web/cert"
2656    ///
2657    /// Transit `path`s are the BARE key name (§2.2): the transit backend builds
2658    /// `transit/<verb>/<name>` from it; a `transit/keys/<name>` path would 404
2659    /// against a live Vault server (`vault-w3n`).
2660    const CATALOG: &str = r#"{
2661      "schemaVersion": 1,
2662      "backends": {
2663        "primary":   {
2664          "kind": "vault", "addr": "https://127.0.0.1:8200",
2665          "engines": ["transit", "kv2"], "capabilities": [],
2666          "mintKeyTypes": ["ed25519", "ed25519-nkey", "rsa-2048", "ecdsa-p256", "ecdsa-p384", "ecdsa-p521"]
2667        },
2668        "secondary": {
2669          "kind": "vault", "addr": "https://127.0.0.1:8201",
2670          "engines": ["transit", "kv2"], "capabilities": [],
2671          "mintKeyTypes": ["ed25519", "ed25519-nkey", "rsa-2048", "ecdsa-p256"]
2672        }
2673      },
2674      "keys": {
2675        "asym.signer": {
2676          "class": "asymmetric", "keyType": "ed25519", "backend": "primary",
2677          "path": "signer", "writable": true, "missing": "error",
2678          "description": "a signing key"
2679        },
2680        "asym.nkey": {
2681          "class": "asymmetric", "keyType": "ed25519-nkey", "backend": "primary",
2682          "path": "nkey-signer", "writable": true, "missing": "error",
2683          "description": "a NATS nkey signing key"
2684        },
2685        "asym.rsa": {
2686          "class": "asymmetric", "keyType": "rsa-2048", "backend": "primary",
2687          "path": "rsa-signer", "writable": true, "missing": "error",
2688          "description": "an RSA signing key"
2689        },
2690        "asym.ecdsa": {
2691          "class": "asymmetric", "keyType": "ecdsa-p256", "backend": "primary",
2692          "path": "ecdsa-signer", "writable": true, "missing": "error",
2693          "description": "an ECDSA P-256 signing key"
2694        },
2695        "asym.ecdsa384": {
2696          "class": "asymmetric", "keyType": "ecdsa-p384", "backend": "primary",
2697          "path": "ecdsa384-signer", "writable": true, "missing": "error",
2698          "description": "an ECDSA P-384 signing key"
2699        },
2700        "asym.ecdsa521": {
2701          "class": "asymmetric", "keyType": "ecdsa-p521", "backend": "primary",
2702          "path": "ecdsa521-signer", "writable": true, "missing": "error",
2703          "description": "an ECDSA P-521 signing key"
2704        },
2705        "sym.box": {
2706          "class": "symmetric", "keyType": "aes-256-gcm", "backend": "secondary",
2707          "path": "box", "writable": true, "missing": "error",
2708          "description": "a symmetric key"
2709        },
2710        "web.value": {
2711          "class": "value", "backend": "primary", "engine": "kv2",
2712          "path": "secret/data/web/value", "writable": true, "missing": "error",
2713          "description": "an opaque value"
2714        },
2715        "gen.value": {
2716          "class": "value", "backend": "primary", "engine": "kv2",
2717          "path": "secret/data/gen/value", "writable": true, "missing": "generate",
2718          "generate": { "format": "ascii-printable", "bytes": 24 },
2719          "description": "a generate-able value (rotate regenerates)"
2720        },
2721        "tls.cert": {
2722          "class": "public", "backend": "primary", "engine": "kv2",
2723          "path": "secret/data/tls/cert", "writable": false, "missing": "generate",
2724          "generate": { "format": "self-signed-tls", "commonName": "example.test", "validity": "1h" },
2725          "description": "a generated self-signed cert"
2726        },
2727        "tls.key": {
2728          "class": "value", "backend": "primary", "engine": "kv2",
2729          "path": "secret/data/tls/key", "writable": true, "missing": "generate",
2730          "generate": { "format": "self-signed-tls-pair-of", "pairOf": "tls.cert" },
2731          "description": "the private key paired with tls.cert"
2732        },
2733        "web.cert": {
2734          "class": "public", "backend": "secondary", "engine": "kv2",
2735          "path": "secret/data/web/cert", "writable": false, "missing": "warn",
2736          "description": "a public cert"
2737        },
2738        "spiffe.issuer": {
2739          "class": "asymmetric", "keyType": "ed25519", "backend": "secondary", "engine": "pki",
2740          "path": "pki/issue/workload", "writable": false, "missing": "error",
2741          "description": "a pki issuer role"
2742        },
2743        "enroll.sealing": {
2744          "class": "sealing", "keyType": "x25519", "backend": "primary", "engine": "kv2",
2745          "path": "secret/data/enroll/x25519",
2746          "publicPath": "secret/data/enroll/x25519-public",
2747          "writable": true, "missing": "error",
2748          "description": "an x25519 enrollment sealing key"
2749        },
2750        "enroll.mlkem": {
2751          "class": "sealing", "keyType": "ml-kem-768", "backend": "primary", "engine": "kv2",
2752          "path": "secret/data/enroll/ml-kem-768",
2753          "publicPath": "secret/data/enroll/ml-kem-768-public",
2754          "labels": [
2755            "crypto_provider=local-software",
2756            "crypto_provider_version=1",
2757            "pqc_algorithm=ml-kem-768",
2758            "pqc_custody=software-encrypted",
2759            "crypto_provider_policy=local-software",
2760            "pqc_storage_key=pqc/storage/wrap"
2761          ],
2762          "writable": true, "missing": "error",
2763          "description": "an ML-KEM enrollment sealing key"
2764        },
2765        "kv2.signer": {
2766          "class": "asymmetric", "keyType": "ed25519", "backend": "primary", "engine": "kv2",
2767          "path": "secret/data/kv2/signer",
2768          "publicPath": "secret/data/kv2/signer-public",
2769          "writable": true, "missing": "error",
2770          "description": "a value-store Ed25519 materialize-to-sign key"
2771        }
2772      }
2773    }"#;
2774
2775    fn parse_catalog(json: &str) -> Catalog {
2776        serde_json::from_str(json).expect("catalog parses")
2777    }
2778
2779    /// Build a manager over the fixture catalog, returning the manager plus the
2780    /// two mock handles so tests can inspect what they were dispatched.
2781    fn fixture() -> (BackendManager, Arc<MockBackend>, Arc<MockBackend>) {
2782        let primary = MockBackend::new("primary");
2783        let secondary = MockBackend::new("secondary");
2784        let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
2785        backends.insert("primary".into(), Box::new(MockHandle(primary.clone())));
2786        backends.insert("secondary".into(), Box::new(MockHandle(secondary.clone())));
2787        let mgr = BackendManager::new(parse_catalog(CATALOG), backends)
2788            .expect("manager constructs cleanly");
2789        (mgr, primary, secondary)
2790    }
2791
2792    #[test]
2793    fn new_rejects_key_naming_absent_backend() {
2794        // A catalog whose key references a backend we never provide.
2795        let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
2796        backends.insert(
2797            "primary".into(),
2798            Box::new(MockHandle(MockBackend::new("primary"))),
2799        );
2800        // "sym.box"/"web.cert" route to "secondary", which is absent here.
2801        let err = BackendManager::new(parse_catalog(CATALOG), backends)
2802            .expect_err("missing backend must fail closed");
2803        match err {
2804            ManagerError::UnknownBackend { backend, .. } => assert_eq!(backend, "secondary"),
2805            other => panic!("expected UnknownBackend, got {other:?}"),
2806        }
2807    }
2808
2809    #[test]
2810    fn resolve_maps_key_to_correct_backend_and_path() {
2811        let (mgr, _p, _s) = fixture();
2812
2813        let routed = mgr.resolve("asym.signer").expect("resolves");
2814        assert_eq!(routed.backend.kind(), "primary");
2815        assert_eq!(routed.path(), "signer");
2816        assert_eq!(routed.class(), Class::Asymmetric);
2817        assert_eq!(routed.engine, Engine::Transit);
2818        assert_eq!(routed.key_type(), Some(KeyAlgorithm::Ed25519));
2819
2820        let routed = mgr.resolve("web.cert").expect("resolves");
2821        assert_eq!(routed.backend.kind(), "secondary");
2822        assert_eq!(routed.path(), "secret/data/web/cert");
2823        assert_eq!(routed.class(), Class::Public);
2824        assert_eq!(routed.engine, Engine::Kv2);
2825
2826        let routed = mgr.resolve("spiffe.issuer").expect("resolves");
2827        assert_eq!(routed.backend.kind(), "secondary");
2828        assert_eq!(routed.path(), "pki/issue/workload");
2829        assert_eq!(routed.class(), Class::Asymmetric);
2830        assert_eq!(routed.engine, Engine::Pki);
2831    }
2832
2833    #[test]
2834    fn resolve_infers_engine_when_catalog_omits_it() {
2835        // asym.signer / sym.box have no explicit `engine` -> inferred Transit.
2836        let (mgr, _p, _s) = fixture();
2837        assert_eq!(mgr.resolve("asym.signer").unwrap().engine, Engine::Transit);
2838        assert_eq!(mgr.resolve("sym.box").unwrap().engine, Engine::Transit);
2839    }
2840
2841    #[test]
2842    fn resolve_unknown_key_errors() {
2843        let (mgr, _p, _s) = fixture();
2844        let err = mgr.resolve("does.not.exist").expect_err("unknown key");
2845        match err {
2846            ManagerError::UnknownKey(k) => assert_eq!(k, "does.not.exist"),
2847            other => panic!("expected UnknownKey, got {other:?}"),
2848        }
2849    }
2850
2851    #[tokio::test]
2852    async fn sign_routes_to_correct_backend_with_path() {
2853        let (mgr, primary, secondary) = fixture();
2854        let sig = mgr.sign("asym.signer", &[1, 2, 3]).await.expect("signs");
2855        assert_eq!(sig, vec![0xAB, 0xCD]);
2856        // Dispatched to "primary" using the catalog PATH (the bare transit key
2857        // name), not the dotted name.
2858        assert_eq!(primary.last_path().as_deref(), Some("signer"));
2859        assert_eq!(
2860            secondary.last_path(),
2861            None,
2862            "the other backend is untouched"
2863        );
2864    }
2865
2866    #[tokio::test]
2867    async fn nkey_sign_uses_raw_ed25519_transit_defaults() {
2868        let (mgr, primary, _secondary) = fixture();
2869        let sig = mgr.sign("asym.nkey", b"nonce").await.expect("signs nkey");
2870        assert_eq!(sig, vec![0xAB, 0xCD]);
2871        assert_eq!(primary.last_path().as_deref(), Some("nkey-signer"));
2872        assert_eq!(primary.last_sign_options(), Some(SignOptions::Default));
2873    }
2874
2875    #[tokio::test]
2876    async fn rsa_sign_and_verify_use_rs256_transit_options() {
2877        let (mgr, primary, _secondary) = fixture();
2878        let sig = mgr.sign("asym.rsa", b"jwt-input").await.expect("signs rsa");
2879        assert_eq!(sig, vec![0xAB, 0xCD, 0x52]);
2880        assert_eq!(primary.last_path().as_deref(), Some("rsa-signer"));
2881        assert_eq!(
2882            primary.last_sign_options(),
2883            Some(SignOptions::Rs256Pkcs1v15Sha256)
2884        );
2885
2886        let valid = mgr
2887            .verify("asym.rsa", b"jwt-input", &sig)
2888            .await
2889            .expect("verifies rsa");
2890        assert!(valid);
2891        assert_eq!(
2892            primary.last_verify_options(),
2893            Some(SignOptions::Rs256Pkcs1v15Sha256)
2894        );
2895    }
2896
2897    #[tokio::test]
2898    async fn ecdsa_sign_and_verify_use_es256_transit_options() {
2899        let (mgr, primary, _secondary) = fixture();
2900        let sig = mgr
2901            .sign("asym.ecdsa", b"jwt-input")
2902            .await
2903            .expect("signs ecdsa");
2904        assert_eq!(sig, vec![0xAB, 0xCD, 0x45]);
2905        assert_eq!(primary.last_path().as_deref(), Some("ecdsa-signer"));
2906        assert_eq!(primary.last_sign_options(), Some(SignOptions::Es256));
2907
2908        let valid = mgr
2909            .verify("asym.ecdsa", b"jwt-input", &sig)
2910            .await
2911            .expect("verifies ecdsa");
2912        assert!(valid);
2913        assert_eq!(primary.last_verify_options(), Some(SignOptions::Es256));
2914    }
2915
2916    #[tokio::test]
2917    async fn ecdsa_p384_sign_and_verify_use_es384_transit_options() {
2918        let (mgr, primary, _secondary) = fixture();
2919        let sig = mgr
2920            .sign("asym.ecdsa384", b"jwt-input")
2921            .await
2922            .expect("signs ecdsa p384");
2923        assert_eq!(sig, vec![0xAB, 0xCD, 0x46]);
2924        assert_eq!(primary.last_path().as_deref(), Some("ecdsa384-signer"));
2925        assert_eq!(primary.last_sign_options(), Some(SignOptions::Es384));
2926
2927        let valid = mgr
2928            .verify("asym.ecdsa384", b"jwt-input", &sig)
2929            .await
2930            .expect("verifies ecdsa p384");
2931        assert!(valid);
2932        assert_eq!(primary.last_verify_options(), Some(SignOptions::Es384));
2933    }
2934
2935    #[tokio::test]
2936    async fn ecdsa_p521_sign_and_verify_use_es512_transit_options() {
2937        let (mgr, primary, _secondary) = fixture();
2938        let sig = mgr
2939            .sign("asym.ecdsa521", b"jwt-input")
2940            .await
2941            .expect("signs ecdsa p521");
2942        assert_eq!(sig, vec![0xAB, 0xCD, 0x47]);
2943        assert_eq!(primary.last_path().as_deref(), Some("ecdsa521-signer"));
2944        assert_eq!(primary.last_sign_options(), Some(SignOptions::Es512));
2945
2946        let valid = mgr
2947            .verify("asym.ecdsa521", b"jwt-input", &sig)
2948            .await
2949            .expect("verifies ecdsa p521");
2950        assert!(valid);
2951        assert_eq!(primary.last_verify_options(), Some(SignOptions::Es512));
2952    }
2953
2954    #[tokio::test]
2955    async fn issue_x509_svid_routes_to_pki_issue_path() {
2956        let (mgr, _primary, secondary) = fixture();
2957        let svid = mgr
2958            .issue_x509_svid("spiffe.issuer", "spiffe://example.test/web", 300)
2959            .await
2960            .expect("issues x509 svid");
2961        assert_eq!(secondary.last_path().as_deref(), Some("pki/issue/workload"));
2962        assert_eq!(
2963            secondary.last_spiffe_id().as_deref(),
2964            Some("spiffe://example.test/web")
2965        );
2966        assert_eq!(svid.cert_chain_der, vec![vec![1, 2, 3]]);
2967        assert_eq!(&*svid.leaf_private_key_der, &[4, 5, 6]);
2968        assert_eq!(svid.bundle_der, vec![vec![7, 8, 9]]);
2969    }
2970
2971    #[tokio::test]
2972    async fn issue_x509_svid_rejects_non_pki_key() {
2973        let (mgr, _primary, _secondary) = fixture();
2974        let err = mgr
2975            .issue_x509_svid("asym.signer", "spiffe://example.test/web", 300)
2976            .await
2977            .expect_err("transit key is not a pki issuer");
2978        assert!(matches!(err, ManagerError::Unsupported("issue_x509_svid")));
2979    }
2980
2981    #[tokio::test]
2982    async fn issue_x509_cert_routes_to_pki_issue_path_with_sans() {
2983        let (mgr, _primary, secondary) = fixture();
2984        let request = X509CertRequest {
2985            common_name: "web.internal".into(),
2986            dns_sans: vec!["web.internal".into(), "alt.internal".into()],
2987            ip_sans: vec!["10.0.0.1".into()],
2988            ttl_seconds: 3600,
2989        };
2990        let issued = mgr
2991            .issue_x509_cert("spiffe.issuer", &request)
2992            .await
2993            .expect("issues x509 cert");
2994        assert_eq!(secondary.last_path().as_deref(), Some("pki/issue/workload"));
2995        assert_eq!(secondary.last_spiffe_id().as_deref(), Some("web.internal"));
2996        assert_eq!(issued.cert_chain_der, vec![vec![0xCE, 0x27]]);
2997        assert_eq!(&*issued.leaf_private_key_der, &[0xDE, 0xAD]);
2998    }
2999
3000    #[tokio::test]
3001    async fn issue_x509_cert_rejects_non_pki_key() {
3002        let (mgr, _primary, _secondary) = fixture();
3003        let request = X509CertRequest {
3004            common_name: "web.internal".into(),
3005            ..X509CertRequest::default()
3006        };
3007        let err = mgr
3008            .issue_x509_cert("asym.signer", &request)
3009            .await
3010            .expect_err("transit key is not a pki issuer");
3011        assert!(matches!(err, ManagerError::Unsupported("issue_x509_cert")));
3012    }
3013
3014    #[tokio::test]
3015    async fn new_key_routes_to_correct_backend() {
3016        let (mgr, primary, secondary) = fixture();
3017        let nk = mgr
3018            .new_key("asym.signer", KeyType::Ed25519)
3019            .await
3020            .expect("new_key");
3021        assert_eq!(nk.key_id, "primary-newkey");
3022        assert_eq!(primary.new_key_calls.load(Ordering::SeqCst), 1);
3023        assert_eq!(primary.last_new_key_type(), Some(KeyType::Ed25519));
3024        assert_eq!(secondary.new_key_calls.load(Ordering::SeqCst), 0);
3025
3026        mgr.new_key("asym.nkey", KeyType::Ed25519Nkey)
3027            .await
3028            .expect("new_key nkey");
3029        assert_eq!(primary.last_new_key_type(), Some(KeyType::Ed25519Nkey));
3030
3031        mgr.new_key("asym.rsa", KeyType::Rsa2048)
3032            .await
3033            .expect("new_key rsa");
3034        assert_eq!(primary.last_new_key_type(), Some(KeyType::Rsa2048));
3035
3036        mgr.new_key("asym.ecdsa", KeyType::EcdsaP256)
3037            .await
3038            .expect("new_key ecdsa");
3039        assert_eq!(primary.last_new_key_type(), Some(KeyType::EcdsaP256));
3040
3041        mgr.new_key("asym.ecdsa384", KeyType::EcdsaP384)
3042            .await
3043            .expect("new_key ecdsa p384");
3044        assert_eq!(primary.last_new_key_type(), Some(KeyType::EcdsaP384));
3045
3046        mgr.new_key("asym.ecdsa521", KeyType::EcdsaP521)
3047            .await
3048            .expect("new_key ecdsa p521");
3049        assert_eq!(primary.last_new_key_type(), Some(KeyType::EcdsaP521));
3050    }
3051
3052    #[tokio::test]
3053    async fn new_key_rejects_key_type_absent_from_static_backend_preset() {
3054        let catalog = CATALOG.replace(
3055            r#""mintKeyTypes": ["ed25519", "ed25519-nkey", "rsa-2048", "ecdsa-p256", "ecdsa-p384", "ecdsa-p521"]"#,
3056            r#""mintKeyTypes": ["ed25519"]"#,
3057        );
3058        let primary = MockBackend::new("primary");
3059        let secondary = MockBackend::new("secondary");
3060        let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
3061        backends.insert("primary".into(), Box::new(MockHandle(primary.clone())));
3062        backends.insert("secondary".into(), Box::new(MockHandle(secondary)));
3063        let mgr = BackendManager::new(parse_catalog(&catalog), backends).expect("manager builds");
3064
3065        let err = mgr
3066            .new_key("asym.signer", KeyType::Rsa2048)
3067            .await
3068            .expect_err("rsa absent from backend preset");
3069        assert!(matches!(
3070            err,
3071            ManagerError::UnsupportedKeyType {
3072                backend,
3073                op: "new_key",
3074                key_type: KeyType::Rsa2048
3075            } if backend == "primary"
3076        ));
3077        assert_eq!(primary.new_key_calls.load(Ordering::SeqCst), 0);
3078    }
3079
3080    #[tokio::test]
3081    async fn get_public_key_valid_on_public_class_returns_real_metadata() {
3082        let (mgr, _p, secondary) = fixture();
3083        let pk = mgr.get_public_key("web.cert").await.expect("public read");
3084        assert_eq!(pk.public_key, vec![9, 9, 9]);
3085        // Real type+version from the backend, NOT the old hardcoded ed25519/1.
3086        assert_eq!(pk.key_type, KeyType::Ed25519Nkey);
3087        assert_eq!(pk.version, 7);
3088        assert_eq!(
3089            secondary.last_path().as_deref(),
3090            Some("secret/data/web/cert")
3091        );
3092    }
3093
3094    #[tokio::test]
3095    async fn import_routes_to_backend_path_and_returns_only_public() {
3096        let (mgr, primary, _s) = fixture();
3097        let nk = mgr
3098            .import(
3099                "asym.signer",
3100                KeyType::Ed25519,
3101                &KeyMaterial::Ed25519Seed(vec![7; 32]),
3102            )
3103            .await
3104            .expect("import");
3105        // Replies with the public half only; never the seed.
3106        assert_eq!(nk.public_key, vec![0xEE, 0xEE]);
3107        // Dispatched to the catalog PATH (bare transit key name), not the dotted name.
3108        assert_eq!(primary.last_path().as_deref(), Some("signer"));
3109    }
3110
3111    #[tokio::test]
3112    async fn import_rejects_key_type_absent_from_static_backend_preset() {
3113        let catalog = CATALOG.replace(
3114            r#""mintKeyTypes": ["ed25519", "ed25519-nkey", "rsa-2048", "ecdsa-p256", "ecdsa-p384", "ecdsa-p521"]"#,
3115            r#""mintKeyTypes": ["ed25519"]"#,
3116        );
3117        let primary = MockBackend::new("primary");
3118        let secondary = MockBackend::new("secondary");
3119        let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
3120        backends.insert("primary".into(), Box::new(MockHandle(primary.clone())));
3121        backends.insert("secondary".into(), Box::new(MockHandle(secondary)));
3122        let mgr = BackendManager::new(parse_catalog(&catalog), backends).expect("manager builds");
3123
3124        let err = mgr
3125            .import(
3126                "asym.signer",
3127                KeyType::Rsa2048,
3128                &KeyMaterial::Pkcs8Der(vec![1, 2, 3]),
3129            )
3130            .await
3131            .expect_err("rsa absent from backend preset");
3132        assert!(matches!(
3133            err,
3134            ManagerError::UnsupportedKeyType {
3135                backend,
3136                op: "import",
3137                key_type: KeyType::Rsa2048
3138            } if backend == "primary"
3139        ));
3140        assert!(primary.last_path().is_none());
3141    }
3142
3143    #[tokio::test]
3144    async fn import_on_value_key_is_op_not_valid_for_class() {
3145        let (mgr, _p, _s) = fixture();
3146        let err = mgr
3147            .import(
3148                "web.value",
3149                KeyType::Ed25519,
3150                &KeyMaterial::Ed25519Seed(vec![0; 32]),
3151            )
3152            .await
3153            .expect_err("import on a value key must be rejected");
3154        assert!(matches!(err, ManagerError::OpNotValidForClass { .. }));
3155    }
3156
3157    #[tokio::test]
3158    async fn list_projects_catalog_value_free_and_respects_visibility() {
3159        let (mgr, _p, _s) = fixture();
3160        // Visibility predicate hides web.value (simulating a PDP filter).
3161        let entries = mgr
3162            //  ubs false positive: name is not secret material
3163            /* ubs:ignore */
3164            .list(None, |name| name != "web.value")
3165            .await
3166            .expect("list");
3167        let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
3168        assert!(names.contains(&"asym.signer"));
3169        assert!(names.contains(&"sym.box"));
3170        assert!(names.contains(&"web.cert"));
3171        assert!(
3172            !names.contains(&"web.value"),
3173            "invisible key must be filtered"
3174        );
3175        // Class -> kind mapping + backend-sourced version, never key bytes.
3176        let signer = entries.iter().find(|e| e.name == "asym.signer").unwrap();
3177        assert_eq!(signer.kind, CatalogKind::Signing);
3178        assert_eq!(signer.key_type, Some(KeyType::Ed25519));
3179        // The mock reports its current latest_version (5 before any rotate).
3180        assert_eq!(signer.latest_version, 5);
3181        let boxx = entries.iter().find(|e| e.name == "sym.box").unwrap();
3182        assert_eq!(boxx.kind, CatalogKind::Encryption);
3183        let cert = entries.iter().find(|e| e.name == "web.cert").unwrap();
3184        assert_eq!(cert.kind, CatalogKind::Signing);
3185    }
3186
3187    #[tokio::test]
3188    async fn list_filters_by_prefix() {
3189        let (mgr, _p, _s) = fixture();
3190        let entries = mgr.list(Some("web."), |_| true).await.expect("list");
3191        let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
3192        assert_eq!(names, vec!["web.cert", "web.value"]);
3193    }
3194
3195    #[tokio::test]
3196    async fn sign_on_value_key_is_op_not_valid_for_class() {
3197        let (mgr, _p, _s) = fixture();
3198        let err = mgr
3199            .sign("web.value", &[1])
3200            .await
3201            .expect_err("sign on a value key must be rejected");
3202        match err {
3203            ManagerError::OpNotValidForClass { op, key, class } => {
3204                assert_eq!(op, "sign");
3205                assert_eq!(key, "web.value");
3206                assert_eq!(class, Class::Value);
3207            }
3208            other => panic!("expected OpNotValidForClass, got {other:?}"),
3209        }
3210    }
3211
3212    #[tokio::test]
3213    async fn new_key_on_value_key_is_op_not_valid_for_class() {
3214        let (mgr, _p, _s) = fixture();
3215        let err = mgr
3216            .new_key("web.value", KeyType::Ed25519)
3217            .await
3218            .expect_err("new_key on a value key must be rejected");
3219        assert!(matches!(err, ManagerError::OpNotValidForClass { .. }));
3220    }
3221
3222    #[tokio::test]
3223    async fn unbacked_op_still_resolves_first_unknown_key_wins() {
3224        // A backed op on an unknown key reports UnknownKey before any dispatch.
3225        let (mgr, _p, _s) = fixture();
3226        assert!(matches!(
3227            mgr.encrypt("nope", AeadAlgorithm::Aes256Gcm, &[1], None)
3228                .await,
3229            Err(ManagerError::UnknownKey(_))
3230        ));
3231    }
3232
3233    // ---- get / set (KV-v2 value ops, vault-jbr) -----------------------------
3234
3235    #[tokio::test]
3236    async fn set_writes_value_and_get_round_trips_it() {
3237        let (mgr, primary, _s) = fixture();
3238        // set writes a fresh KV-v2 version of a value key and returns the version.
3239        let version = mgr.set("web.value", b"super-secret").await.expect("set");
3240        assert_eq!(primary.kv_put_calls.load(Ordering::SeqCst), 1);
3241        // web.value starts at latest 5; the write bumps to 6.
3242        assert_eq!(version, 6);
3243        // Dispatched to the catalog PATH, not the dotted name.
3244        assert_eq!(
3245            primary.last_path().as_deref(),
3246            Some("secret/data/web/value")
3247        );
3248
3249        // get reads the value back + the version (latest when none requested).
3250        let kv = mgr.get("web.value", None).await.expect("get");
3251        assert_eq!(kv.value, b"super-secret");
3252        assert_eq!(kv.version, 6);
3253    }
3254
3255    #[tokio::test]
3256    async fn get_reads_a_specific_version() {
3257        let (mgr, _p, _s) = fixture();
3258        // An explicit version is carried through to the backend + echoed.
3259        let kv = mgr.get("web.value", Some(3)).await.expect("get");
3260        assert_eq!(kv.version, 3);
3261    }
3262
3263    #[tokio::test]
3264    async fn get_valid_on_public_class_key() {
3265        let (mgr, _p, secondary) = fixture();
3266        // get is value/public-class -> a public cert is readable via get.
3267        let kv = mgr.get("web.cert", None).await.expect("get on public key");
3268        assert_eq!(kv.value, b"stored-value");
3269        assert_eq!(
3270            secondary.last_path().as_deref(),
3271            Some("secret/data/web/cert")
3272        );
3273    }
3274
3275    #[tokio::test]
3276    async fn get_on_asymmetric_key_is_op_not_valid_for_class() {
3277        let (mgr, primary, _s) = fixture();
3278        // get on a signing key must be rejected BEFORE any backend call: the
3279        // broker never hands out crypto-key material through get.
3280        let err = mgr
3281            .get("asym.signer", None)
3282            .await
3283            .expect_err("get on a signing key must be rejected");
3284        match err {
3285            ManagerError::OpNotValidForClass { op, key, class } => {
3286                assert_eq!(op, "get");
3287                assert_eq!(key, "asym.signer");
3288                assert_eq!(class, Class::Asymmetric);
3289            }
3290            other => panic!("expected OpNotValidForClass, got {other:?}"),
3291        }
3292        // No backend dispatch happened (the class gate ran first).
3293        assert_eq!(primary.last_path(), None);
3294    }
3295
3296    #[tokio::test]
3297    async fn set_on_public_key_is_op_not_valid_for_class() {
3298        let (mgr, _p, secondary) = fixture();
3299        // set is value-class only -> a public (read-only) key is rejected.
3300        let err = mgr
3301            .set("web.cert", b"x")
3302            .await
3303            .expect_err("set on a public key must be rejected");
3304        assert!(matches!(err, ManagerError::OpNotValidForClass { .. }));
3305        assert_eq!(secondary.last_path(), None);
3306    }
3307
3308    #[tokio::test]
3309    async fn set_on_asymmetric_key_is_op_not_valid_for_class() {
3310        let (mgr, _p, _s) = fixture();
3311        let err = mgr
3312            .set("asym.signer", b"x")
3313            .await
3314            .expect_err("set on a signing key must be rejected");
3315        assert!(matches!(err, ManagerError::OpNotValidForClass { .. }));
3316    }
3317
3318    #[tokio::test]
3319    async fn get_unknown_key_wins_over_class() {
3320        // unknown-key beats the class check (resolution runs first).
3321        let (mgr, _p, _s) = fixture();
3322        assert!(matches!(
3323            mgr.get("does.not.exist", None).await,
3324            Err(ManagerError::UnknownKey(_))
3325        ));
3326    }
3327
3328    // ---- encrypt / decrypt (vault-uo1) --------------------------------------
3329
3330    #[tokio::test]
3331    async fn encrypt_then_decrypt_round_trips_with_aad() {
3332        let (mgr, _p, secondary) = fixture();
3333        let pt = b"top secret payload";
3334        let aad = b"context-42";
3335        let env = mgr
3336            .encrypt("sym.box", AeadAlgorithm::Aes256Gcm, pt, Some(aad))
3337            .await
3338            .expect("encrypt");
3339        // Broker owns the nonce (empty here: transit embeds it) and echoes alg +
3340        // the latest key version; routed to the catalog PATH on "secondary".
3341        assert_eq!(env.alg, AeadAlgorithm::Aes256Gcm);
3342        assert!(env.nonce.is_empty());
3343        assert_eq!(secondary.last_path().as_deref(), Some("box"));
3344
3345        let recovered = mgr
3346            .decrypt("sym.box", &env, Some(aad))
3347            .await
3348            .expect("decrypt");
3349        assert_eq!(recovered, pt);
3350    }
3351
3352    #[tokio::test]
3353    async fn decrypt_with_wrong_aad_is_opaque_decrypt_failed() {
3354        let (mgr, _p, _s) = fixture();
3355        let env = mgr
3356            .encrypt("sym.box", AeadAlgorithm::Aes256Gcm, b"x", Some(b"good"))
3357            .await
3358            .expect("encrypt");
3359        let err = mgr
3360            .decrypt("sym.box", &env, Some(b"BAD"))
3361            .await
3362            .expect_err("aad mismatch must fail");
3363        assert!(matches!(
3364            err,
3365            ManagerError::Backend(BackendError::DecryptFailed)
3366        ));
3367    }
3368
3369    #[tokio::test]
3370    async fn encrypt_algorithm_must_match_catalog_key_type() {
3371        let (mgr, _p, _s) = fixture();
3372        // sym.box is aes-256-gcm; a chacha20-poly1305 request is a mismatch.
3373        let err = mgr
3374            .encrypt("sym.box", AeadAlgorithm::Chacha20Poly1305, b"x", None)
3375            .await
3376            .expect_err("algorithm mismatch must be rejected");
3377        assert!(matches!(err, ManagerError::AlgorithmMismatch { .. }));
3378    }
3379
3380    #[tokio::test]
3381    async fn encrypt_on_non_symmetric_key_is_op_not_valid_for_class() {
3382        let (mgr, _p, _s) = fixture();
3383        let err = mgr
3384            .encrypt("asym.signer", AeadAlgorithm::Aes256Gcm, b"x", None)
3385            .await
3386            .expect_err("encrypt on a signing key must be rejected");
3387        assert!(matches!(err, ManagerError::OpNotValidForClass { .. }));
3388    }
3389
3390    #[tokio::test]
3391    async fn decrypt_targets_the_envelope_key_version() {
3392        // The envelope's key_version is what reaches the backend (rotation grace);
3393        // the mock decrypt round-trips regardless of version, but we assert the
3394        // version is carried through unchanged via a hand-built envelope.
3395        let (mgr, _p, _s) = fixture();
3396        let mut env = mgr
3397            .encrypt("sym.box", AeadAlgorithm::Aes256Gcm, b"data", None)
3398            .await
3399            .expect("encrypt");
3400        env.key_version = 2; // an older version, as a grace-window decrypt would carry
3401        let recovered = mgr.decrypt("sym.box", &env, None).await.expect("decrypt");
3402        assert_eq!(recovered, b"data");
3403    }
3404
3405    // ---- rotate / grace / retention (vault-xhq) -----------------------------
3406
3407    #[tokio::test]
3408    async fn rotate_crypto_key_bumps_version_and_sets_grace_floor() {
3409        let (mgr, _p, secondary) = fixture();
3410        // sym.box (latest 5) -> rotate bumps to 6; default grace_versions=1 sets
3411        // min_decryption_version = 6 - 1 = 5.
3412        let limits = BrokerLimits::default();
3413        let new_version = mgr.rotate("sym.box", limits).await.expect("rotate");
3414        assert_eq!(new_version, 6);
3415        assert_eq!(secondary.rotate_calls.load(Ordering::SeqCst), 1);
3416        assert_eq!(secondary.last_versions_config(), Some((Some(5), None)));
3417    }
3418
3419    #[tokio::test]
3420    async fn rotate_grace_zero_floors_at_latest() {
3421        let (mgr, primary, _s) = fixture();
3422        // asym.signer (latest 5) with grace 0 -> min_decryption_version = 6 (only
3423        // the newest version decrypts/verifies; the compromise setting).
3424        let limits = BrokerLimits {
3425            grace_versions: 0,
3426            ..BrokerLimits::default()
3427        };
3428        let new_version = mgr.rotate("asym.signer", limits).await.expect("rotate");
3429        assert_eq!(new_version, 6);
3430        assert_eq!(primary.last_versions_config(), Some((Some(6), None)));
3431    }
3432
3433    #[tokio::test]
3434    async fn rotate_value_key_with_recipe_regenerates_a_new_version() {
3435        let (mgr, primary, _s) = fixture();
3436        // gen.value has an ascii-printable recipe -> rotate generates a fresh
3437        // value and writes it as a new KV-v2 version (the a2p decision).
3438        let new_version = mgr
3439            .rotate("gen.value", BrokerLimits::default())
3440            .await
3441            .expect("rotate");
3442        assert_eq!(primary.kv_put_calls.load(Ordering::SeqCst), 1);
3443        assert_eq!(new_version, 6);
3444        // A fresh 24-byte printable value was written (never echoed on the wire).
3445        let value = primary.last_kv_value().expect("a value was written");
3446        assert_eq!(value.len(), 24);
3447        assert!(value.iter().all(|b| (b'!'..=b'~').contains(b)));
3448    }
3449
3450    #[tokio::test]
3451    async fn rotate_tls_pair_key_regenerates_matching_cert_side() {
3452        if !step_available() {
3453            return;
3454        }
3455        let (mgr, primary, _s) = fixture();
3456        let new_version = mgr
3457            .rotate("tls.key", BrokerLimits::default())
3458            .await
3459            .expect("rotate tls key");
3460        assert_eq!(new_version, 6);
3461        let writes = primary.kv_put_log();
3462        assert_eq!(writes.len(), 2);
3463        assert_eq!(writes[0].0, "secret/data/tls/key");
3464        assert_eq!(writes[1].0, "secret/data/tls/cert");
3465        assert!(writes[0].1.starts_with(b"-----BEGIN"));
3466        assert!(
3467            writes[0]
3468                .1
3469                .windows(b"PRIVATE KEY".len())
3470                .any(|w| w == b"PRIVATE KEY")
3471        );
3472        assert!(writes[1].1.starts_with(b"-----BEGIN CERTIFICATE-----"));
3473    }
3474
3475    #[test]
3476    fn age_x25519_generate_recipe_emits_parseable_identity() {
3477        let value = generate_value(&GenerateSpec::AgeX25519).expect("age identity generated");
3478        let identity = String::from_utf8(value).expect("age identity is utf8");
3479        assert!(identity.starts_with("AGE-SECRET-KEY-"));
3480        let parsed = identity.parse::<age::x25519::Identity>();
3481        assert!(parsed.is_ok(), "generated identity must parse");
3482    }
3483
3484    fn step_available() -> bool {
3485        Command::new("step")
3486            .arg("version")
3487            .output()
3488            .is_ok_and(|output| output.status.success())
3489    }
3490
3491    #[tokio::test]
3492    async fn rotate_value_key_without_recipe_is_invalid_request() {
3493        let (mgr, _p, _s) = fixture();
3494        // web.value is a value key with NO generate recipe -> rotate is invalid;
3495        // the caller must `set` new material out of band.
3496        let err = mgr
3497            .rotate("web.value", BrokerLimits::default())
3498            .await
3499            .expect_err("value rotate without recipe must fail");
3500        assert!(matches!(err, ManagerError::ValueRotateNeedsSet(_)));
3501    }
3502
3503    #[tokio::test]
3504    async fn sweep_retention_raises_min_available_version() {
3505        let (mgr, _p, secondary) = fixture();
3506        // sym.box latest 5, retain 2 -> min_available_version = 5 - 2 = 3.
3507        let limits = BrokerLimits {
3508            retain_versions: Some(2),
3509            ..BrokerLimits::default()
3510        };
3511        mgr.sweep_retention("sym.box", limits).await.expect("sweep");
3512        assert_eq!(secondary.last_versions_config(), Some((None, Some(3))));
3513    }
3514
3515    #[tokio::test]
3516    async fn sweep_all_retention_walks_crypto_keys() {
3517        let (mgr, primary, secondary) = fixture();
3518        let limits = BrokerLimits {
3519            retain_versions: Some(2),
3520            ..BrokerLimits::default()
3521        };
3522        mgr.sweep_all_retention(limits)
3523            .await
3524            .expect("catalog sweep");
3525        assert_eq!(primary.last_versions_config(), Some((None, Some(3))));
3526        assert_eq!(secondary.last_versions_config(), Some((None, Some(3))));
3527    }
3528
3529    #[tokio::test]
3530    async fn sweep_retention_disabled_is_a_noop() {
3531        let (mgr, _p, secondary) = fixture();
3532        // retain_versions=None -> no configure_versions call at all.
3533        mgr.sweep_retention("sym.box", BrokerLimits::default())
3534            .await
3535            .expect("sweep");
3536        assert_eq!(secondary.last_versions_config(), None);
3537    }
3538
3539    // ---- Sealing class (X25519 sealed-box unseal, basil-t9a) -----------------
3540
3541    /// Provision a fixed X25519 sealing keypair into the `primary` mock out of
3542    /// band: the private at the catalog `path` (materialized only on `unwrap`) and
3543    /// the public at the catalog `public_path` (read by `wrap`/`get_public_key`,
3544    /// basil-o86). Returns the public for sealing payloads to it.
3545    fn seed_sealing_private(primary: &Arc<MockBackend>) -> [u8; 32] {
3546        let private = Zeroizing::new([0x11u8; 32]);
3547        let public = x25519_seal::public_from_private(&private);
3548        primary.seed_kv("secret/data/enroll/x25519", private.to_vec());
3549        primary.seed_kv("secret/data/enroll/x25519-public", public.to_vec());
3550        public
3551    }
3552    fn seed_ml_kem_private(primary: &Arc<MockBackend>) -> [u8; ml_kem_envelope::SEED_LEN] {
3553        let seed = [0x42; ml_kem_envelope::SEED_LEN];
3554        primary.seed_kv(
3555            "secret/data/enroll/ml-kem-768",
3556            ml_kem_record_bytes("enroll.mlkem", &seed, 5),
3557        );
3558        primary.seed_kv("secret/data/enroll/ml-kem-768-public", vec![0x7A; 1184]);
3559        seed
3560    }
3561    fn ml_kem_record_bytes(
3562        key_id: &str,
3563        seed: &[u8; ml_kem_envelope::SEED_LEN],
3564        key_version: u32,
3565    ) -> Vec<u8> {
3566        // Mirror what the local-software provider reconstructs from enroll.mlkem's
3567        // catalog labels (software-encrypted ml-kem-768, provider version 1).
3568        let meta = SoftwareCustodyCatalog {
3569            key_id,
3570            algorithm: ml_kem_envelope::KemAlgorithm::MlKem768.token(),
3571            provider: "local-software",
3572            provider_version: "1",
3573            custody: "software-encrypted",
3574            storage_key: "pqc/storage/wrap",
3575        };
3576        let aad = meta.aad(key_version);
3577        let mut ciphertext = Vec::with_capacity(aad.len() + seed.len() + 1);
3578        ciphertext.push(u8::try_from(aad.len()).expect("test aad fits mock envelope"));
3579        ciphertext.extend_from_slice(&aad);
3580        ciphertext.extend_from_slice(seed);
3581        serde_json::json!({
3582            "schemaVersion": 1,
3583            "keyId": key_id,
3584            "keyVersion": key_version,
3585            "publicKey": encode_record_bytes(&[0x7A; 1184]),
3586            "algorithm": meta.algorithm,
3587            "provider": meta.provider,
3588            "providerVersion": meta.provider_version,
3589            "custody": meta.custody,
3590            "encryptedPrivateKey": {
3591                "wrappingKey": meta.storage_key,
3592                "algorithm": "aes-256-gcm",
3593                "keyVersion": key_version,
3594                "nonce": encode_record_bytes(&[]),
3595                "ciphertext": encode_record_bytes(&ciphertext),
3596            }
3597        })
3598        .to_string()
3599        .into_bytes()
3600    }
3601
3602    #[tokio::test]
3603    async fn sealing_key_rejects_get_and_set() {
3604        // INVARIANT 1: the private half is never get-able/set-able through the
3605        // public KV ops; require_class fails closed before any backend call.
3606        let (mgr, _p, _s) = fixture();
3607        let get_err = mgr
3608            .get("enroll.sealing", None)
3609            .await
3610            .expect_err("get must be denied on a sealing key");
3611        assert!(matches!(
3612            get_err,
3613            ManagerError::OpNotValidForClass {
3614                op: "get",
3615                class: Class::Sealing,
3616                ..
3617            }
3618        ));
3619        let set_err = mgr
3620            .set("enroll.sealing", b"anything")
3621            .await
3622            .expect_err("set must be denied on a sealing key");
3623        assert!(matches!(
3624            set_err,
3625            ManagerError::OpNotValidForClass {
3626                op: "set",
3627                class: Class::Sealing,
3628                ..
3629            }
3630        ));
3631    }
3632
3633    #[tokio::test]
3634    async fn sealing_key_rejects_sign_and_rotate() {
3635        // The signing/transit op surface is closed for a sealing key too.
3636        let (mgr, _p, _s) = fixture();
3637        assert!(matches!(
3638            mgr.sign("enroll.sealing", b"m").await,
3639            Err(ManagerError::OpNotValidForClass { op: "sign", .. })
3640        ));
3641        assert!(matches!(
3642            mgr.rotate("enroll.sealing", BrokerLimits::default()).await,
3643            Err(ManagerError::OpNotValidForClass { op: "rotate", .. })
3644        ));
3645    }
3646
3647    #[tokio::test]
3648    async fn sealing_get_public_key_reads_out_of_band_public() {
3649        // basil-o86: get_public_key returns the out-of-band-provisioned public,
3650        // read from `public_path`: the private is NEVER materialized for it.
3651        let (mgr, primary, _s) = fixture();
3652        let expected_pub = seed_sealing_private(&primary);
3653        let got = mgr
3654            .sealing_public_key("enroll.sealing")
3655            .await
3656            .expect("reads public");
3657        assert_eq!(got, expected_pub);
3658        // The read routed to the PUBLIC path, not the private materialize path.
3659        assert_eq!(
3660            primary.last_path().as_deref(),
3661            Some("secret/data/enroll/x25519-public")
3662        );
3663    }
3664
3665    #[tokio::test]
3666    async fn sealing_public_ops_never_materialize_private() {
3667        // basil-o86 PROOF: provision a CORRECT public but a GARBAGE (7-byte)
3668        // private. If wrap or get_public_key materialized the private they would
3669        // fail closed (Malformed). They read the out-of-band public instead, so
3670        // both succeed with the provisioned public. The private is untouched.
3671        let (mgr, primary, _s) = fixture();
3672        let private = Zeroizing::new([0x11u8; 32]);
3673        let public = x25519_seal::public_from_private(&private);
3674        primary.seed_kv("secret/data/enroll/x25519-public", public.to_vec());
3675        primary.seed_kv("secret/data/enroll/x25519", vec![0xFF; 7]);
3676
3677        let got = mgr
3678            .sealing_public_key("enroll.sealing")
3679            .await
3680            .expect("public read despite garbage private");
3681        assert_eq!(got, public);
3682        // wrap also resolves only the public, so it succeeds with the garbage private
3683        // still in place.
3684        mgr.wrap_envelope("enroll.sealing", b"payload", b"ctx")
3685            .await
3686            .expect("wrap reads only the public");
3687    }
3688
3689    #[tokio::test]
3690    async fn sealing_wrap_then_unwrap_round_trips() {
3691        // INVARIANT 5: server-side wrap (public-only) then unwrap (materialize the
3692        // private, ECDH, zeroize) recovers the plaintext.
3693        let (mgr, primary, _s) = fixture();
3694        seed_sealing_private(&primary);
3695        let plaintext = b"enrollment-payload";
3696        let aad = b"enroll-ctx";
3697        let env = mgr
3698            .wrap_envelope("enroll.sealing", plaintext, aad)
3699            .await
3700            .expect("wrap");
3701        let recovered = mgr
3702            .unwrap_envelope("enroll.sealing", &env, aad)
3703            .await
3704            .expect("unwrap");
3705        assert_eq!(recovered.as_slice(), plaintext);
3706    }
3707
3708    #[tokio::test]
3709    async fn kv_get_secret_returns_zeroizing_bytes_and_round_trips() {
3710        // FIX 1: the SECRET read keeps bytes in `Zeroizing` end-to-end. Functional
3711        // check: it returns exactly what `kv_put` stored (the wipe itself can't be
3712        // unit-tested, but the type is `Zeroizing<Vec<u8>>` and the bytes match).
3713        let backend = MockHandle(MockBackend::new("primary"));
3714        let stored = vec![0x11u8; 32];
3715        backend
3716            .kv_put("secret/data/enroll/x25519", &stored)
3717            .await
3718            .expect("put");
3719        let got: Zeroizing<Vec<u8>> = backend
3720            .kv_get_secret("secret/data/enroll/x25519", None)
3721            .await
3722            .expect("secret read");
3723        assert_eq!(got.as_slice(), stored.as_slice());
3724    }
3725
3726    #[tokio::test]
3727    async fn sealing_unwrap_opens_externally_sealed_payload() {
3728        // The realistic flow: a sender seals with ONLY the recipient public key
3729        // (crypto core, no broker); the broker unwraps with the materialized
3730        // private. This proves the broker isn't just round-tripping its own wrap.
3731        let (mgr, primary, _s) = fixture();
3732        let recipient_pub = seed_sealing_private(&primary);
3733        let env = x25519_seal::seal(&recipient_pub, b"sealed-by-sender", b"ctx").expect("seal");
3734        let recovered = mgr
3735            .unwrap_envelope("enroll.sealing", &env, b"ctx")
3736            .await
3737            .expect("unwrap");
3738        assert_eq!(recovered.as_slice(), b"sealed-by-sender");
3739    }
3740
3741    /// Borrow an [`ml_kem_envelope::MlKemEnvelope`]'s wire fields for the
3742    /// provider-dispatched unwrap path.
3743    fn ml_kem_parts(env: &ml_kem_envelope::MlKemEnvelope) -> MlKemEnvelopeParts<'_> {
3744        MlKemEnvelopeParts {
3745            encapsulated_key: &env.encapsulated_key,
3746            nonce: &env.nonce,
3747            ciphertext: &env.ciphertext,
3748        }
3749    }
3750    #[tokio::test]
3751    async fn ml_kem_provider_wrap_then_unwrap_round_trips() {
3752        for envelope_algorithm in [
3753            ProviderEnvelopeAlgorithm::Aes256Gcm,
3754            ProviderEnvelopeAlgorithm::ChaCha20Poly1305,
3755        ] {
3756            let (mgr, primary, _s) = fixture();
3757            seed_ml_kem_private(&primary);
3758            let (envelope, dispatch) = mgr
3759                .provider_wrap_envelope(
3760                    "enroll.mlkem",
3761                    ProviderKemAlgorithm::MlKem768,
3762                    envelope_algorithm,
3763                    b"top secret",
3764                    b"ctx",
3765                    ProviderGate {
3766                        local_software_allowed: true,
3767                    },
3768                )
3769                .await
3770                .expect("wrap");
3771            // Self-describing: KEM level echoed; key version is the custody record's.
3772            assert_eq!(envelope.kem_algorithm, ProviderKemAlgorithm::MlKem768);
3773            assert_eq!(envelope.key_version, 5);
3774            assert_eq!(dispatch.algorithm, "ml-kem-768");
3775            assert_eq!(dispatch.provider, CryptoProviderId::LocalSoftware);
3776
3777            let (recovered, _) = mgr
3778                .provider_unwrap_envelope(
3779                    "enroll.mlkem",
3780                    ProviderKemAlgorithm::MlKem768,
3781                    envelope_algorithm,
3782                    MlKemEnvelopeParts {
3783                        encapsulated_key: &envelope.encapsulated_key,
3784                        nonce: &envelope.nonce,
3785                        ciphertext: &envelope.ciphertext,
3786                    },
3787                    b"ctx",
3788                    ProviderGate {
3789                        local_software_allowed: true,
3790                    },
3791                )
3792                .await
3793                .expect("unwrap");
3794            assert_eq!(recovered.as_slice(), b"top secret");
3795        }
3796    }
3797    #[tokio::test]
3798    async fn ml_kem_provider_unwrap_opens_externally_sealed_payload() {
3799        let (mgr, primary, _s) = fixture();
3800        let seed = seed_ml_kem_private(&primary);
3801        // A sender seals with ONLY the seed-derived public (crypto core, no broker);
3802        // the broker unwraps via the materialized seed, not just its own wrap.
3803        let env = ml_kem_envelope::seal(
3804            &seed,
3805            ml_kem_envelope::KemAlgorithm::MlKem768,
3806            ml_kem_envelope::EnvelopeAlgorithm::ChaCha20Poly1305,
3807            b"sealed-by-sender",
3808            b"ctx",
3809        )
3810        .expect("seal");
3811        let (recovered, _) = mgr
3812            .provider_unwrap_envelope(
3813                "enroll.mlkem",
3814                ProviderKemAlgorithm::MlKem768,
3815                ProviderEnvelopeAlgorithm::ChaCha20Poly1305,
3816                ml_kem_parts(&env),
3817                b"ctx",
3818                ProviderGate {
3819                    local_software_allowed: true,
3820                },
3821            )
3822            .await
3823            .expect("unwrap");
3824        assert_eq!(recovered.as_slice(), b"sealed-by-sender");
3825    }
3826
3827    /// Build a software-custody ML-KEM-768 record with a chosen public
3828    /// encapsulation key and a chosen (possibly undecryptable) wrapped seed:
3829    /// lets a `get_public_key` test prove it reads only the recorded public.
3830    fn ml_kem_record_with(public: &[u8], wrapped_seed: &[u8], key_version: u32) -> Vec<u8> {
3831        let meta = SoftwareCustodyCatalog {
3832            key_id: "enroll.mlkem",
3833            algorithm: ml_kem_envelope::KemAlgorithm::MlKem768.token(),
3834            provider: "local-software",
3835            provider_version: "1",
3836            custody: "software-encrypted",
3837            storage_key: "pqc/storage/wrap",
3838        };
3839        serde_json::json!({
3840            "schemaVersion": 1,
3841            "keyId": meta.key_id,
3842            "keyVersion": key_version,
3843            "publicKey": encode_record_bytes(public),
3844            "algorithm": meta.algorithm,
3845            "provider": meta.provider,
3846            "providerVersion": meta.provider_version,
3847            "custody": meta.custody,
3848            "encryptedPrivateKey": {
3849                "wrappingKey": meta.storage_key,
3850                "algorithm": "aes-256-gcm",
3851                "keyVersion": key_version,
3852                "nonce": encode_record_bytes(&[]),
3853                "ciphertext": encode_record_bytes(wrapped_seed),
3854            }
3855        })
3856        .to_string()
3857        .into_bytes()
3858    }
3859    #[tokio::test]
3860    async fn ml_kem_sealing_get_public_key_returns_encapsulation_key_without_seed() {
3861        // basil-4ybx: GetPublicKey on a software-custody ML-KEM sealing key returns
3862        // the public ENCAPSULATION key recorded in the custody record, tagged with
3863        // its ML-KEM parameter level, and NEVER materializes the decapsulation
3864        // seed. PROOF (mirrors sealing_public_ops_never_materialize_private): the
3865        // record carries a CORRECT public but an UNDECRYPTABLE (empty) wrapped
3866        // seed. If the read materialized the seed it would fail closed
3867        // (DecryptFailed); it reads only the recorded public, so it succeeds.
3868        let (mgr, primary, _s) = fixture();
3869        let encapsulation_key = vec![0xABu8; 1184];
3870        primary.seed_kv(
3871            "secret/data/enroll/ml-kem-768",
3872            // kv_version is the mock's latest (5); the inner key_version must be
3873            // non-zero. The empty wrapped seed makes any decrypt attempt fail.
3874            ml_kem_record_with(&encapsulation_key, &[], 5),
3875        );
3876
3877        let got = mgr
3878            .get_public_key("enroll.mlkem")
3879            .await
3880            .expect("ML-KEM sealing public-encapsulation-key read");
3881        assert_eq!(got.public_key, encapsulation_key);
3882        assert_eq!(got.key_type, KeyType::MlKem768);
3883        // A software-custody record is a single fixed version.
3884        assert_eq!(got.version, 1);
3885        // The read resolved the custody record, never the AEAD storage key (a
3886        // decrypt would have set last_path to "pqc/storage/wrap").
3887        assert_eq!(
3888            primary.last_path().as_deref(),
3889            Some("secret/data/enroll/ml-kem-768")
3890        );
3891    }
3892    #[tokio::test]
3893    async fn get_public_key_rejects_non_ml_kem_sealing_and_value_classes() {
3894        // The sealing read path is narrow to software-custody ML-KEM: an X25519
3895        // sealing key and a value key are still OpNotValidForClass: the
3896        // asymmetric/public gate is unchanged for everything but ML-KEM sealing.
3897        let (mgr, _p, _s) = fixture();
3898        assert!(matches!(
3899            mgr.get_public_key("enroll.sealing").await,
3900            Err(ManagerError::OpNotValidForClass {
3901                op: "get_public_key",
3902                class: Class::Sealing,
3903                ..
3904            })
3905        ));
3906        assert!(matches!(
3907            mgr.get_public_key("web.value").await,
3908            Err(ManagerError::OpNotValidForClass {
3909                op: "get_public_key",
3910                class: Class::Value,
3911                ..
3912            })
3913        ));
3914    }
3915    #[tokio::test]
3916    async fn ml_kem_provider_unwrap_tampered_ciphertext_fails_opaque() {
3917        let (mgr, primary, _s) = fixture();
3918        seed_ml_kem_private(&primary);
3919        let (mut envelope, _) = mgr
3920            .provider_wrap_envelope(
3921                "enroll.mlkem",
3922                ProviderKemAlgorithm::MlKem768,
3923                ProviderEnvelopeAlgorithm::Aes256Gcm,
3924                b"top secret",
3925                b"ctx",
3926                ProviderGate {
3927                    local_software_allowed: true,
3928                },
3929            )
3930            .await
3931            .expect("wrap");
3932        if let Some(b) = envelope.ciphertext.first_mut() {
3933            *b ^= 0xFF;
3934        }
3935        let err = mgr
3936            .provider_unwrap_envelope(
3937                "enroll.mlkem",
3938                ProviderKemAlgorithm::MlKem768,
3939                ProviderEnvelopeAlgorithm::Aes256Gcm,
3940                MlKemEnvelopeParts {
3941                    encapsulated_key: &envelope.encapsulated_key,
3942                    nonce: &envelope.nonce,
3943                    ciphertext: &envelope.ciphertext,
3944                },
3945                b"ctx",
3946                ProviderGate {
3947                    local_software_allowed: true,
3948                },
3949            )
3950            .await
3951            .expect_err("tampered ciphertext must fail opaque");
3952        assert!(matches!(
3953            err,
3954            ManagerError::Provider(ProviderError::CryptoFailed {
3955                op: "unwrap_envelope",
3956                ..
3957            })
3958        ));
3959    }
3960    #[tokio::test]
3961    async fn ml_kem_provider_unwrap_wrong_aad_fails_opaque() {
3962        let (mgr, primary, _s) = fixture();
3963        seed_ml_kem_private(&primary);
3964        let (envelope, _) = mgr
3965            .provider_wrap_envelope(
3966                "enroll.mlkem",
3967                ProviderKemAlgorithm::MlKem768,
3968                ProviderEnvelopeAlgorithm::ChaCha20Poly1305,
3969                b"top secret",
3970                b"right",
3971                ProviderGate {
3972                    local_software_allowed: true,
3973                },
3974            )
3975            .await
3976            .expect("wrap");
3977        let err = mgr
3978            .provider_unwrap_envelope(
3979                "enroll.mlkem",
3980                ProviderKemAlgorithm::MlKem768,
3981                ProviderEnvelopeAlgorithm::ChaCha20Poly1305,
3982                MlKemEnvelopeParts {
3983                    encapsulated_key: &envelope.encapsulated_key,
3984                    nonce: &envelope.nonce,
3985                    ciphertext: &envelope.ciphertext,
3986                },
3987                b"wrong",
3988                ProviderGate {
3989                    local_software_allowed: true,
3990                },
3991            )
3992            .await
3993            .expect_err("wrong aad must fail opaque");
3994        assert!(matches!(
3995            err,
3996            ManagerError::Provider(ProviderError::CryptoFailed { .. })
3997        ));
3998    }
3999    #[tokio::test]
4000    async fn ml_kem_software_custody_rejects_raw_seed_storage() {
4001        let (mgr, primary, _s) = fixture();
4002        let seed = [0x42; ml_kem_envelope::SEED_LEN];
4003        primary.seed_kv("secret/data/enroll/ml-kem-768", seed.to_vec());
4004        let env = ml_kem_envelope::seal(
4005            &seed,
4006            ml_kem_envelope::KemAlgorithm::MlKem768,
4007            ml_kem_envelope::EnvelopeAlgorithm::ChaCha20Poly1305,
4008            b"sealed-by-sender",
4009            b"ctx",
4010        )
4011        .expect("seal");
4012        let err = mgr
4013            .provider_unwrap_envelope(
4014                "enroll.mlkem",
4015                ProviderKemAlgorithm::MlKem768,
4016                ProviderEnvelopeAlgorithm::ChaCha20Poly1305,
4017                ml_kem_parts(&env),
4018                b"ctx",
4019                ProviderGate {
4020                    local_software_allowed: true,
4021                },
4022            )
4023            .await
4024            .expect_err("raw private seed storage must be rejected");
4025        assert!(matches!(
4026            err,
4027            ManagerError::Provider(ProviderError::CryptoFailed { .. })
4028        ));
4029        assert_eq!(
4030            primary.last_path().as_deref(),
4031            Some("secret/data/enroll/ml-kem-768")
4032        );
4033    }
4034    #[tokio::test]
4035    async fn ml_kem_software_custody_rejects_record_metadata_mismatch() {
4036        let (mgr, primary, _s) = fixture();
4037        let seed = [0x42; ml_kem_envelope::SEED_LEN];
4038        // Record declares keyVersion 4, but the mock serves it at version 5.
4039        primary.seed_kv(
4040            "secret/data/enroll/ml-kem-768",
4041            ml_kem_record_bytes("enroll.mlkem", &seed, 4),
4042        );
4043        let env = ml_kem_envelope::seal(
4044            &seed,
4045            ml_kem_envelope::KemAlgorithm::MlKem768,
4046            ml_kem_envelope::EnvelopeAlgorithm::ChaCha20Poly1305,
4047            b"sealed-by-sender",
4048            b"ctx",
4049        )
4050        .expect("seal");
4051        let err = mgr
4052            .provider_unwrap_envelope(
4053                "enroll.mlkem",
4054                ProviderKemAlgorithm::MlKem768,
4055                ProviderEnvelopeAlgorithm::ChaCha20Poly1305,
4056                ml_kem_parts(&env),
4057                b"ctx",
4058                ProviderGate {
4059                    local_software_allowed: true,
4060                },
4061            )
4062            .await
4063            .expect_err("KV version mismatch must fail before decrypt");
4064        assert!(matches!(
4065            err,
4066            ManagerError::Provider(ProviderError::CryptoFailed { .. })
4067        ));
4068        assert_eq!(
4069            primary.last_path().as_deref(),
4070            Some("secret/data/enroll/ml-kem-768")
4071        );
4072    }
4073
4074    #[tokio::test]
4075    async fn ml_kem_unwrap_rejects_wrong_key_type() {
4076        let (mgr, _primary, _s) = fixture();
4077        // The keyType cross-check fails before any KV read, so no custody record is
4078        // provisioned; a bare seed is enough to build a well-formed envelope.
4079        let seed = [0x42; ml_kem_envelope::SEED_LEN];
4080        let env = ml_kem_envelope::seal(
4081            &seed,
4082            ml_kem_envelope::KemAlgorithm::MlKem768,
4083            ml_kem_envelope::EnvelopeAlgorithm::ChaCha20Poly1305,
4084            b"sealed-by-sender",
4085            b"ctx",
4086        )
4087        .expect("seal");
4088        // enroll.sealing is an X25519 key; it cannot unwrap an ML-KEM envelope.
4089        // The keyType cross-check fails closed before any provider dispatch.
4090        let err = mgr
4091            .provider_unwrap_envelope(
4092                "enroll.sealing",
4093                ProviderKemAlgorithm::MlKem768,
4094                ProviderEnvelopeAlgorithm::ChaCha20Poly1305,
4095                ml_kem_parts(&env),
4096                b"ctx",
4097                ProviderGate {
4098                    local_software_allowed: true,
4099                },
4100            )
4101            .await
4102            .expect_err("x25519 key cannot unwrap ML-KEM");
4103        assert!(matches!(err, ManagerError::KemAlgorithmMismatch { .. }));
4104    }
4105
4106    #[tokio::test]
4107    async fn ml_kem_wrap_rejects_wrong_kem_param_set() {
4108        let (mgr, _primary, _s) = fixture();
4109        // enroll.mlkem is ml-kem-768; a request for ml-kem-512 wrap is rejected
4110        // by the keyType cross-check before any provider dispatch.
4111        let err = mgr
4112            .provider_wrap_envelope(
4113                "enroll.mlkem",
4114                ProviderKemAlgorithm::MlKem512,
4115                ProviderEnvelopeAlgorithm::ChaCha20Poly1305,
4116                b"top secret",
4117                b"ctx",
4118                ProviderGate {
4119                    local_software_allowed: true,
4120                },
4121            )
4122            .await
4123            .expect_err("wrong KEM param set must be rejected");
4124        assert!(matches!(err, ManagerError::KemAlgorithmMismatch { .. }));
4125    }
4126
4127    #[tokio::test]
4128    async fn ml_kem_wrap_and_unwrap_without_grant_denied() {
4129        let (mgr, _primary, _s) = fixture();
4130        let deny = ProviderGate {
4131            local_software_allowed: false,
4132        };
4133        let wrap_err = mgr
4134            .provider_wrap_envelope(
4135                "enroll.mlkem",
4136                ProviderKemAlgorithm::MlKem768,
4137                ProviderEnvelopeAlgorithm::ChaCha20Poly1305,
4138                b"top secret",
4139                b"ctx",
4140                deny,
4141            )
4142            .await
4143            .expect_err("wrap denied without local-software grant");
4144        assert!(matches!(
4145            wrap_err,
4146            ManagerError::Provider(ProviderError::PolicyDenied { .. })
4147        ));
4148        let unwrap_err = mgr
4149            .provider_unwrap_envelope(
4150                "enroll.mlkem",
4151                ProviderKemAlgorithm::MlKem768,
4152                ProviderEnvelopeAlgorithm::ChaCha20Poly1305,
4153                MlKemEnvelopeParts {
4154                    encapsulated_key: b"x",
4155                    nonce: &[0u8; 12],
4156                    ciphertext: b"y",
4157                },
4158                b"ctx",
4159                deny,
4160            )
4161            .await
4162            .expect_err("unwrap denied without local-software grant");
4163        assert!(matches!(
4164            unwrap_err,
4165            ManagerError::Provider(ProviderError::PolicyDenied { .. })
4166        ));
4167    }
4168
4169    #[tokio::test]
4170    async fn sealing_unwrap_wrong_aad_fails_opaque() {
4171        let (mgr, primary, _s) = fixture();
4172        let recipient_pub = seed_sealing_private(&primary);
4173        let env = x25519_seal::seal(&recipient_pub, b"x", b"right").expect("seal");
4174        let err = mgr
4175            .unwrap_envelope("enroll.sealing", &env, b"wrong")
4176            .await
4177            .expect_err("aad mismatch must fail");
4178        assert!(matches!(
4179            err,
4180            ManagerError::Sealing(SealingFailure::OpenFailed)
4181        ));
4182    }
4183
4184    #[tokio::test]
4185    async fn sealing_unwrap_tampered_ciphertext_fails_opaque() {
4186        let (mgr, primary, _s) = fixture();
4187        let recipient_pub = seed_sealing_private(&primary);
4188        let mut env = x25519_seal::seal(&recipient_pub, b"payload", b"aad").expect("seal");
4189        if let Some(b) = env.ciphertext.first_mut() {
4190            *b ^= 0xFF;
4191        }
4192        assert!(matches!(
4193            mgr.unwrap_envelope("enroll.sealing", &env, b"aad").await,
4194            Err(ManagerError::Sealing(SealingFailure::OpenFailed))
4195        ));
4196    }
4197
4198    #[tokio::test]
4199    async fn sealing_unwrap_malformed_private_fails() {
4200        // A non-32-byte stored value (corrupt/misprovisioned key) is Malformed, not
4201        // an opaque open failure; it never reaches the ECDH.
4202        let (mgr, primary, _s) = fixture();
4203        *primary.last_kv_value.lock().unwrap() = Some(vec![0u8; 16]);
4204        let env = SealedEnvelope {
4205            encapsulated_key: [9u8; 32],
4206            nonce: [0u8; 12],
4207            ciphertext: vec![1, 2, 3],
4208        };
4209        assert!(matches!(
4210            mgr.unwrap_envelope("enroll.sealing", &env, b"").await,
4211            Err(ManagerError::Sealing(SealingFailure::Malformed))
4212        ));
4213    }
4214
4215    #[tokio::test]
4216    async fn wrap_unwrap_reject_non_sealing_key() {
4217        // The KEM envelope path is sealing-only: a symmetric key is rejected by
4218        // require_class before any crypto.
4219        let (mgr, _p, _s) = fixture();
4220        assert!(matches!(
4221            mgr.wrap_envelope("sym.box", b"x", b"").await,
4222            Err(ManagerError::OpNotValidForClass {
4223                op: "wrap_envelope",
4224                ..
4225            })
4226        ));
4227        let env = SealedEnvelope {
4228            encapsulated_key: [0u8; 32],
4229            nonce: [0u8; 12],
4230            ciphertext: vec![],
4231        };
4232        assert!(matches!(
4233            mgr.unwrap_envelope("sym.box", &env, b"").await,
4234            Err(ManagerError::OpNotValidForClass {
4235                op: "unwrap_envelope",
4236                ..
4237            })
4238        ));
4239    }
4240
4241    // ---- Value-store Ed25519 materialize-to-sign (engine=kv2, vault-iiz) -------
4242
4243    /// Provision a fixed Ed25519 value-store signing key into the `primary` mock
4244    /// out of band: the 32-byte seed at the catalog `path` (materialized only on
4245    /// `sign`) and the public at the catalog `public_path` (read by
4246    /// `verify`/`get_public_key`, basil-o86). Returns the public.
4247    fn seed_signing_seed(primary: &Arc<MockBackend>) -> [u8; 32] {
4248        let seed = Zeroizing::new([0x11u8; 32]);
4249        let public = ed25519_sign::public_from_seed(&seed);
4250        primary.seed_kv("secret/data/kv2/signer", seed.to_vec());
4251        primary.seed_kv("secret/data/kv2/signer-public", public.to_vec());
4252        public
4253    }
4254
4255    #[tokio::test]
4256    async fn kv2_signer_resolves_as_asymmetric_kv2() {
4257        let (mgr, _p, _s) = fixture();
4258        let routed = mgr.resolve("kv2.signer").expect("resolves");
4259        assert_eq!(routed.class(), Class::Asymmetric);
4260        assert_eq!(routed.engine, Engine::Kv2);
4261        assert_eq!(routed.key_type(), Some(KeyAlgorithm::Ed25519));
4262    }
4263
4264    #[tokio::test]
4265    async fn kv2_signer_rejects_get_and_set() {
4266        // LEAST PRIVILEGE: a value-store signing key inherits the Asymmetric op
4267        // surface, so its private seed is structurally un-gettable/un-settable:
4268        // require_class fails closed before any backend call (same class as a
4269        // transit signer; the seed never leaks through the KV get/set ops).
4270        let (mgr, _p, _s) = fixture();
4271        let get_err = mgr
4272            .get("kv2.signer", None)
4273            .await
4274            .expect_err("get must be denied on a value-store signing key");
4275        assert!(matches!(
4276            get_err,
4277            ManagerError::OpNotValidForClass {
4278                op: "get",
4279                class: Class::Asymmetric,
4280                ..
4281            }
4282        ));
4283        let set_err = mgr
4284            .set("kv2.signer", b"anything")
4285            .await
4286            .expect_err("set must be denied on a value-store signing key");
4287        assert!(matches!(
4288            set_err,
4289            ManagerError::OpNotValidForClass {
4290                op: "set",
4291                class: Class::Asymmetric,
4292                ..
4293            }
4294        ));
4295    }
4296
4297    #[tokio::test]
4298    async fn kv2_signer_rejects_rotate_and_import() {
4299        // A value-store (kv2) signing key is re-provisioned out-of-band, never
4300        // transit-rotated or BYOK-imported through the broker. The refusal is an
4301        // EXPLICIT guard (not an incidental transit-backend 404 on the KV path),
4302        // so the op surface stays intentionally {sign, verify, get_public_key}.
4303        let (mgr, _p, _s) = fixture();
4304        assert!(matches!(
4305            mgr.rotate("kv2.signer", BrokerLimits::default()).await,
4306            Err(ManagerError::Unsupported(m)) if m.starts_with("rotate")
4307        ));
4308        assert!(matches!(
4309            mgr.import(
4310                "kv2.signer",
4311                KeyType::Ed25519,
4312                &KeyMaterial::Ed25519Seed(vec![0; 32]),
4313            )
4314            .await,
4315            Err(ManagerError::Unsupported(m)) if m.starts_with("import")
4316        ));
4317    }
4318
4319    #[tokio::test]
4320    async fn kv2_signer_materializes_and_signs_matching_in_proc() {
4321        // ACCEPTANCE: the in-proc Ed25519 sign matches a fresh in-process sign over
4322        // the same seed+message, and verifies under the derived public.
4323        let (mgr, primary, _s) = fixture();
4324        let public = seed_signing_seed(&primary);
4325        let message = b"sign me with a materialized seed";
4326
4327        let sig = mgr.sign("kv2.signer", message).await.expect("sign");
4328        assert_eq!(sig.len(), ed25519_sign::SIGNATURE_LEN);
4329        // The signature matches the crypto core over the seeded key (deterministic).
4330        let expected = ed25519_sign::sign(&Zeroizing::new([0x11u8; 32]), message);
4331        assert_eq!(sig.as_slice(), expected.as_slice());
4332        // And it verifies under the derived public key.
4333        assert_eq!(ed25519_sign::verify(&public, message, &sig), Ok(true));
4334        // The materialize routed to the catalog KV path (a SECRET read), not a
4335        // transit sign name.
4336        assert_eq!(
4337            primary.last_path().as_deref(),
4338            Some("secret/data/kv2/signer")
4339        );
4340    }
4341
4342    #[tokio::test]
4343    async fn kv2_signer_verify_round_trips_in_proc() {
4344        let (mgr, primary, _s) = fixture();
4345        seed_signing_seed(&primary);
4346        let message = b"verify me";
4347        let sig = mgr.sign("kv2.signer", message).await.expect("sign");
4348        assert!(
4349            mgr.verify("kv2.signer", message, &sig)
4350                .await
4351                .expect("verify")
4352        );
4353        // A tampered message fails verification (no panic on attacker bytes).
4354        assert!(
4355            !mgr.verify("kv2.signer", b"verify-tampered", &sig)
4356                .await
4357                .expect("verify")
4358        );
4359    }
4360
4361    #[tokio::test]
4362    async fn kv2_signer_get_public_key_reads_out_of_band_public() {
4363        // basil-o86: get_public_key reads the out-of-band public from `public_path`.
4364        // The seed is NEVER materialized for it.
4365        let (mgr, primary, _s) = fixture();
4366        let expected_pub = seed_signing_seed(&primary);
4367        let pk = mgr.get_public_key("kv2.signer").await.expect("public");
4368        assert_eq!(pk.public_key.as_slice(), expected_pub.as_slice());
4369        assert_eq!(pk.key_type, KeyType::Ed25519);
4370        // The read routed to the PUBLIC path, not the seed materialize path.
4371        assert_eq!(
4372            primary.last_path().as_deref(),
4373            Some("secret/data/kv2/signer-public")
4374        );
4375    }
4376
4377    #[tokio::test]
4378    async fn kv2_signer_public_ops_never_materialize_seed() {
4379        // basil-o86 PROOF (signing sibling): a CORRECT public but a GARBAGE
4380        // (7-byte) seed. verify + get_public_key read the out-of-band public, so a
4381        // garbage seed can't break them, proving the seed is untouched. `sign`
4382        // (the private op) still materializes the seed; here we only drive the
4383        // public ops.
4384        let (mgr, primary, _s) = fixture();
4385        let seed = Zeroizing::new([0x11u8; 32]);
4386        let public = ed25519_sign::public_from_seed(&seed);
4387        primary.seed_kv("secret/data/kv2/signer-public", public.to_vec());
4388        primary.seed_kv("secret/data/kv2/signer", vec![0xFF; 7]);
4389
4390        let pk = mgr
4391            .get_public_key("kv2.signer")
4392            .await
4393            .expect("public read despite garbage seed");
4394        assert_eq!(pk.public_key.as_slice(), public.as_slice());
4395        // A signature made offline under the real seed verifies through the broker
4396        // (which reads only the out-of-band public).
4397        let sig = ed25519_sign::sign(&seed, b"m");
4398        assert!(
4399            mgr.verify("kv2.signer", b"m", &sig)
4400                .await
4401                .expect("verify reads only the public")
4402        );
4403    }
4404
4405    #[tokio::test]
4406    async fn materialize_public_op_without_public_path_fails_closed() {
4407        // The loader REQUIRES a publicPath on a materialize-to-use key; this guards
4408        // a manager built from an UNVALIDATED catalog (serde-direct, bypassing
4409        // `load`). A public op then fails closed with MissingPublicPath rather than
4410        // re-deriving the public from the private (which the op surface forbids).
4411        const NO_PUB: &str = r#"{
4412          "schemaVersion": 1,
4413          "backends": { "primary": { "kind": "vault", "addr": "https://127.0.0.1:8200" } },
4414          "keys": {
4415            "enroll.sealing": {
4416              "class": "sealing", "keyType": "x25519", "backend": "primary", "engine": "kv2",
4417              "path": "secret/data/enroll/x25519", "writable": true, "missing": "error",
4418              "description": "a sealing key missing its publicPath"
4419            }
4420          }
4421        }"#;
4422        let primary = MockBackend::new("primary");
4423        let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
4424        backends.insert("primary".into(), Box::new(MockHandle(primary)));
4425        let mgr = BackendManager::new(parse_catalog(NO_PUB), backends).expect("constructs");
4426
4427        assert!(matches!(
4428            mgr.sealing_public_key("enroll.sealing").await,
4429            Err(ManagerError::MissingPublicPath(k)) if k == "enroll.sealing"
4430        ));
4431        assert!(matches!(
4432            mgr.wrap_envelope("enroll.sealing", b"x", b"a").await,
4433            Err(ManagerError::MissingPublicPath(_))
4434        ));
4435    }
4436
4437    #[tokio::test]
4438    async fn kv2_signer_malformed_seed_is_signing_failure_not_panic() {
4439        // A non-32-byte stored value (corrupt/misprovisioned seed) fails closed with
4440        // a Signing(Malformed); it never indexes/panics on the KV bytes.
4441        let (mgr, primary, _s) = fixture();
4442        *primary.last_kv_value.lock().unwrap() = Some(vec![0u8; 16]);
4443        assert!(matches!(
4444            mgr.sign("kv2.signer", b"m").await,
4445            Err(ManagerError::Signing(SigningFailure::Malformed))
4446        ));
4447    }
4448
4449    #[tokio::test]
4450    async fn kv2_signer_verify_rejects_wrong_length_signature_opaquely() {
4451        // A wrong-length signature is a malformed verify input (Signing), not a
4452        // crash: the conversion fails closed.
4453        let (mgr, primary, _s) = fixture();
4454        seed_signing_seed(&primary);
4455        assert!(matches!(
4456            mgr.verify("kv2.signer", b"m", &[0u8; 10]).await,
4457            Err(ManagerError::Signing(SigningFailure::Malformed))
4458        ));
4459    }
4460
4461    #[tokio::test]
4462    async fn transit_signer_still_uses_backend_sign_in_place() {
4463        // The transit arm is unchanged: asym.signer (engine inferred Transit) routes
4464        // to the backend's in-place sign, NOT the materialize path.
4465        let (mgr, primary, _s) = fixture();
4466        let sig = mgr.sign("asym.signer", b"m").await.expect("sign");
4467        // The mock transit sign returns the fixed [0xAB, 0xCD]; the materialize path
4468        // would have returned a 64-byte ed25519 signature instead.
4469        assert_eq!(sig, vec![0xAB, 0xCD]);
4470        assert_eq!(primary.last_path().as_deref(), Some("signer"));
4471    }
4472}
4473
4474#[cfg(test)]
4475mod pqc_dispatch_tests {
4476    use std::collections::{BTreeMap, HashMap};
4477    use std::sync::Mutex;
4478
4479    use async_trait::async_trait;
4480
4481    use super::{
4482        BackendManager, Catalog, CryptoProviderId, CustodyMode, KeyProviderDescriptor,
4483        ManagerError, ProviderError, ProviderGate, ProviderPolicy,
4484    };
4485    use crate::backend::{Backend, BackendError, KvValue, NativeAlgorithm, NewKey};
4486    use crate::core::crypto_provider::{ProviderAuditEvent, ProviderAuditOutcome};
4487    use crate::core::ml_dsa_sign::{self, MlDsaAlgorithm};
4488    use basil_proto::{AeadAlgorithm, CiphertextEnvelope, KeyType};
4489
4490    /// A stateful in-memory backend for the ML-DSA provider-dispatch round trip.
4491    /// `encrypt` length-prefixes the AAD ahead of the plaintext so `decrypt` can
4492    /// authenticate it (a faithful AAD check without a real AEAD); `kv_put`/`kv_get`
4493    /// keep records at a fixed version 1, matching a freshly provisioned key.
4494    #[derive(Default)]
4495    struct PqcBackend {
4496        store: Mutex<HashMap<String, Vec<u8>>>,
4497    }
4498
4499    #[async_trait]
4500    impl Backend for PqcBackend {
4501        fn kind(&self) -> &'static str {
4502            "pqc-dispatch-test"
4503        }
4504
4505        async fn new_key(&self, _key_type: KeyType) -> Result<NewKey, BackendError> {
4506            Err(BackendError::Unsupported("new_key"))
4507        }
4508
4509        async fn public_key(&self, _key_id: &str) -> Result<Vec<u8>, BackendError> {
4510            Err(BackendError::Unsupported("public_key"))
4511        }
4512
4513        async fn sign(&self, _key_id: &str, _message: &[u8]) -> Result<Vec<u8>, BackendError> {
4514            Err(BackendError::Unsupported("sign"))
4515        }
4516
4517        async fn verify(
4518            &self,
4519            _key_id: &str,
4520            _message: &[u8],
4521            _signature: &[u8],
4522        ) -> Result<bool, BackendError> {
4523            Err(BackendError::Unsupported("verify"))
4524        }
4525
4526        async fn encrypt(
4527            &self,
4528            _key_id: &str,
4529            algorithm: AeadAlgorithm,
4530            plaintext: &[u8],
4531            aad: Option<&[u8]>,
4532        ) -> Result<CiphertextEnvelope, BackendError> {
4533            let aad = aad.unwrap_or(&[]);
4534            let mut ciphertext = vec![u8::try_from(aad.len()).unwrap_or(u8::MAX)];
4535            ciphertext.extend_from_slice(aad);
4536            ciphertext.extend_from_slice(plaintext);
4537            Ok(CiphertextEnvelope {
4538                alg: algorithm,
4539                key_version: 1,
4540                nonce: Vec::new(),
4541                ciphertext,
4542            })
4543        }
4544
4545        async fn decrypt(
4546            &self,
4547            _key_id: &str,
4548            envelope: &CiphertextEnvelope,
4549            aad: Option<&[u8]>,
4550        ) -> Result<Vec<u8>, BackendError> {
4551            let aad = aad.unwrap_or(&[]);
4552            let ct = &envelope.ciphertext;
4553            let aad_len = *ct.first().ok_or(BackendError::DecryptFailed)? as usize;
4554            let bound = ct.get(1..1 + aad_len).ok_or(BackendError::DecryptFailed)?;
4555            if bound != aad {
4556                return Err(BackendError::DecryptFailed);
4557            }
4558            Ok(ct
4559                .get(1 + aad_len..)
4560                .ok_or(BackendError::DecryptFailed)?
4561                .to_vec())
4562        }
4563
4564        async fn kv_put(&self, key_id: &str, value: &[u8]) -> Result<u32, BackendError> {
4565            self.store
4566                .lock()
4567                .map_err(|_| BackendError::Unsupported("kv_put"))?
4568                .insert(key_id.to_string(), value.to_vec());
4569            Ok(1)
4570        }
4571
4572        async fn kv_get(
4573            &self,
4574            key_id: &str,
4575            _version: Option<u32>,
4576        ) -> Result<KvValue, BackendError> {
4577            let value = self
4578                .store
4579                .lock()
4580                .map_err(|_| BackendError::Unsupported("kv_get"))?
4581                .get(key_id)
4582                .cloned();
4583            value
4584                .map(|value| KvValue { value, version: 1 })
4585                .ok_or(BackendError::Unsupported("kv_get"))
4586        }
4587    }
4588
4589    /// A fake **backend-native** ML-DSA backend for the migration tests.
4590    ///
4591    /// It reports native ML-DSA support and performs generate/sign/verify *in
4592    /// place* (the seed is held inside the backend and never returned), modeling
4593    /// a future Vault/OpenBao with native ML-DSA transit. It also custodies
4594    /// software records (`encrypt`/`decrypt`/`kv_*`, mirroring [`PqcBackend`]) so a
4595    /// software-pinned key on the same backend still round-trips, which is exactly
4596    /// what the "probe must not re-route an existing software key" test needs.
4597    #[derive(Default)]
4598    struct NativePqcBackend {
4599        seeds: Mutex<HashMap<String, [u8; 32]>>,
4600        store: Mutex<HashMap<String, Vec<u8>>>,
4601    }
4602
4603    impl NativePqcBackend {
4604        const fn dsa(native: NativeAlgorithm) -> MlDsaAlgorithm {
4605            match native {
4606                NativeAlgorithm::MlDsa44 => MlDsaAlgorithm::MlDsa44,
4607                NativeAlgorithm::MlDsa65 => MlDsaAlgorithm::MlDsa65,
4608                NativeAlgorithm::MlDsa87 => MlDsaAlgorithm::MlDsa87,
4609            }
4610        }
4611
4612        /// A deterministic in-backend seed for `key_id` (test custody: the seed
4613        /// never leaves the backend, exactly as a native transit key would not).
4614        fn seed_for(key_id: &str) -> [u8; 32] {
4615            let mut seed = [7u8; 32];
4616            for (slot, byte) in seed.iter_mut().zip(key_id.bytes()) {
4617                *slot = byte;
4618            }
4619            seed
4620        }
4621    }
4622
4623    #[async_trait]
4624    impl Backend for NativePqcBackend {
4625        fn kind(&self) -> &'static str {
4626            "pqc-native-test"
4627        }
4628
4629        async fn new_key(&self, _key_type: KeyType) -> Result<NewKey, BackendError> {
4630            Err(BackendError::Unsupported("new_key"))
4631        }
4632
4633        async fn public_key(&self, _key_id: &str) -> Result<Vec<u8>, BackendError> {
4634            Err(BackendError::Unsupported("public_key"))
4635        }
4636
4637        async fn sign(&self, _key_id: &str, _message: &[u8]) -> Result<Vec<u8>, BackendError> {
4638            Err(BackendError::Unsupported("sign"))
4639        }
4640
4641        async fn verify(
4642            &self,
4643            _key_id: &str,
4644            _message: &[u8],
4645            _signature: &[u8],
4646        ) -> Result<bool, BackendError> {
4647            Err(BackendError::Unsupported("verify"))
4648        }
4649
4650        fn supports_native_algorithm(&self, algorithm: NativeAlgorithm) -> bool {
4651            matches!(
4652                algorithm,
4653                NativeAlgorithm::MlDsa44 | NativeAlgorithm::MlDsa65 | NativeAlgorithm::MlDsa87
4654            )
4655        }
4656
4657        async fn create_named_pqc_key(
4658            &self,
4659            key_id: &str,
4660            algorithm: NativeAlgorithm,
4661        ) -> Result<NewKey, BackendError> {
4662            let seed = Self::seed_for(key_id);
4663            let public = ml_dsa_sign::public_from_seed(Self::dsa(algorithm), &seed)
4664                .map_err(|_| BackendError::Unsupported("create_named_pqc_key"))?;
4665            self.seeds
4666                .lock()
4667                .map_err(|_| BackendError::Unsupported("create_named_pqc_key"))?
4668                .insert(key_id.to_string(), seed);
4669            Ok(NewKey {
4670                key_id: key_id.to_string(),
4671                public_key: public,
4672            })
4673        }
4674
4675        async fn sign_pqc(
4676            &self,
4677            key_id: &str,
4678            message: &[u8],
4679            algorithm: NativeAlgorithm,
4680        ) -> Result<Vec<u8>, BackendError> {
4681            let seed = self
4682                .seeds
4683                .lock()
4684                .map_err(|_| BackendError::Unsupported("sign_pqc"))?
4685                .get(key_id)
4686                .copied()
4687                .ok_or_else(|| BackendError::KeyNotFound(key_id.to_string()))?;
4688            ml_dsa_sign::sign(Self::dsa(algorithm), &seed, message)
4689                .map_err(|_| BackendError::Unsupported("sign_pqc"))
4690        }
4691
4692        async fn verify_pqc(
4693            &self,
4694            key_id: &str,
4695            message: &[u8],
4696            signature: &[u8],
4697            algorithm: NativeAlgorithm,
4698        ) -> Result<bool, BackendError> {
4699            let seed = self
4700                .seeds
4701                .lock()
4702                .map_err(|_| BackendError::Unsupported("verify_pqc"))?
4703                .get(key_id)
4704                .copied()
4705                .ok_or_else(|| BackendError::KeyNotFound(key_id.to_string()))?;
4706            let public = ml_dsa_sign::public_from_seed(Self::dsa(algorithm), &seed)
4707                .map_err(|_| BackendError::Unsupported("verify_pqc"))?;
4708            ml_dsa_sign::verify(Self::dsa(algorithm), &public, message, signature)
4709                .map_err(|_| BackendError::Unsupported("verify_pqc"))
4710        }
4711
4712        async fn encrypt(
4713            &self,
4714            _key_id: &str,
4715            algorithm: AeadAlgorithm,
4716            plaintext: &[u8],
4717            aad: Option<&[u8]>,
4718        ) -> Result<CiphertextEnvelope, BackendError> {
4719            let aad = aad.unwrap_or(&[]);
4720            let mut ciphertext = vec![u8::try_from(aad.len()).unwrap_or(u8::MAX)];
4721            ciphertext.extend_from_slice(aad);
4722            ciphertext.extend_from_slice(plaintext);
4723            Ok(CiphertextEnvelope {
4724                alg: algorithm,
4725                key_version: 1,
4726                nonce: Vec::new(),
4727                ciphertext,
4728            })
4729        }
4730
4731        async fn decrypt(
4732            &self,
4733            _key_id: &str,
4734            envelope: &CiphertextEnvelope,
4735            aad: Option<&[u8]>,
4736        ) -> Result<Vec<u8>, BackendError> {
4737            let aad = aad.unwrap_or(&[]);
4738            let ct = &envelope.ciphertext;
4739            let aad_len = *ct.first().ok_or(BackendError::DecryptFailed)? as usize;
4740            let bound = ct.get(1..1 + aad_len).ok_or(BackendError::DecryptFailed)?;
4741            if bound != aad {
4742                return Err(BackendError::DecryptFailed);
4743            }
4744            Ok(ct
4745                .get(1 + aad_len..)
4746                .ok_or(BackendError::DecryptFailed)?
4747                .to_vec())
4748        }
4749
4750        async fn kv_put(&self, key_id: &str, value: &[u8]) -> Result<u32, BackendError> {
4751            self.store
4752                .lock()
4753                .map_err(|_| BackendError::Unsupported("kv_put"))?
4754                .insert(key_id.to_string(), value.to_vec());
4755            Ok(1)
4756        }
4757
4758        async fn kv_get(
4759            &self,
4760            key_id: &str,
4761            _version: Option<u32>,
4762        ) -> Result<KvValue, BackendError> {
4763            let value = self
4764                .store
4765                .lock()
4766                .map_err(|_| BackendError::Unsupported("kv_get"))?
4767                .get(key_id)
4768                .cloned();
4769            value
4770                .map(|value| KvValue { value, version: 1 })
4771                .ok_or(BackendError::Unsupported("kv_get"))
4772        }
4773    }
4774
4775    const PQC_CATALOG: &str = r#"{
4776      "schemaVersion": 1,
4777      "backends": { "primary": { "kind": "vault", "addr": "https://127.0.0.1:8200" } },
4778      "keys": {
4779        "pqc.signer44": {
4780          "class": "asymmetric", "keyType": "ml-dsa-44", "backend": "primary",
4781          "path": "secret/data/pqc/44", "writable": true, "missing": "error",
4782          "labels": ["crypto_provider=local-software", "crypto_provider_policy=local-software",
4783                     "pqc_custody=software-encrypted", "pqc_storage_key=pqc/aead",
4784                     "crypto_provider_version=1"],
4785          "description": "ml-dsa-44 software-custodied signer"
4786        },
4787        "pqc.signer65": {
4788          "class": "asymmetric", "keyType": "ml-dsa-65", "backend": "primary",
4789          "path": "secret/data/pqc/65", "writable": true, "missing": "error",
4790          "labels": ["crypto_provider=local-software", "crypto_provider_policy=local-software",
4791                     "pqc_custody=software-encrypted", "pqc_storage_key=pqc/aead",
4792                     "crypto_provider_version=1"],
4793          "description": "ml-dsa-65 software-custodied signer"
4794        },
4795        "pqc.signer87": {
4796          "class": "asymmetric", "keyType": "ml-dsa-87", "backend": "primary",
4797          "path": "secret/data/pqc/87", "writable": true, "missing": "error",
4798          "labels": ["crypto_provider=local-software", "crypto_provider_policy=local-software",
4799                     "pqc_custody=software-encrypted", "pqc_storage_key=pqc/aead",
4800                     "crypto_provider_version=1"],
4801          "description": "ml-dsa-87 software-custodied signer"
4802        },
4803        "pqc.backendreq": {
4804          "class": "asymmetric", "keyType": "ml-dsa-65", "backend": "primary",
4805          "path": "secret/data/pqc/breq", "writable": true, "missing": "error",
4806          "labels": ["crypto_provider_policy=backend-required", "pqc_custody=software-encrypted"],
4807          "description": "backend-required ml-dsa signer (no native provider)"
4808        },
4809        "pqc.preferred_native": {
4810          "class": "asymmetric", "keyType": "ml-dsa-65", "backend": "primary",
4811          "path": "pqc/native65", "writable": true, "missing": "error",
4812          "labels": ["crypto_provider_policy=backend-preferred", "crypto_provider_version=1"],
4813          "description": "backend-preferred ml-dsa signer (native when the backend supports it)"
4814        },
4815        "pqc.preferred_software": {
4816          "class": "asymmetric", "keyType": "ml-dsa-65", "backend": "primary",
4817          "path": "secret/data/pqc/pref-sw", "writable": true, "missing": "error",
4818          "labels": ["crypto_provider_policy=backend-preferred", "pqc_custody=software-encrypted",
4819                     "pqc_storage_key=pqc/aead", "crypto_provider_version=1"],
4820          "description": "backend-preferred ml-dsa signer pinned to software custody"
4821        },
4822        "pqc.backendreq_native": {
4823          "class": "asymmetric", "keyType": "ml-dsa-65", "backend": "primary",
4824          "path": "pqc/breq-native", "writable": true, "missing": "error",
4825          "labels": ["crypto_provider_policy=backend-required", "crypto_provider_version=1"],
4826          "description": "backend-required ml-dsa signer served by a native backend"
4827        }
4828      }
4829    }"#;
4830
4831    fn manager() -> BackendManager {
4832        let catalog: Catalog = serde_json::from_str(PQC_CATALOG).expect("catalog parses");
4833        let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
4834        backends.insert("primary".into(), Box::new(PqcBackend::default()));
4835        BackendManager::new(catalog, backends).expect("manager builds")
4836    }
4837
4838    /// The same catalog as [`manager`], but served by a backend that reports
4839    /// native ML-DSA support (the migration target).
4840    fn native_manager() -> BackendManager {
4841        let catalog: Catalog = serde_json::from_str(PQC_CATALOG).expect("catalog parses");
4842        let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
4843        backends.insert("primary".into(), Box::new(NativePqcBackend::default()));
4844        BackendManager::new(catalog, backends).expect("manager builds")
4845    }
4846
4847    const fn allowed() -> ProviderGate {
4848        ProviderGate {
4849            local_software_allowed: true,
4850        }
4851    }
4852
4853    #[tokio::test]
4854    async fn generate_sign_verify_round_trip_all_levels() {
4855        let cases = [
4856            ("pqc.signer44", MlDsaAlgorithm::MlDsa44, "ml-dsa-44"),
4857            ("pqc.signer65", MlDsaAlgorithm::MlDsa65, "ml-dsa-65"),
4858            ("pqc.signer87", MlDsaAlgorithm::MlDsa87, "ml-dsa-87"),
4859        ];
4860        for (key_id, dsa, token) in cases {
4861            let mgr = manager();
4862            let message = b"basil ml-dsa dispatch";
4863
4864            let (created, gen_dispatch) = mgr
4865                .provider_generate(key_id, allowed())
4866                .await
4867                .expect("generate");
4868            assert_eq!(gen_dispatch.provider, CryptoProviderId::LocalSoftware);
4869            assert_eq!(gen_dispatch.algorithm, token);
4870            assert!(!created.public_key.is_empty(), "{token} public");
4871
4872            let (signature, sign_dispatch) = mgr
4873                .provider_sign(key_id, message, allowed())
4874                .await
4875                .expect("sign");
4876            assert_eq!(sign_dispatch.provider, CryptoProviderId::LocalSoftware);
4877            assert_eq!(sign_dispatch.algorithm, token);
4878            assert_eq!(sign_dispatch.custody, CustodyMode::SoftwareEncrypted);
4879
4880            let (valid, _) = mgr
4881                .provider_verify(key_id, message, &signature, allowed())
4882                .await
4883                .expect("verify");
4884            assert!(valid, "{token} verifies through the broker");
4885
4886            // The returned public verifies the broker-produced signature directly.
4887            assert!(
4888                ml_dsa_sign::verify(dsa, &created.public_key, message, &signature).expect("core"),
4889                "{token} verifies under returned public",
4890            );
4891
4892            // A tampered message must not verify.
4893            let (bad, _) = mgr
4894                .provider_verify(key_id, b"tampered", &signature, allowed())
4895                .await
4896                .expect("verify");
4897            assert!(!bad, "{token} rejects a tampered message");
4898        }
4899    }
4900
4901    #[tokio::test]
4902    async fn backend_required_ml_dsa_fails_closed() {
4903        let mgr = manager();
4904        // No backend-native ML-DSA provider exists, so a backend-required key
4905        // fails closed at provider selection, before any custody read.
4906        let err = mgr
4907            .provider_sign("pqc.backendreq", b"m", allowed())
4908            .await
4909            .expect_err("backend-required ml-dsa is unsupported");
4910        assert!(matches!(
4911            err,
4912            ManagerError::Provider(ProviderError::Unsupported { .. })
4913        ));
4914        let gen_err = mgr
4915            .provider_generate("pqc.backendreq", allowed())
4916            .await
4917            .expect_err("backend-required ml-dsa generate is unsupported");
4918        assert!(matches!(
4919            gen_err,
4920            ManagerError::Provider(ProviderError::Unsupported { .. })
4921        ));
4922    }
4923
4924    #[tokio::test]
4925    async fn local_software_without_policy_grant_is_denied() {
4926        let mgr = manager();
4927        let denied = ProviderGate {
4928            local_software_allowed: false,
4929        };
4930        for outcome in [
4931            mgr.provider_sign("pqc.signer65", b"m", denied).await,
4932            mgr.provider_generate("pqc.signer65", denied)
4933                .await
4934                .map(|(key, dispatch)| (key.public_key, dispatch)),
4935        ] {
4936            let err = outcome.expect_err("local software requires an explicit policy grant");
4937            assert!(matches!(
4938                err,
4939                ManagerError::Provider(ProviderError::PolicyDenied { .. })
4940            ));
4941        }
4942    }
4943
4944    #[tokio::test]
4945    async fn dispatch_feeds_a_secret_free_provider_audit_event() {
4946        let mgr = manager();
4947        mgr.provider_generate("pqc.signer87", allowed())
4948            .await
4949            .expect("generate");
4950        let (_signature, dispatch) = mgr
4951            .provider_sign("pqc.signer87", b"m", allowed())
4952            .await
4953            .expect("sign");
4954        // The service builds the audit event from the dispatch; it must carry the
4955        // provider and algorithm and never any key/signature bytes.
4956        let event = ProviderAuditEvent {
4957            op: "sign",
4958            key_id: "pqc.signer87",
4959            key_version: None,
4960            algorithm: dispatch.algorithm,
4961            provider: dispatch.provider,
4962            custody: dispatch.custody,
4963            caller_uid: 4242,
4964            outcome: ProviderAuditOutcome::Success,
4965            reason: "ok",
4966        };
4967        let value = event.to_json_value();
4968        assert_eq!(value["provider"], "local-software");
4969        assert_eq!(value["algorithm"], "ml-dsa-87");
4970        assert_eq!(value["op"], "sign");
4971        assert_eq!(value["caller_uid"], 4242);
4972        assert!(value.get("signature").is_none());
4973        assert!(value.get("private_key").is_none());
4974    }
4975
4976    #[tokio::test]
4977    async fn backend_preferred_routes_to_native_when_backend_supports_it() {
4978        // A backend-preferred key with NO declared software custody, served by a
4979        // backend that natively supports ML-DSA: provisioning + ops transparently
4980        // route to the backend-native provider and round-trip in place.
4981        let mgr = native_manager();
4982        let key_id = "pqc.preferred_native";
4983        let message = b"basil native ml-dsa";
4984
4985        let (created, gen_dispatch) = mgr
4986            .provider_generate(key_id, allowed())
4987            .await
4988            .expect("generate");
4989        assert_eq!(gen_dispatch.provider, CryptoProviderId::VaultTransit);
4990        assert_eq!(gen_dispatch.custody, CustodyMode::BackendNative);
4991        assert!(!created.public_key.is_empty(), "native public");
4992
4993        let (signature, sign_dispatch) = mgr
4994            .provider_sign(key_id, message, allowed())
4995            .await
4996            .expect("sign");
4997        assert_eq!(sign_dispatch.provider, CryptoProviderId::VaultTransit);
4998        assert_eq!(sign_dispatch.custody, CustodyMode::BackendNative);
4999
5000        let (valid, _) = mgr
5001            .provider_verify(key_id, message, &signature, allowed())
5002            .await
5003            .expect("verify");
5004        assert!(valid, "native signature verifies through the broker");
5005
5006        let (bad, _) = mgr
5007            .provider_verify(key_id, b"tampered", &signature, allowed())
5008            .await
5009            .expect("verify");
5010        assert!(!bad, "native verify rejects a tampered message");
5011    }
5012
5013    #[tokio::test]
5014    async fn backend_required_routes_to_native_when_backend_supports_it() {
5015        // The capability probe also unblocks a backend-required key: with a native
5016        // backend it provisions + signs natively instead of failing closed (the
5017        // non-native fail-closed case is `backend_required_ml_dsa_fails_closed`).
5018        let mgr = native_manager();
5019        let key_id = "pqc.backendreq_native";
5020
5021        let (_created, gen_dispatch) = mgr
5022            .provider_generate(key_id, allowed())
5023            .await
5024            .expect("generate");
5025        assert_eq!(gen_dispatch.provider, CryptoProviderId::VaultTransit);
5026
5027        let (signature, sign_dispatch) = mgr
5028            .provider_sign(key_id, b"m", allowed())
5029            .await
5030            .expect("sign");
5031        assert_eq!(sign_dispatch.provider, CryptoProviderId::VaultTransit);
5032        let (valid, _) = mgr
5033            .provider_verify(key_id, b"m", &signature, allowed())
5034            .await
5035            .expect("verify");
5036        assert!(valid);
5037    }
5038
5039    #[tokio::test]
5040    async fn backend_preferred_falls_back_to_software_without_native_support() {
5041        // The same backend-preferred software-custodied key on a backend WITHOUT
5042        // native ML-DSA support falls back to the local-software provider.
5043        let mgr = manager();
5044        let key_id = "pqc.preferred_software";
5045
5046        let (_created, gen_dispatch) = mgr
5047            .provider_generate(key_id, allowed())
5048            .await
5049            .expect("generate");
5050        assert_eq!(gen_dispatch.provider, CryptoProviderId::LocalSoftware);
5051        assert_eq!(gen_dispatch.custody, CustodyMode::SoftwareEncrypted);
5052
5053        let (signature, sign_dispatch) = mgr
5054            .provider_sign(key_id, b"m", allowed())
5055            .await
5056            .expect("sign");
5057        assert_eq!(sign_dispatch.provider, CryptoProviderId::LocalSoftware);
5058        let (valid, _) = mgr
5059            .provider_verify(key_id, b"m", &signature, allowed())
5060            .await
5061            .expect("verify");
5062        assert!(valid);
5063    }
5064
5065    #[tokio::test]
5066    async fn software_custodied_key_is_not_rerouted_by_native_probe() {
5067        // The migration invariant: an already-software-custodied key keeps using
5068        // the local-software provider even when the backend NOW reports native
5069        // support. The probe flipping to "supported" must never silently re-route
5070        // a key whose private seed lives software-encrypted in KV.
5071        let mgr = native_manager();
5072        let key_id = "pqc.preferred_software";
5073
5074        let (_created, gen_dispatch) = mgr
5075            .provider_generate(key_id, allowed())
5076            .await
5077            .expect("generate");
5078        assert_eq!(
5079            gen_dispatch.provider,
5080            CryptoProviderId::LocalSoftware,
5081            "software-custodied key stays local-software despite native support",
5082        );
5083
5084        let (_signature, sign_dispatch) = mgr
5085            .provider_sign(key_id, b"m", allowed())
5086            .await
5087            .expect("sign");
5088        assert_eq!(sign_dispatch.provider, CryptoProviderId::LocalSoftware);
5089        assert_eq!(sign_dispatch.custody, CustodyMode::SoftwareEncrypted);
5090    }
5091
5092    #[tokio::test]
5093    async fn describe_provider_surfaces_active_custody_and_migration_availability() {
5094        // The admin read seam: which provider/custody/version a key is under, and
5095        // whether a backend-native migration is now available.
5096        let native = native_manager();
5097
5098        let software = native
5099            .describe_provider("pqc.preferred_software")
5100            .expect("describe");
5101        let expected_software = KeyProviderDescriptor {
5102            policy: ProviderPolicy::BackendPreferred,
5103            provider: None,
5104            custody: Some(CustodyMode::SoftwareEncrypted),
5105            version: Some("1".to_string()),
5106            backend_native_available: true,
5107        };
5108        assert_eq!(
5109            software, expected_software,
5110            "an admin sees software custody AND that a native migration is available",
5111        );
5112
5113        let preferred = native
5114            .describe_provider("pqc.preferred_native")
5115            .expect("describe");
5116        assert_eq!(preferred.custody, None);
5117        assert!(preferred.backend_native_available);
5118
5119        // The same software key on a non-native backend reports no migration.
5120        let classical = manager();
5121        let no_native = classical
5122            .describe_provider("pqc.preferred_software")
5123            .expect("describe");
5124        assert!(!no_native.backend_native_available);
5125
5126        assert!(matches!(
5127            classical.describe_provider("does.not.exist"),
5128            Err(ManagerError::UnknownKey(_))
5129        ));
5130    }
5131}