@cloudhal/release-verify 0.0.0-stage → 0.10.0

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.
Files changed (41) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +81 -2
  3. package/dist/src/attestation.d.ts +42 -0
  4. package/dist/src/attestation.js +180 -0
  5. package/dist/src/crypto.d.ts +15 -0
  6. package/dist/src/crypto.js +87 -0
  7. package/dist/src/documents.d.ts +23 -0
  8. package/dist/src/documents.js +167 -0
  9. package/dist/src/errors.d.ts +13 -0
  10. package/dist/src/errors.js +48 -0
  11. package/dist/src/generated/release-keys.d.ts +67 -0
  12. package/dist/src/generated/release-keys.js +72 -0
  13. package/dist/src/generated/release-manifest.d.ts +211 -0
  14. package/dist/src/generated/release-manifest.js +237 -0
  15. package/dist/src/generated/release-statement.d.ts +61 -0
  16. package/dist/src/generated/release-statement.js +66 -0
  17. package/dist/src/generated/release-yank-list.d.ts +73 -0
  18. package/dist/src/generated/release-yank-list.js +78 -0
  19. package/dist/src/generated/validators.d.ts +22 -0
  20. package/dist/src/generated/validators.js +5656 -0
  21. package/dist/src/index.d.ts +22 -0
  22. package/dist/src/index.js +48 -0
  23. package/dist/src/keys.d.ts +28 -0
  24. package/dist/src/keys.js +84 -0
  25. package/dist/src/limits.d.ts +24 -0
  26. package/dist/src/limits.js +30 -0
  27. package/dist/src/paths.d.ts +14 -0
  28. package/dist/src/paths.js +59 -0
  29. package/dist/src/scopes.d.ts +10 -0
  30. package/dist/src/scopes.js +22 -0
  31. package/dist/src/tar.d.ts +31 -0
  32. package/dist/src/tar.js +398 -0
  33. package/dist/src/trust.d.ts +67 -0
  34. package/dist/src/trust.js +248 -0
  35. package/dist/src/urls.d.ts +8 -0
  36. package/dist/src/urls.js +37 -0
  37. package/dist/src/verify.d.ts +68 -0
  38. package/dist/src/verify.js +171 -0
  39. package/dist/src/yank-memory.d.ts +22 -0
  40. package/dist/src/yank-memory.js +106 -0
  41. package/package.json +51 -3
