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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
179 lines · 5.7 KB Raw History
/**
 * The helper's dependency-free space auth, checked against @pdsjs/spaces: the
 * credential signature it sends, the KEM secret it derives and the record MAC
 * it verifies must be the ones a pds.js repo host accepts and produces.
 */

import { describe, expect, it } from 'vitest';
import { generateKemKey } from '../../core/src/kem.js';
import {
  CID,
  cborEncodeDagCbor,
  cidToBytes,
  cidToString,
  createCid,
} from '../../core/src/repo.js';
import { verifyCredentialSig } from '../../spaces/src/http-signature.js';
import { decapsulate } from '../../spaces/src/kem.js';
import {
  computeRecordMac,
  formatMacHeader,
  parseWantMacHeader,
} from '../../spaces/src/mac.js';
import { createVerifier } from '../../spaces/src/verifier.js';
import {
  encapsulate,
  formatWantMacHeader,
  p256PointFromDidKey,
  parseMacHeader,
  RECORD_MAC_TYPE,
  recordCid,
  verifyRecord,
} from '../src/space-mac.js';
import {
  createSpaceSigHeaders,
  generateSpaceSigKey,
} from '../src/space-signature.js';

const SPACE = 'at://did:plc:authority/space/dev.pdsjs.git.space/repo';
const REPO = 'did:plc:member';

describe('credential signature', () => {
  it('is accepted by a pds.js repo host', async () => {
    const key = await generateSpaceSigKey();
    const headers = await createSpaceSigHeaders(key, {
      authorization: 'Atproto-Space-Credential a.b.c',
      audience: REPO,
    });
    expect(headers['Signature-Input']).toBe(
      'atproto-space-credential=("authorization" "atproto-space-audience");tag="atproto-space-credential"',
    );
    expect(headers.Signature).toMatch(/^atproto-space-credential=:[^:]+=*:$/);
    const verified = await verifyCredentialSig(new Headers(headers), {
      keyId: key.did,
      verifier: createVerifier(),
    });
    expect(verified.audience).toBe(REPO);
  });
});

describe('record MAC', () => {
  it('derives the secret the repo host derives', async () => {
    const kem = await generateKemKey();
    const { keyShare, sharedSecret } = await encapsulate(kem.didKey);
    const header = formatWantMacHeader(RECORD_MAC_TYPE, keyShare);
    expect(parseWantMacHeader(header, RECORD_MAC_TYPE)).toBe(keyShare);
    expect(await decapsulate(kem.privateKey, keyShare)).toEqual(sharedSecret);
  });

  it('decompresses both parities of point', async () => {
    for (let i = 0; i < 4; i++) {
      const pair = /** @type {CryptoKeyPair} */ (
        await crypto.subtle.generateKey(
          { name: 'ECDH', namedCurve: 'P-256' },
          true,
          ['deriveBits'],
        )
      );
      const raw = new Uint8Array(
        await crypto.subtle.exportKey('raw', pair.publicKey),
      );
      const { publicKeyToDidKey } = await import('../../core/src/plc.js');
      const { compressPublicKey } = await import('../../core/src/crypto.js');
      const did = publicKeyToDidKey(compressPublicKey(raw), 'p256');
      expect(p256PointFromDidKey(did)).toEqual(raw);
    }
  });

  it('verifies a record the repo host MACed', async () => {
    const kem = await generateKemKey();
    const { keyShare, sharedSecret } = await encapsulate(kem.didKey);
    const value = {
      $type: 'dev.pdsjs.git.repo',
      name: 'repo',
      refs: [{ name: 'refs/heads/main', sha: 'a'.repeat(40) }],
      size: 70000,
      negative: -3,
      ok: true,
      none: null,
      blob: {
        $type: 'blob',
        ref: {
          $link: 'bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy',
        },
        mimeType: 'application/octet-stream',
        size: 12,
      },
      raw: { $bytes: 'AAEC' },
    };
    // The same record as DAG-CBOR holds it: a link under tag 42, bytes as a
    // byte string.
    const lex = {
      ...value,
      blob: { ...value.blob, ref: new CID(cidToBytes(value.blob.ref.$link)) },
      raw: new Uint8Array([0, 1, 2]),
    };
    const cid = cidToString(await createCid(cborEncodeDagCbor(lex)));
    expect(await recordCid(value)).toBe(cid);

    const uri = `${SPACE}/${REPO}/dev.pdsjs.git.repo/repo`;
    const secret = await decapsulate(kem.privateKey, keyShare);
    const header = formatMacHeader(
      RECORD_MAC_TYPE,
      await computeRecordMac(secret, uri, cid),
    );
    const mac = parseMacHeader(header, RECORD_MAC_TYPE);
    const record = { uri, cid, value };
    expect(await verifyRecord(record, { uri, sharedSecret, mac })).toBe(true);
    expect(
      await verifyRecord(
        { ...record, value: { ...value, name: 'other' } },
        { uri, sharedSecret, mac },
      ),
    ).toBe(false);
    expect(
      await verifyRecord(record, { uri: `${uri}x`, sharedSecret, mac }),
    ).toBe(false);
    expect(
      await verifyRecord(record, {
        uri,
        sharedSecret: new Uint8Array(32),
        mac,
      }),
    ).toBe(false);
  });

  it('encodes a plain record to the cid core computes', async () => {
    const value = {
      $type: 'dev.pdsjs.git.repo',
      zeta: 'z',
      a: [1, 23, 24, 255, 256, 65535, 65536, -1, -25],
      nested: { bb: 'x', a: { c: false } },
      text: 'é'.repeat(30),
    };
    const expected = cidToString(await createCid(cborEncodeDagCbor(value)));
    expect(await recordCid(value)).toBe(expected);
  });

  it('refuses a MAC header of another type or version', () => {
    expect(() =>
      parseMacHeader(
        'type="atproto-space-repo", ver=1, mac=:AAEC:',
        RECORD_MAC_TYPE,
      ),
    ).toThrow();
    expect(() =>
      parseMacHeader(
        'type="atproto-space-record", ver=2, mac=:AAEC:',
        RECORD_MAC_TYPE,
      ),
    ).toThrow();
    expect(() => parseMacHeader(null, RECORD_MAC_TYPE)).toThrow();
  });

  it('refuses a key share that is not a P-256 did:key', async () => {
    await expect(
      encapsulate('did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK'),
    ).rejects.toThrow();
  });
});