Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 29 additions & 17 deletions crates/common/src/integrations/prebid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -333,7 +333,6 @@ fn expand_trusted_server_bidders(
})
.collect()
}

fn transform_prebid_response(
response: &mut Json,
request_host: &str,
Expand Down Expand Up @@ -466,7 +465,7 @@ impl PrebidAuctionProvider {
&self,
request: &AuctionRequest,
context: &AuctionContext<'_>,
signer: Option<(&RequestSigner, String)>,
signer: Option<(&RequestSigner, String, &SigningParams)>,
) -> OpenRtbRequest {
let imps: Vec<Imp> = request
.slots
Expand Down Expand Up @@ -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,
}),
});

Expand Down Expand Up @@ -686,26 +694,30 @@ 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(&params)?;
Some((signer, signature, params))
} else {
None
};
}
} else {
None
};

// Convert to OpenRTB with all enrichments
let openrtb = self.to_openrtb(
request,
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
Expand Down
6 changes: 6 additions & 0 deletions crates/common/src/openrtb.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub signature: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
Expand All @@ -121,6 +124,9 @@ pub struct TrustedServerExt {
pub request_host: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_scheme: Option<String>,
/// Unix timestamp in milliseconds for replay protection
#[serde(skip_serializing_if = "Option::is_none")]
pub ts: Option<u64>,
}

#[derive(Debug, Serialize)]
Expand Down
170 changes: 170 additions & 0 deletions crates/common/src/request_signing/signing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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<String, Report<TrustedServerError>> {
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.
///
Expand Down Expand Up @@ -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<String, Report<TrustedServerError>> {
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.
Expand Down Expand Up @@ -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(&params).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(&params1).unwrap();
let sig2 = signer.sign_request(&params2).unwrap();

assert_ne!(
sig1, sig2,
"Different hosts should produce different signatures"
);
}
}