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