@rightkit/release 0.2.74 → 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 -20
- package/github-release.mjs +13 -17
- package/package.json +1 -2
- package/rightkit-versions.json +3 -2
- package/sign-release-manifest.mjs +82 -56
- package/sign-release-manifest.integration.mjs +0 -27
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}$/;
|
|
@@ -145,8 +145,6 @@ export function buildReleaseManifest({
|
|
|
145
145
|
minimumBootstrapVersion,
|
|
146
146
|
signingKeyId: RIGHTRELEASE_MANIFEST_SIGNER.id,
|
|
147
147
|
signatureAlgorithm: RIGHTRELEASE_MANIFEST_SIGNER.algorithm,
|
|
148
|
-
signatureProvider: RIGHTRELEASE_MANIFEST_SIGNER.provider,
|
|
149
|
-
signatureProviderVersion: RIGHTRELEASE_MANIFEST_SIGNER.providerVersion,
|
|
150
148
|
checksumsSha256: checksumsDigest,
|
|
151
149
|
assets: normalizedAssets,
|
|
152
150
|
};
|
|
@@ -164,14 +162,14 @@ export function materializeDirectRelease({ outputDir, manifestInput }) {
|
|
|
164
162
|
});
|
|
165
163
|
const manifestText = `${canonicalJson(manifest)}\n`;
|
|
166
164
|
const manifestPath = join(outputDir, "release-manifest.json");
|
|
167
|
-
const
|
|
165
|
+
const catalogPath = join(outputDir, "release-manifest.cat");
|
|
168
166
|
writeFileSync(manifestPath, manifestText);
|
|
169
|
-
const signing =
|
|
170
|
-
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");
|
|
171
169
|
if (signing?.signer?.id !== RIGHTRELEASE_MANIFEST_SIGNER.id || signing.signer.subject !== RIGHTRELEASE_MANIFEST_SIGNER.subject || !/^[a-f0-9]{64}$/.test(signing.signer.certificateSha256 ?? "")) {
|
|
172
|
-
throw new Error("
|
|
170
|
+
throw new Error("release manifest catalog signer is not RightRelease Azure authority");
|
|
173
171
|
}
|
|
174
|
-
return { manifest, manifestPath, signaturePath, signing, checksums };
|
|
172
|
+
return { manifest, manifestPath, catalogPath, signaturePath: catalogPath, signing, checksums };
|
|
175
173
|
}
|
|
176
174
|
|
|
177
175
|
function psQuote(value) {
|
|
@@ -239,8 +237,7 @@ export function renderPowerShellBootstrap({
|
|
|
239
237
|
};
|
|
240
238
|
// Bind generated contract metadata so a caller cannot pass a marker plus an
|
|
241
239
|
// arbitrary base64 payload and have it treated as a RightRelease bootstrap.
|
|
242
|
-
// This is
|
|
243
|
-
// integrity fence for the executable bootstrap source itself.
|
|
240
|
+
// This is a structural integrity fence for the executable bootstrap source itself.
|
|
244
241
|
contract.contractSha256 = createHash("sha256").update(canonicalJson(contract)).digest("hex");
|
|
245
242
|
const contractB64 = Buffer.from(JSON.stringify(contract)).toString("base64");
|
|
246
243
|
const lines = [
|
|
@@ -269,18 +266,18 @@ export function renderPowerShellBootstrap({
|
|
|
269
266
|
"$ReleaseBase = " + psQuote("https://github.com/" + repository + "/releases/download/v") + " + $Version",
|
|
270
267
|
"$Work = Join-Path ([IO.Path]::GetTempPath()) (" + psQuote(product + "-install-") + " + [guid]::NewGuid())",
|
|
271
268
|
"New-Item -ItemType Directory -Force -Path $Work | Out-Null",
|
|
272
|
-
"$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'",
|
|
273
270
|
"Invoke-WebRequest ($ReleaseBase + '/release-manifest.json') -OutFile $ManifestPath",
|
|
274
|
-
"Invoke-WebRequest ($ReleaseBase + '/release-manifest.cat') -OutFile $
|
|
275
|
-
"$ManifestBytes = [IO.File]::ReadAllBytes($ManifestPath)
|
|
276
|
-
"$
|
|
277
|
-
"$
|
|
278
|
-
"$
|
|
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' }",
|
|
279
276
|
"$Sha256 = [Security.Cryptography.SHA256]::Create(); try { $SignerFingerprint = ([BitConverter]::ToString($Sha256.ComputeHash($SignerCertificate.RawData))).Replace('-','').ToLowerInvariant() } finally { $Sha256.Dispose() }",
|
|
280
|
-
"$VerifiedKeyId = $null; foreach ($KeyId in $AcceptedSigners.Keys) { $Accepted = $AcceptedSigners[$KeyId]; if ($
|
|
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 } }",
|
|
281
278
|
"if (-not $VerifiedKeyId) { throw 'Release manifest signer is not accepted' }",
|
|
282
279
|
"$Manifest = [Text.Encoding]::UTF8.GetString($ManifestBytes) | ConvertFrom-Json",
|
|
283
|
-
"if ($Manifest.signingKeyId -ne $VerifiedKeyId -or $Manifest.signatureAlgorithm -ne 'authenticode-catalog-sha256'
|
|
280
|
+
"if ($Manifest.signingKeyId -ne $VerifiedKeyId -or $Manifest.signatureAlgorithm -ne 'authenticode-catalog-sha256') { throw 'Release manifest signer identity mismatch' }",
|
|
284
281
|
"if ($Manifest.product -ne " + psQuote(product) + " -or $Manifest.version -ne $Version -or $Manifest.tag -ne ('v' + $Version)) { throw 'Release identity mismatch' }",
|
|
285
282
|
"if ([version]$Contract.bootstrapVersion -lt [version]$Manifest.minimumBootstrapVersion) { throw 'Bootstrap update required' }",
|
|
286
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) }",
|
|
@@ -337,10 +334,9 @@ export function renderPowerShellBootstrap({
|
|
|
337
334
|
export function validatePowerShellBootstrap(script, expected = {}) {
|
|
338
335
|
const required = [
|
|
339
336
|
"# rightkit-direct-bootstrap:v1",
|
|
340
|
-
"Release manifest signature is invalid",
|
|
337
|
+
"Release manifest catalog signature is invalid",
|
|
341
338
|
"certificateSha256",
|
|
342
339
|
"Test-FileCatalog",
|
|
343
|
-
"Get-AuthenticodeSignature",
|
|
344
340
|
"checksums.json is not manifest-bound",
|
|
345
341
|
"Provenance checksum mismatch",
|
|
346
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,13 +68,9 @@ export function prepareGitHubDirectRelease({
|
|
|
68
68
|
signing,
|
|
69
69
|
signingResult,
|
|
70
70
|
signer,
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
cmsCommandRunner,
|
|
74
|
-
cmsPlatform,
|
|
71
|
+
catalogCommandRunner = spawnSync,
|
|
72
|
+
catalogPlatform = process.platform,
|
|
75
73
|
}) {
|
|
76
|
-
const effectiveSignatureRunner = signatureCommandRunner ?? cmsCommandRunner ?? spawnSync;
|
|
77
|
-
const effectiveSignaturePlatform = signaturePlatform ?? cmsPlatform ?? process.platform;
|
|
78
74
|
if (!REPO_RE.test(repo)) throw new Error(`invalid GitHub repository: ${repo}`);
|
|
79
75
|
const root = path.resolve(repoRoot ?? process.cwd());
|
|
80
76
|
const manifestFile = path.resolve(root, manifestPath);
|
|
@@ -88,7 +84,7 @@ export function prepareGitHubDirectRelease({
|
|
|
88
84
|
if (product && manifest.product !== product) throw new Error("direct release product mismatch");
|
|
89
85
|
if (version && manifest.version !== version) throw new Error("direct release version mismatch");
|
|
90
86
|
if (!STABLE_SEMVER_RE.test(manifest.version) || manifest.tag !== `v${manifest.version}`) throw new Error("direct release manifest tag is invalid stable SemVer");
|
|
91
|
-
if (manifest.signingKeyId !== RIGHTRELEASE_MANIFEST_SIGNER.id || manifest.signatureAlgorithm !== RIGHTRELEASE_MANIFEST_SIGNER.algorithm
|
|
87
|
+
if (manifest.signingKeyId !== RIGHTRELEASE_MANIFEST_SIGNER.id || manifest.signatureAlgorithm !== RIGHTRELEASE_MANIFEST_SIGNER.algorithm) throw new Error("direct release manifest signer contract is invalid");
|
|
92
88
|
const declared = new Set((manifest.assets ?? []).map((asset) => asset.name));
|
|
93
89
|
if (!declared.size) throw new Error("direct release manifest has no archive assets");
|
|
94
90
|
if ([...declared].some((name) => /(?:installer|setup)\.(?:exe|dmg|pkg|msi)$/i.test(name))) throw new Error("direct release must not contain installer assets");
|
|
@@ -128,10 +124,10 @@ export function prepareGitHubDirectRelease({
|
|
|
128
124
|
notesFile,
|
|
129
125
|
manifestUrl: `https://github.com/${repo}/releases/download/${manifest.tag}/${path.basename(manifestFile)}`,
|
|
130
126
|
signer: expectedSigner,
|
|
131
|
-
|
|
127
|
+
catalogPlatform,
|
|
132
128
|
};
|
|
133
|
-
Object.defineProperty(plan, "
|
|
134
|
-
validateGitHubDirectReleasePlan(plan, { commandRunner:
|
|
129
|
+
Object.defineProperty(plan, "catalogCommandRunner", { value: catalogCommandRunner, enumerable: false });
|
|
130
|
+
validateGitHubDirectReleasePlan(plan, { commandRunner: catalogCommandRunner, platform: catalogPlatform });
|
|
135
131
|
return plan;
|
|
136
132
|
}
|
|
137
133
|
|
|
@@ -143,7 +139,7 @@ export const prepareGitHubManifestRelease = prepareGitHubDirectRelease;
|
|
|
143
139
|
* signed manifest remains authority; checksums.json is only a digest-bound
|
|
144
140
|
* convenience index for archives and attached provenance/SBOM evidence.
|
|
145
141
|
*/
|
|
146
|
-
export function validateGitHubDirectReleasePlan(plan, { commandRunner = plan?.
|
|
142
|
+
export function validateGitHubDirectReleasePlan(plan, { commandRunner = plan?.catalogCommandRunner ?? spawnSync, platform = plan?.catalogPlatform ?? process.platform } = {}) {
|
|
147
143
|
if (!plan || plan.kind !== "direct-release") throw new Error("invalid direct GitHub release plan");
|
|
148
144
|
if (!plan.manifestPath || !plan.signaturePath || !plan.checksumsPath || !Array.isArray(plan.assets)) throw new Error("direct GitHub release plan is incomplete");
|
|
149
145
|
const manifestFile = path.resolve(plan.manifestPath);
|
|
@@ -152,14 +148,14 @@ export function validateGitHubDirectReleasePlan(plan, { commandRunner = plan?.si
|
|
|
152
148
|
for (const file of [manifestFile, signatureFile, checksumsFile]) {
|
|
153
149
|
if (!existsSync(file) || !statSync(file).isFile()) throw new Error(`direct release payload missing: ${file}`);
|
|
154
150
|
}
|
|
155
|
-
if (statSync(signatureFile).size < 1) throw new Error("
|
|
151
|
+
if (statSync(signatureFile).size < 1) throw new Error("release manifest catalog is missing");
|
|
156
152
|
const manifest = JSON.parse(readFileSync(manifestFile, "utf8"));
|
|
157
153
|
if (manifest.schemaVersion !== 1 || manifest.kind !== "rightkit-direct-release-manifest" || !STABLE_SEMVER_RE.test(manifest.version ?? "") || manifest.tag !== `v${manifest.version}`) {
|
|
158
154
|
throw new Error("invalid direct release manifest");
|
|
159
155
|
}
|
|
160
156
|
if (plan.tag !== manifest.tag) throw new Error("direct GitHub release tag does not match manifest");
|
|
161
|
-
if (manifest.signingKeyId !== RIGHTRELEASE_MANIFEST_SIGNER.id || manifest.signatureAlgorithm !== RIGHTRELEASE_MANIFEST_SIGNER.algorithm
|
|
162
|
-
|
|
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
|
+
verifyReleaseManifestCatalog({ manifestPath: manifestFile, catalogPath: signatureFile, expectedSigner: plan.signer, commandRunner, platform });
|
|
163
159
|
if (!SHA256.test(manifest.checksumsSha256 ?? "")) throw new Error("direct release checksums digest is missing");
|
|
164
160
|
if (sha256(checksumsFile) !== manifest.checksumsSha256) throw new Error("checksums.json is not manifest-bound");
|
|
165
161
|
const checksums = JSON.parse(readFileSync(checksumsFile, "utf8"));
|
|
@@ -236,8 +232,8 @@ export function prepareGitHubAddonRelease({ repoRoot, config, configRoot = repoR
|
|
|
236
232
|
};
|
|
237
233
|
}
|
|
238
234
|
|
|
239
|
-
export function publishGitHubRelease(plan, { repo, dryRun = false, run = runCommand,
|
|
240
|
-
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 });
|
|
241
237
|
const visibility = JSON.parse(run("gh", ["repo", "view", repo, "--json", "visibility"]));
|
|
242
238
|
if (visibility.visibility !== "PUBLIC" && !(plan.kind !== "addon" && visibility.visibility === "PRIVATE")) {
|
|
243
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",
|
|
@@ -29,7 +29,6 @@
|
|
|
29
29
|
"scripts": {
|
|
30
30
|
"test": "node --test --test-concurrency=1 --test-force-exit *.test.mjs",
|
|
31
31
|
"test:registry-parity": "node registry-parity.mjs --allow-unpublished",
|
|
32
|
-
"test:signer:windows": "node sign-release-manifest.integration.mjs",
|
|
33
32
|
"doctor:all": "node --test right-suite-contract.test.mjs",
|
|
34
33
|
"verify:standalone": "node standalone-clone-verify.mjs"
|
|
35
34
|
}
|
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
|
},
|
|
@@ -72,7 +72,8 @@
|
|
|
72
72
|
"0.2.70",
|
|
73
73
|
"0.2.71",
|
|
74
74
|
"0.2.72",
|
|
75
|
-
"0.2.73"
|
|
75
|
+
"0.2.73",
|
|
76
|
+
"0.2.74"
|
|
76
77
|
],
|
|
77
78
|
"@rightkit/qa": [
|
|
78
79
|
"0.1.0",
|
|
@@ -2,10 +2,11 @@ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync,
|
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
5
6
|
|
|
6
7
|
export const RIGHTRELEASE_MANIFEST_SIGNER = Object.freeze({
|
|
7
8
|
id: "azure-artifact-signing-damned-ventures-v1",
|
|
8
|
-
subject: "CN=Damned Ventures LLC",
|
|
9
|
+
subject: "CN=Damned Ventures LLC, O=Damned Ventures LLC, L=new york, S=New York, C=US",
|
|
9
10
|
algorithm: "authenticode-catalog-sha256",
|
|
10
11
|
provider: "windows-authenticode-catalog",
|
|
11
12
|
providerVersion: 1,
|
|
@@ -21,47 +22,52 @@ const EXCLUDE_CREDENTIALS = [
|
|
|
21
22
|
"AzureDeveloperCliCredential",
|
|
22
23
|
"InteractiveBrowserCredential",
|
|
23
24
|
];
|
|
25
|
+
|
|
24
26
|
const CERTIFICATE_SHA256 = /^[a-f0-9]{64}$/i;
|
|
25
27
|
|
|
26
|
-
|
|
27
|
-
export function verifyManifestCatalogSignature({
|
|
28
|
+
export function verifyReleaseManifestCatalog({
|
|
28
29
|
manifestPath,
|
|
29
|
-
|
|
30
|
+
catalogPath,
|
|
30
31
|
expectedSigner,
|
|
31
32
|
commandRunner = spawnSync,
|
|
32
33
|
platform = process.platform,
|
|
33
|
-
powershellPath = "
|
|
34
|
+
powershellPath = "powershell",
|
|
34
35
|
}) {
|
|
35
36
|
const manifest = resolve(manifestPath);
|
|
36
|
-
const
|
|
37
|
-
if (
|
|
38
|
-
if (!existsSync(
|
|
39
|
-
if (!
|
|
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");
|
|
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 ?? "")) {
|
|
40
42
|
throw new Error("catalog verification requires exact Azure signer identity");
|
|
41
43
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (inspection.verified !== true || inspection.catalogStatus !== "Valid" || inspection.signatureStatus !== "Valid") throw new Error("manifest catalog signature is invalid");
|
|
44
|
+
const inspection = verifyCatalogWithPowerShell({ manifestPath: manifest, catalogPath: catalog, commandRunner, powershellPath });
|
|
45
|
+
if (!inspection.verified) throw new Error("release manifest catalog is invalid");
|
|
45
46
|
if (normalizeSubject(inspection.subject) !== normalizeSubject(expectedSigner.subject)) throw new Error("catalog signer subject mismatch");
|
|
46
47
|
const certificateSha256 = String(inspection.certificateSha256 ?? "").replaceAll(":", "").toLowerCase();
|
|
47
48
|
if (!CERTIFICATE_SHA256.test(certificateSha256) || certificateSha256 !== String(expectedSigner.certificateSha256).toLowerCase()) throw new Error("catalog signer certificate fingerprint mismatch");
|
|
48
|
-
return { verified: true, manifestPath: manifest,
|
|
49
|
+
return { verified: true, manifestPath: manifest, catalogPath: catalog, signer: { ...expectedSigner, certificateSha256 } };
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
export function
|
|
52
|
+
export function signReleaseManifestCatalogWithAzure({
|
|
52
53
|
manifestPath,
|
|
53
|
-
|
|
54
|
+
catalogPath,
|
|
54
55
|
commandRunner = spawnSync,
|
|
55
56
|
environment = process.env,
|
|
56
57
|
signtoolPath,
|
|
57
58
|
dlibPath,
|
|
58
59
|
metadataPath,
|
|
59
|
-
powershellPath = "pwsh",
|
|
60
60
|
}) {
|
|
61
|
-
if (process.platform !== "win32" && commandRunner === spawnSync)
|
|
61
|
+
if (process.platform !== "win32" && commandRunner === spawnSync) {
|
|
62
|
+
throw new Error("RightRelease manifest signing must run on protected Windows release host");
|
|
63
|
+
}
|
|
62
64
|
const userEnv = (name) => readUserEnvironment(name, commandRunner);
|
|
63
65
|
const env = (name) => environment[name] || userEnv(name);
|
|
64
|
-
const signtool = signtoolPath || firstExisting([
|
|
66
|
+
const signtool = signtoolPath || firstExisting([
|
|
67
|
+
env("AZURE_SIGNTOOL_PATH"),
|
|
68
|
+
env("SIGNTOOL_PATH"),
|
|
69
|
+
...signtoolCandidates(env),
|
|
70
|
+
]);
|
|
65
71
|
if (!signtool) throw new Error("RightRelease manifest signer could not find signtool.exe");
|
|
66
72
|
const dlib = dlibPath || firstExisting([
|
|
67
73
|
env("AZURE_CODESIGN_DLIB_PATH"),
|
|
@@ -72,70 +78,85 @@ export function signReleaseManifestWithAzure({
|
|
|
72
78
|
]);
|
|
73
79
|
if (!dlib) throw new Error("RightRelease manifest signer could not find Azure Artifact Signing dlib");
|
|
74
80
|
|
|
75
|
-
const temp = mkdtempSync(join(tmpdir(), "rightrelease-manifest-
|
|
81
|
+
const temp = mkdtempSync(join(tmpdir(), "rightrelease-manifest-catalog-"));
|
|
82
|
+
const powershellEnvironment = windowsPowerShellEnvironment(environment);
|
|
76
83
|
const metadata = metadataPath || materializeMetadata(temp, env);
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
writeFileSync(generator, [
|
|
82
|
-
"param([string]$ManifestPath, [string]$CatalogDirectory)",
|
|
83
|
-
"$ErrorActionPreference = 'Stop'",
|
|
84
|
-
"Import-Module Microsoft.PowerShell.Security -ErrorAction Stop",
|
|
85
|
-
"New-FileCatalog -Path $ManifestPath -CatalogFilePath $CatalogDirectory -CatalogVersion 2.0 | Out-Null",
|
|
86
|
-
].join("\r\n") + "\r\n");
|
|
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"));
|
|
87
88
|
try {
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
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()}`);
|
|
91
98
|
const result = commandRunner(signtool, [
|
|
92
99
|
"sign", "/v", "/fd", "SHA256", "/tr", "http://timestamp.acs.microsoft.com", "/td", "SHA256",
|
|
93
|
-
"/dlib", dlib, "/dmdf", metadata,
|
|
100
|
+
"/dlib", dlib, "/dmdf", metadata,
|
|
101
|
+
unsignedCatalog,
|
|
94
102
|
], { encoding: "utf8", windowsHide: true, env: environment });
|
|
95
|
-
if (result?.status !== 0) throw new Error(`RightRelease manifest signing failed: ${String(result?.stderr ?? "").trim()}`);
|
|
96
|
-
const inspection =
|
|
97
|
-
if (inspection.
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
return { signaturePath: resolve(signaturePath), signer: { ...RIGHTRELEASE_MANIFEST_SIGNER, certificateSha256: inspection.certificateSha256.toLowerCase() } };
|
|
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() } };
|
|
102
109
|
} finally {
|
|
103
110
|
rmSync(temp, { recursive: true, force: true });
|
|
104
111
|
}
|
|
105
112
|
}
|
|
106
113
|
|
|
107
|
-
function
|
|
114
|
+
function verifyCatalogWithPowerShell({ manifestPath, catalogPath, commandRunner, powershellPath, environment = process.env }) {
|
|
108
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"));
|
|
109
119
|
const verifier = join(temp, "verify-catalog.ps1");
|
|
110
120
|
writeFileSync(verifier, [
|
|
111
|
-
"param([string]$
|
|
112
|
-
"$ErrorActionPreference = 'Stop'",
|
|
121
|
+
"param([string]$SourcePath, [string]$CatalogPath)",
|
|
113
122
|
"Import-Module Microsoft.PowerShell.Security -ErrorAction Stop",
|
|
114
|
-
"$
|
|
115
|
-
"$Signature
|
|
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' }",
|
|
116
127
|
"$Certificate = $Signature.SignerCertificate",
|
|
117
|
-
"if (-not $Certificate) { throw 'Catalog signer certificate is missing' }",
|
|
118
|
-
"$SimpleName = $Certificate.GetNameInfo([Security.Cryptography.X509Certificates.X509NameType]::SimpleName, $false)",
|
|
119
128
|
"$Sha256 = [Security.Cryptography.SHA256]::Create()",
|
|
120
129
|
"try { $Fingerprint = ([BitConverter]::ToString($Sha256.ComputeHash($Certificate.RawData))).Replace('-','').ToLowerInvariant() } finally { $Sha256.Dispose() }",
|
|
121
|
-
"@{ verified
|
|
130
|
+
"@{ verified=$true; subject=$Certificate.Subject; certificateSha256=$Fingerprint } | ConvertTo-Json -Compress",
|
|
122
131
|
].join("\r\n") + "\r\n");
|
|
123
132
|
try {
|
|
124
|
-
const result = commandRunner(powershellPath, ["-NoProfile", "-File", verifier,
|
|
125
|
-
if (result?.status !== 0) throw new Error(`
|
|
126
|
-
|
|
127
|
-
try { inspection = JSON.parse(String(result?.stdout ?? "").trim()); } catch { throw new Error("catalog verifier returned invalid JSON"); }
|
|
128
|
-
if (typeof inspection.subject !== "string" || typeof inspection.certificateSha256 !== "string") throw new Error("catalog verifier returned incomplete identity");
|
|
129
|
-
return inspection;
|
|
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);
|
|
130
136
|
} finally {
|
|
131
137
|
rmSync(temp, { recursive: true, force: true });
|
|
132
138
|
}
|
|
133
139
|
}
|
|
134
140
|
|
|
141
|
+
function parseCatalogInspection(output) {
|
|
142
|
+
let inspection;
|
|
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");
|
|
145
|
+
return inspection;
|
|
146
|
+
}
|
|
147
|
+
|
|
135
148
|
function normalizeSubject(subject) {
|
|
136
149
|
return String(subject).trim().replace(/\s*=\s*/g, "=").replace(/\s*,\s*/g, ",");
|
|
137
150
|
}
|
|
138
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
|
+
|
|
139
160
|
function materializeMetadata(dir, env) {
|
|
140
161
|
const existing = env("AZURE_ARTIFACT_SIGNING_METADATA") || env("AZURE_SIGNING_METADATA");
|
|
141
162
|
if (existing) {
|
|
@@ -145,7 +166,9 @@ function materializeMetadata(dir, env) {
|
|
|
145
166
|
const Endpoint = env("AZURE_ARTIFACT_SIGNING_ENDPOINT") || env("AZURE_SIGNING_ENDPOINT") || env("AZURE_ENDPOINT");
|
|
146
167
|
const CodeSigningAccountName = env("AZURE_ARTIFACT_SIGNING_ACCOUNT") || env("AZURE_SIGNING_ACCOUNT_NAME") || env("AZURE_ACCOUNT");
|
|
147
168
|
const CertificateProfileName = env("AZURE_ARTIFACT_SIGNING_PROFILE") || env("AZURE_CERTIFICATE_PROFILE_NAME") || env("AZURE_PROFILE");
|
|
148
|
-
if (!Endpoint || !CodeSigningAccountName || !CertificateProfileName)
|
|
169
|
+
if (!Endpoint || !CodeSigningAccountName || !CertificateProfileName) {
|
|
170
|
+
throw new Error("RightRelease manifest signer requires existing Azure Artifact Signing configuration");
|
|
171
|
+
}
|
|
149
172
|
const path = join(dir, "metadata.json");
|
|
150
173
|
writeFileSync(path, `${JSON.stringify({ Endpoint, CodeSigningAccountName, CertificateProfileName, ExcludeCredentials: EXCLUDE_CREDENTIALS }, null, 2)}\n`);
|
|
151
174
|
return path;
|
|
@@ -166,6 +189,9 @@ function firstExisting(candidates) {
|
|
|
166
189
|
}
|
|
167
190
|
|
|
168
191
|
function readUserEnvironment(name, commandRunner) {
|
|
169
|
-
const result = commandRunner("powershell", ["-NoProfile", "-Command", `[Environment]::GetEnvironmentVariable('${name.replaceAll("'", "''")}','User')`], {
|
|
192
|
+
const result = commandRunner("powershell", ["-NoProfile", "-Command", `[Environment]::GetEnvironmentVariable('${name.replaceAll("'", "''")}','User')`], {
|
|
193
|
+
encoding: "utf8",
|
|
194
|
+
windowsHide: true,
|
|
195
|
+
});
|
|
170
196
|
return result?.status === 0 ? String(result.stdout ?? "").trim() : "";
|
|
171
197
|
}
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
import { RIGHTRELEASE_MANIFEST_SIGNER, signReleaseManifestWithAzure, verifyManifestCatalogSignature } from "./sign-release-manifest.mjs";
|
|
6
|
-
|
|
7
|
-
if (process.platform !== "win32") throw new Error("protected manifest signer integration must run on Windows");
|
|
8
|
-
|
|
9
|
-
const root = mkdtempSync(join(tmpdir(), "rightrelease-live-signer-"));
|
|
10
|
-
const manifestPath = join(root, "release-manifest.json");
|
|
11
|
-
const signaturePath = join(root, "release-manifest.cat");
|
|
12
|
-
try {
|
|
13
|
-
writeFileSync(manifestPath, '{"kind":"rightrelease-live-signer-probe","schemaVersion":1}\n');
|
|
14
|
-
const signing = signReleaseManifestWithAzure({ manifestPath, signaturePath });
|
|
15
|
-
const verification = verifyManifestCatalogSignature({ manifestPath, signaturePath, expectedSigner: signing.signer });
|
|
16
|
-
assert.equal(verification.verified, true);
|
|
17
|
-
assert.equal(verification.signer.id, RIGHTRELEASE_MANIFEST_SIGNER.id);
|
|
18
|
-
|
|
19
|
-
writeFileSync(manifestPath, `${readFileSync(manifestPath, "utf8")}tampered\n`);
|
|
20
|
-
assert.throws(
|
|
21
|
-
() => verifyManifestCatalogSignature({ manifestPath, signaturePath, expectedSigner: signing.signer }),
|
|
22
|
-
/manifest catalog signature is invalid/,
|
|
23
|
-
);
|
|
24
|
-
process.stdout.write(`${JSON.stringify({ status: "PASS", provider: signing.signer.provider, providerVersion: signing.signer.providerVersion, algorithm: signing.signer.algorithm, subject: signing.signer.subject, certificateSha256: signing.signer.certificateSha256 })}\n`);
|
|
25
|
-
} finally {
|
|
26
|
-
rmSync(root, { recursive: true, force: true });
|
|
27
|
-
}
|