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.
211 lines
6.7 KiB
Python
211 lines
6.7 KiB
Python
"""Agent authentication: per-PC credentials, with the shared key as fallback.
|
|
|
|
Every agent-facing endpoints route authenticates through authenticate_agent()
|
|
and, when the route names a PC, binds the caller through bind_hostname().
|
|
|
|
X-Endpoint-Credential The PC's own secret. The server derives the asset from
|
|
it; a hostname or assetid in the request that names a
|
|
different asset is refused with ASSET_MISMATCH.
|
|
X-API-Key The shared fleet key (or a collector-scoped managed
|
|
token). Accepted only while
|
|
plugin.endpoints.sharedkeyallowed is on, and never for a
|
|
PC that holds an active credential. A PC blocked
|
|
from re-enrolling has no active credential, so an
|
|
older agent on it keeps working on the shared key.
|
|
|
|
Only the sha256 of a credential is stored. The full secret exists once, in the
|
|
enroll response.
|
|
"""
|
|
|
|
import hashlib
|
|
import hmac
|
|
import re
|
|
import secrets
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from flask import g, request
|
|
|
|
from shopdb.api import (
|
|
db, Setting, error_response, ErrorCodes, collector_authorized,
|
|
)
|
|
|
|
CREDENTIAL_HEADER = 'X-Endpoint-Credential'
|
|
CREDENTIAL_PREFIX = 'sdbe_'
|
|
# sdbe_ + 43 chars base64url (32 random bytes, unpadded).
|
|
_CREDENTIAL_PATTERN = re.compile(r'^sdbe_[A-Za-z0-9_-]{43}$')
|
|
DISPLAY_PREFIX_LENGTH = 12
|
|
|
|
SHARED_KEY_SETTING = 'plugin.endpoints.sharedkeyallowed'
|
|
AUTO_ENROLL_SETTING = 'plugin.endpoints.autoenroll'
|
|
|
|
CODE_CREDENTIAL_INVALID = 'CREDENTIAL_INVALID'
|
|
CODE_SHARED_KEY_REFUSED = 'SHARED_KEY_REFUSED'
|
|
CODE_ASSET_MISMATCH = 'ASSET_MISMATCH'
|
|
|
|
AUTH_CREDENTIAL = 'credential'
|
|
AUTH_SHARED_KEY = 'sharedkey'
|
|
|
|
# lastusedat is a display value, not an audit trail: one write per 5 minutes
|
|
# per PC keeps the long-poll from turning every claim into an UPDATE.
|
|
_TOUCH_INTERVAL = timedelta(minutes=5)
|
|
|
|
|
|
def _naive_utcnow():
|
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
|
|
|
|
|
def generate_credential():
|
|
return CREDENTIAL_PREFIX + secrets.token_urlsafe(32)
|
|
|
|
|
|
def hash_credential(secret):
|
|
return hashlib.sha256(secret.encode('utf-8')).hexdigest()
|
|
|
|
|
|
def shared_key_allowed():
|
|
return Setting.get(SHARED_KEY_SETTING, default=True) is not False
|
|
|
|
|
|
def auto_enroll_enabled():
|
|
return Setting.get(AUTO_ENROLL_SETTING, default=True) is not False
|
|
|
|
|
|
def active_credential_for(assetid):
|
|
from ..models import EndpointCredential
|
|
|
|
if assetid is None:
|
|
return None
|
|
return (EndpointCredential.query
|
|
.filter(EndpointCredential.assetid == assetid,
|
|
EndpointCredential.revokedat.is_(None))
|
|
.first())
|
|
|
|
|
|
def reenroll_blocked(assetid):
|
|
"""True when an admin blocked this asset from enrolling again."""
|
|
from ..models import EndpointCredential
|
|
|
|
if assetid is None:
|
|
return False
|
|
return (EndpointCredential.query
|
|
.filter(EndpointCredential.assetid == assetid,
|
|
EndpointCredential.reenrollblocked.is_(True))
|
|
.first()) is not None
|
|
|
|
|
|
def resolve_credential(secret, touch=True):
|
|
"""Asset id for an active credential, else None."""
|
|
from ..models import EndpointCredential
|
|
|
|
if not secret or not _CREDENTIAL_PATTERN.match(secret):
|
|
return None
|
|
digest = hash_credential(secret)
|
|
row = EndpointCredential.query.filter_by(credentialhash=digest).first()
|
|
if row is None or row.revokedat is not None:
|
|
return None
|
|
if not hmac.compare_digest(row.credentialhash, digest):
|
|
return None
|
|
if touch:
|
|
now = _naive_utcnow()
|
|
if row.lastusedat is None or now - row.lastusedat >= _TOUCH_INTERVAL:
|
|
row.lastusedat = now
|
|
db.session.commit()
|
|
return row.assetid
|
|
|
|
|
|
def resolve_hostname(hostname):
|
|
"""Asset id of the one computer with this hostname (case-insensitive)."""
|
|
from plugins.computers.models import Computer
|
|
|
|
hostname = (hostname or '').strip()
|
|
if not hostname:
|
|
return None
|
|
computer = (Computer.query
|
|
.filter(db.func.lower(Computer.hostname) == hostname.lower())
|
|
.first())
|
|
return computer.assetid if computer is not None else None
|
|
|
|
|
|
def shared_key_refused_for(hostname):
|
|
"""True when the shared key may not report for this hostname."""
|
|
if not shared_key_allowed():
|
|
return True
|
|
assetid = resolve_hostname(hostname)
|
|
return assetid is not None and active_credential_for(assetid) is not None
|
|
|
|
|
|
def credential_invalid_response():
|
|
return error_response(
|
|
CODE_CREDENTIAL_INVALID,
|
|
'Endpoint credential is unknown or revoked; enroll again',
|
|
http_code=401)
|
|
|
|
|
|
def shared_key_refused_response():
|
|
return error_response(
|
|
CODE_SHARED_KEY_REFUSED,
|
|
'The shared key is not accepted for this endpoint; use its credential',
|
|
http_code=401)
|
|
|
|
|
|
def asset_mismatch_response():
|
|
return error_response(
|
|
CODE_ASSET_MISMATCH,
|
|
'The request names a different endpoint than the credential',
|
|
http_code=403)
|
|
|
|
|
|
def authenticate_agent():
|
|
"""Authorize an agent request. Returns None when authorized, else a response.
|
|
|
|
Sets g.agentauth ('credential' or 'sharedkey') and g.agentassetid (the
|
|
credential's asset, or None until bind_hostname resolves one).
|
|
"""
|
|
g.agentauth = None
|
|
g.agentassetid = None
|
|
|
|
secret = request.headers.get(CREDENTIAL_HEADER)
|
|
if secret:
|
|
assetid = resolve_credential(secret.strip())
|
|
if assetid is None:
|
|
return credential_invalid_response()
|
|
g.agentauth = AUTH_CREDENTIAL
|
|
g.agentassetid = assetid
|
|
return None
|
|
|
|
error = collector_authorized('endpoints')
|
|
if error is not None:
|
|
return error
|
|
if not shared_key_allowed():
|
|
return shared_key_refused_response()
|
|
g.agentauth = AUTH_SHARED_KEY
|
|
return None
|
|
|
|
|
|
def is_credential_caller():
|
|
return getattr(g, 'agentauth', None) == AUTH_CREDENTIAL
|
|
|
|
|
|
def bind_hostname(hostname):
|
|
"""The asset this request acts for. Returns (assetid, error_response).
|
|
|
|
Credential: the credential's asset; a hostname naming another asset is an
|
|
ASSET_MISMATCH, and a missing hostname is fine.
|
|
Shared key: the hostname's asset (None when absent or unknown, for the
|
|
route to answer); an enrolled PC is refused.
|
|
"""
|
|
hostname = (hostname or '').strip()
|
|
if is_credential_caller():
|
|
if hostname and resolve_hostname(hostname) != g.agentassetid:
|
|
return None, asset_mismatch_response()
|
|
return g.agentassetid, None
|
|
|
|
if not hostname:
|
|
return None, None
|
|
assetid = resolve_hostname(hostname)
|
|
if assetid is not None and active_credential_for(assetid) is not None:
|
|
return None, shared_key_refused_response()
|
|
g.agentassetid = assetid
|
|
return assetid, None
|
|
|