@rightkit/release 0.2.75 → 0.2.77
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 +20 -16
- package/github-release.mjs +17 -13
- package/package.json +2 -1
- package/release.mjs +24 -13
- package/rightkit-versions.json +11 -3
- package/sign-release-manifest.integration.mjs +27 -0
- package/sign-release-manifest.mjs +56 -82
package/direct-bootstrap.mjs
CHANGED
|
@@ -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,
|
|
5
|
+
import { RIGHTRELEASE_MANIFEST_SIGNER, signReleaseManifestWithAzure } from "./sign-release-manifest.mjs";
|
|
6
6
|
import { validateCycloneDxSbom, validateInTotoSlsaProvenance } from "./supply-chain-evidence.mjs";
|
|
7
7
|
|
|
8
8
|
const SHA256 = /^[a-f0-9]{64}$/;
|
|
@@ -145,6 +145,8 @@ export function buildReleaseManifest({
|
|
|
145
145
|
minimumBootstrapVersion,
|
|
146
146
|
signingKeyId: RIGHTRELEASE_MANIFEST_SIGNER.id,
|
|
147
147
|
signatureAlgorithm: RIGHTRELEASE_MANIFEST_SIGNER.algorithm,
|
|
148
|
+
signatureProvider: RIGHTRELEASE_MANIFEST_SIGNER.provider,
|
|
149
|
+
signatureProviderVersion: RIGHTRELEASE_MANIFEST_SIGNER.providerVersion,
|
|
148
150
|
checksumsSha256: checksumsDigest,
|
|
149
151
|
assets: normalizedAssets,
|
|
150
152
|
};
|
|
@@ -162,14 +164,14 @@ export function materializeDirectRelease({ outputDir, manifestInput }) {
|
|
|
162
164
|
});
|
|
163
165
|
const manifestText = `${canonicalJson(manifest)}\n`;
|
|
164
166
|
const manifestPath = join(outputDir, "release-manifest.json");
|
|
165
|
-
const
|
|
167
|
+
const signaturePath = join(outputDir, "release-manifest.cat");
|
|
166
168
|
writeFileSync(manifestPath, manifestText);
|
|
167
|
-
const signing =
|
|
168
|
-
if (!statSync(
|
|
169
|
+
const signing = signReleaseManifestWithAzure({ manifestPath, signaturePath });
|
|
170
|
+
if (!statSync(signaturePath).isFile() || statSync(signaturePath).size < 1) throw new Error("detached release manifest signature is missing");
|
|
169
171
|
if (signing?.signer?.id !== RIGHTRELEASE_MANIFEST_SIGNER.id || signing.signer.subject !== RIGHTRELEASE_MANIFEST_SIGNER.subject || !/^[a-f0-9]{64}$/.test(signing.signer.certificateSha256 ?? "")) {
|
|
170
|
-
throw new Error("release manifest
|
|
172
|
+
throw new Error("detached release manifest signer is not RightRelease Azure authority");
|
|
171
173
|
}
|
|
172
|
-
return { manifest, manifestPath,
|
|
174
|
+
return { manifest, manifestPath, signaturePath, signing, checksums };
|
|
173
175
|
}
|
|
174
176
|
|
|
175
177
|
function psQuote(value) {
|
|
@@ -237,7 +239,8 @@ export function renderPowerShellBootstrap({
|
|
|
237
239
|
};
|
|
238
240
|
// Bind generated contract metadata so a caller cannot pass a marker plus an
|
|
239
241
|
// arbitrary base64 payload and have it treated as a RightRelease bootstrap.
|
|
240
|
-
// This is a
|
|
242
|
+
// This is not a replacement for manifest catalog signing; it is a structural
|
|
243
|
+
// integrity fence for the executable bootstrap source itself.
|
|
241
244
|
contract.contractSha256 = createHash("sha256").update(canonicalJson(contract)).digest("hex");
|
|
242
245
|
const contractB64 = Buffer.from(JSON.stringify(contract)).toString("base64");
|
|
243
246
|
const lines = [
|
|
@@ -266,18 +269,18 @@ export function renderPowerShellBootstrap({
|
|
|
266
269
|
"$ReleaseBase = " + psQuote("https://github.com/" + repository + "/releases/download/v") + " + $Version",
|
|
267
270
|
"$Work = Join-Path ([IO.Path]::GetTempPath()) (" + psQuote(product + "-install-") + " + [guid]::NewGuid())",
|
|
268
271
|
"New-Item -ItemType Directory -Force -Path $Work | Out-Null",
|
|
269
|
-
"$ManifestPath = Join-Path $Work 'release-manifest.json'; $
|
|
272
|
+
"$ManifestPath = Join-Path $Work 'release-manifest.json'; $SignaturePath = Join-Path $Work 'release-manifest.cat'; $ChecksumsPath = Join-Path $Work 'checksums.json'",
|
|
270
273
|
"Invoke-WebRequest ($ReleaseBase + '/release-manifest.json') -OutFile $ManifestPath",
|
|
271
|
-
"Invoke-WebRequest ($ReleaseBase + '/release-manifest.cat') -OutFile $
|
|
272
|
-
"$ManifestBytes = [IO.File]::ReadAllBytes($ManifestPath)",
|
|
273
|
-
"$
|
|
274
|
-
"$
|
|
275
|
-
"$
|
|
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 'authenticode-catalog-sha256') { throw 'Release manifest signer identity mismatch' }",
|
|
283
|
+
"if ($Manifest.signingKeyId -ne $VerifiedKeyId -or $Manifest.signatureAlgorithm -ne 'authenticode-catalog-sha256' -or $Manifest.signatureProvider -ne 'windows-authenticode-catalog' -or $Manifest.signatureProviderVersion -ne 1) { throw 'Release manifest signer identity mismatch' }",
|
|
281
284
|
"if ($Manifest.product -ne " + psQuote(product) + " -or $Manifest.version -ne $Version -or $Manifest.tag -ne ('v' + $Version)) { throw 'Release identity mismatch' }",
|
|
282
285
|
"if ([version]$Contract.bootstrapVersion -lt [version]$Manifest.minimumBootstrapVersion) { throw 'Bootstrap update required' }",
|
|
283
286
|
"$Machine = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString(); $Target = if ($Machine -eq 'Arm64') { 'windows-arm64' } elseif ($Machine -eq 'X64') { 'windows-x86_64' } else { throw ('Unsupported architecture: ' + $Machine) }",
|
|
@@ -334,9 +337,10 @@ export function renderPowerShellBootstrap({
|
|
|
334
337
|
export function validatePowerShellBootstrap(script, expected = {}) {
|
|
335
338
|
const required = [
|
|
336
339
|
"# rightkit-direct-bootstrap:v1",
|
|
337
|
-
"Release manifest
|
|
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");
|
|
@@ -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?.
|
|
146
|
+
export function validateGitHubDirectReleasePlan(plan, { commandRunner = plan?.signatureCommandRunner ?? plan?.cmsCommandRunner ?? spawnSync, platform = plan?.signaturePlatform ?? plan?.cmsPlatform ?? process.platform } = {}) {
|
|
143
147
|
if (!plan || plan.kind !== "direct-release") throw new Error("invalid direct GitHub release plan");
|
|
144
148
|
if (!plan.manifestPath || !plan.signaturePath || !plan.checksumsPath || !Array.isArray(plan.assets)) throw new Error("direct GitHub release plan is incomplete");
|
|
145
149
|
const manifestFile = path.resolve(plan.manifestPath);
|
|
@@ -148,14 +152,14 @@ export function validateGitHubDirectReleasePlan(plan, { commandRunner = plan?.ca
|
|
|
148
152
|
for (const file of [manifestFile, signatureFile, checksumsFile]) {
|
|
149
153
|
if (!existsSync(file) || !statSync(file).isFile()) throw new Error(`direct release payload missing: ${file}`);
|
|
150
154
|
}
|
|
151
|
-
if (statSync(signatureFile).size < 1) throw new Error("release manifest
|
|
155
|
+
if (statSync(signatureFile).size < 1) throw new Error("detached release manifest signature is missing");
|
|
152
156
|
const manifest = JSON.parse(readFileSync(manifestFile, "utf8"));
|
|
153
157
|
if (manifest.schemaVersion !== 1 || manifest.kind !== "rightkit-direct-release-manifest" || !STABLE_SEMVER_RE.test(manifest.version ?? "") || manifest.tag !== `v${manifest.version}`) {
|
|
154
158
|
throw new Error("invalid direct release manifest");
|
|
155
159
|
}
|
|
156
160
|
if (plan.tag !== manifest.tag) throw new Error("direct GitHub release tag does not match manifest");
|
|
157
|
-
if (manifest.signingKeyId !== RIGHTRELEASE_MANIFEST_SIGNER.id || manifest.signatureAlgorithm !== RIGHTRELEASE_MANIFEST_SIGNER.algorithm) throw new Error("direct release manifest signer contract is invalid");
|
|
158
|
-
|
|
161
|
+
if (manifest.signingKeyId !== RIGHTRELEASE_MANIFEST_SIGNER.id || manifest.signatureAlgorithm !== RIGHTRELEASE_MANIFEST_SIGNER.algorithm || manifest.signatureProvider !== RIGHTRELEASE_MANIFEST_SIGNER.provider || manifest.signatureProviderVersion !== RIGHTRELEASE_MANIFEST_SIGNER.providerVersion) throw new Error("direct release manifest signer contract is invalid");
|
|
162
|
+
verifyManifestCatalogSignature({ manifestPath: manifestFile, signaturePath: signatureFile, expectedSigner: plan.signer, commandRunner, platform });
|
|
159
163
|
if (!SHA256.test(manifest.checksumsSha256 ?? "")) throw new Error("direct release checksums digest is missing");
|
|
160
164
|
if (sha256(checksumsFile) !== manifest.checksumsSha256) throw new Error("checksums.json is not manifest-bound");
|
|
161
165
|
const checksums = JSON.parse(readFileSync(checksumsFile, "utf8"));
|
|
@@ -232,8 +236,8 @@ export function prepareGitHubAddonRelease({ repoRoot, config, configRoot = repoR
|
|
|
232
236
|
};
|
|
233
237
|
}
|
|
234
238
|
|
|
235
|
-
export function publishGitHubRelease(plan, { repo, dryRun = false, run = runCommand,
|
|
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.77",
|
|
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/release.mjs
CHANGED
|
@@ -619,9 +619,6 @@ async function validateHostedWorkflows(root, appName, workflows, policy) {
|
|
|
619
619
|
if (policy !== "right-git-ci-only") {
|
|
620
620
|
fail(`${appName} hosted workflow files are forbidden: ${workflows.join(", ")}`);
|
|
621
621
|
}
|
|
622
|
-
if (workflows.length !== 1 || workflows[0] !== "ci.yml") {
|
|
623
|
-
fail(`${appName} right-git-ci-only policy permits only ci.yml, got: ${workflows.join(", ")}`);
|
|
624
|
-
}
|
|
625
622
|
const manifestPath = path.join(root, ".rightgit.json");
|
|
626
623
|
let manifest;
|
|
627
624
|
try {
|
|
@@ -629,16 +626,30 @@ async function validateHostedWorkflows(root, appName, workflows, policy) {
|
|
|
629
626
|
} catch {
|
|
630
627
|
fail(`${appName} right-git-ci-only policy requires a valid .rightgit.json`);
|
|
631
628
|
}
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
629
|
+
const lanes = manifest?.lanes;
|
|
630
|
+
const safeLanes = new Set(["ci", "release-candidate"]);
|
|
631
|
+
if (manifest?.schemaVersion !== 1
|
|
632
|
+
|| !Array.isArray(lanes)
|
|
633
|
+
|| lanes.length === 0
|
|
634
|
+
|| !lanes.includes("ci")
|
|
635
|
+
|| new Set(lanes).size !== lanes.length
|
|
636
|
+
|| lanes.some((lane) => !safeLanes.has(lane))) {
|
|
637
|
+
fail(`${appName} right-git-ci-only policy requires unique .rightgit.json lanes drawn from ["ci", "release-candidate"] and including "ci"`);
|
|
638
|
+
}
|
|
639
|
+
const expectedWorkflows = lanes.map((lane) => `${lane}.yml`).sort();
|
|
640
|
+
if (JSON.stringify(workflows) !== JSON.stringify(expectedWorkflows)) {
|
|
641
|
+
fail(`${appName} right-git-ci-only policy requires workflows declared by .rightgit.json; expected ${expectedWorkflows.join(", ")}, got: ${workflows.join(", ")}`);
|
|
642
|
+
}
|
|
643
|
+
for (const workflow of expectedWorkflows) {
|
|
644
|
+
const source = await readFile(path.join(root, ".github", "workflows", workflow), "utf8");
|
|
645
|
+
if (!source.startsWith("# Managed by right-git")) {
|
|
646
|
+
fail(`${appName} ${workflow} is not marked as right-git managed`);
|
|
647
|
+
}
|
|
648
|
+
if (/^\s*permissions:\s*write-all\s*$/mi.test(source)
|
|
649
|
+
|| /^\s*(?:actions|attestations|checks|contents|deployments|discussions|id-token|issues|models|packages|pages|pull-requests|security-events|statuses):\s*write\s*$/mi.test(source)
|
|
650
|
+
|| /\bsecrets\s*(?:\.|\[)/i.test(source)) {
|
|
651
|
+
fail(`${appName} right-git managed ${workflow} requests release-capable permissions or secrets`);
|
|
652
|
+
}
|
|
642
653
|
}
|
|
643
654
|
}
|
|
644
655
|
|
package/rightkit-versions.json
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
},
|
|
15
15
|
"stagedNpm": {
|
|
16
16
|
"@rightkit/ax": "0.2.1",
|
|
17
|
-
"@rightkit/git": "0.2.
|
|
17
|
+
"@rightkit/git": "0.2.5",
|
|
18
18
|
"@rightkit/hooks": "0.1.1",
|
|
19
19
|
"@rightkit/legal": "0.3.1",
|
|
20
20
|
"@rightkit/legal-ui": "0.1.1",
|
|
@@ -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.77",
|
|
26
26
|
"@rightkit/tauri": "0.1.1",
|
|
27
27
|
"@rightkit/updates": "0.2.4"
|
|
28
28
|
},
|
|
@@ -30,6 +30,12 @@
|
|
|
30
30
|
"@rightkit/ax": [
|
|
31
31
|
"0.2.0"
|
|
32
32
|
],
|
|
33
|
+
"@rightkit/git": [
|
|
34
|
+
"0.2.1",
|
|
35
|
+
"0.2.2",
|
|
36
|
+
"0.2.3",
|
|
37
|
+
"0.2.4"
|
|
38
|
+
],
|
|
33
39
|
"@rightkit/legal-ui": [
|
|
34
40
|
"0.1.0"
|
|
35
41
|
],
|
|
@@ -73,7 +79,9 @@
|
|
|
73
79
|
"0.2.71",
|
|
74
80
|
"0.2.72",
|
|
75
81
|
"0.2.73",
|
|
76
|
-
"0.2.74"
|
|
82
|
+
"0.2.74",
|
|
83
|
+
"0.2.75",
|
|
84
|
+
"0.2.76"
|
|
77
85
|
],
|
|
78
86
|
"@rightkit/qa": [
|
|
79
87
|
"0.1.0",
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { RIGHTRELEASE_MANIFEST_SIGNER, signReleaseManifestWithAzure, verifyManifestCatalogSignature } from "./sign-release-manifest.mjs";
|
|
6
|
+
|
|
7
|
+
if (process.platform !== "win32") throw new Error("protected manifest signer integration must run on Windows");
|
|
8
|
+
|
|
9
|
+
const root = mkdtempSync(join(tmpdir(), "rightrelease-live-signer-"));
|
|
10
|
+
const manifestPath = join(root, "release-manifest.json");
|
|
11
|
+
const signaturePath = join(root, "release-manifest.cat");
|
|
12
|
+
try {
|
|
13
|
+
writeFileSync(manifestPath, '{"kind":"rightrelease-live-signer-probe","schemaVersion":1}\n');
|
|
14
|
+
const signing = signReleaseManifestWithAzure({ manifestPath, signaturePath });
|
|
15
|
+
const verification = verifyManifestCatalogSignature({ manifestPath, signaturePath, expectedSigner: signing.signer });
|
|
16
|
+
assert.equal(verification.verified, true);
|
|
17
|
+
assert.equal(verification.signer.id, RIGHTRELEASE_MANIFEST_SIGNER.id);
|
|
18
|
+
|
|
19
|
+
writeFileSync(manifestPath, `${readFileSync(manifestPath, "utf8")}tampered\n`);
|
|
20
|
+
assert.throws(
|
|
21
|
+
() => verifyManifestCatalogSignature({ manifestPath, signaturePath, expectedSigner: signing.signer }),
|
|
22
|
+
/manifest catalog signature is invalid/,
|
|
23
|
+
);
|
|
24
|
+
process.stdout.write(`${JSON.stringify({ status: "PASS", provider: signing.signer.provider, providerVersion: signing.signer.providerVersion, algorithm: signing.signer.algorithm, subject: signing.signer.subject, certificateSha256: signing.signer.certificateSha256 })}\n`);
|
|
25
|
+
} finally {
|
|
26
|
+
rmSync(root, { recursive: true, force: true });
|
|
27
|
+
}
|
|
@@ -2,11 +2,10 @@ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync,
|
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { spawnSync } from "node:child_process";
|
|
5
|
-
import { createHash } from "node:crypto";
|
|
6
5
|
|
|
7
6
|
export const RIGHTRELEASE_MANIFEST_SIGNER = Object.freeze({
|
|
8
7
|
id: "azure-artifact-signing-damned-ventures-v1",
|
|
9
|
-
subject: "CN=Damned Ventures LLC
|
|
8
|
+
subject: "CN=Damned Ventures LLC",
|
|
10
9
|
algorithm: "authenticode-catalog-sha256",
|
|
11
10
|
provider: "windows-authenticode-catalog",
|
|
12
11
|
providerVersion: 1,
|
|
@@ -22,52 +21,47 @@ const EXCLUDE_CREDENTIALS = [
|
|
|
22
21
|
"AzureDeveloperCliCredential",
|
|
23
22
|
"InteractiveBrowserCredential",
|
|
24
23
|
];
|
|
25
|
-
|
|
26
24
|
const CERTIFICATE_SHA256 = /^[a-f0-9]{64}$/i;
|
|
27
25
|
|
|
28
|
-
|
|
26
|
+
/** Verify Azure-signed catalog binding exact release-manifest bytes. */
|
|
27
|
+
export function verifyManifestCatalogSignature({
|
|
29
28
|
manifestPath,
|
|
30
|
-
|
|
29
|
+
signaturePath,
|
|
31
30
|
expectedSigner,
|
|
32
31
|
commandRunner = spawnSync,
|
|
33
32
|
platform = process.platform,
|
|
34
|
-
powershellPath = "
|
|
33
|
+
powershellPath = "pwsh",
|
|
35
34
|
}) {
|
|
36
35
|
const manifest = resolve(manifestPath);
|
|
37
|
-
const
|
|
38
|
-
if (
|
|
39
|
-
if (!existsSync(
|
|
40
|
-
if (!
|
|
41
|
-
if (!expectedSigner || expectedSigner.id !== RIGHTRELEASE_MANIFEST_SIGNER.id || expectedSigner.subject !== RIGHTRELEASE_MANIFEST_SIGNER.subject || expectedSigner.algorithm !== RIGHTRELEASE_MANIFEST_SIGNER.algorithm || !CERTIFICATE_SHA256.test(expectedSigner.certificateSha256 ?? "")) {
|
|
36
|
+
const signature = resolve(signaturePath);
|
|
37
|
+
if (!existsSync(manifest) || !statSync(manifest).isFile()) throw new Error("manifest bytes are missing");
|
|
38
|
+
if (!existsSync(signature) || !statSync(signature).isFile() || statSync(signature).size < 1) throw new Error("detached manifest catalog signature is missing");
|
|
39
|
+
if (!expectedSigner || expectedSigner.id !== RIGHTRELEASE_MANIFEST_SIGNER.id || expectedSigner.subject !== RIGHTRELEASE_MANIFEST_SIGNER.subject || expectedSigner.algorithm !== RIGHTRELEASE_MANIFEST_SIGNER.algorithm || expectedSigner.provider !== RIGHTRELEASE_MANIFEST_SIGNER.provider || expectedSigner.providerVersion !== RIGHTRELEASE_MANIFEST_SIGNER.providerVersion || !CERTIFICATE_SHA256.test(expectedSigner.certificateSha256 ?? "")) {
|
|
42
40
|
throw new Error("catalog verification requires exact Azure signer identity");
|
|
43
41
|
}
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
if (platform !== "win32" && commandRunner === spawnSync) throw new Error("RightRelease catalog verification must run on Windows");
|
|
43
|
+
const inspection = inspectCatalog({ manifestPath: manifest, signaturePath: signature, commandRunner, powershellPath });
|
|
44
|
+
if (inspection.verified !== true || inspection.catalogStatus !== "Valid" || inspection.signatureStatus !== "Valid") throw new Error("manifest catalog signature is invalid");
|
|
46
45
|
if (normalizeSubject(inspection.subject) !== normalizeSubject(expectedSigner.subject)) throw new Error("catalog signer subject mismatch");
|
|
47
46
|
const certificateSha256 = String(inspection.certificateSha256 ?? "").replaceAll(":", "").toLowerCase();
|
|
48
47
|
if (!CERTIFICATE_SHA256.test(certificateSha256) || certificateSha256 !== String(expectedSigner.certificateSha256).toLowerCase()) throw new Error("catalog signer certificate fingerprint mismatch");
|
|
49
|
-
return { verified: true, manifestPath: manifest,
|
|
48
|
+
return { verified: true, manifestPath: manifest, signaturePath: signature, signer: { ...expectedSigner, certificateSha256 } };
|
|
50
49
|
}
|
|
51
50
|
|
|
52
|
-
export function
|
|
51
|
+
export function signReleaseManifestWithAzure({
|
|
53
52
|
manifestPath,
|
|
54
|
-
|
|
53
|
+
signaturePath,
|
|
55
54
|
commandRunner = spawnSync,
|
|
56
55
|
environment = process.env,
|
|
57
56
|
signtoolPath,
|
|
58
57
|
dlibPath,
|
|
59
58
|
metadataPath,
|
|
59
|
+
powershellPath = "pwsh",
|
|
60
60
|
}) {
|
|
61
|
-
if (process.platform !== "win32" && commandRunner === spawnSync)
|
|
62
|
-
throw new Error("RightRelease manifest signing must run on protected Windows release host");
|
|
63
|
-
}
|
|
61
|
+
if (process.platform !== "win32" && commandRunner === spawnSync) throw new Error("RightRelease manifest signing must run on protected Windows release host");
|
|
64
62
|
const userEnv = (name) => readUserEnvironment(name, commandRunner);
|
|
65
63
|
const env = (name) => environment[name] || userEnv(name);
|
|
66
|
-
const signtool = signtoolPath || firstExisting([
|
|
67
|
-
env("AZURE_SIGNTOOL_PATH"),
|
|
68
|
-
env("SIGNTOOL_PATH"),
|
|
69
|
-
...signtoolCandidates(env),
|
|
70
|
-
]);
|
|
64
|
+
const signtool = signtoolPath || firstExisting([env("AZURE_SIGNTOOL_PATH"), env("SIGNTOOL_PATH"), ...signtoolCandidates(env)]);
|
|
71
65
|
if (!signtool) throw new Error("RightRelease manifest signer could not find signtool.exe");
|
|
72
66
|
const dlib = dlibPath || firstExisting([
|
|
73
67
|
env("AZURE_CODESIGN_DLIB_PATH"),
|
|
@@ -78,85 +72,70 @@ export function signReleaseManifestCatalogWithAzure({
|
|
|
78
72
|
]);
|
|
79
73
|
if (!dlib) throw new Error("RightRelease manifest signer could not find Azure Artifact Signing dlib");
|
|
80
74
|
|
|
81
|
-
const temp = mkdtempSync(join(tmpdir(), "rightrelease-manifest-
|
|
82
|
-
const powershellEnvironment = windowsPowerShellEnvironment(environment);
|
|
75
|
+
const temp = mkdtempSync(join(tmpdir(), "rightrelease-manifest-sign-"));
|
|
83
76
|
const metadata = metadataPath || materializeMetadata(temp, env);
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
77
|
+
const catalogDir = join(temp, "catalog");
|
|
78
|
+
mkdirSync(catalogDir, { recursive: true });
|
|
79
|
+
const catalog = join(catalogDir, "catalog.cat");
|
|
80
|
+
const generator = join(temp, "create-catalog.ps1");
|
|
81
|
+
writeFileSync(generator, [
|
|
82
|
+
"param([string]$ManifestPath, [string]$CatalogDirectory)",
|
|
83
|
+
"$ErrorActionPreference = 'Stop'",
|
|
84
|
+
"Import-Module Microsoft.PowerShell.Security -ErrorAction Stop",
|
|
85
|
+
"New-FileCatalog -Path $ManifestPath -CatalogFilePath $CatalogDirectory -CatalogVersion 2.0 | Out-Null",
|
|
86
|
+
].join("\r\n") + "\r\n");
|
|
88
87
|
try {
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
"Import-Module Microsoft.PowerShell.Security -ErrorAction Stop",
|
|
93
|
-
"$Result = New-FileCatalog -Path $SourcePath -CatalogFilePath $CatalogPath -CatalogVersion 2.0",
|
|
94
|
-
"if (-not (Test-Path $Result)) { throw 'Manifest catalog was not created' }",
|
|
95
|
-
].join("\r\n") + "\r\n");
|
|
96
|
-
const created = commandRunner("powershell", ["-NoProfile", "-File", materializer, catalogSource, unsignedCatalog], { encoding: "utf8", windowsHide: true, env: powershellEnvironment });
|
|
97
|
-
if (created?.status !== 0 || !existsSync(unsignedCatalog)) throw new Error(`RightRelease manifest catalog creation failed: ${String(created?.stderr ?? "").trim()}`);
|
|
88
|
+
const generation = commandRunner(powershellPath, ["-NoProfile", "-File", generator, resolve(manifestPath), catalogDir], { encoding: "utf8", windowsHide: true, env: environment });
|
|
89
|
+
if (generation?.status !== 0) throw new Error(`RightRelease manifest catalog generation failed: ${String(generation?.stderr ?? "").trim()}`);
|
|
90
|
+
if (!existsSync(catalog) || !statSync(catalog).isFile()) throw new Error("RightRelease manifest catalog was not produced");
|
|
98
91
|
const result = commandRunner(signtool, [
|
|
99
92
|
"sign", "/v", "/fd", "SHA256", "/tr", "http://timestamp.acs.microsoft.com", "/td", "SHA256",
|
|
100
|
-
"/dlib", dlib, "/dmdf", metadata,
|
|
101
|
-
unsignedCatalog,
|
|
93
|
+
"/dlib", dlib, "/dmdf", metadata, catalog,
|
|
102
94
|
], { encoding: "utf8", windowsHide: true, env: environment });
|
|
103
|
-
if (result?.status !== 0) throw new Error(`RightRelease manifest
|
|
104
|
-
const inspection =
|
|
105
|
-
if (
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
95
|
+
if (result?.status !== 0) throw new Error(`RightRelease manifest signing failed: ${String(result?.stderr ?? "").trim()}`);
|
|
96
|
+
const inspection = inspectCatalog({ manifestPath: resolve(manifestPath), signaturePath: catalog, commandRunner, powershellPath, environment });
|
|
97
|
+
if (inspection.verified !== true || inspection.catalogStatus !== "Valid" || inspection.signatureStatus !== "Valid") throw new Error("RightRelease manifest catalog verification failed");
|
|
98
|
+
if (normalizeSubject(inspection.subject) !== normalizeSubject(RIGHTRELEASE_MANIFEST_SIGNER.subject) || !CERTIFICATE_SHA256.test(inspection.certificateSha256 ?? "")) throw new Error("RightRelease manifest signer identity does not match protected Azure profile");
|
|
99
|
+
mkdirSync(dirname(signaturePath), { recursive: true });
|
|
100
|
+
copyFileSync(catalog, signaturePath);
|
|
101
|
+
return { signaturePath: resolve(signaturePath), signer: { ...RIGHTRELEASE_MANIFEST_SIGNER, certificateSha256: inspection.certificateSha256.toLowerCase() } };
|
|
109
102
|
} finally {
|
|
110
103
|
rmSync(temp, { recursive: true, force: true });
|
|
111
104
|
}
|
|
112
105
|
}
|
|
113
106
|
|
|
114
|
-
function
|
|
107
|
+
function inspectCatalog({ manifestPath, signaturePath, commandRunner, powershellPath, environment = process.env }) {
|
|
115
108
|
const temp = mkdtempSync(join(tmpdir(), "rightrelease-catalog-verify-"));
|
|
116
|
-
const source = join(temp, "catalog-source");
|
|
117
|
-
mkdirSync(source, { recursive: true });
|
|
118
|
-
copyFileSync(manifestPath, join(source, "release-manifest.json"));
|
|
119
109
|
const verifier = join(temp, "verify-catalog.ps1");
|
|
120
110
|
writeFileSync(verifier, [
|
|
121
|
-
"param([string]$
|
|
111
|
+
"param([string]$ManifestPath, [string]$SignaturePath)",
|
|
112
|
+
"$ErrorActionPreference = 'Stop'",
|
|
122
113
|
"Import-Module Microsoft.PowerShell.Security -ErrorAction Stop",
|
|
123
|
-
"$
|
|
124
|
-
"
|
|
125
|
-
"$Validation = Test-FileCatalog -Path $SourcePath -CatalogFilePath $CatalogPath",
|
|
126
|
-
"if ([string]$Validation -ne 'Valid') { throw 'Catalog does not bind exact manifest bytes' }",
|
|
114
|
+
"$Catalog = Test-FileCatalog -CatalogFilePath $SignaturePath -Path $ManifestPath -Detailed",
|
|
115
|
+
"$Signature = Get-AuthenticodeSignature -FilePath $SignaturePath",
|
|
127
116
|
"$Certificate = $Signature.SignerCertificate",
|
|
117
|
+
"if (-not $Certificate) { throw 'Catalog signer certificate is missing' }",
|
|
118
|
+
"$SimpleName = $Certificate.GetNameInfo([Security.Cryptography.X509Certificates.X509NameType]::SimpleName, $false)",
|
|
128
119
|
"$Sha256 = [Security.Cryptography.SHA256]::Create()",
|
|
129
120
|
"try { $Fingerprint = ([BitConverter]::ToString($Sha256.ComputeHash($Certificate.RawData))).Replace('-','').ToLowerInvariant() } finally { $Sha256.Dispose() }",
|
|
130
|
-
"@{ verified
|
|
121
|
+
"@{ verified=([string]$Catalog.Status -eq 'Valid' -and [string]$Signature.Status -eq 'Valid'); catalogStatus=[string]$Catalog.Status; signatureStatus=[string]$Signature.Status; subject=('CN=' + $SimpleName); certificateSha256=$Fingerprint } | ConvertTo-Json -Compress",
|
|
131
122
|
].join("\r\n") + "\r\n");
|
|
132
123
|
try {
|
|
133
|
-
const result = commandRunner(powershellPath, ["-NoProfile", "-File", verifier,
|
|
134
|
-
if (result?.status !== 0) throw new Error(`catalog verification failed: ${String(result?.stderr ?? "").trim()}`);
|
|
135
|
-
|
|
124
|
+
const result = commandRunner(powershellPath, ["-NoProfile", "-File", verifier, manifestPath, signaturePath], { encoding: "utf8", windowsHide: true, env: environment });
|
|
125
|
+
if (result?.status !== 0) throw new Error(`manifest catalog verification failed: ${String(result?.stderr ?? "").trim()}`);
|
|
126
|
+
let inspection;
|
|
127
|
+
try { inspection = JSON.parse(String(result?.stdout ?? "").trim()); } catch { throw new Error("catalog verifier returned invalid JSON"); }
|
|
128
|
+
if (typeof inspection.subject !== "string" || typeof inspection.certificateSha256 !== "string") throw new Error("catalog verifier returned incomplete identity");
|
|
129
|
+
return inspection;
|
|
136
130
|
} finally {
|
|
137
131
|
rmSync(temp, { recursive: true, force: true });
|
|
138
132
|
}
|
|
139
133
|
}
|
|
140
134
|
|
|
141
|
-
function parseCatalogInspection(output) {
|
|
142
|
-
let inspection;
|
|
143
|
-
try { inspection = JSON.parse(String(output ?? "").trim()); } catch { throw new Error("catalog verifier returned invalid JSON"); }
|
|
144
|
-
if (inspection.verified !== true || typeof inspection.subject !== "string" || typeof inspection.certificateSha256 !== "string") throw new Error("catalog verifier returned incomplete identity");
|
|
145
|
-
return inspection;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
135
|
function normalizeSubject(subject) {
|
|
149
136
|
return String(subject).trim().replace(/\s*=\s*/g, "=").replace(/\s*,\s*/g, ",");
|
|
150
137
|
}
|
|
151
138
|
|
|
152
|
-
function windowsPowerShellEnvironment(environment) {
|
|
153
|
-
const sanitized = { ...environment };
|
|
154
|
-
for (const key of Object.keys(sanitized)) {
|
|
155
|
-
if (key.toLowerCase() === "psmodulepath") delete sanitized[key];
|
|
156
|
-
}
|
|
157
|
-
return sanitized;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
139
|
function materializeMetadata(dir, env) {
|
|
161
140
|
const existing = env("AZURE_ARTIFACT_SIGNING_METADATA") || env("AZURE_SIGNING_METADATA");
|
|
162
141
|
if (existing) {
|
|
@@ -166,9 +145,7 @@ function materializeMetadata(dir, env) {
|
|
|
166
145
|
const Endpoint = env("AZURE_ARTIFACT_SIGNING_ENDPOINT") || env("AZURE_SIGNING_ENDPOINT") || env("AZURE_ENDPOINT");
|
|
167
146
|
const CodeSigningAccountName = env("AZURE_ARTIFACT_SIGNING_ACCOUNT") || env("AZURE_SIGNING_ACCOUNT_NAME") || env("AZURE_ACCOUNT");
|
|
168
147
|
const CertificateProfileName = env("AZURE_ARTIFACT_SIGNING_PROFILE") || env("AZURE_CERTIFICATE_PROFILE_NAME") || env("AZURE_PROFILE");
|
|
169
|
-
if (!Endpoint || !CodeSigningAccountName || !CertificateProfileName)
|
|
170
|
-
throw new Error("RightRelease manifest signer requires existing Azure Artifact Signing configuration");
|
|
171
|
-
}
|
|
148
|
+
if (!Endpoint || !CodeSigningAccountName || !CertificateProfileName) throw new Error("RightRelease manifest signer requires existing Azure Artifact Signing configuration");
|
|
172
149
|
const path = join(dir, "metadata.json");
|
|
173
150
|
writeFileSync(path, `${JSON.stringify({ Endpoint, CodeSigningAccountName, CertificateProfileName, ExcludeCredentials: EXCLUDE_CREDENTIALS }, null, 2)}\n`);
|
|
174
151
|
return path;
|
|
@@ -189,9 +166,6 @@ function firstExisting(candidates) {
|
|
|
189
166
|
}
|
|
190
167
|
|
|
191
168
|
function readUserEnvironment(name, commandRunner) {
|
|
192
|
-
const result = commandRunner("powershell", ["-NoProfile", "-Command", `[Environment]::GetEnvironmentVariable('${name.replaceAll("'", "''")}','User')`], {
|
|
193
|
-
encoding: "utf8",
|
|
194
|
-
windowsHide: true,
|
|
195
|
-
});
|
|
169
|
+
const result = commandRunner("powershell", ["-NoProfile", "-Command", `[Environment]::GetEnvironmentVariable('${name.replaceAll("'", "''")}','User')`], { encoding: "utf8", windowsHide: true });
|
|
196
170
|
return result?.status === 0 ? String(result.stdout ?? "").trim() : "";
|
|
197
171
|
}
|