Always pin the server, let the registry own pins, and survive a revoke mid-upgrade
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:
@@ -14,13 +14,15 @@ public sealed class AgentConfig
|
||||
[JsonPropertyName("cadenceseconds")] public int CadenceSeconds { get; set; } = 900;
|
||||
|
||||
// Legacy pin: SHA-1 thumbprint of the server leaf certificate (hex, no
|
||||
// separators). Still honoured next to ServerPins; breaks on every cert
|
||||
// renewal, so prefer an SPKI pin. See AgentHttpFactory.
|
||||
// separators). Honoured with serverpins only while the registry ServerPins
|
||||
// value is absent; breaks on every cert renewal, so prefer an SPKI pin.
|
||||
// See AgentHttpFactory.
|
||||
[JsonPropertyName("servercertthumbprint")] public string? ServerCertThumbprint { get; set; }
|
||||
|
||||
// Server public key pins: lowercase hex SHA-256 of the leaf certificate's
|
||||
// SubjectPublicKeyInfo. Merged with HKLM\SOFTWARE\GE\ShopDB ServerPins
|
||||
// (REG_MULTI_SZ). Any pin configured = the leaf key must match one; chain
|
||||
// SubjectPublicKeyInfo. Fallback only: HKLM\SOFTWARE\GE\ShopDB ServerPins
|
||||
// (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.
|
||||
[JsonPropertyName("serverpins")] public List<string> ServerPins { get; set; } = new();
|
||||
|
||||
|
||||
@@ -172,43 +172,55 @@ public sealed record AgentServerError(string Code, string Message)
|
||||
}
|
||||
}
|
||||
|
||||
// Server certificate trust. Pins are hex SHA-256 of the leaf SubjectPublicKeyInfo,
|
||||
// merged from HKLM\SOFTWARE\GE\ShopDB ServerPins (both views) and appsettings
|
||||
// serverpins; the legacy SHA-1 servercertthumbprint still counts. With any pin
|
||||
// configured only a matching leaf is accepted and chain/name errors are ignored
|
||||
// (self-signed servers on the air gap). Without pins: SslPolicyErrors.None.
|
||||
// Registry pins re-read at most every RefreshInterval on the handshake path, so a
|
||||
// GE-Enforce fix or a "next" rotation pin lands without a service restart.
|
||||
// Server certificate trust. Pins are hex SHA-256 of the leaf SubjectPublicKeyInfo.
|
||||
// HKLM\SOFTWARE\GE\ShopDB ServerPins is AUTHORITATIVE whenever the value exists
|
||||
// in either view, even empty (empty = no pins, Windows chain validation), so a
|
||||
// removed or rotated-out pin really goes away. Only when the value is absent in
|
||||
// both views do appsettings serverpins and the legacy SHA-1 servercertthumbprint
|
||||
// apply. With any pin in effect only a matching leaf is accepted and chain/name
|
||||
// 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 const string RegistryValue = "ServerPins";
|
||||
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 string? legacyThumbprint;
|
||||
private readonly ILogger logger;
|
||||
private readonly Func<bool, List<string>?> readRegistry;
|
||||
private readonly Func<bool, RegistryRead?> readRegistry;
|
||||
private readonly object refreshLock = new();
|
||||
private volatile Snapshot current;
|
||||
private long nextRefreshTicks;
|
||||
|
||||
private ServerPinSet(HashSet<string> configPins, string? legacyThumbprint, ILogger logger,
|
||||
Func<bool, List<string>?> readRegistry)
|
||||
Func<bool, RegistryRead?> readRegistry)
|
||||
{
|
||||
this.configPins = configPins;
|
||||
this.legacyThumbprint = legacyThumbprint;
|
||||
this.logger = logger;
|
||||
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;
|
||||
}
|
||||
|
||||
public bool IsPinned => Current().SpkiPins.Count > 0 || legacyThumbprint is not null;
|
||||
public bool IsPinned => Current().Pinned;
|
||||
|
||||
public static ServerPinSet Load(AgentConfig config, ILogger logger)
|
||||
{
|
||||
@@ -221,11 +233,15 @@ public sealed class ServerPinSet
|
||||
thumbprint = config.ServerCertThumbprint.Replace(":", "").Replace(" ", "").Trim().ToUpperInvariant();
|
||||
|
||||
var set = new ServerPinSet(pins, thumbprint, logger, refresh => ReadRegistryPins(logger, refresh));
|
||||
var count = set.current.SpkiPins.Count;
|
||||
var snapshot = set.current;
|
||||
logger.LogInformation(
|
||||
"Server certificate trust: {Pins} SPKI pin(s){Legacy}; {Mode}.",
|
||||
count, thumbprint is null ? "" : " + legacy thumbprint",
|
||||
count > 0 || thumbprint is not null ? "pinned" : "Windows chain validation");
|
||||
"Server certificate trust from {Source}: {Pins} SPKI pin(s){Legacy}; {Mode}.",
|
||||
snapshot.Source, snapshot.SpkiPins.Count,
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -238,33 +254,38 @@ public sealed class ServerPinSet
|
||||
{
|
||||
if (Environment.TickCount64 < nextRefreshTicks)
|
||||
return current;
|
||||
var raw = readRegistry(true);
|
||||
var read = readRegistry(true);
|
||||
Interlocked.Exchange(ref nextRefreshTicks,
|
||||
Environment.TickCount64 + (long)RefreshInterval.TotalMilliseconds);
|
||||
var previous = current;
|
||||
if (raw is null || string.Join("|", raw) == previous.RawRegistry)
|
||||
if (read is null || read.Raw == previous.RawRegistry)
|
||||
return previous;
|
||||
|
||||
var next = Build(raw);
|
||||
var next = Build(read);
|
||||
current = next;
|
||||
if (next.Signature != previous.Signature)
|
||||
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,
|
||||
string.Join(", ", next.SpkiPins.OrderBy(p => p, StringComparer.Ordinal).Select(p => p[..12])),
|
||||
legacyThumbprint is null ? "" : " + legacy thumbprint",
|
||||
next.SpkiPins.Count > 0 || legacyThumbprint is not null ? "pinned" : "Windows chain validation");
|
||||
next.LegacyThumbprint is null ? "" : " + legacy thumbprint",
|
||||
next.Pinned ? "pinned" : "Windows chain validation");
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
foreach (var pin in raw)
|
||||
if (!read.Present)
|
||||
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);
|
||||
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)
|
||||
@@ -276,7 +297,7 @@ public sealed class ServerPinSet
|
||||
}
|
||||
|
||||
var snapshot = Current();
|
||||
if (snapshot.SpkiPins.Count == 0 && legacyThumbprint is null)
|
||||
if (!snapshot.Pinned)
|
||||
{
|
||||
if (errors == SslPolicyErrors.None)
|
||||
return true;
|
||||
@@ -293,8 +314,8 @@ public sealed class ServerPinSet
|
||||
var spki = SpkiSha256(leaf);
|
||||
if (snapshot.SpkiPins.Contains(spki))
|
||||
return true;
|
||||
if (legacyThumbprint is not null
|
||||
&& string.Equals(leaf.GetCertHashString(), legacyThumbprint, StringComparison.OrdinalIgnoreCase))
|
||||
if (snapshot.LegacyThumbprint is not null
|
||||
&& string.Equals(leaf.GetCertHashString(), snapshot.LegacyThumbprint, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
logger.LogError(
|
||||
@@ -327,9 +348,10 @@ public sealed class ServerPinSet
|
||||
}
|
||||
|
||||
// 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).
|
||||
private static List<string>? ReadRegistryPins(ILogger logger, bool refresh)
|
||||
// so a transient denial cannot silently swap the pin source (and the server with it).
|
||||
private static RegistryRead? ReadRegistryPins(ILogger logger, bool refresh)
|
||||
{
|
||||
var present = false;
|
||||
var found = new List<string>();
|
||||
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 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:
|
||||
found.AddRange(many);
|
||||
@@ -357,6 +382,6 @@ public sealed class ServerPinSet
|
||||
logger.LogWarning(error, "Registry read of {Value} in {View} view failed", RegistryValue, view);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
return new RegistryRead(present, found);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,10 +145,16 @@ public sealed class CommandExecutor
|
||||
// installer pinned by SHA-256 and runs it silently. Both download
|
||||
// the body themselves with the collector credential and fail closed.
|
||||
case "run_script":
|
||||
{
|
||||
var auth = new DownloadAuth();
|
||||
return RequireTarget(action, target)
|
||||
?? await RunScript(target, claim.Script, token);
|
||||
?? auth.Stamp(await RunScript(target, claim.Script, auth, token));
|
||||
}
|
||||
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
|
||||
// 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
|
||||
// scheduledtask_run instead: it is a much smaller grant.
|
||||
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))
|
||||
{
|
||||
@@ -1211,10 +1217,7 @@ public sealed class CommandExecutor
|
||||
|
||||
using var client = httpFactory.CreateClient(
|
||||
AgentHttpChannel.Download, TimeSpan.FromMinutes(5));
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, scriptUrl);
|
||||
|
||||
using var response = await client.SendAsync(
|
||||
httpRequest, HttpCompletionOption.ResponseHeadersRead, token);
|
||||
using var response = await SendDownloadAsync(client, scriptUrl, "run_script", auth, token);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return new CommandResult
|
||||
@@ -1872,7 +1875,8 @@ public sealed class CommandExecutor
|
||||
// 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
|
||||
// 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))
|
||||
{
|
||||
@@ -1910,10 +1914,7 @@ public sealed class CommandExecutor
|
||||
|
||||
using var client = httpFactory.CreateClient(
|
||||
AgentHttpChannel.Download, TimeSpan.FromMinutes(10));
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, packageUrl);
|
||||
|
||||
using var response = await client.SendAsync(
|
||||
httpRequest, HttpCompletionOption.ResponseHeadersRead, token);
|
||||
using var response = await SendDownloadAsync(client, packageUrl, "update_app", auth, token);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
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);
|
||||
// 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).
|
||||
|
||||
@@ -274,8 +274,10 @@
|
||||
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
|
||||
credential (CREDENTIAL_INVALID) is deleted and the PC re-enrolls without
|
||||
a restart. Server trust moves to SPKI SHA-256 pins (ServerPins registry
|
||||
value and serverpins in appsettings.json), checked by one AgentHttpFactory
|
||||
a restart, and a script or package download refused that way retries
|
||||
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 previously had their own unpinned handlers. The tray holds no
|
||||
secret any more: it reaches the service over the local named pipe
|
||||
|
||||
@@ -641,7 +641,7 @@ 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 | `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 |
|
||||
| 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
|
||||
|
||||
- `HKLM\SOFTWARE\GE\ShopDB` `ServerPins` (REG_MULTI_SZ, both registry views)
|
||||
merged with `serverpins` (string array) in appsettings.json. Legacy
|
||||
`servercertthumbprint` is still honoured.
|
||||
is authoritative whenever the value EXISTS in either view, even empty: its
|
||||
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
|
||||
SubjectPublicKeyInfo. The Server Certificate settings page shows it
|
||||
(`spkisha256` on `GET /api/endpoints/settings/server-cert`);
|
||||
|
||||
@@ -150,17 +150,42 @@ def _leaf_certificate_facts(pembytes):
|
||||
return _spki_sha256(block), isca
|
||||
|
||||
|
||||
def _current_server_pin():
|
||||
"""Pin of this server's own leaf certificate, or None when not found/CA."""
|
||||
def _current_server_facts():
|
||||
"""(spkisha256, isca) of this server's own leaf certificate, else (None, None)."""
|
||||
path = _server_cert_path()
|
||||
if not path:
|
||||
return None
|
||||
return None, None
|
||||
try:
|
||||
with open(path, 'rb') as handle:
|
||||
pin, isca = _leaf_certificate_facts(handle.read())
|
||||
return _leaf_certificate_facts(handle.read())
|
||||
except OSError:
|
||||
return None
|
||||
return None if isca else pin
|
||||
return None, None
|
||||
|
||||
|
||||
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
|
||||
@@ -485,6 +510,12 @@ if (-not [string]::IsNullOrWhiteSpace($CertFileName)) {{
|
||||
}}
|
||||
if ($certIsCa) {{
|
||||
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 {{
|
||||
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
|
||||
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
|
||||
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:
|
||||
source = request.form
|
||||
@@ -959,29 +992,42 @@ def package_siteagent():
|
||||
|
||||
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
|
||||
# agent hashes the leaf) so fall back to this server's own certificate.
|
||||
# Pin the leaf the server presents: the bundled/uploaded leaf, else this
|
||||
# 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
|
||||
currentpin = None
|
||||
pinisca = False
|
||||
if certfilename:
|
||||
if cert_pem:
|
||||
bundledbytes = cert_pem.encode('ascii')
|
||||
else:
|
||||
with open(certpath, 'rb') as handle:
|
||||
bundledbytes = handle.read()
|
||||
bundledpin, bundledisca = _leaf_certificate_facts(bundledbytes)
|
||||
currentpin, bundledisca = _leaf_certificate_facts(bundledbytes)
|
||||
trustcertinroot = not bundledisca
|
||||
if not bundledisca:
|
||||
currentpin = bundledpin
|
||||
pinisca = bool(bundledisca)
|
||||
# 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:
|
||||
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:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'nextserverpin needs a current server pin; bundle or upload the '
|
||||
'server leaf certificate',
|
||||
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))
|
||||
appsettings['serverpins'] = serverpins
|
||||
|
||||
@@ -1020,8 +1066,11 @@ def package_siteagent():
|
||||
agentversion = agentstore.current_version('agent')
|
||||
download_name = ('shopdb-endpoint-agent-{0}.zip'.format(agentversion)
|
||||
if agentversion else 'shopdb-endpoint-agent.zip')
|
||||
return send_file(
|
||||
response = send_file(
|
||||
buffer,
|
||||
mimetype='application/zip',
|
||||
as_attachment=True,
|
||||
download_name=download_name)
|
||||
if warnings:
|
||||
response.headers[CA_WARNING_HEADER] = ' '.join(warnings)
|
||||
return response
|
||||
|
||||
@@ -19,17 +19,27 @@
|
||||
requested set first, and nothing is written when they already match, so it
|
||||
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
|
||||
both, switch the server certificate, then drop the old pin. A PC holding
|
||||
only the old pin refuses the new certificate.
|
||||
both, switch the server certificate, then write ONLY the new pin to retire
|
||||
the old one. 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.
|
||||
Delete ServerPins from both views. The agent then falls back to the pins
|
||||
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
|
||||
Append a transcript here as well as writing to the pipeline.
|
||||
@@ -42,6 +52,14 @@
|
||||
.\Set-ShopDBServerPins.ps1 -Pins "<oldpin>,<newpin>" -WhatIf
|
||||
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
|
||||
Exit codes:
|
||||
0 written, or already correct
|
||||
@@ -51,6 +69,7 @@
|
||||
param(
|
||||
[string]$Pins,
|
||||
[switch]$Clear,
|
||||
[switch]$None,
|
||||
[string]$LogPath
|
||||
)
|
||||
|
||||
@@ -69,16 +88,20 @@ function Log {
|
||||
}
|
||||
}
|
||||
|
||||
# Returns @{ Present; Pins }. Present = the value exists, even empty.
|
||||
function Get-CurrentPins {
|
||||
param([Microsoft.Win32.RegistryView]$View)
|
||||
$result = @{ Present = $false; Pins = @() }
|
||||
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $View)
|
||||
try {
|
||||
$key = $base.OpenSubKey($SubKey)
|
||||
if (-not $key) { return @() }
|
||||
if (-not $key) { return $result }
|
||||
try {
|
||||
$value = $key.GetValue($ValueName)
|
||||
if ($null -eq $value) { return @() }
|
||||
return @(@($value) | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ })
|
||||
if ($null -eq $value) { return $result }
|
||||
$result.Present = $true
|
||||
$result.Pins = @(@($value) | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ })
|
||||
return $result
|
||||
} finally { $key.Dispose() }
|
||||
} finally { $base.Dispose() }
|
||||
}
|
||||
@@ -97,9 +120,15 @@ function Test-SamePins {
|
||||
# --- 1. validate the request ------------------------------------------------
|
||||
|
||||
$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()) {
|
||||
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
|
||||
}
|
||||
foreach ($raw in ($Pins -split '[,;\s]+')) {
|
||||
@@ -128,10 +157,11 @@ $views = @(
|
||||
$failed = $false
|
||||
foreach ($entry in $views) {
|
||||
try {
|
||||
$current = @(Get-CurrentPins -View $entry.View)
|
||||
$state = Get-CurrentPins -View $entry.View
|
||||
$current = @($state.Pins)
|
||||
|
||||
if ($Clear) {
|
||||
if ($current.Count -eq 0) {
|
||||
if (-not $state.Present) {
|
||||
Log "$($entry.Name): no ServerPins; nothing to do"
|
||||
continue
|
||||
}
|
||||
@@ -143,12 +173,12 @@ foreach ($entry in $views) {
|
||||
try { $key.DeleteValue($ValueName, $false) } finally { $key.Dispose() }
|
||||
}
|
||||
} finally { $base.Dispose() }
|
||||
Log "$($entry.Name): removed ServerPins"
|
||||
Log "$($entry.Name): deleted ServerPins (appsettings.json pins apply)"
|
||||
}
|
||||
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"
|
||||
continue
|
||||
}
|
||||
@@ -164,8 +194,8 @@ foreach ($entry in $views) {
|
||||
} finally { $base.Dispose() }
|
||||
|
||||
# Read back rather than trust SetValue.
|
||||
$after = @(Get-CurrentPins -View $entry.View)
|
||||
if (-not (Test-SamePins -Left $after -Right $requested)) {
|
||||
$after = Get-CurrentPins -View $entry.View
|
||||
if (-not $after.Present -or -not (Test-SamePins -Left @($after.Pins) -Right $requested)) {
|
||||
Log "ERROR $($entry.Name): ServerPins did not read back as written"
|
||||
$failed = $true
|
||||
continue
|
||||
|
||||
@@ -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
|
||||
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
|
||||
`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
|
||||
@@ -113,7 +125,10 @@ RESPONSE line.
|
||||
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`
|
||||
(`"<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
|
||||
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
|
||||
@@ -312,7 +327,8 @@ enrolled PC. Block re-enrollment FIRST.
|
||||
4. Verify: the Fleet row reports the old agent version, and the reporter logs
|
||||
show `RESPONSE (shared key)`.
|
||||
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:
|
||||
`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
|
||||
|
||||
@@ -357,8 +357,9 @@
|
||||
For a reverse-proxy or internal-CA server this app cannot see, upload
|
||||
the server's public certificate in PEM format. It is validated,
|
||||
bundled with the package, pinned, and imported into the endpoint's
|
||||
Trusted Root store by <code>install.ps1</code> (a CA certificate is
|
||||
bundled but never imported or pinned). Leave empty when the
|
||||
Trusted Root store by <code>install.ps1</code> (a CA-flagged
|
||||
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
|
||||
over the bundled server certificate above.
|
||||
</p>
|
||||
@@ -376,6 +377,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<!-- ================================================================
|
||||
@@ -603,6 +605,7 @@ const agentcertfile = ref(null)
|
||||
const agentbusy = ref(false)
|
||||
const agentmessage = ref('')
|
||||
const agenterror = ref('')
|
||||
const agentwarning = ref('')
|
||||
const agentincludesharedkey = ref(true)
|
||||
const agentnextserverpin = ref('')
|
||||
const agentserverpin = ref('')
|
||||
@@ -835,6 +838,7 @@ async function buildSiteAgent() {
|
||||
agentbusy.value = true
|
||||
agentmessage.value = ''
|
||||
agenterror.value = ''
|
||||
agentwarning.value = ''
|
||||
try {
|
||||
const form = new FormData()
|
||||
form.append('baseurl', agentbaseurl.value)
|
||||
@@ -866,6 +870,7 @@ async function buildSiteAgent() {
|
||||
link.remove()
|
||||
window.URL.revokeObjectURL(url)
|
||||
agentmessage.value = 'Site agent built.'
|
||||
agentwarning.value = response.headers?.['x-shopdb-build-warning'] || ''
|
||||
} catch (buildError) {
|
||||
if (buildError.response?.status === 400 && buildError.response?.data) {
|
||||
agenterror.value = await readBlobError(buildError.response.data)
|
||||
|
||||
@@ -168,24 +168,109 @@ def test_installer_never_removes_enrolled_credential(client, db, auth_headers, s
|
||||
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)
|
||||
response = client.post(
|
||||
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)
|
||||
response = _upload(client, auth_headers, capem, 'ca.crt')
|
||||
files = _files(response)
|
||||
script = files['install.ps1'].decode('ascii')
|
||||
|
||||
assert 'server.crt' in files
|
||||
assert '$trustCertInRoot = $false' 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.
|
||||
pins = json.loads(files['appsettings.json'])['serverpins']
|
||||
assert pins == [servercert]
|
||||
assert capin not in script
|
||||
assert 'Install-ShopDBServerCertificate.ps1' in script
|
||||
assert 'older than 1.20' in script
|
||||
# The SPKI pin ignores basicConstraints: the uploaded leaf is pinned.
|
||||
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):
|
||||
|
||||
Reference in New Issue
Block a user