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
21 changes: 17 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,11 @@ jobs:
flags: "--no-default-features --features aws_lc_rs,redis"
- name: injected_verifier
# No built-in verifier at all: the build a consumer takes when it
# injects its own `hooks::TokenVerifier`. Links no JWT crypto (and
# so no `rsa`), which is only true as long as nothing outside the
# `builtin_jwt` gate reaches for jsonwebtoken — this leg is what
# keeps that honest. The binary builds without a verifier too.
# injects its own `hooks::TokenVerifier`. Links no JWT or TLS
# crypto (and so no `rsa`), which is only true as long as nothing
# outside the backend features reaches for jsonwebtoken or a
# rustls provider — this leg is what keeps that honest. The binary
# builds without a verifier too.
flags: "--no-default-features --features cli"
- name: all_features
# Both backends at once, the build cargo-semver-checks and docs.rs
Expand Down Expand Up @@ -88,6 +89,18 @@ jobs:
exit 1
fi

# A build without a crypto backend (`default-features = false`, what a
# transcoding-only consumer takes) must pass an advisory scan without
# the ignores in deny.toml: none of the crates they cover is linked.
- name: No ignored advisories without a crypto backend
if: matrix.backend.name == 'injected_verifier'
run: |
tree=$(cargo tree -e normal --target all --prefix none --format '{p}' ${{ matrix.backend.flags }})
if printf '%s\n' "$tree" | grep -E '^(rsa|rustls-rustcrypto|paste) v|^rustls-webpki v0\.102\.'; then
echo "::error::the build without a crypto backend links a crate with an ignored advisory"
exit 1
fi

# `cargo add structured-proxy` takes the default features: the library
# must compile none of the binary's dependencies, which sit behind `cli`.
- name: No CLI dependencies by default
Expand Down
46 changes: 26 additions & 20 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -93,20 +93,22 @@ redis = { version = "1.2", features = ["tokio-comp", "connection-manager"], opti
jsonwebtoken = { version = "11", default-features = false, features = [
"use_pem",
], optional = true }
# Outbound HTTPS (JWKS fetches, the rate-limit service) links no C: reqwest 0.13's
# `rustls` feature hardwires the aws-lc-rs provider (C FFI), and rustls' `ring`
# provider compiles C and assembly. `rustls-no-provider` builds rustls without a
# bundled provider; src/tls.rs hands reqwest a fully preconfigured ClientConfig
# with the pure-Rust RustCrypto provider and the Mozilla roots from webpki-roots.
# Bundling the roots keeps the binary self-contained, so it works on musl /
# scratch / distroless images with no system CA bundle.
# Outbound HTTPS (JWKS fetches, the rate-limit service). reqwest 0.13's `rustls`
# feature hardwires the aws-lc-rs provider (C FFI), so `rustls-no-provider`
# builds rustls without one; src/tls.rs hands reqwest a fully preconfigured
# ClientConfig with the Mozilla roots from webpki-roots. Bundling the roots
# keeps the binary self-contained, so it works on musl / scratch / distroless
# images with no system CA bundle. The crypto provider follows the crypto
# backend features below: none is linked without one, and the process-wide
# rustls provider the embedder installs is used instead.
reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "json"] }
rustls = { version = "0.23", default-features = false, features = ["std", "tls12"] }
# `0.0.2-alpha` still names its `rustls-webpki` 0.102 dependency, which carries
# four CRL / name-constraint advisories; the provider only reads algorithm
# identifier constants from it, while certificates are verified by rustls' own
# (patched) webpki. See deny.toml.
rustls-rustcrypto = "0.0.2-alpha"
# The pure-Rust provider of the `rust_crypto` backend. `0.0.2-alpha` still
# names its `rustls-webpki` 0.102 dependency, which carries four CRL /
# name-constraint advisories; the provider only reads algorithm identifier
# constants from it, while certificates are verified by rustls' own (patched)
# webpki. See deny.toml.
rustls-rustcrypto = { version = "0.0.2-alpha", optional = true }
webpki-roots = "1"
globset = "0.4"
base64 = "0.23"
Expand All @@ -130,20 +132,22 @@ default = ["rust_crypto"]
# `ProxyServer::with_token_verifier`.
builtin_jwt = ["dep:jsonwebtoken"]

