├── .circleci
└── config.yml
├── .gitignore
├── HISTORY.rst
├── LICENSE
├── MANIFEST.in
├── README.rst
├── RELEASE.md
├── requirements.txt
├── secretcrypt
├── __init__.py
├── base_aes.py
├── decrypt_secret.py
├── encrypt_secret.py
├── kms.py
├── local.py
├── mock_crypter.py
├── password.py
├── plain.py
└── tests
│ ├── __init__.py
│ ├── test_decrypt_secret.py
│ ├── test_encrypt_secret.py
│ ├── test_kms.py
│ ├── test_local.py
│ ├── test_password.py
│ ├── test_plain.py
│ └── test_secret.py
├── setup.py
└── tox.ini
/.circleci/config.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 |
3 | jobs:
4 | test:
5 | docker:
6 | - image: fkrull/multi-python
7 | steps:
8 | - checkout
9 | - run:
10 | name: Test
11 | command: 'tox'
12 | - run:
13 | name: Upload code coverage
14 | command: 'bash <(curl -s https://codecov.io/bash)'
15 |
16 | workflows:
17 | version: 2
18 | test:
19 | jobs:
20 | - test
21 |
22 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | env/
12 | build/
13 | develop-eggs/
14 | dist/
15 | downloads/
16 | eggs/
17 | .eggs/
18 | lib/
19 | lib64/
20 | parts/
21 | sdist/
22 | var/
23 | *.egg-info/
24 | .installed.cfg
25 | *.egg
26 |
27 | # PyInstaller
28 | # Usually these files are written by a python script from a template
29 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
30 | *.manifest
31 | *.spec
32 |
33 | # Installer logs
34 | pip-log.txt
35 | pip-delete-this-directory.txt
36 |
37 | # Unit test / coverage reports
38 | htmlcov/
39 | .tox/
40 | .coverage
41 | .coverage.*
42 | .cache
43 | nosetests.xml
44 | coverage.xml
45 | *,cover
46 | .hypothesis/
47 |
48 | # Translations
49 | *.mo
50 | *.pot
51 |
52 | # Django stuff:
53 | *.log
54 |
55 | # Sphinx documentation
56 | docs/_build/
57 |
58 | # PyBuilder
59 | target/
60 |
61 | #Ipython Notebook
62 | .ipynb_checkpoints
63 |
--------------------------------------------------------------------------------
/HISTORY.rst:
--------------------------------------------------------------------------------
1 | .. :changelog:
2 |
3 | History
4 | -------
5 |
6 | 1.0.5 (unreleased)
7 | ++++++++++++++++++
8 |
9 | - Nothing changed yet.
10 |
11 |
12 | 1.0.4 (2020-09-21)
13 | ++++++++++++++++++
14 |
15 | - fixed reraise error in decrypt module
16 |
17 |
18 | 1.0.3 (2017-11-02)
19 | ++++++++++++++++++
20 |
21 | - reverted scrypt parameter changes
22 |
23 |
24 | 1.0.2 (2017-10-31)
25 | ++++++++++++++++++
26 |
27 | - changed scrypt parameters
28 |
29 |
30 | 1.0.1 (2017-10-31)
31 | ++++++++++++++++++
32 |
33 | - Fixed readme formatting.
34 |
35 |
36 | 1.0.0 (2017-10-31)
37 | ++++++++++++++++++
38 |
39 | * added password encryption/decryption
40 |
41 | 0.9.1 (2017-03-28)
42 | ++++++++++++++++++
43 |
44 | * Python3 local module fixed issue with utf-8
45 | * unpinned dependencies
46 |
47 | 0.4 (2016-03-02)
48 | ++++++++++++++++++
49 |
50 | * plaintexts are now returned as strings not as bytes
51 |
52 | 0.3 (2016-03-02)
53 | ++++++++++++++++++
54 |
55 | * BREAKING CHANGE: introduced new semantics for Secret and a new StrictSecret
56 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "{}"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright 2015-2016 Zemanta
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include HISTORY.rst
2 | include LICENSE
3 | include README.rst
4 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | py-secretcrypt
2 | ==============
3 |
4 | |Circle CI|
5 | |Codecov|
6 |
7 | .. raw:: html
8 |
9 |
10 |
11 |
12 | Utility for keeping your secrets encrypted. Also has a `Go
13 | version `__.
14 |
15 | For example, you have the following configuration file
16 |
17 | ::
18 |
19 | MY_SECRET=VerySecretValue!
20 |
21 | but you can't include that file in VCS because then your secret value
22 | would be exposed.
23 |
24 | With **secretcrypt**, you can encrypt your secret using your AWS KMS
25 | master key aliased *MyKey*:
26 |
27 | .. code:: bash
28 |
29 | $ encrypt-secret kms alias/MyKey
30 | Enter plaintext: VerySecretValue! # enter
31 | kms:region=us-east-1:CiC/SXeuXDGRADRIjc0qcE... # shortened for brevity
32 |
33 | # --- or --
34 | $ echo "VerySecretValue!" | encrypt-secret kms alias/MyKey
35 | kms:region=us-east-1:CiC/SXeuXDGRADRIjc0qcE... # shortened for brevity
36 | # only use piping when scripting, otherwise your secrets will be stored
37 | # in your shell's history!
38 |
39 | use that secret in my config file
40 |
41 | .. code:: python
42 |
43 | from secretcrypt import Secret
44 | MY_SECRET=Secret('kms:region=us-east-1:CiC/SXeuXDGRADRIjc0qcE...') # shortened for brevity
45 |
46 | and get the plaintext like
47 |
48 | .. code:: python
49 |
50 | print MY_SECRET.get()
51 | # VerySecretValue!
52 |
53 | If you are using very sensitive secrets, you can ensure the plaintext
54 | is not kept in memory and is only encrypted on demand by using a stricter
55 | version:
56 |
57 | .. code:: python
58 |
59 | from secretcrypt import StrictSecret
60 | MY_SECRET=StrictSecret('kms:region=us-east-1:CiC/SXeuXDGRADRIjc0qcE...') # shortened for brevity
61 |
62 | and get the plaintext like
63 |
64 | .. code:: python
65 |
66 | print MY_SECRET.decrypt()
67 | # VerySecretValue!
68 |
69 | KMS
70 | ---
71 |
72 | The KMS option uses AWS Key Management Service. When encrypting and
73 | decrypting KMS secrets, you need to provide which AWS region the is to
74 | be or was encrypted on, but it defaults to ``us-east-1``.
75 |
76 | So if you use a custom region, you must provide it to secretcrypt:
77 |
78 | .. code:: bash
79 |
80 | encrypt-secret kms --region us-west-1 alias/MyKey
81 |
82 | Local encryption
83 | ----------------
84 |
85 | This mode is meant for local and/or offline development usage. It
86 | generates a local key in your %USER\_DATA\_DIR% (see
87 | `appdirs `__), so that the key
88 | cannot be accidentally committed to CVS.
89 |
90 | It then uses that key to symmetrically encrypt and decrypt your secrets.
91 |
92 | Password encryption - interactive only
93 | --------------------------------------
94 |
95 | The password encryption mode should not be used in your application - it is
96 | meant for easily sharing secrets among developers. It interactively prompts
97 | the user for a password when encrypting the secret. When decrypting, it
98 | prompts for the password again.
99 |
100 |
101 | .. |Circle CI| image:: https://circleci.com/gh/Zemanta/py-secretcrypt.svg?style=svg
102 | :target: https://circleci.com/gh/Zemanta/py-secretcrypt
103 | .. |Codecov| image:: https://codecov.io/gh/Zemanta/py-secretcrypt/branch/master/graph/badge.svg
104 | :target: https://codecov.io/gh/Zemanta/py-secretcrypt
105 |
--------------------------------------------------------------------------------
/RELEASE.md:
--------------------------------------------------------------------------------
1 | ### Using zest.releaser
2 |
3 | * ensure `.pypirc` is set up
4 |
5 | * install zest releaser
6 |
7 | ```
8 | pip install zest.releaser
9 | ```
10 |
11 | * check HISTORY.rst and update changelog for current dev version (which will become the released version)
12 |
13 | * run the release procedure
14 |
15 | ```
16 | fullrelease
17 | ```
18 |
19 |
20 | ### Vanilla procedure - for reference only
21 |
22 | * Follow the procedure
23 | [here](http://peterdowns.com/posts/first-time-with-pypi.html)
24 |
25 | * Create a git tag
26 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | docopt>=0.6.2
2 | six>=1.10.0
3 |
4 | # kms
5 | boto3>=1.2.3
6 | python-dateutil>=2.4.2
7 |
8 | # local
9 | pyaes>=1.3.0
10 |
11 | # password
12 | pyscrypt>=1.6.2
13 |
14 | # test
15 | mock>=1.3.0
16 | nose>=1.3.7
17 | coverage>=4.4.1
18 |
--------------------------------------------------------------------------------
/secretcrypt/__init__.py:
--------------------------------------------------------------------------------
1 | import importlib
2 | import six
3 | from six.moves import urllib
4 | import sys
5 |
6 | CRYPTER_MODULES = [
7 | 'local',
8 | 'kms',
9 | 'plain',
10 | 'password',
11 | 'mock_crypter',
12 | ]
13 |
14 |
15 | class StrictSecret(object):
16 | """Represents an encrypted secret that can be decrypted on demand.
17 |
18 | Decrypting this secret may incur a side-effect such as a call to a remote
19 | service for decryption.
20 | """
21 |
22 | def __init__(self, secret):
23 | if len(secret) == 0:
24 | # empty secret object
25 | self._crypter = None
26 | return
27 |
28 | tokens = secret.split(':')
29 | if len(tokens) < 3:
30 | raise ValueError('Malformed secret "%s"' % secret)
31 |
32 | crypter_name = tokens[0]
33 | if crypter_name not in CRYPTER_MODULES:
34 | raise ValueError(('Invalid encryption module in secret "%s": %s, ' +
35 | 'not one of %s') % (secret, crypter_name, CRYPTER_MODULES))
36 | try:
37 | self._crypter = importlib.import_module('.' + crypter_name.lower(), package=__name__)
38 | except ImportError as e:
39 | raise ValueError(('Problem importing encryption module "%s", are ' +
40 | 'you missing dependencies? %s') % (crypter_name, e))
41 |
42 | try:
43 | self._decrypt_params = {}
44 | if tokens[1]:
45 | params = urllib.parse.parse_qs(tokens[1], strict_parsing=True)
46 | self._decrypt_params = {k: v[0] for k, v in params.items()}
47 | except ValueError as e:
48 | raise ValueError('Invalid decryption parameters in secret "%s": %s' % (secret, e))
49 |
50 | ciphertext = ':'.join(tokens[2:])
51 | if isinstance(ciphertext, six.string_types):
52 | ciphertext = ciphertext.encode('utf-8') # convert to bytes
53 | self._ciphertext = ciphertext
54 |
55 | def decrypt(self):
56 | """Decrypt decrypts the secret and returns the plaintext.
57 |
58 | Calling decrypt() may incur side effects such as a call to a remote service for decryption.
59 | """
60 | if not self._crypter:
61 | return b''
62 | try:
63 | plaintext = self._crypter.decrypt(self._ciphertext, **self._decrypt_params)
64 | return plaintext
65 | except Exception as e:
66 | exc_info = sys.exc_info()
67 | six.reraise(
68 | ValueError,
69 | ValueError('Invalid ciphertext "%s", error: %s' % (self._ciphertext, e)),
70 | exc_info[2]
71 | )
72 |
73 | def __str__(self):
74 | """Redacted string representation."""
75 | return ''
76 |
77 | def __repr__(self):
78 | """Redacted repr representation."""
79 | return ''
80 |
81 |
82 | class Secret(object):
83 | """Represents a secret that is eagerly decrypted on object creation.
84 |
85 | After that, using this secret does not incur any side effects.
86 | """
87 |
88 | def __init__(self, secret):
89 | self.__plaintext_bytes = None
90 | self.__plaintext_bytes = StrictSecret(secret).decrypt()
91 |
92 | def get(self):
93 | """Return the secret in plain text.
94 |
95 | Calling get() does not incur any side effects.
96 | """
97 | return self.__plaintext_bytes
98 |
99 | def __str__(self):
100 | """Redacted string representation."""
101 | return ''
102 |
103 | def __repr__(self):
104 | """Redacted repr representation."""
105 | return ''
106 |
--------------------------------------------------------------------------------
/secretcrypt/base_aes.py:
--------------------------------------------------------------------------------
1 | import base64
2 | import os
3 |
4 | import pyaes
5 |
6 |
7 | def encrypt_plaintext(key, plaintext):
8 | iv = os.urandom(16)
9 | aes = pyaes.AESModeOfOperationCBC(key, iv=iv)
10 | encrypter = pyaes.Encrypter(aes)
11 | ciphertext_blob = encrypter.feed(plaintext)
12 | ciphertext_blob += encrypter.feed() # flush
13 | return base64.b64encode(iv + ciphertext_blob)
14 |
15 |
16 | def decrypt_ciphertext(key, ciphertext):
17 | blob = base64.b64decode(ciphertext)
18 | iv = blob[:16]
19 | ciphertext_blob = blob[16:]
20 | aes = pyaes.AESModeOfOperationCBC(key, iv=iv)
21 | decrypter = pyaes.Decrypter(aes)
22 | plaintext = decrypter.feed(ciphertext_blob)
23 | plaintext += decrypter.feed() # flush
24 | return plaintext
25 |
--------------------------------------------------------------------------------
/secretcrypt/decrypt_secret.py:
--------------------------------------------------------------------------------
1 | """
2 | Encrypted secrets.
3 |
4 | Usage:
5 | decrypt-secret
6 | """
7 | from __future__ import print_function
8 | from docopt import docopt
9 |
10 | from secretcrypt import StrictSecret
11 |
12 |
13 | def decrypt_secret_cmd():
14 | arguments = docopt(__doc__, options_first=True)
15 | secret = StrictSecret(arguments[''])
16 | print(secret.decrypt().decode('utf-8'))
17 |
18 | if __name__ == '__main__':
19 | decrypt_secret_cmd()
20 |
--------------------------------------------------------------------------------
/secretcrypt/encrypt_secret.py:
--------------------------------------------------------------------------------
1 | """
2 | Encrypts secrets. Reads secrets as user input or from standard input.
3 |
4 | Usage:
5 | encrypt-secret [options] kms [--region=]
6 | encrypt-secret [options] local
7 | encrypt-secret [options] plain
8 | encrypt-secret [options] password
9 |
10 | Options:
11 | --region= AWS Region Name [default: us-east-1]
12 | --multiline Multiline input (read stdin bytes until EOF)
13 | """
14 | from __future__ import print_function
15 | from docopt import docopt
16 | from six.moves import urllib
17 | import os
18 | import sys
19 |
20 |
21 | def encrypt_secret(module, plaintext, encrypt_params):
22 | ciphertext, decrypt_params = module.encrypt(plaintext, **encrypt_params)
23 | module_name = module.__name__.split('.')[-1]
24 | return '{module_name}:{decrypt_params}:{ciphertext}'.format(
25 | module_name=module_name,
26 | ciphertext=ciphertext.decode('utf-8'),
27 | decrypt_params=urllib.parse.urlencode(decrypt_params),
28 | )
29 |
30 |
31 | def encrypt_secret_cmd():
32 | arguments = docopt(__doc__, options_first=True)
33 | encrypt_params = dict()
34 | if arguments['kms']:
35 | from secretcrypt import kms
36 | encrypt_params = dict(
37 | region=arguments['--region'],
38 | key_id=arguments[''],
39 | )
40 | module = kms
41 | elif arguments['local']:
42 | from secretcrypt import local
43 | module = local
44 | elif arguments['plain']:
45 | from secretcrypt import plain
46 | module = plain
47 | elif arguments['password']:
48 | from secretcrypt import password
49 | module = password
50 |
51 | if arguments['--multiline']:
52 | plaintext = sys.stdin.read()
53 | else:
54 | # do not print prompt if input is being piped
55 | if sys.stdin.isatty():
56 | print('Enter plaintext: ', end="", file=sys.stderr),
57 | sys.stderr.flush()
58 | stdin = os.fdopen(sys.stdin.fileno(), 'rb', 0)
59 | plaintext = stdin.readline().rstrip(b'\n')
60 |
61 | secret = encrypt_secret(module, plaintext, encrypt_params)
62 | print(secret)
63 |
64 |
65 | if __name__ == '__main__':
66 | encrypt_secret_cmd()
67 |
--------------------------------------------------------------------------------
/secretcrypt/kms.py:
--------------------------------------------------------------------------------
1 | import base64
2 |
3 | import boto3
4 |
5 | _kms_clients = {}
6 |
7 |
8 | def _kms_client(region):
9 | global _kms_clients
10 | if region not in _kms_clients:
11 | kms_client = boto3.client('kms', region_name=region)
12 | _kms_clients[region] = kms_client
13 | return _kms_clients[region]
14 |
15 |
16 | def encrypt(plaintext, region, key_id):
17 | ciphertext_blob = _kms_client(region).encrypt(
18 | KeyId=key_id,
19 | Plaintext=plaintext
20 | )['CiphertextBlob']
21 | return base64.b64encode(ciphertext_blob), dict(region=region)
22 |
23 |
24 | def decrypt(ciphertext, region):
25 | return _kms_client(region).decrypt(
26 | CiphertextBlob=base64.b64decode(ciphertext)
27 | )['Plaintext']
28 |
--------------------------------------------------------------------------------
/secretcrypt/local.py:
--------------------------------------------------------------------------------
1 | import base64
2 | import os
3 | import sys
4 |
5 | from secretcrypt import base_aes
6 |
7 |
8 | __key = None
9 |
10 |
11 | def _key():
12 | global __key
13 | if __key:
14 | return __key
15 |
16 | data_dir = _key_dir()
17 | key_file = os.path.join(data_dir, 'key')
18 |
19 | if os.path.isfile(key_file):
20 | with open(key_file, 'rb') as f:
21 | __key = base64.b64decode(f.read())
22 | return __key
23 |
24 | __key = base64.b64encode(os.urandom(16))
25 | try:
26 | os.makedirs(data_dir)
27 | except OSError as e:
28 | # errno17 == dir exists
29 | if e.errno != 17:
30 | raise
31 | with open(key_file, 'wb') as f:
32 | f.write(__key)
33 | return __key
34 |
35 |
36 | def _key_dir():
37 | data_dir = os.getenv('XDG_DATA_HOME', os.path.expanduser('~/.local/share'))
38 | if sys.platform.startswith('darwin'):
39 | data_dir = os.path.expanduser('~/Library/Application Support')
40 | elif sys.platform in ['win32', 'cygwin']:
41 | data_dir = os.path.expanduser('~\\AppData\\Local\\')
42 | return os.path.join(data_dir, "secretcrypt")
43 |
44 |
45 | def encrypt(plaintext):
46 | return base_aes.encrypt_plaintext(_key(), plaintext), {}
47 |
48 |
49 | def decrypt(ciphertext):
50 | return base_aes.decrypt_ciphertext(_key(), ciphertext)
51 |
--------------------------------------------------------------------------------
/secretcrypt/mock_crypter.py:
--------------------------------------------------------------------------------
1 | def encrypt(plaintext, my_decrypt_param):
2 | return b'ciphertext', dict(my_decrypt_param=my_decrypt_param)
3 |
4 |
5 | def decrypt(ciphertext):
6 | return b'plaintext'
7 |
--------------------------------------------------------------------------------
/secretcrypt/password.py:
--------------------------------------------------------------------------------
1 | from __future__ import print_function
2 | import base64
3 | import os
4 | import getpass
5 |
6 | import pyscrypt
7 |
8 | from secretcrypt import base_aes
9 |
10 |
11 | def _get_key_salt(salt=None):
12 | password = getpass.getpass('Enter password: ').encode()
13 | if not salt:
14 | salt = base64.b64encode(os.urandom(16))
15 | key = pyscrypt.hash(
16 | password=password,
17 | salt=salt,
18 | N=1024,
19 | r=1,
20 | p=1,
21 | dkLen=24,
22 | )
23 | return key, salt
24 |
25 |
26 | def encrypt(plaintext):
27 | key, salt = _get_key_salt()
28 | return base_aes.encrypt_plaintext(key, plaintext), {'salt': salt}
29 |
30 |
31 | def decrypt(ciphertext, salt):
32 | key, _ = _get_key_salt(salt)
33 | return base_aes.decrypt_ciphertext(key, ciphertext)
34 |
--------------------------------------------------------------------------------
/secretcrypt/plain.py:
--------------------------------------------------------------------------------
1 | def encrypt(plaintext):
2 | return plaintext, dict()
3 |
4 |
5 | def decrypt(plaintext):
6 | return plaintext
7 |
--------------------------------------------------------------------------------
/secretcrypt/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Zemanta/py-secretcrypt/82f5352e8348d93870ad2ccb14e347d6404c5874/secretcrypt/tests/__init__.py
--------------------------------------------------------------------------------
/secretcrypt/tests/test_decrypt_secret.py:
--------------------------------------------------------------------------------
1 | import mock
2 | import sys
3 | import unittest
4 |
5 | from secretcrypt import decrypt_secret
6 |
7 |
8 | class TestDecryptCmd(unittest.TestCase):
9 |
10 | @mock.patch.object(sys, 'stdout')
11 | def test_encrypt_kms(self, mock_stdout):
12 | with mock.patch.object(sys, 'argv', ['decrypt-secret', 'mock_crypter::test']):
13 | decrypt_secret.decrypt_secret_cmd()
14 | mock_stdout.write.assert_has_calls([
15 | mock.call('plaintext'),
16 | mock.call('\n'),
17 | ])
18 |
--------------------------------------------------------------------------------
/secretcrypt/tests/test_encrypt_secret.py:
--------------------------------------------------------------------------------
1 | import mock
2 | import sys
3 | import os
4 | import unittest
5 |
6 | from secretcrypt import encrypt_secret, mock_crypter, kms, plain, local, password
7 |
8 |
9 | class TestEncryptHelper(unittest.TestCase):
10 |
11 | def test_encrypt(self):
12 | secret = encrypt_secret.encrypt_secret(
13 | mock_crypter,
14 | b'myplaintext',
15 | dict(my_decrypt_param='abc')
16 | )
17 | self.assertEqual('mock_crypter:my_decrypt_param=abc:ciphertext',
18 | secret)
19 |
20 |
21 | class TestEncryptCmd(unittest.TestCase):
22 |
23 | def setUp(self):
24 | patcher = mock.patch.object(encrypt_secret, 'encrypt_secret')
25 | self.addCleanup(patcher.stop)
26 | self.mock_encrypt_secret = patcher.start()
27 |
28 | patcher = mock.patch.object(os, 'fdopen')
29 | self.addCleanup(patcher.stop)
30 | mock_fdopen = patcher.start()
31 | self.mock_stdin = mock.MagicMock()
32 | mock_fdopen.return_value = self.mock_stdin
33 |
34 | def test_encrypt_kms(self):
35 | with mock.patch.object(sys, 'argv', ['encrypt-secret', 'kms', 'alias/MyKey']):
36 | self.mock_stdin.readline.return_value = b'myplaintext\n'
37 | encrypt_secret.encrypt_secret_cmd()
38 | self.mock_encrypt_secret.assert_called_once_with(
39 | kms,
40 | b'myplaintext',
41 | dict(region='us-east-1', key_id='alias/MyKey')
42 | )
43 |
44 | def test_encrypt_plain(self):
45 | with mock.patch.object(sys, 'argv', ['encrypt-secret', 'plain']):
46 | self.mock_stdin.readline.return_value = b'myplaintext\n'
47 | encrypt_secret.encrypt_secret_cmd()
48 | self.mock_encrypt_secret.assert_called_once_with(
49 | plain,
50 | b'myplaintext',
51 | dict()
52 | )
53 |
54 | def test_encrypt_local(self):
55 | with mock.patch.object(sys, 'argv', ['encrypt-secret', 'local']):
56 | self.mock_stdin.readline.return_value = b'myplaintext\n'
57 | encrypt_secret.encrypt_secret_cmd()
58 | self.mock_encrypt_secret.assert_called_once_with(
59 | local,
60 | b'myplaintext',
61 | dict()
62 | )
63 |
64 | def test_encrypt_password(self):
65 | with mock.patch.object(sys, 'argv', ['encrypt-secret', 'password']):
66 | self.mock_stdin.readline.return_value = b'myplaintext\n'
67 | encrypt_secret.encrypt_secret_cmd()
68 | self.mock_encrypt_secret.assert_called_once_with(
69 | password,
70 | b'myplaintext',
71 | dict()
72 | )
73 |
--------------------------------------------------------------------------------
/secretcrypt/tests/test_kms.py:
--------------------------------------------------------------------------------
1 | import base64
2 | import mock
3 | from six.moves import reload_module
4 | import unittest
5 |
6 | from secretcrypt import kms
7 |
8 |
9 | class TestLocal(unittest.TestCase):
10 |
11 | def setUp(self):
12 | reload_module(kms)
13 | self.patcher = mock.patch('boto3.client')
14 | self.boto3_client = self.patcher.start()
15 | self.mock_kms_client = mock.MagicMock()
16 | self.boto3_client.return_value = self.mock_kms_client
17 |
18 | self.key = 'mykey'
19 | self.region = 'myregion'
20 | self.plaintext = b'myplaintext'
21 | self.plaintext_bytes = b'myplaintext'
22 | self.ciphertext_blob = b'abc'
23 | self.ciphertext = base64.b64encode(self.ciphertext_blob)
24 |
25 | def test_lazy_kms_client(self):
26 | self.mock_kms_client.encrypt.return_value = dict(CiphertextBlob=self.ciphertext_blob)
27 | ciphertext, decrypt_params = kms.encrypt('a', 'region1', 'key1')
28 | self.boto3_client.assert_called_with(
29 | 'kms',
30 | region_name='region1'
31 | )
32 | ciphertext, decrypt_params = kms.encrypt('a', 'region2', 'key2')
33 | self.boto3_client.assert_called_with(
34 | 'kms',
35 | region_name='region2'
36 | )
37 |
38 | self.boto3_client.reset_mock()
39 | ciphertext, decrypt_params = kms.encrypt('a', 'region1', 'key1')
40 | self.boto3_client.assert_not_called()
41 |
42 | def test_encrypt(self):
43 | self.mock_kms_client.encrypt.return_value = dict(CiphertextBlob=self.ciphertext_blob)
44 | ciphertext, decrypt_params = kms.encrypt(self.plaintext, self.region, self.key)
45 | self.boto3_client.assert_called_with(
46 | 'kms',
47 | region_name=self.region
48 | )
49 | self.mock_kms_client.encrypt.assert_called_with(
50 | KeyId=self.key,
51 | Plaintext=self.plaintext_bytes
52 | )
53 | self.assertEqual(ciphertext, self.ciphertext)
54 | self.assertEqual(decrypt_params, dict(region=self.region))
55 |
56 | def test_decrypt(self):
57 | self.mock_kms_client.decrypt.return_value = dict(Plaintext=self.plaintext_bytes)
58 | plaintext = kms.decrypt(self.ciphertext, self.region)
59 | self.boto3_client.assert_called_with(
60 | 'kms',
61 | region_name=self.region
62 | )
63 | self.mock_kms_client.decrypt.assert_called_with(
64 | CiphertextBlob=self.ciphertext_blob
65 | )
66 | self.assertEqual(plaintext, self.plaintext)
67 |
--------------------------------------------------------------------------------
/secretcrypt/tests/test_local.py:
--------------------------------------------------------------------------------
1 | import base64
2 | import mock
3 | import os
4 | import shutil
5 | import tempfile
6 | import six
7 | from six.moves import reload_module
8 | import unittest
9 |
10 | from secretcrypt import local
11 |
12 |
13 | class TestLocal(unittest.TestCase):
14 |
15 | def setUp(self):
16 | reload_module(local)
17 | self.tmpdir = tempfile.mkdtemp()
18 | self.key_file = os.path.join(self.tmpdir, 'key')
19 | self.patcher = mock.patch('secretcrypt.local._key_dir')
20 | mock_key_dir = self.patcher.start()
21 | mock_key_dir.return_value = self.tmpdir
22 |
23 | def tearDown(self):
24 | self.patcher.stop()
25 | shutil.rmtree(self.tmpdir, ignore_errors=True)
26 |
27 | @mock.patch('os.makedirs')
28 | def test_key_created(self, os_makedirs):
29 | local.encrypt(b'abc')
30 | os_makedirs.assert_called_with(self.tmpdir)
31 | self.assertTrue(os.path.isfile(self.key_file))
32 |
33 | def test_key_loaded(self):
34 | with open(self.key_file, 'wb') as f:
35 | f.write(base64.b64encode(os.urandom(16)))
36 | with open(self.key_file) as f:
37 | with mock.patch.object(six.moves.builtins, 'open') as mock_open:
38 | mock_open.return_value = f
39 | local.encrypt(b'abc')
40 | mock_open.assert_called_with(self.key_file, 'rb')
41 |
42 | def test_encrypt_decrypt(self):
43 | plaintext = b'myplaintext'
44 | ciphertext, decrypt_params = local.encrypt(plaintext)
45 | self.assertEqual(plaintext, local.decrypt(ciphertext, **decrypt_params))
46 |
--------------------------------------------------------------------------------
/secretcrypt/tests/test_password.py:
--------------------------------------------------------------------------------
1 | import getpass
2 | import mock
3 | import unittest
4 |
5 | from secretcrypt import password
6 |
7 |
8 | class TestPassword(unittest.TestCase):
9 |
10 | @mock.patch.object(getpass, 'getpass')
11 | def test_encrypt_decrypt(self, mock_getpass):
12 | mock_getpass.return_value = 'testpass'
13 | plaintext = b'myplaintext'
14 | ciphertext, decrypt_params = password.encrypt(plaintext)
15 | self.assertEqual(plaintext, password.decrypt(ciphertext, **decrypt_params))
16 |
--------------------------------------------------------------------------------
/secretcrypt/tests/test_plain.py:
--------------------------------------------------------------------------------
1 | import unittest
2 |
3 | from secretcrypt import plain
4 |
5 |
6 | class TestLocal(unittest.TestCase):
7 |
8 | def test_encrypt_decrypt(self):
9 | plaintext = b'myplaintext'
10 | ciphertext, decrypt_params = plain.encrypt(plaintext)
11 | self.assertEqual(plaintext, plain.decrypt(ciphertext, **decrypt_params))
12 |
--------------------------------------------------------------------------------
/secretcrypt/tests/test_secret.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | import mock
3 |
4 | import secretcrypt
5 | from secretcrypt import StrictSecret, Secret
6 |
7 |
8 | class TestSecret(unittest.TestCase):
9 |
10 | @mock.patch('importlib.import_module')
11 | def test_decrypt(self, mock_import_module):
12 | mock_crypter_module = mock.MagicMock()
13 | mock_crypter_module.__name__ = 'secretcrypt.mock_crypter'
14 |
15 | def mock_import_side_effect(*args, **kwargs):
16 | self.assertEqual(kwargs['package'], secretcrypt.__name__)
17 | if args[0] == '.mock_crypter':
18 | return mock_crypter_module
19 | raise Exception('Importing wrong module')
20 | mock_import_module.side_effect = mock_import_side_effect
21 |
22 | secret = StrictSecret('mock_crypter:key=value&key2=value2:myciphertext')
23 | self.assertEqual(secret._decrypt_params, dict(key='value', key2='value2'))
24 | self.assertEqual(secret._ciphertext, b'myciphertext')
25 |
26 | secret.decrypt()
27 | secret.decrypt()
28 | mock_crypter_module.decrypt.assert_called_with(
29 | b'myciphertext',
30 | key='value',
31 | key2='value2',
32 | )
33 |
34 | def test_decrypt_plain(self):
35 | secret = StrictSecret('plain::mypass')
36 | self.assertEqual(b'mypass', secret.decrypt())
37 |
38 | @mock.patch('importlib.import_module')
39 | def test_eager_decrypt(self, mock_import_module):
40 | mock_crypter_module = mock.MagicMock()
41 | mock_crypter_module.decrypt.side_effect = lambda *args, **kwargs: b'plaintext'
42 | mock_crypter_module.__name__ = 'secretcrypt.mock_crypter'
43 |
44 | def mock_import_side_effect(*args, **kwargs):
45 | self.assertEqual(kwargs['package'], secretcrypt.__name__)
46 | if args[0] == '.mock_crypter':
47 | return mock_crypter_module
48 | raise Exception('Importing wrong module')
49 | mock_import_module.side_effect = mock_import_side_effect
50 |
51 | secret = Secret('mock_crypter:key=value&key2=value2:myciphertext')
52 | mock_crypter_module.decrypt.assert_called_with(
53 | b'myciphertext',
54 | key='value',
55 | key2='value2',
56 | )
57 | mock_crypter_module.reset_mock()
58 | plaintext = secret.get()
59 | self.assertEqual(b'plaintext', plaintext)
60 | mock_crypter_module.assert_not_called()
61 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | from setuptools import setup
2 | from setuptools.command.test import test as TestCommand
3 | import sys
4 |
5 |
6 | class Tox(TestCommand):
7 | user_options = [('tox-args=', 'a', "Arguments to pass to tox")]
8 |
9 | def initialize_options(self):
10 | TestCommand.initialize_options(self)
11 | self.tox_args = None
12 |
13 | def finalize_options(self):
14 | TestCommand.finalize_options(self)
15 | self.test_args = []
16 | self.test_suite = True
17 |
18 | def run_tests(self):
19 | # import here, cause outside the eggs aren't loaded
20 | import tox
21 | import shlex
22 | args = self.tox_args
23 | if args:
24 | args = shlex.split(self.tox_args)
25 | errno = tox.cmdline(args=args)
26 | sys.exit(errno)
27 |
28 | with open('README.rst') as f:
29 | readme = f.read()
30 |
31 | with open('HISTORY.rst') as f:
32 | history = f.read()
33 | history = history.replace(".. :changelog:", "")
34 |
35 | setup(
36 | name='secretcrypt',
37 | packages=['secretcrypt'],
38 | version='1.0.5.dev0',
39 | description='Encrypt project secrets',
40 | long_description=readme + '\n\n' + history,
41 | author='Nejc Saje, Zemanta',
42 | author_email='nejc@saje.info',
43 | url='https://github.com/Zemanta/secretcrypt',
44 | download_url='https://github.com/Zemanta/secretcrypt/tarball/0.1',
45 | keywords=['secret', 'encrypt', 'decrypt', 'settings'],
46 | entry_points={
47 | 'console_scripts': [
48 | 'encrypt-secret = secretcrypt.encrypt_secret:encrypt_secret_cmd',
49 | 'decrypt-secret = secretcrypt.decrypt_secret:decrypt_secret_cmd',
50 | ],
51 | },
52 | install_requires=[
53 | 'docopt>=0.6.2',
54 | 'six>=1.10.0',
55 | 'boto3>=1.4',
56 | 'pyaes>=1.6.0',
57 | 'pyscrypt>=1.6.2',
58 | ],
59 | tests_require=['tox', 'virtualenv'],
60 | cmdclass={'test': Tox},
61 | classifiers=[
62 | 'Development Status :: 5 - Production/Stable',
63 | 'Intended Audience :: Developers',
64 | 'License :: OSI Approved :: Apache Software License',
65 | 'Programming Language :: Python :: 2.7',
66 | 'Programming Language :: Python :: 3.4',
67 | 'Topic :: Security :: Cryptography',
68 | ],
69 | )
70 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | envlist = py27,py35,py36,py37,py38
3 |
4 | [testenv]
5 | deps =
6 | -rrequirements.txt
7 | commands =
8 | coverage erase
9 | coverage run {envbindir}/nosetests
10 | coverage html --include="secretcrypt*" --omit="*test*"
11 |
--------------------------------------------------------------------------------