Leveraging HashiCorp Vault's SSH Secrets Engine

May 25, 2018 12:00 hvault hashicorp ssh

HashiCorp Vault, or commonly "Vault" ("HVault" is also common parlance), is a tool for securing, storing and controlling access to tokens, passwords, certificates, API keys and other secrets. Certificates are of particular interest in this context as Vault provides a fairly seamless workflow for leveraging them as an SSH authentication method. However, the client-side workflow can be slightly difficult to understand in the beginning. There are also a few operational considerations to keep in mind when working on the server-side of Vault. Below, I’ll walk through a quick setup of Vault, share some deployment considerations and walk through the client workflow for SSH certificates.

Installation

Installing Vault is rather painless as it’s distributed by Hashicorp as a single binary compiled from Go. The latest binary is usually only a curl (or wget, if you’re into that sort of thing) away. 64-bit Linux is assumed:

$ export VAULT_VERSION=0.10.1 # latest at the time of writing
$ curl -LO https://releases.hashicorp.com/vault/${VAULT_VERSION}/vault_${VAULT_VERSION}_linux_amd64.zip

It’s not a terrible idea to verify the checksum as well. In practice, if you don’t trust the TLS certificate for the site you’re grabbing the object from, then you shouldn’t trust the checksum file you downloaded from it either. Verifying the checksum will let us know if the file was somehow corrupted during the download process, though.

$ curl -LO https://releases.hashicorp.com/vault/${VAULT_VERSION}/vault_${VAULT_VERSION}_SHA256SUMS
$ grep "vault_${VAULT_VERSION}_linux_amd64.zip" vault_${VAULT_VERSION}_SHA256SUMS | sha256sum -c -
vault_0.10.1_linux_amd64.zip: OK

Now that we’ve got the archive containing the Vault binary, we’ll unzip it and place the binary some place convenient:

$ unzip -q vault_${VAULT_VERSION}_linux_amd64.zip
$ sudo cp vault /usr/local/bin/

Configuration

There’s not a lot that we need to do with Vault’s configuration file. Many of the important configuration items are stored in the encrypted backend.

Let’s add a minimal (i.e. non-production) configuration:

$ mkdir hvault # where Vault will store all the encrypted bits
$ cat > config.hcl <<EOF
listener "tcp" {
    address     = "0.0.0.0:8200"
    tls_disable = true # don't do this in production - always use TLS in prod
}

storage "file" {
    path = "./hvault"
}

disable_mlock = true # don't do this in production either
# ^ setting this to true allows leaking of sensitive data to disk/swap
# we're doing it here to avoid running the process as root
# or modifying any system tunables
EOF

Start the Vault server:

$ vault server -config=config.hcl

Initialization

Once Vault is up and running, it must be initialized and unsealed. Since Vault is running in the foreground, we’ll need to jump over to another terminal window.

Before initalizing and unsealing, we need to make sure that the Vault client can find the running server. By default, the Vault client expects to speak TLS on tcp port 8200 on 127.0.0.1. Vault is listening on 8200/tcp on all addresses, however we’ve disabled TLS since this setup is only for testing purposes. All we need to do is set the proper URL in the VAULT_ADDR environment variable.

$ export VAULT_ADDR="http://127.0.0.1:8200"

Make sure this gets picked-up in new shells:

$ echo 'export VAULT_ADDR="http://127.0.0.1:8200"' >> ~/.bashrc

Now, we should be able to get the status of the server:

$ vault status
Error checking seal status: Error making API request. URL: GET http://127.0.0.1:8200/v1/sys/seal-status
Code: 400. Errors:

* server is not yet initialized

Next, we initialize Vault:

$ vault operator init -key-shares=1 -key-threshold=1
Unseal Key 1: ySEWQMzGk3l6p+u2xkpjxL+BLGIz8/vauk8NmgvmCx0=

Initial Root Token: 27dd03e7-8cda-0e5f-d53a-d64196945ab9

Vault initialized with 1 key shares and a key threshold of 1. Please securely
distribute the key shares printed above. When the Vault is re-sealed,
restarted, or stopped, you must supply at least 1 of these keys to unseal it
before it can start servicing requests.

Vault does not store the generated master key. Without at least 1 key to
reconstruct the master key, Vault will remain permanently sealed!

It is possible to generate new unseal keys, provided you have a quorum of
existing unseal keys shares. See "vault rekey" for more information.

