Skip to content

Commit 141d5fd

Browse files
committed
Merge branch 'add-encryption'
2 parents ec21f7a + 6effc05 commit 141d5fd

7 files changed

Lines changed: 130 additions & 13 deletions

File tree

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ sh>=2
99
tox
1010
twine
1111
wheel
12+
cryptography==44.0.2

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def read_files(files):
2323
version=meta["__version__"],
2424
author="Saurabh Kumar",
2525
author_email="[email protected]",
26-
url="https://github.com/theskumar/python-dotenv",
26+
url="https://github.com/roymanigley/python-dotenv",
2727
keywords=[
2828
"environment variables",
2929
"deployments",

src/dotenv/cli.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
from contextlib import contextmanager
66
from typing import Any, Dict, IO, Iterator, List, Optional
77

8+
from .crypt import Crypt
9+
810
try:
911
import click
1012
except ImportError:
@@ -163,6 +165,24 @@ def run(ctx: click.Context, override: bool, commandline: List[str]) -> None:
163165
run_command(commandline, dotenv_as_dict)
164166

165167

168+
@cli.command(context_settings={'ignore_unknown_options': True})
169+
def get_secret_key() -> None:
170+
try:
171+
click.echo(Crypt.generate_key())
172+
except Exception as e:
173+
click.echo(f'Failed: {e}', err=True)
174+
175+
176+
@cli.command(context_settings={'ignore_unknown_options': True})
177+
@click.argument('value', type=str)
178+
@click.argument('secret_key', type=str)
179+
def encrypt(value: str, secret_key: str) -> None:
180+
try:
181+
click.echo(Crypt.encrypt_value(value, secret_key))
182+
except Exception as e:
183+
click.echo(f'Failed: {e}', err=True)
184+
185+
166186
def run_command(command: List[str], env: Dict[str, str]) -> None:
167187
"""Replace the current process with the specified command.
168188

src/dotenv/crypt.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import base64
2+
import re
3+
4+
from cryptography.fernet import Fernet
5+
6+
ENCRYPTION_REGEX = r'^enc{(.+)}$'
7+
8+
9+
class Crypt:
10+
11+
@classmethod
12+
def is_encrypted_value(cls, value) -> bool:
13+
return not not re.match(ENCRYPTION_REGEX, value)
14+
15+
@classmethod
16+
def _extract_encrypted_value(cls, value) -> str:
17+
return re.match(ENCRYPTION_REGEX, value).group(1)
18+
19+
@classmethod
20+
def generate_key(cls) -> str:
21+
key_bytes = Fernet.generate_key()
22+
return base64.encodebytes(key_bytes).decode('utf-8')
23+
24+
@classmethod
25+
def decrypt_value(cls, value: str, key: str) -> str:
26+
if not key:
27+
return value
28+
key_bytes = base64.decodebytes(key.encode('utf-8'))
29+
value_bytes = base64.decodebytes(cls._extract_encrypted_value(value).encode('utf-8'))
30+
decrypted_bytes = Fernet(key=key_bytes).decrypt(value_bytes)
31+
return decrypted_bytes.decode('utf-8')
32+
33+
@classmethod
34+
def encrypt_value(cls, value: str, key: str) -> str:
35+
key_bytes = base64.decodebytes(key.encode('utf-8'))
36+
encrypted_bytes = Fernet(key=key_bytes).encrypt(value.encode('utf-8'))
37+
return 'enc{' + base64.encodebytes(encrypted_bytes).decode('utf-8').strip().replace('\n', '') + '}'
38+
39+
40+
if __name__ == '__main__':
41+
key = Crypt.generate_key()
42+
message = 'Hello World'
43+
encrypt_value = Crypt.encrypt_value(message, key)
44+
print(encrypt_value)
45+
print(Crypt.is_encrypted_value(encrypt_value))
46+
print(Crypt._extract_encrypted_value(encrypt_value))
47+
print(Crypt.decrypt_value(encrypt_value, key))

src/dotenv/main.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ def __init__(
4040
encoding: Optional[str] = None,
4141
interpolate: bool = True,
4242
override: bool = True,
43+
secret_key: str = None
4344
) -> None:
4445
self.dotenv_path: Optional[StrPath] = dotenv_path
4546
self.stream: Optional[IO[str]] = stream
@@ -48,6 +49,7 @@ def __init__(
4849
self.encoding: Optional[str] = encoding
4950
self.interpolate: bool = interpolate
5051
self.override: bool = override
52+
self.secret_key = secret_key
5153

5254
@contextmanager
5355
def _get_stream(self) -> Iterator[IO[str]]:
@@ -82,7 +84,7 @@ def dict(self) -> Dict[str, Optional[str]]:
8284

8385
def parse(self) -> Iterator[Tuple[str, Optional[str]]]:
8486
with self._get_stream() as stream:
85-
for mapping in with_warn_for_invalid_lines(parse_stream(stream)):
87+
for mapping in with_warn_for_invalid_lines(parse_stream(stream, self.secret_key)):
8688
if mapping.key is not None:
8789
yield mapping.key, mapping.value
8890

@@ -156,6 +158,7 @@ def set_key(
156158
quote_mode: str = "always",
157159
export: bool = False,
158160
encoding: Optional[str] = "utf-8",
161+
secret_key: str = None,
159162
) -> Tuple[Optional[bool], str, str]:
160163
"""
161164
Adds or Updates a key/value to the given .env
@@ -182,7 +185,7 @@ def set_key(
182185
with rewrite(dotenv_path, encoding=encoding) as (source, dest):
183186
replaced = False
184187
missing_newline = False
185-
for mapping in with_warn_for_invalid_lines(parse_stream(source)):
188+
for mapping in with_warn_for_invalid_lines(parse_stream(source, secret_key)):
186189
if mapping.key == key_to_set:
187190
dest.write(line_out)
188191
replaced = True
@@ -202,6 +205,7 @@ def unset_key(
202205
key_to_unset: str,
203206
quote_mode: str = "always",
204207
encoding: Optional[str] = "utf-8",
208+
secret_key: str = None
205209
) -> Tuple[Optional[bool], str]:
206210
"""
207211
Removes a given key from the given `.env` file.
@@ -215,7 +219,7 @@ def unset_key(
215219

216220
removed = False
217221
with rewrite(dotenv_path, encoding=encoding) as (source, dest):
218-
for mapping in with_warn_for_invalid_lines(parse_stream(source)):
222+
for mapping in with_warn_for_invalid_lines(parse_stream(source, secret_key)):
219223
if mapping.key == key_to_unset:
220224
removed = True
221225
else:
@@ -329,6 +333,7 @@ def load_dotenv(
329333
override: bool = False,
330334
interpolate: bool = True,
331335
encoding: Optional[str] = "utf-8",
336+
secret_key: Optional[str] = os.environ.get('DOTENV_SECRET'),
332337
) -> bool:
333338
"""Parse a .env file and then load all the variables found as environment variables.
334339
@@ -358,6 +363,7 @@ def load_dotenv(
358363
interpolate=interpolate,
359364
override=override,
360365
encoding=encoding,
366+
secret_key=secret_key
361367
)
362368
return dotenv.set_as_environment_variables()
363369

@@ -368,6 +374,7 @@ def dotenv_values(
368374
verbose: bool = False,
369375
interpolate: bool = True,
370376
encoding: Optional[str] = "utf-8",
377+
secret_key: Optional[str] = None
371378
) -> Dict[str, Optional[str]]:
372379
"""
373380
Parse a .env file and return its content as a dict.
@@ -395,4 +402,5 @@ def dotenv_values(
395402
interpolate=interpolate,
396403
override=True,
397404
encoding=encoding,
405+
secret_key=secret_key
398406
).dict()

src/dotenv/parser.py

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from typing import (IO, Iterator, Match, NamedTuple, Optional, # noqa:F401
44
Pattern, Sequence, Tuple)
55

6+
from dotenv.crypt import Crypt
7+
68

79
def make_regex(string: str, extra_flags: int = 0) -> Pattern[str]:
810
return re.compile(string, re.UNICODE | extra_flags)
@@ -118,21 +120,25 @@ def parse_unquoted_value(reader: Reader) -> str:
118120
return re.sub(r"\s+#.*", "", part).rstrip()
119121

120122

121-
def parse_value(reader: Reader) -> str:
123+
def parse_value(reader: Reader, secret_key: str) -> str:
122124
char = reader.peek(1)
123125
if char == u"'":
124126
(value,) = reader.read_regex(_single_quoted_value)
125-
return decode_escapes(_single_quote_escapes, value)
127+
value = decode_escapes(_single_quote_escapes, value)
126128
elif char == u'"':
127129
(value,) = reader.read_regex(_double_quoted_value)
128-
return decode_escapes(_double_quote_escapes, value)
130+
value = decode_escapes(_double_quote_escapes, value)
129131
elif char in (u"", u"\n", u"\r"):
130-
return u""
132+
value = u""
131133
else:
132-
return parse_unquoted_value(reader)
134+
value = parse_unquoted_value(reader)
135+
136+
if Crypt.is_encrypted_value(value):
137+
return Crypt.decrypt_value(value, secret_key)
138+
return value
133139

134140

135-
def parse_binding(reader: Reader) -> Binding:
141+
def parse_binding(reader: Reader, secret_key: str = None) -> Binding:
136142
reader.set_mark()
137143
try:
138144
reader.read_regex(_multiline_whitespace)
@@ -148,7 +154,7 @@ def parse_binding(reader: Reader) -> Binding:
148154
reader.read_regex(_whitespace)
149155
if reader.peek(1) == "=":
150156
reader.read_regex(_equal_sign)
151-
value: Optional[str] = parse_value(reader)
157+
value: Optional[str] = parse_value(reader, secret_key)
152158
else:
153159
value = None
154160
reader.read_regex(_comment)
@@ -168,8 +174,13 @@ def parse_binding(reader: Reader) -> Binding:
168174
error=True,
169175
)
170176

177+
def parse_secret_value(value: str) -> str:
178+
179+
return value
180+
181+
171182

172-
def parse_stream(stream: IO[str]) -> Iterator[Binding]:
183+
def parse_stream(stream: IO[str], secret_key: str = None) -> Iterator[Binding]:
173184
reader = Reader(stream)
174185
while reader.has_next():
175-
yield parse_binding(reader)
186+
yield parse_binding(reader, secret_key)

tests/test_secret_key.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import dotenv
2+
from dotenv.crypt import Crypt
3+
4+
5+
def test_dotenv_with_secret_values__should_not_encrypt__due_to_no_key(dotenv_path):
6+
secret_key = Crypt.generate_key()
7+
secret_value = 'my super secret secret'
8+
secret_value_decrypted = Crypt.encrypt_value(secret_value, secret_key)
9+
assert secret_value_decrypted != secret_value
10+
11+
dotenv_path.write_text(f'my_secret={secret_value_decrypted}')
12+
13+
with dotenv_path.open() as f:
14+
result = dotenv.dotenv_values(stream=f)
15+
16+
assert result == {"my_secret": secret_value_decrypted}
17+
18+
19+
def test_dotenv_with_secret_values__should_encrypt(dotenv_path):
20+
secret_key = Crypt.generate_key()
21+
secret_value = 'my super secret secret'
22+
secret_value_decrypted = Crypt.encrypt_value(secret_value, secret_key)
23+
assert secret_value_decrypted != secret_value
24+
25+
dotenv_path.write_text(f'my_secret={secret_value_decrypted}')
26+
27+
with dotenv_path.open() as f:
28+
result = dotenv.dotenv_values(stream=f, secret_key=secret_key)
29+
30+
assert result == {"my_secret": secret_value}

0 commit comments

Comments
 (0)