Skip to main content

mssql_auth/
azure_keyvault.rs

1//! Azure Key Vault Column Master Key (CMK) provider for Always Encrypted.
2//!
3//! This module provides integration with Azure Key Vault for secure key management
4//! in SQL Server Always Encrypted scenarios.
5//!
6//! ## Overview
7//!
8//! Azure Key Vault is Microsoft's cloud-based key management service that provides
9//! secure storage and access to cryptographic keys. This provider uses Azure Key Vault's
10//! "unwrap" operation to decrypt Column Encryption Keys (CEKs) using Column Master Keys
11//! (CMKs) stored in the vault.
12//!
13//! ## CMK Path Format
14//!
15//! The CMK path for Azure Key Vault follows this format:
16//!
17//! ```text
18//! https://<vault-name>.vault.azure.net/keys/<key-name>/<key-version>
19//! ```
20//!
21//! The key version is optional - if omitted, the latest version is used.
22//!
23//! ## Authentication
24//!
25//! The provider uses Azure Identity for authentication. The following methods are supported:
26//!
27//! - **DefaultAzureCredential**: Tries multiple authentication methods automatically
28//! - **Environment variables**: Uses `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, `AZURE_TENANT_ID`
29//! - **Managed Identity**: For Azure VMs, App Service, AKS, etc.
30//! - **Azure CLI**: Uses credentials from `az login`
31//!
32//! ## Example
33//!
34//! ```rust,ignore
35//! use mssql_auth::azure_keyvault::AzureKeyVaultProvider;
36//! use mssql_auth::ColumnEncryptionConfig;
37//!
38//! // Create provider with default Azure credentials
39//! let provider = AzureKeyVaultProvider::new()?;
40//!
41//! // Or with a specific credential
42//! let credential = azure_identity::DeveloperToolsCredential::new(None)?;
43//! let provider = AzureKeyVaultProvider::with_credential(Arc::new(credential));
44//!
45//! // Register with encryption config
46//! let config = ColumnEncryptionConfig::new()
47//!     .with_provider(provider);
48//! ```
49//!
50//! ## Security Considerations
51//!
52//! - Keys never leave Azure Key Vault; only the unwrap operation is performed
53//! - Access is controlled via Azure RBAC or Key Vault access policies
54//! - All communication uses TLS
55//! - Audit logs are available in Azure Key Vault
56
57use std::sync::Arc;
58
59use azure_core::http::RequestContent;
60use azure_identity::DeveloperToolsCredential;
61use azure_security_keyvault_keys::KeyClient;
62use azure_security_keyvault_keys::models::{EncryptionAlgorithm, KeyOperationParameters};
63use tracing::{debug, instrument};
64use url::Url;
65
66use crate::encryption::{EncryptionError, KeyStoreProvider};
67
68/// SQL Server provider name for Azure Key Vault.
69const PROVIDER_NAME: &str = "AZURE_KEY_VAULT";
70
71/// Default trusted Key Vault / Managed HSM DNS suffixes across Azure clouds.
72///
73/// The CMK path in Always Encrypted metadata is supplied by the server. A
74/// malicious or compromised server could point it at an attacker-controlled
75/// host, causing this provider to send a Key-Vault-scoped bearer token there
76/// (token exfiltration / SSRF). Restricting the host to these
77/// Microsoft-operated suffixes closes that vector; custom or private
78/// deployments can override via
79/// [`AzureKeyVaultProvider::with_trusted_endpoints`].
80const DEFAULT_TRUSTED_KEY_VAULT_SUFFIXES: &[&str] = &[
81    ".vault.azure.net",              // Azure public cloud
82    ".vaultcore.azure.net",          // Azure public cloud (data-plane alias)
83    ".managedhsm.azure.net",         // Managed HSM, public cloud
84    ".vault.azure.cn",               // Azure China (21Vianet)
85    ".managedhsm.azure.cn",          // Managed HSM, Azure China
86    ".vault.usgovcloudapi.net",      // Azure US Government
87    ".managedhsm.usgovcloudapi.net", // Managed HSM, US Government
88    ".vault.microsoftazure.de",      // Azure Germany (legacy)
89];
90
91/// Azure Key Vault Column Master Key provider.
92///
93/// This provider implements the [`KeyStoreProvider`] trait to support
94/// Always Encrypted operations using keys stored in Azure Key Vault.
95///
96/// ## Thread Safety
97///
98/// This provider is `Send + Sync` and can be safely shared across threads.
99pub struct AzureKeyVaultProvider {
100    /// Azure credential for authentication.
101    credential: Arc<DeveloperToolsCredential>,
102    /// Host suffixes a server-supplied CMK path is allowed to target.
103    /// Defaults to `DEFAULT_TRUSTED_KEY_VAULT_SUFFIXES`.
104    trusted_host_suffixes: Vec<String>,
105}
106
107fn default_trusted_suffixes() -> Vec<String> {
108    DEFAULT_TRUSTED_KEY_VAULT_SUFFIXES
109        .iter()
110        .map(|s| (*s).to_string())
111        .collect()
112}
113
114impl AzureKeyVaultProvider {
115    /// Create a new Azure Key Vault provider with default credentials.
116    ///
117    /// This uses [`DeveloperToolsCredential`] which tries multiple authentication
118    /// methods in order:
119    ///
120    /// 1. Azure CLI credentials (`az login`)
121    /// 2. Other developer tools (Visual Studio Code, etc.)
122    ///
123    /// For production environments, use [`Self::with_credential`] with a specific
124    /// credential type such as managed identity or service principal.
125    ///
126    /// # Errors
127    ///
128    /// Returns an error if credential initialization fails.
129    ///
130    /// # Example
131    ///
132    /// ```rust,ignore
133    /// let provider = AzureKeyVaultProvider::new()?;
134    /// ```
135    pub fn new() -> Result<Self, EncryptionError> {
136        let credential = DeveloperToolsCredential::new(None).map_err(|e| {
137            EncryptionError::ConfigurationError(format!("Failed to create Azure credential: {e}"))
138        })?;
139        Ok(Self {
140            credential,
141            trusted_host_suffixes: default_trusted_suffixes(),
142        })
143    }
144
145    /// Create a new Azure Key Vault provider with an existing credential.
146    ///
147    /// Use this when you need to share a credential across multiple providers.
148    ///
149    /// # Example
150    ///
151    /// ```rust,ignore
152    /// use azure_identity::DeveloperToolsCredential;
153    ///
154    /// let credential = Arc::new(DeveloperToolsCredential::new(None)?);
155    /// let provider = AzureKeyVaultProvider::with_credential(credential);
156    /// ```
157    #[must_use]
158    pub fn with_credential(credential: Arc<DeveloperToolsCredential>) -> Self {
159        Self {
160            credential,
161            trusted_host_suffixes: default_trusted_suffixes(),
162        }
163    }
164
165    /// Override the set of host suffixes a server-supplied CMK path may target.
166    ///
167    /// By default only Microsoft-operated Key Vault / Managed HSM endpoints
168    /// (`.vault.azure.net`, `.managedhsm.azure.net`, and the China / US-Gov /
169    /// legacy-Germany variants) are accepted, so a malicious server cannot
170    /// redirect key operations to an attacker-controlled host.
171    /// Use this only for private or sovereign deployments with custom DNS, and
172    /// pass suffixes you fully control (e.g. `".vault.contoso.example"`).
173    /// Replacing the list with an over-broad suffix re-opens the SSRF /
174    /// token-exfiltration vector this guard closes.
175    #[must_use]
176    pub fn with_trusted_endpoints<I, S>(mut self, suffixes: I) -> Self
177    where
178        I: IntoIterator<Item = S>,
179        S: Into<String>,
180    {
181        self.trusted_host_suffixes = suffixes.into_iter().map(Into::into).collect();
182        self
183    }
184
185    /// Parse a CMK path into vault URL, key name, and optional version.
186    ///
187    /// Expected format: `https://<vault>.vault.azure.net/keys/<key-name>[/<version>]`
188    ///
189    /// The scheme must be `https` and the host must match one of
190    /// `trusted_suffixes`; otherwise an error is returned, since the path
191    /// originates from the (untrusted) server.
192    fn parse_cmk_path(
193        cmk_path: &str,
194        trusted_suffixes: &[String],
195    ) -> Result<(String, String, Option<String>), EncryptionError> {
196        let url = Url::parse(cmk_path).map_err(|e| {
197            EncryptionError::CmkError(format!("Invalid CMK path '{cmk_path}': {e}"))
198        })?;
199
200        if url.scheme() != "https" {
201            return Err(EncryptionError::CmkError(format!(
202                "CMK path must use https, got scheme '{}' in '{cmk_path}'",
203                url.scheme()
204            )));
205        }
206
207        let host = url
208            .host_str()
209            .ok_or_else(|| EncryptionError::CmkError("CMK path missing host".into()))?;
210        let host_lc = host.to_ascii_lowercase();
211        let trusted = trusted_suffixes
212            .iter()
213            .any(|suffix| host_lc.ends_with(&suffix.to_ascii_lowercase()));
214        if !trusted {
215            return Err(EncryptionError::CmkError(format!(
216                "CMK host '{host}' is not a trusted Key Vault endpoint. The CMK path is \
217                 supplied by the server; allowing an arbitrary host would let a malicious \
218                 server redirect key operations and exfiltrate access tokens. Trusted \
219                 suffixes: {trusted_suffixes:?}. For custom deployments use \
220                 AzureKeyVaultProvider::with_trusted_endpoints."
221            )));
222        }
223
224        // Extract vault URL (scheme + host)
225        let vault_url = format!("{}://{host}", url.scheme());
226
227        // Parse path segments: /keys/<name>[/<version>]
228        let segments: Vec<&str> = url.path_segments().map(|s| s.collect()).unwrap_or_default();
229
230        if segments.len() < 2 || segments[0] != "keys" {
231            return Err(EncryptionError::CmkError(format!(
232                "Invalid CMK path format: expected /keys/<name>[/<version>], got '{}'",
233                url.path()
234            )));
235        }
236
237        let key_name = segments[1].to_string();
238        let key_version = if segments.len() >= 3 && !segments[2].is_empty() {
239            Some(segments[2].to_string())
240        } else {
241            None
242        };
243
244        Ok((vault_url, key_name, key_version))
245    }
246
247    /// Create a Key Vault client for a specific vault.
248    fn create_client(&self, vault_url: &str) -> Result<KeyClient, EncryptionError> {
249        KeyClient::new(vault_url, self.credential.clone(), None).map_err(|e| {
250            EncryptionError::CmkError(format!("Failed to create Key Vault client: {e}"))
251        })
252    }
253}
254
255impl std::fmt::Debug for AzureKeyVaultProvider {
256    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
257        f.debug_struct("AzureKeyVaultProvider")
258            .field("provider_name", &PROVIDER_NAME)
259            .finish_non_exhaustive()
260    }
261}
262
263#[async_trait::async_trait]
264impl KeyStoreProvider for AzureKeyVaultProvider {
265    fn provider_name(&self) -> &str {
266        PROVIDER_NAME
267    }
268
269    #[instrument(skip(self, encrypted_cek), fields(cmk_path = %cmk_path, algorithm = %algorithm))]
270    async fn decrypt_cek(
271        &self,
272        cmk_path: &str,
273        algorithm: &str,
274        encrypted_cek: &[u8],
275    ) -> Result<Vec<u8>, EncryptionError> {
276        debug!("Decrypting CEK using Azure Key Vault");
277
278        // Parse the CMK path
279        let (vault_url, key_name, key_version) =
280            Self::parse_cmk_path(cmk_path, &self.trusted_host_suffixes)?;
281
282        // Create client for this vault
283        let client = self.create_client(&vault_url)?;
284
285        // Map algorithm name to Azure Key Vault algorithm
286        let kv_algorithm = map_algorithm(algorithm)?;
287
288        // Parse the canonical encrypted-CEK envelope
289        let envelope = crate::cek_envelope::parse(encrypted_cek)?;
290
291        // Verify the envelope signature against the CMK (mandatory, matching
292        // the reference implementation) before asking the vault to unwrap.
293        let digest = envelope.signed_digest();
294        let valid = self
295            .verify_signature(cmk_path, &digest, envelope.signature)
296            .await?;
297        if !valid {
298            return Err(EncryptionError::CekDecryptionFailed(
299                "CEK envelope signature verification failed".into(),
300            ));
301        }
302
303        // Build unwrap parameters
304        let parameters = KeyOperationParameters {
305            algorithm: Some(kv_algorithm),
306            value: Some(envelope.ciphertext.to_vec()),
307            ..Default::default()
308        };
309
310        // key_version is required by the Azure SDK 0.13+ API
311        let version = key_version.ok_or_else(|| {
312            EncryptionError::CmkError(
313                "CMK path must include key version (e.g., /keys/<name>/<version>)".into(),
314            )
315        })?;
316
317        // Convert parameters to RequestContent
318        let request_content: RequestContent<KeyOperationParameters> =
319            parameters.try_into().map_err(|e| {
320                EncryptionError::CekDecryptionFailed(format!("Failed to create request: {e}"))
321            })?;
322
323        // Call Key Vault unwrap operation
324        let result = client
325            .unwrap_key(&key_name, &version, request_content, None)
326            .await
327            .map_err(|e| {
328                EncryptionError::CekDecryptionFailed(format!("Key Vault unwrap failed: {e}"))
329            })?
330            .into_model()
331            .map_err(|e| {
332                EncryptionError::CekDecryptionFailed(format!("Failed to parse response: {e}"))
333            })?;
334
335        // Extract the decrypted CEK from response
336        let decrypted = result.result.ok_or_else(|| {
337            EncryptionError::CekDecryptionFailed("Key Vault unwrap returned no result".into())
338        })?;
339
340        debug!("Successfully decrypted CEK using Azure Key Vault");
341        Ok(decrypted)
342    }
343
344    #[instrument(skip(self, data), fields(cmk_path = %cmk_path))]
345    async fn sign_data(&self, cmk_path: &str, data: &[u8]) -> Result<Vec<u8>, EncryptionError> {
346        debug!("Signing data using Azure Key Vault");
347
348        // Parse the CMK path
349        let (vault_url, key_name, key_version) =
350            Self::parse_cmk_path(cmk_path, &self.trusted_host_suffixes)?;
351
352        // Create client for this vault
353        let client = self.create_client(&vault_url)?;
354
355        // Build sign parameters - use RS256 (RSA-SHA256) by default
356        use azure_security_keyvault_keys::models::{
357            KeyClientSignOptions, SignParameters, SignatureAlgorithm,
358        };
359
360        let parameters = SignParameters {
361            algorithm: Some(SignatureAlgorithm::Rs256),
362            value: Some(data.to_vec()),
363        };
364
365        // key_version is required for this operation. As of azure_security_keyvault_keys
366        // 1.0, sign() takes the key version via options.key_version rather than as a
367        // positional argument (verify()/unwrap_key() still take it positionally).
368        let version = key_version.ok_or_else(|| {
369            EncryptionError::CmkError("CMK path must include key version for sign operation".into())
370        })?;
371
372        let request_content: RequestContent<SignParameters> = parameters
373            .try_into()
374            .map_err(|e| EncryptionError::CmkError(format!("Failed to create request: {e}")))?;
375
376        let sign_options = KeyClientSignOptions {
377            key_version: Some(version),
378            ..Default::default()
379        };
380
381        // Call Key Vault sign operation
382        let result = client
383            .sign(&key_name, request_content, Some(sign_options))
384            .await
385            .map_err(|e| EncryptionError::CmkError(format!("Key Vault sign failed: {e}")))?
386            .into_model()
387            .map_err(|e| EncryptionError::CmkError(format!("Failed to parse response: {e}")))?;
388
389        // Extract the signature from response
390        let signature = result
391            .result
392            .ok_or_else(|| EncryptionError::CmkError("Key Vault sign returned no result".into()))?;
393
394        debug!("Successfully signed data using Azure Key Vault");
395        Ok(signature)
396    }
397
398    #[instrument(skip(self, data, signature), fields(cmk_path = %cmk_path))]
399    async fn verify_signature(
400        &self,
401        cmk_path: &str,
402        data: &[u8],
403        signature: &[u8],
404    ) -> Result<bool, EncryptionError> {
405        debug!("Verifying signature using Azure Key Vault");
406
407        // Parse the CMK path
408        let (vault_url, key_name, key_version) =
409            Self::parse_cmk_path(cmk_path, &self.trusted_host_suffixes)?;
410
411        // Create client for this vault
412        let client = self.create_client(&vault_url)?;
413
414        // Build verify parameters
415        use azure_security_keyvault_keys::models::{SignatureAlgorithm, VerifyParameters};
416
417        let parameters = VerifyParameters {
418            algorithm: Some(SignatureAlgorithm::Rs256),
419            digest: Some(data.to_vec()),
420            signature: Some(signature.to_vec()),
421        };
422
423        // key_version is required by the Azure SDK 0.13+ API
424        let version = key_version.ok_or_else(|| {
425            EncryptionError::CmkError(
426                "CMK path must include key version for verify operation".into(),
427            )
428        })?;
429
430        let request_content: RequestContent<VerifyParameters> = parameters
431            .try_into()
432            .map_err(|e| EncryptionError::CmkError(format!("Failed to create request: {e}")))?;
433
434        // Call Key Vault verify operation
435        let result = client
436            .verify(&key_name, &version, request_content, None)
437            .await
438            .map_err(|e| EncryptionError::CmkError(format!("Key Vault verify failed: {e}")))?
439            .into_model()
440            .map_err(|e| EncryptionError::CmkError(format!("Failed to parse response: {e}")))?;
441
442        // Extract the verification result
443        // KeyVerifyResult has a `value` field of type Option<bool>
444        let is_valid = result.value.unwrap_or(false);
445
446        debug!("Signature verification result: {}", is_valid);
447        Ok(is_valid)
448    }
449}
450
451/// Map SQL Server algorithm name to Azure Key Vault algorithm.
452fn map_algorithm(algorithm: &str) -> Result<EncryptionAlgorithm, EncryptionError> {
453    match algorithm.to_uppercase().as_str() {
454        "RSA_OAEP" | "RSA-OAEP" => Ok(EncryptionAlgorithm::RsaOaep),
455        "RSA_OAEP_256" | "RSA-OAEP-256" => Ok(EncryptionAlgorithm::RsaOaep256),
456        "RSA1_5" | "RSA-1_5" => Ok(EncryptionAlgorithm::Rsa1_5),
457        _ => Err(EncryptionError::ConfigurationError(format!(
458            "Unsupported key encryption algorithm: {algorithm}. Expected RSA_OAEP, RSA_OAEP_256, or RSA1_5"
459        ))),
460    }
461}
462
463#[cfg(test)]
464#[allow(clippy::unwrap_used, clippy::expect_used)]
465mod tests {
466    use super::*;
467
468    fn trusted() -> Vec<String> {
469        default_trusted_suffixes()
470    }
471
472    #[test]
473    fn test_parse_cmk_path() {
474        // Full path with version
475        let (vault, name, version) = AzureKeyVaultProvider::parse_cmk_path(
476            "https://myvault.vault.azure.net/keys/mykey/abc123",
477            &trusted(),
478        )
479        .expect("valid CMK path with version should parse");
480        assert_eq!(vault, "https://myvault.vault.azure.net");
481        assert_eq!(name, "mykey");
482        assert_eq!(version, Some("abc123".to_string()));
483
484        // Path without version
485        let (vault, name, version) = AzureKeyVaultProvider::parse_cmk_path(
486            "https://myvault.vault.azure.net/keys/mykey",
487            &trusted(),
488        )
489        .expect("valid CMK path without version should parse");
490        assert_eq!(vault, "https://myvault.vault.azure.net");
491        assert_eq!(name, "mykey");
492        assert_eq!(version, None);
493
494        // Path with trailing slash (no version)
495        let (vault, name, version) = AzureKeyVaultProvider::parse_cmk_path(
496            "https://myvault.vault.azure.net/keys/mykey/",
497            &trusted(),
498        )
499        .expect("valid CMK path with trailing slash should parse");
500        assert_eq!(vault, "https://myvault.vault.azure.net");
501        assert_eq!(name, "mykey");
502        assert_eq!(version, None);
503
504        // Managed HSM and a sovereign-cloud host are also trusted by default.
505        assert!(
506            AzureKeyVaultProvider::parse_cmk_path(
507                "https://myhsm.managedhsm.azure.net/keys/mykey",
508                &trusted(),
509            )
510            .is_ok()
511        );
512        assert!(
513            AzureKeyVaultProvider::parse_cmk_path(
514                "https://myvault.vault.usgovcloudapi.net/keys/mykey",
515                &trusted(),
516            )
517            .is_ok()
518        );
519    }
520
521    #[test]
522    fn test_parse_cmk_path_invalid() {
523        // Not a URL
524        assert!(AzureKeyVaultProvider::parse_cmk_path("not-a-url", &trusted()).is_err());
525
526        // Wrong path format (host is trusted so we reach the path check)
527        assert!(
528            AzureKeyVaultProvider::parse_cmk_path(
529                "https://myvault.vault.azure.net/secrets/mysecret",
530                &trusted(),
531            )
532            .is_err()
533        );
534
535        // Missing key name
536        assert!(
537            AzureKeyVaultProvider::parse_cmk_path(
538                "https://myvault.vault.azure.net/keys",
539                &trusted(),
540            )
541            .is_err()
542        );
543    }
544
545    /// Issue #162: a server-supplied CMK path pointing at an untrusted host
546    /// must be rejected — otherwise a malicious server could redirect key
547    /// operations and exfiltrate the Key-Vault-scoped access token.
548    #[test]
549    fn test_parse_cmk_path_rejects_untrusted_host() {
550        // Attacker-controlled host
551        let err = AzureKeyVaultProvider::parse_cmk_path(
552            "https://attacker.example.com/keys/mykey",
553            &trusted(),
554        )
555        .expect_err("untrusted host must be rejected");
556        assert!(err.to_string().contains("not a trusted Key Vault endpoint"));
557
558        // Suffix-spoofing: trusted suffix appears as a non-suffix label.
559        assert!(
560            AzureKeyVaultProvider::parse_cmk_path(
561                "https://vault.azure.net.attacker.com/keys/mykey",
562                &trusted(),
563            )
564            .is_err()
565        );
566
567        // http downgrade is rejected even for a trusted host.
568        assert!(
569            AzureKeyVaultProvider::parse_cmk_path(
570                "http://myvault.vault.azure.net/keys/mykey",
571                &trusted(),
572            )
573            .is_err()
574        );
575    }
576
577    /// Issue #162: a custom trusted-endpoint list permits private deployments
578    /// while still rejecting everything outside it.
579    #[test]
580    fn test_with_trusted_endpoints_override() {
581        let custom = vec![".vault.contoso.example".to_string()];
582        assert!(
583            AzureKeyVaultProvider::parse_cmk_path(
584                "https://kv1.vault.contoso.example/keys/mykey",
585                &custom,
586            )
587            .is_ok()
588        );
589        // The Azure default is no longer trusted under the override.
590        assert!(
591            AzureKeyVaultProvider::parse_cmk_path(
592                "https://myvault.vault.azure.net/keys/mykey",
593                &custom,
594            )
595            .is_err()
596        );
597    }
598
599    #[test]
600    fn test_map_algorithm() {
601        assert!(matches!(
602            map_algorithm("RSA_OAEP").expect("RSA_OAEP should be a valid algorithm"),
603            EncryptionAlgorithm::RsaOaep
604        ));
605        assert!(matches!(
606            map_algorithm("RSA-OAEP").expect("RSA-OAEP should be a valid algorithm"),
607            EncryptionAlgorithm::RsaOaep
608        ));
609        assert!(matches!(
610            map_algorithm("RSA_OAEP_256").expect("RSA_OAEP_256 should be a valid algorithm"),
611            EncryptionAlgorithm::RsaOaep256
612        ));
613        // Case insensitive
614        assert!(matches!(
615            map_algorithm("rsa_oaep").expect("lowercase rsa_oaep should be valid"),
616            EncryptionAlgorithm::RsaOaep
617        ));
618        assert!(map_algorithm("UNKNOWN").is_err());
619    }
620
621    /// Live round-trip through the REAL Azure Key Vault unwrap path
622    /// (`decrypt_cek`), closing the audit gap that no test exercised the KV
623    /// provider's actual key-unwrap (only CMK-path parsing was covered).
624    /// Assembles the canonical signed CEK envelope — RSA-OAEP(CEK) wrapped by
625    /// the KV key (supplied via env from `az keyvault key encrypt`), then
626    /// `sign_data` (KV RS256) over its SHA-256 digest — and asserts
627    /// `decrypt_cek` verifies the signature against the CMK and unwraps the CEK
628    /// back to plaintext. Exercises verify_signature (KV verify) + unwrap_key
629    /// (KV unwrap) end to end. Gated on env; skips when unset. Auth via the
630    /// `az` CLI session (`DeveloperToolsCredential`).
631    ///
632    /// Env: `AZURE_KEYVAULT_CMK_PATH` (https://<vault>/keys/<name>/<version>),
633    /// `AEKV_PLAIN_CEK_HEX`, `AEKV_WRAPPED_CEK_HEX`.
634    #[tokio::test]
635    #[ignore = "Requires a live Azure Key Vault + az session (see env vars)"]
636    async fn decrypt_cek_round_trips_through_live_key_vault() {
637        use sha2::Digest;
638
639        fn from_hex(s: &str) -> Vec<u8> {
640            (0..s.len())
641                .step_by(2)
642                .map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("valid hex"))
643                .collect()
644        }
645
646        let (cmk_path, cek, ciphertext) = match (
647            std::env::var("AZURE_KEYVAULT_CMK_PATH").ok(),
648            std::env::var("AEKV_PLAIN_CEK_HEX").ok(),
649            std::env::var("AEKV_WRAPPED_CEK_HEX").ok(),
650        ) {
651            (Some(p), Some(plain), Some(wrapped)) => (p, from_hex(&plain), from_hex(&wrapped)),
652            _ => return, // not configured; skip
653        };
654
655        let provider = AzureKeyVaultProvider::new().expect("provider");
656
657        // Assemble the canonical signed CEK envelope the way provisioning tools
658        // do: signed_portion(cmk_path, ciphertext) followed by an RS256
659        // signature over its SHA-256 digest.
660        let signed_portion = crate::cek_envelope::build_signed_portion(&cmk_path, &ciphertext);
661        let digest: [u8; 32] = sha2::Sha256::digest(&signed_portion).into();
662        let signature = provider
663            .sign_data(&cmk_path, &digest)
664            .await
665            .expect("Key Vault RS256 sign");
666        let mut envelope = signed_portion;
667        envelope.extend_from_slice(&signature);
668
669        let decrypted = provider
670            .decrypt_cek(&cmk_path, "RSA_OAEP", &envelope)
671            .await
672            .expect("decrypt_cek must verify + unwrap via Key Vault");
673
674        assert_eq!(
675            decrypted, cek,
676            "Key-Vault-unwrapped CEK must equal the original plaintext CEK"
677        );
678    }
679}