lib.rsannotatedlib.rssource215 lines · 8.6 KB · raw

Who may come in: nobody Cloudflare Access has not signed in.

Access stands in front of a site and adds a signed token to every request it lets through (Cf-Access-Jwt-Assertion). Trusting that Access is there would be enough only while nothing else leads to the Worker, so the Worker checks the token itself: signed by this team's keys (RS256), for this application (aud), from this team (iss), not yet expired. Anything short of that is refused, and a Worker with no team or audience configured refuses everyone.

Pure Rust and no runtime, so it builds for wasm. Fetching the team's keys (certs) is the adapter's.

11use base64::Engine;
12use base64::engine::general_purpose::URL_SAFE_NO_PAD;
13use rsa::pkcs1v15::Pkcs1v15Sign;
14use rsa::sha2::{Digest, Sha256};
15use rsa::{BigUint, RsaPublicKey};
16use serde::Deserialize;

The header Access puts its token in.

19pub const HEADER: &str = "cf-access-jwt-assertion";

One of the team's public keys, as its certs address lists them.

22#[derive(Clone, Debug, Deserialize)]
23pub struct Key {
24    pub kid: String,
25    pub n: String,
26    pub e: String,
27}
29#[derive(Deserialize)]
30pub struct Keys {
31    pub keys: Vec<Key>,
32}
33
34#[derive(Deserialize)]
35struct Head {
36    alg: String,
37    kid: String,
38}

aud is a list in Access's tokens, and a single string in the standard.

41#[derive(Deserialize)]
42#[serde(untagged)]
43enum Audience {
44    One(String),
45    Many(Vec<String>),
46}
48#[derive(Deserialize)]
49struct Claims {
50    aud: Audience,
51    iss: String,
52    exp: f64,
53    #[serde(default)]
54    email: String,

A service token has no email; Access names it by its client id.

56    #[serde(default)]
57    common_name: String,
58}

Why a request was not let in. Closed, so a caller cannot invent a reason and a test can name each.

61#[derive(Clone, Copy, Debug, PartialEq, Eq)]
62pub enum Refused {
63    NotConfigured,
64    NotAToken,
65    WrongAlgorithm,
66    UnknownKey,
67    BadKey,
68    BadSignature,
69    OtherApplication,
70    OtherTeam,
71    Expired,
72}
74impl std::fmt::Display for Refused {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        f.write_str(match self {
77            Refused::NotConfigured => "no Access team or audience is configured",
78            Refused::NotAToken => "not a token",
79            Refused::WrongAlgorithm => "not signed the way Access signs",
80            Refused::UnknownKey => "signed by a key this team does not have",
81            Refused::BadKey => "the team's key is not a key",
82            Refused::BadSignature => "the signature is not this team's",
83            Refused::OtherApplication => "for another application",
84            Refused::OtherTeam => "from another team",
85            Refused::Expired => "expired",
86        })
87    }
88}
89
90impl std::error::Error for Refused {}

Where the team's keys are read from.

93pub fn certs(team: &str) -> String {
94    format!("{}/cdn-cgi/access/certs", issuer(team))
95}
97fn issuer(team: &str) -> String {
98    format!("https://{team}.cloudflareaccess.com")
99}

The kid a token says it was signed with, to find the key by. Says nothing about whether the token is good.

103pub fn kid(token: &str) -> Option<String> {
104    let head = token.split('.').next()?;
105    serde_json::from_slice::<Head>(&URL_SAFE_NO_PAD.decode(head).ok()?).ok().map(|head| head.kid)
106}

The email of whoever Access signed in, if token is this team's, for this application, in date, and signed by one of keys.

