From 262378e561761d01638aca0295e52a7d755aa3a8 Mon Sep 17 00:00:00 2001 From: Conrad Ludgate Date: Mon, 5 Aug 2024 17:53:01 +0100 Subject: [PATCH] flesh out jwt code --- Cargo.lock | 150 +++++++++++++++- proxy/Cargo.toml | 18 +- proxy/src/auth/backend.rs | 2 +- proxy/src/auth/backend/jwt.rs | 312 ++++++++++++++++++++++++---------- 4 files changed, 380 insertions(+), 102 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3091c602a7..986f672377 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1548,6 +1548,33 @@ dependencies = [ "typenum", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.52", +] + [[package]] name = "darling" version = "0.20.1" @@ -1629,6 +1656,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fffa369a668c8af7dbf8b5e56c9f744fbd399949ed171606040001947de40b1c" dependencies = [ "const-oid", + "pem-rfc7468", "zeroize", ] @@ -1781,10 +1809,48 @@ checksum = "413301934810f597c1d19ca71c8710e99a3f1ba28a0d2ebc01551a2daeea3c5c" dependencies = [ "der 0.6.1", "elliptic-curve 0.12.3", - "rfc6979", + "rfc6979 0.3.1", "signature 1.6.4", ] +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der 0.7.8", + "digest", + "elliptic-curve 0.13.8", + "rfc6979 0.4.0", + "signature 2.2.0", + "spki 0.7.3", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8 0.10.2", + "signature 2.2.0", +] + +[[package]] +name = "ed25519-dalek" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3daa8e81a3963a60642bcc1f90a670680bd4a77535faa384e9d1c79d620871" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.8.1" @@ -1823,6 +1889,9 @@ dependencies = [ "ff 0.13.0", "generic-array", "group 0.13.0", + "hkdf", + "pem-rfc7468", + "pkcs8 0.10.2", "rand_core 0.6.4", "sec1 0.7.3", "subtle", @@ -1988,6 +2057,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "filetime" version = "0.2.22" @@ -2404,6 +2479,15 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.12.1" @@ -2885,6 +2969,20 @@ dependencies = [ "simple_asn1", ] +[[package]] +name = "k256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f01b677d82ef7a676aa37e099defd83a28e15687112cafdd112d60236b6115b" +dependencies = [ + "cfg-if", + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "once_cell", + "sha2", + "signature 2.2.0", +] + [[package]] name = "kqueue" version = "1.0.7" @@ -3586,7 +3684,7 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51f44edd08f51e2ade572f141051021c5af22677e42b7dd28a88155151c33594" dependencies = [ - "ecdsa", + "ecdsa 0.14.8", "elliptic-curve 0.12.3", "sha2", ] @@ -3597,8 +3695,10 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" dependencies = [ + "ecdsa 0.16.9", "elliptic-curve 0.13.8", "primeorder", + "sha2", ] [[package]] @@ -3607,8 +3707,24 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70786f51bcc69f6a4c0360e063a4cac5419ef7c5cd5b3c99ad70f3be5ba79209" dependencies = [ + "ecdsa 0.16.9", "elliptic-curve 0.13.8", "primeorder", + "sha2", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct 0.2.0", + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder", + "rand_core 0.6.4", + "sha2", ] [[package]] @@ -3972,6 +4088,15 @@ dependencies = [ "serde", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.2.0" @@ -4388,6 +4513,7 @@ version = "0.1.0" dependencies = [ "ahash", "anyhow", + "arc-swap", "async-compression", "async-trait", "atomic-take", @@ -4405,6 +4531,8 @@ dependencies = [ "consumption_metrics", "crossbeam-deque", "dashmap", + "ecdsa 0.16.9", + "ed25519-dalek", "env_logger", "fallible-iterator", "framed-websockets", @@ -4427,13 +4555,16 @@ dependencies = [ "itertools 0.10.5", "jose-jwa", "jose-jwk", - "jsonwebtoken", + "k256", "lasso", "md5", "measured", "metrics", "once_cell", "opentelemetry", + "p256 0.13.2", + "p384", + "p521", "parking_lot 0.12.1", "parquet", "parquet_derive", @@ -4454,6 +4585,7 @@ dependencies = [ "reqwest-retry", "reqwest-tracing", "routerify", + "rsa", "rstest", "rustc-hash", "rustls 0.22.4", @@ -4463,6 +4595,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "signature 2.2.0", "smallvec", "smol_str", "socket2 0.5.5", @@ -4965,6 +5098,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.16.20" @@ -5390,6 +5533,7 @@ dependencies = [ "base16ct 0.2.0", "der 0.7.8", "generic-array", + "pkcs8 0.10.2", "subtle", "zeroize", ] diff --git a/proxy/Cargo.toml b/proxy/Cargo.toml index 4d93d4f14f..3624a04878 100644 --- a/proxy/Cargo.toml +++ b/proxy/Cargo.toml @@ -11,6 +11,7 @@ testing = [] [dependencies] ahash.workspace = true anyhow.workspace = true +arc-swap.workspace = true async-compression.workspace = true async-trait.workspace = true atomic-take.workspace = true @@ -46,9 +47,6 @@ http-body-util = { version = "0.1" } indexmap.workspace = true ipnet.workspace = true itertools.workspace = true -jose-jwa = "0.1.2" -jose-jwk = "0.1.2" -jsonwebtoken.workspace = true lasso = { workspace = true, features = ["multi-threaded"] } md5.workspace = true measured = { workspace = true, features = ["lasso"] } @@ -76,7 +74,7 @@ rustls.workspace = true scopeguard.workspace = true serde.workspace = true serde_json.workspace = true -sha2 = { workspace = true, features = ["asm"] } +sha2 = { workspace = true, features = ["asm", "oid"] } smol_str.workspace = true smallvec.workspace = true socket2.workspace = true @@ -106,6 +104,18 @@ x509-parser.workspace = true postgres-protocol.workspace = true redis.workspace = true +# jwt stuff +jose-jwa = "0.1.2" +jose-jwk = { version = "0.1.2", features = ["p256", "p384", "rsa"] } +signature = "2" +ecdsa = "0.16" +p256 = "0.13" +p384 = "0.13" +p521 = "0.13" +k256 = "0.13" +ed25519-dalek = "2" +rsa = "0.9" + workspace_hack.workspace = true [dev-dependencies] diff --git a/proxy/src/auth/backend.rs b/proxy/src/auth/backend.rs index 3b8e1b53bd..c6a0b2af5a 100644 --- a/proxy/src/auth/backend.rs +++ b/proxy/src/auth/backend.rs @@ -1,7 +1,7 @@ mod classic; mod hacks; +pub mod jwt; mod link; -mod jwt; use std::net::IpAddr; use std::sync::Arc; diff --git a/proxy/src/auth/backend/jwt.rs b/proxy/src/auth/backend/jwt.rs index b34883519e..20a7ac4cfc 100644 --- a/proxy/src/auth/backend/jwt.rs +++ b/proxy/src/auth/backend/jwt.rs @@ -1,17 +1,25 @@ -use std::time::Duration; +use std::{sync::Arc, time::Duration}; -use anyhow::{bail, Context}; +use anyhow::{bail, ensure, Context}; +use arc_swap::{access::Access, ArcSwap, Guard}; use dashmap::{mapref::entry::Entry, DashMap}; -use jsonwebtoken::{jwk::JwkSet, Algorithm, DecodingKey, TokenData, Validation}; +use hmac::digest::generic_array::GenericArray; +// use jose_jwa::S; +use jose_jwk::crypto::KeyInfo; use tokio::time::Instant; use crate::intern::EndpointIdInt; +#[derive(Default)] pub struct JWKCache { client: reqwest::Client, - // I don't like this. jsonwebtoken crate is horrendous with the amount of string allocations.. oh well. - map: DashMap, + map: DashMap>, +} + +pub struct JWKCacheEntryLock { + cached: ArcSwap, + lookup: tokio::sync::Mutex<()>, } pub struct JWKCacheEntry { @@ -19,13 +27,46 @@ pub struct JWKCacheEntry { /// Should refetch when new key IDs are seen only every 5 minutes or so last_retrieved: Instant, - /// jwks urls - urls: Vec, - + // /// jwks urls + // urls: Vec, /// cplane will return multiple JWKs urls that we need to scrape. key_sets: Vec, } +impl JWKCacheEntryLock { + async fn renew_jwks( + &self, + client: &reqwest::Client, + endpoint: EndpointIdInt, + ) -> Arc { + let _guard = self.lookup.lock().await; + + // double check that no one beat us to updating the cache. + let now = Instant::now(); + let guard = self.cached.load_full(); + let last_update = now.duration_since(guard.last_retrieved); + if last_update < Duration::from_secs(300) { + return guard; + } + + todo!("refetch jwks from cplane"); + + // fetch from cplane + // fetch jwks + // entry.last_retrieved = now; + + let x = Arc::new(JWKCacheEntry { + last_retrieved: now, + key_sets: vec![], + }); + self.cached.swap(x.clone()); + x + } +} + +const MIN_RENEW: Duration = Duration::from_secs(300); +const MAX_RENEW: Duration = Duration::from_secs(3600); + impl JWKCache { pub async fn check_jwt( &self, @@ -48,82 +89,197 @@ impl JWKCache { let header = serde_json::from_slice::(&header) .context("not a valid compact JWT encoding")?; - // let header = jsonwebtoken::decode_header(&jwt).context("could not parse JWT")?; + ensure!(header.typ == "JWT"); let kid = header.kid.context("missing key id")?; let entry = match self.map.get(&endpoint) { - Some(entry) => entry, + Some(entry) => Arc::clone(&*entry), None => match self.map.entry(endpoint) { - Entry::Occupied(entry) => entry.into_ref().downgrade(), + Entry::Occupied(entry) => Arc::clone(&*entry.into_ref()), Entry::Vacant(_) => todo!("fetch jwks from cplane"), }, }; - let now = Instant::now(); - if now.duration_since(entry.last_retrieved) > Duration::from_secs(3600) { - todo!("refetch jwks from cplane") - // drop(entry) - don't want to hold the lock while running long network operations - // fetch from cplane - // fetch jwks - // entry.last_retrieved = now; + // check if the cached JWKs need updating. + let mut guard = { + let now = Instant::now(); + let guard = entry.cached.load(); + let last_update = now.duration_since(guard.last_retrieved); - // can we use arcswap compare_and_swap here? - } + if last_update > MAX_RENEW { + // it's been too long since we checked the keys. wait for them to update. + entry.renew_jwks(&self.client, endpoint).await + } else if last_update > MIN_RENEW { + // every 5 minutes we should spawn a job to eagerly update the token. - let jwk = entry.key_sets.iter().flat_map(|jwks| &jwks.keys).find(|jwk| { - jwk.prm.kid.as_deref() == Some(kid) && jwk.prm.alg == Some(header.alg) - }); + let entry = entry.clone(); + let client = self.client.clone(); + tokio::spawn(async move { + entry.renew_jwks(&client, endpoint).await; + }); - let key = match jwk { - Some(jwk) if jwk.is_supported() => DecodingKey::from_jwk(jwk).context("invalid key")?, - None if now.duration_since(entry.last_retrieved) > Duration::from_secs(300) => { - // refetch jwks - todo!("fetch jwks again"); - } - _ => { - bail!("jwk not found"); + Guard::into_inner(guard) + } else { + Guard::into_inner(guard) } }; - let mut validation = Validation::new(Algorithm::ES256); - // support all public key algorithms - validation.algorithms = vec![ - Algorithm::ES256, - Algorithm::ES384, - Algorithm::RS256, - Algorithm::RS384, - Algorithm::RS512, - Algorithm::PS256, - Algorithm::PS384, - Algorithm::PS512, - Algorithm::EdDSA, - ]; + // get the key from the JWKs if possible. If not, wait for the keys to update. + let jwk = loop { + let jwk = guard + .key_sets + .iter() + .flat_map(|jwks| &jwks.keys) + .find(|jwk| { + jwk.prm.kid.as_deref() == Some(kid) && jwk.key.is_supported(&header.alg) + }); - // should we require some set of common claims? - // eg `exp`? - validation.required_spec_claims.clear(); - validation.leeway = 60; + match jwk { + Some(jwk) => break jwk, + None if guard.last_retrieved.elapsed() > MIN_RENEW => { + guard = entry.renew_jwks(&self.client, endpoint).await + } + _ => { + bail!("jwk not found"); + } + } + }; - validation.validate_exp = true; - validation.validate_nbf = true; - // we don't have an audience - I want cplane to add this field - validation.validate_aud = false; + let sig = base64::decode_config(signature, base64::URL_SAFE_NO_PAD) + .context("not a valid compact JWT encoding")?; + match &jwk.key { + jose_jwk::Key::Ec(key) => { + verify_ec_signature(header_payload.as_bytes(), &sig, key)?; + } + jose_jwk::Key::Rsa(key) => { + verify_rsa_signature(header_payload.as_bytes(), &sig, key, &jwk.prm.alg)?; + } + jose_jwk::Key::Okp(key) => { + verify_okp_signature(header_payload.as_bytes(), &sig, key)?; + } + key => bail!("unsupported key type {key:?}"), + }; - // subject can be anything - we support all users - validation.sub = None; - // don't know the audience - I want cplane to add this field - validation.aud = None; - // don't know the issuer - I want cplane to add this field - validation.iss = None; - - // ew... - let token: TokenData = - jsonwebtoken::decode(&jwt, &key, &validation).context("invalid token")?; + // TODO: verify iss, exp, nbf, etc... Ok(()) } } +fn verify_ec_signature(data: &[u8], sig: &[u8], key: &jose_jwk::Ec) -> anyhow::Result<()> { + use ecdsa::Signature; + use signature::Verifier; + + match key.crv { + jose_jwk::EcCurves::P256 => { + let pk = + p256::PublicKey::try_from(key).map_err(|_| anyhow::anyhow!("invalid P256 key"))?; + let key = p256::ecdsa::VerifyingKey::from(&pk); + let sig = Signature::from_slice(sig)?; + key.verify(data, &sig)?; + } + jose_jwk::EcCurves::P384 => { + let pk = + p384::PublicKey::try_from(key).map_err(|_| anyhow::anyhow!("invalid P384 key"))?; + let key = p384::ecdsa::VerifyingKey::from(&pk); + let sig = Signature::from_slice(sig)?; + key.verify(data, &sig)?; + } + jose_jwk::EcCurves::P521 => { + ensure!(key.x.len() == 66 && key.y.len() == 66); + let x = GenericArray::from_slice(&key.x); + let y = GenericArray::from_slice(&key.y); + + let encoded = p521::EncodedPoint::from_affine_coordinates(x, y, false); + let key = p521::ecdsa::VerifyingKey::from_encoded_point(&encoded)?; + let sig = Signature::from_slice(sig)?; + key.verify(data, &sig)?; + } + jose_jwk::EcCurves::P256K => { + ensure!(key.x.len() == 32 && key.y.len() == 32); + let x = GenericArray::from_slice(&key.x); + let y = GenericArray::from_slice(&key.y); + + let encoded = k256::EncodedPoint::from_affine_coordinates(x, y, false); + let key = k256::ecdsa::VerifyingKey::from_encoded_point(&encoded)?; + let sig = Signature::from_slice(sig)?; + key.verify(data, &sig)?; + } + key => bail!("unsupported ec key type {key:?}"), + } + + Ok(()) +} + +fn verify_okp_signature(data: &[u8], sig: &[u8], key: &jose_jwk::Okp) -> anyhow::Result<()> { + use ed25519_dalek::Signature; + use signature::Verifier; + + match key.crv { + jose_jwk::OkpCurves::Ed25519 => { + let x = <&[u8; 32]>::try_from(&**key.x)?; + let key = ed25519_dalek::VerifyingKey::from_bytes(x)?; + let sig = Signature::from_slice(sig)?; + key.verify(data, &sig)?; + } + // jose_jwk::OkpCurves::Ed448 => todo!(), + // jose_jwk::OkpCurves::X25519 => todo!(), + // jose_jwk::OkpCurves::X448 => todo!(), + key => bail!("unsupported octet key pair curve type {key:?}"), + } + + Ok(()) +} + +fn verify_rsa_signature( + data: &[u8], + sig: &[u8], + key: &jose_jwk::Rsa, + alg: &Option, +) -> anyhow::Result<()> { + use jose_jwa::{Algorithm, Signing}; + use rsa::{Pkcs1v15Sign, Pss, RsaPublicKey}; + use sha2::Digest; + + let key = RsaPublicKey::try_from(key).map_err(|_| anyhow::anyhow!("invalid RSA key"))?; + + match alg { + Some(Algorithm::Signing(Signing::Ps256)) => { + let hashed = sha2::Sha256::digest(data); + let scheme = Pss::new::(); + key.verify(scheme, &hashed, sig)?; + } + Some(Algorithm::Signing(Signing::Ps384)) => { + let hashed = sha2::Sha384::digest(data); + let scheme = Pss::new::(); + key.verify(scheme, &hashed, sig)?; + } + Some(Algorithm::Signing(Signing::Ps512)) => { + let hashed = sha2::Sha512::digest(data); + let scheme = Pss::new::(); + key.verify(scheme, &hashed, sig)?; + } + Some(Algorithm::Signing(Signing::Rs256)) => { + let hashed = sha2::Sha256::digest(data); + let scheme = Pkcs1v15Sign::new::(); + key.verify(scheme, &hashed, sig)?; + } + Some(Algorithm::Signing(Signing::Rs384)) => { + let hashed = sha2::Sha384::digest(data); + let scheme = Pkcs1v15Sign::new::(); + key.verify(scheme, &hashed, sig)?; + } + Some(Algorithm::Signing(Signing::Rs512)) => { + let hashed = sha2::Sha512::digest(data); + let scheme = Pkcs1v15Sign::new::(); + key.verify(scheme, &hashed, sig)?; + } + _ => bail!("invalid RSA signing algorithm"), + }; + + Ok(()) +} + /// #[derive(serde::Deserialize)] struct JWTHeader<'a> { @@ -134,35 +290,3 @@ struct JWTHeader<'a> { /// key id, must be provided for our usecase kid: Option<&'a str>, } - -enum JwsAlg { - // we do not support symmetric key algorithms such as HMAC. - - // RSASSA PKCS1-v1_5: - /// RSASSA-PKCS1-v1_5 using SHA-256 - RS256, - /// RSASSA-PKCS1-v1_5 using SHA-384 - RS384, - /// RSASSA-PKCS1-v1_5 using SHA-512 - RS512, - - // ECDSA: - /// ECDSA using P-256 and SHA-256 - ES256, - /// ECDSA using P-384 and SHA-384 - ES384, - /// ECDSA using P-521 and SHA-512 - ES512, - - // RSASSA-PSS: - /// RSASSA-PSS using SHA-256 and MGF1 with SHA-256 - PS256, - /// RSASSA-PSS using SHA-384 and MGF1 with SHA-384 - PS384, - /// RSASSA-PSS using SHA-512 and MGF1 with SHA-512 - PS512, - - // EdDSA: - /// Edwards-curve Digital Signature Algorithm (EdDSA) - EdDSA, -}