While explaining Ansible’s local facts to students last week, I was asked whether it’s possible to have encrypted facts on the node which get decrypted on use when read by the Ansible controller. The use-case would be some smallish data which is encrypted at rest, “invisible” to curious people on the node, but usable on the management server.

It might be feasible to to place Vault-encrypted (have you seen nanvault?) data on the node and unvaulting it (that’s verb I invented) on the controller (wasn’t there a filter for doing that..?), but the first thing which came to mind was to use age and to create a small filter for it, particularly as this student stayed on for the advanced Ansible course in which we discuss filter creation amongst other things.

age calls itself a “simple, modern and secure encryption tool (and Go library) with small explicit keys, no config options, and UNIX-style composeability”. I’m quite fond of the concept and the utility and use a pair of Yubikeys on which I keep some of its identities (secret keys).

In order to use age we require a key pair; the public key (recipient in age terminology) is written alongside the secret identity into the key file, and it’s printed to stderr to be copy/pasted directly from the console. As is typical, the secret key must be kept secret. (Here I display one for illustration.)

$ age-keygen -o cow.key
Public key: age1mkmc34wqy8tdda58077cm2p0eg3xedg4g8dk8sqwwczxl69gyvnqq84pha

$ cat cow.key
# created: 2023-06-01T09:56:06+02:00
# public key: age1mkmc34wqy8tdda58077cm2p0eg3xedg4g8dk8sqwwczxl69gyvnqq84pha
AGE-SECRET-KEY-1TU78S7WPWN78XHFKZD368Q4AGHRF6498E4H8ELWXZ5VURK7GZM9Q2WCPQX

$ age-keygen -o ansible.key   # create another because it's fun and they're cheap
Public key: age19q8pzgaxq2uynsrp3dluxv5apxmqym2pyldwpkp4s30qf4vfzqrsvvjzjv

I then encrypt the data I wish to protect to one or more public keys, first using ASCII armour, and then by base64-encoding the binary encrypted data, showing the two distinct output encodings which my Ansible filter (below) will support.

$ echo "The quick brown fox" |
     age -r "age1mkmc34wqy8tdda58077cm2p0eg3xedg4g8dk8sqwwczxl69gyvnqq84pha" -a
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB4Z3VVZDlnQmhHTG8zREJk
VUJ5eHNVLzZ6WFlhVXFkNzEvaHN3QnFkdGlFCm00QnZlcktveXo0bDdzVmZXM0Iz
QjA1SWVhVEY4dThsU2k5SStPS1dJaGcKLS0tIEdyRTduOFAvN0xHZTNMQi9RZnMz
cUNWVHJCV293ajhRSW5NUUptTC9ndmMKI5DvYVXGNT/I+5FBZ1saqSwac9ObmYZd
vK/exrZMqlXVwlsXXavxAPA8R9se6vpMYkkI7w==
-----END AGE ENCRYPTED FILE-----

$ echo "Pack my box with five dozen" |
     age -r "age19q8pzgaxq2uynsrp3dluxv5apxmqym2pyldwpkp4s30qf4vfzqrsvvjzjv" |
     base64
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBpQ21sTVN4ZDVkVEkzRWNCSkhYOGJaM3laOXlaRU5nNit3QmtDZkJUem00Cm1CdUFVSFVUNVEyVEY0S011YnJxQWdmYnljU1ZMb3VPTDN3ZzdBOUNRd0UKLS0tIERMeUdENUxFUVIvSUNxeXU5RmFYRDFRaWZpeEhjZXZDOVA2Q1F6K1VrTnMKmTG2+GrnkCezjKkTC3skuQGGEfleHh/PGfYxp0spYgITPNPXZs43cFECnyupz039QQtxljh1HBpggSe+

I ensure the local fact files, which must be named *.fact on Unix/Linux, are placed on the node I wish them to be on. The first in INI format, the second in JSON:

$ cat /etc/ansible/facts.d/pangram.fact
[p1]
short=YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBpQ21sTVN4ZDVkVEkzRWNCSkhYOGJaM3laOXlaRU5nNit3QmtDZkJUem00Cm1CdUFVSFVUNVEyVEY0S011YnJxQWdmYnljU1ZMb3VPTDN3ZzdBOUNRd0UKLS0tIERMeUdENUxFUVIvSUNxeXU5RmFYRDFRaWZpeEhjZXZDOVA2Q1F6K1VrTnMKmTG2+GrnkCezjKkTC3skuQGGEfleHh/PGfYxp0spYgITPNPXZs43cFECnyupz039QQtxljh1HBpggSe+

$ cat /etc/ansible/facts.d/other.fact
{
  "armored": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB4Z3VVZDlnQmhHTG8zREJk\nVUJ5eHNVLzZ6WFlhVXFkNzEvaHN3QnFkdGlFCm00QnZlcktveXo0bDdzVmZXM0Iz\nQjA1SWVhVEY4dThsU2k5SStPS1dJaGcKLS0tIEdyRTduOFAvN0xHZTNMQi9RZnMz\ncUNWVHJCV293ajhRSW5NUUptTC9ndmMKI5DvYVXGNT/I+5FBZ1saqSwac9ObmYZd\nvK/exrZMqlXVwlsXXavxAPA8R9se6vpMYkkI7w==\n-----END AGE ENCRYPTED FILE-----\n"
}

This is definitely a case in which I’d like Ansible to have support for YAML fact files (quite easily implemented as a custom executable fact – yq –tojson or Python come to mind), as the following looks much more elegant:

