From d6590a28b2ca35af0c60ab85ccdb5d5204728406 Mon Sep 17 00:00:00 2001 From: jevansnyc Date: Tue, 3 Feb 2026 06:23:22 +1000 Subject: [PATCH 1/2] Enhanced request signing with domain verification (v1.1) Implements cryptographic signing of OpenRTB requests that includes publisher domain verification and replay protection. The signed payload now includes: - Key ID (kid) - Request host - Request scheme - Request ID - Unix timestamp This prevents request tampering and domain spoofing by ensuring the signature is bound to the originating publisher domain. Changes: - Add version and ts fields to TrustedServerExt - Add SigningParams struct and sign_request() method - Update PrebidAuctionProvider to use enhanced signing - Add comprehensive tests for payload construction and signing --- crates/common/src/integrations/prebid.rs | 32 +++-- crates/common/src/openrtb.rs | 6 + crates/common/src/request_signing/signing.rs | 134 +++++++++++++++++++ 3 files changed, 164 insertions(+), 8 deletions(-) diff --git a/crates/common/src/integrations/prebid.rs b/crates/common/src/integrations/prebid.rs index 7c982587..66da9342 100644 --- a/crates/common/src/integrations/prebid.rs +++ b/crates/common/src/integrations/prebid.rs @@ -25,7 +25,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"; @@ -262,6 +262,7 @@ impl IntegrationAttributeRewriter for PrebidIntegration { } } + fn transform_prebid_response( response: &mut Json, request_host: &str, @@ -394,7 +395,7 @@ impl PrebidAuctionProvider { &self, request: &AuctionRequest, context: &AuctionContext<'_>, - signer: Option<(&RequestSigner, String)>, + signer: Option<(&RequestSigner, String, &SigningParams)>, ) -> OpenRtbRequest { let imps: Vec = request .slots @@ -475,9 +476,16 @@ 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: if self.config.debug { @@ -486,10 +494,12 @@ impl PrebidAuctionProvider { None }, trusted_server: Some(TrustedServerExt { + version, signature, kid, request_host: Some(request_info.host), request_scheme: Some(request_info.scheme), + ts, }), }); @@ -613,9 +623,15 @@ impl AuctionProvider for PrebidAuctionProvider { 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 signature = signer.sign(request.id.as_bytes())?; - Some((signer, signature)) + 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 } @@ -629,7 +645,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)), ); // Create HTTP request diff --git a/crates/common/src/openrtb.rs b/crates/common/src/openrtb.rs index 3b405209..3b6da6bb 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 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..8ecb5023 100644 --- a/crates/common/src/request_signing/signing.rs +++ b/crates/common/src/request_signing/signing.rs @@ -45,6 +45,45 @@ pub struct RequestSigner { pub kid: String, } +/// Current version of the signing protocol +pub const SIGNING_VERSION: &str = "1.1"; + +/// 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 + #[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_secs()) + .unwrap_or(0), + } + } + + /// Builds the canonical payload string for signing. + /// + /// Format: `kid:request_host:request_scheme:id:ts` + #[must_use] + pub fn build_payload(&self, kid: &str) -> String { + format!( + "{}:{}:{}:{}:{}", + kid, self.request_host, self.request_scheme, self.request_id, self.timestamp + ) + } +} + impl RequestSigner { /// Creates a `RequestSigner` from the current key ID stored in config. /// @@ -82,6 +121,21 @@ 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 format is: `kid:request_host:request_scheme:id:ts` + /// + /// # 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 +288,84 @@ 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"); + assert_eq!(payload, "kid-abc:example.com:https:req-123: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) + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + assert!(params.timestamp <= now); + assert!(params.timestamp >= now - 60); + } + + #[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); + 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"); + } + + #[test] + fn test_signing_version_constant() { + assert_eq!(SIGNING_VERSION, "1.1"); + } } From e2109892f47a75f3996965503a4f991ac0b01cb4 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 19 Feb 2026 11:05:10 -0600 Subject: [PATCH 2/2] Address PR review: use millisecond timestamps, remove unnecessary clones --- crates/common/src/integrations/prebid.rs | 31 +++++++++----------- crates/common/src/openrtb.rs | 2 +- crates/common/src/request_signing/signing.rs | 19 +++++++----- 3 files changed, 26 insertions(+), 26 deletions(-) diff --git a/crates/common/src/integrations/prebid.rs b/crates/common/src/integrations/prebid.rs index 66da9342..2366c066 100644 --- a/crates/common/src/integrations/prebid.rs +++ b/crates/common/src/integrations/prebid.rs @@ -262,7 +262,6 @@ impl IntegrationAttributeRewriter for PrebidIntegration { } } - fn transform_prebid_response( response: &mut Json, request_host: &str, @@ -620,24 +619,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 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 - } + 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( diff --git a/crates/common/src/openrtb.rs b/crates/common/src/openrtb.rs index 3b6da6bb..fd9a84cd 100644 --- a/crates/common/src/openrtb.rs +++ b/crates/common/src/openrtb.rs @@ -124,7 +124,7 @@ pub struct TrustedServerExt { pub request_host: Option, #[serde(skip_serializing_if = "Option::is_none")] pub request_scheme: Option, - /// Unix timestamp for replay protection + /// Unix timestamp in milliseconds for replay protection #[serde(skip_serializing_if = "Option::is_none")] pub ts: Option, } diff --git a/crates/common/src/request_signing/signing.rs b/crates/common/src/request_signing/signing.rs index 8ecb5023..ac38acbc 100644 --- a/crates/common/src/request_signing/signing.rs +++ b/crates/common/src/request_signing/signing.rs @@ -58,7 +58,7 @@ pub struct SigningParams { } impl SigningParams { - /// Creates a new `SigningParams` with the current timestamp + /// 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 { @@ -67,7 +67,7 @@ impl SigningParams { request_scheme, timestamp: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) + .map(|d| d.as_millis() as u64) .unwrap_or(0), } } @@ -313,13 +313,13 @@ mod tests { 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) - let now = std::time::SystemTime::now() + // Timestamp should be recent (within last minute), in milliseconds + let now_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() - .as_secs(); - assert!(params.timestamp <= now); - assert!(params.timestamp >= now - 60); + .as_millis() as u64; + assert!(params.timestamp <= now_ms); + assert!(params.timestamp >= now_ms - 60_000); } #[test] @@ -361,7 +361,10 @@ mod tests { let sig1 = signer.sign_request(¶ms1).unwrap(); let sig2 = signer.sign_request(¶ms2).unwrap(); - assert_ne!(sig1, sig2, "Different hosts should produce different signatures"); + assert_ne!( + sig1, sig2, + "Different hosts should produce different signatures" + ); } #[test]