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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
164 lines · 4.7 KB Raw History
import { describe, expect, it } from 'vitest';
import {
  bytesEqual,
  COMMIT_MAC_TYPE,
  COMMIT_VERSION,
  computeCommitMac,
  computeRecordMac,
  formatMacHeader,
  formatWantMacHeader,
  hkdfSha256,
  parseMacHeader,
  parseWantMacHeader,
  RECORD_MAC_TYPE,
  SpaceMacError,
  timingSafeEqual,
  uint16be,
  verifyCommitMac,
  verifyRecordMac,
} from '../src/mac.js';
import { fromHex, referenceCommit, toHex, vectors } from './helpers/vectors.js';

const secret = fromHex(vectors.commit.secretHex);

describe('commit MAC', () => {
  it('reproduces the reference MAC byte for byte', async () => {
    expect(toHex(await computeCommitMac(secret, referenceCommit()))).toBe(
      vectors.commit.macHex,
    );
  });

  it('verifies the reference MAC and refuses others', async () => {
    const mac = fromHex(vectors.commit.macHex);
    expect(await verifyCommitMac(secret, referenceCommit(), mac)).toBe(true);
    expect(
      await verifyCommitMac(secret, referenceCommit(), mac.subarray(0, 31)),
    ).toBe(false);
    expect(
      await verifyCommitMac(
        secret,
        { ...referenceCommit(), repo: 'did:plc:cccc' },
        mac,
      ),
    ).toBe(false);
  });

  it('rejects a field longer than uint16', () => {
    expect(() => uint16be(0x10000)).toThrow();
  });
});

describe('record MAC', () => {
  it('reproduces the reference MAC byte for byte', async () => {
    const { uri, cid, macHex } = vectors.record;
    expect(toHex(await computeRecordMac(secret, uri, cid))).toBe(macHex);
    expect(await verifyRecordMac(secret, uri, cid, fromHex(macHex))).toBe(true);
    expect(await verifyRecordMac(secret, `${uri}x`, cid, fromHex(macHex))).toBe(
      false,
    );
  });
});

describe('headers', () => {
  it('formats the response header as the reference does', () => {
    expect(
      formatMacHeader(COMMIT_MAC_TYPE, fromHex(vectors.commit.macHex)),
    ).toBe(vectors.commit.macHeader);
    expect(
      formatMacHeader(RECORD_MAC_TYPE, fromHex(vectors.record.macHex)),
    ).toBe(vectors.record.macHeader);
  });

  it('formats the request header as the reference does', () => {
    expect(formatWantMacHeader(COMMIT_MAC_TYPE, vectors.kem.keyShare)).toBe(
      vectors.kem.wantMacHeader,
    );
  });

  it('parses both back', () => {
    expect(
      toHex(parseMacHeader(vectors.commit.macHeader, COMMIT_MAC_TYPE)),
    ).toBe(vectors.commit.macHex);
    expect(parseWantMacHeader(vectors.kem.wantMacHeader, COMMIT_MAC_TYPE)).toBe(
      vectors.kem.keyShare,
    );
  });

  it('refuses the wrong type, version or shape', () => {
    for (const value of [
      null,
      '',
      'type="atproto-space-repo", ver=2, mac=:AAAA:',
      'type="atproto-space-record", ver=1, mac=:AAAA:',
      'type="atproto-space-repo", ver=1, mac="AAAA"',
      'type="atproto-space-repo", ver=1, mac=(:AAAA:)',
      'type=atproto-space-repo, ver=1, mac=:AAAA:',
      '(((',
    ]) {
      expect(
        () => parseMacHeader(value, COMMIT_MAC_TYPE),
        String(value),
      ).toThrow(SpaceMacError);
    }
    expect(() =>
      parseWantMacHeader('type="atproto-space-repo", ver=1', COMMIT_MAC_TYPE),
    ).toThrow(SpaceMacError);
    expect(() =>
      parseWantMacHeader(
        'type="atproto-space-repo", ver=1, key_share=:AAAA:',
        COMMIT_MAC_TYPE,
      ),
    ).toThrow(SpaceMacError);
  });

  it('carries the BadSpaceMac code', () => {
    expect(new SpaceMacError('x').code).toBe('BadSpaceMac');
  });
});

describe('hkdfSha256', () => {
  // RFC 5869 test case 1.
  it('matches RFC 5869', async () => {
    const okm = await hkdfSha256(
      fromHex('0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b'),
      fromHex('000102030405060708090a0b0c'),
      fromHex('f0f1f2f3f4f5f6f7f8f9'),
      42,
    );
    expect(toHex(okm)).toBe(
      '3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865',
    );
  });

  // RFC 5869 test case 3: an empty salt.
  it('treats an empty salt as zeros', async () => {
    const okm = await hkdfSha256(
      fromHex('0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b'),
      new Uint8Array(0),
      new Uint8Array(0),
      42,
    );
    expect(toHex(okm)).toBe(
      '8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d9d201395faa4b61a96c8',
    );
  });
});

describe('byte helpers', () => {
  it('compare by length and content', () => {
    expect(bytesEqual(new Uint8Array([1, 2]), new Uint8Array([1, 2]))).toBe(
      true,
    );
    expect(bytesEqual(new Uint8Array([1, 2]), new Uint8Array([1, 3]))).toBe(
      false,
    );
    expect(bytesEqual(new Uint8Array([1]), new Uint8Array([1, 2]))).toBe(false);
    expect(timingSafeEqual(new Uint8Array([1]), new Uint8Array([1, 2]))).toBe(
      false,
    );
  });

  it('COMMIT_VERSION is 1', () => {
    expect(COMMIT_VERSION).toBe(1);
  });
});