chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
import {
base32Decode,
base32Encode,
base64UrlDecode,
base64UrlEncode,
bytesToHex,
computeJwkThumbprint,
generateKeyPair,
hexToBytes,
importPrivateKey,
sign,
} from '@pdsjs/core/crypto';
import { describe, expect, test } from 'vitest';
describe('Base32 Encoding', () => {
test('encodes bytes to base32lower', () => {
const bytes = new Uint8Array([0x01, 0x71, 0x12, 0x20]);
const encoded = base32Encode(bytes);
expect(typeof encoded).toBe('string');
expect(encoded).toMatch(/^[a-z2-7]+$/);
});
test('base32 encode/decode roundtrip', () => {
const original = new Uint8Array([0x01, 0x71, 0x12, 0x20, 0xab, 0xcd]);
const encoded = base32Encode(original);
const decoded = base32Decode(encoded);
expect(decoded).toEqual(original);
});
});
describe('P-256 Signing', () => {
test('generates key pair with correct sizes', async () => {
const kp = await generateKeyPair();
expect(kp.privateKey.length).toBe(32);
expect(kp.publicKey.length).toBe(33); // compressed
expect(kp.publicKey[0] === 0x02 || kp.publicKey[0] === 0x03).toBe(true);
});
test('can sign data with generated key', async () => {
const kp = await generateKeyPair();
const key = await importPrivateKey(kp.privateKey);
const data = new TextEncoder().encode('test message');
const sig = await sign(key, data);
expect(sig.length).toBe(64); // r (32) + s (32)
});
test('different messages produce different signatures', async () => {
const kp = await generateKeyPair();
const key = await importPrivateKey(kp.privateKey);
const sig1 = await sign(key, new TextEncoder().encode('message 1'));
const sig2 = await sign(key, new TextEncoder().encode('message 2'));
expect(sig1).not.toEqual(sig2);
});
test('bytesToHex and hexToBytes roundtrip', () => {
const original = new Uint8Array([0x00, 0x0f, 0xf0, 0xff, 0xab, 0xcd]);
const hex = bytesToHex(original);
const back = hexToBytes(hex);
expect(hex).toBe('000ff0ffabcd');
expect(back).toEqual(original);
});
test('importPrivateKey rejects invalid key lengths', async () => {
// Too short
await expect(() => importPrivateKey(new Uint8Array(31))).rejects.toThrow(
/expected 32 bytes, got 31/,
);
// Too long
await expect(() => importPrivateKey(new Uint8Array(33))).rejects.toThrow(
/expected 32 bytes, got 33/,
);
// Empty
await expect(() => importPrivateKey(new Uint8Array(0))).rejects.toThrow(
/expected 32 bytes, got 0/,
);
});
test('importPrivateKey rejects non-Uint8Array input', async () => {
// Arrays have .length but aren't Uint8Array
// @ts-expect-error - Testing runtime validation with invalid type
await expect(() => importPrivateKey([1, 2, 3])).rejects.toThrow(
/Invalid private key/,
);
// Strings don't work either
// @ts-expect-error - Testing runtime validation with invalid type
await expect(() => importPrivateKey('not bytes')).rejects.toThrow(
/Invalid private key/,
);
// null/undefined
// @ts-expect-error - Testing runtime validation with invalid type
await expect(() => importPrivateKey(null)).rejects.toThrow(
/Invalid private key/,
);
});
});
describe('JWT Base64URL', () => {
test('base64UrlEncode encodes bytes correctly', () => {
const input = new TextEncoder().encode('hello world');
const encoded = base64UrlEncode(input);
expect(encoded).toBe('aGVsbG8gd29ybGQ');
expect(encoded.includes('+')).toBe(false);
expect(encoded.includes('/')).toBe(false);
expect(encoded.includes('=')).toBe(false);
});
test('base64UrlDecode decodes string correctly', () => {
const decoded = base64UrlDecode('aGVsbG8gd29ybGQ');
const str = new TextDecoder().decode(decoded);
expect(str).toBe('hello world');
});
test('base64url roundtrip', () => {
const original = new Uint8Array([0, 1, 2, 255, 254, 253]);
const encoded = base64UrlEncode(original);
const decoded = base64UrlDecode(encoded);
expect(decoded).toEqual(original);
});
});
describe('JWK Thumbprint', () => {
test('computes deterministic thumbprint for EC key', async () => {
// Test vector: known JWK and its expected thumbprint
const jwk = {
kty: 'EC',
crv: 'P-256',
x: 'WbbCfHGZ9QtKsVuMdPZ8hBbP2949N_CSLG3LVV0nnKY',
y: 'eSgPlDj0RVMw8t8u4MvCYG4j_JfDwvrMUUwEEHVLmqQ',
};
const jkt1 = await computeJwkThumbprint(jwk);
const jkt2 = await computeJwkThumbprint(jwk);
// Thumbprint must be deterministic
expect(jkt1).toBe(jkt2);
// Must be base64url-encoded SHA-256 (43 chars)
expect(jkt1.length).toBe(43);
// Must only contain base64url characters
expect(jkt1).toMatch(/^[A-Za-z0-9_-]+$/);
});
test('produces different thumbprints for different keys', async () => {
const jwk1 = {
kty: 'EC',
crv: 'P-256',
x: 'WbbCfHGZ9QtKsVuMdPZ8hBbP2949N_CSLG3LVV0nnKY',
y: 'eSgPlDj0RVMw8t8u4MvCYG4j_JfDwvrMUUwEEHVLmqQ',
};
const jwk2 = {
kty: 'EC',
crv: 'P-256',
x: 'f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU',
y: 'x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0',
};
const jkt1 = await computeJwkThumbprint(jwk1);
const jkt2 = await computeJwkThumbprint(jwk2);
expect(jkt1).not.toBe(jkt2);
});
});