@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,248 @@
1
+ /**
2
+ * The signed key list and yank list (RFC-0046 §9, #226), and how they change what a consumer
3
+ * trusts: only ever downwards.
4
+ *
5
+ * - `verifyKeys`: `keys.json` + `keys.json.sig`, signed by a pinned root, fresh (`issuedAt` ≤
6
+ * now < `expires`, at most 13 months apart).
7
+ * - `verifyYanked`: `yanked.json` + `yanked.json.sig`, signed by a yank-role key that a verified
8
+ * `keys.json` lists as active and valid now, fresh (`expires` exactly 30 days after `issuedAt`;
9
+ * past it the list is stale, which blocks new installs only), and not rolled back below a
10
+ * `serial` the consumer has already seen.
11
+ * - `mergeTrust`: pinned keys reduced by both: revoke, mark retiring, narrow the validity window.
12
+ * A key the lists name but the consumer does not pin is never added.
13
+ */
14
+ import { sha256Hex, verifyEd25519 } from "./crypto.js";
15
+ import { refuse } from "./errors.js";
16
+
17
+
18
+ import { validateReleaseKeys, validateReleaseYankList } from "./generated/validators.js";
19
+ import { fromBase64, } from "./keys.js";
20
+ import { checkKeysMemory, checkYankMemory, } from "./yank-memory.js";
21
+
22
+ /** Root keys: they sign only `keys.json`. Pinned at the root ceremony; empty until then. */
23
+
24
+
25
+ /**
26
+ * The offline root's public key (docs/security/release-signing-keys.md, kept equal by
27
+ * test/package.test.ts), pinned at the owner's root ceremony on 2026-11-15. It signs only
28
+ * `keys.json`; a rotation of the root is a new pinned entry in a reviewed release.
29
+ */
30
+ export const PINNED_ROOTS = Object.freeze({
31
+ "cloudhal-root-2026a": Object.freeze({
32
+ publicKey: "/rtDvlSlCYV402NiA/7OOFk+ryBv4DIWmkqwRyrVzqw=",
33
+ }),
34
+ });
35
+
36
+ export const KEYS_FILE = "keys.json";
37
+ export const YANKED_FILE = "yanked.json";
38
+ /** Where the lists live (RFC-0046 §9); `<file>.sig` next to each. */
39
+ export const TRUST_BASE_URL = "https://cloudhal.dev/.well-known/cloudhal/";
40
+
41
+ const MAX_KEYS_BYTES = 64 * 1024;
42
+ const MAX_YANKED_BYTES = 256 * 1024;
43
+ /** A list issued a little in the future is a clock skew, not an attack. */
44
+ const SKEW_MS = 5 * 60 * 1000;
45
+ const DAY_MS = 24 * 60 * 60 * 1000;
46
+
47
+
48
+
49
+
50
+
51
+
52
+
53
+
54
+
55
+
56
+
57
+
58
+ const utf8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
59
+
60
+ /** The signed JSON document in `bytes` and its signing key id, before its signature is checked. */
61
+ function signedDoc(
62
+ bytes ,
63
+ signature ,
64
+ max ,
65
+ code ,
66
+ ) {
67
+ const what = code === "key-list-invalid" ? "keys.json" : "yanked.json";
68
+ if (bytes.length > max) refuse(code, `${what} is above ${max} bytes`);
69
+ if (signature.length !== 64) refuse(code, `${what}.sig is not 64 bytes`);
70
+ let doc ;
71
+ try {
72
+ doc = JSON.parse(utf8.decode(bytes));
73
+ } catch {
74
+ return refuse(code, `${what} is not UTF-8 JSON`);
75
+ }
76
+ const keyId = (doc )?.signing?.keyId;
77
+ if (typeof keyId !== "string") refuse(code, `${what} names no signing key`);
78
+ return { doc, keyId: keyId };
79
+ }
80
+
81
+ /** `iso` plus `months` calendar months (UTC), clamped to the target month's last day (01-31 + 1 → 02-28/29). */
82
+ export function addMonths(iso , months ) {
83
+ const d = new Date(iso);
84
+ const target = new Date(
85
+ Date.UTC(
86
+ d.getUTCFullYear(),
87
+ d.getUTCMonth() + months,
88
+ 1,
89
+ d.getUTCHours(),
90
+ d.getUTCMinutes(),
91
+ d.getUTCSeconds(),
92
+ ),
93
+ );
94
+ const last = new Date(
95
+ Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0),
96
+ ).getUTCDate();
97
+ target.setUTCDate(Math.min(d.getUTCDate(), last));
98
+ return target.getTime();
99
+ }
100
+
101
+ /** Verifies `keys.json` (see the file comment); throws a `ReleaseVerifyError`. */
102
+ export async function verifyKeys(input
103
+
104
+
105
+
106
+
107
+
108
+
109
+ ) {
110
+ const now = (input.now ?? new Date()).getTime();
111
+ const { doc, keyId } = signedDoc(
112
+ input.document,
113
+ input.signature,
114
+ MAX_KEYS_BYTES,
115
+ "key-list-invalid",
116
+ );
117
+ const roots = input.roots ?? PINNED_ROOTS;
118
+ const root = Object.hasOwn(roots, keyId) ? roots[keyId] : undefined;
119
+ const raw = root ? fromBase64(root.publicKey) : undefined;
120
+ if (raw?.length !== 32)
121
+ refuse("key-list-invalid", `keys.json is signed by ${keyId}, not a pinned root`);
122
+ if (!(await verifyEd25519(raw , input.document, input.signature)))
123
+ refuse("key-list-invalid", `keys.json.sig does not verify with ${keyId}`);
124
+ if (!validateReleaseKeys(doc)) refuse("key-list-invalid", "keys.json is not a valid key list");
125
+ const list = doc;
126
+ const ids = new Set ();
127
+ for (const k of list.keys) {
128
+ if (ids.has(k.keyId)) refuse("key-list-invalid", `keys.json lists ${k.keyId} twice`);
129
+ ids.add(k.keyId);
130
+ if (!(Date.parse(k.notBefore) < Date.parse(k.notAfter)))
131
+ refuse("key-list-invalid", `${k.keyId}: notBefore is not before notAfter`);
132
+ }
133
+ // One active yank key at a time: rotation replaces it, so a thief of one key cannot switch to
134
+ // another to start a fresh memory (yank-memory.ts).
135
+ const activeYank = list.keys.filter((k) => k.role === "yank" && k.status === "active");
136
+ if (activeYank.length > 1)
137
+ refuse(
138
+ "key-list-invalid",
139
+ `keys.json lists ${activeYank.length} active yank keys (${activeYank.map((k) => k.keyId).join(", ")}); at most one`,
140
+ );
141
+ const issued = Date.parse(list.issuedAt);
142
+ const expires = Date.parse(list.expires);
143
+ if (!(issued < expires) || expires > addMonths(list.issuedAt, 13))
144
+ refuse("key-list-invalid", "keys.json must expire after issuedAt and within 13 months");
145
+ if (issued > now + SKEW_MS) refuse("key-list-invalid", "keys.json is issued in the future");
146
+ if (now >= expires) refuse("key-list-expired", `keys.json expired on ${list.expires}`);
147
+ checkKeysMemory(list, input.memory);
148
+ return { document: list, sha256: await sha256Hex(input.document) };
149
+ }
150
+
151
+ /**
152
+ * Verifies `yanked.json` against a verified key list (see the file comment). A consumer that can
153
+ * keep state passes its `memory` and stores `rememberYanks(result, keys, memory)` (yank-memory.ts):
154
+ * a replayed older `keys.json`, a revoked or replaced yank key, a lower serial or a dropped entry
155
+ * is refused. A stateless consumer (a Worker) cannot detect a replay or an un-yank: for it, a
156
+ * stolen yank key can un-yank and un-revoke until the root rotates the yank key.
157
+ */
158
+ export async function verifyYanked(input
159
+
160
+
161
+
162
+
163
+
164
+ ) {
165
+ const now = (input.now ?? new Date()).getTime();
166
+ const { doc, keyId } = signedDoc(
167
+ input.document,
168
+ input.signature,
169
+ MAX_YANKED_BYTES,
170
+ "yank-list-invalid",
171
+ );
172
+ const signer = input.keys.document.keys.find((k) => k.keyId === keyId);
173
+ if (signer?.role !== "yank" || signer.status !== "active")
174
+ refuse("yank-list-invalid", `yanked.json is signed by ${keyId}, not an active yank key`);
175
+ const key = signer ;
176
+ if (now < Date.parse(key.notBefore) || now > Date.parse(key.notAfter))
177
+ refuse("yank-list-invalid", `the yank key ${keyId} is not valid now`);
178
+ const raw = fromBase64(key.publicKey) ;
179
+ if (!(await verifyEd25519(raw, input.document, input.signature)))
180
+ refuse("yank-list-invalid", `yanked.json.sig does not verify with ${keyId}`);
181
+ if (!validateReleaseYankList(doc))
182
+ refuse("yank-list-invalid", "yanked.json is not a valid yank list");
183
+ const list = doc;
184
+ const issued = Date.parse(list.issuedAt);
185
+ if (Date.parse(list.expires) - issued !== 30 * DAY_MS)
186
+ refuse("yank-list-invalid", "yanked.json must expire exactly 30 days after issuedAt");
187
+ if (issued > now + SKEW_MS) refuse("yank-list-invalid", "yanked.json is issued in the future");
188
+ checkYankMemory(list, input.keys.document, input.memory);
189
+ if (now >= Date.parse(list.expires))
190
+ refuse(
191
+ "stale-yank-list",
192
+ `yanked.json expired on ${list.expires}; new installs wait for a fresh one`,
193
+ );
194
+ return { document: list, sha256: await sha256Hex(input.document) };
195
+ }
196
+
197
+ const RANK = { active: 0, retiring: 1, revoked: 2 };
198
+ const later = (a , b ) =>
199
+ a === undefined || Date.parse(b) > Date.parse(a) ? b : a;
200
+ const earlier = (a , b ) =>
201
+ a === undefined || Date.parse(b) < Date.parse(a) ? b : a;
202
+
203
+ /**
204
+ * `pinned` reduced by a verified key list and yank list (RFC-0046 §9). For each pinned key: a
205
+ * listing with another public key or another role revokes it; otherwise the stricter status wins
206
+ * and the window narrows to the intersection; `listed` records that a fresh list names it. A
207
+ * revocation in either list revokes it. Keys the lists name but `pinned` lacks are not added.
208
+ */
209
+ export function mergeTrust(
210
+ pinned ,
211
+ lists = {},
212
+ ) {
213
+ const out = {};
214
+ const revokedByYank = new Set(lists.yanked?.document.revokedKeys.map((r) => r.keyId) ?? []);
215
+ for (const [keyId, key] of Object.entries(pinned)) {
216
+ let next = { ...key };
217
+ const listed = lists.keys?.document.keys.find((k) => k.keyId === keyId);
218
+ if (listed) {
219
+ const conflict = listed.publicKey !== key.publicKey || listed.role !== "release";
220
+ const status = conflict
221
+ ? "revoked"
222
+ : RANK[listed.status] > RANK[key.status]
223
+ ? listed.status
224
+ : key.status;
225
+ next = {
226
+ ...next,
227
+ status,
228
+ notBefore: later(key.notBefore, listed.notBefore),
229
+ notAfter: earlier(key.notAfter, listed.notAfter),
230
+ listed: status !== "revoked",
231
+ };
232
+ } else if (next.listed) {
233
+ // Only a list verified now may confirm a key; a caller's own flag is not trusted.
234
+ next = { ...next, listed: false };
235
+ }
236
+ if (revokedByYank.has(keyId)) next = { ...next, status: "revoked", listed: false };
237
+ out[keyId] = Object.freeze(next);
238
+ }
239
+ return Object.freeze(out);
240
+ }
241
+
242
+ /** The yank entry for `version`, if the verified list yanks it. */
243
+ export function yankOf(
244
+ yanked ,
245
+ version ,
246
+ ) {
247
+ return yanked?.document.versions.find((v) => v.version === version);
248
+ }
@@ -0,0 +1,8 @@
1
+ export declare const RELEASE_REPOSITORY = "cloudhal-dev/cloudhal";
2
+ /** The five assets of `version`, in §1's order (tarball, manifest, statement, signature, Sigstore). */
3
+ export declare function releaseAssetNames(version: string): string[];
4
+ /** `{ name, url }` of each asset of `version` on the GitHub release `v<version>`. */
5
+ export declare function releaseAssetUrls(version: string): {
6
+ name: string;
7
+ url: string;
8
+ }[];
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Where a release's five assets (RFC-0046 §1) are downloaded from. Downloading is discovery only,
3
+ * never a trust factor: `verifyRelease` decides.
4
+ */
5
+ import {
6
+ MANIFEST_FILE,
7
+ SIGNATURE_FILE,
8
+ STATEMENT_FILE,
9
+ sigstoreName,
10
+ tarballName,
11
+ } from "./limits.js";
12
+
13
+ export const RELEASE_REPOSITORY = "cloudhal-dev/cloudhal";
14
+
15
+ /** `CloudhalVersion` of the contracts: anchored, bounded alternatives, no overlapping runs. */
16
+ const VERSION =
17
+ /^(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})(-[0-9A-Za-z.-]{1,64})?$/;
18
+
19
+ /** The five assets of `version`, in §1's order (tarball, manifest, statement, signature, Sigstore). */
20
+ export function releaseAssetNames(version ) {
21
+ if (!VERSION.test(version)) throw new TypeError(`not a Cloudhal version: ${version}`);
22
+ return [
23
+ tarballName(version),
24
+ MANIFEST_FILE,
25
+ STATEMENT_FILE,
26
+ SIGNATURE_FILE,
27
+ sigstoreName(version),
28
+ ];
29
+ }
30
+
31
+ /** `{ name, url }` of each asset of `version` on the GitHub release `v<version>`. */
32
+ export function releaseAssetUrls(version ) {
33
+ return releaseAssetNames(version).map((name) => ({
34
+ name,
35
+ url: `https://github.com/${RELEASE_REPOSITORY}/releases/download/v${version}/${name}`,
36
+ }));
37
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * `verifyRelease` (RFC-0046 §2, §11a): the six steps of §2 in order, with no soft-fail anywhere.
3
+ *
4
+ * 1. sizes: the statement at most 4096 bytes, the signature exactly 64;
5
+ * 2. Ed25519 over the exact statement bytes with a trusted key for `signing.keyId`, valid at
6
+ * the statement's `createdAt` and still trusted now (§9), and the version not yanked;
7
+ * 3. the statement is for the requested version and channel (invariant 7);
8
+ * 4. the statement's schema and invariants 1, 2, 3 and 6;
9
+ * 5. the manifest: size and SHA-256 equal the statement's, schema, invariants 4 and 5, the
10
+ * signature form, `planVersion`, and every listing (paths, §7 limits);
11
+ * 6. the tarball, read once: size and SHA-256 equal the statement's.
12
+ *
13
+ * `entries()` then reads the tarball a second time and yields each entry only after its path is
14
+ * safe and its size and SHA-256 equal its listing; it throws on an unlisted entry, on anything
15
+ * outside §7's tar format and, at the end, on a listed entry that never came. A consumer may upload
16
+ * entries while it iterates; it must not activate a deployment until the iteration has finished.
17
+ */
18
+ import { type ListedPart, type Worker } from "./documents.js";
19
+ import type { ReleaseManifest } from "./generated/release-manifest.js";
20
+ import type { ReleaseStatement } from "./generated/release-statement.js";
21
+ import { type ReleaseKeys } from "./keys.js";
22
+ import { type ByteSource } from "./tar.js";
23
+ import { type VerifiedYankList } from "./trust.js";
24
+ export interface VerifyReleaseInput {
25
+ /** What the consumer asked for; `channel` defaults to the one the version implies. */
26
+ readonly request: {
27
+ readonly version: string;
28
+ readonly channel?: "stable" | "next";
29
+ };
30
+ /** The exact downloaded bytes; nothing is re-serialised. */
31
+ readonly statement: Uint8Array;
32
+ readonly signature: Uint8Array;
33
+ readonly manifest: Uint8Array;
34
+ /** Opens the tarball; called twice (the digest pass, then `entries()`). */
35
+ readonly tarball: () => ByteSource | Promise<ByteSource>;
36
+ /** The highest `planVersion` the caller supports. */
37
+ readonly planVersion: number;
38
+ /** The trusted keys (default `PINNED_KEYS`); see keys.ts. */
39
+ readonly keys?: ReleaseKeys;
40
+ /** Key ids to distrust on top of `keys` (reduce-only). */
41
+ readonly revoked?: readonly string[];
42
+ /** A verified yank list (`verifyYanked`): a yanked version is refused. Pass `keys` through `mergeTrust` to apply its revocations too. */
43
+ readonly yanked?: VerifiedYankList;
44
+ /** The time keys are checked against (default: now). */
45
+ readonly now?: Date;
46
+ }
47
+ export interface VerifiedEntry {
48
+ readonly worker: Worker | null;
49
+ readonly kind: "module" | "asset" | "starter";
50
+ /** The module path, the asset path (`/x`), or `starter/<file>`. */
51
+ readonly path: string;
52
+ /** The tar path (`site/modules/x.mjs`). */
53
+ readonly tarPath: string;
54
+ /** A module's type from the manifest (`esm`, `cjs`, `wasm`, `text`, `data`). */
55
+ readonly type?: string;
56
+ readonly bytes: Uint8Array;
57
+ }
58
+ export interface VerifiedRelease {
59
+ readonly statement: ReleaseStatement;
60
+ readonly manifest: ReleaseManifest;
61
+ entries(): AsyncGenerator<VerifiedEntry>;
62
+ }
63
+ export declare function verifyRelease(input: VerifyReleaseInput): Promise<VerifiedRelease>;
64
+ /**
65
+ * The second pass (step 6's entries), exported for the producer's self-check
66
+ * (scripts/release-bundle/verify.ts); not part of the package's public exports.
67
+ */
68
+ export declare function verifiedEntries(open: VerifyReleaseInput["tarball"], listed: ReadonlyMap<string, ListedPart>, mtime: number): AsyncGenerator<VerifiedEntry>;
@@ -0,0 +1,171 @@
1
+ /**
2
+ * `verifyRelease` (RFC-0046 §2, §11a): the six steps of §2 in order, with no soft-fail anywhere.
3
+ *
4
+ * 1. sizes: the statement at most 4096 bytes, the signature exactly 64;
5
+ * 2. Ed25519 over the exact statement bytes with a trusted key for `signing.keyId`, valid at
6
+ * the statement's `createdAt` and still trusted now (§9), and the version not yanked;
7
+ * 3. the statement is for the requested version and channel (invariant 7);
8
+ * 4. the statement's schema and invariants 1, 2, 3 and 6;
9
+ * 5. the manifest: size and SHA-256 equal the statement's, schema, invariants 4 and 5, the
10
+ * signature form, `planVersion`, and every listing (paths, §7 limits);
11
+ * 6. the tarball, read once: size and SHA-256 equal the statement's.
12
+ *
13
+ * `entries()` then reads the tarball a second time and yields each entry only after its path is
14
+ * safe and its size and SHA-256 equal its listing; it throws on an unlisted entry, on anything
15
+ * outside §7's tar format and, at the end, on a listed entry that never came. A consumer may upload
16
+ * entries while it iterates; it must not activate a deployment until the iteration has finished.
17
+ */
18
+
19
+ import { incrementalSha256, sha256Hex, verifyEd25519 } from "./crypto.js";
20
+ import {
21
+ checkManifestDoc,
22
+ checkRequested,
23
+ checkStatementDoc,
24
+
25
+ listedParts,
26
+ parseJson,
27
+
28
+ } from "./documents.js";
29
+ import { refuse } from "./errors.js";
30
+
31
+
32
+ import { checkKeyWindow, PINNED_KEYS, trustedKey } from "./keys.js";
33
+ import { LIMITS } from "./limits.js";
34
+ import { checkPath } from "./paths.js";
35
+ import { chunksOf, gunzip, readTar } from "./tar.js";
36
+ import { yankOf } from "./trust.js";
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
+ export async function verifyRelease(input ) {
78
+ // 1.
79
+ if (input.statement.length > LIMITS.statementBytes)
80
+ refuse("statement-too-large", `the statement is above ${LIMITS.statementBytes} bytes`);
81
+ if (input.signature.length !== 64)
82
+ refuse("signature-size", `the signature is ${input.signature.length} bytes, not 64`);
83
+ // 2.
84
+ const doc = parseJson(input.statement, "statement-invalid");
85
+ const keyId = (doc )?.signing?.keyId;
86
+ if (typeof keyId !== "string") refuse("statement-invalid", "the statement names no key");
87
+ const { raw, key } = trustedKey(input.keys ?? PINNED_KEYS, input.revoked ?? [], keyId);
88
+ if (!(await verifyEd25519(raw, input.statement, input.signature)))
89
+ refuse("signature-invalid", `the signature does not verify with ${keyId}`);
90
+ // 3. and 4.
91
+ checkRequested(doc, input.request.version, input.request.channel);
92
+ const statement = checkStatementDoc(doc, input.statement.length);
93
+ // §9: the key was valid when the statement was created, and is still trusted now.
94
+ checkKeyWindow(keyId, key, statement.createdAt, input.now ?? new Date());
95
+ const yank = yankOf(input.yanked, statement.version);
96
+ if (yank) refuse("yanked", `${statement.version} is yanked: ${yank.reason}`);
97
+ // 5.
98
+ if (
99
+ input.manifest.length !== statement.manifest.bytes ||
100
+ (await sha256Hex(input.manifest)) !== statement.manifest.sha256
101
+ )
102
+ refuse("manifest-mismatch", "manifest.json is not the file the statement pins");
103
+ const manifest = checkManifestDoc(
104
+ parseJson(input.manifest, "manifest-invalid"),
105
+ statement,
106
+ input.planVersion,
107
+ );
108
+ // 6.
109
+ await checkTarballDigest(await input.tarball(), statement.tarball);
110
+ const listed = listedParts(manifest);
111
+ const mtime = Date.parse(statement.createdAt) / 1000;
112
+ return {
113
+ statement,
114
+ manifest,
115
+ entries: () => verifiedEntries(input.tarball, listed, mtime),
116
+ };
117
+ }
118
+
119
+ async function checkTarballDigest(
120
+ source ,
121
+ want ,
122
+ ) {
123
+ const h = incrementalSha256();
124
+ let bytes = 0;
125
+ for await (const chunk of chunksOf(source)) {
126
+ bytes += chunk.length;
127
+ if (bytes > want.bytes) refuse("tarball-mismatch", "the tarball is larger than its statement");
128
+ await h.update(chunk);
129
+ }
130
+ if (bytes !== want.bytes || (await h.digest()) !== want.sha256)
131
+ refuse("tarball-mismatch", "the tarball is not the file the statement pins");
132
+ }
133
+
134
+ /**
135
+ * The second pass (step 6's entries), exported for the producer's self-check
136
+ * (scripts/release-bundle/verify.ts); not part of the package's public exports.
137
+ */
138
+ export async function* verifiedEntries(
139
+ open ,
140
+ listed ,
141
+ mtime ,
142
+ ) {
143
+ const left = new Map(listed);
144
+ let previous = "";
145
+ const admit = (tarPath , size ) => {
146
+ checkPath(tarPath, "entry", "tar entry");
147
+ if (tarPath <= previous)
148
+ refuse("tar-format", `${tarPath}: tar entries are not sorted and unique`);
149
+ previous = tarPath;
150
+ const part = left.get(tarPath);
151
+ if (!part) refuse("entry-unlisted", `${tarPath} is in the tarball but not in the manifest`);
152
+ if (size !== part.bytes)
153
+ refuse("entry-mismatch", `${tarPath} is not the size its listing says`);
154
+ };
155
+ for await (const e of readTar(gunzip(await open()), mtime, admit)) {
156
+ const part = left.get(e.path) ;
157
+ if ((await sha256Hex(e.bytes)) !== part.sha256)
158
+ refuse("entry-mismatch", `${e.path} does not match the manifest's SHA-256`);
159
+ left.delete(e.path);
160
+ yield {
161
+ worker: part.worker,
162
+ kind: part.kind,
163
+ path: part.path,
164
+ tarPath: e.path,
165
+ ...(part.type !== undefined ? { type: part.type } : {}),
166
+ bytes: e.bytes,
167
+ };
168
+ }
169
+ const missing = left.keys().next();
170
+ if (!missing.done) refuse("entry-missing", `${missing.value} is listed but not in the tarball`);
171
+ }
@@ -0,0 +1,22 @@
1
+ import type { ReleaseKeys as KeyListDocument } from "./generated/release-keys.js";
2
+ import type { ReleaseYankList } from "./generated/release-yank-list.js";
3
+ export interface YankMemory {
4
+ readonly keyId: string;
5
+ readonly serial: number;
6
+ readonly versions: readonly string[];
7
+ readonly revokedKeys: readonly string[];
8
+ /** `issuedAt` of the newest `keys.json` seen. */
9
+ readonly keysIssuedAt: string;
10
+ /** Yank keys a root list has revoked, or that a newer yank key replaced: never accepted again. */
11
+ readonly retiredYankKeys: readonly string[];
12
+ }
13
+ /** The memory after accepting `yanked` with `keys` (see the file comment). */
14
+ export declare function rememberYanks(yanked: {
15
+ readonly document: ReleaseYankList;
16
+ }, keys: {
17
+ readonly document: KeyListDocument;
18
+ }, previous?: YankMemory): YankMemory;
19
+ /** Refuses a `keys.json` older than the newest one `memory` has seen (a replayed list). */
20
+ export declare function checkKeysMemory(keys: KeyListDocument, memory: YankMemory | undefined): void;
21
+ /** Refuses a yank list (with the key list it verified against) that goes back on `memory`. */
22
+ export declare function checkYankMemory(list: ReleaseYankList, keys: KeyListDocument, memory: YankMemory | undefined): void;
@@ -0,0 +1,106 @@
1
+ /**
2
+ * What a consumer that keeps state remembers of the trust lists it has accepted (RFC-0046 §9), so
3
+ * that neither a stolen yank key nor a replayed older list can take back a yank or a revocation.
4
+ *
5
+ * - The newest `keys.json` seen (`keysIssuedAt`): an older one is refused, so a list from before a
6
+ * revocation cannot bring a revoked yank key back.
7
+ * - Every yank key a root list has revoked or replaced (`retiredYankKeys`): refused forever.
8
+ * - The yank key of the last accepted list (`keyId`), its serial and every yanked version and
9
+ * revoked key: a later list from the same key may not go back in serial or drop an entry.
10
+ * - A list from another yank key starts a new memory (rotation, and how the list is pruned) only
11
+ * when a `keys.json` newer than the remembered one lists that key and no longer lists the old
12
+ * one as active. `verifyKeys` allows at most one active yank key, so two cannot alternate.
13
+ *
14
+ * Store `rememberYanks(yanked, keys, memory)` after every accepted pair.
15
+ */
16
+ import { refuse } from "./errors.js";
17
+
18
+
19
+
20
+
21
+
22
+
23
+
24
+
25
+
26
+
27
+
28
+
29
+
30
+
31
+ const union = (...lists ) => [...new Set(lists.flat())].sort();
32
+
33
+ /** The memory after accepting `yanked` with `keys` (see the file comment). */
34
+ export function rememberYanks(
35
+ yanked ,
36
+ keys ,
37
+ previous ,
38
+ ) {
39
+ const d = yanked.document;
40
+ const revokedByRoot = keys.document.keys
41
+ .filter((k) => k.role === "yank" && k.status === "revoked")
42
+ .map((k) => k.keyId);
43
+ const replaced = previous && previous.keyId !== d.signing.keyId ? [previous.keyId] : [];
44
+ const newerKeys =
45
+ previous && Date.parse(previous.keysIssuedAt) > Date.parse(keys.document.issuedAt)
46
+ ? previous.keysIssuedAt
47
+ : keys.document.issuedAt;
48
+ return {
49
+ keyId: d.signing.keyId,
50
+ serial: d.serial,
51
+ versions: d.versions.map((v) => v.version),
52
+ revokedKeys: d.revokedKeys.map((k) => k.keyId),
53
+ keysIssuedAt: newerKeys,
54
+ retiredYankKeys: union(previous?.retiredYankKeys ?? [], revokedByRoot, replaced),
55
+ };
56
+ }
57
+
58
+ /** Refuses a `keys.json` older than the newest one `memory` has seen (a replayed list). */
59
+ export function checkKeysMemory(keys , memory ) {
60
+ if (memory && Date.parse(keys.issuedAt) < Date.parse(memory.keysIssuedAt))
61
+ refuse(
62
+ "key-list-invalid",
63
+ `keys.json was issued ${keys.issuedAt}, before ${memory.keysIssuedAt} seen before: a replayed key list`,
64
+ );
65
+ }
66
+
67
+ /** Refuses a yank list (with the key list it verified against) that goes back on `memory`. */
68
+ export function checkYankMemory(
69
+ list ,
70
+ keys ,
71
+ memory ,
72
+ ) {
73
+ if (!memory) return;
74
+ checkKeysMemory(keys, memory);
75
+ const signer = list.signing.keyId;
76
+ if (memory.retiredYankKeys.includes(signer))
77
+ refuse(
78
+ "yank-list-invalid",
79
+ `yanked.json is signed by ${signer}, a revoked or replaced yank key`,
80
+ );
81
+ if (memory.keyId !== signer) {
82
+ const old = keys.keys.find((k) => k.keyId === memory.keyId);
83
+ if (!(Date.parse(keys.issuedAt) > Date.parse(memory.keysIssuedAt)) || old?.status === "active")
84
+ refuse(
85
+ "stale-yank-list",
86
+ `yanked.json is signed by ${signer}, not ${memory.keyId}: a new yank key needs a newer keys.json that replaces ${memory.keyId}`,
87
+ );
88
+ return;
89
+ }
90
+ if (list.serial < memory.serial)
91
+ refuse(
92
+ "stale-yank-list",
93
+ `yanked.json has serial ${list.serial}, below ${memory.serial} seen before: a rolled-back list`,
94
+ );
95
+ const versions = new Set(list.versions.map((v) => v.version));
96
+ const revoked = new Set(list.revokedKeys.map((k) => k.keyId));
97
+ const dropped = [
98
+ ...memory.versions.filter((v) => !versions.has(v)),
99
+ ...memory.revokedKeys.filter((k) => !revoked.has(k)),
100
+ ];
101
+ if (dropped.length)
102
+ refuse(
103
+ "stale-yank-list",
104
+ `yanked.json drops ${dropped.join(", ")}, seen before: a yank or revocation is never undone`,
105
+ );
106
+ }