// 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>} */ 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); }); });