chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// @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<string|null>} getDid
* @property {() => Promise<import('../crypto.js').SigningKey|null>} getSigningKey
* @property {() => Promise<boolean>} [isDeactivated]
* @property {(request: Request) => Promise<Response|null>} handleProxyHeader
* @property {(type: 'account'|'identity'|'sync', body: Object) => Promise<void>} emitEvent
* @property {(request: Request) => Promise<any|Response>} readJsonBounded
* @property {() => Response} readOnlyError
*/
/**
* A PLC log entry, as the directory returns it.
* @typedef {Object} PlcOperation
* @property {string} type
* @property {string[]} [rotationKeys]
* @property {Record<string, string>} [verificationMethods]
* @property {string[]} [alsoKnownAs]
* @property {Record<string, {type: string, endpoint: string}>} [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<string|null>,
* renameHandle: (requested: unknown, hostname?: string|null) => Promise<{handle: string, changed: boolean}>,
* getRecommendedDidCredentials: (fallbackHostname?: string|null) => Promise<RecommendedDidCredentials|null>,
* backfillKemKey: () => Promise<KemBackfillOutcome>,
* 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<Response>}
*/
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.<handle>` first, then the HTTPS
* well-known. Returns the DID, or null when it points nowhere we can see.
* @param {string} handle
* @returns {Promise<string|null>}
*/
async function resolveHandleDid(handle) {
// DNS-over-HTTPS TXT lookup for _atproto.<handle> ("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://<handle>/.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<Response>}
*/
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<RecommendedDidCredentials|null>}
*/
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<string[]>}
*/
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<Response>}
*/
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<Response>}
*/
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<boolean>}
*/
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<Response|null>} 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<Response>}
*/
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<Response>}
*/
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<KemBackfillOutcome>}
*/
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<PlcOperation>}
* @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,
},
},
};
}