Always pin the server, let the registry own pins, and survive a revoke mid-upgrade
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

Found in the Windows VM re-test.

- A CA-flagged server certificate (common with a plain openssl self-signed
  cert) produced an installer package with no pin at all, so agent 1.20
  refused the server. The package now always carries the SPKI pin of the
  certificate PCs receive; the CA flag only skips the trusted-root import.
  When an uploaded CA-flagged certificate differs from the server's own, both
  pins are included. The build response carries X-ShopDB-Build-Warning and the
  installer logs what older agents need.
- Pins written into appsettings.json by the installer were merged with the
  registry, so removing a pin from HKLM never took effect. When the ServerPins
  value exists in the registry it is now authoritative, even when empty;
  appsettings pins apply only when it is absent. Set-ShopDBServerPins.ps1
  -Clear deletes the value; new -None writes an empty value (no pins).
- A script or package download sent with a credential revoked moments earlier
  got 401 CREDENTIAL_INVALID and failed the command. The agent now retries that
  download once with the shared key and records the fallback in the result.
This commit is contained in:
cproudlock
2026-09-15 15:22:25 -04:00
parent 949398d495
commit f4a03387b2
10 changed files with 381 additions and 100 deletions

View File

@@ -14,13 +14,15 @@ public sealed class AgentConfig
[JsonPropertyName("cadenceseconds")] public int CadenceSeconds { get; set; } = 900; [JsonPropertyName("cadenceseconds")] public int CadenceSeconds { get; set; } = 900;
// Legacy pin: SHA-1 thumbprint of the server leaf certificate (hex, no // Legacy pin: SHA-1 thumbprint of the server leaf certificate (hex, no
// separators). Still honoured next to ServerPins; breaks on every cert // separators). Honoured with serverpins only while the registry ServerPins
// renewal, so prefer an SPKI pin. See AgentHttpFactory. // value is absent; breaks on every cert renewal, so prefer an SPKI pin.
// See AgentHttpFactory.
[JsonPropertyName("servercertthumbprint")] public string? ServerCertThumbprint { get; set; } [JsonPropertyName("servercertthumbprint")] public string? ServerCertThumbprint { get; set; }
// Server public key pins: lowercase hex SHA-256 of the leaf certificate's // Server public key pins: lowercase hex SHA-256 of the leaf certificate's
// SubjectPublicKeyInfo. Merged with HKLM\SOFTWARE\GE\ShopDB ServerPins // SubjectPublicKeyInfo. Fallback only: HKLM\SOFTWARE\GE\ShopDB ServerPins
// (REG_MULTI_SZ). Any pin configured = the leaf key must match one; chain // (REG_MULTI_SZ), when it exists in either view, replaces these entirely,
// even when empty. Any pin in effect = the leaf key must match one; chain
// and name errors are then ignored. None = normal Windows chain validation. // and name errors are then ignored. None = normal Windows chain validation.
[JsonPropertyName("serverpins")] public List<string> ServerPins { get; set; } = new(); [JsonPropertyName("serverpins")] public List<string> ServerPins { get; set; } = new();

View File

