diff --git a/doc/source/general-roles.rst b/doc/source/general-roles.rst index c81eb4efd..5bfb30046 100644 --- a/doc/source/general-roles.rst +++ b/doc/source/general-roles.rst @@ -15,6 +15,7 @@ General Purpose Roles .. zuul:autorole:: emit-job-header .. zuul:autorole:: enable-fips .. zuul:autorole:: enable-netconsole +.. zuul:autorole:: encrypt-file .. zuul:autorole:: ensure-bazelisk .. zuul:autorole:: ensure-dhall .. zuul:autorole:: ensure-dstat-graph diff --git a/roles/encrypt-file/README.rst b/roles/encrypt-file/README.rst new file mode 100644 index 000000000..d214c8e2a --- /dev/null +++ b/roles/encrypt-file/README.rst @@ -0,0 +1,34 @@ +encrypt-file + +Import GPG keys and encrypt a file + +**Role Variables** + +.. zuul:rolevar:: encrypt_file + :default: *undefined* + + A *string* with the full path to a log file to encrypt, or a *list* + of *string* values of full paths to encrypt. Must be defined. + Resulting file(s) will have ``.gpg`` added. + +.. zuul:rolevar:: encrypt_file_recipients + :default: [] + + List of recipients who will be able to decrypt the file(s). This + should be a list of ``name`` keys that exist in + ``encrypt_file_keys``. + +.. zuul:rolevar:: encrypt_file_keys + :default: [] + + Keys available to encrypt the file with. Each entry is a + dictionary with keys + + * ``name`` : a freeform string identifier + * ``key_id``: the GPG key ID + * ``gpg_asc``: the GPG ASCII-armored public key. If the public-key + is not already available, it will be imported to GPG. + + It is intended that this is a global-variable, and specific files + to be encrypted then choose a subset of keys in this variable for + encryption. diff --git a/roles/encrypt-file/defaults/main.yaml b/roles/encrypt-file/defaults/main.yaml new file mode 100644 index 000000000..cd7daf6a1 --- /dev/null +++ b/roles/encrypt-file/defaults/main.yaml @@ -0,0 +1 @@ +encrypt_file_keys: [] diff --git a/roles/encrypt-file/tasks/import-key.yaml b/roles/encrypt-file/tasks/import-key.yaml new file mode 100644 index 000000000..b90120aa6 --- /dev/null +++ b/roles/encrypt-file/tasks/import-key.yaml @@ -0,0 +1,38 @@ +- name: Check for existing key + command: | + gpg --list-keys {{ zj_encrypt_file.key_id }} + register: _key_exists + # A found key returns 0, a missing key returns 2 + failed_when: _key_exists.rc != 0 and _key_exists.rc != 2 + +- name: Install key + when: _key_exists.rc != 0 + block: + - name: Create temporary keyfile + tempfile: + state: file + register: _keyfile + + - name: Copy keyfile material # noqa risky-file-permissions + copy: + content: '{{ zj_encrypt_file.gpg_asc }}' + dest: '{{ _keyfile.path }}' + + - name: Import key + command: | + gpg --import {{ _keyfile.path }} + + # Strip all whitespace and take the second line of output, which + # is the fingerprint, then import this at "I trust fully" level. + # This was a pain to figure out as gpg really wants to communicate + # with a tty if you do something obvious like "gpg --edit-key <id> + # ...". And what is menu option number "5" is actually "6" in the + # ownertrust db! + - name: Trust key + shell: | + echo $(gpg --fingerprint {{ zj_encrypt_file.key_id }} | sed -n "s/ //g;2 p"):6: | gpg --import-ownertrust + + - name: Remove temporary keyfile + file: + path: '{{ _keyfile.path }}' + state: absent diff --git a/roles/encrypt-file/tasks/main.yaml b/roles/encrypt-file/tasks/main.yaml new file mode 100644 index 000000000..277a35565 --- /dev/null +++ b/roles/encrypt-file/tasks/main.yaml @@ -0,0 +1,37 @@ +- name: Validate input file + fail: + msg: 'Must define "encrypt_file"' + when: encrypt_file is undefined + +- name: Ensure gpg2 installed + package: + name: gnupg2 + state: present + +- name: Check for required keys + fail: + msg: 'Name {{ zj_recipient_name }} not in encrypt_file_keys' + when: zj_recipient_name not in encrypt_file_keys | map(attribute="name") + loop: '{{ encrypt_file_recipients }}' + loop_control: + loop_var: zj_recipient_name + +- name: Build recipient list + set_fact: + _recipients: '{{ encrypt_file_keys | selectattr("name", "in", encrypt_file_recipients) | list }}' + +- name: Install keys + include_tasks: import-key.yaml + loop: '{{ _recipients }}' + loop_control: + loop_var: zj_encrypt_file + +- name: Build recipient list + set_fact: + _recipients_cmd: '--recipient={{ _recipients | map(attribute="key_id") | join(" --recipient=") }}' + +- name: Encrypt file + command: 'gpg2 --encrypt --output {{ zj_encrypt_file }}.gpg {{ _recipients_cmd }} {{ zj_encrypt_file }}' + loop: '{{ [ encrypt_file ] if encrypt_file is string else encrypt_file }}' + loop_control: + loop_var: zj_encrypt_file diff --git a/test-playbooks/encrypt-file.yaml b/test-playbooks/encrypt-file.yaml new file mode 100644 index 000000000..1f4c799b0 --- /dev/null +++ b/test-playbooks/encrypt-file.yaml @@ -0,0 +1,114 @@ +- hosts: all + tasks: + + - name: Make a fake file + tempfile: + state: file + register: _tempfile + + - name: Add some data + copy: + content: 'Hello, I am encrypted' + dest: '{{ _tempfile.path }}' + + - name: Setup encryption variables + set_fact: + encrypt_file_keys: + - name: 'zuul-jobs-test-1' + key_id: '0xD0A3C69F209B3B8E' + gpg_asc: | + -----BEGIN PGP PUBLIC KEY BLOCK----- + + mDMEYgxZHhYJKwYBBAHaRw8BAQdAl9ZJaMvAc4kcO2mWFCZ5Em0xl7kRc3QYgtg0 + +98sqoO0EHp1dWwtam9icy10ZXN0LTGIlAQTFgoAPBYhBKNWMSQoy8kXXIv/T9Cj + xp8gmzuOBQJiDFkeAhsDBQsJCAcCAyICAQYVCgkICwIEFgIDAQIeBwIXgAAKCRDQ + o8afIJs7jqlUAQCVxaINjS5/rd1oCAp19lHrscMhFmQBOtxyU7CTDoCrCAEAh9mH + scfOGRWhxiwg0+dXe6RE/C3Kk13kdN8pfTOIGA24OARiDFkeEgorBgEEAZdVAQUB + AQdAl53uFFzrhnTTmq0YbDRtQ5KrMJYYNahImPzzrvVajW4DAQgHiHgEGBYKACAW + IQSjVjEkKMvJF1yL/0/Qo8afIJs7jgUCYgxZHgIbDAAKCRDQo8afIJs7jqE7AP9M + LRe/tJ+SeHexDI1m9tmo6xcID7UOJW8eIuuwi3kjZgEAl+WqJfqjBxJmBWIjTZcV + zA2T4i8ViORhXLo0oohQVwE= + =/liI + -----END PGP PUBLIC KEY BLOCK----- + + - name: 'zuul-jobs-test-2' + key_id: '0x4E1BA7A3AB674E6F' + gpg_asc: | + -----BEGIN PGP PUBLIC KEY BLOCK----- + + mDMEYgxZkRYJKwYBBAHaRw8BAQdA1/5ta4i1G+NGqRWtlFuzZUmDHZP5uMt1gguX + WcXfoGW0EHp1dWwtam9icy10ZXN0LTKIlAQTFgoAPBYhBN0G/+apoMfgIYAX404b + p6OrZ05vBQJiDFmRAhsDBQsJCAcCAyICAQYVCgkICwIEFgIDAQIeBwIXgAAKCRBO + G6ejq2dOb4BkAQDoHczJaUH0LRgZUE+tkxhyqYY7kevX65vxe6vAqFci1gD/VvtA + lYrnQPqGG1GqRqy7cIsCfnI5lzAlL2Q2tYdO6A24OARiDFmREgorBgEEAZdVAQUB + AQdAZsFeMnJ7FGzNXf2SbGrVvYjgX397PaY6xAQoFWe4IQQDAQgHiHgEGBYKACAW + IQTdBv/mqaDH4CGAF+NOG6ejq2dObwUCYgxZkQIbDAAKCRBOG6ejq2dOb0t2AP9b + 6Lv4BKjblZOhxJbsB9qvQbeyYunCLP07lSHBEhggNQEA+Luzhn3uitf4lyNbv6cS + 9p2BPtxrLR4Ab20opteZ7w0= + =is5k + -----END PGP PUBLIC KEY BLOCK----- + + - name: 'zuul-jobs-test-3' + key_id: '0FDF4D29F272F75A' + gpg_asc: | + -----BEGIN PGP PUBLIC KEY BLOCK----- + + mDMEYgxZmxYJKwYBBAHaRw8BAQdAVUbvG30ucCb6ztQ/iuis7fX5FXssxSfbl/n8 + vl/gyse0EHp1dWwtam9icy10ZXN0LTOIlAQTFgoAPBYhBHfjn1eq18PQIZ3qNA/f + TSnycvdaBQJiDFmbAhsDBQsJCAcCAyICAQYVCgkICwIEFgIDAQIeBwIXgAAKCRAP + 300p8nL3WqgFAQCNhAEx7yC7UMV81IhiWMCDLK66eeHF16CiqPMabwlOEAD/V1cQ + NYCJekbq8GEcB3i36yIMPJokrPmXf6mkebs6vA24OARiDFmbEgorBgEEAZdVAQUB + AQdAPGKpDC3HpbRCJYkMzwY2NybY+/+G1beIjlDjpaxf/mIDAQgHiHgEGBYKACAW + IQR3459XqtfD0CGd6jQP300p8nL3WgUCYgxZmwIbDAAKCRAP300p8nL3WlQpAQC1 + qwAAr63kwKKszAN+J32EGSaXp+dsR04367XacSJ3aQD/Tu6q45tF0t4G0dQIpzxT + jnHN/zEM7eyW45Jf/V8migI= + =CRYD + -----END PGP PUBLIC KEY BLOCK----- + + - name: Encrypt file + include_role: + name: encrypt-file + vars: + encrypt_file: '{{ _tempfile.path }}' + encrypt_file_recipients: + - zuul-jobs-test-2 + - zuul-jobs-test-3 + + - name: Remove temporary file + file: + path: '{{ _tempfile.path }}' + state: absent + when: _tempfile.path is defined + + - name: Check output file + stat: + path: '{{ _tempfile.path }}.gpg' + register: _output + + - name: Ensure exists + fail: + msg: 'Output file not found' + when: not _output.stat.exists + + - name: Dump gpg packets + command: gpg --list-packets '{{ _tempfile.path }}.gpg' + register: _gpg_output + # Because it can't decrypt, gpg give an error. But we're + # interested in the encryption packets so expect this. + failed_when: _gpg_output.rc != 2 + + - name: Show gpg command output + debug: + var: _gpg_output + + - name: Validate packets + assert: + that: + - "'zuul-jobs-test-1' not in _gpg_output.stdout" + - "'zuul-jobs-test-2' in _gpg_output.stdout" + - "'zuul-jobs-test-3' in _gpg_output.stdout" + + - name: Remove output file + file: + path: '{{ _tempfile.path }}.gpg' + state: absent diff --git a/zuul-tests.d/general-roles-jobs.yaml b/zuul-tests.d/general-roles-jobs.yaml index f72113f4c..a3358a4da 100644 --- a/zuul-tests.d/general-roles-jobs.yaml +++ b/zuul-tests.d/general-roles-jobs.yaml @@ -95,6 +95,14 @@ A+tEIZhDiZh2NZoXXAKqV3pH6nOF9kPgRymy7de7BCoQx3rB7YgXpOk= -----END RSA PRIVATE KEY----- +- job: + name: zuul-jobs-test-encrypt-file + description: | + Test encrypt-file role + files: + - roles/encrypt-file/.* + run: test-playbooks/encrypt-file.yaml + - job: name: zuul-jobs-test-base-roles description: | @@ -749,6 +757,7 @@ - zuul-jobs-test-add-authorized-keys - zuul-jobs-test-add-gpgkey - zuul-jobs-test-add-sshkey + - zuul-jobs-test-encrypt-file - zuul-jobs-test-base-roles-centos-7 - zuul-jobs-test-base-roles-centos-8-stream - zuul-jobs-test-base-roles-centos-9-stream