├── pocket_protector ├── __init__.py ├── tests │ ├── __init__.py │ ├── conftest.py │ ├── test_file_keys.py │ └── test_cli.py ├── _version.py ├── __main__.py ├── cli.py └── file_keys.py ├── setup.cfg ├── MANIFEST.in ├── requirements.in ├── .tox-coveragerc ├── .gitignore ├── .travis.yml ├── tox.ini ├── CHANGELOG.md ├── requirements.txt ├── setup.py ├── README.md ├── LICENSE.txt └── USER_GUIDE.md /pocket_protector/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pocket_protector/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE.txt USER_GUIDE.md CHANGELOG.md requirements.in requirements.txt tox.ini .tox-coveragerc 2 | global-exclude flycheck_* 3 | -------------------------------------------------------------------------------- /pocket_protector/_version.py: -------------------------------------------------------------------------------- 1 | 2 | # The version of PocketProtector, used in the version subcommand, as 3 | # well as in setup.py. For full release directions, see the bottom of 4 | # setup.py. 5 | 6 | __version__ = '20.0.2dev' 7 | -------------------------------------------------------------------------------- /requirements.in: -------------------------------------------------------------------------------- 1 | attrs 2 | boltons 3 | coverage 4 | face>=20.1.1 5 | pip-tools 6 | PyNaCl 7 | pytest 8 | # ruamel.ordereddict==0.4.14 # via ruamel.yaml # this stays commented out so py3 builds work 9 | ruamel.yaml 10 | schema 11 | tox 12 | twine 13 | -------------------------------------------------------------------------------- /pocket_protector/__main__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from .cli import main 5 | 6 | if __name__ == '__main__': # pragma: no cover 7 | try: 8 | sys.exit(main() or 0) 9 | except Exception: 10 | if os.getenv('PPROTECT_ENABLE_DEBUG'): 11 | import pdb;pdb.post_mortem() 12 | raise 13 | -------------------------------------------------------------------------------- /.tox-coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = 4 | pocket_protector 5 | ../pocket_protector 6 | omit = 7 | flycheck_*.py 8 | 9 | [paths] 10 | source = 11 | ../pocket_protector 12 | */lib/python*/site-packages/pocket_protector 13 | */Lib/site-packages/pocket_protector 14 | */pypy/site-packages/pocket_protector 15 | -------------------------------------------------------------------------------- /pocket_protector/tests/conftest.py: -------------------------------------------------------------------------------- 1 | 2 | import nacl 3 | import pytest 4 | import pocket_protector.file_keys 5 | 6 | @pytest.fixture 7 | def _fast_crypto(): 8 | old_opslimit = pocket_protector.file_keys.OPSLIMIT 9 | old_memlimit = pocket_protector.file_keys.MEMLIMIT 10 | 11 | pocket_protector.file_keys.OPSLIMIT = nacl.pwhash.OPSLIMIT_MIN 12 | pocket_protector.file_keys.MEMLIMIT = nacl.pwhash.MEMLIMIT_MIN 13 | 14 | yield 15 | 16 | pocket_protector.file_keys.OPSLIMIT = old_opslimit 17 | pocket_protector.file_keys.MEMLIMIT = old_memlimit 18 | return 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | docs/_build 2 | tmp.py 3 | htmlcov/ 4 | *.py[cod] 5 | .pytest_cache 6 | 7 | # emacs 8 | *~ 9 | ._* 10 | .\#* 11 | \#*\# 12 | 13 | # C extensions 14 | *.so 15 | 16 | # Packages 17 | *.egg 18 | *.egg-info 19 | dist 20 | build 21 | eggs 22 | parts 23 | bin 24 | var 25 | sdist 26 | develop-eggs 27 | .installed.cfg 28 | lib 29 | lib64 30 | 31 | # Installer logs 32 | pip-log.txt 33 | 34 | # Unit test / coverage reports 35 | .coverage 36 | .tox 37 | nosetests.xml 38 | 39 | # Translations 40 | *.mo 41 | 42 | # Mr Developer 43 | .mr.developer.cfg 44 | .project 45 | .pydevproject 46 | 47 | # Vim 48 | *.sw[op] 49 | 50 | .cache/ 51 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | cache: 3 | directories: 4 | - $HOME/.cache/pip 5 | 6 | language: python 7 | 8 | 9 | matrix: 10 | include: 11 | - python: "2.7" 12 | env: TOXENV=py27 13 | - python: "3.5" 14 | env: TOXENV=py35 15 | - python: "3.6" 16 | env: TOXENV=py36 17 | - python: "3.7" 18 | dist: xenial 19 | env: TOXENV=py37 20 | - python: "pypy" 21 | env: TOXENV=pypy 22 | - python: "3.6" 23 | env: TOXENV=packaging 24 | 25 | 26 | install: 27 | - "pip install -r requirements.txt" 28 | 29 | script: 30 | - tox 31 | 32 | 33 | before_install: 34 | - pip install codecov coverage 35 | 36 | 37 | after_success: 38 | - tox -e coverage-report 39 | - COVERAGE_FILE=.tox/.coverage coverage xml 40 | - codecov -f coverage.xml 41 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27,py36,py37,pypy,coverage-report,packaging 3 | 4 | [testenv] 5 | changedir = .tox 6 | deps = -rrequirements.txt 7 | commands = coverage run --parallel --rcfile {toxinidir}/.tox-coveragerc -m pytest --doctest-modules {envsitepackagesdir}/pocket_protector {posargs} 8 | 9 | # Uses default basepython otherwise reporting doesn't work on Travis where 10 | # Python 3.6 is only available in 3.6 jobs. 11 | [testenv:coverage-report] 12 | changedir = .tox 13 | deps = coverage 14 | commands = coverage combine --rcfile {toxinidir}/.tox-coveragerc 15 | coverage report --rcfile {toxinidir}/.tox-coveragerc 16 | coverage html --rcfile {toxinidir}/.tox-coveragerc -d {toxinidir}/htmlcov 17 | 18 | 19 | [testenv:packaging] 20 | changedir = {toxinidir} 21 | deps = 22 | twine 23 | check-manifest 24 | commands = 25 | python setup.py sdist bdist_wheel 26 | twine check dist/* 27 | check-manifest 28 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | PocketProtector's CHANGELOG 2 | ============================ 3 | 4 | PocketProtector is a growing utility! This document records its growth. 5 | 6 | PocketProtector uses the [CalVer](https://calver.org) versioning 7 | scheme (`YY.MINOR.MICRO`). 8 | 9 | Check this page when upgrading, we strive to keep the updates 10 | summarized and readable. 11 | 12 | 20.0.1 13 | ------ 14 | *(January 22, 2020)* 15 | 16 | * Fix new user prompt formatting 17 | 18 | 20.0.0 19 | ------ 20 | *(January 21, 2020)* 21 | 22 | * Python 3 support by way of refactor to use the [face](https://github.com/mahmoud/face) framework 23 | * Extensive testing 24 | 25 | 18.0.1 26 | ------ 27 | *(August 22, 2018)* 28 | 29 | Fix a schema validation error that occurred when loading a protected 30 | file, due to a breaking change in `ruamel.yaml` version 31 | 0.15.55. That's [0ver](https://0ver.org/), folks. 32 | 33 | 18.0.0 34 | ------ 35 | *(February 5, 2018)* 36 | 37 | Initial release with complete featureset. 38 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile 3 | # To update, run: 4 | # 5 | # pip-compile requirements.in 6 | # 7 | atomicwrites==1.3.0 # via pytest 8 | attrs==19.3.0 # via -r requirements.in, pytest 9 | bleach==3.1.4 # via readme-renderer 10 | boltons==20.0.0 # via -r requirements.in, face 11 | certifi==2019.11.28 # via requests 12 | cffi==1.13.2 # via pynacl 13 | chardet==3.0.4 # via requests 14 | click==7.0 # via pip-tools 15 | contextlib2==0.5.5 # via schema 16 | coverage==5.0.3 # via -r requirements.in 17 | docutils==0.16 # via readme-renderer 18 | face==20.1.1 # via -r requirements.in 19 | filelock==3.0.12 # via tox 20 | idna==2.8 # via requests 21 | more-itertools==5.0.0 # via pytest 22 | packaging==20.0 # via pytest, tox 23 | pip-tools==4.4.0 # via -r requirements.in 24 | pkginfo==1.5.0.1 # via twine 25 | pluggy==0.13.1 # via pytest, tox 26 | py==1.8.1 # via pytest, tox 27 | pycparser==2.19 # via cffi 28 | pygments==2.5.2 # via readme-renderer 29 | pynacl==1.3.0 # via -r requirements.in 30 | pyparsing==2.4.6 # via packaging 31 | pytest==4.6.9 # via -r requirements.in 32 | readme-renderer==24.0 # via twine 33 | requests-toolbelt==0.9.1 # via twine 34 | requests==2.22.0 # via requests-toolbelt, twine 35 | ruamel.yaml==0.16.6 # via -r requirements.in 36 | schema==0.7.1 # via -r requirements.in 37 | six==1.14.0 # via bleach, more-itertools, packaging, pip-tools, pynacl, pytest, readme-renderer, tox 38 | toml==0.10.0 # via tox 39 | tox==3.14.3 # via -r requirements.in 40 | tqdm==4.41.1 # via twine 41 | twine==1.15.0 # via -r requirements.in 42 | urllib3==1.25.8 # via requests 43 | virtualenv==16.7.9 # via tox 44 | wcwidth==0.1.8 # via pytest 45 | webencodings==0.5.1 # via bleach 46 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | 2 | import os 3 | import imp 4 | from setuptools import setup, find_packages 5 | 6 | __author__ = "Kurt Rose and Mahmoud Hashemi" 7 | __contact__ = "kurt@kurtrose.com" 8 | __license__ = 'Apache License 2.0' 9 | __url__ = 'https://github.com/SimpleLegal/pocket_protector' 10 | 11 | CUR_PATH = os.path.abspath(os.path.dirname(__file__)) 12 | _version_mod_path = os.path.join(CUR_PATH, 'pocket_protector', '_version.py') 13 | _version_mod = imp.load_source('_version', _version_mod_path) 14 | __version__ = _version_mod.__version__ 15 | 16 | with open('README.md') as readme: 17 | long_description = readme.read() 18 | 19 | 20 | setup( 21 | name="pocket-protector", 22 | description="Handy secret management system with a convenient CLI and readable storage format.", 23 | long_description=long_description, 24 | long_description_content_type='text/markdown', 25 | author=__author__, 26 | author_email=__contact__, 27 | url=__url__, 28 | license=__license__, 29 | platforms='any', 30 | version=__version__, 31 | packages=find_packages(), 32 | include_package_data=True, 33 | zip_safe=False, 34 | entry_points={'console_scripts': ['pprotect = pocket_protector.__main__:main', 35 | 'pocket_protector = pocket_protector.__main__:main']}, 36 | install_requires=['attrs', 37 | 'boltons', 38 | 'PyNaCl', 39 | 'ruamel.yaml', 40 | 'schema', 41 | 'face>=20.1.1'] 42 | ) 43 | 44 | """ 45 | Release process: 46 | 47 | * tox 48 | * git commit (if applicable) 49 | * Remove dev suffix from pocket_protector/_version.py version 50 | * git commit -a -m "bump version for vX.Y.Z release" 51 | * rm -rf dist 52 | * python setup.py sdist bdist_wheel 53 | * twine upload dist/* 54 | * git tag -a vX.Y.Z -m "brief summary" 55 | * write CHANGELOG 56 | * git commit 57 | * bump pocket_protector/_version.py version onto n+1 dev 58 | * git commit 59 | * git push 60 | 61 | Versions are of the format YY.MINOR.MICRO, see calver.org for more details. 62 | """ 63 | -------------------------------------------------------------------------------- /pocket_protector/tests/test_file_keys.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals 4 | 5 | import pytest 6 | 7 | from pocket_protector import file_keys 8 | 9 | import tempfile 10 | 11 | 12 | def test_file_keys(_fast_crypto): 13 | bob_creds = file_keys.Creds('bob@example.com', 'super-secret') 14 | alice_creds = file_keys.Creds('alice@example.com', 'super-duper-secret') 15 | 16 | _prev = [None] 17 | def chk(fk): 18 | assert fk.from_contents_and_path(fk.get_contents(), fk.path) == fk 19 | assert _prev[0] != fk, "function call resulted in no changes to data" 20 | _prev[0] = fk 21 | 22 | tmp = tempfile.NamedTemporaryFile() 23 | test1 = test = file_keys.KeyFile.create(path=tmp.name) 24 | chk(test) 25 | test2 = test = test.add_key_custodian(bob_creds) 26 | chk(test) 27 | test3 = test = test.add_domain('new_domain', bob_creds.name) 28 | chk(test) 29 | 30 | with pytest.raises(ValueError): 31 | test3f = test = test.add_secret('new_domain', '$brokenkey', 'world') 32 | 33 | test3a = test = test.add_secret('new_domain', 'hello', 'world') 34 | chk(test) 35 | test3b = test = test.update_secret('new_domain', 'hello', 'world2') 36 | chk(test) 37 | test4 = test = test.set_secret('new_domain', 'hello', 'world') 38 | chk(test) 39 | test4a = test = test.rm_secret('new_domain', 'hello') 40 | chk(test) 41 | test4b = test = test.set_secret('new_domain', 'hello', 'world') 42 | chk(test) 43 | assert test.decrypt_domain('new_domain', bob_creds)['hello'] == 'world' 44 | test5 = test = test.set_secret('new_domain', 'hello', 'better-world') 45 | chk(test) 46 | assert test.decrypt_domain('new_domain', bob_creds)['hello'] == 'better-world' 47 | test6 = test = test.add_key_custodian(alice_creds) 48 | chk(test) 49 | test7 = test = test.add_owner('new_domain', alice_creds.name, bob_creds) 50 | chk(test) 51 | test8 = _test = test.rm_owner('new_domain', alice_creds.name) 52 | chk(_test) # throw away this mutation 53 | test9 = _test = test.rm_key_custodian(alice_creds.name) 54 | chk(_test) # throw away this mutation 55 | test9a = _test = test.rm_domain('new_domain') 56 | chk(_test) 57 | before_rotate = test.decrypt_domain('new_domain', bob_creds) 58 | test10 = test = test.rotate_domain_key('new_domain', bob_creds) 59 | chk(test) 60 | assert test.get_all_secret_names() == {'hello': ['new_domain']} 61 | assert test.decrypt_domain('new_domain', bob_creds) == before_rotate 62 | test11 = test = test.set_key_custodian_passphrase(bob_creds, 'ultra-extra-secret') 63 | test.write() 64 | round_trip = file_keys.KeyFile.from_file(test.path) 65 | assert round_trip == test 66 | print("generated file:") 67 | print(open(test.path).read()) 68 | print("...") 69 | -------------------------------------------------------------------------------- /pocket_protector/tests/test_cli.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals 4 | 5 | import os 6 | import json 7 | import subprocess 8 | 9 | import ruamel.yaml 10 | from face import CommandChecker 11 | 12 | from pocket_protector import cli 13 | 14 | 15 | def test_prepare(): 16 | # confirms that all subcommands compile together nicely 17 | assert cli._get_cmd(prepare=True) 18 | return 19 | 20 | KURT_EMAIL = 'kurt@example.com' 21 | KURT_PHRASE = u'passphrasë' 22 | MH_EMAIL = 'mahmoud@hatnote.com' 23 | MH_PHRASE = 'thegame' 24 | DOMAIN_NAME = 'first-domain' 25 | SECRET_NAME = 'secret-name' 26 | SECRET_VALUE = u'secrët-value' 27 | 28 | 29 | 30 | # _fast_crypto from conftest 31 | def test_cli(tmp_path, _fast_crypto): 32 | cmd = cli._get_cmd() 33 | cc = CommandChecker(cmd, reraise=True) 34 | 35 | assert cc.run('pprotect version').stdout.startswith('pocket_protector version') 36 | 37 | tmp_path = str(tmp_path) 38 | protected_path = tmp_path + '/protected.yaml' 39 | 40 | # fail init and ensure that file isn't created 41 | cc.fail_1('pprotect init --file %s' % protected_path, 42 | input=[KURT_EMAIL, KURT_PHRASE, KURT_PHRASE + 'nope']) 43 | assert not os.path.exists(protected_path) 44 | 45 | # successfully create protected 46 | res = cc.run('pprotect init --file %s' % protected_path, 47 | input=[KURT_EMAIL, KURT_PHRASE, KURT_PHRASE]) 48 | assert res.stdout == 'Adding new key custodian.\nUser email: ' 49 | assert res.stderr == 'Passphrase: Retype passphrase: ' 50 | 51 | # check we can only create it once 52 | res = cc.fail_2('pprotect init --file %s' % protected_path, 53 | input=[KURT_EMAIL, KURT_PHRASE, KURT_PHRASE]) 54 | 55 | file_data = ruamel.yaml.YAML().load(open(protected_path).read()) 56 | assert list(file_data['key-custodians'])[0] == KURT_EMAIL 57 | assert len(file_data['audit-log']) == 2 58 | 59 | res = cc.run('pprotect list-audit-log --file %s' % protected_path) 60 | audit_lines = res.stdout.splitlines() 61 | assert len(audit_lines) == 2 62 | assert 'created' in audit_lines[0] 63 | 64 | # make a new cc, with env and tmp_path baked in (also tests 65 | # protected.yaml in the cur dir being the default file) 66 | kurt_env = {'PPROTECT_USER': KURT_EMAIL, 'PPROTECT_PASSPHRASE': KURT_PHRASE} 67 | cc = CommandChecker(cmd, chdir=tmp_path, env=kurt_env, reraise=True) 68 | 69 | res = cc.run(['pprotect', 'add-domain'], input=[DOMAIN_NAME]) 70 | assert 'Adding new domain.' in res.stdout 71 | 72 | res = cc.run(['pprotect', 'list_domains']) 73 | assert res.stdout.splitlines() == [DOMAIN_NAME] 74 | 75 | cc.run(['pprotect', 'add-secret'], 76 | input=[DOMAIN_NAME, SECRET_NAME, 'tmpval']) 77 | cc.run(['pprotect', 'update-secret'], 78 | input=[DOMAIN_NAME, SECRET_NAME, SECRET_VALUE]) 79 | res = cc.run(['pprotect', 'list-domain-secrets', DOMAIN_NAME]) 80 | assert res.stdout == SECRET_NAME + '\n' 81 | 82 | res = cc.run(['pprotect', 'decrypt-domain', DOMAIN_NAME]) 83 | res_data = json.loads(res.stdout) 84 | assert res_data[SECRET_NAME] == SECRET_VALUE 85 | 86 | cc.fail(['pprotect', 'decrypt-domain', 'nonexistent-domain']) 87 | 88 | # already exists 89 | cc.fail_1('pprotect add-key-custodian', input=[KURT_EMAIL, '']) 90 | 91 | cc.run('pprotect add-key-custodian', input=[MH_EMAIL, MH_PHRASE, MH_PHRASE]) 92 | 93 | cc.run('pprotect add-owner', input=[DOMAIN_NAME, MH_EMAIL]) 94 | 95 | # missing protected 96 | cc.fail_2('pprotect list-all-secrets', chdir=tmp_path + '/..') 97 | 98 | res = cc.run('pprotect list-all-secrets') 99 | assert '{}: {}\n'.format(SECRET_NAME, DOMAIN_NAME) == res.stdout 100 | 101 | cc.run(['pprotect', 'rotate_domain_keys'], input=[DOMAIN_NAME]) 102 | 103 | 104 | # test mixed env var and entry 105 | res = cc.run(['pprotect', 'decrypt-domain', DOMAIN_NAME], 106 | env={'PPROTECT_USER': MH_EMAIL, 'PPROTECT_PASSPHRASE': None}, 107 | input=[MH_PHRASE]) 108 | assert json.loads(res.stdout)[SECRET_NAME] == SECRET_VALUE 109 | assert 'Verify passphrase' in res.stderr 110 | 111 | # test bad creds 112 | cc.fail_1(['pprotect', 'decrypt-domain', DOMAIN_NAME], 113 | env={'PPROTECT_USER': None, 'PPROTECT_PASSPHRASE': 'nope'}, 114 | input=[KURT_EMAIL]) 115 | 116 | res = cc.fail_1('pprotect set-key-custodian-passphrase', 117 | input=[KURT_EMAIL, KURT_PHRASE, KURT_PHRASE, KURT_PHRASE + 'nope']) 118 | assert 'did not match' in res.stderr 119 | 120 | # correctly reset passphrase 121 | new_kurt_phrase = KURT_PHRASE + 'yep' 122 | res = cc.run('pprotect set-key-custodian-passphrase', 123 | input=[KURT_EMAIL, KURT_PHRASE, new_kurt_phrase, new_kurt_phrase]) 124 | 125 | # try new passphrase with a passphrase file why not 126 | ppfile_path = str(tmp_path) + 'tmp_passphrase' 127 | with open(ppfile_path, 'wb') as f: 128 | f.write(new_kurt_phrase.encode('utf8')) 129 | res = cc.run(['pprotect', 'decrypt-domain', '--non-interactive', 130 | '--passphrase-file', ppfile_path, DOMAIN_NAME]) 131 | 132 | res_data = json.loads(res.stdout) 133 | assert res_data[SECRET_NAME] == SECRET_VALUE 134 | 135 | # test mutual exclusivity of check env and interactive 136 | cc.fail_2(['pprotect', 'decrypt-domain', 137 | '--non-interactive', '--ignore-env', DOMAIN_NAME]) 138 | 139 | res = cc.fail_1('pprotect decrypt-domain --non-interactive ' + DOMAIN_NAME, 140 | env={'PPROTECT_PASSPHRASE': None}) 141 | assert 'Warning: Empty passphrase' in res.stderr 142 | 143 | # print(open(protected_path).read()) 144 | 145 | # test removals 146 | cc.run(['pprotect', 'rm-owner'], input=[DOMAIN_NAME, MH_EMAIL]) 147 | cc.run(['pprotect', 'rm-secret'], input=[DOMAIN_NAME, SECRET_NAME]) 148 | cc.run(['pprotect', 'rm-domain', '--confirm'], input=[DOMAIN_NAME, 'y']) 149 | 150 | 151 | def test_main(tmp_path): 152 | # TODO: pytest-cov knows how to make coverage work across 153 | # subprocess boundaries... 154 | os.chdir(str(tmp_path)) 155 | res = subprocess.check_output(['pprotect', 'version']) 156 | assert res.decode('utf8').startswith('pocket_protector version') 157 | 158 | res = subprocess.check_output(['pocket_protector', 'version']) 159 | assert res.decode('utf8').startswith('pocket_protector version') 160 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pocket Protector 🔏 2 | 3 | Pocket Protector provides a cryptographically-strong, serverless secret 4 | management infrastructure. Pocket Protector enables *key management as 5 | code*, securely storing secrets in a versionable format, right 6 | alongside the corresponding application code. 7 | 8 | Pocket Protector's approach lets you: 9 | 10 | * Leverage existing user, versioning, and backup systems, with no 11 | infrastructure to set up 12 | * Support multiple environments 13 | * Integrate easily with existing key management systems 14 | (AWS/Heroku/TravisCI) 15 | 16 | Pocket Protector also: 17 | 18 | * Minimizes the number of passphrases and keys your team has to 19 | remember and secure 20 | * Beats the heck out of hardcoded plaintext secrets! 21 | 22 | 23 | ## Installation 24 | 25 | Right now the easiest way to install Pocket Protector across all 26 | platforms is with `pip`: 27 | 28 | ```sh 29 | pip install pocket_protector 30 | ``` 31 | 32 | This will install the command-line application `pocket_protector`, 33 | conveniently shortened to `pprotect`, which you can use to test your 34 | installation: 35 | 36 | ```sh 37 | $ pprotect version 38 | pocket_protector version 18.0.1 39 | ``` 40 | 41 | Once the above is working, we're ready to start using Pocket Protector! 42 | 43 | 44 | ## Usage 45 | 46 | Pocket Protector aims to be as easy to use as a secret management 47 | system can get. That said, understanding security takes time, so be 48 | sure to go beyond the quick start and reference below, and read our 49 | [User Guide](https://github.com/SimpleLegal/pocket_protector/blob/master/USER_GUIDE.md) 50 | as well. 51 | 52 | 53 | ### Quick start 54 | 55 | Pocket Protector's CLI is its primary interface. It presents a compact 56 | set of commands, each representing one action you might want to take 57 | on a secret store. Basic usage starts on your laptop, inside your 58 | checked out code repository: 59 | 60 | ```sh 61 | # create a new protected file 62 | pprotect init 63 | 64 | # add a key domain 65 | pprotect add-domain 66 | 67 | # add a secret to the new key domain 68 | pprotect add-secret 69 | 70 | # decrypt and read out the secret 71 | pprotect decrypt-domain 72 | ``` 73 | 74 | Each of these will prompt the user for credentials when necessary. See 75 | the section below on passing credentials. 76 | 77 | When you're done updating the secret store, simply `git commit` (or 78 | equivalent) to save your changes. Should you make any mistakes, use 79 | your VCS to revert the changes. 80 | 81 | 82 | ### Passing credentials 83 | 84 | By default, the `pocket_protector` command prompts you for credentials 85 | when necessary. But convenience and automation both demand more 86 | options, highlighted here: 87 | 88 | * Command-line Flags 89 | * `-u / --user USER_EMAIL` - specifies the user email for subcommands which require it 90 | * `--passphrase-file PATH` - specifies a path to a readable file 91 | which contains the passphrase (useful for mount-based key 92 | management, like Docker) 93 | * `--domain DOMAIN` - specifies the name of the domain 94 | * `--non-interactive` - causes the command to fail when credentials cannot be gotten by other means 95 | 96 | * Environment variables 97 | * `PPROTECT_USER` - environment variable which contains the user email 98 | * `PPROTECT_PASSPHRASE` - environment variable which contains the 99 | passphrase (useful for environment variable-based key management, 100 | used by AWS/Heroku/many CI systems) 101 | 102 | In all cases, flags take precedence over environment variables, and 103 | both take precedence over and bypass interactive prompts. In the event 104 | an incorrect credential is passed, `pocket_protector` does *not* 105 | automatically check other sources. 106 | 107 | 108 | See our 109 | [User Guide](https://github.com/SimpleLegal/pocket_protector/blob/master/USER_GUIDE.md) 110 | for more usage tips. 111 | 112 | 113 | ### Command summary 114 | 115 | Here is a summary of all commands: 116 | 117 | ``` 118 | usage: pprotect [COMMANDS] 119 | 120 | Commands: 121 | add-domain add a new domain to the protected 122 | add-key-custodian add a new key custodian to the protected 123 | add-owner add a key custodian as owner of a domain 124 | add-secret add a secret to a specified domain 125 | decrypt-domain decrypt and display JSON-formatted cleartext for a 126 | domain 127 | init create a new pocket-protected file 128 | list-all-secrets display all secrets, with a list of domains the key is 129 | present in 130 | list-audit-log display a chronological list of audit log entries 131 | representing file activity 132 | list-domain-secrets display a list of secrets under a specific domain 133 | list-domains display a list of available domains 134 | list-user-secrets similar to list-all-secrets, but filtered by a given 135 | user 136 | rm-domain remove a domain from the protected 137 | rm-owner remove an owner's privileges on a specified domain 138 | rm-secret remove a secret from a specified domain 139 | rotate-domain-keys rotate the internal keys for a particular domain (must 140 | be owner) 141 | set-key-custodian-passphrase 142 | change a key custodian passphrase 143 | update-secret update an existing secret in a specified domain 144 | ``` 145 | 146 | 147 | ## Design 148 | 149 | The theory of operation is that the `protected.yaml` file consists of 150 | "key domains" at the root level. Each domain stores data encrypted by 151 | a keypair. The public key of the keypair is stored in plaintext, so 152 | that anyone may encrypt and add a new secret. The private key is 153 | encrypted with the owner's passphrase. The owners are known as "key 154 | custodians", and their private keys are protected by passphrases. 155 | 156 | Secrets are broken up into domains for the purposes of granting 157 | security differently. For example, `prod`, `dev`, and `stage` may all 158 | be different domains. Protected stores may have as few or as many 159 | domains as the team and application require. 160 | 161 | To allow secrets to be accessed in a certain environment, Pocket 162 | Protector must be invoked with a user and passphrase. As long as the 163 | credentials are correct and the user has permissions to a domain, all 164 | secrets within that domain are unlocked. 165 | 166 | Passphrase security will depend on the domain. For instance, a domain 167 | used for local development may set the passphrase as an environment 168 | variable, or hardcode it in a configuration file. 169 | 170 | On the other hand, a production domain would likely require manual 171 | entry of an authorized release engineer, or use AWS/GCP/Heroku key 172 | management solutions to inject the passphrase. 173 | 174 | for prod domains, use AWS / heroku key management to store 175 | the passphrase 176 | 177 | An application / script wants to get its secrets: 178 | ```python 179 | # at initialization 180 | secrets = KeyFile.decrypt_domain(domain_name, Creds(name, passphrase)) 181 | # ... later to access a secret 182 | secrets[secret_name] 183 | ``` 184 | 185 | An application / script that wants to add / overwrite a secret: 186 | ```python 187 | KeyFile.from_file(path).with_secret( 188 | domain_name, secret_name, value).write() 189 | ``` 190 | 191 | Note -- the secure environment key is needed to read secrets, but not write them. 192 | Change management on secrets is intended to follow normal source-code 193 | management. 194 | 195 | File structure: 196 | ```yaml 197 | [key-domain]: 198 | meta: 199 | owners: 200 | [name]: [encrypted-private-key] 201 | public_key: [b64-bytes] 202 | private_key: [b64-bytes] 203 | secret-[name]: [b64-bytes] 204 | key-custodians: 205 | [name]: 206 | public-key: [b64-bytes] 207 | encrypted-private-key: [b64-bytes] 208 | ``` 209 | 210 | 211 | ### Threat model 212 | 213 | An attacker is presumed to be able to read but not write the contents 214 | of `protected.yaml`. This could happen because a developer's laptop 215 | is compromised, GitHub credentials are compromised, or (most likely) 216 | Git history is accidentally pushed to a publicly acessible repo. 217 | 218 | With read access, an attacker gets environment and secret names, 219 | and which secrets are used in which environments. 220 | 221 | Neither the file as a whole nor individual entries are signed, 222 | since the security model assumes an attacker does not have 223 | write access. 224 | 225 | 226 | ### Notes 227 | 228 | Pocket Protector is a streamlined, people-centric secret management 229 | system, custom built to work with distributed version control systems. 230 | 231 | * Pocket Protector is a data protection tool, not a change management 232 | tool. While it has convenient affordances like an informal 233 | `audit_log`, Pocket Protector is meant to be used in conjunction with 234 | your version management tool. Signed commits are a particularly good 235 | complement. 236 | * Pocket Protector is designed for single-user usage. This is not a 237 | scaling limitation as much as it is a scaling feature. Single-user 238 | means that every `pprotect` command needs at most one credentialed 239 | user present. No sideband communication is required, minimizing 240 | leakage, while maintaining a system as distributed as your version 241 | management. 242 | 243 | 244 | ## FAQ 245 | 246 | 247 | ### Securing Write Access 248 | 249 | Pocket Protector does not provide any security against unauthorized writes 250 | to the `protected.yaml` file, by design. Firstly, without any Public Key Infrastructure, 251 | Pocket Protector is not a good basis for cryptographic signatures. (An attacker 252 | that modifies the file could also replace the signing keypair with their own; 253 | the only way to detect this would be to have a data-store outside of the file.) 254 | 255 | Secondly -- and more importantly -- the Git or Mercurial repository already has 256 | good controls around write access. All changes are auditable, authenticated with 257 | ssh keypairs or user passphrases. For futher security, consider using signed commits: 258 | 259 | * https://git-scm.com/book/id/v2/Git-Tools-Signing-Your-Work 260 | * https://help.github.com/articles/signing-commits-using-gpg/ 261 | * https://docs.gitlab.com/ee/user/project/repository/gpg_signed_commits/index.html 262 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 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 | Copyright 2017 SimpleLegal, Inc. See AUTHORS.txt for more details. 179 | 180 | Licensed under the Apache License, Version 2.0 (the "License"); 181 | you may not use this file except in compliance with the License. 182 | You may obtain a copy of the License at 183 | 184 | http://www.apache.org/licenses/LICENSE-2.0 185 | 186 | Unless required by applicable law or agreed to in writing, software 187 | distributed under the License is distributed on an "AS IS" BASIS, 188 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 189 | See the License for the specific language governing permissions and 190 | limitations under the License. 191 | -------------------------------------------------------------------------------- /USER_GUIDE.md: -------------------------------------------------------------------------------- 1 | # PocketProtector User Guide 2 | 3 | PocketProtector is a streamlined, people-centric secret management 4 | system, built to work with modern distributed version control systems. 5 | 6 | This guide will walk you through security scenarios commonly faced by 7 | teams, and showcase how PocketProtector's no-nonsense workflow offers 8 | a practical alternative to more complicated solutions. 9 | 10 | ## Starting out 11 | 12 | Let's say we have a small engineering team building a software service 13 | whose source code is versioned in git, and they're looking to improve 14 | their secret management. Our team consists of Engineer Alice, Engineer 15 | Bob, CEO Claire, and CTO Tom. 16 | 17 | The service interacts with other services, including an email 18 | service. The email service provides an API key, which Claire checked 19 | into the code on day 1, despite Tom's protests. 20 | 21 | Let's migrate to a better way, the PocketProtector way! 22 | 23 | ## Installation 24 | 25 | Right now, the easiest way to install PocketProtector across all 26 | platforms is with `pip`: 27 | 28 | ``` 29 | pip install pocket_protector 30 | ``` 31 | 32 | This will install a command-line application, `pocket_protector`, 33 | conveniently shortened to `pprotect`, which you can use to test your 34 | installation: 35 | 36 | ``` 37 | $ pprotect version 38 | pocket_protector version 20.0.0 39 | ``` 40 | 41 | Once the above is working, we're ready to start using PocketProtector! 42 | 43 | ## Creating a New Protected 44 | 45 | With PocketProtector, secrets are encrypted and stored in a file which 46 | is versioned alongside your code. Create this file like so: 47 | 48 | ``` 49 | $ pprotect init 50 | ``` 51 | 52 | You'll be prompted to add a *key custodian*, an administrator for the 53 | secrets we're trying to protect. In our scenario, CTO Tom would be the 54 | natural choice for our first key custodian. 55 | 56 | ``` 57 | tom@tomtop $ pprotect init 58 | Adding new key custodian. 59 | User email: tom@example.com 60 | Passphrase: 61 | Retype passphrase: 62 | ``` 63 | 64 | After successfully creating his credentials, Tom would see a 65 | `protected.yaml` now exists in his current directory: 66 | 67 | ``` 68 | tom@tomtop $ ls -l protected.yaml 69 | -rw-rw-r-- 1 tom tom 275 Nov 13 16:25 protected.yaml 70 | ``` 71 | 72 | PocketProtector will store all secrets encrypted in this YAML file, 73 | which is always safe to check in to the project's repository. It's 74 | commonly put at the root of the repository for discoverability, but 75 | the protected.yaml is self-contained and can exist anywhere in the 76 | project tree. 77 | 78 | ## Adding a Domain 79 | 80 | Right now, the protected only contains credentials for our sole key 81 | custodian, CTO Tom. Before anyone can add any secrets, Tom needs to 82 | create one or more *domains*. 83 | 84 | A domain can represent any set of keys accessible to the same actors, 85 | and in our scenario we're going to have one domain per environment, 86 | which means one domain for `prod` (our production datacenter) and one 87 | for `dev` (our development laptops). 88 | 89 | ``` 90 | tom@tomtop $ pprotect add-domain 91 | Verify credentials for /home/tom/work/project/protected.yaml 92 | User email: tom@example.com 93 | Passphrase: 94 | Adding new domain. 95 | Domain name: dev 96 | ``` 97 | 98 | Tom verifies his credentials and creates the "dev" domain, then does 99 | the same for the "prod" domain. 100 | 101 | > **Tip**: Almost all `pprotect` subcommands accept a `--confirm-diff` 102 | > option, which enables you to see the actual changes being made to the 103 | > protected file, with a prompt to accept or reject. You can use this 104 | > functionality to do dry runs of changes, and don't forget that you can 105 | > and should commit the file regularly so you can revert any changes you 106 | > don't want. 107 | 108 | Now that we have our first custodian and our two domains, we're ready 109 | to start adding secrets! 110 | 111 | ## Adding Secrets 112 | 113 | So far CTO Tom has done all the work. Now it's time for our Engineers 114 | to pick up the slack. CTO Tom asks Engineer Alice to start 115 | investigating chat integration. Since the chat service requires an API key, Alice is 116 | going to have a secret on her hands. 117 | 118 | Alice installs `pprotect`, pulls the repo with `protected.yaml` 119 | created by Tom. She adds the "chat-api-key" to the protected's `dev` 120 | domain like so: 121 | 122 | ``` 123 | alice@alicetop $ pprotect add-secret 124 | Adding secret value. 125 | Domain name: dev 126 | Secret name: chat-api-key 127 | Secret value: abc5ca1ab1e 128 | ``` 129 | 130 | Notice that PocketProtector did not prompt Alice for any 131 | credentials. Because they were added to the "dev" domain, they were 132 | safely added by encrypting them with a key accessible only to Tom 133 | right now. 134 | 135 | But how did the secret get secured without requiring an authenticated 136 | user? 137 | 138 | ### PocketProtector Secret Storage by Analogy 139 | 140 | The best analogy for PocketProtector's internal domain security 141 | mechanism comes from [the NaCl project](#), on top of which PocketProtector 142 | is implemented. 143 | 144 | Imagine you're a security-conscious community member, holding a letter 145 | you'd like a select few of your neighbors to read. 146 | 147 | You want them to securely read the notarized original, so they can be 148 | as sure of the authenticity as you are. A copy simply won't 149 | do. Because we can't make copies of the letter, how do we securely 150 | ensure only specific neighbors read it? 151 | 152 | One elegant solution is to put the letter in your own mailbox, and 153 | make copies of your mailbox key. Then, put a copy of the key (with 154 | instructions) into each of the neighbors' mailboxes. 155 | 156 | PocketProtector uses a cryptographic approach known as two-key 157 | encryption to implement this scheme. Every domain is a mailbox, and 158 | only key custodians assigned to that domain are neighbors with a key 159 | to that mailbox. 160 | 161 | Another advantage of PocketProtector's scheme is that you don't have 162 | to own the mailbox to put another letter in, just as we saw with our 163 | [Adding Secrets](#adding-secrets) scenario, above. Domains are 164 | community mailboxes, where only specific community members have access 165 | to the contents. 166 | 167 | Thus, PocketProtector provides read protection against leaks, 168 | unintentional or otherwise, while relying on repository management 169 | practices for write protection. Anyone with push rights to the repo 170 | can add a key. In our analogy, only people in the building can drop 171 | letters in the mailbox, but it's up to your team to control who can 172 | get into the building (i.e., push to your repo). 173 | 174 | Speaking of reads, let's check in on our scenario using some 175 | PocketProtector's read subcommands. 176 | 177 | ## Reading a Protected 178 | 179 | The first thing to recognize about protected files is that they are 180 | designed for some degree of human readability. They are plaintext YAML 181 | files that you can open in your editor of choice. You should see 182 | something like this: 183 | 184 | ``` 185 | dev: 186 | secret-chat-api-key: ABpVkJKq6WgOgl0rQYDSB0zAjNGD1Gn4aEFmWthMd9l+hjz8rjBJYDm/guyeIVZOwj7m/TQPJNz/yw0D 187 | meta: 188 | public-key: AKKRHVwQcbLkk2yK7L3DWmTKzqYhlFuavNpdzl//hbk1 189 | owners: 190 | tom@example.com: ANrCtPEyppOZt7waOrW/GDQTd7+/tGTLJNqmtaxX8FhbYVsbPWVgSdvzVNEUVM3/bRFsfpw5GHmF93qVwqC7wUtNnIngp1qiDpGyN12iVHEZ 191 | key-custodians: 192 | tom@example.com: 193 | pwdkm: ALLq2pN0MCqlQ3V0SAl7d71zeOd1D0vBzjZ6y5L5uK3TFMuDKe5uCAA= 194 | audit-log: 195 | - 2020-01-22T18:06:40Z -- created key custodian tom@example.com 196 | - 2020-01-22T19:46:15Z -- created domain dev with owner tom@example.com 197 | - 2020-01-22T19:46:38Z -- added secret chat-api-key in dev 198 | ``` 199 | 200 | All of the state PocketProtector needs to operate is included in this 201 | file. Several of the text values should be recognizable from our 202 | scenario above. 203 | 204 | But there are more convenient ways to get access to the values 205 | designed for external consumption. Let's take a look, with a file 206 | that's had a couple more values added to it. 207 | 208 | ### Listing available domains 209 | 210 | The first way to get acquainted with a protected is to list the 211 | domains within the file. 212 | 213 | ``` 214 | $ pprotect list-domains 215 | dev 216 | prod 217 | ``` 218 | 219 | As we can see, Tom has added a `prod` domain in addition to the `dev` 220 | one we created above. Many projects need to function in multiple 221 | environments, and PocketProtector's domains are a natural way to 222 | segment the different secrets used in each environment. 223 | 224 | ### Listing secrets within a given domain 225 | 226 | If we know which domain we want to inspect, we can list its secrets 227 | like so: 228 | 229 | ``` 230 | $ pprotect list-domain-secrets dev 231 | chat-api-key 232 | mail-api-key 233 | ``` 234 | 235 | It seems Tom has recently added a new key for mail integration, in 236 | addition to the chat key we added above. 237 | 238 | But just because a key is in one domain, doesn't mean it has to be in 239 | all of them. Let's get an overview. 240 | 241 | ### Listing all secrets in a protected 242 | 243 | Because domains can overlap and also diverge, it can be very useful to 244 | get an overview of all the secrets contained in a protected. The 245 | `list-all-secrets` subcommand gives a sorted list with each secret, 246 | followed by a colon and a comma-separated list of domains that contain 247 | that secret, like so: 248 | 249 | ``` 250 | $ pprotect list-all-secrets 251 | chat-api-key: dev 252 | mail-api-key: dev, prod 253 | ``` 254 | 255 | As we can see, that mail integration key is actually present for both 256 | `dev` and `prod` domains, so Tom may have rush deployed that 257 | integration already. 258 | 259 | The actual values for these secrets may or may not be the same. In 260 | practice none of them should be, but even if they were, inspecting the 261 | file would not give any indication, because internally different 262 | encryption keys are used for each domain. 263 | 264 | ### Listing activity on the protected file 265 | 266 | So far we've focused on protected domains and secrets, but 267 | PocketProtector also builds in one very useful metadata feature: The 268 | audit log. 269 | 270 | The audit log keeps a human readable list of operations performed on 271 | the protected. You can see this in our full-text example above, but 272 | you can also access it from the command line, one entry per line: 273 | 274 | ``` 275 | $ pprotect list-audit-log 276 | 2020-01-22T18:06:40Z -- created key custodian tom@example.com 277 | 2020-01-22T19:46:15Z -- created domain dev with owner tom@example.com 278 | 2020-01-22T19:46:38Z -- added secret chat-api-key in dev 279 | 2020-01-23T05:12:28Z -- created domain prod with owner tom@example.com 280 | 2020-01-23T05:13:22Z -- added secret mail-api-key in dev 281 | 2020-01-23T05:13:50Z -- added secret mail-api-key in prod 282 | ``` 283 | 284 | And here we can see how it all went down. The audit log is a pretty 285 | good summary that should be used in conjunction with your source 286 | control management tools. Using `git` as an example, `git log 287 | protected.yaml` and `git blame protected.yaml` are both excellent 288 | complements to the audit log. 289 | 290 | The audit log is also completely supplementary. It can safely be 291 | truncated without affecting any other PocketProtector functionality. 292 | 293 | 323 | -------------------------------------------------------------------------------- /pocket_protector/cli.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | pocket_protector 4 | 5 | People-centric secret management system, built to work with modern distributed version control systems. 6 | """ 7 | # Note that the doc above is part of "pprotect -h" output, add to it wisely. 8 | 9 | import os 10 | import sys 11 | import json 12 | import difflib 13 | 14 | from face import Command, Flag, face_middleware, CommandLineError, UsageError, echo, prompt 15 | 16 | from ._version import __version__ 17 | from .file_keys import KeyFile, Creds, PPError 18 | 19 | _ANSI_FORE_RED = '\x1b[31m' 20 | _ANSI_FORE_GREEN = '\x1b[32m' 21 | _ANSI_RESET_ALL = '\x1b[0m' 22 | 23 | # TODO: custodian-signed values. allow custodians to sign values 24 | # added/set by others, then produced reports on which secrets have been 25 | # updated/changed but not signed yet. enables a review/audit mechanism. 26 | 27 | try: 28 | unicode 29 | except NameError: 30 | # py3 31 | unicode = str 32 | 33 | 34 | def _get_text(inp): 35 | if not isinstance(inp, unicode): 36 | return inp.decode('utf8') 37 | return inp 38 | 39 | 40 | def _create_protected(path): 41 | if os.path.exists(path): 42 | raise UsageError('Protected file already exists: %s' % path, 2) 43 | open(path, 'wb').close() 44 | kf = KeyFile.create(path=path) 45 | kf.write() 46 | return kf 47 | 48 | 49 | def _ensure_protected(path): 50 | if not os.path.exists(path): 51 | raise UsageError('Protected file not found: %s' % path, 2) 52 | kf = KeyFile.from_file(path) 53 | return kf 54 | 55 | 56 | def _get_colorized_lines(lines): 57 | ret = [] 58 | colors = {'-': _ANSI_FORE_RED, '+': _ANSI_FORE_GREEN} 59 | for line in lines: 60 | if line[0] in colors: 61 | line = colors[line[0]] + line + _ANSI_RESET_ALL 62 | ret.append(line) 63 | return ret 64 | 65 | 66 | def _get_new_creds(confirm=True): 67 | user_id = prompt('User email: ') 68 | passphrase = prompt.secret('Passphrase: ', confirm=confirm) 69 | ret = Creds(user_id, passphrase) 70 | return ret 71 | 72 | 73 | def _get_creds(kf, 74 | user=None, 75 | interactive=True, 76 | check_env=True, 77 | passphrase_file=None, 78 | user_env_var='PPROTECT_USER', 79 | pass_env_var='PPROTECT_PASSPHRASE'): 80 | if not interactive and not check_env: 81 | raise UsageError('expected at least one of check_env' 82 | ' and interactive to be True', 2) 83 | user_source = 'argument' 84 | passphrase, passphrase_source = None, None 85 | if passphrase_file: 86 | passphrase_file = os.path.abspath(passphrase_file) 87 | try: 88 | passphrase = open(passphrase_file, 'rb').read().decode('utf8') 89 | except IOError as ioe: 90 | if getattr(ioe, 'strerror', None): 91 | msg = '%s while reading passphrase from file at "%s"' % (ioe.strerror, passphrase_file) 92 | else: 93 | msg = 'Failed to read passphrase from file at "%s"' % passphrase_file 94 | raise UsageError(msg=msg) 95 | else: 96 | passphrase_source = "passphrase file: %s" % passphrase_file 97 | if user is None and user_env_var: 98 | user = os.getenv(user_env_var) 99 | user_source = 'env var: %s' % user_env_var 100 | if passphrase is None and pass_env_var: 101 | passphrase = os.getenv(pass_env_var) 102 | passphrase_source = 'env var: %s' % pass_env_var 103 | 104 | if interactive: 105 | msg = '' 106 | if user is None: 107 | msg = 'Verify credentials for %s' % kf.path 108 | elif passphrase is None: 109 | msg = 'Verify passphrase for %s (Using user %s from %s)' % (kf.path, user, user_source) 110 | if msg: 111 | echo.err(msg) 112 | 113 | if user is None: 114 | user = prompt('User email: ') 115 | user_source = 'stdin' 116 | if passphrase is None: 117 | passphrase = prompt.secret('Passphrase: ', confirm=False) 118 | passphrase_source = 'stdin' 119 | 120 | creds = Creds(_get_text(user or ''), _get_text(passphrase or ''), 121 | name_source=user_source, passphrase_source=passphrase_source) 122 | _check_creds(kf, creds) 123 | 124 | return creds 125 | 126 | 127 | def _check_creds(kf, creds): 128 | if kf.check_creds(creds): 129 | return True 130 | 131 | msg = 'Invalid user email' 132 | if creds.name_source: 133 | msg += ' (from %s)' % creds.name_source 134 | msg += ' or passphrase' 135 | if creds.passphrase_source: 136 | msg += ' (from %s)' % creds.passphrase_source 137 | msg += '. Check credentials and try again.' 138 | empty_fields = [] 139 | if creds.name == '': 140 | empty_fields.append('user ID') 141 | if creds.passphrase == '': 142 | empty_fields.append('passphrase') 143 | if empty_fields: 144 | msg += ' (Warning: Empty ' + ' and '.join(empty_fields) + '.)' 145 | 146 | raise UsageError(msg, 1) 147 | 148 | 149 | def _get_cmd(prepare=False): 150 | cmd = Command(name='pocket_protector', func=None, doc=__doc__) # func=None means output help 151 | 152 | # add flags 153 | cmd.add('--file', missing='protected.yaml', 154 | doc='path to the PocketProtector-managed file, defaults to protected.yaml in the working directory') 155 | cmd.add('--confirm', parse_as=True, 156 | doc='show diff and prompt for confirmation before modifying the file') 157 | cmd.add('--non-interactive', parse_as=True, 158 | doc='disable falling back to interactive authentication, useful for automation') 159 | cmd.add('--ignore-env', parse_as=True, display=False, # TODO: keep? 160 | doc='ignore environment variables like PPROTECT_PASSPHRASE') 161 | cmd.add('--user', char='-u', 162 | doc="the acting user's email credential") 163 | cmd.add('--passphrase-file', 164 | doc='path to a file containing only the passphrase, likely provided by a deployment system') 165 | 166 | # add middlewares, outermost first ("first added, first called") 167 | cmd.add(mw_verify_creds) 168 | cmd.add(mw_write_kf) 169 | cmd.add(mw_ensure_kf) 170 | cmd.add(mw_exit_handler) 171 | 172 | # add subcommands 173 | cmd.add(add_key_custodian, name='init', doc='create a new protected') 174 | cmd.add(add_key_custodian) 175 | 176 | cmd.add(add_domain) 177 | cmd.add(rm_domain) 178 | 179 | cmd.add(add_owner) 180 | cmd.add(rm_owner) 181 | 182 | cmd.add(add_secret) 183 | cmd.add(update_secret) 184 | cmd.add(rm_secret) 185 | 186 | cmd.add(set_key_custodian_passphrase) 187 | cmd.add(rotate_domain_keys) 188 | 189 | cmd.add(decrypt_domain, posargs={'count': 1, 'provides': 'domain_name'}) 190 | 191 | cmd.add(list_domains) 192 | cmd.add(list_domain_secrets, posargs={'count': 1, 'provides': 'domain_name'}) 193 | cmd.add(list_all_secrets) 194 | cmd.add(list_audit_log) 195 | 196 | cmd.add(print_version, name='version') 197 | 198 | if prepare: 199 | cmd.prepare() # an optional check on all subcommands, not just the one being executed 200 | 201 | return cmd 202 | 203 | 204 | def main(argv=None): # pragma: no cover (see note in tests.test_cli.test_main) 205 | cmd = _get_cmd() 206 | 207 | cmd.run(argv=argv) # exit behavior is handled by mw_exit_handler 208 | 209 | return 210 | 211 | 212 | """ 213 | The following subcommand handlers all update/write to a protected file (wkf). 214 | """ 215 | 216 | def add_key_custodian(wkf): 217 | 'add a new key custodian to the protected' 218 | echo('Adding new key custodian.') 219 | creds = _get_new_creds() 220 | return wkf.add_key_custodian(creds) 221 | 222 | 223 | def add_domain(wkf, creds): 224 | 'add a new domain to the protected' 225 | echo('Adding new domain.') 226 | domain_name = prompt('Domain name: ') 227 | 228 | return wkf.add_domain(domain_name, creds.name) 229 | 230 | 231 | def rm_domain(wkf): 232 | 'remove a domain and all of its keys from the protected' 233 | echo('Removing domain.') 234 | domain_name = prompt('Domain name: ') 235 | return wkf.rm_domain(domain_name) 236 | 237 | 238 | def add_owner(wkf, creds): 239 | 'add a key custodian to the owner list of a specific domain' 240 | echo('Adding domain owner.') 241 | domain_name = prompt('Domain name: ') 242 | new_owner_name = prompt('New owner email: ') 243 | return wkf.add_owner(domain_name, new_owner_name, creds) 244 | 245 | 246 | def rm_owner(wkf): 247 | 'remove a key custodian from the owner list of a domain' 248 | echo('Removing domain owner.') 249 | domain_name = prompt('Domain name: ') 250 | owner_name = prompt('Owner email: ') 251 | return wkf.rm_owner(domain_name, owner_name) 252 | 253 | 254 | def add_secret(wkf): 255 | 'add a secret to a domain' 256 | echo('Adding secret value.') 257 | domain_name = prompt('Domain name: ') 258 | secret_name = prompt('Secret name: ') 259 | secret_value = prompt('Secret value: ') 260 | return wkf.add_secret(domain_name, secret_name, secret_value) 261 | 262 | 263 | def update_secret(wkf): 264 | 'update a secret value in a domain' 265 | echo('Updating secret value.') 266 | domain_name = prompt('Domain name: ') 267 | secret_name = prompt('Secret name: ') 268 | secret_value = prompt('Secret value: ') 269 | return wkf.update_secret(domain_name, secret_name, secret_value) 270 | 271 | 272 | def rm_secret(wkf): 273 | 'remove a secret from a domain' 274 | echo('Updating secret value.') 275 | domain_name = prompt('Domain name: ') 276 | secret_name = prompt('Secret name: ') 277 | return wkf.rm_secret(domain_name, secret_name) 278 | 279 | 280 | def set_key_custodian_passphrase(wkf): 281 | 'update a key custodian passphrase' 282 | user_id = prompt('User email: ') 283 | passphrase = prompt.secret('Current passphrase: ') 284 | creds = Creds(user_id, passphrase) 285 | _check_creds(wkf, creds) 286 | new_passphrase = prompt.secret('New passphrase: ', confirm=True) 287 | return wkf.set_key_custodian_passphrase(creds, new_passphrase) 288 | 289 | 290 | def rotate_domain_keys(wkf, creds): 291 | 'rotate the internal encryption keys for a given domain' 292 | domain_name = prompt('Domain name: ') 293 | return wkf.rotate_domain_key(domain_name, creds) 294 | 295 | 296 | """ 297 | Read-only operations follow 298 | """ 299 | 300 | def print_version(): 301 | 'print the PocketProtector version and exit' 302 | echo('pocket_protector version %s' % __version__) 303 | sys.exit(0) 304 | 305 | 306 | def decrypt_domain(kf, creds, domain_name): 307 | 'output the decrypted contents of a domain in JSON format' 308 | decrypted_dict = kf.decrypt_domain(domain_name, creds) 309 | echo(json.dumps(decrypted_dict, indent=2, sort_keys=True)) 310 | return 0 311 | 312 | 313 | def list_domains(kf): 314 | 'print a list of domain names, if any' 315 | domain_names = kf.get_domain_names() 316 | if domain_names: 317 | echo('\n'.join(domain_names)) 318 | else: 319 | echo.err('(No domains in protected at %s)' % kf.path) 320 | return 321 | 322 | 323 | def list_domain_secrets(kf, domain_name): 324 | 'print a list of secret names for a given domain' 325 | secret_names = kf.get_domain_secret_names(domain_name) 326 | if secret_names: 327 | echo('\n'.join(secret_names)) 328 | else: 329 | echo.err('(No secrets in domain %r of protected at %s)' 330 | % (domain_name, kf.path)) 331 | return 332 | 333 | 334 | def list_all_secrets(kf): 335 | 'print a list of all secret names, along with the domains that define each' 336 | secrets_map = kf.get_all_secret_names() 337 | if not secrets_map: 338 | echo.err('(No secrets in protected at %s)' % kf.path) 339 | else: 340 | for secret_name in sorted(secrets_map): 341 | domain_names = sorted(set(secrets_map[secret_name])) 342 | echo('%s: %s' % (secret_name, ', '.join(domain_names))) 343 | return 344 | 345 | 346 | def list_audit_log(kf): 347 | 'print a list of actions from the audit log, one per line' 348 | log_list = kf.get_audit_log() 349 | echo('\n'.join(log_list)) 350 | return 351 | 352 | 353 | """ 354 | End subcommand handlers 355 | 356 | Begin middlewares 357 | """ 358 | 359 | 360 | @face_middleware(provides=['creds'], optional=True) 361 | def mw_verify_creds(next_, kf, user, ignore_env, non_interactive, passphrase_file): 362 | creds = _get_creds(kf, user, 363 | check_env=not ignore_env, 364 | interactive=not non_interactive, 365 | passphrase_file=passphrase_file) 366 | return next_(creds=creds) 367 | 368 | 369 | @face_middleware(provides=['kf'], optional=True) 370 | def mw_ensure_kf(next_, file, subcmds_): 371 | file_path = file or 'protected.yaml' 372 | file_abs_path = os.path.abspath(file_path) 373 | init_kf = subcmds_[0] == 'init' 374 | if init_kf: 375 | kf = _create_protected(file_abs_path) 376 | else: 377 | kf = _ensure_protected(file_abs_path) 378 | 379 | try: 380 | ret = next_(kf=kf) 381 | except: 382 | if init_kf: 383 | try: 384 | os.unlink(file_abs_path) 385 | except Exception: 386 | echo.err('Warning: failed to remove file: %s' % file_abs_path) 387 | raise 388 | 389 | return ret 390 | 391 | 392 | @face_middleware(provides=['wkf'], optional=True) 393 | def mw_write_kf(next_, kf, confirm): 394 | if not os.access(kf.path, os.W_OK): 395 | raise UsageError('expected %r to be a writable file. Check the' 396 | ' permissions and try again.' % kf.path) 397 | 398 | modified_kf = next_(wkf=kf) 399 | 400 | if not modified_kf: 401 | return modified_kf 402 | 403 | if confirm: 404 | diff_lines = list(difflib.unified_diff(kf.get_contents().splitlines(), 405 | modified_kf.get_contents().splitlines(), 406 | kf.path + '.old', kf.path + '.new')) 407 | diff_lines = _get_colorized_lines(diff_lines) 408 | echo('Changes to be written:\n') 409 | echo('\n'.join(diff_lines) + '\n') 410 | do_write = prompt('Write changes? [y/N] ') 411 | if not do_write.lower().startswith('y'): 412 | echo('Aborting...') 413 | sys.exit(0) 414 | 415 | modified_kf.write() 416 | 417 | return 418 | 419 | 420 | @face_middleware 421 | def mw_exit_handler(next_): 422 | status = 55 # should always be set to something else 423 | try: 424 | try: 425 | status = next_() or 0 426 | except PPError as ppe: 427 | raise UsageError(ppe.args[0]) 428 | except KeyboardInterrupt: 429 | echo('') 430 | status = 130 431 | except EOFError: 432 | echo('') 433 | status = 1 434 | 435 | sys.exit(status) 436 | 437 | return 438 | -------------------------------------------------------------------------------- /pocket_protector/file_keys.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Functions and classes for dealing with a checked-in file, 3 | protected.yaml, which stores secret data securely. 4 | 5 | There are two public classes: KeyFile, and Creds. 6 | ''' 7 | import os 8 | import re 9 | import base64 10 | import collections 11 | import datetime 12 | import hashlib 13 | try: 14 | from cStringIO import StringIO 15 | except ImportError: 16 | from io import StringIO 17 | 18 | import attr 19 | import nacl.utils 20 | import nacl.public 21 | import nacl.secret 22 | import nacl.pwhash 23 | import schema 24 | import ruamel.yaml 25 | from boltons.dictutils import OMD 26 | from boltons.fileutils import atomic_save 27 | 28 | try: 29 | unicode = unicode 30 | except NameError: 31 | unicode = str # py3 32 | 33 | 34 | _VALID_NAME_RE = re.compile(r"^[A-z][-_A-z0-9]*\Z") 35 | 36 | 37 | class PPError(Exception): 38 | pass 39 | 40 | 41 | class PPKeyError(PPError, KeyError): 42 | pass 43 | 44 | 45 | # _coerce to handle ruamel type switcheroo 46 | def _coerce(type_, sub_schema): 47 | return schema.And(schema.Use(type_), sub_schema) 48 | 49 | 50 | def _as_d(sub_schema): return _coerce(dict, sub_schema) 51 | def _as_l(sub_schema): return _coerce(list, sub_schema) 52 | 53 | 54 | _FILE_SCHEMA = schema.Schema(_as_d( 55 | { 56 | "audit-log": _as_l([str]), 57 | "key-custodians": _as_d({ 58 | schema.Optional(str): _as_d({ 59 | "pwdkm": str, 60 | }), 61 | }), 62 | schema.Optional(schema.Regex("^(?!meta).*$")): _as_d({ 63 | # allow string names for security domains, 64 | # but meta is reserved 65 | "meta": _as_d({ 66 | "owners": _as_d({str: str}), 67 | "public-key": str, 68 | }), 69 | schema.Optional(schema.Regex("secret-.*")): str, 70 | }), 71 | })) 72 | 73 | 74 | OPSLIMIT = nacl.pwhash.argon2id.OPSLIMIT_SENSITIVE 75 | MEMLIMIT = nacl.pwhash.argon2id.MEMLIMIT_MODERATE 76 | 77 | # NOTE: this is a public class since it must be passed in 78 | @attr.s(frozen=True) 79 | class Creds(object): 80 | "Stores credentials used to open a KeyFile" 81 | name = attr.ib(validator=attr.validators.instance_of(unicode)) 82 | passphrase = attr.ib(validator=attr.validators.instance_of(unicode)) 83 | name_source = attr.ib(default=None) 84 | passphrase_source = attr.ib(default=None) 85 | 86 | 87 | def _kdf(creds, salt): 88 | name = creds.name.encode('utf8') 89 | passphrase = creds.passphrase.encode('utf8') 90 | 91 | valet_key = hashlib.sha512(passphrase + salt + name).digest() 92 | # valet key can be used to share credentials 93 | # without exposing password 94 | return nacl.pwhash.argon2id.kdf( 95 | nacl.public.PrivateKey.SIZE, 96 | valet_key, hashlib.sha512(salt + name).digest()[:16], 97 | opslimit=OPSLIMIT, 98 | memlimit=MEMLIMIT) 99 | 100 | 101 | def _decode(b64): 102 | ''' 103 | assert everything is version 0 104 | later on for e.g. algorithm flexibility 105 | version 1, 2, 3 could be added with object 106 | specific encoder / decoder 107 | (this would be a lot of code work, but the files would 108 | be forwards compatible, and backwards can at least 109 | detect the problem and notify the user cleanly) 110 | ''' 111 | raw = base64.b64decode(b64) 112 | if raw[:1] == b'\0': 113 | return raw[1:] 114 | raise PPError('version %s object not supported' % ord(raw[:1])) 115 | 116 | 117 | def _encode(raw): 118 | 'add version 0 byte to everything' 119 | return base64.b64encode(b'\0' + raw).decode('utf8') 120 | 121 | 122 | @attr.s(frozen=True) 123 | class _KeyCustodian(object): 124 | ''' 125 | represents a key-custodian, who may be granted ownership 126 | (aka the ability to decrypt secrets) in one or more domains 127 | ''' 128 | name = attr.ib() 129 | _public_key = attr.ib() 130 | _salt = attr.ib() 131 | 132 | def encrypt_for(self, bytes): 133 | 'encrypt the passed bytes so that this key-custodian can decrypt' 134 | return nacl.public.SealedBox(self._public_key).encrypt(bytes) 135 | 136 | def decrypt_as(self, creds, bytes): 137 | 'decrypt the passed bytes that were encrypted for this key-custodian' 138 | assert creds.name == self.name 139 | return nacl.public.SealedBox( 140 | nacl.public.PrivateKey(_kdf(creds, self._salt))).decrypt(bytes) 141 | 142 | @classmethod 143 | def from_creds(cls, creds): 144 | 'create a new user based on new credentials' 145 | salt = os.urandom(8) 146 | private_key = nacl.public.PrivateKey(_kdf(creds, salt)) 147 | return cls( 148 | name=creds.name, public_key=private_key.public_key, salt=salt) 149 | 150 | @classmethod 151 | def from_data(cls, name, data): 152 | # password derived key material 153 | pwdkm = _decode(data['pwdkm']) 154 | salt, public_key = pwdkm[:8], pwdkm[8:8 + nacl.public.PublicKey.SIZE] 155 | return cls( 156 | name=name, public_key=nacl.public.PublicKey(public_key), salt=salt) 157 | 158 | def as_data(self): 159 | return { 160 | 'pwdkm': _encode(self._salt + self._public_key.encode()), 161 | } 162 | 163 | 164 | @attr.s(frozen=True) 165 | class _Owner(object): 166 | 'represents an ownership relationship between a key-custodian and a key-domain' 167 | _name = attr.ib() 168 | _enc_domain_private_key = attr.ib() 169 | 170 | @classmethod 171 | def from_custodian_and_pkey(cls, key_custodian, pkey): 172 | ''' 173 | create an ownership relationship based on a key_custodian 174 | and decrypted private key 175 | ''' 176 | return cls(key_custodian.name, key_custodian.encrypt_for(pkey.encode())) 177 | 178 | def decrypt_private_key_bytes(self, creds, key_custodian): 179 | 'decrypt the private key based on the passphrase' 180 | return key_custodian.decrypt_as(creds, self._enc_domain_private_key) 181 | 182 | @classmethod 183 | def from_data(cls, name, encrypted_private_key_bytes): 184 | return cls(name, _decode(encrypted_private_key_bytes)) 185 | 186 | def as_data(self): 187 | return _encode(self._enc_domain_private_key) 188 | 189 | 190 | def _err_map_attrib(item_name): 191 | 'utility for giving good error messages' 192 | class MissingErrDict(dict): 193 | def __missing__(self, key): 194 | raise PPKeyError("no {0} of name {1} (known {0}s are {2})".format( 195 | item_name, key, ", ".join(self))) 196 | return attr.ib(default=attr.Factory(dict), converter=MissingErrDict) 197 | 198 | 199 | def _deleted(mapping, key): 200 | ''' 201 | like sort() vs sorted(), return a dict copy of mapping 202 | with key del'd out 203 | ''' 204 | ret = dict(mapping) 205 | del ret[key] 206 | return ret 207 | 208 | 209 | def _setitem(mapping, key, val): 210 | ''' 211 | returns a dict-copy of mapping with key set to val 212 | ''' 213 | ret = dict(mapping) 214 | ret[key] = val 215 | return ret 216 | 217 | 218 | @attr.s(frozen=True) 219 | class _EncryptedKeyDomain(object): 220 | 'Represents a key domain with all values encrypted.' 221 | _name = attr.ib() 222 | _pub_key = attr.ib() 223 | _secrets = _err_map_attrib('secret') 224 | _owners = _err_map_attrib('owner') 225 | 226 | def _decrypt_private_key(self, key_custodian, creds): 227 | return nacl.public.PrivateKey( 228 | self._owners[creds.name].decrypt_private_key_bytes( 229 | creds, key_custodian)) 230 | 231 | def get_decrypted(self, key_custodian, creds): 232 | if creds.name not in self._owners: 233 | raise PPError('{} is not an owner of {}'.format( 234 | creds.name, self._name)) 235 | box = nacl.public.SealedBox(self._decrypt_private_key( 236 | key_custodian, creds)) 237 | secrets = {} 238 | for name, val in self._secrets.items(): 239 | secrets[name] = box.decrypt(val).decode('utf8') 240 | return _KeyDomain(secrets) 241 | 242 | def set_secret(self, name, value): 243 | 'return a copy of the EncryptedKeyDomain with the new secret name/value' 244 | name_match = _VALID_NAME_RE.match(name) 245 | if not name_match: 246 | raise ValueError('valid secret names must begin with a letter, and' 247 | ' consist only of ASCII letters, digits, and' 248 | ' underscores, not: %r' % name) 249 | 250 | secrets = dict(self._secrets) 251 | box = nacl.public.SealedBox(self._pub_key) 252 | secrets[name] = box.encrypt(value.encode('utf8')) 253 | return attr.evolve(self, secrets=secrets) 254 | 255 | def add_secret(self, name, value): 256 | 'like set_secret, but errors if secret exists' 257 | if name in self._secrets: 258 | raise PPError('secret {} already exists in {}'.format( 259 | name, self._name)) 260 | return self.set_secret(name, value) 261 | 262 | def update_secret(self, name, value): 263 | 'like set_secret, but errors if secret doesnt exist' 264 | chk = self._secrets[name] # for error msg 265 | return self.set_secret(name, value) 266 | 267 | def rm_secret(self, name): 268 | return attr.evolve(self, secrets=_deleted(self._secrets, name)) 269 | 270 | def add_owner(self, cur_creds, cur_key_custodian, new_key_custodian): 271 | 'add a new owner based on a current owners credentials' 272 | domain_private_key = self._decrypt_private_key( 273 | cur_key_custodian, cur_creds) 274 | owners = dict(self._owners) 275 | owners[new_key_custodian.name] = _Owner.from_custodian_and_pkey( 276 | new_key_custodian, domain_private_key) 277 | return attr.evolve(self, owners=owners) 278 | 279 | def rm_owner(self, key_custodian_name): 280 | 'remove owner, checking that domain has at least one user' 281 | if key_custodian_name not in self._owners: 282 | raise PPError("{} not an owner of {} (owners are {})".format( 283 | key_custodian_name, self._name, ", ".join(self._owners))) 284 | if len(self._owners) == 1: 285 | raise PPError( 286 | "cannot delete last owner {} from {} " 287 | "(secrets would be irretrievable)".format(key_custodian_name, self._name)) 288 | return attr.evolve(self, owners=_deleted(self._owners, key_custodian_name)) 289 | 290 | def get_owner_names(self): 291 | return list(self._owners) 292 | 293 | @classmethod 294 | def from_owner(cls, name, key_custodian): 295 | 'create a new (empty) EncryptedKeyDomain with an initial owner' 296 | domain_private_key = nacl.public.PrivateKey.generate() 297 | return cls( 298 | name=name, 299 | pub_key=domain_private_key.public_key, 300 | secrets={}, 301 | owners={ 302 | key_custodian.name: 303 | _Owner.from_custodian_and_pkey( 304 | key_custodian, domain_private_key)}) 305 | 306 | @classmethod 307 | def from_data(cls, name, data): 308 | 'convert nested dict/list/str to instance' 309 | return cls( 310 | name=name, 311 | pub_key=nacl.public.PublicKey( 312 | _decode(data['meta']['public-key'])), 313 | owners={ 314 | name: _Owner.from_data(name, owner_data) 315 | for name, owner_data in data['meta']['owners'].items()}, 316 | secrets={ 317 | name.split('secret-', 1)[1]: _decode(val) 318 | for name, val in data.items() 319 | if name.startswith('secret-')}) 320 | 321 | def as_data(self): 322 | 'convert instance to nested dict/list/str' 323 | data = { "secret-" + name: _encode(val) 324 | for name, val in self._secrets.items() } 325 | # ensure keys go in sorted order 326 | data = collections.OrderedDict(sorted(data.items())) 327 | data['meta'] = collections.OrderedDict([ 328 | ("public-key", _encode(self._pub_key.encode())), 329 | ("owners", collections.OrderedDict( 330 | sorted([(name, owner.as_data()) for name, owner in self._owners.items()]))), 331 | ]) 332 | return data 333 | 334 | 335 | class _KeyDomain(dict): 336 | 'Represents a decrypted key domain which secrets can be read from' 337 | def __missing__(self, key): 338 | raise PPKeyError("no secret {} (known secrets are {})".format( 339 | key, ", ".join(self))) 340 | 341 | 342 | def _represent_ordereddict(dumper, data): 343 | value = [] 344 | for item_key, item_value in data.items(): 345 | node_key = dumper.represent_data(item_key) 346 | node_value = dumper.represent_data(item_value) 347 | value.append((node_key, node_value)) 348 | return ruamel.yaml.nodes.MappingNode(u'tag:yaml.org,2002:map', value) 349 | 350 | 351 | ruamel.yaml.representer.RoundTripRepresenter.add_representer( 352 | collections.OrderedDict, _represent_ordereddict) 353 | 354 | 355 | @attr.s(frozen=True) 356 | class KeyFile(object): 357 | ''' 358 | Represents a key-file (containing many domains) 359 | Can be read from and written to disk 360 | ''' 361 | path = attr.ib() 362 | _domains = _err_map_attrib('domain') 363 | _key_custodians = _err_map_attrib('key custodian') 364 | _log = attr.ib(default=attr.Factory(list)) 365 | _yaml = ruamel.yaml.YAML() # class var 366 | _yaml.width = 100 367 | 368 | @classmethod 369 | def create(cls, path): 370 | blank = cls(path=path) 371 | ret = attr.evolve(blank, log=blank._new_log('created')) 372 | return ret 373 | 374 | @classmethod 375 | def from_file(cls, path): 376 | 'create a new KeyFile from path' 377 | with open(path, 'rb') as file: 378 | contents = file.read().decode('utf8') 379 | return cls.from_contents_and_path(contents, path) 380 | 381 | @classmethod 382 | def from_contents_and_path(cls, bytes, path): 383 | 'create a new KeyFile from file contents' 384 | contents = cls._yaml.load(bytes) 385 | _FILE_SCHEMA.validate(contents) 386 | log = contents.pop('audit-log') 387 | key_custodians = { 388 | name: _KeyCustodian.from_data(name, val) 389 | for name, val in contents.pop('key-custodians').items()} 390 | encrypted_domains = { 391 | name: _EncryptedKeyDomain.from_data(name, data) 392 | for name, data in contents.items() } 393 | return cls( 394 | path=path, domains=encrypted_domains, 395 | key_custodians=key_custodians, log=log) 396 | 397 | def get_domain_names(self): 398 | return sorted(self._domains.keys()) 399 | 400 | def get_domain_secret_names(self, domain_name): 401 | domain = self._domains[domain_name] 402 | return sorted(domain._secrets.keys()) 403 | 404 | def get_all_secret_names(self): 405 | "return a map of secret names to names of domains that contain that secret" 406 | res = OMD() 407 | for domain_name, domain in self._domains.items(): 408 | secrets_dict = domain._secrets 409 | for secret_name in secrets_dict: 410 | res.add(secret_name, domain_name) 411 | return res.todict(True) 412 | 413 | def get_contents(self): 414 | data = collections.OrderedDict(sorted([ 415 | (domain_name, domain.as_data()) 416 | for domain_name, domain in self._domains.items()])) 417 | data['key-custodians'] = collections.OrderedDict(sorted([ 418 | (name, kc.as_data()) 419 | for name, kc in self._key_custodians.items()])) 420 | data['audit-log'] = self._log 421 | stream = StringIO() 422 | self._yaml.dump(data, stream) 423 | text = stream.getvalue() 424 | return text 425 | 426 | def _new_log(self, entry, *a, **kw): 427 | cur_time_str = datetime.datetime.utcnow().isoformat().split('.')[0] + 'Z' 428 | new_entry = cur_time_str + ' -- ' + (entry.format(*a, **kw)) 429 | return self._log + [new_entry] 430 | 431 | def get_audit_log(self): 432 | return list(self._log) 433 | 434 | def write(self): 435 | 'write contents to file' 436 | contents = self.get_contents() 437 | with atomic_save(self.path) as file: 438 | file.write(contents.encode('utf8')) 439 | return 440 | 441 | def add_domain(self, domain_name, key_custodian_name): 442 | ''' 443 | return a copy with a new domain, empty but with one initial key custodian 444 | owner who can add other owners 445 | ''' 446 | if domain_name in self._domains: 447 | raise PPError('tried to add domain that already exists: {}'.format(domain_name)) 448 | key_custodian = self._key_custodians[key_custodian_name] 449 | domains = dict(self._domains) 450 | domains[domain_name] = _EncryptedKeyDomain.from_owner( 451 | domain_name, key_custodian) 452 | return attr.evolve( 453 | self, domains=domains, 454 | log=self._new_log('created domain {} with owner {}', 455 | domain_name, key_custodian_name)) 456 | 457 | def rm_domain(self, domain_name): 458 | ''' 459 | return a copy with domain domain_name removed 460 | ''' 461 | return attr.evolve( 462 | self, domains=_deleted(self._domains, domain_name), 463 | log=self._new_log('deleted domain {}', domain_name)) 464 | 465 | def set_secret(self, domain_name, name, value): 466 | 'return a copy of the KeyFile with the given secret name and value added to a domain' 467 | domains = dict(self._domains) 468 | domains[domain_name] = self._domains[domain_name].set_secret(name, value) 469 | return attr.evolve( 470 | self, domains=domains, 471 | log=self._new_log('set secret {} in {}', name, domain_name)) 472 | 473 | def add_secret(self, domain_name, name, value): 474 | 'add a secret that doesnt exist yet' 475 | domains = dict(self._domains) 476 | domains[domain_name] = self._domains[domain_name].add_secret(name, value) 477 | return attr.evolve( 478 | self, domains=domains, 479 | log=self._new_log('added secret {} in {}', name, domain_name)) 480 | 481 | def update_secret(self, domain_name, name, value): 482 | 'update the value of a secret that already exists' 483 | domains = dict(self._domains) 484 | domains[domain_name] = self._domains[domain_name].update_secret(name, value) 485 | return attr.evolve( 486 | self, domains=domains, 487 | log=self._new_log('updated secret {} in {}', name, domain_name)) 488 | 489 | def rm_secret(self, domain_name, name): 490 | 'return a copy with secret removed from domain' 491 | domains = dict(self._domains) 492 | domains[domain_name] = self._domains[domain_name].rm_secret(name) 493 | return attr.evolve( 494 | self, domains=domains, 495 | log=self._new_log('removed secret {} from {}', name, domain_name)) 496 | 497 | def add_owner(self, domain_name, key_custodian_name, creds): 498 | ''' 499 | Register a new key custodian owner of domain_name based on the 500 | credentials of an existing owner 501 | ''' 502 | domains = dict(self._domains) 503 | domains[domain_name] = self._domains[domain_name].add_owner( 504 | cur_creds=creds, cur_key_custodian=self._key_custodians[creds.name], 505 | new_key_custodian=self._key_custodians[key_custodian_name]) 506 | return attr.evolve( 507 | self, domains=domains, 508 | log=self._new_log('{} added owner {} to {}', 509 | creds.name, key_custodian_name, domain_name)) 510 | 511 | def rm_owner(self, domain_name, key_custodian_name): 512 | ''' 513 | Remove an owner from domain. 514 | (NOTE: due to file history, the removed owner 515 | will still be able to get to values until you rotate 516 | the domain keypair, and secret values) 517 | ''' 518 | return attr.evolve( 519 | self, domains=_setitem( 520 | self._domains, domain_name, 521 | self._domains[domain_name].rm_owner(key_custodian_name)), 522 | log=self._new_log('removed owner {} from {}', 523 | key_custodian_name, domain_name)) 524 | 525 | def add_key_custodian(self, creds): 526 | key_custodians = dict(self._key_custodians) 527 | if creds.name in key_custodians: 528 | raise PPError( 529 | 'tried to add key custodian that already exists: {}'.format(creds.name)) 530 | key_custodians[creds.name] = _KeyCustodian.from_creds(creds) 531 | return attr.evolve( 532 | self, key_custodians=key_custodians, 533 | log=self._new_log('created key custodian {}', creds.name)) 534 | 535 | def rm_key_custodian(self, key_custodian_name): 536 | 'remove key custodian and all domain ownerships' 537 | key_custodians = dict(self._key_custodians) 538 | domains = dict(self._domains) 539 | owned = [] 540 | for name, domain in self._domains.items(): 541 | if key_custodian_name in domain.get_owner_names(): 542 | domains[name] = domain.rm_owner(key_custodian_name) 543 | owned.append(name) 544 | del key_custodians[key_custodian_name] 545 | return attr.evolve( 546 | self, key_custodians=key_custodians, domains=domains, 547 | log=self._new_log('removed key custodian {} (was owner of {})', 548 | key_custodian_name, ", ".join(owned))) 549 | 550 | def decrypt_domain(self, domain_name, creds): 551 | return self._domains[domain_name].get_decrypted( 552 | self._key_custodians[creds.name], creds) 553 | 554 | def set_key_custodian_passphrase(self, creds, new_passphrase): 555 | new_kc = _KeyCustodian.from_creds(Creds(creds.name, new_passphrase)) 556 | cur_kc = self._key_custodians[creds.name] 557 | key_custodians = dict(self._key_custodians) 558 | key_custodians[creds.name] = new_kc 559 | domains = dict(self._domains) 560 | updated = [] 561 | for name, domain in domains.items(): 562 | if creds.name in domain.get_owner_names(): 563 | domains[name] = self._domains[name].add_owner( 564 | cur_creds=creds, cur_key_custodian=cur_kc, 565 | new_key_custodian=new_kc) 566 | updated.append(name) 567 | return attr.evolve( 568 | self, key_custodians=key_custodians, domains=domains, 569 | log=self._new_log( 570 | 'updated key custodian passphrase for {} (updated domains -> {})', 571 | creds.name, ", ".join(updated))) 572 | 573 | def check_creds(self, creds): 574 | try: 575 | key_custodian = self._key_custodians[creds.name] 576 | except KeyError: 577 | return False 578 | try: 579 | key_custodian.decrypt_as(creds, key_custodian.encrypt_for(b'\0')) 580 | except Exception: # TODO: what crypto error? 581 | return False 582 | return True 583 | 584 | def rotate_domain_key(self, domain_name, creds): 585 | ''' 586 | rotate the keypair used to secure a domain 587 | NIST recommends keys be rotated and not kept in use for more than ~1-3 years 588 | see http://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-57pt1r4.pdf 589 | Recommendation for Key Management, Part 1: General 590 | section 5.3.6 Cryptoperiod Recommendations for Specific Key Types 591 | ''' 592 | cur_domain = self._domains[domain_name] 593 | key_custodian = self._key_custodians[creds.name] 594 | cur_secrets = cur_domain.get_decrypted(key_custodian, creds) 595 | new_domain = _EncryptedKeyDomain.from_owner(domain_name, key_custodian) 596 | for name, val in cur_secrets.items(): 597 | new_domain = new_domain.set_secret(name, val) 598 | for owner_name in cur_domain.get_owner_names(): 599 | new_domain = new_domain.add_owner( 600 | cur_creds=creds, cur_key_custodian=key_custodian, 601 | new_key_custodian=self._key_custodians[owner_name]) 602 | domains = dict(self._domains) 603 | domains[domain_name] = new_domain 604 | return attr.evolve( 605 | self, domains=domains, 606 | log=self._new_log('rotated key for domain {}', domain_name)) 607 | 608 | def truncate_audit_log(self, max_keep): 609 | max_keep = int(max_keep) 610 | if len(self._log) < max_keep: 611 | return self 612 | msg = 'truncated %s audit log entries' % (len(self._log) - max_keep) 613 | new_log = [msg] + self._log[-max_keep:] 614 | return attr.evolve(self, log=new_log) 615 | --------------------------------------------------------------------------------