Found in an end-to-end test on a Windows 11 VM against a self-signed server.
- Rollback: a 1.20 agent re-enrolls within a second of an un-enroll, so an
older agent pushed afterwards arrived on an enrolled PC and was refused.
Revoke now takes {"blockreenroll": true, "reason": ...}; a blocked PC is
never issued a credential and keeps working on the shared key until
POST .../assets/<assetid>/allow-reenroll. The Enrollment page offers the
block when un-enrolling and shows blocked PCs with an allow button.
- First upgrade of old agents: agents before 1.20 check the server
certificate when downloading the upgrade, so a store wave failed on PCs that
do not trust a self-signed server while the Migration tab said ready. A
failed download now marks the PC blocked with "server certificate not
trusted by this PC". New Install-ShopDBServerCertificate.ps1 (GE-Enforce,
SYSTEM) adds a non-CA server certificate to the trusted roots after checking
its thumbprint; the server certificate page offers a .cer download and the
SHA-1 thumbprint.
- The generated installer writes ServerPins (with an optional next pin for a
planned rotation), can leave the shared key out so the PC waits for
approval, never touches an existing credential, and skips the trusted-root
import for a CA certificate.
- The agent re-reads server pins about every 60 seconds and recycles pooled
connections within 2 minutes, so a pin change needs no restart.
- An alert can only be acknowledged by a PC it targets.
- A wave no longer counts a PC as failed after a later wave upgraded it.
- The scripts report a pin mismatch only when the pin really is missing, and
otherwise log the real error.
- Runbook: trust step before the first wave, rollback order, pin re-read,
verifying the pin against the live URL; agent contract updated.
213 lines
8.7 KiB
Python
213 lines
8.7 KiB
Python
"""Site agent package build: server pins, optional shared key, Root import gate.
|
|
|
|
The package is the only place a PC gets its first trust anchor and (optionally)
|
|
the shared fleet key, so these tests read the actual ZIP the route returns
|
|
rather than the helper in isolation.
|
|
"""
|
|
|
|
import datetime
|
|
import hashlib
|
|
import io
|
|
import json
|
|
import zipfile
|
|
|
|
import pytest
|
|
from cryptography import x509
|
|
from cryptography.hazmat.primitives import hashes, serialization
|
|
from cryptography.hazmat.primitives.asymmetric import ec
|
|
from cryptography.x509.oid import NameOID
|
|
|
|
from plugins.endpoints.api import agentstore, siteagent
|
|
from shopdb.core.models.setting import Setting
|
|
|
|
PACKAGE_URL = '/api/endpoints/siteagent/package'
|
|
STATUS_URL = '/api/endpoints/siteagent'
|
|
SECRET = 'sharedkeyvalue-0123456789abcdef'
|
|
|
|
|
|
def _make_cert(isca):
|
|
key = ec.generate_private_key(ec.SECP256R1())
|
|
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME,
|
|
'ca.test' if isca else 'server.test')])
|
|
now = datetime.datetime.now(datetime.timezone.utc)
|
|
builder = (x509.CertificateBuilder()
|
|
.subject_name(name).issuer_name(name)
|
|
.public_key(key.public_key())
|
|
.serial_number(x509.random_serial_number())
|
|
.not_valid_before(now - datetime.timedelta(days=1))
|
|
.not_valid_after(now + datetime.timedelta(days=30)))
|
|
if isca:
|
|
builder = builder.add_extension(
|
|
x509.BasicConstraints(ca=True, path_length=None), critical=True)
|
|
certificate = builder.sign(key, hashes.SHA256())
|
|
pem = certificate.public_bytes(serialization.Encoding.PEM)
|
|
spki = certificate.public_key().public_bytes(
|
|
serialization.Encoding.DER,
|
|
serialization.PublicFormat.SubjectPublicKeyInfo)
|
|
return pem, hashlib.sha256(spki).hexdigest()
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def isolate(tmp_path, monkeypatch):
|
|
"""Empty agent store and no auto-detected server certificate by default."""
|
|
store = tmp_path / 'siteagent'
|
|
store.mkdir()
|
|
monkeypatch.setattr(agentstore, '_siteagent_dir', lambda: str(store))
|
|
monkeypatch.setattr(siteagent, '_CERT_CANDIDATES', ())
|
|
return tmp_path
|
|
|
|
|
|
@pytest.fixture
|
|
def servercert(tmp_path, monkeypatch):
|
|
"""A self-signed leaf staged as this server's own certificate."""
|
|
pem, pin = _make_cert(isca=False)
|
|
path = tmp_path / 'server.crt'
|
|
path.write_bytes(pem)
|
|
monkeypatch.setattr(siteagent, '_CERT_CANDIDATES', (str(path),))
|
|
return pin
|
|
|
|
|
|
def _build(client, headers, **fields):
|
|
body = {'baseurl': 'https://shopdb.test', 'cadenceseconds': 900}
|
|
body.update(fields)
|
|
return client.post(PACKAGE_URL, json=body, headers=headers)
|
|
|
|
|
|
def _files(response):
|
|
assert response.status_code == 200, response.get_data(as_text=True)[:400]
|
|
archive = zipfile.ZipFile(io.BytesIO(response.data))
|
|
return {name: archive.read(name) for name in archive.namelist()}
|
|
|
|
|
|
def test_installer_writes_current_server_pin(client, db, auth_headers, servercert):
|
|
files = _files(_build(client, auth_headers, collectorkey=SECRET,
|
|
includesharedkey=True, bundlecert=True))
|
|
script = files['install.ps1'].decode('ascii')
|
|
|
|
assert '[string[]] $ServerPins = @("{0}")'.format(servercert) in script
|
|
assert 'Set-HklmMultiStringBothViews -SubKey "SOFTWARE\\GE\\ShopDB" -Name "ServerPins"' in script
|
|
assert '[Microsoft.Win32.RegistryValueKind]::MultiString' in script
|
|
assert '-ceq $wanted' in script
|
|
settings = json.loads(files['appsettings.json'])
|
|
assert settings['serverpins'] == [servercert]
|
|
|
|
|
|
def test_pin_comes_from_server_cert_even_when_not_bundled(
|
|
client, db, auth_headers, servercert):
|
|
files = _files(_build(client, auth_headers, collectorkey=SECRET))
|
|
assert 'server.crt' not in files
|
|
assert json.loads(files['appsettings.json'])['serverpins'] == [servercert]
|
|
|
|
|
|
def test_next_pin_is_shipped_beside_current(client, db, auth_headers, servercert):
|
|
nextpin = 'ab' * 32
|
|
files = _files(_build(client, auth_headers, collectorkey=SECRET,
|
|
nextserverpin=nextpin))
|
|
script = files['install.ps1'].decode('ascii')
|
|
|
|
assert '@("{0}", "{1}")'.format(servercert, nextpin) in script
|
|
assert json.loads(files['appsettings.json'])['serverpins'] == [servercert, nextpin]
|
|
|
|
|
|
@pytest.mark.parametrize('badpin', ['AB' * 32, 'ab' * 31, 'zz' * 32])
|
|
def test_next_pin_must_be_64_lowercase_hex(client, db, auth_headers, servercert, badpin):
|
|
response = _build(client, auth_headers, collectorkey=SECRET, nextserverpin=badpin)
|
|
assert response.status_code == 400
|
|
|
|
|
|
def test_next_pin_without_a_current_pin_is_refused(client, db, auth_headers):
|
|
response = _build(client, auth_headers, collectorkey=SECRET,
|
|
nextserverpin='ab' * 32)
|
|
assert response.status_code == 400
|
|
|
|
|
|
def test_shared_key_on_keeps_key_provisioning(client, db, auth_headers, servercert):
|
|
files = _files(_build(client, auth_headers, collectorkey=SECRET,
|
|
includesharedkey='true'))
|
|
script = files['install.ps1'].decode('ascii')
|
|
|
|
assert '[string] $CollectorKey = "{0}"'.format(SECRET) in script
|
|
assert '-Name "CollectorKey" -Value $CollectorKey' in script
|
|
assert 'Settings > Endpoints > Enrollment' not in script
|
|
|
|
|
|
def test_shared_key_off_leaves_no_key_anywhere(client, db, auth_headers, servercert):
|
|
files = _files(_build(client, auth_headers, collectorkey=SECRET,
|
|
includesharedkey=False))
|
|
|
|
for name, content in files.items():
|
|
assert b'CollectorKey' not in content, name
|
|
assert SECRET.encode('ascii') not in content, name
|
|
script = files['install.ps1'].decode('ascii')
|
|
assert 'Settings > Endpoints > Enrollment' in script
|
|
assert '$sharedKeyMode = "omitted"' in script
|
|
assert json.loads(files['appsettings.json'])['apikey'] == ''
|
|
assert b'Settings > Endpoints > Enrollment' in files['README.txt']
|
|
|
|
|
|
def test_shared_key_default_follows_site_setting(client, db, auth_headers, servercert):
|
|
# Setting on (default): key required.
|
|
assert _build(client, auth_headers).status_code == 400
|
|
|
|
Setting.set('plugin.endpoints.sharedkeyallowed', False, valuetype='boolean')
|
|
status = client.get(STATUS_URL, headers=auth_headers).get_json()['data']
|
|
assert status['sharedkeyallowed'] is False
|
|
assert status['serverpin'] == servercert
|
|
|
|
files = _files(_build(client, auth_headers, collectorkey=SECRET))
|
|
assert b'CollectorKey' not in files['install.ps1']
|
|
assert SECRET.encode('ascii') not in files['install.ps1']
|
|
|
|
|
|
def test_installer_never_removes_enrolled_credential(client, db, auth_headers, servercert):
|
|
script = _files(_build(client, auth_headers, collectorkey=SECRET))[
|
|
'install.ps1'].decode('ascii')
|
|
assert 'existing enrolled credential found; kept as is.' in script
|
|
for line in script.splitlines():
|
|
if 'Remove-Item' in line:
|
|
assert 'credential' not in line.lower(), line
|
|
|
|
|
|
def test_ca_certificate_is_not_imported_into_root(client, db, auth_headers, servercert):
|
|
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)
|
|
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
|
|
|
|
|
|
def test_leaf_certificate_is_imported_into_root(client, db, auth_headers):
|
|
leafpem, leafpin = _make_cert(isca=False)
|
|
response = client.post(
|
|
PACKAGE_URL,
|
|
data={'baseurl': 'https://shopdb.test', 'collectorkey': SECRET,
|
|
'cadenceseconds': '900',
|
|
'certfile': (io.BytesIO(leafpem), 'server.crt')},
|
|
content_type='multipart/form-data', headers=auth_headers)
|
|
files = _files(response)
|
|
script = files['install.ps1'].decode('ascii')
|
|
|
|
assert '$trustCertInRoot = $true' in script
|
|
assert 'Import-Certificate -FilePath $certPath -CertStoreLocation Cert:\\LocalMachine\\Root' in script
|
|
assert json.loads(files['appsettings.json'])['serverpins'] == [leafpin]
|
|
|
|
|
|
def test_install_script_helper_without_pins_or_key():
|
|
script = siteagent._install_script('https://shopdb.test', '', 900,
|
|
serverpins=[], includesharedkey=False)
|
|
assert '[string[]] $ServerPins = @()' in script
|
|
assert 'CollectorKey' not in script
|
|
assert 'ServerPins left as is' in script
|