Source code for pyrad.client

# client.py
#
# Copyright 2002-2007 Wichert Akkerman <wichert@wiggy.net>
"""Blocking RADIUS client."""

__docformat__ = "restructuredtext en"

import hashlib
import select
import socket
import time
import struct
from pyrad import host
from pyrad import packet

EAP_CODE_REQUEST = 1
EAP_CODE_RESPONSE = 2
EAP_TYPE_IDENTITY = 1
EAP_TYPE_MD5 = 4


def _EapMd5Identity(pkt):
    """Start an EAP-MD5 authentication (RFC 3748 section 5.4, RFC 3579):
    set the EAP-Response/Identity, which carries the User-Name, and remove
    the User-Password, which holds the cleartext password. RFC 3579
    section 3.3 doesn't allow User-Password together with EAP-Message.

    :param pkt: Access-Request with User-Name and User-Password
    :type  pkt: pyrad.packet.AuthPacket
    :return: the password and the removed User-Password values, to restore
             them with ``pkt[2] = values`` afterwards
    :rtype:  tuple
    :raise ValueError: User-Name or User-Password is missing
    """
    if 1 not in pkt or 2 not in pkt:
        raise ValueError('EAP-MD5 requires User-Name and the cleartext '
                         'password as User-Password')
    identity = pkt[1][0]
    user_password = pkt[2]
    del pkt[2]
    eap = struct.pack('!BBHB', EAP_CODE_RESPONSE, packet.CurrentID,
                      len(identity) + 5, EAP_TYPE_IDENTITY) + identity
    # RFC 3579 section 3.1: an EAP packet longer than 253 bytes is split
    # over several EAP-Message attributes
    pkt[79] = [eap[i:i + 253] for i in range(0, len(eap), 253)]
    return (user_password[0], user_password)


def _EapMd5Response(pkt, reply, password):
    """Answer the EAP-MD5 challenge of an Access-Challenge.

    Sets the EAP-Response/MD5-Challenge, the State of the challenge and a
    new Request Authenticator in pkt (RFC 2865 section 4.4); the caller
    has to give it a new Identifier before sending it.

    :param pkt: the Access-Request the challenge replies to
    :type  pkt: pyrad.packet.AuthPacket
    :param reply: the reply to pkt
    :type  reply: pyrad.packet.Packet
    :param password: cleartext password
    :type  password: bytes
    :return: True if pkt now answers the challenge, False if the reply
             is not an EAP-MD5 challenge
    :rtype:  bool
    """
    if reply.code != packet.AccessChallenge:
        return False
    # RFC 3579 section 3.1: an EAP packet may be split over several
    # EAP-Message attributes
    eap = b''.join(reply.get(79, []))
    if len(eap) < 6 or eap[0] != EAP_CODE_REQUEST or eap[4] != EAP_TYPE_MD5:
        # not an EAP-MD5 challenge
        return False
    eap_id = eap[1]
    eap = eap[:struct.unpack('!H', eap[2:4])[0]]
    # RFC 3748 section 5.4: Type-Data is Value-Size, Value and an optional
    # Name; the response is the MD5 hash of the EAP Identifier, the
    # password and the Value (RFC 1994 section 4.1)
    value = eap[6:6 + eap[5]]
    digest = hashlib.md5(struct.pack('!B', eap_id) + password + value).digest()
    pkt[79] = [struct.pack('!BBHBB', EAP_CODE_RESPONSE, eap_id,
                           len(digest) + 6, EAP_TYPE_MD5, len(digest)) + digest]
    # RFC 2865 section 4.4: the State of the challenge is returned
    if 24 in reply:
        pkt[24] = reply[24]
    elif 24 in pkt:
        del pkt[24]
    # RFC 2865 section 3: the Request Authenticator is unique. The
    # Message-Authenticator is calculated with it when pkt is encoded.
    pkt.authenticator = pkt.CreateAuthenticator()
    return True


