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