Skip to content

Commit 685d757

Browse files
committed
Merge branch 'master' of github.com:dotenv-org/python-dotenv-vault
2 parents 72212e5 + 090c65c commit 685d757

File tree

11 files changed

+240
-127
lines changed

11 files changed

+240
-127
lines changed

.github/workflows/ci.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
name: Test Python package
2+
3+
on: [push]
4+
5+
jobs:
6+
build:
7+
8+
runs-on: ubuntu-latest
9+
strategy:
10+
matrix:
11+
python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"]
12+
13+
steps:
14+
- uses: actions/checkout@v3
15+
- name: Set up Python ${{ matrix.python-version }}
16+
uses: actions/setup-python@v4
17+
with:
18+
python-version: ${{ matrix.python-version }}
19+
- name: Install dependencies
20+
run: |
21+
python -m pip install --upgrade pip
22+
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
23+
- name: Run tests
24+
run: |
25+
make test

.github/workflows/python-publish.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ jobs:
2929
- name: Install dependencies
3030
run: |
3131
python -m pip install --upgrade pip
32-
pip install setuptools wheel twine
32+
pip install setuptools wheel twine build
3333
- name: Build package
3434
env:
3535
TWINE_USERNAME: __token__

CHANGELOG.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,18 @@
22

33
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
44

5-
## [Unreleased](https://github.com/dotenv-org/python-dotenv-vault/compare/v0.2.0...master)
5+
## [Unreleased](https://github.com/dotenv-org/python-dotenv-vault/compare/v0.5.0...master)
66

7+
## 0.5.0
8+
9+
### Added
10+
11+
- Reorganise and simplify code
12+
- Make API correspond more closely to `python-dotenv`
13+
- Improve error handling
14+
- Add tests and CI
15+
- Upgrade to `build` for release build
16+
717
## 0.4.1
818

919
### Added

MANIFEST.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
include CHANGELOG.md

Makefile

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,16 @@ clean-pyc:
1313
find . -name '*~' -exec rm -f {} +
1414

1515
build: clean
16-
python setup.py sdist bdist_wheel
16+
python -m build
1717

1818
uninstall_local:
1919
pip uninstall python-dotenv-vault -y
2020

2121
install_local:
2222
pip install .
2323

24-
test: uninstall_local build install_local
24+
test: install_local
25+
python -m unittest -v dotenv_vault.test_vault
2526

