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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
169 lines · 5.1 KB Raw History
import { describe, expect, it } from 'vitest';
import {
  createSpaceSigHeaders,
  generateSpaceSigKey,
  isP256DidKey,
  verifyCredentialSig,
} from '../src/http-signature.js';
import { createVerifier } from '../src/verifier.js';
import { vectors } from './helpers/vectors.js';

const verifier = createVerifier();
const AUDIENCE = 'did:plc:bbbb';

/** @param {Record<string, string>} h */
const headers = (h) => new Headers(h);

describe('createSpaceSigHeaders', () => {
  it('writes the reference header shapes', async () => {
    const key = await generateSpaceSigKey();
    const out = await createSpaceSigHeaders(key, {
      authorization: 'Atproto-Space-Credential a.b.c',
      audience: AUDIENCE,
    });
    expect(out['signature-input']).toBe(
      vectors.httpSignature.headers['signature-input'],
    );
    expect(out.signature).toMatch(
      /^atproto-space-credential=:[A-Za-z0-9+/]{86}==:$/,
    );
    expect(out['atproto-space-audience']).toBe(AUDIENCE);
  });

  it('round-trips through verifyCredentialSig', async () => {
    const key = await generateSpaceSigKey();
    const out = await createSpaceSigHeaders(key, {
      authorization: 'Atproto-Space-Credential a.b.c',
      audience: AUDIENCE,
    });
    expect(
      await verifyCredentialSig(headers(out), { keyId: key.did, verifier }),
    ).toEqual({ audience: AUDIENCE });
  });
});

describe('verifyCredentialSig', () => {
  const ref = vectors.httpSignature;

  it('accepts the reference signature', async () => {
    expect(
      await verifyCredentialSig(headers(ref.headers), {
        keyId: ref.keyId,
        verifier,
      }),
    ).toEqual({ audience: ref.headers['atproto-space-audience'] });
  });

  it('accepts a matching keyid and algorithm', async () => {
    // The signature covers the parameters, so add them by re-signing.
    const key = await generateSpaceSigKey();
    const out = await createSpaceSigHeaders(key, {
      authorization: 'Atproto-Space-Credential a.b.c',
      audience: AUDIENCE,
    });
    await expect(
      verifyCredentialSig(
        headers({
          ...out,
          'signature-input': `${out['signature-input']};keyid="${key.did}"`,
        }),
        { keyId: key.did, verifier },
      ),
    ).rejects.toThrow(/invalid HTTP message signature/);
  });

  /** @param {Record<string, string>} changes */
  async function refuse(changes, pattern = /./) {
    const h = { ...ref.headers, ...changes };
    for (const [k, v] of Object.entries(h)) if (v === '') delete h[k];
    const err = await verifyCredentialSig(headers(h), {
      keyId: ref.keyId,
      verifier,
    }).catch((e) => e);
    expect(err?.code).toBe('BadSpaceSignature');
    expect(err.message).toMatch(pattern);
  }

  it('refuses a missing or repeated field', async () => {
    await refuse({ signature: '' }, /missing Signature/);
    await refuse({ 'signature-input': '' }, /missing Signature-Input/);
    await refuse({ 'atproto-space-audience': '' }, /exactly one/);
    await refuse(
      { 'atproto-space-audience': `${AUDIENCE}, ${AUDIENCE}` },
      /exactly one/,
    );
  });

  it('refuses another label, components, tag, alg or keyid', async () => {
    const input = ref.headers['signature-input'];
    await refuse(
      { 'signature-input': input.replace('atproto-space-credential=', 'sig=') },
      /no atproto-space-credential member/,
    );
    await refuse(
      {
        'signature-input':
          'atproto-space-credential=("authorization");tag="atproto-space-credential"',
      },
      /cover exactly/,
    );
    await refuse(
      {
        'signature-input': input.replace(
          'tag="atproto-space-credential"',
          'tag="other"',
        ),
      },
      /tag/,
    );
    await refuse({ 'signature-input': `${input};alg="ed25519"` }, /algorithm/);
    await refuse(
      { 'signature-input': `${input};keyid="did:key:zDnaother"` },
      /keyid/,
    );
    await refuse(
      { 'signature-input': 'atproto-space-credential=:AAAA:' },
      /inner list/,
    );
    await refuse(
      { signature: 'atproto-space-credential=("x")' },
      /byte sequence/,
    );
    await refuse({ signature: '(((' }, /malformed Signature/);
  });

  it('refuses a signature over other values', async () => {
    await refuse(
      { authorization: 'Atproto-Space-Credential x.y.z' },
      /invalid HTTP message signature/,
    );
    await refuse(
      { signature: 'atproto-space-credential=:AAAA:' },
      /invalid HTTP message signature/,
    );
  });

  it('refuses a key that is not P-256', async () => {
    const err = await verifyCredentialSig(headers(ref.headers), {
      keyId: 'did:key:zQ3shokFTS3brHcDQrn82RUDfCZESWL1ZdCEJwekUDPQiYBme',
      verifier,
    }).catch((e) => e);
    expect(err.message).toMatch(/P-256/);
  });
});

describe('isP256DidKey', () => {
  it('accepts P-256 only', () => {
    expect(isP256DidKey(ref())).toBe(true);
    expect(
      isP256DidKey('did:key:zQ3shokFTS3brHcDQrn82RUDfCZESWL1ZdCEJwekUDPQiYBme'),
    ).toBe(false);
    expect(isP256DidKey('did:plc:aaaa')).toBe(false);
    expect(isP256DidKey(undefined)).toBe(false);
    expect(isP256DidKey('did:key:zzzz')).toBe(false);
  });
});

function ref() {
  return vectors.httpSignature.keyId;
}