From b32adcce7804eea1d82023231f0ee9ef0bdb32cd Mon Sep 17 00:00:00 2001 From: Doug Stanley Date: Mon, 19 Oct 2020 12:07:36 -0400 Subject: [PATCH] Implement use_agent option to get signing key from ssh-agent. (#117) --- .../117-openssh_cert-use-ssh-agent.yml | 2 + plugins/module_utils/crypto/openssh.py | 36 +++++++++ plugins/modules/openssh_cert.py | 28 +++++++ .../targets/openssh_cert/meta/main.yml | 1 + .../targets/openssh_cert/tasks/main.yml | 80 +++++++++++++++++++ .../targets/setup_ssh_agent/meta/main.yml | 2 + .../targets/setup_ssh_agent/tasks/main.yml | 43 ++++++++++ 7 files changed, 192 insertions(+) create mode 100644 changelogs/fragments/117-openssh_cert-use-ssh-agent.yml create mode 100644 plugins/module_utils/crypto/openssh.py create mode 100644 tests/integration/targets/setup_ssh_agent/meta/main.yml create mode 100644 tests/integration/targets/setup_ssh_agent/tasks/main.yml diff --git a/changelogs/fragments/117-openssh_cert-use-ssh-agent.yml b/changelogs/fragments/117-openssh_cert-use-ssh-agent.yml new file mode 100644 index 00000000..33ad1197 --- /dev/null +++ b/changelogs/fragments/117-openssh_cert-use-ssh-agent.yml @@ -0,0 +1,2 @@ +minor_changes: +- openssh_cert - add module parameter ``use_agent`` to enable using signing keys stored in ssh-agent (https://github.com/ansible-collections/community.crypto/issues/116). diff --git a/plugins/module_utils/crypto/openssh.py b/plugins/module_utils/crypto/openssh.py new file mode 100644 index 00000000..b41e0161 --- /dev/null +++ b/plugins/module_utils/crypto/openssh.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# +# (c) 2020, Doug Stanley +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +import re + + +def parse_openssh_version(version_string): + """Parse the version output of ssh -V and return version numbers that can be compared""" + + parsed_result = re.match( + r"^.*openssh_(?P[0-9.]+)(p?[0-9]+)[^0-9]*.*$", version_string.lower() + ) + if parsed_result is not None: + version = parsed_result.group("version").strip() + else: + version = None + + return version diff --git a/plugins/modules/openssh_cert.py b/plugins/modules/openssh_cert.py index 1ac86e02..14eb2471 100644 --- a/plugins/modules/openssh_cert.py +++ b/plugins/modules/openssh_cert.py @@ -53,6 +53,12 @@ options: - If this is set, I(signing_key) needs to point to a file containing the public key of the CA. type: str version_added: 1.1.0 + use_agent: + description: + - Should the ssh-keygen use a CA key residing in a ssh-agent. + type: bool + default: false + version_added: 1.3.0 public_key: description: - The path to the public key that will be signed with the signing key in order to generate the certificate. @@ -216,12 +222,14 @@ import tempfile from datetime import datetime from datetime import MINYEAR, MAXYEAR +from distutils.version import LooseVersion from shutil import copy2, rmtree from ansible.module_utils.basic import AnsibleModule from ansible.module_utils._text import to_native from ansible_collections.community.crypto.plugins.module_utils.crypto.support import convert_relative_to_datetime +from ansible_collections.community.crypto.plugins.module_utils.crypto.openssh import parse_openssh_version class CertificateError(Exception): @@ -235,6 +243,7 @@ class Certificate(object): self.force = module.params['force'] self.type = module.params['type'] self.signing_key = module.params['signing_key'] + self.use_agent = module.params['use_agent'] self.pkcs11_provider = module.params['pkcs11_provider'] self.public_key = module.params['public_key'] self.path = module.params['path'] @@ -273,6 +282,9 @@ class Certificate(object): if self.pkcs11_provider: args.extend(['-D', self.pkcs11_provider]) + if self.use_agent: + args.extend(['-U']) + validity = "" if not (self.valid_from == "always" and self.valid_to == "forever"): @@ -547,6 +559,7 @@ def main(): force=dict(type='bool', default=False), type=dict(type='str', choices=['host', 'user']), signing_key=dict(type='path'), + use_agent=dict(type='bool', default=False), pkcs11_provider=dict(type='str'), public_key=dict(type='path'), path=dict(type='path', required=True), @@ -563,6 +576,21 @@ def main(): required_if=[('state', 'present', ['type', 'signing_key', 'public_key', 'valid_from', 'valid_to'])], ) + if module.params['use_agent']: + ssh = module.get_bin_path('ssh', True) + proc = module.run_command([ssh, '-Vq']) + ssh_version_string = proc[2].strip() + ssh_version = parse_openssh_version(ssh_version_string) + if ssh_version is None: + module.fail_json(msg="Failed to parse ssh version") + elif LooseVersion(ssh_version) < LooseVersion("7.6"): + module.fail_json( + msg=( + "Signing with CA key in ssh agent requires ssh 7.6 or newer." + " Your version is: %s" + ) % ssh_version_string + ) + def isBaseDir(path): base_dir = os.path.dirname(path) or '.' if not os.path.isdir(base_dir): diff --git a/tests/integration/targets/openssh_cert/meta/main.yml b/tests/integration/targets/openssh_cert/meta/main.yml index dc973f4e..476cfb39 100644 --- a/tests/integration/targets/openssh_cert/meta/main.yml +++ b/tests/integration/targets/openssh_cert/meta/main.yml @@ -1,2 +1,3 @@ dependencies: - setup_ssh_keygen + - setup_ssh_agent diff --git a/tests/integration/targets/openssh_cert/tasks/main.yml b/tests/integration/targets/openssh_cert/tasks/main.yml index 94085d46..4bce2ac0 100644 --- a/tests/integration/targets/openssh_cert/tasks/main.yml +++ b/tests/integration/targets/openssh_cert/tasks/main.yml @@ -411,3 +411,83 @@ path: '{{ output_dir }}/id_key' state: absent check_mode: yes + +- name: openssh_cert integration tests that require ssh-agent + when: openssh_version is version("7.6",">=") + environment: + SSH_AUTH_SOCK: "{{ openssh_agent_sock }}" + block: + - name: Generate keypair for agent tests + openssh_keypair: + path: '{{ output_dir }}/id_key' + type: rsa + - name: Generate always valid cert using agent without key in agent (should fail) + openssh_cert: + type: user + signing_key: '{{ output_dir }}/id_key' + public_key: '{{ output_dir }}/id_key.pub' + path: '{{ output_dir }}/id_cert_with_agent' + use_agent: yes + valid_from: always + valid_to: forever + register: rc_no_key_in_agent + ignore_errors: yes + - name: Make sure cert creation with agent fails if key not in agent + assert: + that: + - rc_no_key_in_agent is failed + - "'agent contains no identities' in rc_no_key_in_agent.msg or 'not found in agent' in rc_no_key_in_agent.msg" + - name: Add key to agent + command: 'ssh-add {{ output_dir }}/id_key' + - name: Generate always valid cert with agent (check mode) + openssh_cert: + type: user + signing_key: '{{ output_dir }}/id_key' + public_key: '{{ output_dir }}/id_key.pub' + path: '{{ output_dir }}/id_cert_with_agent' + use_agent: yes + valid_from: always + valid_to: forever + check_mode: yes + - name: Generate always valid cert with agent + openssh_cert: + type: user + signing_key: '{{ output_dir }}/id_key' + public_key: '{{ output_dir }}/id_key.pub' + path: '{{ output_dir }}/id_cert_with_agent' + use_agent: yes + valid_from: always + valid_to: forever + - name: Generate always valid cert with agent (idempotent) + openssh_cert: + type: user + signing_key: '{{ output_dir }}/id_key' + public_key: '{{ output_dir }}/id_key.pub' + path: '{{ output_dir }}/id_cert_with_agent' + use_agent: yes + valid_from: always + valid_to: forever + register: rc_cert_with_agent_idempotent + - name: Check agent idempotency + assert: + that: + - rc_cert_with_agent_idempotent is not changed + msg: OpenSSH certificate generation without serial number is idempotent. + - name: Generate always valid cert with agent (idempotent, check mode) + openssh_cert: + type: user + signing_key: '{{ output_dir }}/id_key' + public_key: '{{ output_dir }}/id_key.pub' + path: '{{ output_dir }}/id_cert_with_agent' + use_agent: yes + valid_from: always + valid_to: forever + check_mode: yes + - name: Remove keypair for agent tests + openssh_keypair: + path: '{{ output_dir }}/id_key' + state: absent + - name: Remove certificate + openssh_cert: + state: absent + path: '{{ output_dir }}/id_cert_with_agent' diff --git a/tests/integration/targets/setup_ssh_agent/meta/main.yml b/tests/integration/targets/setup_ssh_agent/meta/main.yml new file mode 100644 index 00000000..dc973f4e --- /dev/null +++ b/tests/integration/targets/setup_ssh_agent/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: + - setup_ssh_keygen diff --git a/tests/integration/targets/setup_ssh_agent/tasks/main.yml b/tests/integration/targets/setup_ssh_agent/tasks/main.yml new file mode 100644 index 00000000..fe552825 --- /dev/null +++ b/tests/integration/targets/setup_ssh_agent/tasks/main.yml @@ -0,0 +1,43 @@ +--- +#################################################################### +# WARNING: These are designed specifically for Ansible tests # +# and should not be used as examples of how to write Ansible roles # +#################################################################### + +- name: Start an ssh agent to use for tests + shell: eval $(ssh-agent)>/dev/null&&echo "${SSH_AGENT_PID};${SSH_AUTH_SOCK}" + register: openssh_agent_env_vars + +- name: Register ssh agent facts + set_fact: + openssh_agent_pid: "{{ openssh_agent_env_vars.stdout.split(';')[0] }}" + openssh_agent_sock: "{{ openssh_agent_env_vars.stdout.split(';')[1] }}" + +- name: stat agent socket + stat: + path: "{{ openssh_agent_sock }}" + register: openssh_agent_socket_stat + +- name: Assert agent socket file is a socket + assert: + that: + - openssh_agent_socket_stat.stat.issock is defined + - openssh_agent_socket_stat.stat.issock + fail_msg: "{{ openssh_agent_sock }} is not a socket" + +- name: Verify agent responds + command: ssh-add -l + register: rc_openssh_agent_ssh_add_check + environment: + SSH_AUTH_SOCK: "{{ openssh_agent_sock }}" + when: openssh_agent_socket_stat.stat.issock + failed_when: rc_openssh_agent_ssh_add_check.rc == 2 + +- name: Get ssh version + shell: ssh -Vq 2>&1|sed 's/^.*OpenSSH_\([0-9]\{1,\}\.[0-9]\{1,\}\).*$/\1/' + register: + rc_openssh_version_output + +- name: Set ssh version facts + set_fact: + openssh_version: "{{ rc_openssh_version_output.stdout.strip() }}"