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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
500 lines · 15.9 KB Raw History
import { beforeEach, describe, expect, it } from 'vitest';
import {
  createServiceJwt,
  verifyAccessJwt,
} from '../packages/core/src/auth.js';
import {
  base64UrlDecode,
  base64UrlEncode,
  generateKeyPair,
  importPrivateKey,
  sign,
} from '../packages/core/src/crypto.js';
import { PersonalDataServer } from '../packages/core/src/pds.js';
import { publicKeyToDidKey } from '../packages/core/src/plc.js';
import { createVerifier } from '../packages/core/src/verify.js';

const HOSTNAME = 'pds.example.com';
const PASSWORD = 'test-password';
const JWT_SECRET = 'test-secret';
const MIGRATING_DID = 'did:plc:migrateexampleaccount000';
const HANDLE = 'alice.pds.example.com';

/**
 * A stateful in-memory actor store, uninitialized until createAccount adopts a
 * DID. Only the fields createAccount, createSession, and describeServer read
 * are backed; the rest are inert stubs.
 */
function createEmptyStorage() {
  /** @type {Record<string, any>} */
  const state = {
    did: null,
    handle: null,
    privateKey: null,
    status: null,
    email: null,
  };
  return {
    _state: state,
    async getDid() {
      return state.did;
    },
    async setDid(/** @type {string} */ did) {
      state.did = did;
    },
    async getHandle() {
      return state.handle;
    },
    async setHandle(/** @type {string} */ handle) {
      state.handle = handle;
    },
    async getPrivateKey() {
      return state.privateKey;
    },
    async setPrivateKey(/** @type {Uint8Array} */ key) {
      state.privateKey = key;
    },
    async getKemKey() {
      return state.kemKey ?? null;
    },
    async setKemKey(/** @type {Uint8Array} */ key) {
      state.kemKey = key;
    },
    async getAccountStatus() {
      return state.status;
    },
    async setAccountStatus(/** @type {string} */ status) {
      state.status = status;
    },
    async getEmail() {
      return state.email;
    },
    async setEmail(/** @type {any} */ email) {
      state.email = email;
    },
  };
}

/** @param {ReturnType<typeof createEmptyStorage>} storage */
function createServer(storage) {
  return new PersonalDataServer({
    actorStorage: /** @type {any} */ (storage),
    sharedStorage: /** @type {any} */ ({}),
    blobs: /** @type {any} */ ({}),
    jwtSecret: JWT_SECRET,
    hostname: HOSTNAME,
    password: PASSWORD,
  });
}

/**
 * Encode a JWT with the given claims. The signature is a placeholder; the
 * server validates claims but not the signature for migration tokens.
 * @param {Record<string, any>} claims
 */
function encodeToken(claims) {
  const b64url = (/** @type {object} */ obj) =>
    Buffer.from(JSON.stringify(obj))
      .toString('base64')
      .replace(/\+/g, '-')
      .replace(/\//g, '_')
      .replace(/=+$/, '');
  return `${b64url({ alg: 'ES256', typ: 'JWT' })}.${b64url(claims)}.sig`;
}

function serviceToken(overrides = {}) {
  return encodeToken({
    iss: MIGRATING_DID,
    aud: `did:web:${HOSTNAME}`,
    lxm: 'com.atproto.server.createAccount',
    exp: Math.floor(Date.now() / 1000) + 300,
    ...overrides,
  });
}

/**
 * @param {PersonalDataServer} pds
 * @param {{body: Record<string, unknown>, headers?: Record<string, string>}} opts
 */
function createAccount(pds, { body, headers = {} }) {
  return pds.fetch(
    new Request(`https://${HOSTNAME}/xrpc/com.atproto.server.createAccount`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', ...headers },
      body: JSON.stringify(body),
    }),
  );
}

