SSH Security: belt & braces|suspenders
🖊️ About 1900 words ⏱️ 11 minutes
Using SSH with Public Key Authentication is a must, but have you considered combining it with OTP (One-time Password) ? This could be your last line of defense 🛡️ in case your private key is stolen… This article explains how to set up an SSH server on FreeBSD with Public Key Authentication and OTP, enabling MFA (Multi-factor Authentication) ✅
Intro
There are some articles explaining on how to set up or to enforce SSH Servers with password-based authentication + OTP. However, I prefer not to allow password authentication at all. With Public Key Authentication, the need to do brute force remediation, (failtoban/pf) or lock/limit users (pam_abl) is minimal…as long as the authentication by the key is not successfully passed, that’s an end! and OTP comes as a second factor.
Let’s start with your shopping list, you need :
- An existing SSH Server, VM or container using an SSH Key pair (public & private) running on FreeBSD (this guide was created on 15.0).
- An 2FA Application on your phone (I am using the excellent FreeOTP+).
Disclaimer
I originally wrote this article using oath-toolkit (pam_oath), but I found it overcomplicated.
oath-toolkit is also using a library which is not maintained and with CVEs :
libxslt-1.1.43_1 is vulnerable:
libxslt -- unmaintained, with multiple unfixed vulnerabilities
CVE: CVE-2025-7425
CVE: CVE-2025-7424
WWW: https://vuxml.FreeBSD.org/freebsd/b0a3466f-5efc-11f0-ae84-99047d0a6bcc.html
Finally, after using it on version 14.3 and upgrading my system to version 15.0 the OTP stopped working for no obvious reason…
Listening to afriqs advices, I decided to use another solution:
$ doas pkg install pam_google_authenticator
We have now two components to tweak : SSH Server and PAM module.
OTP: Time-based or Hash-based
PAM Google Authenticator & FreeOTP+ are supporting both TOTP (Time-based OTP) and HOTP (Hash-based OTP).
HOTP is a counter-based OTP and the moving factor is a counter. When a HOTP is requested and validated, the moving factor in incremented and the code is valid until you ask for another one and that is has been validated by the server. Both are in sync, and the server knows the last code used to invalidate it to prevent replay attacks.
TOTP is a time-based OTP and the moving factor is the time instead of the counter. The code is valid a certain amount of time (here we are using a 30 seconds timer), if you haven’t used your code within that window, it will no longer be valid and you’ll need to request a new one.
Which one to use ?
It depends on your intented use and the balance of security and simplicity that you are looking for…
TOTP is a time-based solution, so any time-drift (server, client, NTP..) can be harmful; monitoring the time sync is a must-have.
HOTP is more simple and have less maintenance, but can be subject to potential brute-force attack.
Bear in mind that this second factor authentication is only proposed when the first is passed successfully. In our example, Public Key Authentication must be accepted for PAM to process the second factor.
It’s up to you, and both are fine, on my side I prefer to use TOTP, because I can always “temper” with window=X option, how many X consecutive keys will be accepted in case user and server clocks are out of sync.
Secret
A comon misconception is that QR Code is encrypted; it is not, it is only encoded, if someone steals your QR Code, it is the same as stealing your 2FA device…
This is why you must never screenshot, save, store or email your QR Code, keeping the secret secret!
How-to (pam_google_authenticator & FreeOTP+)
Just run this:
$ google-authenticator --qr-mode=ANSI_INVERSE
Do you want authentication tokens to be time-based (y/n) y
[QR Code shown here] Now grab your phone & scan the (your) QR Code.
Your new secret key is: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
Enter code from app (-1 to skip): YYYYYY
Code confirmed
Your emergency scratch codes are:
AAAAAAAA
BBBBBBBB
CCCCCCCC
DDDDDDDD
EEEEEEEE
Do you want me to update your "/home/$USER/.google_authenticator" file? (y/n) y
Do you want to disallow multiple uses of the same authentication
token? This restricts you to one login about every 30s, but it increases
your chances to notice or even prevent man-in-the-middle attacks (y/n) y
By default, a new token is generated every 30 seconds by the mobile app.
In order to compensate for possible time-skew between the client and the server,
we allow an extra token before and after the current time. This allows for a
time skew of up to 30 seconds between authentication server and client. If you
experience problems with poor time synchronization, you can increase the window
from its default size of 3 permitted codes (one previous code, the current
code, the next code) to 17 permitted codes (the 8 previous codes, the current
code, and the 8 next codes). This will permit for a time skew of up to 4 minutes
between client and server.
Do you want to do so? (y/n) n
If the computer that you are logging into isn't hardened against brute-force
login attempts, you can enable rate-limiting for the authentication module.
By default, this limits attackers to no more than 3 login attempts every 30s.
Do you want to enable rate-limiting? (y/n) y
💡 --qr-mode=ANSI_INVERSE is necesary on my side, without it FreeOTP+ doesn’t recognize the QR Code, it does with the parameter above which inverses the colors.
Result of the ~/.google_authenticator file:
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
" RATE_LIMIT 3 30
" DISALLOW_REUSE
" TOTP_AUTH
AAAAAAAA
BBBBBBBB
CCCCCCCC
DDDDDDDD
EEEEEEEE
2FA vs MFA
We can debate if this approach is a 2FA (Two-factor Authentication) or a MFA (Multi-Factor Authentication). The Private Key on your computer (something you have) combined with another factor (OTP on your phone) and something you “are”: if you enable Require Authentication on FreeOTP+ you’ll need you fingerprint to access to the OTP. Lastly if you decide to use TOTP like me, time ⏱️ is another additional factor!
PAM
We have to modify our /etc/pam.d/sshd :
By default the auth section is :
#auth sufficient pam_krb5.so no_warn try_first_pass
#auth sufficient pam_ssh.so no_warn try_first_pass
auth required pam_unix.so no_warn try_first_pass
The latest line must be commented out, as this would force a password to be set as an alternative step after the Public key Authentication. We don’t want that, instead we want to use OTP.
#auth sufficient pam_krb5.so no_warn try_first_pass
#auth sufficient pam_ssh.so no_warn try_first_pass
#auth required pam_unix.so no_warn try_first_pass
auth required /usr/local/lib/pam_google_authenticator.so
This will force the OTP validation after a successful Public Key Authentification.
SSH Server
Let’s tweak our /etc/ssh/sshd_config : we need to enable PAM, use Public Key Authentication (which should be the default setting) and we don’t want to accept passwords. Ultimately, we want multiple methods to be used : Public Key + OTP :
UsePAM yes
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication yes
AuthenticationMethods publickey,keyboard-interactive
Restart your SSH Server :
$ doas service sshd restart
Bonus : HostKey & SSHFP
Your host key is a cryptographic key pair used for authentication in the SSH protocol. The private key is stored on the server, while the public keys is distributed to SSH clients.
These keys (RSA, ECDSA and ED25519) are generated automatically when OpenSSH is first installed or started. If this is the case, there is no real any reason to regenerate them.
❗ However, if your server has been cloned from another image, VM or container, you should definitely regenerate them:
$ ssh-keygen -q -N "" -t ed25519 -f /etc/ssh/ssh_host_ed25519_key
I only use ED25519 on my servers. If you are in like me, you can keep the RSA and ECDSA commented and uncomment ED25519 in your /etc/ssh/sshd_config:
#HostKey /etc/ssh/ssh_host_rsa_key
#HostKey /etc/ssh/ssh_host_ecdsa_key
HostKey /etc/ssh/ssh_host_ed25519_key
and restart your SSH Server:
$ doas service sshd restart
The first time you connect to your SSH server, you will be been asked to confirm the host’s authenticity. If you enter yes, a copy of the fingerprint will be saved in your local ~/.ssh/known_hosts file. However, you will need to blind-trust (trust it implicitly) the first time, or compare manually the keys:
$ ssh myserver
The authenticity of host 'X.X.X.X (X.X.X.X)' can't be established.
ED25519 key fingerprint is SHA256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])?
The purpose of this check is to prevent man-in-the-middle attacks, whereby you would be connected to another server without realising it…
❗ If the fingerprint sent from the server differs from the one stored on your local computer, you will see something like this :
$ ssh myserver
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
Someone could be eavesdropping on you right now (man-in-the-middle attack)!
Rather than blindly trust the fingerprint, DNS offers the SSHFP (SSH Fingerprint Record) record type. This stores the fingerprint in a DNS SSHFP record, which your client then uses to check authenticity rather than using the ~/.ssh/known_hosts file.
However, you need to secure your DNS server (aka DNSSEC) to avoid any potential spoofing.
SSH has a useful ssh-keyscan utility that displays the entries that need to be created in your DNS zone.
$ ssh-keyscan -D -t ed25519 myserver
; myserver:22 SSH-2.0-OpenSSH_10.0
myserver IN SSHFP 4 1 e5db231d8a3f7a9d118f888136d0ea681c148d81
myserver IN SSHFP 4 2 8c7d5b7c8208040a5a876d690020ffe4a865df83755c6da710b218c17b754498
Add these entries to your DNS Zone. I only have ED25519 keys, so I have two entries (type 4 1 and 4 2): one for SHA-1 and one for SHA-256. Let’s check :
$ dig SSHFP myserver
;; ANSWER SECTION:
myserver. 3600 IN SSHFP 4 1 E5DB231D8A3F7A9D118F888136D0EA681C148D81
myserver. 3600 IN SSHFP 4 2 8C7D5B7C8208040A5A876D690020FFE4A865DF83755C6DA710B218C1 7B754498
Finally, just add VerifyHostKeyDNS option to your SSH command:
$ ssh -o "VerifyHostKeyDNS yes" myserver
When your tests are done, you can add this option permanently to your ~/.ssh/config file :
Host myserver
VerifyHostKeyDNS yes
End-to-End Test
Let’s connect to the server. I have extracted all the interesting parts of the debug that have been explained in this article:
$ ssh -o "VerifyHostKeyDNS yes" myserver
[...]
debug1: Connecting to myserver [X.X.X.X] port 22.
debug1: Connection established.
[...]
debug1: identity file /home/blt/.ssh/id_ed25519 type 3
[...]
debug1: Authenticating to myserver:22 as 'blt'
[...]
debug1: kex: host key algorithm: ssh-ed25519
[...]
debug1: Server host key: ssh-ed25519 SHA256:aaaaaaaaaaaaaaaaaaaaaaaaa
debug1: found 2 secure fingerprints in DNS
debug1: Fssh_verify_host_key_dns: matched SSHFP type 4 fptype 1
debug1: Fssh_verify_host_key_dns: matched SSHFP type 4 fptype 2
debug1: matching host key fingerprint found in DNS
[...]
debug1: Authentications that can continue: publickey
[...]
Authenticated using "publickey" with partial success.
debug1: Authentications that can continue: keyboard-interactive
debug1: Next authentication method: keyboard-interactive
(blt@myserver) Verification code:
FreeBSD 15.0-RELEASE (GENERIC) releng/15.0-n280995-7aedc8de6446
Welcome to FreeBSD!
blt@myserver:~ $
If you don’t have any complaints, everything is OK Congrats! If there is a fingerprint mismatch or your zone is not protected by DNSSEC, you will receive a warning ⚠️.
💡 Ideally, during the creation of the container, we should consider adding a script to automatically add the entries to the DNS zone as part of the build process…
Wrapping-up
In 99% of situations, Public Key Authentication associated with SSHFP is sufficient to protect you, even on an SSH server that is open to the Internet. If you encrypt your private key or store it in a secure location (like a dedicated hardware) you can reduce your attack surface even further. However, if someone gets hold of your private key or your PC’s memory is dumped…OTP could potentially be your lifeguard 💂 specifically where you want to keep something publicly accessible while maintaining very high security.