// @pdsjs/spaces/attestation - which application is asking for a credential. // // A delegation token says the user delegated to somebody. The client // attestation says who. It is a `private_key_jwt` signed by the client's own // authentication key, so establishing the client_id means resolving it to its // client-metadata.json, fetching the key set that document publishes, and // checking the signature against the key its `kid` names. // // All of it, or a memberspace `allowedApps` list states a rule the authority // cannot enforce: the client_id is otherwise a claim the caller writes for itself. import { fetchClientMetadata, resolveClientJwk, verifyEs256WithJwk, } from '@pdsjs/core/oauth'; import { spend } from './single-use.js'; import { parseSpaceToken, SpaceTokenError } from './token.js'; /** * Verify a client attestation and return the client_id it establishes. * * @param {Object} opts * @param {string} opts.attestation - the raw JWT * @param {string} opts.aud - the space host the attestation must address * @returns {Promise} the attested client_id * @throws {SpaceTokenError} */ export async function verifyClientAttestation({ attestation, aud }) { const { header, payload, signingInput, sig } = parseSpaceToken( 'clientAttestation', attestation, ); const clientId = payload.iss; if (payload.aud !== aud) { throw new SpaceTokenError( 'Client attestation is addressed to another authority', 'BadJwtAudience', ); } const now = Math.floor(Date.now() / 1000); if (payload.exp <= now) { throw new SpaceTokenError('Client attestation has expired', 'JwtExpired'); } if (header.alg !== 'ES256') { throw new SpaceTokenError( `Client attestation "alg" must be ES256, got "${header.alg}"`, 'BadJwt', ); } if (typeof header.kid !== 'string' || !header.kid) { throw new SpaceTokenError('Client attestation needs a "kid"', 'BadJwt'); } /** @type {import('@pdsjs/core/oauth').ClientMetadata} */ let metadata; try { metadata = await fetchClientMetadata(clientId); } catch (err) { throw new SpaceTokenError( `Could not resolve client metadata for ${clientId}: ${err instanceof Error ? err.message : String(err)}`, 'BadJwtIss', ); } const jwk = await resolveClientJwk(metadata, header.kid); if (!jwk) { throw new SpaceTokenError( `Client ${clientId} publishes no key "${header.kid}" to verify an attestation against`, 'BadJwtSignature', ); } let valid = false; try { valid = await verifyEs256WithJwk( jwk, new TextDecoder().decode(signingInput), base64Url(sig), ); } catch { valid = false; } if (!valid) { throw new SpaceTokenError( 'Client attestation signature is invalid', 'BadJwtSignature', ); } // Spent after the signature, so an unsigned guess cannot burn the jti of an // attestation the client is about to present. if (!spend(`attestation@${clientId}`, payload.jti, payload.exp * 1000)) { throw new SpaceTokenError( `Client attestation for ${clientId} has already been used`, 'BadJwt', ); } return clientId; } /** * @param {Uint8Array} bytes * @returns {string} base64url, unpadded */ function base64Url(bytes) { let binary = ''; for (const b of bytes) binary += String.fromCharCode(b); return btoa(binary) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, ''); }