this repo has no description
1import assert from 'node:assert';
2import { describe, test } from 'node:test';
3import {
4 base32Decode,
5 base32Encode,
6 base64UrlDecode,
7 base64UrlEncode,
8 buildCarFile,
9 bytesToHex,
10 cborDecode,
11 cborEncode,
12 cidToString,
13 createAccessJwt,
14 createCid,
15 createRefreshJwt,
16 createTid,
17 generateKeyPair,
18 getKeyDepth,
19 hexToBytes,
20 importPrivateKey,
21 sign,
22 varint,
23 verifyAccessJwt,
24 verifyRefreshJwt,
25} from '../src/pds.js';
26
27describe('CBOR Encoding', () => {
28 test('encodes simple map', () => {
29 const encoded = cborEncode({ hello: 'world', num: 42 });
30 // Expected: a2 65 68 65 6c 6c 6f 65 77 6f 72 6c 64 63 6e 75 6d 18 2a
31 const expected = new Uint8Array([
32 0xa2, 0x65, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x65, 0x77, 0x6f, 0x72, 0x6c,
33 0x64, 0x63, 0x6e, 0x75, 0x6d, 0x18, 0x2a,
34 ]);
35 assert.deepStrictEqual(encoded, expected);
36 });
37
38 test('encodes null', () => {
39 const encoded = cborEncode(null);
40 assert.deepStrictEqual(encoded, new Uint8Array([0xf6]));
41 });
42
43 test('encodes booleans', () => {
44 assert.deepStrictEqual(cborEncode(true), new Uint8Array([0xf5]));
45 assert.deepStrictEqual(cborEncode(false), new Uint8Array([0xf4]));
46 });
47
48 test('encodes small integers', () => {
49 assert.deepStrictEqual(cborEncode(0), new Uint8Array([0x00]));
50 assert.deepStrictEqual(cborEncode(1), new Uint8Array([0x01]));
51 assert.deepStrictEqual(cborEncode(23), new Uint8Array([0x17]));
52 });
53
54 test('encodes integers >= 24', () => {
55 assert.deepStrictEqual(cborEncode(24), new Uint8Array([0x18, 0x18]));
56 assert.deepStrictEqual(cborEncode(255), new Uint8Array([0x18, 0xff]));
57 });
58
59 test('encodes negative integers', () => {
60 assert.deepStrictEqual(cborEncode(-1), new Uint8Array([0x20]));
61 assert.deepStrictEqual(cborEncode(-10), new Uint8Array([0x29]));
62 });
63
64 test('encodes strings', () => {
65 const encoded = cborEncode('hello');
66 // 0x65 = text string of length 5
67 assert.deepStrictEqual(
68 encoded,
69 new Uint8Array([0x65, 0x68, 0x65, 0x6c, 0x6c, 0x6f]),
70 );
71 });
72
73 test('encodes byte strings', () => {
74 const bytes = new Uint8Array([1, 2, 3]);
75 const encoded = cborEncode(bytes);
76 // 0x43 = byte string of length 3
77 assert.deepStrictEqual(encoded, new Uint8Array([0x43, 1, 2, 3]));
78 });
79
80 test('encodes arrays', () => {
81 const encoded = cborEncode([1, 2, 3]);
82 // 0x83 = array of length 3
83 assert.deepStrictEqual(encoded, new Uint8Array([0x83, 0x01, 0x02, 0x03]));
84 });
85
86 test('sorts map keys deterministically', () => {
87 const encoded1 = cborEncode({ z: 1, a: 2 });
88 const encoded2 = cborEncode({ a: 2, z: 1 });
89 assert.deepStrictEqual(encoded1, encoded2);
90 // First key should be 'a' (0x61)
91 assert.strictEqual(encoded1[1], 0x61);
92 });
93
94 test('encodes large integers >= 2^31 without overflow', () => {
95 // 2^31 would overflow with bitshift operators (treated as signed 32-bit)
96 const twoTo31 = 2147483648;
97 const encoded = cborEncode(twoTo31);
98 const decoded = cborDecode(encoded);
99 assert.strictEqual(decoded, twoTo31);
100
101 // 2^32 - 1 (max unsigned 32-bit)
102 const maxU32 = 4294967295;
103 const encoded2 = cborEncode(maxU32);
104 const decoded2 = cborDecode(encoded2);
105 assert.strictEqual(decoded2, maxU32);
106 });
107
108 test('encodes 2^31 with correct byte format', () => {
109 // 2147483648 = 0x80000000
110 // CBOR: major type 0 (unsigned int), additional info 26 (4-byte follows)
111 const encoded = cborEncode(2147483648);
112 assert.strictEqual(encoded[0], 0x1a); // type 0 | info 26
113 assert.strictEqual(encoded[1], 0x80);
114 assert.strictEqual(encoded[2], 0x00);
115 assert.strictEqual(encoded[3], 0x00);
116 assert.strictEqual(encoded[4], 0x00);
117 });
118});
119
120describe('Base32 Encoding', () => {
121 test('encodes bytes to base32lower', () => {
122 const bytes = new Uint8Array([0x01, 0x71, 0x12, 0x20]);
123 const encoded = base32Encode(bytes);
124 assert.strictEqual(typeof encoded, 'string');
125 assert.match(encoded, /^[a-z2-7]+$/);
126 });
127});
128
129describe('CID Generation', () => {
130 test('creates CIDv1 with dag-cbor codec', async () => {
131 const data = cborEncode({ test: 'data' });
132 const cid = await createCid(data);
133
134 assert.strictEqual(cid.length, 36); // 2 prefix + 2 multihash header + 32 hash
135 assert.strictEqual(cid[0], 0x01); // CIDv1
136 assert.strictEqual(cid[1], 0x71); // dag-cbor
137 assert.strictEqual(cid[2], 0x12); // sha-256
138 assert.strictEqual(cid[3], 0x20); // 32 bytes
139 });
140
141 test('cidToString returns base32lower with b prefix', async () => {
142 const data = cborEncode({ test: 'data' });
143 const cid = await createCid(data);
144 const cidStr = cidToString(cid);
145
146 assert.strictEqual(cidStr[0], 'b');
147 assert.match(cidStr, /^b[a-z2-7]+$/);
148 });
149
150 test('same input produces same CID', async () => {
151 const data1 = cborEncode({ test: 'data' });
152 const data2 = cborEncode({ test: 'data' });
153 const cid1 = cidToString(await createCid(data1));
154 const cid2 = cidToString(await createCid(data2));
155
156 assert.strictEqual(cid1, cid2);
157 });
158
159 test('different input produces different CID', async () => {
160 const cid1 = cidToString(await createCid(cborEncode({ a: 1 })));
161 const cid2 = cidToString(await createCid(cborEncode({ a: 2 })));
162
163 assert.notStrictEqual(cid1, cid2);
164 });
165});
166
167describe('TID Generation', () => {
168 test('creates 13-character TIDs', () => {
169 const tid = createTid();
170 assert.strictEqual(tid.length, 13);
171 });
172
173 test('uses valid base32-sort characters', () => {
174 const tid = createTid();
175 assert.match(tid, /^[234567abcdefghijklmnopqrstuvwxyz]+$/);
176 });
177
178 test('generates monotonically increasing TIDs', () => {
179 const tid1 = createTid();
180 const tid2 = createTid();
181 const tid3 = createTid();
182
183 assert.ok(tid1 < tid2, `${tid1} should be less than ${tid2}`);
184 assert.ok(tid2 < tid3, `${tid2} should be less than ${tid3}`);
185 });
186
187 test('generates unique TIDs', () => {
188 const tids = new Set();
189 for (let i = 0; i < 100; i++) {
190 tids.add(createTid());
191 }
192 assert.strictEqual(tids.size, 100);
193 });
194});
195
196describe('P-256 Signing', () => {
197 test('generates key pair with correct sizes', async () => {
198 const kp = await generateKeyPair();
199
200 assert.strictEqual(kp.privateKey.length, 32);
201 assert.strictEqual(kp.publicKey.length, 33); // compressed
202 assert.ok(kp.publicKey[0] === 0x02 || kp.publicKey[0] === 0x03);
203 });
204
205 test('can sign data with generated key', async () => {
206 const kp = await generateKeyPair();
207 const key = await importPrivateKey(kp.privateKey);
208 const data = new TextEncoder().encode('test message');
209 const sig = await sign(key, data);
210
211 assert.strictEqual(sig.length, 64); // r (32) + s (32)
212 });
213
214 test('different messages produce different signatures', async () => {
215 const kp = await generateKeyPair();
216 const key = await importPrivateKey(kp.privateKey);
217
218 const sig1 = await sign(key, new TextEncoder().encode('message 1'));
219 const sig2 = await sign(key, new TextEncoder().encode('message 2'));
220
221 assert.notDeepStrictEqual(sig1, sig2);
222 });
223
224 test('bytesToHex and hexToBytes roundtrip', () => {
225 const original = new Uint8Array([0x00, 0x0f, 0xf0, 0xff, 0xab, 0xcd]);
226 const hex = bytesToHex(original);
227 const back = hexToBytes(hex);
228
229 assert.strictEqual(hex, '000ff0ffabcd');
230 assert.deepStrictEqual(back, original);
231 });
232
233 test('importPrivateKey rejects invalid key lengths', async () => {
234 // Too short
235 await assert.rejects(
236 () => importPrivateKey(new Uint8Array(31)),
237 /expected 32 bytes, got 31/,
238 );
239
240 // Too long
241 await assert.rejects(
242 () => importPrivateKey(new Uint8Array(33)),
243 /expected 32 bytes, got 33/,
244 );
245
246 // Empty
247 await assert.rejects(
248 () => importPrivateKey(new Uint8Array(0)),
249 /expected 32 bytes, got 0/,
250 );
251 });
252
253 test('importPrivateKey rejects non-Uint8Array input', async () => {
254 // Arrays have .length but aren't Uint8Array
255 await assert.rejects(
256 () => importPrivateKey([1, 2, 3]),
257 /Invalid private key/,
258 );
259
260 // Strings don't work either
261 await assert.rejects(
262 () => importPrivateKey('not bytes'),
263 /Invalid private key/,
264 );
265
266 // null/undefined
267 await assert.rejects(() => importPrivateKey(null), /Invalid private key/);
268 });
269});
270
271describe('MST Key Depth', () => {
272 test('returns a non-negative integer', async () => {
273 const depth = await getKeyDepth('app.bsky.feed.post/abc123');
274 assert.strictEqual(typeof depth, 'number');
275 assert.ok(depth >= 0);
276 });
277
278 test('is deterministic for same key', async () => {
279 const key = 'app.bsky.feed.post/test123';
280 const depth1 = await getKeyDepth(key);
281 const depth2 = await getKeyDepth(key);
282 assert.strictEqual(depth1, depth2);
283 });
284
285 test('different keys can have different depths', async () => {
286 // Generate many keys and check we get some variation
287 const depths = new Set();
288 for (let i = 0; i < 100; i++) {
289 depths.add(await getKeyDepth(`collection/key${i}`));
290 }
291 // Should have at least 1 unique depth (realistically more)
292 assert.ok(depths.size >= 1);
293 });
294
295 test('handles empty string', async () => {
296 const depth = await getKeyDepth('');
297 assert.strictEqual(typeof depth, 'number');
298 assert.ok(depth >= 0);
299 });
300
301 test('handles unicode strings', async () => {
302 const depth = await getKeyDepth('app.bsky.feed.post/émoji🎉');
303 assert.strictEqual(typeof depth, 'number');
304 assert.ok(depth >= 0);
305 });
306});
307
308describe('CBOR Decoding', () => {
309 test('decodes what encode produces (roundtrip)', () => {
310 const original = { hello: 'world', num: 42 };
311 const encoded = cborEncode(original);
312 const decoded = cborDecode(encoded);
313 assert.deepStrictEqual(decoded, original);
314 });
315
316 test('decodes null', () => {
317 const encoded = cborEncode(null);
318 const decoded = cborDecode(encoded);
319 assert.strictEqual(decoded, null);
320 });
321
322 test('decodes booleans', () => {
323 assert.strictEqual(cborDecode(cborEncode(true)), true);
324 assert.strictEqual(cborDecode(cborEncode(false)), false);
325 });
326
327 test('decodes integers', () => {
328 assert.strictEqual(cborDecode(cborEncode(0)), 0);
329 assert.strictEqual(cborDecode(cborEncode(42)), 42);
330 assert.strictEqual(cborDecode(cborEncode(255)), 255);
331 assert.strictEqual(cborDecode(cborEncode(-1)), -1);
332 assert.strictEqual(cborDecode(cborEncode(-10)), -10);
333 });
334
335 test('decodes strings', () => {
336 assert.strictEqual(cborDecode(cborEncode('hello')), 'hello');
337 assert.strictEqual(cborDecode(cborEncode('')), '');
338 });
339
340 test('decodes arrays', () => {
341 assert.deepStrictEqual(cborDecode(cborEncode([1, 2, 3])), [1, 2, 3]);
342 assert.deepStrictEqual(cborDecode(cborEncode([])), []);
343 });
344
345 test('decodes nested structures', () => {
346 const original = { arr: [1, { nested: true }], str: 'test' };
347 const decoded = cborDecode(cborEncode(original));
348 assert.deepStrictEqual(decoded, original);
349 });
350});
351
352describe('CAR File Builder', () => {
353 test('varint encodes small numbers', () => {
354 assert.deepStrictEqual(varint(0), new Uint8Array([0]));
355 assert.deepStrictEqual(varint(1), new Uint8Array([1]));
356 assert.deepStrictEqual(varint(127), new Uint8Array([127]));
357 });
358
359 test('varint encodes multi-byte numbers', () => {
360 // 128 = 0x80 -> [0x80 | 0x00, 0x01] = [0x80, 0x01]
361 assert.deepStrictEqual(varint(128), new Uint8Array([0x80, 0x01]));
362 // 300 = 0x12c -> [0xac, 0x02]
363 assert.deepStrictEqual(varint(300), new Uint8Array([0xac, 0x02]));
364 });
365
366 test('base32 encode/decode roundtrip', () => {
367 const original = new Uint8Array([0x01, 0x71, 0x12, 0x20, 0xab, 0xcd]);
368 const encoded = base32Encode(original);
369 const decoded = base32Decode(encoded);
370 assert.deepStrictEqual(decoded, original);
371 });
372
373 test('buildCarFile produces valid structure', async () => {
374 const data = cborEncode({ test: 'data' });
375 const cid = await createCid(data);
376 const cidStr = cidToString(cid);
377
378 const car = buildCarFile(cidStr, [{ cid: cidStr, data }]);
379
380 assert.ok(car instanceof Uint8Array);
381 assert.ok(car.length > 0);
382 // First byte should be varint of header length
383 assert.ok(car[0] > 0);
384 });
385});
386
387describe('JWT Base64URL', () => {
388 test('base64UrlEncode encodes bytes correctly', () => {
389 const input = new TextEncoder().encode('hello world');
390 const encoded = base64UrlEncode(input);
391 assert.strictEqual(encoded, 'aGVsbG8gd29ybGQ');
392 assert.ok(!encoded.includes('+'));
393 assert.ok(!encoded.includes('/'));
394 assert.ok(!encoded.includes('='));
395 });
396
397 test('base64UrlDecode decodes string correctly', () => {
398 const decoded = base64UrlDecode('aGVsbG8gd29ybGQ');
399 const str = new TextDecoder().decode(decoded);
400 assert.strictEqual(str, 'hello world');
401 });
402
403 test('base64url roundtrip', () => {
404 const original = new Uint8Array([0, 1, 2, 255, 254, 253]);
405 const encoded = base64UrlEncode(original);
406 const decoded = base64UrlDecode(encoded);
407 assert.deepStrictEqual(decoded, original);
408 });
409});
410
411describe('JWT Creation', () => {
412 test('createAccessJwt creates valid JWT structure', async () => {
413 const did = 'did:web:test.example';
414 const secret = 'test-secret-key';
415 const jwt = await createAccessJwt(did, secret);
416
417 const parts = jwt.split('.');
418 assert.strictEqual(parts.length, 3);
419
420 // Decode header
421 const header = JSON.parse(
422 new TextDecoder().decode(base64UrlDecode(parts[0])),
423 );
424 assert.strictEqual(header.typ, 'at+jwt');
425 assert.strictEqual(header.alg, 'HS256');
426
427 // Decode payload
428 const payload = JSON.parse(
429 new TextDecoder().decode(base64UrlDecode(parts[1])),
430 );
431 assert.strictEqual(payload.scope, 'com.atproto.access');
432 assert.strictEqual(payload.sub, did);
433 assert.strictEqual(payload.aud, did);
434 assert.ok(payload.iat > 0);
435 assert.ok(payload.exp > payload.iat);
436 });
437
438 test('createRefreshJwt creates valid JWT with jti', async () => {
439 const did = 'did:web:test.example';
440 const secret = 'test-secret-key';
441 const jwt = await createRefreshJwt(did, secret);
442
443 const parts = jwt.split('.');
444 const header = JSON.parse(
445 new TextDecoder().decode(base64UrlDecode(parts[0])),
446 );
447 assert.strictEqual(header.typ, 'refresh+jwt');
448
449 const payload = JSON.parse(
450 new TextDecoder().decode(base64UrlDecode(parts[1])),
451 );
452 assert.strictEqual(payload.scope, 'com.atproto.refresh');
453 assert.ok(payload.jti); // has unique token ID
454 });
455});
456
457describe('JWT Verification', () => {
458 test('verifyAccessJwt returns payload for valid token', async () => {
459 const did = 'did:web:test.example';
460 const secret = 'test-secret-key';
461 const jwt = await createAccessJwt(did, secret);
462
463 const payload = await verifyAccessJwt(jwt, secret);
464 assert.strictEqual(payload.sub, did);
465 assert.strictEqual(payload.scope, 'com.atproto.access');
466 });
467
468 test('verifyAccessJwt throws for wrong secret', async () => {
469 const did = 'did:web:test.example';
470 const jwt = await createAccessJwt(did, 'correct-secret');
471
472 await assert.rejects(
473 () => verifyAccessJwt(jwt, 'wrong-secret'),
474 /invalid signature/i,
475 );
476 });
477
478 test('verifyAccessJwt throws for expired token', async () => {
479 const did = 'did:web:test.example';
480 const secret = 'test-secret-key';
481 // Create token that expired 1 second ago
482 const jwt = await createAccessJwt(did, secret, -1);
483
484 await assert.rejects(() => verifyAccessJwt(jwt, secret), /expired/i);
485 });
486
487 test('verifyAccessJwt throws for refresh token', async () => {
488 const did = 'did:web:test.example';
489 const secret = 'test-secret-key';
490 const jwt = await createRefreshJwt(did, secret);
491
492 await assert.rejects(
493 () => verifyAccessJwt(jwt, secret),
494 /invalid token type/i,
495 );
496 });
497
498 test('verifyRefreshJwt returns payload for valid token', async () => {
499 const did = 'did:web:test.example';
500 const secret = 'test-secret-key';
501 const jwt = await createRefreshJwt(did, secret);
502
503 const payload = await verifyRefreshJwt(jwt, secret);
504 assert.strictEqual(payload.sub, did);
505 assert.strictEqual(payload.scope, 'com.atproto.refresh');
506 assert.ok(payload.jti); // has token ID
507 });
508
509 test('verifyRefreshJwt throws for wrong secret', async () => {
510 const did = 'did:web:test.example';
511 const jwt = await createRefreshJwt(did, 'correct-secret');
512
513 await assert.rejects(
514 () => verifyRefreshJwt(jwt, 'wrong-secret'),
515 /invalid signature/i,
516 );
517 });
518
519 test('verifyRefreshJwt throws for expired token', async () => {
520 const did = 'did:web:test.example';
521 const secret = 'test-secret-key';
522 // Create token that expired 1 second ago
523 const jwt = await createRefreshJwt(did, secret, -1);
524
525 await assert.rejects(() => verifyRefreshJwt(jwt, secret), /expired/i);
526 });
527
528 test('verifyRefreshJwt throws for access token', async () => {
529 const did = 'did:web:test.example';
530 const secret = 'test-secret-key';
531 const jwt = await createAccessJwt(did, secret);
532
533 await assert.rejects(
534 () => verifyRefreshJwt(jwt, secret),
535 /invalid token type/i,
536 );
537 });
538
539 test('verifyAccessJwt throws for malformed JWT', async () => {
540 const secret = 'test-secret-key';
541
542 // Not a JWT at all
543 await assert.rejects(
544 () => verifyAccessJwt('not-a-jwt', secret),
545 /Invalid JWT format/i,
546 );
547
548 // Only two parts
549 await assert.rejects(
550 () => verifyAccessJwt('two.parts', secret),
551 /Invalid JWT format/i,
552 );
553
554 // Four parts
555 await assert.rejects(
556 () => verifyAccessJwt('one.two.three.four', secret),
557 /Invalid JWT format/i,
558 );
559 });
560
561 test('verifyRefreshJwt throws for malformed JWT', async () => {
562 const secret = 'test-secret-key';
563
564 await assert.rejects(
565 () => verifyRefreshJwt('not-a-jwt', secret),
566 /Invalid JWT format/i,
567 );
568
569 await assert.rejects(
570 () => verifyRefreshJwt('two.parts', secret),
571 /Invalid JWT format/i,
572 );
573 });
574});