Three holes in the enrollment path, all of the same shape: a decision the admin made did not survive contact with a PC that simply asked again. REJECTION WAS UNDONE BY A NEW NONCE. A rejected request re-armed itself as a fresh cycle when the PC came back with a different nonce, and with the shared key and auto-enroll on it was then issued the credential the admin had just refused. Rejection now binds to the endpoint identity - hostname plus BIOS serial - not to the nonce, so the PC stays refused however often it reinstalls. An admin clears it deliberately, through the approve path that already exists; a genuinely different PC is untouched. THE PENDING QUEUE COULD BE FILLED UNTIL IT REFUSED EVERY NEW PC. /enroll takes no auth by design - a bay that has never spoken to the server has nothing to authenticate with - so anything that can reach the server could sit at the cap and lock out real enrollments fleet-wide. Rows nobody has touched for seven days now age out before the cap refuses anyone, and the count of what went is audited. Per-identity dedup and the cap itself were already there and are left alone. REVOKE DID NOT STOP A LIVE AGENT. It fell back to the shared key and the server admitted it again, so revoking a compromised PC changed nothing while that key still worked. plugin.endpoints.revokeddeniessharedkey makes a revoked host refused on the shared key too. That switch DEFAULTS OFF, and that is the important part. Turning it on would silence any bay revoked for a key rotation, and would break the documented revoke-then-rollback path to an older agent - on an air-gapped site, mid migration, that is a fleet outage. A never-enrolled bay is never affected either way: with no credential row at all the shared key still works, which is the whole un-migrated fleet. The checkbox sits next to the other two enrollment switches and says what turning it on costs, because a setting that can stop the floor should not be reachable only through the API.
294 lines
10 KiB
Python
294 lines
10 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 whose
|
|
credential was REVOKED falls back to the shared key,
|
|
which is how a rollback to an older agent works and how
|
|
the un-migrated fleet keeps reporting; a site that has
|
|
finished migrating turns
|
|
plugin.endpoints.revokeddeniessharedkey on and a revoked
|
|
PC is then refused until it enrolls again. A PC that
|
|
never enrolled has no credential row at all and is
|
|
never touched by either rule.
|
|
|
|
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'
|
|
# Off by default, which is what this site does today: revoking a credential
|
|
# drops the PC back to the shared key. Turning it on makes a revoke a real
|
|
# eviction - the PC reports nothing until it enrolls again - so it is only safe
|
|
# once the fleet is off the shared key, and it also closes the rollback path
|
|
# that revoke + blockreenroll exists for.
|
|
REVOKED_DENIES_SHARED_KEY_SETTING = 'plugin.endpoints.revokeddeniessharedkey'
|
|
|
|
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 revoked_denies_shared_key():
|
|
"""True when a revoked credential also bars the PC from the shared key."""
|
|
return Setting.get(REVOKED_DENIES_SHARED_KEY_SETTING,
|
|
default=False) is True
|
|
|
|
|
|
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 shared_key_denied_for(assetid):
|
|
"""True when this asset may not report on the shared key.
|
|
|
|
An active credential always denies it: the PC has its own secret and a
|
|
shared-key call for it is either an older agent or somebody else.
|
|
|
|
A revoked credential denies it only while revokeddeniessharedkey is on.
|
|
Today it does not, on purpose: revoke is also how an admin rotates a
|
|
credential or rolls a PC back to an agent that only knows the shared key
|
|
(revoke + blockreenroll), and denying by default would silence that PC
|
|
instead. A PC that never enrolled has no credential row and is never denied,
|
|
which is what keeps the un-migrated fleet reporting.
|
|
"""
|
|
from ..models import EndpointCredential
|
|
|
|
if assetid is None:
|
|
return False
|
|
row = (EndpointCredential.query
|
|
.filter(EndpointCredential.assetid == assetid)
|
|
.first())
|
|
if row is None:
|
|
return False
|
|
if row.revokedat is None:
|
|
return True
|
|
return revoked_denies_shared_key()
|
|
|
|
|
|
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
|
|
return shared_key_denied_for(resolve_hostname(hostname))
|
|
|
|
|
|
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 shared_key_revoked_response():
|
|
"""Same code and status; the operator needs to know which refusal it is.
|
|
|
|
The PC holds no credential and cannot use the shared key either, so an
|
|
admin has to let it enroll again. Nothing changes for the agent, which
|
|
keeps retrying either way.
|
|
"""
|
|
return error_response(
|
|
CODE_SHARED_KEY_REFUSED,
|
|
'This endpoint had its credential revoked, so the shared key is not '
|
|
'accepted for it; let it enroll again',
|
|
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, and so is one whose credential
|
|
was revoked while revokeddeniessharedkey is on.
|
|
"""
|
|
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 shared_key_denied_for(assetid):
|
|
if active_credential_for(assetid) is None:
|
|
return None, shared_key_revoked_response()
|
|
return None, shared_key_refused_response()
|
|
g.agentassetid = assetid
|
|
return assetid, None
|
|
|