Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 92 additions & 1 deletion crates/openshell-core/src/provider_credentials.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1047,6 +1047,15 @@ mod tests {
use super::*;
use crate::google_cloud;

fn jwt_alias(placeholder: &str) -> String {
// Claims are irrelevant to injection authority: retain the exact issued
// identity in the signature while giving clients a parseable JWT envelope.
let suffix = placeholder
.strip_prefix("openshell:resolve:env:")
.expect("issued placeholder");
format!("e30.e30.OPENSHELL-RESOLVE-ENV-{suffix}")
}

#[test]
fn body_classification_distinguishes_literal_foreign_bound_and_revoked() {
use crate::secrets::body::BodyCredentialError;
Expand Down Expand Up @@ -1721,6 +1730,14 @@ mod tests {
None,
"the expired access token must fail closed"
);
assert!(
state
.resolver_for_endpoint("api.example.com", 443, "/v1")
.expect("resolver")
.rewrite_header_value(&format!("Bearer {}", jwt_alias(&workload_placeholder)))
.is_err(),
"wrapping a placeholder must not bypass access-token expiry"
);

state
.install_bound_environment(
Expand All @@ -1741,6 +1758,12 @@ mod tests {
resolver.resolve_placeholder(&workload_placeholder),
Some("current")
);
assert_eq!(
resolver
.rewrite_header_value(&format!("Bearer {}", jwt_alias(&workload_placeholder)))
.expect("refresh updates the JWT alias"),
Some("Bearer current".to_string())
);
assert_eq!(
resolver.resolve_placeholder("openshell:resolve:env:v2_API_KEY"),
None,
Expand All @@ -1762,6 +1785,10 @@ mod tests {
)
.expect("initial stable binding");
let workload_placeholder = state.snapshot().child_env["API_KEY"].clone();
let request = format!(
"GET /v1 HTTP/1.1\r\nAuthorization: Bearer {}\r\n\r\n",
jwt_alias(&workload_placeholder)
);

for revision in 2..=13 {
let token = format!("token-{revision}");
Expand All @@ -1783,6 +1810,16 @@ mod tests {
.resolve_placeholder(&workload_placeholder),
Some(token.as_str())
);
let resolver = state
.resolver_for_endpoint("api.example.com", 443, "/v1")
.expect("resolver");
let rewritten =
crate::secrets::rewrite_http_header_block(request.as_bytes(), Some(&resolver))
.expect("the unchanged JWT alias uses the current token");
assert_eq!(
String::from_utf8(rewritten.rewritten).expect("HTTP header"),
format!("GET /v1 HTTP/1.1\r\nAuthorization: Bearer {token}\r\n\r\n")
);
}

let reconstructed = ProviderCredentialState::from_bound_environment(
Expand All @@ -1805,6 +1842,14 @@ mod tests {
.resolve_placeholder(&workload_placeholder),
Some("token-14")
);
assert_eq!(
reconstructed
.resolver_for_endpoint("api.example.com", 443, "/v1")
.expect("resolver")
.rewrite_header_value(&format!("Bearer {}", jwt_alias(&workload_placeholder)))
.expect("the JWT alias survives supervisor reconstruction"),
Some("Bearer token-14".to_string())
);
}

#[test]
Expand Down Expand Up @@ -1843,6 +1888,24 @@ mod tests {
.resolver_for_endpoint("new.example.com", 443, "/v2/messages")
.expect("current resolver");
assert_eq!(current.resolve_placeholder(&old_placeholder), None);
assert!(
current
.rewrite_header_value(&format!("Bearer {}", jwt_alias(&old_placeholder)))
.is_err(),
"a JWT alias must not carry a revoked identity into the new epoch"
);
for alias in [
"e30.e30.OPENSHELL-RESOLVE-ENV-API_KEY",
"e30.e30.OPENSHELL-RESOLVE-ENV-sshort_API_KEY",
"e30.e30.OPENSHELL-RESOLVE-ENV-v18446744073709551616_API_KEY",
] {
assert!(
current
.rewrite_header_value(&format!("Bearer {alias}"))
.is_err(),
"identityless and malformed JWT aliases must fail closed"
);
}
assert_eq!(
current.resolve_placeholder(&state.snapshot().child_env["API_KEY"]),
Some("new")
Expand All @@ -1854,6 +1917,13 @@ mod tests {
.rewrite_header_value(&state.snapshot().child_env["API_KEY"])
.expect_err("new handle must not resolve at the old endpoint");
assert!(error.is_endpoint_mismatch());
let error = wrong_endpoint
.rewrite_header_value(&format!(
"Bearer {}",
jwt_alias(&state.snapshot().child_env["API_KEY"])
))
.expect_err("JWT wrapping preserves endpoint authorization");
assert!(error.is_endpoint_mismatch());
}

#[test]
Expand All @@ -1870,6 +1940,11 @@ mod tests {
Vec::new(),
)
.expect("initial bindings");
let workload_placeholder = state.snapshot().child_env["API_KEY"].clone();
let request = format!(
"POST /v2/inference HTTP/1.1\r\nHost: api.example.com\r\nAuthorization: Bearer {}\r\nContent-Length: 2\r\n\r\n{{}}",
jwt_alias(&workload_placeholder)
);

for revision in [10, 100, 9, 101, 8, 102, 7, 103, 6] {
state
Expand All @@ -1888,13 +1963,20 @@ mod tests {
}

let resolver = state
.resolver_for_endpoint("api.example.com", 443, "/v1")
.resolver_for_endpoint("api.example.com", 443, "/v2/inference")
.expect("resolver");
assert_eq!(
resolver.resolve_placeholder("openshell:resolve:env:v50_API_KEY"),
Some("secret-6"),
"an aged-out placeholder may use the current secret across revisions in both numeric directions while its provider identity is unchanged"
);
let rewritten =
crate::secrets::rewrite_http_header_block(request.as_bytes(), Some(&resolver))
.expect("an aged-out JWT alias retains the issued provider identity");
assert_eq!(
String::from_utf8(rewritten.rewritten).expect("HTTP request"),
"POST /v2/inference HTTP/1.1\r\nHost: api.example.com\r\nAuthorization: Bearer secret-6\r\nContent-Length: 2\r\n\r\n{}"
);
}

#[test]
Expand Down Expand Up @@ -1930,6 +2012,15 @@ mod tests {
None,
"an opaque revision from another provider identity must fail closed even when it is numerically greater"
);
assert!(
resolver
.rewrite_header_value(&format!(
"Bearer {}",
jwt_alias(&format!("openshell:resolve:env:v{}_API_KEY", u64::MAX))
))
.is_err(),
"a JWT alias must not resolve a replacement provider's credential"
);
assert_eq!(
resolver.resolve_placeholder("openshell:resolve:env:API_KEY"),
None,
Expand Down
21 changes: 15 additions & 6 deletions crates/openshell-core/src/secrets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -431,14 +431,17 @@ impl SecretResolver {
/// Returns `None` if the placeholder is unknown or the resolved value
/// contains prohibited control characters (CRLF, null byte).
pub fn resolve_placeholder(&self, value: &str) -> Option<&str> {
// Normalize aliases before identity checks so a wrapped revision or stable
// handle retains the original endpoint, epoch, and expiration constraints.
let canonical_alias = alias_env_key(value).map(placeholder_for_env_key);
let value = canonical_alias.as_deref().unwrap_or(value);
if placeholder_env_key(value).is_some_and(|key| self.identity_bound_env_keys.contains(key))
&& revisioned_placeholder_parts(value).is_none()
&& stable_placeholder_parts(value).is_none()
{
// Canonical placeholders and provider-shaped aliases carry no
// credential identity. Endpoint-bound request input must use the
// revision-scoped placeholder issued to the workload so a stale
// process cannot resolve a replacement provider's credential.
// Unversioned placeholders carry no credential identity. Endpoint-bound
// request input must retain the issued revision or stable handle so a
// stale process cannot resolve a replacement provider's credential.
return None;
}
let secret = if let Some(secret) = self.by_placeholder.get(value) {
Expand All @@ -449,7 +452,7 @@ impl SecretResolver {
// credential refresh. For endpoint-bound credentials, permit that
// fallback only when the exact opaque revision belongs to the
// current provider-identity epoch.
let key = revisioned_placeholder_env_key(value).or_else(|| alias_env_key(value))?;
let key = revisioned_placeholder_env_key(value)?;
if let Some((revision, key)) = revisioned_placeholder_parts(value)
&& self.identity_bound_env_keys.contains(key)
&& self
Expand Down Expand Up @@ -782,7 +785,13 @@ fn placeholder_env_key(token: &str) -> Option<&str> {
.map(|(_, key)| key)
.or_else(|| revisioned_placeholder_env_key(token))
.or_else(|| token.strip_prefix(PLACEHOLDER_PREFIX))
.or_else(|| alias_env_key(token))
.or_else(|| {
alias_env_key(token).map(|suffix| {
split_stable_env_key(suffix)
.or_else(|| split_revisioned_env_key(suffix))
.map_or(suffix, |(_, key)| key)
})
})
}

pub fn uses_reserved_revision_namespace(key: &str) -> bool {
Expand Down
18 changes: 18 additions & 0 deletions docs/how-it-works/providers/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,24 @@ The proxy resolves credential placeholders in the following parts of an HTTP req
The proxy does not rewrite cookies, response content, unsupported request
bodies, or WebSocket binary frames.

### Clients that parse JWT claims

A trusted launcher can wrap an issued placeholder in a JWT-shaped alias when a
client needs local account metadata. Keep the original placeholder suffix in
the signature segment:

```text
base64url(header).base64url(claims).OPENSHELL-RESOLVE-ENV-<issued-placeholder-suffix>
```

For example, a placeholder beginning `openshell:resolve:env:s` becomes a JWT
whose signature begins `OPENSHELL-RESOLVE-ENV-s`, retaining the entire handle and
environment key. Use the placeholder supplied to the running workload; do not
substitute a bare environment key or manufacture a handle. OpenShell replaces
the entire alias with the resolved credential and preserves its endpoint,
authorization-epoch, and expiry checks. The claims supply local metadata only;
the alias is not a signed identity token or an upstream credential.

### Fail-closed behavior

If policy allows a request but the credential binding does not include the
Expand Down
Loading