2627
release: build
2728
twine check dist/*

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[build-system]
2+
requires = ["setuptools>=61.0.0", "wheel"]
3+
build-backend = "setuptools.build_meta"

requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
python-dotenv~=0.21.0
2+
cryptography<41.0.0,>=3.1.0

src/dotenv_vault/__version__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
__title__ = "python-dotenv-vault"
22
__description__ = "Decrypt .env.vault file."
33
__url__ = "https://github.com/dotenv-org/python-dotenv-vault"
4-
__version__ = "0.4.1"
4+
__version__ = "0.5.0"
55
__author__ = "dotenv"
66
__author_email__ = "[email protected]"
77
__license__ = "MIT"

src/dotenv_vault/main.py

Lines changed: 133 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
from __future__ import annotations
22

3+
from base64 import b64decode
4+
import io
35
import os
4-
import logging
5-
from typing import (IO, Optional,Union)
6-
from dotenv.main import load_dotenv as load_dotenv_file
6+
from typing import (IO, Optional, Union)
7+
from urllib.parse import urlparse, parse_qsl
78

8-
from .vault import DotEnvVault
9-
10-
logging.basicConfig(level = logging.INFO)
11-
12-
logger = logging.getLogger(__name__)
9+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
10+
from cryptography.exceptions import InvalidTag
11+
import dotenv.main as dotenv
1312

1413

1514
def load_dotenv(
@@ -20,22 +19,137 @@ def load_dotenv(
2019
interpolate: bool = True,
2120
encoding: Optional[str] = "utf-8",
2221
) -> bool:
22+
"""This function will read your encrypted env file and load it
23+
into the environment for this process.
24+
25+
Call this function as close as possible to the start of your
26+
program (ideally in main).
27+
28+
If the `DOTENV_KEY` environment variable is set, `load_dotenv`
29+
will load encrypted environment settings from the `.env.vault`
30+
file in the current path.
31+
32+
If the `DOTENV_KEY` environment variable is not set, `load_dotenv`
33+
falls back to the behavior of the python-dotenv library, loading a
34+
specified (unencrypted) environment file.
35+
36+
Other parameters to `load_dotenv` are passed througg to the
37+
python-dotenv loader. In particular, whether `load_dotenv`
38+
overrides existing environment settings or not is determined by
39+
the `override` flag.
40+
2341
"""
24-
parameters are the same as python-dotenv library.
25-
This is to inject the parameters to evironment variables.
26-
"""
27-
dotenv_vault = DotEnvVault()
28-
if dotenv_vault.dotenv_key:
29-
logger.info('Loading env from encrypted .env.vault')
30-
vault_stream = dotenv_vault.parsed_vault(dotenv_path=dotenv_path)
31-
# we're going to override the .vault to any existing keys in local
32-
return load_dotenv_file(stream=vault_stream, override=True)
42+
if "DOTENV_KEY" in os.environ:
43+
vault_stream = parse_vault(open(".env.vault"))
44+
return dotenv.load_dotenv(
45+
dotenv_path=".env.vault",
46+
stream=vault_stream,
47+
verbose=verbose,
48+
override=override,
49+
interpolate=interpolate
50+
)
3351
else:
34-
return load_dotenv_file(
52+
return dotenv.load_dotenv(
3553
dotenv_path=dotenv_path,
3654
stream=stream,
3755
verbose=verbose,
3856
override=override,
3957
interpolate=interpolate,
4058
encoding=encoding
41-
)
59+
)
60+
61+
62+
class DotEnvVaultError(Exception):
63+
pass
64+
65+
66+
KEY_LENGTH = 64
67+
68+
69+
def parse_vault(vault_stream: io.IOBase) -> io.StringIO:
70+
"""Parse information from DOTENV_KEY, and decrypt vault.
71+
"""
72+
dotenv_key = os.environ.get("DOTENV_KEY")
73+
if dotenv_key is None:
74+
raise DotEnvVaultError("NOT_FOUND_DOTENV_KEY: Cannot find ENV['DOTENV_KEY']")
75+
76+
# Use the python-dotenv library to read the .env.vault file.
77+
vault = dotenv.DotEnv(dotenv_path=".env.vault", stream=vault_stream)
78+
79+
# Extract segments from the DOTENV_KEY environment variable one by
80+
# one and retrieve the corresponding ciphertext from the vault
81+
# data.
82+
keys = []
83+
for dotenv_key_entry in [i.strip() for i in dotenv_key.split(',')]:
84+
key, environment_key = parse_key(dotenv_key_entry)
85+
86+
ciphertext = vault.dict().get(environment_key)
87+
88+
if not ciphertext:
89+
raise DotEnvVaultError(f"NOT_FOUND_DOTENV_ENVIRONMENT: Cannot locate environment {environment_key} in your .env.vault file. Run 'npx dotenv-vault build' to include it.")
90+
91+
keys.append({
92+
'encrypted_key': key,
93+
'ciphertext': ciphertext
94+
})
95+
96+
# Try decrypting environments one-by-one in the order they appear
97+
# in the DOTENV_KEY environment variable.
98+
decrypted = _key_rotation(keys=keys)
99+
100+
# Return the decrypted data as a text stream that we can pass to
101+
# the python-dotenv library.
102+
return io.StringIO(decrypted.decode('utf-8'))
103+
104+
105+
def parse_key(dotenv_key):
106+
# Parse a single segment of the DOTENV_KEY environment variable.
107+
# These segments are in the form of URIs (see
108+
# https://www.dotenv.org/docs/security/dotenv-key).
109+
uri = urlparse(dotenv_key)
110+
111+
# The 64-character encryption key is stored in the password field
112+
# of the URI, possibly with a prefix.
113+
key = uri.password
114+
if len(key) < KEY_LENGTH:
115+
raise DotEnvVault('INVALID_DOTENV_KEY: Key part must be 64 characters long (or more)')
116+
117+
# The environment is provided in the URI's query parameters.
118+
params = dict(parse_qsl(uri.query))
119+
vault_environment = params.get('environment')
120+
if not vault_environment:
121+
raise DotEnvVaultError('INVALID_DOTENV_KEY: Missing environment part')
122+
123+
# Form the key used to store the ciphertext for this environment's
124+
# settings in the .env.vault file.
125+
environment_key = f'DOTENV_VAULT_{vault_environment.upper()}'
126+
127+
return key, environment_key
128+
129+
130+
def _decrypt(ciphertext: str, key: str) -> bytes:
131+
"""decrypt method will decrypt via AES-GCM
132+
return: decrypted keys in bytes
133+
"""
134+
# Remove any prefix from the encryption key (at this point, we
135+
# know that the key is at least 64 characters in length) and set
136+
# up the AES cipher.
137+
aesgcm = AESGCM(bytes.fromhex(key[-KEY_LENGTH:]))
138+
139+
# Decrypt the ciphertext: this is base64-encoded in the .env.vault
140+
# file, and the first 12 bytes of the decoded data are used as the
141+
# AES nonce value.
142+
ciphertext = b64decode(ciphertext)
143+
return aesgcm.decrypt(ciphertext[:12], ciphertext[12:], b'')
144+
145+
146+
def _key_rotation(keys: list[dict]) -> str:
147+
"""Iterate through list of keys to check for correct one.
148+
"""
149+
for k in keys:
150+
try:
151+
return _decrypt(ciphertext=k['ciphertext'], key=k['encrypted_key'])
152+
except InvalidTag:
153+
continue
154+
raise DotEnvVaultError('INVALID_DOTENV_KEY: Key must be valid.')
155+

src/dotenv_vault/test_vault.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
from io import StringIO
2+
import os
3+
import unittest
4+
5+
from dotenv.main import DotEnv
6+
7+
import dotenv_vault.main as vault
8+
9+
class TestParsing(unittest.TestCase):
10+
TEST_KEYS = [
11+
# OK.
12+
["dotenv://:key_0dec82bea24ada79a983dcc11b431e28838eae59a07a8f983247c7ca9027a925@dotenv.local/vault/.env.vault?environment=development",
13+
True, "DOTENV_VAULT_DEVELOPMENT"],
14+
15+
# Key too short (must be 64 characters + prefix).
16+
["dotenv://:[email protected]/vault/.env.vault?environment=production",
17+
False, "DOTENV_VAULT_PRODUCTION"],
18+
19+
# Missing key value.
20+
["dotenv://dotenv.org/vault/.env.vault?environment=production",
21+
False, "DOTENV_VAULT_PRODUCTION"],
22+
23+
# Missing environment.
24+
["dotenv://:[email protected]/vault/.env.vault", False, ""]
25+
]
26+
27+
def test_key_parsing(self):
28+
for test in self.TEST_KEYS:
29+
dotenv_key, should_pass, environment_key_check = test
30+
old_dotenv_key = os.environ.get("DOTENV_KEY")
31+
os.environ["DOTENV_KEY"] = dotenv_key
32+
try:
33+
key, environment_key = vault.parse_key(dotenv_key)
34+
self.assertTrue(should_pass)
35+
self.assertEqual(environment_key, environment_key_check)
36+
except Exception as exc:
37+
self.assertFalse(should_pass)
38+
finally:
39+
os.unsetenv("DOTENV_KEY")
40+
if old_dotenv_key:
41+
os.environ["DOTENV_KEY"] = old_dotenv_key
42+
43+
PARSE_TEST_KEY = "dotenv://:key_0dec82bea24ada79a983dcc11b431e28838eae59a07a8f983247c7ca9027a925@dotenv.local/vault/.env.vault?environment=development"
44+
45+
PARSE_TEST_VAULT = """# .env.vault (generated with npx dotenv-vault local build)
46+
DOTENV_VAULT_DEVELOPMENT="H2A2wOUZU+bjKH3kTpeua9iIhtK/q7/VpAn+LLVNnms+CtQ/cwXqiw=="
47+
"""
48+
49+
def test_vault_parsing(self):
50+
old_dotenv_key = os.environ.get("DOTENV_KEY")
51+
os.environ["DOTENV_KEY"] = self.PARSE_TEST_KEY
52+
try:
53+
stream = vault.parse_vault(StringIO(self.PARSE_TEST_VAULT))
54+
dotenv = DotEnv(dotenv_path=".env.vault", stream=stream)
55+
self.assertEqual(dotenv.dict().get("HELLO"), "world")
56+
finally:
57+
os.unsetenv("DOTENV_KEY")
58+
if old_dotenv_key:
59+
os.environ["DOTENV_KEY"] = old_dotenv_key
60+

0 commit comments

Comments
 (0)