Visitar URL original
feat(core,webhook-engine): ECDSA P-384 and prehashed signatures for a… · triggerdotdev/trigger.dev@4ed2e37 · GitHub
Skip to content

Commit 4ed2e37

Browse files
ericallamTrigger.dev RepoOps
authored andcommitted
feat(core,webhook-engine): ECDSA P-384 and prehashed signatures for asymmetric webhook sources
Asymmetric webhook sources can verify ECDSA P-384 signatures (`algorithm: "ecdsa-p384"`, SHA-384) and providers that sign a SHA-256 digest of the signing string (`prehash: "sha256"`). ECDSA signatures are accepted in DER or raw `r||s` form. Mono-RevId: 9292842541dae090fd31b1b746cd7fd9c344f8dc
1 parent e226aae commit 4ed2e37

6 files changed

Lines changed: 125 additions & 18 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/core": patch
3+
---
4+
5+
Asymmetric webhook sources can now verify ECDSA P-384 signatures (`algorithm: "ecdsa-p384"`, SHA-384) and providers that sign a SHA-256 digest of the signing string (`prehash: "sha256"`). ECDSA signatures are accepted in DER or raw `r||s` form.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
Hosted webhook endpoints can verify ECDSA P-384 (SHA-384) signatures, ECDSA signatures in raw `r||s` form, and RSA signatures made over a SHA-256 digest of the signing string (`prehash: "sha256"`).

‎docs/webhooks/sources.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ Custom sources support these schemes:
242242
| `hmac` | Computes an HMAC over the raw body or a signing template and compares it with a request header. |
243243
| `shared-secret` | Compares a static token supplied in a header, bearer token, basic authentication, or request body. |
244244
| `url-secret` | Compares a token in a URL query parameter. Hosted endpoints don't support path placement. |
245-
| `asymmetric` | Verifies an Ed25519, ECDSA, or RSA signature using the provider's public key. |
245+
| `asymmetric` | Verifies an Ed25519, ECDSA (P-256 with SHA-256, P-384 with SHA-384), or RSA-SHA256 signature using the provider's public key. Set `prehash: "sha256"` when the provider signs a SHA-256 digest of the signing string instead of the string itself. |
246246

247247
<Warning>
248248
Query parameters can appear in access logs, proxies, and traces. Prefer a header-based scheme when

‎internal-packages/webhook-engine/src/engine/verification/asymmetric.ts‎

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { WebhookAsymmetricConfig as AsymmetricConfig } from "@trigger.dev/core/v3";
2-
import { createPublicKey, verify as cryptoVerify, type KeyObject } from "node:crypto";
2+
import { createHash, createPublicKey, verify as cryptoVerify, type KeyObject } from "node:crypto";
33
import { deriveIdempotencyKey, parseEventBody } from "./derive.js";
44
import { prepareSignedVerification } from "./parse.js";
55
import type { SchemeVerifier, VerifierResult, VerifyInput } from "./types.js";
@@ -9,6 +9,17 @@ import { decodeSignature } from "./util.js";
99
// Lets us accept a raw Ed25519 key (e.g. Discord's hex public key) without a PEM wrapper.
1010
const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
1111

