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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
596 lines · 18.3 KB Raw History
// Signers here use @pdsjs/core/crypto's sign(), which normalizes to low-S.
// atproto rejects high-S signatures as malleable and raw crypto.subtle.sign
// emits them about half the time, so a test signing the raw way would verify
// fine under WebCrypto while the reference implementation rejected it.

import { sign } from '@pdsjs/core/crypto';
import { beforeEach, describe, expect, it } from 'vitest';
import { verifyCommit } from '../src/commit.js';
import {
  createMemoryBlobContext,
  createMemorySpaceStorage,
} from '../src/memory-storage.js';
import { createSpaceRoutes } from '../src/routes.js';
import { readStar, verifyRecord, verifyRepoStar } from '../src/star.js';
import { applyWrites } from '../src/writer.js';
import { makeKemKey, responseMac, wantMac } from './helpers/kem.js';

const SPACE = 'at://did:plc:aaaa/space/com.example.forum/general';
const DID = 'did:plc:writer';
const COL = 'com.example.post';
const AUTH = { did: DID, scope: 'atproto' };

/** A real P-256 signer so commits can actually be verified. */
async function makeSigner() {
  const keys = await crypto.subtle.generateKey(
    { name: 'ECDSA', namedCurve: 'P-256' },
    true,
    ['sign', 'verify'],
  );
  return {
    signer: {
      /** @param {Uint8Array} bytes */
      sign: (bytes) => sign(keys.privateKey, bytes),
    },
    verifier: {
      /**
       * @param {string} _didKey @param {Uint8Array} data @param {Uint8Array} sig
       */
      verify: async (_didKey, data, sig) =>
        crypto.subtle.verify(
          { name: 'ECDSA', hash: 'SHA-256' },
          keys.publicKey,
          /** @type {BufferSource} */ (sig),
          /** @type {BufferSource} */ (data),
        ),
    },
  };
}

/** @type {any} */
let routes;
/** @type {any} */
let store;
/** @type {any} */
let blobContext;
/** @type {{privateKey: Uint8Array, didKey: string}} */
let kemKey;

beforeEach(async () => {
  store = createMemorySpaceStorage();
  const keys = await makeSigner();
  blobContext = createMemoryBlobContext();
  kemKey = await makeKemKey();
  routes = createSpaceRoutes({
    spaceStorage: store,
    ...blobContext,
    getDid: async () => DID,
    getSigner: async () => keys.signer,
    getKemKey: async () => kemKey,
    resolveDid: async () => null,
    verifier: keys.verifier,
  });
});

/**
 * @param {string} path
 * @param {Record<string, string>} params
 * @param {Record<string, string>} [headers]
 */
function get(path, params = {}, headers = {}) {
  const url = new URL(`https://pds.example${path}`);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
  return routes[path].handler(new Request(url, { headers }), url, AUTH);
}

/** @param {string} s */
const fromB64 = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));

/** @param {any} commit */
function fromJsonCommit(commit) {
  return { ...commit, data: fromB64(commit.data.$bytes) };
}

/** @param {Array<[string, string, any]>} entries */
function seed(entries) {
  return applyWrites(store, {
    space: SPACE,
    writes: entries.map(([collection, rkey, record]) => ({
      action: /** @type {const} */ ('create'),
      collection,
      rkey,
      record,
    })),
  });
}

