@rightkit/release 0.2.73 → 0.2.74

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.
@@ -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,7 +164,7 @@ 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 signaturePath = join(outputDir, "release-manifest.sig");
167
+ const signaturePath = join(outputDir, "release-manifest.cat");
166
168
  writeFileSync(manifestPath, manifestText);
167
169
  const signing = signReleaseManifestWithAzure({ manifestPath, signaturePath });
168
170
  if (!statSync(signaturePath).isFile() || statSync(signaturePath).size < 1) throw new Error("detached release manifest signature is missing");
@@ -237,7 +239,7 @@ 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 not a replacement for CMS manifest signing; it is a structural
242
+ // This is not a replacement for manifest catalog signing; it is a structural
241
243
  // integrity fence for the executable bootstrap source itself.
242
244
  contract.contractSha256 = createHash("sha256").update(canonicalJson(contract)).digest("hex");
243
245
  const contractB64 = Buffer.from(JSON.stringify(contract)).toString("base64");
@@ -267,17 +269,18 @@ export function renderPowerShellBootstrap({
267
269
  "$ReleaseBase = " + psQuote("https://github.com/" + repository + "/releases/download/v") + " + $Version",
268
270
  "$Work = Join-Path ([IO.Path]::GetTempPath()) (" + psQuote(product + "-install-") + " + [guid]::NewGuid())",
269
271
  "New-Item -ItemType Directory -Force -Path $Work | Out-Null",
270
- "$ManifestPath = Join-Path $Work 'release-manifest.json'; $SignaturePath = Join-Path $Work 'release-manifest.sig'; $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'",
271
273
  "Invoke-WebRequest ($ReleaseBase + '/release-manifest.json') -OutFile $ManifestPath",
272
- "Invoke-WebRequest ($ReleaseBase + '/release-manifest.sig') -OutFile $SignaturePath",
273
- "$ManifestBytes = [IO.File]::ReadAllBytes($ManifestPath); $SignatureBytes = [IO.File]::ReadAllBytes($SignaturePath)",
274
- "Add-Type -AssemblyName System.Security; $Cms = New-Object Security.Cryptography.Pkcs.SignedCms((New-Object Security.Cryptography.Pkcs.ContentInfo(,$ManifestBytes)), $true); $Cms.Decode($SignatureBytes); $Cms.CheckSignature($true)",
275
- "if ($Cms.SignerInfos.Count -ne 1) { throw 'Release manifest signature is invalid' }; $SignerCertificate = $Cms.SignerInfos[0].Certificate",
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 'cms-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) }",
@@ -336,7 +339,8 @@ export function validatePowerShellBootstrap(script, expected = {}) {
336
339
  "# rightkit-direct-bootstrap:v1",
337
340
  "Release manifest signature is invalid",
338
341
  "certificateSha256",
339
- "CheckSignature($true)",
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, verifyDetachedCmsSignature } 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
- cmsCommandRunner = spawnSync,
72
- cmsPlatform = 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");
@@ -103,7 +107,7 @@ export function prepareGitHubDirectRelease({
103
107
  if (unique.has(path.basename(file)) && unique.get(path.basename(file)) !== file) throw new Error(`duplicate direct release payload name: ${path.basename(file)}`);
104
108
  unique.set(path.basename(file), file);
105
109
  }
106
- const requiredEvidence = ["release-manifest.json", "release-manifest.sig", "checksums.json"];
110
+ const requiredEvidence = ["release-manifest.json", "release-manifest.cat", "checksums.json"];
107
111
  for (const name of requiredEvidence) if (![...unique.keys()].some((candidate) => candidate === name || candidate.endsWith(`-${name}`))) throw new Error(`direct release payload missing ${name}`);
108
112
  for (const name of declared) if (!unique.has(name)) throw new Error(`direct release archive is not attached: ${name}`);
109
113
  const releaseState = stateRoot ?? path.join(root, ".right-release", "state", "direct", manifest.product, manifest.version, "github");
@@ -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
- cmsPlatform,
131
+ signaturePlatform: effectiveSignaturePlatform,
128
132
  };
129
- Object.defineProperty(plan, "cmsCommandRunner", { value: cmsCommandRunner, enumerable: false });
130
- validateGitHubDirectReleasePlan(plan, { commandRunner: cmsCommandRunner, platform: cmsPlatform });
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?.cmsCommandRunner ?? spawnSync, platform = plan?.cmsPlatform ?? 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);
@@ -154,8 +158,8 @@ export function validateGitHubDirectReleasePlan(plan, { commandRunner = plan?.cm
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
- verifyDetachedCmsSignature({ manifestPath: manifestFile, signaturePath: 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"));
@@ -171,7 +175,7 @@ export function validateGitHubDirectReleasePlan(plan, { commandRunner = plan?.cm
171
175
  if (payloadByName.has(name) && payloadByName.get(name) !== file) throw new Error(`duplicate direct release payload name: ${name}`);
172
176
  payloadByName.set(name, file);
173
177
  }
174
- const requiredEvidence = ["release-manifest.json", "release-manifest.sig", "checksums.json"];
178
+ const requiredEvidence = ["release-manifest.json", "release-manifest.cat", "checksums.json"];
175
179
  for (const name of requiredEvidence) if (![...payloadByName.keys()].some((candidate) => candidate === name || candidate.endsWith(`-${name}`))) throw new Error(`direct release payload missing ${name}`);
176
180
  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
181
  const expected = new Map();
@@ -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, cmsCommandRunner = plan?.cmsCommandRunner, cmsPlatform = plan?.cmsPlatform ?? process.platform } = {}) {
236
- if (plan.kind === "direct-release") validateGitHubDirectReleasePlan(plan, { commandRunner: cmsCommandRunner ?? spawnSync, platform: cmsPlatform });
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.73",
3
+ "version": "0.2.74",
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.73",
25
+ "@rightkit/release": "0.2.74",
26
26
  "@rightkit/tauri": "0.1.1",
27
27
  "@rightkit/updates": "0.2.4"
28
28
  },
@@ -71,7 +71,8 @@
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"
75
76
  ],
76
77
  "@rightkit/qa": [
77
78
  "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
+ }
@@ -1,13 +1,14 @@
1
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
1
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
- import { basename, dirname, join, resolve } from "node:path";
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
8
  subject: "CN=Damned Ventures LLC",
10
- algorithm: "cms-sha256",
9
+ algorithm: "authenticode-catalog-sha256",
10
+ provider: "windows-authenticode-catalog",
11
+ providerVersion: 1,
11
12
  });
12
13
 
13
14
  const EXCLUDE_CREDENTIALS = [
@@ -20,38 +21,30 @@ const EXCLUDE_CREDENTIALS = [
20
21
  "AzureDeveloperCliCredential",
21
22
  "InteractiveBrowserCredential",
22
23
  ];
23
-
24
24
  const CERTIFICATE_SHA256 = /^[a-f0-9]{64}$/i;
25
25
 
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({
26
+ /** Verify Azure-signed catalog binding exact release-manifest bytes. */
27
+ export function verifyManifestCatalogSignature({
33
28
  manifestPath,
34
29
  signaturePath,
35
30
  expectedSigner,
36
31
  commandRunner = spawnSync,
37
32
  platform = process.platform,
38
- powershellPath = "powershell",
39
- opensslPath = "openssl",
33
+ powershellPath = "pwsh",
40
34
  }) {
41
35
  const manifest = resolve(manifestPath);
42
36
  const signature = resolve(signaturePath);
43
- if (!existsSync(manifest) || !statSync(manifest).isFile()) throw new Error("CMS manifest bytes are missing");
44
- if (!existsSync(signature) || !statSync(signature).isFile() || statSync(signature).size < 1) throw new Error("detached CMS signature is missing");
45
- 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("CMS verification requires exact Azure signer identity");
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 ?? "")) {
40
+ throw new Error("catalog verification requires exact Azure signer identity");
47
41
  }
48
- const inspection = platform === "win32"
49
- ? verifyCmsWithPowerShell({ manifestPath: manifest, signaturePath: signature, commandRunner, powershellPath })
50
- : verifyCmsWithOpenSsl({ manifestPath: manifest, signaturePath: signature, commandRunner, opensslPath });
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");
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");
45
+ if (normalizeSubject(inspection.subject) !== normalizeSubject(expectedSigner.subject)) throw new Error("catalog signer subject mismatch");
53
46
  const certificateSha256 = String(inspection.certificateSha256 ?? "").replaceAll(":", "").toLowerCase();
54
- if (!CERTIFICATE_SHA256.test(certificateSha256) || certificateSha256 !== String(expectedSigner.certificateSha256).toLowerCase()) throw new Error("CMS signer certificate fingerprint mismatch");
47
+ if (!CERTIFICATE_SHA256.test(certificateSha256) || certificateSha256 !== String(expectedSigner.certificateSha256).toLowerCase()) throw new Error("catalog signer certificate fingerprint mismatch");
55
48
  return { verified: true, manifestPath: manifest, signaturePath: signature, signer: { ...expectedSigner, certificateSha256 } };
56
49
  }
57
50
 
@@ -63,17 +56,12 @@ export function signReleaseManifestWithAzure({
63
56
  signtoolPath,
64
57
  dlibPath,
65
58
  metadataPath,
59
+ powershellPath = "pwsh",
66
60
  }) {
67
- if (process.platform !== "win32" && commandRunner === spawnSync) {
68
- throw new Error("RightRelease manifest signing must run on protected Windows release host");
69
- }
61
+ if (process.platform !== "win32" && commandRunner === spawnSync) throw new Error("RightRelease manifest signing must run on protected Windows release host");
70
62
  const userEnv = (name) => readUserEnvironment(name, commandRunner);
71
63
  const env = (name) => environment[name] || userEnv(name);
72
- const signtool = signtoolPath || firstExisting([
73
- env("AZURE_SIGNTOOL_PATH"),
74
- env("SIGNTOOL_PATH"),
75
- ...signtoolCandidates(env),
76
- ]);
64
+ const signtool = signtoolPath || firstExisting([env("AZURE_SIGNTOOL_PATH"), env("SIGNTOOL_PATH"), ...signtoolCandidates(env)]);
77
65
  if (!signtool) throw new Error("RightRelease manifest signer could not find signtool.exe");
78
66
  const dlib = dlibPath || firstExisting([
79
67
  env("AZURE_CODESIGN_DLIB_PATH"),
@@ -86,99 +74,64 @@ export function signReleaseManifestWithAzure({
86
74
 
87
75
  const temp = mkdtempSync(join(tmpdir(), "rightrelease-manifest-sign-"));
88
76
  const metadata = metadataPath || materializeMetadata(temp, env);
89
- const outputDir = join(temp, "signature");
90
- mkdirSync(outputDir, { recursive: true });
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");
91
87
  try {
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");
92
91
  const result = commandRunner(signtool, [
93
92
  "sign", "/v", "/fd", "SHA256", "/tr", "http://timestamp.acs.microsoft.com", "/td", "SHA256",
94
- "/dlib", dlib, "/dmdf", metadata,
95
- "/p7", outputDir, "/p7ce", "DetachedSignedData", "/p7co", "1.2.840.113549.1.7.1",
96
- resolve(manifestPath),
93
+ "/dlib", dlib, "/dmdf", metadata, catalog,
97
94
  ], { encoding: "utf8", windowsHide: true, env: environment });
98
95
  if (result?.status !== 0) throw new Error(`RightRelease manifest signing failed: ${String(result?.stderr ?? "").trim()}`);
99
- const generated = join(outputDir, `${basename(manifestPath)}.p7`);
100
- if (!existsSync(generated)) throw new Error("RightRelease manifest signer did not produce detached CMS signature");
101
- const inspector = join(temp, "inspect-signer.ps1");
102
- writeFileSync(inspector, [
103
- "param([string]$ManifestPath, [string]$SignaturePath)",
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
- }
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");
122
99
  mkdirSync(dirname(signaturePath), { recursive: true });
123
- writeFileSync(signaturePath, readFileSync(generated));
124
- return { signaturePath: resolve(signaturePath), signer: { ...RIGHTRELEASE_MANIFEST_SIGNER, certificateSha256: certificate.certificateSha256 } };
100
+ copyFileSync(catalog, signaturePath);
101
+ return { signaturePath: resolve(signaturePath), signer: { ...RIGHTRELEASE_MANIFEST_SIGNER, certificateSha256: inspection.certificateSha256.toLowerCase() } };
125
102
  } finally {
126
103
  rmSync(temp, { recursive: true, force: true });
127
104
  }
128
105
  }
129
106
 
130
- function verifyCmsWithPowerShell({ manifestPath, signaturePath, commandRunner, powershellPath }) {
131
- const temp = mkdtempSync(join(tmpdir(), "rightrelease-cms-verify-"));
132
- const verifier = join(temp, "verify-cms.ps1");
107
+ function inspectCatalog({ manifestPath, signaturePath, commandRunner, powershellPath, environment = process.env }) {
108
+ const temp = mkdtempSync(join(tmpdir(), "rightrelease-catalog-verify-"));
109
+ const verifier = join(temp, "verify-catalog.ps1");
133
110
  writeFileSync(verifier, [
134
111
  "param([string]$ManifestPath, [string]$SignaturePath)",
135
- "Add-Type -AssemblyName System.Security",
136
- "$Content = New-Object Security.Cryptography.Pkcs.ContentInfo(,[IO.File]::ReadAllBytes($ManifestPath))",
137
- "$Cms = New-Object Security.Cryptography.Pkcs.SignedCms($Content, $true)",
138
- "$Cms.Decode([IO.File]::ReadAllBytes($SignaturePath))",
139
- "$Cms.CheckSignature($true)",
140
- "if ($Cms.SignerInfos.Count -ne 1) { throw 'Expected exactly one manifest signer' }",
141
- "$Certificate = $Cms.SignerInfos[0].Certificate",
112
+ "$ErrorActionPreference = 'Stop'",
113
+ "Import-Module Microsoft.PowerShell.Security -ErrorAction Stop",
114
+ "$Catalog = Test-FileCatalog -CatalogFilePath $SignaturePath -Path $ManifestPath -Detailed",
115
+ "$Signature = Get-AuthenticodeSignature -FilePath $SignaturePath",
116
+ "$Certificate = $Signature.SignerCertificate",
117
+ "if (-not $Certificate) { throw 'Catalog signer certificate is missing' }",
118
+ "$SimpleName = $Certificate.GetNameInfo([Security.Cryptography.X509Certificates.X509NameType]::SimpleName, $false)",
142
119
  "$Sha256 = [Security.Cryptography.SHA256]::Create()",
143
120
  "try { $Fingerprint = ([BitConverter]::ToString($Sha256.ComputeHash($Certificate.RawData))).Replace('-','').ToLowerInvariant() } finally { $Sha256.Dispose() }",
144
- "@{ 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",
145
122
  ].join("\r\n") + "\r\n");
146
123
  try {
147
- const result = commandRunner(powershellPath, ["-NoProfile", "-File", verifier, manifestPath, signaturePath], { encoding: "utf8", windowsHide: true });
148
- if (result?.status !== 0) throw new Error(`CMS signature verification failed: ${String(result?.stderr ?? "").trim()}`);
149
- return parseCmsInspection(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;
150
130
  } finally {
151
131
  rmSync(temp, { recursive: true, force: true });
152
132
  }
153
133
  }
154
134
 
155
- function verifyCmsWithOpenSsl({ manifestPath, signaturePath, commandRunner, opensslPath }) {
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) {
176
- let inspection;
177
- try { inspection = JSON.parse(String(output ?? "").trim()); } catch { throw new Error("CMS verifier returned invalid JSON"); }
178
- if (inspection.verified !== true || typeof inspection.subject !== "string" || typeof inspection.certificateSha256 !== "string") throw new Error("CMS verifier returned incomplete identity");
179
- return inspection;
180
- }
181
-
182
135
  function normalizeSubject(subject) {
183
136
  return String(subject).trim().replace(/\s*=\s*/g, "=").replace(/\s*,\s*/g, ",");
184
137
  }
@@ -192,9 +145,7 @@ function materializeMetadata(dir, env) {
192
145
  const Endpoint = env("AZURE_ARTIFACT_SIGNING_ENDPOINT") || env("AZURE_SIGNING_ENDPOINT") || env("AZURE_ENDPOINT");
193
146
  const CodeSigningAccountName = env("AZURE_ARTIFACT_SIGNING_ACCOUNT") || env("AZURE_SIGNING_ACCOUNT_NAME") || env("AZURE_ACCOUNT");
194
147
  const CertificateProfileName = env("AZURE_ARTIFACT_SIGNING_PROFILE") || env("AZURE_CERTIFICATE_PROFILE_NAME") || env("AZURE_PROFILE");
195
- if (!Endpoint || !CodeSigningAccountName || !CertificateProfileName) {
196
- throw new Error("RightRelease manifest signer requires existing Azure Artifact Signing configuration");
197
- }
148
+ if (!Endpoint || !CodeSigningAccountName || !CertificateProfileName) throw new Error("RightRelease manifest signer requires existing Azure Artifact Signing configuration");
198
149
  const path = join(dir, "metadata.json");
199
150
  writeFileSync(path, `${JSON.stringify({ Endpoint, CodeSigningAccountName, CertificateProfileName, ExcludeCredentials: EXCLUDE_CREDENTIALS }, null, 2)}\n`);
200
151
  return path;
@@ -215,9 +166,6 @@ function firstExisting(candidates) {
215
166
  }
216
167
 
217
168
  function readUserEnvironment(name, commandRunner) {
218
- const result = commandRunner("powershell", ["-NoProfile", "-Command", `[Environment]::GetEnvironmentVariable('${name.replaceAll("'", "''")}','User')`], {
219
- encoding: "utf8",
220
- windowsHide: true,
221
- });
169
+ const result = commandRunner("powershell", ["-NoProfile", "-Command", `[Environment]::GetEnvironmentVariable('${name.replaceAll("'", "''")}','User')`], { encoding: "utf8", windowsHide: true });
222
170
  return result?.status === 0 ? String(result.stdout ?? "").trim() : "";
223
171
  }