Decorative illustration for article: We built a RADIUS service for Cisco EasyPSK

We built a RADIUS service for Cisco EasyPSK

Written by 

Sondre Sandberg

 & 

Per Anders Stadheim

August 18, 2026

|

Approximately a 00 minutes read

Development

This is the story of how we built a custom RADIUS service to make Cisco EasyPSK a part of the Intility platform, and what we learned reverse engineering the parts never documented.

Why?

There was a need for shared Wi-Fi to small tenants with wildly different IT maturity, while keeping their traffic segmented, on the Cisco Catalyst platform.

Single PSK gave no segmentation. 802.1X was too heavy. Captive portal and iPSK both broke down for some tenant types. EasyPSK looked like the right compromise: one familiar PSK flow, with segmentation handled server-side. But we couldn't find much documentation about this auth type.

We spun up a lab and started capturing traffic and found unfamiliar RADIUS attributes that ISE couldn't handle out of the box. What little documentation existed pointed toward dedicated hardware to decode them. So the question came up:

Can we build this ourselves?

We started with a tiny RADIUS service that only returned Access-Reject, just to see how the controller interpreted it.

Simple RADIUS deny

import socket, hashlib, struct
 
SECRET = b"SharedSecretExample"
 
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(("0.0.0.0", 1812))
print("Listening on port 1812...")
 
while True:
    data, addr = sock.recvfrom(4096)  # Receive incoming RADIUS packet
    _, identifier, length = struct.unpack("!BBH", data[:4])  # Parse header
    request_auth = data[4:20]  # Extract Request Authenticator
 
    # Build Access-Reject (code 3) with Response Authenticator = MD5(header + RequestAuth + secret)
    response = struct.pack("!BBH", 3, identifier, 20)
    response += hashlib.md5(response + request_auth + SECRET).digest()
 
    sock.sendto(response, addr)  # Send reject back to NAS
    print(f"Access-Reject sent to {addr[0]}")

Seeing the WLC correctly interpret an Access-Reject in the debug was enough to keep digging. We gave the project a name: Rustius, a RADIUS service written in Rust, and went deeper.

The rest of this article covers what we found in the RADIUS packets, how Rustius validates a WPA2-Personal without the client ever sending the password, and how the final Access-Accept maps that PSK to VLAN or UDN segmentation.

Inside an EasyPSK access-request

What is inside an EasyPSK access-request you ask? Below is what you'll find; standard RADIUS, some normal Cisco VSAs and some new.

Standard RADIUS: 
  • User-Name
  • User-Password
  • NAS-IP-Address
  • NAS-Port
  • Service-Type
  • Framed-MTU
  • Called-Station-Id
  • Calling-Station-Id
  • NAS-Identifier
  • NAS-Port-Type
  • Message-Authenticator
Cisco VSA:
  • service-type
  • audit-session-id
  • method, client-iif-id
  • vlan-id
  • cisco-wlan-ssid
  • wlan-profile-name
  • Airespace-Wlan-Id
The new stuff, Cisco VSA - EasyPSK): 
  • cisco-bssid
  • cisco-site-name
  • cisco-8021x-data
  • cisco-anonce

What´s inside the "new" VSAs? Let´s break them down one by one.

cisco-bssid

This explains itself, it is the BSSID. A BSSID uniquely identifies a single SSID on a specific radio, derived from the radio MAC address with a vendor-specific offset per SSID. For example, an AP with radio MAC 34:5d:a8:0b:b3:60 might use 34:5d:a8:0b:b3:60 for the first SSID, 34:5d:a8:0b:b3:61 for the second, and so on. This allows multiple SSIDs on the same radio to be distinguished from each other and from those on other APs.

cisco-site-name

No, it´s not the site-name from Catalyst Center, for those of you that live in that world, but it is the site-tag name in the Catalyst WLC. The site-tag on the controller is basically used to group a set of access points, which are in the same area, but you can also be creative of what you do with the site-tag.

cisco-anonce

ANonce is a random nonce generated by the Authenticator, in infrastructure mode (BSS), this is always the access point or controller side of the exchange. This is usually in the 1st message of the 4-way handshake. That one you can Google; we need to keep this article reasonably short.

cisco-8021x-data

At first glance this one looks like pure chaos. Below is an example.

Cisco-AVPair: cisco-8021x-data

We knew that EasyPSK should be able to recompute a PSK of an EasyPSK authentication, based on the documentation we found, we just did not know how. We knew that we almost had all the pieces to find a PSK, we just did not know where the last pieces of the puzzle was.

