Skip to content

Commit 4560227

Browse files
authored
Merge pull request #9 from britive/develop
aws credential process encrypted credentials - v0.1.3
2 parents 4feb0db + 2a2c0ce commit 4560227

9 files changed

Lines changed: 135 additions & 65 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
installed via the published tar balls in the GitHub repo.
1515

1616
~~~bash
17-
pip install https://github.com/britive/python-cli/releases/download/v0.1.2/pybritive-0.1.2.tar.gz
17+
pip install https://github.com/britive/python-cli/releases/download/v0.1.3/pybritive-0.1.3.tar.gz
1818
~~~
1919

2020
The end user is free to install the CLI into a virtual environment or in the global scope, so it is available

setup.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[metadata]
22
name = pybritive
3-
version = 0.1.2
3+
version = 0.1.3
44
author = Britive Inc.
55
author_email = support@britive.com
66
description = A pure Python CLI for Britive

src/pybritive/britive_cli.py

Lines changed: 55 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
import io
2-
import os
3-
42
from britive.britive import Britive
53
from .helpers.config import ConfigManager
64
from .helpers.credentials import FileCredentialManager, EncryptedFileCredentialManager
@@ -13,6 +11,7 @@
1311
from .helpers.cache import Cache
1412
from britive import exceptions
1513
from pathlib import Path
14+
from datetime import datetime
1615

1716

1817
default_table_format = 'fancy_grid'
@@ -286,46 +285,71 @@ def checkin(self, profile):
286285
application_name=app_name
287286
)
288287

289-
def checkout(self, alias, blocktime, console, justification, mode, maxpolltime, profile):
290-
self.login()
288+
def checkout(self, alias, blocktime, console, justification, mode, maxpolltime, profile, passphrase):
291289
# first check if this is a profile alias
292290
profile_or_alias = alias or profile
293-
profile = self.config.profile_aliases.get(profile, profile)
294-
parts = profile.split('/')
295-
if len(parts) != 3:
296-
raise click.ClickException('Provided profile string does not have the required 3 parts.')
297-
app_name = parts[0]
298-
env_name = parts[1]
299-
profile_name = parts[2]
300291

301-
try:
302-
response = self.b.my_access.checkout_by_name(
303-
profile_name=profile_name,
304-
environment_name=env_name,
305-
application_name=app_name,
306-
programmatic=False if console else True,
307-
include_credentials=True,
308-
wait_time=blocktime,
309-
max_wait_time=maxpolltime,
310-
justification=justification
311-
)
312-
except exceptions.ApprovalRequiredButNoJustificationProvided:
313-
raise click.ClickException('approval required and no justification provided.')
314-
except ValueError as e:
315-
raise click.BadParameter(str(e))
292+
credentials = None
293+
app_type = None
294+
295+
if mode == 'awscredentialprocess':
296+
self.silent = True # the aws credential process CANNOT output anything other than the expected JSON
297+
# we need to check the credential process cache for the credentials first
298+
# then check to see if they are expired
299+
# if not simply return those credentials
300+
# if they are expired
301+
app_type = 'AWS' # just hardcode as we know for sure this is for AWS
302+
credentials = Cache(passphrase=passphrase).get_awscredentialprocess(profile_name=profile_or_alias)
303+
if credentials:
304+
expiration_timestamp_str = credentials['expirationTime'].replace('Z', '')
305+
expires = datetime.fromisoformat(expiration_timestamp_str)
306+
now = datetime.utcnow()
307+
if now >= expires: # check to ensure the credentials are still valid, if not, set to None and get new
308+
credentials = None
309+
310+
if not credentials: # nothing found via aws credential process or not aws credential process mode
311+
self.login()
312+
profile = self.config.profile_aliases.get(profile, profile)
313+
parts = profile.split('/')
314+
if len(parts) != 3:
315+
raise click.ClickException('Provided profile string does not have the required 3 parts.')
316+
app_name = parts[0]
317+
env_name = parts[1]
318+
profile_name = parts[2]
316319

