From 0bc6d8f40bd579c1d280b8a7cd5bfeb2b5b6dde5 Mon Sep 17 00:00:00 2001 From: EmyLIEUTAUD <67641786+EmyLIEUTAUD@users.noreply.github.com> Date: Mon, 11 Sep 2023 15:39:03 +0200 Subject: [PATCH] feat: manage ssh certificates (#252) * Role configured to accept SSH connection via SSH certificates * Works with or without principals and ansible-lint updated * add test for SSH certificates authentication with principals * Add configuration to run tests for SSH certificates authentication with principals * tasks to use SSH certificates grouped into one file * Update README.md --- README.md | 56 ++++++++++++ defaults/main.yml | 20 +++++ examples/example-use-certificates.yml | 23 +++++ tasks/certificates.yml | 54 +++++++++++ tasks/install.yml | 4 + templates/auth_principals.j2 | 5 ++ templates/trusted-user-ca-keys.pub.j2 | 5 ++ tests/tests_certificates.yml | 124 ++++++++++++++++++++++++++ vars/main.yml | 12 +++ 9 files changed, 303 insertions(+) create mode 100644 examples/example-use-certificates.yml create mode 100644 tasks/certificates.yml create mode 100644 templates/auth_principals.j2 create mode 100644 templates/trusted-user-ca-keys.pub.j2 create mode 100644 tests/tests_certificates.yml diff --git a/README.md b/README.md index 48eb133..d8d1748 100644 --- a/README.md +++ b/README.md @@ -282,6 +282,62 @@ Default path to the sftp server binary. This variable is set to *true* after the role was successfully executed. +## Configure SSH certificate authentication + +To configure SSH certificate authentication on your SSH server, you need to provide at least the trusted user CA key, which will be used to validate client certificates against. +This is done with the `sshd_trusted_user_ca_keys_list` variable. + +If you need to map some of the authorized principals to system users, you can do that using the `sshd_principals` variable. + +### Additional variables + +#### sshd_trusted_user_ca_keys_list + +List of the trusted user CA public keys in OpenSSH (one-line) format (mandatory). + +#### sshd_trustedusercakeys_directory_owner, shsd_trustedusercakeys_directory_group, sshd_trustedusercakeys_directory_mode + +Use these variables to set the ownership and permissions for the Trusted User CA Keys directory. Defaults are respectively *root*, *root* and *0755*. + +#### sshd_trustedusercakeys_file_owner, shsd_trustedusercakeys_file_group, sshd_trustedusercakeys_file_mode + +Use these variables to set the ownership and permissions for the Trusted User CA Keys file. Defaults are respectively *root*, *root* and *0640*. + +#### sshd_principals + +A dict containing principals for users in the os (optional). e.g. + +```yaml +sshd_principals: + admin: + - frontend-admin + - backend-admin + somelinuxuser: + - some-principal-defined-in-certificate +``` + +#### sshd_authorizedprincipals_directory_owner, shsd_authorizedprincipals_directory_group, sshd_authorizedprincipals_directory_mode + +Use these variables to set the ownership and permissions for the Authorized Principals directory. Defaults are respectively *root*, *root* and *0755*. + +#### sshd_authorizedprincipals_file_owner, shsd_authorizedprincipals_file_group, sshd_authorizedprincipals_file_mode + +Use these variables to set the ownership and permissions for the Authorized Principals file. Defaults are respectively *root*, *root* and *0644*. + +### Additional configuration + +The SSH server needs this information stored in files so in addition to the above variables, respective configuration options `TrustedUserCAKeys` (mandatory) and `AuthorizedPrincipalsFile` (optional) need to be present the `sshd` dictionary when invoking the role. For example: + +```yaml +sshd: + TrustedUserCAKeys: /etc/ssh/path-to-trusted-user-ca-keys/trusted-user-ca-keys.pub + AuthorizedPrincipalsFile: "/etc/ssh/path-to-auth-principals/auth_principals/%u" +``` + +To learn more about SSH Certificates, here is a [nice tutorial to pure SSH certificates, from wikibooks](https://en.wikibooks.org/wiki/OpenSSH/Cookbook/Certificate-based_Authentication). + +To understand principals and to set up SSH certificates with Vault, this is a [well-explained tutorial from Hashicorp](https://www.hashicorp.com/blog/managing-ssh-access-at-scale-with-hashicorp-vault). + ## Dependencies None diff --git a/defaults/main.yml b/defaults/main.yml index de74add..aba9554 100644 --- a/defaults/main.yml +++ b/defaults/main.yml @@ -44,6 +44,12 @@ sshd: {} # configuration file snippet or configuring second sshd service sshd_config_file: "{{ __sshd_config_file }}" +# If not empty, list of trusted CA keys +sshd_trusted_user_ca_keys_list: [] + +# If not empty, dict containing principals for users in the os +sshd_principals: {} + ### VARS DEFAULTS ### The following are defaults for OS specific configuration in var files in ### this role. They should not be set directly by role users, unless they know @@ -60,6 +66,20 @@ sshd_sftp_server: "{{ __sshd_sftp_server }}" sshd_drop_in_dir_mode: "{{ __sshd_drop_in_dir_mode }}" sshd_main_config_file: "{{ __sshd_main_config_file }}" +sshd_trustedusercakeys_directory_owner: "{{ __sshd_trustedusercakeys_directory_owner }}" +sshd_trustedusercakeys_directory_group: "{{ __sshd_trustedusercakeys_directory_group }}" +sshd_trustedusercakeys_directory_mode: "{{ __sshd_trustedusercakeys_directory_mode }}" +sshd_trustedusercakeys_file_owner: "{{ __sshd_trustedusercakeys_file_owner }}" +sshd_trustedusercakeys_file_group: "{{ __sshd_trustedusercakeys_file_group }}" +sshd_trustedusercakeys_file_mode: "{{ __sshd_trustedusercakeys_file_mode }}" + +sshd_authorizedprincipals_directory_owner: "{{ __sshd_authorizedprincipals_directory_owner }}" +sshd_authorizedprincipals_directory_group: "{{ __sshd_authorizedprincipals_directory_group }}" +sshd_authorizedprincipals_directory_mode: "{{ __sshd_authorizedprincipals_directory_mode }}" +sshd_authorizedprincipals_file_owner: "{{ __sshd_authorizedprincipals_file_owner }}" +sshd_authorizedprincipals_file_group: "{{ __sshd_authorizedprincipals_file_group }}" +sshd_authorizedprincipals_file_mode: "{{ __sshd_authorizedprincipals_file_mode }}" + # This lists by default all hostkeys as rendered in the generated configuration # file ("auto"). Before attempting to run sshd (either for verification of # configuration or restarting), we make sure the keys exist and have correct diff --git a/examples/example-use-certificates.yml b/examples/example-use-certificates.yml new file mode 100644 index 0000000..59dd00e --- /dev/null +++ b/examples/example-use-certificates.yml @@ -0,0 +1,23 @@ +--- +- name: Use SSH certificates + hosts: all + tasks: + - name: Configure sshd to enable SSH Certificate login + ansible.builtin.include_role: + name: ansible-sshd + vars: + sshd: + # Disable password authentication, use SSH Certificates and configure authorized principals + PasswordAuthentication: false + TrustedUserCAKeys: /etc/ssh/trusted-user-ca-keys.pub + AuthorizedPrincipalsFile: "/etc/ssh/auth_principals/%u" + # List of trusted user CA keys + sshd_trusted_user_ca_keys_list: + - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICwqRjI9gAwkQF9iIylhRVAOFy2Joodh3fXJ7CbGWqUd + # Key is the user in the os, values are *Principals* defined in the certificate + sshd_principals: + admin: + - frontend-admin + - backend-admin + somelinuxuser: + - some-principal-defined-in-certificate diff --git a/tasks/certificates.yml b/tasks/certificates.yml new file mode 100644 index 0000000..fe1cb46 --- /dev/null +++ b/tasks/certificates.yml @@ -0,0 +1,54 @@ +--- +- name: Configure Trusted user CA Keys + vars: + # The explicit to_json filter is needed for Python 2 compatibility + __sshd_trustedusercakeys_from_config: >- + {% if sshd_TrustedUserCAKeys is defined %} + {{ sshd_TrustedUserCAKeys | to_json }} + {% else %} + {{ sshd['TrustedUserCAKeys'] | to_json }} + {% endif %} + block: + - name: Create Trusted user CA Keys directory + ansible.builtin.file: + path: "{{ (__sshd_trustedusercakeys_from_config | from_json) | dirname }}" + state: directory + owner: "{{ sshd_trustedusercakeys_directory_owner }}" + group: "{{ sshd_trustedusercakeys_directory_group }}" + mode: "{{ sshd_trustedusercakeys_directory_mode }}" + + - name: Copy Trusted user CA Keys + ansible.builtin.template: + src: "trusted-user-ca-keys.pub.j2" + dest: "{{ __sshd_trustedusercakeys_from_config | from_json }}" + owner: "{{ sshd_trustedusercakeys_file_owner }}" + group: "{{ sshd_trustedusercakeys_file_group }}" + mode: "{{ sshd_trustedusercakeys_file_mode }}" + +- name: Configure Principals + vars: + # The explicit to_json filter is needed for Python 2 compatibility + __sshd_authorizedprincipalsfile_from_config: >- + {% if sshd_AuthorizedPrincipalsFile is defined %} + {{ sshd_AuthorizedPrincipalsFile | to_json }} + {% else %} + {{ sshd['AuthorizedPrincipalsFile'] | to_json }} + {% endif %} + when: sshd_principals != {} + block: + - name: Create Principals directory + ansible.builtin.file: + path: "{{ (__sshd_authorizedprincipalsfile_from_config | from_json) | dirname }}" + state: directory + owner: "{{ sshd_authorizedprincipals_directory_owner }}" + group: "{{ sshd_authorizedprincipals_directory_group }}" + mode: "{{ sshd_authorizedprincipals_directory_mode }}" + + - name: Copy Principals files + ansible.builtin.template: + src: "auth_principals.j2" + dest: "{{ (__sshd_authorizedprincipalsfile_from_config | from_json) | dirname }}/{{ item.key }}" + owner: "{{ sshd_authorizedprincipals_file_owner }}" + group: "{{ sshd_authorizedprincipals_file_group }}" + mode: "{{ sshd_authorizedprincipals_file_mode }}" + with_dict: "{{ sshd_principals }}" diff --git a/tasks/install.yml b/tasks/install.yml index 2012f03..69ca900 100644 --- a/tasks/install.yml +++ b/tasks/install.yml @@ -147,6 +147,10 @@ ansible.builtin.include_tasks: install_namespace.yml when: sshd_config_namespace is not none + - name: Configure sshd to use SSH certificates + ansible.builtin.include_tasks: certificates.yml + when: sshd_trusted_user_ca_keys_list != [] + rescue: - name: Re-raise the error ansible.builtin.fail: diff --git a/templates/auth_principals.j2 b/templates/auth_principals.j2 new file mode 100644 index 0000000..b9c296a --- /dev/null +++ b/templates/auth_principals.j2 @@ -0,0 +1,5 @@ +{{ ansible_managed | comment }} +{{ "willshersystems:ansible-sshd" | comment(prefix="", postfix="") }} +{% for principal in item.value %} +{{ principal }} +{% endfor %} diff --git a/templates/trusted-user-ca-keys.pub.j2 b/templates/trusted-user-ca-keys.pub.j2 new file mode 100644 index 0000000..0444c89 --- /dev/null +++ b/templates/trusted-user-ca-keys.pub.j2 @@ -0,0 +1,5 @@ +{{ ansible_managed | comment }} +{{ "willshersystems:ansible-sshd" | comment(prefix="", postfix="") }} +{% for key in sshd_trusted_user_ca_keys_list %} +{{ key }} +{% endfor %} \ No newline at end of file diff --git a/tests/tests_certificates.yml b/tests/tests_certificates.yml new file mode 100644 index 0000000..3556943 --- /dev/null +++ b/tests/tests_certificates.yml @@ -0,0 +1,124 @@ +--- +- name: Test SSH certificates options + hosts: all + vars: + __sshd_test_backup_files: + - /etc/ssh/sshd_config + - /etc/ssh/sshd_config.d/00-ansible_system_role.conf + tasks: + - name: "Backup configuration files" + ansible.builtin.include_tasks: tasks/backup.yml + + - name: Ensure group 'nobody' exists + ansible.builtin.group: + name: nobody + + - name: Ensure the user 'nobody' exists + ansible.builtin.user: + name: nobody + group: nobody + comment: nobody + create_home: false + shell: /sbin/nologin + + - name: Configure sshd + ansible.builtin.include_role: + name: ansible-sshd + vars: + sshd: + PasswordAuthentication: false + TrustedUserCAKeys: /etc/ssh/ca-keys/trusted-user-ca-keys.pub + AuthorizedPrincipalsFile: "/etc/ssh/auth_principals/%u" + sshd_trusted_user_ca_keys_list: + - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICwqRjI9gAwkQF9iIylhRVAOFy2Joodh3fXJ7CbGWqUd + # Key is the user in the os, values are *Principals* defined in the certificate + sshd_principals: + user: + - principal + sshd_config_file: /etc/ssh/sshd_config + # very BAD example + sshd_trustedusercakeys_directory_owner: "nobody" + sshd_trustedusercakeys_directory_group: "nobody" + sshd_trustedusercakeys_directory_mode: "0770" + sshd_trustedusercakeys_file_owner: "nobody" + sshd_trustedusercakeys_file_group: "nobody" + sshd_trustedusercakeys_file_mode: "0750" + sshd_authorizedprincipals_directory_owner: "nobody" + sshd_authorizedprincipals_directory_group: "nobody" + sshd_authorizedprincipals_directory_mode: "0777" + sshd_authorizedprincipals_file_owner: "nobody" + sshd_authorizedprincipals_file_group: "nobody" + sshd_authorizedprincipals_file_mode: "0755" + + - name: Verify the options are correctly set + tags: tests::verify + block: + - name: Flush handlers + ansible.builtin.meta: flush_handlers + + - name: Print current configuration file + ansible.builtin.slurp: + src: /etc/ssh/sshd_config + register: config + + - name: Check the options are in configuration file + ansible.builtin.assert: + that: + - "'PasswordAuthentication no' in config.content | b64decode" + - "'TrustedUserCAKeys /etc/ssh/ca-keys/trusted-user-ca-keys.pub' in config.content | b64decode" + - "'AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u' in config.content | b64decode" + + - name: Get trusted user CA keys directory stat + ansible.builtin.stat: + path: /etc/ssh/ca-keys + register: trustedusercakeys_directory_stat + + - name: Check trusted user CA keys directory has requested properties + ansible.builtin.assert: + that: + - trustedusercakeys_directory_stat.stat.isdir + - trustedusercakeys_directory_stat.stat.pw_name == "nobody" + - trustedusercakeys_directory_stat.stat.gr_name == "nobody" + - trustedusercakeys_directory_stat.stat.mode == "0770" + + - name: Get trusted user CA keys file stat + ansible.builtin.stat: + path: /etc/ssh/ca-keys/trusted-user-ca-keys.pub + register: trustedusercakeys_file_stat + + - name: Check trusted user CA keys file has requested properties + ansible.builtin.assert: + that: + - trustedusercakeys_file_stat.stat.exists + - trustedusercakeys_file_stat.stat.pw_name == "nobody" + - trustedusercakeys_file_stat.stat.gr_name == "nobody" + - trustedusercakeys_file_stat.stat.mode == "0750" + + - name: Get authorized principals directory stat + ansible.builtin.stat: + path: /etc/ssh/auth_principals + register: authorizedprincipals_directory_stat + + - name: Check authorized principals directory has requested properties + ansible.builtin.assert: + that: + - authorizedprincipals_directory_stat.stat.isdir + - authorizedprincipals_directory_stat.stat.pw_name == "nobody" + - authorizedprincipals_directory_stat.stat.gr_name == "nobody" + - authorizedprincipals_directory_stat.stat.mode == "0777" + + - name: Get authorized principals file stat + ansible.builtin.stat: + path: /etc/ssh/auth_principals/user + register: authorizedprincipals_file_stat + + - name: Check authorized principals file has requested properties + ansible.builtin.assert: + that: + - authorizedprincipals_file_stat.stat.exists + - authorizedprincipals_file_stat.stat.pw_name == "nobody" + - authorizedprincipals_file_stat.stat.gr_name == "nobody" + - authorizedprincipals_file_stat.stat.mode == "0755" + + - name: "Restore configuration files" + ansible.builtin.include_tasks: tasks/restore.yml diff --git a/vars/main.yml b/vars/main.yml index 67f9006..43d8171 100644 --- a/vars/main.yml +++ b/vars/main.yml @@ -5,6 +5,18 @@ __sshd_config_mode: "0600" __sshd_hostkey_owner: "root" __sshd_hostkey_group: "root" __sshd_hostkey_mode: "0600" +__sshd_trustedusercakeys_directory_owner: "root" +__sshd_trustedusercakeys_directory_group: "root" +__sshd_trustedusercakeys_directory_mode: "0755" +__sshd_trustedusercakeys_file_owner: "root" +__sshd_trustedusercakeys_file_group: "root" +__sshd_trustedusercakeys_file_mode: "0640" +__sshd_authorizedprincipals_directory_owner: "root" +__sshd_authorizedprincipals_directory_group: "root" +__sshd_authorizedprincipals_directory_mode: "0755" +__sshd_authorizedprincipals_file_owner: "root" +__sshd_authorizedprincipals_file_group: "root" +__sshd_authorizedprincipals_file_mode: "0644" # The OpenSSH 5.3 in RHEL6 does not support "Match all" so we need a workaround __sshd_compat_match_all: Match all # The hostkeys not supported in FIPS mode, if applicable