#requires -Version 5.1 <# .SYNOPSIS Trust the ShopDB server's own certificate on this PC by adding it to LocalMachine\Root, after checking it is the expected certificate and not a certificate authority. .DESCRIPTION WHY: endpoint agents older than 1.20 check the normal Windows certificate chain when they download the upgrade package. They know nothing about ServerPins. A server with a self-signed certificate is therefore refused by those agents, and the upgrade to 1.20 never lands. The certificate has to be trusted on the PC BEFORE a store wave queues the upgrade. PCs installed by the server's generated installer already trust it. PCs installed from the share do not, and need this entry. Adding a non-CA leaf certificate to Root trusts exactly that one server. It cannot be used to vouch for any other site, so the blast radius is the server itself. A CA certificate in Root would trust everything that CA signs, which is why a CA is refused unless -AllowCa is passed. Get the .cer from Settings > Endpoints > Server certificate (download), or GET /api/endpoints/settings/server-cert/download. The expected thumbprint is the sha1thumbprint field of GET /api/endpoints/settings/server-cert. Meant to run as a GE-Enforce PS1 entry under the SYSTEM task, in the same targeting as the Set-ShopDBServerPins.ps1 entry. Idempotent: the Root store is checked first and nothing is written when the certificate is already there, so it is safe every cycle. Nothing else is removed or changed. .PARAMETER CertPath Path to the server certificate file, DER or PEM (.cer / .crt / .pem). .PARAMETER Thumbprint Expected SHA-1 thumbprint of the certificate. Spaces, colons and case are ignored. The file is refused when it does not match. .PARAMETER AllowCa Import even when the certificate is a CA (basicConstraints CA=true). Only for a site that deliberately trusts its own private CA. .PARAMETER LogPath Append a transcript here as well as writing to the pipeline. .EXAMPLE .\Install-ShopDBServerCertificate.ps1 -CertPath \shopdb-server.cer -Thumbprint Trust the server certificate on this PC. .EXAMPLE .\Install-ShopDBServerCertificate.ps1 -CertPath .\shopdb-server.cer -Thumbprint -WhatIf Show what would be imported without touching the store. .NOTES Exit codes: 0 imported, or already trusted 1 bad input, mismatch, refused CA, or an error stopped the import #> [CmdletBinding(SupportsShouldProcess = $true)] param( [Parameter(Mandatory = $true)] [string]$CertPath, [Parameter(Mandatory = $true)] [string]$Thumbprint, [switch]$AllowCa, [string]$LogPath ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' function Log { param([string]$Message) $line = "{0} [Install-ShopDBServerCertificate] {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message Write-Output $line if ($LogPath) { try { Add-Content -LiteralPath $LogPath -Value $line -WhatIf:$false -ErrorAction Stop } catch { } } } function Read-Certificate { param([string]$Path) $bytes = [System.IO.File]::ReadAllBytes($Path) $text = [System.Text.Encoding]::ASCII.GetString($bytes) $match = [regex]::Match($text, '-----BEGIN CERTIFICATE-----(.+?)-----END CERTIFICATE-----', [System.Text.RegularExpressions.RegexOptions]::Singleline) if ($match.Success) { $bytes = [Convert]::FromBase64String(($match.Groups[1].Value -replace '\s', '')) } return New-Object System.Security.Cryptography.X509Certificates.X509Certificate2(,[byte[]]$bytes) } # --- 1. validate the request ------------------------------------------------ $expected = ($Thumbprint -replace '[\s:]', '').ToUpperInvariant() if ($expected -notmatch '^[0-9A-F]{40}$') { Log "ERROR '$Thumbprint' is not a SHA-1 thumbprint (40 hex characters); nothing imported" exit 1 } if (-not (Test-Path -LiteralPath $CertPath -PathType Leaf)) { Log "ERROR certificate file not found: $CertPath" exit 1 } try { $cert = Read-Certificate -Path $CertPath } catch { Log "ERROR could not read a certificate from $CertPath : $($_.Exception.Message)" exit 1 } $actual = $cert.Thumbprint.ToUpperInvariant() Log "file: $CertPath" Log "subject: $($cert.Subject); issuer: $($cert.Issuer); expires: $($cert.NotAfter.ToString('yyyy-MM-dd'))" if ($actual -ne $expected) { Log "ERROR thumbprint of the file is $actual, expected $expected; refusing to import" exit 1 } if ($cert.HasPrivateKey) { Log 'ERROR the file carries a private key; export the public certificate only' exit 1 } # --- 2. refuse a CA --------------------------------------------------------- $isCa = $false foreach ($extension in $cert.Extensions) { if ($extension.Oid.Value -eq '2.5.29.19') { $constraints = [System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension]$extension $isCa = $constraints.CertificateAuthority } } if ($isCa) { if (-not $AllowCa) { Log ('ERROR the certificate is a CA (basicConstraints CA=true). In Root it would trust every ' + 'certificate it signs, not just the ShopDB server. Refusing; pass -AllowCa only if that is intended.') exit 1 } Log 'WARN the certificate is a CA and -AllowCa was passed; everything it signs will be trusted' } else { Log 'not a CA (basicConstraints CA absent or false); trusts only this server' } if ($cert.Subject -ne $cert.Issuer) { Log 'WARN the certificate is not self-signed; Windows may still need its issuer to build the chain' } if ($cert.NotAfter -lt (Get-Date)) { Log 'WARN the certificate has expired; the server needs a new one, importing anyway' } # --- 3. import into LocalMachine\Root --------------------------------------- $store = New-Object System.Security.Cryptography.X509Certificates.X509Store( [System.Security.Cryptography.X509Certificates.StoreName]::Root, [System.Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine) try { $store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly) try { $present = $store.Certificates.Find( [System.Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint, $actual, $false) $alreadyTrusted = $present.Count -gt 0 } finally { $store.Close() } if ($alreadyTrusted) { Log "LocalMachine\Root already holds $actual; nothing to do" exit 0 } if ($PSCmdlet.ShouldProcess("Cert:\LocalMachine\Root", "Import $actual ($($cert.Subject))")) { $store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite) try { $store.Add($cert) } finally { $store.Close() } # Read back rather than trust Add. $store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly) try { $after = $store.Certificates.Find( [System.Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint, $actual, $false) } finally { $store.Close() } if ($after.Count -eq 0) { Log "ERROR $actual did not read back from LocalMachine\Root after import" exit 1 } Log "imported $actual into LocalMachine\Root" } } catch { Log "ERROR $($_.Exception.Message)" exit 1 } finally { $store.Close() } exit 0