diff --git a/README.md b/README.md index 41daed6b6..7f94b2626 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # winTerm +[![Version](https://img.shields.io/badge/version-1.0.0-blue)](docs/releases/1.0.0.md) [![Validation](https://github.com/HelloThisWorld/winTerm/actions/workflows/winterm-validation.yml/badge.svg)](https://github.com/HelloThisWorld/winTerm/actions/workflows/winterm-validation.yml) [![Windows build](https://github.com/HelloThisWorld/winTerm/actions/workflows/winterm-full-build.yml/badge.svg)](https://github.com/HelloThisWorld/winTerm/actions/workflows/winterm-full-build.yml) [![Stable release](https://img.shields.io/badge/stable%20release-blocked-critical)](docs/release-checklist-v1.0.md) @@ -75,7 +76,24 @@ Use the Microsoft Terminal upstream toolchain described in [build guidance](docs .\scripts\winterm\test.ps1 -Suite Relevant -Configuration Release -Platform x64 ``` -Local builds and unsigned development MSIX packages are not public releases. +To build an installable x64 package without a public CA, run the local +development wrapper from PowerShell 7: + +```powershell +.\scripts\winterm\build-local-development.ps1 -IncludeTests +``` + +It builds and validates Release, creates a disposable self-signed code-signing +certificate, signs the MSIX, exports only the public `.cer`, verifies the +package signature and identity, and writes `SHA256SUMS.txt` under +`artifacts/local/winTerm-1.0.0-development-x64`. The private key is removed +after signing. + +Before installation, an administrator must import the included `.cer` into +the Local Machine `Trusted People` certificate store. This package is for +local or controlled internal testing; it is not publicly trusted, timestamped, +or a Stable release. See [build guidance](docs/build.md) for prerequisites, +output files, installation steps, and optional arguments. ## License and upstream diff --git a/docs/build.md b/docs/build.md index 79c239afd..47f295239 100644 --- a/docs/build.md +++ b/docs/build.md @@ -71,6 +71,73 @@ Build output remains in upstream `bin` and intermediate directories. MSIX output The wrapper builds Release with MSIX generation and signing disabled, locates the package, unpacks it with `makeappx.exe`, revalidates the embedded manifest, and reports path, architecture, signature status, and installation guidance. See [Release process](release-process.md) before signing or installing. +## Self-signed local development package + +Use the following command when a public CA or production signing service is +not available and the package will be used only for local or controlled +internal testing: + +```powershell +.\scripts\winterm\build-local-development.ps1 -IncludeTests +``` + +The wrapper: + +1. verifies the 1.0.0 version and runs the source-level Smoke suite; +2. builds the x64 Release MSIX with signing disabled; +3. validates the generated package; +4. runs the Relevant compiled tests when `-IncludeTests` is present; +5. creates a one-year, non-exportable, self-signed development key for + `CN=winTerm Development`; +6. signs the MSIX without a timestamp and exports only the public `.cer`; +7. validates the package identity, version, architecture, `winterm.exe` + alias, absence of `wt.exe`, and the cryptographic PKCS#7 signature; +8. removes the private key and writes installation instructions and + `SHA256SUMS.txt`. + +The default output directory is ignored by Git: + +```text +artifacts/local/winTerm-1.0.0-development-x64/ + INSTALL.txt + SHA256SUMS.txt + winTerm-1.0.0-development.cer + winTerm-1.0.0-development-x64.msix +``` + +The wrapper refuses to overwrite an existing output directory. Use a new +directory when preserving an earlier build: + +```powershell +.\scripts\winterm\build-local-development.ps1 ` + -OutputDirectory artifacts/local/winTerm-development-test-2 +``` + +An existing unsigned MSIX can be validated and signed without compiling it +again: + +```powershell +.\scripts\winterm\build-local-development.ps1 ` + -PackagePath .\path\to\CascadiaPackage_1.0.0.0_x64.msix ` + -OutputDirectory artifacts/local/winTerm-development-from-existing-package +``` + +Do not pass a previously signed package. The wrapper refuses to replace or +append a signature. + +To install the result: + +1. verify the files against `SHA256SUMS.txt`; +2. open `winTerm-1.0.0-development.cer`; +3. choose **Install Certificate**, **Local Machine**, and **Trusted People**, + and approve the administrator prompt; +4. open `winTerm-1.0.0-development-x64.msix` and select **Install**. + +Equivalent elevated PowerShell commands are included in `INSTALL.txt`. +Remove the development certificate after testing. A self-signed development +package is not a substitute for the production-signed and timestamped Stable +installer required by the [release process](release-process.md). + ## CI runner The validation and full-build workflows use GitHub-hosted Windows runners. The Stable workflow uses the protected `winterm-stable-release` environment and an exact `v1.0.0` checkout. Only the protected prepare job may access production signing configuration; pull-request workflows never receive those secrets. diff --git a/scripts/winterm/build-local-development.ps1 b/scripts/winterm/build-local-development.ps1 new file mode 100644 index 000000000..c3de0e24c --- /dev/null +++ b/scripts/winterm/build-local-development.ps1 @@ -0,0 +1,474 @@ +# Copyright (c) winTerm contributors. +# Licensed under the MIT license. + +[CmdletBinding()] +param( + [Parameter()] + [string]$OutputDirectory, + + [Parameter()] + [string]$PackagePath, + + [Parameter()] + [string]$SignToolPath, + + [Parameter()] + [string]$MakeAppxPath, + + [Parameter()] + [switch]$IncludeTests +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Resolve-WindowsSdkTool +{ + param( + [Parameter(Mandatory)] + [string]$Name, + + [Parameter()] + [string]$ExplicitPath + ) + + if (-not [string]::IsNullOrWhiteSpace($ExplicitPath)) + { + $explicitItem = Get-Item -LiteralPath (Resolve-Path -LiteralPath $ExplicitPath).Path + if ($explicitItem.PSIsContainer) + { + throw "Windows SDK tool '$ExplicitPath' is a directory." + } + return $explicitItem.FullName + } + + $command = Get-Command $Name -ErrorAction SilentlyContinue + if ($null -ne $command) + { + return $command.Source + } + + $sdkBinRoot = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\bin' + if (Test-Path -LiteralPath $sdkBinRoot) + { + $versions = @(Get-ChildItem -LiteralPath $sdkBinRoot -Directory | + Sort-Object Name -Descending) + foreach ($version in $versions) + { + $candidate = Join-Path $version.FullName "x64\$Name" + if (Test-Path -LiteralPath $candidate -PathType Leaf) + { + return $candidate + } + } + } + + throw "$Name was not found. Install the Windows SDK declared in .vsconfig or pass an explicit tool path." +} + +function Invoke-NativeTool +{ + param( + [Parameter(Mandatory)] + [string]$Path, + + [Parameter(Mandatory)] + [string[]]$Arguments, + + [Parameter(Mandatory)] + [string]$Description + ) + + & $Path @Arguments + if ($LASTEXITCODE -ne 0) + { + throw "$Description failed with exit code $LASTEXITCODE." + } +} + +function Assert-DevelopmentPackageManifest +{ + param( + [Parameter(Mandatory)] + [string]$Path + ) + + [xml]$manifest = Get-Content -LiteralPath $Path -Raw + $identity = $manifest.SelectSingleNode( + '/*[local-name()="Package"]/*[local-name()="Identity"]') + if ($null -eq $identity) + { + throw 'The package manifest does not contain an Identity element.' + } + if ($identity.Name -cne 'Kaname.winTerm') + { + throw "Unexpected package identity '$($identity.Name)'." + } + if ($identity.Publisher -cne 'CN=winTerm Development') + { + throw "Unexpected package publisher '$($identity.Publisher)'." + } + if ($identity.Version -cne '1.0.0.0') + { + throw "Unexpected package version '$($identity.Version)'." + } + if ($identity.ProcessorArchitecture -cne 'x64') + { + throw "Unexpected package architecture '$($identity.ProcessorArchitecture)'." + } + + $aliases = @($manifest.SelectNodes( + '//*[local-name()="AppExecutionAlias"]/*[local-name()="ExecutionAlias"]') | + ForEach-Object { $_.Alias }) + if ($aliases -notcontains 'winterm.exe' -or $aliases -contains 'wt.exe') + { + throw 'The development package must register winterm.exe and must not register wt.exe.' + } +} + +function Assert-CryptographicPackageSignature +{ + param( + [Parameter(Mandatory)] + [string]$SignaturePath, + + [Parameter(Mandatory)] + [string]$ExpectedThumbprint + ) + + try + { + Add-Type -AssemblyName System.Security.Cryptography.Pkcs -ErrorAction Stop + } + catch + { + Add-Type -AssemblyName System.Security + } + + $signatureBytes = [IO.File]::ReadAllBytes($SignaturePath) + if ($signatureBytes.Length -le 4 -or + [Text.Encoding]::ASCII.GetString($signatureBytes, 0, 4) -cne 'PKCX') + { + throw 'The MSIX signature part has an invalid header.' + } + + $cmsBytes = New-Object byte[] ($signatureBytes.Length - 4) + [Array]::Copy($signatureBytes, 4, $cmsBytes, 0, $cmsBytes.Length) + $cms = New-Object Security.Cryptography.Pkcs.SignedCms + $cms.Decode($cmsBytes) + $cms.CheckSignature($true) + + if ($cms.SignerInfos.Count -ne 1) + { + throw "Expected one MSIX signer, found $($cms.SignerInfos.Count)." + } + if ($cms.SignerInfos[0].Certificate.Thumbprint -cne $ExpectedThumbprint) + { + throw 'The MSIX signer does not match the exported development certificate.' + } +} + +$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$packageOutputRoot = Join-Path $repositoryRoot 'src\cascadia\CascadiaPackage\AppPackages' +$temporaryDirectory = $null +$gitExcludesPath = $null +$certificateThumbprint = $null +$createdOutputDirectory = $false +$completed = $false + +try +{ + if ($PSVersionTable.PSVersion.Major -lt 7 -and + [string]::IsNullOrWhiteSpace($PackagePath)) + { + throw 'PowerShell 7 or later is required when building the package.' + } + if (-not [string]::IsNullOrWhiteSpace($PackagePath) -and $IncludeTests) + { + throw 'IncludeTests cannot be combined with PackagePath because the package was built separately.' + } + if ($null -eq (Get-Command New-SelfSignedCertificate -ErrorAction SilentlyContinue)) + { + throw 'New-SelfSignedCertificate is unavailable on this Windows installation.' + } + + $makeAppx = Resolve-WindowsSdkTool -Name 'makeappx.exe' -ExplicitPath $MakeAppxPath + $signTool = Resolve-WindowsSdkTool -Name 'signtool.exe' -ExplicitPath $SignToolPath + $toolDirectories = @((Split-Path -Parent $makeAppx), (Split-Path -Parent $signTool)) | + Select-Object -Unique + $env:PATH = (($toolDirectories + @($env:PATH)) -join [IO.Path]::PathSeparator) + + & (Join-Path $PSScriptRoot 'verify-version.ps1') + if (-not $?) + { + throw 'Version validation failed.' + } + & (Join-Path $PSScriptRoot 'test.ps1') -Suite Smoke -Configuration Release -Platform x64 + if (-not $?) + { + throw 'Smoke validation failed.' + } + + $sourcePackage = $null + if ([string]::IsNullOrWhiteSpace($PackagePath)) + { + $buildArguments = @{ + Configuration = 'Release' + Platform = 'x64' + GeneratePackage = $true + } + if ($IncludeTests) + { + $buildArguments.IncludeTests = $true + } + + & (Join-Path $PSScriptRoot 'build.ps1') @buildArguments + if (-not $?) + { + throw 'Release package build failed.' + } + & (Join-Path $PSScriptRoot 'package.ps1') -Platform x64 -SkipBuild + if (-not $?) + { + throw 'Package validation failed.' + } + + $sourcePackage = Get-ChildItem -LiteralPath $packageOutputRoot -Recurse -File -Filter '*.msix' | + Where-Object { $_.FullName -match '(?i)x64' } | + Sort-Object LastWriteTimeUtc -Descending | + Select-Object -First 1 + if ($null -eq $sourcePackage) + { + throw "No x64 MSIX was found under '$packageOutputRoot'." + } + + if ($IncludeTests) + { + & (Join-Path $PSScriptRoot 'test.ps1') -Suite Relevant -Configuration Release -Platform x64 + if (-not $?) + { + throw 'Relevant compiled tests failed.' + } + } + } + else + { + $sourcePackage = Get-Item -LiteralPath (Resolve-Path -LiteralPath $PackagePath).Path + } + + if ($sourcePackage.PSIsContainer -or $sourcePackage.Extension -cne '.msix') + { + throw "Package input '$($sourcePackage.FullName)' is not an MSIX file." + } + if (($sourcePackage.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) + { + throw "Package input '$($sourcePackage.FullName)' is a reparse point." + } + if ($sourcePackage.Length -le 0) + { + throw "Package input '$($sourcePackage.FullName)' is empty." + } + $existingSignature = Get-AuthenticodeSignature -LiteralPath $sourcePackage.FullName + if ($null -ne $existingSignature.SignerCertificate) + { + throw 'The source MSIX is already signed. Refusing to replace or append a signature.' + } + + $outputRoot = if ([string]::IsNullOrWhiteSpace($OutputDirectory)) + { + Join-Path $repositoryRoot 'artifacts\local\winTerm-1.0.0-development-x64' + } + elseif ([IO.Path]::IsPathRooted($OutputDirectory)) + { + [IO.Path]::GetFullPath($OutputDirectory) + } + else + { + [IO.Path]::GetFullPath((Join-Path (Get-Location).Path $OutputDirectory)) + } + if (Test-Path -LiteralPath $outputRoot) + { + throw "Output directory already exists: $outputRoot" + } + New-Item -ItemType Directory -Path $outputRoot | Out-Null + $createdOutputDirectory = $true + + $signedPackagePath = Join-Path $outputRoot 'winTerm-1.0.0-development-x64.msix' + $certificatePath = Join-Path $outputRoot 'winTerm-1.0.0-development.cer' + Copy-Item -LiteralPath $sourcePackage.FullName -Destination $signedPackagePath + + $certificate = New-SelfSignedCertificate ` + -Type CodeSigningCert ` + -Subject 'CN=winTerm Development' ` + -FriendlyName 'winTerm Local Development Signing' ` + -CertStoreLocation 'Cert:\CurrentUser\My' ` + -KeyAlgorithm RSA ` + -KeyLength 3072 ` + -KeyExportPolicy NonExportable ` + -HashAlgorithm SHA256 ` + -NotAfter (Get-Date).AddYears(1) + $certificateThumbprint = $certificate.Thumbprint + Export-Certificate -Cert $certificate -FilePath $certificatePath | Out-Null + + Invoke-NativeTool ` + -Path $signTool ` + -Arguments @('sign', '/fd', 'SHA256', '/s', 'My', '/sha1', $certificateThumbprint, $signedPackagePath) ` + -Description 'Development MSIX signing' + + $temporaryDirectory = Join-Path ([IO.Path]::GetTempPath()) ( + "winterm-development-msix-{0}" -f [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $temporaryDirectory | Out-Null + Invoke-NativeTool ` + -Path $makeAppx ` + -Arguments @('unpack', '/p', $signedPackagePath, '/d', $temporaryDirectory, '/o') ` + -Description 'Signed development MSIX inspection' + + Assert-DevelopmentPackageManifest -Path (Join-Path $temporaryDirectory 'AppxManifest.xml') + Assert-CryptographicPackageSignature ` + -SignaturePath (Join-Path $temporaryDirectory 'AppxSignature.p7x') ` + -ExpectedThumbprint $certificateThumbprint + + $publicCertificate = [Security.Cryptography.X509Certificates.X509Certificate2]::new( + $certificatePath) + if ($publicCertificate.HasPrivateKey) + { + throw 'The exported development certificate unexpectedly contains a private key.' + } + $embeddedSignature = Get-AuthenticodeSignature -LiteralPath $signedPackagePath + if ($null -eq $embeddedSignature.SignerCertificate -or + $embeddedSignature.SignerCertificate.Thumbprint -cne $certificateThumbprint) + { + throw 'The embedded MSIX signer does not match the exported development certificate.' + } + + $repositoryGitPath = $repositoryRoot.Replace('\', '/') + $gitSafeDirectoryArgument = "safe.directory=$repositoryGitPath" + $gitExcludesPath = Join-Path ([IO.Path]::GetTempPath()) ( + "winterm-empty-git-excludes-{0}" -f [guid]::NewGuid().ToString('N')) + [IO.File]::WriteAllText($gitExcludesPath, [string]::Empty) + $gitExcludesGitPath = $gitExcludesPath.Replace('\', '/') + $gitArguments = @( + '-c', + $gitSafeDirectoryArgument, + '-c', + "core.excludesfile=$gitExcludesGitPath") + $commitOutput = @(& git @gitArguments -C $repositoryRoot rev-parse HEAD) + if ($LASTEXITCODE -ne 0) + { + throw "Git commit discovery failed with exit code $LASTEXITCODE." + } + $commitSha = ($commitOutput -join '').Trim() + if ($commitSha -notmatch '^[0-9a-fA-F]{40}$') + { + throw "Git returned an invalid commit SHA '$commitSha'." + } + $statusOutput = @(& git @gitArguments -C $repositoryRoot status --porcelain) + if ($LASTEXITCODE -ne 0) + { + throw "Git working tree discovery failed with exit code $LASTEXITCODE." + } + $workingTreeDirty = -not [string]::IsNullOrWhiteSpace( + ($statusOutput -join [Environment]::NewLine)) + + $instructions = @' +winTerm 1.0.0 x64 Development Build +===================================== + +This package is self-signed for local or controlled internal testing. +It is not signed by a public certificate authority and is not the Stable +release. + +Source commit: __COMMIT_SHA__ +Working tree dirty when packaged: __WORKING_TREE_DIRTY__ +Certificate subject: CN=winTerm Development +Certificate thumbprint: __CERTIFICATE_THUMBPRINT__ +Certificate expires: __CERTIFICATE_EXPIRATION__ + +Installation +------------ + +1. Verify the files with SHA256SUMS.txt. +2. Open winTerm-1.0.0-development.cer. +3. Select Install Certificate, choose Local Machine, approve the + administrator prompt, and place the certificate in Trusted People. +4. Open winTerm-1.0.0-development-x64.msix and select Install. + +PowerShell equivalent (run PowerShell as administrator): + + Import-Certificate ` + -FilePath .\winTerm-1.0.0-development.cer ` + -CertStoreLocation Cert:\LocalMachine\TrustedPeople + + Add-AppxPackage .\winTerm-1.0.0-development-x64.msix + +The package registers winterm.exe. It does not register or replace wt.exe. + +Remove development trust +------------------------ + +After uninstalling winTerm, run PowerShell as administrator: + + $certificate = [Security.Cryptography.X509Certificates.X509Certificate2]::new( + (Resolve-Path .\winTerm-1.0.0-development.cer)) + Remove-Item "Cert:\LocalMachine\TrustedPeople\$($certificate.Thumbprint)" +'@ + $instructions = $instructions. + Replace('__COMMIT_SHA__', $commitSha). + Replace('__WORKING_TREE_DIRTY__', $workingTreeDirty.ToString().ToLowerInvariant()). + Replace('__CERTIFICATE_THUMBPRINT__', $certificateThumbprint). + Replace( + '__CERTIFICATE_EXPIRATION__', + $certificate.NotAfter.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')) + [IO.File]::WriteAllText( + (Join-Path $outputRoot 'INSTALL.txt'), + $instructions + [Environment]::NewLine, + [Text.UTF8Encoding]::new($false)) + + & (Join-Path $PSScriptRoot 'generate-checksums.ps1') -Directory $outputRoot + if (-not $?) + { + throw 'Checksum generation failed.' + } + & (Join-Path $PSScriptRoot 'verify-checksums.ps1') -Directory $outputRoot + if (-not $?) + { + throw 'Checksum verification failed.' + } + + $completed = $true + Write-Host "Created self-signed winTerm development package: $signedPackagePath" -ForegroundColor Green + Write-Host "Public development certificate: $certificatePath" + Write-Host "Checksums: $(Join-Path $outputRoot 'SHA256SUMS.txt')" + Write-Warning 'Install the public certificate only on machines used for controlled development testing.' +} +catch +{ + Write-Error "Local development build failed: $($_.Exception.Message)" + exit 1 +} +finally +{ + if (-not [string]::IsNullOrWhiteSpace($certificateThumbprint)) + { + $certificateStorePath = "Cert:\CurrentUser\My\$certificateThumbprint" + if (Test-Path -LiteralPath $certificateStorePath) + { + Remove-Item -LiteralPath $certificateStorePath -Force + } + } + if ($null -ne $temporaryDirectory -and + (Test-Path -LiteralPath $temporaryDirectory)) + { + Remove-Item -LiteralPath $temporaryDirectory -Recurse -Force + } + if ($null -ne $gitExcludesPath -and + (Test-Path -LiteralPath $gitExcludesPath)) + { + Remove-Item -LiteralPath $gitExcludesPath -Force + } + if (-not $completed -and $createdOutputDirectory -and + (Test-Path -LiteralPath $outputRoot)) + { + Remove-Item -LiteralPath $outputRoot -Recurse -Force + } +}