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