From 6083fa7649b123837f9c9c7112a2a29b995742dd Mon Sep 17 00:00:00 2001 From: Jakub Konka Date: Fri, 16 May 2025 20:32:34 +0200 Subject: [PATCH 1/7] feat(server): add JWT-based authorization mode This mode is an alternative to whitelist authorization mode. It extracts the JWT from the authorization header (bearer token), validates token's signature, claimed expiry times and additional (user-configurable) claims. --- Cargo.lock | 42 ++++ crates/notary/client/src/client.rs | 10 + crates/notary/server/Cargo.toml | 2 + crates/notary/server/README.md | 50 ++++ crates/notary/server/openapi.yaml | 10 +- crates/notary/server/src/auth.rs | 220 ++++-------------- crates/notary/server/src/auth/jwt.rs | 179 ++++++++++++++ crates/notary/server/src/auth/whitelist.rs | 155 ++++++++++++ crates/notary/server/src/config.rs | 72 +++++- crates/notary/server/src/error.rs | 7 + crates/notary/server/src/lib.rs | 3 +- crates/notary/server/src/middleware.rs | 74 ++++-- crates/notary/server/src/server.rs | 27 ++- crates/notary/server/src/types.rs | 12 +- crates/notary/tests-integration/Cargo.toml | 1 + .../tests-integration/fixture/auth/jwt.key | 51 ++++ .../fixture/auth/jwt.key.pub | 14 ++ .../fixture/config/config.yaml | 2 +- .../notary/tests-integration/tests/notary.rs | 71 ++++-- 19 files changed, 777 insertions(+), 225 deletions(-) create mode 100644 crates/notary/server/src/auth/jwt.rs create mode 100644 crates/notary/server/src/auth/whitelist.rs create mode 100644 crates/notary/tests-integration/fixture/auth/jwt.key create mode 100644 crates/notary/tests-integration/fixture/auth/jwt.key.pub diff --git a/Cargo.lock b/Cargo.lock index 8fd92318a3..00d87a41cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3834,6 +3834,21 @@ dependencies = [ "serde", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "pem", + "ring 0.17.14", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "k256" version = "0.13.4" @@ -4706,6 +4721,7 @@ dependencies = [ "http-body-util", "hyper 1.6.0", "hyper-util", + "jsonwebtoken", "k256", "mc-sgx-dcap-types", "notary-common", @@ -4717,6 +4733,7 @@ dependencies = [ "rustls 0.21.12", "rustls-pemfile", "serde", + "serde_json", "serde_yaml", "sha1", "structopt", @@ -4748,6 +4765,7 @@ dependencies = [ "hyper 1.6.0", "hyper-tls", "hyper-util", + "jsonwebtoken", "notary-client", "notary-common", "notary-server", @@ -5046,6 +5064,16 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" +[[package]] +name = "pem" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38af38e8470ac9dee3ce1bae1af9c1671fffc44ddfd8bd1d0a3445bf349a8ef3" +dependencies = [ + "base64 0.22.1", + "serde", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -6531,6 +6559,18 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +[[package]] +name = "simple_asn1" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.12", + "time", +] + [[package]] name = "simplecss" version = "0.2.2" @@ -7167,11 +7207,13 @@ dependencies = [ "futures 0.3.31", "mpz-common", "mpz-core", + "mpz-hash", "mpz-memory-core", "mpz-vm-core", "mpz-zk", "once_cell", "opaque-debug", + "rand 0.9.1", "rangeset", "rstest", "semver 1.0.26", diff --git a/crates/notary/client/src/client.rs b/crates/notary/client/src/client.rs index da3a531c74..46a3693e77 100644 --- a/crates/notary/client/src/client.rs +++ b/crates/notary/client/src/client.rs @@ -7,6 +7,7 @@ use http_body_util::{BodyExt as _, Either, Empty, Full}; use hyper::{ body::{Bytes, Incoming}, client::conn::http1::Parts, + header::AUTHORIZATION, Request, Response, StatusCode, }; use hyper_util::rt::TokioIo; @@ -137,6 +138,10 @@ pub struct NotaryClient { /// in notary server. #[builder(setter(into, strip_option), default)] api_key: Option, + /// JWT token used to callnotary server endpoints if JWT authorization is enabled + /// in notary server. + #[builder(setter(into, strip_option), default)] + jwt: Option, /// The duration of notarization request timeout in seconds. #[builder(default = "60")] request_timeout: usize, @@ -291,6 +296,11 @@ impl NotaryClient { configuration_request_builder.header(X_API_KEY_HEADER, api_key); } + if let Some(jwt) = &self.jwt { + configuration_request_builder = + configuration_request_builder.header(AUTHORIZATION, format!("Bearer {jwt}")); + } + let configuration_request = configuration_request_builder .body(Either::Left(Full::new(Bytes::from( configuration_request_payload, diff --git a/crates/notary/server/Cargo.toml b/crates/notary/server/Cargo.toml index bc6cb0b8ae..607ce6c4d4 100644 --- a/crates/notary/server/Cargo.toml +++ b/crates/notary/server/Cargo.toml @@ -32,6 +32,7 @@ http = { workspace = true } http-body-util = { workspace = true } hyper = { workspace = true, features = ["client", "http1", "server"] } hyper-util = { workspace = true, features = ["full"] } +jsonwebtoken = { version = "9.3.1", features = ["use_pem"] } k256 = { workspace = true } notify = { version = "6.1.1", default-features = false, features = [ "macos_kqueue", @@ -43,6 +44,7 @@ rand06-compat = { workspace = true } rustls = { workspace = true } rustls-pemfile = { workspace = true } serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } serde_yaml = { version = "0.9" } sha1 = { version = "0.10" } structopt = { version = "0.3" } diff --git a/crates/notary/server/README.md b/crates/notary/server/README.md index 92339c1b66..c2f0378c9c 100644 --- a/crates/notary/server/README.md +++ b/crates/notary/server/README.md @@ -193,6 +193,56 @@ The main objective of a notary server is to perform notarizations together with 1. TCP client — which has access and control over the transport layer, i.e. TCP. 2. WebSocket client — which has no access over TCP and instead uses WebSocket for notarizations. +### Features +#### Notarization Configuration +To perform a notarization, some parameters need to be configured by the prover and the notary server (more details in the [OpenAPI specification](./openapi.yaml)), i.e. +- maximum data that can be sent and received +- unique session id + +To streamline this process, a single HTTP endpoint (`/session`) is used by both TCP and WebSocket clients. + +#### Notarization +After calling the configuration endpoint above, the prover can proceed to start the notarization. For a TCP client, that means calling the `/notarize` endpoint using HTTP (`https`), while a WebSocket client should call the same endpoint but using WebSocket (`wss`). Example implementations of these clients can be found in the [integration test](../tests-integration/tests/notary.rs). + +#### Signatures +Currently, both the private key (and cert) used to establish a TLS connection with the prover, and the private key used by the notary server to sign the notarized transcript, are hardcoded PEM keys stored in this repository. Though the paths of these keys can be changed in the config (`notary-key` field) to use different keys instead. + +#### Authorization +An optional authorization module is available to only allow requests with a valid credential attached. Currently, two modes are supported: whitelist and JWT. + +Please note that only *one* mode can be active at any one time. + +##### Whitelist mode +In whitelist mode, an API key is attached in the custom HTTP header `X-API-Key`. The API key whitelist path (as well as the flag to enable/disable this module) can be changed in the config (`authorization` field). + +Hot reloading of the whitelist is supported, i.e. modification of the whitelist file will be automatically applied without needing to restart the server. Please take note of the following +- Avoid using auto save mode when editing the whitelist to prevent spamming hot reloads +- Once the edit is saved, ensure that it has been reloaded successfully by checking the server log + +##### JWT mode +In JWT mode, JSON Web Token is attached in the standard `Authorization` HTTP header as a bearer token. The path to decoding key as well as custom user claims can be changed in the +config (`authorization` field). + +An example JWT config may look something like this: + +```yaml +authorization: + enabled: true + jwt: + algorithm: "RS256" + public_key_path: "./fixture/auth/jwt.key.pub" + claims: + - name: sub + values: ["tlsnotary"] +``` + +#### Optional TLS +TLS between the prover and the notary is currently manually handled in this server, though it can be turned off if any of the following is true +- This server is run locally +- TLS is to be handled by an external environment, e.g. reverse proxy, cloud setup + +The toggle to turn on/off TLS is in the config (`tls` field). Alternatively, use the CLI argument `--tls-enabled` (see [this](#configuration)). + ### Design Choices #### Web Framework Axum is chosen as the framework to serve HTTP and WebSocket requests from the prover clients due to its rich and well supported features, e.g. native integration with Tokio/Hyper/Tower, customizable middleware, the ability to support lower level integrations of TLS ([example](https://github.com/tokio-rs/axum/blob/main/examples/low-level-rustls/src/main.rs)). To simplify the notary server setup, a single Axum router is used to support both HTTP and WebSocket connections, i.e. all requests can be made to the same port of the notary server. diff --git a/crates/notary/server/openapi.yaml b/crates/notary/server/openapi.yaml index 1d8144d35f..673d70cab0 100644 --- a/crates/notary/server/openapi.yaml +++ b/crates/notary/server/openapi.yaml @@ -15,6 +15,7 @@ paths: security: - {} # make security optional - ApiKeyAuth: [] + - BearerAuth: [] responses: '200': description: Ok response from server @@ -38,6 +39,7 @@ paths: security: - {} # make security optional - ApiKeyAuth: [] + - BearerAuth: [] responses: '200': description: Info response from server @@ -60,6 +62,7 @@ paths: security: - {} # make security optional - ApiKeyAuth: [] + - BearerAuth: [] parameters: - in: header name: Content-Type @@ -212,4 +215,9 @@ components: type: apiKey in: header name: X-API-Key - description: Whitelisted API key if auth module is turned on + description: Whitelisted API key if auth module is turned on and in whitelist mode + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: JSON Web Token if auth module is turned on and in JWT mode diff --git a/crates/notary/server/src/auth.rs b/crates/notary/server/src/auth.rs index 28cc6e830a..f1caa68337 100644 --- a/crates/notary/server/src/auth.rs +++ b/crates/notary/server/src/auth.rs @@ -1,189 +1,67 @@ +pub(crate) mod jwt; +pub(crate) mod whitelist; + use eyre::{eyre, Result}; -use notify::{ - event::ModifyKind, Error, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher, -}; -use serde::{Deserialize, Serialize}; +use jwt::load_jwt_key; use std::{ collections::HashMap, - path::Path, sync::{Arc, Mutex}, }; -use tracing::{debug, error, info}; +use tracing::debug; +use whitelist::load_authorization_whitelist; -use crate::{util::parse_csv_file, NotaryServerProperties}; +pub use jwt::{Jwt, JwtError}; +pub use whitelist::{watch_and_reload_authorization_whitelist, AuthorizationWhitelistRecord}; -/// Structure of each whitelisted record of the API key whitelist for -/// authorization purpose -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "PascalCase")] -pub struct AuthorizationWhitelistRecord { - pub name: String, - pub api_key: String, - pub created_at: String, +use crate::{AuthorizationModeProperties, NotaryServerProperties}; + +/// Supported authorization modes. +#[derive(Clone)] +pub enum AuthorizationMode { + Jwt(Jwt), + Whitelist(Arc>>), } -/// Convert whitelist data structure from vector to hashmap using api_key as the -/// key to speed up lookup -pub fn authorization_whitelist_vec_into_hashmap( - authorization_whitelist: Vec, -) -> HashMap { - let mut hashmap = HashMap::new(); - authorization_whitelist.iter().for_each(|record| { - hashmap.insert(record.api_key.clone(), record.to_owned()); - }); - hashmap +impl AuthorizationMode { + pub fn as_whitelist( + &self, + ) -> Option>>> { + match self { + Self::Jwt(..) => None, + Self::Whitelist(whitelist) => Some(whitelist.clone()), + } + } } -/// Load authorization whitelist if it is enabled -pub fn load_authorization_whitelist( +/// Load authorization mode if it is enabled +pub async fn load_authorization_mode( config: &NotaryServerProperties, -) -> Result>> { - let authorization_whitelist = if !config.auth.enabled { +) -> Result> { + if !config.auth.enabled { debug!("Skipping authorization as it is turned off."); - None - } else { - // Check if whitelist_csv_path is Some and convert to &str - let whitelist_csv_path = config.auth.whitelist_path.as_deref().ok_or_else(|| { - eyre!("Authorization whitelist csv path is not provided in the config") - })?; - // Load the csv - let whitelist_csv = parse_csv_file::(whitelist_csv_path) - .map_err(|err| eyre!("Failed to parse authorization whitelist csv: {:?}", err))?; - // Convert the whitelist record into hashmap for faster lookup - let whitelist_hashmap = authorization_whitelist_vec_into_hashmap(whitelist_csv); - Some(whitelist_hashmap) - }; - Ok(authorization_whitelist) -} - -// Setup a watcher to detect any changes to authorization whitelist -// When the list file is modified, the watcher thread will reload the whitelist -// The watcher is setup in a separate thread by the notify library which is -// synchronous -pub fn watch_and_reload_authorization_whitelist( - config: NotaryServerProperties, - authorization_whitelist: Option>>>, -) -> Result> { - // Only setup the watcher if auth whitelist is loaded - let watcher = if let Some(authorization_whitelist) = authorization_whitelist { - let cloned_config = config.clone(); - // Setup watcher by giving it a function that will be triggered when an event is - // detected - let mut watcher = RecommendedWatcher::new( - move |event: Result| { - match event { - Ok(event) => { - // Only reload whitelist if it's an event that modified the file data - if let EventKind::Modify(ModifyKind::Data(_)) = event.kind { - debug!("Authorization whitelist is modified"); - match load_authorization_whitelist(&cloned_config) { - Ok(Some(new_authorization_whitelist)) => { - *authorization_whitelist.lock().unwrap() = new_authorization_whitelist; - info!("Successfully reloaded authorization whitelist!"); - } - Ok(None) => unreachable!( - "Authorization whitelist will never be None as the auth module is enabled" - ), - // Ensure that error from reloading doesn't bring the server down - Err(err) => error!("{err}"), - } - } - }, - Err(err) => { - error!("Error occured when watcher detected an event: {err}") - } - } - }, - notify::Config::default(), - ) - .map_err(|err| eyre!("Error occured when setting up watcher for hot reload: {err}"))?; - - // Check if whitelist_csv_path is Some and convert to &str - let whitelist_csv_path = config.auth.whitelist_path.as_deref().ok_or_else(|| { - eyre!("Authorization whitelist csv path is not provided in the config") - })?; - - // Start watcher to listen to any changes on the whitelist file - watcher - .watch(Path::new(whitelist_csv_path), RecursiveMode::Recursive) - .map_err(|err| eyre!("Error occured when starting up watcher for hot reload: {err}"))?; - - Some(watcher) - } else { - // Skip setup the watcher if auth whitelist is not loaded - None - }; - // Need to return the watcher to parent function, else it will be dropped and - // stop listening - Ok(watcher) -} - -#[cfg(test)] -mod test { - use std::{fs::OpenOptions, time::Duration}; - - use csv::WriterBuilder; - - use crate::AuthorizationProperties; - - use super::*; - - #[tokio::test] - async fn test_watch_and_reload_authorization_whitelist() { - // Clone fixture auth whitelist for testing - let original_whitelist_csv_path = "../tests-integration/fixture/auth/whitelist.csv"; - let whitelist_csv_path = - "../tests-integration/fixture/auth/whitelist_copied.csv".to_string(); - std::fs::copy(original_whitelist_csv_path, &whitelist_csv_path).unwrap(); + return Ok(None); + } - // Setup watcher - let config = NotaryServerProperties { - auth: AuthorizationProperties { - enabled: true, - whitelist_path: Some(whitelist_csv_path.clone()), - }, - ..Default::default() - }; - let authorization_whitelist = load_authorization_whitelist(&config) - .expect("Authorization whitelist csv from fixture should be able to be loaded") - .as_ref() - .map(|whitelist| Arc::new(Mutex::new(whitelist.clone()))); - let _watcher = watch_and_reload_authorization_whitelist( - config.clone(), - authorization_whitelist.as_ref().map(Arc::clone), + let auth_mode = match config.auth.mode.as_ref().ok_or_else(|| { + eyre!( + "Authorization enabled but neither whitelist nor jwt properties provided in the config" ) - .expect("Watcher should be able to be setup successfully") - .expect("Watcher should be set up and not None"); - - // Sleep to buy a bit of time for hot reload task and watcher thread to run - tokio::time::sleep(Duration::from_millis(50)).await; - - // Write a new record to the whitelist to trigger modify event - let new_record = AuthorizationWhitelistRecord { - name: "unit-test-name".to_string(), - api_key: "unit-test-api-key".to_string(), - created_at: "unit-test-created-at".to_string(), - }; - if let Some(ref path) = config.auth.whitelist_path { - let file = OpenOptions::new().append(true).open(path).unwrap(); - let mut wtr = WriterBuilder::new() - .has_headers(false) // Set to false to avoid writing header again - .from_writer(file); - wtr.serialize(new_record).unwrap(); - wtr.flush().unwrap(); - } else { - panic!("Whitelist CSV path should be provided in the config"); + })? { + AuthorizationModeProperties::Jwt(jwt_opts) => { + let algorithm = jwt_opts.algorithm; + let claims = jwt_opts.claims.clone(); + let key = load_jwt_key(&jwt_opts.public_key_path, algorithm).await?; + AuthorizationMode::Jwt(Jwt { + key, + claims, + algorithm, + }) } - // Sleep to buy a bit of time for updated whitelist to be hot reloaded - tokio::time::sleep(Duration::from_millis(50)).await; - - assert!(authorization_whitelist - .unwrap() - .lock() - .unwrap() - .contains_key("unit-test-api-key")); + AuthorizationModeProperties::Whitelist(whitelist_csv_path) => { + let whitelist = load_authorization_whitelist(whitelist_csv_path)?; + AuthorizationMode::Whitelist(Arc::new(Mutex::new(whitelist))) + } + }; - // Delete the cloned whitelist - std::fs::remove_file(&whitelist_csv_path).unwrap(); - } + Ok(Some(auth_mode)) } diff --git a/crates/notary/server/src/auth/jwt.rs b/crates/notary/server/src/auth/jwt.rs new file mode 100644 index 0000000000..81316ff63f --- /dev/null +++ b/crates/notary/server/src/auth/jwt.rs @@ -0,0 +1,179 @@ +use eyre::Result; +use jsonwebtoken::{Algorithm, DecodingKey}; +use serde_json::Value; +use std::io::Read; +use tracing::error; + +use crate::{read_pem_file, JwtClaim, JwtClaimValueType}; + +/// Custom error for JWT handling +#[derive(Debug, thiserror::Error, PartialEq)] +pub enum JwtError { + #[error("unsupported algorithm: {0:?}")] + UnsupportedAlgorithm(Algorithm), + #[error("JWT validation error: {0}")] + Validation(String), +} + +type JwtResult = std::result::Result; + +/// JWT config which also encapsulates claims validation logic. +#[derive(Clone)] +pub struct Jwt { + pub algorithm: Algorithm, + pub key: DecodingKey, + pub claims: Vec, +} + +impl Jwt { + pub fn validate(&self, claims: Value) -> JwtResult<()> { + Jwt::validate_claims(&self.claims, claims) + } + + fn validate_claims(expected: &[JwtClaim], claims: Value) -> JwtResult<()> { + expected + .iter() + .try_for_each(|expected| Self::validate_claim(expected, claims.clone())) + } + + fn validate_claim(expected: &JwtClaim, given: Value) -> JwtResult<()> { + let field = Jwt::get_field(&expected.name, &given).ok_or(JwtError::Validation(format!( + "missing claim '{}'", + expected.name + )))?; + + match expected.value_type { + JwtClaimValueType::String => { + let field_typed = field.as_str().ok_or(JwtError::Validation(format!( + "unexpected type for claim '{}': expected '{:?}'", + expected.name, expected.value_type + )))?; + if !expected.values.is_empty() { + expected.values.iter().any(|exp| exp == field_typed).then_some(()).ok_or_else(|| { + let expected_values = expected.values.iter().map(|x| format!("'{x}'")).collect::>().join(", "); + JwtError::Validation(format!( + "unexpected value for claim '{}': expected one of [ {expected_values} ], received '{field_typed}'", expected.name + )) + })?; + } + } + } + + Ok(()) + } + + fn get_field<'a>(path: &'a str, value: &'a Value) -> Option<&'a Value> { + let (field, path) = match path.split_once('.') { + Some((field, path)) => (field, Some(path)), + None => (path, None), + }; + if let Some(value) = value.get(field) { + match path { + Some(path) => Jwt::get_field(path, value), + None => Some(value), + } + } else { + None + } + } +} + +/// Load JWT public key +pub(super) async fn load_jwt_key( + public_key_pem_path: &str, + algorithm: Algorithm, +) -> Result { + let mut reader = read_pem_file(public_key_pem_path).await?; + let mut key_pem_bytes: Vec = Vec::new(); + reader.read_to_end(&mut key_pem_bytes)?; + let key = match algorithm { + Algorithm::RS256 + | Algorithm::RS384 + | Algorithm::RS512 + | Algorithm::PS256 + | Algorithm::PS384 + | Algorithm::PS512 => DecodingKey::from_rsa_pem(&key_pem_bytes)?, + Algorithm::ES256 | Algorithm::ES384 => DecodingKey::from_ec_pem(&key_pem_bytes)?, + Algorithm::EdDSA => DecodingKey::from_ed_pem(&key_pem_bytes)?, + _ => return Err(JwtError::UnsupportedAlgorithm(algorithm).into()), + }; + Ok(key) +} + +#[cfg(test)] +mod test { + use super::*; + + use serde_json::json; + + #[test] + fn validates_presence() { + let expected = JwtClaim { + name: "sub".to_string(), + ..Default::default() + }; + let given = json!({ + "exp": 12345, + "sub": "test", + }); + assert!(Jwt::validate_claim(&expected, given).is_ok()); + } + + #[test] + fn validates_expected_value() { + let expected = JwtClaim { + name: "custom.host".to_string(), + values: vec!["tlsn.com".to_string(), "api.tlsn.com".to_string()], + ..Default::default() + }; + let given = json!({ + "exp": 12345, + "custom": { + "host": "api.tlsn.com", + }, + }); + assert!(Jwt::validate_claim(&expected, given).is_ok()) + } + + #[test] + fn validates_with_unknown_claims() { + let given = json!({ + "exp": 12345, + "sub": "test", + "what": "is_this", + }); + assert!(Jwt::validate_claims(&[], given).is_ok()) + } + + #[test] + fn fails_if_claim_missing() { + let expected = JwtClaim { + name: "sub".to_string(), + ..Default::default() + }; + let given = json!({ + "exp": 12345, + "host": "localhost", + }); + assert_eq!( + Jwt::validate_claim(&expected, given), + Err(JwtError::Validation("missing claim 'sub'".to_string())) + ) + } + + #[test] + fn fails_if_claim_has_unknown_value() { + let expected = JwtClaim { + name: "sub".to_string(), + values: vec!["tlsn_prod".to_string(), "tlsn_test".to_string()], + ..Default::default() + }; + let given = json!({ + "sub": "tlsn", + }); + assert_eq!( + Jwt::validate_claim(&expected, given), + Err(JwtError::Validation("unexpected value for claim 'sub': expected one of [ 'tlsn_prod', 'tlsn_test' ], received 'tlsn'".to_string())) + ) + } +} diff --git a/crates/notary/server/src/auth/whitelist.rs b/crates/notary/server/src/auth/whitelist.rs new file mode 100644 index 0000000000..951a9ae5f9 --- /dev/null +++ b/crates/notary/server/src/auth/whitelist.rs @@ -0,0 +1,155 @@ +use eyre::{eyre, Result}; +use notify::{ + event::ModifyKind, Error, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher, +}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::HashMap, + path::Path, + sync::{Arc, Mutex}, +}; +use tracing::{debug, error, info}; + +use crate::util::parse_csv_file; + +/// Structure of each whitelisted record of the API key whitelist for +/// authorization purpose +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct AuthorizationWhitelistRecord { + pub name: String, + pub api_key: String, + pub created_at: String, +} + +/// Convert whitelist data structure from vector to hashmap using api_key as the +/// key to speed up lookup +pub(crate) fn authorization_whitelist_vec_into_hashmap( + authorization_whitelist: Vec, +) -> HashMap { + let mut hashmap = HashMap::new(); + authorization_whitelist.iter().for_each(|record| { + hashmap.insert(record.api_key.clone(), record.to_owned()); + }); + hashmap +} + +/// Load authorization whitelist +pub(super) fn load_authorization_whitelist( + whitelist_csv_path: &str, +) -> Result> { + // Load the csv + let whitelist_csv = parse_csv_file::(whitelist_csv_path) + .map_err(|err| eyre!("Failed to parse authorization whitelist csv: {:?}", err))?; + // Convert the whitelist record into hashmap for faster lookup + let whitelist_hashmap = authorization_whitelist_vec_into_hashmap(whitelist_csv); + Ok(whitelist_hashmap) +} + +// Setup a watcher to detect any changes to authorization whitelist +// When the list file is modified, the watcher thread will reload the whitelist +// The watcher is setup in a separate thread by the notify library which is +// synchronous +pub fn watch_and_reload_authorization_whitelist( + authorization_whitelist: Arc>>, + whitelist_csv_path: String, +) -> Result { + let whitelist_csv_path_cloned = whitelist_csv_path.clone(); + // Setup watcher by giving it a function that will be triggered when an event is + // detected + let mut watcher = RecommendedWatcher::new( + move |event: Result| { + match event { + Ok(event) => { + // Only reload whitelist if it's an event that modified the file data + if let EventKind::Modify(ModifyKind::Data(_)) = event.kind { + debug!("Authorization whitelist is modified"); + match load_authorization_whitelist(&whitelist_csv_path_cloned) { + Ok(new_authorization_whitelist) => { + *authorization_whitelist.lock().unwrap() = + new_authorization_whitelist; + info!("Successfully reloaded authorization whitelist!"); + } + // Ensure that error from reloading doesn't bring the server down + Err(err) => error!("{err}"), + } + } + } + Err(err) => { + error!("Error occured when watcher detected an event: {err}") + } + } + }, + notify::Config::default(), + ) + .map_err(|err| eyre!("Error occured when setting up watcher for hot reload: {err}"))?; + + // Start watcher to listen to any changes on the whitelist file + watcher + .watch(Path::new(&whitelist_csv_path), RecursiveMode::Recursive) + .map_err(|err| eyre!("Error occured when starting up watcher for hot reload: {err}"))?; + + // Need to return the watcher to parent function, else it will be dropped and + // stop listening + Ok(watcher) +} + +#[cfg(test)] +mod test { + use std::{fs::OpenOptions, time::Duration}; + + use csv::WriterBuilder; + + use super::*; + + #[tokio::test] + async fn test_watch_and_reload_authorization_whitelist() { + // Clone fixture auth whitelist for testing + let original_whitelist_csv_path = "../tests-integration/fixture/auth/whitelist.csv"; + let whitelist_csv_path = + "../tests-integration/fixture/auth/whitelist_copied.csv".to_string(); + std::fs::copy(original_whitelist_csv_path, &whitelist_csv_path).unwrap(); + + // Setup watcher + let authorization_whitelist = load_authorization_whitelist(&whitelist_csv_path).expect( + "Authorization whitelist csv from fixture should be able + to be loaded", + ); + let authorization_whitelist = Arc::new(Mutex::new(authorization_whitelist)); + let _watcher = watch_and_reload_authorization_whitelist( + authorization_whitelist.clone(), + whitelist_csv_path.clone(), + ) + .expect("Watcher should be able to be setup successfully"); + + // Sleep to buy a bit of time for hot reload task and watcher thread to run + tokio::time::sleep(Duration::from_millis(50)).await; + + // Write a new record to the whitelist to trigger modify event + let new_record = AuthorizationWhitelistRecord { + name: "unit-test-name".to_string(), + api_key: "unit-test-api-key".to_string(), + created_at: "unit-test-created-at".to_string(), + }; + let file = OpenOptions::new() + .append(true) + .open(&whitelist_csv_path) + .unwrap(); + let mut wtr = WriterBuilder::new() + .has_headers(false) // Set to false to avoid writing header again + .from_writer(file); + wtr.serialize(new_record).unwrap(); + wtr.flush().unwrap(); + + // Sleep to buy a bit of time for updated whitelist to be hot reloaded + tokio::time::sleep(Duration::from_millis(50)).await; + + assert!(authorization_whitelist + .lock() + .unwrap() + .contains_key("unit-test-api-key")); + + // Delete the cloned whitelist + std::fs::remove_file(&whitelist_csv_path).unwrap(); + } +} diff --git a/crates/notary/server/src/config.rs b/crates/notary/server/src/config.rs index f18094c3ed..13f6e6de16 100644 --- a/crates/notary/server/src/config.rs +++ b/crates/notary/server/src/config.rs @@ -1,5 +1,6 @@ use config::{Config, Environment}; use eyre::{eyre, Result}; +use jsonwebtoken::Algorithm; use serde::{Deserialize, Serialize}; use std::path::Path; @@ -53,8 +54,24 @@ impl NotaryServerProperties { config.tls.certificate_path = Some(prepend_file_path(path, &parent_dir)?); } // Prepend auth whitelist path. - if let Some(path) = &config.auth.whitelist_path { - config.auth.whitelist_path = Some(prepend_file_path(path, &parent_dir)?); + if let Some(mode) = &config.auth.mode { + config.auth.mode = Some(match mode { + AuthorizationModeProperties::Jwt(JwtAuthorizationProperties { + algorithm, + public_key_path, + claims, + }) => AuthorizationModeProperties::Jwt(JwtAuthorizationProperties { + algorithm: algorithm.clone(), + public_key_path: prepend_file_path(public_key_path, &parent_dir)?, + claims: claims.clone(), + }), + AuthorizationModeProperties::Whitelist(path) => { + AuthorizationModeProperties::Whitelist(prepend_file_path( + path, + &parent_dir, + )?) + } + }); } Ok(config) @@ -134,8 +151,57 @@ pub struct LogProperties { pub struct AuthorizationProperties { /// Flag to turn on or off auth middleware pub enabled: bool, + /// Authorization mode to use: JWT or whitelist + #[serde(flatten)] + pub mode: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum AuthorizationModeProperties { + /// JWT authorization properties + Jwt(JwtAuthorizationProperties), /// File path of the API key whitelist (in CSV format) - pub whitelist_path: Option, + Whitelist(String), +} + +impl AuthorizationModeProperties { + pub fn as_whitelist(&self) -> Option { + match self { + Self::Jwt(..) => None, + Self::Whitelist(path) => Some(path.clone()), + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +pub struct JwtAuthorizationProperties { + /// Algorithm used for signing the JWT + pub algorithm: Algorithm, + /// File path to JWT public key (in PEM format) for verifying token signatures + pub public_key_path: String, + /// Set of required JWT claims + #[serde(default)] + pub claims: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +pub struct JwtClaim { + /// Name of the claim + pub name: String, + /// Optional set of expected values for the claim + #[serde(default)] + pub values: Vec, + /// Optional expected type for the claim + #[serde(default)] + pub value_type: JwtClaimValueType, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +#[serde(rename_all = "kebab-case")] +pub enum JwtClaimValueType { + #[default] + String, } impl Default for NotaryServerProperties { diff --git a/crates/notary/server/src/error.rs b/crates/notary/server/src/error.rs index 4b5ec6691b..b90769e844 100644 --- a/crates/notary/server/src/error.rs +++ b/crates/notary/server/src/error.rs @@ -6,6 +6,8 @@ use tlsn_common::config::ProtocolConfigValidatorBuilderError; use tlsn_verifier::{VerifierConfigBuilderError, VerifierError}; +use crate::auth::JwtError; + #[derive(Debug, thiserror::Error)] pub enum NotaryServerError { #[error(transparent)] @@ -18,6 +20,8 @@ pub enum NotaryServerError { BadProverRequest(String), #[error("Unauthorized request from prover: {0}")] UnauthorizedProverRequest(String), + #[error(transparent)] + Jwt(#[from] JwtError), } impl From for NotaryServerError { @@ -50,6 +54,9 @@ impl AxumCoreIntoResponse for NotaryServerError { unauthorized_request_error.to_string(), ) .into_response(), + jwt_error @ NotaryServerError::Jwt(..) => { + (StatusCode::UNAUTHORIZED, jwt_error.to_string()).into_response() + } _ => ( StatusCode::INTERNAL_SERVER_ERROR, "Something wrong happened.", diff --git a/crates/notary/server/src/lib.rs b/crates/notary/server/src/lib.rs index 2b6f7fda19..7097ca2601 100644 --- a/crates/notary/server/src/lib.rs +++ b/crates/notary/server/src/lib.rs @@ -14,7 +14,8 @@ mod util; pub use cli::CliFields; pub use config::{ - AuthorizationProperties, LogProperties, NotarizationProperties, NotaryServerProperties, + AuthorizationModeProperties, AuthorizationProperties, JwtAuthorizationProperties, JwtClaim, + JwtClaimValueType, LogProperties, NotarizationProperties, NotaryServerProperties, TLSProperties, }; pub use error::NotaryServerError; diff --git a/crates/notary/server/src/middleware.rs b/crates/notary/server/src/middleware.rs index 25e0da452b..7de934ad5c 100644 --- a/crates/notary/server/src/middleware.rs +++ b/crates/notary/server/src/middleware.rs @@ -1,10 +1,16 @@ -use axum::http::request::Parts; +use axum::http::{header, request::Parts}; use axum_core::extract::{FromRef, FromRequestParts}; +use jsonwebtoken::{decode, TokenData, Validation}; use notary_common::X_API_KEY_HEADER; +use serde_json::Value; use std::collections::HashMap; use tracing::{error, trace}; -use crate::{auth::AuthorizationWhitelistRecord, types::NotaryGlobals, NotaryServerError}; +use crate::{ + auth::{AuthorizationMode, AuthorizationWhitelistRecord}, + types::NotaryGlobals, + NotaryServerError, +}; /// Auth middleware to prevent DOS pub struct AuthorizationMiddleware; @@ -18,36 +24,66 @@ where async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { let notary_globals = NotaryGlobals::from_ref(state); - let Some(whitelist) = notary_globals.authorization_whitelist else { - trace!("Skipping authorization as whitelist is not set."); + let Some(mode) = notary_globals.authorization_mode else { + trace!("Skipping authorization as not enabled."); return Ok(Self); }; - let auth_header = parts - .headers - .get(X_API_KEY_HEADER) - .and_then(|value| std::str::from_utf8(value.as_bytes()).ok()); - match auth_header { - Some(auth_header) => { + match mode { + AuthorizationMode::Whitelist(whitelist) => { + let Some(auth_header) = parts + .headers + .get(X_API_KEY_HEADER) + .and_then(|value| std::str::from_utf8(value.as_bytes()).ok()) + else { + return Err(missing_api_key()); + }; let whitelist = whitelist.lock().unwrap(); if api_key_is_valid(auth_header, &whitelist) { trace!("Request authorized."); Ok(Self) } else { - let err_msg = "Invalid API key.".to_string(); - error!(err_msg); - Err(NotaryServerError::UnauthorizedProverRequest(err_msg)) + Err(invalid_api_key()) } } - None => { - let err_msg = "Missing API key.".to_string(); - error!(err_msg); - Err(NotaryServerError::UnauthorizedProverRequest(err_msg)) + AuthorizationMode::Jwt(jwt_config) => { + let Some(auth_header) = parts + .headers + .get(header::AUTHORIZATION) + .and_then(|value| std::str::from_utf8(value.as_bytes()).ok()) + else { + return Err(missing_api_key()); + }; + let raw_token = auth_header + .strip_prefix("Bearer ") + .ok_or_else(invalid_api_key)?; + let mut validation = Validation::new(jwt_config.algorithm); + validation.validate_exp = true; + let TokenData { claims, .. } = + decode::(raw_token, &jwt_config.key, &validation).map_err(|err| { + error!("{err:#?}"); + invalid_api_key() + })?; + jwt_config.validate(claims)?; + trace!("Request authorized."); + Ok(Self) } } } } +fn missing_api_key() -> NotaryServerError { + let err_msg = "Missing API key.".to_string(); + error!(err_msg); + NotaryServerError::UnauthorizedProverRequest(err_msg) +} + +fn invalid_api_key() -> NotaryServerError { + let err_msg = "Invalid API key.".to_string(); + error!(err_msg); + NotaryServerError::UnauthorizedProverRequest(err_msg) +} + /// Helper function to check if an API key is in whitelist fn api_key_is_valid( api_key: &str, @@ -59,7 +95,9 @@ fn api_key_is_valid( #[cfg(test)] mod test { use super::{api_key_is_valid, HashMap}; - use crate::auth::{authorization_whitelist_vec_into_hashmap, AuthorizationWhitelistRecord}; + use crate::auth::{ + whitelist::authorization_whitelist_vec_into_hashmap, AuthorizationWhitelistRecord, + }; use std::sync::Arc; fn get_whitelist_fixture() -> HashMap { diff --git a/crates/notary/server/src/server.rs b/crates/notary/server/src/server.rs index 5354a0828d..b10754dbb2 100644 --- a/crates/notary/server/src/server.rs +++ b/crates/notary/server/src/server.rs @@ -17,7 +17,7 @@ use std::{ io::BufReader, net::{IpAddr, SocketAddr}, pin::Pin, - sync::{Arc, Mutex}, + sync::Arc, }; use tlsn_core::CryptoProvider; use tokio::{fs::File, io::AsyncReadExt, net::TcpListener}; @@ -28,8 +28,8 @@ use tracing::{debug, error, info, warn}; use zeroize::Zeroize; use crate::{ - auth::{load_authorization_whitelist, watch_and_reload_authorization_whitelist}, - config::{NotarizationProperties, NotaryServerProperties}, + auth::{load_authorization_mode, watch_and_reload_authorization_whitelist, AuthorizationMode}, + config::{AuthorizationModeProperties, NotarizationProperties, NotaryServerProperties}, error::NotaryServerError, middleware::AuthorizationMiddleware, service::{initialize, upgrade_protocol}, @@ -87,12 +87,21 @@ pub async fn run_server(config: &NotaryServerProperties) -> Result<(), NotarySer Some(TlsAcceptor::from(tls_config)) }; - // Load the authorization whitelist csv if it is turned on - let authorization_whitelist = - load_authorization_whitelist(config)?.map(|whitelist| Arc::new(Mutex::new(whitelist))); + // Set up authorization if it is turned on + let authorization_mode = load_authorization_mode(config).await?; // Enable hot reload if authorization whitelist is available - let watcher = - watch_and_reload_authorization_whitelist(config.clone(), authorization_whitelist.clone())?; + let watcher = authorization_mode + .as_ref() + .and_then(AuthorizationMode::as_whitelist) + .zip( + config + .auth + .mode + .as_ref() + .and_then(AuthorizationModeProperties::as_whitelist), + ) + .map(|(whitelist, path)| watch_and_reload_authorization_whitelist(whitelist, path)) + .transpose()?; if watcher.is_some() { debug!("Successfully setup watcher for hot reload of authorization whitelist!"); } @@ -113,7 +122,7 @@ pub async fn run_server(config: &NotaryServerProperties) -> Result<(), NotarySer let notary_globals = NotaryGlobals::new( Arc::new(crypto_provider), config.notarization.clone(), - authorization_whitelist, + authorization_mode, Arc::new(Semaphore::new(config.concurrency)), ); diff --git a/crates/notary/server/src/types.rs b/crates/notary/server/src/types.rs index 4d4704a445..ddea516dc2 100644 --- a/crates/notary/server/src/types.rs +++ b/crates/notary/server/src/types.rs @@ -8,7 +8,7 @@ use tokio::sync::Semaphore; #[cfg(feature = "tee_quote")] use crate::tee::Quote; -use crate::{auth::AuthorizationWhitelistRecord, config::NotarizationProperties}; +use crate::{auth::AuthorizationMode, config::NotarizationProperties}; /// Response object of the /info API #[derive(Debug, Clone, Serialize, Deserialize)] @@ -34,14 +34,14 @@ pub struct NotarizationRequestQuery { } /// Global data that needs to be shared with the axum handlers -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct NotaryGlobals { pub crypto_provider: Arc, pub notarization_config: NotarizationProperties, /// A temporary storage to store session_id pub store: Arc>>, - /// Whitelist of API keys for authorization purpose - pub authorization_whitelist: Option>>>, + /// Selected authorization mode if any + pub authorization_mode: Option, /// A semaphore to acquire a permit for notarization pub semaphore: Arc, } @@ -50,14 +50,14 @@ impl NotaryGlobals { pub fn new( crypto_provider: Arc, notarization_config: NotarizationProperties, - authorization_whitelist: Option>>>, + authorization_mode: Option, semaphore: Arc, ) -> Self { Self { crypto_provider, notarization_config, store: Default::default(), - authorization_whitelist, + authorization_mode, semaphore, } } diff --git a/crates/notary/tests-integration/Cargo.toml b/crates/notary/tests-integration/Cargo.toml index 5805606eb9..5f081e15f4 100644 --- a/crates/notary/tests-integration/Cargo.toml +++ b/crates/notary/tests-integration/Cargo.toml @@ -26,6 +26,7 @@ hyper-tls = { version = "0.6", features = [ "vendored", ] } # specify vendored feature to use statically linked copy of OpenSSL hyper-util = { workspace = true, features = ["full"] } +jsonwebtoken = { version = "9.3.1", features = ["use_pem"] } rstest = { workspace = true } rustls = { workspace = true } rustls-pemfile = { workspace = true } diff --git a/crates/notary/tests-integration/fixture/auth/jwt.key b/crates/notary/tests-integration/fixture/auth/jwt.key new file mode 100644 index 0000000000..56ca0a0a6e --- /dev/null +++ b/crates/notary/tests-integration/fixture/auth/jwt.key @@ -0,0 +1,51 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIJKAIBAAKCAgEAxkfGQo2iyUK6sV84rvsb6d4IlorFaX4WDwDnEP/zU2Pduwf7 +kV39x6oqJzNjmfXm/RkcaAZXQdbvjBA9uwM7cd2Z7hMWLT3oix66Qv9d3+PWcdJt +TUVK710+QflZJqOEFOt30eNHm/8pN6z1P6YSZvYpHMVlCC7tL8OLWcMH9gYmUH6c +WOdzCaCigdQD1CqE9TG7jZlIepiWI7QMD7v/yN8tZoV8EzW25JdGPe4gtetBl1O+ +QoAUpuQp7JSUxV4RR8HArGGSQ0OHHZMcfWx/CoLBUyXlmSCC21sSl634l7+HUM6U +/dHo1b+XSMpjjVCTjd0lDFCU+kiLtapdcCiJDijSj+a4xkJP+uvxpTZuB6A4W31e +DeKPIeutPAwcnw8UktdX6eiAt1ONAxB+ytZNcdrAUbMdIcocrMxgyPtCWBgDX1LK +fpPlRTBRI50mdFkIOp9dh02ijpWAYXaFA65aI8Tqh9j3wzbvFsCWpBfK7Zcbl7BZ +skAvXCjnPHDdup5sesnYrhOOG4jo2/nho7AmEEkvZVDyH9jDPrA6imljFX1gpKvW +mtERY7eor3gPI6FFOUh8qwEOB4lTsj9DUd75vvRlaPU7ibvFTdLVoRiXkAraKR59 +WmcOpAfut9yOxNaE+M25jY/Jj+crptEt0MmezfowRimt3wpQ0C7i6hCBgrMCAwEA +AQKCAgAx05WR4e/XbapmqkwfRMEV+xLjacoEIYg/ivWGAxvNh9oPhwkD1b/RbgSb +x0EvTmkWjznhNj61L+MQqoAov74vdgWZmzhGdDk8xKL/9RZNDf80qTGIanJTRnY/ +s/5gRFULwMRifR/gprVf5VnX/c7ACvn33e7uqIQ4LYaWLvmQLKlyLu7xNHBnKfPM +dk/kAC9bQn0kLzHUhQWtwTAKwC6d9t981OyCE0x7kzw2keGsdYsNESFNqswFyG50 +oj3kfygOhTT63KYZux14JCDTr/EY3hTg5TQWT+IyZ2d7sF85GwtRFijAxAAjvrqw +sxNjTq1VyA3oU1OstZBOPZqvdbBC6ZpIiWyPE5j+H4R2/rxnKc/nwI+PXU5L1qKf +kB8yUsXxZQA9KY48VU3Z3WZxGWZwoU8Z+WUN6rJknZkVfk14GdQrf6BNyUVQk/Rd +W1bGZB11CHH80LRdsx5T7B2gwq41EJ33+8Hd8S/9YeSWMnHKkpH39bjMu328jn8U +0TaXQ8H/ZMwEmDZ74nmct4VnmF09dxdIHILKyohjGuU9nUBXnXw8orJPXgFlOkmn +G55/sMqDwnnz9O7wGptY3Vx7xCO4I9N4CijUw065dyZaY6wzyph9dAurnDu0MmA7 +o0JwnhI2iKwPU8hq6nm2Ku2YNz7f0O5v5NMtw5z4lrOo4TX6MQKCAQEA/eLuS+dz +mrLEpKCDi63y1G6SYDM+mHWaN3B/y6XVgVjGyZWvebMIol5nGo0URdUHqXVw4krt +Hjr3AulSASr4s75wfk2bVwVuUQfCQrM+zvqBcApWJq+Ve7LEIYRchr8+vlyqiBBV +IV/XyL/KshSXKDscf/1J881M+ZxuGCfqQ0TADJ7592nHCXcDJ8XJXkPRQQEN2QwT +DGdYDIo276IfbiY2MAjCQRvzdGUocfeNZ5SYXOODhS2n99aLWsK3uXG74+tzFZhl +5fJVjxuipZVO4ycEHX8BYqikXQzQKe8UW2Z2Xhb4CQCDw58KXbLShhtRB16m7HJN +2nPXQYN2S6OMawKCAQEAx+5Ww2MPGLa5gvuJTE/qK4pXHrOGA/QM55qqtMlCPZEz +8/3qgcbkKAQL0GJe8Xlsuu0oKBYxIZQnimxlUAFq776b62s5DJuKMA5WhgTRO7Zg +mV5FZUtx+1H9W7GQsDWYlMjUmZOCvefu5qXOLH5gr9AS3Ckyfq1i8xwvAyXRr/4B +jAFtSUgQpbkFhjQtjEVcFEdJhz4OtIbXD0AWgMPSysH2ABZf+tht27mEvAuBCKzn +qa3aQuR5+D7fuDIN9To5QlFUX52vY+xLiaHgUuqC1Ud7y5TKlfNuG+IbYAUWTddS +j62m1G7xBAAAn+D6PX8egQe8EeTWBUaX159YlX102QKCAQEA4C5atsF4DfietLNb +lKITksrUC4gUVLE7bIq0/ZDAV0eZuHSpDrAtBpqPNh2u8f6qllKyS89XU2NDq9l0 +ZL2Z/7VARfanHQ8Zmwlb2mPGKSN/2fv2mJBgUWrHzsS+oukKMTNIDX9GfILR2lyo +UdjmpEqV3to8S8BToPElMcVFEQMLBdn25SYM72mcaqk2JzuA8YJJxQbpZwF1+RSu +b6jbUfsBzCZfyPgyX+vW69NolDbc1uC6yIVJFQnn4UugyWoJO7cy1rXL/GCgdg4z +7zxI/UD9XEJCaeh5wgRHZ0/JzO9Lw8dKW0COGNU9ZQE67dn/EZ/di1lfL28sepfn +g+C1YwKCAQBnOzJDeq991ENfVV+kLpM73hdzu8BT5DyRjbPc2xo/zeykbBQc5ERE +QSqUc2aQimDQ98lHQYYmz2fHOobpU4ISvjmlydxQHTOx8oVMd8pNabLhHeL5FYaJ +/OCz6rBJu7LICBZ2IctdIReisjQNl0d3IBnM4dy3ufEglAnWNz3ZAG9uCgKS1wn5 +d9pZXDG0fs+3jMNzeGCBaCo9Lpsv62y40oOhsevnCr9Wt6jIq6v5fcW0QBc1eOFd +g6Fiaz33xBNyoanOIQ5Bqu2p6BJ63ammVF2gVXhxCpts/EekQZwtnyN7Gm/Mumfp +59JquvCatjta5lJ+bsjvOm8Gn7lOntOpAoIBAFQqUgq5XllVEAyvsdUrXhv0zTb+ +AeM49hHcGPL3S/pOkiHqsbCfjJe1v5Jqcm579s/O4lqtuL2e4INNqOVmxkOfRbFh +oRioUrdAsWv8t2Q6CkXhwoK59kJwitOaF00OyixxdCO9WY6qhg+ZZgZDiLnM7V7b +u8zMvwgqDKD3+7tTjM318bEyE4MCooh9vVD3CxOcdc7oe9TnZvxyuUIRB6UBEyTg +jfvGcyDTSzW3P4SetKOqenk0HuDTPGHtGjYpRnKFfRRcHOqo3p/l1Z+l08alLNAS +wAREawpeuKGx9/ZrhTrqgLTkbx7lSwP9aTKPQka1CtGvgSUohqQ3OPrG0Jk= +-----END RSA PRIVATE KEY----- diff --git a/crates/notary/tests-integration/fixture/auth/jwt.key.pub b/crates/notary/tests-integration/fixture/auth/jwt.key.pub new file mode 100644 index 0000000000..d1887b7abd --- /dev/null +++ b/crates/notary/tests-integration/fixture/auth/jwt.key.pub @@ -0,0 +1,14 @@ +-----BEGIN PUBLIC KEY----- +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAxkfGQo2iyUK6sV84rvsb +6d4IlorFaX4WDwDnEP/zU2Pduwf7kV39x6oqJzNjmfXm/RkcaAZXQdbvjBA9uwM7 +cd2Z7hMWLT3oix66Qv9d3+PWcdJtTUVK710+QflZJqOEFOt30eNHm/8pN6z1P6YS +ZvYpHMVlCC7tL8OLWcMH9gYmUH6cWOdzCaCigdQD1CqE9TG7jZlIepiWI7QMD7v/ +yN8tZoV8EzW25JdGPe4gtetBl1O+QoAUpuQp7JSUxV4RR8HArGGSQ0OHHZMcfWx/ +CoLBUyXlmSCC21sSl634l7+HUM6U/dHo1b+XSMpjjVCTjd0lDFCU+kiLtapdcCiJ +DijSj+a4xkJP+uvxpTZuB6A4W31eDeKPIeutPAwcnw8UktdX6eiAt1ONAxB+ytZN +cdrAUbMdIcocrMxgyPtCWBgDX1LKfpPlRTBRI50mdFkIOp9dh02ijpWAYXaFA65a +I8Tqh9j3wzbvFsCWpBfK7Zcbl7BZskAvXCjnPHDdup5sesnYrhOOG4jo2/nho7Am +EEkvZVDyH9jDPrA6imljFX1gpKvWmtERY7eor3gPI6FFOUh8qwEOB4lTsj9DUd75 +vvRlaPU7ibvFTdLVoRiXkAraKR59WmcOpAfut9yOxNaE+M25jY/Jj+crptEt0Mme +zfowRimt3wpQ0C7i6hCBgrMCAwEAAQ== +-----END PUBLIC KEY----- diff --git a/crates/notary/tests-integration/fixture/config/config.yaml b/crates/notary/tests-integration/fixture/config/config.yaml index 20a5f9263b..5f310ae401 100644 --- a/crates/notary/tests-integration/fixture/config/config.yaml +++ b/crates/notary/tests-integration/fixture/config/config.yaml @@ -43,4 +43,4 @@ log: auth: enabled: false - whitelist_path: "../auth/whitelist.csv" + whitelist: "../auth/whitelist.csv" diff --git a/crates/notary/tests-integration/tests/notary.rs b/crates/notary/tests-integration/tests/notary.rs index 380ddcb339..6438f22197 100644 --- a/crates/notary/tests-integration/tests/notary.rs +++ b/crates/notary/tests-integration/tests/notary.rs @@ -9,6 +9,7 @@ use hyper_util::{ client::legacy::{connect::HttpConnector, Builder}, rt::{TokioExecutor, TokioIo}, }; +use jsonwebtoken::{encode, get_current_timestamp, Algorithm, EncodingKey, Header}; use notary_client::{Accepted, ClientError, NotarizationRequest, NotaryClient, NotaryConnection}; use notary_common::{ClientType, NotarizationSessionRequest, NotarizationSessionResponse}; use rstest::rstest; @@ -29,8 +30,9 @@ use tracing_subscriber::EnvFilter; use ws_stream_tungstenite::WsStream; use notary_server::{ - read_pem_file, run_server, AuthorizationProperties, NotarizationProperties, - NotaryServerProperties, TLSProperties, + read_pem_file, run_server, AuthorizationModeProperties, AuthorizationProperties, + JwtAuthorizationProperties, JwtClaim, NotarizationProperties, NotaryServerProperties, + TLSProperties, }; const MAX_SENT_DATA: usize = 1 << 13; @@ -41,11 +43,28 @@ const NOTARY_DNS: &str = "tlsnotaryserver.io"; const NOTARY_CA_CERT_PATH: &str = "./fixture/tls/rootCA.crt"; const NOTARY_CA_CERT_BYTES: &[u8] = include_bytes!("../fixture/tls/rootCA.crt"); const API_KEY: &str = "test_api_key_0"; +const JWT_PRIVATE_KEY: &[u8] = include_bytes!("../fixture/auth/jwt.key"); + +enum AuthMode { + Jwt, + Whitelist, +} + +fn get_jwt() -> String { + let priv_key = EncodingKey::from_rsa_pem(JWT_PRIVATE_KEY).unwrap(); + let timestamp = get_current_timestamp() as i64 + 1000; + encode( + &Header::new(Algorithm::RS256), + &serde_json::json!({ "exp": timestamp, "sub": "test"}), + &priv_key, + ) + .unwrap() +} fn get_server_config( port: u16, tls_enabled: bool, - auth_enabled: bool, + auth: Option, concurrency: usize, ) -> NotaryServerProperties { NotaryServerProperties { @@ -63,8 +82,20 @@ fn get_server_config( certificate_path: Some("./fixture/tls/notary.crt".to_string()), }, auth: AuthorizationProperties { - enabled: auth_enabled, - whitelist_path: Some("./fixture/auth/whitelist.csv".to_string()), + enabled: auth.is_some(), + mode: auth.map(|mode| match mode { + AuthMode::Jwt => AuthorizationModeProperties::Jwt(JwtAuthorizationProperties { + algorithm: Algorithm::RS256, + public_key_path: "./fixture/auth/jwt.key.pub".to_string(), + claims: vec![JwtClaim { + name: "sub".to_string(), + ..Default::default() + }], + }), + AuthMode::Whitelist => AuthorizationModeProperties::Whitelist( + "./fixture/auth/whitelist.csv".to_string(), + ), + }), }, concurrency, ..Default::default() @@ -75,10 +106,10 @@ async fn setup_config_and_server( sleep_ms: u64, port: u16, tls_enabled: bool, - auth_enabled: bool, + auth: Option, concurrency: usize, ) -> NotaryServerProperties { - let notary_config = get_server_config(port, tls_enabled, auth_enabled, concurrency); + let notary_config = get_server_config(port, tls_enabled, auth, concurrency); // Abruptly closed connections will cause the server to log errors. We // prevent that by excluding the noisy modules from logging. @@ -113,7 +144,14 @@ fn tcp_prover_client(notary_config: NotaryServerProperties) -> NotaryClient { .enable_tls(false); if notary_config.auth.enabled { - notary_client_builder.api_key(API_KEY); + match notary_config.auth.mode.unwrap() { + AuthorizationModeProperties::Jwt(..) => { + notary_client_builder.jwt(get_jwt()); + } + AuthorizationModeProperties::Whitelist(..) => { + notary_client_builder.api_key(API_KEY); + } + } } notary_client_builder.build().unwrap() @@ -164,13 +202,16 @@ async fn tls_prover(notary_config: NotaryServerProperties) -> (NotaryConnection, // For `tls_without_auth` test to pass, one needs to add " " in /etc/hosts // so that this test programme can resolve the self-named NOTARY_DNS to NOTARY_HOST IP successfully. #[case::tls_without_auth({ - tls_prover(setup_config_and_server(100, 7047, true, false, 100).await) + tls_prover(setup_config_and_server(100, 7047, true, None, 100).await) +})] +#[case::tcp_with_whitelist_auth({ + tcp_prover(setup_config_and_server(100, 7048, false, Some(AuthMode::Whitelist), 100).await) })] -#[case::tcp_with_auth({ - tcp_prover(setup_config_and_server(100, 7048, false, true, 100).await) +#[case::tcp_with_jwt_auth({ + tcp_prover(setup_config_and_server(100, 7049, false, Some(AuthMode::Jwt), 100).await) })] #[case::tcp_without_auth({ - tcp_prover(setup_config_and_server(100, 7049, false, false, 100).await) + tcp_prover(setup_config_and_server(100, 7050, false, None, 100).await) })] #[awt] #[tokio::test] @@ -278,7 +319,7 @@ async fn test_tcp_prover( #[ignore = "expensive"] async fn test_websocket_prover() { // Notary server configuration setup - let notary_config = setup_config_and_server(100, 7050, true, false, 100).await; + let notary_config = setup_config_and_server(100, 7050, true, None, 100).await; let notary_host = notary_config.host.clone(); let notary_port = notary_config.port; @@ -470,7 +511,7 @@ async fn test_websocket_prover() { async fn test_concurrency_limit() { const CONCURRENCY: usize = 5; - let notary_config = setup_config_and_server(100, 7051, false, false, CONCURRENCY).await; + let notary_config = setup_config_and_server(100, 7051, false, None, CONCURRENCY).await; async fn do_test(config: NotaryServerProperties) -> Vec<(NotaryConnection, String)> { // Start notarization requests in parallel. @@ -509,7 +550,7 @@ async fn test_concurrency_limit() { async fn test_notarization_request_retry() { const CONCURRENCY: usize = 5; - let config = setup_config_and_server(100, 7052, false, false, CONCURRENCY).await; + let config = setup_config_and_server(100, 7052, false, None, CONCURRENCY).await; // Max out the concurrency limit. let connections = (0..CONCURRENCY).map(|_| tcp_prover(config.clone())); From 593d1305cfa63276b3abfda62c12ea7cb4505651 Mon Sep 17 00:00:00 2001 From: Jakub Konka Date: Thu, 22 May 2025 09:43:43 +0200 Subject: [PATCH 2/7] Fix formatting and lints --- crates/notary/client/src/client.rs | 4 ++-- crates/notary/server/src/config.rs | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/notary/client/src/client.rs b/crates/notary/client/src/client.rs index 46a3693e77..5d5bb290e6 100644 --- a/crates/notary/client/src/client.rs +++ b/crates/notary/client/src/client.rs @@ -138,8 +138,8 @@ pub struct NotaryClient { /// in notary server. #[builder(setter(into, strip_option), default)] api_key: Option, - /// JWT token used to callnotary server endpoints if JWT authorization is enabled - /// in notary server. + /// JWT token used to callnotary server endpoints if JWT authorization is + /// enabled in notary server. #[builder(setter(into, strip_option), default)] jwt: Option, /// The duration of notarization request timeout in seconds. diff --git a/crates/notary/server/src/config.rs b/crates/notary/server/src/config.rs index 13f6e6de16..111c6f8078 100644 --- a/crates/notary/server/src/config.rs +++ b/crates/notary/server/src/config.rs @@ -61,7 +61,7 @@ impl NotaryServerProperties { public_key_path, claims, }) => AuthorizationModeProperties::Jwt(JwtAuthorizationProperties { - algorithm: algorithm.clone(), + algorithm: *algorithm, public_key_path: prepend_file_path(public_key_path, &parent_dir)?, claims: claims.clone(), }), @@ -178,7 +178,8 @@ impl AuthorizationModeProperties { pub struct JwtAuthorizationProperties { /// Algorithm used for signing the JWT pub algorithm: Algorithm, - /// File path to JWT public key (in PEM format) for verifying token signatures + /// File path to JWT public key (in PEM format) for verifying token + /// signatures pub public_key_path: String, /// Set of required JWT claims #[serde(default)] From 1c82ff8ad3d3d927e215e7dd61ca7fe5e668b377 Mon Sep 17 00:00:00 2001 From: Jakub Konka Date: Fri, 23 May 2025 09:47:48 +0200 Subject: [PATCH 3/7] Address review comments --- Cargo.lock | 29 +++++- crates/notary/client/src/client.rs | 2 +- crates/notary/server/Cargo.toml | 1 + crates/notary/server/README.md | 6 +- crates/notary/server/src/auth.rs | 32 +++--- crates/notary/server/src/auth/jwt.rs | 99 +++++++++++-------- crates/notary/server/src/auth/whitelist.rs | 34 ++++--- crates/notary/server/src/config.rs | 38 +++---- crates/notary/server/src/error.rs | 8 +- crates/notary/server/src/middleware.rs | 39 +++----- crates/notary/server/src/server.rs | 11 +-- .../notary/tests-integration/tests/notary.rs | 8 +- 12 files changed, 171 insertions(+), 136 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 00d87a41cd..136a22e586 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2255,8 +2255,8 @@ dependencies = [ "proc-macro-rules", "proc-macro2", "quote", - "strum", - "strum_macros", + "strum 0.25.0", + "strum_macros 0.25.3", "syn 2.0.101", "thiserror 1.0.69", ] @@ -4737,6 +4737,7 @@ dependencies = [ "serde_yaml", "sha1", "structopt", + "strum 0.27.1", "thiserror 1.0.69", "tlsn-common", "tlsn-core", @@ -6745,7 +6746,16 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" dependencies = [ - "strum_macros", + "strum_macros 0.25.3", +] + +[[package]] +name = "strum" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f64def088c51c9510a8579e3c5d67c65349dcf755e5479ad3d010aa6454e2c32" +dependencies = [ + "strum_macros 0.27.1", ] [[package]] @@ -6761,6 +6771,19 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "strum_macros" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77a8c5abcaf0f9ce05d62342b7d298c346515365c36b673df4ebe3ced01fde8" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.101", +] + [[package]] name = "subtle" version = "2.6.1" diff --git a/crates/notary/client/src/client.rs b/crates/notary/client/src/client.rs index 5d5bb290e6..7fb8cda31d 100644 --- a/crates/notary/client/src/client.rs +++ b/crates/notary/client/src/client.rs @@ -138,7 +138,7 @@ pub struct NotaryClient { /// in notary server. #[builder(setter(into, strip_option), default)] api_key: Option, - /// JWT token used to callnotary server endpoints if JWT authorization is + /// JWT token used to call notary server endpoints if JWT authorization is /// enabled in notary server. #[builder(setter(into, strip_option), default)] jwt: Option, diff --git a/crates/notary/server/Cargo.toml b/crates/notary/server/Cargo.toml index 607ce6c4d4..67319cf857 100644 --- a/crates/notary/server/Cargo.toml +++ b/crates/notary/server/Cargo.toml @@ -48,6 +48,7 @@ serde_json = { workspace = true } serde_yaml = { version = "0.9" } sha1 = { version = "0.10" } structopt = { version = "0.3" } +strum = { version = "0.27", features = ["derive"] } thiserror = { workspace = true } tokio = { workspace = true, features = ["full"] } tokio-rustls = { workspace = true } diff --git a/crates/notary/server/README.md b/crates/notary/server/README.md index c2f0378c9c..11ea84912d 100644 --- a/crates/notary/server/README.md +++ b/crates/notary/server/README.md @@ -213,7 +213,7 @@ An optional authorization module is available to only allow requests with a vali Please note that only *one* mode can be active at any one time. ##### Whitelist mode -In whitelist mode, an API key is attached in the custom HTTP header `X-API-Key`. The API key whitelist path (as well as the flag to enable/disable this module) can be changed in the config (`authorization` field). +In whitelist mode, a valid API key needs to be attached in the custom HTTP header `X-API-Key`. The path of the API key whitelist, path (as well as the flag to enable/disable this module), can be changed in the config (`authorization` field). Hot reloading of the whitelist is supported, i.e. modification of the whitelist file will be automatically applied without needing to restart the server. Please take note of the following - Avoid using auto save mode when editing the whitelist to prevent spamming hot reloads @@ -223,6 +223,10 @@ Hot reloading of the whitelist is supported, i.e. modification of the whitelist In JWT mode, JSON Web Token is attached in the standard `Authorization` HTTP header as a bearer token. The path to decoding key as well as custom user claims can be changed in the config (`authorization` field). +Care should be taken when defining custom user claims as the middleware will: +- accept any claim if no custom claim is defined, +- as long as user defined claims are found, other unknown claims will be ignored. + An example JWT config may look something like this: ```yaml diff --git a/crates/notary/server/src/auth.rs b/crates/notary/server/src/auth.rs index f1caa68337..0d7d5d8acb 100644 --- a/crates/notary/server/src/auth.rs +++ b/crates/notary/server/src/auth.rs @@ -4,14 +4,16 @@ pub(crate) mod whitelist; use eyre::{eyre, Result}; use jwt::load_jwt_key; use std::{ - collections::HashMap, + str::FromStr, sync::{Arc, Mutex}, }; use tracing::debug; use whitelist::load_authorization_whitelist; -pub use jwt::{Jwt, JwtError}; -pub use whitelist::{watch_and_reload_authorization_whitelist, AuthorizationWhitelistRecord}; +pub use jwt::{Algorithm, Jwt, JwtValidationError}; +pub use whitelist::{ + watch_and_reload_authorization_whitelist, AuthorizationWhitelistRecord, Whitelist, +}; use crate::{AuthorizationModeProperties, NotaryServerProperties}; @@ -19,16 +21,14 @@ use crate::{AuthorizationModeProperties, NotaryServerProperties}; #[derive(Clone)] pub enum AuthorizationMode { Jwt(Jwt), - Whitelist(Arc>>), + Whitelist(Whitelist), } impl AuthorizationMode { - pub fn as_whitelist( - &self, - ) -> Option>>> { + pub fn as_whitelist(&self) -> Option<&Whitelist> { match self { Self::Jwt(..) => None, - Self::Whitelist(whitelist) => Some(whitelist.clone()), + Self::Whitelist(whitelist) => Some(whitelist), } } } @@ -44,11 +44,16 @@ pub async fn load_authorization_mode( let auth_mode = match config.auth.mode.as_ref().ok_or_else(|| { eyre!( - "Authorization enabled but neither whitelist nor jwt properties provided in the config" + "Authorization enabled but failed to load either whitelist or jwt properties. They are either absent or malformed." ) })? { AuthorizationModeProperties::Jwt(jwt_opts) => { - let algorithm = jwt_opts.algorithm; + let algorithm = Algorithm::from_str(&jwt_opts.algorithm).map_err(|_| { + eyre!( + "Unexpected JWT signing algorithm specified: '{}'", + jwt_opts.algorithm + ) + })?; let claims = jwt_opts.claims.clone(); let key = load_jwt_key(&jwt_opts.public_key_path, algorithm).await?; AuthorizationMode::Jwt(Jwt { @@ -58,8 +63,11 @@ pub async fn load_authorization_mode( }) } AuthorizationModeProperties::Whitelist(whitelist_csv_path) => { - let whitelist = load_authorization_whitelist(whitelist_csv_path)?; - AuthorizationMode::Whitelist(Arc::new(Mutex::new(whitelist))) + let entries = load_authorization_whitelist(whitelist_csv_path)?; + AuthorizationMode::Whitelist(Whitelist { + entries: Arc::new(Mutex::new(entries)), + csv_path: whitelist_csv_path.clone(), + }) } }; diff --git a/crates/notary/server/src/auth/jwt.rs b/crates/notary/server/src/auth/jwt.rs index 81316ff63f..20fdf5f2dc 100644 --- a/crates/notary/server/src/auth/jwt.rs +++ b/crates/notary/server/src/auth/jwt.rs @@ -1,21 +1,17 @@ use eyre::Result; -use jsonwebtoken::{Algorithm, DecodingKey}; +use jsonwebtoken::{Algorithm as JwtAlgorithm, DecodingKey}; use serde_json::Value; -use std::io::Read; +use strum::EnumString; use tracing::error; -use crate::{read_pem_file, JwtClaim, JwtClaimValueType}; +use crate::{JwtClaim, JwtClaimValueType}; /// Custom error for JWT handling #[derive(Debug, thiserror::Error, PartialEq)] -pub enum JwtError { - #[error("unsupported algorithm: {0:?}")] - UnsupportedAlgorithm(Algorithm), - #[error("JWT validation error: {0}")] - Validation(String), -} +#[error("JWT validation error: {0}")] +pub struct JwtValidationError(String); -type JwtResult = std::result::Result; +type JwtResult = std::result::Result; /// JWT config which also encapsulates claims validation logic. #[derive(Clone)] @@ -26,32 +22,33 @@ pub struct Jwt { } impl Jwt { - pub fn validate(&self, claims: Value) -> JwtResult<()> { + pub fn validate(&self, claims: &Value) -> JwtResult<()> { Jwt::validate_claims(&self.claims, claims) } - fn validate_claims(expected: &[JwtClaim], claims: Value) -> JwtResult<()> { + fn validate_claims(expected: &[JwtClaim], claims: &Value) -> JwtResult<()> { expected .iter() - .try_for_each(|expected| Self::validate_claim(expected, claims.clone())) + .try_for_each(|expected| Self::validate_claim(expected, claims)) } - fn validate_claim(expected: &JwtClaim, given: Value) -> JwtResult<()> { - let field = Jwt::get_field(&expected.name, &given).ok_or(JwtError::Validation(format!( + fn validate_claim(expected: &JwtClaim, given: &Value) -> JwtResult<()> { + let pointer = format!("/{}", expected.name.replace(".", "/")); + let field = given.pointer(&pointer).ok_or(JwtValidationError(format!( "missing claim '{}'", expected.name )))?; match expected.value_type { JwtClaimValueType::String => { - let field_typed = field.as_str().ok_or(JwtError::Validation(format!( + let field_typed = field.as_str().ok_or(JwtValidationError(format!( "unexpected type for claim '{}': expected '{:?}'", expected.name, expected.value_type )))?; if !expected.values.is_empty() { expected.values.iter().any(|exp| exp == field_typed).then_some(()).ok_or_else(|| { let expected_values = expected.values.iter().map(|x| format!("'{x}'")).collect::>().join(", "); - JwtError::Validation(format!( + JwtValidationError(format!( "unexpected value for claim '{}': expected one of [ {expected_values} ], received '{field_typed}'", expected.name )) })?; @@ -61,19 +58,44 @@ impl Jwt { Ok(()) } +} - fn get_field<'a>(path: &'a str, value: &'a Value) -> Option<&'a Value> { - let (field, path) = match path.split_once('.') { - Some((field, path)) => (field, Some(path)), - None => (path, None), - }; - if let Some(value) = value.get(field) { - match path { - Some(path) => Jwt::get_field(path, value), - None => Some(value), - } - } else { - None +#[derive(EnumString, Debug, Clone, Copy, PartialEq, Eq)] +#[strum(ascii_case_insensitive)] +/// Supported JWT signing algorithms +pub enum Algorithm { + /// RSASSA-PSS using SHA-512 + RS256, + /// RSASSA-PKCS1-v1_5 using SHA-384 + RS384, + /// RSASSA-PKCS1-v1_5 using SHA-384 + RS512, + /// RSASSA-PSS using SHA-256 + PS256, + /// RSASSA-PSS using SHA-384 + PS384, + /// RSASSA-PSS using SHA-512 + PS512, + /// ECDSA using SHA-256 + ES256, + /// ECDSA using SHA-384 + ES384, + /// Edwards-curve Digital Signature Algorithm (EdDSA) + EdDSA, +} + +impl From for JwtAlgorithm { + fn from(value: Algorithm) -> Self { + match value { + Algorithm::RS256 => Self::RS256, + Algorithm::RS384 => Self::RS384, + Algorithm::RS512 => Self::RS512, + Algorithm::PS256 => Self::PS256, + Algorithm::PS384 => Self::PS384, + Algorithm::PS512 => Self::PS512, + Algorithm::ES256 => Self::ES256, + Algorithm::ES384 => Self::ES384, + Algorithm::EdDSA => Self::EdDSA, } } } @@ -83,9 +105,7 @@ pub(super) async fn load_jwt_key( public_key_pem_path: &str, algorithm: Algorithm, ) -> Result { - let mut reader = read_pem_file(public_key_pem_path).await?; - let mut key_pem_bytes: Vec = Vec::new(); - reader.read_to_end(&mut key_pem_bytes)?; + let key_pem_bytes = tokio::fs::read(public_key_pem_path).await?; let key = match algorithm { Algorithm::RS256 | Algorithm::RS384 @@ -95,7 +115,6 @@ pub(super) async fn load_jwt_key( | Algorithm::PS512 => DecodingKey::from_rsa_pem(&key_pem_bytes)?, Algorithm::ES256 | Algorithm::ES384 => DecodingKey::from_ec_pem(&key_pem_bytes)?, Algorithm::EdDSA => DecodingKey::from_ed_pem(&key_pem_bytes)?, - _ => return Err(JwtError::UnsupportedAlgorithm(algorithm).into()), }; Ok(key) } @@ -116,7 +135,7 @@ mod test { "exp": 12345, "sub": "test", }); - assert!(Jwt::validate_claim(&expected, given).is_ok()); + assert!(Jwt::validate_claim(&expected, &given).is_ok()); } #[test] @@ -132,7 +151,7 @@ mod test { "host": "api.tlsn.com", }, }); - assert!(Jwt::validate_claim(&expected, given).is_ok()) + assert!(Jwt::validate_claim(&expected, &given).is_ok()) } #[test] @@ -142,7 +161,7 @@ mod test { "sub": "test", "what": "is_this", }); - assert!(Jwt::validate_claims(&[], given).is_ok()) + assert!(Jwt::validate_claims(&[], &given).is_ok()) } #[test] @@ -156,8 +175,8 @@ mod test { "host": "localhost", }); assert_eq!( - Jwt::validate_claim(&expected, given), - Err(JwtError::Validation("missing claim 'sub'".to_string())) + Jwt::validate_claim(&expected, &given), + Err(JwtValidationError("missing claim 'sub'".to_string())) ) } @@ -172,8 +191,8 @@ mod test { "sub": "tlsn", }); assert_eq!( - Jwt::validate_claim(&expected, given), - Err(JwtError::Validation("unexpected value for claim 'sub': expected one of [ 'tlsn_prod', 'tlsn_test' ], received 'tlsn'".to_string())) + Jwt::validate_claim(&expected, &given), + Err(JwtValidationError("unexpected value for claim 'sub': expected one of [ 'tlsn_prod', 'tlsn_test' ], received 'tlsn'".to_string())) ) } } diff --git a/crates/notary/server/src/auth/whitelist.rs b/crates/notary/server/src/auth/whitelist.rs index 951a9ae5f9..72aed9dc53 100644 --- a/crates/notary/server/src/auth/whitelist.rs +++ b/crates/notary/server/src/auth/whitelist.rs @@ -12,6 +12,12 @@ use tracing::{debug, error, info}; use crate::util::parse_csv_file; +#[derive(Clone)] +pub struct Whitelist { + pub entries: Arc>>, + pub csv_path: String, +} + /// Structure of each whitelisted record of the API key whitelist for /// authorization purpose #[derive(Clone, Debug, Deserialize, Serialize)] @@ -51,10 +57,10 @@ pub(super) fn load_authorization_whitelist( // The watcher is setup in a separate thread by the notify library which is // synchronous pub fn watch_and_reload_authorization_whitelist( - authorization_whitelist: Arc>>, - whitelist_csv_path: String, + whitelist: &Whitelist, ) -> Result { - let whitelist_csv_path_cloned = whitelist_csv_path.clone(); + let whitelist_csv_path_cloned = whitelist.csv_path.clone(); + let entries = whitelist.entries.clone(); // Setup watcher by giving it a function that will be triggered when an event is // detected let mut watcher = RecommendedWatcher::new( @@ -66,8 +72,7 @@ pub fn watch_and_reload_authorization_whitelist( debug!("Authorization whitelist is modified"); match load_authorization_whitelist(&whitelist_csv_path_cloned) { Ok(new_authorization_whitelist) => { - *authorization_whitelist.lock().unwrap() = - new_authorization_whitelist; + *entries.lock().unwrap() = new_authorization_whitelist; info!("Successfully reloaded authorization whitelist!"); } // Ensure that error from reloading doesn't bring the server down @@ -86,7 +91,7 @@ pub fn watch_and_reload_authorization_whitelist( // Start watcher to listen to any changes on the whitelist file watcher - .watch(Path::new(&whitelist_csv_path), RecursiveMode::Recursive) + .watch(Path::new(&whitelist.csv_path), RecursiveMode::Recursive) .map_err(|err| eyre!("Error occured when starting up watcher for hot reload: {err}"))?; // Need to return the watcher to parent function, else it will be dropped and @@ -111,16 +116,16 @@ mod test { std::fs::copy(original_whitelist_csv_path, &whitelist_csv_path).unwrap(); // Setup watcher - let authorization_whitelist = load_authorization_whitelist(&whitelist_csv_path).expect( + let entries = load_authorization_whitelist(&whitelist_csv_path).expect( "Authorization whitelist csv from fixture should be able to be loaded", ); - let authorization_whitelist = Arc::new(Mutex::new(authorization_whitelist)); - let _watcher = watch_and_reload_authorization_whitelist( - authorization_whitelist.clone(), - whitelist_csv_path.clone(), - ) - .expect("Watcher should be able to be setup successfully"); + let whitelist = Whitelist { + entries: Arc::new(Mutex::new(entries)), + csv_path: whitelist_csv_path.clone(), + }; + let _watcher = watch_and_reload_authorization_whitelist(&whitelist) + .expect("Watcher should be able to be setup successfully"); // Sleep to buy a bit of time for hot reload task and watcher thread to run tokio::time::sleep(Duration::from_millis(50)).await; @@ -144,7 +149,8 @@ mod test { // Sleep to buy a bit of time for updated whitelist to be hot reloaded tokio::time::sleep(Duration::from_millis(50)).await; - assert!(authorization_whitelist + assert!(whitelist + .entries .lock() .unwrap() .contains_key("unit-test-api-key")); diff --git a/crates/notary/server/src/config.rs b/crates/notary/server/src/config.rs index 111c6f8078..8701e961d4 100644 --- a/crates/notary/server/src/config.rs +++ b/crates/notary/server/src/config.rs @@ -1,6 +1,5 @@ use config::{Config, Environment}; use eyre::{eyre, Result}; -use jsonwebtoken::Algorithm; use serde::{Deserialize, Serialize}; use std::path::Path; @@ -43,31 +42,31 @@ impl NotaryServerProperties { .to_string(); // Prepend notarization key path. - if let Some(path) = &config.notarization.private_key_path { - config.notarization.private_key_path = Some(prepend_file_path(path, &parent_dir)?); + if let Some(path) = config.notarization.private_key_path { + config.notarization.private_key_path = Some(prepend_file_path(&path, &parent_dir)?); } // Prepend TLS key paths. - if let Some(path) = &config.tls.private_key_path { - config.tls.private_key_path = Some(prepend_file_path(path, &parent_dir)?); + if let Some(path) = config.tls.private_key_path { + config.tls.private_key_path = Some(prepend_file_path(&path, &parent_dir)?); } - if let Some(path) = &config.tls.certificate_path { - config.tls.certificate_path = Some(prepend_file_path(path, &parent_dir)?); + if let Some(path) = config.tls.certificate_path { + config.tls.certificate_path = Some(prepend_file_path(&path, &parent_dir)?); } - // Prepend auth whitelist path. - if let Some(mode) = &config.auth.mode { + // Prepend auth file path. + if let Some(mode) = config.auth.mode { config.auth.mode = Some(match mode { AuthorizationModeProperties::Jwt(JwtAuthorizationProperties { algorithm, public_key_path, claims, }) => AuthorizationModeProperties::Jwt(JwtAuthorizationProperties { - algorithm: *algorithm, - public_key_path: prepend_file_path(public_key_path, &parent_dir)?, - claims: claims.clone(), + algorithm, + public_key_path: prepend_file_path(&public_key_path, &parent_dir)?, + claims, }), AuthorizationModeProperties::Whitelist(path) => { AuthorizationModeProperties::Whitelist(prepend_file_path( - path, + &path, &parent_dir, )?) } @@ -151,7 +150,7 @@ pub struct LogProperties { pub struct AuthorizationProperties { /// Flag to turn on or off auth middleware pub enabled: bool, - /// Authorization mode to use: JWT or whitelist + /// Authorization mode to use: JWT or Whitelist #[serde(flatten)] pub mode: Option, } @@ -165,19 +164,10 @@ pub enum AuthorizationModeProperties { Whitelist(String), } -impl AuthorizationModeProperties { - pub fn as_whitelist(&self) -> Option { - match self { - Self::Jwt(..) => None, - Self::Whitelist(path) => Some(path.clone()), - } - } -} - #[derive(Clone, Debug, Serialize, Deserialize, Default)] pub struct JwtAuthorizationProperties { /// Algorithm used for signing the JWT - pub algorithm: Algorithm, + pub algorithm: String, /// File path to JWT public key (in PEM format) for verifying token /// signatures pub public_key_path: String, diff --git a/crates/notary/server/src/error.rs b/crates/notary/server/src/error.rs index b90769e844..d8bc3b2e1c 100644 --- a/crates/notary/server/src/error.rs +++ b/crates/notary/server/src/error.rs @@ -6,7 +6,7 @@ use tlsn_common::config::ProtocolConfigValidatorBuilderError; use tlsn_verifier::{VerifierConfigBuilderError, VerifierError}; -use crate::auth::JwtError; +use crate::auth::JwtValidationError; #[derive(Debug, thiserror::Error)] pub enum NotaryServerError { @@ -21,7 +21,7 @@ pub enum NotaryServerError { #[error("Unauthorized request from prover: {0}")] UnauthorizedProverRequest(String), #[error(transparent)] - Jwt(#[from] JwtError), + JwtValidation(#[from] JwtValidationError), } impl From for NotaryServerError { @@ -54,8 +54,8 @@ impl AxumCoreIntoResponse for NotaryServerError { unauthorized_request_error.to_string(), ) .into_response(), - jwt_error @ NotaryServerError::Jwt(..) => { - (StatusCode::UNAUTHORIZED, jwt_error.to_string()).into_response() + jwt_validation_error @ NotaryServerError::JwtValidation(..) => { + (StatusCode::UNAUTHORIZED, jwt_validation_error.to_string()).into_response() } _ => ( StatusCode::INTERNAL_SERVER_ERROR, diff --git a/crates/notary/server/src/middleware.rs b/crates/notary/server/src/middleware.rs index 7de934ad5c..2efa8806ae 100644 --- a/crates/notary/server/src/middleware.rs +++ b/crates/notary/server/src/middleware.rs @@ -25,7 +25,7 @@ where async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { let notary_globals = NotaryGlobals::from_ref(state); let Some(mode) = notary_globals.authorization_mode else { - trace!("Skipping authorization as not enabled."); + trace!("Skipping authorization as it's not enabled."); return Ok(Self); }; @@ -36,14 +36,14 @@ where .get(X_API_KEY_HEADER) .and_then(|value| std::str::from_utf8(value.as_bytes()).ok()) else { - return Err(missing_api_key()); + return Err(unauthorized("Missing API key")); }; - let whitelist = whitelist.lock().unwrap(); - if api_key_is_valid(auth_header, &whitelist) { + let entries = whitelist.entries.lock().unwrap(); + if api_key_is_valid(auth_header, &entries) { trace!("Request authorized."); Ok(Self) } else { - Err(invalid_api_key()) + Err(unauthorized("Invalid API key")) } } AuthorizationMode::Jwt(jwt_config) => { @@ -52,19 +52,16 @@ where .get(header::AUTHORIZATION) .and_then(|value| std::str::from_utf8(value.as_bytes()).ok()) else { - return Err(missing_api_key()); + return Err(unauthorized("Missing JWT token")); }; - let raw_token = auth_header - .strip_prefix("Bearer ") - .ok_or_else(invalid_api_key)?; - let mut validation = Validation::new(jwt_config.algorithm); - validation.validate_exp = true; + let raw_token = auth_header.strip_prefix("Bearer ").ok_or_else(|| { + unauthorized("Invalid Authorization header: expected 'Bearer '") + })?; + let validation = Validation::new(jwt_config.algorithm.into()); let TokenData { claims, .. } = - decode::(raw_token, &jwt_config.key, &validation).map_err(|err| { - error!("{err:#?}"); - invalid_api_key() - })?; - jwt_config.validate(claims)?; + decode::(raw_token, &jwt_config.key, &validation) + .map_err(|err| unauthorized(format!("Invalid JWT token: {err:#?}")))?; + jwt_config.validate(&claims)?; trace!("Request authorized."); Ok(Self) } @@ -72,14 +69,8 @@ where } } -fn missing_api_key() -> NotaryServerError { - let err_msg = "Missing API key.".to_string(); - error!(err_msg); - NotaryServerError::UnauthorizedProverRequest(err_msg) -} - -fn invalid_api_key() -> NotaryServerError { - let err_msg = "Invalid API key.".to_string(); +fn unauthorized(err_msg: impl ToString) -> NotaryServerError { + let err_msg = err_msg.to_string(); error!(err_msg); NotaryServerError::UnauthorizedProverRequest(err_msg) } diff --git a/crates/notary/server/src/server.rs b/crates/notary/server/src/server.rs index b10754dbb2..3eeb38c0e8 100644 --- a/crates/notary/server/src/server.rs +++ b/crates/notary/server/src/server.rs @@ -29,7 +29,7 @@ use zeroize::Zeroize; use crate::{ auth::{load_authorization_mode, watch_and_reload_authorization_whitelist, AuthorizationMode}, - config::{AuthorizationModeProperties, NotarizationProperties, NotaryServerProperties}, + config::{NotarizationProperties, NotaryServerProperties}, error::NotaryServerError, middleware::AuthorizationMiddleware, service::{initialize, upgrade_protocol}, @@ -93,14 +93,7 @@ pub async fn run_server(config: &NotaryServerProperties) -> Result<(), NotarySer let watcher = authorization_mode .as_ref() .and_then(AuthorizationMode::as_whitelist) - .zip( - config - .auth - .mode - .as_ref() - .and_then(AuthorizationModeProperties::as_whitelist), - ) - .map(|(whitelist, path)| watch_and_reload_authorization_whitelist(whitelist, path)) + .map(watch_and_reload_authorization_whitelist) .transpose()?; if watcher.is_some() { debug!("Successfully setup watcher for hot reload of authorization whitelist!"); diff --git a/crates/notary/tests-integration/tests/notary.rs b/crates/notary/tests-integration/tests/notary.rs index 6438f22197..0afbcd078f 100644 --- a/crates/notary/tests-integration/tests/notary.rs +++ b/crates/notary/tests-integration/tests/notary.rs @@ -85,7 +85,7 @@ fn get_server_config( enabled: auth.is_some(), mode: auth.map(|mode| match mode { AuthMode::Jwt => AuthorizationModeProperties::Jwt(JwtAuthorizationProperties { - algorithm: Algorithm::RS256, + algorithm: "rs256".to_string(), public_key_path: "./fixture/auth/jwt.key.pub".to_string(), claims: vec![JwtClaim { name: "sub".to_string(), @@ -319,7 +319,7 @@ async fn test_tcp_prover( #[ignore = "expensive"] async fn test_websocket_prover() { // Notary server configuration setup - let notary_config = setup_config_and_server(100, 7050, true, None, 100).await; + let notary_config = setup_config_and_server(100, 7051, true, None, 100).await; let notary_host = notary_config.host.clone(); let notary_port = notary_config.port; @@ -511,7 +511,7 @@ async fn test_websocket_prover() { async fn test_concurrency_limit() { const CONCURRENCY: usize = 5; - let notary_config = setup_config_and_server(100, 7051, false, None, CONCURRENCY).await; + let notary_config = setup_config_and_server(100, 7052, false, None, CONCURRENCY).await; async fn do_test(config: NotaryServerProperties) -> Vec<(NotaryConnection, String)> { // Start notarization requests in parallel. @@ -550,7 +550,7 @@ async fn test_concurrency_limit() { async fn test_notarization_request_retry() { const CONCURRENCY: usize = 5; - let config = setup_config_and_server(100, 7052, false, None, CONCURRENCY).await; + let config = setup_config_and_server(100, 7053, false, None, CONCURRENCY).await; // Max out the concurrency limit. let connections = (0..CONCURRENCY).map(|_| tcp_prover(config.clone())); From 0035f766fb007e9ea30961dfd89b3f8741e9ce81 Mon Sep 17 00:00:00 2001 From: Jakub Konka Date: Fri, 23 May 2025 13:09:47 +0200 Subject: [PATCH 4/7] feat(server): remove JwtClaimType config property --- crates/notary/server/src/auth/jwt.rs | 26 ++++++++++---------------- crates/notary/server/src/config.rs | 10 ---------- crates/notary/server/src/lib.rs | 3 +-- 3 files changed, 11 insertions(+), 28 deletions(-) diff --git a/crates/notary/server/src/auth/jwt.rs b/crates/notary/server/src/auth/jwt.rs index 20fdf5f2dc..3d9832163d 100644 --- a/crates/notary/server/src/auth/jwt.rs +++ b/crates/notary/server/src/auth/jwt.rs @@ -4,7 +4,7 @@ use serde_json::Value; use strum::EnumString; use tracing::error; -use crate::{JwtClaim, JwtClaimValueType}; +use crate::JwtClaim; /// Custom error for JWT handling #[derive(Debug, thiserror::Error, PartialEq)] @@ -39,21 +39,17 @@ impl Jwt { expected.name )))?; - match expected.value_type { - JwtClaimValueType::String => { - let field_typed = field.as_str().ok_or(JwtValidationError(format!( - "unexpected type for claim '{}': expected '{:?}'", - expected.name, expected.value_type - )))?; - if !expected.values.is_empty() { - expected.values.iter().any(|exp| exp == field_typed).then_some(()).ok_or_else(|| { + let field_typed = field.as_str().ok_or(JwtValidationError(format!( + "unexpected type for claim '{}': only strings are supported for claim values", + expected.name, + )))?; + if !expected.values.is_empty() { + expected.values.iter().any(|exp| exp == field_typed).then_some(()).ok_or_else(|| { let expected_values = expected.values.iter().map(|x| format!("'{x}'")).collect::>().join(", "); JwtValidationError(format!( "unexpected value for claim '{}': expected one of [ {expected_values} ], received '{field_typed}'", expected.name )) })?; - } - } } Ok(()) @@ -135,7 +131,7 @@ mod test { "exp": 12345, "sub": "test", }); - assert!(Jwt::validate_claim(&expected, &given).is_ok()); + Jwt::validate_claim(&expected, &given).unwrap(); } #[test] @@ -143,7 +139,6 @@ mod test { let expected = JwtClaim { name: "custom.host".to_string(), values: vec!["tlsn.com".to_string(), "api.tlsn.com".to_string()], - ..Default::default() }; let given = json!({ "exp": 12345, @@ -151,7 +146,7 @@ mod test { "host": "api.tlsn.com", }, }); - assert!(Jwt::validate_claim(&expected, &given).is_ok()) + Jwt::validate_claim(&expected, &given).unwrap(); } #[test] @@ -161,7 +156,7 @@ mod test { "sub": "test", "what": "is_this", }); - assert!(Jwt::validate_claims(&[], &given).is_ok()) + Jwt::validate_claims(&[], &given).unwrap(); } #[test] @@ -185,7 +180,6 @@ mod test { let expected = JwtClaim { name: "sub".to_string(), values: vec!["tlsn_prod".to_string(), "tlsn_test".to_string()], - ..Default::default() }; let given = json!({ "sub": "tlsn", diff --git a/crates/notary/server/src/config.rs b/crates/notary/server/src/config.rs index 8701e961d4..9130ed62d6 100644 --- a/crates/notary/server/src/config.rs +++ b/crates/notary/server/src/config.rs @@ -183,16 +183,6 @@ pub struct JwtClaim { /// Optional set of expected values for the claim #[serde(default)] pub values: Vec, - /// Optional expected type for the claim - #[serde(default)] - pub value_type: JwtClaimValueType, -} - -#[derive(Clone, Debug, Serialize, Deserialize, Default)] -#[serde(rename_all = "kebab-case")] -pub enum JwtClaimValueType { - #[default] - String, } impl Default for NotaryServerProperties { diff --git a/crates/notary/server/src/lib.rs b/crates/notary/server/src/lib.rs index 7097ca2601..a57d9031f5 100644 --- a/crates/notary/server/src/lib.rs +++ b/crates/notary/server/src/lib.rs @@ -15,8 +15,7 @@ mod util; pub use cli::CliFields; pub use config::{ AuthorizationModeProperties, AuthorizationProperties, JwtAuthorizationProperties, JwtClaim, - JwtClaimValueType, LogProperties, NotarizationProperties, NotaryServerProperties, - TLSProperties, + LogProperties, NotarizationProperties, NotaryServerProperties, TLSProperties, }; pub use error::NotaryServerError; pub use server::{read_pem_file, run_server}; From ada88250be8fd86fb31998ac729817a709d564b9 Mon Sep 17 00:00:00 2001 From: Jakub Konka Date: Mon, 26 May 2025 14:03:27 +0200 Subject: [PATCH 5/7] Fix missing README comments --- crates/notary/server/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/notary/server/README.md b/crates/notary/server/README.md index 11ea84912d..ac8d2f8fbb 100644 --- a/crates/notary/server/README.md +++ b/crates/notary/server/README.md @@ -213,14 +213,14 @@ An optional authorization module is available to only allow requests with a vali Please note that only *one* mode can be active at any one time. ##### Whitelist mode -In whitelist mode, a valid API key needs to be attached in the custom HTTP header `X-API-Key`. The path of the API key whitelist, path (as well as the flag to enable/disable this module), can be changed in the config (`authorization` field). +In whitelist mode, a valid API key needs to be attached in the custom HTTP header `X-API-Key`. The path of the API key whitelist, as well as the flag to enable/disable this module, can be changed in the config (`authorization` field). Hot reloading of the whitelist is supported, i.e. modification of the whitelist file will be automatically applied without needing to restart the server. Please take note of the following - Avoid using auto save mode when editing the whitelist to prevent spamming hot reloads - Once the edit is saved, ensure that it has been reloaded successfully by checking the server log ##### JWT mode -In JWT mode, JSON Web Token is attached in the standard `Authorization` HTTP header as a bearer token. The path to decoding key as well as custom user claims can be changed in the +In JWT mode, JSON Web Token is attached in the standard `Authorization` HTTP header as a bearer token. The algorithm, the path to verifying key, as well as custom user claims, can be changed in the config (`authorization` field). Care should be taken when defining custom user claims as the middleware will: From df225094aa89939d67e520537a9b6bf8ff86ea15 Mon Sep 17 00:00:00 2001 From: Jakub Konka Date: Mon, 26 May 2025 23:27:19 +0200 Subject: [PATCH 6/7] Address review comments --- crates/notary/server/README.md | 76 +++++++++------------------- crates/notary/server/src/auth.rs | 5 +- crates/notary/server/src/auth/jwt.rs | 26 ++++++++-- crates/notary/server/src/config.rs | 2 +- 4 files changed, 51 insertions(+), 58 deletions(-) diff --git a/crates/notary/server/README.md b/crates/notary/server/README.md index ac8d2f8fbb..89bd87bd9e 100644 --- a/crates/notary/server/README.md +++ b/crates/notary/server/README.md @@ -90,7 +90,7 @@ log: auth: enabled: false - whitelist_path: null + whitelist: null ``` ⚠️ By default, `notarization.private_key_path` is `null`, which means a **random, ephemeral** signing key will be generated at runtime (see [Signing](#signing) for more details). @@ -168,60 +168,20 @@ TLS needs to be turned on between the prover and the notary for security purpose The toggle to turn on TLS, as well as paths to the TLS private key and certificate can be defined in the config (`tls` field). ### Authorization -An optional authorization module is available to only allow requests with a valid API key attached in the custom HTTP header `X-API-Key`. The API key whitelist path, as well as the flag to enable/disable this module, can be changed in the config (`authorization` field). - -Hot reloading of the whitelist is supported, i.e. changes to the whitelist file are automatically applied without needing to restart the server. - -### Logging -The default logging strategy of this server is set to `DEBUG` verbosity level for the crates that are useful for most debugging scenarios, i.e. using the following filtering logic. - -`notary_server=DEBUG,tlsn_verifier=DEBUG,mpc_tls=DEBUG,tls_client_async=DEBUG` - -In the configuration, one can toggle the verbosity level for these crates using the `level` field under `logging`. - -One can also provide a custom filtering logic by adding a `filter` field under `logging`, and use a value that follows the tracing crate's [filter directive syntax](https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#example-syntax). - -Logs can be printed in two formats. Compact and JSON. Compact is human-readable and is best suited for console. JSON is machine-readable and is used to send logs to log collection services. One can change log format by switching the `format` field under `logging`. Accepted values are `COMPACT` and `JSON`. `COMPACT` is used by default. - -### Concurrency -One can limit the number of concurrent notarization requests from provers via `concurrency` in the config. This is to limit resource utilization and mitigate potential DoS attacks. - ---- -## Architecture -### Objective -The main objective of a notary server is to perform notarizations together with a prover. In this case, the prover can either be a -1. TCP client — which has access and control over the transport layer, i.e. TCP. -2. WebSocket client — which has no access over TCP and instead uses WebSocket for notarizations. - -### Features -#### Notarization Configuration -To perform a notarization, some parameters need to be configured by the prover and the notary server (more details in the [OpenAPI specification](./openapi.yaml)), i.e. -- maximum data that can be sent and received -- unique session id - -To streamline this process, a single HTTP endpoint (`/session`) is used by both TCP and WebSocket clients. - -#### Notarization -After calling the configuration endpoint above, the prover can proceed to start the notarization. For a TCP client, that means calling the `/notarize` endpoint using HTTP (`https`), while a WebSocket client should call the same endpoint but using WebSocket (`wss`). Example implementations of these clients can be found in the [integration test](../tests-integration/tests/notary.rs). - -#### Signatures -Currently, both the private key (and cert) used to establish a TLS connection with the prover, and the private key used by the notary server to sign the notarized transcript, are hardcoded PEM keys stored in this repository. Though the paths of these keys can be changed in the config (`notary-key` field) to use different keys instead. - -#### Authorization An optional authorization module is available to only allow requests with a valid credential attached. Currently, two modes are supported: whitelist and JWT. Please note that only *one* mode can be active at any one time. -##### Whitelist mode -In whitelist mode, a valid API key needs to be attached in the custom HTTP header `X-API-Key`. The path of the API key whitelist, as well as the flag to enable/disable this module, can be changed in the config (`authorization` field). +#### Whitelist mode +In whitelist mode, a valid API key needs to be attached in the custom HTTP header `X-API-Key`. The path of the API key whitelist, as well as the flag to enable/disable this module, can be changed in the config (`auth` field). Hot reloading of the whitelist is supported, i.e. modification of the whitelist file will be automatically applied without needing to restart the server. Please take note of the following - Avoid using auto save mode when editing the whitelist to prevent spamming hot reloads - Once the edit is saved, ensure that it has been reloaded successfully by checking the server log -##### JWT mode +#### JWT mode In JWT mode, JSON Web Token is attached in the standard `Authorization` HTTP header as a bearer token. The algorithm, the path to verifying key, as well as custom user claims, can be changed in the -config (`authorization` field). +config (`auth` field). Care should be taken when defining custom user claims as the middleware will: - accept any claim if no custom claim is defined, @@ -230,7 +190,7 @@ Care should be taken when defining custom user claims as the middleware will: An example JWT config may look something like this: ```yaml -authorization: +auth: enabled: true jwt: algorithm: "RS256" @@ -240,12 +200,26 @@ authorization: values: ["tlsnotary"] ``` -#### Optional TLS -TLS between the prover and the notary is currently manually handled in this server, though it can be turned off if any of the following is true -- This server is run locally -- TLS is to be handled by an external environment, e.g. reverse proxy, cloud setup +### Logging +The default logging strategy of this server is set to `DEBUG` verbosity level for the crates that are useful for most debugging scenarios, i.e. using the following filtering logic. + +`notary_server=DEBUG,tlsn_verifier=DEBUG,mpc_tls=DEBUG,tls_client_async=DEBUG` + +In the configuration, one can toggle the verbosity level for these crates using the `level` field under `logging`. + +One can also provide a custom filtering logic by adding a `filter` field under `logging`, and use a value that follows the tracing crate's [filter directive syntax](https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#example-syntax). + +Logs can be printed in two formats. Compact and JSON. Compact is human-readable and is best suited for console. JSON is machine-readable and is used to send logs to log collection services. One can change log format by switching the `format` field under `logging`. Accepted values are `COMPACT` and `JSON`. `COMPACT` is used by default. + +### Concurrency +One can limit the number of concurrent notarization requests from provers via `concurrency` in the config. This is to limit resource utilization and mitigate potential DoS attacks. -The toggle to turn on/off TLS is in the config (`tls` field). Alternatively, use the CLI argument `--tls-enabled` (see [this](#configuration)). +--- +## Architecture +### Objective +The main objective of a notary server is to perform notarizations together with a prover. In this case, the prover can either be a +1. TCP client — which has access and control over the transport layer, i.e. TCP. +2. WebSocket client — which has no access over TCP and instead uses WebSocket for notarizations. ### Design Choices #### Web Framework diff --git a/crates/notary/server/src/auth.rs b/crates/notary/server/src/auth.rs index 0d7d5d8acb..239ff68f91 100644 --- a/crates/notary/server/src/auth.rs +++ b/crates/notary/server/src/auth.rs @@ -7,6 +7,7 @@ use std::{ str::FromStr, sync::{Arc, Mutex}, }; +use strum::VariantNames; use tracing::debug; use whitelist::load_authorization_whitelist; @@ -50,8 +51,8 @@ pub async fn load_authorization_mode( AuthorizationModeProperties::Jwt(jwt_opts) => { let algorithm = Algorithm::from_str(&jwt_opts.algorithm).map_err(|_| { eyre!( - "Unexpected JWT signing algorithm specified: '{}'", - jwt_opts.algorithm + "Unexpected JWT signing algorithm specified: '{}'. Possible values are: {:?}", + jwt_opts.algorithm, Algorithm::VARIANTS, ) })?; let claims = jwt_opts.claims.clone(); diff --git a/crates/notary/server/src/auth/jwt.rs b/crates/notary/server/src/auth/jwt.rs index 3d9832163d..9d1bb8a1b8 100644 --- a/crates/notary/server/src/auth/jwt.rs +++ b/crates/notary/server/src/auth/jwt.rs @@ -1,7 +1,7 @@ use eyre::Result; use jsonwebtoken::{Algorithm as JwtAlgorithm, DecodingKey}; use serde_json::Value; -use strum::EnumString; +use strum::{EnumString, VariantNames}; use tracing::error; use crate::JwtClaim; @@ -56,15 +56,15 @@ impl Jwt { } } -#[derive(EnumString, Debug, Clone, Copy, PartialEq, Eq)] +#[derive(EnumString, Debug, Clone, Copy, PartialEq, Eq, VariantNames)] #[strum(ascii_case_insensitive)] /// Supported JWT signing algorithms pub enum Algorithm { - /// RSASSA-PSS using SHA-512 + /// RSASSA-PKCS1-v1_5 using SHA-256 RS256, /// RSASSA-PKCS1-v1_5 using SHA-384 RS384, - /// RSASSA-PKCS1-v1_5 using SHA-384 + /// RSASSA-PKCS1-v1_5 using SHA-512 RS512, /// RSASSA-PSS using SHA-256 PS256, @@ -189,4 +189,22 @@ mod test { Err(JwtValidationError("unexpected value for claim 'sub': expected one of [ 'tlsn_prod', 'tlsn_test' ], received 'tlsn'".to_string())) ) } + + #[test] + fn fails_if_claim_has_invalid_value_type() { + let expected = JwtClaim { + name: "sub".to_string(), + ..Default::default() + }; + let given = json!({ + "sub": { "name": "john" } + }); + assert_eq!( + Jwt::validate_claim(&expected, &given), + Err(JwtValidationError( + "unexpected type for claim 'sub': only strings are supported for claim values" + .to_string() + )) + ) + } } diff --git a/crates/notary/server/src/config.rs b/crates/notary/server/src/config.rs index 9130ed62d6..8f14b10007 100644 --- a/crates/notary/server/src/config.rs +++ b/crates/notary/server/src/config.rs @@ -171,7 +171,7 @@ pub struct JwtAuthorizationProperties { /// File path to JWT public key (in PEM format) for verifying token /// signatures pub public_key_path: String, - /// Set of required JWT claims + /// Optional set of required JWT claims #[serde(default)] pub claims: Vec, } From c2fa624967de943fd80013d2c5b6c432263e5cbb Mon Sep 17 00:00:00 2001 From: Jakub Konka Date: Tue, 27 May 2025 10:43:27 +0200 Subject: [PATCH 7/7] Address review comments --- crates/notary/server/README.md | 7 ++----- crates/notary/server/src/auth.rs | 11 ++++++++--- crates/notary/server/src/error.rs | 7 ------- crates/notary/server/src/middleware.rs | 15 +++++++++++---- 4 files changed, 21 insertions(+), 19 deletions(-) diff --git a/crates/notary/server/README.md b/crates/notary/server/README.md index 89bd87bd9e..9e6a865012 100644 --- a/crates/notary/server/README.md +++ b/crates/notary/server/README.md @@ -175,13 +175,10 @@ Please note that only *one* mode can be active at any one time. #### Whitelist mode In whitelist mode, a valid API key needs to be attached in the custom HTTP header `X-API-Key`. The path of the API key whitelist, as well as the flag to enable/disable this module, can be changed in the config (`auth` field). -Hot reloading of the whitelist is supported, i.e. modification of the whitelist file will be automatically applied without needing to restart the server. Please take note of the following -- Avoid using auto save mode when editing the whitelist to prevent spamming hot reloads -- Once the edit is saved, ensure that it has been reloaded successfully by checking the server log +Hot reloading of the whitelist is supported, i.e. changes to the whitelist file are automatically applied without needing to restart the server. #### JWT mode -In JWT mode, JSON Web Token is attached in the standard `Authorization` HTTP header as a bearer token. The algorithm, the path to verifying key, as well as custom user claims, can be changed in the -config (`auth` field). +In JWT mode, JSON Web Token is attached in the standard `Authorization` HTTP header as a bearer token. The algorithm, the path to verifying key, as well as custom user claims, can be changed in the config (`auth` field). Care should be taken when defining custom user claims as the middleware will: - accept any claim if no custom claim is defined, diff --git a/crates/notary/server/src/auth.rs b/crates/notary/server/src/auth.rs index 239ff68f91..d3a984cc4e 100644 --- a/crates/notary/server/src/auth.rs +++ b/crates/notary/server/src/auth.rs @@ -11,7 +11,7 @@ use strum::VariantNames; use tracing::debug; use whitelist::load_authorization_whitelist; -pub use jwt::{Algorithm, Jwt, JwtValidationError}; +pub use jwt::{Algorithm, Jwt}; pub use whitelist::{ watch_and_reload_authorization_whitelist, AuthorizationWhitelistRecord, Whitelist, }; @@ -49,14 +49,18 @@ pub async fn load_authorization_mode( ) })? { AuthorizationModeProperties::Jwt(jwt_opts) => { + debug!("Using JWT for authorization"); let algorithm = Algorithm::from_str(&jwt_opts.algorithm).map_err(|_| { eyre!( "Unexpected JWT signing algorithm specified: '{}'. Possible values are: {:?}", - jwt_opts.algorithm, Algorithm::VARIANTS, + jwt_opts.algorithm, + Algorithm::VARIANTS, ) })?; let claims = jwt_opts.claims.clone(); - let key = load_jwt_key(&jwt_opts.public_key_path, algorithm).await?; + let key = load_jwt_key(&jwt_opts.public_key_path, algorithm) + .await + .map_err(|err| eyre!("Failed to parse JWT public key: {:?}", err))?; AuthorizationMode::Jwt(Jwt { key, claims, @@ -64,6 +68,7 @@ pub async fn load_authorization_mode( }) } AuthorizationModeProperties::Whitelist(whitelist_csv_path) => { + debug!("Using whitelist for authorization"); let entries = load_authorization_whitelist(whitelist_csv_path)?; AuthorizationMode::Whitelist(Whitelist { entries: Arc::new(Mutex::new(entries)), diff --git a/crates/notary/server/src/error.rs b/crates/notary/server/src/error.rs index d8bc3b2e1c..4b5ec6691b 100644 --- a/crates/notary/server/src/error.rs +++ b/crates/notary/server/src/error.rs @@ -6,8 +6,6 @@ use tlsn_common::config::ProtocolConfigValidatorBuilderError; use tlsn_verifier::{VerifierConfigBuilderError, VerifierError}; -use crate::auth::JwtValidationError; - #[derive(Debug, thiserror::Error)] pub enum NotaryServerError { #[error(transparent)] @@ -20,8 +18,6 @@ pub enum NotaryServerError { BadProverRequest(String), #[error("Unauthorized request from prover: {0}")] UnauthorizedProverRequest(String), - #[error(transparent)] - JwtValidation(#[from] JwtValidationError), } impl From for NotaryServerError { @@ -54,9 +50,6 @@ impl AxumCoreIntoResponse for NotaryServerError { unauthorized_request_error.to_string(), ) .into_response(), - jwt_validation_error @ NotaryServerError::JwtValidation(..) => { - (StatusCode::UNAUTHORIZED, jwt_validation_error.to_string()).into_response() - } _ => ( StatusCode::INTERNAL_SERVER_ERROR, "Something wrong happened.", diff --git a/crates/notary/server/src/middleware.rs b/crates/notary/server/src/middleware.rs index 2efa8806ae..d8d395b8ea 100644 --- a/crates/notary/server/src/middleware.rs +++ b/crates/notary/server/src/middleware.rs @@ -58,10 +58,17 @@ where unauthorized("Invalid Authorization header: expected 'Bearer '") })?; let validation = Validation::new(jwt_config.algorithm.into()); - let TokenData { claims, .. } = - decode::(raw_token, &jwt_config.key, &validation) - .map_err(|err| unauthorized(format!("Invalid JWT token: {err:#?}")))?; - jwt_config.validate(&claims)?; + let claims = match decode::(raw_token, &jwt_config.key, &validation) { + Ok(TokenData { claims, .. }) => claims, + Err(err) => { + error!("Decoding JWT failed with error: {err:?}"); + return Err(unauthorized("Invalid JWT token")); + } + }; + if let Err(err) = jwt_config.validate(&claims) { + error!("Validating JWT failed with error: {err:?}"); + return Err(unauthorized("Invalid JWT token")); + }; trace!("Request authorized."); Ok(Self) }