chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// @pdsjs/spaces/service-auth - verifying inter-service calls.
//
// notifyWrite and notifySpaceDeleted are server-to-server: a repo host tells an
// authority its user wrote, an authority tells syncers a space is gone. Both are
// authenticated with ordinary atproto service auth — a JWT signed by the
// caller's #atproto key, addressed to us, naming the method it is calling.
import { base64UrlEncode, bytesToHex } from '@pdsjs/core/crypto';
import { atprotoSigningKey } from './authority.js';
import { assertKeyAlg, SpaceTokenError, signerAlg } from './token.js';
const CLOCK_SKEW_SEC = 5;
/**
* @param {string} b64
* @returns {any}
*/
function decodePart(b64) {
const pad = b64.replace(/-/g, '+').replace(/_/g, '/');
const bytes = Uint8Array.from(
atob(pad + '='.repeat((4 - (pad.length % 4)) % 4)),
(c) => c.charCodeAt(0),
);
return JSON.parse(new TextDecoder().decode(bytes));
}
/**
* @param {string} b64
* @returns {Uint8Array}
*/
function decodeSig(b64) {
const pad = b64.replace(/-/g, '+').replace(/_/g, '/');
return Uint8Array.from(
atob(pad + '='.repeat((4 - (pad.length % 4)) % 4)),
(c) => c.charCodeAt(0),
);
}
/**
* Verify a service-auth JWT presented to us.
*
* @param {Object} opts
* @param {string} opts.jwt
* @param {string|string[]} opts.aud - who we answer as; the token must be
* addressed to one of them
* @param {string} opts.lxm - the method being called
* @param {import('@pdsjs/core/ports').DidResolverPort} opts.resolveDid
* @param {import('@pdsjs/core/ports').SignatureVerifierPort} opts.verifier
* @returns {Promise<{iss: string}>}
*/
export async function verifyServiceAuth({
jwt,
aud,
lxm,
resolveDid,
verifier,
}) {
const parts = typeof jwt === 'string' ? jwt.split('.') : [];
if (parts.length !== 3) {
throw new SpaceTokenError('malformed service token', 'BadJwt');
}
const [headerB64, payloadB64, sigB64] = parts;
/** @type {any} */
let header;
/** @type {any} */
let payload;
try {
header = decodePart(headerB64);
payload = decodePart(payloadB64);
} catch {
throw new SpaceTokenError('could not parse service token', 'BadJwt');
}
if (typeof header?.alg !== 'string' || !header.alg) {
throw new SpaceTokenError('missing service token "alg"', 'BadJwt');
}
const now = Math.floor(Date.now() / 1000);
if (typeof payload.exp !== 'number' || now - CLOCK_SKEW_SEC >= payload.exp) {
throw new SpaceTokenError('service token expired', 'JwtExpired');
}
if (!payload.iss) {
throw new SpaceTokenError('missing service token "iss"', 'BadJwtIss');
}
if (!(Array.isArray(aud) ? aud : [aud]).includes(payload.aud)) {
throw new SpaceTokenError(
'service token is not addressed to this service',
'BadJwtAudience',
);
}
// An lxm claim scopes a token to one method, so a token minted for one call
// cannot be replayed against another. Absent, it would be usable anywhere.
if (payload.lxm !== lxm) {
throw new SpaceTokenError(
`service token is not scoped to ${lxm}`,
'BadJwtLexiconMethod',
);
}
const signingInput = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
const sig = decodeSig(sigB64);
/**
* @param {boolean} forceRefresh
* @returns {Promise<{didKey: string, valid: boolean}>}
*/
const matchesSignature = async (forceRefresh) => {
const didDoc = await resolveDid(payload.iss, { forceRefresh });
if (!didDoc) {
throw new SpaceTokenError(
`could not resolve ${payload.iss}`,
'DidNotFound',
);
}
const didKey = atprotoSigningKey(didDoc, payload.iss);
assertKeyAlg(didKey, header.alg);
try {
return {
didKey,
valid: await verifier.verify(didKey, signingInput, sig),
};
} catch (err) {
throw new SpaceTokenError(
`could not verify service token: ${err instanceof Error ? err.message : String(err)}`,
'BadJwtSignature',
);
}
};
const first = await matchesSignature(false);
if (!first.valid) {
// A resolver that holds its answers can name a key the caller has rotated
// away from, and the token is signed by the current one.
const fresh = await matchesSignature(true);
if (fresh.didKey === first.didKey || !fresh.valid) {
throw new SpaceTokenError(
'invalid service token signature',
'BadJwtSignature',
);
}
}
return { iss: payload.iss };
}
/**
* Mint a service-auth JWT for an outbound call.
*
* Core has its own createServiceJwt, but it takes a CryptoKey; @pdsjs/spaces only
* ever holds a signer port, so that private key material never reaches this
* package.
*
* @param {Object} opts
* @param {string} opts.iss - our DID
* @param {string} opts.aud - the service we are calling
* @param {string} opts.lxm - the method, so the token cannot be replayed elsewhere
* @param {import('./token.js').SpaceSigner} opts.signer
* @returns {Promise<string>}
*/
export async function createServiceAuth({ iss, aud, lxm, signer }) {
const now = Math.floor(Date.now() / 1000);
const header = { typ: 'JWT', alg: signerAlg(signer) };
const payload = {
iss,
aud,
exp: now + 60,
iat: now,
jti: bytesToHex(crypto.getRandomValues(new Uint8Array(16))),
lxm,
};
const encode = (/** @type {object} */ o) =>
base64UrlEncode(new TextEncoder().encode(JSON.stringify(o)));
const signingInput = `${encode(header)}.${encode(payload)}`;
const sig = await signer.sign(new TextEncoder().encode(signingInput));
return `${signingInput}.${base64UrlEncode(sig)}`;
}