Skip to content

eap

EAP method registry and built-in method implementations.

This package replaces the single-file pyrad2/eap.py that shipped through 3.0. All historical names — build_eap_identity, inject_eap_identity, apply_eap_md5_challenge, password_from_packet, EAP_MESSAGE_ATTR, STATE_ATTR, USER_NAME_ATTR, USER_PASSWORD_ATTR — remain importable from pyrad2.eap so existing call sites need no change.

The new surface is the EapMethod ABC plus a small registry (register_method / get_method). Both the sync Client and async ClientAsync (and the RadSec client) look up the method by the auth_type set on the outgoing AuthPacket and drive it through a transport-neutral challenge loop, so adding a new method is a matter of subclassing EapMethod and calling register_method — no client changes required.

To add a new method::

from pyrad2.eap import EapMethod, register_method

class MschapV2Method(EapMethod):
    def start(self, pkt): ...
    def respond(self, pkt, challenge): ...

register_method("eap-mschapv2", MschapV2Method)

Callers then set pkt.auth_type = "eap-mschapv2" and the rest is automatic.

EapMethod

Bases: ABC

Pluggable EAP method driving a RADIUS client's challenge loop.

Implementations mutate the same AuthPacket object in place across the round-trips that span one EAP conversation. The client handles transport concerns (id allocation, authenticator regeneration, Message-Authenticator); the method handles only the EAP payload.

Source code in pyrad2/eap/base.py
class EapMethod(abc.ABC):
    """Pluggable EAP method driving a RADIUS client's challenge loop.

    Implementations mutate the *same* ``AuthPacket`` object in place
    across the round-trips that span one EAP conversation. The client
    handles transport concerns (id allocation, authenticator
    regeneration, Message-Authenticator); the method handles only the
    EAP payload.
    """

    @abc.abstractmethod
    def start(self, pkt: "AuthPacket") -> None:
        """Seed the initial ``Access-Request`` with this method's payload.

        Called once before the first send. Typically populates the
        ``EAP-Message`` attribute on ``pkt``.
        """

    @abc.abstractmethod
    def respond(self, pkt: "AuthPacket", challenge: "Packet") -> None:
        """Answer an ``Access-Challenge`` by mutating ``pkt`` in place.

        Implementations are responsible for copying the EAP ``State``
        attribute (RFC 2865 §5.24) from ``challenge`` when the server
        sent one — every multi-roundtrip EAP exchange needs it to keep
        the server's session bookkeeping consistent across replies.
        """

start(pkt) abstractmethod

Seed the initial Access-Request with this method's payload.

Called once before the first send. Typically populates the EAP-Message attribute on pkt.

Source code in pyrad2/eap/base.py
@abc.abstractmethod
def start(self, pkt: "AuthPacket") -> None:
    """Seed the initial ``Access-Request`` with this method's payload.

    Called once before the first send. Typically populates the
    ``EAP-Message`` attribute on ``pkt``.
    """

respond(pkt, challenge) abstractmethod

Answer an Access-Challenge by mutating pkt in place.

Implementations are responsible for copying the EAP State attribute (RFC 2865 §5.24) from challenge when the server sent one — every multi-roundtrip EAP exchange needs it to keep the server's session bookkeeping consistent across replies.

Source code in pyrad2/eap/base.py
@abc.abstractmethod
def respond(self, pkt: "AuthPacket", challenge: "Packet") -> None:
    """Answer an ``Access-Challenge`` by mutating ``pkt`` in place.

    Implementations are responsible for copying the EAP ``State``
    attribute (RFC 2865 §5.24) from ``challenge`` when the server
    sent one — every multi-roundtrip EAP exchange needs it to keep
    the server's session bookkeeping consistent across replies.
    """

GtcMethod

Bases: EapMethod

EAP-GTC plaintext-token method.

Stateless. start reuses the shared EAP-Identity helper so the initial Access-Request looks identical to every other EAP method pyrad2 ships; respond handles the one round of GTC traffic.

