Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ts/packages/shell/electron-builder.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
7 changes: 6 additions & 1 deletion ts/tools/scripts/package-signed-shell.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -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"),
Expand Down
7 changes: 5 additions & 2 deletions ts/tools/scripts/packageOptionalAgents.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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:",
Expand All @@ -75,6 +75,7 @@ function writeRuntimeInstallWorkspace(directory) {
" keytar: true",
" koffi: true",
" onnxruntime-node: true",
" protobufjs: false",
" puppeteer: false",
" sharp: true",
"",
Expand Down Expand Up @@ -257,4 +258,6 @@ async function main() {
);
}

await main();
if (path.resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url)) {
await main();
}
35 changes: 35 additions & 0 deletions ts/tools/scripts/test/optionalAgentInstallPolicy.spec.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
});
169 changes: 161 additions & 8 deletions ts/tools/scripts/test/windowsInstallerTrust.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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;
Expand All @@ -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"),
Expand All @@ -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",
);
Expand Down
5 changes: 4 additions & 1 deletion ts/tools/scripts/verify-shell-artifacts.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
}
Expand Down
2 changes: 1 addition & 1 deletion ts/tools/scripts/verify-shell-publication.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 15 additions & 5 deletions ts/tools/scripts/windows-package-trust.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
}

Expand Down
Loading