317-
if alias: # do this down here so we know that the profile is valid and a checkout was successful
318-
self.config.save_profile_alias(alias=alias, profile=profile)
320+
try:
321+
response = self.b.my_access.checkout_by_name(
322+
profile_name=profile_name,
323+
environment_name=env_name,
324+
application_name=app_name,
325+
programmatic=False if console else True,
326+
include_credentials=True,
327+
wait_time=blocktime,
328+
max_wait_time=maxpolltime,
329+
justification=justification
330+
)
331+
credentials = response['credentials']
332+
app_type = self._get_app_type(response['appContainerId'])
333+
except exceptions.ApprovalRequiredButNoJustificationProvided:
334+
raise click.ClickException('approval required and no justification provided.')
335+
except ValueError as e:
336+
raise click.BadParameter(str(e))
337+
338+
if alias: # do this down here so we know that the profile is valid and a checkout was successful
339+
self.config.save_profile_alias(alias=alias, profile=profile)
340+
if mode == 'awscredentialprocess':
341+
Cache(passphrase=passphrase).save_awscredentialprocess(
342+
profile_name=profile_or_alias,
343+
credentials=credentials
344+
)
319345

320-
app_container_id = response['appContainerId']
321-
app_type = self._get_app_type(app_container_id)
322346
cc_printer = self.__get_cloud_credential_printer(
323347
app_type,
324348
console,
325349
mode,
326350
profile_or_alias,
327351
self.silent,
328-
response['credentials']
352+
credentials
329353
)
330354
cc_printer.print()
331355

src/pybritive/choices/mode.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
'env-nix', # environment variable output specifying "export"
1212
'env-wincmd', # environment variable output specifying "set"
1313
'env-winps', # environment variable output specifying "$env:"
14-
'awscredentialprocess', # aws credential process output
14+
'awscredentialprocess', # aws credential process output with additional caching to make the credential process more performant
1515
'azlogin', # azure az login command with all fields populated (suitable for eval)
1616
'azps', # azure powershell script
1717
'browser' # when console access is checked out open the browser to the URL provided

src/pybritive/commands/checkout.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,6 @@ def checkout(ctx, alias, blocktime, console, justification, mode, maxpolltime, s
2525
justification=justification,
2626
mode=mode,
2727
maxpolltime=maxpolltime,
28-
profile=profile
28+
profile=profile,
29+
passphrase=passphrase
2930
)

src/pybritive/helpers/cache.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
from pathlib import Path
22
import json
33
import os
4+
from .encryption import StringEncryption, InvalidPassphraseException
45

56

67
class Cache:
7-
def __init__(self):
8+
def __init__(self, passphrase: str = None):
9+
self.passphrase = passphrase
10+
self.string_encryptor = StringEncryption(passphrase=self.passphrase)
811
home = os.getenv('PYBRITIVE_HOME_DIR', str(Path.home()))
912
self.path = str(Path(home) / '.britive' / 'pybritive.cache') # handle os specific separators properly
1013
self.cache = {}
@@ -27,6 +30,8 @@ def load(self):
2730

2831
if 'profiles' not in self.cache.keys():
2932
self.cache['profiles'] = []
33+
if 'awscredentialprocess' not in self.cache.keys():
34+
self.cache['awscredentialprocess'] = {}
3035

3136
def write(self):
3237
# write the new cache file
@@ -38,8 +43,25 @@ def get_profiles(self):
3843

3944
def save_profiles(self, profiles: list):
4045
self.cache['profiles'] += profiles
46+
# dedup the list of profiles
47+
self.cache['profiles'] = list(dict.fromkeys(self.cache['profiles']))
4148
self.write()
4249

4350
def clear(self):
4451
self.cache['profiles'] = []
52+
self.cache['awscredentialprocess'] = {}
53+
self.write()
54+
55+
def get_awscredentialprocess(self, profile_name: str):
56+
try:
57+
ciphertext = self.cache['awscredentialprocess'].get(profile_name)
58+
if not ciphertext:
59+
return None
60+
return json.loads(self.string_encryptor.decrypt(ciphertext))
61+
except InvalidPassphraseException: # if we cannot decrypt don't error - just make the API call to get the creds
62+
return None
63+
64+
def save_awscredentialprocess(self, profile_name: str, credentials: dict):
65+
ciphertext = self.string_encryptor.encrypt(json.dumps(credentials, default=str))
66+
self.cache['awscredentialprocess'][profile_name] = ciphertext
4567
self.write()

src/pybritive/helpers/credentials.py

Lines changed: 6 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,9 @@
77
import click
88
import configparser
99
import json
10-
from cryptography.fernet import Fernet, InvalidToken
11-
from cryptography.hazmat.backends import default_backend
12-
from cryptography.hazmat.primitives import hashes
13-
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
1410
from .config import ConfigManager
1511
import os
12+
from .encryption import StringEncryption, InvalidPassphraseException
1613

