import { ed25519 } from "@noble/curves/ed25519.js"; import { DEFAULT_PLC_DIRECTORY, DID_PROFILE_FIELD, MATEY_VERIFICATION_METHOD, MATRIX_SERVICE_ID, MATRIX_SERVICE_TYPE, PLC_MAX_AKA_ENTRIES, PLC_MAX_AKA_LENGTH, PLC_MAX_SERVICE_ENTRIES, PROOF_JWS_TYP, PROOF_PROFILE_FIELD, } from "./constants.js"; import { signCompactJwsEd25519 } from "./jws.js"; import { encodeMultikey } from "./multikey.js"; import { mxidToMatrixUri } from "./mooring.js"; import { MateyError } from "./types.js"; import type { MatrixSession, ResolverOptions } from "./types.js"; import { b64urlEncode, fetchJson, stripTrailingSlash } from "./util.js"; /** * The linking ceremony (spec §5.5) writes the AT-side pointer via the * PDS-mediated PLC flow. It needs an atproto OAuth session holding the * `atproto identity:*` scopes; that session is abstracted as an XRPC caller * so any stack works. With `@atproto/api`: * * const xrpc: XrpcFn = async (nsid, opts) => { * const res = await agent.call(nsid, opts?.params, opts?.data); * return res.data; * }; */ export type XrpcFn = ( nsid: string, opts?: { params?: Record; data?: unknown }, ) => Promise; export interface PlcState { rotationKeys?: string[]; verificationMethods?: Record; alsoKnownAs?: string[]; services?: Record; } /** Current PLC state for a DID (spec §5.5 step 2). */ export async function fetchPlcState(did: string, opts: ResolverOptions = {}): Promise { const f = opts.fetch ?? fetch; const base = stripTrailingSlash(opts.plcDirectory ?? DEFAULT_PLC_DIRECTORY); const { status, json } = await fetchJson(f, `${base}/${did}/data`); if (status !== 200 || json === null || typeof json !== "object") { throw new MateyError("ceremony-failed", `could not fetch PLC state for ${did} (HTTP ${status})`); } return json as PlcState; } export interface MooringPlan { alsoKnownAs: string[]; services: Record; verificationMethods?: Record; } export interface PlannedMooring { plan: MooringPlan; /** Present when a signed mooring was requested (spec §4.5.3). */ mateyKey?: { publicKeyMultibase: string; secretKey: Uint8Array }; } export interface PlanMooringInput { state: PlcState; mxid: string; /** Homeserver C–S base URL for the optional `#matrix` service hint (§3.2.2). */ homeserverBaseUrl?: string; includeServiceHint?: boolean; /** Generate a dedicated `#matey` key and include it as a verification method (§4.5.3). */ signedMooring?: boolean; } /** * Compute the field values for `signPlcOperation` (spec §5.5 step 3). * Supplied fields replace the previous values wholesale, so the plan carries * complete arrays with existing entries preserved. */ export function planMooring(input: PlanMooringInput): PlannedMooring { const existing = input.state.alsoKnownAs ?? []; if (existing.length === 0 || !(existing[0] ?? "").startsWith("at://")) { throw new MateyError( "ceremony-failed", "alsoKnownAs[0] must be the at:// handle; refusing to build an operation the PDS would reject", ); } const uri = mxidToMatrixUri(input.mxid); if (uri.length > PLC_MAX_AKA_LENGTH) { throw new MateyError("ceremony-failed", `matrix URI exceeds PLC's ${PLC_MAX_AKA_LENGTH}-byte alsoKnownAs limit`); } const alsoKnownAs = [...existing.filter((e) => !e.startsWith("matrix:")), uri]; if (alsoKnownAs.length > PLC_MAX_AKA_ENTRIES) { throw new MateyError("ceremony-failed", `alsoKnownAs would exceed PLC's ${PLC_MAX_AKA_ENTRIES}-entry limit`); } const services: Record = { ...(input.state.services ?? {}) }; if ((input.includeServiceHint ?? true) && input.homeserverBaseUrl !== undefined) { services[MATRIX_SERVICE_ID] = { type: MATRIX_SERVICE_TYPE, endpoint: new URL(input.homeserverBaseUrl).origin, }; } else { delete services[MATRIX_SERVICE_ID]; } if (Object.keys(services).length > PLC_MAX_SERVICE_ENTRIES) { throw new MateyError("ceremony-failed", `services would exceed PLC's ${PLC_MAX_SERVICE_ENTRIES}-entry limit`); } if (input.signedMooring === true) { const { secretKey, publicKey } = ed25519.keygen(); const publicKeyMultibase = encodeMultikey("ed25519", publicKey); const verificationMethods = { ...(input.state.verificationMethods ?? {}), [MATEY_VERIFICATION_METHOD]: `did:key:${publicKeyMultibase}`, }; return { plan: { alsoKnownAs, services, verificationMethods }, mateyKey: { publicKeyMultibase, secretKey }, }; } return { plan: { alsoKnownAs, services } }; } /** Step 4: ask the PDS to email the signing code. */ export async function requestPlcSignature(xrpc: XrpcFn): Promise { await xrpc("com.atproto.identity.requestPlcOperationSignature"); } /** Steps 5–6: sign with the emailed code, then submit. */ export async function signAndSubmitMooring( xrpc: XrpcFn, token: string, plan: MooringPlan, ): Promise { const signed = (await xrpc("com.atproto.identity.signPlcOperation", { data: { token, ...plan }, })) as { operation?: unknown }; if (signed?.operation === undefined) { throw new MateyError("ceremony-failed", "signPlcOperation returned no operation"); } await xrpc("com.atproto.identity.submitPlcOperation", { data: { operation: signed.operation } }); } /** Step 7: poll the PLC directory until the pointer appears. */ export async function awaitMooringInPlc( did: string, mxid: string, opts: ResolverOptions & { timeoutMs?: number; intervalMs?: number } = {}, ): Promise { const f = opts.fetch ?? fetch; const base = stripTrailingSlash(opts.plcDirectory ?? DEFAULT_PLC_DIRECTORY); const uri = mxidToMatrixUri(mxid); const deadline = Date.now() + (opts.timeoutMs ?? 30_000); for (;;) { const { status, json } = await fetchJson(f, `${base}/${did}`); if (status === 200 && json !== null && typeof json === "object") { const aka = (json as { alsoKnownAs?: string[] }).alsoKnownAs ?? []; if (aka.includes(uri)) return; } if (Date.now() > deadline) { throw new MateyError("ceremony-failed", `PLC directory did not show the mooring within the timeout`); } await new Promise((r) => setTimeout(r, opts.intervalMs ?? 2000)); } } /** Issue a signed-mooring proof with the dedicated `#matey` key (spec §4.5). */ export function createMooringProof(did: string, mxid: string, secretKey: Uint8Array): string { return signCompactJwsEd25519( { typ: PROOF_JWS_TYP, kid: `${did}#${MATEY_VERIFICATION_METHOD}` }, { iss: did, sub: mxid, iat: Math.floor(Date.now() / 1000) }, secretKey, ); } /** Export the ceremony key for client-side safekeeping (e.g. localStorage). */ export function exportMateyKey(key: { publicKeyMultibase: string; secretKey: Uint8Array }): string { return JSON.stringify({ publicKeyMultibase: key.publicKeyMultibase, secretKey: b64urlEncode(key.secretKey) }); } /** Write the Matrix-side profile fields (spec §3.3.1, §4.5.1). */ export async function writeProfileFields( session: MatrixSession, fields: { did: string; proof?: string }, opts: ResolverOptions = {}, ): Promise { const f = opts.fetch ?? fetch; const entries: Array<[string, string]> = [[DID_PROFILE_FIELD, fields.did]]; if (fields.proof !== undefined) entries.push([PROOF_PROFILE_FIELD, fields.proof]); for (const [field, value] of entries) { const url = `${session.homeserverBaseUrl}/_matrix/client/v3/profile/${encodeURIComponent(session.mxid)}/${encodeURIComponent(field)}`; const { status } = await fetchJson(f, url, { method: "PUT", headers: { authorization: `Bearer ${session.accessToken}`, "content-type": "application/json", }, body: JSON.stringify({ [field]: value }), }); if (status !== 200) { throw new MateyError( "profile-write-failed", `PUT ${field} failed (HTTP ${status}); the homeserver may manage this field itself`, ); } } }