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