Skip to content

mschap

Requires the optional [mschap] extra (pip install pyrad2[mschap]) for the DES primitive. MD4 is bundled — see pyrad2._md4.

MS-CHAPv2 helpers (RFC 2759 + RFC 2548 §2.3).

MS-CHAPv2 is the challenge/response authentication scheme Microsoft defined for PPP and Windows VPN clients. Plain RADIUS deployments typically carry it as Microsoft VSAs (RFC 2548) on an Access-Request / Access-Challenge pair; the EAP variant (EAP-MSCHAPv2) wraps the same primitives in EAP framing and lives under pyrad2.eap.mschapv2.

Optional dependency. The DES step needed for the ChallengeResponse primitive lives in the cryptography package — install with::

pip install pyrad2[mschap]

MD4 (also required by MS-CHAPv2) is bundled in pyrad2._md4 because modern OpenSSL builds no longer ship it. Both halves are loaded lazily so the rest of pyrad2 stays importable even when the extra isn't installed.

Security note. MS-CHAPv2 is cryptographically broken — the challenge/response primitive can be reduced to a single DES key search. Use it strictly for legacy interop, never as a primary authentication factor on a new deployment.

nt_password_hash(password)

Compute the NT Password Hash (RFC 2759 §8.3).

The Microsoft password hash is MD4(password_utf16le) — the password is encoded as little-endian UCS-2/UTF-16 with no terminator and no length prefix.

Source code in pyrad2/mschap.py
def nt_password_hash(password: bytes | str) -> bytes:
    """Compute the NT Password Hash (RFC 2759 §8.3).

    The Microsoft password hash is ``MD4(password_utf16le)`` — the
    password is encoded as little-endian UCS-2/UTF-16 with no
    terminator and no length prefix.
    """
    if isinstance(password, str):
        password = password.encode("utf-16-le")
    return md4(password)

hash_nt_password_hash(password_hash)

Compute the PasswordHashHash (RFC 2759 §8.4).

Just MD4 applied to the 16-byte NT Password Hash. Used inside the Authenticator Response computation to prove the server also knew the password.

Source code in pyrad2/mschap.py
def hash_nt_password_hash(password_hash: bytes) -> bytes:
    """Compute the ``PasswordHashHash`` (RFC 2759 §8.4).

    Just MD4 applied to the 16-byte NT Password Hash. Used inside the
    Authenticator Response computation to prove the server also knew
    the password.
    """
    if len(password_hash) != 16:
        raise ValueError(f"NT password hash must be 16 bytes, got {len(password_hash)}")
    return md4(password_hash)

challenge_hash(peer_challenge, authenticator_challenge, user_name)

RFC 2759 §8.2 — SHA-1 truncated to 8 bytes.

user_name is the bare username — any domain prefix (DOMAIN\user) is stripped by the caller per the spec.

Source code in pyrad2/mschap.py
def challenge_hash(
    peer_challenge: bytes,
    authenticator_challenge: bytes,
    user_name: bytes | str,
) -> bytes:
    """RFC 2759 §8.2 — SHA-1 truncated to 8 bytes.

    ``user_name`` is the *bare* username — any domain prefix
    (``DOMAIN\\user``) is stripped by the caller per the spec.
    """
    if isinstance(user_name, str):
        user_name = user_name.encode("utf-8")
    if len(peer_challenge) != 16:
        raise ValueError(f"Peer challenge must be 16 bytes, got {len(peer_challenge)}")
    if len(authenticator_challenge) != 16:
        raise ValueError(
            f"Authenticator challenge must be 16 bytes, got {len(authenticator_challenge)}"
        )
    h = hashlib.sha1()
    h.update(peer_challenge)
    h.update(authenticator_challenge)
    h.update(user_name)
    return h.digest()[:8]

challenge_response(challenge8, password_hash)

RFC 2759 §8.5 — 24-byte response from challenge + password hash.

Pads the 16-byte hash to 21 bytes, carves it into three 7-byte DES keys, and encrypts the 8-byte challenge under each. Returns the concatenated 24-byte result.