describe('getRecord', () => {
  it('returns the decoded record', async () => {
    await seed([[COL, 'a', { $type: COL, text: 'hi' }]]);
    const res = await get('/xrpc/com.atproto.space.getRecord', {
      space: SPACE,
      collection: COL,
      rkey: 'a',
    });
    expect(res.status).toBe(200);
    const body = await res.json();
    expect(body.uri).toBe(`${SPACE}/${DID}/${COL}/a`);
    expect(body.value).toEqual({ $type: COL, text: 'hi' });
  });

  it('404s a missing record', async () => {
    const res = await get('/xrpc/com.atproto.space.getRecord', {
      space: SPACE,
      collection: COL,
      rkey: 'nope',
    });
    expect(res.status).toBe(404);
    expect((await res.json()).error).toBe('RecordNotFound');
  });

  it('404s a repo this server does not host', async () => {
    const res = await get('/xrpc/com.atproto.space.getRecord', {
      space: SPACE,
      repo: 'did:plc:elsewhere',
      collection: COL,
      rkey: 'a',
    });
    expect(res.status).toBe(404);
    expect((await res.json()).error).toBe('RepoNotFound');
  });

  it('rejects a non-space URI', async () => {
    const res = await get('/xrpc/com.atproto.space.getRecord', {
      space: 'at://did:plc:aaaa/com.example.post/a',
      collection: COL,
      rkey: 'a',
    });
    expect(res.status).toBe(400);
    expect((await res.json()).error).toBe('InvalidSpaceUri');
  });
});

describe('listRecords', () => {
  it('lists and paginates, newest first', async () => {
    await seed([
      [COL, 'a', { v: 1 }],
      [COL, 'b', { v: 2 }],
    ]);
    const first = await (
      await get('/xrpc/com.atproto.space.listRecords', {
        space: SPACE,
        collection: COL,
        limit: '1',
      })
    ).json();
    expect(first.records).toHaveLength(1);
    expect(first.records[0]).toEqual({
      collection: COL,
      rkey: 'b',
      cid: expect.any(String),
      value: { v: 2 },
    });
    expect(first.cursor).toBeTruthy();

    const second = await (
      await get('/xrpc/com.atproto.space.listRecords', {
        space: SPACE,
        collection: COL,
        limit: '1',
        cursor: first.cursor,
      })
    ).json();
    expect(second.records[0].value).toEqual({ v: 1 });
  });

  it('gives the last record AT-URI as the cursor', async () => {
    await seed([
      [COL, 'a', { v: 1 }],
      [COL, 'b', { v: 2 }],
    ]);
    const { cursor } = await (
      await get('/xrpc/com.atproto.space.listRecords', {
        space: SPACE,
        collection: COL,
        limit: '1',
      })
    ).json();
    expect(cursor).toBe(`${SPACE}/${DID}/${COL}/b`);
  });

  it('pages from a cursor the caller builds', async () => {
    await seed([
      [COL, 'k1.x', { v: 1 }],
      [COL, 'k2.x', { v: 2 }],
      [COL, 'k2.y', { v: 3 }],
      [COL, 'k3.x', { v: 4 }],
    ]);
    /** @param {Record<string, string>} params */
    const rkeys = async (params) => {
      const { records } = await (
        await get('/xrpc/com.atproto.space.listRecords', {
          space: SPACE,
          collection: COL,
          ...params,
        })
      ).json();
      return records.map((/** @type {any} */ r) => r.rkey);
    };
    const uri = `${SPACE}/${DID}/${COL}/`;
    expect(await rkeys({ cursor: `${uri}k2/` })).toEqual([
      'k2.y',
      'k2.x',
      'k1.x',
    ]);
    expect(await rkeys({ cursor: `${uri}k2.x`, reverse: 'true' })).toEqual([
      'k2.y',
      'k3.x',
    ]);
  });

  it('puts every row or none past a cursor outside the repo', async () => {
    await seed([[COL, 'a', { v: 1 }]]);
    /** @param {string} cursor @param {boolean} reverse */
    const count = async (cursor, reverse) => {
      const { records } = await (
        await get('/xrpc/com.atproto.space.listRecords', {
          space: SPACE,
          cursor,
          ...(reverse ? { reverse: 'true' } : {}),
        })
      ).json();
      return records.length;
    };
    expect(await count('at://zzz', false)).toBe(1);
    expect(await count('at://zzz', true)).toBe(0);
    expect(await count('at://a', false)).toBe(0);
    expect(await count('at://a', true)).toBe(1);
  });

  it('lists across collections when none is named', async () => {
    await seed([
      [COL, 'a', { v: 1 }],
      ['com.example.other', 'b', { v: 2 }],
    ]);
    const { records } = await (
      await get('/xrpc/com.atproto.space.listRecords', { space: SPACE })
    ).json();
    expect(records.map((/** @type {any} */ r) => r.collection).sort()).toEqual([
      'com.example.other',
      COL,
    ]);
  });

  it('omits values on request', async () => {
    await seed([[COL, 'a', { v: 1 }]]);
    const { records } = await (
      await get('/xrpc/com.atproto.space.listRecords', {
        space: SPACE,
        excludeValues: 'true',
      })
    ).json();
    expect(records[0].value).toBeUndefined();
    expect(records[0].cid).toEqual(expect.any(String));
  });

  it('rejects an out-of-range limit', async () => {
    const res = await get('/xrpc/com.atproto.space.listRecords', {
      space: SPACE,
      collection: COL,
      limit: '1001',
    });
    expect(res.status).toBe(400);
  });
});

