Skip to content

Commit 83235f0

Browse files
committed
Fix Azure Key Vault name collisions
1 parent cb71db9 commit 83235f0

5 files changed

Lines changed: 90 additions & 99 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
AKS workload identity are also available via `?auth=managed_identity` and
1515
`?auth=workload_identity`. Sovereign clouds can be addressed with a full
1616
DNS hostname or an explicit `?suffix=` override. Project/profile/key
17-
combinations that would collide once mapped to Azure's secret-name
18-
charset are rejected rather than silently colliding with another secret.
17+
components use lowercase, unpadded Base32 so case and punctuation remain
18+
distinct within Azure's restricted, case-insensitive secret-name namespace.
1919
- The `awssm` provider accepts `kms_key_id` and `tag.NAME=VALUE` query
2020
parameters (e.g. `awssm://prod@us-east-1?kms_key_id=alias/my-key&tag.team=platform`).
2121
Both are applied only when secretspec creates a secret, so accounts that enforce

Cargo.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/src/content/docs/providers/akv.md

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,9 @@ $ secretspec check --provider akv://myvault?suffix=vault.azure.cn
4848

4949
## Secret References
5050

51-
By default each secret is stored as `secretspec--{project}--{profile}--{key}`. A
52-
secret's [`ref`](/reference/configuration/#secret-references) field names an
51+
By default each secret is stored as
52+
`secretspec--{base32(project)}--{base32(profile)}--{base32(key)}`. A secret's
53+
[`ref`](/reference/configuration/#secret-references) field names an
5354
existing secret instead: `item` is the secret name (`field` and `version` are
5455
not yet supported). References are **read-only** in this provider, and `item`
5556
must already be a valid Azure Key Vault secret name (letters, digits, and
@@ -80,20 +81,16 @@ postgresql://localhost/mydb
8081
### Secret Naming
8182

8283
Azure Key Vault secret names may only contain ASCII letters, digits and
83-
hyphens — notably, unlike every other cloud provider, not underscores or
84-
slashes. Secrets are stored as: `secretspec--{project}--{profile}--{key}`,
85-
with underscores in each component rewritten to hyphens.
86-
87-
Example: `DATABASE_URL` in project `myapp`, profile `production` becomes
88-
`secretspec--myapp--production--DATABASE-URL`.
89-
90-
This rewrite is lossy: a project, profile, or key that differs only in using
91-
hyphens versus underscores (e.g. `FOO_BAR` and `FOO-BAR`) maps to the same Key
92-
Vault secret name. Prefer hyphens if that distinction matters to you. A
93-
project, profile, or key containing a literal `--` or a `__` (which sanitizes
94-
to `--`) is rejected outright — it would be indistinguishable from the `--`
95-
delimiter between components and could otherwise silently collide with an
96-
unrelated secret.
84+
hyphens, and Azure compares object identifiers case-insensitively. SecretSpec
85+
stores convention names as
86+
`secretspec--{base32(project)}--{base32(profile)}--{base32(key)}`, using
87+
lowercase, unpadded Base32 for each component.
88+
89+
This encoding is deterministic and injective: names that differ by case,
90+
underscores versus hyphens, or leading/trailing hyphens remain distinct even
91+
though Key Vault's identifiers do not preserve all of those distinctions. The
92+
encoded components contain no hyphens, so the `--` component separators cannot
93+
be confused with component data.
9794

9895
### CI/CD with a Service Principal
9996

docs/src/content/docs/reference/providers.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ akv://myvault?suffix=vault.azure.cn # Sovereign cloud (explicit suffix, bar
172172

173173
**Features**: Read/write, cloud sync, profiles, service principal/managed identity/workload identity auth
174174
**Prerequisites**: An Azure Key Vault instance, authenticated via one of the methods above, build with `--features akv`
175-
**Storage**: Secret name `secretspec--{project}--{profile}--{key}` (underscores rewritten to hyphens; Azure Key Vault secret names allow only letters, digits, and hyphens)
175+
**Storage**: Secret name `secretspec--{base32(project)}--{base32(profile)}--{base32(key)}` (lowercase, unpadded Base32 preserves case and punctuation distinctions within Azure's case-insensitive secret-name namespace)
176176

177177
## Provider Selection
178178

secretspec/src/provider/akv.rs

Lines changed: 72 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -37,16 +37,13 @@
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
};
7572
use azure_security_keyvault_secrets::{SecretClient, models::SetSecretParameters};
73+
use data_encoding::BASE32_NOPAD;
7674
use secrecy::{ExposeSecret, SecretString};
7775
use serde::{Deserialize, Serialize};
7876
use 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

505486
impl 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

Comments
 (0)