Skip to content

Commit a06b272

Browse files
committed
fix(credentials): harden endpoint-bound rotation
Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>
1 parent 9c56260 commit a06b272

4 files changed

Lines changed: 422 additions & 5 deletions

File tree

crates/openshell-core/src/provider_credentials.rs

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@ struct ProviderCredentialStateInner {
3030
suppressed_keys: HashSet<String>,
3131
static_credential_bindings: HashMap<String, StaticCredentialBinding>,
3232
known_static_credential_keys: HashSet<String>,
33+
static_credential_identity_epochs: HashMap<String, StaticCredentialIdentityEpoch>,
34+
}
35+
36+
#[derive(Debug)]
37+
struct StaticCredentialIdentityEpoch {
38+
identity: String,
39+
first_revision: u64,
3340
}
3441

3542
#[derive(Debug, Clone)]
@@ -81,6 +88,7 @@ impl ProviderCredentialState {
8188
suppressed_keys: HashSet::new(),
8289
static_credential_bindings: HashMap::new(),
8390
known_static_credential_keys: HashSet::new(),
91+
static_credential_identity_epochs: HashMap::new(),
8492
})),
8593
}
8694
}
@@ -108,6 +116,11 @@ impl ProviderCredentialState {
108116
inner
109117
.known_static_credential_keys
110118
.extend(static_credential_bindings.keys().cloned());
119+
update_static_credential_identity_epochs(
120+
&mut inner.static_credential_identity_epochs,
121+
revision,
122+
&static_credential_bindings,
123+
);
111124
inner.static_credential_bindings = static_credential_bindings;
112125
}
113126
Ok(state)
@@ -137,6 +150,7 @@ impl ProviderCredentialState {
137150
suppressed_keys: HashSet::new(),
138151
static_credential_bindings: HashMap::new(),
139152
known_static_credential_keys: HashSet::new(),
153+
static_credential_identity_epochs: HashMap::new(),
140154
})),
141155
}
142156
}
@@ -171,6 +185,7 @@ impl ProviderCredentialState {
171185
inner.combined_resolver = None;
172186
inner.static_credential_bindings.clear();
173187
inner.known_static_credential_keys.clear();
188+
inner.static_credential_identity_epochs.clear();
174189
inner.current.child_env.len()
175190
}
176191