@@ -172,43 +172,55 @@ public sealed record AgentServerError(string Code, string Message)
} }
} }
// Server certificate trust. Pins are hex SHA-256 of the leaf SubjectPublicKeyInfo, // Server certificate trust. Pins are hex SHA-256 of the leaf SubjectPublicKeyInfo.
// merged from HKLM\SOFTWARE\GE\ShopDB ServerPins (both views) and appsettings // HKLM\SOFTWARE\GE\ShopDB ServerPins is AUTHORITATIVE whenever the value exists
// serverpins; the legacy SHA-1 servercertthumbprint still counts. With any pin // in either view, even empty (empty = no pins, Windows chain validation), so a
// configured only a matching leaf is accepted and chain/name errors are ignored // removed or rotated-out pin really goes away. Only when the value is absent in
// (self-signed servers on the air gap). Without pins: SslPolicyErrors.None. // both views do appsettings serverpins and the legacy SHA-1 servercertthumbprint
// Registry pins re-read at most every RefreshInterval on the handshake path, so a // apply. With any pin in effect only a matching leaf is accepted and chain/name
// GE-Enforce fix or a "next" rotation pin lands without a service restart. // errors are ignored (self-signed servers on the air gap). Without pins:
// SslPolicyErrors.None. Registry re-read at most every RefreshInterval on the
// handshake path, so a GE-Enforce fix or a rotation lands without a restart.
public sealed class ServerPinSet public sealed class ServerPinSet
{ {
public const string RegistryValue = "ServerPins"; public const string RegistryValue = "ServerPins";
public static readonly TimeSpan RefreshInterval = TimeSpan.FromSeconds(60); public static readonly TimeSpan RefreshInterval = TimeSpan.FromSeconds(60);
private sealed record Snapshot(HashSet<string> SpkiPins, string RawRegistry) // Present = the value exists in at least one view.
private sealed record RegistryRead(bool Present, List<string> Values)
{ {
public string Signature => string.Join(",", SpkiPins.OrderBy(p => p, StringComparer.Ordinal)); public string Raw => Present ? "registry:" + string.Join("|", Values) : "absent";
}
private sealed record Snapshot(HashSet<string> SpkiPins, string? LegacyThumbprint, bool FromRegistry, string RawRegistry)
{
public string Signature => (FromRegistry ? "registry:" : "appsettings:")
+ string.Join(",", SpkiPins.OrderBy(p => p, StringComparer.Ordinal))
+ (LegacyThumbprint is null ? "" : "+legacy");
public bool Pinned => SpkiPins.Count > 0 || LegacyThumbprint is not null;
public string Source => FromRegistry ? "registry" : "appsettings.json";
} }
private readonly HashSet<string> configPins; private readonly HashSet<string> configPins;
private readonly string? legacyThumbprint; private readonly string? legacyThumbprint;
private readonly ILogger logger; private readonly ILogger logger;
private readonly Func<bool, List<string>?> readRegistry; private readonly Func<bool, RegistryRead?> readRegistry;
private readonly object refreshLock = new(); private readonly object refreshLock = new();
private volatile Snapshot current; private volatile Snapshot current;
private long nextRefreshTicks; private long nextRefreshTicks;
private ServerPinSet(HashSet<string> configPins, string? legacyThumbprint, ILogger logger, private ServerPinSet(HashSet<string> configPins, string? legacyThumbprint, ILogger logger,
Func<bool, List<string>?> readRegistry) Func<bool, RegistryRead?> readRegistry)
{ {
this.configPins = configPins; this.configPins = configPins;
this.legacyThumbprint = legacyThumbprint; this.legacyThumbprint = legacyThumbprint;
this.logger = logger; this.logger = logger;
this.readRegistry = readRegistry; this.readRegistry = readRegistry;
current = Build(readRegistry(false) ?? new List<string>()); current = Build(readRegistry(false) ?? new RegistryRead(false, new List<string>()));
nextRefreshTicks = Environment.TickCount64 + (long)RefreshInterval.TotalMilliseconds; nextRefreshTicks = Environment.TickCount64 + (long)RefreshInterval.TotalMilliseconds;
} }
public bool IsPinned => Current().SpkiPins.Count > 0 || legacyThumbprint is not null; public bool IsPinned => Current().Pinned;
public static ServerPinSet Load(AgentConfig config, ILogger logger) public static ServerPinSet Load(AgentConfig config, ILogger logger)
{ {
@@ -221,11 +233,15 @@ public sealed class ServerPinSet
thumbprint = config.ServerCertThumbprint.Replace(":", "").Replace(" ", "").Trim().ToUpperInvariant(); thumbprint = config.ServerCertThumbprint.Replace(":", "").Replace(" ", "").Trim().ToUpperInvariant();
var set = new ServerPinSet(pins, thumbprint, logger, refresh => ReadRegistryPins(logger, refresh)); var set = new ServerPinSet(pins, thumbprint, logger, refresh => ReadRegistryPins(logger, refresh));
var count = set.current.SpkiPins.Count; var snapshot = set.current;
logger.LogInformation( logger.LogInformation(
"Server certificate trust: {Pins} SPKI pin(s){Legacy}; {Mode}.", "Server certificate trust from {Source}: {Pins} SPKI pin(s){Legacy}; {Mode}.",
count, thumbprint is null ? "" : " + legacy thumbprint", snapshot.Source, snapshot.SpkiPins.Count,
count > 0 || thumbprint is not null ? "pinned" : "Windows chain validation"); snapshot.LegacyThumbprint is null ? "" : " + legacy thumbprint",
snapshot.Pinned ? "pinned" : "Windows chain validation");
if (snapshot.FromRegistry && (pins.Count > 0 || thumbprint is not null))
logger.LogInformation(
"Registry {Value} is set; ignoring appsettings.json serverpins/servercertthumbprint.", RegistryValue);
return set; return set;
} }
@@ -238,33 +254,38 @@ public sealed class ServerPinSet
{ {
if (Environment.TickCount64 < nextRefreshTicks) if (Environment.TickCount64 < nextRefreshTicks)
return current; return current;
var raw = readRegistry(true); var read = readRegistry(true);
Interlocked.Exchange(ref nextRefreshTicks, Interlocked.Exchange(ref nextRefreshTicks,
Environment.TickCount64 + (long)RefreshInterval.TotalMilliseconds); Environment.TickCount64 + (long)RefreshInterval.TotalMilliseconds);
var previous = current; var previous = current;
if (raw is null || string.Join("|", raw) == previous.RawRegistry) if (read is null || read.Raw == previous.RawRegistry)
return previous; return previous;
var next = Build(raw); var next = Build(read);
current = next; current = next;
if (next.Signature != previous.Signature) if (next.Signature != previous.Signature)
logger.LogInformation( logger.LogInformation(
"Server pins changed: {Count} SPKI pin(s) [{Pins}]{Legacy}; {Mode}.", "Server pins changed, source {Source}: {Count} SPKI pin(s) [{Pins}]{Legacy}; {Mode}.",
next.Source,
next.SpkiPins.Count, next.SpkiPins.Count,
string.Join(", ", next.SpkiPins.OrderBy(p => p, StringComparer.Ordinal).Select(p => p[..12])), string.Join(", ", next.SpkiPins.OrderBy(p => p, StringComparer.Ordinal).Select(p => p[..12])),
legacyThumbprint is null ? "" : " + legacy thumbprint", next.LegacyThumbprint is null ? "" : " + legacy thumbprint",
next.SpkiPins.Count > 0 || legacyThumbprint is not null ? "pinned" : "Windows chain validation"); next.Pinned ? "pinned" : "Windows chain validation");
return next; return next;
} }
} }
// Malformed-pin warnings only fire when the raw registry text changes. // Malformed-pin warnings only fire when the raw registry text changes.
private Snapshot Build(List<string> raw) private Snapshot Build(RegistryRead read)
{ {
var pins = new HashSet<string>(configPins, StringComparer.Ordinal); if (!read.Present)
foreach (var pin in raw) return new Snapshot(new HashSet<string>(configPins, StringComparer.Ordinal),
legacyThumbprint, FromRegistry: false, read.Raw);
var pins = new HashSet<string>(StringComparer.Ordinal);
foreach (var pin in read.Values)
AddPin(pins, pin, "registry", logger); AddPin(pins, pin, "registry", logger);
return new Snapshot(pins, string.Join("|", raw)); return new Snapshot(pins, LegacyThumbprint: null, FromRegistry: true, read.Raw);
} }
public bool Validate(X509Certificate? certificate, SslPolicyErrors errors, AgentHttpChannel channel) public bool Validate(X509Certificate? certificate, SslPolicyErrors errors, AgentHttpChannel channel)
@@ -276,7 +297,7 @@ public sealed class ServerPinSet
} }
var snapshot = Current(); var snapshot = Current();
if (snapshot.SpkiPins.Count == 0 && legacyThumbprint is null) if (!snapshot.Pinned)
{ {
if (errors == SslPolicyErrors.None) if (errors == SslPolicyErrors.None)
return true; return true;
@@ -293,8 +314,8 @@ public sealed class ServerPinSet
var spki = SpkiSha256(leaf); var spki = SpkiSha256(leaf);
if (snapshot.SpkiPins.Contains(spki)) if (snapshot.SpkiPins.Contains(spki))
return true; return true;
if (legacyThumbprint is not null if (snapshot.LegacyThumbprint is not null
&& string.Equals(leaf.GetCertHashString(), legacyThumbprint, StringComparison.OrdinalIgnoreCase)) && string.Equals(leaf.GetCertHashString(), snapshot.LegacyThumbprint, StringComparison.OrdinalIgnoreCase))
return true; return true;
logger.LogError( logger.LogError(
@@ -327,9 +348,10 @@ public sealed class ServerPinSet
} }
// Startup keeps whatever views read. A refresh returns null on any failed view // Startup keeps whatever views read. A refresh returns null on any failed view
// so a transient denial cannot silently drop pins (and the server with them). // so a transient denial cannot silently swap the pin source (and the server with it).
private static List<string>? ReadRegistryPins(ILogger logger, bool refresh) private static RegistryRead? ReadRegistryPins(ILogger logger, bool refresh)
{ {
var present = false;
var found = new List<string>(); var found = new List<string>();
foreach (var view in new[] { RegistryView.Registry64, RegistryView.Registry32 }) foreach (var view in new[] { RegistryView.Registry64, RegistryView.Registry32 })
{ {
@@ -337,7 +359,10 @@ public sealed class ServerPinSet
{ {
using var machine = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, view); using var machine = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, view);
using var key = machine.OpenSubKey(AgentConfigProvider.RegistryPath); using var key = machine.OpenSubKey(AgentConfigProvider.RegistryPath);
switch (key?.GetValue(RegistryValue)) var value = key?.GetValue(RegistryValue);
if (value is not null)
present = true;
switch (value)
{ {
case string[] many: case string[] many:
found.AddRange(many); found.AddRange(many);
@@ -357,6 +382,6 @@ public sealed class ServerPinSet
logger.LogWarning(error, "Registry read of {Value} in {View} view failed", RegistryValue, view); logger.LogWarning(error, "Registry read of {Value} in {View} view failed", RegistryValue, view);
} }
} }
return found; return new RegistryRead(present, found);
} }
} }

