@xivdyetools/auth 1.0.3 → 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/dist/hmac.d.ts +6 -0
- package/dist/hmac.d.ts.map +1 -1
- package/dist/hmac.js +45 -4
- package/dist/hmac.js.map +1 -1
- package/dist/jwt.d.ts.map +1 -1
- package/dist/jwt.js +44 -51
- package/dist/jwt.js.map +1 -1
- package/package.json +2 -2
- package/src/hmac.test.ts +42 -28
- package/src/hmac.ts +55 -4
- package/src/jwt.test.ts +7 -7
- package/src/jwt.ts +60 -77
package/dist/hmac.d.ts
CHANGED
|
@@ -15,6 +15,12 @@ export interface BotSignatureOptions {
|
|
|
15
15
|
/** Allowed clock skew in milliseconds (default: 1 minute) */
|
|
16
16
|
clockSkewMs?: number;
|
|
17
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* Get or create a cached CryptoKey for the given secret and usage.
|
|
20
|
+
* Exported for use by jwt.ts — not part of the public package API.
|
|
21
|
+
* @internal
|
|
22
|
+
*/
|
|
23
|
+
export declare function getOrCreateHmacKey(secret: string, usage: 'sign' | 'verify' | 'both'): Promise<CryptoKey>;
|
|
18
24
|
/**
|
|
19
25
|
* Create an HMAC-SHA256 CryptoKey from a secret string.
|
|
20
26
|
*
|
package/dist/hmac.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hmac.d.ts","sourceRoot":"","sources":["../src/hmac.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AASH;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC,oEAAoE;IACpE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,6DAA6D;IAC7D,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,aAAa,CACjC,MAAM,EAAE,MAAM,EACd,KAAK,GAAE,MAAM,GAAG,QAAQ,GAAG,MAAe,GACzC,OAAO,CAAC,SAAS,CAAC,
|
|
1
|
+
{"version":3,"file":"hmac.d.ts","sourceRoot":"","sources":["../src/hmac.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AASH;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC,oEAAoE;IACpE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,6DAA6D;IAC7D,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAmBD;;;;GAIG;AACH,wBAAsB,kBAAkB,CACtC,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,MAAM,GAAG,QAAQ,GAAG,MAAM,GAChC,OAAO,CAAC,SAAS,CAAC,CAmBpB;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,aAAa,CACjC,MAAM,EAAE,MAAM,EACd,KAAK,GAAE,MAAM,GAAG,QAAQ,GAAG,MAAe,GACzC,OAAO,CAAC,SAAS,CAAC,CAmBpB;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAK5E;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,WAAW,CAC/B,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,MAAM,CAAC,CAKjB;AAED;;;;;;;GAOG;AACH,wBAAsB,UAAU,CAC9B,IAAI,EAAE,MAAM,EACZ,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,OAAO,CAAC,CAgBlB;AAED;;;;;;;GAOG;AACH,wBAAsB,aAAa,CACjC,IAAI,EAAE,MAAM,EACZ,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,OAAO,CAAC,CAelB;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAsB,kBAAkB,CACtC,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,aAAa,EAAE,MAAM,GAAG,SAAS,EACjC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,MAAM,EAAE,MAAM,EACd,OAAO,GAAE,mBAAwB,GAChC,OAAO,CAAC,OAAO,CAAC,CAiClB"}
|
package/dist/hmac.js
CHANGED
|
@@ -7,6 +7,43 @@
|
|
|
7
7
|
* @module hmac
|
|
8
8
|
*/
|
|
9
9
|
import { base64UrlEncodeBytes, base64UrlDecodeBytes, bytesToHex, hexToBytes, } from '@xivdyetools/crypto';
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// CryptoKey Cache (OPT-002)
|
|
12
|
+
// ============================================================================
|
|
13
|
+
/**
|
|
14
|
+
* Module-level cache for CryptoKeys.
|
|
15
|
+
*
|
|
16
|
+
* In Cloudflare Workers, module-level state persists across requests within
|
|
17
|
+
* an isolate, making this safe and effective. Eliminates redundant
|
|
18
|
+
* `crypto.subtle.importKey()` calls when the same secret is reused.
|
|
19
|
+
*
|
|
20
|
+
* Cache key format: `${secret}:${usage}` — bounded to 10 entries max
|
|
21
|
+
* to prevent unbounded growth during key rotation.
|
|
22
|
+
*/
|
|
23
|
+
const cryptoKeyCache = new Map();
|
|
24
|
+
const CRYPTO_KEY_CACHE_MAX = 10;
|
|
25
|
+
/**
|
|
26
|
+
* Get or create a cached CryptoKey for the given secret and usage.
|
|
27
|
+
* Exported for use by jwt.ts — not part of the public package API.
|
|
28
|
+
* @internal
|
|
29
|
+
*/
|
|
30
|
+
export async function getOrCreateHmacKey(secret, usage) {
|
|
31
|
+
const cacheKey = `${secret}:${usage}`;
|
|
32
|
+
const cached = cryptoKeyCache.get(cacheKey);
|
|
33
|
+
if (cached) {
|
|
34
|
+
return cached;
|
|
35
|
+
}
|
|
36
|
+
const key = await createHmacKey(secret, usage);
|
|
37
|
+
// Evict oldest entries if cache is full (simple FIFO)
|
|
38
|
+
if (cryptoKeyCache.size >= CRYPTO_KEY_CACHE_MAX) {
|
|
39
|
+
const firstKey = cryptoKeyCache.keys().next().value;
|
|
40
|
+
if (firstKey !== undefined) {
|
|
41
|
+
cryptoKeyCache.delete(firstKey);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
cryptoKeyCache.set(cacheKey, key);
|
|
45
|
+
return key;
|
|
46
|
+
}
|
|
10
47
|
/**
|
|
11
48
|
* Create an HMAC-SHA256 CryptoKey from a secret string.
|
|
12
49
|
*
|
|
@@ -22,6 +59,10 @@ import { base64UrlEncodeBytes, base64UrlDecodeBytes, bytesToHex, hexToBytes, } f
|
|
|
22
59
|
export async function createHmacKey(secret, usage = 'both') {
|
|
23
60
|
const encoder = new TextEncoder();
|
|
24
61
|
const keyData = encoder.encode(secret);
|
|
62
|
+
// FINDING-009: Enforce minimum key length for HMAC-SHA256 security
|
|
63
|
+
if (keyData.length < 32) {
|
|
64
|
+
throw new Error('HMAC secret must be at least 32 bytes (256 bits)');
|
|
65
|
+
}
|
|
25
66
|
const keyUsages = usage === 'both' ? ['sign', 'verify'] : [usage];
|
|
26
67
|
return crypto.subtle.importKey('raw', keyData, { name: 'HMAC', hash: 'SHA-256' }, false, keyUsages);
|
|
27
68
|
}
|
|
@@ -38,7 +79,7 @@ export async function createHmacKey(secret, usage = 'both') {
|
|
|
38
79
|
* ```
|
|
39
80
|
*/
|
|
40
81
|
export async function hmacSign(data, secret) {
|
|
41
|
-
const key = await
|
|
82
|
+
const key = await getOrCreateHmacKey(secret, 'sign');
|
|
42
83
|
const encoder = new TextEncoder();
|
|
43
84
|
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
|
44
85
|
return base64UrlEncodeBytes(new Uint8Array(signature));
|
|
@@ -56,7 +97,7 @@ export async function hmacSign(data, secret) {
|
|
|
56
97
|
* ```
|
|
57
98
|
*/
|
|
58
99
|
export async function hmacSignHex(data, secret) {
|
|
59
|
-
const key = await
|
|
100
|
+
const key = await getOrCreateHmacKey(secret, 'sign');
|
|
60
101
|
const encoder = new TextEncoder();
|
|
61
102
|
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
|
62
103
|
return bytesToHex(new Uint8Array(signature));
|
|
@@ -71,7 +112,7 @@ export async function hmacSignHex(data, secret) {
|
|
|
71
112
|
*/
|
|
72
113
|
export async function hmacVerify(data, signature, secret) {
|
|
73
114
|
try {
|
|
74
|
-
const key = await
|
|
115
|
+
const key = await getOrCreateHmacKey(secret, 'verify');
|
|
75
116
|
const encoder = new TextEncoder();
|
|
76
117
|
const signatureBytes = base64UrlDecodeBytes(signature);
|
|
77
118
|
// Use crypto.subtle.verify() which is inherently timing-safe
|
|
@@ -91,7 +132,7 @@ export async function hmacVerify(data, signature, secret) {
|
|
|
91
132
|
*/
|
|
92
133
|
export async function hmacVerifyHex(data, signature, secret) {
|
|
93
134
|
try {
|
|
94
|
-
const key = await
|
|
135
|
+
const key = await getOrCreateHmacKey(secret, 'verify');
|
|
95
136
|
const encoder = new TextEncoder();
|
|
96
137
|
const signatureBytes = hexToBytes(signature);
|
|
97
138
|
return crypto.subtle.verify('HMAC', key, signatureBytes, encoder.encode(data));
|
package/dist/hmac.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hmac.js","sourceRoot":"","sources":["../src/hmac.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EACL,oBAAoB,EACpB,oBAAoB,EACpB,UAAU,EACV,UAAU,GACX,MAAM,qBAAqB,CAAC;AAY7B;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CACjC,MAAc,EACd,QAAoC,MAAM;IAE1C,MAAM,OAAO,GAAG,IAAI,WAAW,EAAE,CAAC;IAClC,MAAM,OAAO,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IAEvC,MAAM,SAAS,GACb,KAAK,KAAK,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;IAElD,OAAO,MAAM,CAAC,MAAM,CAAC,SAAS,CAC5B,KAAK,EACL,OAAO,EACP,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,EACjC,KAAK,EACL,SAAS,CACV,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,KAAK,UAAU,QAAQ,CAAC,IAAY,EAAE,MAAc;IACzD,MAAM,GAAG,GAAG,MAAM,
|
|
1
|
+
{"version":3,"file":"hmac.js","sourceRoot":"","sources":["../src/hmac.ts"],"names":[],"mappings":"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"}
|
package/dist/jwt.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"jwt.d.ts","sourceRoot":"","sources":["../src/jwt.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAQH;;;;;GAKG;AACH,MAAM,WAAW,UAAU;IACzB,gCAAgC;IAChC,GAAG,EAAE,MAAM,CAAC;IACZ,oCAAoC;IACpC,GAAG,EAAE,MAAM,CAAC;IACZ,qCAAqC;IACrC,GAAG,EAAE,MAAM,CAAC;IACZ,wCAAwC;IACxC,IAAI,EAAE,QAAQ,GAAG,SAAS,CAAC;IAC3B,uBAAuB;IACvB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,0BAA0B;IAC1B,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACxB;AAUD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,UAAU,GAAG,IAAI,CAY1D;
|
|
1
|
+
{"version":3,"file":"jwt.d.ts","sourceRoot":"","sources":["../src/jwt.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAQH;;;;;GAKG;AACH,MAAM,WAAW,UAAU;IACzB,gCAAgC;IAChC,GAAG,EAAE,MAAM,CAAC;IACZ,oCAAoC;IACpC,GAAG,EAAE,MAAM,CAAC;IACZ,qCAAqC;IACrC,GAAG,EAAE,MAAM,CAAC;IACZ,wCAAwC;IACxC,IAAI,EAAE,QAAQ,GAAG,SAAS,CAAC;IAC3B,uBAAuB;IACvB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,0BAA0B;IAC1B,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACxB;AAUD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,UAAU,GAAG,IAAI,CAY1D;AAuDD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAsB,SAAS,CAC7B,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAe5B;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAsB,sBAAsB,CAC1C,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,MAAM,EACd,QAAQ,CAAC,EAAE,MAAM,GAChB,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAkB5B;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAOnD;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAOxD"}
|
package/dist/jwt.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* @module jwt
|
|
13
13
|
*/
|
|
14
14
|
import { base64UrlDecode, base64UrlDecodeBytes, } from '@xivdyetools/crypto';
|
|
15
|
-
import {
|
|
15
|
+
import { getOrCreateHmacKey } from './hmac.js';
|
|
16
16
|
/**
|
|
17
17
|
* Decode a JWT without verifying the signature.
|
|
18
18
|
*
|
|
@@ -41,6 +41,43 @@ export function decodeJWT(token) {
|
|
|
41
41
|
return null;
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* Shared JWT signature verification helper (REFACTOR-003).
|
|
46
|
+
*
|
|
47
|
+
* Validates token structure, ensures HS256 algorithm, and verifies
|
|
48
|
+
* HMAC-SHA256 signature. Used by both `verifyJWT()` and `verifyJWTSignatureOnly()`.
|
|
49
|
+
*
|
|
50
|
+
* @param token - The JWT string
|
|
51
|
+
* @param secret - The HMAC secret
|
|
52
|
+
* @returns Decoded payload if signature is valid, null otherwise
|
|
53
|
+
*/
|
|
54
|
+
async function verifyJWTSignature(token, secret) {
|
|
55
|
+
const parts = token.split('.');
|
|
56
|
+
if (parts.length !== 3) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
const [headerB64, payloadB64, signatureB64] = parts;
|
|
60
|
+
// Decode and validate header
|
|
61
|
+
const headerJson = base64UrlDecode(headerB64);
|
|
62
|
+
const header = JSON.parse(headerJson);
|
|
63
|
+
// SECURITY: Reject non-HS256 algorithms (prevents algorithm confusion attacks)
|
|
64
|
+
if (header.alg !== 'HS256') {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
// SECURITY: Verify signature using crypto.subtle.verify() which is
|
|
68
|
+
// inherently timing-safe (comparison happens in native crypto, not JS)
|
|
69
|
+
const signatureInput = `${headerB64}.${payloadB64}`;
|
|
70
|
+
const key = await getOrCreateHmacKey(secret, 'verify');
|
|
71
|
+
const encoder = new TextEncoder();
|
|
72
|
+
const signatureBytes = base64UrlDecodeBytes(signatureB64);
|
|
73
|
+
const isValid = await crypto.subtle.verify('HMAC', key, signatureBytes, encoder.encode(signatureInput));
|
|
74
|
+
if (!isValid) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
// Decode payload
|
|
78
|
+
const payloadJson = base64UrlDecode(payloadB64);
|
|
79
|
+
return JSON.parse(payloadJson);
|
|
80
|
+
}
|
|
44
81
|
/**
|
|
45
82
|
* Verify a JWT and return the payload if valid.
|
|
46
83
|
*
|
|
@@ -64,34 +101,12 @@ export function decodeJWT(token) {
|
|
|
64
101
|
*/
|
|
65
102
|
export async function verifyJWT(token, secret) {
|
|
66
103
|
try {
|
|
67
|
-
const
|
|
68
|
-
if (
|
|
69
|
-
return null;
|
|
70
|
-
}
|
|
71
|
-
const [headerB64, payloadB64, signatureB64] = parts;
|
|
72
|
-
// Decode and validate header
|
|
73
|
-
const headerJson = base64UrlDecode(headerB64);
|
|
74
|
-
const header = JSON.parse(headerJson);
|
|
75
|
-
// SECURITY: Reject non-HS256 algorithms (prevents algorithm confusion attacks)
|
|
76
|
-
if (header.alg !== 'HS256') {
|
|
104
|
+
const payload = await verifyJWTSignature(token, secret);
|
|
105
|
+
if (!payload)
|
|
77
106
|
return null;
|
|
78
|
-
|
|
79
|
-
// SECURITY: Verify signature using crypto.subtle.verify() which is
|
|
80
|
-
// inherently timing-safe (comparison happens in native crypto, not JS)
|
|
81
|
-
const signatureInput = `${headerB64}.${payloadB64}`;
|
|
82
|
-
const key = await createHmacKey(secret, 'verify');
|
|
83
|
-
const encoder = new TextEncoder();
|
|
84
|
-
const signatureBytes = base64UrlDecodeBytes(signatureB64);
|
|
85
|
-
const isValid = await crypto.subtle.verify('HMAC', key, signatureBytes, encoder.encode(signatureInput));
|
|
86
|
-
if (!isValid) {
|
|
87
|
-
return null;
|
|
88
|
-
}
|
|
89
|
-
// Decode payload
|
|
90
|
-
const payloadJson = base64UrlDecode(payloadB64);
|
|
91
|
-
const payload = JSON.parse(payloadJson);
|
|
92
|
-
// Check expiration
|
|
107
|
+
// FINDING-003: Require exp claim — tokens without expiration are rejected
|
|
93
108
|
const now = Math.floor(Date.now() / 1000);
|
|
94
|
-
if (payload.exp
|
|
109
|
+
if (!payload.exp || payload.exp < now) {
|
|
95
110
|
return null;
|
|
96
111
|
}
|
|
97
112
|
return payload;
|
|
@@ -123,31 +138,9 @@ export async function verifyJWT(token, secret) {
|
|
|
123
138
|
*/
|
|
124
139
|
export async function verifyJWTSignatureOnly(token, secret, maxAgeMs) {
|
|
125
140
|
try {
|
|
126
|
-
const
|
|
127
|
-
if (
|
|
128
|
-
return null;
|
|
129
|
-
}
|
|
130
|
-
const [headerB64, payloadB64, signatureB64] = parts;
|
|
131
|
-
// Decode and validate header
|
|
132
|
-
const headerJson = base64UrlDecode(headerB64);
|
|
133
|
-
const header = JSON.parse(headerJson);
|
|
134
|
-
// SECURITY: Still reject non-HS256 algorithms
|
|
135
|
-
if (header.alg !== 'HS256') {
|
|
141
|
+
const payload = await verifyJWTSignature(token, secret);
|
|
142
|
+
if (!payload)
|
|
136
143
|
return null;
|
|
137
|
-
}
|
|
138
|
-
// SECURITY: Verify signature using crypto.subtle.verify() which is
|
|
139
|
-
// inherently timing-safe (comparison happens in native crypto, not JS)
|
|
140
|
-
const signatureInput = `${headerB64}.${payloadB64}`;
|
|
141
|
-
const key = await createHmacKey(secret, 'verify');
|
|
142
|
-
const encoder = new TextEncoder();
|
|
143
|
-
const signatureBytes = base64UrlDecodeBytes(signatureB64);
|
|
144
|
-
const isValid = await crypto.subtle.verify('HMAC', key, signatureBytes, encoder.encode(signatureInput));
|
|
145
|
-
if (!isValid) {
|
|
146
|
-
return null;
|
|
147
|
-
}
|
|
148
|
-
// Decode payload
|
|
149
|
-
const payloadJson = base64UrlDecode(payloadB64);
|
|
150
|
-
const payload = JSON.parse(payloadJson);
|
|
151
144
|
// Check max age if specified
|
|
152
145
|
if (maxAgeMs !== undefined && payload.iat) {
|
|
153
146
|
const now = Date.now();
|
package/dist/jwt.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"jwt.js","sourceRoot":"","sources":["../src/jwt.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EACL,eAAe,EACf,oBAAoB,GACrB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"jwt.js","sourceRoot":"","sources":["../src/jwt.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xivdyetools/auth",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Shared authentication utilities for xivdyetools ecosystem",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
],
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"discord-interactions": "^4.4.0",
|
|
36
|
-
"@xivdyetools/crypto": "1.
|
|
36
|
+
"@xivdyetools/crypto": "1.1.0"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@cloudflare/workers-types": "^4.20260207.0",
|
package/src/hmac.test.ts
CHANGED
|
@@ -12,33 +12,49 @@ import {
|
|
|
12
12
|
} from './hmac.js';
|
|
13
13
|
|
|
14
14
|
describe('hmac.ts', () => {
|
|
15
|
+
// FINDING-009: All test secrets must be at least 32 bytes
|
|
16
|
+
const TEST_SECRET = 'test-secret-that-is-at-least-32-bytes!';
|
|
17
|
+
const TEST_SECRET_2 = 'another-secret-at-least-32-bytes-long!';
|
|
18
|
+
|
|
15
19
|
describe('createHmacKey', () => {
|
|
16
20
|
it('should create a CryptoKey for signing', async () => {
|
|
17
|
-
const key = await createHmacKey(
|
|
21
|
+
const key = await createHmacKey(TEST_SECRET, 'sign');
|
|
18
22
|
expect(key).toBeDefined();
|
|
19
23
|
expect(key.algorithm.name).toBe('HMAC');
|
|
20
24
|
});
|
|
21
25
|
|
|
22
26
|
it('should create a CryptoKey for verification', async () => {
|
|
23
|
-
const key = await createHmacKey(
|
|
27
|
+
const key = await createHmacKey(TEST_SECRET, 'verify');
|
|
24
28
|
expect(key).toBeDefined();
|
|
25
29
|
expect(key.algorithm.name).toBe('HMAC');
|
|
26
30
|
});
|
|
27
31
|
|
|
28
32
|
it('should create a CryptoKey for both operations', async () => {
|
|
29
|
-
const key = await createHmacKey(
|
|
33
|
+
const key = await createHmacKey(TEST_SECRET, 'both');
|
|
30
34
|
expect(key).toBeDefined();
|
|
31
35
|
});
|
|
32
36
|
|
|
33
37
|
it('should default to both operations', async () => {
|
|
34
|
-
const key = await createHmacKey(
|
|
38
|
+
const key = await createHmacKey(TEST_SECRET);
|
|
35
39
|
expect(key).toBeDefined();
|
|
36
40
|
});
|
|
41
|
+
|
|
42
|
+
it('should reject secrets shorter than 32 bytes (FINDING-009)', async () => {
|
|
43
|
+
await expect(createHmacKey('short-secret', 'sign')).rejects.toThrow(
|
|
44
|
+
'HMAC secret must be at least 32 bytes'
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should reject empty string secret (FINDING-009)', async () => {
|
|
49
|
+
await expect(createHmacKey('', 'sign')).rejects.toThrow(
|
|
50
|
+
'HMAC secret must be at least 32 bytes'
|
|
51
|
+
);
|
|
52
|
+
});
|
|
37
53
|
});
|
|
38
54
|
|
|
39
55
|
describe('hmacSign', () => {
|
|
40
56
|
it('should return a base64url-encoded signature', async () => {
|
|
41
|
-
const signature = await hmacSign('test-data',
|
|
57
|
+
const signature = await hmacSign('test-data', TEST_SECRET);
|
|
42
58
|
expect(signature).toBeDefined();
|
|
43
59
|
expect(typeof signature).toBe('string');
|
|
44
60
|
// Base64URL should not contain + or /
|
|
@@ -46,27 +62,27 @@ describe('hmac.ts', () => {
|
|
|
46
62
|
});
|
|
47
63
|
|
|
48
64
|
it('should produce consistent signatures for same input', async () => {
|
|
49
|
-
const sig1 = await hmacSign('test-data',
|
|
50
|
-
const sig2 = await hmacSign('test-data',
|
|
65
|
+
const sig1 = await hmacSign('test-data', TEST_SECRET);
|
|
66
|
+
const sig2 = await hmacSign('test-data', TEST_SECRET);
|
|
51
67
|
expect(sig1).toBe(sig2);
|
|
52
68
|
});
|
|
53
69
|
|
|
54
70
|
it('should produce different signatures for different data', async () => {
|
|
55
|
-
const sig1 = await hmacSign('data1',
|
|
56
|
-
const sig2 = await hmacSign('data2',
|
|
71
|
+
const sig1 = await hmacSign('data1', TEST_SECRET);
|
|
72
|
+
const sig2 = await hmacSign('data2', TEST_SECRET);
|
|
57
73
|
expect(sig1).not.toBe(sig2);
|
|
58
74
|
});
|
|
59
75
|
|
|
60
76
|
it('should produce different signatures for different secrets', async () => {
|
|
61
|
-
const sig1 = await hmacSign('test-data',
|
|
62
|
-
const sig2 = await hmacSign('test-data',
|
|
77
|
+
const sig1 = await hmacSign('test-data', TEST_SECRET);
|
|
78
|
+
const sig2 = await hmacSign('test-data', TEST_SECRET_2);
|
|
63
79
|
expect(sig1).not.toBe(sig2);
|
|
64
80
|
});
|
|
65
81
|
});
|
|
66
82
|
|
|
67
83
|
describe('hmacSignHex', () => {
|
|
68
84
|
it('should return a hex-encoded signature', async () => {
|
|
69
|
-
const signature = await hmacSignHex('test-data',
|
|
85
|
+
const signature = await hmacSignHex('test-data', TEST_SECRET);
|
|
70
86
|
expect(signature).toBeDefined();
|
|
71
87
|
expect(typeof signature).toBe('string');
|
|
72
88
|
// Should only contain hex characters
|
|
@@ -74,8 +90,8 @@ describe('hmac.ts', () => {
|
|
|
74
90
|
});
|
|
75
91
|
|
|
76
92
|
it('should produce consistent signatures', async () => {
|
|
77
|
-
const sig1 = await hmacSignHex('test-data',
|
|
78
|
-
const sig2 = await hmacSignHex('test-data',
|
|
93
|
+
const sig1 = await hmacSignHex('test-data', TEST_SECRET);
|
|
94
|
+
const sig2 = await hmacSignHex('test-data', TEST_SECRET);
|
|
79
95
|
expect(sig1).toBe(sig2);
|
|
80
96
|
});
|
|
81
97
|
});
|
|
@@ -83,27 +99,26 @@ describe('hmac.ts', () => {
|
|
|
83
99
|
describe('hmacVerify', () => {
|
|
84
100
|
it('should return true for valid signature', async () => {
|
|
85
101
|
const data = 'test-data';
|
|
86
|
-
const
|
|
87
|
-
const
|
|
88
|
-
const isValid = await hmacVerify(data, signature, secret);
|
|
102
|
+
const signature = await hmacSign(data, TEST_SECRET);
|
|
103
|
+
const isValid = await hmacVerify(data, signature, TEST_SECRET);
|
|
89
104
|
expect(isValid).toBe(true);
|
|
90
105
|
});
|
|
91
106
|
|
|
92
107
|
it('should return false for invalid signature', async () => {
|
|
93
|
-
const isValid = await hmacVerify('test-data', 'invalid-signature',
|
|
108
|
+
const isValid = await hmacVerify('test-data', 'invalid-signature', TEST_SECRET);
|
|
94
109
|
expect(isValid).toBe(false);
|
|
95
110
|
});
|
|
96
111
|
|
|
97
112
|
it('should return false for wrong secret', async () => {
|
|
98
113
|
const data = 'test-data';
|
|
99
|
-
const signature = await hmacSign(data,
|
|
100
|
-
const isValid = await hmacVerify(data, signature,
|
|
114
|
+
const signature = await hmacSign(data, TEST_SECRET);
|
|
115
|
+
const isValid = await hmacVerify(data, signature, TEST_SECRET_2);
|
|
101
116
|
expect(isValid).toBe(false);
|
|
102
117
|
});
|
|
103
118
|
|
|
104
119
|
it('should return false for tampered data', async () => {
|
|
105
|
-
const signature = await hmacSign('original-data',
|
|
106
|
-
const isValid = await hmacVerify('tampered-data', signature,
|
|
120
|
+
const signature = await hmacSign('original-data', TEST_SECRET);
|
|
121
|
+
const isValid = await hmacVerify('tampered-data', signature, TEST_SECRET);
|
|
107
122
|
expect(isValid).toBe(false);
|
|
108
123
|
});
|
|
109
124
|
});
|
|
@@ -111,25 +126,24 @@ describe('hmac.ts', () => {
|
|
|
111
126
|
describe('hmacVerifyHex', () => {
|
|
112
127
|
it('should return true for valid hex signature', async () => {
|
|
113
128
|
const data = 'test-data';
|
|
114
|
-
const
|
|
115
|
-
const
|
|
116
|
-
const isValid = await hmacVerifyHex(data, signature, secret);
|
|
129
|
+
const signature = await hmacSignHex(data, TEST_SECRET);
|
|
130
|
+
const isValid = await hmacVerifyHex(data, signature, TEST_SECRET);
|
|
117
131
|
expect(isValid).toBe(true);
|
|
118
132
|
});
|
|
119
133
|
|
|
120
134
|
it('should return false for invalid hex signature', async () => {
|
|
121
|
-
const isValid = await hmacVerifyHex('test-data', '
|
|
135
|
+
const isValid = await hmacVerifyHex('test-data', 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef', TEST_SECRET);
|
|
122
136
|
expect(isValid).toBe(false);
|
|
123
137
|
});
|
|
124
138
|
|
|
125
139
|
it('should return false for malformed hex', async () => {
|
|
126
|
-
const isValid = await hmacVerifyHex('test-data', 'not-hex!',
|
|
140
|
+
const isValid = await hmacVerifyHex('test-data', 'not-hex!', TEST_SECRET);
|
|
127
141
|
expect(isValid).toBe(false);
|
|
128
142
|
});
|
|
129
143
|
});
|
|
130
144
|
|
|
131
145
|
describe('verifyBotSignature', () => {
|
|
132
|
-
const secret = 'bot-signing-secret';
|
|
146
|
+
const secret = 'bot-signing-secret-at-least-32-bytes!!';
|
|
133
147
|
|
|
134
148
|
beforeEach(() => {
|
|
135
149
|
vi.useFakeTimers();
|
package/src/hmac.ts
CHANGED
|
@@ -24,6 +24,52 @@ export interface BotSignatureOptions {
|
|
|
24
24
|
clockSkewMs?: number;
|
|
25
25
|
}
|
|
26
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
|
+
|
|
27
73
|
/**
|
|
28
74
|
* Create an HMAC-SHA256 CryptoKey from a secret string.
|
|
29
75
|
*
|
|
@@ -43,6 +89,11 @@ export async function createHmacKey(
|
|
|
43
89
|
const encoder = new TextEncoder();
|
|
44
90
|
const keyData = encoder.encode(secret);
|
|
45
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
|
+
|
|
46
97
|
const keyUsages: ('sign' | 'verify')[] =
|
|
47
98
|
usage === 'both' ? ['sign', 'verify'] : [usage];
|
|
48
99
|
|
|
@@ -68,7 +119,7 @@ export async function createHmacKey(
|
|
|
68
119
|
* ```
|
|
69
120
|
*/
|
|
70
121
|
export async function hmacSign(data: string, secret: string): Promise<string> {
|
|
71
|
-
const key = await
|
|
122
|
+
const key = await getOrCreateHmacKey(secret, 'sign');
|
|
72
123
|
const encoder = new TextEncoder();
|
|
73
124
|
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
|
74
125
|
return base64UrlEncodeBytes(new Uint8Array(signature));
|
|
@@ -90,7 +141,7 @@ export async function hmacSignHex(
|
|
|
90
141
|
data: string,
|
|
91
142
|
secret: string
|
|
92
143
|
): Promise<string> {
|
|
93
|
-
const key = await
|
|
144
|
+
const key = await getOrCreateHmacKey(secret, 'sign');
|
|
94
145
|
const encoder = new TextEncoder();
|
|
95
146
|
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
|
96
147
|
return bytesToHex(new Uint8Array(signature));
|
|
@@ -110,7 +161,7 @@ export async function hmacVerify(
|
|
|
110
161
|
secret: string
|
|
111
162
|
): Promise<boolean> {
|
|
112
163
|
try {
|
|
113
|
-
const key = await
|
|
164
|
+
const key = await getOrCreateHmacKey(secret, 'verify');
|
|
114
165
|
const encoder = new TextEncoder();
|
|
115
166
|
const signatureBytes = base64UrlDecodeBytes(signature);
|
|
116
167
|
|
|
@@ -140,7 +191,7 @@ export async function hmacVerifyHex(
|
|
|
140
191
|
secret: string
|
|
141
192
|
): Promise<boolean> {
|
|
142
193
|
try {
|
|
143
|
-
const key = await
|
|
194
|
+
const key = await getOrCreateHmacKey(secret, 'verify');
|
|
144
195
|
const encoder = new TextEncoder();
|
|
145
196
|
const signatureBytes = hexToBytes(signature);
|
|
146
197
|
|
package/src/jwt.test.ts
CHANGED
|
@@ -36,7 +36,7 @@ async function createTestJWT(
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
describe('jwt.ts', () => {
|
|
39
|
-
const secret = 'test-jwt-secret-key-
|
|
39
|
+
const secret = 'test-jwt-secret-key-that-is-at-least-32-bytes!';
|
|
40
40
|
|
|
41
41
|
beforeEach(() => {
|
|
42
42
|
vi.useFakeTimers();
|
|
@@ -125,7 +125,7 @@ describe('jwt.ts', () => {
|
|
|
125
125
|
};
|
|
126
126
|
const token = await createTestJWT(payload, secret);
|
|
127
127
|
|
|
128
|
-
const verified = await verifyJWT(token, 'wrong-secret');
|
|
128
|
+
const verified = await verifyJWT(token, 'wrong-secret-that-is-at-least-32-bytes!!');
|
|
129
129
|
|
|
130
130
|
expect(verified).toBeNull();
|
|
131
131
|
});
|
|
@@ -170,8 +170,8 @@ describe('jwt.ts', () => {
|
|
|
170
170
|
expect(verified).toBeNull();
|
|
171
171
|
});
|
|
172
172
|
|
|
173
|
-
it('should
|
|
174
|
-
//
|
|
173
|
+
it('should reject token without exp claim (FINDING-003)', async () => {
|
|
174
|
+
// FINDING-003: Tokens without exp claim must be rejected
|
|
175
175
|
const header = { alg: 'HS256', typ: 'JWT' };
|
|
176
176
|
const payload = {
|
|
177
177
|
sub: '123456789',
|
|
@@ -192,8 +192,8 @@ describe('jwt.ts', () => {
|
|
|
192
192
|
|
|
193
193
|
const verified = await verifyJWT(token, secret);
|
|
194
194
|
|
|
195
|
-
//
|
|
196
|
-
expect(verified).
|
|
195
|
+
// FINDING-003: No exp means token is rejected
|
|
196
|
+
expect(verified).toBeNull();
|
|
197
197
|
});
|
|
198
198
|
});
|
|
199
199
|
|
|
@@ -222,7 +222,7 @@ describe('jwt.ts', () => {
|
|
|
222
222
|
};
|
|
223
223
|
const token = await createTestJWT(payload, secret);
|
|
224
224
|
|
|
225
|
-
const verified = await verifyJWTSignatureOnly(token, 'wrong-secret');
|
|
225
|
+
const verified = await verifyJWTSignatureOnly(token, 'wrong-secret-that-is-at-least-32-bytes!!');
|
|
226
226
|
|
|
227
227
|
expect(verified).toBeNull();
|
|
228
228
|
});
|
package/src/jwt.ts
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
base64UrlDecode,
|
|
17
17
|
base64UrlDecodeBytes,
|
|
18
18
|
} from '@xivdyetools/crypto';
|
|
19
|
-
import {
|
|
19
|
+
import { getOrCreateHmacKey } from './hmac.js';
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* JWT payload structure
|
|
@@ -76,6 +76,59 @@ export function decodeJWT(token: string): JWTPayload | null {
|
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Shared JWT signature verification helper (REFACTOR-003).
|
|
81
|
+
*
|
|
82
|
+
* Validates token structure, ensures HS256 algorithm, and verifies
|
|
83
|
+
* HMAC-SHA256 signature. Used by both `verifyJWT()` and `verifyJWTSignatureOnly()`.
|
|
84
|
+
*
|
|
85
|
+
* @param token - The JWT string
|
|
86
|
+
* @param secret - The HMAC secret
|
|
87
|
+
* @returns Decoded payload if signature is valid, null otherwise
|
|
88
|
+
*/
|
|
89
|
+
async function verifyJWTSignature(
|
|
90
|
+
token: string,
|
|
91
|
+
secret: string
|
|
92
|
+
): Promise<JWTPayload | null> {
|
|
93
|
+
const parts = token.split('.');
|
|
94
|
+
if (parts.length !== 3) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const [headerB64, payloadB64, signatureB64] = parts;
|
|
99
|
+
|
|
100
|
+
// Decode and validate header
|
|
101
|
+
const headerJson = base64UrlDecode(headerB64);
|
|
102
|
+
const header: JWTHeader = JSON.parse(headerJson) as JWTHeader;
|
|
103
|
+
|
|
104
|
+
// SECURITY: Reject non-HS256 algorithms (prevents algorithm confusion attacks)
|
|
105
|
+
if (header.alg !== 'HS256') {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// SECURITY: Verify signature using crypto.subtle.verify() which is
|
|
110
|
+
// inherently timing-safe (comparison happens in native crypto, not JS)
|
|
111
|
+
const signatureInput = `${headerB64}.${payloadB64}`;
|
|
112
|
+
const key = await getOrCreateHmacKey(secret, 'verify');
|
|
113
|
+
const encoder = new TextEncoder();
|
|
114
|
+
const signatureBytes = base64UrlDecodeBytes(signatureB64);
|
|
115
|
+
|
|
116
|
+
const isValid = await crypto.subtle.verify(
|
|
117
|
+
'HMAC',
|
|
118
|
+
key,
|
|
119
|
+
signatureBytes,
|
|
120
|
+
encoder.encode(signatureInput)
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
if (!isValid) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Decode payload
|
|
128
|
+
const payloadJson = base64UrlDecode(payloadB64);
|
|
129
|
+
return JSON.parse(payloadJson) as JWTPayload;
|
|
130
|
+
}
|
|
131
|
+
|
|
79
132
|
/**
|
|
80
133
|
* Verify a JWT and return the payload if valid.
|
|
81
134
|
*
|
|
@@ -102,47 +155,12 @@ export async function verifyJWT(
|
|
|
102
155
|
secret: string
|
|
103
156
|
): Promise<JWTPayload | null> {
|
|
104
157
|
try {
|
|
105
|
-
const
|
|
106
|
-
if (
|
|
107
|
-
return null;
|
|
108
|
-
}
|
|
158
|
+
const payload = await verifyJWTSignature(token, secret);
|
|
159
|
+
if (!payload) return null;
|
|
109
160
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
// Decode and validate header
|
|
113
|
-
const headerJson = base64UrlDecode(headerB64);
|
|
114
|
-
const header: JWTHeader = JSON.parse(headerJson) as JWTHeader;
|
|
115
|
-
|
|
116
|
-
// SECURITY: Reject non-HS256 algorithms (prevents algorithm confusion attacks)
|
|
117
|
-
if (header.alg !== 'HS256') {
|
|
118
|
-
return null;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// SECURITY: Verify signature using crypto.subtle.verify() which is
|
|
122
|
-
// inherently timing-safe (comparison happens in native crypto, not JS)
|
|
123
|
-
const signatureInput = `${headerB64}.${payloadB64}`;
|
|
124
|
-
const key = await createHmacKey(secret, 'verify');
|
|
125
|
-
const encoder = new TextEncoder();
|
|
126
|
-
const signatureBytes = base64UrlDecodeBytes(signatureB64);
|
|
127
|
-
|
|
128
|
-
const isValid = await crypto.subtle.verify(
|
|
129
|
-
'HMAC',
|
|
130
|
-
key,
|
|
131
|
-
signatureBytes,
|
|
132
|
-
encoder.encode(signatureInput)
|
|
133
|
-
);
|
|
134
|
-
|
|
135
|
-
if (!isValid) {
|
|
136
|
-
return null;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// Decode payload
|
|
140
|
-
const payloadJson = base64UrlDecode(payloadB64);
|
|
141
|
-
const payload: JWTPayload = JSON.parse(payloadJson) as JWTPayload;
|
|
142
|
-
|
|
143
|
-
// Check expiration
|
|
161
|
+
// FINDING-003: Require exp claim — tokens without expiration are rejected
|
|
144
162
|
const now = Math.floor(Date.now() / 1000);
|
|
145
|
-
if (payload.exp
|
|
163
|
+
if (!payload.exp || payload.exp < now) {
|
|
146
164
|
return null;
|
|
147
165
|
}
|
|
148
166
|
|
|
@@ -179,43 +197,8 @@ export async function verifyJWTSignatureOnly(
|
|
|
179
197
|
maxAgeMs?: number
|
|
180
198
|
): Promise<JWTPayload | null> {
|
|
181
199
|
try {
|
|
182
|
-
const
|
|
183
|
-
if (
|
|
184
|
-
return null;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
const [headerB64, payloadB64, signatureB64] = parts;
|
|
188
|
-
|
|
189
|
-
// Decode and validate header
|
|
190
|
-
const headerJson = base64UrlDecode(headerB64);
|
|
191
|
-
const header: JWTHeader = JSON.parse(headerJson) as JWTHeader;
|
|
192
|
-
|
|
193
|
-
// SECURITY: Still reject non-HS256 algorithms
|
|
194
|
-
if (header.alg !== 'HS256') {
|
|
195
|
-
return null;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// SECURITY: Verify signature using crypto.subtle.verify() which is
|
|
199
|
-
// inherently timing-safe (comparison happens in native crypto, not JS)
|
|
200
|
-
const signatureInput = `${headerB64}.${payloadB64}`;
|
|
201
|
-
const key = await createHmacKey(secret, 'verify');
|
|
202
|
-
const encoder = new TextEncoder();
|
|
203
|
-
const signatureBytes = base64UrlDecodeBytes(signatureB64);
|
|
204
|
-
|
|
205
|
-
const isValid = await crypto.subtle.verify(
|
|
206
|
-
'HMAC',
|
|
207
|
-
key,
|
|
208
|
-
signatureBytes,
|
|
209
|
-
encoder.encode(signatureInput)
|
|
210
|
-
);
|
|
211
|
-
|
|
212
|
-
if (!isValid) {
|
|
213
|
-
return null;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// Decode payload
|
|
217
|
-
const payloadJson = base64UrlDecode(payloadB64);
|
|
218
|
-
const payload: JWTPayload = JSON.parse(payloadJson) as JWTPayload;
|
|
200
|
+
const payload = await verifyJWTSignature(token, secret);
|
|
201
|
+
if (!payload) return null;
|
|
219
202
|
|
|
220
203
|
// Check max age if specified
|
|
221
204
|
if (maxAgeMs !== undefined && payload.iat) {
|