Inside the bits and bytes of the 8021X-data VSA there was a pattern of zeroes. Between those zeroes there was not really a pattern. Scratching our heads and looking dangerously close at the screens, we finally found something: the SNonce and client-provided MIC. That discovery came by looking into the same 4-way handshake that used the same PSK.

If you Googled how the 4-way handshake works, you now know we have all the pieces needed to perform a WPA2-Personal MIC verification: the same logic an offline dictionary attack would use, but in reverse: instead of guessing an unknown PSK, we verify a known candidate against the client's MIC.

WPA3-Personal mitigates the WPA2-Personal handshake crack by using SAE instead of PSK. This might give you an a-ha moment: EasyPSK currently only works for WPA2-Personal, not WPA3-Personal.

Now what? We need to put together the pieces in this puzzle.

Decoding the access-request

Instead of trying to model every possible RADIUS attribute in the request, Rustius only extracts the fields required to validate the WPA2-Personal proof:

  • Client MAC address: decoded from the encrypted User-Password attribute using normal RFC 2865 password decoding.
  • SSID: read from Called-Station-Id.
  • AP/BSSID MAC address: read from the Cisco VSA with the cisco-bssid= prefix.
  • ANonce: read from the Cisco VSA with the cisco-anonce= prefix.
  • EAPOL frame: read from the Cisco VSA with the cisco-8021x-data= prefix.

The Cisco-specific values are carried as vendor-specific attributes, RADIUS attribute type 26. Rustius does not try to decode a full Cisco dictionary. It treats the VSA payload as bytes and scans for the EasyPSK markers it cares about.

For example, finding the ANonce means iterating through all vendor-specific attributes and looking for the cisco-anonce= marker:

Finding the ANonce in a vendor-specific AVP
// avp.rs
pub fn get_anonce(req_packet: &Packet) -> Result<Vec<u8>, ParseError> {
    for avp in req_packet.lookup_all(26) {
        let value = avp.encode_bytes();

        if let Some(pos) = value
            .windows(PATTERN_ANONCE.len())
            .position(|window| window == PATTERN_ANONCE)
        {
            return Ok(value[pos + PATTERN_ANONCE.len()..].to_vec());
        }
    }

    Err(ParseError::ANonceNotFound)
}

The same pattern is used for cisco-bssid= and cisco-8021x-data=. Rustius parses just the fields it needs from message 2 of the 4-way handshake:

Parsing the EAPOL key frame and preparing it for MIC verification
// avp.rs
pub struct EapolFrame {
    pub snonce: [u8; 32],
    pub client_mic: [u8; 16],
    pub null_eapol_frame: Vec<u8>,
}

pub fn parse_eapol_key(input: &[u8]) -> Result<EapolFrame, ParseError> {
    let mut null_byte_eapol = input.to_vec();

    // Skip the EAPOL header and fixed key fields until SNonce.
    let (input, _) = le_u8(input)?;
    let (input, _) = le_u8(input)?;
    let (input, _) = be_u16(input)?;
    let (input, _) = le_u8(input)?;
    let (input, _) = be_u16(input)?;
    let (input, _) = be_u16(input)?;
    let (input, _) = be_u64(input)?;

    let (input, snonce) = take(32usize)(input)?;

    // Skip replay counter / IV / RSC / ID fields until MIC.
    let (input, _) = take(16usize)(input)?;
    let (input, _) = be_u64(input)?;
    let (input, _) = be_u64(input)?;

    let (input, client_mic) = take(16usize)(input)?;
    let (input, key_data_length) = be_u16(input)?;
    let (_, _) = take(key_data_length as usize)(input)?;

    // Zero the MIC bytes before calculating our own MIC.
    if null_byte_eapol.len() >= 97 {
      null_byte_eapol[81..97].fill(0);
    } else {
      return Err(ParseError::InvalidInput);
    }

    Ok(EapolFrame {
        snonce: snonce.try_into().map_err(|_| ParseError::InvalidInput)?,
        client_mic: client_mic.try_into().map_err(|_| ParseError::InvalidInput)?,
        null_eapol_frame: null_byte_eapol,
    })
}

At this point Rustius has the same material the client used when proving knowledge of the PSK: SSID, client MAC, AP MAC, ANonce, SNonce and the EAPOL frame.

WPA2-Personal does not use the passphrase directly. The client derives a Pairwise Master Key (PMK) from the passphrase and SSID using PBKDF2-HMAC-SHA1 with 4096 iterations. The PMK is then expanded into a Pairwise Transient Key (PTK) using a PRF seeded with both MAC addresses and both nonces, sorted and concatenated in a specific order defined by the standard: Min(AA,SPA) || Max(AA,SPA) || Min(ANonce,SNonce) || Max(ANonce,SNonce). The PTK's Key Confirmation Key (KCK) portion is then used to compute the MIC over the EAPOL key frame.