@@ -205,7 +220,7 @@ impl ProviderCredentialState {
205220
.read()
206221
.expect("provider credential state poisoned");
207222
let request_path = path.split_once('?').map_or(path, |(path, _)| path);
208-
let allowed = inner
223+
let allowed: HashSet<String> = inner
209224
.static_credential_bindings
210225
.iter()
211226
.filter(|(_, binding)| {
@@ -216,7 +231,23 @@ impl ProviderCredentialState {
216231
.map(|(key, _)| key.clone())
217232
.collect();
218233
inner.combined_resolver.as_ref().map(|resolver| {
219-
Arc::new(resolver.scoped_to_env_keys(&inner.known_static_credential_keys, &allowed))
234+
let revision_fallback_min_revisions = inner
235+
.static_credential_identity_epochs
236+
.iter()
237+
.filter(|(key, epoch)| {
238+
allowed.contains(*key)
239+
&& inner
240+
.static_credential_bindings
241+
.get(*key)
242+
.is_some_and(|binding| binding.credential_identity == epoch.identity)
243+
})
244+
.map(|(key, epoch)| (key.clone(), epoch.first_revision))
245+
.collect();
246+
Arc::new(resolver.scoped_to_env_keys(
247+
&inner.known_static_credential_keys,
248+
&allowed,
249+
revision_fallback_min_revisions,
250+
))
220251
})
221252
}
222253

@@ -442,6 +473,11 @@ impl ProviderCredentialState {
442473
inner
443474
.known_static_credential_keys
444475
.extend(static_credential_bindings.keys().cloned());
476+
update_static_credential_identity_epochs(
477+
&mut inner.static_credential_identity_epochs,
478+
revision,
479+
&static_credential_bindings,
480+
);
445481
inner.static_credential_bindings = static_credential_bindings;
446482
Ok(inner.current.child_env.len())
447483
}
@@ -474,6 +510,7 @@ impl ProviderCredentialState {
474510
inner.current_resolver = None;
475511
inner.combined_resolver = None;
476512
inner.static_credential_bindings.clear();
513+
inner.static_credential_identity_epochs.clear();
477514
}
478515
}
479516

@@ -545,6 +582,32 @@ fn static_credential_identities(
545582
.collect()
546583
}
547584

585+
fn update_static_credential_identity_epochs(
586+
epochs: &mut HashMap<String, StaticCredentialIdentityEpoch>,
587+
revision: u64,
588+
bindings: &HashMap<String, StaticCredentialBinding>,
589+
) {
590+
epochs.retain(|key, _| bindings.contains_key(key));
591+
for (key, binding) in bindings {
592+
match epochs.get_mut(key) {
593+
Some(epoch) if epoch.identity == binding.credential_identity => {}
594+
Some(epoch) => {
595+
epoch.identity.clone_from(&binding.credential_identity);
596+
epoch.first_revision = revision;
597+
}
598+
None => {
599+
epochs.insert(
600+
key.clone(),
601+
StaticCredentialIdentityEpoch {
602+
identity: binding.credential_identity.clone(),
603+
first_revision: revision,
604+
},
605+
);
606+
}
607+
}
608+
}
609+
}
610+
548611
fn binding_error(message: &str) -> StaticCredentialBindingError {
549612
StaticCredentialBindingError {
550613
message: message.to_string(),
@@ -722,6 +785,47 @@ mod tests {
722785
);
723786
}
724787

788+
#[test]
789+
fn aged_generation_falls_back_after_many_rotations_of_same_provider_credential() {
790+
let state = ProviderCredentialState::from_bound_environment(
791+
1,
792+
HashMap::from([("API_KEY".to_string(), "secret-1".to_string())]),
793+
HashMap::new(),
794+
HashMap::new(),
795+
HashMap::from([(
796+
"API_KEY".to_string(),
797+
binding("api.example.com", 443, "/**"),
798+
)]),
799+
Vec::new(),
800+
)
801+
.expect("initial bindings");
802+
803+
for revision in 2..=10 {
804+
state
805+
.install_bound_environment(
806+
revision,
807+
HashMap::from([("API_KEY".to_string(), format!("secret-{revision}"))]),
808+
HashMap::new(),
809+
HashMap::new(),
810+
HashMap::from([(
811+
"API_KEY".to_string(),
812+
binding("api.example.com", 443, "/**"),
813+
)]),
814+
Vec::new(),
815+
)
816+
.expect("rotated bindings");
817+
}
818+
819+
let resolver = state
820+
.resolver_for_endpoint("api.example.com", 443, "/v1")
821+
.expect("resolver");
822+
assert_eq!(
823+
resolver.resolve_placeholder("openshell:resolve:env:v1_API_KEY"),
824+
Some("secret-10"),
825+
"an aged-out placeholder may use the current secret while its provider identity is unchanged"
826+
);
827+
}
828+
725829
#[test]
726830
fn replacing_provider_with_reused_key_purges_retained_generation() {
727831
let state = ProviderCredentialState::from_bound_environment(

crates/openshell-core/src/secrets.rs

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ pub struct SecretResolver {
123123
by_placeholder: HashMap<String, SecretValue>,
124124
denied_env_keys: std::collections::HashSet<String>,
125125
no_revision_fallback_env_keys: std::collections::HashSet<String>,
126+
revision_fallback_min_revisions: HashMap<String, u64>,
126127
}
127128

128129
#[derive(Clone)]
@@ -245,6 +246,7 @@ impl SecretResolver {
245246
by_placeholder,
246247
denied_env_keys: std::collections::HashSet::new(),
247248
no_revision_fallback_env_keys: std::collections::HashSet::new(),
249+
revision_fallback_min_revisions: HashMap::new(),
248250
}),
249251
)
250252
}
@@ -254,11 +256,14 @@ impl SecretResolver {
254256
let mut by_placeholder = HashMap::new();
255257
let mut denied_env_keys = std::collections::HashSet::new();
256258
let mut no_revision_fallback_env_keys = std::collections::HashSet::new();
259+
let mut revision_fallback_min_revisions = HashMap::new();
257260
for resolver in resolvers {
258261
by_placeholder.extend(resolver.by_placeholder.clone());
259262
denied_env_keys.extend(resolver.denied_env_keys.iter().cloned());
260263
no_revision_fallback_env_keys
261264
.extend(resolver.no_revision_fallback_env_keys.iter().cloned());
265+
revision_fallback_min_revisions
266+
.extend(resolver.revision_fallback_min_revisions.clone());
262267
}
263268
if by_placeholder.is_empty() {
264269
None
@@ -267,6 +272,7 @@ impl SecretResolver {
267272
by_placeholder,
268273
denied_env_keys,
269274
no_revision_fallback_env_keys,
275+
revision_fallback_min_revisions,
270276
})
271277
}
272278
}
@@ -281,6 +287,7 @@ impl SecretResolver {
281287
&self,
282288
bound_keys: &std::collections::HashSet<String>,
283289
allowed_bound_keys: &std::collections::HashSet<String>,
290+
revision_fallback_min_revisions: HashMap<String, u64>,
284291
) -> Self {
285292
let denied_env_keys = bound_keys
286293
.difference(allowed_bound_keys)
@@ -298,6 +305,7 @@ impl SecretResolver {
298305
by_placeholder,
299306
denied_env_keys,
300307
no_revision_fallback_env_keys: bound_keys.clone(),
308+
revision_fallback_min_revisions,
301309
}
302310
}
303311

@@ -331,8 +339,12 @@ impl SecretResolver {
331339
// to one provider identity, so falling back by key after replacement
332340
// would let the old process obtain the replacement provider's secret.
333341
let key = revisioned_placeholder_env_key(value).or_else(|| alias_env_key(value))?;
334-
if revisioned_placeholder_env_key(value).is_some()
342+
if let Some((revision, key)) = revisioned_placeholder_parts(value)
335343
&& self.no_revision_fallback_env_keys.contains(key)
344+
&& self
345+
.revision_fallback_min_revisions
346+
.get(key)
347+
.is_none_or(|minimum| revision < *minimum)
336348
{
337349
return None;
338350
}
@@ -615,9 +627,13 @@ fn alias_env_key(token: &str) -> Option<&str> {
615627
}
616628

617629
fn revisioned_placeholder_env_key(token: &str) -> Option<&str> {
630+
revisioned_placeholder_parts(token).map(|(_, key)| key)
631+
}
632+
633+
fn revisioned_placeholder_parts(token: &str) -> Option<(u64, &str)> {
618634
let suffix = token.strip_prefix(PLACEHOLDER_PREFIX)?;
619-
let (_, key) = split_revisioned_env_key(suffix)?;
620-
Some(key)
635+
let (revision, key) = split_revisioned_env_key(suffix)?;
636+
Some((revision.parse().ok()?, key))
621637
}
622638

623639
fn placeholder_env_key(token: &str) -> Option<&str> {

0 commit comments

Comments
 (0)