12+
// Ed25519 is PureEdDSA (no prehash) → algorithm null; the others name the digest Node signs with.
13+
const NODE_ALGORITHM: Record<AsymmetricConfig["algorithm"], string | null> = {
14+
ed25519: null,
15+
"ecdsa-p256": "sha256",
16+
"ecdsa-p384": "sha384",
17+
"rsa-sha256": "sha256",
18+
};
19+
20+
// ECDSA signatures arrive either DER-encoded or as raw r||s (IEEE P1363); accept both.
21+
const DSA_ENCODINGS = ["der", "ieee-p1363"] as const;
22+
1223
function importPublicKey(
1324
stored: string,
1425
encoding: AsymmetricConfig["publicKeyEncoding"],
@@ -46,20 +57,22 @@ export const asymmetricVerifier: SchemeVerifier = {
4657
return fail("invalid public key", cfg, input);
4758
}
4859

49-
// Ed25519 is PureEdDSA (no prehash) → algorithm null; ECDSA/RSA hash with sha256.
50-
const nodeAlgorithm = cfg.algorithm === "ed25519" ? null : "sha256";
60+
const nodeAlgorithm = NODE_ALGORITHM[cfg.algorithm];
61+
const message = cfg.prehash
62+
? createHash(cfg.prehash).update(prep.signingBytes).digest()
63+
: prep.signingBytes;
64+
const isEcdsa = cfg.algorithm === "ecdsa-p256" || cfg.algorithm === "ecdsa-p384";
65+
const keys = isEcdsa ? DSA_ENCODINGS.map((dsaEncoding) => ({ key, dsaEncoding })) : [key];
5166

5267
const matched = prep.signatures.some((sig) => {
53-
try {
54-
return cryptoVerify(
55-
nodeAlgorithm,
56-
prep.signingBytes,
57-
key,
58-
decodeSignature(sig, cfg.encoding)
59-
);
60-
} catch {
61-
return false;
62-
}
68+
const signature = decodeSignature(sig, cfg.encoding);
69+
return keys.some((k) => {
70+
try {
71+
return cryptoVerify(nodeAlgorithm, message, k, signature);
72+
} catch {
73+
return false;
74+
}
75+
});
6376
});
6477
if (!matched) return fail("signature mismatch", cfg, input);
6578

‎internal-packages/webhook-engine/src/engine/verification/verify.test.ts‎

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { createHmac, generateKeyPairSync, sign as cryptoSign, type KeyObject } from "node:crypto";
1+
import {
2+
createHash,
3+
createHmac,
4+
generateKeyPairSync,
5+
sign as cryptoSign,
6+
type KeyObject,
7+
} from "node:crypto";
28
import { WebhookVerifierConfig } from "@trigger.dev/core/v3";
39
import { webhookProviderConfigs } from "@trigger.dev/core/webhooks";
410
import { describe, expect, it } from "vitest";
@@ -471,6 +477,77 @@ describe("asymmetric verifier (config-driven)", () => {
471477
);
472478
expect(result.ok).toBe(true);
473479
});
480+
481+
it("verifies a Bridge-style RSA signature over a SHA-256 digest with prehash", () => {
482+
const { publicKey, privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
483+
const pem = publicKey.export({ type: "spki", format: "pem" }).toString();
484+
const ts = String(NOW_MS);
485+
const body = JSON.stringify({ event_type: "customer.updated" });
486+
const digest = createHash("sha256").update(`${ts}.${body}`).digest();
487+
const sig = cryptoSign("sha256", digest, privateKey).toString("base64");
488+
const config = {
489+
scheme: "asymmetric",
490+
algorithm: "rsa-sha256",
491+
encoding: "base64",
492+
signatureHeader: "x-webhook-signature",
493+
signature: { itemSeparator: ",", fieldSeparator: "=", field: "v0" },
494+
timestamp: {
495+
source: { from: "signatureField", field: "t" },
496+
unit: "milliseconds",
497+
toleranceSeconds: 600,
498+
},
499+
signingString: { template: "{timestamp}.{body}" },
500+
prehash: "sha256",
501+
};
502+
const request = (cfgOverrides: Record<string, unknown>, rawBody: string) =>
503+
verify(
504+
cfg({ ...config, ...cfgOverrides }),
505+
input({
506+
rawBytes: bytes(rawBody),
507+
secret: pem,
508+
headers: { "x-webhook-signature": `t=${ts},v0=${sig}` },
509+
})
510+
);
511+
512+
expect(request({}, body).ok).toBe(true);
513+
expect(request({ prehash: undefined }, body).ok).toBe(false);
514+
expect(request({}, '{"event_type":"customer.deleted"}').ok).toBe(false);
515+
});
516+
517+
it("verifies an ECDSA P-384 / SHA-384 signature in DER and raw r||s form", () => {
518+
const { publicKey, privateKey } = generateKeyPairSync("ec", { namedCurve: "P-384" });
519+
const pem = publicKey.export({ type: "spki", format: "pem" }).toString();
520+
const body = JSON.stringify({ id: "evt_1" });
521+
const config = cfg({
522+
scheme: "asymmetric",
523+
algorithm: "ecdsa-p384",
524+
encoding: "base64",
525+
signatureHeader: "webhook-signature",
526+
signature: {},
527+
signingString: "raw",
528+
});
529+
const withSig = (sig: Buffer, rawBody = body) =>
530+
verify(
531+
config,
532+
input({
533+
rawBytes: bytes(rawBody),
534+
secret: pem,
535+
headers: { "webhook-signature": sig.toString("base64") },
536+
})
537+
);
538+
539+
const der = cryptoSign("sha384", Buffer.from(body), privateKey);
540+
const p1363 = cryptoSign("sha384", Buffer.from(body), {
541+
key: privateKey,
542+
dsaEncoding: "ieee-p1363",
543+
});
544+
expect(withSig(der).ok).toBe(true);
545+
expect(withSig(p1363).ok).toBe(true);
546+
expect(withSig(der, '{"id":"evt_2"}').ok).toBe(false);
547+
548+
const sha256Sig = cryptoSign("sha256", Buffer.from(body), privateKey);
549+
expect(withSig(sha256Sig).ok).toBe(false);
550+
});
474551
});
475552

