Stories From The Field / Tech

Simple Two-Factor SSH Authentication with Google Authenticator

Posted on September 23 2011 by Richard Taylor (@moomerman)

In a two-part post I'm going to show you some tricks you can do with SSH logins. This post covers setting up two-factor SSH authentication with the Google Authenticator app.

I was recently getting some servers in shape so I can pass the Payment Card Industry standards questionnaire and one requirement was two-factor authentication access to the server. I queried whether SSH key + passphrase was acceptable but didn't get a clear answer so I figured I'd explore setting up another authentication factor myself, plus it piqued my interest.

After a bit of research I found it was possible using a PAM module but it doesn't work along with SSH key authentication (only password authentication) and I only use SSH key logins for my servers.

The magic

I wanted to find the simplest method of implementing this so I started looking at what we can do with SSH itself. There is an option in the authorized_keys file that allows you to run a command when a user authorizes with a particular key eg.

command="/usr/bin/my_script" ssh-dsa AAA...zzz me@example.com

The command="..." part invokes a different command upon key authentication and runs the /usr/bin/my_script instead. Now we've got a starting point to work on the Google Authenticator logic.

UPDATE A number of comments on hacker news suggest the use of ForceCommand in sshd_config to apply this globally.

Simple implementation

I've chosen ruby to implement this simple example but in theory you could use anything you want. This is a naive implementation but it will prove the concept. You're going to need the rotp library as well for this to work gem install rotp.

We put the following in /usr/bin/two_factor_ssh

#!/usr/bin/env ruby
require 'rubygems'
require 'rotp'

# we'll pass in a secret to this script from the authorized_keys file
abort unless secret = ARGV[0]

# prompt the user for their validation code

STDERR.write "Enter the validation code: "
until validation_code = STDIN.gets.strip
  sleep 1
end

# check the validation code is correct

abort "Invalid" unless validation_code == ROTP::TOTP.new(secret).now.to_s

# user has validated so we'll give them their shell

Kernel.exec ENV['SSH_ORIGINAL_COMMAND'] || ENV['SHELL']

The secret is in Kernel.exec which, upon successful validation, replaces the two_factor_ssh script process with the original command the user was attempting or their default shell so it is a completely seamless experience from that point on.

Generating the secret

We need to generate a secret token that is shared between the Google Authenticator app and the server.

Here's a little script that will spit out a new token and a link to a QR code that can be scanned into the Google Authenticator application.

#!/usr/bin/env ruby
require 'rubygems'
require 'rotp'

secret = ROTP::Base32.random_base32
data = "otpauth://totp/#{`hostname -s`.strip}?secret=#{secret}"
url = "https://chart.googleapis.com/chart?chs=200x200&chld=M|0&cht=qr&chl=#{data}"

puts "Your secret key is: #{secret}"
puts url

Running this produces:

Your secret key is: 4rr7kc47sc5a2fgt
https://chart.googleapis.com/chart?chs=200x200&chld=M|0&cht=qr&chl=otpauth://totp/myserver?secret=4rr7kc47sc5a2fgt

We can scan the QR code directly into Google Authenticator and then update our authorized_keys file as follows:

command="/usr/bin/two_factor_ssh 4rr7kc47sc5a2fgt" ssh-dsa AAA...zzz me@example.com

That should do it!

UPDATE @js4all has identified a security problem with a certain version of OpenSSH which prints out the command (including parameters!) in the debug when connecting. To work around this problem instead of using the secret above, pass through an identifier and then put the security key inside the script and perform a lookup there.

Testing it out


[richard@mbp ~]$ ssh moocode@myserver
Enter the validation code: wrong
Invalid
Connection to myserver closed.
[richard@mbp ~]$
[richard@mbp ~]$ ssh moocode@myserver
Enter the validation code: 410353
moocode@myserver:~$

Great, that seems to work as expected.

Wrapping up

I've got a slightly more involved example that adds in support for 'remember me' by IP address for a fixed period of time so you don't have to reach for the phone on every single login from the same IP.

The extended example also does some primitive logging but I'd like to add in a better auditing system (another PCI compliance requirement) as this would allow us to know which key is used to log into the server and whether they validated.

We should also probably have a fallback mechanism (a master key or 5 one-time codes like Google does) so we don't inadvertently lock ourselves out of the server.

If you have any tips or ideas please leave them in the comments below and if you liked this post follow us on twitter so you'll get notified about next weeks follow-up post.

Hacker News Icon Comments on Hacker News

Comments