"""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