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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
154 lines · 4.7 KB Raw History
import { generateKeyPair, importPrivateKey } from '@pdsjs/core/crypto';
import {
  base58btcDecode,
  base58btcEncode,
  buildPlcOperation,
  publicKeyToDidKey,
  signPlcOperationObject,
} from '@pdsjs/core/plc';
import { cborEncodeDagCbor } from '@pdsjs/core/repo';
import { describe, expect, test } from 'vitest';

/**
 * Derive a verify-only CryptoKey from a signing key.
 * @param {CryptoKey} signingKey
 */
async function toVerifyKey(signingKey) {
  const jwk = await crypto.subtle.exportKey('jwk', signingKey);
  delete jwk.d;
  jwk.key_ops = ['verify'];
  return crypto.subtle.importKey(
    'jwk',
    jwk,
    { name: 'ECDSA', namedCurve: 'P-256' },
    true,
    ['verify'],
  );
}

describe('base58btcEncode', () => {
  test('encodes leading zero bytes as 1s', () => {
    expect(base58btcEncode(new Uint8Array([0, 0, 1]))).toBe('112');
  });

  test('encodes known byte sequence', () => {
    // 'hello world' in base58btc
    expect(base58btcEncode(new TextEncoder().encode('hello world'))).toBe(
      'StV1DL6CwTryKyV',
    );
  });
});

describe('base58btcDecode', () => {
  test('round-trips arbitrary bytes through base58btcEncode', () => {
    const bytes = new Uint8Array([1, 2, 3, 250, 251, 252, 253, 254, 255, 0]);
    expect(base58btcDecode(base58btcEncode(bytes))).toEqual(bytes);
  });

  test('round-trips bytes with leading zeros', () => {
    const bytes = new Uint8Array([0, 0, 1, 2, 3]);
    expect(base58btcDecode(base58btcEncode(bytes))).toEqual(bytes);
  });

  test('decodes a known byte sequence', () => {
    expect(base58btcDecode('StV1DL6CwTryKyV')).toEqual(
      new TextEncoder().encode('hello world'),
    );
  });
});

describe('publicKeyToDidKey', () => {
  test('produces a did:key with the P-256 multicodec prefix', async () => {
    const { publicKey } = await generateKeyPair();
    expect(publicKeyToDidKey(publicKey).startsWith('did:key:zDn')).toBe(true);
  });

  test('is deterministic for the same key', async () => {
    const { publicKey } = await generateKeyPair();
    expect(publicKeyToDidKey(publicKey)).toBe(publicKeyToDidKey(publicKey));
  });
});

describe('buildPlcOperation', () => {
  test('builds a plc_operation with the expected shape', () => {
    const op = /** @type {any} */ (
      buildPlcOperation({
        didKey: 'did:key:zDnaeTEST',
        handle: 'alice.example.com',
        pdsEndpoint: 'https://pds.example.com',
        prev: null,
      })
    );

    expect(op.type).toBe('plc_operation');
    expect(op.rotationKeys).toEqual(['did:key:zDnaeTEST']);
    expect(op.verificationMethods).toEqual({ atproto: 'did:key:zDnaeTEST' });
    expect(op.alsoKnownAs).toEqual(['at://alice.example.com']);
    expect(op.services).toEqual({
      atproto_pds: {
        type: 'AtprotoPersonalDataServer',
        endpoint: 'https://pds.example.com',
      },
    });
    expect(op.prev).toBe(null);
  });

  test('carries prev through for update operations', () => {
    const op = /** @type {any} */ (
      buildPlcOperation({
        didKey: 'did:key:zDnaeTEST',
        handle: 'alice.example.com',
        pdsEndpoint: 'https://pds.example.com',
        prev: 'bafyprevcid',
      })
    );
    expect(op.prev).toBe('bafyprevcid');
  });
});

describe('signPlcOperationObject', () => {
  test('adds a verifiable signature over the unsigned operation', async () => {
    const { privateKey } = await generateKeyPair();
    const signingKey = await importPrivateKey(privateKey);
    const verifyKey = await toVerifyKey(signingKey);

    const op = buildPlcOperation({
      didKey: 'did:key:zDnaeTEST',
      handle: 'alice.example.com',
      pdsEndpoint: 'https://pds.example.com',
      prev: null,
    });
    const signed = await signPlcOperationObject(op, signingKey);

    expect(typeof signed.sig).toBe('string');
    expect(signed.sig.length).toBeGreaterThan(0);

    // Signature verifies against the operation without the sig field
    const { sig, ...unsigned } = signed;
    const encoded = cborEncodeDagCbor(unsigned);
    const sigBytes = Uint8Array.from(
      Buffer.from(sig.replace(/-/g, '+').replace(/_/g, '/'), 'base64'),
    );
    const valid = await crypto.subtle.verify(
      { name: 'ECDSA', hash: 'SHA-256' },
      verifyKey,
      sigBytes,
      /** @type {BufferSource} */ (encoded),
    );
    expect(valid).toBe(true);
  });

  test('does not mutate the input operation', async () => {
    const { privateKey } = await generateKeyPair();
    const signingKey = await importPrivateKey(privateKey);
    const op = buildPlcOperation({
      didKey: 'did:key:zDnaeTEST',
      handle: 'alice.example.com',
      pdsEndpoint: 'https://pds.example.com',
      prev: null,
    });

    await signPlcOperationObject(op, signingKey);
    expect('sig' in op).toBe(false);
  });
});