Skip to content

Commit f79b85e

Browse files
committed
add logic to decrypt .env.vault. load_dotenv signature is the same as python-dotenv
1 parent 5531d47 commit f79b85e

File tree

10 files changed

+141
-10
lines changed

10 files changed

+141
-10
lines changed

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ celerybeat.pid
104104
# Environments
105105
.env
106106
.venv
107+
.env.vault
108+
.env.me
107109
env/
108110
venv/
109111
ENV/
@@ -127,3 +129,5 @@ dmypy.json
127129

128130
# Pyre type checker
129131
.pyre/
132+
133+
.vscode/

Makefile

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ clean-pyc:
1515
build: clean
1616
python setup.py sdist bdist_wheel
1717

18+
uninstall_local:
19+
pip uninstall python-dotenv-vault -y
20+
21+
install_local:
22+
pip install .
23+
24+
test: uninstall_local build install_local
25+
1826
release: build
1927
twine check dist/*
2028
twine upload dist/*

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,9 @@ dotenv-vault-python allows your app to sync the `.env` file to the cloud. This m
88
```shell
99
pip install python-dotenv-vault --no-cache-dir
1010
```
11+
12+
```shell
13+
from dotenv_vault import load_dotenv
14+
15+
load_dotenv()
16+
```

setup.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
src = {}
66
dir = os.path.abspath(os.path.dirname(__file__))
7-
with open(os.path.join(dir, "src/dotenv", "__version__.py"), "r") as f:
7+
with open(os.path.join(dir, "src/dotenv_vault", "__version__.py"), "r") as f:
88
exec(f.read(), src)
99

1010
def read_files(files):
@@ -39,5 +39,8 @@ def read_files(files):
3939
'python',
4040
'dotenv-vault'
4141
],
42-
install_requires=[],
42+
install_requires=[
43+
'python-dotenv~=0.21.0',
44+
'cryptography~=38.0.1'
45+
],
4346
)

src/dotenv/__init__.py

Lines changed: 0 additions & 1 deletion
This file was deleted.

src/dotenv/main.py

Lines changed: 0 additions & 7 deletions
This file was deleted.

src/dotenv_vault/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .main import load_dotenv

src/dotenv_vault/main.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import os
2+
import logging
3+
from typing import (IO, Optional,Union)
4+
from dotenv.main import load_dotenv as load_dotenv_file
5+
6+
from .vault import DotEnvVault
7+
8+
logging.basicConfig(level = logging.INFO)
9+
10+
logger = logging.getLogger(__name__)
11+
12+
13+
def load_dotenv(
14+
dotenv_path: Union[str, os.PathLike, None] = None,
15+
stream: Optional[IO[str]] = None,
16+
verbose: bool = False,
17+
override: bool = False,
18+
interpolate: bool = True,
19+
encoding: Optional[str] = "utf-8",
20+
) -> bool:
21+
"""
22+
parameters are the same as python-dotenv library.
23+
This is to inject the parameters to evironment variables.
24+
"""
25+
dotenv_vault = DotEnvVault()
26+
logger.info(f'dotenv_key:{dotenv_vault.dotenv_key}')
27+
if dotenv_vault.dotenv_key:
28+
logger.info('Getting .env from vault.')
29+
vault_stream = dotenv_vault.parsed_vault()
30+
return load_dotenv_file(stream=vault_stream)
31+
else:
32+
logger.info('Getting .env from local.')
33+
return load_dotenv_file()

src/dotenv_vault/vault.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
2+
import logging
3+
import os
4+
import sys
5+
import io
6+
7+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
8+
from base64 import b64decode
9+
from urllib.parse import urlparse, parse_qsl
10+
11+
from dotenv.main import DotEnv, find_dotenv, load_dotenv
12+
13+
14+
logging.basicConfig(level = logging.INFO)
15+
16+
logger = logging.getLogger(__name__)
17+
18+
19+
class DotEnvVaultError(Exception):
20+
pass
21+
22+
23+
class DotEnvVault(): #vault stuff
24+
def __init__(self) -> None:
25+
logger.info('initializing DotEnvVault')
26+
self.dotenv_key = os.environ.get('DOTENV_KEY')
27+
28+
29+
def parsed_vault(self) -> bytes:
30+
"""
31+
Parse information from DOTENV_KEY, and decrypt vault key.
32+
"""
33+
if self.dotenv_key is None: raise DotEnvVaultError("NOT_FOUND_DOTENV_KEY: Cannot find ENV['DOTENV_KEY']")
34+
35+
# .env.vault needs to be present.
36+
env_vault_path = find_dotenv(filename='.env.vault')
37+
if env_vault_path == '':
38+
raise DotEnvVaultError("ENV_VAULT_NOT_FOUND: .env.vault is not present.")
39+
40+
# parse DOTENV_KEY, format is a URI
41+
uri = urlparse(self.dotenv_key)
42+
# Get encrypted key
43+
key = uri.password
44+
# Get environment from query params.
45+
params = dict(parse_qsl(uri.query))
46+
vault_environment = params.get('environment').upper()
47+
48+
if vault_environment is None or vault_environment not in ['PRODUCTION', 'DEVELOPMENT', 'CI', 'STAGING']:
49+
raise DotEnvVaultError('Incorrect Vault Environment.')
50+
51+
# Getting ciphertext from correct environment in .env.vault
52+
environment_key = f'DOTENV_VAULT_{vault_environment}'
53+
logging.info(f'Getting {environment_key}.')
54+
55+
# use python-dotenv library class.
56+
dotenv = DotEnv(dotenv_path=env_vault_path)
57+
ciphertext = dotenv.dict().get(environment_key)
58+
59+
decrypted = self._decrypt(ciphertext=ciphertext, key=key)
60+
return self._to_text_stream(decrypted)
61+
62+
63+
def _decrypt(self, ciphertext: str, key: str) -> bytes:
64+
"""
65+
decrypt method will decrypt via AES-GCM
66+
return: decrypted keys in bytes
67+
"""
68+
_key = key[4:]
69+
if len(_key) < 64: raise DotEnvVault('INVALID_DOTENV_KEY: Key part must be 64 characters long (or more)')
70+
71+
_key = bytes.fromhex(_key)
72+
ciphertext = b64decode(ciphertext)
73+
74+
aesgcm = AESGCM(_key)
75+
return aesgcm.decrypt(ciphertext[:12], ciphertext[12:], b'')
76+
77+
def _to_text_stream(self, decrypted_obj: bytes) -> io.StringIO:
78+
"""
79+
convert decrypted object (in bytes) to io.StringIO format.
80+
Python-dotenv is expecting stream to be text stream (such as `io.StringIO`).
81+
return: io.StringIO
82+
"""
83+
decoded_str = decrypted_obj.decode('utf-8')
84+
return io.StringIO(decoded_str)

0 commit comments

Comments
 (0)