diff --git a/drivers/rust/driver/src/wasm_plugin.rs b/drivers/rust/driver/src/wasm_plugin.rs index 8b3c2a83..c2f7c761 100644 --- a/drivers/rust/driver/src/wasm_plugin.rs +++ b/drivers/rust/driver/src/wasm_plugin.rs @@ -22,7 +22,14 @@ use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, WasiView}; use crate::catalogue_manager::{CatalogueEntryProviderType, CatalogueEntryType, register_plugin_entries}; use crate::mock_server::{MockServerConfig, MockServerDetails, MockServerResults}; -use crate::plugin_models::{CompareContentRequest, CompareContentResult, GenerateContentRequest, PactPlugin, PactPluginManifest}; +use crate::plugin_models::{ + CompareContentRequest, + CompareContentResult, + GenerateContentRequest, + PactPlugin, + PactPluginManifest, + PluginInteractionConfig +}; use crate::verification::{InteractionVerificationData, InteractionVerificationResult}; bindgen!(); @@ -71,6 +78,78 @@ impl Into for ContentTypeHint { } } +impl From for CompareContentsRequest { + fn from(value: CompareContentRequest) -> Self { + CompareContentsRequest { + expected: value.expected_contents.into(), + actual: value.actual_contents.into(), + allow_unexpected_keys: value.allow_unexpected_keys, + plugin_configuration: value.plugin_configuration + .map(|config| config.into()) + .unwrap_or_else(|| PluginConfiguration { + interaction_configuration: Default::default(), + pact_configuration: Default::default() + }) + } + } +} + +impl From for Body { + fn from(value: OptionalBody) -> Self { + Body { + content: value.value().unwrap_or_default().to_vec(), + content_type: value.content_type().unwrap_or_default().to_string(), + content_type_hint: None + } + } +} + +impl From for PluginConfiguration { + fn from(value: PluginInteractionConfig) -> Self { + PluginConfiguration { + pact_configuration: Value::Object(value.pact_configuration + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect()).to_string(), + interaction_configuration: Value::Object(value.interaction_configuration + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect()).to_string(), + } + } +} + +impl Into for CompareContentsResponse { + fn into(self) -> CompareContentResult { + if let Some(type_mismatch) = &self.type_mismatch { + CompareContentResult::TypeMismatch(type_mismatch.expected.clone(), type_mismatch.actual.clone()) + } else if !self.results.is_empty() { + let mismatches = self.results + .iter() + .map(|(path, mismatches)| { + (path.clone(), mismatches.iter().map(|m| m.into()).collect()) + }) + .collect(); + CompareContentResult::Mismatches(mismatches) + } else { + CompareContentResult::OK + } + } +} + +impl From<&ContentMismatch> for crate::content::ContentMismatch { + fn from(value: &ContentMismatch) -> Self { + crate::content::ContentMismatch { + expected: "".to_string(), + actual: "".to_string(), + mismatch: value.mismatch.to_string(), + path: value.path.to_string(), + diff: None, + mismatch_type: None, + } + } +} + /// Plugin that executes in a WASM VM #[derive(Clone)] pub struct WasmPlugin { @@ -137,8 +216,17 @@ impl PactPlugin for WasmPlugin { todo!() } - async fn match_contents(&self, request: CompareContentRequest) -> anyhow::Result { - todo!() + async fn match_contents( + &self, + request: CompareContentRequest + ) -> anyhow::Result { + let mut store = self.store.lock().unwrap(); + + let result = self.instance.call_compare_contents(store.as_context_mut(), &request.into())? + .map_err(|err| anyhow!(err))?; + debug!("Result from call to compare_contents: {:?}", result); + + Ok(result.into()) } async fn configure_interaction( diff --git a/drivers/rust/driver/wit/plugin.wit b/drivers/rust/driver/wit/plugin.wit index e5b46ba1..c0be36fe 100644 --- a/drivers/rust/driver/wit/plugin.wit +++ b/drivers/rust/driver/wit/plugin.wit @@ -120,6 +120,53 @@ world plugin { plugin-config: option } + // Request to preform a comparison on an actual body given the expected one + record compare-contents-request { + // Expected body from the Pact interaction + expected: body, + // Actual received body + actual: body, + // If unexpected keys or attributes should be allowed. Setting this to false results in additional keys or fields + // will cause a mismatch + allow-unexpected-keys: bool, + // Map of expressions to matching rules. The expressions follow the documented Pact matching rule expressions + // map rules = 4; + // Additional data added to the Pact/Interaction by the plugin + plugin-configuration: plugin-configuration + } + + // Indicates that there was a mismatch with the content type and the body was not compared + record content-type-mismatch { + // Expected content type (MIME format) + expected: string, + // Actual content type received (MIME format) + actual: string + } + + // A mismatch for an particular item of content + record content-mismatch { + // Expected data bytes + expected: list, + // Actual data bytes + actual: list, + // Description of the mismatch + mismatch: string, + // Path to the item that was matched. This is the value as per the documented Pact matching rule expressions. + path: string, + // Optional diff of the contents + diff: option, + // Part of the interaction that the mismatch is for: body, headers, metadata, etc. + mismatch-type: string + } + + // Response to the CompareContentsRequest with the results of the comparison + record compare-contents-response { + // There was a mismatch with the types of content. If this is set, the results may not be set. + type-mismatch: option, + // Results of the match, keyed by matching rule expression + results: list>> + } + // Init function is called after the plugin is loaded. It needs to return the plugin catalog // entries to be added to the global catalog export init: func(implementation: string, version: string) -> list; @@ -129,4 +176,7 @@ world plugin { // Request to configure/setup the interaction for later verification. Data returned will be persisted in the pact file. export configure-interaction: func(content-type: string, config-json: string) -> result; + + // Request to perform a comparison of some contents (matching request) + export compare-contents: func(request: compare-contents-request) -> result; } diff --git a/plugins/jwt/wasm-plugin/Cargo.lock b/plugins/jwt/wasm-plugin/Cargo.lock index 04ca362a..fcb28bb3 100644 --- a/plugins/jwt/wasm-plugin/Cargo.lock +++ b/plugins/jwt/wasm-plugin/Cargo.lock @@ -552,6 +552,7 @@ dependencies = [ "anyhow", "base64 0.22.1", "expectest", + "maplit", "pact_models", "rsa", "serde_json", diff --git a/plugins/jwt/wasm-plugin/Cargo.toml b/plugins/jwt/wasm-plugin/Cargo.toml index fb68c242..b2957b19 100644 --- a/plugins/jwt/wasm-plugin/Cargo.toml +++ b/plugins/jwt/wasm-plugin/Cargo.toml @@ -9,6 +9,7 @@ crate-type = ["cdylib"] [dependencies] anyhow = "1.0.86" base64 = "0.22.1" +maplit = "1.0.2" pact_models = { version = "1.2.2", default-features = false } serde_json = "1.0.120" rsa = { version = "0.9.6", features = ["sha2"] } diff --git a/plugins/jwt/wasm-plugin/jwt_plugin.wasm b/plugins/jwt/wasm-plugin/jwt_plugin.wasm index e2dca790..e921c866 100644 Binary files a/plugins/jwt/wasm-plugin/jwt_plugin.wasm and b/plugins/jwt/wasm-plugin/jwt_plugin.wasm differ diff --git a/plugins/jwt/wasm-plugin/src/jwt.rs b/plugins/jwt/wasm-plugin/src/jwt.rs index dc1f97d5..a71a560c 100644 --- a/plugins/jwt/wasm-plugin/src/jwt.rs +++ b/plugins/jwt/wasm-plugin/src/jwt.rs @@ -1,14 +1,16 @@ +use std::str::from_utf8; use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::anyhow; use base64::Engine; -use base64::engine::general_purpose::URL_SAFE as BASE64; +use base64::engine::general_purpose::URL_SAFE_NO_PAD as BASE64; use pact_models::generators::generate_hexadecimal; -use rsa::pkcs1::DecodeRsaPrivateKey; -use rsa::pkcs1v15::SigningKey; -use rsa::RsaPrivateKey; +use rsa::pkcs1::{DecodeRsaPrivateKey, DecodeRsaPublicKey}; +use rsa::pkcs1v15::{Signature, SigningKey, VerifyingKey}; +use rsa::{RsaPrivateKey, RsaPublicKey}; +use rsa::pkcs8::DecodePublicKey; use rsa::sha2::Sha512; -use rsa::signature::{SignatureEncoding, Signer}; +use rsa::signature::{SignatureEncoding, Signer, Verifier}; use serde_json::{Map, Value}; use crate::json_to_string; @@ -89,3 +91,59 @@ pub(crate) fn sign_token( let encoded_signature = BASE64.encode(signature.to_bytes()); Ok(encoded_signature) } + +#[derive(Debug, Clone)] +pub struct Token { + pub header: Map, + pub claims: Map, + pub signature: String, + pub encoded: String, +} + +pub(crate) fn decode_token(token_bytes: &[u8]) -> anyhow::Result { + let encoded_string = from_utf8(token_bytes)?; + log(format!("Encoded token = {}", encoded_string).as_str()); + let parts: Vec<_> = encoded_string.split('.').collect(); + + let header_part = parts.get(0) + .ok_or_else(|| anyhow!("Token header was missing from token string"))?; + let header = BASE64.decode(header_part) + .map_err(|err| anyhow!("Failed to decode the token bytes: {}", err)) + .and_then(|bytes| serde_json::from_slice::(bytes.as_slice()) + .map_err(|err| anyhow!("Failed to parse token header as JSON: {}", err)))?; + log(format!("Token header = {}", header).as_str()); + + let claims_part = parts.get(1) + .ok_or_else(|| anyhow!("Token claims was missing from token string"))?; + let claims = BASE64.decode(claims_part) + .map_err(|err| anyhow!("Failed to decode the token bytes: {}", err)) + .and_then(|bytes| serde_json::from_slice::(bytes.as_slice()) + .map_err(|err| anyhow!("Failed to parse token claims as JSON: {}", err)))?; + log(format!("Token claims = {}", claims).as_str()); + + let signature = parts.get(1) + .ok_or_else(|| anyhow!("Token signature was missing from token string"))?; + log(format!("Token signature = {}", signature).as_str()); + + Ok(Token { + header: header.as_object().cloned().unwrap_or_default(), + claims: claims.as_object().cloned().unwrap_or_default(), + signature: signature.to_string(), + encoded: encoded_string.to_string() + }) +} + +pub(crate) fn validate_signature(token: &Token, algorithm: &String, public_key: &String) -> anyhow::Result<()> { + log(format!("Signature algorithm is set to {}", algorithm).as_str()); + if algorithm != "RS512" { + return Err(anyhow!("Only the RS512 algorithm is supported at the moment")); + } + + let public_key = RsaPublicKey::from_public_key_pem(public_key)?; + let verifying_key = VerifyingKey::::new(public_key); + let (base_token, sig) = token.encoded.rsplit_once('.') + .ok_or_else(|| anyhow!("Encoded token is not valid, was expecting parts seperated with a '.'"))?; + let signature = Signature::try_from(BASE64.decode(sig)?.as_slice())?; + verifying_key.verify(base_token.as_bytes(), &signature) + .map_err(|err| anyhow!("Failed to verify token signature: {}", err)) +} diff --git a/plugins/jwt/wasm-plugin/src/lib.rs b/plugins/jwt/wasm-plugin/src/lib.rs index 510007b9..d1814069 100644 --- a/plugins/jwt/wasm-plugin/src/lib.rs +++ b/plugins/jwt/wasm-plugin/src/lib.rs @@ -1,12 +1,13 @@ use anyhow::anyhow; use base64::Engine; -use base64::engine::general_purpose::URL_SAFE as BASE64; +use base64::engine::general_purpose::URL_SAFE_NO_PAD as BASE64; use rsa::pkcs1::{DecodeRsaPrivateKey, LineEnding}; use rsa::pkcs8::EncodePublicKey; use rsa::RsaPrivateKey; use serde_json::{json, Value}; mod jwt; +mod matching; wit_bindgen::generate!(); @@ -77,7 +78,7 @@ impl Guest for JwtPlugin { let plugin_config = PluginConfiguration { interaction_configuration: json!({ "public-key": public_key, - "algorithm": format!("{}", header["alg"]) + "algorithm": json_to_string(&header["alg"]) }).to_string(), pact_configuration: "".to_string() }; @@ -98,6 +99,53 @@ impl Guest for JwtPlugin { plugin_config: Some(plugin_config) }) } + + // This function does the actual matching + fn compare_contents(request: CompareContentsRequest) -> Result { + log(format!("Got a match request: {:?}", request).as_str()); + + let interaction_configuration: Value = serde_json::from_str(request.plugin_configuration.interaction_configuration.as_str()) + .map_err(|err| format!("Failed to parse the plugin configuration: {}", err))?; + let public_key = json_to_string(&interaction_configuration.get("public-key") + .cloned() + .unwrap_or_default()); + let algorithm = json_to_string(&interaction_configuration + .get("algorithm") + .cloned() + .unwrap_or_default()); + + let expected_jwt = jwt::decode_token(request.expected.content.as_slice()) + .map_err(|err| format!("Failed to decode the expected token: {}", err))?; + log(format!("Expected JWT: {:?}", expected_jwt).as_str()); + + let actual_jwt = jwt::decode_token(request.actual.content.as_slice()) + .map_err(|err| format!("Failed to decode the actual token: {}", err))?; + log(format!("Actual JWT: {:?}", actual_jwt).as_str()); + + let mut result = CompareContentsResponse { + type_mismatch: None, + results: vec![] + }; + + if let Err(token_issues) = matching::validate_token(&actual_jwt, &algorithm, &public_key) { + result.results.push(("$".to_string(), token_issues)); + } + + if let Err(header_mismatches) = matching::match_headers(&expected_jwt.header, &actual_jwt.header) { + for (k, v) in header_mismatches { + result.results.push((format!("header:{}", k), v)); + } + } + + if let Err(claim_mismatches) = matching::match_claims(&expected_jwt.claims, &actual_jwt.claims) { + for (k, v) in claim_mismatches { + result.results.push((format!("claims:{}", k), v)); + } + } + + log(format!("returning match result -> {:?}", result).as_str()); + Ok(result) + } } fn rsa_public_key(private_key: &str) -> anyhow::Result { diff --git a/plugins/jwt/wasm-plugin/src/matching.rs b/plugins/jwt/wasm-plugin/src/matching.rs new file mode 100644 index 00000000..973fd9eb --- /dev/null +++ b/plugins/jwt/wasm-plugin/src/matching.rs @@ -0,0 +1,195 @@ +use std::collections::{HashMap, HashSet}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use maplit::{hashmap, hashset}; +use serde_json::{Map, Value}; + +use crate::ContentMismatch; +use crate::jwt::Token; +use crate::log; + +pub(crate) fn validate_token(token: &Token, algorithm: &String, public_key: &String) -> Result<(), Vec> { + let mut mismatches = vec![]; + + if let Err(signature_error) = crate::jwt::validate_signature(&token, algorithm, public_key) { + mismatches.push(ContentMismatch { + expected: vec![], + actual: vec![], + mismatch: format!("Actual token signature is not valid: {}", signature_error), + path: "$".to_string(), + diff: None, + mismatch_type: "".to_string() + }); + } + + let expiration_time = token.claims.get("exp") + .cloned() + .unwrap_or_default(); + if let Some(expiration_time) = expiration_time.as_u64() { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or(Duration::new(0, 0)) + .as_secs(); + if expiration_time < now { + mismatches.push(ContentMismatch { + expected: vec![], + actual: vec![], + mismatch: format!("Actual token has expired (exp {} < current clock {})", expiration_time, now), + path: "$.exp".to_string(), + diff: None, + mismatch_type: "".to_string() + }); + } + } else { + mismatches.push(ContentMismatch { + expected: vec![], + actual: vec![], + mismatch: "Actual token expiration time (exp) was missing or not a valid number".to_string(), + path: "$.exp".to_string(), + diff: None, + mismatch_type: "".to_string() + }); + } + + if let Some(not_before_time) = token.claims.get("nbf") { + if let Some(not_before_time) = not_before_time.as_u64() { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or(Duration::new(0, 0)) + .as_secs(); + if not_before_time > now { + mismatches.push(ContentMismatch { + expected: vec![], + actual: vec![], + mismatch: format!("Actual token is not to be used yet (nbf {} > current clock {})", not_before_time, now), + path: "$.exp".to_string(), + diff: None, + mismatch_type: "".to_string() + }); + } + } else { + mismatches.push(ContentMismatch { + expected: vec![], + actual: vec![], + mismatch: "Actual token not before time (nbf) is not a valid number".to_string(), + path: "$.nbf".to_string(), + diff: None, + mismatch_type: "".to_string() + }); + } + } + + if mismatches.is_empty() { + Ok(()) + } else { + Err(mismatches) + } +} + +pub(crate) fn match_headers(expected: &Map, actual: &Map) -> Result<(), HashMap>> { + log("matching JWT headers"); + log(format!("expected headers: {:?}", expected).as_str()); + log(format!("actual headers: {:?}", actual).as_str()); + match_map( + expected, + actual, + hashset!{"typ", "alg"}, + hashset!{"alg", "jku", "jwk", "kid", "x5u", "x5c", "x5t", "x5t#S256", "typ", "cty", "crit"}, + hashset!{"jku"} + ) +} + +pub(crate) fn match_claims(expected: &Map, actual: &Map) -> Result<(), HashMap>> { + log("matching JWT claims"); + log(format!("expected claims: {:?}", expected).as_str()); + log(format!("actual claims: {:?}", actual).as_str()); + match_map( + expected, + actual, + hashset!{"iss", "sub", "aud", "exp"}, + hashset!{}, + hashset!{"exp", "nbf", "iat", "jti"} + ) +} + +fn match_map( + expected: &Map, + actual: &Map, + compulsory_keys: HashSet<&str>, + allow_keys: HashSet<&str>, + keys_to_ignore: HashSet<&str> +) -> Result<(), HashMap>> { + let mut mismatches: HashMap<_, Vec> = hashmap![]; + + for (k, v) in expected { + if !keys_to_ignore.contains(k.as_str()) { + if let Some(actual_value) = actual.get(k) { + if actual_value != v { + mismatches + .entry(k.clone()) + .or_default() + .push(ContentMismatch { + expected: v.to_string().as_bytes().to_vec(), + actual: actual_value.to_string().as_bytes().to_vec(), + mismatch: format!("Expected value {} but got value {}", v, actual_value), + path: k.to_string(), + diff: None, + mismatch_type: "".to_string() + }) + } + } else { + mismatches + .entry(k.clone()) + .or_default() + .push(ContentMismatch { + expected: v.to_string().as_bytes().to_vec(), + actual: vec![], + mismatch: format!("Expected value {} but did not get a value", v), + path: k.clone(), + diff: None, + mismatch_type: "".to_string() + }) + } + } + } + + if !allow_keys.is_empty() { + for (k, v) in actual { + if !allow_keys.contains(k.as_str()) { + mismatches + .entry(k.clone()) + .or_default() + .push(ContentMismatch { + expected: vec![], + actual: v.to_string().as_bytes().to_vec(), + mismatch: format!("{} is not allowed as a key", k), + path: k.clone(), + diff: None, + mismatch_type: "".to_string() + }) + } + } + } + + for k in compulsory_keys { + if !actual.contains_key(k) { + mismatches + .entry(k.to_string()) + .or_default() + .push(ContentMismatch { + expected: k.as_bytes().to_vec(), + actual: vec![], + mismatch: format!("{} is a compulsory key", k), + path: k.to_string(), + diff: None, + mismatch_type: "".to_string() + }) + } + } + + if mismatches.is_empty() || mismatches.values().all(|v| v.is_empty()) { + Ok(()) + } else { + Err(mismatches) + } +} diff --git a/plugins/jwt/wasm-plugin/wit/plugin.wit b/plugins/jwt/wasm-plugin/wit/plugin.wit index e5b46ba1..c0be36fe 100644 --- a/plugins/jwt/wasm-plugin/wit/plugin.wit +++ b/plugins/jwt/wasm-plugin/wit/plugin.wit @@ -120,6 +120,53 @@ world plugin { plugin-config: option } + // Request to preform a comparison on an actual body given the expected one + record compare-contents-request { + // Expected body from the Pact interaction + expected: body, + // Actual received body + actual: body, + // If unexpected keys or attributes should be allowed. Setting this to false results in additional keys or fields + // will cause a mismatch + allow-unexpected-keys: bool, + // Map of expressions to matching rules. The expressions follow the documented Pact matching rule expressions + // map rules = 4; + // Additional data added to the Pact/Interaction by the plugin + plugin-configuration: plugin-configuration + } + + // Indicates that there was a mismatch with the content type and the body was not compared + record content-type-mismatch { + // Expected content type (MIME format) + expected: string, + // Actual content type received (MIME format) + actual: string + } + + // A mismatch for an particular item of content + record content-mismatch { + // Expected data bytes + expected: list, + // Actual data bytes + actual: list, + // Description of the mismatch + mismatch: string, + // Path to the item that was matched. This is the value as per the documented Pact matching rule expressions. + path: string, + // Optional diff of the contents + diff: option, + // Part of the interaction that the mismatch is for: body, headers, metadata, etc. + mismatch-type: string + } + + // Response to the CompareContentsRequest with the results of the comparison + record compare-contents-response { + // There was a mismatch with the types of content. If this is set, the results may not be set. + type-mismatch: option, + // Results of the match, keyed by matching rule expression + results: list>> + } + // Init function is called after the plugin is loaded. It needs to return the plugin catalog // entries to be added to the global catalog export init: func(implementation: string, version: string) -> list; @@ -129,4 +176,7 @@ world plugin { // Request to configure/setup the interaction for later verification. Data returned will be persisted in the pact file. export configure-interaction: func(content-type: string, config-json: string) -> result; + + // Request to perform a comparison of some contents (matching request) + export compare-contents: func(request: compare-contents-request) -> result; }