describe('createAccount (migration destination)', () => {
  /** @type {ReturnType<typeof createEmptyStorage>} */
  let storage;
  /** @type {PersonalDataServer} */
  let pds;

  beforeEach(() => {
    storage = createEmptyStorage();
    pds = createServer(storage);
  });

  it('adopts an existing DID authorized by a service token', async () => {
    const res = await createAccount(pds, {
      body: { did: MIGRATING_DID, handle: HANDLE },
      headers: { Authorization: `Bearer ${serviceToken()}` },
    });
    expect(res.status).toBe(200);
    const data = await res.json();
    expect(data.did).toBe(MIGRATING_DID);
    expect(data.handle).toBe(HANDLE);
    expect(data.accessJwt).toBeTruthy();
    expect(data.refreshJwt).toBeTruthy();

    // The access token names the adopted DID.
    const payload = await verifyAccessJwt(data.accessJwt, JWT_SECRET);
    expect(payload.sub).toBe(MIGRATING_DID);

    // The account lands deactivated with a freshly minted signing key.
    expect(storage._state.status).toBe('deactivated');
    expect(storage._state.did).toBe(MIGRATING_DID);
    expect(storage._state.handle).toBe(HANDLE);
    expect(storage._state.privateKey).toBeInstanceOf(Uint8Array);
  });

  it('authorizes with the configured password as a manual fallback', async () => {
    const res = await createAccount(pds, {
      body: { did: MIGRATING_DID, handle: HANDLE, password: PASSWORD },
    });
    expect(res.status).toBe(200);
    expect(storage._state.did).toBe(MIGRATING_DID);
  });

  it('rejects a service token whose audience is another server', async () => {
    const res = await createAccount(pds, {
      body: { did: MIGRATING_DID, handle: HANDLE },
      headers: {
        Authorization: `Bearer ${serviceToken({ aud: 'did:web:other.example' })}`,
      },
    });
    expect(res.status).toBe(401);
    expect(storage._state.did).toBeNull();
  });

  it('rejects a service token issued by a different DID', async () => {
    const res = await createAccount(pds, {
      body: { did: MIGRATING_DID, handle: HANDLE },
      headers: {
        Authorization: `Bearer ${serviceToken({ iss: 'did:plc:someoneelse00000000000000' })}`,
      },
    });
    expect(res.status).toBe(401);
  });

  it('rejects an expired service token', async () => {
    const res = await createAccount(pds, {
      body: { did: MIGRATING_DID, handle: HANDLE },
      headers: {
        Authorization: `Bearer ${serviceToken({ exp: Math.floor(Date.now() / 1000) - 1 })}`,
      },
    });
    expect(res.status).toBe(401);
  });

  it('rejects a service token scoped to a different method', async () => {
    const res = await createAccount(pds, {
      body: { did: MIGRATING_DID, handle: HANDLE },
      headers: {
        Authorization: `Bearer ${serviceToken({ lxm: 'com.atproto.repo.createRecord' })}`,
      },
    });
    expect(res.status).toBe(401);
  });

  it('refuses without any authorization', async () => {
    const res = await createAccount(pds, {
      body: { did: MIGRATING_DID, handle: HANDLE },
    });
    expect(res.status).toBe(401);
    expect(storage._state.did).toBeNull();
  });

  it('requires an existing DID (migration only)', async () => {
    const res = await createAccount(pds, {
      body: { handle: HANDLE, password: PASSWORD },
    });
    expect(res.status).toBe(400);
  });

  it('requires a valid handle', async () => {
    const res = await createAccount(pds, {
      body: { did: MIGRATING_DID, handle: 'not a handle', password: PASSWORD },
    });
    expect(res.status).toBe(400);
  });

  it('refuses when the server already hosts an account', async () => {
    storage._state.did = 'did:plc:alreadyhereaccount000000000';
    const res = await createAccount(pds, {
      body: { did: MIGRATING_DID, handle: HANDLE },
      headers: { Authorization: `Bearer ${serviceToken()}` },
    });
    expect(res.status).toBe(400);
  });

  it('the returned session can sign in via createSession', async () => {
    await createAccount(pds, {
      body: { did: MIGRATING_DID, handle: HANDLE, password: PASSWORD },
    });
    const res = await pds.fetch(
      new Request(`https://${HOSTNAME}/xrpc/com.atproto.server.createSession`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ identifier: HANDLE, password: PASSWORD }),
      }),
    );
    expect(res.status).toBe(200);
    const data = await res.json();
    expect(data.did).toBe(MIGRATING_DID);
  });
});