Source code in pyrad2/eap/gtc.py
class GtcMethod(EapMethod):
    """EAP-GTC plaintext-token method.

    Stateless. ``start`` reuses the shared EAP-Identity helper so the
    initial Access-Request looks identical to every other EAP method
    pyrad2 ships; ``respond`` handles the one round of GTC traffic.
    """

    def start(self, pkt) -> None:
        inject_eap_identity(pkt)

    def respond(self, pkt, challenge) -> None:
        apply_eap_gtc_challenge(pkt, challenge)

MschapV2Method

Bases: EapMethod

EAP-MSCHAPv2 driver, stateful per conversation.

Carries the authenticator challenge, peer challenge, and computed NT-Response across the Challenge → Response → Success rounds so the Authenticator Response check on the final Success-Request can use the same inputs the Response was built from.

Source code in pyrad2/eap/mschapv2.py
class MschapV2Method(EapMethod):
    """EAP-MSCHAPv2 driver, stateful per conversation.

    Carries the authenticator challenge, peer challenge, and computed
    NT-Response across the Challenge → Response → Success rounds so
    the Authenticator Response check on the final Success-Request can
    use the same inputs the Response was built from.
    """

    def __init__(self) -> None:
        self._user_name: bytes = b""
        # ``_password`` is stored as ``str`` because MS-CHAPv2's NT
        # Password Hash is MD4 over the password in *UTF-16LE*
        # (RFC 2759 §8.3). RADIUS attributes are raw bytes — we decode
        # them to a string so ``mschap.nt_password_hash`` does the
        # UTF-16LE conversion itself instead of double-encoding.
        self._password: str = ""
        self._authenticator_challenge: bytes = b""
        self._peer_challenge: bytes = b""
        self._nt_response: bytes = b""

    def start(self, pkt: "AuthPacket") -> None:
        self._user_name = _user_name_from_packet(pkt)
        raw_password = password_from_packet(pkt)
        # User-Password lands here as UTF-8 bytes from the typical
        # ``User_Password="..."`` kwarg path. Decode strictly: bytes
        # that aren't a legal UTF-8 sequence would silently produce
        # the wrong hash if we fell back to raw passthrough.
        self._password = raw_password.decode("utf-8")
        pkt[EAP_MESSAGE_ATTR] = [_build_eap_identity_with_name(self._user_name)]

    def respond(self, pkt: "AuthPacket", challenge: "Packet") -> None:
        eap_payload = challenge[EAP_MESSAGE_ATTR][0]
        if len(eap_payload) < 6:
            raise ValueError(
                f"EAP-MSCHAPv2 header truncated: got {len(eap_payload)} bytes, need at least 6"
            )
        eap_id = eap_payload[1]
        eap_type = eap_payload[4]
        if eap_type != EAP_TYPE_MSCHAPV2:
            raise ValueError(
                f"Expected EAP-Type {EAP_TYPE_MSCHAPV2} (MS-CHAPv2), got {eap_type}"
            )
        op_code = eap_payload[5]

        if op_code == OP_CHALLENGE:
            response_payload = self._build_response(eap_id, eap_payload)
        elif op_code == OP_SUCCESS:
            response_payload = self._build_success_response(eap_id, eap_payload)
        elif op_code == OP_FAILURE:
            response_payload = self._build_failure_response(eap_id)
        else:
            raise ValueError(f"Unknown EAP-MSCHAPv2 OpCode {op_code}")

        pkt[EAP_MESSAGE_ATTR] = [response_payload]
        # State must carry across every round of a multi-challenge EAP
        # session — without it the server has no way to correlate.
        pkt[STATE_ATTR] = challenge[STATE_ATTR]

    def _build_response(self, eap_id: int, challenge_payload: bytes) -> bytes:
        """Parse OpCode 1 (Challenge) and build OpCode 2 (Response).

        Inbound wire (draft-kamath §3.2.1)::

            code(1) | id(1) | length(2) | type(1) | OpCode=1(1)
            | MS-CHAPv2-Id(1) | MS-Length(2) | Value-Size=16(1)
            | Challenge(16) | Name(variable)

        The Name field is informational — pyrad2 ignores it on the
        wire and uses the local ``User-Name`` for the ChallengeHash
        computation per RFC 2759.
        """
        if len(challenge_payload) < 26:
            raise ValueError(
                f"EAP-MSCHAPv2 Challenge body truncated: got {len(challenge_payload)} bytes, need at least 26"
            )
        mschap_id = challenge_payload[6]
        value_size = challenge_payload[9]
        if value_size != 16:
            raise ValueError(
                f"EAP-MSCHAPv2 Challenge Value-Size must be 16, got {value_size}"
            )
        self._authenticator_challenge = challenge_payload[10:26]
        self._peer_challenge = secrets.token_bytes(16)
        self._nt_response = generate_nt_response(
            self._authenticator_challenge,
            self._peer_challenge,
            self._user_name,
            self._password,
        )

        # 49-byte Response value: Peer-Challenge(16) | Reserved(8) | NT-Response(24) | Flags(1).
        response_value = (
            self._peer_challenge + b"\x00" * 8 + self._nt_response + b"\x00"
        )

        # MS-Length spans from OpCode through the end of Name.
        ms_length = 1 + 1 + 2 + 1 + 49 + len(self._user_name)
        eap_length = 5 + ms_length

        header = struct.pack(
            "!BBHBB",
            EAPPacketType.RESPONSE,
            eap_id,
            eap_length,
            EAP_TYPE_MSCHAPV2,
            OP_RESPONSE,
        )
        return (
            header
            + bytes([mschap_id])
            + struct.pack("!H", ms_length)
            + bytes([49])
            + response_value
            + self._user_name
        )

    def _build_success_response(self, eap_id: int, success_payload: bytes) -> bytes:
        """Verify the server's Authenticator Response and ACK with OpCode 3.

        The server's Success message body is ``"S=<40 hex> M=<text>"``
        (RFC 2548 §2.3.3). When present, we recompute the expected
        ``S=...`` from the stored NT-Response inputs and refuse to
        ACK if it doesn't match — that's the mutual-auth part of
        MS-CHAPv2.
        """
        message = success_payload[6:] if len(success_payload) > 6 else b""
        if message and b"S=" in message:
            if not verify_authenticator_response(
                self._password,
                self._nt_response,
                self._peer_challenge,
                self._authenticator_challenge,
                self._user_name,
                message,
            ):
                raise ValueError(
                    "EAP-MSCHAPv2 server Authenticator Response failed verification"
                )
        # Six-byte ACK: EAP header (5) + OpCode (1). No payload.
        return struct.pack(
            "!BBHBB",
            EAPPacketType.RESPONSE,
            eap_id,
            6,
            EAP_TYPE_MSCHAPV2,
            OP_SUCCESS,
        )

    def _build_failure_response(self, eap_id: int) -> bytes:
        """ACK a server-reported failure so the EAP session closes cleanly.

        After a Failure-Request the server will send Access-Reject;
        the bare Failure-Response keeps the EAP framing well-formed so
        downstream logging picks up the explicit ``OpCode == 4``
        rather than a transport-level oddity.
        """
        return struct.pack(
            "!BBHBB",
            EAPPacketType.RESPONSE,
            eap_id,
            6,
            EAP_TYPE_MSCHAPV2,
            OP_FAILURE,
        )

