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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
239 lines · 8.5 KB Raw History
// The PLC operation flow, driven through the identity handlers: the mailed
// confirmation code, signing an operation chained onto the DID's log, and the
// endpoint wrapper around the recommended credentials.
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
  generateKeyPair,
  importPrivateKey,
} from '../packages/core/src/crypto.js';
import { createIdentityHandlers } from '../packages/core/src/handlers/xrpc-identity.js';

const DID = 'did:plc:plcoperationtestacct2345';
const AUTH = { did: DID, scope: 'com.atproto.access' };
const SECRET = 'test-secret';

afterEach(() => vi.unstubAllGlobals());

/** Answer the PLC directory's /data and /log/last endpoints. */
function stubPlcDirectory() {
  vi.stubGlobal(
    'fetch',
    vi.fn(async (/** @type {string} */ url) => {
      const u = String(url);
      if (u.endsWith('/data')) {
        return new Response(JSON.stringify({ rotationKeys: [] }));
      }
      if (u.endsWith('/log/last')) {
        return new Response(JSON.stringify({ cid: 'bafyreiprevop' }));
      }
      return new Response('', { status: 404 });
    }),
  );
}

/**
 * @param {Object} [options]
 * @param {{address: string|null, verified: boolean}|null} [options.email]
 * @param {boolean} [options.withEmailer]
 * @param {boolean} [options.withKey]
 * @param {boolean} [options.failSend]
 */
async function createIdentity({
  email = { address: 'chad@example.com', verified: true },
  withEmailer = true,
  withKey = true,
  failSend = false,
} = {}) {
  const { privateKey } = await generateKeyPair();
  const signingKey = withKey ? await importPrivateKey(privateKey) : null;
  /** @type {{token: {codeHash: string, expiresAt: number}|null}} */
  const state = { token: null };
  /** @type {Array<{to: string, text: string}>} */
  const sent = [];

  const identity = createIdentityHandlers(
    /** @type {any} */ ({
      actorStorage: {
        getHandle: async () => 'alice.example.com',
        getPrivateKey: async () => (withKey ? privateKey : null),
        getKemKey: async () => null,
        getEmail: async () => email && { ...email, pending: null },
        setPlcToken: async (/** @type {any} */ token) => {
          state.token = token;
        },
        getPlcToken: async () => state.token,
      },
      hostname: 'pds.example.com',
      plcUrl: 'https://plc.example.com',
      jwtSecret: SECRET,
      emailer: withEmailer
        ? {
            async send(/** @type {any} */ message) {
              if (failSend) throw new Error('smtp down');
              sent.push(message);
            },
          }
        : null,
      k256Signer: null,
      getDid: async () => DID,
      getSigningKey: async () => signingKey,
      readJsonBounded: async (/** @type {Request} */ request) => request.json(),
    }),
  );
  return { identity, state, sent };
}

/**
 * @param {ReturnType<typeof createIdentityHandlers>} identity
 * @param {string} path
 * @param {{body?: Object, auth?: Object|null}} [options]
 */
