FreeIPA: FreeRADIUS and WiFi authentication


Something that seemed like a fun idea at the time turned out to be an interesting challenge. There is documentation online that show you some basics around FreeRADIUS, FreeIPA, and WiFi authentication. However, most of it is outdated as it expects an NT Hash on user objects. There are a few pages about EAP-TLS authentication, but nothing fully detailed. In fact, there are some documentation pages at Red Hat’s website around this idea, which tells me someone has done it before.

So I decided to try my hand at it and see where it gets me.

What were my end goals?

I needed a set of deliverables for this to work. Essentially I wanted:

  • FreeRADIUS must run on CentOS Stream 9 or 10 (or AlmaLinux)
    • In my case, it will run on my CentOS Stream 9 router.
  • Sub-CA for general WiFi clients and users
  • Sub-CA for IoT devices
  • Separate WiFi SSID’s for each
  • FreeRADIUS server that verifies against user and host objects
    • Object cannot be disabled
    • User object must be in wifi_users
    • Host object must be in wifi_hosts
    • Certificates are verified against Sub-CA’s, not the root CA

All of the above is non-negotiable, especially the fifth one. We don’t want FreeRADIUS to accept authentication from certificates not signed by either of the two Sub-CA’s and the objects cannot be disabled.

Red Hat documentation assumes Elliptic Curve is available

Red Hat’s documentation provides commands to use Elliptic Curve keys when issuing certificates. FreeIPA’s CA does not support this out-of-the-box. This requires using CA profiles to achieve EC certificates. As I did not want to go through setting this up, I opted to just use RSA keys.

The downside to using RSA keys over EC keys is that the RSA keys will simply be slower. Though, this may not be too noticeable, depending on your use case.

How do I make Sub-CA’s?

This was very straight forward. I just followed what the ipa help commands gave me and it seemed to work fine.

% ipa ca-add --desc="IPA IoT Chains" --subject="CN=IoT,O=CLOCKWORK.HOST" --chain
% ipa ca-add --desc="IPA WiFi Chains" --subject="CN=WiFi,O=CLOCKWORK.HOST" --chain

If you use --chain, it will give you an output that chains the CA’s together if you desire. Use --certificate-out if you want to export it that way as well.

To make sure that clients can freely pull those CA’s, I put them in the same place that the ca.crt (IPA’s root CA) is. This isn’t required, but I thought it was a nice to have.

% ipa ca-show ipa_iot --certificate-out=/usr/share/ipa/html/iot.crt
% ipa ca-show ipa_wifi --certificate-out=/usr/share/ipa/html/wifi.crt

FreeRADIUS Configuration

I needed to install FreeRADIUS on my CentOS Stream 9 router.

# Generate certificates with ipa-getcert
# Note: Red Hat docs state -G EC -g prime256v1 - This does NOT work unless you
#       have an ECDSA profile. caIPAserviceCert does NOT allow EC. I'm on a home
#       network, so I don't need EC (yet). When I do, I'll make a CA profile.
% ipa-getcert request -w -f /etc/pki/tls/certs/radius.crt \
  -k /etc/pki/tls/private/radius.key \
  -D $(hostname -f) \
  -K radius/$(hostname -f) \
  -m 0640 -o root:radiusd -O root:radiusd -M 0640 \
  -T caIPAserviceCert \
  -C '/bin/systemctl restart radiusd.service' \
  -N $(hostname -f) \
  -D $(hostname -f)

# Install the packages
% dnf install freeradius-ldap freeradius-utils

# Enable LDAP
% cd /etc/raddb/mods-enabled/
% ln -s ../mods-available/ldap

# Enable check-eap-tls
# This will be needed to do cert and client checking
% cd /etc/raddb/sites-enabled/
% ln -s ../sites-available/check-eap-tls

# Drop the certificates and hash them
% mkdir /etc/raddb/certs/freeipa
% cd /etc/raddb/certs/freeipa
# note: I put my CA's in the same location as the root.
# you can use ipa ca-show instead if you prefer.
% wget http://ipa01.clockwork.host/ipa/config/iot.crt
% wget http://ipa01.clockwork.host/ipa/config/wifi.crt

# bundle all the CA's together
% cat /etc/ipa/ca.crt wifi.crt iot.crt >> freeipa-bundle.crt
% c_rehash $(pwd)

This is where I needed to do some digging and just read the comments in the files thoroughly. Let’s configure the LDAP portion first and get it out of the way.

LDAP Portion

Since we need freeradius to talk to our IPA servers, we should configure it first. We need to create a system account that will take care of the searching needed across the directory.

% ipa sysaccount-add --password radius
# it will ask you to set a password
# note that the DN will be uid=name,cn=sysaccounts,cn=etc,$DOMAIN

