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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
138 lines · 4.1 KB Raw History
// Inter-service auth: the token a repo host presents to an authority for
// notifyWrite, and the authority presents to a syncer for notifySpaceDeleted.
//
// Signers here use @pdsjs/core/crypto's sign(), which normalizes to low-S, for
// the reason auth.test.js gives.

import { compressPublicKey, sign } from '@pdsjs/core/crypto';
import { publicKeyToDidKey } from '@pdsjs/core/plc';
import { describe, expect, it } from 'vitest';
import { ATPROTO_KEY_ID } from '../src/authority.js';
import { createServiceAuth, verifyServiceAuth } from '../src/service-auth.js';
import { createVerifier } from '../src/verifier.js';

const CALLER = 'did:plc:servicecaller00000000000';
const US = 'did:plc:servicereceiver000000000';
const LXM = 'com.atproto.space.notifyWrite';

/** A P-256 identity, its did:key published as `#atproto`. */
async function makeIdentity() {
  const keys = await crypto.subtle.generateKey(
    { name: 'ECDSA', namedCurve: 'P-256' },
    true,
    ['sign', 'verify'],
  );
  const raw = new Uint8Array(
    await crypto.subtle.exportKey('raw', keys.publicKey),
  );
  const didKey = publicKeyToDidKey(compressPublicKey(raw));
  return {
    didDoc: {
      id: CALLER,
      verificationMethod: [
        {
          id: ATPROTO_KEY_ID,
          type: 'Multikey',
          publicKeyMultibase: didKey.slice('did:key:'.length),
        },
      ],
    },
    signer: {
      curve: /** @type {const} */ ('p256'),
      /** @param {Uint8Array} bytes */
      sign: (bytes) => sign(keys.privateKey, bytes),
    },
  };
}

/** @param {{signer: {sign: (b: Uint8Array) => Promise<Uint8Array>}}} identity */
function tokenFrom(identity) {
  return createServiceAuth({
    iss: CALLER,
    aud: US,
    lxm: LXM,
    signer: identity.signer,
  });
}

/**
 * @param {string} jwt
 * @param {(did: string, opts?: {forceRefresh?: boolean}) => Promise<any>} resolveDid
 */
function verify(jwt, resolveDid) {
  return verifyServiceAuth({
    jwt,
    aud: US,
    lxm: LXM,
    resolveDid,
    verifier: createVerifier(),
  });
}

describe('verifyServiceAuth', () => {
  it('accepts a token signed by the published key', async () => {
    const caller = await makeIdentity();
    const jwt = await tokenFrom(caller);
    await expect(verify(jwt, async () => caller.didDoc)).resolves.toEqual({
      iss: CALLER,
    });
  });

  it('refuses a token signed by another key', async () => {
    const caller = await makeIdentity();
    const impostor = await makeIdentity();
    const jwt = await tokenFrom(impostor);
    await expect(verify(jwt, async () => caller.didDoc)).rejects.toMatchObject({
      code: 'BadJwtSignature',
    });
  });

  it('accepts a token signed by the key the caller rotated to', async () => {
    const held = await makeIdentity();
    const rotated = await makeIdentity();
    const jwt = await tokenFrom(rotated);

    const resolved = /** @type {boolean[]} */ ([]);
    const doc = await verify(jwt, async (_did, opts) => {
      resolved.push(Boolean(opts?.forceRefresh));
      return opts?.forceRefresh ? rotated.didDoc : held.didDoc;
    });

    expect(doc).toEqual({ iss: CALLER });
    // The held answer is read first; the forced read only follows a failure.
    expect(resolved).toEqual([false, true]);
  });

  it('reads the directory once when the held key verifies', async () => {
    const caller = await makeIdentity();
    const jwt = await tokenFrom(caller);
    let reads = 0;
    await verify(jwt, async () => {
      reads++;
      return caller.didDoc;
    });
    expect(reads).toBe(1);
  });

  it('refuses when the forced read returns the same key', async () => {
    const caller = await makeIdentity();
    const impostor = await makeIdentity();
    const jwt = await tokenFrom(impostor);
    let reads = 0;
    await expect(
      verify(jwt, async () => {
        reads++;
        return caller.didDoc;
      }),
    ).rejects.toMatchObject({ code: 'BadJwtSignature' });
    expect(reads).toBe(2);
  });

  it('refuses a token whose issuer does not resolve', async () => {
    const caller = await makeIdentity();
    const jwt = await tokenFrom(caller);
    await expect(verify(jwt, async () => null)).rejects.toMatchObject({
      code: 'DidNotFound',
    });
  });
});