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:
@@ -25,6 +25,14 @@
|
||||
# succeeds, the agent that would have reported it has been restarted. The new
|
||||
# version is confirmed on the next check-in. This script's own transcript is
|
||||
# the local record; see $LogFile below.
|
||||
#
|
||||
# AUTH AND PINS
|
||||
# The download authenticates the way the agent does: the per-PC credential from
|
||||
# the agent's SYSTEM-only credential.bin when this PC has enrolled, otherwise
|
||||
# the shared CollectorKey. When ServerPins is set (registry, or serverpins in
|
||||
# appsettings.json) the server's TLS key must match a pin or nothing is
|
||||
# downloaded. A 1.19 agent upgrading to 1.20 has no credential yet and uses the
|
||||
# shared key; the new agent enrolls itself after it starts.
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
@@ -59,6 +67,230 @@ function Write-Log {
|
||||
try { Add-Content -Path $LogFile -Value $line -ErrorAction Stop } catch { }
|
||||
}
|
||||
|
||||
# The shared credential block below logs through Log, as the reporters do.
|
||||
function Log([string] $msg) { Write-Log $msg }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-PC credential and server pin. Kept in step with the same block in
|
||||
# Report-AssetToShopDB.ps1, Report-PrintersToShopDB.ps1 and
|
||||
# upgrade-shopdbagent.ps1: each runs standalone, so the text is duplicated.
|
||||
#
|
||||
# Credential: the endpoint agent (1.20+) enrolls and stores its own credential
|
||||
# in credential.bin, DPAPI LocalMachine, readable only by SYSTEM and
|
||||
# Administrators. When present it is sent as X-Endpoint-Credential and the
|
||||
# shared key is not sent. Without it the shared CollectorKey goes as X-API-Key.
|
||||
#
|
||||
# Pins: HKLM\SOFTWARE\GE\ShopDB ServerPins (REG_MULTI_SZ), lowercase hex
|
||||
# SHA-256 of the server leaf certificate's SubjectPublicKeyInfo. When any pin
|
||||
# is set the TLS handshake is accepted only if the leaf key matches one.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
$ShopDbCredentialFile = Join-Path $env:ProgramData 'ShopDB\EndpointAgent\credential\credential.bin'
|
||||
|
||||
$ShopDbPinSource = @'
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Security;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
public static class ShopDbServerPinValidator
|
||||
{
|
||||
private static readonly HashSet<string> Pins = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
public static string LastPresented;
|
||||
|
||||
public static void Install(string[] pins)
|
||||
{
|
||||
Pins.Clear();
|
||||
foreach (string pin in pins)
|
||||
{
|
||||
if (!String.IsNullOrEmpty(pin)) { Pins.Add(pin); }
|
||||
}
|
||||
ServicePointManager.ServerCertificateValidationCallback = Validate;
|
||||
}
|
||||
|
||||
public static bool Validate(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors errors)
|
||||
{
|
||||
if (Pins.Count == 0) { return errors == SslPolicyErrors.None; }
|
||||
if (certificate == null) { return false; }
|
||||
try
|
||||
{
|
||||
LastPresented = SpkiSha256Hex(certificate.GetRawCertData());
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
LastPresented = null;
|
||||
return false;
|
||||
}
|
||||
return Pins.Contains(LastPresented);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
'@
|
||||
|
||||
function Get-ShopDbServerPins {
|
||||
# Both registry views, like CollectorKey. Anything not 64 hex chars is dropped.
|
||||
$pins = @()
|
||||
foreach ($view in @([Microsoft.Win32.RegistryView]::Registry64, [Microsoft.Win32.RegistryView]::Registry32)) {
|
||||
try {
|
||||
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $view)
|
||||
try {
|
||||
$key = $base.OpenSubKey('SOFTWARE\GE\ShopDB')
|
||||
if ($key) {
|
||||
try {
|
||||
foreach ($value in @($key.GetValue('ServerPins'))) {
|
||||
if ($null -eq $value) { continue }
|
||||
$pin = ([string]$value -replace '[\s:]', '').ToLowerInvariant()
|
||||
if ($pin -match '^[0-9a-f]{64}$' -and $pins -notcontains $pin) { $pins += $pin }
|
||||
}
|
||||
} finally { $key.Dispose() }
|
||||
}
|
||||
} finally { $base.Dispose() }
|
||||
} catch {}
|
||||
}
|
||||
return ,$pins
|
||||
}
|
||||
|
||||
function Enable-ShopDbServerPins([string[]]$Pins) {
|
||||
# True when pinning is active. False means pins are set but could not be
|
||||
# enforced; the caller must not send anything then.
|
||||
if ($PSVersionTable.PSEdition -eq 'Core') {
|
||||
# PowerShell 7 web cmdlets ignore ServicePointManager, so the check
|
||||
# would silently not happen.
|
||||
Log 'ERROR ServerPins is set; pin checking needs Windows PowerShell 5.1 (powershell.exe), not pwsh'
|
||||
return $false
|
||||
}
|
||||
try {
|
||||
if (-not ('ShopDbServerPinValidator' -as [type])) {
|
||||
Add-Type -TypeDefinition $ShopDbPinSource -ErrorAction Stop
|
||||
}
|
||||
[ShopDbServerPinValidator]::Install($Pins)
|
||||
return $true
|
||||
} catch {
|
||||
Log "ERROR ServerPins is set but pin checking could not be enabled: $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Get-ShopDbEndpointCredential([string]$TargetUrl) {
|
||||
# Empty when there is none or it cannot be used for this URL.
|
||||
if (-not (Test-Path -LiteralPath $ShopDbCredentialFile)) { return '' }
|
||||
try {
|
||||
Add-Type -AssemblyName System.Security -ErrorAction Stop
|
||||
$protected = [System.IO.File]::ReadAllBytes($ShopDbCredentialFile)
|
||||
$plain = [System.Security.Cryptography.ProtectedData]::Unprotect(
|
||||
$protected, $null, [System.Security.Cryptography.DataProtectionScope]::LocalMachine)
|
||||
$record = [System.Text.Encoding]::UTF8.GetString($plain) | ConvertFrom-Json
|
||||
} catch {
|
||||
Log "WARN could not read the endpoint credential ($ShopDbCredentialFile): $($_.Exception.Message)"
|
||||
return ''
|
||||
}
|
||||
$credential = [string]$record.credential
|
||||
if (-not $credential.StartsWith('sdbe_')) {
|
||||
Log 'WARN endpoint credential file has no usable credential; ignoring it'
|
||||
return ''
|
||||
}
|
||||
if (-not $TargetUrl.StartsWith('https://', [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
Log "WARN not sending the endpoint credential over a non-https URL ($TargetUrl)"
|
||||
return ''
|
||||
}
|
||||
# Never hand a credential to a server other than the one that issued it.
|
||||
$issuer = ([string]$record.baseurl).Trim().TrimEnd('/')
|
||||
if ($issuer -and -not ($TargetUrl.TrimEnd('/') + '/').StartsWith($issuer + '/', [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
Log "WARN endpoint credential was issued by $issuer, not the target $TargetUrl; not sending it"
|
||||
return ''
|
||||
}
|
||||
return $credential
|
||||
}
|
||||
|
||||
function Get-ShopDbHttpFailure($ErrorRecord) {
|
||||
# Status and error envelope code (data.error.code) from a failed request.
|
||||
$status = 0
|
||||
$code = ''
|
||||
$text = ''
|
||||
try { $status = [int]$ErrorRecord.Exception.Response.StatusCode } catch {}
|
||||
if ($ErrorRecord.ErrorDetails -and $ErrorRecord.ErrorDetails.Message) {
|
||||
$text = $ErrorRecord.ErrorDetails.Message
|
||||
} else {
|
||||
try {
|
||||
$reader = New-Object System.IO.StreamReader($ErrorRecord.Exception.Response.GetResponseStream())
|
||||
$text = $reader.ReadToEnd()
|
||||
$reader.Dispose()
|
||||
} catch {}
|
||||
}
|
||||
if ($text) {
|
||||
try { $code = [string](($text | ConvertFrom-Json).data.error.code) } catch {}
|
||||
}
|
||||
$presented = ''
|
||||
if ('ShopDbServerPinValidator' -as [type]) { $presented = [string][ShopDbServerPinValidator]::LastPresented }
|
||||
return @{ Status = $status; Code = $code; PresentedPin = $presented }
|
||||
}
|
||||
|
||||
function Write-ShopDbAuthFailure($Failure) {
|
||||
switch ($Failure.Code) {
|
||||
'CREDENTIAL_INVALID' {
|
||||
Log ('ERROR 401 CREDENTIAL_INVALID: the server does not accept this PC''s endpoint credential ' +
|
||||
'(revoked or unknown). The endpoint agent deletes it and re-enrolls on its own.')
|
||||
}
|
||||
'SHARED_KEY_REFUSED' {
|
||||
Log ('ERROR 401 SHARED_KEY_REFUSED: the server refuses the shared key for this PC, because ' +
|
||||
'the PC is enrolled or the shared key is switched off. Reports need the endpoint agent ' +
|
||||
"credential: check agent 1.20+ is installed, running and enrolled ($ShopDbCredentialFile).")
|
||||
}
|
||||
'ASSET_MISMATCH' {
|
||||
Log ('ERROR 403 ASSET_MISMATCH: this PC''s credential belongs to a different asset than the ' +
|
||||
'hostname in the report. Check the hostname and the enrollment in ShopDB.')
|
||||
}
|
||||
default {
|
||||
if ($Failure.Status -eq 0 -and $Failure.PresentedPin) {
|
||||
Log "ERROR TLS refused: server key pin $($Failure.PresentedPin) is not in ServerPins"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Read a value from the 64-bit HKLM view explicitly. If this runs under 32-bit
|
||||
# PowerShell the HKLM: PSDrive silently redirects to WOW6432Node, where the
|
||||
# 64-bit agent's config is not, and the upgrade would look unconfigured.
|
||||
@@ -83,25 +315,33 @@ function Get-AgentConfig {
|
||||
$baseUrl = Get-Hklm64Value -SubKey 'SOFTWARE\GE\ShopDB' -Name 'BaseUrl'
|
||||
$apiKey = Get-Hklm64Value -SubKey 'SOFTWARE\GE\ShopDB' -Name 'CollectorKey'
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($baseUrl) -or [string]::IsNullOrWhiteSpace($apiKey)) {
|
||||
$settings = Join-Path $InstallDir 'appsettings.json'
|
||||
if (Test-Path $settings) {
|
||||
try {
|
||||
$json = Get-Content $settings -Raw | ConvertFrom-Json
|
||||
$pins = @()
|
||||
|
||||
# Read even when the registry is complete: serverpins lives only here.
|
||||
$settings = Join-Path $InstallDir 'appsettings.json'
|
||||
if (Test-Path $settings) {
|
||||
try {
|
||||
$json = Get-Content $settings -Raw | ConvertFrom-Json
|
||||
if ([string]::IsNullOrWhiteSpace($baseUrl) -or [string]::IsNullOrWhiteSpace($apiKey)) {
|
||||
if ([string]::IsNullOrWhiteSpace($baseUrl)) { $baseUrl = $json.baseurl }
|
||||
if ([string]::IsNullOrWhiteSpace($apiKey)) { $apiKey = $json.apikey }
|
||||
Write-Log 'read config from appsettings.json (registry unavailable or incomplete)'
|
||||
}
|
||||
catch {
|
||||
Write-Log "could not parse appsettings.json: $($_.Exception.Message)" 'WARN'
|
||||
foreach ($value in @($json.serverpins)) {
|
||||
if ($null -eq $value) { continue }
|
||||
$pin = ([string]$value -replace '[\s:]', '').ToLowerInvariant()
|
||||
if ($pin -match '^[0-9a-f]{64}$') { $pins += $pin }
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Log "could not parse appsettings.json: $($_.Exception.Message)" 'WARN'
|
||||
}
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($baseUrl) -or [string]::IsNullOrWhiteSpace($apiKey)) {
|
||||
throw 'No BaseUrl/CollectorKey found in the registry or appsettings.json. This endpoint does not look installed; run install-shopdbagent.ps1 instead.'
|
||||
if ([string]::IsNullOrWhiteSpace($baseUrl)) {
|
||||
throw 'No BaseUrl found in the registry or appsettings.json. This endpoint does not look installed; run install-shopdbagent.ps1 instead.'
|
||||
}
|
||||
return @{ BaseUrl = $baseUrl.TrimEnd('/'); ApiKey = $apiKey }
|
||||
return @{ BaseUrl = $baseUrl.TrimEnd('/'); ApiKey = [string]$apiKey; ServerPins = $pins }
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -145,8 +385,45 @@ try {
|
||||
# TLS 1.2 explicitly: Windows PowerShell 5.1 still defaults to older
|
||||
# protocols the server will refuse.
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
Invoke-WebRequest -Uri $url -Headers @{ 'X-API-Key' = $config.ApiKey } `
|
||||
-OutFile $zipPath -UseBasicParsing -TimeoutSec 600
|
||||
|
||||
$serverPins = @(Get-ShopDbServerPins)
|
||||
foreach ($pin in $config.ServerPins) {
|
||||
if ($serverPins -notcontains $pin) { $serverPins += $pin }
|
||||
}
|
||||
if ($serverPins.Count -gt 0) {
|
||||
if (-not (Enable-ShopDbServerPins $serverPins)) {
|
||||
throw 'ServerPins is set but the pin check could not be enabled; not downloading.'
|
||||
}
|
||||
Write-Log "server pin check on ($($serverPins.Count) pin(s))"
|
||||
}
|
||||
|
||||
$credential = Get-ShopDbEndpointCredential $config.BaseUrl
|
||||
$attempts = @()
|
||||
if ($credential) { $attempts += ,@('credential', @{ 'X-Endpoint-Credential' = $credential }) }
|
||||
if ($config.ApiKey) { $attempts += ,@('shared key', @{ 'X-API-Key' = $config.ApiKey }) }
|
||||
if ($attempts.Count -eq 0) {
|
||||
throw 'No endpoint credential and no CollectorKey on this endpoint; cannot authenticate the download.'
|
||||
}
|
||||
|
||||
# Credential first. A refused credential (revoked) falls back to the shared
|
||||
# key once, which the server admits again for an un-enrolled PC.
|
||||
$downloaded = $false
|
||||
foreach ($attempt in $attempts) {
|
||||
try {
|
||||
Invoke-WebRequest -Uri $url -Headers $attempt[1] `
|
||||
-OutFile $zipPath -UseBasicParsing -TimeoutSec 600
|
||||
Write-Log "authenticated with $($attempt[0])"
|
||||
$downloaded = $true
|
||||
break
|
||||
}
|
||||
catch {
|
||||
$failure = Get-ShopDbHttpFailure $_
|
||||
Write-Log "download with $($attempt[0]) failed: $($_.Exception.Message)" 'WARN'
|
||||
Write-ShopDbAuthFailure $failure
|
||||
if ($failure.Code -ne 'CREDENTIAL_INVALID') { throw }
|
||||
}
|
||||
}
|
||||
if (-not $downloaded) { throw 'Package download was refused.' }
|
||||
|
||||
$size = (Get-Item $zipPath).Length
|
||||
Write-Log "downloaded $size bytes"
|
||||
|
||||
Reference in New Issue
Block a user