Skip to content

Add Web Crypto verifyWebhookSignature export for in-handler use#71

Merged
etbyrd merged 3 commits into
mainfrom
webhook-verifier-workers-safe
May 11, 2026
Merged

Add Web Crypto verifyWebhookSignature export for in-handler use#71
etbyrd merged 3 commits into
mainfrom
webhook-verifier-workers-safe

Conversation

@etbyrd
Copy link
Copy Markdown
Member

@etbyrd etbyrd commented May 11, 2026

Summary

AGX feedback flagged a contradictory pair of statements in /docs/functions: the handler example imports verifyWebhookSignature from @primitivedotdev/sdk, but the imports section warns that the root export pulls in node:crypto which inflates the bundle past the Function deploy cap. The agent was stuck: follow the docs and npm run build fails with Could 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.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.

Surface

Mirrors the Node verifier exactly so a handler can swap the import path:

- import { verifyWebhookSignature } from '@primitivedotdev/sdk';
+ import { verifyWebhookSignature } from '@primitivedotdev/sdk/api';

Same WebhookVerificationError class (re-exported from webhook/errors.js, the leaf module with no crypto dep), same error codes. The only shape difference: the Web Crypto version is async because crypto.subtle.sign is async-only, so callers await it.

PRIMITIVE_SIGNATURE_HEADER is redeclared in verify-signature.ts (with a comment to keep it in lockstep with webhook/signing.ts) so the api entry's dependency graph never walks into webhook/signing.ts and drags node:crypto into the bundle.

Bundle check

grep -nE "^import|^require\(" sdk-node/dist/api/*.js after pnpm build:

dist/api/verify-signature.js:27:import { WebhookVerificationError } from "../webhook/errors.js";
dist/api/index.js:7:import { formatAddress } from "../webhook/received-email.js";
dist/api/index.js:8:import { createClient, createConfig, } from "./generated/client/index.js";
dist/api/index.js:9:import * as generatedOperations from "./generated/sdk.gen.js";

No node:crypto. The string appears in JSDoc and code comments only.

Tests

10 new tests in tests/api/verify-signature.test.ts:

  • accept (signature produced by Node signWebhookPayload, asserts cross-impl wire compatibility)
  • tamper rejection
  • old-timestamp rejection
  • future-timestamp rejection
  • empty-secret rejection
  • malformed-header rejection
  • key-rotation header with two v1 entries
  • every-candidate-invalid-hex case
  • custom toleranceSeconds
  • type guard asserting every rejection is a WebhookVerificationError instance (consumers branch on instanceof)

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/functions HANDLER_EXAMPLE to import from @primitivedotdev/sdk/api so the doc example matches what actually bundles.

Test plan

  • CI green.
  • After publish, a real Function bundle that imports verifyWebhookSignature from @primitivedotdev/sdk/api builds without Could not resolve "node:crypto" and deploys without tripping the size cap.
  • A signature produced by the server's webhook signer verifies against the Web Crypto path in a real Function.

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-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 11, 2026

Greptile Summary

This PR adds a verifyWebhookSignature function built on the Web Crypto API (crypto.subtle) as a new export from @primitivedotdev/sdk/api, solving a build-failure/size-cap conflict that made the existing Node verifier (which imports node:crypto) unbundleable in Primitive Function handlers.

  • sdk-node/src/api/verify-signature.ts — New 270-line implementation that mirrors the Node verifier's interface exactly: same VerifyOptions shape, same WebhookVerificationError class and error codes, same timestamp-tolerance logic. Uses crypto.subtle.importKey/sign for the HMAC step and a character-level XOR loop for constant-time hex comparison. PRIMITIVE_SIGNATURE_HEADER is intentionally redeclared to keep the api entry's import graph free of webhook/signing.ts (and therefore node:crypto).
  • sdk-node/src/api/index.ts — Re-exports verifyWebhookSignature, WebhookVerificationError, PRIMITIVE_SIGNATURE_HEADER, and the two supporting types from the new file, making @primitivedotdev/sdk/api the single import a Function handler needs.
  • sdk-node/tests/api/verify-signature.test.ts — 10 new tests covering the happy path (cross-impl wire compatibility via signWebhookPayload), all four error codes, key-rotation multi-signature headers, uppercase-hex regression guard, and toleranceSeconds override.

Confidence Score: 5/5

Safe 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

Filename Overview
sdk-node/src/api/verify-signature.ts New Web Crypto verifier; logic mirrors the Node implementation correctly, both previous review issues (HEX_PATTERN case-insensitivity and redundant typeof guard) have been resolved.
sdk-node/src/api/index.ts Adds type and value re-exports for the new verifier; no dependency on node:crypto introduced.
sdk-node/tests/api/verify-signature.test.ts 10 tests covering all error codes, key rotation, uppercase-hex regression, and cross-implementation wire compatibility.
sdk-node/package.json Patch version bump from 0.25.0 to 0.25.1; no dependency changes.

Reviews (3): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile

Comment thread sdk-node/src/api/verify-signature.ts Outdated
Comment thread sdk-node/src/api/verify-signature.ts Outdated
etbyrd added 2 commits May 11, 2026 02:14
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.
@etbyrd etbyrd merged commit 95d4b95 into main May 11, 2026
9 checks passed
@etbyrd etbyrd deleted the webhook-verifier-workers-safe branch May 11, 2026 09:26
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.

1 participant