Md5Method

Bases: EapMethod

EAP-MD5 challenge/response (RFC 3748 §5.4).

Stateless — the entire exchange is one round and every input lives in the packet objects passed to start/respond.

Source code in pyrad2/eap/md5.py
class Md5Method(EapMethod):
    """EAP-MD5 challenge/response (RFC 3748 §5.4).

    Stateless — the entire exchange is one round and every input lives
    in the packet objects passed to ``start``/``respond``.
    """

    def start(self, pkt) -> None:
        inject_eap_identity(pkt)

    def respond(self, pkt, challenge) -> None:
        apply_eap_md5_challenge(pkt, challenge)

get_method(name)

Return a fresh instance of the method registered under name.

Returns None when name is None or unregistered so the caller can branch into a non-EAP send path without raising.

Source code in pyrad2/eap/base.py
def get_method(name: str | None) -> EapMethod | None:
    """Return a fresh instance of the method registered under ``name``.

    Returns ``None`` when ``name`` is ``None`` or unregistered so the
    caller can branch into a non-EAP send path without raising.
    """
    if name is None:
        return None
    factory = _METHODS.get(name)
    if factory is None:
        return None
    return factory()

register_method(name, factory)

Register a method factory under an auth_type string.

name is the value callers set on AuthPacket.auth_type to select the method. Re-registering the same name replaces the previous factory, which lets tests swap a method's implementation without restarting the process.