describe('getLatestCommit', () => {
  it('returns nothing before any write', async () => {
    const res = await get('/xrpc/com.atproto.space.getLatestCommit', {
      space: SPACE,
    });
    expect(await res.json()).toEqual({});
  });

  it('answers the same commit under the getRepoState name', async () => {
    await seed([[COL, 'a', { v: 1 }]]);
    const canonical = await (
      await get('/xrpc/com.atproto.space.getLatestCommit', { space: SPACE })
    ).json();
    const alias = await (
      await get('/xrpc/com.atproto.space.getRepoState', { space: SPACE })
    ).json();
    expect(alias).toEqual(canonical);
  });

  it('returns a plain commit, with no MAC for the own session that asks for none', async () => {
    const { rev } = await seed([[COL, 'a', { v: 1 }]]);
    const res = await get('/xrpc/com.atproto.space.getLatestCommit', {
      space: SPACE,
    });
    const { commit } = await res.json();
    expect(Object.keys(commit).sort()).toEqual([
      'data',
      'repo',
      'rev',
      'space',
      'ver',
    ]);
    expect(commit).toMatchObject({ ver: 1, repo: DID, space: SPACE, rev });
    // Bytes travel as atproto's $bytes JSON shape.
    expect(typeof commit.data.$bytes).toBe('string');
    expect(res.headers.get('atproto-space-mac')).toBeNull();
    expect(res.headers.get('vary')).toContain('atproto-space-want-mac');
  });

  it('MACs the commit under the secret the Want-Mac key share derives', async () => {
    await seed([[COL, 'a', { v: 1 }]]);
    const want = await wantMac('atproto-space-repo', kemKey.didKey);
    const res = await get(
      '/xrpc/com.atproto.space.getLatestCommit',
      { space: SPACE },
      { 'atproto-space-want-mac': want.header },
    );
    expect(res.status).toBe(200);
    expect(res.headers.get('access-control-expose-headers')).toContain(
      'atproto-space-mac',
    );
    const { commit } = await res.json();
    const params = {
      space: SPACE,
      repo: DID,
      sharedSecret: want.sharedSecret,
      mac: responseMac(res, 'atproto-space-repo'),
    };
    expect(await verifyCommit(fromJsonCommit(commit), params)).toBe(true);
    // Another syncer's secret does not verify it.
    const other = await wantMac('atproto-space-repo', kemKey.didKey);
    expect(
      await verifyCommit(fromJsonCommit(commit), {
        ...params,
        sharedSecret: other.sharedSecret,
      }),
    ).toBe(false);
  });

  it('refuses a malformed Want-Mac header', async () => {
    await seed([[COL, 'a', { v: 1 }]]);
    for (const header of [
      'type="atproto-space-record", ver=1, key_share="did:key:zDnae"',
      'type="atproto-space-repo", ver=2, key_share="did:key:zDnae"',
      'type="atproto-space-repo", ver=1',
      'type="atproto-space-repo", ver=1, key_share="did:key:z6Mkhello"',
      'not a dictionary (',
    ]) {
      const res = await get(
        '/xrpc/com.atproto.space.getLatestCommit',
        { space: SPACE },
        { 'atproto-space-want-mac': header },
      );
      expect(res.status, header).toBe(400);
      expect((await res.json()).error).toBe('BadSpaceMac');
    }
  });
});

