@rightkit/release 0.2.101 → 0.2.102

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.
@@ -302,7 +302,7 @@ export function renderPowerShellBootstrap({
302
302
  "Invoke-WebRequest ($ReleaseBase + '/release-manifest.json') -OutFile $ManifestPath",
303
303
  "Invoke-WebRequest ($ReleaseBase + '/release-manifest.cat') -OutFile $SignaturePath",
304
304
  ]),
305
- "$ManifestBytes = [IO.File]::ReadAllBytes($ManifestPath); Import-Module Microsoft.PowerShell.Security -ErrorAction Stop",
305
+ "$ManifestBytes = [IO.File]::ReadAllBytes($ManifestPath); Import-Module Microsoft.PowerShell.Security -ErrorAction SilentlyContinue",
306
306
  "$Catalog = Test-FileCatalog -CatalogFilePath $SignaturePath -Path $ManifestPath -Detailed; if ([string]$Catalog.Status -ne 'Valid') { throw 'Release manifest catalog binding is invalid' }",
307
307
  "$CatalogSignature = Get-AuthenticodeSignature -FilePath $SignaturePath; if ([string]$CatalogSignature.Status -ne 'Valid' -or -not $CatalogSignature.SignerCertificate) { throw 'Release manifest signature is invalid' }; $SignerCertificate = $CatalogSignature.SignerCertificate",
308
308
  "$SignerSubject = 'CN=' + $SignerCertificate.GetNameInfo([Security.Cryptography.X509Certificates.X509NameType]::SimpleName, $false)",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.2.101",
3
+ "version": "0.2.102",
4
4
  "description": "Portable release CLI/SDK with fail-closed public GitHub Releases and private R2 + RightApps distribution.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "type": "module",
@@ -22,7 +22,7 @@
22
22
  "@rightkit/logs": "0.1.4",
23
23
  "@rightkit/platform-ui": "0.1.1",
24
24
  "@rightkit/qa": "0.2.1",
25
- "@rightkit/release": "0.2.101",
25
+ "@rightkit/release": "0.2.102",
26
26
  "@rightkit/tauri": "0.1.1",
27
27
  "@rightkit/updates": "0.2.4"
28
28
  },
