@hookflo/tern 3.0.1 → 3.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -109,7 +109,7 @@ exports.platformAlgorithmConfigs = {
109
109
  signatureFormat: 'v1={signature}',
110
110
  payloadFormat: '{id}.{timestamp}.{body}',
111
111
  encoding: 'base64',
112
- secretEncoding: 'base64',
112
+ secretEncoding: 'utf8',
113
113
  idHeader: 'webhook-id',
114
114
  },
115
115
  },
@@ -6,7 +6,7 @@ export declare abstract class AlgorithmBasedVerifier extends WebhookVerifier {
6
6
  constructor(secret: string, config: SignatureConfig, platform: WebhookPlatform, toleranceInSeconds?: number);
7
7
  abstract verify(request: Request): Promise<WebhookVerificationResult>;
8
8
  protected parseDelimitedHeader(headerValue: string): Record<string, string>;
9
- protected extractSignature(request: Request): string | null;
9
+ protected extractSignatures(request: Request): string[];
10
10
  protected extractTimestamp(request: Request): number | null;
11
11
  protected extractTimestampFromSignature(request: Request): number | null;
12
12
  protected formatPayload(rawBody: string, request: Request): string;
@@ -31,31 +31,47 @@ class AlgorithmBasedVerifier extends base_1.WebhookVerifier {
31
31
  }
32
32
  return values;
33
33
  }