Source code in pyrad2/eap/base.py
def register_method(name: str, factory: MethodFactory) -> None:
    """Register a method factory under an ``auth_type`` string.

    ``name`` is the value callers set on ``AuthPacket.auth_type`` to
    select the method. Re-registering the same name replaces the
    previous factory, which lets tests swap a method's implementation
    without restarting the process.
    """
    _METHODS[name] = factory

registered_methods()

Return a sorted list of registered auth_type names.

Exposed mainly for diagnostics and tests — production code should look up by name through get_method.

Source code in pyrad2/eap/base.py
def registered_methods() -> list[str]:
    """Return a sorted list of registered ``auth_type`` names.

    Exposed mainly for diagnostics and tests — production code should
    look up by name through ``get_method``.
    """
    return sorted(_METHODS)

apply_eap_gtc_challenge(pkt, reply)

Mutate pkt in place to answer an EAP-GTC prompt.

Reads the EAP id from the server's EAP-Request/GTC so the response echoes it (RFC 3748 §4.2 requires the id to round-trip), then writes the password back as the GTC data field. The prompt text after the header is ignored — pyrad2 doesn't surface it because the client already has the credential to send.

Source code in pyrad2/eap/gtc.py
def apply_eap_gtc_challenge(pkt: "AuthPacket", reply: "Packet") -> None:
    """Mutate ``pkt`` in place to answer an EAP-GTC prompt.

    Reads the EAP id from the server's ``EAP-Request/GTC`` so the
    response echoes it (RFC 3748 §4.2 requires the id to round-trip),
    then writes the password back as the GTC ``data`` field. The
    prompt text after the header is ignored — pyrad2 doesn't surface
    it because the client already has the credential to send.
    """
    eap_payload = reply[EAP_MESSAGE_ATTR][0]
    if len(eap_payload) < 5:
        raise ValueError(
            f"EAP-GTC challenge header truncated: got {len(eap_payload)} bytes, need at least 5"
        )
    eap_id = eap_payload[1]
    password = password_from_packet(pkt)
    pkt[EAP_MESSAGE_ATTR] = [build_eap_gtc_response(eap_id, password)]
    pkt[STATE_ATTR] = reply[STATE_ATTR]

build_eap_gtc_response(eap_id, password)

Build an EAP-Response/GTC payload.

Layout (5-byte header + N-byte data):

code(1) + id(1) + length(2) + type(1) + data(N)

where length is the total payload length including the header.

Source code in pyrad2/eap/gtc.py
def build_eap_gtc_response(eap_id: int, password: bytes) -> bytes:
    """Build an EAP-Response/GTC payload.

    Layout (5-byte header + N-byte data):

    ``code(1) + id(1) + length(2) + type(1) + data(N)``

    where ``length`` is the total payload length including the header.
    """
    return struct.pack(
        "!BBHB%ds" % len(password),
        EAPPacketType.RESPONSE,
        eap_id,
        len(password) + 5,
        EAP_TYPE_GTC,
        password,
    )

apply_eap_md5_challenge(pkt, reply)