describe('describeServer service identity', () => {
  it('falls back to a did:web identity before an account exists', async () => {
    const pds = createServer(createEmptyStorage());
    const res = await pds.fetch(
      new Request(`https://${HOSTNAME}/xrpc/com.atproto.server.describeServer`),
    );
    const data = await res.json();
    expect(data.did).toBe(`did:web:${HOSTNAME}`);
  });
});

describe('createAccount with signature verification', () => {
  /**
   * Build a server that verifies token signatures, resolving the issuer's DID
   * to the given document. A P-256 verifier suffices for these tests; the
   * secp256k1 path is covered where @noble is available (see the node package).
   * @param {ReturnType<typeof createEmptyStorage>} storage
   * @param {import('../packages/core/src/ports.js').DidResolverPort} didResolver
   */
  function createVerifyingServer(storage, didResolver) {
    return new PersonalDataServer({
      actorStorage: /** @type {any} */ (storage),
      sharedStorage: /** @type {any} */ ({}),
      blobs: /** @type {any} */ ({}),
      jwtSecret: JWT_SECRET,
      hostname: HOSTNAME,
      password: PASSWORD,
      verifier: createVerifier(),
      didResolver,
    });
  }

  /**
   * A DID document publishing `didKey` as the account's atproto key.
   * @param {string} did
   * @param {string} didKey
   */
  function didDoc(did, didKey) {
    return {
      id: did,
      verificationMethod: [
        {
          id: `${did}#atproto`,
          type: 'Multikey',
          controller: did,
          publicKeyMultibase: didKey.replace('did:key:', ''),
        },
      ],
    };
  }

  /** Generate a P-256 keypair and its did:key. */
  async function makeKey() {
    const { privateKey, publicKey } = await generateKeyPair();
    const signingKey = await importPrivateKey(privateKey);
    return { signingKey, didKey: publicKeyToDidKey(publicKey) };
  }

  /**
   * @param {PersonalDataServer} pds
   * @param {string} token
   */
  function post(pds, token) {
    return pds.fetch(
      new Request(`https://${HOSTNAME}/xrpc/com.atproto.server.createAccount`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${token}`,
        },
        body: JSON.stringify({ did: MIGRATING_DID, handle: HANDLE }),
      }),
    );
  }

  it('accepts a token whose signature matches the published atproto key', async () => {
    const { signingKey, didKey } = await makeKey();
    const token = await createServiceJwt({
      iss: MIGRATING_DID,
      aud: `did:web:${HOSTNAME}`,
      lxm: 'com.atproto.server.createAccount',
      signingKey,
    });
    const storage = createEmptyStorage();
    const pds = createVerifyingServer(storage, async () =>
      didDoc(MIGRATING_DID, didKey),
    );

    const res = await post(pds, token);
    expect(res.status).toBe(200);
    expect(storage._state.did).toBe(MIGRATING_DID);
  });

  it('reads past a held DID document', async () => {
    // Adopting a DID is worth a fresh read: a held document can name a key the
    // account has rotated away from, and no longer controls.
    const { signingKey, didKey } = await makeKey();
    const token = await createServiceJwt({
      iss: MIGRATING_DID,
      aud: `did:web:${HOSTNAME}`,
      lxm: 'com.atproto.server.createAccount',
      signingKey,
    });
    /** @type {Array<{forceRefresh?: boolean}|undefined>} */
    const reads = [];
    const pds = createVerifyingServer(
      createEmptyStorage(),
      async (_did, opts) => {
        reads.push(opts);
        return didDoc(MIGRATING_DID, didKey);
      },
    );

    expect((await post(pds, token)).status).toBe(200);
    expect(reads).toEqual([{ forceRefresh: true }]);
  });

  it('rejects a token signed by a key other than the published one', async () => {
    // Signed by an attacker key; the DID document advertises a different key.
    const attacker = await makeKey();
    const published = await makeKey();
    const token = await createServiceJwt({
      iss: MIGRATING_DID,
      aud: `did:web:${HOSTNAME}`,
      lxm: 'com.atproto.server.createAccount',
      signingKey: attacker.signingKey,
    });
    const storage = createEmptyStorage();
    const pds = createVerifyingServer(storage, async () =>
      didDoc(MIGRATING_DID, published.didKey),
    );

    const res = await post(pds, token);
    expect(res.status).toBe(401);
    expect(storage._state.did).toBeNull();
  });

  /**
   * A service token with the header written by hand, so a test can stamp an
   * `alg` the signing key does not produce and still sign it correctly.
   * @param {string} alg
   * @param {import('../packages/core/src/crypto.js').SigningKey} signingKey
   */
  async function serviceJwtWithAlg(alg, signingKey) {
    const encode = (/** @type {object} */ o) =>
      base64UrlEncode(new TextEncoder().encode(JSON.stringify(o)));
    const now = Math.floor(Date.now() / 1000);
    const signingInput = `${encode({ typ: 'JWT', alg })}.${encode({
      iss: MIGRATING_DID,
      aud: `did:web:${HOSTNAME}`,
      lxm: 'com.atproto.server.createAccount',
      iat: now,
      exp: now + 60,
    })}`;
    const sig = await sign(signingKey, new TextEncoder().encode(signingInput));
    return `${signingInput}.${base64UrlEncode(sig)}`;
  }

  it('accepts a hand-built token stamped ES256 by a P-256 key', async () => {
    const { signingKey, didKey } = await makeKey();
    const storage = createEmptyStorage();
    const pds = createVerifyingServer(storage, async () =>
      didDoc(MIGRATING_DID, didKey),
    );

    const res = await post(pds, await serviceJwtWithAlg('ES256', signingKey));
    expect(res.status).toBe(200);
    expect(storage._state.did).toBe(MIGRATING_DID);
  });

  it('rejects a token whose alg the published key cannot produce', async () => {
    // The same signature, stamped ES256K. The key is P-256, and the reference
    // implementation refuses the mismatch before it compares bytes.
    const { signingKey, didKey } = await makeKey();
    const storage = createEmptyStorage();
    const pds = createVerifyingServer(storage, async () =>
      didDoc(MIGRATING_DID, didKey),
    );

    const res = await post(pds, await serviceJwtWithAlg('ES256K', signingKey));
    expect(res.status).toBe(401);
    expect(storage._state.did).toBeNull();
  });

  it('rejects when the issuer DID cannot be resolved', async () => {
    const { signingKey } = await makeKey();
    const token = await createServiceJwt({
      iss: MIGRATING_DID,
      aud: `did:web:${HOSTNAME}`,
      lxm: 'com.atproto.server.createAccount',
      signingKey,
    });
    const storage = createEmptyStorage();
    const pds = createVerifyingServer(storage, async () => null);

    const res = await post(pds, token);
    expect(res.status).toBe(401);
  });

  it('rejects when the DID document has no atproto key', async () => {
    const { signingKey } = await makeKey();
    const token = await createServiceJwt({
      iss: MIGRATING_DID,
      aud: `did:web:${HOSTNAME}`,
      lxm: 'com.atproto.server.createAccount',
      signingKey,
    });
    const storage = createEmptyStorage();
    const pds = createVerifyingServer(storage, async () => ({
      id: MIGRATING_DID,
      verificationMethod: [],
    }));

    const res = await post(pds, token);
    expect(res.status).toBe(401);
  });

  it('rejects a tampered signature segment', async () => {
    const { signingKey, didKey } = await makeKey();
    const token = await createServiceJwt({
      iss: MIGRATING_DID,
      aud: `did:web:${HOSTNAME}`,
      lxm: 'com.atproto.server.createAccount',
      signingKey,
    });
    // Corrupt the first byte of the signature. Flipping a base64url *character*
    // is unreliable — the last char carries padding bits a flip may not change —
    // so decode, mutate a real byte, and re-encode.
    const parts = token.split('.');
    const sigBytes = base64UrlDecode(parts[2]);
    sigBytes[0] ^= 0xff;
    parts[2] = base64UrlEncode(sigBytes);
    const storage = createEmptyStorage();
    const pds = createVerifyingServer(storage, async () =>
      didDoc(MIGRATING_DID, didKey),
    );

    const res = await post(pds, parts.join('.'));
    expect(res.status).toBe(401);
  });
});