Add the enrollment page, move scripts to per-PC credentials, and document the rollout
- Settings > Endpoints > Enrollment. Migration tab: readiness counts and progress, each PC's state with the reasons it is blocked, "Upgrade selected" and "Upgrade next 25", and live wave progress. Credentials tab: enrolled PCs with revoke, pending requests with approve and reject, and the shared key and auto-enroll switches. Turning the shared key off asks for confirmation and shows how many PCs are not enrolled. - The server certificate page shows the SPKI pin to put in the GE-Enforce pin entry. - The agent upgrade script and the asset and printer reporters send the PC's credential when it has one (over https, only to the issuing server), fall back to the shared key, check server pins, and log refusals clearly. - New Set-ShopDBServerPins.ps1 (GE-Enforce entry) and Get-ShopDBServerPin.ps1. - AGENT-CONTRACT.md documents per-PC credentials; the rollout runbook covers phases, gates, rollback, pin and wave entries, and every place the shared key lives and how to clear it at cutover. - Removed the 09-14 certificate design notes and dated session reports, and two VM notes that carried a live collector key and advised plain http. - Corrected the GE-Enforce collector note, which claimed a plain registry token is protected by ACL.
This commit is contained in:
157
plugins/endpoints/client/Get-ShopDBServerPin.ps1
Normal file
157
plugins/endpoints/client/Get-ShopDBServerPin.ps1
Normal file
@@ -0,0 +1,157 @@
|
||||
#requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Print the SPKI SHA-256 pin of a ShopDB server certificate, for building the
|
||||
Set-ShopDBServerPins.ps1 entry.
|
||||
|
||||
.DESCRIPTION
|
||||
The pin is the lowercase hex SHA-256 of the leaf certificate's
|
||||
SubjectPublicKeyInfo: the same value the Server Certificate settings page
|
||||
shows and the endpoint agent compares. It depends only on the key pair, so
|
||||
a certificate renewed with the same key keeps its pin.
|
||||
|
||||
Give it either a URL (the certificate the server actually presents is read
|
||||
from the TLS handshake; it is NOT trusted or validated, only hashed) or a
|
||||
certificate file (.cer, .crt, .pem, DER or PEM). Use the file form to pin a
|
||||
new certificate BEFORE it goes live on the server.
|
||||
|
||||
Reading from a URL trusts the network path at that moment. Compare the
|
||||
printed pin with the one on the Server Certificate settings page before
|
||||
rolling it out.
|
||||
|
||||
.PARAMETER Url
|
||||
https://<server>[:port]. Only the host and port are used.
|
||||
|
||||
.PARAMETER Path
|
||||
A certificate file.
|
||||
|
||||
.EXAMPLE
|
||||
.\Get-ShopDBServerPin.ps1 -Url https://<server>
|
||||
|
||||
.EXAMPLE
|
||||
.\Get-ShopDBServerPin.ps1 -Path .\server.crt
|
||||
#>
|
||||
[CmdletBinding(DefaultParameterSetName = 'Url')]
|
||||
param(
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'Url', Position = 0)]
|
||||
[string]$Url,
|
||||
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'Path')]
|
||||
[string]$Path,
|
||||
|
||||
[int]$TimeoutSec = 15
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$source = @'
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net.Security;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Authentication;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
public static class ShopDbServerPinReader
|
||||
{
|
||||
public static byte[] FetchLeafCertificate(string host, int port, int timeoutMilliseconds)
|
||||
{
|
||||
using (TcpClient client = new TcpClient())
|
||||
{
|
||||
IAsyncResult pending = client.BeginConnect(host, port, null, null);
|
||||
if (!pending.AsyncWaitHandle.WaitOne(timeoutMilliseconds))
|
||||
{
|
||||
throw new TimeoutException("Timed out connecting to " + host + ":" + port);
|
||||
}
|
||||
client.EndConnect(pending);
|
||||
client.ReceiveTimeout = timeoutMilliseconds;
|
||||
client.SendTimeout = timeoutMilliseconds;
|
||||
|
||||
byte[] captured = null;
|
||||
RemoteCertificateValidationCallback capture = delegate(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors errors)
|
||||
{
|
||||
if (certificate != null) { captured = certificate.GetRawCertData(); }
|
||||
return true;
|
||||
};
|
||||
using (SslStream ssl = new SslStream(client.GetStream(), false, capture))
|
||||
{
|
||||
ssl.AuthenticateAsClient(host, null, SslProtocols.Tls12, false);
|
||||
}
|
||||
if (captured == null) { throw new InvalidOperationException("The server presented no certificate"); }
|
||||
return captured;
|
||||
}
|
||||
}
|
||||
|
||||
// Walks the DER: Certificate > TBSCertificate > [0] version (optional),
|
||||
// serialNumber, signature, issuer, validity, subject, subjectPublicKeyInfo.
|
||||
public static string SpkiSha256Hex(byte[] der)
|
||||
{
|
||||
int length;
|
||||
int position = EnterSequence(der, 0, out length);
|
||||
position = EnterSequence(der, position, out length);
|
||||
if (der[position] == 0xA0) { position = Skip(der, position); }
|
||||
for (int field = 0; field < 5; field++) { position = Skip(der, position); }
|
||||
if (der[position] != 0x30) { throw new FormatException("SubjectPublicKeyInfo not found"); }
|
||||
int end = Skip(der, position);
|
||||
using (SHA256 sha = SHA256.Create())
|
||||
{
|
||||
byte[] hash = sha.ComputeHash(der, position, end - position);
|
||||
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
private static int ReadLength(byte[] der, int position, out int length)
|
||||
{
|
||||
int first = der[position];
|
||||
if (first < 0x80) { length = first; return position + 1; }
|
||||
int count = first & 0x7F;
|
||||
if (count == 0 || count > 4) { throw new FormatException("Unsupported DER length"); }
|
||||
length = 0;
|
||||
for (int i = 1; i <= count; i++) { length = (length << 8) | der[position + i]; }
|
||||
return position + 1 + count;
|
||||
}
|
||||
|
||||
private static int EnterSequence(byte[] der, int position, out int length)
|
||||
{
|
||||
if (der[position] != 0x30) { throw new FormatException("Expected SEQUENCE"); }
|
||||
return ReadLength(der, position + 1, out length);
|
||||
}
|
||||
|
||||
private static int Skip(byte[] der, int position)
|
||||
{
|
||||
int length;
|
||||
int content = ReadLength(der, position + 1, out length);
|
||||
return content + length;
|
||||
}
|
||||
}
|
||||
'@
|
||||
|
||||
if (-not ('ShopDbServerPinReader' -as [type])) {
|
||||
Add-Type -TypeDefinition $source
|
||||
}
|
||||
|
||||
try {
|
||||
if ($PSCmdlet.ParameterSetName -eq 'Path') {
|
||||
$resolved = (Resolve-Path -LiteralPath $Path).ProviderPath
|
||||
$certificate = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 -ArgumentList $resolved
|
||||
$der = $certificate.RawData
|
||||
$label = "$resolved (subject $($certificate.Subject), expires $($certificate.NotAfter.ToString('yyyy-MM-dd')))"
|
||||
} else {
|
||||
$uri = [Uri]$Url
|
||||
if ($uri.Scheme -ne 'https') { throw "The URL must be https ($Url)" }
|
||||
$der = [ShopDbServerPinReader]::FetchLeafCertificate($uri.Host, $uri.Port, $TimeoutSec * 1000)
|
||||
$certificate = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 -ArgumentList (,$der)
|
||||
$label = "$($uri.Host):$($uri.Port) (subject $($certificate.Subject), expires $($certificate.NotAfter.ToString('yyyy-MM-dd')))"
|
||||
}
|
||||
$pin = [ShopDbServerPinReader]::SpkiSha256Hex($der)
|
||||
} catch {
|
||||
Write-Error "Could not read a certificate: $($_.Exception.Message)"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Certificate: $label"
|
||||
Write-Host "Pin (SPKI SHA-256):"
|
||||
Write-Output $pin
|
||||
exit 0
|
||||
182
plugins/endpoints/client/Set-ShopDBServerPins.ps1
Normal file
182
plugins/endpoints/client/Set-ShopDBServerPins.ps1
Normal file
@@ -0,0 +1,182 @@
|
||||
#requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Write the ShopDB server public key pins to HKLM:\SOFTWARE\GE\ShopDB
|
||||
ServerPins, so the endpoint agent and the SYSTEM reporters trust only this
|
||||
site's server key.
|
||||
|
||||
.DESCRIPTION
|
||||
Each pin is the lowercase hex SHA-256 of the server leaf certificate's
|
||||
SubjectPublicKeyInfo. The ShopDB Server Certificate settings page shows the
|
||||
current one, and Get-ShopDBServerPin.ps1 computes one from a URL or a
|
||||
certificate file.
|
||||
|
||||
Written as REG_MULTI_SZ in BOTH registry views (64-bit and WOW6432Node),
|
||||
the same way CollectorKey is, so a 32-bit reader sees the same set.
|
||||
|
||||
Meant to run as a GE-Enforce PS1 entry under the SYSTEM task, before agent
|
||||
1.20 reaches the PC. Idempotent: the set on the PC is compared with the
|
||||
requested set first, and nothing is written when they already match, so it
|
||||
is safe every cycle.
|
||||
|
||||
ROTATING THE SERVER KEY: pass the old pin AND the new pin until every PC has
|
||||
both, switch the server certificate, then drop the old pin. A PC holding
|
||||
only the old pin refuses the new certificate.
|
||||
|
||||
.PARAMETER Pins
|
||||
One or more pins separated by commas, semicolons or spaces. Colons and case
|
||||
are ignored. Every value must be 64 hex characters or nothing is written.
|
||||
|
||||
.PARAMETER Clear
|
||||
Remove ServerPins from both views. Pinning is then off and the normal
|
||||
Windows certificate checks apply.
|
||||
|
||||
.PARAMETER LogPath
|
||||
Append a transcript here as well as writing to the pipeline.
|
||||
|
||||
.EXAMPLE
|
||||
.\Set-ShopDBServerPins.ps1 -Pins <pin>
|
||||
Pin one server key.
|
||||
|
||||
.EXAMPLE
|
||||
.\Set-ShopDBServerPins.ps1 -Pins "<oldpin>,<newpin>" -WhatIf
|
||||
Show what a key rotation would write without touching the registry.
|
||||
|
||||
.NOTES
|
||||
Exit codes:
|
||||
0 written, or already correct
|
||||
1 bad input or an error stopped the write
|
||||
#>
|
||||
[CmdletBinding(SupportsShouldProcess = $true)]
|
||||
param(
|
||||
[string]$Pins,
|
||||
[switch]$Clear,
|
||||
[string]$LogPath
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$SubKey = 'SOFTWARE\GE\ShopDB'
|
||||
$ValueName = 'ServerPins'
|
||||
|
||||
function Log {
|
||||
param([string]$Message)
|
||||
$line = "{0} [Set-ShopDBServerPins] {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message
|
||||
Write-Output $line
|
||||
if ($LogPath) {
|
||||
try { Add-Content -LiteralPath $LogPath -Value $line -ErrorAction Stop } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
function Get-CurrentPins {
|
||||
param([Microsoft.Win32.RegistryView]$View)
|
||||
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $View)
|
||||
try {
|
||||
$key = $base.OpenSubKey($SubKey)
|
||||
if (-not $key) { return @() }
|
||||
try {
|
||||
$value = $key.GetValue($ValueName)
|
||||
if ($null -eq $value) { return @() }
|
||||
return @(@($value) | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ })
|
||||
} finally { $key.Dispose() }
|
||||
} finally { $base.Dispose() }
|
||||
}
|
||||
|
||||
function Test-SamePins {
|
||||
param([string[]]$Left, [string[]]$Right)
|
||||
$a = @($Left | Sort-Object -Unique)
|
||||
$b = @($Right | Sort-Object -Unique)
|
||||
if ($a.Count -ne $b.Count) { return $false }
|
||||
for ($i = 0; $i -lt $a.Count; $i++) {
|
||||
if ($a[$i] -ne $b[$i]) { return $false }
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
# --- 1. validate the request ------------------------------------------------
|
||||
|
||||
$requested = @()
|
||||
if (-not $Clear) {
|
||||
if (-not $Pins -or -not $Pins.Trim()) {
|
||||
Log 'ERROR no -Pins given (use -Clear to remove pinning)'
|
||||
exit 1
|
||||
}
|
||||
foreach ($raw in ($Pins -split '[,;\s]+')) {
|
||||
if (-not $raw) { continue }
|
||||
$pin = ($raw -replace ':', '').ToLowerInvariant()
|
||||
if ($pin -notmatch '^[0-9a-f]{64}$') {
|
||||
Log "ERROR '$raw' is not a SHA-256 pin (64 hex characters); nothing written"
|
||||
exit 1
|
||||
}
|
||||
if ($requested -notcontains $pin) { $requested += $pin }
|
||||
}
|
||||
if ($requested.Count -eq 0) {
|
||||
Log 'ERROR -Pins held no values; nothing written'
|
||||
exit 1
|
||||
}
|
||||
Log "requested $($requested.Count) pin(s): $($requested -join ', ')"
|
||||
}
|
||||
|
||||
# --- 2. write each view -----------------------------------------------------
|
||||
|
||||
$views = @(
|
||||
@{ Name = '64-bit'; View = [Microsoft.Win32.RegistryView]::Registry64 },
|
||||
@{ Name = '32-bit'; View = [Microsoft.Win32.RegistryView]::Registry32 }
|
||||
)
|
||||
|
||||
$failed = $false
|
||||
foreach ($entry in $views) {
|
||||
try {
|
||||
$current = @(Get-CurrentPins -View $entry.View)
|
||||
|
||||
if ($Clear) {
|
||||
if ($current.Count -eq 0) {
|
||||
Log "$($entry.Name): no ServerPins; nothing to do"
|
||||
continue
|
||||
}
|
||||
if ($PSCmdlet.ShouldProcess("HKLM\$SubKey ($($entry.Name))", "Remove $ValueName")) {
|
||||
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $entry.View)
|
||||
try {
|
||||
$key = $base.OpenSubKey($SubKey, $true)
|
||||
if ($key) {
|
||||
try { $key.DeleteValue($ValueName, $false) } finally { $key.Dispose() }
|
||||
}
|
||||
} finally { $base.Dispose() }
|
||||
Log "$($entry.Name): removed ServerPins"
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (Test-SamePins -Left $current -Right $requested) {
|
||||
Log "$($entry.Name): ServerPins already correct; nothing to do"
|
||||
continue
|
||||
}
|
||||
|
||||
$action = "Set $ValueName to $($requested.Count) pin(s) (was $($current.Count))"
|
||||
if ($PSCmdlet.ShouldProcess("HKLM\$SubKey ($($entry.Name))", $action)) {
|
||||
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $entry.View)
|
||||
try {
|
||||
$key = $base.CreateSubKey($SubKey)
|
||||
try {
|
||||
$key.SetValue($ValueName, [string[]]$requested, [Microsoft.Win32.RegistryValueKind]::MultiString)
|
||||
} finally { $key.Dispose() }
|
||||
} finally { $base.Dispose() }
|
||||
|
||||
# Read back rather than trust SetValue.
|
||||
$after = @(Get-CurrentPins -View $entry.View)
|
||||
if (-not (Test-SamePins -Left $after -Right $requested)) {
|
||||
Log "ERROR $($entry.Name): ServerPins did not read back as written"
|
||||
$failed = $true
|
||||
continue
|
||||
}
|
||||
Log "$($entry.Name): wrote ServerPins ($($requested.Count) pin(s), was $($current.Count))"
|
||||
}
|
||||
} catch {
|
||||
Log "ERROR $($entry.Name): $($_.Exception.Message)"
|
||||
$failed = $true
|
||||
}
|
||||
}
|
||||
|
||||
if ($failed) { exit 1 }
|
||||
exit 0
|
||||
Reference in New Issue
Block a user