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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
522 lines · 17.2 KB Raw History
// An account whose atproto key is secp256k1 — the curve the reference PDS
// gives every account, and the only one some third-party services (Go feed
// generators) can verify. WebCrypto cannot sign it, so core takes the
// implementation as a port; these tests drive that port end to end with the
// same @noble/curves implementation the platform packages inject.

import { k256Signer, secp256k1Verify } from '@pdsjs/node';
import { describe, expect, it } from 'vitest';
import { createAccessJwt } from '../packages/core/src/auth.js';
import {
  base64UrlDecode,
  decodeStoredKey,
  encodeStoredKey,
  generateKeyPair,
  importPrivateKey,
  sign,
} from '../packages/core/src/crypto.js';
import { createIdentityHandlers } from '../packages/core/src/handlers/xrpc-identity.js';
import { PersonalDataServer } from '../packages/core/src/pds.js';
import { publicKeyToDidKey } from '../packages/core/src/plc.js';
import { createVerifier, parseDidKey } from '../packages/core/src/verify.js';
import { ATPROTO_PDS_ID } from '../packages/spaces/src/authority.js';
import {
  createMemoryBlobContext,
  createMemorySpaceStorage,
} from '../packages/spaces/src/memory-storage.js';
import { createSpaceRoutes } from '../packages/spaces/src/routes.js';
import {
  createServiceAuth,
  verifyServiceAuth,
} from '../packages/spaces/src/service-auth.js';
import { createSpaceToken } from '../packages/spaces/src/token.js';

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

/**
 * @param {Uint8Array} stored - Key as persisted (curve-tagged for secp256k1)
 * @param {{signer?: any}} [opts]
 */
function server(stored, opts = {}) {
  let saved = stored;
  return new PersonalDataServer({
    actorStorage: /** @type {any} */ ({
      async getDid() {
        return DID;
      },
      async getPrivateKey() {
        return saved;
      },
      async setPrivateKey(/** @type {Uint8Array} */ key) {
        saved = key;
      },
      async getKemKey() {
        return null;
      },
      async getHandle() {
        return 'alice.example.com';
      },
    }),
    sharedStorage: /** @type {any} */ ({}),
    blobs: /** @type {any} */ ({}),
    jwtSecret: JWT_SECRET,
    hostname: 'pds.example.com',
    verifier: createVerifier({ secp256k1: secp256k1Verify }),
    k256Signer: 'signer' in opts ? opts.signer : k256Signer,
  });
}

function k256Account() {
  const privateKey = k256Signer.generatePrivateKey();
  return {
    privateKey,
    stored: encodeStoredKey(privateKey, 'secp256k1'),
    didKey: publicKeyToDidKey(k256Signer.getPublicKey(privateKey), 'secp256k1'),
  };
}

describe('stored key format', () => {
  it('round-trips a secp256k1 key with its curve', () => {
    const privateKey = k256Signer.generatePrivateKey();
    const decoded = decodeStoredKey(encodeStoredKey(privateKey, 'secp256k1'));
    expect(decoded.curve).toBe('secp256k1');
    expect(decoded.privateKey).toEqual(privateKey);
  });

  it('reads a bare 32-byte key as P-256, the format stored before curves were tagged', async () => {
    const { privateKey } = await generateKeyPair();
    const decoded = decodeStoredKey(privateKey);
    expect(decoded.curve).toBe('p256');
    expect(decoded.privateKey).toEqual(privateKey);
  });
});

describe('did:key encoding', () => {
  it('gives a secp256k1 key the zQ3s prefix the ecosystem expects', () => {
    const { didKey } = k256Account();
    expect(didKey.startsWith('did:key:zQ3s')).toBe(true);
    expect(parseDidKey(didKey).curve).toBe('secp256k1');
  });
});

