diff --git a/Cargo.lock b/Cargo.lock index 17d5ee6..ee8f801 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -142,6 +142,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" dependencies = [ "aws-lc-sys", + "untrusted 0.7.1", "zeroize", ] @@ -1013,6 +1014,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-cache-semantics" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0603b99309a1e404c2c0945650c0f775b50bb72ac90ae570cb93f9ff05d9efe7" +dependencies = [ + "http", + "reqwest", + "time", +] + [[package]] name = "http-range-header" version = "0.4.2" @@ -1391,6 +1403,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "10.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +dependencies = [ + "aws-lc-rs", + "base64", + "getrandom 0.2.17", + "js-sys", + "pem", + "serde", + "serde_json", + "signature", + "simple_asn1", + "zeroize", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1653,6 +1683,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -1854,6 +1894,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -2278,7 +2328,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -2417,7 +2467,7 @@ dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -2713,6 +2763,18 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + [[package]] name = "slab" version = "0.4.12" @@ -3575,6 +3637,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -3704,15 +3772,19 @@ dependencies = [ "axum", "clap", "futures-util", + "http-cache-semantics", "humantime-serde", + "jsonwebtoken", "macaddr", "nix 0.31.2", "reqwest", "serde", "serde_json", "sqlx", + "thiserror 2.0.18", "tokio", "toml", + "tower", "tower-http", "tracing", "uuid", @@ -4517,6 +4589,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zerotrie" diff --git a/deploy/control-plane.Caddyfile b/deploy/control-plane.Caddyfile index e4ff78f..01ca1da 100644 --- a/deploy/control-plane.Caddyfile +++ b/deploy/control-plane.Caddyfile @@ -2,9 +2,11 @@ # # Security model: # - Public endpoints for agents: health, enrollment, control WS, and scoped terminal relay WS -# - Private admin surface: /ui/* and /api/v1/control/* (requires CF Access headers) +# - Private admin surface: root, /ui/* and /api/v1/control/* (verified Access JWT) +# Configure CC's [access] section and loopback bind before enabling this template. +# Never expose /internal/auth/verify through this virtual host. # -# Replace cp.example.com with your public control-plane domain. +# Replace wakey.ldlda.com and the loopback upstream port for your deployment. wakey.ldlda.com { encode zstd gzip @@ -15,15 +17,14 @@ wakey.ldlda.com { reverse_proxy 127.0.0.1:6767 } - # Admin surface requires Cloudflare Access headers. - @admin path /ui* /api/v1/control/* - @cf_access header_regexp CFJWT Cf-Access-Jwt-Assertion .+ + # Applies to HTTP requests and operator WebSocket upgrades, not WS frames. + @admin path / /ui* /api/v1/control/* handle @admin { - handle @cf_access { - reverse_proxy 127.0.0.1:6767 + forward_auth 127.0.0.1:6767 { + uri /internal/auth/verify } - respond "forbidden" 403 + reverse_proxy 127.0.0.1:6767 } # Deny unknown paths by default. diff --git a/docs/origin-auth.md b/docs/origin-auth.md new file mode 100644 index 0000000..dae669d --- /dev/null +++ b/docs/origin-auth.md @@ -0,0 +1,118 @@ +# Origin Authentication With Caddy + +Cloudflare Access protects the operator surface at the edge. An origin reachable +directly must also verify Access tokens: a nonempty assertion header is not +authentication. A read-only probe through `mails.ldlda.com`, preserving Wakey's +TLS hostname and HTTP host, returned 403 without a header and 200 with a forged +header on the same origin IP. Do not retain the old header-presence matcher. + +## Configure CC + +Add this to the deployed CC configuration, using your protected application's +settings, not the bypass application's audience: + +```toml +bind = "127.0.0.1:23501" + +[access] +issuer = "https://ldlda.cloudflareaccess.com" +audience = "0c8ca0bce40a48ddc5bc065cea8390eaddd75a84458f147bbe249fcc57c3d781" +# Optional; defaults to the issuer's /cdn-cgi/access/certs endpoint. +# jwks_url = "https://ldlda.cloudflareaccess.com/cdn-cgi/access/certs" +``` + +Issuer and key URLs must use HTTPS without user credentials, query or fragment. +The issuer is an origin without a trailing slash; it must exactly match the JWT +issuer. The audience is not a secret. URLs come only from trusted configuration, +never from an incoming token. `init-config --from-config` preserves `[access]`. + +CC requires a loopback bind when `[access]` is configured. With no `[access]`, +the verifier returns 401; existing local development routes remain unchanged. +Changes to Access configuration require a daemon restart, not a state reload. + +## Configure Caddy + +Use [the Caddy template](../deploy/control-plane.Caddyfile), replacing the domain +and every upstream port with your deployment values. Its protected handler calls +CC's `GET /internal/auth/verify` through `forward_auth`, then proxies the original +request only on success. No additional service or custom Caddy module is needed. + +Protect `/`, `/ui*` and `/api/v1/control/*`. This includes operator WebSocket +handshakes; established WebSocket frames are not individually authenticated and +token expiry does not automatically disconnect an existing WebSocket. + +Keep `/healthz`, `/api/v1/agents/enroll`, `/api/v1/agent/ws` and +`/api/v1/agent/terminals/*` outside Access verification. They retain Wakey's own +agent/scoped-relay authentication. Cloudflare's bypass application should cover +these paths; terminal child paths must be included. If using HTTP ACME challenges, +ensure `/.well-known/acme-challenge/*` is exempt at Cloudflare; Caddy handles its +own active challenges, not CC. + +Do not proxy `/internal/auth/verify` publicly and do not put `forward_auth` in +front of the verifier itself. Do not replace verification with a static header +that Caddy adds to every request. Caddy owns enforcement: direct access to the +CC port would bypass it, so loopback binding and host access controls matter. + +## Verification And Cache + +The verifier accepts only RS256, verifies signatures, and requires matching +issuer/audience plus expiry. A future `nbf` also rejects the token. No clock-skew +leeway is applied; keep the host clock synchronized. Duplicate assertion headers +and assertions over 16 KiB are rejected. No tokens, claims or upstream bodies +are logged. + +Responses have no identity payload: + +| Status | Meaning | +| --- | --- | +| 204 | Valid protected-application token | +| 401 | Missing/invalid token, unknown key after successful key retrieval, or unconfigured verifier | +| 503 | Required signing keys cannot be obtained safely | + +CC reuses one HTTP client and stores parsed keys with their HTTP cache policy. +`http-cache-semantics` determines freshness from the issuer's `Cache-Control`, +`Age`, `Date` and `Expires`, rather than a built-in key TTL. For example, +`public, max-age=14400, must-revalidate, no-transform` permits four hours of +freshness minus the response's existing age; `must-revalidate` forbids stale +reuse, not every fresh reuse. No heuristic lifetime is invented when freshness +is unspecified. A `no-cache` response requires revalidation before reuse; +`no-store` retains neither keys nor policy after serving the fetching request. + +Fresh matching keys verify locally. Stale policies produce conditional requests +using ETag or Last-Modified when available. A matching 304 renews the policy and +reuses parsed keys without downloading/parsing a body; mismatched or unsolicited +304 responses fail closed. An unknown key ID forces conditional revalidation +even when the stored response remains fresh, to discover rotation. + +Fetches are single-flight and limited to once per minute, including failures. +Rotation immediately after a fetch may therefore take up to a minute to become +available. If the issuer requires revalidation on every use (or allows no +storage), subsequent callers inside that cooldown receive 503 rather than reuse +keys contrary to the HTTP policy. Successful 200 refresh replaces the entire +set, including removing all keys for a valid empty set. Failed refresh does not +extend freshness: matching keys may be used only while the stored policy still +permits reuse. There is no indefinitely stale fallback, including on outages. + +Fetches have a five-second timeout, no redirects, a 256 KiB body limit and at +most 64 keys. These limits are internal safeguards, not new config knobs. + +## Rollout + +1. Install the new CC binary, add `[access]`, and restart CC. Check the loopback + verifier returns 401 for an absent or forged assertion. +2. Update Caddy from the template and validate its configuration before reload. + The repo template uses port 6767; this deployment uses 23501. +3. Confirm a real Access login can use the UI/control APIs and operator terminal + WebSocket. Confirm agent connections and enrollment still work. +4. Repeat a direct-origin request with Wakey's Host/SNI. Both absent and forged + assertions must return 401, not the UI. Verify the internal endpoint itself + returns 404 through the public virtual host. + +Do not roll back to header-only acceptance on a verification failure. A UI +build or a CC restart alone does not update the deployed Caddy policy. + +References: [Cloudflare token validation](https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/validating-json/) +and [Caddy forward_auth](https://caddyserver.com/docs/caddyfile/directives/forward_auth). +HTTP policy is provided by [http-cache-semantics](https://docs.rs/http-cache-semantics/latest/http_cache_semantics/struct.CachePolicy.html); +only the single-resource storage, bounded fetch and anti-hammering policy are +application-owned. No general-purpose disk or Moka response cache is needed. diff --git a/docs/roadmap.md b/docs/roadmap.md index b60454f..ccd6b52 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -89,10 +89,11 @@ firmware actually exposes before making any of them an agent requirement. ## Origin Authentication -- [ ] Replace Caddy's nonempty `Cf-Access-Jwt-Assertion` matcher: header presence does not authenticate a request that reaches the origin directly. The `lb.ldlda.com` probe reached Cloudflare and redirected to Access login, so a direct-origin bypass was not reproduced; the matcher remains insufficient. -- [ ] Add a CC-owned, loopback-accessible `/internal/auth/verify` endpoint for Caddy `forward_auth`. Validate the Access JWT signature, permitted algorithm, exact team issuer, protected Wakey application AUD and expiry using a standard JWT library. Cache signing keys with bounded fetches/timeouts and rate-limited refreshes; arbitrary token key IDs must not trigger unlimited upstream requests. Return 204 for valid credentials, 401 for missing/invalid credentials, and 503 when verification cannot be completed safely. Never log tokens. -- [ ] Apply `forward_auth` to `/`, `/ui*` and `/api/v1/control/*`, including operator WebSocket handshakes. Keep health, agent enrollment/WS/terminal relay and ACME challenge paths outside Access verification; agent endpoints retain their own authentication. Do not accept the bypass application's AUD as an operator credential or expose the internal verifier through public Caddy routes. Keep CC bound to loopback: Caddy owns enforcement, so direct CC access would bypass it. -- [ ] Test forged/expired/wrong-audience tokens, signing-key rotation and unavailable key retrieval, protected HTTP/WS rejection, and unaffected public agent routes before deployment. Validate the Caddy configuration and test a real direct-origin connection as well as the Cloudflare-facing hostname. Authentication failures must never fall back to header-only acceptance. +- [x] Replace the nonempty assertion matcher in the Caddy template with `forward_auth`. The initial `lb.ldlda.com` probe reached Cloudflare; the later `mails.ldlda.com` probe reached an origin and confirmed 403 without an assertion versus 200 with a forged assertion on the same IP. This is a reproduced bypass, not just a theoretical concern; production rollout remains required. +- [x] Add CC's `/internal/auth/verify` endpoint with configured issuer/audience/key URL, RS256 signature and required claim validation, bounded cached key retrieval, single-flight refresh and outage cooldown. Return 204/401/503 without exposing tokens or identity data. Configured verification requires loopback binding, and `init-config --from-config` preserves it. See [origin authentication](origin-auth.md). +- [x] Cover forged/expired/wrong-audience/missing-claim tokens, key rotation/removal (including an empty set), duplicate assertions, unavailable/malformed/oversized key responses, cooldown and concurrent fetches. Router coverage verifies the oracle is separate from public/control handlers; actual Caddy enforcement is deployment-owned and still requires validation. +- [x] Use `http-cache-semantics` for issuer-supplied HTTP freshness instead of a hardcoded signing-key TTL. Preserve bounded streaming reads, force revalidation on unknown key IDs, and handle ETag/Last-Modified 304 responses without reparsing a body. Tests cover fresh reuse, Age, no-cache/no-store, unspecified freshness, conditional rotation and invalid 304 rejection; no sleeps required. +- [ ] Deploy the new CC verifier and Caddy policy for root/UI/control HTTP and operator WS handshakes. Keep public agent paths outside Access checks and the verifier unexposed publicly. Validate Caddy configuration, real Access login, agent connectivity, protected WebSocket rejection, and forged-header direct-origin rejection. Authentication failures must never fall back to header-only acceptance. ## Multi-CC Planning diff --git a/wakey-control-plane/Cargo.toml b/wakey-control-plane/Cargo.toml index 01cd2f9..f0e6483 100644 --- a/wakey-control-plane/Cargo.toml +++ b/wakey-control-plane/Cargo.toml @@ -10,6 +10,8 @@ axum = { version = "0.8", features = ["ws", "json"] } clap = { version = "4", features = ["derive"] } futures-util = "0.3" humantime-serde = "1" +http-cache-semantics = { version = "3", default-features = false, features = ["reqwest"] } +jsonwebtoken = { version = "10.3", default-features = false, features = ["aws_lc_rs"] } macaddr = { workspace = true } nix = { version = "0.31", default-features = false, features = [ "signal", @@ -21,6 +23,7 @@ reqwest = { version = "0.13", default-features = false, features = [ ] } serde = { workspace = true } serde_json = { workspace = true } +thiserror = "2" sqlx = { version = "0.8", features = [ "runtime-tokio", @@ -40,3 +43,7 @@ wakey-core = { path = "../wakey-core", registry = "gitea", version = "0", featur [target.'cfg(target_os = "linux")'.dependencies] wakey-linux = { path = "../wakey-linux", registry = "gitea", version = "0", features = ["telemetry"] } + +[dev-dependencies] +tower = { version = "0.5", features = ["util"] } +jsonwebtoken = { version = "10.3", default-features = false, features = ["use_pem", "aws_lc_rs"] } diff --git a/wakey-control-plane/src/config/init.rs b/wakey-control-plane/src/config/init.rs index 4eb46b6..5121e12 100644 --- a/wakey-control-plane/src/config/init.rs +++ b/wakey-control-plane/src/config/init.rs @@ -107,6 +107,7 @@ pub fn write_init_config(args: &InitConfigArgs) -> Result> { super::resolve::validate_device_retention(device_retention)?; let body = FileConfig { + access: base.access, data_dir: Some(data_dir), bind: Some(bind.to_string()), public_url: Some(public_url), @@ -245,6 +246,11 @@ pid_file = "old.pid" ui_dist_dir = "/opt/wakey/ui" bootstrap_enroll_tokens = ["old-token"] +[access] +issuer = "https://access.example" +audience = "protected-app" +jwks_url = "https://keys.example/custom" + [telemetry] otlp_endpoint = "http://otel.example" authorization_env = "WAKEY_OTLP_AUTHORIZATION" @@ -293,6 +299,14 @@ json_logs = true ); assert!(parsed.get("command_timeout_ms").is_none()); assert_eq!(converted.device_retention, Some(std::time::Duration::ZERO)); + assert_eq!( + converted.access, + Some(crate::config::AccessConfig { + issuer: "https://access.example".into(), + audience: "protected-app".into(), + jwks_url: Some("https://keys.example/custom".into()), + }) + ); assert!(parsed.get("enroll_token_ttl_seconds").is_none()); assert_eq!( parsed["state_file"].as_str(), diff --git a/wakey-control-plane/src/config/mod.rs b/wakey-control-plane/src/config/mod.rs index 9d94b03..eae4b71 100644 --- a/wakey-control-plane/src/config/mod.rs +++ b/wakey-control-plane/src/config/mod.rs @@ -8,4 +8,4 @@ pub use resolve::{ resolve_revoke_agent_settings, resolve_revoke_enroll_token_settings, resolve_state_stats_settings, }; -pub use types::{DaemonConfig, TelemetryConfig}; +pub use types::{AccessConfig, DaemonConfig, TelemetryConfig}; diff --git a/wakey-control-plane/src/config/resolve.rs b/wakey-control-plane/src/config/resolve.rs index 3eb6722..dad035c 100644 --- a/wakey-control-plane/src/config/resolve.rs +++ b/wakey-control-plane/src/config/resolve.rs @@ -105,6 +105,7 @@ impl DaemonConfig { ui_dist_dir, bootstrap_enroll_tokens, telemetry, + access: file.access, }) } } diff --git a/wakey-control-plane/src/config/types.rs b/wakey-control-plane/src/config/types.rs index ec3dc7e..b644e7d 100644 --- a/wakey-control-plane/src/config/types.rs +++ b/wakey-control-plane/src/config/types.rs @@ -22,6 +22,17 @@ pub struct DaemonConfig { pub ui_dist_dir: PathBuf, pub bootstrap_enroll_tokens: Vec, pub telemetry: TelemetryConfig, + pub access: Option, +} + +/// Trusted origin-verifier settings; no URL is taken from a JWT header or claim. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct AccessConfig { + pub issuer: String, + pub audience: String, + /// Omission uses the Cloudflare Access certs path beneath the issuer. + pub jwks_url: Option, } /// TOML DTO for both loading partial files and generating complete files. @@ -46,6 +57,7 @@ pub(crate) struct FileConfig { #[serde(alias = "enroll_tokens")] pub(crate) bootstrap_enroll_tokens: Option>, pub(crate) telemetry: Option, + pub(crate) access: Option, } pub(crate) const DURATION_KEYS: &[LegacyDuration] = &[ @@ -103,6 +115,7 @@ pub(crate) static DEFAULT_CONFIG: LazyLock = LazyLock::new(|| Daem pid_file: "wakey-control-plane.pid".into(), ui_dist_dir: "ui/dist".into(), bootstrap_enroll_tokens: Vec::new(), + access: None, telemetry: TelemetryConfig { console_filter: None, otlp_log_filter: None, diff --git a/wakey-control-plane/src/runtime/access.rs b/wakey-control-plane/src/runtime/access.rs new file mode 100644 index 0000000..1fff059 --- /dev/null +++ b/wakey-control-plane/src/runtime/access.rs @@ -0,0 +1,337 @@ +//! Origin authentication oracle for Caddy, not middleware on the control API. +//! Enforcement belongs to the proxy; the daemon must remain loopback-only. + +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::{Duration, SystemTime}; + +use anyhow::{Context, Result, ensure}; +use axum::{ + Extension, Router, + http::{HeaderMap, StatusCode}, + routing::get, +}; +use http_cache_semantics::{AfterResponse, BeforeRequest, CacheOptions, CachePolicy}; +use jsonwebtoken::jwk::{AlgorithmParameters, JwkSet, KeyAlgorithm, KeyOperations, PublicKeyUse}; +use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header}; +use reqwest::{Client, Url}; +use tokio::sync::Mutex; +use tokio::time::Instant; + +use super::AppState; +use crate::config::AccessConfig; + +const REFRESH_COOLDOWN: Duration = Duration::from_secs(60); +const FETCH_TIMEOUT: Duration = Duration::from_secs(5); +const MAX_JWKS_BYTES: usize = 256 * 1024; +const MAX_TOKEN_BYTES: usize = 16 * 1024; +const MAX_KEYS: usize = 64; + +// This is a private, single-resource cache. Do not invent a freshness lifetime +// when the issuer supplies none, or override its no-cache/no-store directives. +const CACHE_OPTIONS: CacheOptions = CacheOptions { + shared: false, + cache_heuristic: 0.0, + immutable_min_time_to_live: Duration::ZERO, + ignore_cargo_cult: false, +}; + +/// Absent configuration deliberately leaves the oracle denying every request. +pub(super) fn routes(config: Option<&AccessConfig>, bind: SocketAddr) -> Result> { + let verifier = config + .map(|config| { + ensure!( + bind.ip().is_loopback(), + "Access verification requires a loopback bind; Caddy owns enforcement" + ); + Verifier::new(config).map(Arc::new) + }) + .transpose()?; + Ok(verifier_routes(verifier)) +} + +fn verifier_routes(verifier: Option>) -> Router { + Router::new() + .route("/internal/auth/verify", get(verify)) + .layer(Extension(verifier)) +} + +async fn verify( + Extension(verifier): Extension>>, + headers: HeaderMap, +) -> StatusCode { + let Some(verifier) = verifier else { + return StatusCode::UNAUTHORIZED; + }; + let mut values = headers.get_all("cf-access-jwt-assertion").iter(); + let Some(token) = values.next().and_then(|value| value.to_str().ok()) else { + return StatusCode::UNAUTHORIZED; + }; + if values.next().is_some() { + return StatusCode::UNAUTHORIZED; + } + match verifier.validate(token).await { + Ok(()) => StatusCode::NO_CONTENT, + Err(VerifyError::Invalid) => StatusCode::UNAUTHORIZED, + Err(VerifyError::Unavailable) => StatusCode::SERVICE_UNAVAILABLE, + } +} + +struct Verifier { + client: Client, + jwks_url: Url, + validation: Validation, + cache: Mutex, +} + +#[derive(Default)] +struct KeyCache { + keys: HashMap, + policy: Option, + attempted_at: Option, + last_fetch_failed: bool, +} + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +enum VerifyError { + #[error("invalid Access assertion")] + Invalid, + #[error("Access signing keys unavailable")] + Unavailable, +} + +impl Verifier { + fn new(config: &AccessConfig) -> Result { + let issuer = trusted_url(&config.issuer)?; + ensure!( + issuer.path() == "/" && !config.issuer.ends_with('/'), + "Access issuer must be an HTTPS origin without a trailing slash" + ); + ensure!( + !config.audience.trim().is_empty(), + "Access audience must not be empty" + ); + let jwks_url = match &config.jwks_url { + Some(url) => trusted_url(url)?, + None => issuer.join("/cdn-cgi/access/certs")?, + }; + let mut validation = Validation::new(Algorithm::RS256); + validation.set_issuer(&[&config.issuer]); + validation.set_audience(&[&config.audience]); + validation.set_required_spec_claims(&["exp", "iss", "aud"]); + validation.validate_nbf = true; + validation.leeway = 0; + Ok(Self { + client: Client::builder() + .timeout(FETCH_TIMEOUT) + .redirect(reqwest::redirect::Policy::none()) + .build()?, + jwks_url, + validation, + cache: Mutex::new(KeyCache::default()), + }) + } + + async fn validate(&self, token: &str) -> Result<(), VerifyError> { + if token.len() > MAX_TOKEN_BYTES { + return Err(VerifyError::Invalid); + } + // Only kid selects a cached key. Token-supplied URLs never drive I/O. + let header = decode_header(token).map_err(|_| VerifyError::Invalid)?; + if header.alg != Algorithm::RS256 { + return Err(VerifyError::Invalid); + } + let kid = header + .kid + .filter(|kid| !kid.is_empty() && kid.len() <= 256) + .ok_or(VerifyError::Invalid)?; + let key = self.key(&kid).await?; + decode::(token, &key, &self.validation) + .map(|_| ()) + .map_err(|_| VerifyError::Invalid) + } + + async fn key(&self, kid: &str) -> Result { + // Single-flight refresh with a bounded HTTP fetch. The cooldown is + // global, not an attacker-controlled per-kid cache, including outages. + let mut cache = self.cache.lock().await; + let mut request = self + .client + .get(self.jwks_url.clone()) + .build() + .map_err(|_| VerifyError::Unavailable)?; + let now = Instant::now(); + let fresh = cache.policy.as_ref().is_some_and(|policy| { + matches!( + policy.before_request(&request, SystemTime::now()), + BeforeRequest::Fresh(_) + ) + }); + if fresh && let Some(key) = cache.keys.get(kid) { + return Ok(key.clone()); + } + if cache + .attempted_at + .is_none_or(|at| now.duration_since(at) >= REFRESH_COOLDOWN) + { + cache.attempted_at = Some(now); + // A missing kid must bypass a still-fresh response cache to notice + // rotation. The HTTP library adds ETag/Last-Modified validators. + request.headers_mut().insert( + reqwest::header::CACHE_CONTROL, + reqwest::header::HeaderValue::from_static("no-cache"), + ); + if let Some(policy) = &cache.policy + && let BeforeRequest::Stale { + request: conditional, + .. + } = policy.before_request(&request, SystemTime::now()) + { + *request.headers_mut() = conditional.headers; + } + match self.fetch_keys(&request, &cache).await { + Ok((keys, policy)) => { + let selected = keys.get(kid).cloned().ok_or(VerifyError::Invalid); + if policy.is_storable() { + cache.keys = keys; + cache.policy = Some(policy); + } else { + // no-store permits this response's use for this request, + // but neither keys nor HTTP metadata may be retained. + cache.keys.clear(); + cache.policy = None; + } + cache.last_fetch_failed = false; + return selected; + } + Err(_) => { + // Never include upstream bodies, tokens or claims in logs. + tracing::warn!( + "Access signing-key refresh failed; verification fails closed when no fresh matching key is available" + ); + cache.last_fetch_failed = true; + } + } + } + if !fresh || cache.last_fetch_failed { + Err(VerifyError::Unavailable) + } else { + Err(VerifyError::Invalid) + } + } + + async fn fetch_keys( + &self, + request: &reqwest::Request, + cached: &KeyCache, + ) -> Result<(HashMap, CachePolicy)> { + let mut response = self + .client + .execute( + request + .try_clone() + .context("JWKS request cannot be cloned")?, + ) + .await? + .error_for_status()?; + let received_at = SystemTime::now(); + if response.status() == StatusCode::NOT_MODIFIED { + ensure!( + request + .headers() + .contains_key(reqwest::header::IF_NONE_MATCH) + || request + .headers() + .contains_key(reqwest::header::IF_MODIFIED_SINCE), + "unsolicited JWKS 304" + ); + let policy = cached + .policy + .as_ref() + .context("JWKS 304 without stored keys")?; + return match policy.after_response(request, &response, received_at) { + AfterResponse::NotModified(policy, _) => Ok((cached.keys.clone(), policy)), + AfterResponse::Modified(_, _) => { + anyhow::bail!("JWKS 304 did not match stored validators") + } + }; + } + ensure!( + response.status() == StatusCode::OK, + "JWKS must return 200 or a matching 304" + ); + let policy = CachePolicy::new_options(request, &response, received_at, CACHE_OPTIONS); + ensure!( + response + .content_length() + .is_none_or(|len| len <= MAX_JWKS_BYTES as u64), + "JWKS too large" + ); + let mut body = Vec::new(); + while let Some(chunk) = response.chunk().await? { + ensure!(chunk.len() <= MAX_JWKS_BYTES - body.len(), "JWKS too large"); + body.extend_from_slice(&chunk); + } + Ok((parse_keys(&body)?, policy)) + } +} + +fn trusted_url(value: &str) -> Result { + let url = Url::parse(value).context("invalid Access URL")?; + ensure!( + url.scheme() == "https" + && url.host_str().is_some() + && url.username().is_empty() + && url.password().is_none() + && url.query().is_none() + && url.fragment().is_none(), + "Access URLs require HTTPS without credentials, query or fragment" + ); + Ok(url) +} + +fn parse_keys(body: &[u8]) -> Result> { + let jwks: JwkSet = serde_json::from_slice(body)?; + ensure!(jwks.keys.len() <= MAX_KEYS, "too many signing keys"); + let mut keys = HashMap::new(); + for jwk in jwks.keys { + if !matches!(jwk.algorithm, AlgorithmParameters::RSA(_)) + || jwk + .common + .key_algorithm + .is_some_and(|alg| alg != KeyAlgorithm::RS256) + || jwk + .common + .public_key_use + .as_ref() + .is_some_and(|usage| *usage != PublicKeyUse::Signature) + || jwk + .common + .key_operations + .as_ref() + .is_some_and(|ops| !ops.contains(&KeyOperations::Verify)) + { + continue; + } + let Some(kid) = jwk + .common + .key_id + .as_ref() + .filter(|kid| !kid.is_empty() && kid.len() <= 256) + else { + continue; + }; + let key = DecodingKey::from_jwk(&jwk)?; + ensure!( + keys.insert(kid.clone(), key).is_none(), + "duplicate signing key ID" + ); + } + // A valid empty set revokes every cached key. Do not treat it as a fetch + // failure and preserve keys that the issuer has deliberately removed. + Ok(keys) +} + +#[cfg(test)] +mod tests; diff --git a/wakey-control-plane/src/runtime/access/test-key.pem b/wakey-control-plane/src/runtime/access/test-key.pem new file mode 100644 index 0000000..5fea60f --- /dev/null +++ b/wakey-control-plane/src/runtime/access/test-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCpN0wYAVcYDhNr +MNgF22jtk0uiZZQQl4K+uM1S3ivY3sQDjZj3y6icwR5MhYHOtCW2+EY/2IctpEcd +/3li5OOna2RKDdTpshw/mE3Q4NYZyk8JCAoIY+J9yzQsBdTjKbSUu3tMFxQb89ap +baAooZ5TpnZ3ZVZUN1SQjYq5my8sZAkO9JOOMHnPx/5Ba5Z4IT9DX8hzlgG+e1F3 +lW3XpjTt3xgGp3ZWNUU6hA2dnwHrkvmy91U1ojC0Bow2ySL0hbgHnFwFNpsx5wFs +Mum4/LI+ZA9yaWKdbPKXx7W0g+x0ihqyq/K00z/KE4zIIENS+hNv/Ko7nMB0S/s2 +gkdmgiEjAgMBAAECggEAE0olR6lDczpg4VEYlxZWGvBgaLCj/9OIXn3KPijrHlZK +nvhnGHxOtMvY9zJ9mSwfuStf4AsADJETtao3/VuH+L+nwj9bEZGHDILWqrnZU5L5 +49bW70eU+++2+1EQ3ce/zk1O9rNnd0ZFJrGYq85nXN7MK2Z8awOXBWwf2nJZHs9Q +QYM+LFiLDqkT+sTGIwV8mNwVN+S4tKEyxPWuFmGU649nQL6LAy/NJKtH+6C7feEa +pdIoUZ6TT9QvR2kVC5XTaDeLGf1uwgzFBkB9hZoFMBzh8Bx1k0p9WkA1ViJkSzXc +T38deIES5RUid0/GVxOKZkuYk7jnoLWMxK8T5SzeWQKBgQDg3VOyPy0ykD+YtgbL +4DWrBW/kregMfximpESEL7yP+XAKv6vYox4oGDncoX7g48C6K6c1kSdlGb0We1nA +md+TraIQjDWv9Zyfr7FeiD8739xJGz8T7Y0TU7sdd1EYz5fUkURLqzkX9Tpy4+Ia +mXSUH4jfyjMWVghhe9E2rJmUawKBgQDApWvHFIp2nWDvErE/xPQnMCTxc3ndGbfi +1CszYlQcm+C57KhMprWUbDSXrGzqlqn75npdIsXXbY1SORb44antwHzofJyhE9aA +RGPDKtym9d8/+zy7DtYfw7IlIDZtXFpgCQPtKPXVZ0v3gE0/kYfeRcoyyQnsT8jd +MqiX0zMUKQKBgEf+tQhx78+rnaEQTrbB74z1LbEi+RPfMY5RM9q6yf2y2GuomKXA +/CvMLeJ1FoF8ij1RMrUU528YQZFMJx1iZtIKmsP/kETpN59tCjYBG3XZI9eIULbZ +rPyOJ2+0vxNZi/w0EpDDnoEMNA2K5t6rWUb/nbxBKgAxxXcoxIBj3LxHAoGAJYFP +DVATcOpMz96MwYR/oA2Ac71ukTwLj86MppFTHMrAIpJPZbTjDwVN6+V2wukYKOGD +GxTE/Z7nb4WL0mJkqdPavYxdFgL9tLUb0whKMvsVPqiT9zr5Nu/rg8zUq8/aDG5U +5FwgGC7nHEVPRLv5fq2Jov9+vhH+YgoVxpEolZkCgYAgZ+PdXcm1XkwioHlBCfOH +ilXAaubbb1SZtrodabplqut8ox9oq9RL/Fa549IPIB5e+SSAU3S/al21/0+bxs8C +QyoWII8ayb7vgZ46TBuaaZVT4Oxd83GQgc4Az91m7UZIgxhbS/3os1tqXJqQKujt +Wy7tHJh9RC/z2+kFSog3tA== +-----END PRIVATE KEY----- diff --git a/wakey-control-plane/src/runtime/access/tests.rs b/wakey-control-plane/src/runtime/access/tests.rs new file mode 100644 index 0000000..f56e388 --- /dev/null +++ b/wakey-control-plane/src/runtime/access/tests.rs @@ -0,0 +1,590 @@ +use super::*; +use axum::body::Body; +use axum::http::{HeaderValue, Request}; +use jsonwebtoken::jwk::Jwk; +use jsonwebtoken::{EncodingKey, Header, encode}; +use serde_json::{Value, json}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use tokio::sync::RwLock; +use tower::ServiceExt; + +// Public test-only private key, never used as a deployment credential. +const TEST_KEY: &[u8] = include_bytes!("test-key.pem"); +const ISSUER: &str = "https://access.example"; +const AUDIENCE: &str = "wakey-test"; + +fn config() -> AccessConfig { + AccessConfig { + issuer: ISSUER.into(), + audience: AUDIENCE.into(), + jwks_url: None, + } +} + +fn encoding_key() -> EncodingKey { + EncodingKey::from_rsa_pem(TEST_KEY).unwrap() +} + +fn jwks(kid: &str) -> Value { + let mut jwk = Jwk::from_encoding_key(&encoding_key(), Algorithm::RS256).unwrap(); + jwk.common.key_id = Some(kid.into()); + json!({"keys": [jwk]}) +} + +fn claims() -> Value { + // Fixed dates make boundary tests independent of scheduling delays. + json!({"iss": ISSUER, "aud": [AUDIENCE], "exp": 4102444800u64}) +} + +fn token(kid: &str, claims: &Value) -> String { + let mut header = Header::new(Algorithm::RS256); + header.kid = Some(kid.into()); + encode(&header, claims, &encoding_key()).unwrap() +} + +async fn cached() -> Arc { + let verifier = Arc::new(Verifier::new(&config()).unwrap()); + let mut cache = verifier.cache.lock().await; + cache.keys = parse_keys(&serde_json::to_vec(&jwks("one")).unwrap()).unwrap(); + cache.policy = Some(test_policy( + &verifier, + "public, max-age=14400, must-revalidate", + None, + )); + cache.attempted_at = Some(Instant::now()); + drop(cache); + verifier +} + +fn test_policy(verifier: &Verifier, cache_control: &str, etag: Option<&str>) -> CachePolicy { + let request = verifier + .client + .get(verifier.jwks_url.clone()) + .build() + .unwrap(); + let mut response = axum::http::Response::builder().header("cache-control", cache_control); + if let Some(etag) = etag { + response = response.header("etag", etag); + } + CachePolicy::new_options( + &request, + &response.body(()).unwrap(), + SystemTime::now(), + CACHE_OPTIONS, + ) +} + +async fn status(app: Router, assertion: Option<&str>) -> StatusCode { + let mut request = Request::builder().uri("/internal/auth/verify"); + if let Some(assertion) = assertion { + request = request.header("cf-access-jwt-assertion", assertion); + } + app.oneshot(request.body(Body::empty()).unwrap()) + .await + .unwrap() + .status() +} + +#[tokio::test] +async fn oracle_accepts_only_valid_application_tokens() { + let verifier = cached().await; + let app = verifier_routes(Some(verifier)); + assert_eq!( + status(app.clone(), Some(&token("one", &claims()))).await, + StatusCode::NO_CONTENT + ); + assert_eq!(status(app.clone(), None).await, StatusCode::UNAUTHORIZED); + assert_eq!( + status(app.clone(), Some("wakey-invalid-test")).await, + StatusCode::UNAUTHORIZED + ); + for (field, value) in [ + ("iss", json!("https://other.example")), + ("aud", json!(["bypass-app"])), + ("exp", json!(1)), + ("nbf", json!(4102444800u64)), + ] { + let mut invalid = claims(); + invalid[field] = value; + assert_eq!( + status(app.clone(), Some(&token("one", &invalid))).await, + StatusCode::UNAUTHORIZED, + "{field}" + ); + } + for field in ["iss", "aud", "exp"] { + let mut invalid = claims(); + invalid.as_object_mut().unwrap().remove(field); + assert_eq!( + status(app.clone(), Some(&token("one", &invalid))).await, + StatusCode::UNAUTHORIZED, + "missing {field}" + ); + } + let mut forged = token("one", &claims()).into_bytes(); + let signature_start = forged.iter().rposition(|byte| *byte == b'.').unwrap() + 1; + forged[signature_start] = if forged[signature_start] == b'A' { + b'B' + } else { + b'A' + }; + assert_eq!( + status(app.clone(), Some(std::str::from_utf8(&forged).unwrap())).await, + StatusCode::UNAUTHORIZED + ); + let mut header = Header::new(Algorithm::HS256); + header.kid = Some("one".into()); + let hmac = encode( + &header, + &claims(), + &EncodingKey::from_secret(b"not-an-rsa-key"), + ) + .unwrap(); + assert_eq!( + status(app.clone(), Some(&hmac)).await, + StatusCode::UNAUTHORIZED + ); + assert_eq!( + status(app.clone(), Some(&token("unknown", &claims()))).await, + StatusCode::UNAUTHORIZED + ); + assert_eq!( + status(app.clone(), Some(&"a".repeat(MAX_TOKEN_BYTES + 1))).await, + StatusCode::UNAUTHORIZED + ); + let mut request = Request::builder() + .uri("/internal/auth/verify") + .body(Body::empty()) + .unwrap(); + let valid = token("one", &claims()); + request.headers_mut().append( + "cf-access-jwt-assertion", + HeaderValue::from_str(&valid).unwrap(), + ); + request + .headers_mut() + .append("cf-access-jwt-assertion", HeaderValue::from_static("fake")); + assert_eq!( + app.oneshot(request).await.unwrap().status(), + StatusCode::UNAUTHORIZED + ); + assert_eq!( + status(verifier_routes(None), Some(&valid)).await, + StatusCode::UNAUTHORIZED + ); +} + +#[test] +fn trusted_configuration_rejects_unsafe_urls_and_non_loopback_bind() { + assert_eq!( + Verifier::new(&config()).unwrap().jwks_url.as_str(), + "https://access.example/cdn-cgi/access/certs" + ); + let mut explicit = config(); + explicit.jwks_url = Some("https://keys.example/custom".into()); + assert_eq!( + Verifier::new(&explicit).unwrap().jwks_url.as_str(), + "https://keys.example/custom" + ); + for url in [ + "http://access.example", + "https://user:pass@access.example", + "https://access.example?q=1", + "https://access.example/#fragment", + "not a url", + ] { + assert!(trusted_url(url).is_err(), "{url}"); + } + let mut invalid = config(); + invalid.audience.clear(); + assert!(Verifier::new(&invalid).is_err()); + invalid = config(); + invalid.issuer.push_str("/wrong-path"); + assert!(Verifier::new(&invalid).is_err()); + assert!(routes(Some(&config()), "0.0.0.0:8080".parse().unwrap()).is_err()); + assert!(routes(Some(&config()), "[::1]:8080".parse().unwrap()).is_ok()); +} + +#[test] +fn signing_key_set_rejects_duplicates_and_unusable_keys() { + let key = jwks("one")["keys"][0].clone(); + assert!(parse_keys(br#"{"keys": []}"#).unwrap().is_empty()); + for set in [ + json!({"keys": [key.clone(), key.clone()]}), + json!({"keys": vec![key.clone(); MAX_KEYS + 1]}), + ] { + assert!(parse_keys(&serde_json::to_vec(&set).unwrap()).is_err()); + } + for (field, value) in [ + ("use", json!("enc")), + ("alg", json!("RS512")), + ("key_ops", json!(["encrypt"])), + ] { + let mut invalid = key.clone(); + invalid[field] = value; + assert!( + parse_keys(&serde_json::to_vec(&json!({"keys": [invalid]})).unwrap()) + .unwrap() + .is_empty() + ); + } +} + +#[tokio::test] +async fn key_fetch_is_single_flight_rotates_and_fails_closed() { + let hits = Arc::new(AtomicUsize::new(0)); + let reply = Arc::new(RwLock::new(( + StatusCode::OK, + serde_json::to_string(&jwks("one")).unwrap(), + ))); + let app = Router::new().route( + "/keys", + get({ + let hits = hits.clone(); + let reply = reply.clone(); + move || { + let hits = hits.clone(); + let reply = reply.clone(); + async move { + hits.fetch_add(1, Ordering::Relaxed); + let (status, body) = reply.read().await.clone(); + ( + status, + [("cache-control", "public, max-age=14400, must-revalidate")], + body, + ) + } + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let mut verifier = Verifier::new(&config()).unwrap(); + // HTTP is allowed only in this in-process fixture, never in loaded config. + verifier.jwks_url = Url::parse(&format!("http://{address}/keys")).unwrap(); + let verifier = Arc::new(verifier); + let valid = token("one", &claims()); + let (first, second) = tokio::join!(verifier.validate(&valid), verifier.validate(&valid)); + assert!(first.is_ok() && second.is_ok()); + assert_eq!(hits.load(Ordering::Relaxed), 1); + for kid in ["random-1", "random-2"] { + assert_eq!( + verifier.validate(&token(kid, &claims())).await, + Err(VerifyError::Invalid) + ); + } + assert_eq!(hits.load(Ordering::Relaxed), 1); + *reply.write().await = (StatusCode::OK, serde_json::to_string(&jwks("two")).unwrap()); + verifier.cache.lock().await.attempted_at = Some(Instant::now() - REFRESH_COOLDOWN); + assert!(verifier.validate(&token("two", &claims())).await.is_ok()); + assert_eq!(verifier.validate(&valid).await, Err(VerifyError::Invalid)); + assert_eq!(hits.load(Ordering::Relaxed), 2); + // A failed refresh cannot authorize unknown keys or expired cached keys. + *reply.write().await = (StatusCode::SERVICE_UNAVAILABLE, "unavailable".into()); + verifier.cache.lock().await.attempted_at = Some(Instant::now() - REFRESH_COOLDOWN); + assert_eq!( + verifier.validate(&valid).await, + Err(VerifyError::Unavailable) + ); + assert!(verifier.validate(&token("two", &claims())).await.is_ok()); + verifier.cache.lock().await.policy = + Some(test_policy(&verifier, "max-age=0, must-revalidate", None)); + assert_eq!( + verifier.validate(&token("two", &claims())).await, + Err(VerifyError::Unavailable) + ); + assert_eq!(hits.load(Ordering::Relaxed), 3); + // Oversized and malformed responses also fail closed, with bounded retries. + for body in ["x".repeat(MAX_JWKS_BYTES + 1), "not json".into()] { + *reply.write().await = (StatusCode::OK, body); + verifier.cache.lock().await.attempted_at = Some(Instant::now() - REFRESH_COOLDOWN); + assert_eq!( + verifier.validate(&valid).await, + Err(VerifyError::Unavailable) + ); + } + assert_eq!( + status(verifier_routes(Some(verifier.clone())), Some(&valid)).await, + StatusCode::SERVICE_UNAVAILABLE + ); + // A successful empty set must revoke previously cached keys immediately. + *reply.write().await = (StatusCode::OK, r#"{"keys": []}"#.into()); + verifier.cache.lock().await.attempted_at = Some(Instant::now() - REFRESH_COOLDOWN); + assert_eq!( + verifier.validate(&token("two", &claims())).await, + Err(VerifyError::Invalid) + ); + assert!(verifier.cache.lock().await.keys.is_empty()); + server.abort(); + let _ = server.await; +} + +struct KeyOrigin { + verifier: Arc, + reply: Arc>, + requests: Arc>>, + server: tokio::task::JoinHandle<()>, +} + +impl KeyOrigin { + async fn new(headers: &[(&str, &str)]) -> Self { + let reply = Arc::new(RwLock::new(( + StatusCode::OK, + HeaderMap::new(), + String::new(), + ))); + let requests = Arc::new(Mutex::new(Vec::new())); + let app = Router::new().route( + "/keys", + get({ + let reply = reply.clone(); + let requests = requests.clone(); + move |headers: HeaderMap| { + let reply = reply.clone(); + let requests = requests.clone(); + async move { + requests.lock().await.push(headers); + reply.read().await.clone() + } + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let mut verifier = Verifier::new(&config()).unwrap(); + verifier.jwks_url = Url::parse(&format!("http://{address}/keys")).unwrap(); + let origin = Self { + verifier: Arc::new(verifier), + reply, + requests, + server, + }; + origin + .respond( + StatusCode::OK, + serde_json::to_string(&jwks("one")).unwrap(), + headers, + ) + .await; + origin + } + + async fn respond(&self, status: StatusCode, body: String, headers: &[(&str, &str)]) { + let mut response = axum::http::Response::builder().status(status); + for (name, value) in headers { + response = response.header(*name, *value); + } + let (parts, body) = response.body(body).unwrap().into_parts(); + *self.reply.write().await = (status, parts.headers, body); + } + + async fn allow_refresh(&self) { + self.verifier.cache.lock().await.attempted_at = None; + } + + async fn stop(self) { + self.server.abort(); + let _ = self.server.await; + } +} + +#[tokio::test] +async fn http_cache_reuses_fresh_keys_revalidates_etags_and_forces_rotation() { + let origin = KeyOrigin::new(&[ + ( + "cache-control", + "public, max-age=14400, must-revalidate, no-transform", + ), + ("etag", "\"v1\""), + ]) + .await; + let one = token("one", &claims()); + for _ in 0..2 { + assert!(origin.verifier.validate(&one).await.is_ok()); + } + assert_eq!(origin.requests.lock().await.len(), 1); + // Expire the policy directly; no wall-clock sleeps or delayed retries. + origin.verifier.cache.lock().await.policy = Some(test_policy( + &origin.verifier, + "max-age=0, must-revalidate", + Some("\"v1\""), + )); + origin.allow_refresh().await; + origin + .respond( + StatusCode::NOT_MODIFIED, + String::new(), + &[ + ("etag", "\"v1\""), + ("cache-control", "public, max-age=14400, must-revalidate"), + ], + ) + .await; + for _ in 0..2 { + assert!(origin.verifier.validate(&one).await.is_ok()); + } + assert_eq!(origin.requests.lock().await.len(), 2); + assert_eq!(origin.requests.lock().await[1]["if-none-match"], "\"v1\""); + // An unknown kid forces revalidation even though the HTTP entry is fresh. + origin.allow_refresh().await; + origin + .respond( + StatusCode::OK, + serde_json::to_string(&jwks("two")).unwrap(), + &[ + ("etag", "\"v2\""), + ("cache-control", "public, max-age=14400, must-revalidate"), + ], + ) + .await; + assert!( + origin + .verifier + .validate(&token("two", &claims())) + .await + .is_ok() + ); + assert_eq!(origin.requests.lock().await.len(), 3); + assert_eq!(origin.requests.lock().await[2]["cache-control"], "no-cache"); + assert_eq!(origin.requests.lock().await[2]["if-none-match"], "\"v1\""); + assert_eq!( + origin.verifier.validate(&one).await, + Err(VerifyError::Invalid) + ); + // A mismatched 304 must not renew trust in the stored keys. + origin.verifier.cache.lock().await.policy = Some(test_policy( + &origin.verifier, + "max-age=0, must-revalidate", + Some("\"v2\""), + )); + origin.allow_refresh().await; + origin + .respond( + StatusCode::NOT_MODIFIED, + String::new(), + &[("etag", "\"wrong\""), ("cache-control", "max-age=14400")], + ) + .await; + assert_eq!( + origin.verifier.validate(&token("two", &claims())).await, + Err(VerifyError::Unavailable) + ); + origin.stop().await; +} + +#[tokio::test] +async fn http_cache_honors_age_no_cache_and_no_store() { + for (cache_control, age, stores) in [ + ("no-store", "0", false), + ("no-cache", "0", true), + ("public, max-age=14400, must-revalidate", "14401", true), + ("", "0", true), + ] { + let origin = KeyOrigin::new(&[("cache-control", cache_control), ("age", age)]).await; + let one = token("one", &claims()); + assert!(origin.verifier.validate(&one).await.is_ok()); + assert_eq!(origin.verifier.cache.lock().await.policy.is_some(), stores); + if !stores { + assert!(origin.verifier.cache.lock().await.keys.is_empty()); + } + // A successful origin response can serve the requesting caller, but + // an HTTP-stale/non-storable response cannot authorize later requests. + assert_eq!( + origin.verifier.validate(&one).await, + Err(VerifyError::Unavailable) + ); + assert_eq!(origin.requests.lock().await.len(), 1); + origin.allow_refresh().await; + assert!(origin.verifier.validate(&one).await.is_ok()); + assert_eq!(origin.requests.lock().await.len(), 2); + origin.stop().await; + } +} + +#[tokio::test] +async fn http_cache_revalidates_last_modified_and_rejects_unsolicited_304() { + let modified = "Wed, 01 Jan 2025 00:00:00 GMT"; + let origin = KeyOrigin::new(&[ + ("cache-control", "max-age=0, must-revalidate"), + ("last-modified", modified), + ]) + .await; + let one = token("one", &claims()); + assert!(origin.verifier.validate(&one).await.is_ok()); + origin.allow_refresh().await; + origin + .respond( + StatusCode::NOT_MODIFIED, + String::new(), + &[ + ("last-modified", modified), + ("cache-control", "max-age=14400"), + ], + ) + .await; + assert!(origin.verifier.validate(&one).await.is_ok()); + assert_eq!( + origin.requests.lock().await[1]["if-modified-since"], + modified + ); + origin.stop().await; + + let origin = KeyOrigin::new(&[]).await; + origin + .respond(StatusCode::NOT_MODIFIED, String::new(), &[]) + .await; + assert_eq!( + origin.verifier.validate(&one).await, + Err(VerifyError::Unavailable) + ); + assert!(origin.verifier.cache.lock().await.policy.is_none()); + origin.stop().await; +} + +#[tokio::test] +async fn verifier_routes_do_not_gate_agent_or_control_handlers() { + let dir = std::env::temp_dir().join(format!("wakey-access-routes-{}", uuid::Uuid::new_v4())); + let store = crate::state::Store::load_or_init( + &dir.join("state.sqlite3"), + vec![], + Duration::from_secs(60), + ) + .await + .unwrap(); + let state = AppState { + store: Arc::new(store), + sessions: Arc::new(RwLock::new(HashMap::new())), + pending: Arc::new(Mutex::new(HashMap::new())), + public_url: "http://localhost".into(), + command_timeout: Duration::from_secs(30), + enroll_token_ttl: Duration::from_secs(60), + terminals: super::super::terminals::TerminalRegistry::new(), + alerts: crate::api::AlertsHub::new(), + fleet: Arc::new(crate::api::FleetCache::default()), + }; + let app = Router::new() + .merge(routes(Some(&config()), "127.0.0.1:8080".parse().unwrap()).unwrap()) + .merge(super::super::public_api_routes(dir.clone())) + .merge(super::super::control_api_routes()) + .with_state(state); + for (path, expected) in [ + ("/healthz", StatusCode::OK), + ("/api/v1/control/agents", StatusCode::OK), + ("/internal/auth/verify", StatusCode::UNAUTHORIZED), + ] { + let response = app + .clone() + .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), expected, "{path}"); + } + drop(app); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/wakey-control-plane/src/runtime/mod.rs b/wakey-control-plane/src/runtime/mod.rs index 45dcbc5..ca68637 100644 --- a/wakey-control-plane/src/runtime/mod.rs +++ b/wakey-control-plane/src/runtime/mod.rs @@ -23,6 +23,7 @@ use crate::config; use crate::state; use crate::ws; +mod access; mod admin; mod fleet_metrics; mod process; @@ -177,6 +178,9 @@ fn control_api_routes() -> Router { /// Starts the control-plane HTTP and websocket surfaces and manages daemon lifecycle hooks. pub async fn serve(daemon: config::DaemonConfig) -> Result<()> { + // The verifier delegates enforcement to Caddy. Do not expose the other + // handlers directly when that boundary is configured. + let access_routes = access::routes(daemon.access.as_ref(), daemon.bind)?; write_pid_file(&daemon.pid_file)?; info!(pid_file = %daemon.pid_file.display(), "wrote control-plane pid file"); @@ -207,6 +211,7 @@ pub async fn serve(daemon: config::DaemonConfig) -> Result<()> { // - public_api_routes: intended internet-facing agent endpoints // - control_api_routes: intended admin-only endpoints behind Access let app = Router::new() + .merge(access_routes) .merge(public_api_routes(daemon.ui_dist_dir.clone())) .merge(control_api_routes()) .with_state(app_state.clone());