// @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} */ 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)}`; }