// @pdsjs/spaces/http-signature - the HTTP message signature a space credential // travels with (RFC 9421). // // Every request presenting a credential signs it together with the DID the // request is addressed to, `Atproto-Space-Audience`, under the label and tag // `atproto-space-credential`, ecdsa-p256-sha256, by the P-256 key the // credential names in `cnf.kid`. A host given the credential to serve one // account cannot present it to another. // // A signature covers no method, URL or nonce, so it may be reused for one // credential and one audience while the credential is valid. import { compressPublicKey, sign } from '@pdsjs/core/crypto'; import { publicKeyToDidKey } from '@pdsjs/core/plc'; import { parseDidKey } from '@pdsjs/core/verify'; import { parseDictionary, serializeInnerList, toPaddedBase64, } from './structured-fields.js'; import { SpaceTokenError } from './token.js'; const LABEL = 'atproto-space-credential'; const TAG = 'atproto-space-credential'; const ALG = 'ecdsa-p256-sha256'; const COMPONENTS = ['authorization', 'atproto-space-audience']; const SIGNATURE_INPUT = `(${COMPONENTS.map((c) => `"${c}"`).join(' ')});tag="${TAG}"`; /** * @typedef {Object} SpaceSigKey * @property {string} did - the public key as a P-256 did:key * @property {(data: Uint8Array) => Promise} sign - 64-byte r||s */ /** * A fresh P-256 key for one credential. The private key is not extractable. * @returns {Promise} */ export async function generateSpaceSigKey() { const pair = /** @type {CryptoKeyPair} */ ( await crypto.subtle.generateKey( { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign', 'verify'], ) ); const raw = new Uint8Array( await crypto.subtle.exportKey('raw', pair.publicKey), ); return { did: publicKeyToDidKey(compressPublicKey(raw), 'p256'), sign: (data) => sign(pair.privateKey, data), }; } /** * Whether `keyId` is a P-256 did:key, the only kind a credential binds to. * @param {unknown} keyId * @returns {boolean} */ export function isP256DidKey(keyId) { if (typeof keyId !== 'string' || !keyId.startsWith('did:key:')) return false; try { return parseDidKey(keyId).curve === 'p256'; } catch { return false; } } /** * The authorization, audience and signature headers for a request presenting * a space credential. * * @param {SpaceSigKey} key * @param {{authorization: string, audience: string}} opts * @returns {Promise>} */ export async function createSpaceSigHeaders(key, { authorization, audience }) { const signature = await key.sign( signatureBase(authorization, audience, SIGNATURE_INPUT), ); return { authorization, 'atproto-space-audience': audience, 'signature-input': `${LABEL}=${SIGNATURE_INPUT}`, signature: `${LABEL}=:${toPaddedBase64(signature)}:`, }; } /** * Verify the `atproto-space-credential` signature on a request against the key * the credential is bound to (its `cnf.kid`). A `keyid` parameter, when * present, must name that key. * * @param {Headers} headers * @param {Object} opts * @param {string} opts.keyId * @param {import('@pdsjs/core/ports').SignatureVerifierPort} opts.verifier * @returns {Promise<{audience: string}>} * @throws {SpaceTokenError} code BadSpaceSignature */ export async function verifyCredentialSig(headers, { keyId, verifier }) { const authorization = single(headers, 'authorization'); const audience = single(headers, 'atproto-space-audience'); const input = memberOf(headers.get('signature-input'), 'Signature-Input'); const signature = memberOf(headers.get('signature'), 'Signature'); if (input.kind !== 'innerList') fail('Signature-Input is not an inner list'); if ( signature.kind !== 'item' || !(signature.value instanceof Uint8Array) || signature.params.size !== 0 ) { fail('Signature is not a byte sequence'); } if ( input.items.length !== COMPONENTS.length || input.items.some( (item, i) => item.value !== COMPONENTS[i] || item.params.size !== 0, ) ) { fail( `signature must cover exactly ${COMPONENTS.map((c) => `"${c}"`).join(' ')}, in order`, ); } if (input.params.get('tag') !== TAG) fail(`signature tag must be "${TAG}"`); const alg = input.params.get('alg'); if (alg !== undefined && alg !== ALG) { fail(`signature algorithm must be ${ALG}`); } const named = input.params.get('keyid'); if (named !== undefined && named !== keyId) { fail('signature keyid does not match the credential key'); } if (!isP256DidKey(keyId)) fail('signature key must be a P-256 did:key'); const bytes = /** @type {Uint8Array} */ (signature.value); const base = signatureBase( authorization, audience, serializeInnerList(input.items, input.params), ); let valid = false; if (bytes.length === 64) { try { valid = await verifier.verify(keyId, base, bytes); } catch { valid = false; } } if (!valid) fail('invalid HTTP message signature'); return { audience }; } /** * @param {string} authorization * @param {string} audience * @param {string} params - the serialized signature parameters * @returns {Uint8Array} */ function signatureBase(authorization, audience, params) { return new TextEncoder().encode( [ `"authorization": ${authorization.trim()}`, `"atproto-space-audience": ${audience.trim()}`, `"@signature-params": ${params}`, ].join('\n'), ); } /** * One field value. Fetch joins repeated fields with ", ", and neither a token * nor a DID contains a comma, so a comma means the field was sent twice. * * @param {Headers} headers * @param {string} name * @returns {string} */ function single(headers, name) { const value = headers.get(name); if (!value || value.includes(',')) { fail(`request requires exactly one "${name}" field`); } return /** @type {string} */ (value); } /** * @param {string} message * @returns {never} */ function fail(message) { throw new SpaceTokenError(message, 'BadSpaceSignature'); } /** * @param {string|null} header * @param {string} name * @returns {import('./structured-fields.js').SfMember} */ function memberOf(header, name) { if (!header) fail(`missing ${name} field`); /** @type {Map} */ let dict; try { dict = parseDictionary(/** @type {string} */ (header)); } catch { fail(`malformed ${name} field`); } const member = dict.get(LABEL); if (!member) fail(`no ${LABEL} member in ${name}`); return /** @type {import('./structured-fields.js').SfMember} */ (member); }