110pub fn admitted(token: &str, keys: &[Key], team: &str, audience: &str, now_s: f64) -> Result<String, Refused> {
111    if team.is_empty() || audience.is_empty() {
112        return Err(Refused::NotConfigured);
113    }
114    let mut parts = token.split('.');
115    let (Some(head), Some(body), Some(signature), None) = (parts.next(), parts.next(), parts.next(), parts.next()) else {
116        return Err(Refused::NotAToken);
117    };
118    let decoded = |part: &str| URL_SAFE_NO_PAD.decode(part).map_err(|_| Refused::NotAToken);
119    let head_json: Head = serde_json::from_slice(&decoded(head)?).map_err(|_| Refused::NotAToken)?;
120    if head_json.alg != "RS256" {
121        return Err(Refused::WrongAlgorithm);
122    }
123    let key = keys.iter().find(|key| key.kid == head_json.kid).ok_or(Refused::UnknownKey)?;
124    let number = |part: &str| decoded(part).map(|bytes| BigUint::from_bytes_be(&bytes));
125    let public = RsaPublicKey::new(number(&key.n)?, number(&key.e)?).map_err(|_| Refused::BadKey)?;
126    let signed = Sha256::digest(format!("{head}.{body}").as_bytes());
127    public.verify(Pkcs1v15Sign::new::<Sha256>(), &signed, &decoded(signature)?).map_err(|_| Refused::BadSignature)?;
128    // Only now is what the token says worth reading.
129    let claims: Claims = serde_json::from_slice(&decoded(body)?).map_err(|_| Refused::NotAToken)?;
130    let ours = match &claims.aud {
131        Audience::One(one) => one == audience,
132        Audience::Many(many) => many.iter().any(|one| one == audience),
133    };
134    if !ours {
135        return Err(Refused::OtherApplication);
136    }
137    if claims.iss != issuer(team) {
138        return Err(Refused::OtherTeam);
139    }
140    if claims.exp <= now_s {
141        return Err(Refused::Expired);
142    }
143    Ok(if claims.email.is_empty() { claims.common_name } else { claims.email })
144}
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use rsa::RsaPrivateKey;
150    use rsa::traits::PublicKeyParts;
151
152    const TEAM: &str = "deizel";
153    const AUD: &str = "0123456789abcdef";
154
155    struct Signer {
156        private: RsaPrivateKey,
157        key: Key,
158    }
159
160    fn signer(kid: &str) -> Signer {
161        let private = RsaPrivateKey::new(&mut rand::thread_rng(), 1024).unwrap();
162        let encoded = |number: &BigUint| URL_SAFE_NO_PAD.encode(number.to_bytes_be());
163        let key = Key { kid: kid.into(), n: encoded(private.n()), e: encoded(private.e()) };
164        Signer { private, key }
165    }
166
167    fn token(signer: &Signer, alg: &str, claims: serde_json::Value) -> String {
168        let head = URL_SAFE_NO_PAD.encode(serde_json::json!({ "alg": alg, "kid": signer.key.kid }).to_string());
169        let body = URL_SAFE_NO_PAD.encode(claims.to_string());
170        let signed = Sha256::digest(format!("{head}.{body}").as_bytes());
171        let signature = signer.private.sign(Pkcs1v15Sign::new::<Sha256>(), &signed).unwrap();
172        format!("{head}.{body}.{}", URL_SAFE_NO_PAD.encode(signature))
173    }
174
175    fn claims(aud: &str, iss: &str, exp: f64) -> serde_json::Value {
176        serde_json::json!({ "aud": [aud], "iss": iss, "exp": exp, "email": "owner@example.com" })
177    }
178
179    #[test]
180    fn only_this_teams_token_for_this_application_in_date_gets_in() {
181        let ours = signer("a");
182        let keys = [ours.key.clone()];
183        let iss = "https://deizel.cloudflareaccess.com";
184        let good = token(&ours, "RS256", claims(AUD, iss, 2000.0));
185        assert_eq!(admitted(&good, &keys, TEAM, AUD, 1000.0).as_deref(), Ok("owner@example.com"));
186        assert_eq!(kid(&good).as_deref(), Some("a"));
187
188        // Expired, for another application, from another team.
189        assert_eq!(admitted(&good, &keys, TEAM, AUD, 2000.0), Err(Refused::Expired));
190        assert_eq!(admitted(&token(&ours, "RS256", claims("other", iss, 2000.0)), &keys, TEAM, AUD, 1000.0), Err(Refused::OtherApplication));
191        assert_eq!(admitted(&token(&ours, "RS256", claims(AUD, "https://evil.cloudflareaccess.com", 2000.0)), &keys, TEAM, AUD, 1000.0), Err(Refused::OtherTeam));
192
193        // Signed by someone else, even under our key's name.
194        let theirs = signer("a");
195        assert_eq!(admitted(&token(&theirs, "RS256", claims(AUD, iss, 2000.0)), &keys, TEAM, AUD, 1000.0), Err(Refused::BadSignature));
196        assert_eq!(admitted(&token(&signer("b"), "RS256", claims(AUD, iss, 2000.0)), &keys, TEAM, AUD, 1000.0), Err(Refused::UnknownKey));
197
198        // A token whose body was changed after signing.
199        let mut parts: Vec<String> = good.split('.').map(str::to_owned).collect();
200        parts[1] = URL_SAFE_NO_PAD.encode(claims(AUD, iss, 9e9).to_string());
201        assert_eq!(admitted(&parts.join("."), &keys, TEAM, AUD, 1000.0), Err(Refused::BadSignature));
202
203        // No signature at all, and the `none` trick.
204        assert_eq!(admitted("", &keys, TEAM, AUD, 1000.0), Err(Refused::NotAToken));
205        assert_eq!(admitted(&token(&ours, "none", claims(AUD, iss, 2000.0)), &keys, TEAM, AUD, 1000.0), Err(Refused::WrongAlgorithm));
206    }
207
208    #[test]
209    fn a_worker_with_nothing_configured_lets_nobody_in() {
210        let ours = signer("a");
211        let good = token(&ours, "RS256", claims("", "https://.cloudflareaccess.com", 2000.0));
212        assert_eq!(admitted(&good, &[ours.key.clone()], "", "", 1000.0), Err(Refused::NotConfigured));
213        assert_eq!(admitted(&good, &[ours.key.clone()], TEAM, "", 1000.0), Err(Refused::NotConfigured));
214    }
215}