@xivdyetools/auth 1.0.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/discord.ts ADDED
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Discord Request Verification
3
+ *
4
+ * Wraps the `discord-interactions` library's Ed25519 signature verification
5
+ * with additional security checks (body size limits, header validation).
6
+ *
7
+ * @see https://discord.com/developers/docs/interactions/receiving-and-responding#security-and-authorization
8
+ * @module discord
9
+ */
10
+
11
+ import { verifyKey } from 'discord-interactions';
12
+
13
+ /**
14
+ * Result of Discord request verification
15
+ */
16
+ export interface DiscordVerificationResult {
17
+ /** Whether the signature is valid */
18
+ isValid: boolean;
19
+ /** The raw request body (needed for parsing after verification) */
20
+ body: string;
21
+ /** Error message if verification failed */
22
+ error?: string;
23
+ }
24
+
25
+ /**
26
+ * Options for Discord verification
27
+ */
28
+ export interface DiscordVerifyOptions {
29
+ /** Maximum request body size in bytes (default: 100KB) */
30
+ maxBodySize?: number;
31
+ }
32
+
33
+ /** Default maximum body size (100KB) */
34
+ const DEFAULT_MAX_BODY_SIZE = 100_000;
35
+
36
+ /**
37
+ * Verify that a request came from Discord using Ed25519 signature verification.
38
+ *
39
+ * Security features:
40
+ * - Content-Length header check (before reading body)
41
+ * - Actual body size validation (Content-Length can be spoofed)
42
+ * - Required header validation (X-Signature-Ed25519, X-Signature-Timestamp)
43
+ *
44
+ * @param request - The incoming HTTP request
45
+ * @param publicKey - Your Discord application's public key
46
+ * @param options - Verification options
47
+ * @returns Verification result with the request body
48
+ *
49
+ * @example
50
+ * ```typescript
51
+ * const result = await verifyDiscordRequest(request, env.DISCORD_PUBLIC_KEY);
52
+ * if (!result.isValid) {
53
+ * return new Response(result.error, { status: 401 });
54
+ * }
55
+ * const interaction = JSON.parse(result.body);
56
+ * ```
57
+ */
58
+ export async function verifyDiscordRequest(
59
+ request: Request,
60
+ publicKey: string,
61
+ options: DiscordVerifyOptions = {}
62
+ ): Promise<DiscordVerificationResult> {
63
+ const maxBodySize = options.maxBodySize ?? DEFAULT_MAX_BODY_SIZE;
64
+
65
+ // Check Content-Length header first (if present) to reject obviously large requests
66
+ const contentLength = request.headers.get('Content-Length');
67
+ if (contentLength && parseInt(contentLength, 10) > maxBodySize) {
68
+ return {
69
+ isValid: false,
70
+ body: '',
71
+ error: 'Request body too large',
72
+ };
73
+ }
74
+
75
+ // Get required headers
76
+ const signature = request.headers.get('X-Signature-Ed25519');
77
+ const timestamp = request.headers.get('X-Signature-Timestamp');
78
+
79
+ if (!signature || !timestamp) {
80
+ return {
81
+ isValid: false,
82
+ body: '',
83
+ error: 'Missing signature headers',
84
+ };
85
+ }
86
+
87
+ // Get the raw body
88
+ const body = await request.text();
89
+
90
+ // Verify actual body size (Content-Length can be spoofed)
91
+ if (body.length > maxBodySize) {
92
+ return {
93
+ isValid: false,
94
+ body: '',
95
+ error: 'Request body too large',
96
+ };
97
+ }
98
+
99
+ // Verify the signature using discord-interactions library
100
+ try {
101
+ const isValid = await verifyKey(body, signature, timestamp, publicKey);
102
+
103
+ return {
104
+ isValid,
105
+ body,
106
+ error: isValid ? undefined : 'Invalid signature',
107
+ };
108
+ } catch (error) {
109
+ return {
110
+ isValid: false,
111
+ body,
112
+ error: error instanceof Error ? error.message : 'Verification failed',
113
+ };
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Creates a 401 Unauthorized response for failed verification.
119
+ *
120
+ * @param message - Error message (default: 'Invalid request signature')
121
+ * @returns Response object
122
+ */
123
+ export function unauthorizedResponse(
124
+ message = 'Invalid request signature'
125
+ ): Response {
126
+ return new Response(JSON.stringify({ error: message }), {
127
+ status: 401,
128
+ headers: { 'Content-Type': 'application/json' },
129
+ });
130
+ }
131
+
132
+ /**
133
+ * Creates a 400 Bad Request response.
134
+ *
135
+ * @param message - Error message
136
+ * @returns Response object
137
+ */
138
+ export function badRequestResponse(message: string): Response {
139
+ return new Response(JSON.stringify({ error: message }), {
140
+ status: 400,
141
+ headers: { 'Content-Type': 'application/json' },
142
+ });
143
+ }
@@ -0,0 +1,325 @@
1
+ /**
2
+ * Tests for HMAC Signing Utilities
3
+ */
4
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
5
+ import {
6
+ createHmacKey,
7
+ hmacSign,
8
+ hmacSignHex,
9
+ hmacVerify,
10
+ hmacVerifyHex,
11
+ verifyBotSignature,
12
+ } from './hmac.js';
13
+
14
+ describe('hmac.ts', () => {
15
+ describe('createHmacKey', () => {
16
+ it('should create a CryptoKey for signing', async () => {
17
+ const key = await createHmacKey('test-secret', 'sign');
18
+ expect(key).toBeDefined();
19
+ expect(key.algorithm.name).toBe('HMAC');
20
+ });
21
+
22
+ it('should create a CryptoKey for verification', async () => {
23
+ const key = await createHmacKey('test-secret', 'verify');
24
+ expect(key).toBeDefined();
25
+ expect(key.algorithm.name).toBe('HMAC');
26
+ });
27
+
28
+ it('should create a CryptoKey for both operations', async () => {
29
+ const key = await createHmacKey('test-secret', 'both');
30
+ expect(key).toBeDefined();
31
+ });
32
+
33
+ it('should default to both operations', async () => {
34
+ const key = await createHmacKey('test-secret');
35
+ expect(key).toBeDefined();
36
+ });
37
+ });
38
+
39
+ describe('hmacSign', () => {
40
+ it('should return a base64url-encoded signature', async () => {
41
+ const signature = await hmacSign('test-data', 'test-secret');
42
+ expect(signature).toBeDefined();
43
+ expect(typeof signature).toBe('string');
44
+ // Base64URL should not contain + or /
45
+ expect(signature).not.toMatch(/[+/]/);
46
+ });
47
+
48
+ it('should produce consistent signatures for same input', async () => {
49
+ const sig1 = await hmacSign('test-data', 'test-secret');
50
+ const sig2 = await hmacSign('test-data', 'test-secret');
51
+ expect(sig1).toBe(sig2);
52
+ });
53
+
54
+ it('should produce different signatures for different data', async () => {
55
+ const sig1 = await hmacSign('data1', 'test-secret');
56
+ const sig2 = await hmacSign('data2', 'test-secret');
57
+ expect(sig1).not.toBe(sig2);
58
+ });
59
+
60
+ it('should produce different signatures for different secrets', async () => {
61
+ const sig1 = await hmacSign('test-data', 'secret1');
62
+ const sig2 = await hmacSign('test-data', 'secret2');
63
+ expect(sig1).not.toBe(sig2);
64
+ });
65
+ });
66
+
67
+ describe('hmacSignHex', () => {
68
+ it('should return a hex-encoded signature', async () => {
69
+ const signature = await hmacSignHex('test-data', 'test-secret');
70
+ expect(signature).toBeDefined();
71
+ expect(typeof signature).toBe('string');
72
+ // Should only contain hex characters
73
+ expect(signature).toMatch(/^[0-9a-f]+$/);
74
+ });
75
+
76
+ it('should produce consistent signatures', async () => {
77
+ const sig1 = await hmacSignHex('test-data', 'test-secret');
78
+ const sig2 = await hmacSignHex('test-data', 'test-secret');
79
+ expect(sig1).toBe(sig2);
80
+ });
81
+ });
82
+
83
+ describe('hmacVerify', () => {
84
+ it('should return true for valid signature', async () => {
85
+ const data = 'test-data';
86
+ const secret = 'test-secret';
87
+ const signature = await hmacSign(data, secret);
88
+ const isValid = await hmacVerify(data, signature, secret);
89
+ expect(isValid).toBe(true);
90
+ });
91
+
92
+ it('should return false for invalid signature', async () => {
93
+ const isValid = await hmacVerify('test-data', 'invalid-signature', 'test-secret');
94
+ expect(isValid).toBe(false);
95
+ });
96
+
97
+ it('should return false for wrong secret', async () => {
98
+ const data = 'test-data';
99
+ const signature = await hmacSign(data, 'secret1');
100
+ const isValid = await hmacVerify(data, signature, 'secret2');
101
+ expect(isValid).toBe(false);
102
+ });
103
+
104
+ it('should return false for tampered data', async () => {
105
+ const signature = await hmacSign('original-data', 'test-secret');
106
+ const isValid = await hmacVerify('tampered-data', signature, 'test-secret');
107
+ expect(isValid).toBe(false);
108
+ });
109
+ });
110
+
111
+ describe('hmacVerifyHex', () => {
112
+ it('should return true for valid hex signature', async () => {
113
+ const data = 'test-data';
114
+ const secret = 'test-secret';
115
+ const signature = await hmacSignHex(data, secret);
116
+ const isValid = await hmacVerifyHex(data, signature, secret);
117
+ expect(isValid).toBe(true);
118
+ });
119
+
120
+ it('should return false for invalid hex signature', async () => {
121
+ const isValid = await hmacVerifyHex('test-data', 'deadbeef', 'test-secret');
122
+ expect(isValid).toBe(false);
123
+ });
124
+
125
+ it('should return false for malformed hex', async () => {
126
+ const isValid = await hmacVerifyHex('test-data', 'not-hex!', 'test-secret');
127
+ expect(isValid).toBe(false);
128
+ });
129
+ });
130
+
131
+ describe('verifyBotSignature', () => {
132
+ const secret = 'bot-signing-secret';
133
+
134
+ beforeEach(() => {
135
+ vi.useFakeTimers();
136
+ vi.setSystemTime(new Date('2024-01-15T12:00:00Z'));
137
+ });
138
+
139
+ afterEach(() => {
140
+ vi.useRealTimers();
141
+ });
142
+
143
+ it('should return true for valid signature', async () => {
144
+ const timestamp = Math.floor(Date.now() / 1000).toString();
145
+ const userId = '123456789';
146
+ const userName = 'testuser';
147
+ const message = `${timestamp}:${userId}:${userName}`;
148
+ const signature = await hmacSignHex(message, secret);
149
+
150
+ const isValid = await verifyBotSignature(
151
+ signature,
152
+ timestamp,
153
+ userId,
154
+ userName,
155
+ secret
156
+ );
157
+ expect(isValid).toBe(true);
158
+ });
159
+
160
+ it('should return false for missing signature', async () => {
161
+ const isValid = await verifyBotSignature(
162
+ undefined,
163
+ '1234567890',
164
+ '123456789',
165
+ 'testuser',
166
+ secret
167
+ );
168
+ expect(isValid).toBe(false);
169
+ });
170
+
171
+ it('should return false for missing timestamp', async () => {
172
+ const isValid = await verifyBotSignature(
173
+ 'somesignature',
174
+ undefined,
175
+ '123456789',
176
+ 'testuser',
177
+ secret
178
+ );
179
+ expect(isValid).toBe(false);
180
+ });
181
+
182
+ it('should return false for missing userId', async () => {
183
+ const isValid = await verifyBotSignature(
184
+ 'somesignature',
185
+ '1234567890',
186
+ undefined,
187
+ 'testuser',
188
+ secret
189
+ );
190
+ expect(isValid).toBe(false);
191
+ });
192
+
193
+ it('should return false for missing userName', async () => {
194
+ const isValid = await verifyBotSignature(
195
+ 'somesignature',
196
+ '1234567890',
197
+ '123456789',
198
+ undefined,
199
+ secret
200
+ );
201
+ expect(isValid).toBe(false);
202
+ });
203
+
204
+ it('should return false for expired signature', async () => {
205
+ // Create signature from 10 minutes ago (default max age is 5 minutes)
206
+ const oldTimestamp = Math.floor(Date.now() / 1000) - 600;
207
+ const userId = '123456789';
208
+ const userName = 'testuser';
209
+ const message = `${oldTimestamp}:${userId}:${userName}`;
210
+ const signature = await hmacSignHex(message, secret);
211
+
212
+ const isValid = await verifyBotSignature(
213
+ signature,
214
+ oldTimestamp.toString(),
215
+ userId,
216
+ userName,
217
+ secret
218
+ );
219
+ expect(isValid).toBe(false);
220
+ });
221
+
222
+ it('should return false for future timestamp beyond clock skew', async () => {
223
+ // Create signature 2 minutes in the future (default clock skew is 1 minute)
224
+ const futureTimestamp = Math.floor(Date.now() / 1000) + 120;
225
+ const userId = '123456789';
226
+ const userName = 'testuser';
227
+ const message = `${futureTimestamp}:${userId}:${userName}`;
228
+ const signature = await hmacSignHex(message, secret);
229
+
230
+ const isValid = await verifyBotSignature(
231
+ signature,
232
+ futureTimestamp.toString(),
233
+ userId,
234
+ userName,
235
+ secret
236
+ );
237
+ expect(isValid).toBe(false);
238
+ });
239
+
240
+ it('should accept signature within clock skew tolerance', async () => {
241
+ // Create signature 30 seconds in the future (within default 1 minute clock skew)
242
+ const futureTimestamp = Math.floor(Date.now() / 1000) + 30;
243
+ const userId = '123456789';
244
+ const userName = 'testuser';
245
+ const message = `${futureTimestamp}:${userId}:${userName}`;
246
+ const signature = await hmacSignHex(message, secret);
247
+
248
+ const isValid = await verifyBotSignature(
249
+ signature,
250
+ futureTimestamp.toString(),
251
+ userId,
252
+ userName,
253
+ secret
254
+ );
255
+ expect(isValid).toBe(true);
256
+ });
257
+
258
+ it('should return false for invalid timestamp format', async () => {
259
+ const isValid = await verifyBotSignature(
260
+ 'somesignature',
261
+ 'not-a-number',
262
+ '123456789',
263
+ 'testuser',
264
+ secret
265
+ );
266
+ expect(isValid).toBe(false);
267
+ });
268
+
269
+ it('should return false for wrong signature', async () => {
270
+ const timestamp = Math.floor(Date.now() / 1000).toString();
271
+ const userId = '123456789';
272
+ const userName = 'testuser';
273
+
274
+ const isValid = await verifyBotSignature(
275
+ 'wrongsignature',
276
+ timestamp,
277
+ userId,
278
+ userName,
279
+ secret
280
+ );
281
+ expect(isValid).toBe(false);
282
+ });
283
+
284
+ it('should respect custom maxAgeMs option', async () => {
285
+ // Create signature from 2 minutes ago
286
+ const oldTimestamp = Math.floor(Date.now() / 1000) - 120;
287
+ const userId = '123456789';
288
+ const userName = 'testuser';
289
+ const message = `${oldTimestamp}:${userId}:${userName}`;
290
+ const signature = await hmacSignHex(message, secret);
291
+
292
+ // Should fail with default 5 minute max age... wait, 2 minutes is within 5 minutes
293
+ // Let's use 1 minute max age instead
294
+ const isValid = await verifyBotSignature(
295
+ signature,
296
+ oldTimestamp.toString(),
297
+ userId,
298
+ userName,
299
+ secret,
300
+ { maxAgeMs: 60 * 1000 } // 1 minute
301
+ );
302
+ expect(isValid).toBe(false);
303
+ });
304
+
305
+ it('should respect custom clockSkewMs option', async () => {
306
+ // Create signature 30 seconds in the future
307
+ const futureTimestamp = Math.floor(Date.now() / 1000) + 30;
308
+ const userId = '123456789';
309
+ const userName = 'testuser';
310
+ const message = `${futureTimestamp}:${userId}:${userName}`;
311
+ const signature = await hmacSignHex(message, secret);
312
+
313
+ // Should fail with 10 second clock skew tolerance
314
+ const isValid = await verifyBotSignature(
315
+ signature,
316
+ futureTimestamp.toString(),
317
+ userId,
318
+ userName,
319
+ secret,
320
+ { clockSkewMs: 10 * 1000 } // 10 seconds
321
+ );
322
+ expect(isValid).toBe(false);
323
+ });
324
+ });
325
+ });
package/src/hmac.ts ADDED
@@ -0,0 +1,213 @@
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
+ bytesToHex,
13
+ hexToBytes,
14
+ } from '@xivdyetools/crypto';
15
+
16
+ /**
17
+ * Options for bot signature verification
18
+ */
19
+ export interface BotSignatureOptions {
20
+ /** Maximum age of signature in milliseconds (default: 5 minutes) */
21
+ maxAgeMs?: number;
22
+ /** Allowed clock skew in milliseconds (default: 1 minute) */
23
+ clockSkewMs?: number;
24
+ }
25
+
26
+ /**
27
+ * Create an HMAC-SHA256 CryptoKey from a secret string.
28
+ *
29
+ * @param secret - The secret string to use as key material
30
+ * @param usage - Key usage: 'sign', 'verify', or 'both'
31
+ * @returns CryptoKey for HMAC operations
32
+ *
33
+ * @example
34
+ * ```typescript
35
+ * const key = await createHmacKey(process.env.JWT_SECRET, 'verify');
36
+ * ```
37
+ */
38
+ export async function createHmacKey(
39
+ secret: string,
40
+ usage: 'sign' | 'verify' | 'both' = 'both'
41
+ ): Promise<CryptoKey> {
42
+ const encoder = new TextEncoder();
43
+ const keyData = encoder.encode(secret);
44
+
45
+ const keyUsages: ('sign' | 'verify')[] =
46
+ usage === 'both' ? ['sign', 'verify'] : [usage];
47
+
48
+ return crypto.subtle.importKey(
49
+ 'raw',
50
+ keyData,
51
+ { name: 'HMAC', hash: 'SHA-256' },
52
+ false,
53
+ keyUsages
54
+ );
55
+ }
56
+
57
+ /**
58
+ * Sign data with HMAC-SHA256 and return base64url-encoded signature.
59
+ *
60
+ * @param data - The data to sign
61
+ * @param secret - The secret key
62
+ * @returns Base64URL-encoded signature
63
+ *
64
+ * @example
65
+ * ```typescript
66
+ * const signature = await hmacSign('header.payload', jwtSecret);
67
+ * ```
68
+ */
69
+ export async function hmacSign(data: string, secret: string): Promise<string> {
70
+ const key = await createHmacKey(secret, 'sign');
71
+ const encoder = new TextEncoder();
72
+ const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
73
+ return base64UrlEncodeBytes(new Uint8Array(signature));
74
+ }
75
+
76
+ /**
77
+ * Sign data with HMAC-SHA256 and return hex-encoded signature.
78
+ *
79
+ * @param data - The data to sign
80
+ * @param secret - The secret key
81
+ * @returns Hex-encoded signature
82
+ *
83
+ * @example
84
+ * ```typescript
85
+ * const signature = await hmacSignHex('timestamp:userId:userName', secret);
86
+ * ```
87
+ */
88
+ export async function hmacSignHex(
89
+ data: string,
90
+ secret: string
91
+ ): Promise<string> {
92
+ const key = await createHmacKey(secret, 'sign');
93
+ const encoder = new TextEncoder();
94
+ const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
95
+ return bytesToHex(new Uint8Array(signature));
96
+ }
97
+
98
+ /**
99
+ * Verify HMAC-SHA256 signature (base64url-encoded).
100
+ *
101
+ * @param data - The original data that was signed
102
+ * @param signature - Base64URL-encoded signature to verify
103
+ * @param secret - The secret key
104
+ * @returns true if signature is valid
105
+ */
106
+ export async function hmacVerify(
107
+ data: string,
108
+ signature: string,
109
+ secret: string
110
+ ): Promise<boolean> {
111
+ try {
112
+ const expectedSignature = await hmacSign(data, secret);
113
+ // Use timing-safe comparison
114
+ return expectedSignature === signature;
115
+ } catch {
116
+ return false;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Verify HMAC-SHA256 signature (hex-encoded).
122
+ *
123
+ * @param data - The original data that was signed
124
+ * @param signature - Hex-encoded signature to verify
125
+ * @param secret - The secret key
126
+ * @returns true if signature is valid
127
+ */
128
+ export async function hmacVerifyHex(
129
+ data: string,
130
+ signature: string,
131
+ secret: string
132
+ ): Promise<boolean> {
133
+ try {
134
+ const key = await createHmacKey(secret, 'verify');
135
+ const encoder = new TextEncoder();
136
+ const signatureBytes = hexToBytes(signature);
137
+
138
+ return crypto.subtle.verify(
139
+ 'HMAC',
140
+ key,
141
+ signatureBytes,
142
+ encoder.encode(data)
143
+ );
144
+ } catch {
145
+ return false;
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Verify a bot request signature.
151
+ *
152
+ * Bot signatures use the format: `${timestamp}:${userDiscordId}:${userName}`
153
+ * Signatures are hex-encoded HMAC-SHA256.
154
+ *
155
+ * @param signature - Hex-encoded signature
156
+ * @param timestamp - Unix timestamp string (seconds)
157
+ * @param userDiscordId - Discord user ID
158
+ * @param userName - Discord username
159
+ * @param secret - The signing secret
160
+ * @param options - Verification options
161
+ * @returns true if signature is valid and not expired
162
+ *
163
+ * @example
164
+ * ```typescript
165
+ * const isValid = await verifyBotSignature(
166
+ * request.headers.get('X-Signature'),
167
+ * request.headers.get('X-Timestamp'),
168
+ * request.headers.get('X-User-Id'),
169
+ * request.headers.get('X-User-Name'),
170
+ * env.BOT_SIGNING_SECRET
171
+ * );
172
+ * ```
173
+ */
174
+ export async function verifyBotSignature(
175
+ signature: string | undefined,
176
+ timestamp: string | undefined,
177
+ userDiscordId: string | undefined,
178
+ userName: string | undefined,
179
+ secret: string,
180
+ options: BotSignatureOptions = {}
181
+ ): Promise<boolean> {
182
+ const { maxAgeMs = 5 * 60 * 1000, clockSkewMs = 60 * 1000 } = options;
183
+
184
+ // Validate required fields
185
+ if (!signature || !timestamp || !userDiscordId || !userName) {
186
+ return false;
187
+ }
188
+
189
+ // Validate timestamp format
190
+ const timestampNum = parseInt(timestamp, 10);
191
+ if (isNaN(timestampNum)) {
192
+ return false;
193
+ }
194
+
195
+ // Check timestamp age (with clock skew tolerance)
196
+ const now = Date.now();
197
+ const signatureTime = timestampNum * 1000; // Convert to milliseconds
198
+ const age = now - signatureTime;
199
+
200
+ // Reject if too old
201
+ if (age > maxAgeMs) {
202
+ return false;
203
+ }
204
+
205
+ // Reject if too far in the future (clock skew protection)
206
+ if (signatureTime > now + clockSkewMs) {
207
+ return false;
208
+ }
209
+
210
+ // Verify the signature
211
+ const message = `${timestamp}:${userDiscordId}:${userName}`;
212
+ return hmacVerifyHex(message, signature, secret);
213
+ }