All three found testing the bootstrap installer on Windows. - The published command used the irm-pipe-iex form, which Microsoft Defender detects as Trojan:Win32/Commando.A!ml and kills before it runs, every time, on a default install. The command now downloads the script to a file, runs it, and removes it in a finally block, which Defender does not object to. The UI also offers the script as a plain download for a tech who would rather save it. - Approving a request only linked the asset, so a PC whose pending reason was "unknown hostname" - which is every PC installed this way - kept getting 403 on every call afterwards, because its computer record had no hostname to match the credential against. Approval now stamps the hostname when the record has none, refuses with a 409 naming both when the record has a different one, and overwrites only when the admin confirms. The Credentials tab offers that confirmation. - A hostname that resolves to no record now says so instead of reading as an impostor, while still refusing the request. - While waiting for approval the agent retried on the 15-minute telemetry cadence, so a PC approved immediately enrolled a quarter of an hour later and the installer always ended on "still pending". It now retries about every minute for the first ten minutes, then every five to thirty minutes, then every fifteen, with jitter. A rate limit or server error still uses the slower backoff.
233 lines
7.7 KiB
Python
233 lines
7.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 hostname_unrecorded_response(hostname):
|
|
"""Refused, but say which of the two failures this actually is.
|
|
|
|
The credential is good and the hostname is simply on no computer record, so
|
|
there is nothing to compare it with. That is a data problem on this server -
|
|
an enrolled PC whose computer record carries no hostname - not a PC claiming
|
|
to be another one. Same code and status, because nothing changes for the
|
|
agent: it keeps retrying, and an admin has to set the hostname.
|
|
"""
|
|
return error_response(
|
|
CODE_ASSET_MISMATCH,
|
|
'No computer record carries the hostname {0}, so this credential '
|
|
'cannot be matched to it. Set the hostname on the enrolled '
|
|
'computer.'.format(hostname),
|
|
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, a hostname on no computer record at all is refused with the
|
|
same code but its own message, 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:
|
|
named = resolve_hostname(hostname)
|
|
if named is None:
|
|
return None, hostname_unrecorded_response(hostname)
|
|
if named != 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
|
|
|