Source code in pyrad2/mschap.py
def challenge_response(challenge8: bytes, password_hash: bytes) -> bytes:
    """RFC 2759 §8.5 — 24-byte response from challenge + password hash.

    Pads the 16-byte hash to 21 bytes, carves it into three 7-byte DES
    keys, and encrypts the 8-byte challenge under each. Returns the
    concatenated 24-byte result.
    """
    if len(challenge8) != 8:
        raise ValueError(f"Challenge must be 8 bytes, got {len(challenge8)}")
    if len(password_hash) != 16:
        raise ValueError(f"Password hash must be 16 bytes, got {len(password_hash)}")
    z = password_hash + b"\x00" * 5
    return (
        _des_encrypt(z[0:7], challenge8)
        + _des_encrypt(z[7:14], challenge8)
        + _des_encrypt(z[14:21], challenge8)
    )

generate_nt_response(authenticator_challenge, peer_challenge, user_name, password)

RFC 2759 §8.1 — produce the 24-byte NT-Response.

The end-to-end primitive most callers want: takes the two 16-byte challenges, the username, and the cleartext password, and returns the 24 bytes that go into the Response field of MS-CHAP2-Response (RFC 2548 §2.3.2) or the EAP-MSCHAPv2 response packet.

Source code in pyrad2/mschap.py
def generate_nt_response(
    authenticator_challenge: bytes,
    peer_challenge: bytes,
    user_name: bytes | str,
    password: bytes | str,
) -> bytes:
    """RFC 2759 §8.1 — produce the 24-byte NT-Response.

    The end-to-end primitive most callers want: takes the two 16-byte
    challenges, the username, and the cleartext password, and returns
    the 24 bytes that go into the ``Response`` field of
    ``MS-CHAP2-Response`` (RFC 2548 §2.3.2) or the EAP-MSCHAPv2
    response packet.
    """
    challenge = challenge_hash(peer_challenge, authenticator_challenge, user_name)
    pw_hash = nt_password_hash(password)
    return challenge_response(challenge, pw_hash)

build_mschap2_response(ident, peer_challenge, nt_response, flags=0)

Build the 50-byte MS-CHAP2-Response VSA payload (RFC 2548 §2.3.2).

Wire layout::

Ident(1) | Flags(1) | Peer-Challenge(16) | Reserved(8) | Response(24)

The Reserved field is always 8 zero octets. Flags is 0 in every conformant deployment — set non-zero only if a specific NAS requires it.

Source code in pyrad2/mschap.py
def build_mschap2_response(
    ident: int,
    peer_challenge: bytes,
    nt_response: bytes,
    flags: int = 0,
) -> bytes:
    """Build the 50-byte ``MS-CHAP2-Response`` VSA payload (RFC 2548 §2.3.2).

    Wire layout::

        Ident(1) | Flags(1) | Peer-Challenge(16) | Reserved(8) | Response(24)

    The ``Reserved`` field is always 8 zero octets. ``Flags`` is 0 in
    every conformant deployment — set non-zero only if a specific NAS
    requires it.
    """
    if not 0 <= ident <= 0xFF:
        raise ValueError(f"Ident must fit in one byte, got {ident}")
    if not 0 <= flags <= 0xFF:
        raise ValueError(f"Flags must fit in one byte, got {flags}")
    if len(peer_challenge) != 16:
        raise ValueError(f"Peer challenge must be 16 bytes, got {len(peer_challenge)}")
    if len(nt_response) != 24:
        raise ValueError(f"NT response must be 24 bytes, got {len(nt_response)}")
    return bytes([ident, flags]) + peer_challenge + b"\x00" * 8 + nt_response

generate_authenticator_response(password, nt_response, peer_challenge, authenticator_challenge, user_name)

RFC 2759 §8.7 — produce the 42-byte S=... authenticator response.

The server returns this in the MS-CHAP2-Success VSA (RFC 2548 §2.3.3); the client recomputes it locally and compares. The output is an ASCII bytestring of the form b"S=" + 40 uppercase hex characters — exactly what appears on the wire.