describe('getRecord MAC', () => {
  it('MACs the record uri and cid', async () => {
    await seed([[COL, 'a', { $type: COL, text: 'hi' }]]);
    const want = await wantMac('atproto-space-record', kemKey.didKey);
    const res = await get(
      '/xrpc/com.atproto.space.getRecord',
      { space: SPACE, collection: COL, rkey: 'a' },
      { 'atproto-space-want-mac': want.header },
    );
    const record = await res.json();
    const uri = `${SPACE}/${DID}/${COL}/a`;
    const params = {
      uri,
      sharedSecret: want.sharedSecret,
      mac: responseMac(res, 'atproto-space-record'),
    };
    expect(await verifyRecord(record, params)).toBe(true);
    expect(
      await verifyRecord({ ...record, value: { text: 'changed' } }, params),
    ).toBe(false);
    expect(
      await verifyRecord(record, {
        ...params,
        uri: `${SPACE}/${DID}/${COL}/b`,
      }),
    ).toBe(false);
  });

  // A blob ref is a CID link, so the record's CID is the one any other
  // implementation computes from the JSON it is served.
  it('serves a record with a blob ref under the CID of its JSON', async () => {
    const record = {
      $type: COL,
      image: {
        $type: 'blob',
        ref: {
          $link: 'bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy',
        },
        mimeType: 'image/png',
        size: 3,
      },
    };
    await seed([[COL, 'a', record]]);
    const want = await wantMac('atproto-space-record', kemKey.didKey);
    const res = await get(
      '/xrpc/com.atproto.space.getRecord',
      { space: SPACE, collection: COL, rkey: 'a' },
      { 'atproto-space-want-mac': want.header },
    );
    const body = await res.json();
    expect(body.value).toEqual(record);
    expect(
      await verifyRecord(body, {
        uri: `${SPACE}/${DID}/${COL}/a`,
        sharedSecret: want.sharedSecret,
        mac: responseMac(res, 'atproto-space-record'),
      }),
    ).toBe(true);
  });

  it('refuses a repo MAC type on getRecord', async () => {
    await seed([[COL, 'a', { v: 1 }]]);
    const want = await wantMac('atproto-space-repo', kemKey.didKey);
    const res = await get(
      '/xrpc/com.atproto.space.getRecord',
      { space: SPACE, collection: COL, rkey: 'a' },
      { 'atproto-space-want-mac': want.header },
    );
    expect(res.status).toBe(400);
    expect((await res.json()).error).toBe('BadSpaceMac');
  });
});

