3737//! # Secret Naming
3838//!
3939//! Azure Key Vault secret names may only contain ASCII letters, digits and
40- //! hyphens (`^[0-9a-zA-Z-]+$`, 1-127 characters) -- notably, unlike every other
41- //! cloud provider in this codebase, *not* underscores or slashes. Convention
42- //! secrets are named `secretspec--{project}--{profile}--{key}`, with each
43- //! component's underscores rewritten to hyphens to fit that charset. This is
44- //! lossy: `FOO_BAR` and `FOO-BAR` map to the same Key Vault secret name. Prefer
45- //! hyphens over underscores in project/profile/key names if that matters to
46- //! you. A component containing a literal `--` or a `__` (which sanitizes to
47- //! `--`) is rejected outright, since it would be indistinguishable from the
48- //! delimiter joining project/profile/key and could silently collide with a
49- //! different secret.
40+ //! hyphens (`^[0-9a-zA-Z-]+$`, 1-127 characters), and Key Vault compares object
41+ //! identifiers case-insensitively. Convention secrets are therefore named
42+ //! `secretspec--{base32(project)}--{base32(profile)}--{base32(key)}`. Encoding
43+ //! each component as lowercase, unpadded Base32 preserves case and punctuation
44+ //! distinctions while producing only Azure-compatible characters. It also
45+ //! keeps the `--` separators unambiguous because encoded components contain no
46+ //! hyphens.
5047//!
5148//! Native `ref` addresses (naming a secret that already exists in the vault)
5249//! are validated against this same charset but never rewritten: silently
@@ -73,6 +70,7 @@ use azure_identity::{
7370 WorkloadIdentityCredential ,
7471} ;
7572use azure_security_keyvault_secrets:: { SecretClient , models:: SetSecretParameters } ;
73+ use data_encoding:: BASE32_NOPAD ;
7674use secrecy:: { ExposeSecret , SecretString } ;
7775use serde:: { Deserialize , Serialize } ;
7876use std:: sync:: Arc ;
@@ -222,10 +220,7 @@ impl AkvProvider {
222220 Self { config }
223221 }
224222
225- /// Validates a secret name component against the characters Azure Key
226- /// Vault allows once mapped (alphanumeric, underscore, hyphen -- the
227- /// underscore is rewritten to a hyphen by the caller, everything else is
228- /// rejected up front rather than silently dropped).
223+ /// Validates a convention-name component before encoding it.
229224 fn validate_name_component ( name : & str , component : & str ) -> Result < ( ) > {
230225 if component. is_empty ( ) {
231226 return Err ( SecretSpecError :: ProviderOperationFailed ( format ! (
@@ -245,44 +240,33 @@ impl AkvProvider {
245240 Ok ( ( ) )
246241 }
247242
248- /// Sanitizes a single name component (`_` -> `-`) and rejects the result
249- /// if it contains `--`, which is indistinguishable from the delimiter
250- /// used to join `project`/`profile`/`key` in [`format_secret_name`].
251- /// Without this check, a literal `--` or a `__` (which sanitizes to `--`)
252- /// in one component can produce the exact same secret name as different
253- /// project/profile/key values -- e.g. `("a", "b__c", "d")` and
254- /// `("a", "b", "c__d")` would otherwise both map to
255- /// `secretspec--a--b--c--d`.
256- fn sanitize_name_component ( name : & str , component : & str ) -> Result < String > {
257- let sanitized = component. replace ( '_' , "-" ) ;
258- if sanitized. contains ( "--" ) {
259- return Err ( SecretSpecError :: ProviderOperationFailed ( format ! (
260- "{name} '{component}' contains a double underscore or double hyphen, which \
261- sanitizes to the same '--' delimiter used between project/profile/key and \
262- can collide with a different secret. Use single underscores or hyphens instead."
263- ) ) ) ;
264- }
265- Ok ( sanitized)
243+ /// Encodes a component into a lowercase, Azure-compatible and injective
244+ /// representation. Lowercasing the Base32 output is safe because Base32's
245+ /// alphabet is case-insensitive, while the encoded bytes preserve the
246+ /// original component's case. The output contains no hyphens, so it cannot
247+ /// consume or shift the `--` delimiter between components.
248+ fn encode_name_component ( component : & str ) -> String {
249+ BASE32_NOPAD
250+ . encode ( component. as_bytes ( ) )
251+ . to_ascii_lowercase ( )
266252 }
267253
268254 /// Formats and validates the secret name for Azure Key Vault.
269255 ///
270256 /// Converts the SecretSpec path format to an Azure-compatible name:
271- /// `secretspec--{project}--{profile}--{key}`, with underscores in each
272- /// component rewritten to hyphens (Azure Key Vault secret names may only
273- /// contain `[0-9a-zA-Z-]`, 1-127 characters). Rejects components that
274- /// would collide with a different project/profile/key combination once
275- /// sanitized (see [`sanitize_name_component`](Self::sanitize_name_component)).
257+ /// `secretspec--{base32(project)}--{base32(profile)}--{base32(key)}`.
258+ /// Lowercase, unpadded Base32 avoids both Key Vault's case-insensitive
259+ /// identifier comparisons and ambiguity with the `--` component delimiter.
276260 fn format_secret_name ( project : & str , profile : & str , key : & str ) -> Result < String > {
277261 Self :: validate_name_component ( "project" , project) ?;
278262 Self :: validate_name_component ( "profile" , profile) ?;
279263 Self :: validate_name_component ( "key" , key) ?;
280264
281265 let secret_name = format ! (
282266 "secretspec--{}--{}--{}" ,
283- Self :: sanitize_name_component ( " project" , project ) ? ,
284- Self :: sanitize_name_component ( " profile" , profile ) ? ,
285- Self :: sanitize_name_component ( " key" , key ) ?
267+ Self :: encode_name_component ( project) ,
268+ Self :: encode_name_component ( profile) ,
269+ Self :: encode_name_component ( key)
286270 ) ;
287271
288272 if secret_name. len ( ) > 127 {
@@ -366,14 +350,12 @@ impl AkvProvider {
366350 e
367351 ) )
368352 } ) ? as Arc < dyn TokenCredential > ) ,
369- AuthMethod :: ManagedIdentity => {
370- Ok ( ManagedIdentityCredential :: new ( None ) . map_err ( |e| {
371- SecretSpecError :: ProviderOperationFailed ( format ! (
372- "Failed to create managed identity credential: {}" ,
373- e
374- ) )
375- } ) ? as Arc < dyn TokenCredential > )
376- }
353+ AuthMethod :: ManagedIdentity => Ok ( ManagedIdentityCredential :: new ( None ) . map_err ( |e| {
354+ SecretSpecError :: ProviderOperationFailed ( format ! (
355+ "Failed to create managed identity credential: {}" ,
356+ e
357+ ) )
358+ } ) ? as Arc < dyn TokenCredential > ) ,
377359 AuthMethod :: WorkloadIdentity => {
378360 Ok ( WorkloadIdentityCredential :: new ( None ) . map_err ( |e| {
379361 SecretSpecError :: ProviderOperationFailed ( format ! (
@@ -391,21 +373,20 @@ impl AkvProvider {
391373 let client_secret = Self :: non_empty_env ( "AZURE_CLIENT_SECRET" ) ;
392374
393375 match Self :: classify_env_credentials ( tenant_id, client_id, client_secret) ? {
394- Some ( ( tenant_id, client_id, client_secret) ) => {
395- Ok ( ClientSecretCredential :: new (
396- & tenant_id,
397- client_id,
398- Secret :: new ( client_secret) ,
399- None ,
400- )
401- . map_err ( |e| {
402- SecretSpecError :: ProviderOperationFailed ( format ! (
403- "Failed to create service principal credential from \
376+ Some ( ( tenant_id, client_id, client_secret) ) => Ok ( ClientSecretCredential :: new (
377+ & tenant_id,
378+ client_id,
379+ Secret :: new ( client_secret) ,
380+ None ,
381+ )
382+ . map_err ( |e| {
383+ SecretSpecError :: ProviderOperationFailed ( format ! (
384+ "Failed to create service principal credential from \
404385 AZURE_TENANT_ID/AZURE_CLIENT_ID/AZURE_CLIENT_SECRET: {}",
405- e
406- ) )
407- } ) ? as Arc < dyn TokenCredential > )
408- }
386+ e
387+ ) )
388+ } ) ?
389+ as Arc < dyn TokenCredential > ) ,
409390 None => {
410391 // No service principal env vars: fall back to the signed-in
411392 // Azure CLI / azd session so local development works after
@@ -503,8 +484,8 @@ impl AkvProvider {
503484}
504485
505486impl Provider for AkvProvider {
506- /// Convention secrets are named `secretspec--{project}--{profile}--{key}`
507- /// ( Azure Key Vault secret names cannot contain underscores or slashes) .
487+ /// Convention names use lowercase Base32 components so they remain
488+ /// injective despite Azure Key Vault's restricted, case-insensitive names .
508489 fn convention_address (
509490 & self ,
510491 project : & str ,
@@ -573,7 +554,7 @@ mod tests {
573554 #[ test]
574555 fn test_format_secret_name ( ) {
575556 let name = AkvProvider :: format_secret_name ( "myapp" , "prod" , "DB_URL" ) . unwrap ( ) ;
576- assert_eq ! ( name, "secretspec--myapp--prod--DB-URL " ) ;
557+ assert_eq ! ( name, "secretspec--nv4wc4dq--obzg6za--irbf6vksjq " ) ;
577558 }
578559
579560 #[ test]
@@ -590,16 +571,26 @@ mod tests {
590571 }
591572
592573 #[ test]
593- fn test_format_secret_name_rejects_double_underscore_collision ( ) {
594- // Without rejection, "b__c" and "b" + "c__d" would both sanitize to
595- // the same "secretspec--a--b--c--d" name.
596- assert ! ( AkvProvider :: format_secret_name( "a" , "b__c" , "d" ) . is_err( ) ) ;
597- assert ! ( AkvProvider :: format_secret_name( "a" , "b" , "c__d" ) . is_err( ) ) ;
574+ fn test_format_secret_name_preserves_case_distinctions ( ) {
575+ let upper = AkvProvider :: format_secret_name ( "app" , "prod" , "API_KEY" ) . unwrap ( ) ;
576+ let lower = AkvProvider :: format_secret_name ( "app" , "prod" , "api_key" ) . unwrap ( ) ;
577+ assert_ne ! ( upper, lower) ;
578+ assert_eq ! ( upper, upper. to_ascii_lowercase( ) ) ;
579+ assert_eq ! ( lower, lower. to_ascii_lowercase( ) ) ;
580+ }
581+
582+ #[ test]
583+ fn test_format_secret_name_prevents_boundary_delimiter_collision ( ) {
584+ let trailing = AkvProvider :: format_secret_name ( "a" , "b-" , "C" ) . unwrap ( ) ;
585+ let leading = AkvProvider :: format_secret_name ( "a" , "b" , "_C" ) . unwrap ( ) ;
586+ assert_ne ! ( trailing, leading) ;
598587 }
599588
600589 #[ test]
601- fn test_format_secret_name_rejects_double_hyphen_collision ( ) {
602- assert ! ( AkvProvider :: format_secret_name( "a--b" , "c" , "d" ) . is_err( ) ) ;
590+ fn test_format_secret_name_encodes_internal_delimiters ( ) {
591+ let left = AkvProvider :: format_secret_name ( "a" , "b__c" , "d" ) . unwrap ( ) ;
592+ let right = AkvProvider :: format_secret_name ( "a" , "b" , "c__d" ) . unwrap ( ) ;
593+ assert_ne ! ( left, right) ;
603594 }
604595
605596 #[ test]
@@ -714,17 +705,19 @@ mod tests {
714705
715706 #[ test]
716707 fn test_unknown_auth_method_errors ( ) {
717- assert ! ( AkvConfig :: try_from( & ProviderUrl :: new(
718- Url :: parse( "akv://myvault?auth=bogus" ) . unwrap( )
719- ) )
720- . is_err( ) ) ;
708+ assert ! (
709+ AkvConfig :: try_from( & ProviderUrl :: new(
710+ Url :: parse( "akv://myvault?auth=bogus" ) . unwrap( )
711+ ) )
712+ . is_err( )
713+ ) ;
721714 }
722715
723716 #[ test]
724717 fn test_convention_address ( ) {
725718 let p = AkvProvider :: new ( config ( "akv://myvault" ) ) ;
726719 let coords = p. convention_address ( "proj" , "default" , "A" ) . unwrap ( ) ;
727- assert_eq ! ( coords. item, "secretspec--proj--default--A " ) ;
720+ assert_eq ! ( coords. item, "secretspec--obzg62q--mrswmylvnr2a--ie " ) ;
728721 assert_eq ! ( coords. field, None ) ;
729722 }
730723
@@ -774,7 +767,8 @@ mod tests {
774767 } ;
775768 let err = p. get ( Address :: Native ( & addr) ) . unwrap_err ( ) ;
776769 assert ! (
777- err. to_string( ) . contains( "not a valid Azure Key Vault secret name" ) ,
770+ err. to_string( )
771+ . contains( "not a valid Azure Key Vault secret name" ) ,
778772 "{err}"
779773 ) ;
780774 }
0 commit comments