Skip to content

Commit

Permalink
Support encryption of environment variables at rest
Browse files Browse the repository at this point in the history
  • Loading branch information
nathan-muir committed May 26, 2017
1 parent 4f1134a commit ebae7c0
Show file tree
Hide file tree
Showing 4 changed files with 146 additions and 11 deletions.
25 changes: 19 additions & 6 deletions nv/cli.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import click

import logging
import os

import click

from .core import create, remove, launch_shell

logging.basicConfig()
Expand All @@ -25,10 +26,16 @@ def main():
@click.option('--aws-profile', default=None,
help='''Obtain credentials for the given profile.''')
@click.option('environment_vars', '--env', type=(unicode, unicode), multiple=True)
def cmd_create(environment_name, project_name, project_dir, use_pew, aws_profile, environment_vars):
@click.option('wants_password', '-P', default=False, is_flag=True)
@click.option('use_keyring', '-K', default=False, is_flag=True)
def cmd_create(environment_name, project_name, project_dir, use_pew, aws_profile, environment_vars, wants_password, use_keyring):
"""Create a new environment in %PROJECT%/.nv-%ENVIRONMENT_NAME%"""
password = None
if wants_password:
password = click.prompt('Password', hide_input=True)

nv_dir = create(environment_name, project_dir, project_name=project_name, use_pew=use_pew, aws_profile=aws_profile,
environment_vars=dict(environment_vars))
environment_vars=dict(environment_vars), password=password, use_keyring=use_keyring)
rel_dir = os.path.relpath(nv_dir, os.getcwd())
click.echo("""
environment created at {0}.
Expand All @@ -55,8 +62,14 @@ def cmd_remove(environment_name, project_dir):
@click.option('--project-dir', '-d', default='.',
type=click.Path(file_okay=False, dir_okay=True, exists=True, resolve_path=True),
help='''Path to the project project (defaults to current directory)''')
def cmd_shell(environment_name, project_dir):
@click.option('wants_password', '-P', default=False, is_flag=True)
@click.option('update_keyring', '-K', default=False, is_flag=True)
def cmd_shell(environment_name, project_dir, wants_password, update_keyring):
"""Launch a new shell in the specified environment."""
click.echo("Launching nv subshell. Type 'exit' or 'Ctrl+D' to return.")
launch_shell(environment_name, project_dir)
password = None
if wants_password:
password = click.prompt('Password', hide_input=True)

launch_shell(environment_name, project_dir, password, update_keyring)
click.echo('Environment closed.')
30 changes: 25 additions & 5 deletions nv/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@
import sh
import six

from .crypto import DisabledCrypto, Crypto, keyring_store, keyring_retrieve

logger = logging.getLogger(__name__)


def create(environment_name, project_dir, project_name=None, use_pew=False, aws_profile=None, environment_vars=None):
def create(environment_name, project_dir, project_name=None, use_pew=False, aws_profile=None,
environment_vars=None, password=None, use_keyring=False):
# TODO check that environment_name contains only [a-z0-9_]
# TODO check that project_dir is fully resolved

Expand All @@ -32,10 +35,18 @@ def create(environment_name, project_dir, project_name=None, use_pew=False, aws_
if not project_name:
project_name = basename(project_dir)

if password:
crypto = Crypto.from_password(password)
if use_keyring:
keyring_store(nv_dir, password)
else:
crypto = DisabledCrypto()

nv_conf = {
'project_name': project_name,
'environment_name': environment_name,
'aws_profile': aws_profile,
'encryption': crypto.get_memo(),
}

if use_pew:
Expand All @@ -53,8 +64,7 @@ def create(environment_name, project_dir, project_name=None, use_pew=False, aws_

if environment_vars:
with open(join(nv_dir, 'environment.json'), 'wb') as fp:
json.dump(environment_vars, fp, indent=2)

crypto.json_dump(fp, environment_vars)
return nv_dir


Expand All @@ -73,13 +83,23 @@ def remove(environment_name, project_dir):
shutil.rmtree(nv_dir)


def launch_shell(environment_name, project_dir):
def launch_shell(environment_name, project_dir, password=None, update_keyring=False):
nv_dir = join(project_dir, '.nv-{0}'.format(environment_name))
if not exists(nv_dir):
raise RuntimeError("Not found: '{0}'".format(nv_dir))
with open(join(nv_dir, 'nv.json'), 'rb') as fp:
nv_conf = json.load(fp)

if password and update_keyring:
keyring_store(nv_dir, password)
elif not password:
password = keyring_retrieve(nv_dir)

if password:
crypto = Crypto.from_memo(nv_conf.get('encryption'), password)
else:
crypto = DisabledCrypto()

# TODO Unset environment variables based on pattern.
new_env = os.environ.copy()
new_env.update({
Expand All @@ -106,7 +126,7 @@ def launch_shell(environment_name, project_dir):

if exists(join(nv_dir, 'environment.json')):
with open(join(nv_dir, 'environment.json'), 'rb') as fp:
extra_env = json.load(fp)
extra_env = crypto.json_load(fp)
if not isinstance(extra_env, dict):
raise RuntimeError('Environment: Expected dict got {0}'.format(type(extra_env)))
for k, v in extra_env.items():
Expand Down
100 changes: 100 additions & 0 deletions nv/crypto.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
from __future__ import absolute_import, division, print_function, unicode_literals

import base64
import json
import os
from os.path import abspath

import keyring
import six
from cryptography.fernet import Fernet
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt

KEYRING_SERVICE = 'com.3stack.nv'


def keyring_store(nv_dir, password):
keyring.set_password(KEYRING_SERVICE, abspath(nv_dir), password)
return


def keyring_retrieve(nv_dir):
try:
return keyring.get_password(KEYRING_SERVICE, abspath(nv_dir))
except:
pass


class DisabledCrypto(object):

def get_memo(self):
return None

def json_load(self, fp):
return json.load(fp)

def json_dump(self, fp, obj):
return json.dump(obj, fp, indent=2)


class Crypto(object):

def __init__(self, salt, key):
self._salt = salt
self._engine = Fernet(key)

@classmethod
def from_password(cls, password):
salt = cls.generate_salt()
key = cls.derive_key(salt, password)
return cls(salt, key)

@classmethod
def from_memo(cls, memo, password):
if not isinstance(memo, dict):
raise ValueError('No encryption metadata found')
version = memo.get('version')
if version != '1':
raise ValueError('Unsupported version: {!r}'.format(version))

key = cls.derive_key(memo['salt'], password)
return cls(memo['salt'], key)

def get_memo(self):
return {
'version': '1',
'salt': self._salt
}

def json_load(self, fp):
ciphertext = fp.read()
plaintext = self._engine.decrypt(ciphertext)
return json.loads(plaintext)

def json_dump(self, fp, obj):
plaintext = json.dumps(obj, indent=2)
ciphertext = self._engine.encrypt(plaintext)
fp.write(ciphertext)

@staticmethod
def derive_key(salt, password, key_length=32):
if isinstance(salt, six.text_type):
salt = salt.encode('utf-8')
if isinstance(password, six.text_type):
password = password.encode('utf-8')
kdf = Scrypt(
salt=base64.urlsafe_b64decode(salt),
length=key_length,
n=2**14,
r=8,
p=1,
backend=default_backend(),
)
key = kdf.derive(bytes(password))
return base64.urlsafe_b64encode(key)

@staticmethod
def generate_salt(n=16):
salt = os.urandom(n)
return base64.urlsafe_b64encode(salt)
2 changes: 2 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ def find_version(package):
'boto3',
'click',
'pew',
'cryptography',
'keyring',
],
entry_points={
'console_scripts': [
Expand Down

0 comments on commit ebae7c0

Please sign in to comment.