describe('listRepoOps', () => {
  it('inlines current record values and carries the head commit', async () => {
    await seed([[COL, 'a', { v: 1 }]]);
    const body = await (
      await get('/xrpc/com.atproto.space.listRepoOps', { space: SPACE })
    ).json();

    expect(body.ops).toHaveLength(1);
    expect(body.ops[0].value).toEqual({ v: 1 });
    expect(body.ops[0].prev).toBeNull();
    expect(body.commit).toBeTruthy();
  });

  it('MACs the head commit it carries', async () => {
    await seed([[COL, 'a', { v: 1 }]]);
    const want = await wantMac('atproto-space-repo', kemKey.didKey);
    const res = await get(
      '/xrpc/com.atproto.space.listRepoOps',
      { space: SPACE },
      { 'atproto-space-want-mac': want.header },
    );
    const body = await res.json();
    expect(
      await verifyCommit(fromJsonCommit(body.commit), {
        space: SPACE,
        repo: DID,
        sharedSecret: want.sharedSecret,
        mac: responseMac(res, 'atproto-space-repo'),
      }),
    ).toBe(true);
  });

  it('omits values when excludeValues is set', async () => {
    await seed([[COL, 'a', { v: 1 }]]);
    const body = await (
      await get('/xrpc/com.atproto.space.listRepoOps', {
        space: SPACE,
        excludeValues: 'true',
      })
    ).json();
    expect(body.ops[0].value).toBeUndefined();
  });

  it('omits a superseded value but keeps the op', async () => {
    await seed([[COL, 'a', { v: 1 }]]);
    await applyWrites(store, {
      space: SPACE,
      writes: [
        { action: 'update', collection: COL, rkey: 'a', record: { v: 2 } },
      ],
    });
    const body = await (
      await get('/xrpc/com.atproto.space.listRepoOps', { space: SPACE })
    ).json();

    expect(body.ops).toHaveLength(2);
    // The first op's value is stale: the stored record is the second's.
    expect(body.ops[0].value).toBeUndefined();
    expect(body.ops[1].value).toEqual({ v: 2 });
  });

  it('gives a cursor and no commit or MAC when more ops remain', async () => {
    await seed([
      [COL, 'a', { v: 1 }],
      [COL, 'b', { v: 2 }],
    ]);
    const want = await wantMac('atproto-space-repo', kemKey.didKey);
    const res = await get(
      '/xrpc/com.atproto.space.listRepoOps',
      { space: SPACE, limit: '1' },
      { 'atproto-space-want-mac': want.header },
    );
    const body = await res.json();
    expect(body.ops).toHaveLength(1);
    expect(body.cursor).toBeTruthy();
    // The commit describes the head, so it must not appear on a backfill page.
    expect(body.commit).toBeUndefined();
    expect(res.headers.get('atproto-space-mac')).toBeNull();
  });

  it('deletes carry a null cid and no value', async () => {
    await seed([[COL, 'a', { v: 1 }]]);
    const { rev } = await applyWrites(store, {
      space: SPACE,
      writes: [{ action: 'delete', collection: COL, rkey: 'a' }],
    });
    const body = await (
      await get('/xrpc/com.atproto.space.listRepoOps', {
        space: SPACE,
        since: '',
      })
    ).json();
    const del = body.ops.find((/** @type {any} */ o) => o.rev === rev);
    expect(del.cid).toBeNull();
    expect(del.value).toBeUndefined();
  });
});

describe('getRepo', () => {
  it('404s a repo that has never been written to', async () => {
    const res = await get('/xrpc/com.atproto.space.getRepo', { space: SPACE });
    expect(res.status).toBe(404);
    expect((await res.json()).error).toBe('RepoNotFound');
  });

  it('serialises the commit then records in byte order of their paths', async () => {
    // 'com.e.p/a' sorts before 'com.example.post/a' byte for byte.
    await seed([
      [COL, 'a', { v: 1 }],
      ['com.e.p', 'a', { v: 2 }],
    ]);

    const res = await get('/xrpc/com.atproto.space.getRepo', { space: SPACE });
    expect(res.status).toBe(200);
    expect(res.headers.get('content-type')).toBe('application/octet-stream');

    const { meta, pairs } = readStar(new Uint8Array(await res.arrayBuffer()));
    expect(meta.$type).toBe('atproto-space-repo');
    const decoder = new TextDecoder();
    expect(pairs.map((p) => decoder.decode(p.key))).toEqual([
      'com.e.p/a',
      `${COL}/a`,
    ]);
  });

  it('serves a repo that verifies against the MAC it carries', async () => {
    const { rev } = await seed([
      [COL, 'a', { v: 1 }],
      [COL, 'b', { v: 2 }],
    ]);
    const want = await wantMac('atproto-space-repo', kemKey.didKey);
    const res = await get(
      '/xrpc/com.atproto.space.getRepo',
      { space: SPACE },
      { 'atproto-space-want-mac': want.header },
    );
    const { commit, records } = await verifyRepoStar(
      new Uint8Array(await res.arrayBuffer()),
      {
        space: SPACE,
        repo: DID,
        sharedSecret: want.sharedSecret,
        mac: responseMac(res, 'atproto-space-repo'),
      },
    );
    expect(commit.rev).toBe(rev);
    expect(records.map((r) => r.record)).toEqual([{ v: 1 }, { v: 2 }]);
  });
});