Skip to content

Align signature verification with Trusted Server v1.1#80

Open
ChristianPavilonis wants to merge 4 commits intomainfrom
fix/request-verification-issue
Open

Align signature verification with Trusted Server v1.1#80
ChristianPavilonis wants to merge 4 commits intomainfrom
fix/request-verification-issue

Conversation

@ChristianPavilonis
Copy link
Contributor

@ChristianPavilonis ChristianPavilonis commented Mar 3, 2026

Summary

  • Align request signature verification with Trusted Server signing v1.1 so signed requests are validated against the canonical payload expected by upstream.
  • Require and parse all v1.1 ext.trusted_server fields (version, kid, request_host, request_scheme, ts) to prevent partial or ambiguous verification inputs.
  • Use a fixed CPM bid value ($0.01) for generated OpenRTB and APS responses, and remove request-level bid override behavior from generated ad markup.
  • Keep signature visibility in creatives consistent while simplifying badge text to signature status only, and document both the v1.1 signing payload and fixed pricing behavior for integrators.

Changes

Crate / File Change
crates/mocktioneer-core/src/verification.rs Reworked verification to build and verify canonical JSON payload (version, kid, host, scheme, id, ts), required v1.1 ext fields, enforced signing version 1.1, and added payload/version tests.
crates/mocktioneer-core/src/render.rs Added coverage asserting the signature badge is always rendered in creative HTML.
crates/mocktioneer-core/static/templates/creative.html.hbs Updated badge behavior so it is always shown and displays signature status only (verified/failed/not_present).
crates/mocktioneer-core/src/auction.rs Switched generated OpenRTB and APS bids to fixed CPM ($0.01), removed request-supplied bid overrides from response generation, and updated related tests.
docs/api/openrtb-auction.md Updated Trusted Server v1.1 signature docs and aligned pricing docs/examples with fixed CPM behavior.

Closes

Closes #79
Closes #81

Test plan

  • cargo test --workspace --all-targets
  • cargo clippy --workspace --all-targets --all-features -- -D warnings
  • cargo check --workspace --all-targets --features "fastly cloudflare"
  • Manual testing via cargo run -p mocktioneer-adapter-axum
  • Playwright e2e tests (cd tests/playwright && npm test)
  • Other: cargo fmt --all -- --check; cargo test -p mocktioneer-core

Checklist

  • Changes follow CLAUDE.md conventions
  • Business logic lives in mocktioneer-core, not in adapter crates
  • New routes added to both routes.rs and edgezero.toml
  • Determinism preserved — no randomness or time-dependent logic
  • Ad markup rendered via templates in render.rs, not inlined in handlers
  • New code has tests (colocated #[cfg(test)] or in tests/)
  • No secrets or credentials committed

@ChristianPavilonis ChristianPavilonis self-assigned this Mar 3, 2026
Copy link
Contributor

@aram356 aram356 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review

The v1.1 signing protocol and SigningPayload canonicalization are solid structural improvements. The fixed-pricing simplification makes sense for a mock bidder. A few things need attention before merge — mostly around the trust model for signed fields and consistency across docs.

Summary

Emoji Count Meaning
🔧 4 Needs change
♻️ 1 Refactoring suggestion
🌱 1 Future consideration
📌 1 Out-of-scope follow-up
💯 1 Positive

Non-inline notes

test_price_from_ext_and_iframe_bid_param (auction.rs:474) — the test name still implies we're verifying that ext.bid drives the price. It now tests the opposite. Rename to something like test_ext_bid_override_is_ignored.

log::info!("URI: {}", uri) at verification.rs:73 looks like a debug leftover from development. The line above already logs at debug level. Demote or remove.

📌 The request example in openrtb-auction.md (lines 52–55) still includes "ext": {"mocktioneer": {"bid": 2.5}} in the impression — contradicts the new Pricing section that says bid overrides are ignored. Remove it from the example to avoid confusion.

request_scheme,
timestamp,
version,
)?;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 request_host, request_scheme, and ts are all read from ext.trusted_server (untrusted client input) and passed straight into the signed payload with no cross-check against server-observed values.

This means the signature proves "someone with the private key signed some payload" — but not that the payload matches the actual request context. A compromised key for domain A could generate valid signatures claiming any host/scheme/timestamp.

