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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
146 lines · 4.9 KB Raw History
// /init adopts a DID and its signing key. On an empty server that is the
// bootstrap path — there is no account to authenticate as yet. Once a DID is
// adopted the same endpoint could repoint the account at an attacker's
// identity and key, so from then on it takes the account's own credentials.

import { describe, expect, it } from 'vitest';
import { createAccessJwt } from '../packages/core/src/auth.js';
import { bytesToHex, generateKeyPair } from '../packages/core/src/crypto.js';
import { PersonalDataServer } from '../packages/core/src/pds.js';

const DID = 'did:plc:initexampleaccountaaaaaa';
const JWT_SECRET = 'test-secret';

/** @param {{did?: string|null, password?: string}} [opts] */
function server(opts = {}) {
  const state = {
    did: opts.did ?? null,
    /** @type {Uint8Array|null} */ privateKey: null,
    /** @type {Uint8Array|null} */ kemKey: null,
    handle: /** @type {string|null} */ (null),
  };
  const pds = new PersonalDataServer({
    actorStorage: /** @type {any} */ ({
      async getDid() {
        return state.did;
      },
      async setDid(/** @type {string} */ did) {
        state.did = did;
      },
      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 getHandle() {
        return state.handle;
      },
      async setHandle(/** @type {string} */ handle) {
        state.handle = handle;
      },
      async getLatestCommit() {
        return null;
      },
      async getLatestSeq() {
        return 0;
      },
      async putEvent() {},
    }),
    sharedStorage: /** @type {any} */ ({
      async putActor() {},
    }),
    blobs: /** @type {any} */ ({}),
    jwtSecret: JWT_SECRET,
    hostname: 'pds.example.com',
    password: opts.password,
  });
  return { pds, state };
}

async function initRequest(
  /** @type {{token?: string, password?: string}} */ opts = {},
) {
  const { privateKey } = await generateKeyPair();
  /** @type {Record<string, string>} */
  const headers = { 'Content-Type': 'application/json' };
  if (opts.token) headers.Authorization = `Bearer ${opts.token}`;
  return new Request('https://pds.example.com/init', {
    method: 'POST',
    headers,
    body: JSON.stringify({
      did: DID,
      privateKey: bytesToHex(privateKey),
      handle: 'alice.example.com',
      ...(opts.password ? { password: opts.password } : {}),
    }),
  });
}

describe('/init', () => {
  it('bootstraps an empty server without credentials', async () => {
    const { pds, state } = server();
    const res = await pds.fetch(await initRequest());

    expect(res.status).toBe(200);
    expect(state.did).toBe(DID);
    expect(state.privateKey).not.toBeNull();
  });

  it('refuses to repoint an initialized server without credentials', async () => {
    const { pds, state } = server({ did: 'did:plc:existingaccountaaaaaaaa' });
    const res = await pds.fetch(await initRequest());

    expect(res.status).toBe(401);
    expect((await res.json()).error).toBe('AuthenticationRequired');
    // The identity and key are untouched: nothing was adopted.
    expect(state.did).toBe('did:plc:existingaccountaaaaaaaa');
    expect(state.privateKey).toBeNull();
  });

  it('lets the account itself repoint an initialized server', async () => {
    const existing = 'did:plc:existingaccountaaaaaaaa';
    const { pds, state } = server({ did: existing });
    const token = await createAccessJwt(existing, JWT_SECRET);
    const res = await pds.fetch(await initRequest({ token }));

    expect(res.status).toBe(200);
    expect(state.did).toBe(DID);
  });

  // A deployment can set the password before the first request reaches the
  // server. That closes the window between deploy and bootstrap, where the
  // endpoint would otherwise adopt any caller's identity.
  it('requires the configured password to bootstrap an empty server', async () => {
    const { pds, state } = server({ password: 'hunter2' });
    const res = await pds.fetch(await initRequest());

    expect(res.status).toBe(401);
    expect((await res.json()).error).toBe('AuthenticationRequired');
    expect(state.did).toBeNull();
    expect(state.privateKey).toBeNull();
  });

  it('rejects a wrong bootstrap password', async () => {
    const { pds, state } = server({ password: 'hunter2' });
    const res = await pds.fetch(await initRequest({ password: 'hunter3' }));

    expect(res.status).toBe(401);
    expect(state.did).toBeNull();
  });

  it('bootstraps with the configured password', async () => {
    const { pds, state } = server({ password: 'hunter2' });
    const res = await pds.fetch(await initRequest({ password: 'hunter2' }));

    expect(res.status).toBe(200);
    expect(state.did).toBe(DID);
    expect(state.privateKey).not.toBeNull();
  });
});