Files
shopdb-flask/plugins/endpoints/client/Install-ShopDBServerCertificate.ps1
cproudlock da4b224ac1 Make rollback, first upgrades and pin changes work as tested on Windows
Found in an end-to-end test on a Windows 11 VM against a self-signed server.

- Rollback: a 1.20 agent re-enrolls within a second of an un-enroll, so an
  older agent pushed afterwards arrived on an enrolled PC and was refused.
  Revoke now takes {"blockreenroll": true, "reason": ...}; a blocked PC is
  never issued a credential and keeps working on the shared key until
  POST .../assets/<assetid>/allow-reenroll. The Enrollment page offers the
  block when un-enrolling and shows blocked PCs with an allow button.
- First upgrade of old agents: agents before 1.20 check the server
  certificate when downloading the upgrade, so a store wave failed on PCs that
  do not trust a self-signed server while the Migration tab said ready. A
  failed download now marks the PC blocked with "server certificate not
  trusted by this PC". New Install-ShopDBServerCertificate.ps1 (GE-Enforce,
  SYSTEM) adds a non-CA server certificate to the trusted roots after checking
  its thumbprint; the server certificate page offers a .cer download and the
  SHA-1 thumbprint.
- The generated installer writes ServerPins (with an optional next pin for a
  planned rotation), can leave the shared key out so the PC waits for
  approval, never touches an existing credential, and skips the trusted-root
  import for a CA certificate.
- The agent re-reads server pins about every 60 seconds and recycles pooled
  connections within 2 minutes, so a pin change needs no restart.
- An alert can only be acknowledged by a PC it targets.
- A wave no longer counts a PC as failed after a later wave upgraded it.
- The scripts report a pin mismatch only when the pin really is missing, and
  otherwise log the real error.
- Runbook: trust step before the first wave, rollback order, pin re-read,
  verifying the pin against the live URL; agent contract updated.
2026-09-15 13:37:37 -04:00

195 lines
7.3 KiB
PowerShell

#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 <share>\shopdb-server.cer -Thumbprint <sha1thumbprint>
Trust the server certificate on this PC.
.EXAMPLE
.\Install-ShopDBServerCertificate.ps1 -CertPath .\shopdb-server.cer -Thumbprint <sha1thumbprint> -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