Verified Meta ingress: HMAC fail-closed on the raw body + SQS
This story ships the WhatsApp webhook receiver — the D24 wax-seal,
the most security-critical code in RFD-AGT-013 — into the Terraform-owned
Lambda shell pramaan-whatsapp-webhook-staging (Chore-2845).
Every Meta delivery must prove transport authenticity before anything
else happens: X-Hub-Signature-256 HMAC-SHA256 over the
raw request body bytes, before any parsing, before
identity resolution, before dedupe, before any work is queued.
Why raw bytes are the whole property (adversary A-1)
The classic webhook bug is verifying the HMAC over the
parsed/re-serialized body. A parse round trip changes key order,
whitespace, and unicode escapes — so a re-serializing verifier
authenticates a body we invented, not the bytes that arrived. The proof
lives in tests/unit/test_whatsapp_webhook_signature.py: a
validly signed non-canonical body (shuffled keys, odd spacing)
verifies — a re-serializing implementation rejects Meta's real signature
— while the same semantics re-serialized and signed with the wrong key
fails closed.
The pipeline order is the security property
| Step | What happens | On failure |
|---|---|---|
| 1. Wax-seal | HMAC over raw bytes; secret from SSM
/pramaan/stg/meta-app-secret, resolved in-process at
cold start (ADR-162 D3). |
401 + audit row (channel_webhook.rejected, system
actor, NULL firm) + structured log. Zero dedupe
rows, zero SQS, zero runs (CC-44). |
| 2. Dedupe | One channel_inbound_messages row per
(provider, provider_message_id) — insert-and-claim in
a single round trip before any queueing. |
Replay (Meta redelivery): 200, nothing re-queued (CC-15). |
| 3. Enqueue | One small JSON pointer envelope per message onto
pramaan-whatsapp-ingress-staging (never media bytes);
dedupe rows commit only after every send succeeded. |
SQS/DB failure: 500 so Meta redelivers; the dedupe claim rolls back with the transaction, so the redelivery re-claims cleanly. |
GET /webhooks/whatsapp answers Meta's one-time verification
handshake (constant-time token compare; a wrong token never gets the
challenge back). Status callbacks pass through dedupe-less — callback
idempotency is T2d-a's delivery-records upsert. The channel-ingress
consumer (binding resolution + live membership check + clerk-rail
re-enqueue) is the follow-up behavior ticket's code, not this story's.
The schema gap the seed left (migration 0208)
The 0207 schema-seed intended NULL-firm dedupe rows ("resolution fields
stay NULL until identity resolution succeeds") but shipped no RLS write
path for them: firm_isolation_policy's WITH CHECK can never
pass a NULL-firm row, and the staff policy was SELECT-only. Red-first
proof: the ingress integration tests fail on a database at 0207 with
new row violates row-level security policy for table
"channel_inbound_messages" and pass at 0208. The fix mirrors the
0018 webhook precedent — a staff_write_policy on exactly
one table; a permanent test proves the same insert without staff
context is still denied, so firm isolation is unchanged.
Code + grant + check travel together (R2)
- Code —
src/whatsapp_webhook/(signature, SSM secrets, handler),src/domains/channel/service.py(dedupe claim, audit emit, payload flattening). - Grant — twin PR pramaan-infra#202 adds both whatsapp
roles to
db_secret_reader_roles(the ADR-162 in-process DB resolve simulated implicitDeny without it). Ingress-secret and SQS grants shipped in Chore-2845. - Check — the deploy canary
(
deploy-whatsapp-webhook.yml) drives both cold-start SSM reads, the raw-body HMAC closure on a self-signed body, and the DB SecureString resolve, asserting log evidence and failing onAccessDenied. An empty smoke proves imports only (R4); this one exercises the changed code path.
CC-44 acceptance
Scenario: An unsigned or forged webhook starts no run Given an inbound webhook whose signature is missing or invalid When it claims to be a message from a bound counsel's number Then the adapter rejects it before identity resolution And no run is enqueued and the attempt is audit-logged
- Unit: forged/missing/malformed → 401 + audit call, zero dedupe, zero SQS; valid → dedupe-then-enqueue; replay → deduped, nothing queued; base64 bodies verify byte-for-byte; infra failures → 500.
- Integration (real Postgres,
pramaan_approle): dedupe claim + replay against live RLS, the system-actor NULL-firm audit row inops.audit_log, firm isolation intact. - Live (post-merge, operator): real Meta test webhook accepted + forged curl rejected with an audit row — the exact curl steps are in the PR body.