# Crypto backend for the BUILT-IN verifier. Additive, like every Cargo feature:
# with both on, jsonwebtoken cannot infer a provider, so the built-in verifier
# installs `aws_lc_rs` (constant-time, advisory-free) for the process. They no
# longer decide for the whole dependency graph: a consumer that cannot accept
# this crate's choice supplies its own verifier instead (see `builtin_jwt`).
# Crypto backend for the BUILT-IN verifier and the outbound TLS client.
# Additive, like every Cargo feature: with both on, jsonwebtoken cannot infer a
# provider, so the built-in verifier installs `aws_lc_rs` (constant-time,
# advisory-free) for the process, and TLS uses aws-lc too. They no longer
# decide for the whole dependency graph: a consumer that cannot accept this
# crate's choice supplies its own verifier (see `builtin_jwt`) and installs its
# own rustls provider. With neither, the crate links no crypto backend at all.
#
# `rust_crypto` (default): pure-Rust RustCrypto backend. Pulls in `rsa`, which
# carries RUSTSEC-2023-0071 (Marvin Attack). Not exploitable on our verify-only
# path (public key, no private-key ops); see deny.toml / issue #48.
rust_crypto = ["builtin_jwt", "jsonwebtoken/rust_crypto"]
rust_crypto = ["builtin_jwt", "jsonwebtoken/rust_crypto", "dep:rustls-rustcrypto"]
# `aws_lc_rs`: constant-time / FIPS-capable backend via aws-lc (C FFI),
# advisory-free. Opt-in for consumers that allow FFI; enable with
# `default-features = false, features = ["aws_lc_rs"]`.
aws_lc_rs = ["builtin_jwt", "jsonwebtoken/aws_lc_rs"]
aws_lc_rs = ["builtin_jwt", "jsonwebtoken/aws_lc_rs", "rustls/aws_lc_rs"]

# Shared Redis-backed rate-limit store for multi-instance deployments.
redis = ["dep:redis"]
Expand All @@ -161,8 +165,10 @@ http-body-util = "0.1"
# tests/upstream_controls.rs (tonic's server API cannot set success trailers).
http-body = "1"
# The local HTTPS server the outbound TLS client is tested against
# (src/tls/tests.rs). Default features would pull in aws-lc.
# (src/tls/tests.rs), and the provider it runs on in every build. Default
# features would pull in aws-lc.
tokio-rustls = { version = "0.26", default-features = false, features = ["tls12"] }
rustls-rustcrypto = "0.0.2-alpha"
# benches/jwt_verify.rs. Plots and rayon are left out: numbers are enough.
criterion = { version = "0.8", default-features = false, features = ["async_tokio", "cargo_bench_support"] }
# The embedding-hooks integration test (tests/hooks.rs) writes hook impls using
Expand Down
41 changes: 26 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -791,27 +791,38 @@ async-trait = "0.1"
serde_json = "1"
```

which links no JWT crypto, and supplies the backend from its own binary. With no
which links no JWT or TLS crypto (see [Outbound TLS](#outbound-tls)), and
supplies the backend from its own binary. With no
verifier injected and no backend feature, an `auth.mode: "jwt"` config is
rejected at startup with that instruction, rather than silently accepting
tokens.

## Outbound TLS

The proxy's own HTTPS calls (JWKS fetches, the rate-limit service) use rustls
with the pure-Rust RustCrypto provider (`rustls-rustcrypto`) and Mozilla's root
store bundled from `webpki-roots`, so no system CA bundle is needed. Neither
`ring` nor aws-lc is linked: the default build and the
`default-features = false` build contain no C crypto, which CI checks. Only the
opt-in `aws_lc_rs` JWT backend brings aws-lc in.

The provider verifies RSA server signatures with `rsa`, so every build links
that crate, under the same RUSTSEC-2023-0071 note as the `rust_crypto` backend:
only public-key verification runs. The current provider release still names
`rustls-webpki` 0.102, whose CRL and name-constraint advisories are listed in
`deny.toml` with why they do not apply: the provider reads only algorithm
identifiers from it, and rustls verifies certificates with its own patched
`rustls-webpki`.
The proxy's own HTTP calls (JWKS fetches, the rate-limit service) use rustls
with Mozilla's root store bundled from `webpki-roots`, so no system CA bundle is
needed. The rustls crypto provider is, in order:

1. the one your process installed with
`rustls::crypto::CryptoProvider::install_default`, if any: an explicit
choice wins;
2. aws-lc, with the `aws_lc_rs` feature;
3. the pure-Rust RustCrypto provider (`rustls-rustcrypto`), with `rust_crypto`.

Neither `ring` nor aws-lc is linked unless you ask: the default build contains
no C crypto, which CI checks. A `default-features = false` build links no TLS
crypto provider at all, so a crate that only transcodes pulls in neither `rsa`
nor `rustls-rustcrypto`. If such a build configures a JWKS endpoint or the
rate-limit service, install a provider before building the proxy (the client
needs one even for an `http://` endpoint); otherwise startup fails with an error
that says so.