NOTE: In production, you’ll want to increase the key shares (number of keys that may unseal Vault) and the key threshold (number of keys required to unseal Vault).

Be sure to store the unseal key(s) some place safe. These are the keys to the kingdom and you’ll need them later. You should also save the initial root token to $HOME/.vault-token.

Unseal

Vault is ready to be unsealed using the unseal key we just received:

$ vault operator unseal
Unseal Key (will be hidden):
Key             Value
---             -----
Seal Type       shamir
Sealed          false
Total Shares    1
Threshold       1
Version         0.10.1
Cluster Name    vault-cluster-0241c38f
Cluster ID      5b9ab5c2-fd50-46d8-c016-3411c04570bc
HA Enabled      false

At this point, Vault should be ready for (non-prod!) use.

Auditing

Vault has some pretty robust auditing capabilities. Unfortunately, they’re not enabled by default. You’ll want to enable an audit device and potentially ship the logs to a log aggregator. Enabling auditing will allow you answer questions such as "When was the last time Bob requested a new certificate?".

Enabling the Syslog audit device is simple:

$ vault audit enable syslog
Success! Enabled the syslog audit device at: syslog/

Having detailed logs is crucial to success when operating Vault. Do yourself a favor and get an audit device enabled before interacting with any of the Secrets Engines.

SSH Engine

In order to make use of the SSH Secrets Engine, we must first enable it:

$ vault secrets enable -path=ssh-client ssh
Success! Enabled the ssh secrets engine at: ssh-client/

We then configure a Certificate Authority for signing the client certificates. We’ll redirect the public key output to a file which we’ll eventually need to distribute any hosts we wish to SSH to using client certificates:

$ vault write \
  -field=public_key \
  ssh-client/config/ca \
  generate_signing_key=true \
  | sudo tee /etc/ssh/trusted-user-ca-keys.pem

We then configure the SSH daemon to use our new CA certificate:

$ echo "TrustedUserCAKeys /etc/ssh/trusted-user-ca-keys.pem" | sudo tee -a /etc/ssh/sshd_config
$ sudo sshd -t # test config just to be safe - no output means all is ok
$ sudo systemctl reload sshd

Let’s also add a user called centos for later:

$ sudo useradd centos

Check out Vault’s other Secrets Engines here.

Roles

Roles in Vault are created by writing data to special paths. Roles provide a fine-grained interface for constraining the details that go into a signed client certificate. For example, one could have a role that allows SSH access as the root user to non-prod IP ranges and another role to SSH as root to production.

NOTE: It’s very tempting to create dozens of highly-specific roles, but this is problematic for a number of reasons. To start, there’s the operational burden of managing and tracking many roles. For every role created, it’s conceivable that a complementary policy will also need to be created and managed. Also, clients are indirectly required to keep track of which roles (paths) they need to access in order to request a signed certificate. Defense-in-depth is a winning strategy when it comes to securing systems. Don’t fall into the trap of creating dozens of highly-customized roles where 2 or 3 generic roles and proper network access controls would be sufficient.

Let’s create a couple of roles. One role for SSH as a regular user and another role SSH as root:

$ cat > regular-user-role.hcl <<EOF
{
    "allow_user_certificates": true,
    "allowed_users": "centos,ec2-user,ubuntu",
    "default_user": "ec2-user",
    "allow_user_key_ids": "true",
    "default_extensions": [
        {
          "permit-pty": ""
        }
    ],
    "key_type": "ca",
    "ttl": "60m0s",
    "allow_user_key_ids": "false",
    "key_id_format": "{{token_display_name}}"
}
EOF

This role will allow clients to request a signed certificate to SSH as centos,ec2-user or ubuntu only - it does not allow them to specify any other user such a root. This role does a couple of other nice things such as ensure that users can get a shell upon successful login ("permit-pty") and it sets a default principal (i.e. login user) when clients don’t specify any principals when requesting the certificate. The role also prevents users from specifying a key ID. This is important because the key ID is what gets logged by the SSH daemon during authentication attempts. Clients would have a field day with this - setting the key ID to all sorts of fun things. Using {{token_display_name}} will force the key ID to be the name of the Vault authentication token. This will simplify the process of correlating login attempts to clients.

By changing allowed_users and lowering ttl this same role would be perfectly suited a role for SSH’ing as root:

$ cat > root-user-role.hcl <<EOF
{
    "allow_user_certificates": true,
    "allowed_users": "root",
    "default_extensions": [
        {
          "permit-pty": ""
        }
    ],
    "key_type": "ca",
    "default_user": "root",
    "ttl": "15m0s",
    "allow_user_key_ids": "false",
    "key_id_format": "{{token_display_name}}"
}
EOF

Now we write the roles to Vault:

$ cat regular-user-role.hcl | vault write ssh-client/roles/regular -
Success! Data written to: ssh-client/roles/regular

$ cat root-user-role.hcl | vault write ssh-client/roles/root -
Success! Data written to: ssh-client/roles/root

Because we care about our users, we’ve made the process easy on them. When users have need to SSH as a regular user they hit the ssh-client/sign/regular path. When users have need (and are authorized) to SSH as root, they hit the ssh-client/sign/root path.

For a full list of role options, reference the API docs.

Policies

Policies allow us to define which users can request certificates from (i.e. write data to) which paths. Not all users should be able to request a certificate which would allow them to SSH as root. However, we assume all users should be able to SSH as a regular user.

First we a create a policy for regular users:

$ cat > regular-user-role-policy.hcl <<EOF
path "ssh-client/sign/regular" {
    capabilities = ["create","update"]
}
EOF

Next, we create a policy for admins - who will SSH as root:

$ cat > root-user-role-policy.hcl <<EOF
path "ssh-client/sign/root" {
    capabilities = ["create","update"]
}
EOF

In both cases, we allow users with the attached policy to create new data and update existing data at the respective paths.

When we’re satisfied with the policies, we can write them to Vault:

$ vault policy write ssh-regular-user regular-user-role-policy.hcl
Success! Uploaded policy: ssh-regular-user

$ vault policy write ssh-root-user root-user-role-policy.hcl
Success! Uploaded policy: ssh-root-user

Client Workflow

In order to test the client workflow for both the regular user path and the root user path, we’ll need a couple of users. We start by enabling the userpass authentication method:

$ vault auth enable -path=plain userpass
Success! Enabled userpass auth method at: plain/

This authentication method is a basic username and password authentication mechanism.

Next, we create some users in Vault:

$ vault write auth/plain/users/withoutroot \
  password="withoutroot" \
  policies="ssh-regular-user"
Success! Data written to: auth/plain/users/withoutroot

$ vault write auth/plain/users/withroot \
  password="withroot" \
  policies="ssh-regular-user,ssh-root-user"
Success! Data written to: auth/plain/users/withroot

Now, we’re ready to test the client-side workflow.

The very first step is to generate an RSA key pair if you don’t already have one:

$ ssh-keygen -qf $HOME/.ssh/id_rsa -t rsa -N ""

Then, we authenticate and get a token from Vault.

$ cp $HOME/.vault-token{,.root} # copy off initial root token if needed
$ vault login \
    -path=plain \
    -method=userpass \
    username=withoutroot \
    password=withoutroot

Next, we request that Vault sign our SSH public key and return the signed certificate:

$ vault write \
    -field=signed_key \
    ssh-client/sign/regular \
    valid_principals="centos" \
    public_key=@$HOME/.ssh/id_rsa.pub \
    > $HOME/.ssh/cert-signed.pub

We can examine the resultant certificate with ssh-keygen:

$ ssh-keygen -Lf $HOME/.ssh/cert-signed.pub
/home/myuser/.ssh/cert-signed.pub:
        Type: ssh-rsa-cert-v01@openssh.com user certificate
        Public key: RSA-CERT SHA256:QSnDLe2BGDDA6rpHe2rQo3XnuwSPqgUH8gfYyYhDpXk
        Signing CA: RSA SHA256:sx1n54ExAdcM+G4vTNjmJ/u+WZISFVzgM5ar7mh9f2M
        Key ID: "plain-withoutroot"
        Serial: 5213796768017119073
        Valid: from 2018-05-25T17:28:55 to 2018-05-25T18:29:25
        Principals:
                centos
        Critical Options: (none)
        Extensions:
                permit-pty

Let’s try to SSH using our new certificate:

$ ssh -i $HOME/.ssh/id_rsa -i $HOME/.ssh/cert-signed.pub centos@127.0.0.1
[centos@localhost ~]$ exit
logout
Connection to 127.0.0.1 closed.

If everything goes according to plan, we should see a successful authentication attempt in the journal:

