@rightkit/release 0.2.74 → 0.2.75

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