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});