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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
259 lines · 7.5 KB Raw History
import { cborEncodeDagCbor } from '@pdsjs/core/repo';
import { describe, expect, it } from 'vitest';
import {
  compareBytes,
  encodeVarint,
  MAX_RECORD_SIZE,
  RepoVerificationError,
  readStar,
  STAR_MAGIC,
  serializeRepo,
  verifyRecord,
  verifyRepoStar,
} from '../src/star.js';
import { fromHex, referenceCommit, toHex, vectors } from './helpers/vectors.js';

const records = vectors.commit.records.map((/** @type {any} */ r) => ({
  collection: r.collection,
  rkey: r.rkey,
  bytes: fromHex(r.valueHex),
}));
const params = {
  space: vectors.commit.space,
  repo: vectors.commit.repo,
  sharedSecret: fromHex(vectors.commit.secretHex),
  mac: fromHex(vectors.commit.macHex),
};

/**
 * A STAR archive from raw frames.
 * @param {Uint8Array} meta
 * @param {Array<[Uint8Array, Uint8Array]>} pairs
 */
function frames(meta, pairs) {
  /** @type {number[]} */
  const out = [...STAR_MAGIC, ...encodeVarint(meta.length), ...meta];
  for (const [k, v] of pairs) {
    out.push(
      1,
      ...encodeVarint(k.length),
      ...k,
      ...encodeVarint(v.length),
      ...v,
    );
  }
  out.push(0);
  return new Uint8Array(out);
}

const enc = new TextEncoder();
const meta = () =>
  cborEncodeDagCbor({ $type: 'atproto-space-repo', ...referenceCommit() });

describe('serializeRepo', () => {
  it('reproduces the reference archive byte for byte', () => {
    expect(toHex(serializeRepo(referenceCommit(), records))).toBe(
      vectors.commit.starHex,
    );
  });

  it('sorts records given out of order', () => {
    expect(
      toHex(serializeRepo(referenceCommit(), [...records].reverse())),
    ).toBe(vectors.commit.starHex);
  });

  it('refuses a duplicate path', () => {
    expect(() =>
      serializeRepo(referenceCommit(), [records[0], records[0]]),
    ).toThrow(/duplicate/);
  });
});

describe('verifyRepoStar', () => {
  it('verifies the reference archive', async () => {
    const { commit, records: out } = await verifyRepoStar(
      fromHex(vectors.commit.starHex),
      params,
    );
    expect(commit).toEqual(referenceCommit());
    expect(out.map((r) => r.record)).toEqual([
      { text: 'hello' },
      { text: 'world' },
    ]);
    expect(out.map((r) => r.cid)).toEqual(
      vectors.commit.records.map((/** @type {any} */ r) => r.cid),
    );
  });

  it('refuses an archive missing a record', async () => {
    const star = serializeRepo(referenceCommit(), [records[0]]);
    await expect(verifyRepoStar(star, params)).rejects.toThrow(/do not match/);
  });

  it('refuses a commit the MAC does not cover', async () => {
    await expect(
      verifyRepoStar(fromHex(vectors.commit.starHex), {
        ...params,
        sharedSecret: new Uint8Array(32),
      }),
    ).rejects.toThrow(/commit failed verification/);
  });

  it('refuses records out of order', async () => {
    const star = frames(meta(), [
      [
        enc.encode(`${records[1].collection}/${records[1].rkey}`),
        records[1].bytes,
      ],
      [
        enc.encode(`${records[0].collection}/${records[0].rkey}`),
        records[0].bytes,
      ],
    ]);
    await expect(verifyRepoStar(star, params)).rejects.toThrow(/out of order/);
  });

  it('refuses a bad path, bad CBOR or a non-map record', async () => {
    for (const [key, value, pattern] of /** @type {const} */ ([
      ['nopath', records[0].bytes, /invalid record path/],
      ['app.example.post/a', new Uint8Array([0xa1]), /./],
      ['app.example.post/a', cborEncodeDagCbor([1]), /invalid record at/],
    ])) {
      await expect(
        verifyRepoStar(frames(meta(), [[enc.encode(key), value]]), params),
      ).rejects.toThrow(pattern);
    }
  });

  it('refuses metadata of another type or shape', async () => {
    for (const m of [
      { ...referenceCommit(), $type: 'something-else' },
      { $type: 'atproto-space-repo', ...referenceCommit(), repo: 'nope' },
      { $type: 'atproto-space-repo', ...referenceCommit(), data: 'nope' },
    ]) {
      await expect(
        verifyRepoStar(frames(cborEncodeDagCbor(m), []), params),
      ).rejects.toThrow(RepoVerificationError);
    }
  });
});