describe('service auth with a secp256k1 account key', () => {
  it('signs the token ES256K, and the signature verifies against the account did:key', async () => {
    const { stored, didKey } = k256Account();
    const pds = server(stored);

    const jwt = await pds._proxy.createServiceAuthForAppView(
      DID,
      'app.bsky.feed.sendInteractions',
    );
    const [headerB64, payloadB64, sigB64] = jwt.split('.');

    const header = JSON.parse(
      new TextDecoder().decode(base64UrlDecode(headerB64)),
    );
    // ES256K, not ES256: the alg has to match the curve in the DID document,
    // or a verifier picks the wrong algorithm and rejects the token.
    expect(header.alg).toBe('ES256K');

    const claims = JSON.parse(
      new TextDecoder().decode(base64UrlDecode(payloadB64)),
    );
    expect(claims.iss).toBe(DID);
    expect(claims.lxm).toBe('app.bsky.feed.sendInteractions');

    const verifier = createVerifier({ secp256k1: secp256k1Verify });
    const valid = await verifier.verify(
      didKey,
      new TextEncoder().encode(`${headerB64}.${payloadB64}`),
      base64UrlDecode(sigB64),
    );
    expect(valid).toBe(true);
  });

  it('still signs ES256 for a P-256 account, even with a k256 signer available', async () => {
    const { privateKey, publicKey } = await generateKeyPair();
    const pds = server(privateKey);

    const jwt = await pds._proxy.createServiceAuthForAppView(
      DID,
      'app.bsky.feed.getTimeline',
    );
    const header = JSON.parse(
      new TextDecoder().decode(base64UrlDecode(jwt.split('.')[0])),
    );
    expect(header.alg).toBe('ES256');

    const [headerB64, payloadB64, sigB64] = jwt.split('.');
    const valid = await createVerifier().verify(
      publicKeyToDidKey(publicKey),
      new TextEncoder().encode(`${headerB64}.${payloadB64}`),
      base64UrlDecode(sigB64),
    );
    expect(valid).toBe(true);
  });
});

describe('recommended DID credentials', () => {
  it('publishes the secp256k1 did:key as the account verification method', async () => {
    const { stored, didKey } = k256Account();
    const identity = createIdentityHandlers(
      /** @type {any} */ ({
        actorStorage: {
          getPrivateKey: async () => stored,
          getKemKey: async () => null,
          getHandle: async () => 'alice.example.com',
        },
        hostname: 'pds.example.com',
        plcUrl: 'https://plc.test',
        k256Signer,
        getDid: async () => DID,
      }),
    );
    const credentials = await identity.getRecommendedDidCredentials();
    expect(credentials?.verificationMethods.atproto).toBe(didKey);
    expect(credentials?.rotationKeys).toContain(didKey);
  });
});

describe('OAuth token signing', () => {
  it('mints at+jwt with ES256K and publishes a matching JWKS entry', async () => {
    const { stored } = k256Account();
    const pds = server(stored);

    const token = await pds._oauth.createOAuthAccessToken({
      issuer: 'https://pds.example.com',
      subject: DID,
      clientId: 'https://app.example.com/client-metadata.json',
      scope: 'atproto',
      tokenId: 'tok-1',
      dpopJkt: 'jkt-1',
      expiresIn: 3600,
    });
    const header = JSON.parse(
      new TextDecoder().decode(base64UrlDecode(token.split('.')[0])),
    );
    expect(header.alg).toBe('ES256K');

    const jwks = await (await pds._oauth.handleOAuthJwks()).json();
    expect(jwks.keys[0].alg).toBe('ES256K');
    expect(jwks.keys[0].crv).toBe('secp256k1');
  });
});

describe('repo commits', () => {
  it('signs a commit that verifies against the account did:key', async () => {
    const { stored, didKey } = k256Account();
    const pds = server(stored);
    const signingKey = await pds.getSigningKey();
    expect(signingKey).not.toBeNull();

    const bytes = new TextEncoder().encode('commit bytes');
    const sig = await sign(/** @type {any} */ (signingKey), bytes);

    expect(sig.length).toBe(64);
    const verifier = createVerifier({ secp256k1: secp256k1Verify });
    expect(await verifier.verify(didKey, bytes, sig)).toBe(true);
  });
});

describe('a deployment without a secp256k1 signer', () => {
  it('refuses to use a stored secp256k1 key rather than signing it wrong', async () => {
    const { stored } = k256Account();
    const pds = server(stored, { signer: undefined });
    await expect(pds.getSigningKey()).rejects.toThrow(/secp256k1/);
  });

  it('mints P-256 keys for new accounts', async () => {
    const { privateKey } = await generateKeyPair();
    const pds = server(privateKey, { signer: undefined });
    const minted = await pds.generateSigningKey();
    expect(decodeStoredKey(minted.stored).curve).toBe('p256');
    expect(minted.didKey.startsWith('did:key:zDn')).toBe(true);
  });
});

describe('new accounts on a secp256k1-capable deployment', () => {
  it('mint secp256k1 keys, matching what the reference PDS issues', async () => {
    const { privateKey } = await generateKeyPair();
    const minted = await server(privateKey).generateSigningKey();
    expect(decodeStoredKey(minted.stored).curve).toBe('secp256k1');
    expect(minted.didKey.startsWith('did:key:zQ3s')).toBe(true);

    // The minted handle signs, and its signature verifies under the did:key
    // that would be published for it.
    const data = new TextEncoder().encode('hello');
    const sig = await sign(minted.signingKey, data);
    const verifier = createVerifier({ secp256k1: secp256k1Verify });
    expect(await verifier.verify(minted.didKey, data, sig)).toBe(true);
  });
});