1714

1815
interactive_login_fields_to_pop = [
@@ -179,23 +176,9 @@ def __init__(self, tenant_name: str, tenant_alias: str, cli: ConfigManager, pass
179176
self.path = str(Path(home) / '.britive' / 'pybritive.credentials.encrypted')
180177
self.passphrase = passphrase
181178
self.prompt()
179+
self.string_encryptor = StringEncryption(passphrase=self.passphrase)
182180
super().__init__(tenant_name, tenant_alias, cli)
183181

184-
@staticmethod
185-
def salt():
186-
return base64.b64encode(os.urandom(32)).decode('utf-8')
187-
188-
def key(self, salt: str):
189-
kdf = PBKDF2HMAC(
190-
algorithm=hashes.SHA256(),
191-
length=32,
192-
salt=base64.b64decode(salt.encode()),
193-
iterations=100000,
194-
backend=default_backend()
195-
)
196-
key = base64.urlsafe_b64encode(kdf.derive(self.passphrase.encode()))
197-
return key
198-
199182
def prompt(self):
200183
if not self.passphrase:
201184
self.passphrase = click.prompt(
@@ -205,17 +188,12 @@ def prompt(self):
205188

206189
def decrypt(self, encrypted_access_token: str):
207190
try:
208-
encrypted_access_token, b64salt = encrypted_access_token.split(':')
209-
key = self.key(b64salt)
210-
return Fernet(key).decrypt(base64.b64decode(encrypted_access_token.encode())).decode('utf-8')
211-
except InvalidToken:
212-
raise click.ClickException('Invalid passphrase provided. Unable to decrypt credentials.')
191+
return self.string_encryptor.decrypt(ciphertext=encrypted_access_token)
192+
except InvalidPassphraseException:
193+
click.ClickException('invalid passphrase provided - cannot decrypt credentials.')
213194

214195
def encrypt(self, decrypted_access_token: str):
215-
salt = self.salt()
216-
key = self.key(salt)
217-
encrypted_access_token = Fernet(key).encrypt(decrypted_access_token.encode())
218-
return f'{base64.b64encode(encrypted_access_token).decode("utf-8")}:{salt}'
196+
return self.string_encryptor.encrypt(plaintext=decrypted_access_token)
219197

220198
def load(self, full=False):
221199
path = Path(self.path)
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import uuid
2+
from cryptography.fernet import Fernet, InvalidToken
3+
from cryptography.hazmat.backends import default_backend
4+
from cryptography.hazmat.primitives import hashes
5+
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
6+
import os
7+
import base64
8+
import click
9+
10+
11+
class InvalidPassphraseException(Exception):
12+
pass
13+
14+
15+
class StringEncryption:
16+
def __init__(self, passphrase: str = None):
17+
self.passphrase = passphrase or str(uuid.getnode()) # TODO change?
18+
19+
@staticmethod
20+
def _salt():
21+
return base64.b64encode(os.urandom(32)).decode('utf-8')
22+
23+
def _key(self, salt: str):
24+
kdf = PBKDF2HMAC(
25+
algorithm=hashes.SHA256(),
26+
length=32,
27+
salt=base64.b64decode(salt.encode()),
28+
iterations=100000,
29+
backend=default_backend()
30+
)
31+
return base64.urlsafe_b64encode(kdf.derive(self.passphrase.encode()))
32+
33+
def encrypt(self, plaintext: str) -> str:
34+
salt = self._salt()
35+
key = self._key(salt)
36+
ciphertext = Fernet(key).encrypt(plaintext.encode('utf-8'))
37+
return f'{base64.b64encode(ciphertext).decode("utf-8")}:{salt}'
38+
39+
def decrypt(self, ciphertext: str):
40+
try:
41+
ciphertext, b64salt = ciphertext.split(':')
42+
key = self._key(b64salt)
43+
return Fernet(key).decrypt(base64.b64decode(ciphertext.encode())).decode('utf-8')
44+
except InvalidToken:
45+
raise InvalidPassphraseException()

src/pybritive/options/passphrase.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
option = click.option(
55
'--passphrase', '-p',
6-
help='The passphrase to use for the encrypted-file credential backend type.',
6+
help='The passphrase to use for encrypting credentials.',
77
envvar='PYBRITIVE_ENCRYPTED_CREDENTIAL_PASSPHRASE',
88
show_envvar=True,
99
show_default=True

0 commit comments

Comments
 (0)