@kindgi/crypto 0.1.6 → 0.1.7-rc.0
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.
- package/dist/inbound-signature.d.ts +65 -0
- package/dist/inbound-signature.d.ts.map +1 -0
- package/dist/inbound-signature.js +96 -0
- package/dist/inbound-signature.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/webhook.d.ts +25 -0
- package/dist/webhook.d.ts.map +1 -1
- package/dist/webhook.js +15 -6
- package/dist/webhook.js.map +1 -1
- package/package.json +2 -2
- package/src/inbound-signature.ts +155 -0
- package/src/index.ts +7 -0
- package/src/webhook.ts +21 -8
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verifying a request a webhook trigger receives, in the scheme its sender
|
|
3
|
+
* signs with (`WebhookSignatureScheme`, `@kindgi/types`):
|
|
4
|
+
*
|
|
5
|
+
* - `hmac-sha256`: HMAC-SHA256 of the raw body under the secret's UTF-8
|
|
6
|
+
* bytes, read from one header (`hex` or `base64`, after an optional
|
|
7
|
+
* prefix). WooCommerce, Shopify, GitHub and Drupal's Webhooks module sign
|
|
8
|
+
* this way.
|
|
9
|
+
* - `standard-webhooks`: {@link verifyWebhook}.
|
|
10
|
+
*
|
|
11
|
+
* The comparison is constant-time. A missing header, a malformed one and a
|
|
12
|
+
* wrong signature are told apart only in the result's `reason`, for the
|
|
13
|
+
* receiver's own record: a receiver answers all of them the same way.
|
|
14
|
+
*/
|
|
15
|
+
import { type KeyObject } from 'node:crypto';
|
|
16
|
+
import type { WebhookSignatureScheme } from '@kindgi/types';
|
|
17
|
+
import { type WebhookRequestHeaders } from './webhook.js';
|
|
18
|
+
export interface VerifyInboundSignatureInput {
|
|
19
|
+
readonly scheme: WebhookSignatureScheme;
|
|
20
|
+
/**
|
|
21
|
+
* The shared secret (its UTF-8 bytes for `hmac-sha256`; `whsec_…` for
|
|
22
|
+
* `standard-webhooks`), or the key {@link inboundSigningKey} derived
|
|
23
|
+
* from it for the same scheme.
|
|
24
|
+
*/
|
|
25
|
+
readonly secret: string | KeyObject;
|
|
26
|
+
readonly headers: WebhookRequestHeaders;
|
|
27
|
+
/** The raw request body, exactly as received — never a re-serialized JSON value. */
|
|
28
|
+
readonly body: string | Uint8Array;
|
|
29
|
+
/** Clock in milliseconds, for a scheme with a timestamp; default `Date.now`. */
|
|
30
|
+
readonly now?: () => number;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Why a request isn't verified:
|
|
34
|
+
* - `signature-missing`: the scheme's header (or headers) isn't there;
|
|
35
|
+
* - `signature-invalid`: present, but malformed or not matching;
|
|
36
|
+
* - `stale`: the signed timestamp is outside the tolerance;
|
|
37
|
+
* - `secret-invalid`: the secret can't be used with the scheme (empty, or
|
|
38
|
+
* not a `whsec_` key for `standard-webhooks`).
|
|
39
|
+
*/
|
|
40
|
+
export type VerifyInboundSignatureFailure = 'signature-missing' | 'signature-invalid' | 'stale' | 'secret-invalid';
|
|
41
|
+
export type VerifyInboundSignatureResult = {
|
|
42
|
+
readonly kind: 'ok';
|
|
43
|
+
/** The id the scheme itself signs (`webhook-id`), when it signs one. */
|
|
44
|
+
readonly signedId?: string;
|
|
45
|
+
} | {
|
|
46
|
+
readonly kind: 'err';
|
|
47
|
+
readonly reason: VerifyInboundSignatureFailure;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* A trigger's signing key, derived from its secret for its scheme: the
|
|
51
|
+
* secret's UTF-8 bytes for `hmac-sha256`, the key a `whsec_…` secret
|
|
52
|
+
* encodes for `standard-webhooks`. A `KeyObject`, so a receiver that keeps
|
|
53
|
+
* it for a while holds no string of the secret, and nothing logs or
|
|
54
|
+
* serializes it by accident (`JSON.stringify` gives `{}`).
|
|
55
|
+
*/
|
|
56
|
+
export declare function inboundSigningKey(scheme: WebhookSignatureScheme, secret: string): {
|
|
57
|
+
readonly kind: 'ok';
|
|
58
|
+
readonly key: KeyObject;
|
|
59
|
+
} | {
|
|
60
|
+
readonly kind: 'err';
|
|
61
|
+
readonly reason: 'secret-invalid';
|
|
62
|
+
};
|
|
63
|
+
/** Verify a received request against its trigger's signature scheme. */
|
|
64
|
+
export declare function verifyInboundSignature(input: VerifyInboundSignatureInput): VerifyInboundSignatureResult;
|
|
65
|
+
//# sourceMappingURL=inbound-signature.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"inbound-signature.d.ts","sourceRoot":"","sources":["../src/inbound-signature.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAAE,KAAK,SAAS,EAAgD,MAAM,aAAa,CAAC;AAE3F,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,eAAe,CAAC;AAE5D,OAAO,EACL,KAAK,qBAAqB,EAI3B,MAAM,cAAc,CAAC;AAItB,MAAM,WAAW,2BAA2B;IAC1C,QAAQ,CAAC,MAAM,EAAE,sBAAsB,CAAC;IACxC;;;;OAIG;IACH,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC;IACpC,QAAQ,CAAC,OAAO,EAAE,qBAAqB,CAAC;IACxC,oFAAoF;IACpF,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,CAAC;IACnC,gFAAgF;IAChF,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CAC7B;AAED;;;;;;;GAOG;AACH,MAAM,MAAM,6BAA6B,GACrC,mBAAmB,GACnB,mBAAmB,GACnB,OAAO,GACP,gBAAgB,CAAC;AAErB,MAAM,MAAM,4BAA4B,GACpC;IACE,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC;IACpB,wEAAwE;IACxE,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC5B,GACD;IAAE,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,6BAA6B,CAAA;CAAE,CAAC;AAE7E;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,sBAAsB,EAC9B,MAAM,EAAE,MAAM,GAEZ;IAAE,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,GAAG,EAAE,SAAS,CAAA;CAAE,GAChD;IAAE,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,gBAAgB,CAAA;CAAE,CAY9D;AAED,wEAAwE;AACxE,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,2BAA2B,GACjC,4BAA4B,CA8C9B"}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (C) 2026 Kindgi Inc.
|
|
3
|
+
/**
|
|
4
|
+
* Verifying a request a webhook trigger receives, in the scheme its sender
|
|
5
|
+
* signs with (`WebhookSignatureScheme`, `@kindgi/types`):
|
|
6
|
+
*
|
|
7
|
+
* - `hmac-sha256`: HMAC-SHA256 of the raw body under the secret's UTF-8
|
|
8
|
+
* bytes, read from one header (`hex` or `base64`, after an optional
|
|
9
|
+
* prefix). WooCommerce, Shopify, GitHub and Drupal's Webhooks module sign
|
|
10
|
+
* this way.
|
|
11
|
+
* - `standard-webhooks`: {@link verifyWebhook}.
|
|
12
|
+
*
|
|
13
|
+
* The comparison is constant-time. A missing header, a malformed one and a
|
|
14
|
+
* wrong signature are told apart only in the result's `reason`, for the
|
|
15
|
+
* receiver's own record: a receiver answers all of them the same way.
|
|
16
|
+
*/
|
|
17
|
+
import { createHmac, createSecretKey, timingSafeEqual } from 'node:crypto';
|
|
18
|
+
import { parseWebhookSecret, readHeader, verifyWebhookWith, } from './webhook.js';
|
|
19
|
+
const SHA256_BYTES = 32;
|
|
20
|
+
/**
|
|
21
|
+
* A trigger's signing key, derived from its secret for its scheme: the
|
|
22
|
+
* secret's UTF-8 bytes for `hmac-sha256`, the key a `whsec_…` secret
|
|
23
|
+
* encodes for `standard-webhooks`. A `KeyObject`, so a receiver that keeps
|
|
24
|
+
* it for a while holds no string of the secret, and nothing logs or
|
|
25
|
+
* serializes it by accident (`JSON.stringify` gives `{}`).
|
|
26
|
+
*/
|
|
27
|
+
export function inboundSigningKey(scheme, secret) {
|
|
28
|
+
const bytes = scheme.kind === 'standard-webhooks'
|
|
29
|
+
? parseWebhookSecret(secret)
|
|
30
|
+
: secret.length === 0
|
|
31
|
+
? null
|
|
32
|
+
: Buffer.from(secret, 'utf-8');
|
|
33
|
+
if (bytes === null)
|
|
34
|
+
return { kind: 'err', reason: 'secret-invalid' };
|
|
35
|
+
const key = createSecretKey(bytes);
|
|
36
|
+
// The key keeps its own copy.
|
|
37
|
+
bytes.fill(0);
|
|
38
|
+
return { kind: 'ok', key };
|
|
39
|
+
}
|
|
40
|
+
/** Verify a received request against its trigger's signature scheme. */
|
|
41
|
+
export function verifyInboundSignature(input) {
|
|
42
|
+
const { scheme } = input;
|
|
43
|
+
if (scheme.kind === 'standard-webhooks') {
|
|
44
|
+
const verified = verifyWebhookWith({
|
|
45
|
+
headers: input.headers,
|
|
46
|
+
body: input.body,
|
|
47
|
+
...(scheme.toleranceSeconds !== undefined && {
|
|
48
|
+
toleranceSeconds: scheme.toleranceSeconds,
|
|
49
|
+
}),
|
|
50
|
+
...(input.now !== undefined && { now: input.now }),
|
|
51
|
+
}, input.secret);
|
|
52
|
+
if (verified.kind === 'ok')
|
|
53
|
+
return { kind: 'ok', signedId: verified.id };
|
|
54
|
+
switch (verified.reason) {
|
|
55
|
+
case 'missing-headers':
|
|
56
|
+
return { kind: 'err', reason: 'signature-missing' };
|
|
57
|
+
case 'timestamp-out-of-tolerance':
|
|
58
|
+
return { kind: 'err', reason: 'stale' };
|
|
59
|
+
case 'invalid-secret':
|
|
60
|
+
return { kind: 'err', reason: 'secret-invalid' };
|
|
61
|
+
default:
|
|
62
|
+
return { kind: 'err', reason: 'signature-invalid' };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const { secret } = input;
|
|
66
|
+
if (typeof secret === 'string' && secret.length === 0) {
|
|
67
|
+
return { kind: 'err', reason: 'secret-invalid' };
|
|
68
|
+
}
|
|
69
|
+
const raw = readHeader(input.headers, scheme.header.toLowerCase());
|
|
70
|
+
if (raw === undefined)
|
|
71
|
+
return { kind: 'err', reason: 'signature-missing' };
|
|
72
|
+
const prefix = scheme.prefix ?? '';
|
|
73
|
+
if (!raw.startsWith(prefix))
|
|
74
|
+
return { kind: 'err', reason: 'signature-invalid' };
|
|
75
|
+
const provided = decodeDigest(raw.slice(prefix.length).trim(), scheme.encoding);
|
|
76
|
+
if (provided === undefined)
|
|
77
|
+
return { kind: 'err', reason: 'signature-invalid' };
|
|
78
|
+
const expected = createHmac('sha256', typeof secret === 'string' ? Buffer.from(secret, 'utf-8') : secret)
|
|
79
|
+
.update(typeof input.body === 'string' ? Buffer.from(input.body, 'utf-8') : input.body)
|
|
80
|
+
.digest();
|
|
81
|
+
return timingSafeEqual(provided, expected)
|
|
82
|
+
? { kind: 'ok' }
|
|
83
|
+
: { kind: 'err', reason: 'signature-invalid' };
|
|
84
|
+
}
|
|
85
|
+
/** A 32-byte digest from its `hex` or `base64` form; `undefined` when it isn't one. */
|
|
86
|
+
function decodeDigest(text, encoding) {
|
|
87
|
+
if (encoding === 'hex') {
|
|
88
|
+
return /^[0-9a-fA-F]{64}$/.test(text) ? Buffer.from(text, 'hex') : undefined;
|
|
89
|
+
}
|
|
90
|
+
// Base64 of 32 bytes is 44 characters with one `=` of padding.
|
|
91
|
+
if (!/^[A-Za-z0-9+/]{43}=$/.test(text))
|
|
92
|
+
return undefined;
|
|
93
|
+
const bytes = Buffer.from(text, 'base64');
|
|
94
|
+
return bytes.length === SHA256_BYTES ? bytes : undefined;
|
|
95
|
+
}
|
|
96
|
+
//# sourceMappingURL=inbound-signature.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"inbound-signature.js","sourceRoot":"","sources":["../src/inbound-signature.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,8 @@ export type { Ed25519KeyPair } from './ed25519.js';
|
|
|
3
3
|
export { hmacSha256Sign, hmacSha256Verify } from './hmac.js';
|
|
4
4
|
export { DEFAULT_WEBHOOK_TOLERANCE_SECONDS, generateWebhookSecret, isStrongWebhookSecret, signWebhook, verifyWebhook, WEBHOOK_HEADERS, WEBHOOK_SECRET_MIN_BYTES, WEBHOOK_SECRET_PREFIX, webhookHeaders, } from './webhook.js';
|
|
5
5
|
export type { SignWebhookInput, VerifyWebhookFailure, VerifyWebhookInput, VerifyWebhookResult, WebhookRequestHeaders, } from './webhook.js';
|
|
6
|
+
export { inboundSigningKey, verifyInboundSignature } from './inbound-signature.js';
|
|
7
|
+
export type { VerifyInboundSignatureFailure, VerifyInboundSignatureInput, VerifyInboundSignatureResult, } from './inbound-signature.js';
|
|
6
8
|
export { createInMemorySigningKeyBinding } from './binding.js';
|
|
7
9
|
export { EXPORT_SIGNING_ALGORITHMS, createEcdsaP256ExportSigner, createEd25519ExportSigner, createExportSignerFromPem, ecdsaDerToP1363, exportSigningKey, } from './export-signing.js';
|
|
8
10
|
export { parseRetiredExportKeys, withRetiredExportKeys } from './retired-export-keys.js';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,OAAO,EACL,uBAAuB,EACvB,sBAAsB,EACtB,qBAAqB,EACrB,kBAAkB,EAClB,oBAAoB,EACpB,iBAAiB,EACjB,yBAAyB,EACzB,sBAAsB,EACtB,wBAAwB,EACxB,qBAAqB,EACrB,WAAW,EACX,aAAa,GACd,MAAM,cAAc,CAAC;AACtB,YAAY,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAEnD,OAAO,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAE7D,OAAO,EACL,iCAAiC,EACjC,qBAAqB,EACrB,qBAAqB,EACrB,WAAW,EACX,aAAa,EACb,eAAe,EACf,wBAAwB,EACxB,qBAAqB,EACrB,cAAc,GACf,MAAM,cAAc,CAAC;AACtB,YAAY,EACV,gBAAgB,EAChB,oBAAoB,EACpB,kBAAkB,EAClB,mBAAmB,EACnB,qBAAqB,GACtB,MAAM,cAAc,CAAC;AAEtB,OAAO,EAAE,+BAA+B,EAAE,MAAM,cAAc,CAAC;AAE/D,OAAO,EACL,yBAAyB,EACzB,2BAA2B,EAC3B,yBAAyB,EACzB,yBAAyB,EACzB,eAAe,EACf,gBAAgB,GACjB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,0BAA0B,CAAC;AACzF,YAAY,EACV,eAAe,EACf,sBAAsB,EACtB,oBAAoB,EACpB,kBAAkB,EAClB,gBAAgB,GACjB,MAAM,qBAAqB,CAAC;AAC7B,YAAY,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAC;AAE9F,YAAY,EACV,WAAW,EACX,iBAAiB,EACjB,uBAAuB,EACvB,sBAAsB,GACvB,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,qBAAqB,EAAE,MAAM,eAAe,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,OAAO,EACL,uBAAuB,EACvB,sBAAsB,EACtB,qBAAqB,EACrB,kBAAkB,EAClB,oBAAoB,EACpB,iBAAiB,EACjB,yBAAyB,EACzB,sBAAsB,EACtB,wBAAwB,EACxB,qBAAqB,EACrB,WAAW,EACX,aAAa,GACd,MAAM,cAAc,CAAC;AACtB,YAAY,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAEnD,OAAO,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAE7D,OAAO,EACL,iCAAiC,EACjC,qBAAqB,EACrB,qBAAqB,EACrB,WAAW,EACX,aAAa,EACb,eAAe,EACf,wBAAwB,EACxB,qBAAqB,EACrB,cAAc,GACf,MAAM,cAAc,CAAC;AACtB,YAAY,EACV,gBAAgB,EAChB,oBAAoB,EACpB,kBAAkB,EAClB,mBAAmB,EACnB,qBAAqB,GACtB,MAAM,cAAc,CAAC;AAEtB,OAAO,EAAE,iBAAiB,EAAE,sBAAsB,EAAE,MAAM,wBAAwB,CAAC;AACnF,YAAY,EACV,6BAA6B,EAC7B,2BAA2B,EAC3B,4BAA4B,GAC7B,MAAM,wBAAwB,CAAC;AAEhC,OAAO,EAAE,+BAA+B,EAAE,MAAM,cAAc,CAAC;AAE/D,OAAO,EACL,yBAAyB,EACzB,2BAA2B,EAC3B,yBAAyB,EACzB,yBAAyB,EACzB,eAAe,EACf,gBAAgB,GACjB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,0BAA0B,CAAC;AACzF,YAAY,EACV,eAAe,EACf,sBAAsB,EACtB,oBAAoB,EACpB,kBAAkB,EAClB,gBAAgB,GACjB,MAAM,qBAAqB,CAAC;AAC7B,YAAY,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAC;AAE9F,YAAY,EACV,WAAW,EACX,iBAAiB,EACjB,uBAAuB,EACvB,sBAAsB,GACvB,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,qBAAqB,EAAE,MAAM,eAAe,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
export { ED25519_SIGNATURE_BYTES, generateEd25519KeyPair, parsePrivateKeyBase64, parsePrivateKeyPem, parsePublicKeyBase64, parsePublicKeyPem, serializePrivateKeyBase64, serializePrivateKeyPem, serializePublicKeyBase64, serializePublicKeyPem, signEd25519, verifyEd25519, } from './ed25519.js';
|
|
4
4
|
export { hmacSha256Sign, hmacSha256Verify } from './hmac.js';
|
|
5
5
|
export { DEFAULT_WEBHOOK_TOLERANCE_SECONDS, generateWebhookSecret, isStrongWebhookSecret, signWebhook, verifyWebhook, WEBHOOK_HEADERS, WEBHOOK_SECRET_MIN_BYTES, WEBHOOK_SECRET_PREFIX, webhookHeaders, } from './webhook.js';
|
|
6
|
+
export { inboundSigningKey, verifyInboundSignature } from './inbound-signature.js';
|
|
6
7
|
export { createInMemorySigningKeyBinding } from './binding.js';
|
|
7
8
|
export { EXPORT_SIGNING_ALGORITHMS, createEcdsaP256ExportSigner, createEd25519ExportSigner, createExportSignerFromPem, ecdsaDerToP1363, exportSigningKey, } from './export-signing.js';
|
|
8
9
|
export { parseRetiredExportKeys, withRetiredExportKeys } from './retired-export-keys.js';
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,sCAAsC;AACtC,iCAAiC;AAEjC,OAAO,EACL,uBAAuB,EACvB,sBAAsB,EACtB,qBAAqB,EACrB,kBAAkB,EAClB,oBAAoB,EACpB,iBAAiB,EACjB,yBAAyB,EACzB,sBAAsB,EACtB,wBAAwB,EACxB,qBAAqB,EACrB,WAAW,EACX,aAAa,GACd,MAAM,cAAc,CAAC;AAGtB,OAAO,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAE7D,OAAO,EACL,iCAAiC,EACjC,qBAAqB,EACrB,qBAAqB,EACrB,WAAW,EACX,aAAa,EACb,eAAe,EACf,wBAAwB,EACxB,qBAAqB,EACrB,cAAc,GACf,MAAM,cAAc,CAAC;AAStB,OAAO,EAAE,+BAA+B,EAAE,MAAM,cAAc,CAAC;AAE/D,OAAO,EACL,yBAAyB,EACzB,2BAA2B,EAC3B,yBAAyB,EACzB,yBAAyB,EACzB,eAAe,EACf,gBAAgB,GACjB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,0BAA0B,CAAC;AAiBzF,OAAO,EAAE,qBAAqB,EAAE,MAAM,eAAe,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,sCAAsC;AACtC,iCAAiC;AAEjC,OAAO,EACL,uBAAuB,EACvB,sBAAsB,EACtB,qBAAqB,EACrB,kBAAkB,EAClB,oBAAoB,EACpB,iBAAiB,EACjB,yBAAyB,EACzB,sBAAsB,EACtB,wBAAwB,EACxB,qBAAqB,EACrB,WAAW,EACX,aAAa,GACd,MAAM,cAAc,CAAC;AAGtB,OAAO,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAE7D,OAAO,EACL,iCAAiC,EACjC,qBAAqB,EACrB,qBAAqB,EACrB,WAAW,EACX,aAAa,EACb,eAAe,EACf,wBAAwB,EACxB,qBAAqB,EACrB,cAAc,GACf,MAAM,cAAc,CAAC;AAStB,OAAO,EAAE,iBAAiB,EAAE,sBAAsB,EAAE,MAAM,wBAAwB,CAAC;AAOnF,OAAO,EAAE,+BAA+B,EAAE,MAAM,cAAc,CAAC;AAE/D,OAAO,EACL,yBAAyB,EACzB,2BAA2B,EAC3B,yBAAyB,EACzB,yBAAyB,EACzB,eAAe,EACf,gBAAgB,GACjB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,0BAA0B,CAAC;AAiBzF,OAAO,EAAE,qBAAqB,EAAE,MAAM,eAAe,CAAC"}
|
package/dist/webhook.d.ts
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook signatures in the Standard Webhooks format
|
|
3
|
+
* (https://www.standardwebhooks.com), symmetric variant `v1`:
|
|
4
|
+
*
|
|
5
|
+
* webhook-id: the message id (the same on every retry)
|
|
6
|
+
* webhook-timestamp: Unix seconds when the message was signed
|
|
7
|
+
* webhook-signature: `v1,<base64 HMAC-SHA256>`, space-separated when
|
|
8
|
+
* several secrets sign (secret rotation)
|
|
9
|
+
*
|
|
10
|
+
* The signed content is `${id}.${timestamp}.${body}`, with `body` the raw
|
|
11
|
+
* request body exactly as sent. Secrets are `whsec_` + base64 of the key
|
|
12
|
+
* bytes. A receiver can verify with this module or with any Standard
|
|
13
|
+
* Webhooks library.
|
|
14
|
+
*/
|
|
15
|
+
import { type KeyObject } from 'node:crypto';
|
|
1
16
|
import type { Result } from '@kindgi/types';
|
|
2
17
|
import type { MalformedKeyError } from './errors.js';
|
|
3
18
|
/** Prefix of a webhook signing secret. */
|
|
@@ -71,4 +86,14 @@ export type VerifyWebhookResult = {
|
|
|
71
86
|
* constant-time. On `ok`, deduplicate on `id` — delivery is at least once.
|
|
72
87
|
*/
|
|
73
88
|
export declare function verifyWebhook(input: VerifyWebhookInput): VerifyWebhookResult;
|
|
89
|
+
/**
|
|
90
|
+
* {@link verifyWebhook} with the secret as given, or as the key already
|
|
91
|
+
* decoded from it (a `KeyObject`, which a receiver can keep without holding
|
|
92
|
+
* the secret as a string). Within the package.
|
|
93
|
+
*/
|
|
94
|
+
export declare function verifyWebhookWith(input: Omit<VerifyWebhookInput, 'secret'>, secret: string | KeyObject): VerifyWebhookResult;
|
|
95
|
+
/** Key bytes of a `whsec_…` (or bare base64) secret; `null` when malformed. Within the package. */
|
|
96
|
+
export declare function parseWebhookSecret(secret: string): Buffer | null;
|
|
97
|
+
/** A header's first value (`name` in lower case, matched case-insensitively); `undefined` when absent or empty. */
|
|
98
|
+
export declare function readHeader(headers: WebhookRequestHeaders, name: string): string | undefined;
|
|
74
99
|
//# sourceMappingURL=webhook.d.ts.map
|
package/dist/webhook.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"webhook.d.ts","sourceRoot":"","sources":["../src/webhook.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"webhook.d.ts","sourceRoot":"","sources":["../src/webhook.ts"],"names":[],"mappings":"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"}
|
package/dist/webhook.js
CHANGED
|
@@ -39,7 +39,7 @@ export function generateWebhookSecret() {
|
|
|
39
39
|
* base64) of at least {@link WEBHOOK_SECRET_MIN_BYTES} bytes.
|
|
40
40
|
*/
|
|
41
41
|
export function isStrongWebhookSecret(secret) {
|
|
42
|
-
const key =
|
|
42
|
+
const key = parseWebhookSecret(secret);
|
|
43
43
|
return key !== null && key.length >= WEBHOOK_SECRET_MIN_BYTES;
|
|
44
44
|
}
|
|
45
45
|
/**
|
|
@@ -58,7 +58,7 @@ export function signWebhook(input) {
|
|
|
58
58
|
const content = signedContent(input.id, String(input.timestamp), input.body);
|
|
59
59
|
const signatures = [];
|
|
60
60
|
for (const secret of secrets) {
|
|
61
|
-
const key =
|
|
61
|
+
const key = parseWebhookSecret(secret);
|
|
62
62
|
if (key === null)
|
|
63
63
|
return malformedKey('webhook secret must be `whsec_` + base64 key bytes');
|
|
64
64
|
signatures.push(`${SIGNATURE_VERSION},${hmac(key, content).toString('base64')}`);
|
|
@@ -85,6 +85,14 @@ export function webhookHeaders(input) {
|
|
|
85
85
|
* constant-time. On `ok`, deduplicate on `id` — delivery is at least once.
|
|
86
86
|
*/
|
|
87
87
|
export function verifyWebhook(input) {
|
|
88
|
+
return verifyWebhookWith(input, input.secret);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* {@link verifyWebhook} with the secret as given, or as the key already
|
|
92
|
+
* decoded from it (a `KeyObject`, which a receiver can keep without holding
|
|
93
|
+
* the secret as a string). Within the package.
|
|
94
|
+
*/
|
|
95
|
+
export function verifyWebhookWith(input, secret) {
|
|
88
96
|
const id = readHeader(input.headers, WEBHOOK_HEADERS.id);
|
|
89
97
|
const timestampRaw = readHeader(input.headers, WEBHOOK_HEADERS.timestamp);
|
|
90
98
|
const signatureHeader = readHeader(input.headers, WEBHOOK_HEADERS.signature);
|
|
@@ -99,7 +107,7 @@ export function verifyWebhook(input) {
|
|
|
99
107
|
if (Math.abs(nowSeconds - timestamp) > tolerance) {
|
|
100
108
|
return { kind: 'err', reason: 'timestamp-out-of-tolerance' };
|
|
101
109
|
}
|
|
102
|
-
const key =
|
|
110
|
+
const key = typeof secret === 'string' ? parseWebhookSecret(secret) : secret;
|
|
103
111
|
if (key === null)
|
|
104
112
|
return { kind: 'err', reason: 'invalid-secret' };
|
|
105
113
|
const expected = hmac(key, signedContent(id, timestampRaw, input.body));
|
|
@@ -122,8 +130,8 @@ function signedContent(id, timestamp, body) {
|
|
|
122
130
|
function hmac(key, content) {
|
|
123
131
|
return createHmac('sha256', key).update(content).digest();
|
|
124
132
|
}
|
|
125
|
-
/** Key bytes of a `whsec_…` (or bare base64) secret; `null` when malformed. */
|
|
126
|
-
function
|
|
133
|
+
/** Key bytes of a `whsec_…` (or bare base64) secret; `null` when malformed. Within the package. */
|
|
134
|
+
export function parseWebhookSecret(secret) {
|
|
127
135
|
const encoded = secret.startsWith(WEBHOOK_SECRET_PREFIX)
|
|
128
136
|
? secret.slice(WEBHOOK_SECRET_PREFIX.length)
|
|
129
137
|
: secret;
|
|
@@ -132,7 +140,8 @@ function parseSecret(secret) {
|
|
|
132
140
|
const key = Buffer.from(encoded, 'base64');
|
|
133
141
|
return key.length === 0 ? null : key;
|
|
134
142
|
}
|
|
135
|
-
|
|
143
|
+
/** A header's first value (`name` in lower case, matched case-insensitively); `undefined` when absent or empty. */
|
|
144
|
+
export function readHeader(headers, name) {
|
|
136
145
|
if (typeof headers.get === 'function') {
|
|
137
146
|
const value = headers.get(name);
|
|
138
147
|
return value === null || value.length === 0 ? undefined : value;
|
package/dist/webhook.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"webhook.js","sourceRoot":"","sources":["../src/webhook.ts"],"names":[],"mappings":"AAAA,sCAAsC;AACtC,iCAAiC;AAEjC;;;;;;;;;;;;;GAaG;AAEH,OAAO,
|
|
1
|
+
{"version":3,"file":"webhook.js","sourceRoot":"","sources":["../src/webhook.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kindgi/crypto",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7-rc.0",
|
|
4
4
|
"description": "Kindgi™ cryptographic primitives. Ed25519 signatures for asymmetric use (signed audit bundles, provenance exports, deployment attestations) and HMAC-SHA256 for symmetric use (webhook payloads). Includes SigningKeyBinding interface + in-memory reference impl, PEM/base64 key format converters, and typed error interfaces. Pure Node built-ins, zero runtime deps. Deployments plug their own key store (KMS, Vault, env-var) via SigningKeyBinding.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"README.md"
|
|
29
29
|
],
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@kindgi/types": "0.1.
|
|
31
|
+
"@kindgi/types": "0.1.7-rc.0"
|
|
32
32
|
},
|
|
33
33
|
"engines": {
|
|
34
34
|
"node": ">=22.12.0"
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (C) 2026 Kindgi Inc.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Verifying a request a webhook trigger receives, in the scheme its sender
|
|
6
|
+
* signs with (`WebhookSignatureScheme`, `@kindgi/types`):
|
|
7
|
+
*
|
|
8
|
+
* - `hmac-sha256`: HMAC-SHA256 of the raw body under the secret's UTF-8
|
|
9
|
+
* bytes, read from one header (`hex` or `base64`, after an optional
|
|
10
|
+
* prefix). WooCommerce, Shopify, GitHub and Drupal's Webhooks module sign
|
|
11
|
+
* this way.
|
|
12
|
+
* - `standard-webhooks`: {@link verifyWebhook}.
|
|
13
|
+
*
|
|
14
|
+
* The comparison is constant-time. A missing header, a malformed one and a
|
|
15
|
+
* wrong signature are told apart only in the result's `reason`, for the
|
|
16
|
+
* receiver's own record: a receiver answers all of them the same way.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { type KeyObject, createHmac, createSecretKey, timingSafeEqual } from 'node:crypto';
|
|
20
|
+
|
|
21
|
+
import type { WebhookSignatureScheme } from '@kindgi/types';
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
type WebhookRequestHeaders,
|
|
25
|
+
parseWebhookSecret,
|
|
26
|
+
readHeader,
|
|
27
|
+
verifyWebhookWith,
|
|
28
|
+
} from './webhook.js';
|
|
29
|
+
|
|
30
|
+
const SHA256_BYTES = 32;
|
|
31
|
+
|
|
32
|
+
export interface VerifyInboundSignatureInput {
|
|
33
|
+
readonly scheme: WebhookSignatureScheme;
|
|
34
|
+
/**
|
|
35
|
+
* The shared secret (its UTF-8 bytes for `hmac-sha256`; `whsec_…` for
|
|
36
|
+
* `standard-webhooks`), or the key {@link inboundSigningKey} derived
|
|
37
|
+
* from it for the same scheme.
|
|
38
|
+
*/
|
|
39
|
+
readonly secret: string | KeyObject;
|
|
40
|
+
readonly headers: WebhookRequestHeaders;
|
|
41
|
+
/** The raw request body, exactly as received — never a re-serialized JSON value. */
|
|
42
|
+
readonly body: string | Uint8Array;
|
|
43
|
+
/** Clock in milliseconds, for a scheme with a timestamp; default `Date.now`. */
|
|
44
|
+
readonly now?: () => number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Why a request isn't verified:
|
|
49
|
+
* - `signature-missing`: the scheme's header (or headers) isn't there;
|
|
50
|
+
* - `signature-invalid`: present, but malformed or not matching;
|
|
51
|
+
* - `stale`: the signed timestamp is outside the tolerance;
|
|
52
|
+
* - `secret-invalid`: the secret can't be used with the scheme (empty, or
|
|
53
|
+
* not a `whsec_` key for `standard-webhooks`).
|
|
54
|
+
*/
|
|
55
|
+
export type VerifyInboundSignatureFailure =
|
|
56
|
+
| 'signature-missing'
|
|
57
|
+
| 'signature-invalid'
|
|
58
|
+
| 'stale'
|
|
59
|
+
| 'secret-invalid';
|
|
60
|
+
|
|
61
|
+
export type VerifyInboundSignatureResult =
|
|
62
|
+
| {
|
|
63
|
+
readonly kind: 'ok';
|
|
64
|
+
/** The id the scheme itself signs (`webhook-id`), when it signs one. */
|
|
65
|
+
readonly signedId?: string;
|
|
66
|
+
}
|
|
67
|
+
| { readonly kind: 'err'; readonly reason: VerifyInboundSignatureFailure };
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A trigger's signing key, derived from its secret for its scheme: the
|
|
71
|
+
* secret's UTF-8 bytes for `hmac-sha256`, the key a `whsec_…` secret
|
|
72
|
+
* encodes for `standard-webhooks`. A `KeyObject`, so a receiver that keeps
|
|
73
|
+
* it for a while holds no string of the secret, and nothing logs or
|
|
74
|
+
* serializes it by accident (`JSON.stringify` gives `{}`).
|
|
75
|
+
*/
|
|
76
|
+
export function inboundSigningKey(
|
|
77
|
+
scheme: WebhookSignatureScheme,
|
|
78
|
+
secret: string,
|
|
79
|
+
):
|
|
80
|
+
| { readonly kind: 'ok'; readonly key: KeyObject }
|
|
81
|
+
| { readonly kind: 'err'; readonly reason: 'secret-invalid' } {
|
|
82
|
+
const bytes =
|
|
83
|
+
scheme.kind === 'standard-webhooks'
|
|
84
|
+
? parseWebhookSecret(secret)
|
|
85
|
+
: secret.length === 0
|
|
86
|
+
? null
|
|
87
|
+
: Buffer.from(secret, 'utf-8');
|
|
88
|
+
if (bytes === null) return { kind: 'err', reason: 'secret-invalid' };
|
|
89
|
+
const key = createSecretKey(bytes);
|
|
90
|
+
// The key keeps its own copy.
|
|
91
|
+
bytes.fill(0);
|
|
92
|
+
return { kind: 'ok', key };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Verify a received request against its trigger's signature scheme. */
|
|
96
|
+
export function verifyInboundSignature(
|
|
97
|
+
input: VerifyInboundSignatureInput,
|
|
98
|
+
): VerifyInboundSignatureResult {
|
|
99
|
+
const { scheme } = input;
|
|
100
|
+
if (scheme.kind === 'standard-webhooks') {
|
|
101
|
+
const verified = verifyWebhookWith(
|
|
102
|
+
{
|
|
103
|
+
headers: input.headers,
|
|
104
|
+
body: input.body,
|
|
105
|
+
...(scheme.toleranceSeconds !== undefined && {
|
|
106
|
+
toleranceSeconds: scheme.toleranceSeconds,
|
|
107
|
+
}),
|
|
108
|
+
...(input.now !== undefined && { now: input.now }),
|
|
109
|
+
},
|
|
110
|
+
input.secret,
|
|
111
|
+
);
|
|
112
|
+
if (verified.kind === 'ok') return { kind: 'ok', signedId: verified.id };
|
|
113
|
+
switch (verified.reason) {
|
|
114
|
+
case 'missing-headers':
|
|
115
|
+
return { kind: 'err', reason: 'signature-missing' };
|
|
116
|
+
case 'timestamp-out-of-tolerance':
|
|
117
|
+
return { kind: 'err', reason: 'stale' };
|
|
118
|
+
case 'invalid-secret':
|
|
119
|
+
return { kind: 'err', reason: 'secret-invalid' };
|
|
120
|
+
default:
|
|
121
|
+
return { kind: 'err', reason: 'signature-invalid' };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const { secret } = input;
|
|
126
|
+
if (typeof secret === 'string' && secret.length === 0) {
|
|
127
|
+
return { kind: 'err', reason: 'secret-invalid' };
|
|
128
|
+
}
|
|
129
|
+
const raw = readHeader(input.headers, scheme.header.toLowerCase());
|
|
130
|
+
if (raw === undefined) return { kind: 'err', reason: 'signature-missing' };
|
|
131
|
+
const prefix = scheme.prefix ?? '';
|
|
132
|
+
if (!raw.startsWith(prefix)) return { kind: 'err', reason: 'signature-invalid' };
|
|
133
|
+
const provided = decodeDigest(raw.slice(prefix.length).trim(), scheme.encoding);
|
|
134
|
+
if (provided === undefined) return { kind: 'err', reason: 'signature-invalid' };
|
|
135
|
+
const expected = createHmac(
|
|
136
|
+
'sha256',
|
|
137
|
+
typeof secret === 'string' ? Buffer.from(secret, 'utf-8') : secret,
|
|
138
|
+
)
|
|
139
|
+
.update(typeof input.body === 'string' ? Buffer.from(input.body, 'utf-8') : input.body)
|
|
140
|
+
.digest();
|
|
141
|
+
return timingSafeEqual(provided, expected)
|
|
142
|
+
? { kind: 'ok' }
|
|
143
|
+
: { kind: 'err', reason: 'signature-invalid' };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** A 32-byte digest from its `hex` or `base64` form; `undefined` when it isn't one. */
|
|
147
|
+
function decodeDigest(text: string, encoding: 'hex' | 'base64'): Buffer | undefined {
|
|
148
|
+
if (encoding === 'hex') {
|
|
149
|
+
return /^[0-9a-fA-F]{64}$/.test(text) ? Buffer.from(text, 'hex') : undefined;
|
|
150
|
+
}
|
|
151
|
+
// Base64 of 32 bytes is 44 characters with one `=` of padding.
|
|
152
|
+
if (!/^[A-Za-z0-9+/]{43}=$/.test(text)) return undefined;
|
|
153
|
+
const bytes = Buffer.from(text, 'base64');
|
|
154
|
+
return bytes.length === SHA256_BYTES ? bytes : undefined;
|
|
155
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -38,6 +38,13 @@ export type {
|
|
|
38
38
|
WebhookRequestHeaders,
|
|
39
39
|
} from './webhook.js';
|
|
40
40
|
|
|
41
|
+
export { inboundSigningKey, verifyInboundSignature } from './inbound-signature.js';
|
|
42
|
+
export type {
|
|
43
|
+
VerifyInboundSignatureFailure,
|
|
44
|
+
VerifyInboundSignatureInput,
|
|
45
|
+
VerifyInboundSignatureResult,
|
|
46
|
+
} from './inbound-signature.js';
|
|
47
|
+
|
|
41
48
|
export { createInMemorySigningKeyBinding } from './binding.js';
|
|
42
49
|
|
|
43
50
|
export {
|
package/src/webhook.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* Webhooks library.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
19
|
+
import { type KeyObject, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
20
20
|
|
|
21
21
|
import type { Result } from '@kindgi/types';
|
|
22
22
|
|
|
@@ -52,7 +52,7 @@ export function generateWebhookSecret(): string {
|
|
|
52
52
|
* base64) of at least {@link WEBHOOK_SECRET_MIN_BYTES} bytes.
|
|
53
53
|
*/
|
|
54
54
|
export function isStrongWebhookSecret(secret: string): boolean {
|
|
55
|
-
const key =
|
|
55
|
+
const key = parseWebhookSecret(secret);
|
|
56
56
|
return key !== null && key.length >= WEBHOOK_SECRET_MIN_BYTES;
|
|
57
57
|
}
|
|
58
58
|
|
|
@@ -86,7 +86,7 @@ export function signWebhook(input: SignWebhookInput): Result<string, MalformedKe
|
|
|
86
86
|
const content = signedContent(input.id, String(input.timestamp), input.body);
|
|
87
87
|
const signatures: string[] = [];
|
|
88
88
|
for (const secret of secrets) {
|
|
89
|
-
const key =
|
|
89
|
+
const key = parseWebhookSecret(secret);
|
|
90
90
|
if (key === null) return malformedKey('webhook secret must be `whsec_` + base64 key bytes');
|
|
91
91
|
signatures.push(`${SIGNATURE_VERSION},${hmac(key, content).toString('base64')}`);
|
|
92
92
|
}
|
|
@@ -142,6 +142,18 @@ export type VerifyWebhookResult =
|
|
|
142
142
|
* constant-time. On `ok`, deduplicate on `id` — delivery is at least once.
|
|
143
143
|
*/
|
|
144
144
|
export function verifyWebhook(input: VerifyWebhookInput): VerifyWebhookResult {
|
|
145
|
+
return verifyWebhookWith(input, input.secret);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* {@link verifyWebhook} with the secret as given, or as the key already
|
|
150
|
+
* decoded from it (a `KeyObject`, which a receiver can keep without holding
|
|
151
|
+
* the secret as a string). Within the package.
|
|
152
|
+
*/
|
|
153
|
+
export function verifyWebhookWith(
|
|
154
|
+
input: Omit<VerifyWebhookInput, 'secret'>,
|
|
155
|
+
secret: string | KeyObject,
|
|
156
|
+
): VerifyWebhookResult {
|
|
145
157
|
const id = readHeader(input.headers, WEBHOOK_HEADERS.id);
|
|
146
158
|
const timestampRaw = readHeader(input.headers, WEBHOOK_HEADERS.timestamp);
|
|
147
159
|
const signatureHeader = readHeader(input.headers, WEBHOOK_HEADERS.signature);
|
|
@@ -155,7 +167,7 @@ export function verifyWebhook(input: VerifyWebhookInput): VerifyWebhookResult {
|
|
|
155
167
|
if (Math.abs(nowSeconds - timestamp) > tolerance) {
|
|
156
168
|
return { kind: 'err', reason: 'timestamp-out-of-tolerance' };
|
|
157
169
|
}
|
|
158
|
-
const key =
|
|
170
|
+
const key = typeof secret === 'string' ? parseWebhookSecret(secret) : secret;
|
|
159
171
|
if (key === null) return { kind: 'err', reason: 'invalid-secret' };
|
|
160
172
|
|
|
161
173
|
const expected = hmac(key, signedContent(id, timestampRaw, input.body));
|
|
@@ -176,12 +188,12 @@ function signedContent(id: string, timestamp: string, body: string | Uint8Array)
|
|
|
176
188
|
return Buffer.concat([prefix, bodyBytes]);
|
|
177
189
|
}
|
|
178
190
|
|
|
179
|
-
function hmac(key: Buffer, content: Buffer): Buffer {
|
|
191
|
+
function hmac(key: Buffer | KeyObject, content: Buffer): Buffer {
|
|
180
192
|
return createHmac('sha256', key).update(content).digest();
|
|
181
193
|
}
|
|
182
194
|
|
|
183
|
-
/** Key bytes of a `whsec_…` (or bare base64) secret; `null` when malformed. */
|
|
184
|
-
function
|
|
195
|
+
/** Key bytes of a `whsec_…` (or bare base64) secret; `null` when malformed. Within the package. */
|
|
196
|
+
export function parseWebhookSecret(secret: string): Buffer | null {
|
|
185
197
|
const encoded = secret.startsWith(WEBHOOK_SECRET_PREFIX)
|
|
186
198
|
? secret.slice(WEBHOOK_SECRET_PREFIX.length)
|
|
187
199
|
: secret;
|
|
@@ -190,7 +202,8 @@ function parseSecret(secret: string): Buffer | null {
|
|
|
190
202
|
return key.length === 0 ? null : key;
|
|
191
203
|
}
|
|
192
204
|
|
|
193
|
-
|
|
205
|
+
/** A header's first value (`name` in lower case, matched case-insensitively); `undefined` when absent or empty. */
|
|
206
|
+
export function readHeader(headers: WebhookRequestHeaders, name: string): string | undefined {
|
|
194
207
|
if (typeof (headers as { get?: unknown }).get === 'function') {
|
|
195
208
|
const value = (headers as { get(name: string): string | null }).get(name);
|
|
196
209
|
return value === null || value.length === 0 ? undefined : value;
|