The LDAP configuration for freeradius will then need that sysaccount, among other configuration changes. Edit /etc/raddb/mods-available/ldap:

ldap {
        # Add your server here
        server = 'ipa01.clockwork.host'
        identity = 'uid=radius,cn=sysaccounts,cn=etc,dc=clockwork,dc=host'
        password = 'example'
        # Set the base DN to the accounts part of your tree.
        base_dn = 'cn=accounts,dc=clockwork,dc=host'

        user {
                # We'll just use what we have already
                base_dn = "${..base_dn}"

                # This filter is complex, but it essentially checks both users
                # via the posixAccount objectclass and hosts via ipaHost.
                filter = ""

                # Ensure that the search will go where it needs to. Don't set
                # this to anything else.
                scope = 'sub'

                # These are the attributes needed to help determine if an
                # account is disabled.
                access_attribute = 'nsAccountLock'
                access_positive = no
                access_value_negate = 'false'

                # We don't use passwords.
                except_password = no
                dn_attribute = 'LDAP-UserDN'
        }
        group {
                base_dn = "${..base_dn}"

                # This filter is similar. We're looking for groups and host
                # groups.
                filter = ""
                scope = 'sub'
                name_attribute = cn
                membership_attribute = 'memberOf'
                
        }
        tls {
                start_tls = yes
                # Default location of IPA's root CA on any client
                ca_file = /etc/ipa/ca.crt
                require_cert = 'demand'
                # leave this at 1.2
                tls_min_version = '1.2'
        }
}

The Basics

We need to make changes to the default configuration first. Comment the following in /etc/raddb/sites-available/default:

  • chap
  • mschap
  • digest
  • files
  • -sql

There are also Auth-Type sections for these that should be commented out as well. Long and the short, they’re not needed.

EAP

Edit /etc/raddb/mods-available/eap and make the following changes as needed.

eap {
        default_eap_type = tls
        #md5 {
        #}
        tls-config tls-common {
                private_key_file = /etc/pki/tls/private/radius.key
                certificate_file = /etc/pki/tls/certs/radius.crt
                ca_file = /etc/raddb/certs/freeipa/freeipa-bundle.crt
                ca_path = /etc/raddb/certs/freeipa
                check_cert_cn = %{User-Name}
                reject_unknown_intermediate_ca = no
                # You can change the max TLS version to 1.3, but you may receive
                # warnings on your version of freeradius. I left mine at 1.2 for
                # now.
                tls_max_version = "1.2"
        }

        tls {
                # Required for certificate and identity checking
                virtual_server = check-eap-tls
        }
}

So now we need to configure check-eap-tls. This file is where we will have the logic needed to verify multiple things:

  • The certificate was issued by one of the two Sub-CA’s
  • The certificate common name and “identity” in FreeIPA match
  • The identity exists and is not locked out
  • The identity exists in the proper user or host group

The final file authorize block looked like this in /etc/raddb/sites-available/check-eap-tls.

authorize {
        # This is the default, comment it out.
        #update config {
        #       &Auth-Type := Accept
        #}

        # Checks that the issuer is either IoT or WiFi
        if (&TLS-Client-Cert-Issuer != "/O=CLOCKWORK.HOST/CN=IoT" && &TLS-Client-Cert-Issuer != "/O=CLOCKWORK.HOST/CN=WiFi") {
                update reply {
                        Reply-Message := "Client certificate was not issued by an approved CA"
                }
                reject
        }

        # Checks that our CN and user name match
        if (&TLS-Client-Cert-Common-Name != &User-Name) {
                update reply {
                        Reply-Message := "Identity does not match client cert CN"
                }
                reject
        }

        # Required for LDAP checks below
        ldap

        # Rejects if the user/host is not found...
        if (notfound) {
                reject
        }

        # ... locked
        if (userlock) {
                reject
        }

        # ... or server(s) cannot be searched for some reason
        if (fail) {
                reject
        }

        # Check if the user is in wifi_users
        if ("%{control:LDAP-UserDN}" =~ /^uid=/) {
                if (!(LDAP-Group == "cn=wifi_users,cn=groups,cn=accounts,dc=clockwork,dc=host")) {
                        update reply {
                                Reply-Message := "User is not a member of wifi users"
                        }
                        reject
                }
        }
        # Check if the host is in wifi_hosts
        elsif ("%{control:LDAP-UserDN}" =~ /^fqdn=/) {
                if (!(LDAP-Group == "cn=wifi_hosts,cn=hostgroups,cn=accounts,dc=clockwork,dc=host")) {
                        update reply {
                                Reply-Message := "Host is not a member of wifi hosts"
                        }
                        reject
                }
        }
        # otherwise, reject flat out
        else {
                update reply {
                        Reply-Message := "Identity cannot be verified."
                }
                reject
        }

        # If all goes well, accept the request
        update control {
                Auth-Type := Accept
        }

        # default setting. keep it on.
        auth_log
}