package/verifier.mjs ADDED
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Deterministic release verifier (Gate 6).
4
+ *
5
+ * Three capability grants exist so that no single stage can authorize the next
6
+ * one for itself: BUILD_AUTHORIZED admits compilation, SIGNING_AUTHORIZED
7
+ * admits production signing of exact package digests, and RELEASE_AUTHORIZED
8
+ * admits public promotion of exact qualified bytes.
9
+ *
10
+ * The verifier is a pure function of receipts. It never reads a `decision`
11
+ * field supplied by its caller: an input that already claims AUTHORIZED is
12
+ * rejected outright, because the whole point of the gate is that neither a
13
+ * human nor a model can hand-edit its way to a grant.
14
+ */
15
+ import { createHash } from "node:crypto";
16
+
17
+ export const GRANTS = Object.freeze(["BUILD_AUTHORIZED", "SIGNING_AUTHORIZED", "RELEASE_AUTHORIZED"]);
18
+
19
+ const SHA1_40 = /^[a-f0-9]{40}$/;
20
+ const SHA256_64 = /^[a-f0-9]{64}$/;
21
+
22
+ /** Receipts each grant requires, in the order the pipeline produces them. */
23
+ export const REQUIRED_RECEIPTS = Object.freeze({
24
+ BUILD_AUTHORIZED: Object.freeze([
25
+ "source_sha",
26
+ "verifier_sha",
27
+ "obligation_schema_digest",
28
+ "ci_receipt",
29
+ "static_source_closure_receipt",
30
+ "platform_preflight_receipt",
31
+ ]),
32
+ SIGNING_AUTHORIZED: Object.freeze([
33
+ "build_input_digest",
34
+ "windows_candidate_sha256",
35
+ "macos_candidate_sha256",
36
+ "unsigned_transaction_receipt",
37
+ ]),
38
+ RELEASE_AUTHORIZED: Object.freeze([
39
+ "signing_authorization_receipt",
40
+ "windows_signed_sha256",
41
+ "macos_notarized_sha256",
42
+ "windows_install_receipt",
43
+ "macos_install_receipt",
44
+ "compatibility_matrix_receipt",
45
+ "real_client_contract_receipt",
46
+ "channel_stage_receipt",
47
+ ]),
48
+ });
49
+
50
+ /** Fields that must look like a 64-character lowercase SHA-256. */
51
+ const DIGEST_FIELDS = new Set([
52
+ "obligation_schema_digest",
53
+ "build_input_digest",
54
+ "windows_candidate_sha256",
55
+ "macos_candidate_sha256",
56
+ "windows_signed_sha256",
57
+ "macos_notarized_sha256",
58
+ ]);
59
+
60
+ /** A grant only carries the receipts it and its predecessors require. */
61
+ export function receiptsFor(grant) {
62
+ const order = GRANTS.slice(0, GRANTS.indexOf(grant) + 1);
63
+ return order.flatMap((each) => REQUIRED_RECEIPTS[each]);
64
+ }
65
+
66
+ function receiptIssues(receipts, names) {
67
+ const issues = [];
68
+ for (const name of names) {
69
+ const value = receipts[name];
70
+ if (value === undefined || value === null || value === "") {
71
+ issues.push(`missing receipt: ${name}`);
72
+ continue;
73
+ }
74
+ if (typeof value !== "string") {
75
+ issues.push(`receipt must be a string: ${name}`);
76
+ continue;
77
+ }
78
+ if (name === "source_sha" && !SHA1_40.test(value)) issues.push("source_sha must be a 40-character lowercase commit SHA");
79
+ if (name === "verifier_sha" && !SHA1_40.test(value)) issues.push("verifier_sha must be a 40-character lowercase commit SHA");
80
+ if (DIGEST_FIELDS.has(name) && !SHA256_64.test(value)) issues.push(`${name} must be a 64-character lowercase SHA-256`);
81
+ }
82
+ return issues;
83
+ }
84
+
85
+ /**
86
+ * Computes one grant. Returns a decision object; never throws for an ordinary
87
+ * refusal, because a refusal is a normal, expected outcome that must be
88
+ * reportable rather than exceptional.
89
+ */
90
+ export function computeGrant(grant, input = {}, { now = Date.now(), maxAgeSeconds = 3600 } = {}) {
91
+ if (!GRANTS.includes(grant)) throw new Error(`unknown grant: ${grant}`);
92
+
93
+ const issues = [];
94
+ // A caller-supplied decision is an attempt to skip the gate.
95
+ if (Object.hasOwn(input, "decision")) issues.push("input must not supply a decision; the verifier computes it");
96
+ if (Object.hasOwn(input, "grant")) issues.push("input must not supply a grant; the verifier computes it");
97
+
98
+ const receipts = input.receipts ?? {};
99
+ if (typeof receipts !== "object" || receipts === null || Array.isArray(receipts)) {
100
+ return decision(grant, false, ["receipts must be an object"], input);
101
+ }
102
+ issues.push(...receiptIssues(receipts, receiptsFor(grant)));
103
+
104
+ // Short-lived: a stale authorization must not be replayable against bytes
105
+ // that were rebuilt or re-signed after it was issued.
106
+ const issuedAt = input.issuedAt;
107
+ if (typeof issuedAt !== "string" || Number.isNaN(Date.parse(issuedAt))) {
108
+ issues.push("issuedAt must be an ISO-8601 timestamp");
109
+ } else {
110
+ const ageSeconds = (now - Date.parse(issuedAt)) / 1000;
111
+ if (ageSeconds < 0) issues.push("issuedAt is in the future");
112
+ else if (ageSeconds > maxAgeSeconds) issues.push(`authorization input is stale: ${Math.round(ageSeconds)}s old, limit ${maxAgeSeconds}s`);
113
+ }
114
+
115
+ return decision(grant, issues.length === 0, issues, input);
116
+ }
117
+
118
+ function decision(grant, granted, issues, input) {
119
+ const receipts = input.receipts ?? {};
120
+ const bound = {};
121
+ for (const name of receiptsFor(grant)) if (typeof receipts[name] === "string") bound[name] = receipts[name];
122
+ const body = {
123
+ schemaVersion: 1,
124
+ kind: "rightkit-release-authorization",
125
+ grant,
126
+ decision: granted ? "AUTHORIZED" : "REFUSED",
127
+ issues,
128
+ boundReceipts: bound,
129
+ issuedAt: typeof input.issuedAt === "string" ? input.issuedAt : null,
130
+ };
131
+ // The digest covers the bound receipts, so an authorization cannot be moved
132
+ // onto a different source revision or different artifact bytes.
133
+ body.authorizationDigest = createHash("sha256")
134
+ .update(JSON.stringify({ grant: body.grant, decision: body.decision, boundReceipts: bound, issuedAt: body.issuedAt }))
135
+ .digest("hex");
136
+ return body;
137
+ }
138
+
139
+ const invokedDirectly = process.argv[1] && process.argv[1].endsWith("verifier.mjs");
140
+ if (invokedDirectly) {
141
+ const grant = process.argv[2];
142
+ let input = {};
143
+ try { input = JSON.parse(process.argv[3] ?? "{}"); }
144
+ catch (error) { process.stderr.write(`verifier: input is not JSON: ${error.message}\n`); process.exit(2); }
145
+ const result = computeGrant(grant, input);
146
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
147
+ process.exitCode = result.decision === "AUTHORIZED" ? 0 : 1;
148
+ }