Mutate pkt in place to answer an EAP-MD5 Access-Challenge.

Source code in pyrad2/eap/md5.py
def apply_eap_md5_challenge(pkt, reply) -> None:
    """Mutate ``pkt`` in place to answer an EAP-MD5 Access-Challenge."""
    eap_payload = reply[EAP_MESSAGE_ATTR][0]
    _, eap_id, _, _, eap_md5 = struct.unpack(
        "!BBHB%ds" % (len(eap_payload) - 5), eap_payload
    )
    pkt[EAP_MESSAGE_ATTR] = [
        build_eap_md5_challenge(eap_id, password_from_packet(pkt), eap_md5)
    ]
    # Carry the server's State across the challenge round-trip.
    pkt[STATE_ATTR] = reply[STATE_ATTR]

build_eap_identity(password)

Build an EAP-Identity Response payload from a password.

Matches the sync client's historic behaviour: the EAP identifier is taken from the module-level packet.CURRENT_ID rolling counter.

Source code in pyrad2/eap/md5.py
def build_eap_identity(password: bytes) -> bytes:
    """Build an EAP-Identity Response payload from a password.

    Matches the sync client's historic behaviour: the EAP identifier is
    taken from the module-level ``packet.CURRENT_ID`` rolling counter.
    """
    return struct.pack(
        "!BBHB%ds" % len(password),
        EAPPacketType.RESPONSE,
        packet.CURRENT_ID,
        len(password) + 5,
        EAPType.IDENTITY,
        password,
    )

build_eap_md5_challenge(eap_id, password, eap_md5)

Build an EAP-Type-MD5-Challenge response payload.

Parameters:

Name Type Description Default
eap_id int

EAP identifier copied from the Access-Challenge.

required
password bytes

User password (used as the MD5 secret).

required
eap_md5 bytes

Raw EAP-MD5 attribute payload from the challenge, starting with the length-prefix byte the server sent.

required
Source code in pyrad2/eap/md5.py
def build_eap_md5_challenge(eap_id: int, password: bytes, eap_md5: bytes) -> bytes:
    """Build an EAP-Type-MD5-Challenge response payload.

    Args:
        eap_id: EAP identifier copied from the Access-Challenge.
        password: User password (used as the MD5 secret).
        eap_md5: Raw EAP-MD5 attribute payload from the challenge,
            starting with the length-prefix byte the server sent.
    """
    md5_challenge = hashlib.md5(
        struct.pack("!B", eap_id) + password + eap_md5[1:]
    ).digest()
    return (
        struct.pack(
            "!BBHBB",
            EAPPacketType.RESPONSE,
            eap_id,
            len(md5_challenge) + 6,
            4,
            len(md5_challenge),
        )
        + md5_challenge
    )

inject_eap_identity(pkt)

Populate the EAP-Message attribute with an EAP-Identity response.

Source code in pyrad2/eap/md5.py
def inject_eap_identity(pkt) -> None:
    """Populate the EAP-Message attribute with an EAP-Identity response."""
    pkt[EAP_MESSAGE_ATTR] = [build_eap_identity(password_from_packet(pkt))]

password_from_packet(pkt)

Extract the user password from an AuthPacket for EAP framing.

Raises PacketError if no User-Password is present. The legacy fall-back to User-Name silently mis-keyed the EAP-MD5 challenge with the username, downgrading authentication to a value that anyone observing the request could reproduce.

Source code in pyrad2/eap/md5.py
def password_from_packet(pkt) -> bytes:
    """Extract the user password from an AuthPacket for EAP framing.

    Raises ``PacketError`` if no ``User-Password`` is present. The
    legacy fall-back to ``User-Name`` silently mis-keyed the EAP-MD5
    challenge with the username, downgrading authentication to a value
    that anyone observing the request could reproduce.
    """
    if USER_PASSWORD_ATTR not in pkt:
        raise PacketError(
            "EAP framing requires a User-Password attribute on the packet"
        )
    return pkt[USER_PASSWORD_ATTR][0]