Source code in pyrad2/mschap.py
def generate_authenticator_response(
    password: bytes | str,
    nt_response: bytes,
    peer_challenge: bytes,
    authenticator_challenge: bytes,
    user_name: bytes | str,
) -> bytes:
    """RFC 2759 §8.7 — produce the 42-byte ``S=...`` authenticator response.

    The server returns this in the ``MS-CHAP2-Success`` VSA (RFC 2548
    §2.3.3); the client recomputes it locally and compares. The output
    is an ASCII bytestring of the form ``b"S=" + 40 uppercase hex
    characters`` — exactly what appears on the wire.
    """
    if len(nt_response) != 24:
        raise ValueError(f"NT response must be 24 bytes, got {len(nt_response)}")
    password_hash = nt_password_hash(password)
    password_hash_hash = hash_nt_password_hash(password_hash)

    h1 = hashlib.sha1()
    h1.update(password_hash_hash)
    h1.update(nt_response)
    h1.update(_AUTH_RESPONSE_MAGIC_1)
    inner = h1.digest()

    server_challenge = challenge_hash(
        peer_challenge, authenticator_challenge, user_name
    )

    h2 = hashlib.sha1()
    h2.update(inner)
    h2.update(server_challenge)
    h2.update(_AUTH_RESPONSE_MAGIC_2)
    final = h2.digest()

    return b"S=" + final.hex().upper().encode("ascii")

verify_authenticator_response(password, nt_response, peer_challenge, authenticator_challenge, user_name, received)

Validate the server's MS-CHAP2-Success Authenticator Response.

RFC 2548 §2.3.3 specifies the MS-CHAP2-Success VSA value as a one-byte MS-CHAP-Identifier followed by the Success-Message "S=<authenticator> M=<message>". This helper locates the S= marker and compares the 42-byte authenticator slice against the locally-recomputed expected value; M=<message> (an optional operator-facing note) and any preceding identifier byte are ignored.

Source code in pyrad2/mschap.py
def verify_authenticator_response(
    password: bytes | str,
    nt_response: bytes,
    peer_challenge: bytes,
    authenticator_challenge: bytes,
    user_name: bytes | str,
    received: bytes,
) -> bool:
    """Validate the server's ``MS-CHAP2-Success`` Authenticator Response.

    RFC 2548 §2.3.3 specifies the ``MS-CHAP2-Success`` VSA value as a
    one-byte ``MS-CHAP-Identifier`` followed by the ``Success-Message``
    ``"S=<authenticator> M=<message>"``. This helper locates the
    ``S=`` marker and compares the 42-byte authenticator slice against
    the locally-recomputed expected value; ``M=<message>`` (an optional
    operator-facing note) and any preceding identifier byte are
    ignored.
    """
    expected = generate_authenticator_response(
        password, nt_response, peer_challenge, authenticator_challenge, user_name
    )
    if not isinstance(received, (bytes, bytearray)):
        raise TypeError("received must be bytes")
    received = bytes(received)
    idx = received.find(b"S=")
    if idx < 0:
        return False
    return received[idx : idx + 42] == expected

prepare_mschap2_request(pkt, *, user_name, password, authenticator_challenge, peer_challenge, ident=0, flags=0)

Stamp an AuthPacket with the two MS-CHAPv2 VSAs and return the NT-Response.

Mutates pkt in place — User-Password is removed, MS-CHAP-Challenge and MS-CHAP2-Response VSAs are set — and returns the 24-byte NT-Response so the caller can later verify the server's Authenticator Response without recomputing the chain.

Source code in pyrad2/mschap.py
def prepare_mschap2_request(
    pkt: "AuthPacket",
    *,
    user_name: bytes | str,
    password: bytes | str,
    authenticator_challenge: bytes,
    peer_challenge: bytes,
    ident: int = 0,
    flags: int = 0,
) -> bytes:
    """Stamp an ``AuthPacket`` with the two MS-CHAPv2 VSAs and return the NT-Response.

    Mutates ``pkt`` in place — ``User-Password`` is removed,
    ``MS-CHAP-Challenge`` and ``MS-CHAP2-Response`` VSAs are set — and
    returns the 24-byte NT-Response so the caller can later verify the
    server's Authenticator Response without recomputing the chain.
    """
    if "User-Password" in pkt:
        del pkt["User-Password"]

    nt_response = generate_nt_response(
        authenticator_challenge, peer_challenge, user_name, password
    )
    pkt["MS-CHAP-Challenge"] = authenticator_challenge
    pkt["MS-CHAP2-Response"] = build_mschap2_response(
        ident, peer_challenge, nt_response, flags=flags
    )
    return nt_response