This is why Rustius can validate a candidate PSK without ever seeing the password on the wire. If a passphrase produces the same MIC as the client sent, that is the PSK the client used.

easypsk

In code, that comparison starts by deriving the PMK and PTK, then calculating the MIC over the zeroed EAPOL frame:

Deriving the PTK and calculating the EAPOL MIC
// calculate_mic.rs
pub fn calculate_mic(
    anonce: &[u8],
    client_mac: &str,
    ap_mac: &str,
    eapol_frame: &EapolFrame,
    ssid: &str,
    passphrase: &str,
) -> Result<Vec<u8>, ParseError> {
    let pmk = derive_pmk(passphrase, ssid.as_ref())?;

    let ptk_salt = [
        cmp::min(hex::decode(ap_mac)?, hex::decode(client_mac)?),
        cmp::max(hex::decode(ap_mac)?, hex::decode(client_mac)?),
        cmp::min(anonce.to_vec(), eapol_frame.snonce.to_vec()),
        cmp::max(anonce.to_vec(), eapol_frame.snonce.to_vec()),
    ]
    .concat();

    let ptk = derive_ptk(ptk_salt, &pmk)?;

    let mut computed_mic =
        HmacSha1::new_from_slice(&ptk[..16]).map_err(|_| ParseError::MICDerivationError)?;

    computed_mic.update(&eapol_frame.null_eapol_frame);

    Ok(computed_mic.finalize().into_bytes().to_vec())
}

The MIC calculation only tells us whether one candidate passphrase matches. Rustius still needs to decide which candidates are valid for this SSID at this point in time.

Rustius fetches candidate PSKs from Postgres, but only those currently valid. Each login can have a valid_from and an optional expire_at; expired or not-yet-active PSKs are never part of the authentication attempt.

sql
SELECT
    l.id,
    seg.segmentation_value,
    l.password_value,
    seg.segmentation_type
FROM login l
JOIN segmentation seg ON seg.id = l.segmentation_id
JOIN ssid s ON s.id = seg.ssid_id
WHERE s.ssid = $1
AND l.valid_from <= NOW()
AND (
    l.expire_at IS NULL
    OR l.expire_at > NOW()
);

password_value is still encrypted at this point. Decryption happens inside the MIC worker, immediately before testing the candidate.

The decoding flow looks roughly like this:

  1. Read the incoming RADIUS Access-Request.
  2. Decode the client MAC from User-Password.
  3. Read the SSID from Called-Station-Id.
  4. Scan Cisco VSAs, type 26, for cisco-anonce=, cisco-bssid= and cisco-8021x-data=.
  5. Parse the EAPOL key frame.
  6. Extract the SNonce and client MIC.
  7. Zero out the MIC field in the EAPOL frame.
  8. Recalculate the MIC for each candidate PSK.
  9. Accept the request if the calculated MIC matches the client MIC.

Rustius fails closed: if any required field is missing, SSID, client MAC, ANonce, BSSID or EAPOL data, the request is rejected. Partial parsing should never result in accidental access.

"To deny, or to accept, that is the question"

Rejecting requests

On rejection, EasyPSK includes the cisco-easy-psk-error-cause vendor-specific attribute in the Access-Reject response, giving the controller a machine-readable reason for the failure.

We discovered cisco-easy-psk-error-cause kind of accidentally when reviewing an access-reject, it was there - just not filled out. Spraying that attribute with values and then do a debug each time we could find the values and meanings. More on that shortly

Accepting requests

On success, the response is still a normal RADIUS Access-Accept, but with Cisco-specific attributes telling the controller which PSK matched.

Returning the matched PSK to the WLC in the Access-Accept
// request_handler.rs
let mut resp_packet = req_packet.make_response_packet(Code::AccessAccept);

// Tell the WLC which PSK matched.
let vsa_ascii = StringVSA::new(9, 1, "psk-mode=ascii");
resp_packet.add(AVP::from_bytes(26, &vsa_ascii.message()));

let vsa_psk = StringVSA::new(9, 1, &format!("psk={password}"));
resp_packet.add(AVP::from_bytes(26, &vsa_psk.message()));

This is similar to how an iPSK Access-Accept looks, which helped us find the right response attributes.The segmentation part depends on what kind of segment the login belongs to. For VLAN we return normal tunnel attributes. For UDN we return Cisco private group attributes.

That is what turns a successful PSK match into a network policy decision.

Other stuff the documentation didn't cover

