Comments on Active Directory IdP and YubiKey OTP integration that supports MS-CHAP v2

David Herselman dhe at syrex.co
Mon Feb 22 22:45:01 CET 2021


Hi,

I was hoping the following may provide a more complete start to finish integration guide for others that may have similar requirements. I would very much like to invite dialogue regarding any recommendations anyone may have.

Our requirements were:

  *   RADIUS servers domain joined to handle PAP and MS-CHAP v2 authentication using information only from Active Directory
  *   We do not want to store credentials in AD with reversible encryption
  *   AD Group membership validation based on source
  *   Yubico OTP Multi-Factor Authentication (MFA)


System are domain joined as member servers using Samba. Samba winbind subsequently presents users and groups as native system accounts, which allows references to just the group name and unlike LDAP does not make the link sensitive to the placement of the security group within the AD folders or organisational units (distinguished name or DN). Password Authentication Protocol (PAP) transmits unencrypted passwords (i.e. plain-text) over the network so connectivity should be encrypted using either IPSec or TLS (RadSec or EAP-TTLS). MS-CHAP v2 provides mutual authentication and uses a hash of the password. This means that the request only includes a hash representing the password, so there is nothing to cut the password and OTP out of. One can however transmit the OTP as the username and then lookup the associated owner's AD account name for FreeRADIUS to retrieve the hash used during authentication via Samba winbind.

Yubico devices generate a unique 32 character modhex passcode each time you press the button on a YubiKey 5 or scan it using NFC. This passcode comes after a 12 character public id, which is unique to each YubiKey. We therefore have two method of transmitting the OTP during login. The first is the classic RSA token method (although reversed) where we use our username, type our password and then press the YubiKey button, herewith an example:
  Username: davidh
  Password: secretcccccct00001krbhnvrjrdlujuujdcjvltdcrdkhhtit

This method requires PAP, as we require the client to transmit the entered credentials, so that we can cut the information in to a password and Yubico OTP.

The alternative is to make use of the YubiKey's public ID to lookup the AD username. This method works for PAP and allows MS-CHAP v2 authentication, herewith an example:
  Username: cccccct00001krbhnvrjrdlujuujdcjvltdcrdkhhtit
  Password: secret

In the second case we type out password out and then press the YubiKey when prompted for the username. Devices we were authenticating to are generally switches and routers where we can transmit attributes as part of the access accept message to grant eg access permissions. Some platforms however (eg Check Point Management and VyOS) require user accounts to be defined, in those cases you are stuck with PAP being the only possible integration.

PS: All is not lost, if you implement a jump host that you enforce MFA access to, which in turn simply uses single factor.


Additional detail on the Yubico OTP is available directly from Yubico:
  https://developers.yubico.com/OTP/OTPs_Explained.html



The following notes pertain to a Debian buster (10) install, they would require path changes but should in essence work on any Linux distribution.


Join system to Active Directory:
    apt-get install libnss-winbind samba winbind;
    pico /etc/krb5.conf
      [libdefaults]
        default_realm = AD.ACME.COM
        dns_lookup_realm = false
        dns_lookup_kdc = true
  pico /etc/samba/smb.conf
[---------------------------------------- /etc/samba/smb.conf ----------------------------------------]
[global]
        server role = member server

        workgroup = ACME
        realm = ad.acme.com
        netbios name = FreeRADIUS1
        server string = FreeRADIUS and Yubico OTP

        bind interfaces only = yes
        interfaces = 127.0.0.1/8 192.168.1.48/24

        ntlm auth = mschapv2-and-ntlmv2-only
        guest account = nobody
        idmap cache time = 300
        kerberos method = system keytab

        idmap config * : backend = tdb
        idmap config * : range = 100001-120000
        idmap config Domain-01 : backend = rid
        idmap config Domain-01 : range = 1000-100000

       template homedir = /home/users/%U
       template shell = /sbin/nologin
        log level = 2
        log file = /var/log/samba/%m.log
        enable core files = no
        max log size = 50
        dont descend = /dev, /mirror, /proc

        time server = no
        wins support = no
        map acl inherit = yes
        store dos attributes = yes

        printing = cups
        cups options = raw

        winbind use default domain = yes
        winbind enum users = yes
        winbind enum groups = yes
        winbind expand groups = 2

[nobody]
        path = /dev/null
        comment = Access denied - Guest
        guest ok = no
        printable = no
        browseable = no
[---------------------------------------- /etc/samba/smb.conf ----------------------------------------]
    net ads join -U adm-domain-davidh;
    systemctl restart smbd;
    pico /etc/nsswitch.conf;
      # Append 'winbind' to 'passwd' and 'group'
  Check winbind and ntlm_auth authentication are working:
    wbinfo -t
    ntlm_auth --allow-mschapv2 --request-nt-key --domain=ACME --username=davidh
  pico /etc/group /etc/gshadow
    # Add 'freerad' to 'winbindd_priv:'
  Finally check that you are able to retrieve users and groups as if they were local users:
    getent passwd davidh
    getent group client_cpe_admin