[docs] class Timeout(Exception): """Raised by :meth:`Client.SendPacket` when no valid reply was received. :class:`pyrad.client_async.ClientAsync` fails its futures with :class:`TimeoutError` instead. """
[docs] class Client(host.Host): """Basic blocking RADIUS client. Sends requests to one RADIUS server, retransmits them if no reply is received and verifies the replies. Packets are created with :meth:`CreateAuthPacket`, :meth:`CreateAcctPacket` and :meth:`CreateCoAPacket` and sent with :meth:`SendPacket`. :ivar server: hostname or IP address of the RADIUS server :type server: str :ivar secret: RADIUS secret shared with the server :type secret: bytes :ivar retries: maximum number of times a request is sent in total (``0`` never sends it and raises :exc:`Timeout`) :type retries: int :ivar timeout: number of seconds to wait for a reply after each send :type timeout: float :ivar enforce_ma: require a Message-Authenticator in replies to Access-Request and Status-Server packets :type enforce_ma: bool """ def __init__(self, server, authport=1812, acctport=1813, coaport=3799, secret=b'', dict=None, retries=3, timeout=5, enforce_ma=False): """ :param server: hostname or IP address of the RADIUS server :type server: str :param authport: server port for authentication packets :type authport: int :param acctport: server port for accounting packets :type acctport: int :param coaport: server port for CoA and Disconnect packets :type coaport: int :param secret: RADIUS secret :type secret: bytes :param dict: RADIUS dictionary :type dict: pyrad.dictionary.Dictionary :param retries: maximum number of times a request is sent in total :type retries: int :param timeout: number of seconds to wait for a reply after each send :type timeout: float :param enforce_ma: require a Message-Authenticator in replies to Access-Request and Status-Server packets (a Message-Authenticator in a reply is always verified) :type enforce_ma: bool """ host.Host.__init__(self, authport, acctport, coaport, dict) self.server = server self.secret = secret self._socket = None self.retries = retries self.timeout = timeout self.enforce_ma = enforce_ma self._poll = select.poll()
[docs] def bind(self, addr): """Bind the client socket to a local address. Useful on a machine with multiple addresses. The socket is closed and opened again first. :param addr: local address (hostname or IP) and port to bind to, port 0 for any free port :type addr: tuple """ self._CloseSocket() self._SocketOpen() self._socket.bind(addr)
def _SocketOpen(self): try: family = socket.getaddrinfo(self.server, 80)[0][0] except Exception: family = socket.AF_INET if not self._socket: self._socket = socket.socket(family, socket.SOCK_DGRAM) self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self._poll.register(self._socket, select.POLLIN) def _CloseSocket(self): if self._socket: self._poll.unregister(self._socket) self._socket.close() self._socket = None
[docs] def CreateAuthPacket(self, **args): """Create an authentication packet for the server of this client. The packet has the dictionary and secret of the client. The keyword arguments are passed to :class:`pyrad.packet.AuthPacket`: packet arguments (``code``, ``id``, ``authenticator``, ``auth_type``, ``message_authenticator``, ``packet``) and attributes to add, with ``-`` in the attribute names replaced by ``_``. The default code is Access-Request, use ``code=pyrad.packet.StatusServer`` for a Status-Server packet to the authentication port. A Message-Authenticator is added when the packet is encoded, as countermeasure against the BlastRADIUS attack (CVE-2024-3596); disable it with ``message_authenticator=False``. For PAP the password must be encrypted: ``pkt['User-Password'] = pkt.PwCrypt(password)``. For EAP-MD5 pass ``auth_type='eap-md5'`` and set the cleartext password as User-Password, see :meth:`SendPacket`. :return: a new packet :rtype: pyrad.packet.AuthPacket """ if 'packet' not in args: # a decoded packet has a Message-Authenticator if it contains one args.setdefault('message_authenticator', True) return host.Host.CreateAuthPacket(self, secret=self.secret, **args)
[docs] def CreateAcctPacket(self, **args): """Create an accounting packet for the server of this client. The packet has the dictionary and secret of the client. The keyword arguments are passed to :class:`pyrad.packet.AcctPacket`: packet arguments (``code``, ``id``, ``authenticator``, ``message_authenticator``, ``packet``) and attributes to add, with ``-`` in the attribute names replaced by ``_``. The default code is Accounting-Request; ``code=pyrad.packet.StatusServer`` creates a Status-Server packet to the accounting port. :return: a new packet :rtype: pyrad.packet.AcctPacket """ return host.Host.CreateAcctPacket(self, secret=self.secret, **args)
[docs] def CreateCoAPacket(self, **args): """Create a CoA-Request or Disconnect-Request for this client's server. The packet has the dictionary and secret of the client. The keyword arguments are passed to :class:`pyrad.packet.CoAPacket`: packet arguments (``code``, ``id``, ``authenticator``, ``message_authenticator``, ``packet``) and attributes to add, with ``-`` in the attribute names replaced by ``_``. The default code is CoA-Request, pass ``code=pyrad.packet.DisconnectRequest`` for a Disconnect-Request. :return: a new packet :rtype: pyrad.packet.CoAPacket """ return host.Host.CreateCoAPacket(self, secret=self.secret, **args)
def _UpdateAcctDelayTime(self, pkt): """Add the time waited for a reply to the Acct-Delay-Time of an accounting request that is sent again. This is skipped if the dictionary does not define Acct-Delay-Time.""" dictionary = getattr(pkt, 'dict', None) if dictionary is None or 'Acct-Delay-Time' not in dictionary: return if 'Acct-Delay-Time' in pkt: pkt['Acct-Delay-Time'] = pkt['Acct-Delay-Time'][0] + self.timeout else: pkt['Acct-Delay-Time'] = self.timeout def _VerifyReply(self, pkt, reply, rawreply, authenticators): """Verify a reply against the Request Authenticators of all attempts of the request, newest first. :return: whether the reply is valid, and the Request Authenticator of the attempt it replies to :rtype: (bool, bytes) tuple """ current = getattr(pkt, 'authenticator', None) for authenticator in reversed(authenticators): if authenticator == current: valid = pkt.VerifyReply(reply, rawreply, enforce_ma=self.enforce_ma) else: pkt.authenticator = authenticator try: valid = pkt.VerifyReply(reply, rawreply, enforce_ma=self.enforce_ma) finally: pkt.authenticator = current if valid: return (True, authenticator) return (False, None) def _SendPacket(self, pkt, port): """Send a packet to a RADIUS server. :param pkt: the packet to send :type pkt: pyrad.packet.Packet :param port: UDP port to send packet to :type port: int :return: the reply packet received :rtype: pyrad.packet.Packet :raise Timeout: RADIUS server does not reply """ self._SocketOpen() # Request Authenticators of all attempts of this request, oldest # first. They differ if the packet changes between attempts # (Acct-Delay-Time), and a late reply to an earlier attempt is still # a valid reply. authenticators = [] for attempt in range(self.retries): if attempt and pkt.code == packet.AccountingRequest: self._UpdateAcctDelayTime(pkt) now = time.monotonic() waitto = now + self.timeout self._socket.sendto(pkt.RequestPacket(), (self.server, port)) authenticator = getattr(pkt, 'authenticator', None) if authenticator in authenticators: authenticators.remove(authenticator) authenticators.append(authenticator) while now < waitto: ready = self._poll.poll((waitto - now) * 1000) if ready: rawreply = self._socket.recv(4096) else: now = time.monotonic() continue try: reply = pkt.CreateReply(packet=rawreply) (valid, authenticator) = self._VerifyReply( pkt, reply, rawreply, authenticators) if valid: if hasattr(pkt, 'authenticator'): reply.request_authenticator = authenticator return reply except packet.PacketError: pass now = time.monotonic() raise Timeout def _SendEapMd5Packet(self, pkt): """Authenticate with EAP-MD5 (RFC 3748 section 5.4) over RADIUS (RFC 3579). The EAP-Response/Identity carries the User-Name. The password is the cleartext value of the User-Password attribute of the packet, which is only used for the MD5 response: it is removed while the requests are sent, because it would be sent in clear and RFC 3579 section 3.3 doesn't allow User-Password together with EAP-Message. :param pkt: the Access-Request with User-Name and User-Password :type pkt: pyrad.packet.AuthPacket :return: the reply packet received :rtype: pyrad.packet.Packet :raise ValueError: User-Name or User-Password is missing :raise Timeout: RADIUS server does not reply """ (password, user_password) = _EapMd5Identity(pkt) try: reply = self._SendPacket(pkt, self.authport) if not _EapMd5Response(pkt, reply, password): return reply # RFC 2865 section 4.4: the Access-Request answering an # Access-Challenge has a new Identifier previous_id = pkt.id while pkt.id == previous_id: pkt.id = pkt.CreateID() return self._SendPacket(pkt, self.authport) finally: pkt[2] = user_password
[docs] def SendPacket(self, pkt): """Send a request to the RADIUS server and wait for a valid reply. The port depends on the packet class: an :class:`~pyrad.packet.AuthPacket` (also a Status-Server created with :meth:`CreateAuthPacket`) is sent to ``authport``, a :class:`~pyrad.packet.CoAPacket` to ``coaport`` and any other packet (also a Status-Server :class:`~pyrad.packet.AcctPacket`) to ``acctport``. The request is sent at most ``retries`` times, waiting ``timeout`` seconds for a reply after each send. Invalid replies are ignored: a wrong Response Authenticator or Message-Authenticator, a Proxy-State that was not sent, or a missing Message-Authenticator with ``enforce_ma``. Before an Accounting-Request is sent again, its Acct-Delay-Time is increased by ``timeout`` (this modifies ``pkt``). With ``auth_type='eap-md5'`` the client authenticates with EAP-MD5 (RFC 3748, RFC 3579): the EAP-Response/Identity carries the User-Name, and User-Password holds the cleartext password, which is used for the MD5 response but not sent. The Access-Challenge of the server is answered automatically, so ``pkt`` ends up with an EAP-Message, the State of the challenge and a new id. :param pkt: the request to send :type pkt: pyrad.packet.Packet :return: the reply; check ``reply.code``, e.g. Access-Accept or Access-Reject :rtype: pyrad.packet.Packet :raise Timeout: no valid reply was received :raise ValueError: ``auth_type='eap-md5'`` without User-Name or User-Password, or an attribute value that cannot be encoded :raise OSError: the socket fails, e.g. the server name cannot be resolved """ if isinstance(pkt, packet.AuthPacket): if pkt.auth_type == 'eap-md5': return self._SendEapMd5Packet(pkt) return self._SendPacket(pkt, self.authport) elif isinstance(pkt, packet.CoAPacket): return self._SendPacket(pkt, self.coaport) else: return self._SendPacket(pkt, self.acctport)