@rightkit/release 0.2.75 → 0.2.76

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