View File

@@ -145,10 +145,16 @@ public sealed class CommandExecutor
// installer pinned by SHA-256 and runs it silently. Both download // installer pinned by SHA-256 and runs it silently. Both download
// the body themselves with the collector credential and fail closed. // the body themselves with the collector credential and fail closed.
case "run_script": case "run_script":
{
var auth = new DownloadAuth();
return RequireTarget(action, target) return RequireTarget(action, target)
?? await RunScript(target, claim.Script, token); ?? auth.Stamp(await RunScript(target, claim.Script, auth, token));
}
case "update_app": case "update_app":
return await UpdateApp(claim.App, token); {
var auth = new DownloadAuth();
return auth.Stamp(await UpdateApp(claim.App, auth, token));
}
// Fixed destructive action: reboot this endpoint immediately. Runs // Fixed destructive action: reboot this endpoint immediately. Runs
// as the SYSTEM agent, so shutdown does not need a user session. // as the SYSTEM agent, so shutdown does not need a user session.
@@ -1158,7 +1164,7 @@ public sealed class CommandExecutor
// is to trigger something already installed on the endpoint, use // is to trigger something already installed on the endpoint, use
// scheduledtask_run instead: it is a much smaller grant. // scheduledtask_run instead: it is a much smaller grant.
private async Task<CommandResult> RunScript( private async Task<CommandResult> RunScript(
string name, ScriptDeliveryClaim? script, CancellationToken token) string name, ScriptDeliveryClaim? script, DownloadAuth auth, CancellationToken token)
{ {
if (script is null || string.IsNullOrWhiteSpace(script.InstallPath)) if (script is null || string.IsNullOrWhiteSpace(script.InstallPath))
{ {
@@ -1211,10 +1217,7 @@ public sealed class CommandExecutor
using var client = httpFactory.CreateClient( using var client = httpFactory.CreateClient(
AgentHttpChannel.Download, TimeSpan.FromMinutes(5)); AgentHttpChannel.Download, TimeSpan.FromMinutes(5));
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, scriptUrl); using var response = await SendDownloadAsync(client, scriptUrl, "run_script", auth, token);
using var response = await client.SendAsync(
httpRequest, HttpCompletionOption.ResponseHeadersRead, token);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
return new CommandResult return new CommandResult
@@ -1872,7 +1875,8 @@ public sealed class CommandExecutor
// credential, verifies the SHA-256 (when the server pinned one), then runs // credential, verifies the SHA-256 (when the server pinned one), then runs
// an MSI via msiexec /qn or an EXE with the version's SilentArgs. Anything // an MSI via msiexec /qn or an EXE with the version's SilentArgs. Anything
// missing or mismatched fails closed. // missing or mismatched fails closed.
private async Task<CommandResult> UpdateApp(AppDeliveryClaim? app, CancellationToken token) private async Task<CommandResult> UpdateApp(
AppDeliveryClaim? app, DownloadAuth auth, CancellationToken token)
{ {
if (app is null || string.IsNullOrWhiteSpace(app.InstallPath)) if (app is null || string.IsNullOrWhiteSpace(app.InstallPath))
{ {
@@ -1910,10 +1914,7 @@ public sealed class CommandExecutor
using var client = httpFactory.CreateClient( using var client = httpFactory.CreateClient(
AgentHttpChannel.Download, TimeSpan.FromMinutes(10)); AgentHttpChannel.Download, TimeSpan.FromMinutes(10));
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, packageUrl); using var response = await SendDownloadAsync(client, packageUrl, "update_app", auth, token);
using var response = await client.SendAsync(
httpRequest, HttpCompletionOption.ResponseHeadersRead, token);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
return new CommandResult return new CommandResult
@@ -2010,6 +2011,64 @@ public sealed class CommandExecutor
} }
} }
// What a download had to do about a revoked credential, stamped onto the
// command result (message suffix + detail.authfallback) however it ends.
private sealed class DownloadAuth
{
public string? Note { get; set; }
public CommandResult Stamp(CommandResult result)
{
if (Note is null)
return result;
result.Message = $"{result.Message} ({Note})";
result.Detail ??= new Dictionary<string, object>();
result.Detail["authfallback"] = Note;
return result;
}
}
// GET for run_script/update_app bodies. A command claimed before a revoke
// downloads with the revoked credential and gets 401 CREDENTIAL_INVALID; the
// auth handler has already deleted it and woken enrollment. Resend ONCE,
// which picks up the shared key (or a credential enrolled meanwhile).
private async Task<HttpResponseMessage> SendDownloadAsync(
HttpClient client, string url, string action, DownloadAuth auth, CancellationToken token)
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
var response = await client.SendAsync(
request, HttpCompletionOption.ResponseHeadersRead, token);
if (response.StatusCode != System.Net.HttpStatusCode.Unauthorized
|| !request.Headers.Contains(AgentAuthHandler.CredentialHeader))
return response;
// Buffered by the auth handler on 401.
var body = await response.Content.ReadAsStringAsync(token);
if (AgentServerError.TryParse(body)?.Code != AgentAuthErrorCodes.CredentialInvalid)
return response;
var credentials = httpFactory.Credentials;
if (credentials.Current is null && credentials.SharedKey is null)
{
auth.Note = "endpoint credential was revoked and no shared key is configured to retry the download; "
+ "the PC must re-enroll";
logger.LogWarning("{Action} download refused the revoked endpoint credential; no shared key to retry with",
action);
return response;
}
response.Dispose();
using var retry = new HttpRequestMessage(HttpMethod.Get, url);
var second = await client.SendAsync(
retry, HttpCompletionOption.ResponseHeadersRead, token);
var via = retry.Headers.Contains(AgentAuthHandler.SharedKeyHeader)
? "the shared key" : "the newly enrolled credential";
auth.Note = $"endpoint credential was revoked; download retried once with {via}: HTTP {(int)second.StatusCode}";
logger.LogWarning("{Action} download refused the revoked endpoint credential; retried with {Via}: HTTP {Status}",
action, via, (int)second.StatusCode);
return second;
}
// MSI installs run through msiexec /i <pkg> /qn (any SilentArgs appended); // MSI installs run through msiexec /i <pkg> /qn (any SilentArgs appended);
// an EXE runs directly with its version's SilentArgs (homegrown EXEs vary by // an EXE runs directly with its version's SilentArgs (homegrown EXEs vary by
// developer, so the switches are per-version server data, not guessed here). // developer, so the switches are per-version server data, not guessed here).

View File

@@ -274,8 +274,10 @@
fallback until the PC is enrolled, so the build also runs against an fallback until the PC is enrolled, so the build also runs against an
older server (a 404 on enroll parks enrollment for 6 hours). A revoked older server (a 404 on enroll parks enrollment for 6 hours). A revoked
credential (CREDENTIAL_INVALID) is deleted and the PC re-enrolls without credential (CREDENTIAL_INVALID) is deleted and the PC re-enrolls without
a restart. Server trust moves to SPKI SHA-256 pins (ServerPins registry a restart, and a script or package download refused that way retries
value and serverpins in appsettings.json), checked by one AgentHttpFactory once. Server trust moves to SPKI SHA-256 pins (the ServerPins registry
value when it exists, even empty; otherwise serverpins in
appsettings.json), checked by one AgentHttpFactory
that now builds every HttpClient, including script and package downloads that now builds every HttpClient, including script and package downloads
that previously had their own unpinned handlers. The tray holds no that previously had their own unpinned handlers. The tray holds no
secret any more: it reaches the service over the local named pipe secret any more: it reaches the service over the local named pipe

View File

@@ -641,7 +641,7 @@ All use the standard error envelope (`data.error.code`).
| HTTP | Code | Meaning | Agent action | | HTTP | Code | Meaning | Agent action |
|---|---|---|---| |---|---|---|---|
| 401 | `CREDENTIAL_INVALID` | unknown or revoked credential | delete credential.bin, fall back to the shared key, wake enrollment | | 401 | `CREDENTIAL_INVALID` | unknown or revoked credential | delete credential.bin, fall back to the shared key, wake enrollment; a `scriptbody` / `apppackage` download retries once (shared key, or a credential enrolled meanwhile) and the command result carries `detail.authfallback`; with no shared key the command fails saying so |
| 401 | `SHARED_KEY_REFUSED` | shared key sent, but this PC is enrolled or the shared key is switched off | wake enrollment; use the credential | | 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 | | 403 | `ASSET_MISMATCH` | the credential's asset differs from the hostname/assetid named in the request | log; do not retry the same request |
@@ -674,8 +674,16 @@ for the SYSTEM reporter scripts.
### 9.5 Server pins ### 9.5 Server pins
- `HKLM\SOFTWARE\GE\ShopDB` `ServerPins` (REG_MULTI_SZ, both registry views) - `HKLM\SOFTWARE\GE\ShopDB` `ServerPins` (REG_MULTI_SZ, both registry views)
merged with `serverpins` (string array) in appsettings.json. Legacy is authoritative whenever the value EXISTS in either view, even empty: its
`servercertthumbprint` is still honoured. pins (possibly none) are the whole set and `serverpins` / legacy
`servercertthumbprint` in appsettings.json are ignored. Only when the value
is absent in both views do the appsettings.json pins apply. The agent logs
which source is in effect (`registry` or `appsettings.json`) at startup and
on every change.
- `Set-ShopDBServerPins.ps1 -Pins <pins>` writes the set; `-None` writes an
empty value (registry authoritative, no pins, normal chain validation);
`-Clear` deletes the value (appsettings.json pins apply again). To retire an
old pin after a rotation, write only the new pin.
- A pin is the lowercase hex SHA-256 of the server leaf certificate's - A pin is the lowercase hex SHA-256 of the server leaf certificate's
SubjectPublicKeyInfo. The Server Certificate settings page shows it SubjectPublicKeyInfo. The Server Certificate settings page shows it
(`spkisha256` on `GET /api/endpoints/settings/server-cert`); (`spkisha256` on `GET /api/endpoints/settings/server-cert`);

View File

@@ -150,17 +150,42 @@ def _leaf_certificate_facts(pembytes):
return _spki_sha256(block), isca return _spki_sha256(block), isca
def _current_server_pin(): def _current_server_facts():
"""Pin of this server's own leaf certificate, or None when not found/CA.""" """(spkisha256, isca) of this server's own leaf certificate, else (None, None)."""
path = _server_cert_path() path = _server_cert_path()
if not path: if not path:
return None return None, None
try: try:
with open(path, 'rb') as handle: with open(path, 'rb') as handle:
pin, isca = _leaf_certificate_facts(handle.read()) return _leaf_certificate_facts(handle.read())
except OSError: except OSError:
return None return None, None
return None if isca else pin
def _current_server_pin():
"""Pin of this server's own leaf certificate, or None when not found.
The SPKI pin does not depend on basicConstraints, so a CA-flagged
self-signed certificate is pinned like any other leaf.
"""
return _current_server_facts()[0]
# Shown to the admin at build time (response header and UI) when the pinned
# certificate carries basicConstraints CA:TRUE.
CA_CERT_BUILD_WARNING = (
'The server certificate has basicConstraints CA:TRUE, so install.ps1 will '
'not import it into LocalMachine\\Root. The package still pins it: agents '
'1.20 and newer trust the server by pin alone. Agents older than 1.20 and '
'PowerShell 5.1 downloads without pin support will not trust this server. '
'Regenerate the server certificate in Settings > Endpoints > Server '
'certificate (the generator does not set CA), or trust a non-CA '
'certificate on those PCs with Install-ShopDBServerCertificate.ps1.')
CA_CERT_BOTH_PINS_WARNING = (
'The uploaded CA-flagged certificate does not match this server\'s own '
'certificate, so both pins were included in ServerPins: the uploaded '
'certificate and the server\'s own certificate.')
CA_WARNING_HEADER = 'X-ShopDB-Build-Warning'
# The non-secret runtime config shipped in the package. baseurl and apikey stay # The non-secret runtime config shipped in the package. baseurl and apikey stay
@@ -485,6 +510,12 @@ if (-not [string]::IsNullOrWhiteSpace($CertFileName)) {{
}} }}
if ($certIsCa) {{ if ($certIsCa) {{
Write-Warning "server certificate $CertFileName is a CA certificate; not importing it into LocalMachine\\Root." Write-Warning "server certificate $CertFileName is a CA certificate; not importing it into LocalMachine\\Root."
if ($pinList.Count -gt 0) {{
Write-Warning "ServerPins were written: agents 1.20 and newer trust the server by pin alone."
}}
Write-Warning "Agents older than 1.20, and PowerShell 5.1 downloads without pin support, will NOT trust this server."
Write-Warning "Fix: regenerate the server certificate with the ShopDB generator (Settings > Endpoints > Server certificate; it does not set CA),"
Write-Warning "or have an admin trust a non-CA server certificate on this PC with Install-ShopDBServerCertificate.ps1."
}} }}
else {{ else {{
Write-Host " trusting bundled server certificate $CertFileName ..." Write-Host " trusting bundled server certificate $CertFileName ..."
@@ -889,7 +920,9 @@ def package_siteagent():
Returns an in-memory ZIP with the staged shopdbagent.exe (and tray when Returns an in-memory ZIP with the staged shopdbagent.exe (and tray when
present), a non-secret appsettings.json, install.ps1, and README.txt. The present), a non-secret appsettings.json, install.ps1, and README.txt. The
collector key is embedded only in install.ps1 as its default parameter and collector key is embedded only in install.ps1 as its default parameter and
is never stored or logged. is never stored or logged. When the pinned certificate is CA-flagged the
response carries an X-ShopDB-Build-Warning header explaining that only
pinning (agents 1.20 and newer) will trust the server.
""" """
if request.form or request.files: if request.form or request.files:
source = request.form source = request.form
@@ -959,29 +992,42 @@ def package_siteagent():
certfilename = 'server.crt' if (cert_pem or certpath) else None certfilename = 'server.crt' if (cert_pem or certpath) else None
# Pin the bundled leaf when there is one; a CA bundle cannot be pinned (the # Pin the leaf the server presents: the bundled/uploaded leaf, else this
# agent hashes the leaf) so fall back to this server's own certificate. # server's own certificate. The SPKI pin ignores basicConstraints, so a
# CA-flagged self-signed cert is still pinned; the CA flag only gates the
# Root import.
trustcertinroot = True trustcertinroot = True
currentpin = None currentpin = None
pinisca = False
if certfilename: if certfilename:
if cert_pem: if cert_pem:
bundledbytes = cert_pem.encode('ascii') bundledbytes = cert_pem.encode('ascii')
else: else:
with open(certpath, 'rb') as handle: with open(certpath, 'rb') as handle:
bundledbytes = handle.read() bundledbytes = handle.read()
bundledpin, bundledisca = _leaf_certificate_facts(bundledbytes) currentpin, bundledisca = _leaf_certificate_facts(bundledbytes)
trustcertinroot = not bundledisca trustcertinroot = not bundledisca
if not bundledisca: pinisca = bool(bundledisca)
currentpin = bundledpin # An uploaded CA-flagged cert may be the server's own self-signed cert or an
# internal CA root sent by mistake. Ship the server's own pin beside it so
# the agent (which accepts any listed pin) works either way.
serverownpin = None
if cert_pem and pinisca:
serverownpin = _current_server_pin()
if serverownpin == currentpin:
serverownpin = None
if currentpin is None: if currentpin is None:
currentpin = _current_server_pin() currentpin, pinisca = _current_server_facts()
warnings = [CA_CERT_BUILD_WARNING] if (currentpin and pinisca) else []
if serverownpin:
warnings.append(CA_CERT_BOTH_PINS_WARNING)
if nextserverpin and currentpin is None: if nextserverpin and currentpin is None:
return error_response( return error_response(
ErrorCodes.VALIDATION_ERROR, ErrorCodes.VALIDATION_ERROR,
'nextserverpin needs a current server pin; bundle or upload the ' 'nextserverpin needs a current server pin; bundle or upload the '
'server leaf certificate', 'server leaf certificate',
http_code=400) http_code=400)
serverpins = [pin for pin in (currentpin, nextserverpin) if pin] serverpins = [pin for pin in (currentpin, serverownpin, nextserverpin) if pin]
serverpins = list(dict.fromkeys(serverpins)) serverpins = list(dict.fromkeys(serverpins))
appsettings['serverpins'] = serverpins appsettings['serverpins'] = serverpins
@@ -1020,8 +1066,11 @@ def package_siteagent():
agentversion = agentstore.current_version('agent') agentversion = agentstore.current_version('agent')
download_name = ('shopdb-endpoint-agent-{0}.zip'.format(agentversion) download_name = ('shopdb-endpoint-agent-{0}.zip'.format(agentversion)
if agentversion else 'shopdb-endpoint-agent.zip') if agentversion else 'shopdb-endpoint-agent.zip')
return send_file( response = send_file(
buffer, buffer,
mimetype='application/zip', mimetype='application/zip',
as_attachment=True, as_attachment=True,
download_name=download_name) download_name=download_name)
if warnings:
response.headers[CA_WARNING_HEADER] = ' '.join(warnings)
return response

View File

@@ -19,17 +19,27 @@
requested set first, and nothing is written when they already match, so it requested set first, and nothing is written when they already match, so it
is safe every cycle. is safe every cycle.
THE REGISTRY WINS. When ServerPins exists in either view, even empty, the
agent uses it alone and ignores serverpins / servercertthumbprint in
appsettings.json (the installer and store upgrade write those). Only when
the value is absent in both views do the appsettings pins apply.
ROTATING THE SERVER KEY: pass the old pin AND the new pin until every PC has 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 both, switch the server certificate, then write ONLY the new pin to retire
only the old pin refuses the new certificate. the old one. A PC holding only the old pin refuses the new certificate.
.PARAMETER Pins .PARAMETER Pins
One or more pins separated by commas, semicolons or spaces. Colons and case 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. are ignored. Every value must be 64 hex characters or nothing is written.
.PARAMETER Clear .PARAMETER Clear
Remove ServerPins from both views. Pinning is then off and the normal Delete ServerPins from both views. The agent then falls back to the pins
Windows certificate checks apply. in appsettings.json; with none there, normal Windows certificate checks.
.PARAMETER None
Write an EMPTY ServerPins in both views. The registry stays authoritative
with no pins, so appsettings.json pins are ignored and the normal Windows
certificate checks apply.
.PARAMETER LogPath .PARAMETER LogPath
Append a transcript here as well as writing to the pipeline. Append a transcript here as well as writing to the pipeline.
@@ -42,6 +52,14 @@
.\Set-ShopDBServerPins.ps1 -Pins "<oldpin>,<newpin>" -WhatIf .\Set-ShopDBServerPins.ps1 -Pins "<oldpin>,<newpin>" -WhatIf
Show what a key rotation would write without touching the registry. Show what a key rotation would write without touching the registry.
.EXAMPLE
.\Set-ShopDBServerPins.ps1 -Pins <newpin>
After the server switched certificates: retire the old pin.
.EXAMPLE
.\Set-ShopDBServerPins.ps1 -None
Turn pinning off on a PC whose appsettings.json still carries pins.
.NOTES .NOTES
Exit codes: Exit codes:
0 written, or already correct 0 written, or already correct
@@ -51,6 +69,7 @@
param( param(
[string]$Pins, [string]$Pins,
[switch]$Clear, [switch]$Clear,
[switch]$None,
[string]$LogPath [string]$LogPath
) )
@@ -69,16 +88,20 @@ function Log {
} }
} }
# Returns @{ Present; Pins }. Present = the value exists, even empty.
function Get-CurrentPins { function Get-CurrentPins {
param([Microsoft.Win32.RegistryView]$View) param([Microsoft.Win32.RegistryView]$View)
$result = @{ Present = $false; Pins = @() }
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $View) $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $View)
try { try {
$key = $base.OpenSubKey($SubKey) $key = $base.OpenSubKey($SubKey)
if (-not $key) { return @() } if (-not $key) { return $result }
try { try {
$value = $key.GetValue($ValueName) $value = $key.GetValue($ValueName)
if ($null -eq $value) { return @() } if ($null -eq $value) { return $result }
return @(@($value) | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) $result.Present = $true
$result.Pins = @(@($value) | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ })
return $result
} finally { $key.Dispose() } } finally { $key.Dispose() }
} finally { $base.Dispose() } } finally { $base.Dispose() }
} }
@@ -97,9 +120,15 @@ function Test-SamePins {
# --- 1. validate the request ------------------------------------------------ # --- 1. validate the request ------------------------------------------------
$requested = @() $requested = @()
if (-not $Clear) { if (@(@($Clear.IsPresent, $None.IsPresent, [bool]$Pins) | Where-Object { $_ }).Count -gt 1) {
Log 'ERROR use only one of -Pins, -Clear, -None'
exit 1
}
if ($None) {
Log 'requested an empty ServerPins (registry authoritative, no pins)'
} elseif (-not $Clear) {
if (-not $Pins -or -not $Pins.Trim()) { if (-not $Pins -or -not $Pins.Trim()) {
Log 'ERROR no -Pins given (use -Clear to remove pinning)' Log 'ERROR no -Pins given (-Clear deletes the value, -None writes it empty)'
exit 1 exit 1
} }
foreach ($raw in ($Pins -split '[,;\s]+')) { foreach ($raw in ($Pins -split '[,;\s]+')) {
@@ -128,10 +157,11 @@ $views = @(
$failed = $false $failed = $false
foreach ($entry in $views) { foreach ($entry in $views) {
try { try {
$current = @(Get-CurrentPins -View $entry.View) $state = Get-CurrentPins -View $entry.View
$current = @($state.Pins)
if ($Clear) { if ($Clear) {
if ($current.Count -eq 0) { if (-not $state.Present) {
Log "$($entry.Name): no ServerPins; nothing to do" Log "$($entry.Name): no ServerPins; nothing to do"
continue continue
} }
@@ -143,12 +173,12 @@ foreach ($entry in $views) {
try { $key.DeleteValue($ValueName, $false) } finally { $key.Dispose() } try { $key.DeleteValue($ValueName, $false) } finally { $key.Dispose() }
} }
} finally { $base.Dispose() } } finally { $base.Dispose() }
Log "$($entry.Name): removed ServerPins" Log "$($entry.Name): deleted ServerPins (appsettings.json pins apply)"
} }
continue continue
} }
if (Test-SamePins -Left $current -Right $requested) { if ($state.Present -and (Test-SamePins -Left $current -Right $requested)) {
Log "$($entry.Name): ServerPins already correct; nothing to do" Log "$($entry.Name): ServerPins already correct; nothing to do"
continue continue
} }
@@ -164,8 +194,8 @@ foreach ($entry in $views) {
} finally { $base.Dispose() } } finally { $base.Dispose() }
# Read back rather than trust SetValue. # Read back rather than trust SetValue.
$after = @(Get-CurrentPins -View $entry.View) $after = Get-CurrentPins -View $entry.View
if (-not (Test-SamePins -Left $after -Right $requested)) { if (-not $after.Present -or -not (Test-SamePins -Left @($after.Pins) -Right $requested)) {
Log "ERROR $($entry.Name): ServerPins did not read back as written" Log "ERROR $($entry.Name): ServerPins did not read back as written"
$failed = $true $failed = $true
continue continue

View File

@@ -103,6 +103,18 @@ The script compares before it writes, so `Always` costs nothing per cycle.
A running agent picks up a changed `ServerPins` within about 60 seconds; no A running agent picks up a changed `ServerPins` within about 60 seconds; no
service restart is needed. service restart is needed.
**The registry wins.** The installer and the store upgrade also write
`serverpins` into appsettings.json. When `ServerPins` exists in the registry
(either view), even empty, the agent uses only the registry and ignores the
appsettings.json pins; only a PC with no `ServerPins` value falls back to them.
The agent log says which source is in effect (`Server certificate trust from
registry` / `from appsettings.json`, and `Server pins changed, source ...`).
- `-Pins <pin>[,<pin>]`: write exactly this set.
- `-None`: write an EMPTY value. No pins, normal Windows certificate checks,
appsettings.json pins ignored.
- `-Clear`: DELETE the value. The appsettings.json pins apply again (if any).
**Gate:** on the pilot, `HKLM\SOFTWARE\GE\ShopDB` and **Gate:** on the pilot, `HKLM\SOFTWARE\GE\ShopDB` and
`HKLM\SOFTWARE\WOW6432Node\GE\ShopDB` both hold `ServerPins` (REG_MULTI_SZ) `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 equal to the pin on the settings page, and the next `report-asset-*.log` under
@@ -113,7 +125,10 @@ RESPONSE line.
certificate a new key (a regenerated or re-issued certificate) changes the pin. certificate a new key (a regenerated or re-issued certificate) changes the pin.
Add the NEXT pin everywhere first: put the old AND the new pin in `-Pins` Add the NEXT pin everywhere first: put the old AND the new pin in `-Pins`
(`"<oldpin>,<newpin>"`), wait until every PC has both, only then switch the (`"<oldpin>,<newpin>"`), wait until every PC has both, only then switch the
certificate on the server, then drop the old pin. Switching the server first certificate on the server, then retire the old pin by writing ONLY the new pin
to the registry (`-Pins <newpin>`). Do not use `-Clear` for this: deleting the
value brings back whatever pins appsettings.json carries, which may be the old
one. Switching the server first
cuts off every pinned PC, including its ability to receive the fixed pin list cuts off every pinned PC, including its ability to receive the fixed pin list
through anything that talks to ShopDB. A renewal that keeps the same key keeps through anything that talks to ShopDB. A renewal that keeps the same key keeps
the same pin. PCs trusted through the Root store (below) also need the new the same pin. PCs trusted through the Root store (below) also need the new
@@ -312,7 +327,8 @@ enrolled PC. Block re-enrollment FIRST.
4. Verify: the Fleet row reports the old agent version, and the reporter logs 4. Verify: the Fleet row reports the old agent version, and the reporter logs
show `RESPONSE (shared key)`. show `RESPONSE (shared key)`.
5. Remove the PC from the pin entry's targets only if pinning itself is the 5. Remove the PC from the pin entry's targets only if pinning itself is the
problem (`Set-ShopDBServerPins.ps1 -Clear` removes the value). problem. `Set-ShopDBServerPins.ps1 -None` turns pinning off outright;
`-Clear` deletes the value, which falls back to any appsettings.json pins.
6. Later, when moving the PC forward again, lift the block: 6. Later, when moving the PC forward again, lift the block:
`POST /api/endpoints/enrollment/assets/<assetid>/allow-reenroll` (or the `POST /api/endpoints/enrollment/assets/<assetid>/allow-reenroll` (or the
same action in the UI). The PC enrolls on its next attempt once it runs same action in the UI). The PC enrolls on its next attempt once it runs

View File

@@ -357,8 +357,9 @@
For a reverse-proxy or internal-CA server this app cannot see, upload For a reverse-proxy or internal-CA server this app cannot see, upload
the server's public certificate in PEM format. It is validated, the server's public certificate in PEM format. It is validated,
bundled with the package, pinned, and imported into the endpoint's bundled with the package, pinned, and imported into the endpoint's
Trusted Root store by <code>install.ps1</code> (a CA certificate is Trusted Root store by <code>install.ps1</code> (a CA-flagged
bundled but never imported or pinned). Leave empty when the certificate is bundled and pinned but never imported, so only agents
1.20 and newer will trust the server). Leave empty when the
server uses a publicly trusted certificate. An upload takes precedence server uses a publicly trusted certificate. An upload takes precedence
over the bundled server certificate above. over the bundled server certificate above.
</p> </p>
@@ -376,6 +377,7 @@
</button> </button>
</div> </div>
<p v-if="agentmessage" class="field-hint">{{ agentmessage }}</p> <p v-if="agentmessage" class="field-hint">{{ agentmessage }}</p>
<p v-if="agentwarning" class="field-warning">{{ agentwarning }}</p>
<p v-if="agenterror" class="field-error">{{ agenterror }}</p> <p v-if="agenterror" class="field-error">{{ agenterror }}</p>
<!-- ================================================================ <!-- ================================================================
@@ -603,6 +605,7 @@ const agentcertfile = ref(null)
const agentbusy = ref(false) const agentbusy = ref(false)
const agentmessage = ref('') const agentmessage = ref('')
const agenterror = ref('') const agenterror = ref('')
const agentwarning = ref('')
const agentincludesharedkey = ref(true) const agentincludesharedkey = ref(true)
const agentnextserverpin = ref('') const agentnextserverpin = ref('')
const agentserverpin = ref('') const agentserverpin = ref('')
@@ -835,6 +838,7 @@ async function buildSiteAgent() {
agentbusy.value = true agentbusy.value = true
agentmessage.value = '' agentmessage.value = ''
agenterror.value = '' agenterror.value = ''
agentwarning.value = ''
try { try {
const form = new FormData() const form = new FormData()
form.append('baseurl', agentbaseurl.value) form.append('baseurl', agentbaseurl.value)
@@ -866,6 +870,7 @@ async function buildSiteAgent() {
link.remove() link.remove()
window.URL.revokeObjectURL(url) window.URL.revokeObjectURL(url)
agentmessage.value = 'Site agent built.' agentmessage.value = 'Site agent built.'
agentwarning.value = response.headers?.['x-shopdb-build-warning'] || ''
} catch (buildError) { } catch (buildError) {
if (buildError.response?.status === 400 && buildError.response?.data) { if (buildError.response?.status === 400 && buildError.response?.data) {
agenterror.value = await readBlobError(buildError.response.data) agenterror.value = await readBlobError(buildError.response.data)

View File

@@ -168,24 +168,109 @@ def test_installer_never_removes_enrolled_credential(client, db, auth_headers, s
assert 'credential' not in line.lower(), line assert 'credential' not in line.lower(), line
def test_ca_certificate_is_not_imported_into_root(client, db, auth_headers, servercert): def _upload(client, headers, pem, filename, **fields):
data = {'baseurl': 'https://shopdb.test', 'collectorkey': SECRET,
'cadenceseconds': '900', 'certfile': (io.BytesIO(pem), filename)}
data.update(fields)
return client.post(PACKAGE_URL, data=data,
content_type='multipart/form-data', headers=headers)
def test_ca_certificate_is_pinned_but_not_imported_into_root(
client, db, auth_headers):
capem, capin = _make_cert(isca=True) capem, capin = _make_cert(isca=True)
response = client.post( response = _upload(client, auth_headers, capem, 'ca.crt')
PACKAGE_URL,
data={'baseurl': 'https://shopdb.test', 'collectorkey': SECRET,
'cadenceseconds': '900',
'certfile': (io.BytesIO(capem), 'ca.crt')},
content_type='multipart/form-data', headers=auth_headers)
files = _files(response) files = _files(response)
script = files['install.ps1'].decode('ascii') script = files['install.ps1'].decode('ascii')
assert 'server.crt' in files assert 'server.crt' in files
assert '$trustCertInRoot = $false' in script assert '$trustCertInRoot = $false' in script
assert 'is a CA certificate; not importing it' in script assert 'is a CA certificate; not importing it' in script
# A CA cannot be the pinned leaf; the server's own leaf is pinned instead. assert 'Install-ShopDBServerCertificate.ps1' in script
pins = json.loads(files['appsettings.json'])['serverpins'] assert 'older than 1.20' in script
assert pins == [servercert] # The SPKI pin ignores basicConstraints: the uploaded leaf is pinned.
assert capin not in script assert '[string[]] $ServerPins = @("{0}")'.format(capin) in script
assert json.loads(files['appsettings.json'])['serverpins'] == [capin]
assert response.headers.get(siteagent.CA_WARNING_HEADER) == \
siteagent.CA_CERT_BUILD_WARNING
def test_uploaded_ca_certificate_ships_both_pins_when_server_cert_differs(
client, db, auth_headers, servercert):
capem, capin = _make_cert(isca=True)
nextpin = 'ef' * 32
response = _upload(client, auth_headers, capem, 'ca.crt',
nextserverpin=nextpin)
files = _files(response)
script = files['install.ps1'].decode('ascii')
assert '[string[]] $ServerPins = @("{0}", "{1}", "{2}")'.format(
capin, servercert, nextpin) in script
assert json.loads(files['appsettings.json'])['serverpins'] == [
capin, servercert, nextpin]
assert '$trustCertInRoot = $false' in script
warning = response.headers.get(siteagent.CA_WARNING_HEADER)
assert siteagent.CA_CERT_BUILD_WARNING in warning
assert 'both pins were included' in warning
def test_uploaded_ca_certificate_matching_server_cert_ships_one_pin(
client, db, auth_headers, tmp_path, monkeypatch):
capem, capin = _make_cert(isca=True)
path = tmp_path / 'server.crt'
path.write_bytes(capem)
monkeypatch.setattr(siteagent, '_CERT_CANDIDATES', (str(path),))
response = _upload(client, auth_headers, capem, 'server.crt')
files = _files(response)
assert json.loads(files['appsettings.json'])['serverpins'] == [capin]
assert 'both pins' not in response.headers.get(siteagent.CA_WARNING_HEADER)
@pytest.fixture
def caservercert(tmp_path, monkeypatch):
"""A CA-flagged self-signed cert staged as this server's own certificate,
as a plain openssl req -x509 produces."""
pem, pin = _make_cert(isca=True)
path = tmp_path / 'server.crt'
path.write_bytes(pem)
monkeypatch.setattr(siteagent, '_CERT_CANDIDATES', (str(path),))
return pin
def test_ca_flagged_server_cert_bundled_is_still_pinned(
client, db, auth_headers, caservercert):
nextpin = 'cd' * 32
response = _build(client, auth_headers, collectorkey=SECRET,
bundlecert=True, nextserverpin=nextpin)
files = _files(response)
script = files['install.ps1'].decode('ascii')
assert 'server.crt' in files
assert '[string[]] $ServerPins = @("{0}", "{1}")'.format(caservercert, nextpin) in script
assert json.loads(files['appsettings.json'])['serverpins'] == [caservercert, nextpin]
assert '$trustCertInRoot = $false' in script
warning = response.headers.get(siteagent.CA_WARNING_HEADER)
assert warning and 'Install-ShopDBServerCertificate.ps1' in warning
warning.encode('ascii')
def test_ca_flagged_server_cert_not_bundled_is_still_pinned(
client, db, auth_headers, caservercert):
response = _build(client, auth_headers, collectorkey=SECRET)
files = _files(response)
assert 'server.crt' not in files
assert json.loads(files['appsettings.json'])['serverpins'] == [caservercert]
assert response.headers.get(siteagent.CA_WARNING_HEADER)
status = client.get(STATUS_URL, headers=auth_headers).get_json()['data']
assert status['serverpin'] == caservercert
def test_leaf_certificate_build_has_no_warning(client, db, auth_headers, servercert):
response = _build(client, auth_headers, collectorkey=SECRET, bundlecert=True)
_files(response)
assert siteagent.CA_WARNING_HEADER not in response.headers
def test_leaf_certificate_is_imported_into_root(client, db, auth_headers): def test_leaf_certificate_is_imported_into_root(client, db, auth_headers):