mirror of
https://github.com/saltstack/salt.git
synced 2025-04-17 10:10:20 +00:00
Merge pull request #28921 from rallytime/bp-25470
Back-port #25470 to 2015.8
This commit is contained in:
commit
91a327bbce
2 changed files with 149 additions and 158 deletions
|
@ -2,8 +2,8 @@
|
|||
'''
|
||||
Renderer that will decrypt GPG ciphers
|
||||
|
||||
Any key in the SLS file can be a GPG cipher, and this renderer will decrypt
|
||||
it before passing it off to Salt. This allows you to safely store secrets in
|
||||
Any key in the SLS file can be a GPG cipher, and this renderer will decrypt it
|
||||
before passing it off to Salt. This allows you to safely store secrets in
|
||||
source control, in such a way that only your Salt master can decrypt them and
|
||||
distribute them only to the minions that need them.
|
||||
|
||||
|
@ -11,14 +11,17 @@ The typical use-case would be to use ciphers in your pillar data, and keep a
|
|||
secret key on your master. You can put the public key in source control so that
|
||||
developers can add new secrets quickly and easily.
|
||||
|
||||
This renderer requires the python-gnupg package. Be careful to install the
|
||||
``python-gnupg`` package, not the ``gnupg`` package, or you will get errors.
|
||||
This renderer requires the gpg binary.
|
||||
|
||||
**No python libraries are required as of the 2015.8.3 release.**
|
||||
|
||||
To set things up, you will first need to generate a keypair. On your master,
|
||||
run:
|
||||
|
||||
.. code-block:: bash
|
||||
.. code-block:: shell
|
||||
|
||||
# mkdir -p /etc/salt/gpgkeys
|
||||
# chmod 0700 /etc/salt/gpgkeys
|
||||
# gpg --gen-key --homedir /etc/salt/gpgkeys
|
||||
|
||||
Do not supply a password for your keypair, and use a name that makes sense
|
||||
|
@ -33,46 +36,36 @@ for your application. Be sure to back up your gpg directory someplace safe!
|
|||
|
||||
To retrieve the public key:
|
||||
|
||||
.. code-block:: bash
|
||||
.. code-block:: shell
|
||||
|
||||
# gpg --armor --homedir /etc/salt/gpgkeys --armor --export <KEY-NAME> \
|
||||
> exported_pubkey.gpg
|
||||
# gpg --homedir /etc/salt/gpgkeys --armor --export <KEY-NAME> \
|
||||
> exported_pubkey.gpg
|
||||
|
||||
Now, to encrypt secrets, copy the public key to your local machine and run:
|
||||
|
||||
.. code-block:: bash
|
||||
.. code-block:: shell
|
||||
|
||||
$ gpg --import exported_pubkey.gpg
|
||||
|
||||
To generate a cipher from a secret:
|
||||
|
||||
.. code-block:: bash
|
||||
.. code-block:: shell
|
||||
|
||||
$ echo -n "supersecret" | gpg --homedir ~/.gnupg --armor --encrypt -r <KEY-name>
|
||||
$ echo -n "supersecret" | gpg --armor --encrypt -r <KEY-name>
|
||||
|
||||
There are two ways to configure salt for the usage of this renderer:
|
||||
|
||||
1. Set up the renderer on your master by adding something like this line to your
|
||||
config:
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
renderer: jinja | yaml | gpg
|
||||
|
||||
This will apply the renderers to all pillars and states while requiring
|
||||
``python-gnupg`` to be installed on all minions since the decryption
|
||||
will happen on the minions.
|
||||
|
||||
2. To apply the renderer on a file-by-file basis add the following line to the top of any pillar with gpg data in it:
|
||||
To apply the renderer on a file-by-file basis add the following line to the
|
||||
top of any pillar with gpg data in it:
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
#!yaml|gpg
|
||||
|
||||
Now with your renderers configured, you can include your ciphers in your pillar data like so:
|
||||
Now with your renderer configured, you can include your ciphers in your pillar data like so:
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
#!yaml|gpg
|
||||
|
||||
a-secret: |
|
||||
-----BEGIN PGP MESSAGE-----
|
||||
Version: GnuPG v1
|
||||
|
@ -94,6 +87,7 @@ from __future__ import absolute_import
|
|||
import os
|
||||
import re
|
||||
import logging
|
||||
from subprocess import Popen, PIPE
|
||||
|
||||
# Import salt libs
|
||||
import salt.utils
|
||||
|
@ -102,44 +96,56 @@ from salt.exceptions import SaltRenderError
|
|||
|
||||
# Import 3rd-party libs
|
||||
import salt.ext.six as six
|
||||
# pylint: disable=import-error
|
||||
try:
|
||||
import gnupg
|
||||
HAS_GPG = True
|
||||
if salt.utils.which('gpg') is None:
|
||||
HAS_GPG = False
|
||||
except ImportError:
|
||||
HAS_GPG = False
|
||||
# pylint: enable=import-error
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
if salt.utils.which('gpg'):
|
||||
HAS_GPG = True
|
||||
else:
|
||||
HAS_GPG = False
|
||||
raise SaltRenderError('GPG unavailable')
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
GPG_HEADER = re.compile(r'-----BEGIN PGP MESSAGE-----')
|
||||
DEFAULT_GPG_KEYDIR = os.path.join(salt.syspaths.CONFIG_DIR, 'gpgkeys')
|
||||
|
||||
|
||||
def decrypt_ciphertext(cypher, gpg, safe=False):
|
||||
def _get_gpg_exec():
|
||||
'''
|
||||
return the GPG executable or raise an error
|
||||
'''
|
||||
gpg_exec = salt.utils.which('gpg')
|
||||
if gpg_exec:
|
||||
return gpg_exec
|
||||
else:
|
||||
raise SaltRenderError('GPG unavailable')
|
||||
|
||||
|
||||
def _get_key_dir():
|
||||
'''
|
||||
return the location of the GPG key directory
|
||||
'''
|
||||
if __salt__['config.get']('gpg_keydir'):
|
||||
return __salt__['config.get']('gpg_keydir')
|
||||
else:
|
||||
return os.path.join(salt.syspaths.CONFIG_DIR, 'gpgkeys')
|
||||
|
||||
|
||||
def _decrypt_ciphertext(cipher):
|
||||
'''
|
||||
Given a block of ciphertext as a string, and a gpg object, try to decrypt
|
||||
the cipher and return the decrypted string. If the cipher cannot be
|
||||
decrypted, log the error, and return the ciphertext back out.
|
||||
|
||||
:param safe: Raise an exception on failure instead of returning the ciphertext
|
||||
'''
|
||||
decrypted_data = gpg.decrypt(cypher)
|
||||
if not decrypted_data.ok:
|
||||
decrypt_err = "Could not decrypt cipher {0}, received {1}".format(
|
||||
cypher, decrypted_data.stderr)
|
||||
log.error(decrypt_err)
|
||||
if safe:
|
||||
raise SaltRenderError(decrypt_err)
|
||||
else:
|
||||
return cypher
|
||||
cmd = [_get_gpg_exec(), '--homedir', _get_key_dir(), '-d']
|
||||
proc = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE, shell=False)
|
||||
decrypted_data, decrypt_error = proc.communicate(input=cipher)
|
||||
if not decrypted_data:
|
||||
LOG.error('Could not decrypt cipher %s, received: %s', cipher, decrypt_error)
|
||||
return cipher
|
||||
else:
|
||||
return str(decrypted_data)
|
||||
|
||||
|
||||
def decrypt_object(obj, gpg):
|
||||
def _decrypt_object(obj):
|
||||
'''
|
||||
Recursively try to decrypt any object. If the object is a string, and
|
||||
it contains a valid GPG header, decrypt it, otherwise keep going until
|
||||
|
@ -147,16 +153,16 @@ def decrypt_object(obj, gpg):
|
|||
'''
|
||||
if isinstance(obj, str):
|
||||
if GPG_HEADER.search(obj):
|
||||
return decrypt_ciphertext(obj, gpg)
|
||||
return _decrypt_ciphertext(obj)
|
||||
else:
|
||||
return obj
|
||||
elif isinstance(obj, dict):
|
||||
for key, val in six.iteritems(obj):
|
||||
obj[key] = decrypt_object(val, gpg)
|
||||
obj[key] = _decrypt_object(val)
|
||||
return obj
|
||||
elif isinstance(obj, list):
|
||||
for n, v in enumerate(obj):
|
||||
obj[n] = decrypt_object(v, gpg)
|
||||
for key, value in enumerate(obj):
|
||||
obj[key] = _decrypt_object(value)
|
||||
return obj
|
||||
else:
|
||||
return obj
|
||||
|
@ -167,17 +173,8 @@ def render(gpg_data, saltenv='base', sls='', argline='', **kwargs):
|
|||
Create a gpg object given a gpg_keydir, and then use it to try to decrypt
|
||||
the data to be rendered.
|
||||
'''
|
||||
if not HAS_GPG:
|
||||
if not _get_gpg_exec():
|
||||
raise SaltRenderError('GPG unavailable')
|
||||
if 'config.get' in __salt__:
|
||||
homedir = __salt__['config.get']('gpg_keydir', DEFAULT_GPG_KEYDIR)
|
||||
else:
|
||||
homedir = __opts__.get('gpg_keydir', DEFAULT_GPG_KEYDIR)
|
||||
log.debug('Reading GPG keys from: {0}'.format(homedir))
|
||||
try:
|
||||
gpg = gnupg.GPG(gnupghome=homedir)
|
||||
except TypeError:
|
||||
gpg = gnupg.GPG()
|
||||
except OSError:
|
||||
raise SaltRenderError('Cannot initialize gnupg')
|
||||
return decrypt_object(gpg_data, gpg)
|
||||
LOG.debug('Reading GPG keys from: %s', _get_key_dir())
|
||||
|
||||
return _decrypt_object(gpg_data)
|
||||
|
|
|
@ -1,119 +1,113 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Import Python libs
|
||||
# Import Python Libs
|
||||
from __future__ import absolute_import
|
||||
import os
|
||||
from imp import find_module
|
||||
|
||||
# Import Salt Testing libs
|
||||
from salttesting import skipIf
|
||||
from salttesting import skipIf, TestCase
|
||||
from salttesting.helpers import ensure_in_syspath
|
||||
from salttesting.mock import patch, Mock, NO_MOCK, NO_MOCK_REASON
|
||||
from salttesting.mock import (
|
||||
NO_MOCK,
|
||||
NO_MOCK_REASON,
|
||||
MagicMock,
|
||||
patch
|
||||
)
|
||||
|
||||
ensure_in_syspath('../../')
|
||||
|
||||
# Import Salt libs
|
||||
import salt.loader
|
||||
import salt.config
|
||||
import salt.utils
|
||||
from integration import TMP, ModuleCase
|
||||
from salt.utils.odict import OrderedDict
|
||||
from salt.renderers import gpg
|
||||
from salt.exceptions import SaltRenderError
|
||||
|
||||
import copy
|
||||
|
||||
GPG_KEYDIR = os.path.join(TMP, 'gpg-keydir')
|
||||
|
||||
# The keyring library uses `getcwd()`, let's make sure we in a good directory
|
||||
# before importing keyring
|
||||
if not os.path.isdir(GPG_KEYDIR):
|
||||
os.makedirs(GPG_KEYDIR)
|
||||
|
||||
os.chdir(GPG_KEYDIR)
|
||||
|
||||
ENCRYPTED_STRING = '''
|
||||
-----BEGIN PGP MESSAGE-----
|
||||
I AM SO SECRET!
|
||||
-----END PGP MESSAGE-----
|
||||
'''
|
||||
DECRYPTED_STRING = 'I am not a secret anymore'
|
||||
SKIP = False
|
||||
|
||||
try:
|
||||
find_module('gnupg')
|
||||
except ImportError:
|
||||
SKIP = True
|
||||
|
||||
if salt.utils.which('gpg') is None:
|
||||
SKIP = True
|
||||
gpg.__salt__ = {}
|
||||
|
||||
|
||||
@skipIf(NO_MOCK, NO_MOCK_REASON)
|
||||
@skipIf(SKIP, "GPG must be installed")
|
||||
class GPGTestCase(ModuleCase):
|
||||
class GPGTestCase(TestCase):
|
||||
'''
|
||||
unit test GPG renderer
|
||||
'''
|
||||
def test__get_gpg_exec(self):
|
||||
'''
|
||||
test _get_gpg_exec
|
||||
'''
|
||||
gpg_exec = '/bin/gpg'
|
||||
|
||||
def setUp(self):
|
||||
opts = dict(copy.copy(self.minion_opts))
|
||||
opts['state_events'] = False
|
||||
opts['id'] = 'whatever'
|
||||
opts['file_client'] = 'local'
|
||||
opts['file_roots'] = dict(base=['/tmp'])
|
||||
opts['cachedir'] = 'cachedir'
|
||||
opts['test'] = False
|
||||
opts['grains'] = salt.loader.grains(opts)
|
||||
opts['gpg_keydir'] = GPG_KEYDIR
|
||||
opts['gpg_keydir'] = opts['gpg_keydir']
|
||||
with patch('salt.utils.which', MagicMock(return_value=gpg_exec)):
|
||||
self.assertEqual(gpg._get_gpg_exec(), gpg_exec)
|
||||
|
||||
self.opts = opts
|
||||
with patch('salt.utils.which', MagicMock(return_value=False)):
|
||||
self.assertRaises(SaltRenderError, gpg._get_gpg_exec)
|
||||
|
||||
self.funcs = salt.loader.minion_mods(self.opts)
|
||||
self.render = salt.loader.render(self.opts, self.funcs)['gpg']
|
||||
def test__get_key_dir(self):
|
||||
'''
|
||||
test _get_key_dir
|
||||
'''
|
||||
cfg_dir = '/gpg/cfg/dir'
|
||||
with patch.dict(gpg.__salt__, {'config.get': MagicMock(return_value=cfg_dir)}):
|
||||
self.assertEqual(gpg._get_key_dir(), cfg_dir)
|
||||
|
||||
def render_sls(self, data, sls='', env='base', **kws):
|
||||
return self.render(
|
||||
data, env=env, sls=sls, **kws
|
||||
)
|
||||
def_dir = '/etc/salt/gpgkeys'
|
||||
with patch.dict(gpg.__salt__, {'config.get': MagicMock(return_value=False)}):
|
||||
self.assertEqual(gpg._get_key_dir(), def_dir)
|
||||
|
||||
def make_decryption_mock(self):
|
||||
decrypted_data_mock = Mock()
|
||||
decrypted_data_mock.ok = True
|
||||
decrypted_data_mock.__str__ = lambda x: DECRYPTED_STRING
|
||||
return decrypted_data_mock
|
||||
def test__decrypt_ciphertext(self):
|
||||
'''
|
||||
test _decrypt_ciphertext
|
||||
'''
|
||||
key_dir = '/etc/salt/gpgkeys'
|
||||
secret = 'Use more salt.'
|
||||
crypted = '!@#$%^&*()_+'
|
||||
|
||||
def make_nested_object(self, s):
|
||||
return OrderedDict([
|
||||
('array_key', [1, False, s]),
|
||||
('string_key', 'A Normal String'),
|
||||
('dict_key', {1: None}),
|
||||
])
|
||||
class GPGDecrypt(object):
|
||||
def communicate(self, *args, **kwargs):
|
||||
return [secret, None]
|
||||
|
||||
@patch('gnupg.GPG')
|
||||
def test_homedir_is_passed_to_gpg(self, gpg_mock):
|
||||
self.render_sls({})
|
||||
gpg_mock.assert_called_with(gnupghome=self.opts['gpg_keydir'])
|
||||
class GPGNotDecrypt(object):
|
||||
def communicate(self, *args, **kwargs):
|
||||
return [None, 'decrypt error']
|
||||
|
||||
def test_normal_string_is_unchanged(self):
|
||||
s = 'I am just another string'
|
||||
new_s = self.render_sls(s)
|
||||
self.assertEqual(s, new_s)
|
||||
with patch('salt.renderers.gpg._get_key_dir', MagicMock(return_value=key_dir)):
|
||||
with patch('salt.renderers.gpg.Popen', MagicMock(return_value=GPGDecrypt())):
|
||||
self.assertEqual(gpg._decrypt_ciphertext(crypted), secret)
|
||||
with patch('salt.renderers.gpg.Popen', MagicMock(return_value=GPGNotDecrypt())):
|
||||
self.assertEqual(gpg._decrypt_ciphertext(crypted), crypted)
|
||||
|
||||
def test_encrypted_string_is_decrypted(self):
|
||||
with patch('gnupg.GPG.decrypt', return_value=self.make_decryption_mock()):
|
||||
new_s = self.render_sls(ENCRYPTED_STRING)
|
||||
self.assertEqual(new_s, DECRYPTED_STRING)
|
||||
def test__decrypt_object(self):
|
||||
'''
|
||||
test _decrypt_object
|
||||
'''
|
||||
|
||||
def test_encrypted_string_is_unchanged_when_gpg_fails(self):
|
||||
d_mock = self.make_decryption_mock()
|
||||
d_mock.ok = False
|
||||
with patch('gnupg.GPG.decrypt', return_value=d_mock):
|
||||
new_s = self.render_sls(ENCRYPTED_STRING)
|
||||
self.assertEqual(new_s, ENCRYPTED_STRING)
|
||||
secret = 'Use more salt.'
|
||||
crypted = '-----BEGIN PGP MESSAGE-----!@#$%^&*()_+'
|
||||
|
||||
secret_map = {'secret': secret}
|
||||
crypted_map = {'secret': crypted}
|
||||
|
||||
secret_list = [secret]
|
||||
crypted_list = [crypted]
|
||||
|
||||
with patch('salt.renderers.gpg._decrypt_ciphertext', MagicMock(return_value=secret)):
|
||||
self.assertEqual(gpg._decrypt_object(secret), secret)
|
||||
self.assertEqual(gpg._decrypt_object(crypted), secret)
|
||||
self.assertEqual(gpg._decrypt_object(crypted_map), secret_map)
|
||||
self.assertEqual(gpg._decrypt_object(crypted_list), secret_list)
|
||||
self.assertEqual(gpg._decrypt_object(None), None)
|
||||
|
||||
def test_render(self):
|
||||
'''
|
||||
test render
|
||||
'''
|
||||
|
||||
key_dir = '/etc/salt/gpgkeys'
|
||||
secret = 'Use more salt.'
|
||||
crypted = '-----BEGIN PGP MESSAGE-----!@#$%^&*()_+'
|
||||
|
||||
with patch('salt.renderers.gpg._get_gpg_exec', MagicMock(return_value=True)):
|
||||
with patch('salt.renderers.gpg._get_key_dir', MagicMock(return_value=key_dir)):
|
||||
with patch('salt.renderers.gpg._decrypt_object', MagicMock(return_value=secret)):
|
||||
self.assertEqual(gpg.render(crypted), secret)
|
||||
|
||||
def test_nested_object_is_decrypted(self):
|
||||
encrypted_o = self.make_nested_object(ENCRYPTED_STRING)
|
||||
decrypted_o = self.make_nested_object(DECRYPTED_STRING)
|
||||
with patch('gnupg.GPG.decrypt', return_value=self.make_decryption_mock()):
|
||||
new_o = self.render_sls(encrypted_o)
|
||||
self.assertEqual(new_o, decrypted_o)
|
||||
|
||||
if __name__ == '__main__':
|
||||
from integration import run_tests
|
||||
|
|
Loading…
Add table
Reference in a new issue