chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
/**
* 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<DpopKey>}
*/
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<JsonWebKey>} 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<DpopKey>}
*/
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<string>}
*/
export async function createDpopProof({ key, method, url, credential, nonce }) {
const header = { typ: 'dpop+jwt', alg: 'ES256', jwk: key.jwk };
/** @type {Record<string, string|number>} */
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))}`;
}