/** * DPoP proof generation (RFC 9449), for an OAuth access token. The token is * bound to a key that outlives the token pair. That key is generated * extractable, serialized into the session file, and imported back on the next * invocation. Space credentials are signed differently: see space-signature.js. * * Self-contained on WebCrypto because this package installs as a global CLI * and keeps its dependency list empty. */ /** * @typedef {Object} EcJwk * @property {string} kty * @property {string} crv * @property {string} x * @property {string} y */ /** * @typedef {Object} DpopKey * @property {CryptoKey} privateKey * @property {EcJwk} jwk - public half * @property {string} jkt - RFC 7638 thumbprint of the public half */ /** * @param {Uint8Array} bytes * @returns {string} */ function base64UrlEncode(bytes) { let binary = ''; for (const b of bytes) binary += String.fromCharCode(b); return btoa(binary) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, ''); } /** * @param {CryptoKey} publicKey * @returns {Promise<{jwk: EcJwk, jkt: string}>} */ async function publicHalf(publicKey) { const exported = await crypto.subtle.exportKey('jwk', publicKey); /** @type {EcJwk} */ const jwk = { kty: exported.kty ?? '', crv: exported.crv ?? '', x: exported.x ?? '', y: exported.y ?? '', }; // RFC 7638: hash over the required members in lexicographic order. const thumbprintInput = JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y, }); const hash = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(thumbprintInput), ); return { jwk, jkt: base64UrlEncode(new Uint8Array(hash)) }; } /** * Generate an ES256 key pair for token binding. * @param {{extractable?: boolean}} [opts] - extractable allows exportDpopKey, * for a key that has to survive into the next invocation * @returns {Promise} */ export async function generateDpopKey({ extractable = false } = {}) { const keyPair = await crypto.subtle.generateKey( { name: 'ECDSA', namedCurve: 'P-256' }, extractable, ['sign', 'verify'], ); return { privateKey: keyPair.privateKey, ...(await publicHalf(keyPair.publicKey)), }; } /** * Serialize a key generated with extractable set, for the session file. * @param {DpopKey} key * @returns {Promise} the private key as a JWK */ export function exportDpopKey(key) { return crypto.subtle.exportKey('jwk', key.privateKey); } /** * Rebuild a DpopKey from a serialized private JWK. * @param {JsonWebKey} privateJwk * @returns {Promise} */ export async function importDpopKey(privateJwk) { const privateKey = await crypto.subtle.importKey( 'jwk', privateJwk, { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign'], ); const publicKey = await crypto.subtle.importKey( 'jwk', { kty: privateJwk.kty, crv: privateJwk.crv, x: privateJwk.x, y: privateJwk.y, }, { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify'], ); return { privateKey, ...(await publicHalf(publicKey)) }; } /** * Create the proof JWT for one request. * @param {Object} opts * @param {DpopKey} opts.key * @param {string} opts.method - HTTP method, the htm claim * @param {string} opts.url - request URL, the htu claim * @param {string} [opts.credential] - the presented credential or access * token, hashed into ath. Omitted for authorization-server requests, which * present no token. * @param {string} [opts.nonce] - server nonce, required by the authorization * server endpoints * @returns {Promise} */ export async function createDpopProof({ key, method, url, credential, nonce }) { const header = { typ: 'dpop+jwt', alg: 'ES256', jwk: key.jwk }; /** @type {Record} */ const payload = { jti: base64UrlEncode(crypto.getRandomValues(new Uint8Array(16))), htm: method, htu: url, iat: Math.floor(Date.now() / 1000), }; if (credential) { const tokenHash = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(credential), ); payload.ath = base64UrlEncode(new Uint8Array(tokenHash)); } if (nonce) payload.nonce = nonce; const signingInput = `${base64UrlEncode( new TextEncoder().encode(JSON.stringify(header)), )}.${base64UrlEncode(new TextEncoder().encode(JSON.stringify(payload)))}`; const signature = await crypto.subtle.sign( { name: 'ECDSA', hash: 'SHA-256' }, key.privateKey, new TextEncoder().encode(signingInput), ); return `${signingInput}.${base64UrlEncode(new Uint8Array(signature))}`; }