function call(identity, path, { body, auth = null } = {}) {
  const url = new URL(`https://pds.example.com${path}`);
  const request = new Request(url, {
    method: body === undefined ? 'GET' : 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const handler = /** @type {any} */ (identity.routes[path]).handler;
  return handler(request, url, auth);
}

const REQUEST = '/xrpc/com.atproto.identity.requestPlcOperationSignature';
const SIGN = '/xrpc/com.atproto.identity.signPlcOperation';
const CREDENTIALS = '/xrpc/com.atproto.identity.getRecommendedDidCredentials';

describe('getRecommendedDidCredentials endpoint', () => {
  it('answers the credentials, gating on auth and initialization', async () => {
    stubPlcDirectory();
    const { identity } = await createIdentity();
    expect((await call(identity, CREDENTIALS)).status).toBe(401);

    const { identity: uninit } = await createIdentity({ withKey: false });
    expect((await call(uninit, CREDENTIALS, { auth: AUTH })).status).toBe(400);

    const res = await call(identity, CREDENTIALS, { auth: AUTH });
    const body = await res.json();
    expect(body.alsoKnownAs).toEqual(['at://alice.example.com']);
    expect(body.verificationMethods.atproto).toMatch(/^did:key:/);
    expect(body.services.atproto_pds.endpoint).toBe('https://pds.example.com');
  });
});

describe('requestPlcOperationSignature', () => {
  it('requires authentication', async () => {
    const { identity } = await createIdentity();
    expect((await call(identity, REQUEST, { body: {} })).status).toBe(401);
  });

  it('issues no code when it cannot reach the account holder', async () => {
    // No sender, and separately no confirmed address: both answer 200 with no
    // code, leaving the session as the only authorization.
    for (const options of [
      { withEmailer: false },
      { email: { address: 'chad@example.com', verified: false } },
      { email: null },
    ]) {
      const { identity, state } = await createIdentity(options);
      const res = await call(identity, REQUEST, { body: {}, auth: AUTH });
      expect(res.status).toBe(200);
      expect(state.token).toBeNull();
    }
  });

  it('mails a code and stores its hash', async () => {
    const { identity, state, sent } = await createIdentity();
    const res = await call(identity, REQUEST, { body: {}, auth: AUTH });
    expect(res.status).toBe(200);
    expect(sent).toHaveLength(1);
    expect(sent[0].to).toBe('chad@example.com');
    expect(sent[0].text).toMatch(/\d{6}/);
    expect(state.token?.codeHash).toBeTruthy();
  });

  it('clears the stored token when the message cannot be sent', async () => {
    const { identity, state } = await createIdentity({ failSend: true });
    const res = await call(identity, REQUEST, { body: {}, auth: AUTH });
    expect(res.status).toBe(502);
    expect(state.token).toBeNull();
  });
});

describe('signPlcOperation', () => {
  it('requires authentication', async () => {
    const { identity } = await createIdentity();
    expect((await call(identity, SIGN, { body: {} })).status).toBe(401);
  });

  it('signs without a code when the server cannot confirm by email', async () => {
    stubPlcDirectory();
    const { identity } = await createIdentity({ withEmailer: false });
    const res = await call(identity, SIGN, { body: {}, auth: AUTH });
    expect(res.status).toBe(200);
    const { operation } = await res.json();
    expect(operation.type).toBe('plc_operation');
    expect(operation.prev).toBe('bafyreiprevop');
    expect(operation.sig).toBeTruthy();
  });

  it('demands the mailed code once one can be sent', async () => {
    stubPlcDirectory();
    const { identity, sent } = await createIdentity();

    const withoutCode = await call(identity, SIGN, { body: {}, auth: AUTH });
    expect(withoutCode.status).toBe(400);
    expect((await withoutCode.json()).message).toContain(
      'requestPlcOperationSignature',
    );

    await call(identity, REQUEST, { body: {}, auth: AUTH });
    const code = /** @type {RegExpMatchArray} */ (
      sent[0].text.match(/\d{6}/)
    )[0];

    const wrongCode = await call(identity, SIGN, {
      body: { token: '000000' },
      auth: AUTH,
    });
    expect(wrongCode.status).toBe(400);
    expect((await wrongCode.json()).message).toBe(
      'Confirmation code is invalid',
    );

    const res = await call(identity, SIGN, {
      body: { token: code },
      auth: AUTH,
    });
    expect(res.status).toBe(200);
    expect((await res.json()).operation.sig).toBeTruthy();

    // Spent: the same code signs exactly one operation.
    const reuse = await call(identity, SIGN, {
      body: { token: code },
      auth: AUTH,
    });
    expect(reuse.status).toBe(400);
  });

  it('lets caller-supplied fields override the recommended defaults', async () => {
    stubPlcDirectory();
    const { identity } = await createIdentity({ withEmailer: false });
    const res = await call(identity, SIGN, {
      body: { rotationKeys: ['did:key:zCallerChoice'] },
      auth: AUTH,
    });
    const { operation } = await res.json();
    expect(operation.rotationKeys).toEqual(['did:key:zCallerChoice']);
    expect(operation.alsoKnownAs).toEqual(['at://alice.example.com']);
  });

  it('refuses before the server holds a signing key', async () => {
    const { identity } = await createIdentity({
      withKey: false,
      withEmailer: false,
    });
    const res = await call(identity, SIGN, { body: {}, auth: AUTH });
    expect(res.status).toBe(400);
    expect((await res.json()).message).toBe('PDS not initialized');
  });
});