@rightkit/release 0.2.73 → 0.2.75
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.
- package/direct-bootstrap.mjs +16 -16
- package/github-release.mjs +13 -13
- package/package.json +1 -1
- package/rightkit-versions.json +4 -2
- package/sign-release-manifest.mjs +69 -95
package/direct-bootstrap.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
4
|
import { spawnSync } from "node:child_process";
|
|
5
|
-
import { RIGHTRELEASE_MANIFEST_SIGNER,
|
|
5
|
+
import { RIGHTRELEASE_MANIFEST_SIGNER, signReleaseManifestCatalogWithAzure } from "./sign-release-manifest.mjs";
|
|
6
6
|
import { validateCycloneDxSbom, validateInTotoSlsaProvenance } from "./supply-chain-evidence.mjs";
|
|
7
7
|
|
|
8
8
|
const SHA256 = /^[a-f0-9]{64}$/;
|
|
@@ -162,14 +162,14 @@ export function materializeDirectRelease({ outputDir, manifestInput }) {
|
|
|
162
162
|
});
|
|
163
163
|
const manifestText = `${canonicalJson(manifest)}\n`;
|
|
164
164
|
const manifestPath = join(outputDir, "release-manifest.json");
|
|
165
|
-
const
|
|
165
|
+
const catalogPath = join(outputDir, "release-manifest.cat");
|
|
166
166
|
writeFileSync(manifestPath, manifestText);
|
|
167
|
-
const signing =
|
|
168
|
-
if (!statSync(
|
|
167
|
+
const signing = signReleaseManifestCatalogWithAzure({ manifestPath, catalogPath });
|
|
168
|
+
if (!statSync(catalogPath).isFile() || statSync(catalogPath).size < 1) throw new Error("release manifest catalog is missing");
|
|
169
169
|
if (signing?.signer?.id !== RIGHTRELEASE_MANIFEST_SIGNER.id || signing.signer.subject !== RIGHTRELEASE_MANIFEST_SIGNER.subject || !/^[a-f0-9]{64}$/.test(signing.signer.certificateSha256 ?? "")) {
|
|
170
|
-
throw new Error("
|
|
170
|
+
throw new Error("release manifest catalog signer is not RightRelease Azure authority");
|
|
171
171
|
}
|
|
172
|
-
return { manifest, manifestPath, signaturePath, signing, checksums };
|
|
172
|
+
return { manifest, manifestPath, catalogPath, signaturePath: catalogPath, signing, checksums };
|
|
173
173
|
}
|
|
174
174
|
|
|
175
175
|
function psQuote(value) {
|
|
@@ -237,8 +237,7 @@ export function renderPowerShellBootstrap({
|
|
|
237
237
|
};
|
|
238
238
|
// Bind generated contract metadata so a caller cannot pass a marker plus an
|
|
239
239
|
// arbitrary base64 payload and have it treated as a RightRelease bootstrap.
|
|
240
|
-
// This is
|
|
241
|
-
// integrity fence for the executable bootstrap source itself.
|
|
240
|
+
// This is a structural integrity fence for the executable bootstrap source itself.
|
|
242
241
|
contract.contractSha256 = createHash("sha256").update(canonicalJson(contract)).digest("hex");
|
|
243
242
|
const contractB64 = Buffer.from(JSON.stringify(contract)).toString("base64");
|
|
244
243
|
const lines = [
|
|
@@ -267,17 +266,18 @@ export function renderPowerShellBootstrap({
|
|
|
267
266
|
"$ReleaseBase = " + psQuote("https://github.com/" + repository + "/releases/download/v") + " + $Version",
|
|
268
267
|
"$Work = Join-Path ([IO.Path]::GetTempPath()) (" + psQuote(product + "-install-") + " + [guid]::NewGuid())",
|
|
269
268
|
"New-Item -ItemType Directory -Force -Path $Work | Out-Null",
|
|
270
|
-
"$ManifestPath = Join-Path $Work 'release-manifest.json'; $
|
|
269
|
+
"$ManifestPath = Join-Path $Work 'release-manifest.json'; $CatalogPath = Join-Path $Work 'release-manifest.cat'; $ChecksumsPath = Join-Path $Work 'checksums.json'",
|
|
271
270
|
"Invoke-WebRequest ($ReleaseBase + '/release-manifest.json') -OutFile $ManifestPath",
|
|
272
|
-
"Invoke-WebRequest ($ReleaseBase + '/release-manifest.
|
|
273
|
-
"$ManifestBytes = [IO.File]::ReadAllBytes($ManifestPath)
|
|
274
|
-
"
|
|
275
|
-
"
|
|
271
|
+
"Invoke-WebRequest ($ReleaseBase + '/release-manifest.cat') -OutFile $CatalogPath",
|
|
272
|
+
"$ManifestBytes = [IO.File]::ReadAllBytes($ManifestPath)",
|
|
273
|
+
"$CatalogSignature = Get-AuthenticodeSignature -FilePath $CatalogPath; if ($CatalogSignature.Status -ne 'Valid' -or $null -eq $CatalogSignature.SignerCertificate) { throw 'Release manifest catalog signature is invalid' }; $SignerCertificate = $CatalogSignature.SignerCertificate",
|
|
274
|
+
"$ManifestCatalogRoot = Join-Path $Work 'manifest-catalog'; New-Item -ItemType Directory -Force -Path $ManifestCatalogRoot | Out-Null; Copy-Item -LiteralPath $ManifestPath -Destination (Join-Path $ManifestCatalogRoot 'release-manifest.json')",
|
|
275
|
+
"$CatalogValidation = Test-FileCatalog -Path $ManifestCatalogRoot -CatalogFilePath $CatalogPath; if ([string]$CatalogValidation -ne 'Valid') { throw 'Release manifest catalog does not bind exact bytes' }",
|
|
276
276
|
"$Sha256 = [Security.Cryptography.SHA256]::Create(); try { $SignerFingerprint = ([BitConverter]::ToString($Sha256.ComputeHash($SignerCertificate.RawData))).Replace('-','').ToLowerInvariant() } finally { $Sha256.Dispose() }",
|
|
277
277
|
"$VerifiedKeyId = $null; foreach ($KeyId in $AcceptedSigners.Keys) { $Accepted = $AcceptedSigners[$KeyId]; if ($SignerCertificate.Subject -eq [string]$Accepted.subject -and $SignerFingerprint -eq [string]$Accepted.certificateSha256) { $VerifiedKeyId = $KeyId; break } }",
|
|
278
278
|
"if (-not $VerifiedKeyId) { throw 'Release manifest signer is not accepted' }",
|
|
279
279
|
"$Manifest = [Text.Encoding]::UTF8.GetString($ManifestBytes) | ConvertFrom-Json",
|
|
280
|
-
"if ($Manifest.signingKeyId -ne $VerifiedKeyId -or $Manifest.signatureAlgorithm -ne '
|
|
280
|
+
"if ($Manifest.signingKeyId -ne $VerifiedKeyId -or $Manifest.signatureAlgorithm -ne 'authenticode-catalog-sha256') { throw 'Release manifest signer identity mismatch' }",
|
|
281
281
|
"if ($Manifest.product -ne " + psQuote(product) + " -or $Manifest.version -ne $Version -or $Manifest.tag -ne ('v' + $Version)) { throw 'Release identity mismatch' }",
|
|
282
282
|
"if ([version]$Contract.bootstrapVersion -lt [version]$Manifest.minimumBootstrapVersion) { throw 'Bootstrap update required' }",
|
|
283
283
|
"$Machine = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString(); $Target = if ($Machine -eq 'Arm64') { 'windows-arm64' } elseif ($Machine -eq 'X64') { 'windows-x86_64' } else { throw ('Unsupported architecture: ' + $Machine) }",
|
|
@@ -334,9 +334,9 @@ export function renderPowerShellBootstrap({
|
|
|
334
334
|
export function validatePowerShellBootstrap(script, expected = {}) {
|
|
335
335
|
const required = [
|
|
336
336
|
"# rightkit-direct-bootstrap:v1",
|
|
337
|
-
"Release manifest signature is invalid",
|
|
337
|
+
"Release manifest catalog signature is invalid",
|
|
338
338
|
"certificateSha256",
|
|
339
|
-
"
|
|
339
|
+
"Test-FileCatalog",
|
|
340
340
|
"checksums.json is not manifest-bound",
|
|
341
341
|
"Provenance checksum mismatch",
|
|
342
342
|
"SBOM checksum mismatch",
|
package/github-release.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { spawnSync } from "node:child_process";
|
|
|
6
6
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
7
|
import { verifySealedRelease } from "./release-state.mjs";
|
|
8
8
|
import { addonManifestSha256, validateAddonConfig, validateAddonManifest } from "./addon-contract.mjs";
|
|
9
|
-
import { RIGHTRELEASE_MANIFEST_SIGNER,
|
|
9
|
+
import { RIGHTRELEASE_MANIFEST_SIGNER, verifyReleaseManifestCatalog } from "./sign-release-manifest.mjs";
|
|
10
10
|
|
|
11
11
|
const REPO_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
|
12
12
|
const RELEASE_RE = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,159}$/;
|
|
@@ -68,8 +68,8 @@ export function prepareGitHubDirectRelease({
|
|
|
68
68
|
signing,
|
|
69
69
|
signingResult,
|
|
70
70
|
signer,
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
catalogCommandRunner = spawnSync,
|
|
72
|
+
catalogPlatform = process.platform,
|
|
73
73
|
}) {
|
|
74
74
|
if (!REPO_RE.test(repo)) throw new Error(`invalid GitHub repository: ${repo}`);
|
|
75
75
|
const root = path.resolve(repoRoot ?? process.cwd());
|
|
@@ -103,7 +103,7 @@ export function prepareGitHubDirectRelease({
|
|
|
103
103
|
if (unique.has(path.basename(file)) && unique.get(path.basename(file)) !== file) throw new Error(`duplicate direct release payload name: ${path.basename(file)}`);
|
|
104
104
|
unique.set(path.basename(file), file);
|
|
105
105
|
}
|
|
106
|
-
const requiredEvidence = ["release-manifest.json", "release-manifest.
|
|
106
|
+
const requiredEvidence = ["release-manifest.json", "release-manifest.cat", "checksums.json"];
|
|
107
107
|
for (const name of requiredEvidence) if (![...unique.keys()].some((candidate) => candidate === name || candidate.endsWith(`-${name}`))) throw new Error(`direct release payload missing ${name}`);
|
|
108
108
|
for (const name of declared) if (!unique.has(name)) throw new Error(`direct release archive is not attached: ${name}`);
|
|
109
109
|
const releaseState = stateRoot ?? path.join(root, ".right-release", "state", "direct", manifest.product, manifest.version, "github");
|
|
@@ -124,10 +124,10 @@ export function prepareGitHubDirectRelease({
|
|
|
124
124
|
notesFile,
|
|
125
125
|
manifestUrl: `https://github.com/${repo}/releases/download/${manifest.tag}/${path.basename(manifestFile)}`,
|
|
126
126
|
signer: expectedSigner,
|
|
127
|
-
|
|
127
|
+
catalogPlatform,
|
|
128
128
|
};
|
|
129
|
-
Object.defineProperty(plan, "
|
|
130
|
-
validateGitHubDirectReleasePlan(plan, { commandRunner:
|
|
129
|
+
Object.defineProperty(plan, "catalogCommandRunner", { value: catalogCommandRunner, enumerable: false });
|
|
130
|
+
validateGitHubDirectReleasePlan(plan, { commandRunner: catalogCommandRunner, platform: catalogPlatform });
|
|
131
131
|
return plan;
|
|
132
132
|
}
|
|
133
133
|
|
|
@@ -139,7 +139,7 @@ export const prepareGitHubManifestRelease = prepareGitHubDirectRelease;
|
|
|
139
139
|
* signed manifest remains authority; checksums.json is only a digest-bound
|
|
140
140
|
* convenience index for archives and attached provenance/SBOM evidence.
|
|
141
141
|
*/
|
|
142
|
-
export function validateGitHubDirectReleasePlan(plan, { commandRunner = plan?.
|
|
142
|
+
export function validateGitHubDirectReleasePlan(plan, { commandRunner = plan?.catalogCommandRunner ?? spawnSync, platform = plan?.catalogPlatform ?? process.platform } = {}) {
|
|
143
143
|
if (!plan || plan.kind !== "direct-release") throw new Error("invalid direct GitHub release plan");
|
|
144
144
|
if (!plan.manifestPath || !plan.signaturePath || !plan.checksumsPath || !Array.isArray(plan.assets)) throw new Error("direct GitHub release plan is incomplete");
|
|
145
145
|
const manifestFile = path.resolve(plan.manifestPath);
|
|
@@ -148,14 +148,14 @@ export function validateGitHubDirectReleasePlan(plan, { commandRunner = plan?.cm
|
|
|
148
148
|
for (const file of [manifestFile, signatureFile, checksumsFile]) {
|
|
149
149
|
if (!existsSync(file) || !statSync(file).isFile()) throw new Error(`direct release payload missing: ${file}`);
|
|
150
150
|
}
|
|
151
|
-
if (statSync(signatureFile).size < 1) throw new Error("
|
|
151
|
+
if (statSync(signatureFile).size < 1) throw new Error("release manifest catalog is missing");
|
|
152
152
|
const manifest = JSON.parse(readFileSync(manifestFile, "utf8"));
|
|
153
153
|
if (manifest.schemaVersion !== 1 || manifest.kind !== "rightkit-direct-release-manifest" || !STABLE_SEMVER_RE.test(manifest.version ?? "") || manifest.tag !== `v${manifest.version}`) {
|
|
154
154
|
throw new Error("invalid direct release manifest");
|
|
155
155
|
}
|
|
156
156
|
if (plan.tag !== manifest.tag) throw new Error("direct GitHub release tag does not match manifest");
|
|
157
157
|
if (manifest.signingKeyId !== RIGHTRELEASE_MANIFEST_SIGNER.id || manifest.signatureAlgorithm !== RIGHTRELEASE_MANIFEST_SIGNER.algorithm) throw new Error("direct release manifest signer contract is invalid");
|
|
158
|
-
|
|
158
|
+
verifyReleaseManifestCatalog({ manifestPath: manifestFile, catalogPath: signatureFile, expectedSigner: plan.signer, commandRunner, platform });
|
|
159
159
|
if (!SHA256.test(manifest.checksumsSha256 ?? "")) throw new Error("direct release checksums digest is missing");
|
|
160
160
|
if (sha256(checksumsFile) !== manifest.checksumsSha256) throw new Error("checksums.json is not manifest-bound");
|
|
161
161
|
const checksums = JSON.parse(readFileSync(checksumsFile, "utf8"));
|
|
@@ -171,7 +171,7 @@ export function validateGitHubDirectReleasePlan(plan, { commandRunner = plan?.cm
|
|
|
171
171
|
if (payloadByName.has(name) && payloadByName.get(name) !== file) throw new Error(`duplicate direct release payload name: ${name}`);
|
|
172
172
|
payloadByName.set(name, file);
|
|
173
173
|
}
|
|
174
|
-
const requiredEvidence = ["release-manifest.json", "release-manifest.
|
|
174
|
+
const requiredEvidence = ["release-manifest.json", "release-manifest.cat", "checksums.json"];
|
|
175
175
|
for (const name of requiredEvidence) if (![...payloadByName.keys()].some((candidate) => candidate === name || candidate.endsWith(`-${name}`))) throw new Error(`direct release payload missing ${name}`);
|
|
176
176
|
for (const file of [manifestFile, signatureFile, checksumsFile]) if (![...payloadByName.values()].includes(file)) throw new Error(`direct release authority file is not attached: ${path.basename(file)}`);
|
|
177
177
|
const expected = new Map();
|
|
@@ -232,8 +232,8 @@ export function prepareGitHubAddonRelease({ repoRoot, config, configRoot = repoR
|
|
|
232
232
|
};
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
-
export function publishGitHubRelease(plan, { repo, dryRun = false, run = runCommand,
|
|
236
|
-
if (plan.kind === "direct-release") validateGitHubDirectReleasePlan(plan, { commandRunner:
|
|
235
|
+
export function publishGitHubRelease(plan, { repo, dryRun = false, run = runCommand, catalogCommandRunner = plan?.catalogCommandRunner, catalogPlatform = plan?.catalogPlatform ?? process.platform } = {}) {
|
|
236
|
+
if (plan.kind === "direct-release") validateGitHubDirectReleasePlan(plan, { commandRunner: catalogCommandRunner ?? spawnSync, platform: catalogPlatform });
|
|
237
237
|
const visibility = JSON.parse(run("gh", ["repo", "view", repo, "--json", "visibility"]));
|
|
238
238
|
if (visibility.visibility !== "PUBLIC" && !(plan.kind !== "addon" && visibility.visibility === "PRIVATE")) {
|
|
239
239
|
throw new Error(`GitHub releases require a public or private repository: ${repo}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rightkit/release",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.75",
|
|
4
4
|
"description": "Portable Right Suite release CLI/SDK: signed native archives, direct bootstrap transactions, immutable GitHub Release upload, R2 bootstrap publication, and add-on adoption.",
|
|
5
5
|
"license": "MIT OR Apache-2.0",
|
|
6
6
|
"type": "module",
|
package/rightkit-versions.json
CHANGED
|
@@ -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.
|
|
25
|
+
"@rightkit/release": "0.2.75",
|
|
26
26
|
"@rightkit/tauri": "0.1.1",
|
|
27
27
|
"@rightkit/updates": "0.2.4"
|
|
28
28
|
},
|
|
@@ -71,7 +71,9 @@
|
|
|
71
71
|
"0.2.69",
|
|
72
72
|
"0.2.70",
|
|
73
73
|
"0.2.71",
|
|
74
|
-
"0.2.72"
|
|
74
|
+
"0.2.72",
|
|
75
|
+
"0.2.73",
|
|
76
|
+
"0.2.74"
|
|
75
77
|
],
|
|
76
78
|
"@rightkit/qa": [
|
|
77
79
|
"0.1.0",
|
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, mkdtempSync,
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
|
-
import {
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { spawnSync } from "node:child_process";
|
|
5
5
|
import { createHash } from "node:crypto";
|
|
6
6
|
|
|
7
7
|
export const RIGHTRELEASE_MANIFEST_SIGNER = Object.freeze({
|
|
8
8
|
id: "azure-artifact-signing-damned-ventures-v1",
|
|
9
|
-
subject: "CN=Damned Ventures LLC",
|
|
10
|
-
algorithm: "
|
|
9
|
+
subject: "CN=Damned Ventures LLC, O=Damned Ventures LLC, L=new york, S=New York, C=US",
|
|
10
|
+
algorithm: "authenticode-catalog-sha256",
|
|
11
|
+
provider: "windows-authenticode-catalog",
|
|
12
|
+
providerVersion: 1,
|
|
11
13
|
});
|
|
12
14
|
|
|
13
15
|
const EXCLUDE_CREDENTIALS = [
|
|
@@ -23,41 +25,33 @@ const EXCLUDE_CREDENTIALS = [
|
|
|
23
25
|
|
|
24
26
|
const CERTIFICATE_SHA256 = /^[a-f0-9]{64}$/i;
|
|
25
27
|
|
|
26
|
-
|
|
27
|
-
* Verify detached CMS over exact manifest bytes & bind signer identity to the
|
|
28
|
-
* shared Azure Artifact Signing result. `commandRunner` is an injectable
|
|
29
|
-
* system-process seam; it cannot replace CMS verification because production
|
|
30
|
-
* verification script/arguments are always issued here.
|
|
31
|
-
*/
|
|
32
|
-
export function verifyDetachedCmsSignature({
|
|
28
|
+
export function verifyReleaseManifestCatalog({
|
|
33
29
|
manifestPath,
|
|
34
|
-
|
|
30
|
+
catalogPath,
|
|
35
31
|
expectedSigner,
|
|
36
32
|
commandRunner = spawnSync,
|
|
37
33
|
platform = process.platform,
|
|
38
34
|
powershellPath = "powershell",
|
|
39
|
-
opensslPath = "openssl",
|
|
40
35
|
}) {
|
|
41
36
|
const manifest = resolve(manifestPath);
|
|
42
|
-
const
|
|
43
|
-
if (
|
|
44
|
-
if (!existsSync(
|
|
37
|
+
const catalog = resolve(catalogPath);
|
|
38
|
+
if (platform !== "win32") throw new Error("Authenticode catalog verification requires Windows");
|
|
39
|
+
if (!existsSync(manifest) || !statSync(manifest).isFile()) throw new Error("catalog manifest bytes are missing");
|
|
40
|
+
if (!existsSync(catalog) || !statSync(catalog).isFile() || statSync(catalog).size < 1) throw new Error("release manifest catalog is missing");
|
|
45
41
|
if (!expectedSigner || expectedSigner.id !== RIGHTRELEASE_MANIFEST_SIGNER.id || expectedSigner.subject !== RIGHTRELEASE_MANIFEST_SIGNER.subject || expectedSigner.algorithm !== RIGHTRELEASE_MANIFEST_SIGNER.algorithm || !CERTIFICATE_SHA256.test(expectedSigner.certificateSha256 ?? "")) {
|
|
46
|
-
throw new Error("
|
|
42
|
+
throw new Error("catalog verification requires exact Azure signer identity");
|
|
47
43
|
}
|
|
48
|
-
const inspection =
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if (!inspection.verified) throw new Error("detached CMS signature is invalid");
|
|
52
|
-
if (normalizeSubject(inspection.subject) !== normalizeSubject(expectedSigner.subject)) throw new Error("CMS signer subject mismatch");
|
|
44
|
+
const inspection = verifyCatalogWithPowerShell({ manifestPath: manifest, catalogPath: catalog, commandRunner, powershellPath });
|
|
45
|
+
if (!inspection.verified) throw new Error("release manifest catalog is invalid");
|
|
46
|
+
if (normalizeSubject(inspection.subject) !== normalizeSubject(expectedSigner.subject)) throw new Error("catalog signer subject mismatch");
|
|
53
47
|
const certificateSha256 = String(inspection.certificateSha256 ?? "").replaceAll(":", "").toLowerCase();
|
|
54
|
-
if (!CERTIFICATE_SHA256.test(certificateSha256) || certificateSha256 !== String(expectedSigner.certificateSha256).toLowerCase()) throw new Error("
|
|
55
|
-
return { verified: true, manifestPath: manifest,
|
|
48
|
+
if (!CERTIFICATE_SHA256.test(certificateSha256) || certificateSha256 !== String(expectedSigner.certificateSha256).toLowerCase()) throw new Error("catalog signer certificate fingerprint mismatch");
|
|
49
|
+
return { verified: true, manifestPath: manifest, catalogPath: catalog, signer: { ...expectedSigner, certificateSha256 } };
|
|
56
50
|
}
|
|
57
51
|
|
|
58
|
-
export function
|
|
52
|
+
export function signReleaseManifestCatalogWithAzure({
|
|
59
53
|
manifestPath,
|
|
60
|
-
|
|
54
|
+
catalogPath,
|
|
61
55
|
commandRunner = spawnSync,
|
|
62
56
|
environment = process.env,
|
|
63
57
|
signtoolPath,
|
|
@@ -84,98 +78,70 @@ export function signReleaseManifestWithAzure({
|
|
|
84
78
|
]);
|
|
85
79
|
if (!dlib) throw new Error("RightRelease manifest signer could not find Azure Artifact Signing dlib");
|
|
86
80
|
|
|
87
|
-
const temp = mkdtempSync(join(tmpdir(), "rightrelease-manifest-
|
|
81
|
+
const temp = mkdtempSync(join(tmpdir(), "rightrelease-manifest-catalog-"));
|
|
82
|
+
const powershellEnvironment = windowsPowerShellEnvironment(environment);
|
|
88
83
|
const metadata = metadataPath || materializeMetadata(temp, env);
|
|
89
|
-
const
|
|
90
|
-
|
|
84
|
+
const catalogSource = join(temp, "catalog-source");
|
|
85
|
+
const unsignedCatalog = join(temp, "release-manifest.cat");
|
|
86
|
+
mkdirSync(catalogSource, { recursive: true });
|
|
87
|
+
copyFileSync(resolve(manifestPath), join(catalogSource, "release-manifest.json"));
|
|
91
88
|
try {
|
|
89
|
+
const materializer = join(temp, "new-catalog.ps1");
|
|
90
|
+
writeFileSync(materializer, [
|
|
91
|
+
"param([string]$SourcePath, [string]$CatalogPath)",
|
|
92
|
+
"Import-Module Microsoft.PowerShell.Security -ErrorAction Stop",
|
|
93
|
+
"$Result = New-FileCatalog -Path $SourcePath -CatalogFilePath $CatalogPath -CatalogVersion 2.0",
|
|
94
|
+
"if (-not (Test-Path $Result)) { throw 'Manifest catalog was not created' }",
|
|
95
|
+
].join("\r\n") + "\r\n");
|
|
96
|
+
const created = commandRunner("powershell", ["-NoProfile", "-File", materializer, catalogSource, unsignedCatalog], { encoding: "utf8", windowsHide: true, env: powershellEnvironment });
|
|
97
|
+
if (created?.status !== 0 || !existsSync(unsignedCatalog)) throw new Error(`RightRelease manifest catalog creation failed: ${String(created?.stderr ?? "").trim()}`);
|
|
92
98
|
const result = commandRunner(signtool, [
|
|
93
99
|
"sign", "/v", "/fd", "SHA256", "/tr", "http://timestamp.acs.microsoft.com", "/td", "SHA256",
|
|
94
100
|
"/dlib", dlib, "/dmdf", metadata,
|
|
95
|
-
|
|
96
|
-
resolve(manifestPath),
|
|
101
|
+
unsignedCatalog,
|
|
97
102
|
], { encoding: "utf8", windowsHide: true, env: environment });
|
|
98
|
-
if (result?.status !== 0) throw new Error(`RightRelease manifest signing failed: ${String(result?.stderr ?? "").trim()}`);
|
|
99
|
-
const
|
|
100
|
-
if (!
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
"Add-Type -AssemblyName System.Security",
|
|
105
|
-
"$Content = New-Object Security.Cryptography.Pkcs.ContentInfo(,[IO.File]::ReadAllBytes($ManifestPath))",
|
|
106
|
-
"$Cms = New-Object Security.Cryptography.Pkcs.SignedCms($Content, $true)",
|
|
107
|
-
"$Cms.Decode([IO.File]::ReadAllBytes($SignaturePath))",
|
|
108
|
-
"$Cms.CheckSignature($true)",
|
|
109
|
-
"if ($Cms.SignerInfos.Count -ne 1) { throw 'Expected exactly one manifest signer' }",
|
|
110
|
-
"$Certificate = $Cms.SignerInfos[0].Certificate",
|
|
111
|
-
"$Sha256 = [Security.Cryptography.SHA256]::Create()",
|
|
112
|
-
"try { $Fingerprint = ([BitConverter]::ToString($Sha256.ComputeHash($Certificate.RawData))).Replace('-','').ToLowerInvariant() } finally { $Sha256.Dispose() }",
|
|
113
|
-
"@{ subject=$Certificate.Subject; certificateSha256=$Fingerprint } | ConvertTo-Json -Compress",
|
|
114
|
-
].join("\r\n") + "\r\n");
|
|
115
|
-
const inspection = commandRunner("powershell", ["-NoProfile", "-File", inspector, resolve(manifestPath), generated], { encoding: "utf8", windowsHide: true, env: environment });
|
|
116
|
-
if (inspection?.status !== 0) throw new Error(`RightRelease could not inspect manifest signer: ${String(inspection?.stderr ?? "").trim()}`);
|
|
117
|
-
let certificate;
|
|
118
|
-
try { certificate = JSON.parse(String(inspection.stdout ?? "").trim()); } catch { throw new Error("RightRelease manifest signer inspection returned invalid JSON"); }
|
|
119
|
-
if (certificate.subject !== RIGHTRELEASE_MANIFEST_SIGNER.subject || !/^[a-f0-9]{64}$/.test(certificate.certificateSha256 ?? "")) {
|
|
120
|
-
throw new Error("RightRelease manifest signer identity does not match protected Azure profile");
|
|
121
|
-
}
|
|
122
|
-
mkdirSync(dirname(signaturePath), { recursive: true });
|
|
123
|
-
writeFileSync(signaturePath, readFileSync(generated));
|
|
124
|
-
return { signaturePath: resolve(signaturePath), signer: { ...RIGHTRELEASE_MANIFEST_SIGNER, certificateSha256: certificate.certificateSha256 } };
|
|
103
|
+
if (result?.status !== 0) throw new Error(`RightRelease manifest catalog signing failed: ${String(result?.stderr ?? "").trim()}`);
|
|
104
|
+
const inspection = verifyCatalogWithPowerShell({ manifestPath: resolve(manifestPath), catalogPath: unsignedCatalog, commandRunner, powershellPath: "powershell", environment: powershellEnvironment });
|
|
105
|
+
if (normalizeSubject(inspection.subject) !== normalizeSubject(RIGHTRELEASE_MANIFEST_SIGNER.subject) || !CERTIFICATE_SHA256.test(inspection.certificateSha256 ?? "")) throw new Error(`RightRelease catalog signer identity does not match protected Azure profile: ${inspection.subject}`);
|
|
106
|
+
mkdirSync(dirname(catalogPath), { recursive: true });
|
|
107
|
+
copyFileSync(unsignedCatalog, catalogPath);
|
|
108
|
+
return { catalogPath: resolve(catalogPath), signer: { ...RIGHTRELEASE_MANIFEST_SIGNER, certificateSha256: inspection.certificateSha256.toLowerCase() } };
|
|
125
109
|
} finally {
|
|
126
110
|
rmSync(temp, { recursive: true, force: true });
|
|
127
111
|
}
|
|
128
112
|
}
|
|
129
113
|
|
|
130
|
-
function
|
|
131
|
-
const temp = mkdtempSync(join(tmpdir(), "rightrelease-
|
|
132
|
-
const
|
|
114
|
+
function verifyCatalogWithPowerShell({ manifestPath, catalogPath, commandRunner, powershellPath, environment = process.env }) {
|
|
115
|
+
const temp = mkdtempSync(join(tmpdir(), "rightrelease-catalog-verify-"));
|
|
116
|
+
const source = join(temp, "catalog-source");
|
|
117
|
+
mkdirSync(source, { recursive: true });
|
|
118
|
+
copyFileSync(manifestPath, join(source, "release-manifest.json"));
|
|
119
|
+
const verifier = join(temp, "verify-catalog.ps1");
|
|
133
120
|
writeFileSync(verifier, [
|
|
134
|
-
"param([string]$
|
|
135
|
-
"
|
|
136
|
-
"$
|
|
137
|
-
"$
|
|
138
|
-
"$
|
|
139
|
-
"
|
|
140
|
-
"
|
|
141
|
-
"$Certificate = $Cms.SignerInfos[0].Certificate",
|
|
121
|
+
"param([string]$SourcePath, [string]$CatalogPath)",
|
|
122
|
+
"Import-Module Microsoft.PowerShell.Security -ErrorAction Stop",
|
|
123
|
+
"$Signature = Get-AuthenticodeSignature -FilePath $CatalogPath",
|
|
124
|
+
"if ($Signature.Status -ne 'Valid' -or $null -eq $Signature.SignerCertificate) { throw 'Catalog Authenticode signature is invalid' }",
|
|
125
|
+
"$Validation = Test-FileCatalog -Path $SourcePath -CatalogFilePath $CatalogPath",
|
|
126
|
+
"if ([string]$Validation -ne 'Valid') { throw 'Catalog does not bind exact manifest bytes' }",
|
|
127
|
+
"$Certificate = $Signature.SignerCertificate",
|
|
142
128
|
"$Sha256 = [Security.Cryptography.SHA256]::Create()",
|
|
143
129
|
"try { $Fingerprint = ([BitConverter]::ToString($Sha256.ComputeHash($Certificate.RawData))).Replace('-','').ToLowerInvariant() } finally { $Sha256.Dispose() }",
|
|
144
130
|
"@{ verified=$true; subject=$Certificate.Subject; certificateSha256=$Fingerprint } | ConvertTo-Json -Compress",
|
|
145
131
|
].join("\r\n") + "\r\n");
|
|
146
132
|
try {
|
|
147
|
-
const result = commandRunner(powershellPath, ["-NoProfile", "-File", verifier,
|
|
148
|
-
if (result?.status !== 0) throw new Error(`
|
|
149
|
-
return
|
|
133
|
+
const result = commandRunner(powershellPath, ["-NoProfile", "-File", verifier, source, catalogPath], { encoding: "utf8", windowsHide: true, env: windowsPowerShellEnvironment(environment) });
|
|
134
|
+
if (result?.status !== 0) throw new Error(`catalog verification failed: ${String(result?.stderr ?? "").trim()}`);
|
|
135
|
+
return parseCatalogInspection(result?.stdout);
|
|
150
136
|
} finally {
|
|
151
137
|
rmSync(temp, { recursive: true, force: true });
|
|
152
138
|
}
|
|
153
139
|
}
|
|
154
140
|
|
|
155
|
-
function
|
|
156
|
-
const temp = mkdtempSync(join(tmpdir(), "rightrelease-cms-verify-"));
|
|
157
|
-
const verified = join(temp, "verified-manifest");
|
|
158
|
-
const certificate = join(temp, "signer.pem");
|
|
159
|
-
try {
|
|
160
|
-
const result = commandRunner(opensslPath, ["cms", "-verify", "-binary", "-inform", "DER", "-in", signaturePath, "-content", manifestPath, "-noverify", "-certsout", certificate, "-out", verified], { encoding: "utf8", windowsHide: true });
|
|
161
|
-
if (result?.status !== 0) throw new Error(`CMS signature verification failed: ${String(result?.stderr ?? "").trim()}`);
|
|
162
|
-
if (!existsSync(verified) || !readFileSync(verified).equals(readFileSync(manifestPath))) throw new Error("CMS signature content does not match manifest bytes");
|
|
163
|
-
if (!existsSync(certificate)) throw new Error("CMS signer certificate is missing");
|
|
164
|
-
const inspection = commandRunner(opensslPath, ["x509", "-in", certificate, "-noout", "-subject", "-fingerprint", "-sha256"], { encoding: "utf8", windowsHide: true });
|
|
165
|
-
if (inspection?.status !== 0) throw new Error(`CMS signer inspection failed: ${String(inspection?.stderr ?? "").trim()}`);
|
|
166
|
-
const output = String(inspection?.stdout ?? "");
|
|
167
|
-
const subject = output.match(/^\s*subject\s*=\s*(.+)$/im)?.[1]?.trim();
|
|
168
|
-
const certificateSha256 = output.match(/^\s*sha256\s+Fingerprint\s*=\s*([0-9a-f:]+)\s*$/im)?.[1];
|
|
169
|
-
return parseCmsInspection(JSON.stringify({ verified: true, subject, certificateSha256 }));
|
|
170
|
-
} finally {
|
|
171
|
-
rmSync(temp, { recursive: true, force: true });
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
function parseCmsInspection(output) {
|
|
141
|
+
function parseCatalogInspection(output) {
|
|
176
142
|
let inspection;
|
|
177
|
-
try { inspection = JSON.parse(String(output ?? "").trim()); } catch { throw new Error("
|
|
178
|
-
if (inspection.verified !== true || typeof inspection.subject !== "string" || typeof inspection.certificateSha256 !== "string") throw new Error("
|
|
143
|
+
try { inspection = JSON.parse(String(output ?? "").trim()); } catch { throw new Error("catalog verifier returned invalid JSON"); }
|
|
144
|
+
if (inspection.verified !== true || typeof inspection.subject !== "string" || typeof inspection.certificateSha256 !== "string") throw new Error("catalog verifier returned incomplete identity");
|
|
179
145
|
return inspection;
|
|
180
146
|
}
|
|
181
147
|
|
|
@@ -183,6 +149,14 @@ function normalizeSubject(subject) {
|
|
|
183
149
|
return String(subject).trim().replace(/\s*=\s*/g, "=").replace(/\s*,\s*/g, ",");
|
|
184
150
|
}
|
|
185
151
|
|
|
152
|
+
function windowsPowerShellEnvironment(environment) {
|
|
153
|
+
const sanitized = { ...environment };
|
|
154
|
+
for (const key of Object.keys(sanitized)) {
|
|
155
|
+
if (key.toLowerCase() === "psmodulepath") delete sanitized[key];
|
|
156
|
+
}
|
|
157
|
+
return sanitized;
|
|
158
|
+
}
|
|
159
|
+
|
|
186
160
|
function materializeMetadata(dir, env) {
|
|
187
161
|
const existing = env("AZURE_ARTIFACT_SIGNING_METADATA") || env("AZURE_SIGNING_METADATA");
|
|
188
162
|
if (existing) {
|