armored: |
   -----BEGIN AGE ENCRYPTED FILE-----
   YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB4Z3VVZDlnQmhHTG8zREJk
   VUJ5eHNVLzZ6WFlhVXFkNzEvaHN3QnFkdGlFCm00QnZlcktveXo0bDdzVmZXM0Iz
   QjA1SWVhVEY4dThsU2k5SStPS1dJaGcKLS0tIEdyRTduOFAvN0xHZTNMQi9RZnMz
   cUNWVHJCV293ajhRSW5NUUptTC9ndmMKI5DvYVXGNT/I+5FBZ1saqSwac9ObmYZd
   vK/exrZMqlXVwlsXXavxAPA8R9se6vpMYkkI7w==
   -----END AGE ENCRYPTED FILE-----

Now we have age-encrypted data on a controlled node. Where the data was encrypted is unimportant; what matters is that it is located on the node and can be read by the controlling node during a fact-gathering dance. When Ansible obtains the local facts from a node, it will see the likes of this:

"ansible_local": {
    "other": {
        "armored": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB4Z3VVZDlnQmhHTG8zREJk\nVUJ5eHNVLzZ6WFlhVXFkNzEvaHN3QnFkdGlFCm00QnZlcktveXo0bDdzVmZXM0Iz\nQjA1SWVhVEY4dThsU2k5SStPS1dJaGcKLS0tIEdyRTduOFAvN0xHZTNMQi9RZnMz\ncUNWVHJCV293ajhRSW5NUUptTC9ndmMKI5DvYVXGNT/I+5FBZ1saqSwac9ObmYZd\nvK/exrZMqlXVwlsXXavxAPA8R9se6vpMYkkI7w==\n-----END AGE ENCRYPTED FILE-----\n"
    },
    "pangram": {
        "p1": {
            "short": "YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBpQ21sTVN4ZDVkVEkzRWNCSkhYOGJaM3laOXlaRU5nNit3QmtDZkJUem00Cm1CdUFVSFVUNVEyVEY0S011YnJxQWdmYnljU1ZMb3VPTDN3ZzdBOUNRd0UKLS0tIERMeUdENUxFUVIvSUNxeXU5RmFYRDFRaWZpeEhjZXZDOVA2Q1F6K1VrTnMKmTG2+GrnkCezjKkTC3skuQGGEfleHh/PGfYxp0spYgITPNPXZs43cFECnyupz039QQtxljh1HBpggSe+"
        }
    }
},
"ansible_machine": "arm64"

Back on the Ansible controller machine, the playbook will use the content of the two facts which it will decrypt using an age identity; in the first instance using a default key file named ansible.key, and in the second the specified secret key, with both files located on the file system of the controller node (filters run in the templating engine which is invoked on the controller):

- hosts: alice
  tasks:
    - name: Decrypt the age-encrypted and base64-encoded pangram
      debug:
         msg: "{{ ansible_local.pangram.p1.short | age_d }} liquor jugs"

    - name: Decrypt the age-encrypted and ASCII-armoured 2nd pangram
      debug:
         msg: "{{ ansible_local.other.armored | age_d('cow.key') }}" 

    - name: Use age command to encrypt the current date string ...
      shell:
         cmd: "age -e -a -r age19q8pzgaxq2uynsrp3dluxv5apxmqym2pyldwpkp4s30qf4vfzqrsvvjzjv <(date)"
         executable: /bin/bash  # yuck
      register: c

    - name: ... and decrypt it using our filter
      debug:
         msg: "{{ c.stdout | age_d }}"

The output should be predictable:

PLAY [alice] *******************************************************

TASK [Gathering Facts] *********************************************
ok: [alice]

TASK [Decrypt the age-encrypted and base64-encoded pangram] ********
ok: [alice] => {
    "msg": "Pack my box with five dozen liquor jugs"
}

TASK [Decrypt the age-encrypted and ASCII-armoured 2nd pangram] ****
ok: [alice] => {
    "msg": "The quick brown fox"
}

TASK [Use age command to encrypt the current date string ...] ******
changed: [alice]

TASK [... and decrypt it using our filter] *************************
ok: [alice] => {
    "msg": "Fri Jun  2 12:55:17 UTC 2023"
}

For decryption the filter requires a path to a file containing identities (i.e. one or more secret keys). While this file could, in theory, be Ansible-vaulted, the age CLI sensibly has no provision for passing the identity on the CLI so Ansible would have to unvault the file before giving to age – probably quite unwise to do.

age can encrypt data to a set of recipients, making it possible to decrypt the data with distinct identities keys, say, when more than one Ansible controller access nodes and should use individual identities for decryption. age’s keys can also be password-protected, but I feel that would be overkill for this task.

On the train-ride back from Berlin today, I decided encryption might also be interesting, so the filter now has that too:

vars:
    recipient: "age19q8pzgaxq2uynsrp3dluxv5apxmqym2pyldwpkp4s30qf4vfzqrsvvjzjv"
tasks:
  - name: Encrypt to age with base64 encoding ..
    set_fact:
        secret: "{{ 'Moo 🐄' | age_e(recipient) | b64encode }}"
        armored: "{{ 'more moo 🐮' | age_e(recipient, true) }}"

  - name: .. and decrypt using age_d
    debug:
       msg: "{{ secret | age_d }}"

  - name: .. and decrypt the armored value using age_d
    debug:
       msg: "{{ armored | age_d }}"

And that produces this output:

TASK [Encrypt to age with base64 encoding ..] **************
ok: [localhost]

TASK [.. and decrypt using age_d] **************************
ok: [localhost] => {
    "msg": "Moo 🐄"
}

TASK [.. and decrypt the armored value using age_d] ********
ok: [localhost] => {
    "msg": "more moo 🐮"
}

I’m quite sure my filter’s Python code can be improved upon, so you know what to do: here it is.