// @pdsjs/core/handlers/xrpc-identity - com.atproto.identity.*: handle // resolution and renaming, DID credential recommendation, and the PLC // operation flow (request a mailed code, sign, submit). // // A handle counts only when it points back to this DID, and where it sits decides // how that is established. See classifyHandle. import { derivePublicKey, timingSafeEqual } from '../crypto.js'; import { EMAIL_CODE_TTL_MS, generateEmailCode, hashEmailCode, readEmailState, renderPlcOperationEmail, } from '../email.js'; import { drainRequestBody } from '../http.js'; import { generateKemKey, kemDidKey } from '../kem.js'; import { computePlcOperationCid, publicKeyToDidKey, signPlcOperationObject, } from '../plc.js'; import { isValidDid, isValidHandle } from '../validation.js'; /** * @typedef {Object} IdentityContext * @property {import('../ports.js').ActorStoragePort} actorStorage * @property {import('../ports.js').SharedStoragePort} sharedStorage * @property {string} [hostname] * @property {string} [plcUrl] * @property {string} jwtSecret * @property {boolean} readOnly * @property {import('../ports.js').EmailPort|null} emailer * @property {import('../ports.js').Secp256k1SignerPort|null} k256Signer * @property {() => Promise} getDid * @property {() => Promise} getSigningKey * @property {() => Promise} [isDeactivated] * @property {(request: Request) => Promise} handleProxyHeader * @property {(type: 'account'|'identity'|'sync', body: Object) => Promise} emitEvent * @property {(request: Request) => Promise} readJsonBounded * @property {() => Response} readOnlyError */ /** * A PLC log entry, as the directory returns it. * @typedef {Object} PlcOperation * @property {string} type * @property {string[]} [rotationKeys] * @property {Record} [verificationMethods] * @property {string[]} [alsoKnownAs] * @property {Record} [services] * @property {string|null} [prev] * @property {string} [sig] */ /** * @typedef {Object} RecommendedDidCredentials * @property {string[]} rotationKeys * @property {string[]} alsoKnownAs * @property {{atproto: string, atproto_kem?: string}} verificationMethods * @property {{atproto_pds: {type: string, endpoint: string}}} services */ /** * What a KEM key backfill did. `published` put the key in the PLC document. * `stored` kept a key this server cannot publish, which the owner must add to * their DID document. `skipped` changed nothing. * @typedef {'published'|'stored'|'skipped'} KemBackfillOutcome */ /** * @param {IdentityContext} ctx * @returns {{ * ownDomain: (requestHost?: string|null) => string, * classifyHandle: (handle: string, requestHost?: string|null) => 'self'|'internal'|'external', * resolveHandleDid: (handle: string) => Promise, * renameHandle: (requested: unknown, hostname?: string|null) => Promise<{handle: string, changed: boolean}>, * getRecommendedDidCredentials: (fallbackHostname?: string|null) => Promise, * backfillKemKey: () => Promise, * routes: import('../pds.js').Routes, * }} */ export function createIdentityHandlers(ctx) { const { actorStorage, sharedStorage, hostname: configuredHostname, plcUrl, jwtSecret, readOnly, emailer, k256Signer, getDid, getSigningKey, handleProxyHeader, emitEvent, readJsonBounded, readOnlyError, } = ctx; /** * com.atproto.identity.resolveHandle * @param {Request} request * @param {URL} url * @returns {Promise} */ async function handleResolveHandle(request, url) { const proxyResponse = await handleProxyHeader(request); if (proxyResponse) return proxyResponse; const handle = url.searchParams.get('handle'); if (!handle) { return Response.json( { error: 'InvalidRequest', message: 'Missing handle parameter' }, { status: 400 }, ); } const did = await sharedStorage.resolveHandle(handle); if (did) { return Response.json({ did }); } const ourHandle = await actorStorage.getHandle(); const ourDid = await getDid(); if (ourHandle === handle && ourDid) { return Response.json({ did: ourDid }); } return Response.json( { error: 'InvalidRequest', message: 'Handle not found' }, { status: 400 }, ); } /** * The domain this server answers on: its configured hostname, or the host the * request arrived on when none is set. Lowercased, without a port. * @param {string|null} [requestHost] * @returns {string} */ function ownDomain(requestHost = null) { return (configuredHostname || requestHost || '') .split(':')[0] .toLowerCase(); } /** * Where a requested handle sits relative to this PDS, which decides how it is * verified: * - 'self': the server's own address — self-verifying (it serves its own * `/.well-known/atproto-did`), so it is taken as-is. * - 'internal': a subdomain of that address — a single-user PDS answers only * at its own host, so such a name would never resolve; not allowed here. * - 'external': a domain elsewhere — must already resolve to this DID. * @param {string} handle * @param {string|null} [requestHost] * @returns {'self'|'internal'|'external'} */ function classifyHandle(handle, requestHost = null) { const domain = ownDomain(requestHost); if (!domain) return 'external'; if (handle === domain) return 'self'; if (handle.endsWith(`.${domain}`)) return 'internal'; return 'external'; } /** * Resolve a handle to a DID the way the network does, so a handle on a domain * this server does not host can be verified before it is written into the DID * document: the DNS TXT record at `_atproto.` first, then the HTTPS * well-known. Returns the DID, or null when it points nowhere we can see. * @param {string} handle * @returns {Promise} */ async function resolveHandleDid(handle) { // DNS-over-HTTPS TXT lookup for _atproto. ("did=did:...") try { const name = encodeURIComponent(`_atproto.${handle}`); const res = await fetch( `https://cloudflare-dns.com/dns-query?name=${name}&type=TXT`, { headers: { Accept: 'application/dns-json' } }, ); if (res.ok) { const data = await res.json(); for (const answer of data.Answer ?? []) { if (answer.type !== 16) continue; // 16 = TXT const value = String(answer.data ?? '').replace(/^"|"$/g, ''); const match = value.match(/^did=(.+)$/); if (match && isValidDid(match[1])) return match[1]; } } } catch { // DNS unreachable: fall through to the well-known method } // HTTPS well-known: https:///.well-known/atproto-did returns the DID try { const res = await fetch(`https://${handle}/.well-known/atproto-did`, { redirect: 'error', }); if (res.ok) { const did = (await res.text()).trim(); if (isValidDid(did)) return did; } } catch { // Not served, or not reachable } return null; } /** * Change the account handle: point the DID document's alsoKnownAs at the new * handle with a did:plc operation, persist it, and announce an #identity * firehose event so consumers re-resolve. Shared by the XRPC endpoint and the * account page; throws with a caller-facing message on any failure. * @param {unknown} requested - The requested handle * @param {string|null} [hostname] - Hostname for the recommended PDS endpoint * @returns {Promise<{handle: string, changed: boolean}>} */ async function renameHandle(requested, hostname = null) { // atproto stores handles lowercased; normalize before validating so a // mixed-case request resolves the same as the value we persist. const handle = typeof requested === 'string' ? requested.toLowerCase() : requested; if (!isValidHandle(handle)) throw new Error('Invalid handle'); const did = await getDid(); const signingKey = await getSigningKey(); if (!did || !signingKey) throw new Error('PDS not initialized'); // Refuse a handle another account on this server already holds. const owner = await sharedStorage.resolveHandle(handle); if (owner && owner !== did) throw new Error('Handle is already in use'); // Nothing to do when the handle is unchanged. const current = await actorStorage.getHandle(); if (current === handle) return { handle, changed: false }; // A handle only counts if it points back to this DID. Where it sits decides // how that is established (see classifyHandle). A subdomain of our own // address is refused outright; any other outside domain must already resolve // to this DID. This is also what stops an arbitrary value like "blah.blah". const scope = classifyHandle(handle, hostname); if (scope === 'internal') { const domain = ownDomain(hostname); throw new Error( `${handle} is a subdomain of this server's own address (${domain}). A ` + `single-user PDS only answers at ${domain} itself, so this name would ` + `never resolve unless you separately served ` + `https://${handle}/.well-known/atproto-did returning ${did}. To change ` + `your username, use a domain you own and verify it with DNS instead.`, ); } if (scope === 'external') { const resolved = await resolveHandleDid(handle); if (resolved !== did) { throw new Error( `${handle} does not point to this account yet. Add a DNS TXT record ` + `at _atproto.${handle} with the value "did=${did}", or serve ${did} ` + `from https://${handle}/.well-known/atproto-did, then try again.`, ); } } // scope === 'self' → the server's own address, taken as-is // did:web documents are maintained out of band; only did:plc identities get // an operation submitted on their behalf here. if (did.startsWith('did:plc:')) { const recommended = await getRecommendedDidCredentials(hostname); if (!recommended) throw new Error('PDS not initialized'); // Chain onto the current operation in the DID's PLC log. let prev = null; try { const logRes = await fetch( `${plcUrl}/${encodeURIComponent(did)}/log/last`, ); if (logRes.ok) { const last = await logRes.json(); prev = last?.cid ?? (await computePlcOperationCid(last)); } } catch { // No existing log entry: nothing to chain onto } const operation = await signPlcOperationObject( { type: 'plc_operation', rotationKeys: recommended.rotationKeys, verificationMethods: recommended.verificationMethods, alsoKnownAs: [`at://${handle}`], services: recommended.services, prev, }, signingKey, ); const res = await fetch(`${plcUrl}/${encodeURIComponent(did)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(operation), }); if (!res.ok) { const text = await res.text(); throw new Error( `PLC directory rejected operation: ${res.status} ${text}`, ); } } // Persist the handle and refresh the handle -> DID resolution mapping, // preserving the account's original creation time. If this fails after the // PLC op above, the caller can safely retry. const existing = await sharedStorage.getActor(did); await actorStorage.setHandle(handle); await sharedStorage.putActor( did, handle, existing?.createdAt ?? Date.now(), ); // Tell the firehose the identity changed so consumers re-resolve. await emitEvent('identity', { handle }); return { handle, changed: true }; } /** * com.atproto.identity.updateHandle * @param {Request} request * @param {URL} url * @param {import('../pds.js').Auth|null} auth * @returns {Promise} */ async function handleUpdateHandle(request, url, auth) { if (readOnly) return readOnlyError(); const body = await readJsonBounded(request); if (body instanceof Response) return body; if (!auth) { return Response.json( { error: 'AuthenticationRequired', message: 'Authentication required' }, { status: 401 }, ); } try { await renameHandle(body.handle, url.hostname); } catch (err) { const message = err instanceof Error ? err.message : String(err); return Response.json( { error: 'InvalidRequest', message }, { status: 400 }, ); } return Response.json({}); } /** * Build the DID credentials this PDS recommends for its account. * @param {string|null} [fallbackHostname] - Hostname to publish when none is * configured, so an unconfigured server does not recommend * `https://undefined` as its endpoint * @returns {Promise} */ async function getRecommendedDidCredentials(fallbackHostname = null) { const privateKey = await actorStorage.getPrivateKey(); const handle = await actorStorage.getHandle(); if (!privateKey) return null; const { curve, publicKey } = await derivePublicKey(privateKey, k256Signer); const didKey = publicKeyToDidKey(publicKey, curve); // Preserve the DID's existing rotation keys alongside this server's. Those // keys are what can recover or repoint the identity; dropping them would // mean that losing this server's key locks the owner out of their DID // forever. This server's key goes first so it is always included even if // the DID is already at the PLC limit of five, and so this server can sign // its own operations; the owner's recovery keys follow. A handle change or // a migration onto this server both flow through here, so both are safe. const did = await getDid(); const existing = did ? await fetchRotationKeys(did) : []; const rotationKeys = [didKey, ...existing] .filter((key, index, all) => all.indexOf(key) === index) .slice(0, 5); const kemKey = await actorStorage.getKemKey(); return { rotationKeys, alsoKnownAs: handle ? [`at://${handle}`] : [], verificationMethods: kemKey ? { atproto: didKey, atproto_kem: await kemDidKey(kemKey) } : { atproto: didKey }, services: { atproto_pds: { type: 'AtprotoPersonalDataServer', endpoint: `https://${configuredHostname || fallbackHostname}`, }, }, }; } /** * The rotation keys currently registered for a DID in the PLC directory. * Best-effort: any failure returns an empty list so credential recommendation * degrades to this server's key alone rather than throwing. * @param {string} did * @returns {Promise} */ async function fetchRotationKeys(did) { if (!did.startsWith('did:plc:')) return []; try { const res = await fetch(`${plcUrl}/${encodeURIComponent(did)}/data`); if (!res.ok) return []; const data = await res.json(); return Array.isArray(data?.rotationKeys) ? data.rotationKeys : []; } catch { return []; } } /** * com.atproto.identity.getRecommendedDidCredentials * @param {Request} _request * @param {URL} url * @param {import('../pds.js').Auth|null} auth * @returns {Promise} */ async function handleGetRecommendedDidCredentials(_request, url, auth) { if (!auth) { return Response.json( { error: 'AuthenticationRequired', message: 'Authentication required' }, { status: 401 }, ); } const credentials = await getRecommendedDidCredentials(url.hostname); if (!credentials) { return Response.json( { error: 'InvalidRequest', message: 'PDS not initialized' }, { status: 400 }, ); } return Response.json(credentials); } /** * com.atproto.identity.requestPlcOperationSignature * * Mails a confirmation code, which signPlcOperation then requires. A server * with no sender, or an account with no confirmed address, has no way to * reach anyone: it issues no code and the session stays the only * authorization, which is how this endpoint behaved before email existed. * @param {Request} request * @param {URL} url * @param {import('../pds.js').Auth|null} auth * @returns {Promise} */ async function handleRequestPlcOperationSignature(request, url, auth) { await drainRequestBody(request); if (!auth) { return Response.json( { error: 'AuthenticationRequired', message: 'Authentication required' }, { status: 401 }, ); } // A PLC operation can move the account to another server or swap its // keys, so the reference PDS mails a code before signing one. Here that // needs both a sender and an address proven reachable; short of that the // session remains the only authorization, as it was before email existed. if (!(await canConfirmByEmail())) return Response.json({}); const state = await readEmailState(actorStorage); const address = /** @type {string} */ (state.address); const code = generateEmailCode(); await actorStorage.setPlcToken({ codeHash: await hashEmailCode(address, code, jwtSecret), expiresAt: Date.now() + EMAIL_CODE_TTL_MS, }); try { const message = renderPlcOperationEmail({ code, hostname: url.host }); await /** @type {import('../ports.js').EmailPort} */ (emailer).send({ to: address, ...message, }); } catch (err) { await actorStorage.setPlcToken(null); console.error('Failed to send PLC confirmation email:', err); return Response.json( { error: 'InternalServerError', message: 'Could not send the confirmation code', }, { status: 502 }, ); } return Response.json({}); } /** * Whether this server can prove a request reached the account holder: it * needs a sender and an address that completed a code exchange. * @returns {Promise} */ async function canConfirmByEmail() { if (!emailer || !actorStorage.setPlcToken) return false; const state = await readEmailState(actorStorage); return Boolean(state.verified && state.address); } /** * Check the code mailed by requestPlcOperationSignature and spend it. * @param {string} token - The code the caller supplied * @returns {Promise} An error response, or null when allowed */ async function checkPlcToken(token) { if (!(await canConfirmByEmail())) return null; const stored = await actorStorage.getPlcToken?.(); if (!stored || stored.expiresAt <= Date.now()) { return Response.json( { error: 'InvalidToken', message: 'Ask for a confirmation code first with requestPlcOperationSignature', }, { status: 400 }, ); } const state = await readEmailState(actorStorage); const candidate = await hashEmailCode( /** @type {string} */ (state.address), (token || '').trim(), jwtSecret, ); if (!(await timingSafeEqual(candidate, stored.codeHash))) { return Response.json( { error: 'InvalidToken', message: 'Confirmation code is invalid' }, { status: 400 }, ); } // Spent: one code signs one operation await actorStorage.setPlcToken(null); return null; } /** * com.atproto.identity.signPlcOperation * @param {Request} request * @param {URL} url * @param {import('../pds.js').Auth|null} auth * @returns {Promise} */ async function handleSignPlcOperation(request, url, auth) { const body = await readJsonBounded(request); if (body instanceof Response) return body; if (!auth) { return Response.json( { error: 'AuthenticationRequired', message: 'Authentication required' }, { status: 401 }, ); } const tokenError = await checkPlcToken(body.token); if (tokenError) return tokenError; const did = await getDid(); const signingKey = await getSigningKey(); const recommended = await getRecommendedDidCredentials(url.hostname); if (!did || !signingKey || !recommended) { return Response.json( { error: 'InvalidRequest', message: 'PDS not initialized' }, { status: 400 }, ); } // Chain onto the current operation in the DID's PLC log let prev = null; try { const logRes = await fetch( `${plcUrl}/${encodeURIComponent(did)}/log/last`, ); if (logRes.ok) { const last = await logRes.json(); prev = last?.cid ?? (await computePlcOperationCid(last)); } } catch { // No existing log entry: this signs a genesis operation } // Caller-supplied fields win over the recommended defaults const operation = { type: 'plc_operation', rotationKeys: body.rotationKeys ?? recommended.rotationKeys, verificationMethods: body.verificationMethods ?? recommended.verificationMethods, alsoKnownAs: body.alsoKnownAs ?? recommended.alsoKnownAs, services: body.services ?? recommended.services, prev, }; const signed = await signPlcOperationObject(operation, signingKey); return Response.json({ operation: signed }); } /** * com.atproto.identity.submitPlcOperation * @param {Request} request * @param {URL} _url * @param {import('../pds.js').Auth|null} auth * @returns {Promise} */ async function handleSubmitPlcOperation(request, _url, auth) { const body = await readJsonBounded(request); if (body instanceof Response) return body; if (!auth) { return Response.json( { error: 'AuthenticationRequired', message: 'Authentication required' }, { status: 401 }, ); } const did = await getDid(); if (!did) { return Response.json( { error: 'InvalidRequest', message: 'PDS not initialized' }, { status: 400 }, ); } if (!body.operation) { return Response.json( { error: 'InvalidRequest', message: 'operation is required' }, { status: 400 }, ); } const kemKey = await actorStorage.getKemKey(); if ( kemKey && body.operation.verificationMethods?.atproto_kem !== (await kemDidKey(kemKey)) ) { return Response.json( { error: 'InvalidRequest', message: 'Incorrect KEM key' }, { status: 400 }, ); } const res = await fetch(`${plcUrl}/${encodeURIComponent(did)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body.operation), }); if (!res.ok) { const text = await res.text(); return Response.json( { error: 'InvalidRequest', message: `PLC directory rejected operation: ${res.status} ${text}`, }, { status: 400 }, ); } // The DID document just changed (e.g. rotation/signing keys, PDS endpoint). // Announce an #identity event so consumers re-resolve rather than keep a // stale signing key that would fail service-token verification. const handle = await actorStorage.getHandle(); await emitEvent('identity', { handle: handle || null }); return Response.json({}); } /** * Give an account created before KEM keys existed its `#atproto_kem` key. * * The key is stored only after the PLC directory accepts it, so a failed * publication throws and the next run tries again. An identity this server * cannot update still gets a key, and its owner must publish it. * @returns {Promise} */ async function backfillKemKey() { if (readOnly) return 'skipped'; const did = await getDid(); if (!did || (await actorStorage.getKemKey())) return 'skipped'; // An account that moved away is no longer this server's to update. if (await ctx.isDeactivated?.()) return 'skipped'; const signingKey = await getSigningKey(); const privateKey = await actorStorage.getPrivateKey(); if (!signingKey || !privateKey) return 'skipped'; const kemKey = await generateKemKey(); const last = did.startsWith('did:plc:') ? await fetchLastOp(did) : null; const { curve, publicKey } = await derivePublicKey(privateKey, k256Signer); const serverKey = publicKeyToDidKey(publicKey, curve); const endpoint = configuredHostname ? `https://${configuredHostname}` : null; const canPublish = last !== null && last.type === 'plc_operation' && Array.isArray(last.rotationKeys) && last.rotationKeys.includes(serverKey) && (endpoint === null || last.services?.atproto_pds?.endpoint === endpoint); if (canPublish) { const operation = await signPlcOperationObject( { ...last, verificationMethods: { ...last.verificationMethods, atproto_kem: kemKey.didKey, }, prev: await computePlcOperationCid(last), }, signingKey, ); const res = await fetch(`${plcUrl}/${encodeURIComponent(did)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(operation), }); if (!res.ok) { const text = await res.text(); throw new Error( `PLC directory rejected the KEM key: ${res.status} ${text}`, ); } } else { console.warn( `Cannot publish a KEM key for ${did}: its DID document must be updated to include #atproto_kem ${kemKey.didKey}`, ); } await actorStorage.setKemKey(kemKey.privateKey); if (canPublish) { const handle = await actorStorage.getHandle(); await emitEvent('identity', { handle: handle || null }); return 'published'; } return 'stored'; } /** * The latest operation in a DID's PLC log. * @param {string} did * @returns {Promise} * @throws when the directory cannot be read, so the caller retries later */ async function fetchLastOp(did) { const res = await fetch(`${plcUrl}/${encodeURIComponent(did)}/log/last`); if (!res.ok) { throw new Error(`Could not read the PLC log for ${did}: ${res.status}`); } return /** @type {PlcOperation} */ (await res.json()); } return { ownDomain, classifyHandle, resolveHandleDid, renameHandle, getRecommendedDidCredentials, backfillKemKey, routes: { '/xrpc/com.atproto.identity.resolveHandle': { handler: handleResolveHandle, }, '/xrpc/com.atproto.identity.updateHandle': { method: 'POST', auth: 'required', handler: handleUpdateHandle, }, '/xrpc/com.atproto.identity.getRecommendedDidCredentials': { auth: 'required', handler: handleGetRecommendedDidCredentials, }, '/xrpc/com.atproto.identity.requestPlcOperationSignature': { method: 'POST', auth: 'required', handler: handleRequestPlcOperationSignature, }, '/xrpc/com.atproto.identity.signPlcOperation': { method: 'POST', auth: 'required', handler: handleSignPlcOperation, }, '/xrpc/com.atproto.identity.submitPlcOperation': { method: 'POST', auth: 'required', handler: handleSubmitPlcOperation, }, }, }; }