describe('readStar', () => {
  it('refuses bad magic, missing metadata and truncation', () => {
    expect(() => readStar(new Uint8Array([1, 2, 3, 4]))).toThrow(/magic/);
    expect(() => readStar(STAR_MAGIC)).toThrow(/unexpected end/);
    const star = fromHex(vectors.commit.starHex);
    expect(() => readStar(star.subarray(0, star.length - 1))).toThrow(
      /unexpected end/,
    );
    // Ended where a pair's value should start.
    const open = frames(meta(), []).slice(0, -1);
    expect(() => readStar(new Uint8Array([...open, 1, 1, 0x61]))).toThrow(
      /unexpected end/,
    );
    expect(() => readStar(open)).toThrow(/unexpected end/);
  });

  it('refuses an archive without its control bytes, or with more after the end', () => {
    const open = frames(meta(), []).slice(0, -1);
    expect(() => readStar(new Uint8Array([...open, 7]))).toThrow(
      /invalid control byte: 7/,
    );
    expect(() => readStar(new Uint8Array([...open, 0, 1]))).toThrow(
      /after end of archive/,
    );
  });

  it('refuses metadata that is not a canonical typed map', () => {
    expect(() => readStar(frames(new Uint8Array([0xa1]), []))).toThrow(
      RepoVerificationError,
    );
    expect(() => readStar(frames(cborEncodeDagCbor({ a: 1 }), []))).toThrow(
      /\$type/,
    );
    // { $type: 'x', a: 1 } with its keys out of canonical order, which puts
    // the shorter key first.
    const nonCanonical = new Uint8Array([
      0xa2,
      0x65,
      ...enc.encode('$type'),
      0x61,
      0x78,
      0x61,
      0x61,
      0x01,
    ]);
    expect(() => readStar(frames(nonCanonical, []))).toThrow(/canonical/);
  });

  it('refuses a padded or overlong varint and an oversized value', () => {
    expect(() => readStar(new Uint8Array([...STAR_MAGIC, 0x81, 0x00]))).toThrow(
      /non-canonical/,
    );
    expect(() =>
      readStar(
        new Uint8Array([
          ...STAR_MAGIC,
          0x80,
          0x80,
          0x80,
          0x80,
          0x80,
          0x80,
          0x80,
          0x80,
          0x80,
          0x01,
        ]),
      ),
    ).toThrow(/too long/);
    expect(() =>
      readStar(frames(meta(), [[enc.encode('a/b'), new Uint8Array(10)]]), {
        maxValueLength: 5,
      }),
    ).toThrow(/exceeds limit/);
  });

  it('encodes lengths past 32 bits', () => {
    expect(toHex(encodeVarint(2 ** 35))).toBe('808080808001');
    expect(MAX_RECORD_SIZE).toBe(1024 * 1024);
  });

  it('orders bytes lexicographically', () => {
    expect(compareBytes(enc.encode('a'), enc.encode('ab'))).toBeLessThan(0);
    expect(compareBytes(enc.encode('b'), enc.encode('ab'))).toBeGreaterThan(0);
  });
});

describe('verifyRecord', () => {
  const { uri, cid } = vectors.record;
  const recordParams = {
    uri,
    sharedSecret: fromHex(vectors.record.secretHex),
    mac: fromHex(vectors.record.macHex),
  };
  const record = { uri, cid, value: { text: 'hello' } };

  it('verifies the reference record', async () => {
    expect(await verifyRecord(record, recordParams)).toBe(true);
  });

  it('refuses another uri, value, cid or MAC', async () => {
    expect(
      await verifyRecord(record, { ...recordParams, uri: `${uri}x` }),
    ).toBe(false);
    expect(
      await verifyRecord({ ...record, value: { text: 'world' } }, recordParams),
    ).toBe(false);
    expect(
      await verifyRecord({ ...record, cid: 'not-a-cid' }, recordParams),
    ).toBe(false);
    expect(
      await verifyRecord(record, {
        ...recordParams,
        mac: new Uint8Array(32),
      }),
    ).toBe(false);
  });
});