Skip to content

Commit 33ffcfd

Browse files
Enhanced request signing with domain verification (v1.1) (#220)
* 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 * Address PR review: use millisecond timestamps, remove unnecessary clones * Use JSON payload for request signing to prevent signature confusion Address PR review feedback from aram356: - Replace colon-delimited build_payload with JSON serialization to prevent signature confusion via crafted Host headers - Include signing protocol version in the signed payload - Remove trivial test_signing_version_constant test --------- Co-authored-by: Christian <christianpavilonis@outlook.com>
1 parent b2291fd commit 33ffcfd

3 files changed

Lines changed: 205 additions & 17 deletions

File tree

crates/common/src/integrations/prebid.rs

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ use crate::openrtb::{
2626
Banner, Device, Format, Geo, Imp, ImpExt, OpenRtbRequest, PrebidExt, PrebidImpExt, Regs,
2727
RegsExt, RequestExt, Site, TrustedServerExt, User, UserExt,
2828
};
29-
use crate::request_signing::RequestSigner;
29+
use crate::request_signing::{RequestSigner, SigningParams, SIGNING_VERSION};
3030
use crate::settings::{IntegrationConfig, Settings};
3131

3232
const PREBID_INTEGRATION_ID: &str = "prebid";
@@ -333,7 +333,6 @@ fn expand_trusted_server_bidders(
333333
})
334334
.collect()
335335
}
336-
337336
fn transform_prebid_response(
338337
response: &mut Json,
339338
request_host: &str,
@@ -466,7 +465,7 @@ impl PrebidAuctionProvider {
466465
&self,
467466
request: &AuctionRequest,
468467
context: &AuctionContext<'_>,
469-
signer: Option<(&RequestSigner, String)>,
468+
signer: Option<(&RequestSigner, String, &SigningParams)>,
470469
) -> OpenRtbRequest {
471470
let imps: Vec<Imp> = request
472471
.slots
@@ -553,19 +552,28 @@ impl PrebidAuctionProvider {
553552

554553
// Build ext object
555554
let request_info = RequestInfo::from_request(context.request);
556-
let (signature, kid) = signer
557-
.map(|(s, sig)| (Some(sig), Some(s.kid.clone())))
558-
.unwrap_or((None, None));
555+
let (version, signature, kid, ts) = signer
556+
.map(|(s, sig, params)| {
557+
(
558+
Some(SIGNING_VERSION.to_string()),
559+
Some(sig),
560+
Some(s.kid.clone()),
561+
Some(params.timestamp),
562+
)
563+
})
564+
.unwrap_or((None, None, None, None));
559565

560566
let ext = Some(RequestExt {
561567
prebid: Some(PrebidExt {
562568
debug: if self.config.debug { Some(true) } else { None },
563569
}),
564570
trusted_server: Some(TrustedServerExt {
571+
version,
565572
signature,
566573
kid,
567574
request_host: Some(request_info.host),
568575
request_scheme: Some(request_info.scheme),
576+
ts,
569577
}),
570578
});
571579

@@ -686,26 +694,30 @@ impl AuctionProvider for PrebidAuctionProvider {
686694
log::info!("Prebid: requesting bids for {} slots", request.slots.len());
687695

688696
// Create signer and compute signature if request signing is enabled
689-
let signer_with_signature =
690-
if let Some(request_signing_config) = &context.settings.request_signing {
691-
if request_signing_config.enabled {
692-
let signer = RequestSigner::from_config()?;
693-
let signature = signer.sign(request.id.as_bytes())?;
694-
Some((signer, signature))
695-
} else {
696-
None
697-
}
697+
let signer_with_signature = if let Some(request_signing_config) =
698+
&context.settings.request_signing
699+
{
700+
if request_signing_config.enabled {
701+
let request_info = RequestInfo::from_request(context.request);
702+
let signer = RequestSigner::from_config()?;
703+
let params =
704+
SigningParams::new(request.id.clone(), request_info.host, request_info.scheme);
705+
let signature = signer.sign_request(&params)?;
706+
Some((signer, signature, params))
698707
} else {
699708
None
700-
};
709+
}
710+
} else {
711+
None
712+
};
701713

702714
// Convert to OpenRTB with all enrichments
703715
let openrtb = self.to_openrtb(
704716
request,
705717
context,
706718
signer_with_signature
707719
.as_ref()
708-
.map(|(s, sig)| (s, sig.clone())),
720+
.map(|(s, sig, params)| (s, sig.clone(), params)),
709721
);
710722

711723
// Log the outgoing OpenRTB request for debugging

crates/common/src/openrtb.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,9 @@ pub struct PrebidExt {
113113

114114
#[derive(Debug, Serialize, Default)]
115115
pub struct TrustedServerExt {
116+
/// Version of the signing protocol (e.g., "1.1")
117+
#[serde(skip_serializing_if = "Option::is_none")]
118+
pub version: Option<String>,
116119
#[serde(skip_serializing_if = "Option::is_none")]
117120
pub signature: Option<String>,
118121
#[serde(skip_serializing_if = "Option::is_none")]
@@ -121,6 +124,9 @@ pub struct TrustedServerExt {
121124
pub request_host: Option<String>,
122125
#[serde(skip_serializing_if = "Option::is_none")]
123126
pub request_scheme: Option<String>,
127+
/// Unix timestamp in milliseconds for replay protection
128+
#[serde(skip_serializing_if = "Option::is_none")]
129+
pub ts: Option<u64>,
124130
}
125131

126132
#[derive(Debug, Serialize)]

crates/common/src/request_signing/signing.rs

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use base64::{engine::general_purpose, Engine};
77
use ed25519_dalek::{Signature, Signer as Ed25519Signer, SigningKey, Verifier, VerifyingKey};
88
use error_stack::{Report, ResultExt};
9+
use serde::Serialize;
910

1011
use crate::error::TrustedServerError;
1112
use crate::fastly_storage::{FastlyConfigStore, FastlySecretStore};
@@ -45,6 +46,72 @@ pub struct RequestSigner {
4546
pub kid: String,
4647
}
4748

49+
/// Current version of the signing protocol
50+
pub const SIGNING_VERSION: &str = "1.1";
51+
52+
/// Canonical payload structure for request signing.
53+
///
54+
/// Serialized as JSON to prevent signature confusion attacks that could
55+
/// exploit delimiter-based formats.
56+
#[derive(Serialize)]
57+
struct SigningPayload<'a> {
58+
version: &'a str,
59+
kid: &'a str,
60+
host: &'a str,
61+
scheme: &'a str,
62+
id: &'a str,
63+
ts: u64,
64+
}
65+
66+
/// Parameters for enhanced request signing
67+
#[derive(Debug, Clone)]
68+
pub struct SigningParams {
69+
pub request_id: String,
70+
pub request_host: String,
71+
pub request_scheme: String,
72+
pub timestamp: u64,
73+
}
74+
75+
impl SigningParams {
76+
/// Creates a new `SigningParams` with the current timestamp in milliseconds
77+
#[must_use]
78+
pub fn new(request_id: String, request_host: String, request_scheme: String) -> Self {
79+
Self {
80+
request_id,
81+
request_host,
82+
request_scheme,
83+
timestamp: std::time::SystemTime::now()
84+
.duration_since(std::time::UNIX_EPOCH)
85+
.map(|d| d.as_millis() as u64)
86+
.unwrap_or(0),
87+
}
88+
}
89+
90+
/// Builds the canonical payload string for signing.
91+
///
92+
/// The payload is a JSON-serialized [`SigningPayload`] to prevent signature
93+
/// confusion attacks that could exploit delimiter-based formats.
94+
///
95+
/// # Errors
96+
///
97+
/// Returns an error if the payload cannot be serialized to JSON.
98+
pub fn build_payload(&self, kid: &str) -> Result<String, Report<TrustedServerError>> {
99+
let payload = SigningPayload {
100+
version: SIGNING_VERSION,
101+
kid,
102+
host: &self.request_host,
103+
scheme: &self.request_scheme,
104+
id: &self.request_id,
105+
ts: self.timestamp,
106+
};
107+
serde_json::to_string(&payload).map_err(|e| {
108+
Report::new(TrustedServerError::Configuration {
109+
message: format!("Failed to serialize signing payload: {}", e),
110+
})
111+
})
112+
}
113+
}
114+
48115
impl RequestSigner {
49116
/// Creates a `RequestSigner` from the current key ID stored in config.
50117
///
@@ -82,6 +149,22 @@ impl RequestSigner {
82149

83150
Ok(general_purpose::URL_SAFE_NO_PAD.encode(signature_bytes))
84151
}
152+
153+
/// Signs a request using the enhanced v1.1 signing protocol.
154+
///
155+
/// The signed payload is a JSON object containing version, kid, host,
156+
/// scheme, id, and ts fields.
157+
///
158+
/// # Errors
159+
///
160+
/// Returns an error if signing fails.
161+
pub fn sign_request(
162+
&self,
163+
params: &SigningParams,
164+
) -> Result<String, Report<TrustedServerError>> {
165+
let payload = params.build_payload(&self.kid)?;
166+
self.sign(payload.as_bytes())
167+
}
85168
}
86169

87170
/// Verifies a signature using the public key associated with the given key ID.
@@ -234,4 +317,91 @@ mod tests {
234317
let result = verify_signature(payload, malformed_signature, &signer.kid);
235318
assert!(result.is_err(), "Should error for malformed signature");
236319
}
320+
321+
#[test]
322+
fn test_signing_params_build_payload() {
323+
let params = SigningParams {
324+
request_id: "req-123".to_string(),
325+
request_host: "example.com".to_string(),
326+
request_scheme: "https".to_string(),
327+
timestamp: 1706900000,
328+
};
329+
330+
let payload = params
331+
.build_payload("kid-abc")
332+
.expect("should build payload");
333+
let parsed: serde_json::Value =
334+
serde_json::from_str(&payload).expect("should be valid JSON");
335+
assert_eq!(parsed["version"], SIGNING_VERSION);
336+
assert_eq!(parsed["kid"], "kid-abc");
337+
assert_eq!(parsed["host"], "example.com");
338+
assert_eq!(parsed["scheme"], "https");
339+
assert_eq!(parsed["id"], "req-123");
340+
assert_eq!(parsed["ts"], 1706900000);
341+
}
342+
343+
#[test]
344+
fn test_signing_params_new_creates_timestamp() {
345+
let params = SigningParams::new(
346+
"req-123".to_string(),
347+
"example.com".to_string(),
348+
"https".to_string(),
349+
);
350+
351+
assert_eq!(params.request_id, "req-123");
352+
assert_eq!(params.request_host, "example.com");
353+
assert_eq!(params.request_scheme, "https");
354+
// Timestamp should be recent (within last minute), in milliseconds
355+
let now_ms = std::time::SystemTime::now()
356+
.duration_since(std::time::UNIX_EPOCH)
357+
.unwrap()
358+
.as_millis() as u64;
359+
assert!(params.timestamp <= now_ms);
360+
assert!(params.timestamp >= now_ms - 60_000);
361+
}
362+
363+
#[test]
364+
fn test_sign_request_enhanced() {
365+
let signer = RequestSigner::from_config().unwrap();
366+
let params = SigningParams::new(
367+
"auction-123".to_string(),
368+
"publisher.com".to_string(),
369+
"https".to_string(),
370+
);
371+
372+
let signature = signer.sign_request(&params).unwrap();
373+
assert!(!signature.is_empty());
374+
375+
// Verify the signature is valid by reconstructing the payload
376+
let payload = params.build_payload(&signer.kid).unwrap();
377+
let result = verify_signature(payload.as_bytes(), &signature, &signer.kid).unwrap();
378+
assert!(result, "Enhanced signature should be valid");
379+
}
380+
381+
#[test]
382+
fn test_sign_request_different_params_different_signature() {
383+
let signer = RequestSigner::from_config().unwrap();
384+
385+
let params1 = SigningParams {
386+
request_id: "req-1".to_string(),
387+
request_host: "host1.com".to_string(),
388+
request_scheme: "https".to_string(),
389+
timestamp: 1706900000,
390+
};
391+
392+
let params2 = SigningParams {
393+
request_id: "req-1".to_string(),
394+
request_host: "host2.com".to_string(), // Different host
395+
request_scheme: "https".to_string(),
396+
timestamp: 1706900000,
397+
};
398+
399+
let sig1 = signer.sign_request(&params1).unwrap();
400+
let sig2 = signer.sign_request(&params2).unwrap();
401+
402+
assert_ne!(
403+
sig1, sig2,
404+
"Different hosts should produce different signatures"
405+
);
406+
}
237407
}

0 commit comments

Comments
 (0)