~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
170 lines · 5.2 KB Raw History
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);
  });
});