diff --git a/ts/packages/shell/electron-builder.config.js b/ts/packages/shell/electron-builder.config.js index d44b2222bf..b1bf8ba6e5 100644 --- a/ts/packages/shell/electron-builder.config.js +++ b/ts/packages/shell/electron-builder.config.js @@ -100,7 +100,7 @@ export default { signtoolOptions: { publisherName: windowsTrust.publisher, signingHashAlgorithms: ["sha256"], - rfc3161TimeStampServer: "https://timestamp.digicert.com", + rfc3161TimeStampServer: "http://timestamp.digicert.com", }, verifyUpdateCodeSignature: true, appId: `Microsoft.TypeAgentShell`, diff --git a/ts/tools/scripts/package-signed-shell.ps1 b/ts/tools/scripts/package-signed-shell.ps1 index 2a51b82a14..3903fe242c 100644 --- a/ts/tools/scripts/package-signed-shell.ps1 +++ b/ts/tools/scripts/package-signed-shell.ps1 @@ -14,8 +14,10 @@ try { if ($env:TYPEAGENT_SHELL_VALIDATION_ONLY -eq "1") { throw "Release signing must not use unsigned validation-only packaging." } + Write-Host "Retrieving shell signing certificate." & node (Join-Path $PSScriptRoot "getCert.mjs") pull if ($LASTEXITCODE -ne 0) { throw "Could not retrieve the development signing certificate." } + Write-Host "Retrieving shell signing certificate password." $passwordOutput = & node (Join-Path $PSScriptRoot "getCert.mjs") get-password if ($LASTEXITCODE -ne 0) { throw "Could not retrieve the signing certificate password." } $passwordFile = @($passwordOutput | Where-Object { $_ -match '^[A-Za-z]:\\.+\.txt$' }) | Select-Object -Last 1 @@ -26,7 +28,8 @@ try { Remove-Item -LiteralPath $passwordFile -Force } $env:CSC_LINK = Join-Path $HOME ".typeagent\TypeAgent-Development-Certificate.pfx" - $certificate = New-Object Security.Cryptography.X509Certificates.X509Certificate2( + Write-Host "Loading shell signing certificate and checking publisher policy." + $certificate = [Security.Cryptography.X509Certificates.X509Certificate2]::new( $env:CSC_LINK, $env:CSC_KEY_PASSWORD, [Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet) if ($certificate.Subject -ne $policy.publisher -or $certificate.Thumbprint -notin $policy.thumbprints) { @@ -36,9 +39,11 @@ try { Push-Location (Join-Path $PSScriptRoot "..\..") try { Use-TypeAgentBuildTrust -Certificate $certificate -Action { + Write-Host "Packaging and signing Windows shell." & $env:ComSpec /d /c "pnpm run shell:package" if ($LASTEXITCODE -ne 0) { throw "Signed shell packaging failed." } $dist = Join-Path $PSScriptRoot "..\..\packages\shell\dist" + Write-Host "Verifying signed Windows shell artifacts." & node (Join-Path $PSScriptRoot "verify-shell-artifacts.mjs") $dist if ($LASTEXITCODE -ne 0) { throw "Signed shell artifact verification failed." } [IO.File]::WriteAllBytes((Join-Path $dist "windows-package-trust.cer"), diff --git a/ts/tools/scripts/packageOptionalAgents.mjs b/ts/tools/scripts/packageOptionalAgents.mjs index 15fd117e25..c64d16a3f0 100644 --- a/ts/tools/scripts/packageOptionalAgents.mjs +++ b/ts/tools/scripts/packageOptionalAgents.mjs @@ -65,7 +65,7 @@ function run(cmd, cmdArgs, cwd, env = process.env) { throw new Error(`Command failed (${res.status}): ${cmd}`); } -function writeRuntimeInstallWorkspace(directory) { +export function writeRuntimeInstallWorkspace(directory) { const content = [ 'packages: ["."]', "allowBuilds:", @@ -75,6 +75,7 @@ function writeRuntimeInstallWorkspace(directory) { " keytar: true", " koffi: true", " onnxruntime-node: true", + " protobufjs: false", " puppeteer: false", " sharp: true", "", @@ -257,4 +258,6 @@ async function main() { ); } -await main(); +if (path.resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url)) { + await main(); +} diff --git a/ts/tools/scripts/test/optionalAgentInstallPolicy.spec.mjs b/ts/tools/scripts/test/optionalAgentInstallPolicy.spec.mjs new file mode 100644 index 0000000000..ec5e6cf0d2 --- /dev/null +++ b/ts/tools/scripts/test/optionalAgentInstallPolicy.spec.mjs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { writeRuntimeInstallWorkspace } from "../packageOptionalAgents.mjs"; + +const yaml = createRequire(import.meta.url)("js-yaml"); + +test("optional-agent installs explicitly deny protobufjs builds without weakening native builds", (t) => { + const directory = fs.mkdtempSync( + path.join(os.tmpdir(), "typeagent-optional-policy-"), + ); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const workspace = writeRuntimeInstallWorkspace(directory); + const config = yaml.load(fs.readFileSync(workspace, "utf8")); + const canonical = yaml.load( + fs.readFileSync( + new URL("../../../pnpm-workspace.yaml", import.meta.url), + "utf8", + ), + ); + assert.deepEqual(config.packages, ["."]); + assert.equal(config.allowBuilds.protobufjs, false); + assert.equal(config.allowBuilds.puppeteer, false); + assert.equal(config.allowBuilds["onnxruntime-node"], true); + assert.equal(config.allowBuilds["better-sqlite3"], true); + for (const [name, allowed] of Object.entries(config.allowBuilds)) { + assert.equal(allowed, canonical.allowBuilds[name], name); + } +}); diff --git a/ts/tools/scripts/test/windowsInstallerTrust.spec.mjs b/ts/tools/scripts/test/windowsInstallerTrust.spec.mjs index 892502f40e..3485a2de92 100644 --- a/ts/tools/scripts/test/windowsInstallerTrust.spec.mjs +++ b/ts/tools/scripts/test/windowsInstallerTrust.spec.mjs @@ -36,15 +36,19 @@ function importFunctions(file) { `; } -function runPowerShell(code, expectedStatus = 0) { +function runPowerShell( + code, + expectedStatus = 0, + executable = path.join( + process.env.SystemRoot, + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe", + ), +) { const result = spawnSync( - path.join( - process.env.SystemRoot, - "System32", - "WindowsPowerShell", - "v1.0", - "powershell.exe", - ), + executable, [ "-NoProfile", "-NonInteractive", @@ -90,6 +94,112 @@ function fixture(t) { return { dir, metadata }; } +test( + "build trust uses prompt-free machine trust and restores it on success and failure", + { skip: !windows }, + () => { + runPowerShell( + ` + . ${quote(path.join(scripts, "windows-package-trust.ps1"))} + $rsa = [Security.Cryptography.RSA]::Create(2048) + $request = [Security.Cryptography.X509Certificates.CertificateRequest]::new( + 'CN=TypeAgent-Test', $rsa, [Security.Cryptography.HashAlgorithmName]::SHA256, + [Security.Cryptography.RSASignaturePadding]::Pkcs1) + $certificate = $request.CreateSelfSigned( + [DateTimeOffset]::UtcNow.AddMinutes(-1), [DateTimeOffset]::UtcNow.AddMinutes(5)) + try { + $script:testPolicy = @{ + publisher = $certificate.Subject + thumbprints = @($certificate.Thumbprint) + } | ConvertTo-Json + function Get-Content { $script:testPolicy } + $script:added = 0 + $script:removed = 0 + $script:closed = 0 + $script:actions = 0 + $script:stores = 0 + $script:store = [pscustomobject]@{ Certificates = @() } + $script:store | Add-Member ScriptMethod Open { + param($flags) + if ($flags -ne [Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite) { + throw 'Unexpected store flags' + } + } + $script:store | Add-Member ScriptMethod Add { + param($public) + if ($public.HasPrivateKey) { throw 'Private key reached trust store' } + if ($public.Thumbprint -ne $certificate.Thumbprint) { throw 'Certificate changed' } + $script:added++ + } + $script:store | Add-Member ScriptMethod Remove { param($public) $script:removed++ } + $script:store | Add-Member ScriptMethod Close { $script:closed++ } + function New-Object { + param($TypeName, $ArgumentList) + if ($TypeName -eq 'Security.Cryptography.X509Certificates.X509Store') { + if ($ArgumentList.Count -ne 2 -or $ArgumentList[0] -ne 'Root' -or + $ArgumentList[1] -ne 'LocalMachine') { + throw 'Build trust must avoid the CurrentUser Root confirmation dialog' + } + $script:stores++ + return $script:store + } + Microsoft.PowerShell.Utility\\New-Object -TypeName $TypeName -ArgumentList $ArgumentList + } + Use-TypeAgentBuildTrust -Certificate $certificate -Action { $script:actions++ } + $failed = $false + try { + Use-TypeAgentBuildTrust -Certificate $certificate -Action { throw 'Expected action failure' } + } catch { + if ($_.Exception.Message -ne 'Expected action failure') { throw } + $failed = $true + } + if (-not $failed -or $script:actions -ne 1 -or $script:added -ne 2 -or + $script:removed -ne 2 -or $script:closed -ne 2) { + throw 'Temporary trust was not restored' + } + $script:store.Certificates = @($certificate) + Use-TypeAgentBuildTrust -Certificate $certificate -Action { $script:actions++ } + if ($script:actions -ne 2 -or $script:added -ne 2 -or + $script:removed -ne 2 -or $script:closed -ne 3) { + throw 'Pre-existing machine trust was modified' + } + $script:store | Add-Member ScriptMethod Open { throw 'Store access denied' } -Force + $failed = $false + try { + Use-TypeAgentBuildTrust -Certificate $certificate -Action { throw 'Action ran without trust' } + } catch { + if ($_.Exception.Message -notlike '*Store access denied*') { throw } + $failed = $true + } + if (-not $failed -or $script:added -ne 2 -or $script:removed -ne 2 -or + $script:closed -ne 4) { + throw 'Store access failure was not propagated and cleaned up' + } + $script:testPolicy = @{ + publisher = $certificate.Subject + thumbprints = @('0' * 40) + } | ConvertTo-Json + $failed = $false + try { + Use-TypeAgentBuildTrust -Certificate $certificate -Action { throw 'Unpinned action ran' } + } catch { + if ($_.Exception.Message -ne 'Build certificate does not match the independently pinned signing identity.') { throw } + $failed = $true + } + if (-not $failed -or $script:stores -ne 4) { + throw 'Unpinned certificate reached the machine trust store' + } + } finally { + $certificate.Dispose() + $rsa.Dispose() + } + `, + 0, + "pwsh", + ); + }, +); + test("publication verifies final hashes, metadata and signatures", (t) => { const { dir, metadata } = fixture(t); let calls = 0; @@ -115,6 +225,45 @@ test("publication verifies final hashes, metadata and signatures", (t) => { ); }); +test("publication ignores builder diagnostics but still requires channel metadata", (t) => { + const { dir } = fixture(t); + writeFileSync( + path.join(dir, "builder-debug.yml"), + "x64:\n target: nsis\n", + ); + writeFileSync( + path.join(dir, "builder-effective-config.yaml"), + "win:\n executableName: typeagentshell\n", + ); + const verified = []; + assert.equal( + verifyShellArtifacts(dir, (file) => verified.push(file)), + 1, + ); + assert.deepEqual(verified, [path.join(dir, installer)]); + rmSync(path.join(dir, "ci-x64.yml")); + assert.throws( + () => verifyShellArtifacts(dir, () => {}), + /No shell channel metadata found/, + ); +}); + +test("publication rejects unexpected YAML and unreferenced executables beside builder diagnostics", (t) => { + const { dir } = fixture(t); + writeFileSync(path.join(dir, "builder-debug.yml"), "x64: {}\n"); + writeFileSync(path.join(dir, "unexpected.yml"), "path: ../outside.exe\n"); + assert.throws( + () => verifyShellArtifacts(dir, () => {}), + /Unsafe installer path in unexpected\.yml/, + ); + rmSync(path.join(dir, "unexpected.yml")); + writeFileSync(path.join(dir, "unexpected.exe"), "unsigned fixture"); + assert.throws( + () => verifyShellArtifacts(dir, () => {}), + /Unreferenced executable in shell publication: unexpected\.exe/, + ); +}); + test("electron-builder validates the required Windows signing and publisher configuration", async () => { const builderRequire = createRequire( shellRequire.resolve("electron-builder"), @@ -140,6 +289,10 @@ test("electron-builder validates the required Windows signing and publisher conf assert.deepEqual(config.win.signtoolOptions.signingHashAlgorithms, [ "sha256", ]); + assert.equal( + config.win.signtoolOptions.rfc3161TimeStampServer, + "http://timestamp.digicert.com", + ); const trustResource = config.extraResources.find( (resource) => resource.to === "windows-package-trust", ); diff --git a/ts/tools/scripts/verify-shell-artifacts.mjs b/ts/tools/scripts/verify-shell-artifacts.mjs index a561b49999..2e70ccd3a6 100644 --- a/ts/tools/scripts/verify-shell-artifacts.mjs +++ b/ts/tools/scripts/verify-shell-artifacts.mjs @@ -53,7 +53,10 @@ function verifySignature(packagePath) { export function verifyShellArtifacts(directory, verify = verifySignature) { const names = readdirSync(directory); - const metadataNames = names.filter((name) => name.endsWith(".yml")); + // Electron-builder diagnostics share the output directory with channel metadata. + const metadataNames = names.filter( + (name) => name.endsWith(".yml") && name !== "builder-debug.yml", + ); if (metadataNames.length === 0) { throw new Error("No shell channel metadata found."); } diff --git a/ts/tools/scripts/verify-shell-publication.ps1 b/ts/tools/scripts/verify-shell-publication.ps1 index a5a839beb2..0e871facec 100644 --- a/ts/tools/scripts/verify-shell-publication.ps1 +++ b/ts/tools/scripts/verify-shell-publication.ps1 @@ -8,7 +8,7 @@ $ErrorActionPreference = "Stop" . (Join-Path $PSScriptRoot "windows-package-trust.ps1") $certificate = $null try { - $certificate = New-Object Security.Cryptography.X509Certificates.X509Certificate2( + $certificate = [Security.Cryptography.X509Certificates.X509Certificate2]::new( (Join-Path $Directory "windows-package-trust.cer")) Use-TypeAgentBuildTrust -Certificate $certificate -Action { & node (Join-Path $PSScriptRoot "verify-shell-artifacts.mjs") $Directory diff --git a/ts/tools/scripts/windows-package-trust.ps1 b/ts/tools/scripts/windows-package-trust.ps1 index f40105d991..deb2086b94 100644 --- a/ts/tools/scripts/windows-package-trust.ps1 +++ b/ts/tools/scripts/windows-package-trust.ps1 @@ -36,21 +36,31 @@ function Use-TypeAgentBuildTrust { if ($Certificate.Subject -ne $policy.publisher -or $Certificate.Thumbprint -notin $policy.thumbprints) { throw "Build certificate does not match the independently pinned signing identity." } - $publicCertificate = New-Object Security.Cryptography.X509Certificates.X509Certificate2( + $publicCertificate = [Security.Cryptography.X509Certificates.X509Certificate2]::new( $Certificate.Export([Security.Cryptography.X509Certificates.X509ContentType]::Cert)) - $store = New-Object Security.Cryptography.X509Certificates.X509Store("Root", "CurrentUser") + # CurrentUser\Root can display a native confirmation dialog even in non-interactive PowerShell. + $store = New-Object Security.Cryptography.X509Certificates.X509Store("Root", "LocalMachine") $added = $false try { + Write-Host "Opening temporary build trust in LocalMachine\Root (requires administrator rights)." $store.Open([Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite) if (-not @($store.Certificates | Where-Object { $_.Thumbprint -eq $publicCertificate.Thumbprint }).Count) { + Write-Host "Adding pinned build certificate $($publicCertificate.Thumbprint) to LocalMachine\Root." $store.Add($publicCertificate) $added = $true } + Write-Host "Build trust ready; running package action." & $Action } finally { - if ($added) { $store.Remove($publicCertificate) } - $store.Close() - $publicCertificate.Dispose() + try { + if ($added) { + Write-Host "Removing temporary build certificate $($publicCertificate.Thumbprint) from LocalMachine\Root." + $store.Remove($publicCertificate) + } + } finally { + $store.Close() + $publicCertificate.Dispose() + } } }