@rightkit/release 0.2.73 → 0.2.75

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