@rightkit/release 0.2.72 → 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.
- package/direct-bootstrap.mjs +14 -10
- package/github-release.mjs +18 -14
- package/package.json +2 -1
- package/registry-parity.mjs +19 -2
- package/rightkit-versions.json +4 -2
- package/sign-release-manifest.integration.mjs +27 -0
- package/sign-release-manifest.mjs +58 -110
package/direct-bootstrap.mjs
CHANGED
|
@@ -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.
|
|
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
|
|
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.
|
|
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.
|
|
273
|
-
"$ManifestBytes = [IO.File]::ReadAllBytes($ManifestPath);
|
|
274
|
-
"
|
|
275
|
-
"if ($
|
|
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 ($
|
|
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 '
|
|
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
|
-
"
|
|
342
|
+
"Test-FileCatalog",
|
|
343
|
+
"Get-AuthenticodeSignature",
|
|
340
344
|
"checksums.json is not manifest-bound",
|
|
341
345
|
"Provenance checksum mismatch",
|
|
342
346
|
"SBOM checksum mismatch",
|
package/github-release.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { spawnSync } from "node:child_process";
|
|
|
6
6
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
7
|
import { verifySealedRelease } from "./release-state.mjs";
|
|
8
8
|
import { addonManifestSha256, validateAddonConfig, validateAddonManifest } from "./addon-contract.mjs";
|
|
9
|
-
import { RIGHTRELEASE_MANIFEST_SIGNER,
|
|
9
|
+
import { RIGHTRELEASE_MANIFEST_SIGNER, 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
|
-
|
|
72
|
-
|
|
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.
|
|
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
|
-
|
|
131
|
+
signaturePlatform: effectiveSignaturePlatform,
|
|
128
132
|
};
|
|
129
|
-
Object.defineProperty(plan, "
|
|
130
|
-
validateGitHubDirectReleasePlan(plan, { commandRunner:
|
|
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
|
-
|
|
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.
|
|
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,
|
|
236
|
-
if (plan.kind === "direct-release") validateGitHubDirectReleasePlan(plan, { commandRunner:
|
|
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.
|
|
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
|
}
|
package/registry-parity.mjs
CHANGED
|
@@ -39,6 +39,23 @@ export function comparePackedTrees(local, published) {
|
|
|
39
39
|
return Object.freeze({ equal: false, differences: Object.freeze(differences) });
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
export function packedArchivePath(output) {
|
|
43
|
+
const parsed = typeof output === 'string' ? JSON.parse(output) : output;
|
|
44
|
+
const record = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
45
|
+
if (typeof record?.filename !== 'string' || !record.filename.trim()) {
|
|
46
|
+
throw new Error('pnpm pack output omitted filename');
|
|
47
|
+
}
|
|
48
|
+
return record.filename;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function runPnpm(args, options = {}) {
|
|
52
|
+
const npmExecPath = process.env.npm_execpath;
|
|
53
|
+
if (typeof npmExecPath !== 'string' || !npmExecPath.trim()) {
|
|
54
|
+
throw new Error('registry parity must run through the pnpm package script');
|
|
55
|
+
}
|
|
56
|
+
return execFileSync(process.execPath, [npmExecPath, ...args], options);
|
|
57
|
+
}
|
|
58
|
+
|
|
42
59
|
async function extract(archive, destination) {
|
|
43
60
|
await mkdir(destination, { recursive: true });
|
|
44
61
|
execFileSync('tar', ['-xzf', archive, '-C', destination], { stdio: 'pipe' });
|
|
@@ -57,8 +74,8 @@ export async function verifyRegistryParity({ allowUnpublished = false, fetchImpl
|
|
|
57
74
|
try {
|
|
58
75
|
const localDir = join(root, 'local'); const publishedArchive = join(root, 'published.tgz');
|
|
59
76
|
await mkdir(localDir, { recursive: true });
|
|
60
|
-
const packed =
|
|
61
|
-
const localArchive = join(localDir, basename(packed
|
|
77
|
+
const packed = runPnpm(['pack', '--pack-destination', localDir, '--json'], { cwd: packageRoot, encoding: 'utf8' });
|
|
78
|
+
const localArchive = join(localDir, basename(packedArchivePath(packed)));
|
|
62
79
|
const response = await fetchImpl(record.dist.tarball);
|
|
63
80
|
if (!response.ok) throw new Error(`registry tarball failed: HTTP ${response.status}`);
|
|
64
81
|
await writeFile(publishedArchive, Buffer.from(await response.arrayBuffer()));
|
package/rightkit-versions.json
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"@rightkit/logs": "0.1.4",
|
|
23
23
|
"@rightkit/platform-ui": "0.1.1",
|
|
24
24
|
"@rightkit/qa": "0.2.1",
|
|
25
|
-
"@rightkit/release": "0.2.
|
|
25
|
+
"@rightkit/release": "0.2.74",
|
|
26
26
|
"@rightkit/tauri": "0.1.1",
|
|
27
27
|
"@rightkit/updates": "0.2.4"
|
|
28
28
|
},
|
|
@@ -70,7 +70,9 @@
|
|
|
70
70
|
"0.2.68",
|
|
71
71
|
"0.2.69",
|
|
72
72
|
"0.2.70",
|
|
73
|
-
"0.2.71"
|
|
73
|
+
"0.2.71",
|
|
74
|
+
"0.2.72",
|
|
75
|
+
"0.2.73"
|
|
74
76
|
],
|
|
75
77
|
"@rightkit/qa": [
|
|
76
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,
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
|
-
import {
|
|
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: "
|
|
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
|
-
|
|
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 = "
|
|
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("
|
|
44
|
-
if (!existsSync(signature) || !statSync(signature).isFile() || statSync(signature).size < 1) throw new Error("detached
|
|
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("
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if (
|
|
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("
|
|
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
|
|
90
|
-
mkdirSync(
|
|
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
|
|
100
|
-
if (
|
|
101
|
-
|
|
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
|
-
|
|
124
|
-
return { signaturePath: resolve(signaturePath), signer: { ...RIGHTRELEASE_MANIFEST_SIGNER, 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
|
|
131
|
-
const temp = mkdtempSync(join(tmpdir(), "rightrelease-
|
|
132
|
-
const verifier = join(temp, "verify-
|
|
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
|
-
"
|
|
136
|
-
"
|
|
137
|
-
"$
|
|
138
|
-
"$
|
|
139
|
-
"$
|
|
140
|
-
"if (
|
|
141
|
-
"$
|
|
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
|
|
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(`
|
|
149
|
-
|
|
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
|
}
|