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