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