Lunar - Authentication
RADIUS carries several different authentication methods, and each one presents the subscriber's credentials differently. This page explains how lunar handles the methods it supports today (PAP, CHAP, MS-CHAP and MS-CHAPv2), the design principle behind that handling, and where EAP fits in now and in future releases.
The guiding principle is simple: lunar hands your backend everything it needs to decide in a single API call. Your backend never has to call lunar back, run a second round trip, or understand RADIUS wire crypto that lunar has already dealt with. One inbound Access-Request becomes one auth POST to your backend carrying a ready-to-use credential, and one decision comes back.
One backend call per request
Traditional RADIUS servers often couple credential verification tightly to the server: the method logic, the password store lookup and the reply are all entangled inside the server's own modules. Lunar deliberately splits this (the server owns the wire and the crypto, your backend owns the decision) and it does so without making your backend chatty.
For every authentication method, lunar does whatever protocol-level work is needed so that the auth POST it sends already contains a credential your backend can act on directly:
it decrypts what it can decrypt (PAP), and
it forwards the raw challenge/response material plus the request authenticator for the methods it cannot (CHAP, MS-CHAP), so your backend can run the one comparison it needs against its stored password.
The result is a single, self-contained request/response with your backend, no extra lookups against lunar, no multi-call handshake for the common methods.
PAP: Password Authentication Protocol
PAP (RFC 2865 s5.2) sends the password obscured with the shared secret in the User-Password attribute. Lunar holds the client's shared secret, so it decrypts User-Password and forwards the cleartext password to your backend as a normal text value.
Your backend therefore just compares two strings: the username and the password it received against what it has on record. No RADIUS crypto, no challenge handling. This is the simplest method to integrate, and for many subscriber deployments it is all that is needed.
In detail:
The user-password is decrypted by Lunar and contains the cleartext version of the password when it reaches the backend API.
Attribute present: user-password (a normal text value).
Verify: compare user-password to the subscriber's stored password directly, no crypto on your side.
Reply: access-accept on a match, access-reject otherwise.
Working Examples
For those in a hurry, complete, readable implementations of CHAP, MS-CHAPv1 and MS-CHAPv2 (including the NT-hash, MD4 and DES primitives) live in the Interstellio examples repository on GitHub: https://github.com/interstellio/examples/tree/master/lunar/radius-backend. Every backend there (Python with Flask, Falcon, Falcon-async or FastAPI, plus Go, Node.js/Express, PHP, .NET and Java) ships the same auth/ package with a chap, mschap and mschapv2 module and a shared nt module. Pick the one for your stack. What follows is exactly what those modules do.
Challenge Authentication
CHAP, MS-CHAP and MS-CHAPv2 are all "challenge/response" methods, and they work very differently from PAP. It helps to understand why before looking at any one of them.
With PAP the password itself travels across the network (obscured with the shared secret, which lunar can undo), so lunar can hand your backend the actual cleartext password. Challenge methods deliberately never do this. Instead of sending the password, the subscriber's device mixes the password together with a random "challenge" value and a one-way hash function (MD5, or the Microsoft NT-hash scheme) and sends only the result of that calculation. A one-way hash cannot be reversed: you cannot get the password back out of it. This is the whole point, it means the password never crosses the wire, not even in a form anyone could decrypt.
Because it is one-way, lunar cannot see the password and cannot give it to you in cleartext. There is nothing to decrypt, the password simply is not in the packet. All lunar can do is forward the pieces it did receive (the challenge and the hashed response) to your backend untouched.
Your backend cannot see the password either. It receives the same hashed response, which it also cannot reverse. So how does it check the login? It has to already know the subscriber's password. It takes the password it holds, runs it through the exact same calculation (password + challenge + hash) itself, and compares its own result with the response the subscriber sent. If the two match, the passwords matched. This is why verification can only ever happen in your backend: only your backend holds the password to redo the calculation with.
This has one important consequence for how you store passwords. To repeat the calculation your backend needs the real password (or the NT-hash of it for the MS-CHAP family, which serves the same purpose). It cannot use a modern salted password hash such as bcrypt, argon2 or a salted SHA-256, because those are themselves one-way and there is no way to feed them back into the CHAP/MS-CHAP maths. So if you use CHAP, MS-CHAP or MS-CHAPv2 you cannot store your subscribers' passwords as secure salted hashes in your database. You must keep them either in cleartext or, for the MS-CHAP family, as the reversible NT-hash, both of which are equivalent to holding the password. Protect that store accordingly. If you need to store only strong one-way password hashes, use PAP (over TLS) instead, which is the only method where the backend receives the actual password and can compare it against a hash it computes at that moment.
You might ask why lunar does not just do this comparison for you. It could, but only by breaking its own single-call model. Lunar does not hold any subscriber passwords, so to verify a challenge itself it would first have to call your backend to fetch the subscriber's cleartext password (or NT-hash), redo the calculation, and then make a second call to tell your backend whether the login succeeded and to collect the reply attributes. That is at least two backend calls per authentication, plus handing every subscriber's password out to lunar. We deliberately avoid this: it adds far more complexity and moving parts for no benefit. Forwarding the challenge material in one call and letting your backend, which already holds the password, do the comparison is simpler, faster and keeps the passwords where they belong.
CHAP
CHAP (RFC 1994, RFC 2865 s5.3) is the simplest of the challenge methods. The NAS sends a CHAP-Password (an identifier plus an MD5 hash) computed over a challenge, either an explicit CHAP-Challenge attribute or, when that is absent, the Request Authenticator itself (RFC 2865 s2.2).
Lunar forwards the CHAP material untouched (as 0x raw bytes) and includes the request authenticator in the body, so your backend has the challenge even when CHAP-Challenge is not present. Your backend runs the CHAP algorithm itself (hash the identifier, the stored cleartext password and the challenge, and compare with the response) in that one call.
In detail:
Attributes present: chap-password, and usually chap-challenge.
chap-password is 17 bytes: a 1-byte CHAP identifier followed by a 16-byte MD5 digest.
chap-challenge is the challenge. If it is absent, the challenge is the packet's Request Authenticator. Use the authenticator field from the request body instead.
Verify: compute MD5(chap_id || cleartext_password || challenge) and compare it to the 16-byte digest from chap-password (chap_id is that first byte).
Reply: access-accept on a match, else access-reject.
chap_id = chap_password[0]
digest = chap_password[1:17]
expected = md5(bytes([chap_id]) + password + challenge)
accept if expected == digest
MS-CHAP and MS-CHAPv2
MS-CHAP (RFC 2433, RFC 2548) and MS-CHAPv2 (RFC 2759, RFC 2548) are Microsoft's challenge/response variants, carried in vendor attributes (MS-CHAP-Challenge / MS-CHAP-Response and the v2 equivalents). As with CHAP, the password is never sent and the response cannot be reversed.
Lunar treats them the same way as CHAP: the challenge and response attributes are forwarded as raw bytes, and your backend runs the MS-CHAP(v2) algorithm against its stored password to accept or reject, again, in a single auth call. (radtest can generate MS-CHAP and MS-CHAPv2 requests to exercise this path against a real backend, see Tools. Lunar's testing mode and the lunar_backend_sim simulator always return an Access-Accept without validating the credential, so they cannot authenticate these methods; only PAP is meaningful against them.)
MS-CHAPv1
MS-CHAPv1 (RFC 2433) verifies an NT-hash challenge/response.
Attributes present: ms-chap-challenge (8 bytes) and ms-chap-response.
ms-chap-response is 49 bytes: 1 byte ident, 1 byte flags, a 24-byte LM-Response (you can ignore it) and the 24-byte NT-Response (the part you check).
Verify the NT-Response:
Compute the NT hash of the password: MD4(UTF-16LE(password)), 16 bytes.
Pad that 16-byte hash to 21 bytes with zeros and split it into three 7-byte DES keys.
DES-encrypt the 8-byte ms-chap-challenge with each key and concatenate the three 8-byte outputs into a 24-byte value.
Accept if that equals the NT-Response from ms-chap-response.
Reply: access-accept or access-reject.
MS-CHAPv2
MS-CHAPv2 (RFC 2759) is mutual: on success you also prove the server to the client and return the MPPE keying material.
Attributes present: ms-chap-challenge (16 bytes, the Authenticator Challenge) and ms-chap2-response.
ms-chap2-response is 50 bytes: 1 byte ident, 1 byte flags, a 16-byte Peer-Challenge, 8 reserved bytes and the 24-byte NT-Response.
Verify:
ChallengeHash = SHA1(Peer-Challenge || Authenticator-Challenge || user_name) and take the first 8 bytes. user_name is the bare account name (no domain), ASCII.
Compute the NT hash MD4(UTF-16LE(password)), pad to 21 bytes and split into three 7-byte DES keys, as for MS-CHAPv1.
DES-encrypt the 8-byte ChallengeHash with each key, concatenate to 24 bytes, and accept if it equals the NT-Response.
On accept you must also return, so the client can verify the server and derive session keys:
ms-chap2-success: the "S=" Authenticator Response string from RFC 2759 (built from the NT hash, the NT-Response and the ChallengeHash).
ms-mppe-send-key / ms-mppe-recv-key: the MPPE session keys (RFC 3079), returned as plaintext 0x hex. lunar salt-encrypts them on the wire (they are encrypt=2 attributes), so you never touch the Request Authenticator for this.
Reply: access-accept with those attributes, or access-reject (you may add an ms-chap-error string).
The mschapv2 and nt modules in the examples implement every step above (MD4, the DES key schedule, the ChallengeHash, the Authenticator Response and the MPPE keys) so you can read a full, tested version next to this description.
Detecting the method
Your backend tells which method a request used from which credential attribute is present: user-password means PAP, chap-password means CHAP, ms-chap-response / ms-chap2-response mean MS-CHAP / MS-CHAPv2, and eap-message means EAP. A backend that only supports PAP simply reads the cleartext user-password. One that supports CHAP or MS-CHAP implements those algorithms from the raw attributes, each covered in full above.
(If you also stream to Kafka, the authentication event there carries an explicit method field (pap / chap / mschap / mschapv2 / eap), see Kafka. The synchronous auth POST itself identifies the method by attribute presence, as above.)
Message-Authenticator (BlastRADIUS)
Independently of the method, lunar follows modern RADIUS security practice by adding and verifying the Message-Authenticator attribute (RFC 3579), the mitigation for the BlastRADIUS attack (CVE-2024-3596). This is handled entirely on the wire by lunar. Your backend neither sees nor produces it. Clients can be configured to require it on inbound requests and to have it signed on replies.
EAP today
EAP (RFC 3579 RADIUS transport) is a multi-round method: it is not a single question and answer but an exchange that bounces back and forth between the supplicant and the authentication server, carried in EAP-Message attributes with Access-Challenge responses in between.
Today lunar transports EAP but does not itself terminate any EAP method. It forwards the EAP-Message bytes to your backend and relays your access-challenge replies back to the NAS, correlating the rounds on the State attribute. A specific method such as EAP-TLS would have to run inside your backend across those rounds. This means EAP is the one method that is not a single backend call. It is inherently several, one per round.
EAP-TLS on the roadmap
Requiring every backend to implement a full EAP method is a heavy ask. EAP-TLS in particular means driving a complete TLS handshake over EAP. So a planned future release moves that burden into lunar. Lunar will perform the EAP-TLS handshake itself across the multi-round exchange. It then makes a single call to your backend to validate the presented certificate (identity, chain, policy) and return the decision.
That restores the same model used for PAP, CHAP and MS-CHAP, where lunar does the protocol-heavy work on the wire and your backend makes one clean policy decision. It brings that model to a method that today would otherwise force a multi-call integration. Until then, EAP is transported for backends that choose to terminate it themselves.