Add the enrollment page, move scripts to per-PC credentials, and document the rollout
Some checks failed
CI / backend (push) Has been cancelled
CI / naming (push) Has been cancelled
CI / gates (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / lean-build (push) Has been cancelled

- 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:
cproudlock
2026-09-15 12:04:12 -04:00
parent a4c4bd1f40
commit 8b144a17bb
26 changed files with 2769 additions and 4058 deletions

View File

@@ -17,11 +17,18 @@
# to Asset.assetnumber; the placeholder 9999 is skipped server-side.
#
# AUTH: the collector API does NOT honor the GE-Enforce IP allowlist (that only
# covers the geenforce fetch/report endpoints). It needs a collector.ingest key,
# sent as the X-API-Key header. The key is read from HKLM:\SOFTWARE\GE\ShopDB
# CollectorKey (same secret store the GE-Enforce client uses; provisioned at
# imaging), or overridden via the manifest entry's Args -ApiKey. Never bake the
# key into the manifest JSON on the share.
# covers the geenforce fetch/report endpoints). Once the endpoint agent (1.20+)
# has enrolled this PC, its per-PC credential is read from the agent's
# SYSTEM-only credential.bin and sent as X-Endpoint-Credential; the server then
# refuses the shared key for this PC (401 SHARED_KEY_REFUSED). Before that, a
# collector.ingest key is sent as the X-API-Key header, read from
# HKLM:\SOFTWARE\GE\ShopDB CollectorKey (same secret store the GE-Enforce client
# uses; provisioned at imaging), or overridden via the manifest entry's Args
# -ApiKey. Never bake the key into the manifest JSON on the share.
#
# PINS: when HKLM:\SOFTWARE\GE\ShopDB ServerPins is set, the server's TLS key
# must match one of them or nothing is sent. See
# plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md.
#
# Deployed in common\ (runs on EVERY shopfloor pc-type). Non-DNC bays report
# identity with no machineNo, so no PC-to-machine link is built - by design.
@@ -70,6 +77,227 @@ function Log([string]$msg) {
"$ts $msg" | Tee-Object -FilePath $logFile -Append | Out-Null
}
# ---------------------------------------------------------------------------
# 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"
}
}
}
}
# Server from the GE-Enforce config hive when not passed via Args. Any site
# running this script is running the enforcement client, which cannot work
# without BaseUrl, so it is present wherever this is deployed.
@@ -102,11 +330,21 @@ if (-not $ApiKey) {
} catch {}
}
}
if (-not $ApiKey) {
Log 'ERROR no collector key (HKLM:\SOFTWARE\GE\ShopDB CollectorKey or -ApiKey). Skipping.'
$EndpointCredential = Get-ShopDbEndpointCredential $ApiUrl
if (-not $EndpointCredential -and -not $ApiKey) {
Log 'ERROR no endpoint credential and no collector key (HKLM:\SOFTWARE\GE\ShopDB CollectorKey or -ApiKey). Skipping.'
exit 0
}
$serverPins = Get-ShopDbServerPins
if ($serverPins.Count -gt 0) {
if (-not (Enable-ShopDbServerPins $serverPins)) {
Log 'ERROR refusing to report without the server pin check. Skipping.'
exit 0
}
Log "server pin check on ($($serverPins.Count) pin(s))"
}
# -AllowedRanges and the CollectorRanges registry value are ACCEPTED AND
# IGNORED. Deciding which NIC is the PC's address moved to the server, where
# the site's addressing actually lives: this script now reports every NIC and
@@ -326,14 +564,27 @@ $json = $body | ConvertTo-Json -Compress -Depth 4
Log ("POST {0} host={1} serial={2} pcType={3} make={4} model={5} os={6} boot={7} machineNo={8} user={9} ip={10} deviceId={11}" -f `
$ApiUrl, $hostname, $serialNumber, $pcType, $manufacturer, $model, $osVersion, $lastBootTime, $machineNo, $loggedInUser, $corpIp, $deviceId)
try {
$resp = Invoke-RestMethod -Uri $ApiUrl -Method Post -Body $json `
-ContentType 'application/json' `
-Headers @{ 'X-API-Key' = $ApiKey } `
-TimeoutSec $TimeoutSec -ErrorAction Stop
Log ("RESPONSE {0}" -f ($resp | ConvertTo-Json -Compress -Depth 4))
} catch {
Log "ERROR POST failed: $($_.Exception.Message)"
# Credential first; the shared key only when there is no credential, or when
# the server has just refused the credential (revoked: the shared key is
# admitted again for this PC until the agent re-enrolls). Never both at once.
$attempts = @()
if ($EndpointCredential) { $attempts += ,@('credential', @{ 'X-Endpoint-Credential' = $EndpointCredential }) }
if ($ApiKey) { $attempts += ,@('shared key', @{ 'X-API-Key' = $ApiKey }) }
foreach ($attempt in $attempts) {
try {
$resp = Invoke-RestMethod -Uri $ApiUrl -Method Post -Body $json `
-ContentType 'application/json' `
-Headers $attempt[1] `
-TimeoutSec $TimeoutSec -ErrorAction Stop
Log ("RESPONSE ({0}) {1}" -f $attempt[0], ($resp | ConvertTo-Json -Compress -Depth 4))
break
} catch {
Log "ERROR POST with $($attempt[0]) failed: $($_.Exception.Message)"
$failure = Get-ShopDbHttpFailure $_
Write-ShopDbAuthFailure $failure
if ($failure.Code -ne 'CREDENTIAL_INVALID') { break }
}
}
exit 0

View File

@@ -12,7 +12,8 @@ schema changes, update this file in the same change.
key is a UI-managed, endpoints-only service token minted from Settings ->
Endpoint agent key (section 7). A bootstrap `.env` collector key still works
until the first UI key is generated, then it is switched off (section 7). No
querystring fallback.
querystring fallback. Agents 1.20 and later enroll and then send
`X-Endpoint-Credential` instead of the shared key (section 9).
- Body: `application/json`, a single flat object (NOT an array). One POST per
report per endpoint.
- Identity field: `hostname` (required, case-insensitive).
@@ -578,3 +579,130 @@ oversized payload rejected by the server returns nothing at all.
Timed-out runs do not carry output: the script is still running and still holds
the capture file open, so anything read at that moment would be misleading.
## 9. Per-PC credentials
Each endpoint authenticates with its OWN secret, readable only by SYSTEM on
that PC. The server derives the calling asset from the secret. The shared fleet
key (section 7) keeps working for PCs that have not enrolled until an admin
switches it off, and is refused for any PC that has enrolled. The rollout is in
`docs/PER-PC-CREDENTIALS-ROLLOUT.md`.
### 9.1 Headers
| State | Header | Value |
|---|---|---|
| Not enrolled | `X-API-Key` | the shared endpoints key, as today |
| Enrolled | `X-Endpoint-Credential` | `sdbe_` + 43 chars base64url (32 random bytes) |
Never both on one request. The server stores only the SHA-256 of the full
credential plus its first 12 characters (`credentialprefix`) for display.
### 9.2 Enroll
`POST {baseurl}/api/endpoints/enroll`, authenticated with `X-API-Key` while
`plugin.endpoints.sharedkeyallowed` is true. With the shared key switched off
the route takes no auth, but then only returns a credential for a request an
admin has approved.
```jsonc
{
"hostname": "SHOP-PC-014", // 1-255
"serialnumber": "5CD1234ABC", // 1-128, Win32_BIOS.SerialNumber
"deviceid": null, // HKLM\SOFTWARE\GE\ShopDB DeviceId or null
"enrollnonce": "<43 chars>", // 32-128 [A-Za-z0-9_-], generated once, persisted
"agentversion": "1.20.0"
}
```
| Status | Body | Agent action |
|---|---|---|
| 201 | `{"status":"enrolled","credential":"sdbe_...","assetid":123}` | store, switch headers, no restart |
| 202 | `{"status":"pending","requestid":45,"reason":"serial mismatch"}` | retry every 15 min with jitter |
| 403 `ENROLL_NONCE_MISMATCH` | error envelope | an approved request belongs to a different nonce; keep retrying, an admin must reject and re-approve |
| other | error envelope | back off 1, 5, 15 min (cap) |
Auto-issue happens only when `plugin.endpoints.autoenroll` is true, the shared
key authenticated the call, the hostname matches exactly one computer, the
serial on record matches (trimmed, case-insensitive) and the asset has no active
credential. Otherwise the request is pending with one of the reasons `unknown
hostname`, `duplicate hostname`, `no serial on record`, `serial mismatch`,
`already enrolled`, `awaiting approval`, `nonce changed`. The credential is in
the 201 body exactly once.
### 9.3 Error codes
All use the standard error envelope (`data.error.code`).
| HTTP | Code | Meaning | Agent action |
|---|---|---|---|
| 401 | `CREDENTIAL_INVALID` | unknown or revoked credential | delete credential.bin, fall back to the shared key, wake enrollment |
| 401 | `SHARED_KEY_REFUSED` | shared key sent, but this PC is enrolled or the shared key is switched off | wake enrollment; use the credential |
| 403 | `ASSET_MISMATCH` | the credential's asset differs from the hostname/assetid named in the request | log; do not retry the same request |
Routes that authenticate this way: `POST /api/endpoints/collect` (same handler
as `/api/collector/endpoints`), `GET /api/endpoints/monitors/resolved`,
`GET /api/endpoints/commands/next`, `POST /api/endpoints/commands/<id>/result`,
`GET /api/endpoints/apppackage/...`, `GET /api/endpoints/scriptbody/<id>`,
`POST /api/endpoints/operatorreports`, `GET /api/endpoints/alerts`,
`POST /api/endpoints/alerts/<id>/acknowledge`. With a credential, a missing
hostname is allowed and the asset comes from the credential; an acknowledge with
assetid 0 or absent means the caller. The core collector
(`/api/collector/computers`, `/api/collector/printers`) accepts the same header
for the SYSTEM reporter scripts.
### 9.4 Storage on the endpoint
- `%ProgramData%\ShopDB\EndpointAgent\credential\` is ACLed SYSTEM and
Administrators only.
- `credential.bin` = DPAPI `LocalMachine` of UTF-8 JSON
`{"credential":"sdbe_...","assetid":123,"issuedat":"...","baseurl":"..."}`.
SYSTEM scripts read it with
`[System.Security.Cryptography.ProtectedData]::Unprotect(bytes, $null, 'LocalMachine')`
and must not send the credential to a URL outside `baseurl`.
- `enroll.json` = `{"enrollnonce":"...","createdat":"..."}`, plain, same folder.
- The shared key in the registry and appsettings.json is never deleted by the
agent; clearing it is a cutover step in the runbook.
### 9.5 Server pins
- `HKLM\SOFTWARE\GE\ShopDB` `ServerPins` (REG_MULTI_SZ, both registry views)
merged with `serverpins` (string array) in appsettings.json. Legacy
`servercertthumbprint` is still honoured.
- A pin is the lowercase hex SHA-256 of the server leaf certificate's
SubjectPublicKeyInfo. The Server Certificate settings page shows it
(`spkisha256` on `GET /api/endpoints/settings/server-cert`);
`client/Get-ShopDBServerPin.ps1` computes it from a URL or a file;
`client/Set-ShopDBServerPins.ps1` writes it.
- Any pin configured: accept the handshake if and only if the leaf key hash is
in the set (chain and name errors ignored). No pin: require a clean Windows
validation.
### 9.6 Tray pipe
The tray holds no secret and talks to the SYSTEM agent over
`\\.\pipe\ShopDB.EndpointAgent.v1` (SYSTEM and Administrators full control,
INTERACTIVE read/write, 8 instances). One UTF-8 JSON object per line, request
then response, then close; request max 64 KB, read timeout 10 s.
| Request | Response |
|---|---|
| `{"op":"ping"}` | `{"ok":true,"agentversion":"1.20.0","enrolled":true,"assetid":123}` |
| `{"op":"sendstatus"}` | `{"ok":true}` or `{"ok":false,"error":"..."}` |
| `{"op":"sendreport","spoolpath":"C:\\Users\\<user>\\...\\report-xxxx"}` | `{"ok":true}` or `{"ok":false,"error":"..."}`; the path must be inside the caller's own profile and not a reparse point |
| `{"op":"alerts"}` | `{"ok":true,"alerts":[ ...same objects as GET /alerts... ]}` |
| `{"op":"ackalert","notificationid":42}` | `{"ok":true}` or `{"ok":false,"error":"..."}` |
The caller is identified from the pipe (impersonation user name and SID), never
from the request body.
### 9.7 Admin API (WebUI side, for reference)
Read needs `endpoints.fleet`; writes need the admin role and are audited.
- `GET /api/endpoints/enrollment` (`page`, `perpage` <= 100, `state`, `search`)
- `POST /api/endpoints/enrollment/requests/<id>/approve` `{"assetid": n}`
- `POST /api/endpoints/enrollment/requests/<id>/reject`
- `POST /api/endpoints/enrollment/assets/<assetid>/revoke` (un-enroll)
- `PUT /api/endpoints/enrollment/settings` `{"sharedkeyallowed": bool, "autoenroll": bool}`
- `GET /api/endpoints/enrollment/migration` (`page`, `perpage` <= 100, `state`, `search`)
- `POST /api/endpoints/enrollment/migration/wave` `{"assetids": [...]}` or `{"next": n}` (n <= 100)

View File

@@ -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"

View 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

View 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

View File

@@ -0,0 +1,238 @@
# Per-PC credentials rollout
Runbook for moving a site's endpoint fleet from the shared collector key to
per-PC credentials. The wire contract is `AGENT-CONTRACT.md` section 9.
Placeholders: `<server>` is the ShopDB base URL, `<share>` the GE-Enforce share
root, `<pin>` a server key pin, `<pilotbay>` / `<pilothost>` the pilot PC.
## What changes
- Every endpoint gets its own secret (`sdbe_...`), stored in
`%ProgramData%\ShopDB\EndpointAgent\credential\credential.bin`, DPAPI
LocalMachine, readable by SYSTEM and Administrators only.
- The server knows which asset is calling from the secret. A PC can no longer
report as another PC.
- The tray holds no secret. It talks to the SYSTEM agent over a local pipe.
- The agent, the upgrade script and the SYSTEM reporters
(`Report-AssetToShopDB.ps1`, `Report-PrintersToShopDB.ps1`) pin the server's
public key when `ServerPins` is set.
- The shared key keeps working for PCs that have NOT enrolled, until an admin
turns it off. An enrolled PC is always refused the shared key
(`401 SHARED_KEY_REFUSED`).
## Before you start
1. Server deployed with the endpoints migration `endpoints0023credentials`
applied (`flask plugin upgrade-all`).
2. Settings -> Endpoints -> Server Certificate shows a **Public key pin (SPKI
SHA-256)**. Cross-check it from a PC:
`Get-ShopDBServerPin.ps1 -Url https://<server>`. The two must match.
3. Agent 1.20 (and tray 1.7) uploaded as the current release under Settings ->
Endpoints -> Upload New/Legacy Agents. Keep the previous release staged as a
legacy build: it is the rollback.
4. Settings -> Endpoints -> Enrollment -> Credentials: **Shared key allowed**
on, **Auto-enroll** on (both are the defaults).
5. The GE-Enforce manifest scope that will carry the entries below is at
`Version` 2.2 or later, and the fleet's GE-Enforce client library
understands `TargetHostnames` / `TargetMachineNumbers`. An older manifest or
library ignores those lists and applies the entry to EVERY PC in scope. The
same happens with an empty list: never publish a targeted entry with a blank
target.
## Phase 1: pins on the PCs
Pins must be on a PC BEFORE agent 1.20 arrives there. A 1.20 agent with no pin
falls back to plain Windows validation, which a self-signed or internal-CA
server may fail; a pin set after the fact is not a rollout order you want to
debug per bay.
Stage `plugins/endpoints/client/Set-ShopDBServerPins.ps1` on the share and add
a PS1 entry. Pilot first:
```json
{
"_comment": "Server key pin for per-PC credentials. Pilot only.",
"Name": "ShopDB server pins",
"Type": "PS1",
"Script": "scripts/Set-ShopDBServerPins.ps1",
"Args": "-Pins <pin>",
"DetectionMethod": "Always",
"TargetMachineNumbers": ["<pilotbay>"]
}
```
Then widen by hostname list (or machine number list) in batches:
```json
{
"Name": "ShopDB server pins",
"Type": "PS1",
"Script": "scripts/Set-ShopDBServerPins.ps1",
"Args": "-Pins <pin>",
"DetectionMethod": "Always",
"TargetHostnames": ["<host1>", "<host2>", "<host3>"]
}
```
The script compares before it writes, so `Always` costs nothing per cycle.
**Gate:** on the pilot, `HKLM\SOFTWARE\GE\ShopDB` and
`HKLM\SOFTWARE\WOW6432Node\GE\ShopDB` both hold `ServerPins` (REG_MULTI_SZ)
equal to the pin on the settings page, and the next `report-asset-*.log` under
`C:\Logs\Shopfloor` says `server pin check on (1 pin(s))` followed by a
RESPONSE line.
**Rotating the server key later:** put the old AND the new pin in `-Pins`
(`"<oldpin>,<newpin>"`), wait until every PC has both, switch the certificate,
then drop the old pin.
## Phase 2: re-stamp the share's agent entries
PCs that got the agent from the share (collections manifest entries with a
`Hash` detection on `shopdbagent.exe`) are held to that hash by GE-Enforce. If
a store upgrade wave puts 1.20 on such a PC while the entry still carries the
1.19 hash, GE-Enforce reinstalls 1.19 on its next cycle and the PC flips back.
Before any wave reaches a share-installed PC, either:
- re-stamp those entries to the 1.20 payload and its `DetectionValue` SHA-256
(and keep the `ProductCode`/version guards the entries already have), or
- remove the entries for the PCs the wave will cover.
Agent entries stay targeted. Illustrative shape for the pilot (keep your
existing entry's type, installer and guards; what matters is the hash and a
non-empty target):
```json
{
"Name": "ShopDB endpoint agent 1.20",
"Type": "PS1",
"Script": "apps/shopdbagent/1.20.0/install-shopdbagent.ps1",
"DetectionMethod": "Hash",
"DetectionPath": "C:\\Program Files\\ShopDB\\EndpointAgent\\shopdbagent.exe",
"DetectionValue": "<sha256 of the 1.20 shopdbagent.exe>",
"TargetHostnames": ["<pilothost>"]
}
```
**Gate:** the manifest preview for the pilot shows the 1.20 hash, and no entry
for the same file with a blank target list exists in any scope that reaches it.
## Phase 3: pilot one PC
1. Enrollment -> Migration: find `<pilothost>`. It should be **Ready**. If it is
**Blocked**, the Blockers column lists every reason; fix those first.
2. Tick it, **Upgrade selected**.
3. Watch the row: **Upgrading**, then **Enrolled** within a few minutes of the
new agent starting. The agent enrolls on its own with the shared key it
already holds.
**Gate before any wave:**
- Credentials tab shows the PC enrolled, `enrolledvia` = `auto`, and a
`lastusedat` that keeps moving.
- The PC's Fleet row keeps reporting; commands (Collect now) still work.
- The tray shows alerts and Send Status works (it now goes through the pipe).
- `report-asset-*.log` and `report-printers-*.log` show `RESPONSE (credential)`.
- Nothing reinstalled the old agent after two GE-Enforce cycles.
## Phase 4: waves
Migration tab -> **Upgrade next 25**. The confirm dialog states how many PCs get
agent 1.20. The server re-checks readiness per PC and packages the agent once
per wave; PCs that stopped being ready are returned as skipped.
Between waves, in the Waves list:
- wait until the wave shows **Done** (every PC enrolled or failed),
- investigate every **failed** PC before the next wave,
- check nothing moved from Enrolled back to Upgrading or Ready (a share entry
reinstalling the old agent, see phase 2).
Repeat until **Ready** is 0.
## Phase 5: stragglers and new PCs
- **Blocked:** each blocker names its fix. `offline`: bring the PC back or wait.
`no serial on record` / `duplicate hostname`: fix the computer record.
`commands locked`: release the endpoint lock or policy hold. `no agent package
in store`: upload 1.20. `agent too old for remote upgrade`: move it via the
share (targeted agent entry as in phase 2).
- **Not reporting:** the PC has no working agent. It can only move via the
share channel: add its hostname to a targeted 1.20 agent entry. It shows as
Pending or Enrolled once the agent runs.
- **Pending:** the agent asked but auto-enroll could not decide (serial
mismatch, duplicate or unknown hostname, already enrolled, or auto-enroll
off). Credentials tab -> Requests -> **Approve**; the picker defaults to the
matched computer, search to choose another. The PC collects its credential on
its next retry (within 15 minutes). **Reject** anything you do not recognise.
- **Newly imaged PCs:** imaging installs 1.20 with the shared key, and the agent
auto-enrolls on first start. After the shared key is turned off (phase 6) a
new PC cannot auto-enroll: its request lands in Pending and an admin approves
it in the UI. That is the steady-state path for new PCs.
## Phase 6: cutover
**Gate:** Credentials tab **Not enrolled** is 0, or every remaining PC is one
you have decided may stop reporting. Remember that turning the shared key off
also refuses it on `/api/collector/computers` and `/api/collector/printers`, so
a PC with reporter scripts but no enrolled agent goes silent.
1. Turn **Shared key allowed** off. The confirm shows how many PCs are not
enrolled; type `OFF`.
2. Watch Fleet for an hour. A PC that went quiet is either unenrolled (turn the
shared key back on, fix it, try again) or failed pinning.
3. Remove the shared key from every place it lives (below).
4. Rotate the old key so any copy you missed is dead: Settings -> Endpoints ->
**Regenerate key** (and rotate the collector token the reporters used, if it
was a separate token). With the shared key off nothing on the floor needs
the new key: approved enroll requests are collected without it. Store it
the way the key page describes and keep it out of every file below. A
fleet rollback after this point (see Rollback) needs the new key pushed to
the PCs again.
### Every place the shared key lives
| Where | Put there by | How to clear at cutover |
|---|---|---|
| GE-Enforce manifest entry `Args` (`-ApiKey ...`) | whoever authored the entry | Remove the argument from the entry and publish. Search every scope for the key prefix. |
| `HKLM\SOFTWARE\GE\ShopDB` `CollectorKey`, both registry views | agent `install.ps1`; `Set-ShopdbCollectorKey.ps1` from `<share>\common\configs\shopdb-collector.txt` | First retire the `Set-ShopdbCollectorKey.ps1` entry (or it rewrites the value every cycle), then a targeted PS1 entry that deletes `CollectorKey` in both views. |
| `<share>\common\configs\shopdb-collector.txt` | share staging | Delete the file after the entry above is retired. |
| `appsettings.json` `apikey` in the agent install folder | agent package / hand edits | Targeted PS1 entry that sets `apikey` to an empty string. The agent never needs it once enrolled. |
| Generated `install.ps1` default `-CollectorKey` (Build site agent downloads) | Settings -> Endpoints -> Build site agent | Delete every kept copy (share, USB, work folders). Rebuild after rotation only if you still need the package. |
| Display kiosk `shopdb-key.txt` | display imaging | Confirm what the kiosk uses it for before clearing; a kiosk has no endpoint agent, so anything it reports with the shared key stops at cutover. Delete the file once that is settled. |
The agent never deletes the shared key itself: other SYSTEM scripts may still
read it until this table is done.
## Rollback
**One PC:**
1. Make sure **Shared key allowed** is on.
2. Credentials tab -> **Un-enroll** the PC. Its credential stops working
immediately; the server admits the shared key for it again.
3. Push the previous agent: Upload New/Legacy Agents -> make the legacy build
current and upgrade that PC, or re-stamp its share entry to the old hash.
4. Remove the PC from the pin entry's targets only if pinning itself is the
problem (`Set-ShopDBServerPins.ps1 -Clear` removes the value).
A 1.20 agent that is un-enrolled but NOT downgraded simply enrolls again on its
next cycle; that is expected.
**Whole fleet:** turn **Shared key allowed** back on first. Every PC that still
holds the shared key reports again immediately. Then un-enroll and downgrade
PCs as above, in waves.
## Quick reference
- Pin of the live server: Server Certificate settings page, or
`Get-ShopDBServerPin.ps1 -Url https://<server>`.
- Pin of a certificate file: `Get-ShopDBServerPin.ps1 -Path <file>`.
- Agent log of enrollment: `%ProgramData%\ShopDB\EndpointAgent\logs`.
- Reporter logs: `C:\Logs\Shopfloor\report-asset-*.log`,
`report-printers-*.log`. `SHARED_KEY_REFUSED` there means the PC is enrolled
or the shared key is off and the reporter could not read the credential;
`CREDENTIAL_INVALID` means the PC was un-enrolled and the agent is
re-enrolling.

View File

@@ -43,6 +43,18 @@ export default [
requiresPermission: 'endpoints.runscript',
plugin: 'endpoints'
}
},
// Readable by endpoints.fleet holders so they can watch a migration; every
// write on the page is admin-only and re-checked server-side.
{
path: 'settings/endpoints/enrollment',
name: 'endpoints-enrollment-settings',
component: () => import('./views/EnrollmentSettings.vue'),
meta: {
requiresAuth: true,
requiresPermission: 'endpoints.fleet',
plugin: 'endpoints'
}
}
]

View File

@@ -0,0 +1,989 @@
<template>
<div>
<div class="page-header">
<h2>Enrollment</h2>
</div>
<p class="setting-description">
Each endpoint agent authenticates with its own credential, readable only
by SYSTEM on that PC. Agents from {{ target }} onward enroll themselves
with the shared key they already hold; older agents move over by
upgrading. Use Migration to move the fleet, and Credentials to approve
requests, un-enroll a PC, and retire the shared key.
</p>
<div class="enroll-tabs">
<button
type="button"
class="enroll-tab"
:class="{ active: tab === 'migration' }"
@click="tab = 'migration'"
>
Migration
</button>
<button
type="button"
class="enroll-tab"
:class="{ active: tab === 'credentials' }"
@click="tab = 'credentials'"
>
Credentials
</button>
</div>
<!-- ==================================================================
Migration
=============================================================== -->
<section v-if="tab === 'migration'" class="migration-tab">
<div v-if="migrationError" class="load-error">{{ migrationError }}</div>
<div class="enroll-tiles">
<button
v-for="tile in migrationTiles"
:key="tile.state"
type="button"
class="enroll-tile"
:class="[tile.tone, { on: migrationState === tile.state }]"
@click="toggleMigrationState(tile.state)"
>
<span class="n" :data-count="tile.state">{{ migrationCounts[tile.state] || 0 }}</span>
<span class="l">{{ tile.label }}</span>
</button>
</div>
<div class="progress-row">
<div class="progress-track">
<div class="progress-fill" :style="{ width: enrolledPercent + '%' }"></div>
</div>
<span class="progress-text">
{{ migrationCounts.enrolled || 0 }} of {{ migrationTotal }} enrolled
</span>
</div>
<div class="filters">
<input
v-model="migrationSearch"
type="text"
class="form-control"
placeholder="Search hostname..."
@input="debouncedMigrationSearch"
/>
<select v-model="migrationState" class="form-control enroll-filter" @change="reloadMigration">
<option value="">All states</option>
<option v-for="tile in migrationTiles" :key="tile.state" :value="tile.state">
{{ tile.label }}
</option>
</select>
</div>
<div v-if="isadmin" class="bulk-bar">
<span class="bulk-count">{{ selected.length }} selected</span>
<button
type="button"
class="btn btn-primary btn-sm upgrade-selected"
:disabled="busy || !selected.length"
@click="upgradeSelected"
>
Upgrade selected
</button>
<button
type="button"
class="btn btn-secondary btn-sm upgrade-next"
:disabled="busy || !(migrationCounts.ready > 0)"
@click="upgradeNext"
>
Upgrade next {{ waveSize }}
</button>
</div>
<div class="card">
<div v-if="migrationLoading" class="loading">Loading...</div>
<div v-else class="table-container">
<table>
<thead>
<tr>
<th v-if="isadmin" class="select-cell">
<input
type="checkbox"
:checked="allReadySelected"
:disabled="!readyRows.length"
title="Select every ready PC on this page"
@change="toggleAllReady"
/>
</th>
<th>Hostname</th>
<th>State</th>
<th>Agent version</th>
<th>Last reported</th>
<th>Blockers</th>
</tr>
</thead>
<tbody>
<tr v-if="!migrationRows.length">
<td :colspan="isadmin ? 6 : 5" class="empty-cell">No computers match.</td>
</tr>
<tr v-for="row in migrationRows" :key="row.assetid" class="migration-row">
<td v-if="isadmin" class="select-cell">
<input
v-if="row.state === 'ready'"
v-model="selected"
type="checkbox"
:value="row.assetid"
/>
</td>
<td>{{ row.hostname || '-' }}</td>
<td><span class="state-chip" :class="row.state">{{ stateLabel(row.state) }}</span></td>
<td>{{ row.agentversion || '-' }}</td>
<td>{{ formatDate(row.lastreportedat) }}</td>
<td class="blockers">{{ (row.blockers || []).join(', ') || '-' }}</td>
</tr>
</tbody>
</table>
</div>
<PaginationBar
:page="migrationPage"
:total-pages="migrationTotalPages"
@update:page="goToMigrationPage"
/>
</div>
<h3 class="section-title">Waves</h3>
<div class="card">
<div class="table-container">
<table>
<thead>
<tr>
<th>Wave</th>
<th>Created</th>
<th>By</th>
<th>Size</th>
<th>Queued</th>
<th>Enrolled</th>
<th>Failed</th>
<th>Progress</th>
</tr>
</thead>
<tbody>
<tr v-if="!waves.length">
<td colspan="8" class="empty-cell">No waves yet.</td>
</tr>
<tr v-for="wave in waves" :key="wave.waveid">
<td>{{ wave.waveid }}</td>
<td>{{ formatDate(wave.createdat) }}</td>
<td>{{ wave.createdby || '-' }}</td>
<td>{{ wave.size }}</td>
<td>{{ wave.queued }}</td>
<td>{{ wave.enrolled }}</td>
<td>{{ wave.failed }}</td>
<td>
<div class="progress-track small">
<div class="progress-fill" :style="{ width: wavePercent(wave) + '%' }"></div>
</div>
<span class="field-hint">{{ wave.done ? 'Done' : 'In progress' }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- ==================================================================
Credentials
=============================================================== -->
<section v-else class="credentials-tab">
<div v-if="enrollmentError" class="load-error">{{ enrollmentError }}</div>
<div class="card form-card">
<h3>Settings</h3>
<label class="checkbox-row">
<input
type="checkbox"
class="toggle-sharedkey"
:checked="enrollmentSettings.sharedkeyallowed"
:disabled="!isadmin || busy"
@change="onSharedKeyToggle($event)"
/>
<span>
<strong>Shared key allowed.</strong> PCs that have not enrolled may
still report with the fleet shared key. An enrolled PC is always
refused the shared key. Turn this off once every PC is enrolled.
</span>
</label>
<label class="checkbox-row">
<input
type="checkbox"
class="toggle-autoenroll"
:checked="enrollmentSettings.autoenroll"
:disabled="!isadmin || busy"
@change="onAutoEnrollToggle($event)"
/>
<span>
<strong>Auto-enroll.</strong> Issue a credential without approval
when the hostname matches exactly one computer and the serial number
on record matches. Anything else waits below for an admin.
</span>
</label>
</div>
<div class="enroll-tiles">
<div class="enroll-tile static ok">
<span class="n" data-count="enrolled">{{ enrollmentCounts.enrolled || 0 }}</span>
<span class="l">Enrolled</span>
</div>
<div class="enroll-tile static warn">
<span class="n" data-count="notenrolled">{{ enrollmentCounts.notenrolled || 0 }}</span>
<span class="l">Not enrolled</span>
</div>
<div class="enroll-tile static">
<span class="n" data-count="pending">{{ enrollmentCounts.pending || 0 }}</span>
<span class="l">Pending requests</span>
</div>
</div>
<h3 class="section-title">Requests</h3>
<div class="card">
<div class="table-container">
<table>
<thead>
<tr>
<th>Hostname</th>
<th>Serial</th>
<th>Device id</th>
<th>Reason</th>
<th>Status</th>
<th>Source</th>
<th>Last seen</th>
<th v-if="isadmin"></th>
</tr>
</thead>
<tbody>
<tr v-if="!requests.length">
<td :colspan="isadmin ? 8 : 7" class="empty-cell">No enrollment requests.</td>
</tr>
<template v-for="request in requests" :key="request.requestid">
<tr class="request-row">
<td>{{ request.hostname }}</td>
<td>{{ request.serialnumber || '-' }}</td>
<td>{{ request.deviceid || '-' }}</td>
<td>{{ request.reason || '-' }}</td>
<td>{{ request.status }}</td>
<td>{{ request.sourceip || '-' }}</td>
<td>{{ formatDate(request.lastseenat) }}</td>
<td v-if="isadmin" class="action-cell">
<template v-if="request.status === 'pending'">
<button
type="button"
class="btn btn-primary btn-sm approve-open"
:disabled="busy"
@click="openApprove(request)"
>
Approve
</button>
<button
type="button"
class="btn btn-secondary btn-sm reject"
:disabled="busy"
@click="reject(request)"
>
Reject
</button>
</template>
</td>
</tr>
<tr v-if="approving && approving.requestid === request.requestid" class="approve-row">
<td :colspan="isadmin ? 8 : 7">
<div class="approve-form">
<label class="field-label">Issue the credential for computer</label>
<input
v-model="approving.search"
type="text"
class="form-control asset-search"
placeholder="Search hostname or serial..."
@input="debouncedAssetSearch"
/>
<select v-model="approving.assetid" class="form-control asset-picker">
<option :value="null">Choose a computer</option>
<option
v-for="option in approving.options"
:key="option.assetid"
:value="option.assetid"
>
{{ option.label }}
</option>
</select>
<button
type="button"
class="btn btn-primary btn-sm approve-confirm"
:disabled="busy || !approving.assetid"
@click="approve"
>
Approve
</button>
<button type="button" class="btn btn-secondary btn-sm" @click="approving = null">
Cancel
</button>
</div>
<p class="field-hint">
The PC receives its credential the next time it asks,
which is within 15 minutes. Only the PC that made this
request can collect it.
</p>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<h3 class="section-title">Computers</h3>
<div class="filters">
<input
v-model="enrollmentSearch"
type="text"
class="form-control"
placeholder="Search hostname..."
@input="debouncedEnrollmentSearch"
/>
<select v-model="enrollmentState" class="form-control enroll-filter" @change="reloadEnrollment">
<option value="">All states</option>
<option value="enrolled">Enrolled</option>
<option value="notenrolled">Not enrolled</option>
<option value="pending">Pending</option>
<option value="revoked">Revoked</option>
</select>
</div>
<div class="card">
<div v-if="enrollmentLoading" class="loading">Loading...</div>
<div v-else class="table-container">
<table>
<thead>
<tr>
<th>Hostname</th>
<th>Serial</th>
<th>State</th>
<th>Credential</th>
<th>Issued</th>
<th>Last used</th>
<th>Via</th>
<th v-if="isadmin"></th>
</tr>
</thead>
<tbody>
<tr v-if="!credentialRows.length">
<td :colspan="isadmin ? 8 : 7" class="empty-cell">No computers match.</td>
</tr>
<tr v-for="row in credentialRows" :key="row.assetid">
<td>{{ row.hostname || '-' }}</td>
<td>{{ row.serialnumber || '-' }}</td>
<td><span class="state-chip" :class="row.state">{{ stateLabel(row.state) }}</span></td>
<td>
<code v-if="row.credentialprefix" class="mono">{{ row.credentialprefix }}...</code>
<span v-else>-</span>
</td>
<td>{{ formatDate(row.issuedat) }}</td>
<td>{{ formatDate(row.lastusedat) }}</td>
<td>{{ row.enrolledvia || '-' }}</td>
<td v-if="isadmin" class="action-cell">
<button
v-if="row.state === 'enrolled'"
type="button"
class="btn btn-danger btn-sm revoke"
:disabled="busy"
@click="revoke(row)"
>
Un-enroll
</button>
</td>
</tr>
</tbody>
</table>
</div>
<PaginationBar
:page="enrollmentPage"
:total-pages="enrollmentTotalPages"
@update:page="goToEnrollmentPage"
/>
</div>
</section>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import api, { computersApi } from '@/api'
import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
import PaginationBar from '@/components/PaginationBar.vue'
const PERPAGE = 50
const POLL_MS = 30000
const authStore = useAuthStore()
const toast = useToast()
// UI convenience only; every write is re-checked server-side.
const isadmin = computed(() => authStore.isAdmin)
const tab = ref('migration')
const busy = ref(false)
const waveSize = 25
// ---- Migration --------------------------------------------------------
const migrationTiles = [
{ state: 'enrolled', label: 'Enrolled', tone: 'ok' },
{ state: 'ready', label: 'Ready', tone: 'ok' },
{ state: 'blocked', label: 'Blocked', tone: 'bad' },
{ state: 'upgrading', label: 'Upgrading', tone: '' },
{ state: 'pending', label: 'Pending', tone: 'warn' },
{ state: 'notreporting', label: 'Not reporting', tone: '' }
]
const target = ref('1.20.0')
const migrationCounts = ref({})
const migrationRows = ref([])
const waves = ref([])
const migrationPage = ref(1)
const migrationTotalPages = ref(1)
const migrationState = ref('')
const migrationSearch = ref('')
const migrationLoading = ref(false)
const migrationError = ref('')
const selected = ref([])
const migrationTotal = computed(() =>
migrationTiles.reduce((sum, tile) => sum + (Number(migrationCounts.value[tile.state]) || 0), 0))
const enrolledPercent = computed(() => {
if (!migrationTotal.value) return 0
return Math.round(100 * (Number(migrationCounts.value.enrolled) || 0) / migrationTotal.value)
})
const readyRows = computed(() => migrationRows.value.filter(row => row.state === 'ready'))
const allReadySelected = computed(() =>
readyRows.value.length > 0 && readyRows.value.every(row => selected.value.includes(row.assetid)))
function toggleAllReady(event) {
selected.value = event.target.checked ? readyRows.value.map(row => row.assetid) : []
}
function toggleMigrationState(state) {
migrationState.value = migrationState.value === state ? '' : state
reloadMigration()
}
async function loadMigration({ quiet = false } = {}) {
if (!quiet) migrationLoading.value = true
try {
const params = { page: migrationPage.value, perpage: PERPAGE }
if (migrationState.value) params.state = migrationState.value
if (migrationSearch.value) params.search = migrationSearch.value
const response = await api.get('/endpoints/enrollment/migration', { params })
const data = response.data.data || {}
if (data.target) target.value = data.target
migrationCounts.value = data.counts || {}
migrationRows.value = data.computers || []
waves.value = data.waves || []
migrationTotalPages.value = response.data.meta?.pagination?.totalpages || 1
// Drop selections that are no longer ready (queued by a wave, went offline).
const readyIds = new Set(readyRows.value.map(row => row.assetid))
selected.value = selected.value.filter(id => readyIds.has(id))
migrationError.value = ''
} catch (error) {
migrationError.value = apiError(error, 'Could not load migration status')
} finally {
migrationLoading.value = false
}
}
function reloadMigration() {
migrationPage.value = 1
loadMigration()
}
function goToMigrationPage(page) {
migrationPage.value = page
loadMigration()
}
let migrationSearchTimer = null
function debouncedMigrationSearch() {
clearTimeout(migrationSearchTimer)
migrationSearchTimer = setTimeout(reloadMigration, 300)
}
function waveConfirmText(count) {
return `Upgrade ${count} PC(s) to endpoint agent ${target.value}?\n\n`
+ 'Each PC downloads the new agent and restarts its service. Once the new '
+ 'agent is running it enrolls automatically with the shared key it already '
+ 'holds; no further action is needed unless it lands in Pending.'
}
async function startWave(body, count) {
if (!count) return
if (!confirm(waveConfirmText(count))) return
busy.value = true
try {
const response = await api.post('/endpoints/enrollment/migration/wave', body)
const data = response.data.data || {}
const queued = (data.queued || []).length
const skipped = data.skipped || []
if (queued) toast.success(`Wave ${data.waveid}: upgrade queued for ${queued} PC(s)`)
if (skipped.length) {
toast.info(`${skipped.length} skipped: `
+ skipped.slice(0, 5).map(item => `${item.assetid} (${item.reason})`).join(', '))
}
selected.value = []
await loadMigration({ quiet: true })
} catch (error) {
toast.error(apiError(error, 'Could not start the wave'))
} finally {
busy.value = false
}
}
function upgradeSelected() {
const assetids = [...selected.value]
return startWave({ assetids }, assetids.length)
}
function upgradeNext() {
const count = Math.min(waveSize, Number(migrationCounts.value.ready) || 0)
return startWave({ next: waveSize }, count)
}
function wavePercent(wave) {
if (!wave.size) return 0
return Math.round(100 * ((Number(wave.enrolled) || 0) + (Number(wave.failed) || 0)) / wave.size)
}
// ---- Credentials ------------------------------------------------------
const enrollmentSettings = ref({ sharedkeyallowed: true, autoenroll: true })
const enrollmentCounts = ref({})
const credentialRows = ref([])
const requests = ref([])
const enrollmentPage = ref(1)
const enrollmentTotalPages = ref(1)
const enrollmentState = ref('')
const enrollmentSearch = ref('')
const enrollmentLoading = ref(false)
const enrollmentError = ref('')
const approving = ref(null)
async function loadEnrollment({ quiet = false } = {}) {
if (!quiet) enrollmentLoading.value = true
try {
const params = { page: enrollmentPage.value, perpage: PERPAGE }
if (enrollmentState.value) params.state = enrollmentState.value
if (enrollmentSearch.value) params.search = enrollmentSearch.value
const response = await api.get('/endpoints/enrollment', { params })
const data = response.data.data || {}
enrollmentSettings.value = {
sharedkeyallowed: data.settings?.sharedkeyallowed !== false,
autoenroll: data.settings?.autoenroll !== false
}
enrollmentCounts.value = data.counts || {}
credentialRows.value = data.computers || []
requests.value = data.requests || []
enrollmentTotalPages.value = response.data.meta?.pagination?.totalpages || 1
enrollmentError.value = ''
} catch (error) {
enrollmentError.value = apiError(error, 'Could not load enrollment')
} finally {
enrollmentLoading.value = false
}
}
function reloadEnrollment() {
enrollmentPage.value = 1
loadEnrollment()
}
function goToEnrollmentPage(page) {
enrollmentPage.value = page
loadEnrollment()
}
let enrollmentSearchTimer = null
function debouncedEnrollmentSearch() {
clearTimeout(enrollmentSearchTimer)
enrollmentSearchTimer = setTimeout(reloadEnrollment, 300)
}
async function saveSettings(next) {
busy.value = true
try {
await api.put('/endpoints/enrollment/settings', next)
toast.success('Enrollment settings saved')
} catch (error) {
toast.error(apiError(error, 'Could not save enrollment settings'))
} finally {
busy.value = false
await loadEnrollment({ quiet: true })
}
}
function onSharedKeyToggle(event) {
const turningOn = event.target.checked
if (!turningOn) {
const notenrolled = Number(enrollmentCounts.value.notenrolled) || 0
const answer = prompt(
'Turn OFF the shared key?\n\n'
+ `${notenrolled} PC(s) are not enrolled yet. Every one of them stops `
+ 'reporting, stops receiving commands, and cannot enroll on its own until '
+ 'the shared key is turned back on or an admin approves its request.\n\n'
+ 'Type OFF to confirm.')
if (!answer || answer.trim().toUpperCase() !== 'OFF') {
event.target.checked = true
return
}
}
return saveSettings({
sharedkeyallowed: turningOn,
autoenroll: enrollmentSettings.value.autoenroll
})
}
function onAutoEnrollToggle(event) {
return saveSettings({
sharedkeyallowed: enrollmentSettings.value.sharedkeyallowed,
autoenroll: event.target.checked
})
}
function openApprove(request) {
const options = []
if (request.matchedassetid) {
options.push({ assetid: request.matchedassetid, label: `${request.hostname} (matched)` })
}
approving.value = {
requestid: request.requestid,
hostname: request.hostname,
assetid: request.matchedassetid || null,
search: '',
options
}
}
let assetSearchTimer = null
function debouncedAssetSearch() {
clearTimeout(assetSearchTimer)
assetSearchTimer = setTimeout(searchAssets, 300)
}
async function searchAssets() {
const current = approving.value
if (!current || !current.search) return
try {
const response = await computersApi.list({ search: current.search, perpage: 20 })
const found = (response.data.data || []).map(item => ({
assetid: item.assetid,
label: `${item.computer?.hostname || item.name || item.assetid}`
+ (item.serialnumber ? ` (${item.serialnumber})` : '')
}))
const keep = current.options.filter(option => option.assetid === current.assetid)
const merged = [...keep]
found.forEach(option => {
if (!merged.some(existing => existing.assetid === option.assetid)) merged.push(option)
})
current.options = merged
} catch (error) {
toast.error(apiError(error, 'Could not search computers'))
}
}
async function approve() {
const current = approving.value
if (!current || !current.assetid) return
const option = current.options.find(item => item.assetid === current.assetid)
if (!confirm(`Approve the request from ${current.hostname} and issue a credential for `
+ `${option ? option.label : 'asset ' + current.assetid}?`)) return
busy.value = true
try {
await api.post(`/endpoints/enrollment/requests/${current.requestid}/approve`,
{ assetid: current.assetid })
toast.success(`Approved ${current.hostname}`)
approving.value = null
await loadEnrollment({ quiet: true })
} catch (error) {
toast.error(apiError(error, 'Could not approve the request'))
} finally {
busy.value = false
}
}
async function reject(request) {
if (!confirm(`Reject the enrollment request from ${request.hostname}?`)) return
busy.value = true
try {
await api.post(`/endpoints/enrollment/requests/${request.requestid}/reject`)
toast.success(`Rejected ${request.hostname}`)
await loadEnrollment({ quiet: true })
} catch (error) {
toast.error(apiError(error, 'Could not reject the request'))
} finally {
busy.value = false
}
}
async function revoke(row) {
const fallback = enrollmentSettings.value.sharedkeyallowed
? 'It falls back to the shared key and enrolls again on its own.'
: 'The shared key is off, so it cannot report until an admin approves its new request.'
if (!confirm(`Un-enroll ${row.hostname}?\n\nIts credential stops working immediately. ${fallback}`)) return
busy.value = true
try {
await api.post(`/endpoints/enrollment/assets/${row.assetid}/revoke`)
toast.success(`Un-enrolled ${row.hostname}`)
await loadEnrollment({ quiet: true })
} catch (error) {
toast.error(apiError(error, 'Could not un-enroll'))
} finally {
busy.value = false
}
}
// ---- Shared -----------------------------------------------------------
function stateLabel(state) {
return {
enrolled: 'Enrolled',
notenrolled: 'Not enrolled',
pending: 'Pending',
revoked: 'Revoked',
upgrading: 'Upgrading',
ready: 'Ready',
blocked: 'Blocked',
notreporting: 'Not reporting'
}[state] || state || '-'
}
function formatDate(value) {
if (!value) return '-'
return new Date(value).toLocaleString()
}
// Waves progress on their own; poll while this tab is open.
let pollTimer = null
function startPolling() {
stopPolling()
pollTimer = setInterval(() => {
if (tab.value === 'migration' && !busy.value) loadMigration({ quiet: true })
}, POLL_MS)
}
function stopPolling() {
if (pollTimer) clearInterval(pollTimer)
pollTimer = null
}
watch(tab, value => {
if (value === 'migration') loadMigration()
else loadEnrollment()
})
onMounted(() => {
loadMigration()
loadEnrollment({ quiet: true })
startPolling()
})
onBeforeUnmount(() => {
stopPolling()
clearTimeout(migrationSearchTimer)
clearTimeout(enrollmentSearchTimer)
clearTimeout(assetSearchTimer)
})
</script>
<style scoped>
.enroll-tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--border);
margin-bottom: 1rem;
}
.enroll-tab {
padding: 0.6rem 1.25rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
font: inherit;
font-weight: 500;
color: var(--text-light);
cursor: pointer;
}
.enroll-tab:hover { color: var(--text); }
.enroll-tab.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
.load-error {
padding: 0.75rem 0;
color: var(--danger);
}
.enroll-tiles {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.75rem;
margin-bottom: 1rem;
}
.enroll-tile {
display: flex;
flex-direction: column;
align-items: flex-start;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 0.25rem;
padding: 0.8rem 0.95rem;
font: inherit;
color: var(--text);
text-align: left;
cursor: pointer;
}
.enroll-tile.static { cursor: default; }
.enroll-tile:not(.static):hover,
.enroll-tile.on { border-color: var(--primary); }
.enroll-tile .n {
font-size: 1.6rem;
font-weight: 700;
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
.enroll-tile .l {
font-size: 0.8rem;
color: var(--text-light);
}
.enroll-tile.ok .n { color: var(--success-dark); }
.enroll-tile.warn .n { color: var(--warning-dark); }
.enroll-tile.bad .n { color: var(--danger); }
[data-theme="dark"] .enroll-tile.ok .n { color: var(--success); }
[data-theme="dark"] .enroll-tile.warn .n { color: var(--warning); }
.progress-row {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
.progress-track {
flex: 1;
height: 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 5px;
overflow: hidden;
}
.progress-track.small {
height: 6px;
min-width: 80px;
}
.progress-fill {
height: 100%;
background: var(--success);
}
.progress-text {
font-size: 0.9rem;
color: var(--text-light);
white-space: nowrap;
}
.enroll-filter { max-width: 200px; }
.bulk-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.6rem 0.9rem;
margin-bottom: 0.75rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 0.25rem;
}
.bulk-count { font-size: 0.9rem; margin-right: 0.25rem; }
.select-cell { width: 1%; }
.empty-cell {
text-align: center;
color: var(--text-light);
}
.state-chip {
display: inline-block;
font-size: 0.78rem;
font-weight: 500;
padding: 0.1rem 0.5rem;
border-radius: 10px;
background: var(--bg);
color: var(--text-light);
white-space: nowrap;
}
.state-chip.enrolled,
.state-chip.ready { color: var(--success-dark); }
.state-chip.pending,
.state-chip.upgrading { color: var(--warning-dark); }
.state-chip.blocked,
.state-chip.revoked { color: var(--danger); }
[data-theme="dark"] .state-chip.enrolled,
[data-theme="dark"] .state-chip.ready { color: var(--success); }
[data-theme="dark"] .state-chip.pending,
[data-theme="dark"] .state-chip.upgrading { color: var(--warning); }
.blockers {
font-size: 0.85rem;
color: var(--text-light);
}
.section-title { margin: 1.5rem 0 0.5rem; }
.checkbox-row {
display: flex;
align-items: flex-start;
gap: 0.5rem;
margin: 0.5rem 0;
}
.action-cell {
white-space: nowrap;
text-align: right;
}
.action-cell .btn + .btn { margin-left: 0.35rem; }
.approve-form {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.approve-form .form-control { max-width: 260px; }
.mono {
font-family: 'Courier New', monospace;
font-size: 13px;
}
</style>

View File

@@ -66,6 +66,24 @@
</p>
</div>
<div class="form-group spki-pin">
<label>Public key pin (SPKI SHA-256)</label>
<p v-if="cert.spkisha256" class="field-hint mono">{{ cert.spkisha256 }}</p>
<p v-else class="field-hint">Not reported by this server.</p>
<button
v-if="cert.spkisha256"
class="btn btn-secondary btn-sm"
@click="copyPin"
>
{{ pinCopied ? 'Copied' : 'Copy pin' }}
</button>
<p class="field-hint">
Paste this into the GE-Enforce Set-ShopDBServerPins.ps1 entry
(-Pins) so agents 1.20 and later trust only this server's key.
It survives a renewal that keeps the same private key.
</p>
</div>
<div class="form-group">
<label>Issuer</label>
<p class="field-hint mono">{{ cert.issuer }}</p>
@@ -134,7 +152,11 @@
<div class="warning-banner" v-if="cert">
<strong>Warning:</strong> Generating a new certificate will replace the current one.
Agents using ServerCertThumbprint pinning will reject connections until you update
their appsettings.json with the new thumbprint.
their appsettings.json with the new thumbprint. A new certificate also has a new
key, so agents with ServerPins set will reject it until the pin entry carries the
new pin as well. Generating here gives no window to do that; to rotate without an
outage, compute the pin of a prepared certificate with Get-ShopDBServerPin.ps1, add
it to the pin entry alongside the old one, and upload that certificate below.
</div>
<div class="button-row">
@@ -278,6 +300,7 @@ const message = ref('')
const error = ref('')
const cert = ref(null)
const thumbprintCopied = ref(false)
const pinCopied = ref(false)
const generateForm = ref({
cn: 'localhost',
@@ -405,6 +428,14 @@ function copyThumbprint() {
}, 2000)
}
function copyPin() {
navigator.clipboard.writeText(cert.value.spkisha256)
pinCopied.value = true
setTimeout(() => {
pinCopied.value = false
}, 2000)
}
function formatDate(dateString) {
if (!dateString) return 'N/A'
const date = new Date(dateString)

View File

@@ -0,0 +1,200 @@
/**
* Enrollment settings page (per-PC credentials).
*
* Pins the wire shapes the page sends, because the server contract is exact:
* a wave is {"next": n} or {"assetids": [...]}, an approval carries the chosen
* assetid, and turning the shared key off must tell the admin how many PCs are
* not enrolled yet before anything is sent.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
vi.mock('@/api', () => ({
default: { get: vi.fn(), post: vi.fn(), put: vi.fn() },
computersApi: { list: vi.fn() }
}))
vi.mock('@/stores/auth', () => ({
useAuthStore: () => ({ isAdmin: true, hasPermission: () => true })
}))
vi.mock('@/composables/toast', () => ({
useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn() })
}))
vi.mock('@/components/PaginationBar.vue', () => ({
default: { name: 'PaginationBar', template: '<div />' }
}))
import api, { computersApi } from '@/api'
import EnrollmentSettings from './EnrollmentSettings.vue'
function migrationPayload() {
return {
data: {
data: {
target: '1.20.0',
counts: { enrolled: 40, pending: 2, upgrading: 3, ready: 30, blocked: 5, notreporting: 20 },
computers: [
{ assetid: 11, hostname: 'PC-READY', agentversion: '1.19.2', lastreportedat: null, state: 'ready', blockers: [] },
{ assetid: 12, hostname: 'PC-BLOCKED', agentversion: '1.18.0', lastreportedat: null, state: 'blocked',
blockers: ['offline', 'no serial on record'] }
],
waves: [
{ waveid: 3, createdat: null, createdby: 'admin', size: 25, queued: 25, enrolled: 10, failed: 1, done: false }
]
},
meta: { pagination: { totalpages: 1 } }
}
}
}
function enrollmentPayload() {
return {
data: {
data: {
settings: { sharedkeyallowed: true, autoenroll: true },
counts: { enrolled: 40, notenrolled: 58, pending: 2 },
computers: [
{ assetid: 21, hostname: 'PC-ENROLLED', serialnumber: 'S1', state: 'enrolled',
credentialprefix: 'sdbe_abcdefg', issuedat: null, lastusedat: null, enrolledvia: 'auto' }
],
requests: [
{ requestid: 45, hostname: 'PC-NEW', serialnumber: 'S9', deviceid: null, reason: 'serial mismatch',
status: 'pending', matchedassetid: 77, requestedat: null, lastseenat: null, sourceip: null }
]
},
meta: { pagination: { totalpages: 1 } }
}
}
}
async function render() {
api.get.mockImplementation(path => Promise.resolve(
path === '/endpoints/enrollment/migration' ? migrationPayload() : enrollmentPayload()))
api.post.mockResolvedValue({ data: { data: { waveid: 4, queued: [11], skipped: [] } } })
api.put.mockResolvedValue({ data: { data: {} } })
const wrapper = mount(EnrollmentSettings)
await flushPromises()
return wrapper
}
async function openCredentials(wrapper) {
const tab = wrapper.findAll('.enroll-tab').find(button => button.text() === 'Credentials')
await tab.trigger('click')
await flushPromises()
}
describe('EnrollmentSettings', () => {
beforeEach(() => {
api.get.mockReset()
api.post.mockReset()
api.put.mockReset()
computersApi.list.mockReset()
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('renders the migration counts and enrolled progress', async () => {
const wrapper = await render()
expect(wrapper.find('[data-count="enrolled"]').text()).toBe('40')
expect(wrapper.find('[data-count="ready"]').text()).toBe('30')
expect(wrapper.find('[data-count="blocked"]').text()).toBe('5')
expect(wrapper.find('[data-count="notreporting"]').text()).toBe('20')
expect(wrapper.find('.progress-text').text()).toContain('40 of 100 enrolled')
})
it('shows every blocker for a blocked PC', async () => {
const wrapper = await render()
const row = wrapper.findAll('.migration-row').find(r => r.text().includes('PC-BLOCKED'))
expect(row.find('.blockers').text()).toBe('offline, no serial on record')
})
it('only offers a checkbox on ready rows', async () => {
const wrapper = await render()
const rows = wrapper.findAll('.migration-row')
expect(rows[0].find('input[type="checkbox"]').exists()).toBe(true)
expect(rows[1].find('input[type="checkbox"]').exists()).toBe(false)
})
it('posts {"next":25} for Upgrade next 25 after a confirm naming the count', async () => {
const confirmSpy = vi.fn(() => true)
vi.stubGlobal('confirm', confirmSpy)
const wrapper = await render()
await wrapper.find('.upgrade-next').trigger('click')
await flushPromises()
expect(confirmSpy).toHaveBeenCalledTimes(1)
expect(confirmSpy.mock.calls[0][0]).toMatch(/Upgrade 25 PC\(s\) to endpoint agent 1\.20\.0/)
expect(confirmSpy.mock.calls[0][0]).toMatch(/enrolls automatically/)
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/migration/wave', { next: 25 })
})
it('sends nothing when the wave confirm is declined', async () => {
vi.stubGlobal('confirm', vi.fn(() => false))
const wrapper = await render()
await wrapper.find('.upgrade-next').trigger('click')
await flushPromises()
expect(api.post).not.toHaveBeenCalled()
})
it('posts the selected assetids for Upgrade selected', async () => {
vi.stubGlobal('confirm', vi.fn(() => true))
const wrapper = await render()
await wrapper.findAll('.migration-row')[0].find('input[type="checkbox"]').setValue(true)
await wrapper.find('.upgrade-selected').trigger('click')
await flushPromises()
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/migration/wave', { assetids: [11] })
})
it('approves a request with the matched asset by default', async () => {
vi.stubGlobal('confirm', vi.fn(() => true))
const wrapper = await render()
await openCredentials(wrapper)
await wrapper.find('.approve-open').trigger('click')
expect(wrapper.find('.asset-picker').element.value).toBe('77')
await wrapper.find('.approve-confirm').trigger('click')
await flushPromises()
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/requests/45/approve', { assetid: 77 })
})
it('rejects a request after confirm', async () => {
vi.stubGlobal('confirm', vi.fn(() => true))
const wrapper = await render()
await openCredentials(wrapper)
await wrapper.find('.reject').trigger('click')
await flushPromises()
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/requests/45/reject')
})
it('un-enrolls through the revoke route', async () => {
vi.stubGlobal('confirm', vi.fn(() => true))
const wrapper = await render()
await openCredentials(wrapper)
await wrapper.find('.revoke').trigger('click')
await flushPromises()
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/assets/21/revoke')
})
it('shows the not-enrolled count before turning the shared key off', async () => {
const promptSpy = vi.fn(() => 'OFF')
vi.stubGlobal('prompt', promptSpy)
const wrapper = await render()
await openCredentials(wrapper)
await wrapper.find('.toggle-sharedkey').setValue(false)
await flushPromises()
expect(promptSpy).toHaveBeenCalledTimes(1)
expect(promptSpy.mock.calls[0][0]).toMatch(/58 PC\(s\) are not enrolled yet/)
expect(api.put).toHaveBeenCalledWith('/endpoints/enrollment/settings',
{ sharedkeyallowed: false, autoenroll: true })
})
it('leaves the shared key on when the confirmation is not typed', async () => {
vi.stubGlobal('prompt', vi.fn(() => 'yes'))
const wrapper = await render()
await openCredentials(wrapper)
const toggle = wrapper.find('.toggle-sharedkey')
await toggle.setValue(false)
await flushPromises()
expect(api.put).not.toHaveBeenCalled()
expect(toggle.element.checked).toBe(true)
})
})

View File

@@ -30,11 +30,19 @@
# lost its printers.
#
# AUTH: the collector API does NOT honor the GE-Enforce IP allowlist (that only
# covers the geenforce fetch/report endpoints). It needs a collector.ingest key,
# sent as the X-API-Key header. The key is read from HKLM:\SOFTWARE\GE\ShopDB
# CollectorKey (the same secret store Report-AssetToShopDB.ps1 uses; provisioned
# at imaging), or overridden via the manifest entry's Args -ApiKey. Never bake
# the key into the manifest JSON on the share.
# covers the geenforce fetch/report endpoints). Once the endpoint agent (1.20+)
# has enrolled this PC, its per-PC credential is read from the agent's
# SYSTEM-only credential.bin and sent as X-Endpoint-Credential; the server then
# refuses the shared key for this PC (401 SHARED_KEY_REFUSED). Before that, a
# collector.ingest key is sent as the X-API-Key header, read from
# HKLM:\SOFTWARE\GE\ShopDB CollectorKey (the same secret store
# Report-AssetToShopDB.ps1 uses; provisioned at imaging), or overridden via the
# manifest entry's Args -ApiKey. Never bake the key into the manifest JSON on
# the share.
#
# PINS: when HKLM:\SOFTWARE\GE\ShopDB ServerPins is set, the server's TLS key
# must match one of them or nothing is sent. See
# plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md.
#
# Runs every GE-Enforce cycle as a Type=PS1 / DetectionMethod=Always entry under
# the SYSTEM task. Always exits 0 so a printer problem never fails an
@@ -77,6 +85,227 @@ function Log([string]$msg) {
"$ts $msg" | Tee-Object -FilePath $logFile -Append | Out-Null
}
# ---------------------------------------------------------------------------
# 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"
}
}
}
}
$REGPATHS = @('HKLM:\SOFTWARE\GE\ShopDB', 'HKLM:\SOFTWARE\WOW6432Node\GE\ShopDB')
function Get-ShopdbRegValue([string]$name) {
@@ -107,9 +336,22 @@ if (-not $ApiUrl -and -not $WhatIfOnly) {
# collector key from the GE-Enforce secret store when not passed via Args.
if (-not $ApiKey) { $ApiKey = Get-ShopdbRegValue 'CollectorKey' }
if (-not $ApiKey -and -not $WhatIfOnly) {
Log 'ERROR no collector key (HKLM:\SOFTWARE\GE\ShopDB CollectorKey or -ApiKey). Skipping.'
exit 0
$EndpointCredential = ''
if (-not $WhatIfOnly) {
$EndpointCredential = Get-ShopDbEndpointCredential $ApiUrl
if (-not $EndpointCredential -and -not $ApiKey) {
Log 'ERROR no endpoint credential and no collector key (HKLM:\SOFTWARE\GE\ShopDB CollectorKey or -ApiKey). Skipping.'
exit 0
}
$serverPins = Get-ShopDbServerPins
if ($serverPins.Count -gt 0) {
if (-not (Enable-ShopDbServerPins $serverPins)) {
Log 'ERROR refusing to report without the server pin check. Skipping.'
exit 0
}
Log "server pin check on ($($serverPins.Count) pin(s))"
}
}
# Queues that are not devices: the Windows-supplied virtual printers plus the
@@ -301,14 +543,27 @@ if ($WhatIfOnly) {
}
Log ("POST {0} host={1} queues={2}" -f $ApiUrl, $Hostname, $reported.Count)
try {
$response = Invoke-RestMethod -Uri $ApiUrl -Method Post -Body $json `
-ContentType 'application/json' `
-Headers @{ 'X-API-Key' = $ApiKey } `
-TimeoutSec $TimeoutSec -ErrorAction Stop
Log ("RESPONSE {0}" -f ($response | ConvertTo-Json -Compress -Depth 4))
} catch {
Log "ERROR POST failed: $($_.Exception.Message)"
# Credential first; the shared key only when there is no credential, or when
# the server has just refused the credential (revoked: the shared key is
# admitted again for this PC until the agent re-enrolls). Never both at once.
$attempts = @()
if ($EndpointCredential) { $attempts += ,@('credential', @{ 'X-Endpoint-Credential' = $EndpointCredential }) }
if ($ApiKey) { $attempts += ,@('shared key', @{ 'X-API-Key' = $ApiKey }) }
foreach ($attempt in $attempts) {
try {
$response = Invoke-RestMethod -Uri $ApiUrl -Method Post -Body $json `
-ContentType 'application/json' `
-Headers $attempt[1] `
-TimeoutSec $TimeoutSec -ErrorAction Stop
Log ("RESPONSE ({0}) {1}" -f $attempt[0], ($response | ConvertTo-Json -Compress -Depth 4))
break
} catch {
Log "ERROR POST with $($attempt[0]) failed: $($_.Exception.Message)"
$failure = Get-ShopDbHttpFailure $_
Write-ShopDbAuthFailure $failure
if ($failure.Code -ne 'CREDENTIAL_INVALID') { break }
}
}
exit 0