# Install the firmfact command-line client on Windows: # # irm https://firmfact.com/install.ps1 | iex # # This file is the whole installer, so you can read it before you run it. It # downloads a release from https://github.com/firmfact/cli/releases and checks # it before it installs anything: # # 1. The release's checksums.txt must carry a valid signature by a firmfact # release key. The keys are below, and `firmfact update` trusts the same # ones. .NET has no Ed25519, so this script checks the signature itself # (Test-FirmfactSignature, as RFC 8032 section 5.1.7 sets out), once it # has checked itself against a test vector from that RFC. If the GitHub # CLI (gh) 2.49 or later is installed and signed in, GitHub also has to # attest that the firmfact/cli release workflow built the archive # from the release's own tag. # 2. The archive must match its SHA-256 in checksums.txt. # 3. The new program must run here and report the version it was # downloaded as. # # If a check fails, it stops, installs nothing and ends with an error, so a # script or `powershell -Command` that runs it fails too. It puts # firmfact.exe in %LOCALAPPDATA%\Programs\firmfact\bin and adds that folder # to your user Path. It needs no administrator rights and changes no # execution policy. It runs on Windows PowerShell 5.1 and PowerShell 7; the # one-line command above needs Windows 11 or Windows Server 2022 or later. # # Settings, as environment variables set before it runs: # FIRMFACT_VERSION the version to install, such as 0.1.0; the latest # release by default # FIRMFACT_INSTALL_DIR the folder to install into; by default # %LOCALAPPDATA%\Programs\firmfact\bin # FIRMFACT_DOWNLOAD_BASE an HTTPS mirror of the release downloads, laid out # as GitHub's are; needs FIRMFACT_VERSION # # $env:FIRMFACT_VERSION = '0.1.0'; irm https://firmfact.com/install.ps1 | iex # # Documentation, and the same checks by hand: https://firmfact.com/cli function Install-Firmfact { param([string[]]$Arguments) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' $Repository = 'firmfact/cli' $ReleaseWorkflow = 'https://github.com/firmfact/cli/.github/workflows/release.yml' $ReleasesUrl = 'https://github.com/firmfact/cli/releases' $LatestUrl = 'https://github.com/firmfact/cli/releases/latest' $DefaultDownloadBase = 'https://github.com/firmfact/cli/releases/download' $CliPage = 'https://firmfact.com/cli' $InstallPs1Url = 'https://firmfact.com/install.ps1' $InstallSh = 'curl -fsSL https://firmfact.com/install.sh | sh' $ScoopBucketAdd = 'scoop bucket add firmfact https://github.com/firmfact/scoop-bucket' $ScoopInstall = 'scoop install firmfact' $SecurityEmail = 'security@firmfact.com' # The firmfact release keys: Ed25519 public keys, base64 of the raw 32 # bytes, as `firmfact update` trusts them. $ReleaseKeys = @( 'gBd0tWG56OhnOnS2t5ixh1ZWdDUoHWQoUarNgP1Cc+o=' ) function Show-FirmfactHelp { Write-Host 'Install the firmfact command-line client on Windows.' Write-Host '' Write-Host " irm $InstallPs1Url | iex" Write-Host '' Write-Host 'Settings, as environment variables set before it runs:' Write-Host ' FIRMFACT_VERSION the version to install, such as 0.1.0 (default: the' Write-Host ' latest release)' Write-Host ' FIRMFACT_INSTALL_DIR the folder to install firmfact.exe into (default:' Write-Host ' %LOCALAPPDATA%\Programs\firmfact\bin)' Write-Host ' FIRMFACT_DOWNLOAD_BASE an HTTPS mirror of' Write-Host " $DefaultDownloadBase," Write-Host ' laid out the same way; needs FIRMFACT_VERSION. The' Write-Host " signature is still checked against firmfact's keys." Write-Host ' FIRMFACT_INSTALL_HELP set to 1 to show this help' Write-Host '' Write-Host "For example: `$env:FIRMFACT_VERSION = '0.1.0'; irm $InstallPs1Url | iex" Write-Host '' Write-Host 'It checks that the release is signed by firmfact and matches its checksum' Write-Host "before it installs anything, and needs no administrator rights. More: $CliPage" } # Exit-FirmfactFailure: end in failure, once the reason is on screen. A # saved script run as a file exits with code 1. Under irm | iex there is # no script to exit, and returning would count as success, so it throws: # `powershell -Command` then exits with 1, and a script that runs this # stops, while an interactive window stays open. function Exit-FirmfactFailure([string]$Message) { if ($PSCommandPath) { exit 1 } throw $Message } # Stop-FirmfactInstall: stop with a message, and details on lines of # their own. function Stop-FirmfactInstall { param([string]$Message, [string[]]$Details = @()) $exception = New-Object System.InvalidOperationException $Message $exception.Data['FirmfactDetails'] = $Details throw $exception } # New-FirmfactHash: SHA256 or SHA512. .NET Framework before 4.8 refuses # its own implementations where the Windows FIPS policy is on, and allows # the ones Windows provides. function New-FirmfactHash([string]$Name) { try { if ($Name -eq 'SHA512') { return [System.Security.Cryptography.SHA512]::Create() } return [System.Security.Cryptography.SHA256]::Create() } catch { return New-Object "System.Security.Cryptography.${Name}CryptoServiceProvider" } } # Get-FirmfactSha256: the SHA-256 of a file, in lower-case hex. function Get-FirmfactSha256([string]$Path) { $sha256 = New-FirmfactHash 'SHA256' $stream = [System.IO.File]::OpenRead($Path) try { return ([System.BitConverter]::ToString($sha256.ComputeHash($stream)) -replace '-', '').ToLowerInvariant() } finally { $stream.Dispose() $sha256.Dispose() } } # Test-FirmfactSignature: whether Signature is a valid Ed25519 signature # by PublicKey over Message, checked as RFC 8032 section 5.1.7 sets out: # decode A and R, refuse an S that is not below L, and check that # [S]B = R + [k]A with k = SHA-512(R || A || M) mod L. It only ever # handles public data, so it need not run in constant time. function Test-FirmfactSignature { param([byte[]]$PublicKey, [byte[]]$Message, [byte[]]$Signature) if ($null -eq $PublicKey -or $PublicKey.Length -ne 32) { return $false } if ($null -eq $Signature -or $Signature.Length -ne 64) { return $false } if ($null -eq $Message) { $Message = [byte[]]@() } $two = [System.Numerics.BigInteger]2 $fieldPrime = [System.Numerics.BigInteger]::Pow($two, 255) - 19 $groupOrder = [System.Numerics.BigInteger]::Pow($two, 252) + [System.Numerics.BigInteger]::Parse('27742317777372353535851937790883648493') $curveD = [System.Numerics.BigInteger]::Parse('37095705934669439343138083508754565189542113879843219016388785533085940283555') $curveD2 = ($curveD * 2) % $fieldPrime $sqrtMinusOne = [System.Numerics.BigInteger]::Parse('19681161376707505956807079304988542015446066515923890162744021073123829784752') $baseX = [System.Numerics.BigInteger]::Parse('15112221349535400772501151409588531511454012693041857206046113283949847762202') $baseY = [System.Numerics.BigInteger]::Parse('46316835694926478169428394003475163141307993866256225615783033603165251855960') $basePoint = @($baseX, $baseY, [System.Numerics.BigInteger]::One, (($baseX * $baseY) % $fieldPrime)) # A little-endian unsigned integer. function ConvertFrom-LittleEndian([byte[]]$Bytes) { $unsigned = New-Object byte[] ($Bytes.Length + 1) [Array]::Copy($Bytes, $unsigned, $Bytes.Length) return New-Object System.Numerics.BigInteger (, $unsigned) } # Points are extended coordinates (X, Y, Z, T): x = X/Z, y = Y/Z, # xy = T/Z. This is RFC 8032's addition (section 5.1.4), which also # doubles a point added to itself. Values may be negative until # they are compared. function Add-Point($Left, $Right) { $termA = (($Left[1] - $Left[0]) * ($Right[1] - $Right[0])) % $fieldPrime $termB = (($Left[1] + $Left[0]) * ($Right[1] + $Right[0])) % $fieldPrime $termC = (($Left[3] * $curveD2) % $fieldPrime * $Right[3]) % $fieldPrime $termD = ($Left[2] * $Right[2] * 2) % $fieldPrime $termE = $termB - $termA $termF = $termD - $termC $termG = $termD + $termC $termH = $termB + $termA return , @((($termE * $termF) % $fieldPrime), (($termG * $termH) % $fieldPrime), (($termF * $termG) % $fieldPrime), (($termE * $termH) % $fieldPrime)) } # [Scalar]Point, the scalar as 32 little-endian bytes. function Get-Multiple([byte[]]$Scalar, $Point) { $result = @([System.Numerics.BigInteger]::Zero, [System.Numerics.BigInteger]::One, [System.Numerics.BigInteger]::One, [System.Numerics.BigInteger]::Zero) for ($bit = 255; $bit -ge 0; $bit--) { $result = Add-Point $result $result if ((($Scalar[$bit -shr 3] -shr ($bit -band 7)) -band 1) -eq 1) { $result = Add-Point $result $Point } } return , $result } # Decode a point (section 5.1.3), or $null when the 32 bytes are not # the canonical encoding of a point on the curve. function ConvertTo-Point([byte[]]$Bytes) { $copy = [byte[]]$Bytes.Clone() $xOdd = ($copy[31] -shr 7) -eq 1 $copy[31] = $copy[31] -band 0x7f $y = ConvertFrom-LittleEndian $copy if ($y -ge $fieldPrime) { return $null } $y2 = ($y * $y) % $fieldPrime $u = ($y2 - 1 + $fieldPrime) % $fieldPrime $v = ($curveD * $y2 + 1) % $fieldPrime $v3 = ($v * $v % $fieldPrime) * $v % $fieldPrime $v7 = ($v3 * $v3 % $fieldPrime) * $v % $fieldPrime $exponent = [System.Numerics.BigInteger]::Divide($fieldPrime - 5, 8) $x = ($u * $v3 % $fieldPrime) * [System.Numerics.BigInteger]::ModPow(($u * $v7) % $fieldPrime, $exponent, $fieldPrime) % $fieldPrime $vx2 = ($v * $x % $fieldPrime) * $x % $fieldPrime if ($vx2 -eq $u) { } elseif ($vx2 -eq (($fieldPrime - $u) % $fieldPrime)) { $x = ($x * $sqrtMinusOne) % $fieldPrime } else { return $null } if ($x.IsZero -and $xOdd) { return $null } if ((-not $x.IsEven) -ne $xOdd) { $x = $fieldPrime - $x } return , @($x, $y, [System.Numerics.BigInteger]::One, (($x * $y) % $fieldPrime)) } $sBytes = [byte[]]$Signature[32..63] $s = ConvertFrom-LittleEndian $sBytes if ($s -ge $groupOrder) { return $false } $pointA = ConvertTo-Point $PublicKey if ($null -eq $pointA) { return $false } $pointR = ConvertTo-Point ([byte[]]$Signature[0..31]) if ($null -eq $pointR) { return $false } $hashInput = New-Object byte[] (64 + $Message.Length) [Array]::Copy($Signature, 0, $hashInput, 0, 32) [Array]::Copy($PublicKey, 0, $hashInput, 32, 32) [Array]::Copy($Message, 0, $hashInput, 64, $Message.Length) $sha512 = New-FirmfactHash 'SHA512' try { $digest = $sha512.ComputeHash($hashInput) } finally { $sha512.Dispose() } $k = (ConvertFrom-LittleEndian $digest) % $groupOrder $kBytes = New-Object byte[] 32 $kRaw = $k.ToByteArray() [Array]::Copy($kRaw, $kBytes, [Math]::Min($kRaw.Length, 32)) $left = Get-Multiple $sBytes $basePoint $right = Add-Point $pointR (Get-Multiple $kBytes $pointA) $sameX = ((($left[0] * $right[2]) - ($right[0] * $left[2])) % $fieldPrime).IsZero $sameY = ((($left[1] * $right[2]) - ($right[1] * $left[2])) % $fieldPrime).IsZero return ($sameX -and $sameY) } # Test-FirmfactSelf: whether Test-FirmfactSignature works on this # PowerShell. It must accept RFC 8032's test 2 and refuse the same # signature over another message. function Test-FirmfactSelf { $key = [Convert]::FromBase64String('PUAXw+hDiVqStwqnTRt+vJyYLM8uxJaMwM1V8Sr0Zgw=') $signature = [Convert]::FromBase64String('kqAJqfDUyrhyDoILX2QlQKKye1QWUD+Ps3YiI+vbadoIWsHkPhWZbkWPNhPQ8R2MOHsurrQwKu6wDSkWErsMAA==') $accepts = Test-FirmfactSignature -PublicKey $key -Message ([byte[]]@(0x72)) -Signature $signature $refuses = -not (Test-FirmfactSignature -PublicKey $key -Message ([byte[]]@(0x73)) -Signature $signature) return ($accepts -and $refuses) } # Test-FirmfactVersion: whether a version looks like a release version # (1.2.0, 1.3.0-rc.1). It becomes part of URLs and file names, so # nothing else passes. function Test-FirmfactVersion([string]$Version) { return ($Version -cmatch '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?\z') } # New-FirmfactHttpClient: an HttpClient that follows no redirect itself, # so that every hop can be held to HTTPS. function New-FirmfactHttpClient { Add-Type -AssemblyName System.Net.Http $handler = New-Object System.Net.Http.HttpClientHandler $handler.AllowAutoRedirect = $false # A proxy that asks who you are is answered with your Windows sign-in, # as your browser answers it. if ($handler.PSObject.Properties['DefaultProxyCredentials']) { $handler.DefaultProxyCredentials = [System.Net.CredentialCache]::DefaultCredentials } $client = New-Object System.Net.Http.HttpClient -ArgumentList $handler $client.Timeout = [TimeSpan]::FromMinutes(10) $client.DefaultRequestHeaders.UserAgent.ParseAdd('firmfact-installer') return $client } function Send-FirmfactRequest($Client, [Uri]$Uri, [string]$Method) { $request = New-Object System.Net.Http.HttpRequestMessage -ArgumentList (New-Object System.Net.Http.HttpMethod -ArgumentList $Method), $Uri try { return $Client.SendAsync($request, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead).GetAwaiter().GetResult() } catch { $problem = $_.Exception while ($null -ne $problem.InnerException) { $problem = $problem.InnerException } Stop-FirmfactInstall "could not reach ${Uri}: $($problem.Message)" } } # Save-FirmfactFile: download Url into Path, following redirects only to # HTTPS addresses, and stop on an HTTP error. function Save-FirmfactFile($Client, [string]$Url, [string]$Path, [string[]]$Hint) { $uri = [Uri]$Url for ($hop = 0; $hop -le 10; $hop++) { if ($uri.Scheme -ne 'https') { Stop-FirmfactInstall "refusing to follow a redirect to $uri, which is not HTTPS." @("It came from $Url.") } $response = Send-FirmfactRequest $Client $uri 'GET' try { $status = [int]$response.StatusCode if ($status -ge 300 -and $status -lt 400 -and $null -ne $response.Headers.Location) { $uri = New-Object Uri -ArgumentList $uri, $response.Headers.Location continue } if ($status -ne 200) { Stop-FirmfactInstall "could not download $Url (HTTP $status)." $Hint } $file = [System.IO.File]::Create($Path) try { $null = $response.Content.CopyToAsync($file).GetAwaiter().GetResult() } finally { $file.Dispose() } return } finally { $response.Dispose() } } Stop-FirmfactInstall "stopped after 10 redirects from $Url." } # Get-FirmfactLatestVersion: the latest release's tag, found the way the # CLI finds it: GitHub's latest-release page redirects to the newest # release's tag. That page is not GitHub's API, whose 60 requests an hour # per address an office behind one address soon uses up. The only # redirect followed is to another latest-release page, as a renamed # repository sends. function Get-FirmfactLatestVersion($Client, [string]$Page) { $uri = [Uri]$Page $retry = 'Check your connection, or set $env:FIRMFACT_VERSION to the version to install.' for ($hop = 0; ; $hop++) { $response = Send-FirmfactRequest $Client $uri 'HEAD' try { $status = [int]$response.StatusCode $location = $response.Headers.Location } finally { $response.Dispose() } if ($status -lt 300 -or $status -ge 400 -or $null -eq $location) { Stop-FirmfactInstall "$uri answered HTTP $status, not a redirect to the latest release." @($retry) } $to = New-Object Uri -ArgumentList $uri, $location $path = [Uri]::UnescapeDataString($to.AbsolutePath) $at = $path.IndexOf('/releases/tag/') if ($at -ge 0) { return $path.Substring($at + '/releases/tag/'.Length) } if (-not $path.EndsWith('/releases/latest') -or $to.Scheme -ne 'https') { Stop-FirmfactInstall "$Page names no latest release." } if ($hop -ge 5) { Stop-FirmfactInstall 'stopped after 5 redirects looking up the latest release.' } $uri = $to } } # Get-FirmfactChecksum: the SHA-256 that checksums.txt lists for Name, # by exact file name. function Get-FirmfactChecksum([string]$Path, [string]$Name) { foreach ($line in [System.IO.File]::ReadAllLines($Path)) { $fields = @($line.Trim() -split '\s+') if ($fields.Count -eq 2 -and $fields[1] -ceq $Name) { return $fields[0].ToLowerInvariant() } } return $null } # Expand-FirmfactProgram: copy firmfact.exe out of the archive. function Expand-FirmfactProgram([string]$Archive, [string]$Destination) { Add-Type -AssemblyName System.IO.Compression Add-Type -AssemblyName System.IO.Compression.FileSystem $zip = [System.IO.Compression.ZipFile]::OpenRead($Archive) try { $entry = $null foreach ($candidate in $zip.Entries) { if ($candidate.FullName -ceq 'firmfact.exe') { $entry = $candidate } } if ($null -eq $entry) { Stop-FirmfactInstall "$([System.IO.Path]::GetFileName($Archive)) holds no firmfact.exe." } $source = $entry.Open() try { $target = [System.IO.File]::Create($Destination) try { $source.CopyTo($target) } finally { $target.Dispose() } } finally { $source.Dispose() } } finally { $zip.Dispose() } } # Invoke-FirmfactProgram: run a program with FIRMFACT_NO_UPDATE_CHECK set # for it alone, and return its exit code, output and errors. function Invoke-FirmfactProgram([string]$Path, [string[]]$ArgumentList) { $info = New-Object System.Diagnostics.ProcessStartInfo $info.FileName = $Path $quoted = foreach ($argument in $ArgumentList) { if ($argument -match '[\s"]') { '"' + $argument + '"' } else { $argument } } $info.Arguments = ($quoted -join ' ') $info.UseShellExecute = $false $info.CreateNoWindow = $true $info.RedirectStandardOutput = $true $info.RedirectStandardError = $true $info.EnvironmentVariables['FIRMFACT_NO_UPDATE_CHECK'] = '1' $process = [System.Diagnostics.Process]::Start($info) try { $output = $process.StandardOutput.ReadToEndAsync() $errors = $process.StandardError.ReadToEndAsync() # The first run of a new program can take a while as antivirus # software scans it. if (-not $process.WaitForExit(120000)) { $process.Kill() Stop-FirmfactInstall "$([System.IO.Path]::GetFileName($Path)) did not finish within two minutes." } $process.WaitForExit() return [pscustomobject]@{ ExitCode = $process.ExitCode Output = $output.GetAwaiter().GetResult() Errors = $errors.GetAwaiter().GetResult() } } finally { $process.Dispose() } } # Add-FirmfactUserPath: add Directory to the user Path unless it is there # already, and say whether it did. The registry value is edited as it is # stored, so entries such as %USERPROFILE%\bin stay as they were, which # [Environment]::SetEnvironmentVariable would expand for good. function Add-FirmfactUserPath([string]$Directory) { $environment = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true) try { $current = '' $kind = [Microsoft.Win32.RegistryValueKind]::ExpandString if ($environment.GetValueNames() -contains 'Path') { $current = [string]$environment.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) $kind = $environment.GetValueKind('Path') } foreach ($entry in ($current -split ';')) { if ($entry -and [Environment]::ExpandEnvironmentVariables($entry).TrimEnd('\') -ieq $Directory.TrimEnd('\')) { return $false } } if ($current.TrimEnd(';')) { $environment.SetValue('Path', $current.TrimEnd(';') + ';' + $Directory, $kind) } else { $environment.SetValue('Path', $Directory, [Microsoft.Win32.RegistryValueKind]::ExpandString) } } finally { $environment.Close() } # Tell Explorer, and the terminals it starts, that the environment # changed. .NET announces it whenever it sets a user variable, so set # one and take it away again. [Environment]::SetEnvironmentVariable('FIRMFACT_INSTALL_PATH_CHANGED', '1', 'User') [Environment]::SetEnvironmentVariable('FIRMFACT_INSTALL_PATH_CHANGED', $null, 'User') return $true } # Constrained Language Mode, which an organisation's policy can impose, # allows none of the .NET this needs, including the signature check. if ($ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage') { Write-Host "firmfact installer: PowerShell runs in $($ExecutionContext.SessionState.LanguageMode) here, where this installer cannot check the release signature." -ForegroundColor Red Write-Host " Install with Scoop ($ScoopBucketAdd; $ScoopInstall), or check and install a release by hand: $CliPage" Write-Host 'Nothing was installed.' Exit-FirmfactFailure "firmfact was not installed: PowerShell runs in $($ExecutionContext.SessionState.LanguageMode) here." } foreach ($argument in @($Arguments)) { if ($argument -in @('-h', '--help', '-help', '-?', '/?')) { Show-FirmfactHelp return } if ($argument) { Write-Host "firmfact installer: unknown option $argument. The settings are environment variables; see --help." -ForegroundColor Red Exit-FirmfactFailure "firmfact was not installed: unknown option $argument." } } if ($env:FIRMFACT_INSTALL_HELP -and $env:FIRMFACT_INSTALL_HELP -ne '0') { Show-FirmfactHelp return } $failure = $null $installed = $false $tempDir = $null $staged = $null $client = $null $savedProtocol = $null try { if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { Stop-FirmfactInstall 'this installer is for Windows. On Linux and macOS, run:' @($InstallSh) } # The processor's own architecture. A 32-bit PowerShell reads it from # PROCESSOR_ARCHITEW6432. An x64 PowerShell 7 on arm64 Windows runs # emulated and reads AMD64 from both, so there .NET's # OSArchitecture, which reports the machine's, decides. $arch = $env:PROCESSOR_ARCHITEW6432 if (-not $arch) { $arch = $env:PROCESSOR_ARCHITECTURE } $runtime = 'System.Runtime.InteropServices.RuntimeInformation' -as [type] if ($PSVersionTable.PSEdition -eq 'Core' -and $null -ne $runtime) { $machine = [string]$runtime::OSArchitecture if ($machine -eq 'Arm64') { $arch = 'ARM64' } elseif ($machine -eq 'X64') { $arch = 'AMD64' } } switch ($arch) { 'AMD64' { $arch = 'amd64' } 'ARM64' { $arch = 'arm64' } default { Stop-FirmfactInstall "firmfact is built for 64-bit Windows on amd64 (x64) and arm64 processors, not $arch." } } if ($env:FIRMFACT_INSTALL_DIR) { $dir = $env:FIRMFACT_INSTALL_DIR if ($dir -notmatch '^([A-Za-z]:\\|\\\\)') { Stop-FirmfactInstall "FIRMFACT_INSTALL_DIR must be a full path, such as `$env:USERPROFILE\Tools\firmfact, not $dir." } } else { if (-not $env:LOCALAPPDATA) { Stop-FirmfactInstall 'LOCALAPPDATA is not set, so there is no default folder to install into.' @('Set FIRMFACT_INSTALL_DIR to the folder to install firmfact.exe into.') } $dir = Join-Path $env:LOCALAPPDATA 'Programs\firmfact\bin' } $dir = [System.IO.Path]::GetFullPath($dir) if ($dir.Length -gt 3) { $dir = $dir.TrimEnd('\') } # Windows PowerShell 5.1 may still offer TLS 1.0; hold it to TLS 1.2 # while this runs, and put back what was there afterwards. if ($PSVersionTable.PSEdition -ne 'Core') { $savedProtocol = [System.Net.ServicePointManager]::SecurityProtocol [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 } $client = New-FirmfactHttpClient if ($env:FIRMFACT_VERSION) { $version = $env:FIRMFACT_VERSION if ($version.StartsWith('v')) { $version = $version.Substring(1) } if (-not (Test-FirmfactVersion $version)) { Stop-FirmfactInstall "FIRMFACT_VERSION is $($env:FIRMFACT_VERSION), not a version such as 0.1.0." } $versionSource = 'the version FIRMFACT_VERSION names' } else { if ($env:FIRMFACT_DOWNLOAD_BASE) { Stop-FirmfactInstall 'FIRMFACT_DOWNLOAD_BASE is set, so set FIRMFACT_VERSION too:' @('a mirror has no latest-release page to ask.') } $tag = Get-FirmfactLatestVersion $client $LatestUrl $version = $tag if ($version.StartsWith('v')) { $version = $version.Substring(1) } if (-not (Test-FirmfactVersion $version)) { Stop-FirmfactInstall "the latest release is tagged $tag, which is not a version." } $versionSource = 'the latest release' } $base = $DefaultDownloadBase if ($env:FIRMFACT_DOWNLOAD_BASE) { $base = $env:FIRMFACT_DOWNLOAD_BASE } $base = $base.TrimEnd('/') if (-not $base.StartsWith('https://', [System.StringComparison]::OrdinalIgnoreCase)) { Stop-FirmfactInstall "FIRMFACT_DOWNLOAD_BASE must be an https:// address, not $base." } $archive = "firmfact_${version}_windows_${arch}.zip" $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ('firmfact-install-' + [guid]::NewGuid().ToString('N')) New-Item -ItemType Directory -Path $tempDir | Out-Null Write-Host "Downloading firmfact $version for windows/$arch from $base/v$version/" $hint = @("Check that firmfact $version exists: $ReleasesUrl") foreach ($file in @('checksums.txt', 'checksums.txt.sig', $archive)) { # checksums.txt came, so the release exists; without its # signature it cannot be checked. if ($file -eq 'checksums.txt.sig') { Save-FirmfactFile $client "$base/v$version/$file" (Join-Path $tempDir $file) @('A release cannot be checked without its signature.') } else { Save-FirmfactFile $client "$base/v$version/$file" (Join-Path $tempDir $file) $hint } } # 1. The signature over checksums.txt, by a firmfact release key. $sums = [System.IO.File]::ReadAllBytes((Join-Path $tempDir 'checksums.txt')) $signature = [System.IO.File]::ReadAllBytes((Join-Path $tempDir 'checksums.txt.sig')) if ($signature.Length -ne 64) { Stop-FirmfactInstall "checksums.txt.sig is $($signature.Length) bytes, not the 64 of an Ed25519 signature." } if (-not (Test-FirmfactSelf)) { Stop-FirmfactInstall 'the signature check failed its own test on this PowerShell, so it cannot be relied on here.' @("Install with Scoop ($ScoopBucketAdd; $ScoopInstall), or check and install a release by hand: $CliPage") } $signed = $false foreach ($key in $ReleaseKeys) { if (Test-FirmfactSignature -PublicKey ([Convert]::FromBase64String($key)) -Message $sums -Signature $signature) { $signed = $true break } } if (-not $signed) { Stop-FirmfactInstall "the signature on checksums.txt does not match firmfact's release key." @("The download may have been changed on its way here. Please tell $SecurityEmail.") } $signatureCheck = 'checksums.txt is signed with a firmfact release key (checked by this script)' # And, where the GitHub CLI can check it, GitHub's attestation that # the release workflow run for this version's tag built the archive. # The identity is checked exactly: any workflow on any branch of the # repository can attest a file. gh before 2.49 cannot check this, and # the signature above already has, so an old gh only goes unused. $provenanceCheck = 'not checked: that needs the GitHub CLI (gh) 2.49 or later, signed in' $gh = Get-Command gh -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 if ($null -ne $gh) { $ghHelp = Invoke-FirmfactProgram $gh.Source @('attestation', 'verify', '--help') if ($ghHelp.ExitCode -ne 0 -or -not ($ghHelp.Output + $ghHelp.Errors).Contains('--cert-identity')) { $provenanceCheck = 'not checked: the GitHub CLI (gh) here is older than 2.49, which cannot check it' } elseif ((Invoke-FirmfactProgram $gh.Source @('auth', 'status', '--hostname', 'github.com')).ExitCode -eq 0) { Write-Host 'Asking GitHub how the archive was built.' $attestation = Invoke-FirmfactProgram $gh.Source @('attestation', 'verify', (Join-Path $tempDir $archive), '--repo', $Repository, '--cert-identity', "$ReleaseWorkflow@refs/tags/v$version", '--deny-self-hosted-runners') if ($attestation.ExitCode -ne 0) { $said = @(($attestation.Errors + "`n" + $attestation.Output).Trim() -split "`r?`n" | Where-Object { $_.Trim() }) $lines = @($said | Where-Object { $_ -match 'error|fail|unknown|denied|not found|no attestation|mismatch' } | Select-Object -First 3) if ($lines.Count -eq 0) { $lines = @($said | Select-Object -Last 3) } Stop-FirmfactInstall "GitHub does not attest that the $Repository release workflow built $archive from tag v${version}:" $lines } $provenanceCheck = "GitHub attests that the $Repository release workflow built this archive from tag v$version" } } # 2. The archive against its line in checksums.txt. $want = Get-FirmfactChecksum (Join-Path $tempDir 'checksums.txt') $archive if (-not $want) { Stop-FirmfactInstall "checksums.txt lists no SHA-256 for $archive." } $got = Get-FirmfactSha256 (Join-Path $tempDir $archive) if ($got -ne $want) { Stop-FirmfactInstall "$archive does not match its SHA-256 in checksums.txt." @("The download may have been changed on its way here. Please tell $SecurityEmail.") } # 3. A trial run, from a copy next to the target so that a rename # puts it in place. if (-not (Test-Path -LiteralPath $dir -PathType Container)) { try { New-Item -ItemType Directory -Path $dir -Force | Out-Null } catch { Stop-FirmfactInstall "could not create $dir." @('Set FIRMFACT_INSTALL_DIR to a folder you can write to.') } } $target = Join-Path $dir 'firmfact.exe' if (Test-Path -LiteralPath $target -PathType Container) { Stop-FirmfactInstall "$target is a folder." } $staged = Join-Path $dir ('.firmfact-install-' + [guid]::NewGuid().ToString('N').Substring(0, 12) + '.exe') try { Expand-FirmfactProgram (Join-Path $tempDir $archive) $staged } catch { $problem = $_.Exception while ($null -ne $problem.InnerException) { $problem = $problem.InnerException } if ($problem -is [System.UnauthorizedAccessException]) { Stop-FirmfactInstall "could not write to $dir." @('Set FIRMFACT_INSTALL_DIR to a folder you can write to.') } throw } $trial = Invoke-FirmfactProgram $staged @('--version') if ($trial.ExitCode -ne 0) { Stop-FirmfactInstall 'the new firmfact does not run on this machine:' @(($trial.Errors.Trim() -split "`r?`n") | Select-Object -First 3) } $words = @($trial.Output.Trim() -split '\s+') $reported = $words[$words.Count - 1] if ($reported.StartsWith('v')) { $reported = $reported.Substring(1) } if ($reported -ne $version) { if (-not $reported) { $reported = 'nothing' } Stop-FirmfactInstall "the new firmfact reports version $reported, not $version." } # How firmfact judges its own install decides whether firmfact update # replaces it; a copy it takes for a package manager's, it leaves # alone. $report = Invoke-FirmfactProgram $staged @('version', '--json') $method = '' if ($report.ExitCode -eq 0) { try { $method = [string](ConvertFrom-Json -InputObject $report.Output).install_method } catch { $method = '' } } if ($method -eq 'homebrew' -or $method -eq 'scoop') { Stop-FirmfactInstall "firmfact takes a copy in $dir for a $method install, so firmfact update would not update it." @("Set FIRMFACT_INSTALL_DIR to another folder, or install with $method.") } # A running firmfact.exe cannot be replaced, but it can be renamed: # the one in place moves aside to firmfact.exe.old, which firmfact # removes the next time it starts. $old = "$target.old" if (Test-Path -LiteralPath $target) { Remove-Item -LiteralPath $old -Force -ErrorAction SilentlyContinue if (Test-Path -LiteralPath $old) { Stop-FirmfactInstall "$old is still in use." @('Close any firmfact that is running, then run this again.') } Move-Item -LiteralPath $target -Destination $old } try { Move-Item -LiteralPath $staged -Destination $target } catch { if (Test-Path -LiteralPath $old) { Move-Item -LiteralPath $old -Destination $target -ErrorAction SilentlyContinue } throw } $staged = $null $installed = $true Remove-Item -LiteralPath $old -Force -ErrorAction SilentlyContinue Write-Host '' Write-Host "firmfact $version ($versionSource) is installed at ${target}:" $report = Invoke-FirmfactProgram $target @('version') foreach ($line in ($report.Output.TrimEnd() -split "`r?`n")) { Write-Host " $line" } Write-Host '' Write-Host 'Checked before it was installed:' Write-Host (' {0,-11} {1}' -f 'signature', $signatureCheck) Write-Host (' {0,-11} {1}' -f 'provenance', $provenanceCheck) Write-Host (' {0,-11} {1}' -f 'checksum', "$archive matches its SHA-256 in checksums.txt") Write-Host (' {0,-11} {1}' -f 'trial run', "the new program runs here and reports version $version") Write-Host '' $onPath = $false foreach ($entry in ($env:Path -split ';')) { if ($entry -and $entry.TrimEnd('\') -ieq $dir) { $onPath = $true } } try { if (Add-FirmfactUserPath $dir) { Write-Host "Added $dir to your user Path. Open a new terminal to use firmfact." } elseif (-not $onPath) { Write-Host "$dir is on your user Path. Open a new terminal to use firmfact." } } catch { Write-Host "Could not add $dir to your user Path: $($_.Exception.Message)" -ForegroundColor Yellow Write-Host 'Add it under Settings, System, About, Advanced system settings, Environment Variables.' } if (-not $onPath) { $env:Path = "$env:Path;$dir" } $first = Get-Command firmfact -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 if ($null -ne $first -and $first.Source -ne $target) { Write-Host '' Write-Host "Another firmfact comes first on your Path: $($first.Source)" -ForegroundColor Yellow Write-Host "Remove it, or put $dir before its folder, to use this one." } Write-Host 'Tab completion for PowerShell: firmfact completion powershell --help' Write-Host '' Write-Host 'Next:' Write-Host ' firmfact signup create an account from the terminal' Write-Host ' firmfact login sign in to an account you already have' Write-Host ' firmfact update update to a newer release when there is one' Write-Host "Documentation: $CliPage" } catch { $failure = $_.Exception } finally { if ($null -ne $staged) { Remove-Item -LiteralPath $staged -Force -ErrorAction SilentlyContinue } if ($null -ne $tempDir) { Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue } if ($null -ne $client) { $client.Dispose() } if ($null -ne $savedProtocol) { [System.Net.ServicePointManager]::SecurityProtocol = $savedProtocol } } if ($null -ne $failure) { $message = $failure.Message $details = @() if ($failure.Data.Contains('FirmfactDetails')) { $details = @($failure.Data['FirmfactDetails']) } else { while ($null -ne $failure.InnerException) { $failure = $failure.InnerException } $message = $failure.Message } Write-Host "firmfact installer: $message" -ForegroundColor Red foreach ($detail in $details) { if ($detail) { Write-Host " $detail" } } if ($installed) { Exit-FirmfactFailure "firmfact installer: $message" } Write-Host 'Nothing was installed.' Exit-FirmfactFailure "firmfact was not installed: $message" } } # irm | iex runs this in your own session; leave nothing of it behind there, # whether it succeeds or fails. try { Install-Firmfact -Arguments @($args) } finally { Remove-Item -LiteralPath Function:\Install-Firmfact -ErrorAction SilentlyContinue }