The RustCrypto provider verifies RSA server signatures with `rsa`, under the same
RUSTSEC-2023-0071 note as the `rust_crypto` JWT backend: only public-key
verification runs. Its current release still names `rustls-webpki` 0.102, whose
CRL and name-constraint advisories are listed in `deny.toml` with why they do not
apply: the provider reads only algorithm identifiers from it, and rustls
verifies certificates with its own patched `rustls-webpki`.

## How It Works

Expand Down
5 changes: 3 additions & 2 deletions deny.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@
# (decrypt/sign), which never runs on a verify path.
# Revisit when RustCrypto ships a constant-time `rsa` stable release.
#
# The TLS provider has no per-algorithm features, so `rsa` is in every build,
# including `default-features = false` with an injected `hooks::TokenVerifier`.
# Both chains come with the `rust_crypto` feature: a `default-features = false`
# build links neither, and CI checks that none of the crates ignored here is in
# it.
#
# RUSTSEC-2026-0049 / -0098 / -0099 / -0104: `rustls-webpki` 0.102.
# Chain: rustls-webpki 0.102.8 -> rustls-rustcrypto 0.0.2-alpha -> structured-proxy.
Expand Down
17 changes: 11 additions & 6 deletions src/auth/jwks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,23 +53,28 @@ const JWKS_HTTP_TIMEOUT: Duration = Duration::from_secs(5);
impl JwksCache {
/// Create a cache for `uri` (keys are loaded lazily on first lookup), whose
/// keys are refetched after [`DEFAULT_MAX_AGE`].
pub fn new(uri: String) -> Self {
///
/// # Errors
///
/// The HTTPS client cannot be built: the rustls crypto provider installed
/// for the process supports neither TLS 1.2 nor TLS 1.3.
pub fn new(uri: String) -> Result<Self, String> {
let client = reqwest::Client::builder()
.timeout(JWKS_HTTP_TIMEOUT)
// Hand reqwest a fully preconfigured rustls backend rather than
// relying on a process-global default provider: no install ordering
// relying on reqwest to find a provider: no install ordering
// constraint, no global side effect, safe for library/test callers.
.tls_backend_preconfigured(crate::tls::client_config())
.tls_backend_preconfigured(crate::tls::client_config()?)
.build()
.unwrap_or_default();
Self {
.map_err(|e| format!("invalid JWKS client: {e}"))?;
Ok(Self {
uri,
client,
set: RwLock::new(KeySet::default()),
last_refresh: Mutex::new(None),
max_age: DEFAULT_MAX_AGE,
min_refresh_interval: MIN_REFRESH_INTERVAL,
}
})
}

/// Refetch the keys once they are older than `max_age`. Refreshes stay
Expand Down
11 changes: 7 additions & 4 deletions src/auth/jwks/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ async fn endpoint() -> (Arc<Endpoint>, String) {
#[tokio::test]
async fn fresh_keys_are_served_from_the_cache() {
let (endpoint, uri) = endpoint().await;
let cache = JwksCache::new(uri);
let cache = JwksCache::new(uri).unwrap();
assert!(cache.key_for("k1").await.is_some());
assert!(cache.key_for("k1").await.is_some());
assert_eq!(endpoint.fetches(), 1);
Expand All @@ -123,6 +123,7 @@ async fn a_key_removed_by_the_provider_stops_verifying_once_the_set_ages_out() {
// aged set is refetched on the next lookup, and k1 is gone.
let (endpoint, uri) = endpoint().await;
let cache = JwksCache::new(uri)
.unwrap()
.with_max_age(Duration::ZERO)
.with_min_refresh_interval(Duration::ZERO);
assert!(cache.key_for("k1").await.is_some());
Expand All @@ -135,6 +136,7 @@ async fn a_key_removed_by_the_provider_stops_verifying_once_the_set_ages_out() {
async fn an_unreachable_provider_keeps_the_known_keys() {
let (endpoint, uri) = endpoint().await;
let cache = JwksCache::new(uri)
.unwrap()
.with_max_age(Duration::ZERO)
.with_min_refresh_interval(Duration::ZERO);
assert!(cache.key_for("k1").await.is_some());
Expand All @@ -151,7 +153,7 @@ async fn aged_keys_refresh_no_more_often_than_the_minimum_interval() {
// Aged out, but a refresh has just run: the known key is used without
// another fetch.
let (endpoint, uri) = endpoint().await;
let cache = JwksCache::new(uri).with_max_age(Duration::ZERO);
let cache = JwksCache::new(uri).unwrap().with_max_age(Duration::ZERO);
assert!(cache.key_for("k1").await.is_some());
assert!(cache.key_for("k1").await.is_some());
assert_eq!(endpoint.fetches(), 1);
Expand All @@ -163,7 +165,7 @@ async fn a_lookup_overtaken_by_a_refresh_uses_the_refreshed_keys() {
// slot; meanwhile another request's refresh replaces the set without k1.
// The waiting lookup is throttled, and must not hand back its old k1.
let (_endpoint, uri) = endpoint().await;
let cache = Arc::new(JwksCache::new(uri).with_max_age(Duration::ZERO));
let cache = Arc::new(JwksCache::new(uri).unwrap().with_max_age(Duration::ZERO));
assert!(cache.key_for("k1").await.is_some());

let mut slot = cache.last_refresh.lock().await;
Expand Down Expand Up @@ -192,7 +194,7 @@ async fn an_empty_key_set_is_throttled_like_any_other() {
// loaded: lookups for a kid it lacks must not refetch every time.
let (endpoint, uri) = endpoint().await;
endpoint.answer(StatusCode::OK, serde_json::json!({ "keys": [] }));
let cache = JwksCache::new(uri);
let cache = JwksCache::new(uri).unwrap();
for _ in 0..3 {
assert!(cache.key_for("k1").await.is_none());
}
Expand All @@ -208,6 +210,7 @@ async fn a_lookup_during_a_refresh_waits_for_its_keys() {
let interval = Duration::from_millis(50);
let cache = Arc::new(
JwksCache::new(uri)
.unwrap()
.with_max_age(Duration::ZERO)
.with_min_refresh_interval(interval),
);
Expand Down
2 changes: 1 addition & 1 deletion src/auth/verifier.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ impl ConfigVerifier {
MIN_REFRESH_INTERVAL.as_secs()
));
}
KeySource::Jwks(JwksCache::new(uri.clone()).with_max_age(max_age))
KeySource::Jwks(JwksCache::new(uri.clone())?.with_max_age(max_age))
} else if let Some(pem_path) = &jwt.public_key_pem_file {
let pem = std::fs::read(pem_path)
.map_err(|e| format!("failed to read auth.jwt.public_key_pem_file: {e}"))?;
Expand Down
10 changes: 10 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@
//! injection is how a consumer that needs a different one gets it without
//! deciding for everyone else who links this crate. Such a build takes
//! `default-features = false` and links no JWT crypto at all.
//!
//! ## Outbound TLS
//!
//! JWKS fetches and the rate-limit service go over rustls. The crypto provider
//! is the one the process installed with
//! `rustls::crypto::CryptoProvider::install_default`, else the one the crypto
//! backend feature brings (aws-lc for `aws_lc_rs`, RustCrypto for
//! `rust_crypto`). A build with neither feature links no TLS crypto, and a
//! config that needs an outbound client then fails at startup unless a
//! provider is installed first.

// `builtin_jwt` is implied by each backend and never meant to stand alone: on
// its own it would link jsonwebtoken with no provider, which panics at runtime.
Expand Down
Loading
Loading