From c1187a792640d8b66255c8b19521f51a42f79ddf Mon Sep 17 00:00:00 2001 From: rays Date: Sat, 19 Sep 2026 14:38:25 +0000 Subject: [PATCH] Verify Cloudflare Access's signed token before trusting the proxy The proxy sign-in believed Cf-Access-Authenticated-User-Email from any address in trusted_proxies. On Tower that address is the Docker gateway, so any container there could name itself anyone (docs/sso.md said as much, and CLAUDE.md listed it as a known gap). With [web] access_team and access_aud set, a proxied request must also carry a Cf-Access-Jwt-Assertion that verifies against Cloudflare's keys (RS256 only, this application's audience, the team's issuer, not expired), and the name comes from its email claim. The keys are fetched at start and again when a token names an unseen key, at most once a minute, so made-up key ids cannot make every request a request to Cloudflare. While the keys cannot be had, proxied sign-in is refused; password and token sign-in are unaffected. Both settings empty, nothing changes. jsonwebtoken does the checking, on the aws-lc-rs backend already in the tree through rustls. Tests sign with throwaway keys in tests/data: a valid token, another app's audience, expired, a forged signature, HS256, alg none, the refetch limit, and keys that cannot be fetched. Checked live on a scratch daemon: the header alone and a forged token got 401, the admin token still signed in. vouched_name takes the peer and headers rather than the request: a &Request held across the new await made the auth middleware's future unsendable, as a body is not Sync. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 4 + CLAUDE.md | 3 +- Cargo.lock | 40 ++++++ Cargo.toml | 1 + docs/configuration.md | 5 + docs/sso.md | 30 ++++- src/access.rs | 215 +++++++++++++++++++++++++++++++ src/config.rs | 16 +++ src/main.rs | 7 + src/web.rs | 39 ++++-- tests/data/access-forger.der | Bin 0 -> 1192 bytes tests/data/access-test.der | Bin 0 -> 1191 bytes tests/data/access-test.jwks.json | 12 ++ 13 files changed, 354 insertions(+), 18 deletions(-) create mode 100644 src/access.rs create mode 100644 tests/data/access-forger.der create mode 100644 tests/data/access-test.der create mode 100644 tests/data/access-test.jwks.json diff --git a/CHANGELOG.md b/CHANGELOG.md index b165810..c808705 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The daemon no longer prints the web token when it starts, so it stays out of `docker logs`. It says where the token is kept instead: `[web] token` in config.toml. +- Signing in through Cloudflare Access can check the token Access signs: set `access_team` and + `access_aud` under `[web]`, and a request has to carry a valid `Cf-Access-Jwt-Assertion` as well as + the email header. Without it, anything on the same Docker host as ipx could send the header. See + docs/sso.md. ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 0a20056..9292b43 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -183,8 +183,7 @@ Deliberate simplifications get a `ponytail:` comment naming the ceiling and the ## Known gaps -* Cloudflare's `Cf-Access-Jwt-Assertion` is not verified — ipx trusts the hop plus `trusted_proxies` - (documented in [docs/sso.md](docs/sso.md)). +* Nothing at the moment. Add one here when a limitation is known and left in place. # Command output diff --git a/Cargo.lock b/Cargo.lock index afe4a64..59587cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1830,6 +1830,7 @@ dependencies = [ "chrono", "clap", "futures-util", + "jsonwebtoken", "librqbit", "opml", "percent-encoding", @@ -2007,6 +2008,22 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "11.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "getrandom 0.2.17", + "js-sys", + "serde", + "serde_json", + "signature", + "zeroize", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -3786,6 +3803,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -5183,6 +5209,20 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +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 2.0.119", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index 11a2fc4..b9ac25e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ axum = "0.8.9" chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] } clap = { version = "4.6.6", features = ["derive"] } futures-util = { version = "0.3.34", default-features = false, features = ["std"] } +jsonwebtoken = { version = "11.1.0", default-features = false, features = ["aws_lc_rs"] } librqbit = { version = "9.0.1", default-features = false, features = ["rust-tls", "http-api-client"] } opml = "1.1.6" percent-encoding = "2.3.2" diff --git a/docs/configuration.md b/docs/configuration.md index 8b5a528..9908cc0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -83,6 +83,8 @@ bind = "0.0.0.0:8099" # 127.0.0.1:8080 by default token = "" # generated and saved on first run trusted_header = "" # e.g. "Cf-Access-Authenticated-User-Email" trusted_proxies = ["127.0.0.1", "::1"] +access_team = "" # e.g. ".cloudflareaccess.com" +access_aud = "" # the Access application's AUD tag auto_create_users = true sign_out_url = "" # e.g. "/cdn-cgi/access/logout" session_days = 30 @@ -94,6 +96,9 @@ session_days = 30 disables that path. See [sso.md](sso.md). * **`trusted_proxies`** — addresses allowed to assert that header, and the entire security boundary for it. Name the proxy, never a subnet. +* **`access_team`**, **`access_aud`** — with both set, a request through the proxy also has to + carry the `Cf-Access-Jwt-Assertion` Cloudflare Access signed for this application, and the name + comes from that token instead of the header. See [sso.md](sso.md#verifying-cloudflares-token). * **`auto_create_users`** — create an account the first time the proxy vouches for a new name. * **`sign_out_url`** — where Sign out sends someone the proxy signed in: the proxy's own sign-out, `/cdn-cgi/access/logout` behind Cloudflare Access. Empty sends them to the sign-in page, where diff --git a/docs/sso.md b/docs/sso.md index 7020c21..35d67f1 100644 --- a/docs/sso.md +++ b/docs/sso.md @@ -172,9 +172,33 @@ itself, arrive under their own addresses and cannot set the header; the checks a sides. Never list a LAN address or range: anyone there could then send `Cf-Access-Authenticated-User-Email: rays@sdf1.net` and be you. -**What ipx does not do:** it does not verify Cloudflare's signed `Cf-Access-Jwt-Assertion`. It -trusts the hop. Verifying the signature would make the containers on Tower irrelevant to the -boundary, and is the upgrade if that ever matters. +**Unless the token is checked.** With `access_team` and `access_aud` set (next section), the +header is not enough on its own: the request has to carry the token Cloudflare Access signed, and +a container on Tower cannot make one. + +### Verifying Cloudflare's token + +Access adds `Cf-Access-Jwt-Assertion` to every request it forwards: a JWT naming the person, +signed with keys only Cloudflare holds. With these two settings ipx checks it on every proxied +request, and takes the name from its `email` claim. + +```toml +[web] +access_team = ".cloudflareaccess.com" # Zero Trust → Settings: the team domain +access_aud = "…" # Access → Applications → ipodderx → Overview: Application Audience (AUD) Tag +``` + +ipx fetches the public keys from `https:///cdn-cgi/access/certs` when it starts, and +again when a token names a key it has not seen (Cloudflare rotates them every six weeks or so), at +most once a minute. It checks the signature (RS256 only), that the audience is this application's +tag, the issuer, and the expiry. Anything else is refused, and so is every proxied request while +the keys cannot be fetched; password and token sign-in still work then. + +`trusted_header` and `trusted_proxies` still apply: the check is added to them, not put in their +place. + +Check it: the busybox request under [Check it](#check-it), which sends the email header without a +token from the Docker bridge, now gets `sign in`, and the site still signs you in through Authentik. **Turning it off:** clear `trusted_header` and restart. Proxy-made accounts stay, but nobody can sign in with them until they are given a password (`ipx user passwd `). diff --git a/src/access.rs b/src/access.rs new file mode 100644 index 0000000..2a4c9a1 --- /dev/null +++ b/src/access.rs @@ -0,0 +1,215 @@ +//! Cloudflare Access's signed assertion, `Cf-Access-Jwt-Assertion`. Without it, the proxy +//! sign-in trusts a plain header from any address in `trusted_proxies`, and on Tower that +//! address is the Docker gateway: any container there could send the header and be anyone. +//! Access signs the same identity with keys only Cloudflare holds, so checking that signature +//! takes the network out of the question. + +use std::collections::HashMap; +use std::future::Future; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; +use jsonwebtoken::jwk::JwkSet; +use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header}; + +/// Cloudflare rotates its keys every six weeks or so, publishing the new one before using it. +/// A token naming a key not seen yet refetches, but no more often than this, so a stream of +/// made-up key ids cannot turn every request into a request to Cloudflare. +const REFETCH_EVERY: Duration = Duration::from_secs(60); + +#[derive(Default)] +pub struct Keys { + cache: Mutex, +} + +#[derive(Default)] +struct Cache { + keys: HashMap, + fetched: Option, +} + +#[derive(serde::Deserialize)] +struct Claims { + email: Option, +} + +impl Keys { + /// The name the token vouches for, or None: a bad signature, the wrong audience or issuer, an + /// expired token, a key that cannot be had, or no email in it (a service token has none). + pub async fn verify(&self, client: &reqwest::Client, team: &str, aud: &str, token: &str) -> Option { + self.verify_with(team, aud, token, || fetch(client, team)).await + } + + /// Fill the cache before the first request needs it. A failure is only logged: the next + /// request tries again, and until one succeeds the proxy sign-in refuses everyone. + pub async fn prefetch(&self, client: &reqwest::Client, team: &str) { + match fetch(client, team).await { + Ok(set) => self.store(set), + Err(e) => tracing::warn!(error = %format!("{e:#}"), "could not fetch Cloudflare Access's signing keys"), + } + } + + async fn verify_with(&self, team: &str, aud: &str, token: &str, fetch: F) -> Option + where + F: FnOnce() -> Fut, + Fut: Future>, + { + let kid = decode_header(token).ok()?.kid?; + let key = match self.key(&kid) { + Some(k) => k, + None => { + if !self.may_refetch() { + return None; + } + match fetch().await { + Ok(set) => self.store(set), + Err(e) => { + tracing::warn!(error = %format!("{e:#}"), "could not fetch Cloudflare Access's signing keys"); + return None; + } + } + self.key(&kid)? + } + }; + // RS256 only: a token that names HS256 or none is refused here, before its signature + // is looked at, rather than checked with the public key as if it were a secret. + let mut v = Validation::new(Algorithm::RS256); + v.set_audience(&[aud]); + v.set_issuer(&[format!("https://{team}")]); + v.validate_nbf = true; + match decode::(token, &key, &v) { + Ok(data) => crate::auth::name_from_header(&data.claims.email?), + Err(e) => { + tracing::warn!(error = %e, "refused a Cloudflare Access token"); + None + } + } + } + + fn key(&self, kid: &str) -> Option { + self.cache.lock().unwrap().keys.get(kid).cloned() + } + + /// Takes the slot as it answers, so two requests at once do not both fetch. + fn may_refetch(&self) -> bool { + let mut c = self.cache.lock().unwrap(); + if c.fetched.is_some_and(|t| t.elapsed() < REFETCH_EVERY) { + return false; + } + c.fetched = Some(Instant::now()); + true + } + + /// Replaces the whole set, so a key Cloudflare has retired stops being accepted. + fn store(&self, set: JwkSet) { + let keys = set + .keys + .iter() + .filter_map(|k| Some((k.common.key_id.clone()?, DecodingKey::from_jwk(k).ok()?))) + .collect(); + let mut c = self.cache.lock().unwrap(); + c.keys = keys; + c.fetched = Some(Instant::now()); + } +} + +async fn fetch(client: &reqwest::Client, team: &str) -> Result { + let url = format!("https://{team}/cdn-cgi/access/certs"); + client + .get(&url) + .timeout(Duration::from_secs(10)) + .send() + .await + .with_context(|| format!("fetching {url}"))? + .error_for_status()? + .json() + .await + .context("reading the signing keys") +} + +#[cfg(test)] +mod tests { + use super::*; + use jsonwebtoken::{EncodingKey, Header, encode, get_current_timestamp}; + + const TEAM: &str = "team.cloudflareaccess.com"; + const AUD: &str = "aud-tag"; + + fn jwks() -> JwkSet { + serde_json::from_str(include_str!("../tests/data/access-test.jwks.json")).unwrap() + } + + fn token(key: &[u8], alg: Algorithm, claims: serde_json::Value) -> String { + let mut h = Header::new(alg); + h.kid = Some("k1".into()); + let k = if alg == Algorithm::RS256 { EncodingKey::from_rsa_der(key) } else { EncodingKey::from_secret(key) }; + encode(&h, &claims, &k).unwrap() + } + + fn claims(aud: &str, exp_in: i64) -> serde_json::Value { + let now = get_current_timestamp() as i64; + serde_json::json!({ + "aud": [aud], "iss": format!("https://{TEAM}"), "email": "Rays@SDF1.net", + "iat": now, "nbf": now, "exp": now + exp_in, "type": "app", + }) + } + + const SIGNER: &[u8] = include_bytes!("../tests/data/access-test.der"); + const FORGER: &[u8] = include_bytes!("../tests/data/access-forger.der"); + + async fn check(keys: &Keys, t: &str) -> Option { + keys.verify_with(TEAM, AUD, t, || async { Ok(jwks()) }).await + } + + #[tokio::test] + async fn only_a_token_cloudflare_signed_for_this_app_signs_anyone_in() { + let keys = Keys::default(); + keys.store(jwks()); + let ok = token(SIGNER, Algorithm::RS256, claims(AUD, 300)); + assert_eq!(check(&keys, &ok).await.as_deref(), Some("rays@sdf1.net"), "lower-cased like the header"); + + let other_app = token(SIGNER, Algorithm::RS256, claims("another-app", 300)); + assert_eq!(check(&keys, &other_app).await, None, "an Access token for another application"); + + let expired = token(SIGNER, Algorithm::RS256, claims(AUD, -3600)); + assert_eq!(check(&keys, &expired).await, None, "expired"); + + let forged = token(FORGER, Algorithm::RS256, claims(AUD, 300)); + assert_eq!(check(&keys, &forged).await, None, "signed by a key that is not Cloudflare's"); + + // HMAC and none, the classic ways to get a token past a verifier that trusts its header. + let hs = token(b"any secret at all", Algorithm::HS256, claims(AUD, 300)); + assert_eq!(check(&keys, &hs).await, None, "HS256"); + let none = format!("{}.{}.", "eyJhbGciOiJub25lIiwia2lkIjoiazEifQ", + ok.split('.').nth(1).unwrap()); + assert_eq!(check(&keys, &none).await, None, "alg none"); + } + + #[tokio::test] + async fn an_unknown_key_refetches_once_a_minute_at_most() { + let keys = Keys::default(); + let ok = token(SIGNER, Algorithm::RS256, claims(AUD, 300)); + let fetches = std::sync::atomic::AtomicUsize::new(0); + let count = || { fetches.fetch_add(1, std::sync::atomic::Ordering::SeqCst); async { Ok(jwks()) } }; + assert_eq!(keys.verify_with(TEAM, AUD, &ok, count).await.as_deref(), Some("rays@sdf1.net"), + "a key not cached yet is fetched"); + assert_eq!(fetches.load(std::sync::atomic::Ordering::SeqCst), 1); + + // A made-up key id straight after: not fetched again. + let mut h = Header::new(Algorithm::RS256); + h.kid = Some("nobody".into()); + let stray = encode(&h, &claims(AUD, 300), &EncodingKey::from_rsa_der(SIGNER)).unwrap(); + let count = || { fetches.fetch_add(1, std::sync::atomic::Ordering::SeqCst); async { Ok(jwks()) } }; + assert_eq!(keys.verify_with(TEAM, AUD, &stray, count).await, None); + assert_eq!(fetches.load(std::sync::atomic::Ordering::SeqCst), 1, "rate-limited"); + } + + #[tokio::test] + async fn keys_that_cannot_be_fetched_refuse_rather_than_wave_through() { + let keys = Keys::default(); + let ok = token(SIGNER, Algorithm::RS256, claims(AUD, 300)); + let got = keys.verify_with(TEAM, AUD, &ok, || async { Err(anyhow::anyhow!("offline")) }).await; + assert_eq!(got, None); + } +} diff --git a/src/config.rs b/src/config.rs index b63967f..2e05a20 100644 --- a/src/config.rs +++ b/src/config.rs @@ -78,6 +78,14 @@ pub struct Web { /// hop that set it, so an empty list means nobody: on a LAN-bound port anyone could /// otherwise claim to be anyone. Loopback covers a tunnel running beside the daemon. pub trusted_proxies: Vec, + /// Cloudflare Access's team domain, `.cloudflareaccess.com`. With `access_aud`, the + /// proxy sign-in also needs the `Cf-Access-Jwt-Assertion` Access signs, and takes the name + /// from it: a header from a trusted address is otherwise all it asks for, and on a Docker + /// host any container can send one from the gateway's address. + pub access_team: String, + /// The Access application's Application Audience (AUD) tag. Empty, with `access_team`, + /// leaves the signature unchecked. + pub access_aud: String, /// Create an account the first time the proxy vouches for a name it has not seen. pub auto_create_users: bool, /// Where Sign out sends someone the proxy signed in. Signing out of ipx alone cannot stick @@ -96,6 +104,8 @@ impl Default for Web { token: String::new(), trusted_header: String::new(), trusted_proxies: vec!["127.0.0.1".into(), "::1".into()], + access_team: String::new(), + access_aud: String::new(), auto_create_users: true, sign_out_url: String::new(), session_days: 30, @@ -107,6 +117,12 @@ impl Web { pub fn binds_publicly(&self) -> bool { !self.bind.starts_with("127.") && !self.bind.starts_with("localhost") } + + /// Both halves of the Access check, or None while either is unset. + pub fn access(&self) -> Option<(&str, &str)> { + (!self.access_team.is_empty() && !self.access_aud.is_empty()) + .then_some((self.access_team.as_str(), self.access_aud.as_str())) + } } #[derive(Debug, Clone, Deserialize, Serialize)] diff --git a/src/main.rs b/src/main.rs index c5e29eb..073cf38 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod access; mod auth; mod config; mod db; @@ -567,10 +568,16 @@ async fn start_web( tracing::warn!(bind, "web ui is reachable off this machine; the token is all that guards it"); } + let access = Arc::new(access::Keys::default()); + if let Some((team, _)) = ctx.cfg().web.access() { + let (access, ctx, team) = (access.clone(), ctx.clone(), team.to_owned()); + tokio::spawn(async move { access.prefetch(&ctx.client, &team).await }); + } let state = web::WebState { ctx: ctx.clone(), cmds: cmds.clone(), events: events.clone(), + access, }; Ok(Some(tokio::spawn(async move { if let Err(e) = web::serve(state, &bind).await { diff --git a/src/web.rs b/src/web.rs index 16da0b0..407b767 100644 --- a/src/web.rs +++ b/src/web.rs @@ -29,6 +29,8 @@ pub struct WebState { pub ctx: Arc, pub cmds: mpsc::Sender, pub events: broadcast::Sender, + /// Cloudflare Access's signing keys, fetched once and kept. + pub access: Arc, } pub fn router(state: WebState) -> Router { @@ -99,7 +101,7 @@ async fn auth(State(state): State, mut req: Request, next: Next) -> Re let token = cfg.web.token.clone(); // 1. A header, but only from a hop we were told to believe. - let vouched = vouched_name(&cfg, &req); + let vouched = vouched_name(&state, &cfg, peer(&req), req.headers()).await; let mut set_cookie: Option = None; let mut user = None; @@ -201,20 +203,31 @@ struct Proxied(bool); /// The name the proxy vouches for, when this request came from one of `trusted_proxies` and /// carries `trusted_header`. Anyone able to reach the port could otherwise send the header and -/// be whoever they liked. -fn vouched_name(cfg: &crate::config::Config, req: &Request) -> Option { - let peer = req - .extensions() - .get::>() - .map(|c| c.0.ip().to_string()) - .unwrap_or_default(); +/// be whoever they liked. With `access_team` and `access_aud` set, it also has to carry a +/// token Cloudflare Access signed, and the name is the one in the token. +/// +/// Takes the request's parts rather than the request: a `&Request` held across the await makes +/// the future unsendable, as a body is not `Sync`. +async fn vouched_name(state: &WebState, cfg: &crate::config::Config, peer: String, headers: &axum::http::HeaderMap) -> Option { if cfg.web.trusted_header.is_empty() || !cfg.web.trusted_proxies.iter().any(|p| p == &peer) { return None; } - req.headers() - .get(&cfg.web.trusted_header) - .and_then(|v| v.to_str().ok()) - .and_then(crate::auth::name_from_header) + let header = |name: &str| headers.get(name).and_then(|v| v.to_str().ok()); + let Some((team, aud)) = cfg.web.access() else { + return header(&cfg.web.trusted_header).and_then(crate::auth::name_from_header); + }; + // The plain header still has to be there, as it is what switches this path on for a + // request; who it names is the token's to say. + header(&cfg.web.trusted_header)?; + let token = header("Cf-Access-Jwt-Assertion")?; + state.access.verify(&state.ctx.client, team, aud, token).await +} + +fn peer(req: &Request) -> String { + req.extensions() + .get::>() + .map(|c| c.0.ip().to_string()) + .unwrap_or_default() } /// Handlers take `User` to say they need one; the auth layer put it there, and nothing @@ -474,7 +487,7 @@ async fn remove_user( /// The password form, except for someone the proxy vouches for: they are signed in already, and /// the form only made it look as if they were not. async fn login_page(State(state): State, req: Request) -> Response { - if vouched_name(&state.ctx.cfg(), &req).is_some() { + if vouched_name(&state, &state.ctx.cfg(), peer(&req), req.headers()).await.is_some() { return Redirect::to("/").into_response(); } ([(header::CACHE_CONTROL, PAGE_CACHE)], Html(include_str!(concat!(env!("OUT_DIR"), "/login.html")))) diff --git a/tests/data/access-forger.der b/tests/data/access-forger.der new file mode 100644 index 0000000000000000000000000000000000000000..bb13737b0530baa1bcf286d81b15d39b71f25dc7 GIT binary patch literal 1192 zcmV;Z1Xueof&`=j0RRGm0RaHD5$cMJ0?IT(&$Q!I+|bk|I^C8TL0^7pi8Jwxp4LZMv$5v&=cROxho8TE)6C z%v?#Ql~XLj;X~ zk~nig0I=n@-KUyLhK9MSpDh^b2@n@5+ZI7|D(R940<7{3t3eyI#&O5}D{`3Wectxc zV*t1n-$B-d`>lA|Aq2Oh-w`j$umynLgnulSUA0ZX_`jVtqhjrefL1o6z3_Osh_j=) zPZbYQ_cEI;Uf*q`FR29r0|5X50)hbmD321-W%^nG%GPVvU^DhJ4+G9=9}m^BrJR(5 z&<*oAx6rfUHQ(l%cX6}Vbuzir;X48~1uwiKH3ZYTBF@T0pj3evb=Veg3}vC=Ym8fO zo_*H5mRksM*cHTwF;y0~@#4bRLB^pzucmZl-Pqp0yuIXIT}!b0+7m9>Dq-7!BS?XRk;b08qMR`=U-$}EBv zEnWA_U=PW*X+{fNY6clXA4=%~0)c@5=6MVxoj-4 zNS7I6Ti8u-+kjHVaHz2dWlj|9YEtrExJtKz` zf3i{Aqv@mc${d2kDUpm`whEZdq5(pE0)c=BaQPQ!)l&AV{n;=kT`QwOLM+B)EU<+4 z!XH2TK0Vjxn~VL!#?rM-8|v3&W}O_6E@PQ872lOVuG@MEWx$$$-o-gBxrL)HyxbEr zeCjaoc(s^kU?ax@$+u86=j?1-J*ocPMD+jnTqb7hA3<96og)z`$MRu#U{8Q$dIq5a zfq?*>06T%A#J>Lzg#~!+@LRTBm{SxR=B&@R)WZYPsHlE%1eC0OoGi?EcN5{f#V!bb z-b9bgSNsAU2hCmU3Bap3>jn||tC7ORa9aMYDEMdUq2p22#mgDu=haCJm+rJB^0P1Q zD+sHQ_)Basbr&||djlOy@xf@pq+SWnm^vi_fq?*qoSKJOHyANlhXCknzA8bxV6SS| zl?8+vv1(D%m1kAIRH}6Moc8pO7HF5DX}5@gPhpQ1qkz(hKt+Qx39r$4RXUG74ZDi0 z%L&l8oo&iJE{F-+m5W!^+KI`S&`L?)uRHW|h%e4uLzS*w>f^RhJKS^niclAjFV^wJ GrA4B)+E1kb literal 0 HcmV?d00001 diff --git a/tests/data/access-test.der b/tests/data/access-test.der new file mode 100644 index 0000000000000000000000000000000000000000..6c33ae546aeb0d9b0457afa2024921d6562353a4 GIT binary patch literal 1191 zcmV;Y1X%kpf&`-i0RRGm0RaHjKjUMEMx9PrCW*^2cT>utT~}g$>$`b1Ig>wGpXxyv z!}(U%6@l-4I>W`zikVgq*8~puiifMvZe-J3zP``JryK8Bc^eNn>-4?9EygReC7pC- z0KuiO-F_!r5i0VRt8DM!DX?7j>>WDO35QPt_@^uo!;CPRyPJ|EaK4zG%o=76Sq5Zx za@}=yO*ivH3=_3Gf=8iU=szWVtrst%c~CErOn*PW)x?Zm!<>)Xeo%CxQ7{6cbo+5Q z#OVE^Le4vzAHYqLW==8?$VFF-YL46d5ZwwfK7km4to>8NttD-@ZR`-s13G7p==c2# zrsQ8PpJ@>(v3?$kbTX3y0|5X50)hbmW7B7c)bN^o?MVe9D1zFjwTLr%`8_h?m!{t) zJ-1zYp|nHEf*>GPQvxbkGs&;y7{DjDHe>4q%%r5u3-FyVK=i5sO`G^ z)v!ClnKXJOc0Jz?f2Ukvgdp_rKc82Dn0~C)t!kDu-{aS9@A)D40 z7>$GU-b5*g07P@`b@Yap^BGLx1~T0J?bTD}qJHUagf`K}G-WWt1Ys6#1~VpfyM)=V z;2RYJL=g+`k69;~;|*g2jeP#$hg_>NTIH3D6Euq6$HNHV)e!vOr9HU#GkFQ80)c@5 z)+DmQu&$Rg+^ux^J(9d3@`6{gnM6}tGrL0s6*57GEJuV(T@!j6?$lJbqGvC7;QX|I z@1RWqUGwM1)!9x6CyiuB9Pr$Ky6@}_j@1d!N z*${S;9sp?pN6AT9Nh{BrQNaUg?BSI)0)c@5%Jyz=Nt>KXg!-J|$rr zl;hhlR2v(q*C{_Zb_YZ$7lh;N!z>^zbcTCJ zcWyKxD+tLP5}fI+0n4cf3RViwiEZNVhafY#$cf+`d|CEcWl;5oP{aO*{P7i)swp>n zX*bzoVqwo9R$uf;GxGGmZyVi+J(2#Hzg};BwkbmqYy+pCfWE<)ZpXhdC-`n)Qu^de FK{jN