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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
187 lines · 5.8 KB Raw History
// Client attestations: the JWT that says which application is asking a space
// authority for a credential.
//
// Signers here use @pdsjs/core/crypto's sign(), which normalizes to low-S, for
// the reason auth.test.js gives.

import { sign } from '@pdsjs/core/crypto';
import { beforeEach, describe, expect, it } from 'vitest';
import { verifyClientAttestation } from '../src/attestation.js';
import { createSpaceToken, SpaceTokenError } from '../src/token.js';

const CLIENT = 'https://app.example/client-metadata.json';
const AUD = 'did:plc:authority0000000000000#space_host';
const KID = 'key-1';

/** A P-256 key pair, with the public half as a JWK a client would publish. */
async function makeKey() {
  const keys = await crypto.subtle.generateKey(
    { name: 'ECDSA', namedCurve: 'P-256' },
    true,
    ['sign', 'verify'],
  );
  const jwk = /** @type {any} */ (
    await crypto.subtle.exportKey('jwk', keys.publicKey)
  );
  return {
    publicJwk: { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y, kid: KID },
    signer: {
      /** @param {Uint8Array} bytes */
      sign: (bytes) => sign(keys.privateKey, bytes),
    },
  };
}

/** @type {Awaited<ReturnType<typeof makeKey>>} */
let key;
/** What the client_id URL answers with. */
/** @type {(url: string) => Response} */
let serve;

beforeEach(async () => {
  key = await makeKey();
  serve = () =>
    Response.json({ client_id: CLIENT, jwks: { keys: [key.publicJwk] } });
  globalThis.fetch = /** @type {any} */ (
    async (/** @type {any} */ input) => serve(String(input))
  );
});

/**
 * @param {{iss?: string, aud?: string, kid?: string, alg?: string, expiresInSec?: number, signer?: any}} [opts]
 */
function attestation(opts = {}) {
  return createSpaceToken(
    'clientAttestation',
    {
      iss: opts.iss ?? CLIENT,
      sub: opts.iss ?? CLIENT,
      aud: opts.aud ?? AUD,
      kid: opts.kid ?? KID,
      ...(opts.alg ? { alg: opts.alg } : {}),
      ...(opts.expiresInSec ? { expiresInSec: opts.expiresInSec } : {}),
    },
    opts.signer ?? key.signer,
  );
}

describe('verifyClientAttestation', () => {
  it('establishes the client_id a signed attestation claims', async () => {
    const clientId = await verifyClientAttestation({
      attestation: await attestation(),
      aud: AUD,
    });
    expect(clientId).toBe(CLIENT);
  });

  it('reads the key set from jwks_uri when the metadata has no inline jwks', async () => {
    const jwksUri = 'https://app.example/jwks.json';
    serve = (url) =>
      url === CLIENT
        ? Response.json({ client_id: CLIENT, jwks_uri: jwksUri })
        : Response.json({ keys: [key.publicJwk] });
    await expect(
      verifyClientAttestation({ attestation: await attestation(), aud: AUD }),
    ).resolves.toBe(CLIENT);
  });

  it('refuses an attestation addressed to another authority', async () => {
    await expect(
      verifyClientAttestation({
        attestation: await attestation({ aud: 'did:plc:elsewhere#space_host' }),
        aud: AUD,
      }),
    ).rejects.toThrow(/another authority/);
  });

  it('refuses an expired attestation', async () => {
    await expect(
      verifyClientAttestation({
        attestation: await attestation({ expiresInSec: -10 }),
        aud: AUD,
      }),
    ).rejects.toThrow(/expired/);
  });

  it('refuses an algorithm other than ES256', async () => {
    await expect(
      verifyClientAttestation({
        attestation: await attestation({ alg: 'ES256K' }),
        aud: AUD,
      }),
    ).rejects.toThrow(/ES256/);
  });

  it('refuses an attestation naming no key', async () => {
    // An empty kid never matches a published key, so it is refused before any
    // fetch rather than resolving to whichever key comes first.
    await expect(
      verifyClientAttestation({
        attestation: await attestation({ kid: '' }),
        aud: AUD,
      }),
    ).rejects.toThrow(/"kid"/);
  });

  it('refuses a client whose metadata does not resolve', async () => {
    serve = () => new Response('gone', { status: 404 });
    await expect(
      verifyClientAttestation({ attestation: await attestation(), aud: AUD }),
    ).rejects.toThrow(/Could not resolve client metadata/);
  });

  it('refuses a kid the client does not publish', async () => {
    serve = () =>
      Response.json({
        client_id: CLIENT,
        jwks: { keys: [{ ...key.publicJwk, kid: 'other' }] },
      });
    await expect(
      verifyClientAttestation({ attestation: await attestation(), aud: AUD }),
    ).rejects.toThrow(/publishes no key/);
  });

  it('refuses a signature from a key the client does not publish', async () => {
    const impostor = await makeKey();
    await expect(
      verifyClientAttestation({
        attestation: await attestation({ signer: impostor.signer }),
        aud: AUD,
      }),
    ).rejects.toThrow(/signature is invalid/);
  });

  it('spends the attestation, so a captured one cannot be presented twice', async () => {
    const jwt = await attestation();
    await expect(
      verifyClientAttestation({ attestation: jwt, aud: AUD }),
    ).resolves.toBe(CLIENT);
    await expect(
      verifyClientAttestation({ attestation: jwt, aud: AUD }),
    ).rejects.toThrow(/already been used/);
  });

  it('refuses an attestation carrying no jti to be spent by', async () => {
    const jwt = await attestation();
    const [header, payload, sig] = jwt.split('.');
    const decoded = JSON.parse(
      new TextDecoder().decode(
        Uint8Array.from(
          atob(payload.replace(/-/g, '+').replace(/_/g, '/')),
          (c) => c.charCodeAt(0),
        ),
      ),
    );
    decoded.jti = undefined;
    const stripped = btoa(JSON.stringify(decoded))
      .replace(/\+/g, '-')
      .replace(/\//g, '_')
      .replace(/=+$/, '');
    await expect(
      verifyClientAttestation({
        attestation: `${header}.${stripped}.${sig}`,
        aud: AUD,
      }),
    ).rejects.toThrow(SpaceTokenError);
  });
});