476553
describe("adversarial / fail-closed", () => {

‎packages/core/src/v3/schemas/webhookConfig.ts‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -167,17 +167,23 @@ export type WebhookHmacConfig = z.infer<typeof WebhookHmacConfig>;
167167

168168
// Asymmetric (public-key) signatures: the provider signs with a private key, we verify with
169169
// their public key. Same parsing/template primitives as HMAC; only the final verify differs.
170-
// Covers Discord (ed25519), SendGrid (ecdsa-p256), RSA providers. Dynamic key fetch (PayPal
171-
// cert URL / JWKS) is out of scope → the custom-bundle escape hatch.
170+
// Covers Discord (ed25519), SendGrid (ecdsa-p256), ecdsa-p384 (SHA-384), RSA providers. Dynamic
171+
// key fetch (PayPal cert URL / JWKS) is out of scope → the custom-bundle escape hatch.
172172
export const WebhookAsymmetricConfig = z
173173
.object({
174174
scheme: z.literal("asymmetric"),
175-
algorithm: z.enum(["ed25519", "ecdsa-p256", "rsa-sha256"]),
175+
algorithm: z.enum(["ed25519", "ecdsa-p256", "ecdsa-p384", "rsa-sha256"]),
176176
encoding: z.enum(["hex", "base64"]), // how the signature value is encoded
177177
signatureHeader: z.string(),
178178
signature: WebhookSignatureExtraction.optional(),
179179
timestamp: WebhookTimestampConfig.optional(),
180180
signingString: WebhookSigningString,
181+
/**
182+
* Hash the signing string first and verify the signature over that raw digest, for providers
183+
* that sign a digest rather than the payload (Bridge signs `sha256("{timestamp}.{body}")` with
184+
* RSA-SHA256, so the payload is hashed twice). Omit to verify over the signing string itself.
185+
*/
186+
prehash: z.enum(["sha256"]).optional(),
181187
// How the stored public key is encoded: a PEM string, base64 of DER SPKI (SendGrid),
182188
// or raw key bytes (ed25519 raw, Discord). Defaults to "pem".
183189
publicKeyEncoding: z.enum(["pem", "spki-der-base64", "raw-hex", "raw-base64"]).optional(),

0 commit comments

Comments
 (0)