diff --git a/crates/common/src/integrations/prebid.rs b/crates/common/src/integrations/prebid.rs index 1ad84f9e..4116f3bf 100644 --- a/crates/common/src/integrations/prebid.rs +++ b/crates/common/src/integrations/prebid.rs @@ -26,7 +26,7 @@ use crate::openrtb::{ Banner, Device, Format, Geo, Imp, ImpExt, OpenRtbRequest, PrebidExt, PrebidImpExt, Regs, RegsExt, RequestExt, Site, TrustedServerExt, User, UserExt, }; -use crate::request_signing::RequestSigner; +use crate::request_signing::{RequestSigner, SigningParams, SIGNING_VERSION}; use crate::settings::{IntegrationConfig, Settings}; const PREBID_INTEGRATION_ID: &str = "prebid"; @@ -333,7 +333,6 @@ fn expand_trusted_server_bidders( }) .collect() } - fn transform_prebid_response( response: &mut Json, request_host: &str, @@ -466,7 +465,7 @@ impl PrebidAuctionProvider { &self, request: &AuctionRequest, context: &AuctionContext<'_>, - signer: Option<(&RequestSigner, String)>, + signer: Option<(&RequestSigner, String, &SigningParams)>, ) -> OpenRtbRequest { let imps: Vec = request .slots @@ -553,19 +552,28 @@ impl PrebidAuctionProvider { // Build ext object let request_info = RequestInfo::from_request(context.request); - let (signature, kid) = signer - .map(|(s, sig)| (Some(sig), Some(s.kid.clone()))) - .unwrap_or((None, None)); + let (version, signature, kid, ts) = signer + .map(|(s, sig, params)| { + ( + Some(SIGNING_VERSION.to_string()), + Some(sig), + Some(s.kid.clone()), + Some(params.timestamp), + ) + }) + .unwrap_or((None, None, None, None)); let ext = Some(RequestExt { prebid: Some(PrebidExt { debug: if self.config.debug { Some(true) } else { None }, }), trusted_server: Some(TrustedServerExt { + version, signature, kid, request_host: Some(request_info.host), request_scheme: Some(request_info.scheme), + ts, }), }); @@ -686,18 +694,22 @@ impl AuctionProvider for PrebidAuctionProvider { log::info!("Prebid: requesting bids for {} slots", request.slots.len()); // Create signer and compute signature if request signing is enabled - let signer_with_signature = - if let Some(request_signing_config) = &context.settings.request_signing { - if request_signing_config.enabled { - let signer = RequestSigner::from_config()?; - let signature = signer.sign(request.id.as_bytes())?; - Some((signer, signature)) - } else { - None - } + let signer_with_signature = if let Some(request_signing_config) = + &context.settings.request_signing + { + if request_signing_config.enabled { + let request_info = RequestInfo::from_request(context.request); + let signer = RequestSigner::from_config()?; + let params = + SigningParams::new(request.id.clone(), request_info.host, request_info.scheme); + let signature = signer.sign_request(¶ms)?; + Some((signer, signature, params)) } else { None - }; + } + } else { + None + }; // Convert to OpenRTB with all enrichments let openrtb = self.to_openrtb( @@ -705,7 +717,7 @@ impl AuctionProvider for PrebidAuctionProvider { context, signer_with_signature .as_ref() - .map(|(s, sig)| (s, sig.clone())), + .map(|(s, sig, params)| (s, sig.clone(), params)), ); // Log the outgoing OpenRTB request for debugging diff --git a/crates/common/src/openrtb.rs b/crates/common/src/openrtb.rs index 3b405209..fd9a84cd 100644 --- a/crates/common/src/openrtb.rs +++ b/crates/common/src/openrtb.rs @@ -113,6 +113,9 @@ pub struct PrebidExt { #[derive(Debug, Serialize, Default)] pub struct TrustedServerExt { + /// Version of the signing protocol (e.g., "1.1") + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, #[serde(skip_serializing_if = "Option::is_none")] pub signature: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -121,6 +124,9 @@ pub struct TrustedServerExt { pub request_host: Option, #[serde(skip_serializing_if = "Option::is_none")] pub request_scheme: Option, + /// Unix timestamp in milliseconds for replay protection + #[serde(skip_serializing_if = "Option::is_none")] + pub ts: Option, } #[derive(Debug, Serialize)] diff --git a/crates/common/src/request_signing/signing.rs b/crates/common/src/request_signing/signing.rs index 1961c780..7086bfdf 100644 --- a/crates/common/src/request_signing/signing.rs +++ b/crates/common/src/request_signing/signing.rs @@ -6,6 +6,7 @@ use base64::{engine::general_purpose, Engine}; use ed25519_dalek::{Signature, Signer as Ed25519Signer, SigningKey, Verifier, VerifyingKey}; use error_stack::{Report, ResultExt}; +use serde::Serialize; use crate::error::TrustedServerError; use crate::fastly_storage::{FastlyConfigStore, FastlySecretStore}; @@ -45,6 +46,72 @@ pub struct RequestSigner { pub kid: String, } +/// Current version of the signing protocol +pub const SIGNING_VERSION: &str = "1.1"; + +/// Canonical payload structure for request signing. +/// +/// Serialized as JSON to prevent signature confusion attacks that could +/// exploit delimiter-based formats. +#[derive(Serialize)] +struct SigningPayload<'a> { + version: &'a str, + kid: &'a str, + host: &'a str, + scheme: &'a str, + id: &'a str, + ts: u64, +} + +/// Parameters for enhanced request signing +#[derive(Debug, Clone)] +pub struct SigningParams { + pub request_id: String, + pub request_host: String, + pub request_scheme: String, + pub timestamp: u64, +} + +impl SigningParams { + /// Creates a new `SigningParams` with the current timestamp in milliseconds + #[must_use] + pub fn new(request_id: String, request_host: String, request_scheme: String) -> Self { + Self { + request_id, + request_host, + request_scheme, + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0), + } + } + + /// Builds the canonical payload string for signing. + /// + /// The payload is a JSON-serialized [`SigningPayload`] to prevent signature + /// confusion attacks that could exploit delimiter-based formats. + /// + /// # Errors + /// + /// Returns an error if the payload cannot be serialized to JSON. + pub fn build_payload(&self, kid: &str) -> Result> { + let payload = SigningPayload { + version: SIGNING_VERSION, + kid, + host: &self.request_host, + scheme: &self.request_scheme, + id: &self.request_id, + ts: self.timestamp, + }; + serde_json::to_string(&payload).map_err(|e| { + Report::new(TrustedServerError::Configuration { + message: format!("Failed to serialize signing payload: {}", e), + }) + }) + } +} + impl RequestSigner { /// Creates a `RequestSigner` from the current key ID stored in config. /// @@ -82,6 +149,22 @@ impl RequestSigner { Ok(general_purpose::URL_SAFE_NO_PAD.encode(signature_bytes)) } + + /// Signs a request using the enhanced v1.1 signing protocol. + /// + /// The signed payload is a JSON object containing version, kid, host, + /// scheme, id, and ts fields. + /// + /// # Errors + /// + /// Returns an error if signing fails. + pub fn sign_request( + &self, + params: &SigningParams, + ) -> Result> { + let payload = params.build_payload(&self.kid)?; + self.sign(payload.as_bytes()) + } } /// Verifies a signature using the public key associated with the given key ID. @@ -234,4 +317,91 @@ mod tests { let result = verify_signature(payload, malformed_signature, &signer.kid); assert!(result.is_err(), "Should error for malformed signature"); } + + #[test] + fn test_signing_params_build_payload() { + let params = SigningParams { + request_id: "req-123".to_string(), + request_host: "example.com".to_string(), + request_scheme: "https".to_string(), + timestamp: 1706900000, + }; + + let payload = params + .build_payload("kid-abc") + .expect("should build payload"); + let parsed: serde_json::Value = + serde_json::from_str(&payload).expect("should be valid JSON"); + assert_eq!(parsed["version"], SIGNING_VERSION); + assert_eq!(parsed["kid"], "kid-abc"); + assert_eq!(parsed["host"], "example.com"); + assert_eq!(parsed["scheme"], "https"); + assert_eq!(parsed["id"], "req-123"); + assert_eq!(parsed["ts"], 1706900000); + } + + #[test] + fn test_signing_params_new_creates_timestamp() { + let params = SigningParams::new( + "req-123".to_string(), + "example.com".to_string(), + "https".to_string(), + ); + + assert_eq!(params.request_id, "req-123"); + assert_eq!(params.request_host, "example.com"); + assert_eq!(params.request_scheme, "https"); + // Timestamp should be recent (within last minute), in milliseconds + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64; + assert!(params.timestamp <= now_ms); + assert!(params.timestamp >= now_ms - 60_000); + } + + #[test] + fn test_sign_request_enhanced() { + let signer = RequestSigner::from_config().unwrap(); + let params = SigningParams::new( + "auction-123".to_string(), + "publisher.com".to_string(), + "https".to_string(), + ); + + let signature = signer.sign_request(¶ms).unwrap(); + assert!(!signature.is_empty()); + + // Verify the signature is valid by reconstructing the payload + let payload = params.build_payload(&signer.kid).unwrap(); + let result = verify_signature(payload.as_bytes(), &signature, &signer.kid).unwrap(); + assert!(result, "Enhanced signature should be valid"); + } + + #[test] + fn test_sign_request_different_params_different_signature() { + let signer = RequestSigner::from_config().unwrap(); + + let params1 = SigningParams { + request_id: "req-1".to_string(), + request_host: "host1.com".to_string(), + request_scheme: "https".to_string(), + timestamp: 1706900000, + }; + + let params2 = SigningParams { + request_id: "req-1".to_string(), + request_host: "host2.com".to_string(), // Different host + request_scheme: "https".to_string(), + timestamp: 1706900000, + }; + + let sig1 = signer.sign_request(¶ms1).unwrap(); + let sig2 = signer.sign_request(¶ms2).unwrap(); + + assert_ne!( + sig1, sig2, + "Different hosts should produce different signatures" + ); + } }