// A did:key identity the host holds for a module, and the two pseudo-calls that use it. The // fedproxy ingress relay (xrpc.fedproxy.com) registers a service by did:key: // a service-auth JWT the did:key signs itself (iss = the did:key) and a signature over a // relay nonce. The private key never enters wasm; the module asks the host to sign. // // com.fedfork.exec.didkey.getServiceAuth params { aud, lxm, exp? } -> { token, did } // as com.atproto.server.getServiceAuth, signed by the did:key (ES256); exp is unix seconds, // default now + 5 min, at most now + 1 h // com.fedfork.exec.didkey.sign input { data: base64 } -> { did, signature: base64 } // sha256 + ECDSA P-256, compact r||s, low-S (what atproto's HashAndVerify accepts). Only a // relay nonce: exactly 64 bytes, and not all JWS-compact characters. The same key signs the // service-auth JWTs, so signing any bytes would let a module mint its own (no exp cap, any aud). // // P-256 through WebCrypto, so the same file runs in browsers and Node. import { base58 } from "../encoding/index.js"; import { json, serviceAud, xrpcBody } from "../run-module/host-call.js"; export { serviceAud }; export const GET_SERVICE_AUTH = "com.fedfork.exec.didkey.getServiceAuth"; export const SIGN = "com.fedfork.exec.didkey.sign"; const N = 0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551n; const ALG = { name: "ECDSA", namedCurve: "P-256" }; const MAX_TTL_S = 3600; const NONCE_BYTES = 64; const JWS_CHAR = /[A-Za-z0-9_.=-]/; // The relay nonce shape: 64 random bytes. A JWS signing input is base64url "header.payload". const nonceShaped = (bytes) => bytes.length === NONCE_BYTES && !Array.from(bytes).every((b) => JWS_CHAR.test(String.fromCharCode(b))); const b64 = (bytes) => btoa(Array.from(bytes, (b) => String.fromCharCode(b)).join("")); const unb64 = (text) => Uint8Array.from(atob(text), (c) => c.charCodeAt(0)); const b64url = (bytes) => b64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); const hex = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); // did:key of a P-256 public key: multicodec p256-pub (0x1200, varint 0x80 0x24) + compressed point. function didKeyP256(raw) { const point = new Uint8Array([0x80, 0x24, 2 + (raw[64] & 1), ...raw.subarray(1, 33)]); return `did:key:z${base58(point)}`; } // The host name the relay serves a did at: did:key:zDn... -> did-key-zdn.... export const ingressHost = (did, relay = "https://xrpc.fedproxy.com") => `${did.replaceAll(":", "-").toLowerCase()}.${new URL(relay).hostname}`; /** * A P-256 did:key. Pass the JWK a previous call returned to keep the same did (and host name). * @param {JsonWebKey} [jwk] private JWK * @returns {Promise<{ did: string, jwk: JsonWebKey, sign: (data: Uint8Array) => Promise, serviceAuth: (claims: { aud: string, lxm: string, exp?: number }) => Promise }>} */ export async function createDidKey(jwk) { if (!jwk) jwk = await crypto.subtle.exportKey("jwk", (await crypto.subtle.generateKey(ALG, true, ["sign"])).privateKey); const key = await crypto.subtle.importKey("jwk", jwk, ALG, false, ["sign"]); const pub = await crypto.subtle.importKey("jwk", { kty: "EC", crv: "P-256", x: jwk.x, y: jwk.y }, ALG, true, ["verify"]); const did = didKeyP256(new Uint8Array(await crypto.subtle.exportKey("raw", pub))); const sign = async (data) => { const rs = new Uint8Array(await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, key, data)); const s = BigInt(`0x${hex(rs.subarray(32))}`); if (s > N / 2n) rs.set((N - s).toString(16).padStart(64, "0").match(/../g).map((h) => parseInt(h, 16)), 32); return rs; }; const serviceAuth = async ({ aud, lxm, exp }) => { const iat = Math.floor(Date.now() / 1000); exp = Math.min(Number.isSafeInteger(exp) && exp > iat ? exp : iat + 300, iat + MAX_TTL_S); const enc = (value) => b64url(new TextEncoder().encode(JSON.stringify(value))); const signing = `${enc({ typ: "JWT", alg: "ES256" })}.${enc({ iss: did, aud, lxm, iat, exp, jti: hex(crypto.getRandomValues(new Uint8Array(16))) })}`; return `${signing}.${b64url(await sign(new TextEncoder().encode(signing)))}`; }; return { did, jwk, sign, serviceAuth }; } /** * Wrap a run-module `call` with the did:key pseudo-calls. Request calls to an https service in * `services` carry a service-auth token the did:key mints (the relay's getRegistrationNonce wants * one in the Authorization header, which a module cannot set); everything else goes to `inner`. * @param {Awaited>} key * @param {(nsid: string, request?: object) => Promise} inner * * One serving instance per did:key: the relay keeps only the newest registration of a DID, so two * runs serving one identity would take it from each other on every reconnect. A subscribe to a listed * service holds `lease(did)` while it is open and is refused while another run holds it. * @param {{ services?: string[], fetch?: typeof fetch, canHeader?: boolean, lease?: (did: string) => Promise<(() => void) | null> }} [options] * services: https origins; canHeader false (browsers) refuses a subscribe serviceAuth that would need an * Authorization header; lease: take the identity now (a release function) or null when another run holds it, * default webLockLease (runs in this page or process, and tabs of its origin); Node hosts pass ctx.lease * (javascript/src/server), which every process sharing the state dir respects */ export function didkeyCall(key, inner, { services = [], fetch = globalThis.fetch, canHeader, lease = webLockLease } = {}) { const origins = services.map((service) => new URL(service).origin); return async (nsid, request = {}) => { const { params, input, service, subscribe, signal } = request; if (nsid === GET_SERVICE_AUTH) { if (typeof params?.aud !== "string" || !params.aud.startsWith("did:") || typeof params?.lxm !== "string") throw new Error(`${GET_SERVICE_AUTH} needs params aud (a DID) and lxm`); return { token: await key.serviceAuth({ aud: params.aud, lxm: params.lxm, exp: params.exp === undefined ? undefined : Number(params.exp) }), did: key.did }; } if (nsid === SIGN) { if (typeof input?.data !== "string") throw new Error(`${SIGN} needs input data (base64)`); const data = unb64(input.data); if (!nonceShaped(data)) throw new Error(`${SIGN} signs only a relay nonce (${NONCE_BYTES} random bytes)`); return { did: key.did, signature: b64(await key.sign(data)) }; } // A subscribe with serviceAuth to a listed service: the did:key mints the token, the host opens the socket. if (subscribe && request.serviceAuth && service?.startsWith("https://") && origins.includes(new URL(service).origin)) { const { serviceAuth, ...rest } = request; if (serviceAuth.param === undefined && canHeader === false) throw new Error(`${nsid}: browsers cannot set WebSocket headers; serviceAuth needs { param }`); const token = await key.serviceAuth({ aud: serviceAud(service), lxm: nsid }); const release = await lease(`atlibs-didkey-serving:${key.did}`); if (!release) throw new Error(`${key.did} is already serving in another run: one serving instance per did:key (the relay keeps only its newest registration)`); try { return held(await inner(nsid, serviceAuth.param === undefined ? { ...rest, headers: { ...rest.headers, authorization: `Bearer ${token}` } } : { ...rest, params: { ...rest.params, [serviceAuth.param]: token } }), release); } catch (error) { release(); throw error; } } if (service?.startsWith("https://") && !subscribe && origins.includes(new URL(service).origin)) { const token = await key.serviceAuth({ aud: serviceAud(service), lxm: nsid }); const body = xrpcBody(input, request.encoding); const url = `${new URL(`/xrpc/${nsid}`, service).href}${params ? `?${new URLSearchParams(params)}` : ""}`; return json(nsid, await fetch(url, { method: input !== undefined ? "POST" : "GET", signal, headers: { authorization: `Bearer ${token}`, ...body.headers }, body: body.body })); } return inner(nsid, request); }; } // A subscription that gives `release` back when it ends or is closed (run-module closes every stream it opened). function held(sub, release) { return { async *[Symbol.asyncIterator]() { try { yield* sub; } finally { release(); } }, ...(typeof sub.send === "function" && { send: (data) => sub.send(data) }), close: () => { release(); return sub.close?.(); }, }; } /** didkeyCall's default lease: a Web Lock (navigator.locks, ifAvailable), shared by the runs of a page or process and by the tabs of an origin; none to take: always granted. */ export async function webLockLease(name) { const locks = globalThis.navigator?.locks; if (!locks) return () => {}; let release; const gone = new Promise((resolve) => (release = resolve)); const got = await new Promise((resolve) => { locks.request(name, { ifAvailable: true }, (lock) => (resolve(!!lock), lock && gone)).catch(() => resolve(false)); }); return got ? release : null; }