SSH Key Based API Authentication

Last year I, once again, attended the excellent Configuration Management Camp in Ghent. It’s a great little conference bringing together some great people from the system administration software scene.

During a chat with @purpleidea of mgmt fame about secrets management in mgmt he pointed out that any worthwhile API should offer some form of public/private key cryptography based authentication. I contemplated how to best adopt this idea in serveradmin, InnoGames configuration management database. At the time serveradmins API only allowed authentication via pre shared key API tokens. The obvious first choice for a HTTP API would be the use of x509 client certificates. Unfortunately anybody that ever had to use them will be quick to point out that they are inconvenient to generate, manage and use.

Fortunately InnoGames already has an alternative authentication scheme in place. All the admins and many developers already have SSH key pairs and they even have their public keys in LDAP already💡

Serveradmin and its client library are written in python, so I started digging into Paramiko, a pure python SSH library. While the Paramiko API around signing and verifying of ssh messages has some gotchas, its generally straight forward enough. What follows are some pointers how to abuse Paramikos crypto for arbitrary blobs. The source code in this blog post is MIT licensed and partially copied from the serveradmin project.

from paramiko import RSAKey
# Load private RSA key from file. This will raise
# paramiko.ssh_exception.PasswordRequiredException
# if a password is required.
key = RSAKey.from_private_key_file(private_key_path)
# Sign some blob
msg = key.sign_ssh_data(b"somestuff")
msg.rewind()

Unfortunately we have to know what key type to expect. To work around this we can try to load the key with all of Paramikos key types:

from paramiko.ssh_exception import (
    SSHException,
    PasswordRequiredException,
)
try:
    from paramiko import RSAKey, ECDSAKey, Ed25519Key
    key_classes = (RSAKey, ECDSAKey, Ed25519Key)
except ImportError:
    # Ed25519Key requires paramiko >= 2.2
    from paramiko import RSAKey, ECDSAKey
    key_classes = (RSAKey, ECDSAKey)
 
def load_private_key_file(private_key_path):
    # I don't think there is a key type independent
    # way of doing this
    for key_class in key_classes:
        try:
            return key_class.from_private_key_file(
                private_key_path
            )
        except PasswordRequiredException as e:
            raise AuthenticationError(e)
        except SSHException:
            continue
 
    raise AuthenticationError(
        'Loading private key failed'
    )
 
key = load_private_key_file(private_key_path)
msg = key.sign_ssh_data(b"somestuff")
msg.rewind()

From the loaded private key we can derive the public key. Unfortunately the API is not super obvious here. For loading the public key, we again need to know the key type. Using this public key object we can now verify the signature in the message object:

# This derives the public key.. Obviously.
pub = RSAKey(data=key.asbytes())
# Returns a boolean if the signature matched
pub.verify_ssh_sig(b"somestuff", msg)

The asbytes method on private keys returns the public key blob. That is the key you get when you load the base64 encoded bit of your public key files. This example is a bit of a stretch though, as we won’t have the private key on the server. So we have to do this:

def load_public_key_file(public_key_path):
    with open(public_key_path, 'r') as fd:
        public_key = fd.read()

    key_algorithm, key_base64, *_ = public_key.split(' ', 2)
    public_key_blob = b64decode(key_base64)
    if key_algorithm.startswith('ssh-ed25519'):
        try:
            return Ed25519Key(data=public_key_blob)
        except NameError:
            raise ValidationError('Paramiko too old to load ed25519 keys')
    elif key_algorithm.startswith('ecdsa-'):
        return ECDSAKey(data=public_key_blob)
    elif key_algorithm.startswith('ssh-rsa'):
        return RSAKey(data=public_key_blob)

    raise SSHException('Key is not RSA, ECDSA or Ed25519')


public_key = load_public_key_file(public_key_path)
if not public_key.verify_ssh_sig(
    data=expected_message.encode(),
    msg=msg,
):
    raise PermissionDenied('Invalid signature')

This is nice already, but we can do one better on the client side. Part of the openssh project is the ssh-agent. Using the agent over a key file has some benefits like:

  • With public keys in LDAP and private keys in the agent we require no additional setup on a developers or sysadmins computer.
  • Keys in the ssh-agent can be password protected on disk and decrypted only inside the agent. Adminapi never even sees the private part of the key.
  • Serveradmin only knows the public part of the key, nothing secret is saved there.

Obligatory warning: Do not forward your ssh-agent to other servers, especially not servers which other people have access to. Privileged users will be able to sign stuff using your agent for as long as you are connected.

Signing stuff using your own agent works like this:

# Load private RSA keys from agent
from base64 import b64encode
from paramiko.ssh_exception import SSHException
from paramiko.agent import Agent
from paramiko.message import Message

try:
    agent = Agent()
    for key in agent.get_keys():
        # This doesn't return a Message object but
        # bytes. That's dumb as it's not in line
        # with the other key types sign_ssh_data
        # methods. That also means we don't have to
        # call rewind on it, we can't actually.
        sig = private_key.sign_ssh_data(b"somestuff")
        # So we make sure its bytes here
        if isinstance(sig, Message):
            sig = sig.asbytes()
        # Now we could send it over the wire in base64
        print(b64encode(sig).decode())
except SSHException:
    raise AuthenticationError('No ssh agent found')

The return type of the sign_ssh_data method on agent keys is a bit of a gotcha. Note also that the can_sign method on agent keys always seems to return False, which is incorrect. Other then that it works like the keys loaded from a file.

One design note: In SSH you log in via a user and key combination. One key can allow access to multiple users. That is not how I implemented the API authentication in serveradmin. Instead I enforce that one key can only belong to one user. This way I am able to send the public key and a signed version of the request in the HTTP headers and authenticate the user with no setup required at all.