Verify the configuration

Always verify the configuration.

% radiusd -XC
. . .
Configuration appears to be OK

If you get the green light, you can start it up.

systemctl enable radiusd.service --now

As a side note, you can always run it in debug: radiusd -X

Generate certificate for a spare android phone

I have a spare phone I test things on, so this was a good opportunity to try this out. Since phones are generally not IPA clients, I had to create a dummy host object (with no IP) and use that.

# --force bypasses IP/DNS requirement
% ipa host-add android02.phone.clockwork.host --force

There’s two choices I had in this scenario:

  • Generate the CSR’s via openssl and have them signed via ipa cert-request
  • Assign a host that manages android02 and use ipa-getcert normally

Both should, in theory, give you the same result. What I did, for devices that cannot be IPA clients is generate the CSR’s and send them in to be signed. For ipa-getcert to work, you can use ipa caacl-add-ca and add your Sub-CA’s to the default hosts_services_caIPAserviceCert ACL. See Creating Sub-CA’s.

Since this device is a dummy and it’s purpose is to be a panel for Home Assistant, I decided to sign its certificate with the IoT CA.

Below is a quick script for this purpose.

#!/bin/bash
set -euo pipefail
declare -a FQDN_SPLIT
DEVICE_FQDN="${1}"
FQDN_SPLIT=(${DEVICE_FQDN//./ })
SHORT="${FQDN_SPLIT[0]-}"
REALM="${2}"
CA="${3}"

openssl req -new -newkey rsa:3072 -nodes \
  -keyout ${SHORT}.key \
  -out ${SHORT}.csr \
  -subj "/CN=${DEVICE_FQDN}"

ipa cert-request ${SHORT}.csr \
  --principal="host/${DEVICE_FQDN}@${REALM}" \
  --ca="${CA}" \
  --certificate-out=${SHORT}.pem

Now use the script to generate them.

% bash gen_wifi_cert.sh android02.phone.clockwork.host CLOCKWORK.HOST ipa_iot

Running this script generated the CSR I needed, sent the request, and gave me a signed certificate. After generating the certificates, I needed to actually test them against my radius configuration.

# In another pane, I ran radiusd -XC to test this
% dnf install hostapd wpa_supplicant
% cat wifi-user.conf
ap_scan=0

network={
    key_mgmt=IEEE8021X
    eap=TLS
    identity="android02.phone.clockwork.host"
    ca_cert="/etc/ipa/ca.crt"
    client_cert="/etc/pki/tls/hosts/android02.pem"
    private_key="/etc/pki/tls/hosts/android02.key"
    eapol_flags=0
}

% eapol_test -c wifi-user.conf -a 127.0.0.1 -p 1812 -s testing123
. . . (bunch of data here)
RADIUS packet matching with station
MS-MPPE-Send-Key (sign) - hexdump(len=32): 92 22 52 59 cf 83 09 2d 56 d3 29 85 e4 2d 9e 5d 14 34 bd e2 12 e5 0d eb 3b bb 0e 15 05 da 56 50
MS-MPPE-Recv-Key (crypt) - hexdump(len=32): a3 a7 af a5 5a af da 6a 31 ec a6 09 b4 00 d1 9a 87 e1 8e 90 a2 a1 49 73 69 72 a4 f8 0a df 04 0c
decapsulated EAP packet (code=3 id=70 len=4) from RADIUS server: EAP Success
EAPOL: Received EAP-Packet frame
EAPOL: SUPP_BE entering state REQUEST
EAPOL: getSuppRsp
EAP: EAP entering state RECEIVED
EAP: Received EAP-Success
EAP: Status notification: completion (param=success)
EAP: EAP entering state SUCCESS
CTRL-EVENT-EAP-SUCCESS EAP authentication completed successfully
EAPOL: IEEE 802.1X for plaintext connection; no EAPOL-Key frames required
WPA: EAPOL processing complete
Cancelling authentication timeout
State: DISCONNECTED -> COMPLETED
EAPOL: SUPP_PAE entering state AUTHENTICATED
EAPOL: SUPP_BE entering state RECEIVE
EAPOL: SUPP_BE entering state SUCCESS
EAPOL: SUPP_BE entering state IDLE
eapol_sm_cb: result=1
EAPOL: Successfully fetched key (len=32)
PMK from EAPOL - hexdump(len=32): a3 a7 af a5 5a af da 6a 31 ec a6 09 b4 00 d1 9a 87 e1 8e 90 a2 a1 49 73 69 72 a4 f8 0a df 04 0c
No EAP-Key-Name received from server
WPA: Clear old PMK and PTK
EAP: deinitialize previously used EAP method (13, TLS) at EAP deinit
ENGINE: engine deinit
MPPE keys OK: 1  mismatch: 0
SUCCESS

It should end with a SUCCESS at the bottom.

Generating certificates for other platforms

For macOS, I did the same thing as my spare phone. Even though my macbook is technically a client of IPA, it does not (and cannot) use the IPA utilities or even certmonger. For native Linux clients, this would be a different story. I don’t have any mobile Linux systems to test this properly, but in my mind, these scenarios seem logical:

  • Client is already enrolled, use openssl and friends
  • Client is already enrolled, use ipa-getcert including post commands if needed to set up the certificates
  • Brand new client, connect via ethernet or an applicable “guest” wifi network, enroll, use ipa-getcert, connect to new network.

There might be an easier way to handle this, but I didn’t do any discovery around this.

AP Configuration

I configured my mikrotik AP to have SSID’s specifically for this. I’m not covering this fully here, but essentially your AP needs to be configured to talk to your FreeRADIUS server. Below are example commands I ran on my mikrotik AP to achieve what I needed.

# Add a radius service
/radius
add service=wireless \
  address=10.100.0.1 \
  secret="RANDOM_STRING_HERE" \
  authentication-port=1812 \
  accounting-port=1813 \
  timeout=3s \
  src-address=10.100.0.254

# Create an EAP security profile
/interface wifi security
add name=sec-eap \
  authentication-types=wpa2-eap,wpa3-eap \
  encryption=ccmp \
  group-encryption=ccmp \
  management-protection=allowed \
  eap-accounting=yes

# Create a configuration for it
/interface wifi configuration
add name=eap-cfg \
  mode=ap \
  country="United States" \
  security=sec-eap \
  datapath.bridge=bridge

# Apply configuration to already existing wifi ssid...
# ... or create a new one and apply configuration

Either way, long and the short, you need to setup clients in the radius configuration. As an example, this is what I have in /etc/raddb/clients.conf. My mikrotik AP has v4 and v6 addresses, so I have both just in case.

client access-point-main-v4 {
        ipaddr = 10.100.0.254
        secret = RANDOM_STRING_HERE
}

client access-point-main-v6 {
        ipv6addr = fd8d:5b07:5803:9caf::254
        secret = RANDOM_STRING_HERE
}

The Actual Test

At least for android, I needed to take the certificate and key and bundle them into a p12.

openssl pkcs12 -export \
  -out android02.p12 \
  -name android02.phone.clockwork.host \
  -inkey android02.key \
  -in android02.pem \
  -certfile /etc/ipa/iot.crt

# Set an export password, otherwise your device may not let you import it
# Yes, do this even if you're just testing.
  1. Download both the p12 and your CA bundle to your device.
  2. Go to settings -> Settings & Privacy -> More security & privacy
  3. Go to Encryption & Credentials
  4. Install a certificate -> CA certificate and choose your CA’s
  5. Go back and tap Trusted credentials -> User. Your certificates should appear.
  6. Install a certificate -> Wi-Fi certificate -> Choose your CA’s again
  7. Install a certificate -> Wi-Fi certificate -> Choose your p12

I provided step 4 because I want to make sure that my system trusts my LAN hosts, not just for wifi.

On the android device, find the network and select it. These should be the settings:

  • EAP method: TLS
  • CA certificate: Choose your root CA
  • Minimum TLS version: 1.2
  • Online Certificate Status: Request
  • Domain: Your IPA domain (e.g. clockwork.host for mine) or radius hostname
  • User certificate: Choose your user certificate
  • Identity: Put the full identity here (username or FQDN)

Tap connect. And that’s it.

Issues and Discoveries

I discovered multiple things:

  • An exported p12 file needs an export password, even for testing. Don’t leave it blank.
  • Including the Sub-CA intermediate in the p12 isn’t required, but it’s useful to do.
  • On my android, CA certificate user trusts are not enough, they have to be trusted for wifi
  • Putting in an “identity” is a hard requirement, otherwise your AP will NOT send the request
  • The identity has to match the CN of the certificate, so think a username or FQDN of a device

My takeaway on the above is that if you have issues, put radiusd into debug mode to see where the issue may be. It’s verbose enough to figure out where the problem is.

Final Thoughts

This actually wasn’t too bad. What was the more time consuming part was the all the reading. However, any sysadmin knows that reading is part of what is needed to make this happen. Even if you stumble upon pages like my own here, reading is required.

The other thing is that the freeradius on 9 is old. This means that some configuration items in 10 (and later) will very likely differ from what I did here. Since this may end up being the case, I’ll try to document this at my usual wiki.