|
| 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