describe('proxying to a feed generator', () => {
  it('sends an ES256K service token — what foryou.club and other Go feedgens verify', async () => {
    const { stored, didKey } = k256Account();
    const calls = /** @type {{url: string, auth: string|null}[]} */ ([]);
    const originalFetch = globalThis.fetch;
    globalThis.fetch = /** @type {any} */ (
      async (/** @type {any} */ url, /** @type {any} */ init) => {
        calls.push({
          url: String(url),
          auth: init?.headers?.get?.('Authorization') ?? null,
        });
        return new Response('{}', {
          headers: { 'Content-Type': 'application/json' },
        });
      }
    );

    try {
      const didResolver = async (/** @type {string} */ did) =>
        did === 'did:web:foryou.club'
          ? {
              service: [
                {
                  id: '#bsky_fg',
                  type: 'BskyFeedGenerator',
                  serviceEndpoint: 'https://foryou.club',
                },
              ],
            }
          : null;

      const pds = new PersonalDataServer({
        actorStorage: /** @type {any} */ ({
          async getDid() {
            return DID;
          },
          async getPrivateKey() {
            return stored;
          },
          async getKemKey() {
            return null;
          },
          async getHandle() {
            return 'alice.example.com';
          },
        }),
        sharedStorage: /** @type {any} */ ({}),
        blobs: /** @type {any} */ ({}),
        jwtSecret: JWT_SECRET,
        hostname: 'pds.example.com',
        didResolver,
        verifier: createVerifier({ secp256k1: secp256k1Verify }),
        k256Signer,
      });

      const token = await createAccessJwt(DID, JWT_SECRET);
      const res = await pds.fetch(
        new Request(
          'https://pds.example.com/xrpc/app.bsky.feed.sendInteractions',
          {
            method: 'POST',
            headers: {
              Authorization: `Bearer ${token}`,
              'atproto-proxy': 'did:web:foryou.club#bsky_fg',
              'Content-Type': 'application/json',
            },
            body: JSON.stringify({ interactions: [] }),
          },
        ),
      );

      expect(res.status).toBe(200);
      expect(calls).toHaveLength(1);
      const jwt = (calls[0].auth || '').slice('Bearer '.length);
      const [headerB64, payloadB64, sigB64] = jwt.split('.');
      const header = JSON.parse(
        new TextDecoder().decode(base64UrlDecode(headerB64)),
      );
      expect(header.alg).toBe('ES256K');

      const claims = JSON.parse(
        new TextDecoder().decode(base64UrlDecode(payloadB64)),
      );
      expect(claims.aud).toBe('did:web:foryou.club');
      expect(claims.lxm).toBe('app.bsky.feed.sendInteractions');

      const verifier = createVerifier({ secp256k1: secp256k1Verify });
      expect(
        await verifier.verify(
          didKey,
          new TextEncoder().encode(`${headerB64}.${payloadB64}`),
          base64UrlDecode(sigB64),
        ),
      ).toBe(true);
    } finally {
      globalThis.fetch = originalFetch;
    }
  });
});

const SPACE_AUTHORITY = 'did:plc:k256spaceauthority00000';
const SPACE = `at://${SPACE_AUTHORITY}/space/com.example.forum/general`;
const SPACE_SCOPE =
  `space:com.example.forum?authority=${SPACE_AUTHORITY}` +
  '&collection=com.example.post';

/** @param {string} jwt */
function jwtHeader(jwt) {
  return JSON.parse(
    new TextDecoder().decode(base64UrlDecode(jwt.split('.')[0])),
  );
}

/** A secp256k1 signer in the shape the platform packages hand @pdsjs/spaces. */
function k256SpaceSigner() {
  const privateKey = k256Signer.generatePrivateKey();
  const didKey = publicKeyToDidKey(
    k256Signer.getPublicKey(privateKey),
    'secp256k1',
  );
  return {
    didKey,
    /** A DID document publishing the key as the account's atproto method. */
    didDoc: {
      verificationMethod: [
        {
          id: '#atproto',
          type: 'Multikey',
          publicKeyMultibase: didKey.slice('did:key:'.length),
        },
      ],
    },
    signer: {
      curve: /** @type {const} */ ('secp256k1'),
      /** @param {Uint8Array} bytes */
      sign: (bytes) => k256Signer.sign(privateKey, bytes),
    },
  };
}

