@graphty/visual-review 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Passkey approvals of review records: the owner's device (Face ID or Touch ID, a passkey in
3
+ * iCloud Keychain) signs the SHA-256 of a review record, and this module checks that signature.
4
+ * The gate runs it on every record a pull request adds; the server runs it at Finish, immediately
5
+ * before the record is committed.
6
+ *
7
+ * WebAuthn's ES256 signature is over `authenticatorData || SHA-256(clientDataJSON)`, and the
8
+ * challenge inside clientDataJSON is the record's hash, so the signature covers the record. Only
9
+ * `node:crypto`, so the gate still runs from a checkout without an install.
10
+ */
11
+
12
+ import { createHash, createPublicKey, verify } from "node:crypto";
13
+
14
+ /** Where the registered public keys live, relative to the repository root. */
15
+ export const PASSKEYS_FILE = "visual-review/passkeys.json";
16
+
17
+ const B64U = /^[A-Za-z0-9_-]+$/;
18
+ const HOST = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i;
19
+ const FIELDS = ["credentialId", "authenticatorData", "clientDataJSON", "signature"];
20
+ const UP = 0x01;
21
+ const UV = 0x04;
22
+ const AT = 0x40;
23
+
24
+ const sha256 = (bytes) => createHash("sha256").update(bytes).digest();
25
+ const b64u = (s) => Buffer.from(s, "base64url");
26
+
27
+ /**
28
+ * JSON with object keys sorted and no whitespace, so one record has exactly one byte form. A
29
+ * number JSON cannot write back as it was read (1e999 is Infinity, -0 is 0) throws, so two
30
+ * different records never hash the same.
31
+ * @param {unknown} value a JSON value
32
+ * @returns {string} its canonical text
33
+ */
34
+ export function canonical(value) {
35
+ if (typeof value === "number" && (!Number.isFinite(value) || Object.is(value, -0))) {
36
+ throw new Error(`${value} has no canonical JSON form`);
37
+ }
38
+ if (Array.isArray(value)) {
39
+ return `[${value.map((v) => canonical(v ?? null)).join(",")}]`;
40
+ }
41
+ if (value !== null && typeof value === "object") {
42
+ const keys = Object.keys(value)
43
+ .filter((k) => value[k] !== undefined)
44
+ .sort();
45
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(",")}}`;
46
+ }
47
+ return JSON.stringify(value);
48
+ }
49
+
50
+ /**
51
+ * The bytes an approval signs: SHA-256 of the canonical record without its `approval`.
52
+ * @param {object} record the review record
53
+ * @returns {Buffer} 32 bytes
54
+ */
55
+ export function recordHash(record) {
56
+ const rest = { ...record };
57
+ delete rest.approval;
58
+ return sha256(Buffer.from(canonical(rest), "utf8"));
59
+ }
60
+
61
+ /**
62
+ * Loads a key's SubjectPublicKeyInfo; only P-256 (ES256) is accepted.
63
+ * @param {string} spki base64url DER
64
+ * @returns {import("node:crypto").KeyObject} the key
65
+ */
66
+ function loadKey(spki) {
67
+ const key = createPublicKey({ key: b64u(spki), format: "der", type: "spki" });
68
+ if (key.asymmetricKeyType !== "ec" || key.asymmetricKeyDetails?.namedCurve !== "prime256v1") {
69
+ throw new Error("the public key is not a P-256 (ES256) key");
70
+ }
71
+ return key;
72
+ }
73
+
74
+ /**
75
+ * Parses passkeys.json. One bad entry makes the whole file invalid, so the gate fails closed.
76
+ * @param {string} text the file
77
+ * @returns {{ id: string, publicKey: string, rpId: string, label?: string, registeredAt?: string }[]}
78
+ * the keys
79
+ */
80
+ export function parsePasskeys(text) {
81
+ const doc = JSON.parse(text);
82
+ if (doc?.version !== 1) {
83
+ throw new Error("version must be 1");
84
+ }
85
+ if (!Array.isArray(doc.keys)) {
86
+ throw new Error("keys must be an array");
87
+ }
88
+ const seen = new Set();
89
+ for (const [i, k] of doc.keys.entries()) {
90
+ const where = `keys[${i}]`;
91
+ if (typeof k?.id !== "string" || !B64U.test(k.id)) {
92
+ throw new Error(`${where}.id must be a non-empty base64url string`);
93
+ }
94
+ if (seen.has(k.id)) {
95
+ throw new Error(`${where}.id is a duplicate`);
96
+ }
97
+ seen.add(k.id);
98
+ if (typeof k.rpId !== "string" || !HOST.test(k.rpId)) {
99
+ throw new Error(`${where}.rpId must be a host name`);
100
+ }
101
+ if (typeof k.publicKey !== "string" || !B64U.test(k.publicKey)) {
102
+ throw new Error(`${where}.publicKey must be base64url`);
103
+ }
104
+ try {
105
+ loadKey(k.publicKey);
106
+ } catch (err) {
107
+ throw new Error(`${where}.publicKey: ${err.message}`);
108
+ }
109
+ }
110
+ return doc.keys;
111
+ }
112
+
113
+ /**
114
+ * Checks clientDataJSON and authenticatorData shared by registration and approval.
115
+ * @param {object} c the ceremony
116
+ * @param {any} c.clientData the parsed clientDataJSON
117
+ * @param {Buffer} c.authData authenticatorData
118
+ * @param {string} c.type webauthn.get or webauthn.create
119
+ * @param {string} c.challenge the expected challenge, base64url
120
+ * @param {string} c.rpId the relying party id
121
+ * @param {(origin: unknown) => boolean} c.originOk whether the origin is acceptable
122
+ * @returns {string | null} why not, or null
123
+ */
124
+ function checkCeremony({ clientData, authData, type, challenge, rpId, originOk }) {
125
+ if (clientData?.type !== type) {
126
+ return `clientDataJSON.type is not ${type}`;
127
+ }
128
+ if (clientData.challenge !== challenge) {
129
+ return "the challenge does not match: it was signed for other contents than these";
130
+ }
131
+ if (clientData.crossOrigin === true) {
132
+ return "the approval was made in a cross-origin frame";
133
+ }
134
+ if (!originOk(clientData.origin)) {
135
+ return `the approval was made on ${JSON.stringify(clientData.origin)}, not on the review page's origin`;
136
+ }
137
+ if (authData.length < 37) {
138
+ return "authenticatorData is too short";
139
+ }
140
+ if (!authData.subarray(0, 32).equals(sha256(rpId))) {
141
+ return `authenticatorData is not for the rpId ${rpId}`;
142
+ }
143
+ if (!(authData[32] & UP)) {
144
+ return "the authenticator did not confirm user presence";
145
+ }
146
+ if (!(authData[32] & UV)) {
147
+ return "the authenticator did not verify the user (no Face ID, Touch ID or PIN)";
148
+ }
149
+ return null;
150
+ }
151
+
152
+ const jsonOf = (s) => {
153
+ try {
154
+ return JSON.parse(b64u(s).toString("utf8"));
155
+ } catch {
156
+ return undefined;
157
+ }
158
+ };
159
+
160
+ /**
161
+ * Whether a record's approval is a valid passkey signature over that record. Never throws.
162
+ * @param {object} record the review record, with `approval`
163
+ * @param {{ id: string, publicKey: string, rpId: string }[]} keys the keys to accept
164
+ * @param {{ origin?: string }} [options] the exact origin required (the server's); without it any
165
+ * https origin whose host is exactly the key's rpId, on any port (servherd assigns the review
166
+ * server's), never a subdomain
167
+ * @returns {string | null} why it fails, or null when it verifies
168
+ */
169
+ export function verifyApproval(record, keys, { origin } = {}) {
170
+ const a = record?.approval;
171
+ if (typeof a !== "object" || a === null) {
172
+ return "the record has no passkey approval";
173
+ }
174
+ for (const f of FIELDS) {
175
+ if (typeof a[f] !== "string" || !B64U.test(a[f])) {
176
+ return `approval.${f} is not base64url`;
177
+ }
178
+ }
179
+ const key = keys.find((k) => k.id === a.credentialId);
180
+ if (!key) {
181
+ return "approval is from a key not in passkeys.json on the base branch";
182
+ }
183
+ const clientDataBytes = b64u(a.clientDataJSON);
184
+ const clientData = jsonOf(a.clientDataJSON);
185
+ if (clientData === undefined) {
186
+ return "approval.clientDataJSON is not JSON";
187
+ }
188
+ const authData = b64u(a.authenticatorData);
189
+ let challenge;
190
+ try {
191
+ challenge = recordHash(record).toString("base64url");
192
+ } catch (err) {
193
+ return `the record cannot be hashed: ${err.message}`;
194
+ }
195
+ const why = checkCeremony({
196
+ clientData,
197
+ authData,
198
+ type: "webauthn.get",
199
+ challenge,
200
+ rpId: key.rpId,
201
+ originOk: (o) => {
202
+ if (origin !== undefined) {
203
+ return o === origin;
204
+ }
205
+ try {
206
+ // u.origin === o refuses anything a browser never writes as an origin (a user, a path).
207
+ const u = new URL(String(o));
208
+ return u.origin === o && u.protocol === "https:" && u.hostname === key.rpId;
209
+ } catch {
210
+ return false;
211
+ }
212
+ },
213
+ });
214
+ if (why) {
215
+ return why;
216
+ }
217
+ let ok = false;
218
+ try {
219
+ ok = verify(
220
+ "sha256",
221
+ Buffer.concat([authData, sha256(clientDataBytes)]),
222
+ loadKey(key.publicKey),
223
+ b64u(a.signature),
224
+ );
225
+ } catch {
226
+ ok = false;
227
+ }
228
+ return ok ? null : "the approval's signature does not verify";
229
+ }
230
+
231
+ /**
232
+ * The gate's check of one record a pull request adds, once approvals are enforced.
233
+ * @param {object} record the parsed record
234
+ * @param {{ id: string, publicKey: string, rpId: string }[]} keys the base branch's keys
235
+ * @param {{ pr: number }} options the pull request the gate runs on
236
+ * @returns {string | null} why it fails, or null
237
+ */
238
+ export function verifyRecord(record, keys, { pr }) {
239
+ if (typeof record !== "object" || record === null || Array.isArray(record)) {
240
+ return "not a review record";
241
+ }
242
+ if (record.version !== 2) {
243
+ return `a version ${JSON.stringify(record.version)} record has no passkey approval; review it again with Face ID`;
244
+ }
245
+ if (!Array.isArray(record.items) || !Array.isArray(record.rejects)) {
246
+ return "items and rejects must be arrays";
247
+ }
248
+ if (record.pr !== pr && record.pr !== null) {
249
+ return `the record is for pull request #${record.pr}, not #${pr}`;
250
+ }
251
+ return verifyApproval(record, keys);
252
+ }
253
+
254
+ /**
255
+ * The server's check of a `navigator.credentials.create` response. Attestation is not checked
256
+ * (Apple passkeys give "none"): the owner merging the key's pull request is the trust step.
257
+ * @param {{ credentialId: string, publicKey: string, algorithm: number, authenticatorData: string,
258
+ * clientDataJSON: string }} input what the page sent
259
+ * @param {{ challenge: string, origin: string, rpId: string }} expected the server's challenge
260
+ * (base64url), its origin and rpId
261
+ * @returns {string | null} why it fails, or null
262
+ */
263
+ export function verifyRegistration(input, { challenge, origin, rpId }) {
264
+ for (const f of ["credentialId", "publicKey", "authenticatorData", "clientDataJSON"]) {
265
+ if (typeof input?.[f] !== "string" || !B64U.test(input[f])) {
266
+ return `${f} is not base64url`;
267
+ }
268
+ }
269
+ if (input.algorithm !== -7) {
270
+ return `algorithm ${input.algorithm} is not ES256 (-7)`;
271
+ }
272
+ const clientData = jsonOf(input.clientDataJSON);
273
+ if (clientData === undefined) {
274
+ return "clientDataJSON is not JSON";
275
+ }
276
+ const authData = b64u(input.authenticatorData);
277
+ const why = checkCeremony({
278
+ clientData,
279
+ authData,
280
+ type: "webauthn.create",
281
+ challenge,
282
+ rpId,
283
+ originOk: (o) => o === origin,
284
+ });
285
+ if (why) {
286
+ return why;
287
+ }
288
+ // The attested credential data names the credential: it must be the id sent.
289
+ const id = b64u(input.credentialId);
290
+ const length = authData.length >= 55 ? authData.readUInt16BE(53) : -1;
291
+ if (!(authData[32] & AT) || length !== id.length || !authData.subarray(55, 55 + length).equals(id)) {
292
+ return "authenticatorData does not hold this credential id";
293
+ }
294
+ try {
295
+ loadKey(input.publicKey);
296
+ } catch (err) {
297
+ return err.message;
298
+ }
299
+ return null;
300
+ }
@@ -57,6 +57,14 @@ export const ghRunner = (cwd) => withRetries((args, input) => exec("gh", args, {
57
57
  const TRANSIENT =
58
58
  /could not resolve host|no such host|error connecting to|connection (reset|refused|timed out)|i\/o timeout|TLS handshake timeout|HTTP 5\d\d|unexpected EOF|GOAWAY|stream error|context deadline exceeded|timed out after/i;
59
59
 
60
+ /**
61
+ * The gh calls waiting to retry after a network failure, newest last, each with its error, which
62
+ * try it waits for and until when: the review page shows the newest, so a dropped DNS lookup reads
63
+ * as "retrying" and never as a hang.
64
+ * @type {Set<{ error: string, attempt: number, of: number, until: number }>}
65
+ */
66
+ export const retrying = new Set();
67
+
60
68
  /**
61
69
  * Retries a gh runner's calls that failed on the network, after each delay in turn. A write
62
70
  * (`--input`) is never retried: GitHub may have applied it before the connection dropped. Every
@@ -79,7 +87,15 @@ export const withRetries =
79
87
  if (!retry) {
80
88
  throw err;
81
89
  }
90
+ const wait = {
91
+ error: err.message.split("\n")[0],
92
+ attempt: i + 2,
93
+ of: delays.length + 1,
94
+ until: Date.now() + delays[i],
95
+ };
96
+ retrying.add(wait);
82
97
  await new Promise((resolve) => setTimeout(resolve, delays[i]));
98
+ retrying.delete(wait);
83
99
  }
84
100
  }
85
101
  };
@@ -158,6 +174,29 @@ export async function visualJobs(gh, run, attempt, projects) {
158
174
  // Downloads in flight, by target directory: concurrent refreshes of one run await the same one.
159
175
  const downloading = new Map();
160
176
 
177
+ // At most this many `gh run download` at once: every open pull request's captures start at once,
178
+ // and dozens of parallel transfers only slow each other (and the one the reviewer is waiting for).
179
+ const DOWNLOADS = 8;
180
+ let active = 0;
181
+ /** Downloads waiting for a slot, by target directory, in the order they start. */
182
+ const queued = [];
183
+ const pump = () => {
184
+ while (active < DOWNLOADS && queued.length > 0) {
185
+ active++;
186
+ queued.shift().start();
187
+ }
188
+ };
189
+
190
+ /**
191
+ * Moves the waiting downloads whose directory `wanted` picks to the front of the queue: the
192
+ * project the reviewer is opening downloads next, before the ones nobody is waiting for.
193
+ * @param {(dir: string) => boolean} wanted picks the directories to hurry
194
+ */
195
+ export function hurry(wanted) {
196
+ const first = queued.filter((q) => wanted(q.dir));
197
+ queued.splice(0, queued.length, ...first, ...queued.filter((q) => !wanted(q.dir)));
198
+ }
199
+
161
200
  /**
162
201
  * Downloads one artifact into `dir`, unless it is already there. It is extracted into a sibling
163
202
  * temporary directory and renamed into place only once its results.json is there, so `dir` either
@@ -192,6 +231,10 @@ function download(gh, runId, name, dir) {
192
231
  rmSync(join(dirname(dir), f), { recursive: true, force: true });
193
232
  }
194
233
  }
234
+ await new Promise((start) => {
235
+ queued.push({ dir, start });
236
+ pump();
237
+ });
195
238
  const part = mkdtempSync(`${dir}.part-`);
196
239
  try {
197
240
  await gh(["run", "download", String(runId), "-n", name, "-D", part]);
@@ -200,6 +243,8 @@ function download(gh, runId, name, dir) {
200
243
  }
201
244
  } finally {
202
245
  rmSync(part, { recursive: true, force: true });
246
+ active--;
247
+ pump();
203
248
  }
204
249
  })().finally(() => downloading.delete(dir));
205
250
  downloading.set(dir, done);
@@ -218,11 +263,16 @@ function download(gh, runId, name, dir) {
218
263
  * @param {string[]} projects project ids
219
264
  * @param {string} tmp the download root
220
265
  * @param {string[]} [others] receives the projects the run captured that are not in `projects`
221
- * @returns {Promise<Record<string, { dir: string | null, attempt: number, error?: string,
222
- * expired?: true } | null>>} null for a project with no artifact; `error` (and no `dir`) when
223
- * its download failed; `expired` (and no `dir`) when GitHub deleted it and it is not on disk
266
+ * @param {(project: string, got: object | null) => void} [landed] told as each project's download
267
+ * ends, with what the result holds for it, so a page can fill rows in one by one
268
+ * @param {(sizes: Record<string, number>) => void} [planned] told first, with the size in bytes
269
+ * of each project's artifact, so a page can count the bytes still to come
270
+ * @returns {Promise<Record<string, { dir: string | null, attempt: number, bytes: number,
271
+ * error?: string, expired?: true } | null>>} null for a project with no artifact; `error`
272
+ * (and no `dir`) when its download failed; `expired` (and no `dir`) when GitHub deleted it and
273
+ * it is not on disk
224
274
  */
225
- export async function downloadCaptures(gh, run, projects, tmp, others = []) {
275
+ export async function downloadCaptures(gh, run, projects, tmp, others = [], landed = () => {}, planned = () => {}) {
226
276
  const { artifacts } = await api(gh, `repos/{owner}/{repo}/actions/runs/${run.id}/artifacts?per_page=100`);
227
277
  for (const a of artifacts) {
228
278
  const p = /^visual-(.+)-\d+$/.exec(a.name)?.[1];
@@ -230,32 +280,49 @@ export async function downloadCaptures(gh, run, projects, tmp, others = []) {
230
280
  others.push(p);
231
281
  }
232
282
  }
233
- /** @type {Record<string, { dir: string | null, attempt: number, error?: string, expired?: true } | null>} */
283
+ const newest = Object.fromEntries(
284
+ projects.map((project) => {
285
+ const pattern = new RegExp(`^visual-${project}-(\\d+)$`);
286
+ const a = artifacts
287
+ .filter((x) => pattern.test(x.name))
288
+ .map((x) => ({
289
+ name: x.name,
290
+ expired: x.expired,
291
+ bytes: x.size_in_bytes ?? 0,
292
+ attempt: Number(pattern.exec(x.name)[1]),
293
+ }))
294
+ .sort((x, y) => y.attempt - x.attempt)[0];
295
+ return [project, a ?? null];
296
+ }),
297
+ );
298
+ planned(Object.fromEntries(Object.entries(newest).flatMap(([p, a]) => (a ? [[p, a.bytes]] : []))));
299
+ /** @type {Record<string, { dir: string | null, attempt: number, bytes: number, error?: string, expired?: true } | null>} */
234
300
  const out = {};
235
- for (const project of projects) {
236
- const pattern = new RegExp(`^visual-${project}-(\\d+)$`);
237
- const newest = artifacts
238
- .filter((a) => pattern.test(a.name))
239
- .map((a) => ({ name: a.name, expired: a.expired, attempt: Number(pattern.exec(a.name)[1]) }))
240
- .sort((a, b) => b.attempt - a.attempt)[0];
241
- if (!newest) {
242
- out[project] = null;
243
- continue;
244
- }
245
- const dir = join(tmp, `${run.id}-${newest.attempt}`, project);
246
- if (newest.expired) {
247
- out[project] = existsSync(join(dir, "results.json"))
248
- ? { dir, attempt: newest.attempt }
249
- : { dir: null, attempt: newest.attempt, expired: true };
250
- continue;
251
- }
252
- try {
253
- await download(gh, run.id, newest.name, dir);
254
- out[project] = { dir, attempt: newest.attempt };
255
- } catch (err) {
256
- out[project] = { dir: null, attempt: newest.attempt, error: err.message };
257
- }
258
- }
301
+ // Every project at once: the shared cap on downloads keeps the number of transfers sane.
302
+ await Promise.all(
303
+ projects.map(async (project) => {
304
+ const a = newest[project];
305
+ if (!a) {
306
+ out[project] = null;
307
+ } else {
308
+ const dir = join(tmp, `${run.id}-${a.attempt}`, project);
309
+ const got = { attempt: a.attempt, bytes: a.bytes };
310
+ if (a.expired) {
311
+ out[project] = existsSync(join(dir, "results.json"))
312
+ ? { dir, ...got }
313
+ : { dir: null, ...got, expired: true };
314
+ } else {
315
+ try {
316
+ await download(gh, run.id, a.name, dir);
317
+ out[project] = { dir, ...got };
318
+ } catch (err) {
319
+ out[project] = { dir: null, ...got, error: err.message };
320
+ }
321
+ }
322
+ }
323
+ landed(project, out[project]);
324
+ }),
325
+ );
259
326
  return out;
260
327
  }
261
328