chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// 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);
});
});