@@ -0,0 +1,398 @@
1
+ /**
2
+ * Streams a release tarball (RFC-0046 §7) without holding it whole. It accepts exactly what the
3
+ * producer (`scripts/release-bundle/tar.ts`) writes and refuses everything else (`tar-format`):
4
+ *
5
+ * - gzip with the fixed header (no flags, mtime 0, OS 255), decompressed by `DecompressionStream`;
6
+ * - ustar headers with a valid checksum, `ustar\0` + `00`, mode 0644, uid/gid 0, no owner names,
7
+ * link name or prefix, the release's mtime, and typeflag `0` (or `x` with a single PAX `path`
8
+ * record for the entry that follows); links, directories and every other type are refused;
9
+ * - two or more zero blocks, and nothing but zeros, at the end.
10
+ */
11
+ import { ReleaseVerifyError, refuse } from "./errors.js";
12
+ import { LIMITS, MAX_TAR_BYTES } from "./limits.js";
13
+
14
+
15
+
16
+ /** The chunks of `source`, whatever its form. */
17
+ export async function* chunksOf(source ) {
18
+ if (source instanceof Uint8Array) {
19
+ yield source;
20
+ return;
21
+ }
22
+ if (typeof (source ).getReader === "function") {
23
+ const reader = (source ).getReader();
24
+ try {
25
+ for (;;) {
26
+ const { done, value } = await reader.read();
27
+ if (done) return;
28
+ yield value;
29
+ }
30
+ } finally {
31
+ await reader.cancel().catch(() => {});
32
+ }
33
+ }
34
+ yield* source ;
35
+ }
36
+
37
+ const GZIP_FIXED = [0x1f, 0x8b, 8, 0, 0, 0, 0, 0];
38
+
39
+ /** Refuses a gzip stream whose first ten bytes are not the fixed header (§7). */
40
+ export function checkGzipHeader(head ) {
41
+ if (head.length < 10) refuse("tar-format", "the tarball is not gzip");
42
+ for (let i = 0; i < GZIP_FIXED.length; i++)
43
+ if (head[i] !== GZIP_FIXED[i]) refuse("tar-format", "the tarball's gzip header is not fixed");
44
+ if (head[9] !== 255) refuse("tar-format", "the tarball's gzip OS byte is not 255");
45
+ }
46
+
47
+ /**
48
+ * `source` as a ReadableStream we own: errors from the source itself are remembered (so they
49
+ * reach the caller unchanged, never as a tamper code), and `release()` cancels the caller's
50
+ * stream or ends its iterator, whatever state the pipe is in.
51
+ */
52
+ function sourceStream(source ) {
53
+ let failure ;
54
+ let release ;
55
+ let pull ;
56
+ if (typeof (source ).getReader === "function") {
57
+ const reader = (source ).getReader();
58
+ pull = async () => {
59
+ const r = await reader.read();
60
+ return r.done ? { done: true, value: undefined } : { done: false, value: r.value };
61
+ };
62
+ release = () => reader.cancel().catch(() => {});
63
+ } else {
64
+ const it = (
65
+ source instanceof Uint8Array
66
+ ? (async function* () {
67
+ yield source;
68
+ })()
69
+ : (source )
70
+ )[Symbol.asyncIterator]();
71
+ pull = () => it.next();
72
+ release = async () => {
73
+ await it.return?.()?.catch?.(() => {});
74
+ };
75
+ }
76
+ let released = false;
77
+ const once = async () => {
78
+ if (released) return;
79
+ released = true;
80
+ await release();
81
+ };
82
+ const stream = new ReadableStream ({
83
+ async pull(c) {
84
+ try {
85
+ const next = await pull();
86
+ if (next.done) c.close();
87
+ else c.enqueue(next.value);
88
+ } catch (error) {
89
+ failure = { error };
90
+ throw error;
91
+ }
92
+ },
93
+ cancel: once,
94
+ });
95
+ return { stream, release: once, failure: () => failure };
96
+ }
97
+
98
+ /** Errors the stream unless its first ten bytes are the fixed gzip header (§7). */
99
+ function gzipHeaderCheck() {
100
+ const head = [];
101
+ return new TransformStream({
102
+ transform(chunk, c) {
103
+ if (head.length < 10) {
104
+ for (const b of chunk.subarray(0, 10 - head.length)) head.push(b);
105
+ if (head.length === 10) checkGzipHeader(Uint8Array.from(head));
106
+ }
107
+ c.enqueue(chunk);
108
+ },
109
+ flush() {
110
+ if (head.length < 10) checkGzipHeader(Uint8Array.from(head));
111
+ },
112
+ });
113
+ }
114
+
115
+ /**
116
+ * The decompressed bytes of a gzip `source` whose header is the fixed one. Every exit (the end, a
117
+ * refusal, a consumer that stops early) cancels the chain and releases the caller's stream.
118
+ */
119
+ export async function* gunzip(
120
+ source ,
121
+ max = MAX_TAR_BYTES,
122
+ ) {
123
+ const src = sourceStream(source);
124
+ const reader = src.stream
125
+ .pipeThrough(gzipHeaderCheck())
126
+ // Typed through explicitly: lib.dom and @types/node disagree on DecompressionStream's chunk types.
127
+ .pipeThrough(
128
+ new DecompressionStream("gzip") ,
129
+ )
130
+ .getReader();
131
+ let total = 0;
132
+ try {
133
+ for (;;) {
134
+ // The reader's own result type: not every lib names `ReadableStreamReadResult`.
135
+ let next ;
136
+ try {
137
+ next = await reader.read();
138
+ } catch (e) {
139
+ const failed = src.failure();
140
+ if (failed) throw failed.error; // the source's own error, unchanged
141
+ if (e instanceof ReleaseVerifyError) throw e;
142
+ return refuse("tar-format", `the tarball is not valid gzip (${(e ).message})`);
143
+ }
144
+ if (next.done) return;
145
+ total += next.value.length;
146
+ if (total > max)
147
+ refuse("limit-exceeded", `the tarball decompresses to more than ${max} bytes`);
148
+ yield next.value;
149
+ }
150
+ } finally {
151
+ await reader.cancel().catch(() => {});
152
+ await src.release();
153
+ }
154
+ }
155
+
156
+ /** A FIFO of byte chunks that hands out exact lengths. */
157
+ class ByteQueue {
158
+ chunks = [];
159
+ offset = 0;
160
+ length = 0;
161
+ push(chunk ) {
162
+ if (chunk.length === 0) return;
163
+ this.chunks.push(chunk);
164
+ this.length += chunk.length;
165
+ }
166
+ take(n ) {
167
+ const out = new Uint8Array(n);
168
+ let at = 0;
169
+ while (at < n) {
170
+ const head = this.chunks[0] ;
171
+ const part = head.subarray(this.offset, this.offset + (n - at));
172
+ out.set(part, at);
173
+ at += part.length;
174
+ this.offset += part.length;
175
+ if (this.offset === head.length) {
176
+ this.chunks.shift();
177
+ this.offset = 0;
178
+ }
179
+ }
180
+ this.length -= n;
181
+ return out;
182
+ }
183
+ }
184
+
185
+ const dec = new TextDecoder("utf-8", { fatal: true, ignoreBOM: false });
186
+
187
+ function str(h , at , len ) {
188
+ const s = h.subarray(at, at + len);
189
+ const end = s.indexOf(0);
190
+ try {
191
+ return dec.decode(end >= 0 ? s.subarray(0, end) : s);
192
+ } catch {
193
+ return refuse("tar-format", "a tar header field is not UTF-8");
194
+ }
195
+ }
196
+
197
+ /** An octal field: 1 to 11 digits, NUL- or space-terminated (anchored and bounded: linear). */
198
+ function num(h , at , len , what ) {
199
+ const s = str(h, at, len).trim();
200
+ if (!/^[0-7]{1,11}$/.test(s)) refuse("tar-format", `a tar header has a bad ${what} field`);
201
+ return Number.parseInt(s, 8);
202
+ }
203
+
204
+ function checksumOk(h ) {
205
+ let sum = 0;
206
+ for (let i = 0; i < 512; i++) sum += i >= 148 && i < 156 ? 0x20 : (h[i] );
207
+ return num(h, 148, 8, "checksum") === sum;
208
+ }
209
+
210
+
211
+
212
+
213
+
214
+
215
+
216
+
217
+ function checkHeader(h , mtime ) {
218
+ if (!checksumOk(h)) refuse("tar-format", "a tar header has a bad checksum");
219
+ if (str(h, 257, 6) !== "ustar" || h[262] !== 0 || str(h, 263, 2) !== "00")
220
+ refuse("tar-format", "a tar header is not ustar");
221
+ const flag = h[156] === 0 ? "0" : String.fromCharCode(h[156] );
222
+ if (flag !== "0" && flag !== "x") refuse("tar-format", `a tar entry has type ${flag}`);
223
+ if (num(h, 100, 8, "mode") !== 0o644) refuse("tar-format", "a tar entry's mode is not 0644");
224
+ if (num(h, 108, 8, "uid") !== 0 || num(h, 116, 8, "gid") !== 0)
225
+ refuse("tar-format", "a tar entry's uid or gid is not 0");
226
+ if (num(h, 136, 12, "mtime") !== mtime)
227
+ refuse("tar-format", "a tar entry's mtime is not the release's createdAt");
228
+ if (str(h, 157, 100) || str(h, 265, 32) || str(h, 297, 32) || str(h, 345, 155))
229
+ refuse("tar-format", "a tar entry has a link name, an owner name or a prefix");
230
+ const field = h.subarray(0, 100);
231
+ const end = field.indexOf(0);
232
+ return { name: end >= 0 ? field.subarray(0, end) : field, size: num(h, 124, 12, "size"), flag };
233
+ }
234
+
235
+ /** The `path` of a PAX header holding exactly one `path` record. */
236
+ function paxPath(data ) {
237
+ let text ;
238
+ try {
239
+ text = dec.decode(data);
240
+ } catch {
241
+ return refuse("tar-format", "a PAX header is not UTF-8");
242
+ }
243
+ const sp = text.indexOf(" ");
244
+ const len = Number(text.slice(0, sp));
245
+ if (sp < 1 || len !== data.length || !text.endsWith("\n") || !text.startsWith(`${len} path=`))
246
+ refuse("tar-format", "a PAX header is not a single path record");
247
+ return text.slice(sp + 6, -1);
248
+ }
249
+
250
+
251
+
252
+
253
+
254
+
255
+ const padded = (size ) => Math.ceil(size / 512) * 512;
256
+ const isZero = (b ) => b.every((x) => x === 0);
257
+
258
+ /** A tar stream read in exact lengths. */
259
+ class TarReader {
260
+ q = new ByteQueue();
261
+ it ;
262
+ constructor(source ) {
263
+ this.it = source[Symbol.asyncIterator]();
264
+ }
265
+ /** Ends the source early (a refusal or a consumer that stopped): its own cleanup runs. */
266
+ async close() {
267
+ await this.it.return?.();
268
+ }
269
+ /** Buffers at least `n` bytes; false when the stream ends first. */
270
+ async fill(n ) {
271
+ while (this.q.length < n) {
272
+ const next = await this.it.next();
273
+ if (next.done) return false;
274
+ this.q.push(next.value);
275
+ }
276
+ return true;
277
+ }
278
+ /** Exactly `n` bytes, or the refusal of a truncated archive. */
279
+ async take(n ) {
280
+ if (!(await this.fill(n))) refuse("tar-format", "the tar archive is truncated");
281
+ return this.q.take(n);
282
+ }
283
+ /** Everything left in the stream must be zeros, and with `zeros` already read, 1024 or more. */
284
+ async checkEnd(zeros ) {
285
+ let trailing = zeros;
286
+ do {
287
+ const rest = this.q.take(this.q.length);
288
+ if (!isZero(rest)) refuse("tar-format", "the tar archive has data after its end");
289
+ trailing += rest.length;
290
+ } while (await this.fill(1));
291
+ if (trailing < 1024) refuse("tar-format", "the tar archive does not end with two zero blocks");
292
+ }
293
+ }
294
+
295
+ const utf8 = new TextEncoder();
296
+
297
+ /**
298
+ * Whether `path` needs a PAX `path` record: longer than the 100-byte ustar name field in UTF-8,
299
+ * or not printable ASCII. The producer (scripts/release-bundle/tar.ts) imports this rule.
300
+ */
301
+ export function needsPax(path ) {
302
+ if (utf8.encode(path).length > 100) return true;
303
+ for (let i = 0; i < path.length; i++) {
304
+ const c = path.charCodeAt(i);
305
+ if (c < 0x20 || c > 0x7e) return true;
306
+ }
307
+ return false;
308
+ }
309
+
310
+ /**
311
+ * The ustar name field of `path`: its longest suffix whose UTF-8 is at most 100 bytes, cut on a
312
+ * code-point boundary (so it is valid UTF-8). For a path of at most 100 ASCII bytes that is the
313
+ * path itself. One byte-based rule for the producer, which imports it, and the verifier.
314
+ */
315
+ export function ustarName(path ) {
316
+ const points = Array.from(path);
317
+ let bytes = 0;
318
+ let i = points.length;
319
+ while (i > 0) {
320
+ const n = utf8.encode(points[i - 1] ).length;
321
+ if (bytes + n > 100) break;
322
+ bytes += n;
323
+ i--;
324
+ }
325
+ return points.slice(i).join("");
326
+ }
327
+
328
+ const sameBytes = (a , b ) =>
329
+ a.length === b.length && a.every((x, i) => x === b[i]);
330
+
331
+ /** The path of a file header: the PAX path when one came first, else its own ASCII name. */
332
+ function entryPath(name , pax ) {
333
+ if (pax !== null) {
334
+ if (!needsPax(pax)) refuse("tar-format", `${pax}: a PAX record for a name that needs none`);
335
+ if (!sameBytes(name, utf8.encode(ustarName(pax))))
336
+ refuse("tar-format", `${pax}: the ustar name is not the end of the PAX path`);
337
+ return pax;
338
+ }
339
+ let path = "";
340
+ for (const c of name) {
341
+ if (c < 0x20 || c > 0x7e)
342
+ refuse("tar-format", "a tar name that is not ASCII has no PAX record");
343
+ path += String.fromCharCode(c);
344
+ }
345
+ return path;
346
+ }
347
+
348
+ /** The next file header with its PAX path applied, or null at the first zero block. */
349
+ async function nextHeader(
350
+ r ,
351
+ mtime ,
352
+ ) {
353
+ let pax = null;
354
+ for (;;) {
355
+ const h = await r.take(512);
356
+ if (isZero(h)) {
357
+ if (pax !== null) refuse("tar-format", "a PAX header ends the archive");
358
+ return null;
359
+ }
360
+ const { name, size, flag } = checkHeader(h, mtime);
361
+ if (size > LIMITS.partBytes)
362
+ refuse("limit-exceeded", `a tar entry is above ${LIMITS.partBytes} bytes`);
363
+ if (flag === "0") return { path: entryPath(name, pax), size };
364
+ if (pax !== null) refuse("tar-format", "two PAX headers in a row");
365
+ if (!sameBytes(name, utf8.encode("././@PaxHeader")))
366
+ refuse("tar-format", "a PAX header is not named ././@PaxHeader");
367
+ if (size > 4096) refuse("tar-format", "a PAX header is too large");
368
+ pax = paxPath((await r.take(padded(size))).subarray(0, size));
369
+ }
370
+ }
371
+
372
+ /**
373
+ * The entries of a decompressed tar stream, in archive order. `admit(path, size)` runs on each
374
+ * entry's header, before its bytes are buffered, so an unlisted or oversized entry is refused
375
+ * without reading it into memory.
376
+ */
377
+ export async function* readTar(
378
+ source ,
379
+ mtime ,
380
+ admit ,
381
+ ) {
382
+ const r = new TarReader(source);
383
+ let count = 0;
384
+ try {
385
+ for (;;) {
386
+ const header = await nextHeader(r, mtime);
387
+ if (!header) break;
388
+ if (++count > LIMITS.tarEntries)
389
+ refuse("limit-exceeded", `the tarball has more than ${LIMITS.tarEntries} entries`);
390
+ admit(header.path, header.size);
391
+ const data = await r.take(padded(header.size));
392
+ yield { path: header.path, bytes: data.subarray(0, header.size) };
393
+ }
394
+ await r.checkEnd(512);
395
+ } finally {
396
+ await r.close();
397
+ }
398
+ }
@@ -0,0 +1,67 @@
1
+ import type { ReleaseKeys as KeyListDocument } from "./generated/release-keys.js";
2
+ import type { ReleaseYankList } from "./generated/release-yank-list.js";
3
+ import { type ReleaseKeys } from "./keys.js";
4
+ import { type YankMemory } from "./yank-memory.js";
5
+ /** Root keys: they sign only `keys.json`. Pinned at the root ceremony; empty until then. */
6
+ export type RootKeys = Readonly<Record<string, {
7
+ readonly publicKey: string;
8
+ }>>;
9
+ /**
10
+ * The offline root's public key (docs/security/release-signing-keys.md, kept equal by
11
+ * test/package.test.ts), pinned at the owner's root ceremony on 2026-11-15. It signs only
12
+ * `keys.json`; a rotation of the root is a new pinned entry in a reviewed release.
13
+ */
14
+ export declare const PINNED_ROOTS: RootKeys;
15
+ export declare const KEYS_FILE = "keys.json";
16
+ export declare const YANKED_FILE = "yanked.json";
17
+ /** Where the lists live (RFC-0046 §9); `<file>.sig` next to each. */
18
+ export declare const TRUST_BASE_URL = "https://cloudhal.dev/.well-known/cloudhal/";
19
+ export interface VerifiedKeyList {
20
+ readonly document: KeyListDocument;
21
+ /** SHA-256 of the exact bytes, for logs. */
22
+ readonly sha256: string;
23
+ }
24
+ export interface VerifiedYankList {
25
+ readonly document: ReleaseYankList;
26
+ readonly sha256: string;
27
+ }
28
+ /** `iso` plus `months` calendar months (UTC), clamped to the target month's last day (01-31 + 1 → 02-28/29). */
29
+ export declare function addMonths(iso: string, months: number): number;
30
+ /** Verifies `keys.json` (see the file comment); throws a `ReleaseVerifyError`. */
31
+ export declare function verifyKeys(input: {
32
+ document: Uint8Array;
33
+ signature: Uint8Array;
34
+ roots?: RootKeys;
35
+ now?: Date;
36
+ /** A consumer's `YankMemory`: a `keys.json` older than the newest one seen is refused. */
37
+ memory?: YankMemory;
38
+ }): Promise<VerifiedKeyList>;
39
+ /**
40
+ * Verifies `yanked.json` against a verified key list (see the file comment). A consumer that can
41
+ * keep state passes its `memory` and stores `rememberYanks(result, keys, memory)` (yank-memory.ts):
42
+ * a replayed older `keys.json`, a revoked or replaced yank key, a lower serial or a dropped entry
43
+ * is refused. A stateless consumer (a Worker) cannot detect a replay or an un-yank: for it, a
44
+ * stolen yank key can un-yank and un-revoke until the root rotates the yank key.
45
+ */
46
+ export declare function verifyYanked(input: {
47
+ document: Uint8Array;
48
+ signature: Uint8Array;
49
+ keys: VerifiedKeyList;
50
+ now?: Date;
51
+ memory?: YankMemory;
52
+ }): Promise<VerifiedYankList>;
53
+ /**
54
+ * `pinned` reduced by a verified key list and yank list (RFC-0046 §9). For each pinned key: a
55
+ * listing with another public key or another role revokes it; otherwise the stricter status wins
56
+ * and the window narrows to the intersection; `listed` records that a fresh list names it. A
57
+ * revocation in either list revokes it. Keys the lists name but `pinned` lacks are not added.
58
+ */
59
+ export declare function mergeTrust(pinned: ReleaseKeys, lists?: {
60
+ keys?: VerifiedKeyList;
61
+ yanked?: VerifiedYankList;
62
+ }): ReleaseKeys;
63
+ /** The yank entry for `version`, if the verified list yanks it. */
64
+ export declare function yankOf(yanked: VerifiedYankList | undefined, version: string): {
65
+ reason: string;
66
+ yankedAt: string;
67
+ } | undefined;