There is no formal vendor dictionary for the EasyPSK attributes in the way there is for standard RADIUS. cisco-anonce=, cisco-bssid= and cisco-8021x-data= are not listed in any public Cisco documentation we could find for this feature. They had to be discovered by inspecting actual traffic from the controller.

The same approach was needed for the cisco-easy-psk-error-cause attribute included in Access-Reject responses. The attribute exists, but the meaning of its values is not publicly documented. We mapped the values the controller actually produces:

| Value | Meaning |
|---:|---|
| 0 | No Error |
| 1 | Unspecified Error |
| 2 | Password Mismatch |
| 3 | Server Busy |
| 4 | STA's Limit Reached |
| 5 | Bad 802.1x Frame |
| 6 | Missing Attribute in Request |

This mapping was confirmed through WLC debug logs.

Applying Attribute : cisco-easy-psk-error-cause 0 2 [Password Mismatch].

Language matters

We genuinely like Rust, but there are also technical reasons it was a natural fit.

The main one is parallelism. When Rustius receives an Access-Request, it may need to test several candidate PSKs. One PBKDF2 derivation per candidate. find_correct_login spawns a task per candidate, using full CPU capacity to find a match as fast as possible. The first successful match cancels the remaining tasks:

Testing candidate passphrases in parallel until the MIC matches
// request_handler.rs
set.spawn_blocking(move || {
    if child_token.is_cancelled() {
        return None;
    }

    let decrypted_pass =
        Secret::decrypt(&login.password_value, &encode_secret.cipher).ok()?;

    let mic = calculate_mic(
        &anonce,
        &client_mac,
        &ap_mac,
        &eapol_frame,
        &ssid,
        &decrypted_pass,
    )
    .ok()?;

    if mic[..16] == eapol_frame.client_mic {
        child_token.cancel();

        return Some(LoginWithCredentials {
            login,
            passphrase: decrypted_pass,
        });
    }

    None
});

This matters in practice. During testing with 1500 active PSKs on a single SSID, sequential processing took around 50 seconds. After parallelising across all available cores, the same workload completed in 0.42 seconds. If latency becomes a bottleneck, adding CPU is enough. In a sequential implementation, it wouldn't be.

Rust's lack of a garbage collector also fits well here. For a service doing cryptographic operations under load, predictable memory behavior matters. The type system eliminates a class of bugs around byte-level parsing and a RADIUS decoder handling raw EAPOL frames is exactly where that counts.

Closing thoughts

Rustius is a good example of the kind of engineering we value: understand the protocol, build the necessary automation, and make the capability an integrated part of the platform, not another system next to it.

Glossary

AA — Authenticator Address: The MAC address of the authenticator in the WPA2 4-way handshake. In infrastructure Wi-Fi, this is the AP/BSSID side of the exchange. It is used together with the client MAC and both nonces when deriving the PTK.

Access-Accept: A RADIUS response indicating that authentication succeeded. In this article, Rustius returns Access-Accept together with policy attributes that tell the controller which segment the client should land in.

Access-Reject: A RADIUS response indicating that authentication failed. Rustius returns Access-Reject when the PSK cannot be verified or required EasyPSK fields are missing.

Access-Request: A RADIUS request sent by the WLC to the RADIUS server when a client attempts to authenticate. For EasyPSK, this request includes both standard RADIUS attributes and Cisco-specific EasyPSK attributes.

ANonce — Authenticator Nonce: A random nonce generated by the authenticator, usually the AP or controller side. It is sent in message 1 of the WPA2 4-way handshake and is one of the inputs used to derive the PTK.

AP — Access Point: The Wi-Fi access point serving the wireless client. In this setup, APs are controlled by the Catalyst WLC.

Authenticator: The network-side participant in the WPA2 4-way handshake. In infrastructure Wi-Fi this is the AP or controller side, depending on implementation.

BSS — Basic Service Set: A single Wi-Fi cell served by one access point radio for one SSID. In practice, when a client joins an SSID on a specific AP radio, it joins a BSS.

BSSID — Basic Service Set Identifier: The MAC address identifying a specific SSID on a specific AP radio. One AP can advertise multiple SSIDs, and each SSID/radio combination gets its own BSSID.

Captive portal: A web-based authentication flow where users are redirected to a login or registration page before getting network access. It can be useful for guest access, but does not fit every tenant or device type.

Catalyst Center: Cisco’s management platform for network automation, assurance and inventory. In this article it is only mentioned to clarify that cisco-site-name refers to the Catalyst WLC site-tag, not the Catalyst Center site name.

