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.
195 lines
7.3 KiB
PowerShell
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
|