Browse code

Use vault_id when encrypted via vault-edit (#30772)

* Use vault_id when encrypted via vault-edit

On the encryption stage of
'ansible-vault edit --vault-id=someid@passfile somefile',
the vault id was not being passed to encrypt() so the files were
always saved with the default vault id in the 1.1 version format.

When trying to edit that file a second time, also with a --vault-id,
the file would be decrypted with the secret associated with the
provided vault-id, but since the encrypted file had no vault id
in the envelope there would be no match for 'default' secrets.
(Only the --vault-id was included in the potential matches, so
the vault id actually used to decrypt was not).

If that list was empty, there would be an IndexError when trying
to encrypted the changed file. This would result in the displayed
error:

ERROR! Unexpected Exception, this is probably a bug: list index out of range

Fix is two parts:

1) use the vault id when encrypting from edit

2) when matching the secret to use for encrypting after edit,
include the vault id that was used for decryption and not just
the vault id (or lack of vault id) from the envelope.

add unit tests for #30575 and intg tests for 'ansible-vault edit'

Fixes #30575

(cherry picked from commit a14d0f3586617f678b2843a25b521243a99cd589)

Adrian Likins authored on 2017/09/27 01:31:58
Showing 5 changed files
... ...
@@ -42,6 +42,7 @@ Ansible Changes By Release
42 42
 * Fix cpu facts on sparc64 (https://github.com/ansible/ansible/pull/30261)
43 43
 * Fix ansible_distribution fact for Arch linux (https://github.com/ansible/ansible/issues/30600)
44 44
 * remove print statements from play_context/become
45
+* Fix vault errors after 'ansible-vault edit' (https://github.com/ansible/ansible/issues/30575)
45 46
 
46 47
 <a id="2.4"></a>
47 48
 
... ...
@@ -491,6 +491,20 @@ class VaultLib:
491 491
         return b_vaulttext
492 492
 
493 493
     def decrypt(self, vaulttext, filename=None):
494
+        '''Decrypt a piece of vault encrypted data.
495
+
496
+        :arg vaulttext: a string to decrypt.  Since vault encrypted data is an
497
+            ascii text format this can be either a byte str or unicode string.
498
+        :kwarg filename: a filename that the data came from.  This is only
499
+            used to make better error messages in case the data cannot be
500
+            decrypted.
501
+        :returns: a byte string containing the decrypted data and the vault-id that was used
502
+
503
+        '''
504
+        plaintext, vault_id = self.decrypt_and_get_vault_id(vaulttext, filename=filename)
505
+        return plaintext
506
+
507
+    def decrypt_and_get_vault_id(self, vaulttext, filename=None):
494 508
         """Decrypt a piece of vault encrypted data.
495 509
 
496 510
         :arg vaulttext: a string to decrypt.  Since vault encrypted data is an
... ...
@@ -498,7 +512,8 @@ class VaultLib:
498 498
         :kwarg filename: a filename that the data came from.  This is only
499 499
             used to make better error messages in case the data cannot be
500 500
             decrypted.
501
-        :returns: a byte string containing the decrypted data
501
+        :returns: a byte string containing the decrypted data and the vault-id that was used
502
+
502 503
         """
503 504
         b_vaulttext = to_bytes(vaulttext, errors='strict', encoding='utf-8')
504 505
 
... ...
@@ -536,6 +551,7 @@ class VaultLib:
536 536
         # we check it first.
537 537
 
538 538
         vault_id_matchers = []
539
+        vault_id_used = None
539 540
 
540 541
         if vault_id:
541 542
             display.vvvvv('Found a vault_id (%s) in the vaulttext' % (vault_id))
... ...
@@ -563,6 +579,7 @@ class VaultLib:
563 563
                 display.vvvv('Trying secret %s for vault_id=%s' % (vault_secret, vault_secret_id))
564 564
                 b_plaintext = this_cipher.decrypt(b_vaulttext, vault_secret)
565 565
                 if b_plaintext is not None:
566
+                    vault_id_used = vault_secret_id
566 567
                     display.vvvvv('decrypt succesful with secret=%s and vault_id=%s' % (vault_secret, vault_secret_id))
567 568
                     break
568 569
             except AnsibleError as e:
... ...
@@ -581,7 +598,7 @@ class VaultLib:
581 581
                 msg += " on %s" % filename
582 582
             raise AnsibleError(msg)
583 583
 
584
-        return b_plaintext
584
+        return b_plaintext, vault_id_used
585 585
 
586 586
 
587 587
 class VaultEditor:
... ...
@@ -692,6 +709,7 @@ class VaultEditor:
692 692
 
693 693
         # shuffle tmp file into place
694 694
         self.shuffle_files(tmp_path, filename)
695
+        display.vvvvv('Saved edited file "%s" encrypted using %s and  vault id "%s"' % (filename, secret, vault_id))
695 696
 
696 697
     def _real_path(self, filename):
697 698
         # '-' is special to VaultEditor, dont expand it.
... ...
@@ -754,7 +772,8 @@ class VaultEditor:
754 754
 
755 755
         try:
756 756
             # vaulttext gets converted back to bytes, but alas
757
-            plaintext = self.vault.decrypt(vaulttext)
757
+            # TODO: return the vault_id that worked?
758
+            plaintext, vault_id_used = self.vault.decrypt_and_get_vault_id(vaulttext)
758 759
         except AnsibleError as e:
759 760
             raise AnsibleError("%s for %s" % (to_bytes(e), to_bytes(filename)))
760 761
 
... ...
@@ -762,15 +781,25 @@ class VaultEditor:
762 762
         # (duplicates parts of decrypt, but alas...)
763 763
         dummy, dummy, cipher_name, vault_id = parse_vaulttext_envelope(b_vaulttext)
764 764
 
765
-        # if we could decrypt, the vault_id should be in secrets
765
+        # vault id here may not be the vault id actually used for decrypting
766
+        # as when the edited file has no vault-id but is decrypted by non-default id in secrets
767
+        # (vault_id=default, while a different vault-id decrypted)
768
+
769
+        # if we could decrypt, the vault_id should be in secrets or we use vault_id_used
766 770
         # though we could have multiple secrets for a given vault_id, pick the first one
767
-        secrets = match_secrets(self.vault.secrets, [vault_id])
771
+        secrets = match_secrets(self.vault.secrets, [vault_id_used, vault_id])
772
+
773
+        if not secrets:
774
+            raise AnsibleVaultError('Attempting to encrypt "%s" but no vault secrets were found for vault ids "%s" or "%s"' %
775
+                                    (filename, vault_id, vault_id_used))
776
+
768 777
         secret = secrets[0][1]
778
+
769 779
         if cipher_name not in CIPHER_WRITE_WHITELIST:
770 780
             # we want to get rid of files encrypted with the AES cipher
771
-            self._edit_file_helper(filename, secret, existing_data=plaintext, force_save=True)
781
+            self._edit_file_helper(filename, secret, existing_data=plaintext, force_save=True, vault_id=vault_id)
772 782
         else:
773
-            self._edit_file_helper(filename, secret, existing_data=plaintext, force_save=False)
783
+            self._edit_file_helper(filename, secret, existing_data=plaintext, force_save=False, vault_id=vault_id)
774 784
 
775 785
     def plaintext(self, filename):
776 786
 
777 787
new file mode 100755
... ...
@@ -0,0 +1,44 @@
0
+#!/usr/bin/env python
1
+#
2
+# Ansible is free software: you can redistribute it and/or modify
3
+# it under the terms of the GNU General Public License as published by
4
+# the Free Software Foundation, either version 3 of the License, or
5
+# (at your option) any later version.
6
+#
7
+# Ansible is distributed in the hope that it will be useful,
8
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
9
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
10
+# GNU General Public License for more details.
11
+#
12
+# You should have received a copy of the GNU General Public License
13
+# along with Ansible.  If not, see <http://www.gnu.org/licenses/>.
14
+#
15
+# ansible-vault is a script that encrypts/decrypts YAML files. See
16
+# http://docs.ansible.com/playbooks_vault.html for more details.
17
+
18
+from __future__ import (absolute_import, division, print_function)
19
+__metaclass__ = type
20
+
21
+import sys
22
+import time
23
+import os
24
+
25
+
26
+def main(args):
27
+    path = os.path.abspath(args[1])
28
+
29
+    fo = open(path, 'r+')
30
+
31
+    content = fo.readlines()
32
+
33
+    content.append('faux editor added at %s\n' % time.time())
34
+
35
+    fo.seek(0)
36
+    fo.write(''.join(content))
37
+    fo.close()
38
+
39
+    return 0
40
+
41
+
42
+if __name__ == '__main__':
43
+    sys.exit(main(sys.argv[:]))
... ...
@@ -14,7 +14,14 @@ echo "This is a test file for format 1.2" > "${TEST_FILE_1_2}"
14 14
 
15 15
 TEST_FILE_OUTPUT="${MYTMPDIR}/test_file_output"
16 16
 
17
+TEST_FILE_EDIT="${MYTMPDIR}/test_file_edit"
18
+echo "This is a test file for edit" > "${TEST_FILE_EDIT}"
17 19
 
20
+TEST_FILE_EDIT2="${MYTMPDIR}/test_file_edit2"
21
+echo "This is a test file for edit2" > "${TEST_FILE_EDIT2}"
22
+
23
+FORMAT_1_1_HEADER="\$ANSIBLE_VAULT;1.1;AES256"
24
+FORMAT_1_2_HEADER="\$ANSIBLE_VAULT;1.2;AES256"
18 25
 
19 26
 # old format
20 27
 ansible-vault view "$@" --vault-password-file vault-password-ansible format_1_0_AES.yml
... ...
@@ -234,6 +241,27 @@ ansible-vault encrypt_string "$@" --vault-password-file "${NEW_VAULT_PASSWORD}"
234 234
 # write to file
235 235
 ansible-vault encrypt_string "$@" --vault-password-file "${NEW_VAULT_PASSWORD}" --name "blippy" "a test string names blippy" --output "${MYTMPDIR}/enc_string_test_file"
236 236
 
237
+# test ansible-vault edit with a faux editor
238
+ansible-vault encrypt "$@" --vault-password-file vault-password "${TEST_FILE_EDIT}"
239
+
240
+# edit a 1.1 format with no vault-id, should stay 1.1
241
+EDITOR=./faux-editor.py ansible-vault edit "$@" --vault-password-file vault-password "${TEST_FILE_EDIT}"
242
+head -1 "${TEST_FILE_EDIT}" | grep "${FORMAT_1_1_HEADER}"
243
+
244
+# edit a 1.1 format with vault-id, should stay 1.1
245
+EDITOR=./faux-editor.py ansible-vault edit "$@" --vault-id vault_password@vault-password "${TEST_FILE_EDIT}"
246
+head -1 "${TEST_FILE_EDIT}" | grep "${FORMAT_1_1_HEADER}"
247
+
248
+ansible-vault encrypt "$@" --vault-id vault_password@vault-password "${TEST_FILE_EDIT2}"
249
+
250
+# edit a 1.2 format with vault id, should keep vault id and 1.2 format
251
+EDITOR=./faux-editor.py ansible-vault edit "$@" --vault-id vault_password@vault-password "${TEST_FILE_EDIT2}"
252
+head -1 "${TEST_FILE_EDIT2}" | grep "${FORMAT_1_2_HEADER};vault_password"
253
+
254
+# edit a 1.2 file with no vault-id, should keep vault id and 1.2 format
255
+EDITOR=./faux-editor.py ansible-vault edit "$@" --vault-password-file vault-password "${TEST_FILE_EDIT2}"
256
+head -1 "${TEST_FILE_EDIT2}" | grep "${FORMAT_1_2_HEADER};vault_password"
257
+
237 258
 
238 259
 # test playbooks using vaulted files
239 260
 ansible-playbook test_vault.yml          -i ../../inventory -v "$@" --vault-password-file vault-password --list-tasks
... ...
@@ -309,7 +309,7 @@ class TestVaultEditor(unittest.TestCase):
309 309
         self._assert_file_is_link(src_file_link_path, src_file_path)
310 310
 
311 311
     @patch('ansible.parsing.vault.subprocess.call')
312
-    def test_edit_file(self, mock_sp_call):
312
+    def test_edit_file_no_vault_id(self, mock_sp_call):
313 313
         self._test_dir = self._create_test_dir()
314 314
         src_contents = to_bytes("some info in a file\nyup.")
315 315
 
... ...
@@ -330,6 +330,36 @@ class TestVaultEditor(unittest.TestCase):
330 330
         new_src_file = open(src_file_path, 'rb')
331 331
         new_src_file_contents = new_src_file.read()
332 332
 
333
+        self.assertTrue(b'$ANSIBLE_VAULT;1.1;AES256' in new_src_file_contents)
334
+
335
+        src_file_plaintext = ve.vault.decrypt(new_src_file_contents)
336
+        self.assertEqual(src_file_plaintext, new_src_contents)
337
+
338
+    @patch('ansible.parsing.vault.subprocess.call')
339
+    def test_edit_file_with_vault_id(self, mock_sp_call):
340
+        self._test_dir = self._create_test_dir()
341
+        src_contents = to_bytes("some info in a file\nyup.")
342
+
343
+        src_file_path = self._create_file(self._test_dir, 'src_file', content=src_contents)
344
+
345
+        new_src_contents = to_bytes("The info is different now.")
346
+
347
+        def faux_editor(editor_args):
348
+            self._faux_editor(editor_args, new_src_contents)
349
+
350
+        mock_sp_call.side_effect = faux_editor
351
+
352
+        ve = self._vault_editor()
353
+
354
+        ve.encrypt_file(src_file_path, self.vault_secret,
355
+                        vault_id='vault_secrets')
356
+        ve.edit_file(src_file_path)
357
+
358
+        new_src_file = open(src_file_path, 'rb')
359
+        new_src_file_contents = new_src_file.read()
360
+
361
+        self.assertTrue(b'$ANSIBLE_VAULT;1.2;AES256;vault_secrets' in new_src_file_contents)
362
+
333 363
         src_file_plaintext = ve.vault.decrypt(new_src_file_contents)
334 364
         self.assertEqual(src_file_plaintext, new_src_contents)
335 365