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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
262 lines · 9.2 KB Raw History
// Verifies a permissioned repo, MACs and space tokens produced by the reference
// implementation (bluesky-social/atproto), from fixtures committed under
// test/fixtures/space-reference-*. Exercises our decoder against their encoder;
// the opposite direction is scripts/space-reference/run.sh, which also
// regenerates these fixtures.

import { readFileSync } from 'node:fs';
import { cborDecode } from '@pdsjs/core/repo';
import { verifyCommit } from '@pdsjs/spaces/commit';
import { verifyCredentialSig } from '@pdsjs/spaces/http-signature';
import { decapsulate } from '@pdsjs/spaces/kem';
import { parseMacHeader } from '@pdsjs/spaces/mac';
import {
  createMemoryBlobContext,
  createMemorySpaceStorage,
} from '@pdsjs/spaces/memory-storage';
import { createSpaceRoutes } from '@pdsjs/spaces/routes';
import { verifyRecord, verifyRepoStar } from '@pdsjs/spaces/star';
import { parseSpaceToken } from '@pdsjs/spaces/token';
import { createVerifier } from '@pdsjs/spaces/verifier';
import { applyWrites } from '@pdsjs/spaces/writer';
import { afterEach, describe, expect, it, vi } from 'vitest';

// REF_ARTIFACTS_DIR overrides the committed fixtures with a freshly generated
// set; scripts/space-reference/run.sh uses it to check upstream has not drifted.
const DIR = process.env.REF_ARTIFACTS_DIR;
const manifestPath = DIR
  ? `${DIR}/manifest.json`
  : new URL('./fixtures/space-reference-manifest.json', import.meta.url);
const starPath = DIR
  ? `${DIR}/repo.star`
  : new URL('./fixtures/space-reference-repo.star', import.meta.url);

const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
const star = new Uint8Array(readFileSync(starPath));

const fromB64 = (/** @type {string} */ s) =>
  Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
const kemPrivateKey = new Uint8Array(
  Buffer.from(manifest.kemPrivateKeyHex, 'hex'),
);

/** The secret the reference MACed a read under, derived as a repo host. */
const secretFor = (/** @type {{keyShare: string}} */ read) =>
  decapsulate(kemPrivateKey, read.keyShare);

async function repoParams() {
  return {
    space: manifest.space,
    repo: manifest.author,
    sharedSecret: await secretFor(manifest.getRepo),
    mac: parseMacHeader(manifest.getRepo.mac, 'atproto-space-repo'),
  };
}

describe('pds.js verifies reference output', () => {
  it('accepts the reference STAR archive end to end', async () => {
    const { commit, records } = await verifyRepoStar(star, await repoParams());
    expect(commit.rev).toBe(manifest.rev);
    expect(records.map((r) => `${r.collection}/${r.rkey}`)).toEqual(
      manifest.expectedPaths,
    );
    const reply = records.find((r) => r.collection === 'com.example.reply');
    expect(reply?.record).toEqual({
      $type: 'com.example.reply',
      deep: { nested: [1, 2, 3] },
    });
    // The blob ref arrives as a CID link, tag 42.
    const pictured = records.find((r) => r.rkey === 'ccc');
    expect(pictured?.cid).toBe(manifest.getRecord.record.cid);
  });

  it('rejects the archive for the wrong repo or space', async () => {
    const params = await repoParams();
    await expect(
      verifyRepoStar(star, { ...params, repo: 'did:plc:wrong' }),
    ).rejects.toThrow(/commit failed verification/);
    await expect(
      verifyRepoStar(star, {
        ...params,
        space: 'at://did:plc:refauthority/space/com.example.forum/other',
      }),
    ).rejects.toThrow(/commit failed verification/);
  });

  it('verifies the JSON-shaped commit under the same MAC', async () => {
    const commit = {
      ...manifest.commit,
      data: fromB64(manifest.commit.data.$bytes),
    };
    expect(await verifyCommit(commit, await repoParams())).toBe(true);
  });

  it('verifies a record whose value carries a blob ref', async () => {
    const { record, mac } = manifest.getRecord;
    expect(
      await verifyRecord(record, {
        uri: record.uri,
        sharedSecret: await secretFor(manifest.getRecord),
        mac: parseMacHeader(mac, 'atproto-space-record'),
      }),
    ).toBe(true);
  });
});