handle_openrtb_auction already has ForwardedHost(host) available but doesn't pass it into verification. Suggested fix:

  1. Pass the server-observed host and scheme into verify_request_id_signature
  2. After extracting request_host / request_scheme from ext, compare them against the trusted values
  3. Enforce a ts freshness window (e.g. ±5 minutes) to prevent replay

Verification is informational today, but this should be fixed before it's ever promoted to enforcement.

Comment on lines +37 to +45
#[derive(Serialize)]
struct SigningPayload<'a> {
version: &'a str,
kid: &'a str,
host: &'a str,
scheme: &'a str,
id: &'a str,
ts: u64,
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🌱 Field order in this struct is load-bearing — serde_json::to_string serializes struct fields in declaration order, and the output is used as the canonical signing payload. If anyone reorders these fields, all existing signatures silently break.

Consider adding a comment like:

// IMPORTANT: Field order defines the canonical signing payload.
// Reordering fields will break signature verification.

Comment on lines +466 to +479
fn build_signing_payload_uses_v11_shape() {
let payload = build_signing_payload(
"req-123",
"kid-abc",
"publisher.example",
"https",
1706900000,
"1.1",
)
.expect("payload");

assert_eq!(
payload,
"{\"version\":\"1.1\",\"kid\":\"kid-abc\",\"host\":\"publisher.example\",\"scheme\":\"https\",\"id\":\"req-123\",\"ts\":1706900000}"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 Two issues with this test:

1. ts unit mismatch. The test uses 1706900000 (seconds) but the docs describe ts as "Unix timestamp (milliseconds)" and use 1706900000000 in examples. Since SigningPayload just passes through the u64, the canonical payload will differ depending on which unit the Trusted Server actually sends. Align on one — if millis, update the tests; if seconds, update the docs.

2. No positive-path E2E test. This test validates the shape of the canonical payload, but there's no test that actually verifies a valid Ed25519 signature against a known keypair. Adding a deterministic test vector (generate a keypair, sign a canonical payload, verify it round-trips) would catch regressions in both the payload construction and the signature verification path.

.max_by(|a, b| a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal));

let Some((w, h, price)) = best_size else {
let Some((w, h, _)) = best_size else {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ This code still calls get_cpm() to rank sizes by CPM, picks the highest-CPM size, then throws away the price and uses FIXED_BID_CPM. The size selection is now effectively arbitrary (just happens to match the old CPM table ordering).

Since all bids are the same price, simplify to pick by largest area (or just first standard size). This also removes the last dependency on the old SIZE_MAP CPM values from the bid path.

| `ext.trusted_server.kid` | string | No | Key ID used for signature |
| `ext.trusted_server.request_host` | string | No | Host included in signed payload |
| `ext.trusted_server.request_scheme` | string | No | Scheme included in signed payload |
| `ext.trusted_server.ts` | integer | No | Unix timestamp (milliseconds) |
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 Docs say ts is "Unix timestamp (milliseconds)" here and the example at line 65 uses 1706900000000, but the verification tests all use 1706900000 (seconds). One of them is wrong — see corresponding comment on verification.rs.

Without a price override, Mocktioneer uses fixed CPM prices based on ad size. Prices range from $1.70 (300x50) to $4.20 (970x250). Non-standard sizes use an area-based fallback formula.

See the [complete pricing table](/api/#supported-sizes) for all supported sizes and their CPM values.
If `imp[].ext.mocktioneer.bid` is present, it is ignored.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 This section was updated but several other docs pages still describe the old pricing/override behavior:

  • openrtb-auction.md:182 — "With Price Override" curl example still shows "ext": {"mocktioneer": {"bid": 10.00}}
  • prebidjs.md:76-93 — documents bid as a supported parameter with override explanation
  • prebid-server.md:65,121,163,184,201,212,223,291,313 — multiple examples using mocktioneer.bid
  • aps-bid.md:119 — decoded price example shows 2.50 instead of 0.01
  • docs/index.md:24 — landing page advertises "price overrides"

A repo-wide docs sweep should accompany this pricing change.

Comment on lines +81 to +86
var validSig = {
verified: true,
failed: true,
not_present: true
};
var sig = validSig[sigParam] ? sigParam : "not_present";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💯 Good call adding the allowlist sanitization for sig. The old code did badges[sig] with raw user input from query params — this bounds it to known-safe values and defaults to not_present. Clean pattern.

… with fixed pricing

- Cross-check ext.trusted_server host/scheme against server-observed values
- Enforce timestamp freshness window (±5 min) to prevent replay attacks
- Add field-order warning comment on SigningPayload struct
- Fix ts unit mismatch: align tests with docs (milliseconds)
- Add positive-path Ed25519 round-trip test with deterministic keypair
- Remove debug log leftover in fetch_jwks
- Simplify APS size selection to area-based ranking (replaces CPM)
- Remove CPM from /sizes endpoint response
- Rename test to test_ext_bid_override_is_ignored
- Docs sweep: remove bid override references across all doc pages
@ChristianPavilonis
Copy link
Contributor Author

ChristianPavilonis commented Mar 6, 2026

Review feedback addressed in 3bbbbc7

🔧 Trust model for signed fields (verification.rs)

  • verify_request_id_signature now accepts trusted_host and trusted_scheme parameters (server-observed values)
  • After extracting request_host/request_scheme from ext.trusted_server, they are compared against the trusted values — mismatches return VerificationError::InvalidSignature
  • The handler in routes.rs passes ForwardedHost and extracts scheme from the X-Forwarded-Proto header (defaulting to "https")
  • Timestamp freshness: enforced via a ±5 minute window (TS_FRESHNESS_WINDOW_MS). Stale or future timestamps are rejected with a descriptive error including the drift amount.
  • Added tests: verify_host_mismatch_rejected, verify_scheme_mismatch_rejected, verify_stale_timestamp_rejected, verify_future_timestamp_rejected, check_timestamp_freshness_within_window

🌱 Field order comment on SigningPayload

  • Added a prominent comment above the struct explaining that field order defines the canonical signing payload and reordering will silently break verification.

🔧 ts unit mismatch

  • Aligned on milliseconds (matching docs and Trusted Server spec). All test values updated from 17069000001706900000000. The canonical payload example in docs already used millis.

🔧 Positive-path E2E test

  • Added verify_ed25519_roundtrip_with_known_keypair: generates a deterministic Ed25519 keypair from a fixed seed, builds a canonical payload, signs it, and verifies the round-trip. Also asserts that a tampered payload produces SignatureVerificationFailed.

🔧 Docs sweep for pricing

  • openrtb-auction.md: Removed ext.mocktioneer.bid from the Full Request example and removed the "With Price Override" cURL section
  • prebidjs.md: Removed bid parameter from table, removed Price Override section, removed bid values from all examples, removed High CPM Testing section
  • prebid-server.md: Removed bid parameter from table, removed With Price Override section, removed bid values from all examples, updated response price to 0.01
  • aps-bid.md: Updated decoded price example to 0.01, updated amznbid/amznp values to match $0.01 encoding, updated size selection description to area-based
  • docs/index.md: Changed "price overrides" to "fixed pricing"
  • docs/api/index.md: Removed CPM column from sizes table, updated /sizes endpoint response docs

♻️ APS size selection simplification (auction.rs)

  • Replaced get_cpm() ranking with w * h area-based ranking. Since all bids use FIXED_BID_CPM, CPM-based ranking was effectively arbitrary. Largest area is now the explicit selection criterion.
  • Updated related tests in both auction.rs and tests/aps_endpoints.rs

/sizes endpoint consistency (routes.rs)

  • Removed cpm from the /sizes response since it contradicted fixed pricing. The endpoint now returns only width/height per size.

⛏ Quick fixes

  • Renamed test_price_from_ext_and_iframe_bid_paramtest_ext_bid_override_is_ignored
  • Removed log::info!("URI: {}", uri) debug leftover from fetch_jwks

💯 Sig allowlist

  • No changes needed — kept as-is.

All CI gates pass: cargo test --workspace --all-targets (100 tests), cargo fmt, cargo clippy.

Align remaining docs with fixed /bin/zsh.01 bidding and update Playwright /_/sizes assertions to match the cpm-free response, so review cleanups and CI checks reflect the current API behavior.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Document fixed CPM behavior for auction responses Align signature verification with Trusted Server v1.1

2 participants