$ sudo journalctl --lines=1 -u sshd --no-pager
-- Logs begin at Mon 2018-05-21 12:11:35 UTC, end at Fri 2018-05-25 17:35:07 UTC. --
May 25 17:30:39 localhost sshd[15149]: Accepted publickey for centos from 127.0.0.1 port 56210 ssh2: RSA-CERT ID plain-withoutroot (serial 5213796768017119073) CA RSA SHA256:sx1n54ExAdcM+G4vTNjmJ/u+WZISFVzgM5ar7mh9f2M

Note that the certificate ID is plain-withoutroot. plain is the authentication mechanism path and withoutroot is the user we authenticated with.

Now we can try getting a certificate to SSH as root by hitting the ssh-client/sign/root path:

$ vault write \
    -field=signed_key \
    ssh-client/sign/root \
    valid_principals="centos" \
    public_key=@$HOME/.ssh/id_rsa.pub \
    > $HOME/.ssh/cert-signed.pub
Error writing data to ssh-client/sign/root: Error making API request.

URL: PUT http://127.0.0.1:8200/v1/ssh-client/sign/root
Code: 403. Errors:

* permission denied

Bummer. It failed. Why? Because we’re still using the token for the user withoutroot who doesn’t have any permissions to ssh-client/sign/root.

We’ll need to authenticate as withroot and try again:

$ vault login \
    -path=plain \
    -method=userpass \
    username=withroot \
    password=withroot
Success! You are now authenticated. The token information displayed below
is already stored in the token helper. You do NOT need to run "vault login"
again. Future Vault requests will automatically use this token.

Let’s try to get the certificate again:

$ vault write \
    -field=signed_key \
    ssh-client/sign/root \
    valid_principals="centos" \
    public_key=@$HOME/.ssh/id_rsa.pub \
    > $HOME/.ssh/cert-signed.pub
Error writing data to ssh-client/sign/root: Error making API request.

URL: PUT http://127.0.0.1:8200/v1/ssh-client/sign/root
Code: 400. Errors:

* centos is not a valid value for valid_principals

Another failure! This time it has to do with the principals we’re passing. Recall that the root role we created earlier only has a single valid principal: root. Therefore, any attempt to pass a different principal will result in a failure.

We can either adjust valid_principals or remove it entirely since root is the default principal for the role:

$ vault write \
    -field=signed_key \
    ssh-client/sign/root \
    public_key=@$HOME/.ssh/id_rsa.pub \
    > $HOME/.ssh/cert-signed.pub

No errors this time!

Let’s examine the certificate:

$ ssh-keygen -Lf $HOME/.ssh/cert-signed.pub
/home/ll/.ssh/cert-signed.pub:
        Type: ssh-rsa-cert-v01@openssh.com user certificate
        Public key: RSA-CERT SHA256:QSnDLe2BGDDA6rpHe2rQo3XnuwSPqgUH8gfYyYhDpXk
        Signing CA: RSA SHA256:sx1n54ExAdcM+G4vTNjmJ/u+WZISFVzgM5ar7mh9f2M
        Key ID: "plain-withroot"
        Serial: 1184788779153026243
        Valid: from 2018-05-25T20:08:03 to 2018-05-25T20:23:33
        Principals:
                root
        Critical Options: (none)
        Extensions:
                permit-pty

We now see that the key ID reflects the user we authenticated with and the "Principals" field is set to root.

About CRLs

When using certificate-based authentication, questions about how to revoke certificates naturally arise. Currently, Vault does not have any CRL or OCSP capabilities. Based on this issue, there also do not appear to be any plans to add such a feature any time soon.

As mentioned in the issue, Vault provides the ability to issue very short-lived certificates. This largely negates any need for a CRL. Using a CRL could quickly turn out to be an administrative nightmare when attempting to use it at scale with thousands of hosts who will need to get constant CRL updates.

Parting Thoughts

Hopefully the utility of Vault for SSH certificate management is obvious at this point. The process turns out to be relatively painless for everyone involved. A whole new world of possibilities opens up when adding a reverse proxy in front of Vault. You could get fancy and abstract away many of the Vault paths into flat URLs such as https://ssh.example.com which passes requests to ssh-client/sign/regular on the backend.

In future posts, I hope to share some thoughts on other Vault use-cases and deployment practices. Until then, hopefully the information in this post gets you decent milage with Vault’s SSH Secrets Engine.