import { DID_PROFILE_FIELD, PROOF_PROFILE_FIELD, PROOF_JWS_TYP } from "./constants.js"; import { matrixServiceHint, resolveDidDocument, resolveIdentity } from "./identity.js"; import { ALG_FOR_CURVE, parseCompactJws, verifyJwsSignature } from "./jws.js"; import { decodeMultikey } from "./multikey.js"; import { MateyError } from "./types.js"; import type { DidDocument, MooringPointer, MooringVerification, ResolverOptions } from "./types.js"; import { fetchJson, stripTrailingSlash } from "./util.js"; /** Max clock skew tolerated on proof `iat`, seconds. */ const PROOF_IAT_SKEW = 300; /** Encode a localpart for use in a matrix: URI (only `/` and `%` need escaping). */ function encodeLocalpart(localpart: string): string { return localpart.replaceAll("%", "%25").replaceAll("/", "%2F"); } /** Build the `matrix:u/...` alsoKnownAs entry for an MXID (spec §3.2.1). */ export function mxidToMatrixUri(mxid: string): string { const { localpart, serverName } = parseMxid(mxid); return `matrix:u/${encodeLocalpart(localpart)}:${serverName}`; } export function parseMxid(mxid: string): { localpart: string; serverName: string } { if (!mxid.startsWith("@")) throw new MateyError("invalid-identifier", `not an MXID: ${mxid}`); const idx = mxid.indexOf(":"); if (idx < 2 || idx === mxid.length - 1) throw new MateyError("invalid-identifier", `not an MXID: ${mxid}`); return { localpart: mxid.slice(1, idx), serverName: mxid.slice(idx + 1) }; } /** Parse a `matrix:u/{id}` URI into an MXID; returns null for other matrix: URIs. */ export function matrixUriToMxid(uri: string): string | null { if (!uri.startsWith("matrix:")) return null; let rest = uri.slice("matrix:".length); if (rest.startsWith("//")) { // Authority form (matrix://authority/...): skip the authority segment. const slash = rest.indexOf("/", 2); if (slash === -1) return null; rest = rest.slice(slash + 1); } if (!rest.startsWith("u/")) return null; const id = rest.slice(2).split("?")[0]?.split("#")[0] ?? ""; const idx = id.indexOf(":"); if (idx < 1 || idx === id.length - 1) return null; let localpart: string; try { localpart = decodeURIComponent(id.slice(0, idx)); } catch { return null; } const serverName = id.slice(idx + 1); return `@${localpart}:${serverName}`; } /** * Extract the mooring pointer from a DID document (spec §4.1 step 2): the * first syntactically valid `matrix:u/` alsoKnownAs entry, never index 0. */ export function mooringPointerFromDocument(doc: DidDocument): MooringPointer | null { const aka = doc.alsoKnownAs ?? []; for (let i = 1; i < aka.length; i++) { const entry = aka[i]; if (entry === undefined || !entry.startsWith("matrix:")) continue; const mxid = matrixUriToMxid(entry); if (mxid === null) continue; const { localpart, serverName } = parseMxid(mxid); return { mxid, localpart, serverName, homeserverHint: matrixServiceHint(doc) }; } return null; } async function probeBaseUrl(f: typeof fetch, baseUrl: string): Promise { try { const { status } = await fetchJson(f, `${baseUrl}/_matrix/client/versions`); return status === 200; } catch { return false; } } /** * Discover the C–S base URL for a server name (spec §4.1 step 3): * .well-known → `#matrix` service hint → https://server_name. */ export async function discoverHomeserver( serverName: string, hint: string | null = null, opts: ResolverOptions = {}, ): Promise { const f = opts.fetch ?? fetch; const candidates: string[] = []; try { const { status, json } = await fetchJson(f, `https://${serverName}/.well-known/matrix/client`); if (status === 200 && json !== null && typeof json === "object") { const base = (json as Record>)["m.homeserver"]?.["base_url"]; if (typeof base === "string") candidates.push(stripTrailingSlash(base)); } } catch { // fall through to other candidates } if (hint !== null) candidates.push(stripTrailingSlash(hint)); candidates.push(`https://${serverName}`); for (const candidate of candidates) { if (await probeBaseUrl(f, candidate)) return candidate; } throw new MateyError("discovery-failed", `no reachable homeserver for ${serverName} (tried ${candidates.join(", ")})`); } /** Read an extended profile field; null on 403/404 or missing key. */ export async function fetchProfileField( baseUrl: string, mxid: string, field: string, opts: ResolverOptions = {}, ): Promise { const f = opts.fetch ?? fetch; const url = `${baseUrl}/_matrix/client/v3/profile/${encodeURIComponent(mxid)}/${encodeURIComponent(field)}`; const { status, json } = await fetchJson(f, url); if (status !== 200 || json === null || typeof json !== "object") return null; return (json as Record)[field] ?? null; } function verifyProof( proof: unknown, doc: DidDocument, did: string, mxid: string, ): { signed: boolean; proofError?: string } { if (typeof proof !== "string") return { signed: false, proofError: "proof field is not a string" }; try { const { header, payload, signature, signingInput } = parseCompactJws(proof); if (header.typ !== PROOF_JWS_TYP) return { signed: false, proofError: `bad typ ${String(header.typ)}` }; const kid = header.kid; if (typeof kid !== "string") return { signed: false, proofError: "missing kid" }; const fragment = kid.startsWith("#") ? kid : kid.startsWith(`${did}#`) ? kid.slice(did.length) : null; if (fragment === null) return { signed: false, proofError: `kid ${kid} is not a verification method of ${did}` }; const vm = (doc.verificationMethod ?? []).find( (m) => m.id === `${did}${fragment}` || m.id === fragment, ); if (vm?.publicKeyMultibase === undefined) { return { signed: false, proofError: `no verification method ${fragment} in current DID document` }; } const { curve, keyBytes } = decodeMultikey(vm.publicKeyMultibase); if (header.alg !== ALG_FOR_CURVE[curve]) { return { signed: false, proofError: `alg ${String(header.alg)} does not match ${curve} key` }; } if (!verifyJwsSignature(curve, keyBytes, signingInput, signature)) { return { signed: false, proofError: "signature verification failed" }; } if (payload.iss !== did) return { signed: false, proofError: "iss does not match DID" }; if (payload.sub !== mxid) return { signed: false, proofError: "sub does not match MXID" }; const iat = payload.iat; if (typeof iat !== "number" || iat > Date.now() / 1000 + PROOF_IAT_SKEW) { return { signed: false, proofError: "bad iat" }; } return { signed: true }; } catch (err) { return { signed: false, proofError: err instanceof Error ? err.message : String(err) }; } } async function checkMatrixSide( did: string, doc: DidDocument, pointer: MooringPointer, opts: ResolverOptions, ): Promise { let baseUrl: string; try { baseUrl = await discoverHomeserver(pointer.serverName, pointer.homeserverHint, opts); } catch (err) { return { status: "error", did, mxid: pointer.mxid, document: doc, signed: false, detail: String(err) }; } const value = await fetchProfileField(baseUrl, pointer.mxid, DID_PROFILE_FIELD, opts); if (value === null) { return { status: "no-mooring", did, mxid: pointer.mxid, homeserverBaseUrl: baseUrl, document: doc, signed: false, detail: `no ${DID_PROFILE_FIELD} profile field on ${pointer.mxid}`, }; } if (value !== did) { return { status: "mismatch", did, mxid: pointer.mxid, homeserverBaseUrl: baseUrl, document: doc, signed: false, detail: `profile field holds ${String(value)}`, }; } const proof = await fetchProfileField(baseUrl, pointer.mxid, PROOF_PROFILE_FIELD, opts); const proofResult = proof === null ? { signed: false } : verifyProof(proof, doc, did, pointer.mxid); return { status: "verified", did, mxid: pointer.mxid, homeserverBaseUrl: baseUrl, document: doc, ...proofResult, }; } /** Verify a mooring starting from a DID or handle (spec §4.1). */ export async function verifyMooringFromDid( identifier: string, opts: ResolverOptions = {}, ): Promise { let identity; try { identity = await resolveIdentity(identifier, opts); } catch (err) { return { status: "error", signed: false, detail: String(err) }; } const pointer = mooringPointerFromDocument(identity.document); if (pointer === null) { return { status: "no-mooring", did: identity.did, document: identity.document, signed: false }; } return checkMatrixSide(identity.did, identity.document, pointer, opts); } /** Verify a mooring starting from an MXID (spec §4.2). */ export async function verifyMooringFromMxid( mxid: string, homeserverBaseUrl: string | null = null, opts: ResolverOptions = {}, ): Promise { const { serverName } = parseMxid(mxid); let baseUrl: string; try { baseUrl = homeserverBaseUrl ?? (await discoverHomeserver(serverName, null, opts)); } catch (err) { return { status: "error", mxid, signed: false, detail: String(err) }; } const value = await fetchProfileField(baseUrl, mxid, DID_PROFILE_FIELD, opts); if (typeof value !== "string") { return { status: "no-mooring", mxid, homeserverBaseUrl: baseUrl, signed: false }; } let doc: DidDocument; try { doc = await resolveDidDocument(value, opts); } catch (err) { return { status: "error", mxid, did: value, homeserverBaseUrl: baseUrl, signed: false, detail: String(err) }; } const pointer = mooringPointerFromDocument(doc); if (pointer === null || pointer.mxid !== mxid) { return { status: pointer === null ? "no-mooring" : "mismatch", mxid, did: value, homeserverBaseUrl: baseUrl, document: doc, signed: false, detail: pointer === null ? "DID document has no matrix: entry" : `DID document points at ${pointer.mxid}`, }; } const proof = await fetchProfileField(baseUrl, mxid, PROOF_PROFILE_FIELD, opts); const proofResult = proof === null ? { signed: false } : verifyProof(proof, doc, value, mxid); return { status: "verified", mxid, did: value, homeserverBaseUrl: baseUrl, document: doc, ...proofResult }; }