Dictionary attack: An attack where an attacker tests many candidate passwords or passphrases until one matches. Rustius uses the same cryptographic verification pattern, but for the opposite purpose: verifying approved candidate PSKs.

EAPOL — Extensible Authentication Protocol over LAN: The frame format used for the WPA/WPA2 4-way handshake. Rustius parses the EAPOL key frame to extract the SNonce and MIC needed to verify the PSK.

EAPOL key frame: The specific EAPOL frame used during the WPA2 4-way handshake. Rustius parses this frame to extract the SNonce and client MIC.

Four-way handshake: The WPA/WPA2 exchange where the client and authenticator prove they both know the PMK without sending the passphrase over the air. The handshake produces the keys used to protect the session.

iPSK — Identity PSK: A model where different clients or users can authenticate to the same SSID using different pre-shared keys. The matched key can then be mapped to different policies based on for example the MAC adresses.

ISE — Identity Services Engine: Cisco’s policy and RADIUS platform. In this article, ISE could not handle the EasyPSK-specific attributes out of the box, which led to building Rustius.

KCK — Key Confirmation Key: The part of the PTK used to calculate and verify the MIC in the WPA2 4-way handshake.

MAC — Media Access Control address: The layer 2 hardware address used to identify network interfaces. In this article, both the client MAC and AP/BSSID MAC are used as inputs when deriving the PTK.

MIC — Message Integrity Code: A cryptographic integrity value in the WPA2 4-way handshake. Rustius recalculates the MIC for candidate PSKs and compares it with the client-provided MIC to determine whether the PSK matches.

NAS — Network Access Server: In RADIUS terminology, the device that sends authentication requests to the RADIUS server. Here, the Catalyst WLC acts as the NAS.

PBKDF2 — Password-Based Key Derivation Function 2: The key derivation function WPA2-Personal uses to turn a passphrase and SSID into a PMK. WPA2-Personal uses PBKDF2-HMAC-SHA1 with 4096 iterations.

PMK — Pairwise Master Key: The key derived from the Wi-Fi passphrase and SSID. The PMK is not used directly for frame protection; it is expanded into the PTK during the 4-way handshake.

PRF — Pseudo-Random Function: The function used to expand the PMK into the PTK using the MAC addresses and nonces from both sides of the handshake.

PSK — Pre-Shared Key: The Wi-Fi passphrase used with WPA2-Personal. In this article, Rustius validates candidate PSKs without the password ever being sent over the wire.

PTK — Pairwise Transient Key: A key derived during the WPA2 4-way handshake from the PMK, both MAC addresses and both nonces. The KCK, used for MIC validation, is part of the PTK.

RADIUS — Remote Authentication Dial-In User Service: A protocol used for authentication, authorization and accounting. The Catalyst WLC sends RADIUS Access-Request packets to Rustius, which responds with either Access-Reject or Access-Accept.

RADIUS attribute: A field inside a RADIUS packet. Attributes can be standard, such as User-Name, or vendor-specific, such as Cisco’s EasyPSK fields.

Segmentation: Separating network traffic into different logical areas, usually for security, tenancy or policy reasons. In this article, segmentation is enforced by mapping a matched PSK to either a VLAN or UDN.

SNonce — Supplicant Nonce: A random nonce generated by the Wi-Fi client, also called the supplicant. It is sent in message 2 of the WPA2 4-way handshake and is used when deriving the PTK.

SSID — Service Set Identifier: The Wi-Fi network name visible to clients. In WPA2-Personal, the SSID is also used as input when deriving the PMK from the passphrase.

Supplicant: The client-side participant in the WPA/WPA2 authentication exchange. In this article, the supplicant is the Wi-Fi client trying to join the network.

Tunnel attributes: Standard RADIUS attributes used to assign a client to a VLAN. Rustius returns these when a matched PSK should result in VLAN-based segmentation.

UDN — User Defined Network: A Cisco segmentation construct that can group clients into isolated private networks. In this article, Rustius can return Cisco private group attributes to map a matched PSK to a UDN segment.

VLAN — Virtual Local Area Network: A layer 2 segmentation mechanism. Rustius can return standard RADIUS tunnel attributes to place a client into a VLAN after a successful PSK match.

VSA — Vendor-Specific Attribute: A RADIUS attribute used by vendors to carry proprietary data. Cisco’s EasyPSK fields such as cisco-anonce, cisco-bssid and cisco-8021x-data are carried as VSAs.

WLC — Wireless LAN Controller: The controller managing the access points and WLANs. In this article, the Cisco Catalyst WLC sends EasyPSK RADIUS requests to Rustius.

if (wantUpdates == true) {followIntilityOnLinkedIn();}