Skip to content

Commit 6ec940d

Browse files
bokelleyclaude
andauthored
feat(webhooks): sign deliveries under web-bot-auth when a registration asks (#1860)
WebhookSender signs deliveries under web-bot-auth when a registration selects it (from_wba, with_web_bot_auth, key directory helper); the task, notification and reporting outboxes carry each registration's signing_profile to every freshly signed attempt; dispatch refuses unadvertised push signing profiles before the task is issued. Closes #1859 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 00defc2 commit 6ec940d

26 files changed

Lines changed: 2228 additions & 141 deletions

‎docs/adcp-3.3-sdk-inventory.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,8 @@ it adopted the bundle, and what is left to the codegen/wire-fidelity (2) and
8888
| `by_property`, `by_installment_property` | `MediaBuyFeatures.supports_property_breakdown`, `supports_installment_property_breakdown` | Property-grain rollups. | generated; storage package rollups: (3), #1669 |
8989
| Reliable Reporting `source.custody` | `core/reporting-report-definition.json` `Source.custody`; enum `reporting-custody` (`system_of_record`, `upstream_derived`) | | generated; `adcp.reporting` ledger: (3), #1669 |
9090
| `reporting_webhook.operation_id` | `core/reporting-webhook.json` (`minLength` 1, `maxLength` 255, `^[A-Za-z0-9_.:-]{1,255}$`) | Native in 3.3 (adcp#7895); echoed verbatim; nothing is sent to a registration without it. The 3.2 forward-compat runtime patch in `_forward_compat.py` is retired. | done (1) |
91-
| `reporting_webhook.authentication`, `artifact_webhook.authentication` optional | `core/reporting-webhook.json` `required` is `url`, `reporting_frequency`; `create_media_buy` `ArtifactWebhook` | Required only on requests served below 3.3; buyers pinned to 3.2 keep sending them. The 3.3 → 3.2 downgrade of a request without `authentication` must fail structured (#1621). | generated; downgrade fails structured: done (3), #1667; seller signing when absent: `webhook_signing_profile_for` (also refuses a missing `authentication` below 3.3), and `WebhookSender` refuses before any I/O to send such a registration unsigned or under another profile: done (3), #1644; Web Bot Auth webhook signing: not built |
92-
| `signing_profile` | `ReportingWebhook`, `PushNotificationConfig`, `NotificationConfig`, `AgentNotificationConfig`, `ArtifactWebhook`; enum `webhook-signing-profile` | Per-registration webhook signing profile (Web Bot Auth or RFC 9421). | generated; webhook sender profile selection: (3) |
91+
| `reporting_webhook.authentication`, `artifact_webhook.authentication` optional | `core/reporting-webhook.json` `required` is `url`, `reporting_frequency`; `create_media_buy` `ArtifactWebhook` | Required only on requests served below 3.3; buyers pinned to 3.2 keep sending them. The 3.3 → 3.2 downgrade of a request without `authentication` must fail structured (#1621). | generated; downgrade fails structured: done (3), #1667; seller signing when absent: `webhook_signing_profile_for` (also refuses a missing `authentication` below 3.3), and `WebhookSender` refuses before any I/O to send such a registration unsigned or under another profile: done (3), #1644; Web Bot Auth webhook signing (`WebhookSender.from_wba`, `with_web_bot_auth`): done (3), #1859 |
92+
| `signing_profile` | `ReportingWebhook`, `PushNotificationConfig`, `NotificationConfig`, `AgentNotificationConfig`, `ArtifactWebhook`; enum `webhook-signing-profile` | Per-registration webhook signing profile (Web Bot Auth or RFC 9421). | generated; webhook sender profile selection: done (3), #1859 — `WebhookSender` signs under the registration's profile; `PgTaskWebhookOutbox`, `PgNotificationOutbox` and the reporting notification worker (`ReportingNotificationSubscription.signing_profile`, `ReportingWebBotAuthKey`) carry it to every attempt. `NotificationConfig` / `AgentNotificationConfig` registrations: the caller sets `required_signing_profile` on the prepared notification |
9393
| Multi-qualifier metrics omit flat totals | `totals` on buys, windows and webhook per-buy | Receivers ignore `aggregated_totals` in delivery webhooks. | `WebhookReceiver` drops `aggregated_totals` from a `media_buy_delivery` result before parsing; the sender already dropped it: done (3), #1644. The SDK builds no buy or window `totals` (adopters' `get_media_buy_delivery` does), so there is no flat carrier to omit; `adcp.reporting` readers: #1669 |
9494
| `sync_audiences` `audience_size`, `targetable_size`, `match_rate_basis`, `platform_id` | `core/audience-size.json`; enums `audience-size-precision`, `audience-match-rate-basis` | Ranges; `matched_count` / `effective_match_rate` may be absent on `ready` audiences. | generated |
9595
| `sync_event_sources` `platform_id`, `archived` | request/response `EventSource`; capability `media_buy.event_source_archive` | `platform_id` dual-emitted with deprecated `seller_id`. | generated |

‎docs/reporting-production.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,15 @@ contract. Private key material stays in the resolver and sender. The operator
140140
must publish its brand document and corresponding signing keys at the declared
141141
identity; the SDK does not assert that a supplied URL proves external ownership.
142142
The declaration is RFC 9421 `adcp/webhook-signing/v1`, the exact supplied
143-
algorithm set, no legacy fallback, and an 86,400-second retry horizon. Each
143+
algorithm set, no legacy fallback, and an 86,400-second retry horizon. Pass
144+
`web_bot_auth=True` to also sign subscriptions selecting `web-bot-auth` (AdCP
145+
3.3): the declaration then lists both `profiles`, `ed25519` must be among the
146+
algorithms, and every resolved `ReportingSigningMaterial` must carry
147+
`web_bot_auth=ReportingWebBotAuthKey(key, agent_origin)`. Without it, a
148+
`web-bot-auth` subscription is skipped and logged as an error (alert on it:
149+
that subscriber receives nothing), and the account's other subscribers are
150+
still notified. See
151+
[Web Bot Auth webhooks](webhooks/web-bot-auth.md#reporting-notifications). Each
144152
queue reserves the first HTTP attempt and its key/body binding before peer I/O,
145153
using database time in PostgreSQL. Window insertion, HTTP attempt and ordinal
146154
reservation share one transaction; a failed reservation leaves none of them.

‎docs/webhooks/reporting-operation-id-3.2.md‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -151,10 +151,11 @@ raises `WebhookSigningProfileUnavailableError` before any network I/O unless the
151151
sender signs under that profile. A bearer or HMAC sender
152152
(`from_bearer_token`, `from_adcp_legacy_hmac`, `from_standard_webhooks_secret`)
153153
would otherwise send a credential the buyer never registered. Use
154-
`WebhookSender.from_jwk` / `from_pem` for these registrations. The SDK does not
155-
yet sign webhooks under `web-bot-auth`, so a registration selecting it also
156-
raises; do not list `web-bot-auth` in `webhook_signing.profiles` and reject such
157-
registrations with `INVALID_REQUEST`. For artifact webhooks pass the stored
154+
`WebhookSender.from_jwk` / `from_pem` for `adcp/webhook-signing/v1`, and
155+
`WebhookSender.from_wba` (or `with_web_bot_auth` for both profiles) for
156+
`web-bot-auth`; see [Web Bot Auth webhooks](web-bot-auth.md). Reject a
157+
registration selecting a profile you do not list in `webhook_signing.profiles`
158+
with `INVALID_REQUEST`. For artifact webhooks pass the stored
158159
`artifact_webhook` as `registration=` to `prepare_artifact_webhook` /
159160
`send_artifact_webhook`, or call `sender.check_registration(registration)`.
160161

‎docs/webhooks/web-bot-auth.md‎

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
# Web Bot Auth webhooks (AdCP 3.3)
2+
3+
At AdCP 3.3 a webhook registration (`push_notification_config`,
4+
`reporting_webhook`, `artifact_webhook`, `accounts[].notification_configs[]`,
5+
or an agent-level notification configuration) may omit `authentication` and
6+
name a `signing_profile`. The seller signs every delivery under that profile:
7+
8+
- `adcp/webhook-signing/v1`: the AdCP RFC 9421 webhook profile, also used
9+
when the registration names no profile. Sign with
10+
`WebhookSender.from_jwk` / `from_pem`.
11+
- `web-bot-auth`: the Web Bot Auth profile, experimental in 3.3 and the only
12+
HTTP signing profile in AdCP 4.0. A webhook is an ordinary Web Bot Auth
13+
request from the seller's origin. Sign with `WebhookSender.from_wba`.
14+
15+
## Senders
16+
17+
A seller whose buyers all use Web Bot Auth:
18+
19+
```python
20+
from adcp.signing import load_private_key_pem
21+
from adcp.webhooks import WebhookSender
22+
23+
wba_key = load_private_key_pem(Path("wba-ed25519.pem").read_bytes())
24+
sender = WebhookSender.from_wba(wba_key, agent_origin="https://seller.example")
25+
```
26+
27+
During 3.3 a seller usually has buyers on both profiles. Extend the existing
28+
RFC 9421 sender, which stays the default:
29+
30+
```python
31+
sender = WebhookSender.from_jwk(webhook_jwk).with_web_bot_auth(
32+
wba_key, agent_origin="https://seller.example"
33+
)
34+
assert sender.signing_profiles == {"adcp/webhook-signing/v1", "web-bot-auth"}
35+
```
36+
37+
The sender signs a delivery under the profile its registration selected,
38+
which the prepared request carries as `required_signing_profile`
39+
(`prepare_media_buy_delivery`, `prepare_artifact_webhook(registration=...)`,
40+
and the task outbox below set it). A delivery whose profile the sender holds
41+
no key for raises `WebhookSigningProfileUnavailableError` before any network
42+
I/O. `sender.check_registration(registration)` runs the same check when the
43+
buyer registers. Prove control of a notification destination under its
44+
profile too: `challenge_webhook_destination(..., sender=sender,
45+
signing_profile=config.signing_profile)` signs the challenge under it
46+
(`adcp/webhook-signing/v1` when omitted).
47+
48+
Each `web-bot-auth` attempt is signed afresh by
49+
`adcp.signing.wba.sign_wba_request`: a new 64-byte nonce and `created`,
50+
`Signature-Agent` naming the origin, the key's RFC 7638 thumbprint as
51+
`keyid`, and `@method`, `@target-uri`, `@authority`, `content-type`,
52+
`content-digest` and `signature-agent` covered. Receivers record nonces,
53+
including on an attempt they answered 503, so `resend`, `send_prepared` and
54+
both outboxes never resend a stored signature. `resend` signs under the
55+
profile the original was signed under. `Signature-Agent` is reserved like the
56+
other signature fields: `extra_headers` cannot set it.
57+
58+
The key must be Ed25519, the profile's one algorithm. An ES256 key stays on
59+
`adcp/webhook-signing/v1`.
60+
61+
## Publish the key directory first
62+
63+
Receivers verify a `web-bot-auth` webhook against the key directory at
64+
`https://seller.example/.well-known/http-message-signatures-directory`. The
65+
sender's key is verifiable only once that directory is live, so publish it
66+
before the first delivery:
67+
68+
```python
69+
from adcp.signing.wba import DIRECTORY_PATH
70+
71+
directory = sender.sign_web_bot_auth_directory(
72+
other_keys=[request_signing_key], # every other Web Bot Auth key of the origin
73+
)
74+
75+
@app.get(DIRECTORY_PATH)
76+
def key_directory() -> Response:
77+
return Response(directory.body, status_code=200, headers=dict(directory.headers))
78+
```
79+
80+
The directory belongs to the origin, not to the sender. List every Ed25519 key
81+
the origin signs with under Web Bot Auth, including request-signing keys and
82+
keys mid-rotation, in `other_keys`; a key left out stops verifying once
83+
receivers refresh their cached copy (up to five minutes). The signatures
84+
expire after `expires_in_seconds` (one day by default), so re-sign and
85+
republish well before then. When rotating, publish the new key, wait for
86+
caches to expire, switch the sender, and keep the old key in the directory
87+
while redeliveries signed with it may still arrive.
88+
`adcp.signing.wba.sign_key_directory` is the underlying helper; see
89+
[agent identity](../agent-identity-3-3-migration.md) for the directory rules.
90+
91+
## Capabilities
92+
93+
Advertise the profiles you can sign under:
94+
95+
```python
96+
WebhookSigning(
97+
supported=True,
98+
profiles=["adcp/webhook-signing/v1", "web-bot-auth"],
99+
algorithms=["ed25519"],
100+
delivery_retry_horizon_seconds=86400,
101+
)
102+
```
103+
104+
At boot, `validate_webhook_signing_for_capabilities` refuses a wired sender
105+
that holds no key for an advertised profile (omitted `profiles` means
106+
`adcp/webhook-signing/v1`, so a `from_wba`-only sender must list
107+
`web-bot-auth`), or that signs with an algorithm outside `algorithms` (a dual
108+
sender is checked for both of its keys).
109+
110+
## Outboxes
111+
112+
`PgTaskWebhookOutbox` reads `push_notification_config.signing_profile` when
113+
the task is issued and stores it in the encrypted registration and, at the
114+
terminal transition, in the encrypted delivery row. Every attempt is signed
115+
under it, `adcp/webhook-signing/v1` when the registration named none and
116+
carried no `authentication`. Before the task is issued, a registration is
117+
refused with `INVALID_REQUEST` (field `push_notification_config.signing_profile`)
118+
when the profile it requires is not in this request's advertised
119+
`webhook_signing.profiles` (`adcp/webhook-signing/v1` when omitted), checked
120+
against the effective capabilities, a tenant's
121+
`get_adcp_capabilities_for_request` override included. That applies to every
122+
publisher: a fixed-sender outbox, a `sender_resolver` outbox, or an externally
123+
managed one. A fixed sender is also checked for a key under that profile. A
124+
`sender_resolver` outbox's resolved sender is checked again at each attempt,
125+
and a row it cannot sign is quarantined. A resolved sender's every algorithm
126+
must be in the scope's `advertised_algorithms`, including its Web Bot Auth
127+
key's `ed25519`. Registrations naming `adcp/webhook-signing/v1` are stored
128+
exactly like ones naming no profile.
129+
130+
`PgNotificationOutbox` stores the prepared request's
131+
`required_signing_profile` with the body and signs each attempt under it. For
132+
notification registrations (`accounts[].notification_configs[]`, agent-level
133+
configurations), set it from the stored registration before enqueueing:
134+
135+
```python
136+
from dataclasses import replace
137+
from adcp.webhooks import webhook_signing_profile_for
138+
139+
prepared = WebhookSender.prepare_account_status_changed(url=str(config.url), payload=event)
140+
prepared = replace(prepared, required_signing_profile=webhook_signing_profile_for(config))
141+
await outbox.enqueue_prepared(
142+
conn, prepared, notification_type="account.status_changed", caller_scope_id=scope
143+
)
144+
```
145+
146+
Rolling deploys: a delivery row stored with a profile by an upgraded pod is
147+
quarantined, not sent under the wrong profile, by a pod running an earlier
148+
SDK, in both outboxes. Rows without a profile are unchanged. One gap remains:
149+
a task registered on an upgraded pod and completed on an earlier one is
150+
enqueued and signed by the earlier pod under its sender's default profile,
151+
because that pod ignores the registration's profile. Finish the rollout
152+
before listing `web-bot-auth` in `webhook_signing.profiles`.
153+
154+
## Reporting notifications
155+
156+
`ReportingNotificationSubscription` takes the registration's
157+
`signing_profile` (only with a `signing_scope_id`, never with legacy
158+
`authentication`). A subscription without one is stored and fingerprinted
159+
exactly as before, so existing subscriptions and queued deliveries are
160+
unaffected. For a `web-bot-auth` subscription, the
161+
`ReportingSigningMaterial` your `ReportingSigningResolver` returns must carry
162+
the origin's key:
163+
164+
```python
165+
from adcp.reporting.outbox import ReportingSigningMaterial, ReportingWebBotAuthKey
166+
167+
return ReportingSigningMaterial(
168+
webhook_key, webhook_kid, "ed25519", frozenset({"ed25519"}),
169+
web_bot_auth=ReportingWebBotAuthKey(wba_key, "https://seller.example"),
170+
)
171+
```
172+
173+
The worker builds a `from_wba` sender for each attempt of a `web-bot-auth`
174+
subscription, and keeps the `adcp/webhook-signing/v1` key for every other
175+
one. A `web-bot-auth` subscription whose material carries no Web Bot Auth key
176+
is quarantined (`permanent_scope`). `ed25519` must be among
177+
`advertised_algorithms` whenever `web_bot_auth` is set. Production reporting
178+
opts in with `ReportingProductionSigning(..., web_bot_auth=True)`; see
179+
[reporting production](../reporting-production.md).
180+
181+
Rolling deploys: a pod on an earlier SDK quarantines a delivery stored for a
182+
subscription with `signing_profile` (`integrity_failure`) rather than sign it
183+
under the wrong profile, and its resolver cannot return such a subscription.
184+
Deploy every reporting worker before your subscription resolver starts
185+
setting the field.

0 commit comments

Comments
 (0)