Enforcing SSH key policies using Ansible

Thomas Sjögren
6 min readJul 9, 2020

--

Information security starts with who is given access to systems and data, and when it comes to accessing GNU/Linux or UNIX systems, that usually involves SSH (Secure Shell).

While most organizations have a password policy regarding length, reuse and rotation, managing remote access SSH keys seems to be far less common.

According to SSH Communications Security, Inc most organizations:

  • Have extremely large numbers of SSH keys — even several million — and their use is grossly underestimated
  • Have no provisioning and termination processes in place for key based access
  • Have no records of who provisioned each key and for what purpose
  • Allow their system administrators to self-provision permanent key-based access without policies, processes, or oversight.

Also, many keys are unused and represent access that was never properly terminated, even though these keys are like passwords; they grant access to resources.

In this article I’ll go step-by-step how to implement a basic Ansible playbook that enforces set rules regarding SSH key length and age.

Note that managing SSH keys on servers does not guarantee proper key, password generation or key management by a user.

You also need to be familiar with shell scripts, Ansible administration, have Ansible configured, and know how to write and deploy playbooks.

Please refer to the Ansible documentation for details.

The baseline: defining minimum security levels

Before we start writing any tasks, we need to set an acceptable security baseline when it comes to the SSH keys present on a server.

Finding various recommendations and guidelines isn’t very hard, and when we browse the National Institute of Standards and Technology (NIST) publication database, as we often do, we come across NIST Special Publication 800–57 Part 1 Revision 5, Recommendation for Key Management: Part 1 — General.

In that document we find the following tables:

We will assume that the operational lifetime of a server, either hardware or operating system, is 5 years and then decommissioned and possibly replaced.

Based on the above tables, we’ll then aim for a minimum of 128 bit security strength, which means that we will enforce RSA or DSA keys equal or larger than 3072 bits and ECDSA or ED25519 keys equal or larger than 256 bits.

The rules: what to enforce

Lets define the term user as any account on a system that is not a system account or the root user account, so following the Linux Standard Base Core Specification we will assume that every account with a user ID greater or equal to 500 falls into this category.

  • Users owning DSA or RSA keys with a key size less than 3072 bits will be locked.
  • Users owning ED25519 or ECSDA keys with a key size less than 256 bits will be locked.
  • Users owning keys older than 90 days will be locked.
  • We will also enforce strict permission settings, allowing only 0600/-rw------- on the affected files.

We are excluding the root user since the possible impact on the system if the permissions on files required for sshd is modified.

We also assume security best practices are followed, and these state that the root user should never be allowed to login over ssh or used at all unless necessary, see for example the CIS Ubuntu Linux 18.04 LTS Benchmark and Canonical Ubuntu 18.04 LTS STIG - Ver 1, Rel 1.

The SSH keys: using local facts

As I’ve written earlier, local facts are a way to expand the information gathered by Ansible about the managed host, and as long as the output is in valid JSON or INI format we can use Ansible to act upon that information.

The following shell script will find common OpenSSH related files and then output that information in JSON.

Note that this script doesn’t take any local configuration changes into consideration.

Running the script on a Ubuntu Groovy 20.04 Vagrant server will result in something similar to the following.

The tasks: converting policy to commands

After establishing an policy, written the Ansible .fact script and verifying that it works, it's time to write the actual playbook.

We will be using json_query to parse the script output so we will have to make sure python3-jmespath is installed on the management server.

---
- hosts: all
become: true
tasks:
- name: Install python-jmespath
become: 'yes'
apt:
name: python-jmespath
state: present
update_cache: 'yes'
when: ansible_python.version.major <= 2
- name: Install python3-jmespath
become: 'yes'
apt:
name: python3-jmespath
state: present
update_cache: 'yes'
when: ansible_python.version.major >= 3

We then create the Ansible facts directory in case it doesn’t exist and copy the script to that directory.

By using setup: ~ afterwards we make Ansible gather the host information, including our newly added SSH key facts.

- name: Create local facts directory
become: 'yes'
file:
path: /etc/ansible/facts.d
recurse: true
state: directory
mode: 0755
owner: root
group: root
tags:
- fact
- name: Add SSH keys fact script
become: 'yes'
template:
src: etc/ansible/facts.d/sshkeys.fact
dest: /etc/ansible/facts.d/sshkeys.fact
mode: 0755
owner: root
group: root
tags:
- sshd
- fact
- name: Update facts
setup: ~
tags:
- fact

The three last tasks enforces our policy.

First we lock any users, user_id >= 500, with short SSH keys then users with keys older than 90 days. All other user files found will have their permission set to 0600/-rw-------.

    - name: Lock users with short SSH keys
user:
name: "{{ item.user_name }}"
password_lock: 'yes'
shell: "/bin/false"
with_items:
- "{{ ansible_local['sshkeys'] | json_query('keys.*') }}"
when: item.user_id >= 500
and ((item.type == "DSA" or item.type == "RSA")
and item.size < 3072)
or ((item.type == "ECDSA" or item.type == "ED25519")
and item.size < 256)
tags:
- sshd
- name: Lock users with SSH keys older than 90 days
vars:
- old_key: "{{ ansible_date_time.epoch|int - (86400*90) }}"
user:
name: "{{ item.user_name }}"
password_lock: 'yes'
shell: "/bin/false"
with_items:
- "{{ ansible_local['sshkeys'] | json_query('keys.*') }}"
when: item.modified_epoch|int <= old_key|int
and item.user_id >= 500
tags:
- sshd
- name: Set SSH key file permissions
file:
path: "{{ item.file }}"
mode: '600'
with_items:
- "{{ ansible_local['sshkeys'] | json_query('keys.*') }}"
when: item.user_id >= 500 and item.permissions|int > 600
tags:
- sshd
...

The result: enforcing with Ansible

After writing the .fact script and creating the Ansible tasks, it's time to test.

In this example we’ll create a user named Keys on a managed server and then create a 2048 bit RSA SSH key as that user.

~$ sudo useradd -c "ansible test user" -d /home/keys -m -s /bin/bash keys
~$ grep keys /etc/passwd keys:x:1002:100:ansible test user:/home/keys:/bin/bash
~$ sudo passwd keys
~$ sudo passwd -S keys
keys P 07/08/2020 1 60 7 35
~$ sudo su - keys
~$ ssh-keygen -t rsa -b 2048 -C "weak ssh key"

We then run the Ansible playbook on the management server; ansible-playbook -i hosts ssh-key-policies.yaml.

changed: [X.X.X.X] => (item={'file': '/home/keys/.ssh/id_rsa.pub', 'size': 2048, 'hash': 'SHA256:qLjv5mSATfHU+z8qvaDxCvR1KZeJi2UGp4pXwMxQDGg', 'comment': 'weak ssh key', 'type': 'RSA', 'modified_epoch': 1594293187, 'modified_human': '2020-07-09 11:13:07.379265542 +0000', 'user_name': 'keys', 'group_name': 'users', 'user_id': 1002, 'group_id': 100, 'permissions': 600})

After the run has completed, we verify the result:

~$ grep keys /etc/passwd keys:x:1002:100:ansible test user:/home/keys:/bin/false
~$ sudo passwd -S keys keys L 07/08/2020 1 60 7 35

Notice how the user shell has changed to /bin/false and the user has been locked.

Originally published at https://github.com.

--

--

Thomas Sjögren
Thomas Sjögren

Written by Thomas Sjögren

Various tech ramblings but usually GNU/Linux system administration with focus on high availability and security.

No responses yet