describe('pds.js verifies reference tokens', () => {
  const verifier = createVerifier();

  // A committed token is always expired, and expiry is a local clock check
  // rather than a cross-implementation property. These check what does cross
  // the wire: that their JWT parses and their signature verifies.
  /**
   * @param {'delegation'|'credential'} type
   * @param {string} jwt
   * @param {string} didKey
   */
  async function verifySignatureOnly(type, jwt, didKey) {
    const { header, payload, signingInput, sig } = parseSpaceToken(type, jwt);
    const ok = await verifier.verify(didKey, signingInput, sig);
    return { header, payload, ok };
  }

  it('parses and verifies their delegation token', async () => {
    const { payload, ok } = await verifySignatureOnly(
      'delegation',
      manifest.delegationToken,
      manifest.userDidKey,
    );
    expect(ok).toBe(true);
    expect(payload.iss).toBe(manifest.author);
    expect(payload.sub).toBe(manifest.space);
    expect(payload.aud).toBe(manifest.delegationAud);
  });

  it('parses and verifies their space credential', async () => {
    const { header, payload, ok } = await verifySignatureOnly(
      'credential',
      manifest.credential,
      manifest.authorityDidKey,
    );
    expect(ok).toBe(true);
    expect(payload.iss).toBe(manifest.authority);
    expect(header.kid).toBe('#atproto');
    expect(payload.aud).toBeUndefined();
    expect(payload.cnf?.kid).toBe(manifest.credentialKeyId);
  });

  it('verifies their credential signature for its audience', async () => {
    const { audience } = await verifyCredentialSig(
      new Headers(manifest.useSignature),
      { keyId: manifest.credentialKeyId, verifier },
    );
    expect(audience).toBe(manifest.author);
  });

  it('rejects a delegation token parsed as a credential', async () => {
    expect(() =>
      parseSpaceToken('credential', manifest.delegationToken),
    ).toThrow(/wrong token type/);
  });

  it('rejects their credential under the wrong key', async () => {
    const { ok } = await verifySignatureOnly(
      'credential',
      manifest.credential,
      manifest.userDidKey,
    );
    expect(ok).toBe(false);
  });
});

describe('pds.js serves a reference syncer', () => {
  afterEach(() => {
    vi.useRealTimers();
  });

  // pds.js as the author's repo host, holding the reference's KEM key: their
  // credential, signed with their createSpaceSigHeaders, is accepted, and the
  // MAC it answers their key share with is the one the reference computed.
  it('accepts their credential and MACs the record as they did', async () => {
    const { iat } = parseSpaceToken('credential', manifest.credential).payload;
    vi.useFakeTimers({ toFake: ['Date'] });
    vi.setSystemTime((iat + 1) * 1000);

    const spaceStorage = createMemorySpaceStorage();
    const { record } = manifest.getRecord;
    const [, , , , , , , collection, rkey] = record.uri.split('/');
    await applyWrites(spaceStorage, {
      space: manifest.space,
      writes: [{ action: 'create', collection, rkey, record: record.value }],
    });
    const routes = /** @type {Record<string, any>} */ (
      createSpaceRoutes({
        spaceStorage,
        ...createMemoryBlobContext(),
        getDid: async () => manifest.author,
        getSigner: async () => {
          throw new Error('not used');
        },
        getKemKey: async () => ({
          privateKey: kemPrivateKey,
          didKey: manifest.kemDidKey,
        }),
        resolveDid: async (/** @type {string} */ did) =>
          did === manifest.authority
            ? {
                id: did,
                verificationMethod: [
                  {
                    id: '#atproto',
                    type: 'Multikey',
                    publicKeyMultibase: manifest.authorityDidKey.slice(
                      'did:key:'.length,
                    ),
                  },
                ],
              }
            : null,
        verifier: createVerifier(),
      })
    );

    const path = '/xrpc/com.atproto.space.getRecord';
    const url = new URL(`https://pds.example${path}`);
    url.searchParams.set('space', manifest.space);
    url.searchParams.set('collection', collection);
    url.searchParams.set('rkey', rkey);
    const res = await routes[path].handler(
      new Request(url, {
        headers: {
          ...manifest.useSignature,
          'atproto-space-want-mac': manifest.getRecord.wantMac,
        },
      }),
      url,
      null,
    );
    expect(res.status).toBe(200);
    const body = await res.json();
    expect(body.cid).toBe(record.cid);
    expect(body.value).toEqual(record.value);
    expect(res.headers.get('atproto-space-mac')).toBe(manifest.getRecord.mac);
  });

  it('stores their record under the CID they computed', async () => {
    const spaceStorage = createMemorySpaceStorage();
    const { record } = manifest.getRecord;
    const [, , , , , , , collection, rkey] = record.uri.split('/');
    await applyWrites(spaceStorage, {
      space: manifest.space,
      writes: [{ action: 'create', collection, rkey, record: record.value }],
    });
    const row = await spaceStorage.getSpaceRecord(
      manifest.space,
      collection,
      rkey,
    );
    if (!row) throw new Error('expected the record');
    expect(row.cid).toBe(record.cid);
    expect(cborDecode(row.value).image.ref).toBeInstanceOf(Uint8Array);
  });
});