Skip to main content

basil_core/core/
manager.rs

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