@kindgi/crypto 0.0.0-bootstrap.0 → 0.1.1
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/LICENSE +201 -0
- package/README.md +112 -2
- package/dist/binding.d.ts +60 -0
- package/dist/binding.d.ts.map +1 -0
- package/dist/binding.js +37 -0
- package/dist/binding.js.map +1 -0
- package/dist/ed25519.d.ts +64 -0
- package/dist/ed25519.d.ts.map +1 -0
- package/dist/ed25519.js +169 -0
- package/dist/ed25519.js.map +1 -0
- package/dist/encoding.d.ts +40 -0
- package/dist/encoding.d.ts.map +1 -0
- package/dist/encoding.js +123 -0
- package/dist/encoding.js.map +1 -0
- package/dist/errors.d.ts +51 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +27 -0
- package/dist/errors.js.map +1 -0
- package/dist/hmac.d.ts +16 -0
- package/dist/hmac.d.ts.map +1 -0
- package/dist/hmac.js +59 -0
- package/dist/hmac.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/webhook.d.ts +74 -0
- package/dist/webhook.d.ts.map +1 -0
- package/dist/webhook.js +149 -0
- package/dist/webhook.js.map +1 -0
- package/package.json +48 -4
- package/src/binding.ts +103 -0
- package/src/ed25519.ts +228 -0
- package/src/encoding.ts +166 -0
- package/src/errors.ts +84 -0
- package/src/hmac.ts +62 -0
- package/src/index.ts +51 -0
- package/src/webhook.ts +205 -0
package/src/hmac.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (C) 2026 Kindgi Inc.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* HMAC-SHA256 message authentication. Symmetric: sender and receiver share
|
|
6
|
+
* a secret. Suited to signing webhook-style payloads — the receiver
|
|
7
|
+
* verifies with the same secret. Follows the common webhook convention
|
|
8
|
+
* of a hex-encoded signature over the raw payload bytes.
|
|
9
|
+
*
|
|
10
|
+
* Verify uses a constant-time comparison to prevent timing side channels
|
|
11
|
+
* from leaking the correct signature byte-by-byte.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Compute the HMAC-SHA256 of `message` under `secret`. Returns the
|
|
18
|
+
* signature hex-encoded (lowercase) — the wire form of the common
|
|
19
|
+
* "hex HMAC over the raw body" webhook convention.
|
|
20
|
+
*/
|
|
21
|
+
export function hmacSha256Sign(secret: string | Uint8Array, message: string | Uint8Array): string {
|
|
22
|
+
const secretBuf = normalizeSecret(secret);
|
|
23
|
+
const mac = createHmac('sha256', secretBuf);
|
|
24
|
+
mac.update(message);
|
|
25
|
+
return mac.digest('hex');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Verify an HMAC-SHA256 signature in constant time. Returns `false` on
|
|
30
|
+
* length mismatch or hex-decode failure (both are shape errors the caller
|
|
31
|
+
* should treat as authentication failure — a well-formed signer would
|
|
32
|
+
* never produce them, so distinguishing them from a genuine mismatch
|
|
33
|
+
* offers no operational value and leaks structural information to an
|
|
34
|
+
* attacker probing the endpoint).
|
|
35
|
+
*/
|
|
36
|
+
export function hmacSha256Verify(
|
|
37
|
+
secret: string | Uint8Array,
|
|
38
|
+
message: string | Uint8Array,
|
|
39
|
+
signature: string,
|
|
40
|
+
): boolean {
|
|
41
|
+
if (typeof signature !== 'string') return false;
|
|
42
|
+
const expected = hmacSha256Sign(secret, message);
|
|
43
|
+
// Length mismatch is a definitive no; short-circuit before allocating.
|
|
44
|
+
if (expected.length !== signature.length) return false;
|
|
45
|
+
// timingSafeEqual requires equal-length buffers. Both are 64-char hex
|
|
46
|
+
// (32-byte SHA-256), pre-verified above.
|
|
47
|
+
let expectedBuf: Buffer;
|
|
48
|
+
let providedBuf: Buffer;
|
|
49
|
+
try {
|
|
50
|
+
expectedBuf = Buffer.from(expected, 'hex');
|
|
51
|
+
providedBuf = Buffer.from(signature, 'hex');
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
if (expectedBuf.length !== providedBuf.length) return false;
|
|
56
|
+
return timingSafeEqual(expectedBuf, providedBuf);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function normalizeSecret(secret: string | Uint8Array): Buffer {
|
|
60
|
+
if (typeof secret === 'string') return Buffer.from(secret, 'utf-8');
|
|
61
|
+
return Buffer.from(secret);
|
|
62
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (C) 2026 Kindgi Inc.
|
|
3
|
+
|
|
4
|
+
export {
|
|
5
|
+
ED25519_SIGNATURE_BYTES,
|
|
6
|
+
generateEd25519KeyPair,
|
|
7
|
+
parsePrivateKeyBase64,
|
|
8
|
+
parsePrivateKeyPem,
|
|
9
|
+
parsePublicKeyBase64,
|
|
10
|
+
parsePublicKeyPem,
|
|
11
|
+
serializePrivateKeyBase64,
|
|
12
|
+
serializePrivateKeyPem,
|
|
13
|
+
serializePublicKeyBase64,
|
|
14
|
+
serializePublicKeyPem,
|
|
15
|
+
signEd25519,
|
|
16
|
+
verifyEd25519,
|
|
17
|
+
} from './ed25519.js';
|
|
18
|
+
export type { Ed25519KeyPair } from './ed25519.js';
|
|
19
|
+
|
|
20
|
+
export { hmacSha256Sign, hmacSha256Verify } from './hmac.js';
|
|
21
|
+
|
|
22
|
+
export {
|
|
23
|
+
DEFAULT_WEBHOOK_TOLERANCE_SECONDS,
|
|
24
|
+
generateWebhookSecret,
|
|
25
|
+
isStrongWebhookSecret,
|
|
26
|
+
signWebhook,
|
|
27
|
+
verifyWebhook,
|
|
28
|
+
WEBHOOK_HEADERS,
|
|
29
|
+
WEBHOOK_SECRET_MIN_BYTES,
|
|
30
|
+
WEBHOOK_SECRET_PREFIX,
|
|
31
|
+
webhookHeaders,
|
|
32
|
+
} from './webhook.js';
|
|
33
|
+
export type {
|
|
34
|
+
SignWebhookInput,
|
|
35
|
+
VerifyWebhookFailure,
|
|
36
|
+
VerifyWebhookInput,
|
|
37
|
+
VerifyWebhookResult,
|
|
38
|
+
WebhookRequestHeaders,
|
|
39
|
+
} from './webhook.js';
|
|
40
|
+
|
|
41
|
+
export { createInMemorySigningKeyBinding } from './binding.js';
|
|
42
|
+
export type { SigningAlgorithm, SigningKeyBinding, SigningKeyDescriptor } from './binding.js';
|
|
43
|
+
|
|
44
|
+
export type {
|
|
45
|
+
CryptoError,
|
|
46
|
+
MalformedKeyError,
|
|
47
|
+
MalformedSignatureError,
|
|
48
|
+
UnsupportedFormatError,
|
|
49
|
+
} from './errors.js';
|
|
50
|
+
|
|
51
|
+
export { ED25519_RAW_KEY_BYTES } from './encoding.js';
|
package/src/webhook.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (C) 2026 Kindgi Inc.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Webhook signatures in the Standard Webhooks format
|
|
6
|
+
* (https://www.standardwebhooks.com), symmetric variant `v1`:
|
|
7
|
+
*
|
|
8
|
+
* webhook-id: the message id (the same on every retry)
|
|
9
|
+
* webhook-timestamp: Unix seconds when the message was signed
|
|
10
|
+
* webhook-signature: `v1,<base64 HMAC-SHA256>`, space-separated when
|
|
11
|
+
* several secrets sign (secret rotation)
|
|
12
|
+
*
|
|
13
|
+
* The signed content is `${id}.${timestamp}.${body}`, with `body` the raw
|
|
14
|
+
* request body exactly as sent. Secrets are `whsec_` + base64 of the key
|
|
15
|
+
* bytes. A receiver can verify with this module or with any Standard
|
|
16
|
+
* Webhooks library.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
20
|
+
|
|
21
|
+
import type { Result } from '@kindgi/types';
|
|
22
|
+
|
|
23
|
+
import { malformedKey } from './errors.js';
|
|
24
|
+
import type { MalformedKeyError } from './errors.js';
|
|
25
|
+
|
|
26
|
+
/** Prefix of a webhook signing secret. */
|
|
27
|
+
export const WEBHOOK_SECRET_PREFIX = 'whsec_';
|
|
28
|
+
|
|
29
|
+
/** The three request headers a signed webhook carries. */
|
|
30
|
+
export const WEBHOOK_HEADERS = {
|
|
31
|
+
id: 'webhook-id',
|
|
32
|
+
timestamp: 'webhook-timestamp',
|
|
33
|
+
signature: 'webhook-signature',
|
|
34
|
+
} as const;
|
|
35
|
+
|
|
36
|
+
/** How far a message's timestamp may be from the receiver's clock (seconds). */
|
|
37
|
+
export const DEFAULT_WEBHOOK_TOLERANCE_SECONDS = 300;
|
|
38
|
+
|
|
39
|
+
const SIGNATURE_VERSION = 'v1';
|
|
40
|
+
const SECRET_BYTES = 32;
|
|
41
|
+
|
|
42
|
+
/** The fewest random bytes a webhook secret may have (Standard Webhooks: 24–64). */
|
|
43
|
+
export const WEBHOOK_SECRET_MIN_BYTES = 24;
|
|
44
|
+
|
|
45
|
+
/** A new random signing secret: `whsec_` + base64 of 32 random bytes. */
|
|
46
|
+
export function generateWebhookSecret(): string {
|
|
47
|
+
return `${WEBHOOK_SECRET_PREFIX}${randomBytes(SECRET_BYTES).toString('base64')}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether a secret is usable for signing: `whsec_` + base64 (or the bare
|
|
52
|
+
* base64) of at least {@link WEBHOOK_SECRET_MIN_BYTES} bytes.
|
|
53
|
+
*/
|
|
54
|
+
export function isStrongWebhookSecret(secret: string): boolean {
|
|
55
|
+
const key = parseSecret(secret);
|
|
56
|
+
return key !== null && key.length >= WEBHOOK_SECRET_MIN_BYTES;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface SignWebhookInput {
|
|
60
|
+
/**
|
|
61
|
+
* The endpoint's secret. During a rotation, pass the new and the
|
|
62
|
+
* previous secret: the header then carries one signature per secret,
|
|
63
|
+
* and a receiver holding either one accepts the message.
|
|
64
|
+
*/
|
|
65
|
+
readonly secret: string | readonly string[];
|
|
66
|
+
/** The message id; stays the same across retries of one message. */
|
|
67
|
+
readonly id: string;
|
|
68
|
+
/** Unix seconds. */
|
|
69
|
+
readonly timestamp: number;
|
|
70
|
+
/** The raw request body, exactly as sent. */
|
|
71
|
+
readonly body: string | Uint8Array;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The `webhook-signature` header value: `v1,<base64>` per secret,
|
|
76
|
+
* space-separated. A malformed secret is a `malformed-key` error. An
|
|
77
|
+
* empty secret list or a timestamp that isn't a non-negative integer is
|
|
78
|
+
* a caller bug and throws.
|
|
79
|
+
*/
|
|
80
|
+
export function signWebhook(input: SignWebhookInput): Result<string, MalformedKeyError> {
|
|
81
|
+
if (!Number.isSafeInteger(input.timestamp) || input.timestamp < 0) {
|
|
82
|
+
throw new TypeError('signWebhook: timestamp must be a non-negative integer (Unix seconds)');
|
|
83
|
+
}
|
|
84
|
+
const secrets = typeof input.secret === 'string' ? [input.secret] : input.secret;
|
|
85
|
+
if (secrets.length === 0) throw new TypeError('signWebhook: at least one secret is required');
|
|
86
|
+
const content = signedContent(input.id, String(input.timestamp), input.body);
|
|
87
|
+
const signatures: string[] = [];
|
|
88
|
+
for (const secret of secrets) {
|
|
89
|
+
const key = parseSecret(secret);
|
|
90
|
+
if (key === null) return malformedKey('webhook secret must be `whsec_` + base64 key bytes');
|
|
91
|
+
signatures.push(`${SIGNATURE_VERSION},${hmac(key, content).toString('base64')}`);
|
|
92
|
+
}
|
|
93
|
+
return { kind: 'ok', value: signatures.join(' ') };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The three signed headers for a message, ready to send. */
|
|
97
|
+
export function webhookHeaders(
|
|
98
|
+
input: SignWebhookInput,
|
|
99
|
+
): Result<Readonly<Record<string, string>>, MalformedKeyError> {
|
|
100
|
+
const signature = signWebhook(input);
|
|
101
|
+
if (signature.kind === 'err') return signature;
|
|
102
|
+
return {
|
|
103
|
+
kind: 'ok',
|
|
104
|
+
value: {
|
|
105
|
+
[WEBHOOK_HEADERS.id]: input.id,
|
|
106
|
+
[WEBHOOK_HEADERS.timestamp]: String(input.timestamp),
|
|
107
|
+
[WEBHOOK_HEADERS.signature]: signature.value,
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Request headers as a `Headers` object or a plain (Node-style) record. */
|
|
113
|
+
export type WebhookRequestHeaders =
|
|
114
|
+
| { get(name: string): string | null }
|
|
115
|
+
| Readonly<Record<string, string | readonly string[] | undefined>>;
|
|
116
|
+
|
|
117
|
+
export interface VerifyWebhookInput {
|
|
118
|
+
readonly secret: string;
|
|
119
|
+
readonly headers: WebhookRequestHeaders;
|
|
120
|
+
/** The raw request body, exactly as received — not a re-serialized JSON value. */
|
|
121
|
+
readonly body: string | Uint8Array;
|
|
122
|
+
/** Default {@link DEFAULT_WEBHOOK_TOLERANCE_SECONDS}. */
|
|
123
|
+
readonly toleranceSeconds?: number;
|
|
124
|
+
/** Clock in milliseconds; default `Date.now`. */
|
|
125
|
+
readonly now?: () => number;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export type VerifyWebhookFailure =
|
|
129
|
+
| 'missing-headers'
|
|
130
|
+
| 'invalid-timestamp'
|
|
131
|
+
| 'timestamp-out-of-tolerance'
|
|
132
|
+
| 'invalid-secret'
|
|
133
|
+
| 'no-matching-signature';
|
|
134
|
+
|
|
135
|
+
export type VerifyWebhookResult =
|
|
136
|
+
| { readonly kind: 'ok'; readonly id: string; readonly timestamp: number }
|
|
137
|
+
| { readonly kind: 'err'; readonly reason: VerifyWebhookFailure };
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Verify a received webhook: the timestamp is within tolerance and at
|
|
141
|
+
* least one `v1` signature in the header matches. Comparison is
|
|
142
|
+
* constant-time. On `ok`, deduplicate on `id` — delivery is at least once.
|
|
143
|
+
*/
|
|
144
|
+
export function verifyWebhook(input: VerifyWebhookInput): VerifyWebhookResult {
|
|
145
|
+
const id = readHeader(input.headers, WEBHOOK_HEADERS.id);
|
|
146
|
+
const timestampRaw = readHeader(input.headers, WEBHOOK_HEADERS.timestamp);
|
|
147
|
+
const signatureHeader = readHeader(input.headers, WEBHOOK_HEADERS.signature);
|
|
148
|
+
if (id === undefined || timestampRaw === undefined || signatureHeader === undefined) {
|
|
149
|
+
return { kind: 'err', reason: 'missing-headers' };
|
|
150
|
+
}
|
|
151
|
+
if (!/^\d{1,15}$/.test(timestampRaw)) return { kind: 'err', reason: 'invalid-timestamp' };
|
|
152
|
+
const timestamp = Number(timestampRaw);
|
|
153
|
+
const tolerance = input.toleranceSeconds ?? DEFAULT_WEBHOOK_TOLERANCE_SECONDS;
|
|
154
|
+
const nowSeconds = Math.floor((input.now ?? Date.now)() / 1000);
|
|
155
|
+
if (Math.abs(nowSeconds - timestamp) > tolerance) {
|
|
156
|
+
return { kind: 'err', reason: 'timestamp-out-of-tolerance' };
|
|
157
|
+
}
|
|
158
|
+
const key = parseSecret(input.secret);
|
|
159
|
+
if (key === null) return { kind: 'err', reason: 'invalid-secret' };
|
|
160
|
+
|
|
161
|
+
const expected = hmac(key, signedContent(id, timestampRaw, input.body));
|
|
162
|
+
for (const part of signatureHeader.split(' ')) {
|
|
163
|
+
const comma = part.indexOf(',');
|
|
164
|
+
if (comma < 0 || part.slice(0, comma) !== SIGNATURE_VERSION) continue;
|
|
165
|
+
const provided = Buffer.from(part.slice(comma + 1), 'base64');
|
|
166
|
+
if (provided.length === expected.length && timingSafeEqual(provided, expected)) {
|
|
167
|
+
return { kind: 'ok', id, timestamp };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return { kind: 'err', reason: 'no-matching-signature' };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function signedContent(id: string, timestamp: string, body: string | Uint8Array): Buffer {
|
|
174
|
+
const prefix = Buffer.from(`${id}.${timestamp}.`, 'utf-8');
|
|
175
|
+
const bodyBytes = typeof body === 'string' ? Buffer.from(body, 'utf-8') : Buffer.from(body);
|
|
176
|
+
return Buffer.concat([prefix, bodyBytes]);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function hmac(key: Buffer, content: Buffer): Buffer {
|
|
180
|
+
return createHmac('sha256', key).update(content).digest();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Key bytes of a `whsec_…` (or bare base64) secret; `null` when malformed. */
|
|
184
|
+
function parseSecret(secret: string): Buffer | null {
|
|
185
|
+
const encoded = secret.startsWith(WEBHOOK_SECRET_PREFIX)
|
|
186
|
+
? secret.slice(WEBHOOK_SECRET_PREFIX.length)
|
|
187
|
+
: secret;
|
|
188
|
+
if (encoded.length === 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)) return null;
|
|
189
|
+
const key = Buffer.from(encoded, 'base64');
|
|
190
|
+
return key.length === 0 ? null : key;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function readHeader(headers: WebhookRequestHeaders, name: string): string | undefined {
|
|
194
|
+
if (typeof (headers as { get?: unknown }).get === 'function') {
|
|
195
|
+
const value = (headers as { get(name: string): string | null }).get(name);
|
|
196
|
+
return value === null || value.length === 0 ? undefined : value;
|
|
197
|
+
}
|
|
198
|
+
const record = headers as Readonly<Record<string, string | readonly string[] | undefined>>;
|
|
199
|
+
for (const [key, value] of Object.entries(record)) {
|
|
200
|
+
if (key.toLowerCase() !== name) continue;
|
|
201
|
+
const first = typeof value === 'string' ? value : value?.[0];
|
|
202
|
+
return first === undefined || first.length === 0 ? undefined : first;
|
|
203
|
+
}
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|