Skip to content

Verify signed push deliveries - #22

Open
akselkvitberg wants to merge 7 commits into
mainfrom
claude/delivery-verification
Open

akselkvitberg wants to merge 7 commits into
mainfrom
claude/delivery-verification

Conversation

@akselkvitberg

@akselkvitberg akselkvitberg commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

Summary

Part of bcc-code/bcc-pubsub#56. Go counterpart of the .NET and Node verifiers in bcc-code/bcc-pubsub#181 and bcc-code/bcc-pubsub#192.

bcc-pubsub is starting to sign every push with an X-PubSub-Token header. The token tells the receiver that the push came from bcc-pubsub, was meant for its client, and has the body bcc-pubsub sent. This PR lets Go services check it.

What it does

  • NewDeliveryVerifier(DeliveryVerifierOptions{Issuer, Audience}) creates a verifier. Verify(ctx, token, body) returns the claims (subscription or dispatch, message id, issue time), or:
    • a *DeliveryVerificationError with a Reason (wrong_audience, expired, body_mismatch, …) when the push must be rejected with 401;
    • an error wrapping ErrDeliveryKeysUnavailable when bcc-pubsub's public keys can't be fetched, so the service answers 5xx and bcc-pubsub retries.
  • RequireDelivery(verifier, maxBodyBytes, next) is net/http middleware. It answers 401 to a push without a valid token, without reading the body when the header is missing, and 503 when the keys are unavailable. It hands the verified body and claims (DeliveryClaimsFrom) to the handler. Other routers can call Verify directly.
  • Issuer constants DeliveryIssuerProd, DeliveryIssuerSandbox and DeliveryIssuerDev. The token's issuer is the pubsub.api.* URL, which differs from the api.bcc.no/pubsub gateway URLs the client uses for publishing, so it can't be taken from the client.
  • Same behaviour as the .NET and Node verifiers: the same checks and failure reasons, and the same 14 shared test cases, copied into pubsubsdk/testdata (Scripts/update-test-vectors.sh refreshes them).
  • Key caching: keys are cached for an hour and fetched again when a new key appears, at most once a minute, even under concurrent requests.
  • Standard library only (crypto/ecdsa, crypto/ecdh to check the key is on the curve). No new dependency, and it still builds with go 1.21.
  • README with a net/http example. The README was empty before.

Existing users

Nothing changes for code that only publishes or manages subscriptions. The verifier is new API that nothing calls unless a service chooses to.

Go-live

  • bcc-code/bcc-pubsub#181 is deployed. Until Delivery signs pushes, RequireDelivery rejects every push.
  • Merge and tag a new version.

Follow-up fixes

After comparing with the .NET and Node verifiers:

  • Cached keys are served without waiting for a key set fetch in progress (the lock used to be held during the fetch, up to 10 s).
  • A cancelled request no longer stops the shared key fetch or blocks the next refetch for a minute.
  • RequireDelivery answers 413 only for a too large body; other read errors give 400.
  • The keys-unavailable error wraps the underlying fetch error (errors.Is/As work on it).
  • Wrongly typed alg/kid give invalid_signature/unknown_key, as in Node, instead of malformed.
  • The concurrent fetch test no longer relies on time.Sleep.

Part of the .NET parity work with #24. #23 raises go in go.mod to 1.26; this PR still builds with 1.21.

Verification

  • go test ./... -race: all pass. That covers the 14 shared test cases, refetching on a new key, the refetch rate limit, one fetch for 20 concurrent requests, unavailable keys, keeping cached keys on a failed refresh, missing claims, and the middleware (valid push, missing token, wrong audience, changed body).
  • go vet ./... is clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TBbSY7a3FBE2CZNvFF44QH


Generated by Claude Code

NewDeliveryVerifier(...).Verify checks the X-PubSub-Token header that
bcc-pubsub sends with every push: ES256 only, key from the issuer's JWKS,
issuer, audience (your client id), expiry and issued-at with 60 seconds of
skew, required claims and the SHA-256 body hash. Same rules and reason codes
as the .NET and Node SDKs, checked with their shared test vectors.

RequireDelivery is net/http middleware: 401 for a push without a valid
token, 503 when the keys cannot be fetched, and the verified body and claims
for the handler. Standard library only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBbSY7a3FBE2CZNvFF44QH
akselkvitberg and others added 6 commits October 13, 2026 13:41
The key set is now an immutable snapshot behind an atomic pointer. A
request that finds its key in a fresh cache returns at once; only a
request that needs a fetch takes the fetch mutex, and it re-reads the
snapshot after getting it so queued requests use the result of the fetch
that just finished. The concurrent test no longer sleeps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The key set fetch is shared by all waiting requests, so it now runs on a
context detached from the request that started it and is bounded by its
own 10 second timeout (also for a caller-supplied HTTPClient without a
Timeout). Before, a cancelled request failed the fetch and still set the
refresh back-off, blocking the next fetch for a minute.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RequireDelivery returned 413 for every body read error. It now does so
only for http.MaxBytesError; other read errors get 400.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Callers can now errors.Is/As the underlying cause of a failed key set
fetch as well as ErrDeliveryKeysUnavailable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Header and payload are now decoded as JSON objects first, and individual
members are typed only where they are used. A non-string alg gives
invalid_signature and a non-string kid gives unknown_key, as before the
key lookup in the other SDKs; non-JSON or non-object segments stay
malformed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Same change as the .NET and Node verifiers: when the key id is unknown
and the latest JWKS fetch failed, return ErrDeliveryKeysUnavailable (5xx,
retried) instead of rejecting with unknown_key. A rotated key is no
longer rejected just because the refresh failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBbSY7a3FBE2CZNvFF44QH
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.

2 participants