1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
// 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;
}