Install FreeRADIUS with the YubiKey module:
  apt-get install freeradius-yubikey

  If necessary set shortname on defined clients to apply different AD group membership or altering what reply attributes are sent back
  /etc/freeradius/3.0/clients.conf
    # Hash out existing active lines, then append:
    client fw1.acme.com {
        ipaddr                          = 192.168.10.1
        secret                          = ****************
        require_message_authenticator   = yes
        nastype                         = other
        shortname                       = checkpoint
    }
    client switch6.acme.com {
        ipaddr                          = 192.168.20.7
        secret                          = ****************
        require_message_authenticator   = yes
        nastype                         = cisco
        shortname                       = client_cpe
    }

  Define custom attributes:
  /etc/freeradius/3.0/dictionary.custom
    ATTRIBUTE           sAMAccountName                  3000    string
    ATTRIBUTE           Original-User-Name              3001    string

  Sample Livingston style users file, in this case for use with MikroTik RouterOS, make as many as you need with whatever reply attributes you require:
  /etc/freeradius/3.0/users
    DEFAULT     FreeRADIUS-Client-Shortname == "client_cpe", Group == "client_cpe_view"
                Mikrotik-Group = "view"

    DEFAULT     FreeRADIUS-Client-Shortname == "client_cpe", Group == "client_cpe_admin"
                Mikrotik-Group = "full"

    DEFAULT     FreeRADIUS-Client-Shortname == "client_cpe", Auth-Type := Reject
                Reply-Message = "Access Denied - Not a member of any client_cpe security groups"

    DEFAULT     Auth-Type := Reject
                Reply-Message = "Access Denied"

  Edit MS-CHAP v2 module to use winbind and alter the username:
  /etc/freeradius/3.0/mods-available/mschap
    winbind_username = "%{%{sAMAccountName}:-%{mschap:User-Name}}"
    winbind_domain = "%{mschap:NT-Domain}"

  Add 'allow-mschapv2' and again change the username expansion variable:
  /etc/freeradius/3.0/mods-available/ntlm_auth
    program = "/usr/bin/ntlm_auth --allow-mschapv2 --request-nt-key --domain=DOMAIN-01 --username=%{%{sAMAccountName}:-%{mschap:User-Name}} --password=%{User-Password}"

  Register for a Yubico API integration account and then enable and configure the module:
   cd /etc/freeradius/3.0/mods-enabled;
    ln -s ../mods-available/yubikey yubikey;
    /etc/freeradius/3.0/mods-enabled/yubikey
      validate = yes
      client_id = *****
      api_key = '****************************'

  The following changes are all in
  /etc/freeradius/3.0/sites-available/default
  Hash out 'pap', we'll force NTLMv2 by obtaining a hash of the password hash from AD with each authentication request and perform necessary operations on the plain text password we receive as part of the request

    After 'preprocess' in 'authorize {'
      if (&User-Name =~ /^([cbdefghijklnrtuv]{44})$/) {
        update request {Yubikey-OTP = "%{1}"}
      } elsif (&User-Password =~ /^(.+?)([cbdefghijklnrtuv]{44})$/) {
        update request {User-Password := "%{1}", Yubikey-OTP = "%{2}"}
      }
      if (&Yubikey-OTP =~ /^([cbdefghijklnrtuv]{12})/) {
        switch "%{1}" {
                case cccccct00001 {update request {sAMAccountName = "davidh"}}
                case cccccct00002 {update request {sAMAccountName = "philipo"}}
        }
        if (!&sAMAccountName || (&User-Name != &Yubikey-OTP && &User-Name != &sAMAccountName)) {
                update reply {Reply-Message := "Unregistered or user mismatch"}
                reject
        }
      }

    Before and after 'files' in 'authorize {'
      update request {FreeRADIUS-Client-Shortname = "%{Client-Shortname}"}
      if (&sAMAccountName && &User-Name != &sAMAccountName) {
        update request {
                Original-User-Name := "%{User-Name}",
                User-Name := "%{sAMAccountName}"}}
      files
      if (&Original-User-Name) {update request {User-Name := "%{Original-User-Name}"}}

    End of 'authorize {'
      if (&control:Auth-Type == "mschap") {
        if (&Yubikey-OTP) {
                update control {Auth-Type := "YubiCHAP"}
        } else {update control {Auth-Type := "MS-CHAP"}}
      if (!control:Auth-Type && User-Password) {
        if (&Yubikey-OTP) {
                update control {Auth-Type := "YubiNTLM"}
        } else {update control {Auth-Type := "ntlm_auth"}}
      }

    Exclusively have the following in  'authenticate {'
      Auth-Type MS-CHAP {
        mschap
      }
      Auth-Type YubiCHAP {
        mschap
        yubikey
      }
      Auth-Type ntlm_auth {
        ntlm_auth
      }
      Auth-Type YubiNTLM {
        ntlm_auth
        yubikey
      }

    Beginning of 'post-auth {'
      if (&sAMAccountName && &User-Name != &sAMAccountName) {update request {User-Name := "%{sAMAccountName}"}}

    Beginning of 'Post-Auth-Type REJECT {'
     if (&sAMAccountName && &User-Name == &Yubikey-OTP) {update request {User-Name := "%{sAMAccountName}"}}


Herewith all active lines, as a comparative to where you've ended up:
[---------------------------------------- grep -v '^\s*#\|^\s*$' /etc/freeradius/3.0/sites-enabled/default ----------------------------------------]
server default {
listen {
        type = auth
        ipaddr = *
        port = 0
        limit {
              max_connections = 16
              lifetime = 0
              idle_timeout = 30
        }
}
listen {
        ipaddr = *
        port = 0
        type = acct
        limit {
        }
}
listen {
        type = auth
        ipv6addr = ::   # any.  ::1 == localhost
        port = 0
        limit {
              max_connections = 16
              lifetime = 0
              idle_timeout = 30
        }
}
listen {
        ipv6addr = ::
        port = 0
        type = acct
        limit {
        }
}
authorize {
        filter_username
        preprocess
        if (&User-Name =~ /^([cbdefghijklnrtuv]{44})$/) {
                update request {Yubikey-OTP = "%{1}"}
        } elsif (&User-Password =~ /^(.+?)([cbdefghijklnrtuv]{44})$/) {
                update request {User-Password := "%{1}", Yubikey-OTP = "%{2}"}
        }
        if (&Yubikey-OTP =~ /^([cbdefghijklnrtuv]{12})/) {
                switch "%{1}" {
                        case cccccct0001 {update request {sAMAccountName = "davidh"}}
                        case cccccct0002 {update request {sAMAccountName = "philipo"}}
                }
                if (!&sAMAccountName || (&User-Name != &Yubikey-OTP && &User-Name != &sAMAccountName)) {
                        update reply {Reply-Message := "Unregistered or user missmatch"}
                        reject
                }
        }
        chap
        mschap
        digest
        suffix
        eap {
                ok = return
        }
        update request {FreeRADIUS-Client-Shortname = "%{Client-Shortname}"}
        if (&sAMAccountName && &User-Name != &sAMAccountName) {
                update request {
                        Original-User-Name := "%{User-Name}",
                        User-Name := "%{sAMAccountName}"}}
        files
        if (&Original-User-Name) {update request {User-Name := "%{Original-User-Name}"}}
        -sql
        -ldap
        expiration
        logintime
        if (&control:Auth-Type == "mschap") {
                if (&Yubikey-OTP) {
                        update control {Auth-Type := "YubiCHAP"}
                } else {update control {Auth-Type := "MS-CHAP"}}
        }
        if (!control:Auth-Type && User-Password) {
                if (&Yubikey-OTP) {
                        update control {Auth-Type := "YubiNTLM"}
                } else {update control {Auth-Type := "ntlm_auth"}}
        }
}
authenticate {
        Auth-Type MS-CHAP {
                mschap
        }
        Auth-Type YubiCHAP {
                mschap
                yubikey
        }
        Auth-Type ntlm_auth {
                ntlm_auth
        }
        Auth-Type YubiNTLM {
                ntlm_auth
                yubikey
        }
}
preacct {
        preprocess
        acct_unique
        suffix
        files
}
accounting {
        detail
        unix
        -sql
        exec
        attr_filter.accounting_response
}
session {
}
post-auth {
        if (&sAMAccountName && &User-Name != &sAMAccountName) {update request {User-Name := "%{sAMAccountName}"}}
        update {
                &reply: += &session-state:
        }
        -sql
        exec
        remove_reply_message_if_eap
        Post-Auth-Type REJECT {
                if (&sAMAccountName && &User-Name == &Yubikey-OTP) {update request {User-Name := "%{sAMAccountName}"}}
                -sql
                attr_filter.access_reject
                eap
                remove_reply_message_if_eap
        }
        Post-Auth-Type Challenge {
        }
}
pre-proxy {
}
post-proxy {
        eap
}
}
[---------------------------------------- grep -v '^\s*#\|^\s*$' /etc/freeradius/3.0/sites-enabled/default ----------------------------------------]

NB: Once everyone has a YubiKey hash out the single factor authenticate options (Auth-Type MS-CHAP and Auth-Type ntlm_auth) methods or update unlang to exempt 2FA from jump hosts or NMS.
    Unvalidated idea for users file:
        DEFAULT Yubikey-OTP !* "", Packet-Src-IP-Address !~ /192\.168\.14\./, Auth-Type := Reject
        Reply-Message = "Access Denied - 2FA required"


Regards
David Herselman


More information about the Freeradius-Users mailing list