34
- extractSignature(request) {
34
+ extractSignatures(request) {
35
35
  const headerValue = request.headers.get(this.config.headerName);
36
36
  if (!headerValue)
37
- return null;
37
+ return [];
38
38
  switch (this.config.headerFormat) {
39
39
  case "prefixed":
40
- return headerValue.trim();
40
+ return [headerValue.trim()].filter(Boolean);
41
41
  case "comma-separated": {
42
42
  const sigMap = this.parseDelimitedHeader(headerValue);
43
43
  const signatureKey = this.config.customConfig?.signatureKey || "v1";
44
- return sigMap[signatureKey] || sigMap.signature || sigMap.v1 || null;
44
+ return [sigMap[signatureKey], sigMap.signature, sigMap.v1].filter((value) => Boolean(value));
45
45
  }
46
46
  case "raw":
47
47
  default:
48
48
  if (this.config.customConfig?.signatureFormat?.includes("v1=")) {
49
- const signatures = headerValue.split(" ");
49
+ const signatures = headerValue
50
+ .trim()
51
+ .split(/\s+/)
52
+ .map((entry) => entry.trim())
53
+ .filter(Boolean);
54
+ const normalized = [];
50
55
  for (const sig of signatures) {
51
- const [version, signature] = sig.split(",");
52
- if (version === "v1") {
53
- return signature;
56
+ // Standard Webhooks format is usually "v1,<signature>"
57
+ if (sig.startsWith("v1,")) {
58
+ const [, value] = sig.split(",", 2);
59
+ if (value) {
60
+ normalized.push(value.trim());
61
+ }
62
+ continue;
63
+ }
64
+ // Accept "v1=<signature>" variants used by some providers/docs.
65
+ if (sig.startsWith("v1=")) {
66
+ const [, value] = sig.split("=", 2);
67
+ if (value) {
68
+ normalized.push(value.trim());
69
+ }
54
70
  }
55
71
  }
56
- return null;
72
+ return normalized;
57
73
  }
58
- return headerValue;
74
+ return [headerValue.trim()].filter(Boolean);
59
75
  }
60
76
  }
61
77
  extractTimestamp(request) {
@@ -154,12 +170,13 @@ class AlgorithmBasedVerifier extends base_1.WebhookVerifier {
154
170
  const secretEncoding = this.config.customConfig?.secretEncoding || "base64";
155
171
  let secretMaterial = this.secret;
156
172
  if (secretEncoding === "base64") {
157
- const base64Secret = this.secret.includes("_")
158
- ? this.secret.split("_").slice(1).join("_")
159
- : this.secret;
160
- secretMaterial = new Uint8Array(Buffer.from(base64Secret, "base64"));
173
+ let rawSecret = this.secret;
174
+ const lastUnderscore = rawSecret.lastIndexOf("_");
175
+ if (lastUnderscore !== -1) {
176
+ rawSecret = rawSecret.slice(lastUnderscore + 1);
177
+ }
178
+ secretMaterial = new Uint8Array(Buffer.from(rawSecret, "base64"));
161
179
  }
162
- // 'utf8', 'raw', or anything else → use secret as-is
163
180
  const hmac = (0, crypto_1.createHmac)(algorithm, secretMaterial);
164
181
  hmac.update(payload);
165
182
  const expectedSignature = hmac.digest("base64");
@@ -239,8 +256,8 @@ class GenericHMACVerifier extends AlgorithmBasedVerifier {
239
256
  }
240
257
  async verify(request) {
241
258
  try {
242
- const signature = this.extractSignature(request);
243
- if (!signature) {
259
+ const signatures = this.extractSignatures(request);
260
+ if (signatures.length === 0) {
244
261
  return {
245
262
  isValid: false,
246
263
  error: `Missing signature header: ${this.config.headerName}`,
@@ -270,14 +287,19 @@ class GenericHMACVerifier extends AlgorithmBasedVerifier {
270
287
  let isValid = false;
271
288
  const algorithm = this.config.algorithm.replace("hmac-", "");
272
289
  for (const payload of payloadCandidates) {
273
- if (this.config.customConfig?.encoding === "base64") {
274
- isValid = this.verifyHMACWithBase64(payload, signature, algorithm);
275
- }
276
- else if (this.config.headerFormat === "prefixed") {
277
- isValid = this.verifyHMACWithPrefix(payload, signature, algorithm);
278
- }
279
- else {
280
- isValid = this.verifyHMAC(payload, signature, algorithm);
290
+ for (const signature of signatures) {
291
+ if (this.config.customConfig?.encoding === "base64") {
292
+ isValid = this.verifyHMACWithBase64(payload, signature, algorithm);
293
+ }
294
+ else if (this.config.headerFormat === "prefixed") {
295
+ isValid = this.verifyHMACWithPrefix(payload, signature, algorithm);
296
+ }
297
+ else {
298
+ isValid = this.verifyHMAC(payload, signature, algorithm);
299
+ }
300
+ if (isValid) {
301
+ break;
302
+ }
281
303
  }
282
304
  if (isValid) {
283
305
  break;
@@ -412,8 +434,8 @@ class Ed25519Verifier extends AlgorithmBasedVerifier {
412
434
  }
413
435
  async verify(request) {
414
436
  try {
415
- const signature = this.extractSignature(request);
416
- if (!signature) {
437
+ const signatures = this.extractSignatures(request);
438
+ if (signatures.length === 0) {
417
439
  return {
418
440
  isValid: false,
419
441
  error: `Missing signature header: ${this.config.headerName}`,
@@ -466,23 +488,28 @@ class Ed25519Verifier extends AlgorithmBasedVerifier {
466
488
  // fal.ai signature is hex encoded (not base64)
467
489
  // other ED25519 platforms use base64 by default
468
490
  const signatureEncoding = this.platform === "falai" ? "hex" : "base64";
469
- const signatureBytes = new Uint8Array(Buffer.from(signature, signatureEncoding));
470
491
  const payloadBytes = new Uint8Array(Buffer.from(payload, "utf-8"));
471
492
  // try all keys — succeeds if any key validates
472
493
  // this handles key rotation gracefully
473
494
  let isValid = false;
474
- for (const pem of publicKeys) {
475
- try {
476
- const keyObject = (0, crypto_1.createPublicKey)(pem);
477
- const valid = (0, crypto_1.verify)(null, payloadBytes, keyObject, signatureBytes);
478
- if (valid) {
479
- isValid = true;
480
- break;
495
+ for (const signature of signatures) {
496
+ const signatureBytes = new Uint8Array(Buffer.from(signature, signatureEncoding));
497
+ for (const pem of publicKeys) {
498
+ try {
499
+ const keyObject = (0, crypto_1.createPublicKey)(pem);
500
+ const valid = (0, crypto_1.verify)(null, payloadBytes, keyObject, signatureBytes);
501
+ if (valid) {
502
+ isValid = true;
503
+ break;
504
+ }
505
+ }
506
+ catch {
507
+ // this key failed — try next
508
+ continue;
481
509
  }
482
510
  }
483
- catch {
484
- // this key failed — try next
485
- continue;
511
+ if (isValid) {
512
+ break;
486
513
  }
487
514
  }
488
515
  if (!isValid) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hookflo/tern",
3
- "version": "3.0.1",
3
+ "version": "3.0.3",
4
4
  "description": "A robust, scalable webhook verification framework supporting multiple platforms and signature algorithms",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",