main was 16 days and 259 commits behind the line the product is actually built on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It was neither current nor curated - just stale. This brings it forward and, at the same time, makes it mean something: main is what a receiving site installs, and nothing else. WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides, knowledgebase, machines, network, notifications, printers, slides, tools, warranty - plus applications, which is manifest-less core and always ships. The only manifest dependency inside that set is endpoints on computers, and both are in it, so the closure is self-contained. WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb stay on the site line. So do the Windows installer tree, the VM rehearsal helpers, the agent tooling config, one site's data loader, the internal audit and findings records, and the planner spreadsheet. 1,135 tracked files against 1,438. A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped: deploy/site-profile-universal.json still named all seventeen plugins. It is the file docs/DEPLOY.md tells a site to provision from, so a fresh install would have failed at `flask plugin apply-profile` with PluginError - the first thing a receiving site does, broken by the branch meant to serve them. It now names the twelve that exist, and its comment says the two must agree. agent/ was never refreshed. Whoever populated this branch copied shopdb/, plugins/, frontend/ and tests/ from the site line and left the agent tree at its September state: 22 C# and PowerShell sources missing, including AgentCredentials, EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the site line had deleted still present. The old tree is internally consistent, so it compiles, and nothing in CI builds it - every gate was green over a server that speaks enrollment and per-PC credentials to a bundled agent source that cannot. Resynced whole. DECISIONS THIS ENCODES, rather than defers: - The plugin-authoring lab builds printedparts, whose manifest depends on employees, so it could not stay. The lab remains on the site line and main ships no build-along tutorial. That is a real loss for an adopting developer and is recorded here rather than discovered later. - Recognition, Training and Recertification notifications are no longer seeded. Each splits one notification into a card per employee and renders a photo, which needs a plugin this branch does not bundle. Seeding them would offer an author a type that cannot resolve anybody. A site that installs employees gets them from that plugin's own seed. - Windows/IIS is documented as the legacy path throughout, and the README leads with Docker, because that is what a receiving site gets. The API description is regenerated for THIS branch - 347 paths and 483 operations, down from 415 and 573 - so the published contract describes what is here rather than what the site line serves. docs/CONFIG.md lost the two measuring-tool label keys the code no longer declares, which two lanes had decided opposite ways. Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run rather than skip, and every index line count is derived from the file it names.
158 lines
5.9 KiB
PowerShell
158 lines
5.9 KiB
PowerShell
#requires -Version 5.1
|
|
<#
|
|
.SYNOPSIS
|
|
Print the SPKI SHA-256 pin of a ShopDB server certificate, for building the
|
|
Set-ShopDBServerPins.ps1 entry.
|
|
|
|
.DESCRIPTION
|
|
The pin is the lowercase hex SHA-256 of the leaf certificate's
|
|
SubjectPublicKeyInfo: the same value the Server Certificate settings page
|
|
shows and the endpoint agent compares. It depends only on the key pair, so
|
|
a certificate renewed with the same key keeps its pin.
|
|
|
|
Give it either a URL (the certificate the server actually presents is read
|
|
from the TLS handshake; it is NOT trusted or validated, only hashed) or a
|
|
certificate file (.cer, .crt, .pem, DER or PEM). Use the file form to pin a
|
|
new certificate BEFORE it goes live on the server.
|
|
|
|
Reading from a URL trusts the network path at that moment. Compare the
|
|
printed pin with the one on the Server Certificate settings page before
|
|
rolling it out.
|
|
|
|
.PARAMETER Url
|
|
https://<server>[:port]. Only the host and port are used.
|
|
|
|
.PARAMETER Path
|
|
A certificate file.
|
|
|
|
.EXAMPLE
|
|
.\Get-ShopDBServerPin.ps1 -Url https://<server>
|
|
|
|
.EXAMPLE
|
|
.\Get-ShopDBServerPin.ps1 -Path .\server.crt
|
|
#>
|
|
[CmdletBinding(DefaultParameterSetName = 'Url')]
|
|
param(
|
|
[Parameter(Mandatory = $true, ParameterSetName = 'Url', Position = 0)]
|
|
[string]$Url,
|
|
|
|
[Parameter(Mandatory = $true, ParameterSetName = 'Path')]
|
|
[string]$Path,
|
|
|
|
[int]$TimeoutSec = 15
|
|
)
|
|
|
|
Set-StrictMode -Version Latest
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
$source = @'
|
|
using System;
|
|
using System.IO;
|
|
using System.Net.Security;
|
|
using System.Net.Sockets;
|
|
using System.Security.Authentication;
|
|
using System.Security.Cryptography;
|
|
using System.Security.Cryptography.X509Certificates;
|
|
|
|
public static class ShopDbServerPinReader
|
|
{
|
|
public static byte[] FetchLeafCertificate(string host, int port, int timeoutMilliseconds)
|
|
{
|
|
using (TcpClient client = new TcpClient())
|
|
{
|
|
IAsyncResult pending = client.BeginConnect(host, port, null, null);
|
|
if (!pending.AsyncWaitHandle.WaitOne(timeoutMilliseconds))
|
|
{
|
|
throw new TimeoutException("Timed out connecting to " + host + ":" + port);
|
|
}
|
|
client.EndConnect(pending);
|
|
client.ReceiveTimeout = timeoutMilliseconds;
|
|
client.SendTimeout = timeoutMilliseconds;
|
|
|
|
byte[] captured = null;
|
|
RemoteCertificateValidationCallback capture = delegate(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors errors)
|
|
{
|
|
if (certificate != null) { captured = certificate.GetRawCertData(); }
|
|
return true;
|
|
};
|
|
using (SslStream ssl = new SslStream(client.GetStream(), false, capture))
|
|
{
|
|
ssl.AuthenticateAsClient(host, null, SslProtocols.Tls12, false);
|
|
}
|
|
if (captured == null) { throw new InvalidOperationException("The server presented no certificate"); }
|
|
return captured;
|
|
}
|
|
}
|
|
|
|
// Walks the DER: Certificate > TBSCertificate > [0] version (optional),
|
|
// serialNumber, signature, issuer, validity, subject, subjectPublicKeyInfo.
|
|
public static string SpkiSha256Hex(byte[] der)
|
|
{
|
|
int length;
|
|
int position = EnterSequence(der, 0, out length);
|
|
position = EnterSequence(der, position, out length);
|
|
if (der[position] == 0xA0) { position = Skip(der, position); }
|
|
for (int field = 0; field < 5; field++) { position = Skip(der, position); }
|
|
if (der[position] != 0x30) { throw new FormatException("SubjectPublicKeyInfo not found"); }
|
|
int end = Skip(der, position);
|
|
using (SHA256 sha = SHA256.Create())
|
|
{
|
|
byte[] hash = sha.ComputeHash(der, position, end - position);
|
|
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
|
}
|
|
}
|
|
|
|
private static int ReadLength(byte[] der, int position, out int length)
|
|
{
|
|
int first = der[position];
|
|
if (first < 0x80) { length = first; return position + 1; }
|
|
int count = first & 0x7F;
|
|
if (count == 0 || count > 4) { throw new FormatException("Unsupported DER length"); }
|
|
length = 0;
|
|
for (int i = 1; i <= count; i++) { length = (length << 8) | der[position + i]; }
|
|
return position + 1 + count;
|
|
}
|
|
|
|
private static int EnterSequence(byte[] der, int position, out int length)
|
|
{
|
|
if (der[position] != 0x30) { throw new FormatException("Expected SEQUENCE"); }
|
|
return ReadLength(der, position + 1, out length);
|
|
}
|
|
|
|
private static int Skip(byte[] der, int position)
|
|
{
|
|
int length;
|
|
int content = ReadLength(der, position + 1, out length);
|
|
return content + length;
|
|
}
|
|
}
|
|
'@
|
|
|
|
if (-not ('ShopDbServerPinReader' -as [type])) {
|
|
Add-Type -TypeDefinition $source
|
|
}
|
|
|
|
try {
|
|
if ($PSCmdlet.ParameterSetName -eq 'Path') {
|
|
$resolved = (Resolve-Path -LiteralPath $Path).ProviderPath
|
|
$certificate = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 -ArgumentList $resolved
|
|
$der = $certificate.RawData
|
|
$label = "$resolved (subject $($certificate.Subject), expires $($certificate.NotAfter.ToString('yyyy-MM-dd')))"
|
|
} else {
|
|
$uri = [Uri]$Url
|
|
if ($uri.Scheme -ne 'https') { throw "The URL must be https ($Url)" }
|
|
$der = [ShopDbServerPinReader]::FetchLeafCertificate($uri.Host, $uri.Port, $TimeoutSec * 1000)
|
|
$certificate = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 -ArgumentList (,$der)
|
|
$label = "$($uri.Host):$($uri.Port) (subject $($certificate.Subject), expires $($certificate.NotAfter.ToString('yyyy-MM-dd')))"
|
|
}
|
|
$pin = [ShopDbServerPinReader]::SpkiSha256Hex($der)
|
|
} catch {
|
|
Write-Error "Could not read a certificate: $($_.Exception.Message)"
|
|
exit 1
|
|
}
|
|
|
|
Write-Host "Certificate: $label"
|
|
Write-Host "Pin (SPKI SHA-256):"
|
|
Write-Output $pin
|
|
exit 0
|