@techiev2/vajra 1.4.0 → 1.4.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/README CHANGED
@@ -22,7 +22,10 @@ Like the Vajra, this server delivers maximum power in minimal form.
22
22
 
23
23
  ## Changelog
24
24
 
25
- ### 1.4.0 (Current)
25
+ ### 1.4.1 (Current)
26
+ - Added support to handle drift in system time after signing
27
+
28
+ ### 1.4.0 (2025-12-31)
26
29
  - Added full HS256 JWT support (`@techiev2/vajra/libs/auth/jwt.js`)
27
30
  - Ultra-minimal, zero-dependency implementation
28
31
  - Key and header caching for maximum performance
package/README.md CHANGED
@@ -22,7 +22,10 @@ Like the Vajra, this server delivers maximum power in minimal form.
22
22
 
23
23
  ## Changelog
24
24
 
25
- ### 1.4.0 (Current)
25
+ ### 1.4.1 (Current)
26
+ - Added support to handle drift in system time after signing
27
+
28
+ ### 1.4.0 (2025-12-31)
26
29
  - Added full HS256 JWT support (`@techiev2/vajra/libs/auth/jwt.js`)
27
30
  - Ultra-minimal, zero-dependency implementation
28
31
  - Key and header caching for maximum performance
package/libs/auth/jwt.js CHANGED
@@ -11,7 +11,7 @@ async function populateKeyCache(secret) {
11
11
  export async function sign(payload, secret, options = {}) {
12
12
  if (!payload || typeof payload !== 'object') throw new Error("Payload to sign must be an object"); if (typeof secret !== 'string') throw new Error("Secret must be a string")
13
13
  const { signKey } = await populateKeyCache(secret); const { alg = 'HS256' } = options;
14
- ENCODED_HEADERS[alg] = ENCODED_HEADERS[alg] || DATA.encoder.encode({ ...DATA.headers.sign, alg }); const encodedPayload = encode(DATA.encoder.encode(JSON.stringify(payload)))
14
+ ENCODED_HEADERS[alg] = ENCODED_HEADERS[alg] || DATA.encoder.encode({ ...DATA.headers.sign, alg }); const encodedPayload = encode(DATA.encoder.encode(JSON.stringify({ ...payload, _gen_ts: Math.floor(Date.now() / 1000) })))
15
15
  const encodedSignature = encode(await subtle.sign('HMAC', signKey, DATA.encoder.encode(`${ENCODED_HEADERS[alg]}.${encodedPayload}`))); return `${ENCODED_HEADERS[alg]}.${encodedPayload}.${encodedSignature}`;
16
16
  }
17
17
  export async function verify(token, secret) {
@@ -19,5 +19,7 @@ export async function verify(token, secret) {
19
19
  const [encodedHeader, encodedPayload, encodedSignature, ..._] = token.split('.'); if (!encodedPayload || !encodedSignature || _.length) throw new Error('Invalid token format');
20
20
  const { verifyKey } = await populateKeyCache(secret); const valid = await subtle.verify('HMAC', verifyKey, decode(encodedSignature), DATA.encoder.encode(`${encodedHeader}.${encodedPayload}`));
21
21
  if (!valid) throw new Error('Invalid signature'); const payload = JSON.parse(DATA.decoder.decode(decode(encodedPayload)));
22
- if (payload.exp && isNaN(+payload.exp)) throw new Error("Expiry must be numeric."); if (payload.exp && Date.now() >= payload.exp * 1000) { throw new Error('Token expired'); } return payload;
22
+ const genTs = payload._gen_ts; const now = Math.floor(Date.now() / 1000); const maxBackwardDrift = 300;
23
+ if (typeof genTs === 'number' && (now + maxBackwardDrift < genTs)) { throw new Error('System clock appears to have moved backward — token rejected'); }
24
+ delete payload._gen_ts; if (payload.exp && isNaN(+payload.exp)) throw new Error("Expiry must be numeric."); if (payload.exp && Date.now() >= payload.exp * 1000) { throw new Error('Token expired'); } return payload;
23
25
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@techiev2/vajra",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "Blazing-fast, zero-dependency Node.js server with routing, middleware, multipart uploads, and templating. 111 lines · ~95k req/s · ~52 MB idle.",
5
5
  "keywords": [
6
6
  "http-server",
package/tests/tests.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import assert from 'node:assert';
2
2
  import { encode } from 'node:querystring';
3
- import { suite, test } from 'node:test';
3
+ import { afterEach, beforeEach, suite, test } from 'node:test';
4
4
  import { randomBytes, randomUUID } from 'node:crypto';
5
5
 
6
6
  import pkg from '../package.json' with {type: 'json'}
@@ -17,6 +17,7 @@ async function getJSON(url, method = 'GET', body) {
17
17
  return (await getResponse(url, method, body)).json()
18
18
  }
19
19
 
20
+
20
21
  suite('Test HTTP API at port 4002', () => {
21
22
  const BASE_URL = 'http://localhost:4002'
22
23
  suite('Test HTTP GET', () => {
@@ -64,7 +65,18 @@ suite('Test HTTP API at port 4002', () => {
64
65
  })
65
66
 
66
67
  suite('Tests for library functions', () => {
67
- const encoder = new TextEncoder(); const signHeaders = { alg: 'HS256', typ: 'JWT' }
68
+ const encoder = new TextEncoder(); const signHeaders = { alg: 'HS256', typ: 'JWT' }; let timeOffsetMs = 0;
69
+ // const originalDateNow = Date.now;
70
+ // Date.now = function () { return originalDateNow() + timeOffsetMs; };
71
+ function setupMockTimer(timing) {
72
+ const originalNow = globalThis.Date.now
73
+ let timeDelta = timing
74
+ globalThis.Date.now = () => { return originalNow() + (timeDelta || 0) }
75
+ }
76
+ async function withMockTimer(timeDelta, fn) {
77
+ setupMockTimer(timeDelta)
78
+ await fn()
79
+ }
68
80
  test('Verify that JWT helper returns the right token', async () => {
69
81
  const secret = randomBytes(16).toString('hex')
70
82
  const data = {
@@ -99,7 +111,7 @@ suite('Tests for library functions', () => {
99
111
  await assert.rejects(verify('a..c', secret),);
100
112
  await assert.rejects(verify('a.b.', secret),);
101
113
  });
102
-
114
+
103
115
  test('rejects tampered signature', async () => {
104
116
  const token = await sign({a:1}, 'secret');
105
117
  const tampered = token.slice(0, -1) + (token.at(-1) === 'A' ? 'B' : 'A');
@@ -120,7 +132,7 @@ suite('Tests for library functions', () => {
120
132
  const verified = await verify(token, secret);
121
133
  assert.deepStrictEqual(verified, payload);
122
134
  });
123
-
135
+
124
136
  test('rejects token with future exp after clock advances', async () => {
125
137
  const secret = 's';
126
138
  const past = Math.floor(Date.now() / 1000) - 3600;
@@ -128,7 +140,7 @@ suite('Tests for library functions', () => {
128
140
  const token = await sign(payload, secret);
129
141
  await assert.rejects(verify(token, secret), /Token expired/);
130
142
  });
131
-
143
+
132
144
  test('handles large payload without truncation', async () => {
133
145
  const secret = 's';
134
146
  const large = { data: 'x'.repeat(5000), arr: Array(100).fill(42) };
@@ -144,13 +156,13 @@ suite('Tests for library functions', () => {
144
156
  const badToken = `${header}.${badPayload}.${fakeSig}`;
145
157
  await assert.rejects(verify(badToken, 'secret'), /Invalid signature/);
146
158
  });
147
-
159
+
148
160
  test('rejects non-object payload on sign', async () => {
149
161
  await assert.rejects(sign('string', 'secret'));
150
162
  await assert.rejects(sign(null, 'secret'));
151
163
  await assert.rejects(sign(123, 'secret'));
152
164
  });
153
-
165
+
154
166
  // Add to existing suite
155
167
  test('rejects non-numeric exp claim', async () => {
156
168
  const secret = 's';
@@ -158,7 +170,7 @@ suite('Tests for library functions', () => {
158
170
  const token = await sign(payload, secret);
159
171
  await assert.rejects(verify(token, secret), /Expiry must be numeric/);
160
172
  });
161
-
173
+
162
174
  test('handles clock skew edge case', async () => {
163
175
  const secret = 's';
164
176
  const now = Math.floor(Date.now() / 1000);
@@ -167,7 +179,7 @@ suite('Tests for library functions', () => {
167
179
  await new Promise(resolve => setTimeout(resolve, 2000)); // Wait 2s
168
180
  await assert.rejects(verify(token, secret), /Token expired/);
169
181
  });
170
-
182
+
171
183
  test('rejects malformed base64url segments', async () => {
172
184
  const secret = 's';
173
185
  const header = encode(encoder.encode(JSON.stringify(signHeaders)));
@@ -176,7 +188,7 @@ suite('Tests for library functions', () => {
176
188
  await assert.rejects(verify(`${header}.${badSegment}.AAAA`, secret), /Invalid signature/);
177
189
  await assert.rejects(verify(`${header}.${validPayload}.!!invalid@@`, secret), /Invalid signature/);
178
190
  });
179
-
191
+
180
192
  test('handles empty payload object', async () => {
181
193
  const secret = 's';
182
194
  const payload = {};
@@ -184,7 +196,7 @@ suite('Tests for library functions', () => {
184
196
  const verified = await verify(token, secret);
185
197
  assert.deepStrictEqual(verified, payload);
186
198
  });
187
-
199
+
188
200
  test('rejects very weak secret', async () => {
189
201
  const secret = 'a'; // Short but still works (crypto allows it)
190
202
  const payload = { sub: '123' };
@@ -192,5 +204,99 @@ suite('Tests for library functions', () => {
192
204
  const verified = await verify(token, secret);
193
205
  assert.deepStrictEqual(verified, payload);
194
206
  });
207
+
208
+ test('clock drift: expired token should NOT become valid when clock moves backward', async () => {
209
+ const secret = randomBytes(16).toString('hex');
210
+ const issueTime = Date.now();
211
+ const payload = { sub: 'test-user', exp: Math.floor((issueTime + 5000) / 1000) }; // Set expiry of 5 seconds
212
+ const token = await sign(payload, secret);
213
+ setupMockTimer(10000)
214
+ await assert.rejects(verify(token, secret), /Token expired/);
215
+ setupMockTimer(-3000000)
216
+ await assert.rejects(verify(token, secret), /System clock appears to have moved backward/);
217
+ });
218
+
219
+ test('clock drift: small backward drift does not falsely reject', async () => {
220
+ const secret = randomBytes(16).toString('hex');
221
+ const issueTime = Date.now();
222
+ const payload = { sub: 'test', exp: Math.floor((issueTime + 3600000) / 1000) };
223
+ const token = await sign(payload, secret);
224
+ setupMockTimer(-120000)
225
+ const verified = await verify(token, secret);
226
+ assert.strictEqual(verified.sub, 'test');
227
+ });
228
+
229
+ test('clock drift: small backward drift within leeway should be tolerated', async () => {
230
+ const secret = randomBytes(16).toString('hex');
231
+ const issueTime = Date.now();
232
+ const payload = { exp: Math.floor((issueTime + 5000) / 1000) };
233
+ const token = await sign(payload, secret);
234
+ setupMockTimer(-30000)
235
+ assert.deepStrictEqual(await verify(token, secret), payload);
236
+ });
237
+
238
+ test('clock drift: large backward jump should reject even non-expired token', async () => {
239
+ const secret = randomBytes(16).toString('hex');
240
+ const issueTime = Date.now();
241
+ const payload = { sub: 'drift-test', exp: Math.floor((issueTime + 3600000) / 1000) };
242
+ const token = await sign(payload, secret);
243
+ setupMockTimer(1000)
244
+ const verified = await verify(token, secret);
245
+ assert.strictEqual(verified.sub, 'drift-test');
246
+ setupMockTimer(-8640000);
247
+ await assert.rejects(
248
+ verify(token, secret),
249
+ /System clock appears to have moved backward — token rejected/
250
+ );
251
+ });
195
252
 
253
+ test('clock drift: forward jump should not falsely expire valid token', async () => {
254
+ const secret = randomBytes(16).toString('hex');
255
+ const issueTime = Date.now();
256
+ const payload = { exp: Math.floor((issueTime + 3600000) / 1000) }; // 1 hour valid
257
+ const token = await sign(payload, secret);
258
+ // Jump forward 30 minutes — should still be valid
259
+ setupMockTimer(1800000)
260
+ const verified = await verify(token, secret);
261
+ assert.ok(verified);
262
+ });
263
+
264
+ test('clock drift: large rollback rejects all tokens regardless of individual exp', async () => {
265
+ const secret = randomBytes(16).toString('hex');
266
+ const baseTime = Date.now();
267
+ const payloads = [
268
+ { id: 'expired', exp: Math.floor((baseTime + 5000) / 1000) },
269
+ { id: 'valid', exp: Math.floor((baseTime + 3600000) / 1000) }
270
+ ];
271
+ const [expiredToken, validToken] = await Promise.all([
272
+ sign(payloads[0], secret),
273
+ sign(payloads[1], secret)
274
+ ]);
275
+ await withMockTimer(-720000, async () => {
276
+ await Promise.all([
277
+ assert.rejects(verify(expiredToken, secret), /System clock appears to have moved backward/),
278
+ assert.rejects(verify(validToken, secret), /System clock appears to have moved backward/)
279
+ ])
280
+ })
281
+ })
282
+
283
+ test('clock drift: large rollback rejects all tokens regardless of individual exp', async () => {
284
+ const secret = randomBytes(16).toString('hex');
285
+ const baseTime = Date.now();
286
+ const payloads = [
287
+ { id: 'expired', exp: Math.floor((baseTime + 5000) / 1000) },
288
+ { id: 'valid', exp: Math.floor((baseTime + 3600000) / 1000) }
289
+ ];
290
+ const [expiredToken, validToken] = await Promise.all([
291
+ sign(payloads[0], secret),
292
+ sign(payloads[1], secret)
293
+ ]);
294
+ await withMockTimer(-1000000, async () => {
295
+ await Promise.all([
296
+ assert.rejects(verify(expiredToken, secret), /System clock appears to have moved backward/),
297
+ assert.rejects(verify(validToken, secret), /System clock appears to have moved backward/)
298
+ ])
299
+ })
300
+ });
301
+
196
302
  })