~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/atlibs

0
0
main
154 lines · 8.9 KB Raw History
// 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...<relay host>.
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<Uint8Array>, serviceAuth: (claims: { aud: string, lxm: string, exp?: number }) => Promise<string> }>}
 */
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<ReturnType<typeof createDidKey>>} key
 * @param {(nsid: string, request?: object) => Promise<unknown>} 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;
}