@xivdyetools/auth 1.0.2 → 1.1.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/src/hmac.ts CHANGED
@@ -1,222 +1,274 @@
1
- /**
2
- * HMAC Signing Utilities
3
- *
4
- * Provides HMAC-SHA256 signing and verification using the Web Crypto API.
5
- * Used for JWT signing and bot request authentication.
6
- *
7
- * @module hmac
8
- */
9
-
10
- import {
11
- base64UrlEncodeBytes,
12
- base64UrlDecodeBytes,
13
- bytesToHex,
14
- hexToBytes,
15
- } from '@xivdyetools/crypto';
16
-
17
- /**
18
- * Options for bot signature verification
19
- */
20
- export interface BotSignatureOptions {
21
- /** Maximum age of signature in milliseconds (default: 5 minutes) */
22
- maxAgeMs?: number;
23
- /** Allowed clock skew in milliseconds (default: 1 minute) */
24
- clockSkewMs?: number;
25
- }
26
-
27
- /**
28
- * Create an HMAC-SHA256 CryptoKey from a secret string.
29
- *
30
- * @param secret - The secret string to use as key material
31
- * @param usage - Key usage: 'sign', 'verify', or 'both'
32
- * @returns CryptoKey for HMAC operations
33
- *
34
- * @example
35
- * ```typescript
36
- * const key = await createHmacKey(process.env.JWT_SECRET, 'verify');
37
- * ```
38
- */
39
- export async function createHmacKey(
40
- secret: string,
41
- usage: 'sign' | 'verify' | 'both' = 'both'
42
- ): Promise<CryptoKey> {
43
- const encoder = new TextEncoder();
44
- const keyData = encoder.encode(secret);
45
-
46
- const keyUsages: ('sign' | 'verify')[] =
47
- usage === 'both' ? ['sign', 'verify'] : [usage];
48
-
49
- return crypto.subtle.importKey(
50
- 'raw',
51
- keyData,
52
- { name: 'HMAC', hash: 'SHA-256' },
53
- false,
54
- keyUsages
55
- );
56
- }
57
-
58
- /**
59
- * Sign data with HMAC-SHA256 and return base64url-encoded signature.
60
- *
61
- * @param data - The data to sign
62
- * @param secret - The secret key
63
- * @returns Base64URL-encoded signature
64
- *
65
- * @example
66
- * ```typescript
67
- * const signature = await hmacSign('header.payload', jwtSecret);
68
- * ```
69
- */
70
- export async function hmacSign(data: string, secret: string): Promise<string> {
71
- const key = await createHmacKey(secret, 'sign');
72
- const encoder = new TextEncoder();
73
- const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
74
- return base64UrlEncodeBytes(new Uint8Array(signature));
75
- }
76
-
77
- /**
78
- * Sign data with HMAC-SHA256 and return hex-encoded signature.
79
- *
80
- * @param data - The data to sign
81
- * @param secret - The secret key
82
- * @returns Hex-encoded signature
83
- *
84
- * @example
85
- * ```typescript
86
- * const signature = await hmacSignHex('timestamp:userId:userName', secret);
87
- * ```
88
- */
89
- export async function hmacSignHex(
90
- data: string,
91
- secret: string
92
- ): Promise<string> {
93
- const key = await createHmacKey(secret, 'sign');
94
- const encoder = new TextEncoder();
95
- const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
96
- return bytesToHex(new Uint8Array(signature));
97
- }
98
-
99
- /**
100
- * Verify HMAC-SHA256 signature (base64url-encoded).
101
- *
102
- * @param data - The original data that was signed
103
- * @param signature - Base64URL-encoded signature to verify
104
- * @param secret - The secret key
105
- * @returns true if signature is valid
106
- */
107
- export async function hmacVerify(
108
- data: string,
109
- signature: string,
110
- secret: string
111
- ): Promise<boolean> {
112
- try {
113
- const key = await createHmacKey(secret, 'verify');
114
- const encoder = new TextEncoder();
115
- const signatureBytes = base64UrlDecodeBytes(signature);
116
-
117
- // Use crypto.subtle.verify() which is inherently timing-safe
118
- return crypto.subtle.verify(
119
- 'HMAC',
120
- key,
121
- signatureBytes,
122
- encoder.encode(data)
123
- );
124
- } catch {
125
- return false;
126
- }
127
- }
128
-
129
- /**
130
- * Verify HMAC-SHA256 signature (hex-encoded).
131
- *
132
- * @param data - The original data that was signed
133
- * @param signature - Hex-encoded signature to verify
134
- * @param secret - The secret key
135
- * @returns true if signature is valid
136
- */
137
- export async function hmacVerifyHex(
138
- data: string,
139
- signature: string,
140
- secret: string
141
- ): Promise<boolean> {
142
- try {
143
- const key = await createHmacKey(secret, 'verify');
144
- const encoder = new TextEncoder();
145
- const signatureBytes = hexToBytes(signature);
146
-
147
- return crypto.subtle.verify(
148
- 'HMAC',
149
- key,
150
- signatureBytes,
151
- encoder.encode(data)
152
- );
153
- } catch {
154
- return false;
155
- }
156
- }
157
-
158
- /**
159
- * Verify a bot request signature.
160
- *
161
- * Bot signatures use the format: `${timestamp}:${userDiscordId}:${userName}`
162
- * Signatures are hex-encoded HMAC-SHA256.
163
- *
164
- * @param signature - Hex-encoded signature
165
- * @param timestamp - Unix timestamp string (seconds)
166
- * @param userDiscordId - Discord user ID
167
- * @param userName - Discord username
168
- * @param secret - The signing secret
169
- * @param options - Verification options
170
- * @returns true if signature is valid and not expired
171
- *
172
- * @example
173
- * ```typescript
174
- * const isValid = await verifyBotSignature(
175
- * request.headers.get('X-Signature'),
176
- * request.headers.get('X-Timestamp'),
177
- * request.headers.get('X-User-Id'),
178
- * request.headers.get('X-User-Name'),
179
- * env.BOT_SIGNING_SECRET
180
- * );
181
- * ```
182
- */
183
- export async function verifyBotSignature(
184
- signature: string | undefined,
185
- timestamp: string | undefined,
186
- userDiscordId: string | undefined,
187
- userName: string | undefined,
188
- secret: string,
189
- options: BotSignatureOptions = {}
190
- ): Promise<boolean> {
191
- const { maxAgeMs = 5 * 60 * 1000, clockSkewMs = 60 * 1000 } = options;
192
-
193
- // Validate required fields
194
- if (!signature || !timestamp || !userDiscordId || !userName) {
195
- return false;
196
- }
197
-
198
- // Validate timestamp format
199
- const timestampNum = parseInt(timestamp, 10);
200
- if (isNaN(timestampNum)) {
201
- return false;
202
- }
203
-
204
- // Check timestamp age (with clock skew tolerance)
205
- const now = Date.now();
206
- const signatureTime = timestampNum * 1000; // Convert to milliseconds
207
- const age = now - signatureTime;
208
-
209
- // Reject if too old
210
- if (age > maxAgeMs) {
211
- return false;
212
- }
213
-
214
- // Reject if too far in the future (clock skew protection)
215
- if (signatureTime > now + clockSkewMs) {
216
- return false;
217
- }
218
-
219
- // Verify the signature
220
- const message = `${timestamp}:${userDiscordId}:${userName}`;
221
- return hmacVerifyHex(message, signature, secret);
222
- }
1
+ /**
2
+ * HMAC Signing Utilities
3
+ *
4
+ * Provides HMAC-SHA256 signing and verification using the Web Crypto API.
5
+ * Used for JWT signing and bot request authentication.
6
+ *
7
+ * @module hmac
8
+ */
9
+
10
+ import {
11
+ base64UrlEncodeBytes,
12
+ base64UrlDecodeBytes,
13
+ bytesToHex,
14
+ hexToBytes,
15
+ } from '@xivdyetools/crypto';
16
+
17
+ /**
18
+ * Options for bot signature verification
19
+ */
20
+ export interface BotSignatureOptions {
21
+ /** Maximum age of signature in milliseconds (default: 5 minutes) */
22
+ maxAgeMs?: number;
23
+ /** Allowed clock skew in milliseconds (default: 1 minute) */
24
+ clockSkewMs?: number;
25
+ }
26
+
27
+ // ============================================================================
28
+ // CryptoKey Cache (OPT-002)
29
+ // ============================================================================
30
+
31
+ /**
32
+ * Module-level cache for CryptoKeys.
33
+ *
34
+ * In Cloudflare Workers, module-level state persists across requests within
35
+ * an isolate, making this safe and effective. Eliminates redundant
36
+ * `crypto.subtle.importKey()` calls when the same secret is reused.
37
+ *
38
+ * Cache key format: `${secret}:${usage}` — bounded to 10 entries max
39
+ * to prevent unbounded growth during key rotation.
40
+ */
41
+ const cryptoKeyCache = new Map<string, CryptoKey>();
42
+ const CRYPTO_KEY_CACHE_MAX = 10;
43
+
44
+ /**
45
+ * Get or create a cached CryptoKey for the given secret and usage.
46
+ * Exported for use by jwt.ts — not part of the public package API.
47
+ * @internal
48
+ */
49
+ export async function getOrCreateHmacKey(
50
+ secret: string,
51
+ usage: 'sign' | 'verify' | 'both'
52
+ ): Promise<CryptoKey> {
53
+ const cacheKey = `${secret}:${usage}`;
54
+ const cached = cryptoKeyCache.get(cacheKey);
55
+ if (cached) {
56
+ return cached;
57
+ }
58
+
59
+ const key = await createHmacKey(secret, usage);
60
+
61
+ // Evict oldest entries if cache is full (simple FIFO)
62
+ if (cryptoKeyCache.size >= CRYPTO_KEY_CACHE_MAX) {
63
+ const firstKey = cryptoKeyCache.keys().next().value;
64
+ if (firstKey !== undefined) {
65
+ cryptoKeyCache.delete(firstKey);
66
+ }
67
+ }
68
+
69
+ cryptoKeyCache.set(cacheKey, key);
70
+ return key;
71
+ }
72
+
73
+ /**
74
+ * Create an HMAC-SHA256 CryptoKey from a secret string.
75
+ *
76
+ * @param secret - The secret string to use as key material
77
+ * @param usage - Key usage: 'sign', 'verify', or 'both'
78
+ * @returns CryptoKey for HMAC operations
79
+ *
80
+ * @example
81
+ * ```typescript
82
+ * const key = await createHmacKey(process.env.JWT_SECRET, 'verify');
83
+ * ```
84
+ */
85
+ export async function createHmacKey(
86
+ secret: string,
87
+ usage: 'sign' | 'verify' | 'both' = 'both'
88
+ ): Promise<CryptoKey> {
89
+ const encoder = new TextEncoder();
90
+ const keyData = encoder.encode(secret);
91
+
92
+ // FINDING-009: Enforce minimum key length for HMAC-SHA256 security
93
+ if (keyData.length < 32) {
94
+ throw new Error('HMAC secret must be at least 32 bytes (256 bits)');
95
+ }
96
+
97
+ const keyUsages: ('sign' | 'verify')[] =
98
+ usage === 'both' ? ['sign', 'verify'] : [usage];
99
+
100
+ return crypto.subtle.importKey(
101
+ 'raw',
102
+ keyData,
103
+ { name: 'HMAC', hash: 'SHA-256' },
104
+ false,
105
+ keyUsages
106
+ );
107
+ }
108
+
109
+ /**
110
+ * Sign data with HMAC-SHA256 and return base64url-encoded signature.
111
+ *
112
+ * @param data - The data to sign
113
+ * @param secret - The secret key
114
+ * @returns Base64URL-encoded signature
115
+ *
116
+ * @example
117
+ * ```typescript
118
+ * const signature = await hmacSign('header.payload', jwtSecret);
119
+ * ```
120
+ */
121
+ export async function hmacSign(data: string, secret: string): Promise<string> {
122
+ const key = await getOrCreateHmacKey(secret, 'sign');
123
+ const encoder = new TextEncoder();
124
+ const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
125
+ return base64UrlEncodeBytes(new Uint8Array(signature));
126
+ }
127
+
128
+ /**
129
+ * Sign data with HMAC-SHA256 and return hex-encoded signature.
130
+ *
131
+ * @param data - The data to sign
132
+ * @param secret - The secret key
133
+ * @returns Hex-encoded signature
134
+ *
135
+ * @example
136
+ * ```typescript
137
+ * const signature = await hmacSignHex('timestamp:userId:userName', secret);
138
+ * ```
139
+ */
140
+ export async function hmacSignHex(
141
+ data: string,
142
+ secret: string
143
+ ): Promise<string> {
144
+ const key = await getOrCreateHmacKey(secret, 'sign');
145
+ const encoder = new TextEncoder();
146
+ const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
147
+ return bytesToHex(new Uint8Array(signature));
148
+ }
149
+
150
+ /**
151
+ * Verify HMAC-SHA256 signature (base64url-encoded).
152
+ *
153
+ * @param data - The original data that was signed
154
+ * @param signature - Base64URL-encoded signature to verify
155
+ * @param secret - The secret key
156
+ * @returns true if signature is valid
157
+ */
158
+ export async function hmacVerify(
159
+ data: string,
160
+ signature: string,
161
+ secret: string
162
+ ): Promise<boolean> {
163
+ try {
164
+ const key = await getOrCreateHmacKey(secret, 'verify');
165
+ const encoder = new TextEncoder();
166
+ const signatureBytes = base64UrlDecodeBytes(signature);
167
+
168
+ // Use crypto.subtle.verify() which is inherently timing-safe
169
+ return crypto.subtle.verify(
170
+ 'HMAC',
171
+ key,
172
+ signatureBytes,
173
+ encoder.encode(data)
174
+ );
175
+ } catch {
176
+ return false;
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Verify HMAC-SHA256 signature (hex-encoded).
182
+ *
183
+ * @param data - The original data that was signed
184
+ * @param signature - Hex-encoded signature to verify
185
+ * @param secret - The secret key
186
+ * @returns true if signature is valid
187
+ */
188
+ export async function hmacVerifyHex(
189
+ data: string,
190
+ signature: string,
191
+ secret: string
192
+ ): Promise<boolean> {
193
+ try {
194
+ const key = await getOrCreateHmacKey(secret, 'verify');
195
+ const encoder = new TextEncoder();
196
+ const signatureBytes = hexToBytes(signature);
197
+
198
+ return crypto.subtle.verify(
199
+ 'HMAC',
200
+ key,
201
+ signatureBytes,
202
+ encoder.encode(data)
203
+ );
204
+ } catch {
205
+ return false;
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Verify a bot request signature.
211
+ *
212
+ * Bot signatures use the format: `${timestamp}:${userDiscordId}:${userName}`
213
+ * Signatures are hex-encoded HMAC-SHA256.
214
+ *
215
+ * @param signature - Hex-encoded signature
216
+ * @param timestamp - Unix timestamp string (seconds)
217
+ * @param userDiscordId - Discord user ID
218
+ * @param userName - Discord username
219
+ * @param secret - The signing secret
220
+ * @param options - Verification options
221
+ * @returns true if signature is valid and not expired
222
+ *
223
+ * @example
224
+ * ```typescript
225
+ * const isValid = await verifyBotSignature(
226
+ * request.headers.get('X-Signature'),
227
+ * request.headers.get('X-Timestamp'),
228
+ * request.headers.get('X-User-Id'),
229
+ * request.headers.get('X-User-Name'),
230
+ * env.BOT_SIGNING_SECRET
231
+ * );
232
+ * ```
233
+ */
234
+ export async function verifyBotSignature(
235
+ signature: string | undefined,
236
+ timestamp: string | undefined,
237
+ userDiscordId: string | undefined,
238
+ userName: string | undefined,
239
+ secret: string,
240
+ options: BotSignatureOptions = {}
241
+ ): Promise<boolean> {
242
+ const { maxAgeMs = 5 * 60 * 1000, clockSkewMs = 60 * 1000 } = options;
243
+
244
+ // Validate required fields (signature and timestamp are required;
245
+ // userDiscordId and userName are optional for system-level bot requests)
246
+ if (!signature || !timestamp) {
247
+ return false;
248
+ }
249
+
250
+ // Validate timestamp format
251
+ const timestampNum = parseInt(timestamp, 10);
252
+ if (isNaN(timestampNum)) {
253
+ return false;
254
+ }
255
+
256
+ // Check timestamp age (with clock skew tolerance)
257
+ const now = Date.now();
258
+ const signatureTime = timestampNum * 1000; // Convert to milliseconds
259
+ const age = now - signatureTime;
260
+
261
+ // Reject if too old
262
+ if (age > maxAgeMs) {
263
+ return false;
264
+ }
265
+
266
+ // Reject if too far in the future (clock skew protection)
267
+ if (signatureTime > now + clockSkewMs) {
268
+ return false;
269
+ }
270
+
271
+ // Verify the signature
272
+ const message = `${timestamp}:${userDiscordId ?? ''}:${userName ?? ''}`;
273
+ return hmacVerifyHex(message, signature, secret);
274
+ }
package/src/index.ts CHANGED
@@ -1,54 +1,54 @@
1
- /**
2
- * @xivdyetools/auth
3
- *
4
- * Shared authentication utilities for the xivdyetools ecosystem.
5
- *
6
- * @example
7
- * ```typescript
8
- * import { verifyJWT, verifyDiscordRequest, timingSafeEqual } from '@xivdyetools/auth';
9
- *
10
- * // Verify JWT
11
- * const payload = await verifyJWT(token, env.JWT_SECRET);
12
- *
13
- * // Verify Discord request
14
- * const result = await verifyDiscordRequest(request, env.DISCORD_PUBLIC_KEY);
15
- *
16
- * // Timing-safe comparison
17
- * const isValid = await timingSafeEqual(provided, expected);
18
- * ```
19
- *
20
- * @module @xivdyetools/auth
21
- */
22
-
23
- // JWT utilities
24
- export {
25
- verifyJWT,
26
- verifyJWTSignatureOnly,
27
- decodeJWT,
28
- isJWTExpired,
29
- getJWTTimeToExpiry,
30
- type JWTPayload,
31
- } from './jwt.js';
32
-
33
- // HMAC utilities
34
- export {
35
- createHmacKey,
36
- hmacSign,
37
- hmacSignHex,
38
- hmacVerify,
39
- hmacVerifyHex,
40
- verifyBotSignature,
41
- type BotSignatureOptions,
42
- } from './hmac.js';
43
-
44
- // Timing-safe utilities
45
- export { timingSafeEqual, timingSafeEqualBytes } from './timing.js';
46
-
47
- // Discord verification
48
- export {
49
- verifyDiscordRequest,
50
- unauthorizedResponse,
51
- badRequestResponse,
52
- type DiscordVerificationResult,
53
- type DiscordVerifyOptions,
54
- } from './discord.js';
1
+ /**
2
+ * @xivdyetools/auth
3
+ *
4
+ * Shared authentication utilities for the xivdyetools ecosystem.
5
+ *
6
+ * @example
7
+ * ```typescript
8
+ * import { verifyJWT, verifyDiscordRequest, timingSafeEqual } from '@xivdyetools/auth';
9
+ *
10
+ * // Verify JWT
11
+ * const payload = await verifyJWT(token, env.JWT_SECRET);
12
+ *
13
+ * // Verify Discord request
14
+ * const result = await verifyDiscordRequest(request, env.DISCORD_PUBLIC_KEY);
15
+ *
16
+ * // Timing-safe comparison
17
+ * const isValid = await timingSafeEqual(provided, expected);
18
+ * ```
19
+ *
20
+ * @module @xivdyetools/auth
21
+ */
22
+
23
+ // JWT utilities
24
+ export {
25
+ verifyJWT,
26
+ verifyJWTSignatureOnly,
27
+ decodeJWT,
28
+ isJWTExpired,
29
+ getJWTTimeToExpiry,
30
+ type JWTPayload,
31
+ } from './jwt.js';
32
+
33
+ // HMAC utilities
34
+ export {
35
+ createHmacKey,
36
+ hmacSign,
37
+ hmacSignHex,
38
+ hmacVerify,
39
+ hmacVerifyHex,
40
+ verifyBotSignature,
41
+ type BotSignatureOptions,
42
+ } from './hmac.js';
43
+
44
+ // Timing-safe utilities
45
+ export { timingSafeEqual, timingSafeEqualBytes } from './timing.js';
46
+
47
+ // Discord verification
48
+ export {
49
+ verifyDiscordRequest,
50
+ unauthorizedResponse,
51
+ badRequestResponse,
52
+ type DiscordVerificationResult,
53
+ type DiscordVerifyOptions,
54
+ } from './discord.js';