Add Web Crypto verifyWebhookSignature export for in-handler use#71
Conversation
AGX feedback: /docs/functions tells handler authors to call
verifyWebhookSignature from @primitivedotdev/sdk, but the SDK's root
verifier imports node:crypto which esbuild bundles as a hefty
polyfill that trips the Function deploy size cap. The same docs page
warns about this in the imports section. The agent was stuck:
follow the docs and the build breaks; skip the verifier and ship
unsigned-payload handling, which is security-malpractice.
Add a Workers-safe verifier in src/api/verify-signature.ts that
implements HMAC-SHA256 via crypto.subtle (Web Crypto). Available
natively in Workers, Node 22+, browsers, Deno, and Bun. Zero
polyfill weight, zero new runtime dependencies. About 200 lines
including comments.
Surface contract mirrors the Node verifier exactly: same input
shape, same WebhookVerificationError class re-exported from
webhook/errors.js (the leaf, no-crypto-dep module), same error
codes (MISSING_SECRET, INVALID_SIGNATURE_HEADER,
TIMESTAMP_OUT_OF_RANGE, SIGNATURE_MISMATCH). A handler can swap
the import path from @primitivedotdev/sdk to
@primitivedotdev/sdk/api with no other code changes; the only
shape difference is that the new verifier is async (crypto.subtle
is async-only) so callers `await` it instead of calling directly.
PRIMITIVE_SIGNATURE_HEADER is redeclared in verify-signature.ts
to keep the api entry's dependency graph free of any path that
walks into webhook/signing.ts. A comment notes the two constants
must stay in lockstep.
Tests cover accept (sig produced by Node signWebhookPayload),
tamper rejection, old-timestamp rejection, future-timestamp
rejection, missing-secret rejection, malformed-header rejection,
key-rotation header with two v1 entries, every-candidate-invalid-hex
case, custom toleranceSeconds, and a type guard that every error
is a WebhookVerificationError instance. 710 sdk-node tests green;
lint and typecheck clean.
Verified the api bundle has no node:crypto import after build:
`grep -nE "^import\|^require\(" dist/api/*.js` shows only
webhook/errors.js and generated client/operations entries.
Bumps sdk-node to 0.25.1. Mono-repo /docs/functions update to
point in-handler users at the new import is the paired follow-up.
Greptile SummaryThis PR adds a
Confidence Score: 5/5Safe to merge — the new Web Crypto verifier is a clean addition with no changes to existing code paths. The two issues flagged in earlier review rounds (case-insensitive HEX_PATTERN and the redundant typeof guard) are both resolved. The implementation correctly mirrors the Node verifier's timestamp validation, key-rotation handling, and constant-time hex comparison. The test suite exercises all error codes and includes a cross-implementation wire-compatibility check using the existing Node signer. No existing exports are modified. No files require special attention. Important Files Changed
Reviews (3): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile |
Greptile flagged two inconsistencies between the new Web Crypto verifier and the existing Node verifier in src/webhook/signing.ts: 1. HEX_PATTERN lacked the /i flag. The Node verifier accepts case-insensitive hex (it decodes via Buffer.from(str, 'hex'), which is case-insensitive). A third-party signer that emitted uppercase hex would verify against Node but silently fail through to SIGNATURE_MISMATCH against Web Crypto. Add /i and lowercase the candidate before the constant-time comparison (expectedHex from arrayBufferToHex is always lowercase). Add a regression test that signs lowercase, uppercases the digest in the header, and asserts the verifier accepts it. 2. Redundant typeof guard on the MISSING_SECRET check. The Node verifier carries that guard because it also accepts Buffer; the Web Crypto verifier only accepts string (Buffer is not a thing in Workers). The !secret check is sufficient on its own for the narrowed type. Drop the dead branch.
Summary
AGX feedback flagged a contradictory pair of statements in
/docs/functions: the handler example importsverifyWebhookSignaturefrom@primitivedotdev/sdk, but the imports section warns that the root export pulls innode:cryptowhich inflates the bundle past the Function deploy cap. The agent was stuck: follow the docs andnpm run buildfails withCould not resolve "node:crypto"; skip the verifier and ship an unsigned-payload handler, which is security-malpractice.Add a Workers-safe verifier in
src/api/verify-signature.tsthat implements HMAC-SHA256 viacrypto.subtle(Web Crypto). Available natively in Workers, Node 22+, browsers, Deno, and Bun. Zero polyfill weight, zero new runtime dependencies.Surface
Mirrors the Node verifier exactly so a handler can swap the import path:
Same
WebhookVerificationErrorclass (re-exported fromwebhook/errors.js, the leaf module with no crypto dep), same error codes. The only shape difference: the Web Crypto version isasyncbecausecrypto.subtle.signis async-only, so callersawaitit.PRIMITIVE_SIGNATURE_HEADERis redeclared inverify-signature.ts(with a comment to keep it in lockstep withwebhook/signing.ts) so theapientry's dependency graph never walks intowebhook/signing.tsand dragsnode:cryptointo the bundle.Bundle check
grep -nE "^import|^require\(" sdk-node/dist/api/*.jsafterpnpm build:No
node:crypto. The string appears in JSDoc and code comments only.Tests
10 new tests in
tests/api/verify-signature.test.ts:signWebhookPayload, asserts cross-impl wire compatibility)v1entriestoleranceSecondsWebhookVerificationErrorinstance (consumers branch oninstanceof)710 sdk-node tests green total; lint and typecheck clean.
Release
Bumps sdk-node from 0.25.0 to 0.25.1. sdk-python and sdk-go don't have the same Workers bundle problem and are unchanged.
Pairs with
Follow-up mono-repo PR updating
/docs/functionsHANDLER_EXAMPLE to import from@primitivedotdev/sdk/apiso the doc example matches what actually bundles.Test plan
verifyWebhookSignaturefrom@primitivedotdev/sdk/apibuilds withoutCould not resolve "node:crypto"and deploys without tripping the size cap.