describe('space tokens with a secp256k1 account key', () => {
  it('signs a delegation token ES256K, and the signature verifies against the account did:key', async () => {
    const { didKey, signer } = k256SpaceSigner();
    const path = '/xrpc/com.atproto.space.getDelegationToken';
    const routes = createSpaceRoutes({
      spaceStorage: createMemorySpaceStorage(),
      ...createMemoryBlobContext(),
      getDid: async () => DID,
      getSigner: async () => signer,
      getKemKey: async () => null,
      resolveDid: async () => null,
      verifier: createVerifier({ secp256k1: secp256k1Verify }),
    });

    const url = new URL(`https://pds.example.com${path}`);
    url.searchParams.set('space', SPACE);
    // The handler declares `this: PersonalDataServer` and does not use it.
    const res = await routes[path].handler.call(
      /** @type {any} */ (null),
      new Request(url),
      url,
      { did: DID, scope: SPACE_SCOPE },
    );
    expect(res.status).toBe(200);
    const { token } = await res.json();

    const [headerB64, payloadB64, sigB64] = token.split('.');
    // The authority resolves the issuer's key, reads secp256k1, and refuses a
    // header naming another algorithm before it checks the signature.
    expect(jwtHeader(token).alg).toBe('ES256K');

    const payload = JSON.parse(
      new TextDecoder().decode(base64UrlDecode(payloadB64)),
    );
    expect(payload.aud).toBe(`${SPACE_AUTHORITY}${ATPROTO_PDS_ID}`);

    const verifier = createVerifier({ secp256k1: secp256k1Verify });
    expect(
      await verifier.verify(
        didKey,
        new TextEncoder().encode(`${headerB64}.${payloadB64}`),
        base64UrlDecode(sigB64),
      ),
    ).toBe(true);
  });

  it('signs a space credential ES256K', async () => {
    const { signer } = k256SpaceSigner();
    const credential = await createSpaceToken(
      'credential',
      { iss: SPACE_AUTHORITY, sub: SPACE, keyId: 'did:key:zDnaeholder' },
      signer,
    );
    expect(jwtHeader(credential).alg).toBe('ES256K');
  });

  it('signs a notifyWrite service token ES256K', async () => {
    const { signer } = k256SpaceSigner();
    const token = await createServiceAuth({
      iss: DID,
      aud: `${SPACE_AUTHORITY}${ATPROTO_PDS_ID}`,
      lxm: 'com.atproto.space.notifyWrite',
      signer,
    });
    expect(jwtHeader(token).alg).toBe('ES256K');
  });

  it('signs ES256 for a P-256 account key', async () => {
    const { privateKey } = await generateKeyPair();
    const key = await importPrivateKey(privateKey);
    const signer = {
      curve: /** @type {const} */ ('p256'),
      /** @param {Uint8Array} bytes */
      sign: (bytes) => sign(key, bytes),
    };

    const token = await createSpaceToken(
      'delegation',
      { iss: DID, sub: SPACE, aud: `${SPACE_AUTHORITY}${ATPROTO_PDS_ID}` },
      signer,
    );
    expect(jwtHeader(token).alg).toBe('ES256');
  });
});

describe('service tokens presented to a space endpoint', () => {
  const AUD = 'did:plc:k256spacerepohost000000';
  const LXM = 'com.atproto.space.notifyWrite';

  /** @param {{didDoc: object}} identity @param {string} jwt */
  function verifyOpts(identity, jwt) {
    return {
      jwt,
      aud: AUD,
      lxm: LXM,
      resolveDid: async () => identity.didDoc,
      verifier: createVerifier({ secp256k1: secp256k1Verify }),
    };
  }

  it('accepts a token stamped ES256K by a secp256k1 key', async () => {
    const identity = k256SpaceSigner();
    const jwt = await createServiceAuth({
      iss: DID,
      aud: AUD,
      lxm: LXM,
      signer: identity.signer,
    });
    await expect(verifyServiceAuth(verifyOpts(identity, jwt))).resolves.toEqual(
      { iss: DID },
    );
  });

  it('refuses a token stamped ES256 by the same key', async () => {
    const identity = k256SpaceSigner();
    // A signer that does not declare its curve stamps ES256, which is the
    // token this server sent until the alg was derived from the key.
    const jwt = await createServiceAuth({
      iss: DID,
      aud: AUD,
      lxm: LXM,
      signer: { sign: identity.signer.sign },
    });
    expect(jwtHeader(jwt).alg).toBe('ES256');
    await expect(
      verifyServiceAuth(verifyOpts(identity, jwt)),
    ).rejects.toMatchObject({ code: 'BadJwtSignature' });
  });
});