envlockup 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- envlockup/__init__.py +133 -0
- envlockup/__main__.py +132 -0
- envlockup/_bech32.py +56 -0
- envlockup/_dotenv.py +95 -0
- envlockup/_keys.py +270 -0
- envlockup/_vault.py +211 -0
- envlockup/errors.py +54 -0
- envlockup/py.typed +0 -0
- envlockup-0.2.0.dist-info/METADATA +101 -0
- envlockup-0.2.0.dist-info/RECORD +12 -0
- envlockup-0.2.0.dist-info/WHEEL +4 -0
- envlockup-0.2.0.dist-info/licenses/LICENSE +21 -0
envlockup/__init__.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Read envlockup files: .env files whose values are encrypted for SSH or age keys.
|
|
2
|
+
|
|
3
|
+
import envlockup
|
|
4
|
+
envlockup.config() # like python-dotenv's load_dotenv(), decrypting as it goes
|
|
5
|
+
|
|
6
|
+
The private key is looked for in this order:
|
|
7
|
+
|
|
8
|
+
1. ENVLOCKUP_PRIVATE_KEY the key itself (CI and servers: store it as a single secret)
|
|
9
|
+
2. ENVLOCKUP_IDENTITY path to a key file
|
|
10
|
+
3. ~/.ssh/id_ed25519 your SSH key
|
|
11
|
+
|
|
12
|
+
1 and 2 are "explicit": when set, nothing else is tried. A passphrase-protected
|
|
13
|
+
SSH key is unlocked with ENVLOCKUP_SSH_PASSPHRASE. ssh-agent is not supported yet
|
|
14
|
+
by the Python package.
|
|
15
|
+
|
|
16
|
+
Files are created and edited with the envlockup CLI from npm.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import MutableMapping
|
|
24
|
+
|
|
25
|
+
from ._keys import Identity, parse_identity
|
|
26
|
+
from ._vault import SealedFile
|
|
27
|
+
from .errors import (
|
|
28
|
+
DecryptError,
|
|
29
|
+
EnvlockupError,
|
|
30
|
+
FormatError,
|
|
31
|
+
NoKeyError,
|
|
32
|
+
NotARecipientError,
|
|
33
|
+
RecipientListError,
|
|
34
|
+
PassphraseRequiredError,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
__version__ = "0.2.0"
|
|
38
|
+
__all__ = [
|
|
39
|
+
"config",
|
|
40
|
+
"load",
|
|
41
|
+
"DecryptError",
|
|
42
|
+
"EnvlockupError",
|
|
43
|
+
"FormatError",
|
|
44
|
+
"NoKeyError",
|
|
45
|
+
"NotARecipientError",
|
|
46
|
+
"RecipientListError",
|
|
47
|
+
"PassphraseRequiredError",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
# Removed from the environment once the file is decrypted, so the rest of the
|
|
51
|
+
# application (and anything it starts) never sees the key.
|
|
52
|
+
SECRET_ENV_VARS = ("ENVLOCKUP_PRIVATE_KEY", "ENVLOCKUP_SSH_PASSPHRASE")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _default_path() -> Path:
|
|
56
|
+
return Path(os.environ.get("ENVLOCKUP_FILE") or ".env")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _find_identity(private_key: str | None, identity_path: str | os.PathLike[str] | None) -> tuple[str, str] | None:
|
|
60
|
+
"""(key text, where it came from), or None."""
|
|
61
|
+
if private_key:
|
|
62
|
+
return private_key, "private_key argument"
|
|
63
|
+
if identity_path:
|
|
64
|
+
return Path(identity_path).read_text(encoding="utf-8"), str(identity_path)
|
|
65
|
+
inline = os.environ.get("ENVLOCKUP_PRIVATE_KEY", "").strip()
|
|
66
|
+
if inline:
|
|
67
|
+
# CI systems often flatten multi-line secrets; accept literal "\n".
|
|
68
|
+
return (inline if "\n" in inline else inline.replace("\\n", "\n")), "ENVLOCKUP_PRIVATE_KEY"
|
|
69
|
+
file = os.environ.get("ENVLOCKUP_IDENTITY")
|
|
70
|
+
if file:
|
|
71
|
+
return Path(file).read_text(encoding="utf-8"), file
|
|
72
|
+
default = Path.home() / ".ssh" / "id_ed25519"
|
|
73
|
+
if default.is_file():
|
|
74
|
+
return default.read_text(encoding="utf-8"), str(default)
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def load(
|
|
79
|
+
path: str | os.PathLike[str] | None = None,
|
|
80
|
+
*,
|
|
81
|
+
private_key: str | None = None,
|
|
82
|
+
identity: str | os.PathLike[str] | None = None,
|
|
83
|
+
passphrase: str | None = None,
|
|
84
|
+
) -> dict[str, str]:
|
|
85
|
+
"""Decrypt a file and return its variables, without touching os.environ.
|
|
86
|
+
|
|
87
|
+
`path` defaults to $ENVLOCKUP_FILE or .env in the current directory. Pass
|
|
88
|
+
`private_key` (key text) or `identity` (key file path) to skip the default
|
|
89
|
+
lookup. The private key is only needed when the file has encrypted values.
|
|
90
|
+
"""
|
|
91
|
+
file = Path(path) if path is not None else _default_path()
|
|
92
|
+
sealed = SealedFile(file.read_text(encoding="utf-8", errors="replace"))
|
|
93
|
+
if sealed.has_encrypted_values:
|
|
94
|
+
found = _find_identity(private_key, identity)
|
|
95
|
+
if found is None:
|
|
96
|
+
hint = " ssh-agent is not supported by the Python package yet." if sealed.agent_recipients else ""
|
|
97
|
+
raise NoKeyError(
|
|
98
|
+
f"{file} contains encrypted values but no private key was found. "
|
|
99
|
+
f"Use ~/.ssh/id_ed25519, or set ENVLOCKUP_PRIVATE_KEY or ENVLOCKUP_IDENTITY.{hint}"
|
|
100
|
+
)
|
|
101
|
+
text, source = found
|
|
102
|
+
key: Identity = parse_identity(text, source, passphrase or os.environ.get("ENVLOCKUP_SSH_PASSPHRASE"))
|
|
103
|
+
sealed.unlock(key)
|
|
104
|
+
return sealed.decrypt_all()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def config(
|
|
108
|
+
path: str | os.PathLike[str] | None = None,
|
|
109
|
+
*,
|
|
110
|
+
override: bool = False,
|
|
111
|
+
private_key: str | None = None,
|
|
112
|
+
identity: str | os.PathLike[str] | None = None,
|
|
113
|
+
passphrase: str | None = None,
|
|
114
|
+
environ: MutableMapping[str, str] | None = None,
|
|
115
|
+
) -> dict[str, str]:
|
|
116
|
+
"""Load the file into os.environ (or `environ`) and return its variables.
|
|
117
|
+
|
|
118
|
+
Variables already set are kept unless `override` is true, as in dotenv. A
|
|
119
|
+
missing file returns {}; a file that cannot be decrypted raises, because
|
|
120
|
+
silently starting without secrets is worse.
|
|
121
|
+
"""
|
|
122
|
+
file = Path(path) if path is not None else _default_path()
|
|
123
|
+
if not file.is_file():
|
|
124
|
+
return {}
|
|
125
|
+
parsed = load(file, private_key=private_key, identity=identity, passphrase=passphrase)
|
|
126
|
+
target = os.environ if environ is None else environ
|
|
127
|
+
for name, value in parsed.items():
|
|
128
|
+
if override or name not in target:
|
|
129
|
+
target[name] = value
|
|
130
|
+
# The key has done its job; don't leave it around for the rest of the app.
|
|
131
|
+
for name in SECRET_ENV_VARS:
|
|
132
|
+
os.environ.pop(name, None)
|
|
133
|
+
return parsed
|
envlockup/__main__.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""python -m envlockup: read envlockup files from the command line.
|
|
2
|
+
|
|
3
|
+
Edit them with the envlockup CLI from npm (`npx envlockup set NAME`).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import getpass
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from . import SECRET_ENV_VARS, _find_identity, load
|
|
18
|
+
from ._dotenv import format_value
|
|
19
|
+
from ._keys import is_encrypted_ssh_key
|
|
20
|
+
from .errors import EnvlockupError, PassphraseRequiredError
|
|
21
|
+
|
|
22
|
+
USAGE = """python -m envlockup [-f FILE] [-i KEY] <command>
|
|
23
|
+
|
|
24
|
+
Commands:
|
|
25
|
+
get NAME Print one decrypted value.
|
|
26
|
+
decrypt [--format json]
|
|
27
|
+
Print the whole file decrypted, in dotenv format
|
|
28
|
+
(or as a JSON object).
|
|
29
|
+
run [--override] -- COMMAND [ARGS]...
|
|
30
|
+
Run COMMAND with the variables in its environment.
|
|
31
|
+
Nothing decrypted is written to disk.
|
|
32
|
+
|
|
33
|
+
Options:
|
|
34
|
+
-f, --file FILE File to use (default: $ENVLOCKUP_FILE or .env)
|
|
35
|
+
-i, --identity KEY Private key file (default: $ENVLOCKUP_PRIVATE_KEY,
|
|
36
|
+
$ENVLOCKUP_IDENTITY, or ~/.ssh/id_ed25519)
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _parser() -> argparse.ArgumentParser:
|
|
41
|
+
p = argparse.ArgumentParser(prog="python -m envlockup", usage=USAGE, add_help=True)
|
|
42
|
+
p.add_argument("-f", "--file")
|
|
43
|
+
p.add_argument("-i", "--identity")
|
|
44
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
45
|
+
get = sub.add_parser("get")
|
|
46
|
+
get.add_argument("name")
|
|
47
|
+
decrypt = sub.add_parser("decrypt")
|
|
48
|
+
decrypt.add_argument("--format", choices=["dotenv", "json"], default="dotenv")
|
|
49
|
+
run = sub.add_parser("run")
|
|
50
|
+
run.add_argument("--override", action="store_true")
|
|
51
|
+
run.add_argument("argv", nargs=argparse.REMAINDER)
|
|
52
|
+
return p
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _values(args: argparse.Namespace) -> dict[str, str]:
|
|
56
|
+
try:
|
|
57
|
+
return load(args.file, identity=args.identity)
|
|
58
|
+
except PassphraseRequiredError:
|
|
59
|
+
# Ask interactively, like the Node CLI, when there is someone to ask.
|
|
60
|
+
if not sys.stdin.isatty():
|
|
61
|
+
raise
|
|
62
|
+
found = _find_identity(None, args.identity)
|
|
63
|
+
if not found or not is_encrypted_ssh_key(found[0]):
|
|
64
|
+
raise
|
|
65
|
+
passphrase = getpass.getpass(f"Passphrase for {found[1]}: ")
|
|
66
|
+
return load(args.file, identity=args.identity, passphrase=passphrase)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _run(command: list[str], env: dict[str, str]) -> int:
|
|
70
|
+
if os.name == "posix":
|
|
71
|
+
# Replace this process, so the command gets signals and keeps our PID
|
|
72
|
+
# (what systemd, supervisord and Docker expect).
|
|
73
|
+
try:
|
|
74
|
+
os.execvpe(command[0], command, env)
|
|
75
|
+
except OSError as err:
|
|
76
|
+
print(f"envlockup: cannot run {command[0]}: {err.strerror}", file=sys.stderr)
|
|
77
|
+
return 127
|
|
78
|
+
# Windows: resolve .exe/.cmd through PATHEXT, then wait for the child.
|
|
79
|
+
exe = shutil.which(command[0], path=env.get("PATH")) or command[0]
|
|
80
|
+
try:
|
|
81
|
+
return subprocess.call([exe, *command[1:]], env=env)
|
|
82
|
+
except OSError as err:
|
|
83
|
+
print(f"envlockup: cannot run {command[0]}: {err.strerror}", file=sys.stderr)
|
|
84
|
+
return 127
|
|
85
|
+
except KeyboardInterrupt:
|
|
86
|
+
return 130
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def main(argv: list[str] | None = None) -> int:
|
|
90
|
+
args = _parser().parse_args(argv)
|
|
91
|
+
try:
|
|
92
|
+
if args.command == "get":
|
|
93
|
+
values = _values(args)
|
|
94
|
+
if args.name not in values:
|
|
95
|
+
print(f"envlockup: {args.name} is not set", file=sys.stderr)
|
|
96
|
+
return 1
|
|
97
|
+
print(values[args.name])
|
|
98
|
+
elif args.command == "decrypt":
|
|
99
|
+
values = _values(args)
|
|
100
|
+
if args.format == "json":
|
|
101
|
+
print(json.dumps(values, ensure_ascii=False, indent=2))
|
|
102
|
+
else:
|
|
103
|
+
lines = []
|
|
104
|
+
for name, value in values.items():
|
|
105
|
+
try:
|
|
106
|
+
lines.append(f"{name}={format_value(value)}")
|
|
107
|
+
except ValueError:
|
|
108
|
+
print(f"envlockup: {name} cannot be written in dotenv syntax (it mixes ', \" and `); use --format json", file=sys.stderr)
|
|
109
|
+
return 1
|
|
110
|
+
if lines:
|
|
111
|
+
print("\n".join(lines))
|
|
112
|
+
elif args.command == "run":
|
|
113
|
+
command = args.argv[1:] if args.argv[:1] == ["--"] else args.argv
|
|
114
|
+
if not command:
|
|
115
|
+
print("usage: python -m envlockup run -- COMMAND [ARGS]...", file=sys.stderr)
|
|
116
|
+
return 2
|
|
117
|
+
values = _values(args)
|
|
118
|
+
env = dict(os.environ)
|
|
119
|
+
for name in SECRET_ENV_VARS:
|
|
120
|
+
env.pop(name, None)
|
|
121
|
+
for name, value in values.items():
|
|
122
|
+
if args.override or name not in env:
|
|
123
|
+
env[name] = value
|
|
124
|
+
return _run(command, env)
|
|
125
|
+
except (EnvlockupError, OSError) as err:
|
|
126
|
+
print(f"envlockup: {err}", file=sys.stderr)
|
|
127
|
+
return 1
|
|
128
|
+
return 0
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
if __name__ == "__main__":
|
|
132
|
+
sys.exit(main())
|
envlockup/_bech32.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Bech32 (BIP 173) as used by age for `age1...` recipients and
|
|
2
|
+
# `AGE-SECRET-KEY-1...` identities. age does not enforce the 90 character limit.
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
|
7
|
+
_GENERATOR = (0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _polymod(values: list[int]) -> int:
|
|
11
|
+
chk = 1
|
|
12
|
+
for v in values:
|
|
13
|
+
top = chk >> 25
|
|
14
|
+
chk = ((chk & 0x1FFFFFF) << 5) ^ v
|
|
15
|
+
for i in range(5):
|
|
16
|
+
if (top >> i) & 1:
|
|
17
|
+
chk ^= _GENERATOR[i]
|
|
18
|
+
return chk
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _hrp_expand(hrp: str) -> list[int]:
|
|
22
|
+
return [ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _convert_bits(data: list[int], frm: int, to: int) -> bytes:
|
|
26
|
+
acc = bits = 0
|
|
27
|
+
out = []
|
|
28
|
+
maxv = (1 << to) - 1
|
|
29
|
+
for value in data:
|
|
30
|
+
acc = (acc << frm) | value
|
|
31
|
+
bits += frm
|
|
32
|
+
while bits >= to:
|
|
33
|
+
bits -= to
|
|
34
|
+
out.append((acc >> bits) & maxv)
|
|
35
|
+
if bits >= frm or ((acc << (to - bits)) & maxv) != 0:
|
|
36
|
+
raise ValueError("invalid bech32 padding")
|
|
37
|
+
return bytes(out)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def decode(s: str) -> tuple[str, bytes]:
|
|
41
|
+
if s != s.lower() and s != s.upper():
|
|
42
|
+
raise ValueError("mixed-case bech32 string")
|
|
43
|
+
lower = s.lower()
|
|
44
|
+
pos = lower.rfind("1")
|
|
45
|
+
if pos < 1 or pos + 7 > len(lower):
|
|
46
|
+
raise ValueError("invalid bech32 string")
|
|
47
|
+
hrp = lower[:pos]
|
|
48
|
+
words = []
|
|
49
|
+
for c in lower[pos + 1 :]:
|
|
50
|
+
v = _CHARSET.find(c)
|
|
51
|
+
if v == -1:
|
|
52
|
+
raise ValueError("invalid bech32 character")
|
|
53
|
+
words.append(v)
|
|
54
|
+
if _polymod(_hrp_expand(hrp) + words) != 1:
|
|
55
|
+
raise ValueError("invalid bech32 checksum")
|
|
56
|
+
return hrp, _convert_bits(words[:-6], 5, 8)
|
envlockup/_dotenv.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# A port of the dotenv 16/17 parser (lib/main.js), so that a file means the same
|
|
2
|
+
# thing to Python as to Node's dotenv and to envlockup. python-dotenv parses
|
|
3
|
+
# differently and is not used.
|
|
4
|
+
#
|
|
5
|
+
# The JavaScript regex is translated by hand, because JavaScript and Python
|
|
6
|
+
# disagree on what \s, \w, ^, $ and . match:
|
|
7
|
+
#
|
|
8
|
+
# \s JavaScript's whitespace set (includes U+FEFF, excludes U+001C-U+001F and U+0085)
|
|
9
|
+
# \w ASCII letters, digits and _
|
|
10
|
+
# ^ $ with the m flag, line starts/ends at \n, \r, U+2028 and U+2029
|
|
11
|
+
# . anything but those four line terminators
|
|
12
|
+
#
|
|
13
|
+
# The shared test vectors (test/vectors.json) check this against dotenv itself.
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
|
|
19
|
+
_WS_CHARS = "\t\n\x0b\x0c\r \xa0 " + "".join(chr(c) for c in range(0x2000, 0x200B)) + "
"
|
|
20
|
+
_S = "[\t\n\x0b\x0c\r \xa0 -
]"
|
|
21
|
+
_BOL = "(?<![^\n\r
])" # start of input, or just after a line terminator
|
|
22
|
+
_EOL = "(?![^\n\r
])" # end of input, or just before a line terminator
|
|
23
|
+
_DOT = "[^\n\r\u2028\u2029]"
|
|
24
|
+
|
|
25
|
+
# dotenv: /(?:^|^)\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/mg
|
|
26
|
+
_LINE = re.compile(
|
|
27
|
+
_BOL
|
|
28
|
+
+ _S + "*"
|
|
29
|
+
+ "(?:export" + _S + "+)?"
|
|
30
|
+
+ "([A-Za-z0-9_.-]+)"
|
|
31
|
+
+ "(?:" + _S + "*=" + _S + "*?|:" + _S + "+?)"
|
|
32
|
+
+ "("
|
|
33
|
+
+ _S + r"*'(?:\\'|[^'])*'|"
|
|
34
|
+
+ _S + r'*"(?:\\"|[^"])*"|'
|
|
35
|
+
+ _S + r"*`(?:\\`|[^`])*`|"
|
|
36
|
+
+ r"[^#\r\n]+"
|
|
37
|
+
+ ")?"
|
|
38
|
+
+ _S + "*"
|
|
39
|
+
+ "(?:#" + _DOT + "*)?"
|
|
40
|
+
+ _EOL
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# dotenv: value.replace(/^(['"`])([\s\S]*)\1$/mg, '$2')
|
|
44
|
+
_QUOTED = re.compile(_BOL + r"(['\"`])([\s\S]*)\1" + _EOL)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def split_lines(text: str) -> list[str]:
|
|
48
|
+
lines = re.split(r"\r\n?|\n", text)
|
|
49
|
+
if lines and lines[-1] == "":
|
|
50
|
+
lines.pop()
|
|
51
|
+
return lines
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _unquote(raw: str) -> str:
|
|
55
|
+
value = raw.strip(_WS_CHARS)
|
|
56
|
+
quote = value[:1]
|
|
57
|
+
value = _QUOTED.sub(r"\2", value)
|
|
58
|
+
if quote == '"':
|
|
59
|
+
value = value.replace("\\n", "\n").replace("\\r", "\r")
|
|
60
|
+
return value
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def parse_entries(lines: list[str]) -> list[tuple[str, str]]:
|
|
64
|
+
"""(name, value) pairs in file order. Later definitions win, as in dotenv."""
|
|
65
|
+
text = "\n".join(lines)
|
|
66
|
+
return [(m.group(1), _unquote(m.group(2) or "")) for m in _LINE.finditer(text)]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def parse(text: str) -> dict[str, str]:
|
|
70
|
+
return dict(parse_entries(split_lines(text)))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_BARE = re.compile(r"[A-Za-z0-9_./:@%+,=~-]*")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def format_value(value: str) -> str:
|
|
77
|
+
"""Serialize a value so that dotenv (and this parser) read it back unchanged.
|
|
78
|
+
|
|
79
|
+
Same rules as formatValue in the Node implementation. Raises ValueError for
|
|
80
|
+
the rare values dotenv's syntax cannot represent.
|
|
81
|
+
"""
|
|
82
|
+
if _BARE.fullmatch(value):
|
|
83
|
+
return value
|
|
84
|
+
# dotenv turns every \r\n or \r into \n before parsing; only the \r escape inside double quotes survives.
|
|
85
|
+
if "\r" in value:
|
|
86
|
+
if '"' not in value and not re.search(r"\\[nr]", value):
|
|
87
|
+
return '"' + value.replace("\r", "\\r") + '"'
|
|
88
|
+
raise ValueError("this value cannot be written as a dotenv value (it has a carriage return and a \" or \\n)")
|
|
89
|
+
if "'" not in value:
|
|
90
|
+
return f"'{value}'"
|
|
91
|
+
if '"' not in value and not re.search(r"\\[nr]", value):
|
|
92
|
+
return f'"{value}"'
|
|
93
|
+
if "`" not in value:
|
|
94
|
+
return f"`{value}`"
|
|
95
|
+
raise ValueError("this value cannot be written as a dotenv value (it mixes ', \" and `)")
|
envlockup/_keys.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
# Identities (private keys that can decrypt a file) and the age v1 stanzas
|
|
2
|
+
# they open (https://age-encryption.org/v1):
|
|
3
|
+
#
|
|
4
|
+
# ssh-ed25519 an OpenSSH ed25519 key file, as in age
|
|
5
|
+
# X25519 a native age key (AGE-SECRET-KEY-1...), for CI and servers;
|
|
6
|
+
# also what an ssh-agent recipient line wraps for
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import hashlib
|
|
12
|
+
import hmac
|
|
13
|
+
import re
|
|
14
|
+
import struct
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
from cryptography.exceptions import InvalidTag
|
|
18
|
+
from cryptography.hazmat.primitives import serialization
|
|
19
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
20
|
+
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
|
|
21
|
+
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
|
|
22
|
+
|
|
23
|
+
from . import _bech32
|
|
24
|
+
from .errors import EnvlockupError, PassphraseRequiredError
|
|
25
|
+
|
|
26
|
+
X25519_LABEL = b"age-encryption.org/v1/X25519"
|
|
27
|
+
SSH_ED25519_LABEL = b"age-encryption.org/v1/ssh-ed25519"
|
|
28
|
+
_ZERO_NONCE = bytes(12)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# --- Primitives ---------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def hkdf(ikm: bytes, salt: bytes, info: bytes, length: int = 32) -> bytes:
|
|
35
|
+
"""HKDF-SHA256 (RFC 5869). An empty salt is equivalent to HashLen zero bytes."""
|
|
36
|
+
prk = hmac.new(salt, ikm, hashlib.sha256).digest()
|
|
37
|
+
out, prev, i = b"", b"", 1
|
|
38
|
+
while len(out) < length:
|
|
39
|
+
prev = hmac.new(prk, prev + info + bytes([i]), hashlib.sha256).digest()
|
|
40
|
+
out += prev
|
|
41
|
+
i += 1
|
|
42
|
+
return out[:length]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def open_sealed(key: bytes, ciphertext: bytes, aad: bytes | None = None) -> bytes:
|
|
46
|
+
"""ChaCha20-Poly1305 with a zero nonce. Raises InvalidTag."""
|
|
47
|
+
if len(ciphertext) < 16:
|
|
48
|
+
raise InvalidTag()
|
|
49
|
+
return ChaCha20Poly1305(key).decrypt(_ZERO_NONCE, ciphertext, aad)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _x25519(scalar: bytes, point: bytes) -> bytes:
|
|
53
|
+
"""X25519(scalar, point). Rejects the all-zero output, as required by age."""
|
|
54
|
+
if len(point) != 32:
|
|
55
|
+
raise ValueError("X25519 point must be 32 bytes")
|
|
56
|
+
shared = X25519PrivateKey.from_private_bytes(scalar).exchange(X25519PublicKey.from_public_bytes(point))
|
|
57
|
+
if shared == bytes(32):
|
|
58
|
+
raise ValueError("invalid X25519 point (low order)")
|
|
59
|
+
return shared
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _x25519_base(scalar: bytes) -> bytes:
|
|
63
|
+
return X25519PrivateKey.from_private_bytes(scalar).public_key().public_bytes(
|
|
64
|
+
serialization.Encoding.Raw, serialization.PublicFormat.Raw
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def b64(data: bytes) -> str:
|
|
69
|
+
return base64.b64encode(data).decode().rstrip("=")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def unb64(s: str) -> bytes:
|
|
73
|
+
"""Strict unpadded base64: only the canonical encoding of the bytes is accepted."""
|
|
74
|
+
if not re.fullmatch(r"[A-Za-z0-9+/]*", s) or len(s) % 4 == 1:
|
|
75
|
+
raise ValueError("invalid base64")
|
|
76
|
+
raw = base64.b64decode(s + "=" * (-len(s) % 4))
|
|
77
|
+
if base64.b64encode(raw).rstrip(b"=").decode() != s:
|
|
78
|
+
raise ValueError("invalid base64")
|
|
79
|
+
return raw
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# --- SSH wire format --------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class _Reader:
|
|
86
|
+
def __init__(self, buf: bytes) -> None:
|
|
87
|
+
self.buf = buf
|
|
88
|
+
self.pos = 0
|
|
89
|
+
|
|
90
|
+
def u32(self) -> int:
|
|
91
|
+
if self.pos + 4 > len(self.buf):
|
|
92
|
+
raise ValueError("truncated SSH key")
|
|
93
|
+
(v,) = struct.unpack_from(">I", self.buf, self.pos)
|
|
94
|
+
self.pos += 4
|
|
95
|
+
return v
|
|
96
|
+
|
|
97
|
+
def string(self) -> bytes:
|
|
98
|
+
n = self.u32()
|
|
99
|
+
if self.pos + n > len(self.buf):
|
|
100
|
+
raise ValueError("truncated SSH key")
|
|
101
|
+
v = self.buf[self.pos : self.pos + n]
|
|
102
|
+
self.pos += n
|
|
103
|
+
return v
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def done(self) -> bool:
|
|
107
|
+
return self.pos == len(self.buf)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _ssh_string(data: bytes) -> bytes:
|
|
111
|
+
return struct.pack(">I", len(data)) + data
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def ssh_key_type(wire: bytes) -> str:
|
|
115
|
+
return _Reader(wire).string().decode()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def ed25519_from_wire(wire: bytes) -> bytes:
|
|
119
|
+
r = _Reader(wire)
|
|
120
|
+
key_type = r.string().decode()
|
|
121
|
+
if key_type != "ssh-ed25519":
|
|
122
|
+
raise ValueError(f"unsupported SSH key type {key_type} (only ssh-ed25519 is supported)")
|
|
123
|
+
key = r.string()
|
|
124
|
+
if len(key) != 32 or not r.done:
|
|
125
|
+
raise ValueError("malformed ssh-ed25519 key")
|
|
126
|
+
if not is_ed25519_point(key):
|
|
127
|
+
raise ValueError("malformed ssh-ed25519 key (not a point on the curve)")
|
|
128
|
+
return key
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
_P = 2**255 - 19
|
|
132
|
+
_D = -121665 * pow(121666, _P - 2, _P) % _P
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def is_ed25519_point(public_key: bytes) -> bool:
|
|
136
|
+
"""Whether 32 bytes decode to a point on the Ed25519 curve (RFC 8032, 5.1.3)."""
|
|
137
|
+
if len(public_key) != 32:
|
|
138
|
+
return False
|
|
139
|
+
y = int.from_bytes(public_key, "little") & ((1 << 255) - 1)
|
|
140
|
+
if y >= _P:
|
|
141
|
+
return False
|
|
142
|
+
x2 = (y * y - 1) * pow(_D * y * y + 1, _P - 2, _P) % _P
|
|
143
|
+
if x2 == 0:
|
|
144
|
+
return public_key[31] >> 7 == 0
|
|
145
|
+
return pow(x2, (_P - 1) // 2, _P) == 1
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def decode_age_recipient(s: str) -> bytes:
|
|
149
|
+
hrp, data = _bech32.decode(s)
|
|
150
|
+
if hrp != "age" or len(data) != 32:
|
|
151
|
+
raise ValueError("invalid age recipient")
|
|
152
|
+
return data
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def fingerprint(material: bytes) -> str:
|
|
156
|
+
"""Matches `ssh-keygen -l` for SSH keys, and the Node CLI for age keys."""
|
|
157
|
+
return "SHA256:" + b64(hashlib.sha256(material).digest())
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# --- Identities -------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass
|
|
164
|
+
class Stanza:
|
|
165
|
+
type: str
|
|
166
|
+
args: list[str]
|
|
167
|
+
body: bytes
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class Identity:
|
|
171
|
+
fingerprint: str
|
|
172
|
+
|
|
173
|
+
def unwrap(self, stanza: Stanza) -> bytes | None:
|
|
174
|
+
raise NotImplementedError
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class X25519Identity(Identity):
|
|
178
|
+
def __init__(self, secret: bytes) -> None:
|
|
179
|
+
self._secret = secret
|
|
180
|
+
self._public = _x25519_base(secret)
|
|
181
|
+
self.fingerprint = fingerprint(self._public)
|
|
182
|
+
|
|
183
|
+
def unwrap(self, stanza: Stanza) -> bytes | None:
|
|
184
|
+
if stanza.type != "X25519" or len(stanza.args) != 1:
|
|
185
|
+
return None
|
|
186
|
+
try:
|
|
187
|
+
share = unb64(stanza.args[0])
|
|
188
|
+
shared = _x25519(self._secret, share)
|
|
189
|
+
key = hkdf(shared, share + self._public, X25519_LABEL)
|
|
190
|
+
return open_sealed(key, stanza.body)
|
|
191
|
+
except (ValueError, InvalidTag):
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class SSHEd25519Identity(Identity):
|
|
196
|
+
def __init__(self, public_key: bytes, seed: bytes) -> None:
|
|
197
|
+
self._wire = _ssh_string(b"ssh-ed25519") + _ssh_string(public_key)
|
|
198
|
+
# age's X25519 scalar for an Ed25519 seed. Its public half equals the
|
|
199
|
+
# Ed25519 public key mapped to Curve25519, which the stanza is wrapped for.
|
|
200
|
+
self._secret = hashlib.sha512(seed).digest()[:32]
|
|
201
|
+
self._ours = _x25519_base(self._secret)
|
|
202
|
+
self._tag = b64(hashlib.sha256(self._wire).digest()[:4])
|
|
203
|
+
self.fingerprint = fingerprint(self._wire)
|
|
204
|
+
|
|
205
|
+
def unwrap(self, stanza: Stanza) -> bytes | None:
|
|
206
|
+
if stanza.type != "ssh-ed25519" or len(stanza.args) != 2 or stanza.args[0] != self._tag:
|
|
207
|
+
return None
|
|
208
|
+
try:
|
|
209
|
+
share = unb64(stanza.args[1])
|
|
210
|
+
tweak = hkdf(b"", self._wire, SSH_ED25519_LABEL)
|
|
211
|
+
shared = _x25519(tweak, _x25519(self._secret, share))
|
|
212
|
+
key = hkdf(shared, share + self._ours, SSH_ED25519_LABEL)
|
|
213
|
+
return open_sealed(key, stanza.body)
|
|
214
|
+
except (ValueError, InvalidTag):
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
_OPENSSH = re.compile(r"-----BEGIN OPENSSH PRIVATE KEY-----([\s\S]*?)-----END OPENSSH PRIVATE KEY-----")
|
|
219
|
+
_MAGIC = b"openssh-key-v1\0"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _find_age_secret(text: str) -> str | None:
|
|
223
|
+
for line in re.split(r"\r?\n", text):
|
|
224
|
+
if line.strip().startswith("AGE-SECRET-KEY-1"):
|
|
225
|
+
return line.strip()
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def is_encrypted_ssh_key(text: str) -> bool:
|
|
230
|
+
if _find_age_secret(text):
|
|
231
|
+
return False
|
|
232
|
+
m = _OPENSSH.search(text)
|
|
233
|
+
if not m:
|
|
234
|
+
return False
|
|
235
|
+
raw = base64.b64decode(re.sub(r"\s+", "", m.group(1)))
|
|
236
|
+
return raw.startswith(_MAGIC) and _Reader(raw[len(_MAGIC) :]).string() != b"none"
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def parse_identity(text: str, source: str, passphrase: str | None = None) -> Identity:
|
|
240
|
+
"""An OpenSSH ed25519 key file, or an age identity (AGE-SECRET-KEY-1..., optionally in an age-keygen file)."""
|
|
241
|
+
age = _find_age_secret(text)
|
|
242
|
+
if age:
|
|
243
|
+
try:
|
|
244
|
+
hrp, data = _bech32.decode(age)
|
|
245
|
+
except ValueError:
|
|
246
|
+
hrp, data = "", b""
|
|
247
|
+
if hrp != "age-secret-key-" or len(data) != 32:
|
|
248
|
+
raise EnvlockupError(f"{source}: invalid age secret key")
|
|
249
|
+
return X25519Identity(data)
|
|
250
|
+
|
|
251
|
+
m = _OPENSSH.search(text)
|
|
252
|
+
if not m:
|
|
253
|
+
raise EnvlockupError(f"{source} is not an OpenSSH private key")
|
|
254
|
+
encrypted = is_encrypted_ssh_key(text)
|
|
255
|
+
if encrypted and not passphrase:
|
|
256
|
+
raise PassphraseRequiredError(source)
|
|
257
|
+
try:
|
|
258
|
+
key = serialization.load_ssh_private_key(
|
|
259
|
+
m.group(0).encode(), password=passphrase.encode() if encrypted and passphrase else None
|
|
260
|
+
)
|
|
261
|
+
except ValueError as err:
|
|
262
|
+
raise EnvlockupError(f"wrong passphrase for {source}" if encrypted else f"{source} is corrupted ({err})") from None
|
|
263
|
+
except Exception as err: # unsupported cipher or key format
|
|
264
|
+
raise EnvlockupError(f"{source}: {err}") from None
|
|
265
|
+
if not isinstance(key, Ed25519PrivateKey):
|
|
266
|
+
raise EnvlockupError(f"{source} is not an ssh-ed25519 key")
|
|
267
|
+
raw = serialization.PrivateFormat.Raw
|
|
268
|
+
seed = key.private_bytes(serialization.Encoding.Raw, raw, serialization.NoEncryption())
|
|
269
|
+
public = key.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
|
|
270
|
+
return SSHEd25519Identity(public, seed)
|
envlockup/_vault.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# Reading an envlockup file (see docs/DESIGN.md in the repository):
|
|
2
|
+
#
|
|
3
|
+
# #envlockup:v1 ...
|
|
4
|
+
# #envlockup:recipient <type> <public key> <stanza args...> <wrapped key> [label]
|
|
5
|
+
# API_KEY=envlockup:v1:<base64url(salt || ciphertext || tag)>
|
|
6
|
+
# PORT=3000
|
|
7
|
+
#
|
|
8
|
+
# Writing (set, add, remove, ...) is done with the envlockup CLI from npm.
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import hashlib
|
|
14
|
+
import hmac
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
from cryptography.exceptions import InvalidTag
|
|
18
|
+
|
|
19
|
+
from ._dotenv import parse_entries, split_lines
|
|
20
|
+
from ._keys import (
|
|
21
|
+
Identity,
|
|
22
|
+
Stanza,
|
|
23
|
+
decode_age_recipient,
|
|
24
|
+
ed25519_from_wire,
|
|
25
|
+
hkdf,
|
|
26
|
+
open_sealed,
|
|
27
|
+
ssh_key_type,
|
|
28
|
+
unb64,
|
|
29
|
+
)
|
|
30
|
+
from .errors import DecryptError, FormatError, NotARecipientError, RecipientListError
|
|
31
|
+
|
|
32
|
+
HEADER = "#envlockup:"
|
|
33
|
+
VERSION_LINE = "#envlockup:v1"
|
|
34
|
+
RECIPIENT_LINE = "#envlockup:recipient "
|
|
35
|
+
MAC_LINE = "#envlockup:mac "
|
|
36
|
+
VALUE_PREFIX = "envlockup:v1:"
|
|
37
|
+
VALUE_INFO = b"github.com/denisbilli/envlockup/v1/value"
|
|
38
|
+
RECIPIENTS_INFO = b"github.com/denisbilli/envlockup/v1/recipients"
|
|
39
|
+
|
|
40
|
+
# Per recipient type: how many tokens the public key takes, the stanza it uses
|
|
41
|
+
# and how many stanza arguments follow.
|
|
42
|
+
_LINE_LAYOUT = {
|
|
43
|
+
"ssh-ed25519": (1, "ssh-ed25519", 2),
|
|
44
|
+
"X25519": (1, "X25519", 1),
|
|
45
|
+
"ssh-agent": (3, "X25519", 1),
|
|
46
|
+
}
|
|
47
|
+
_WS = re.compile(r"[\s]+")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def is_encrypted_value(value: str) -> bool:
|
|
51
|
+
return value.startswith(VALUE_PREFIX)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _is_version_line(line: str) -> bool:
|
|
55
|
+
return line == VERSION_LINE or line.startswith(VERSION_LINE + " ")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _check_recipient(kind: str, tokens: list[str]) -> None:
|
|
59
|
+
"""Validate the public key on a recipient line, as the Node implementation does."""
|
|
60
|
+
if kind == "X25519":
|
|
61
|
+
if not tokens[0].startswith("age1"):
|
|
62
|
+
raise ValueError("not an age recipient")
|
|
63
|
+
decode_age_recipient(tokens[0])
|
|
64
|
+
elif kind == "ssh-ed25519":
|
|
65
|
+
ed25519_from_wire(unb64(tokens[0].rstrip("=")))
|
|
66
|
+
else:
|
|
67
|
+
decode_age_recipient(tokens[0])
|
|
68
|
+
if ssh_key_type(unb64(tokens[2].rstrip("="))) != tokens[1]:
|
|
69
|
+
raise ValueError("SSH key type mismatch")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _parse_recipient_line(line: str) -> Stanza:
|
|
73
|
+
parts = _WS.split(line[len(RECIPIENT_LINE) :].strip())
|
|
74
|
+
layout = _LINE_LAYOUT.get(parts[0])
|
|
75
|
+
if not layout or len(parts) < 2 + layout[0] + layout[2]:
|
|
76
|
+
raise FormatError(f"malformed envlockup recipient line: {line}")
|
|
77
|
+
key_tokens, stanza_type, n_args = layout
|
|
78
|
+
key_end = 1 + key_tokens
|
|
79
|
+
stanza_end = key_end + n_args
|
|
80
|
+
try:
|
|
81
|
+
_check_recipient(parts[0], parts[1:key_end])
|
|
82
|
+
return Stanza(stanza_type, parts[key_end:stanza_end], unb64(parts[stanza_end]))
|
|
83
|
+
except (ValueError, UnicodeDecodeError) as err:
|
|
84
|
+
raise FormatError(f"malformed envlockup recipient line ({err}): {line}") from None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _recipients_mac(data_key: bytes, lines: list[str]) -> str:
|
|
88
|
+
"""HMAC over the recipient lines as they appear in the file, each prefixed with its length."""
|
|
89
|
+
mac = hmac.new(hkdf(data_key, b"", RECIPIENTS_INFO), digestmod=hashlib.sha256)
|
|
90
|
+
for line in lines:
|
|
91
|
+
raw = line.encode("utf-8")
|
|
92
|
+
mac.update(len(raw).to_bytes(4, "big"))
|
|
93
|
+
mac.update(raw)
|
|
94
|
+
return base64.urlsafe_b64encode(mac.digest()).rstrip(b"=").decode()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _value_aad(name: str) -> bytes:
|
|
98
|
+
return (VALUE_PREFIX + name).encode()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _decrypt_value(data_key: bytes, name: str, value: str) -> str:
|
|
102
|
+
encoded = value[len(VALUE_PREFIX) :]
|
|
103
|
+
raw = b""
|
|
104
|
+
# Strict: unpadded base64url in its canonical form only, as in every implementation.
|
|
105
|
+
if re.fullmatch(r"[A-Za-z0-9_-]*", encoded) and len(encoded) % 4 != 1:
|
|
106
|
+
raw = base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4))
|
|
107
|
+
if base64.urlsafe_b64encode(raw).rstrip(b"=").decode() != encoded:
|
|
108
|
+
raw = b""
|
|
109
|
+
if len(raw) < 32:
|
|
110
|
+
raise DecryptError(f"{name}: malformed encrypted value")
|
|
111
|
+
key = hkdf(data_key, raw[:16], VALUE_INFO)
|
|
112
|
+
try:
|
|
113
|
+
return open_sealed(key, raw[16:], _value_aad(name)).decode("utf-8", errors="replace")
|
|
114
|
+
except InvalidTag:
|
|
115
|
+
raise DecryptError(
|
|
116
|
+
f"{name}: cannot decrypt. The value was changed or moved from another variable, or it was "
|
|
117
|
+
"encrypted before the data key changed on another branch (a `remove` merged with a `set`). "
|
|
118
|
+
f"Set it again with `envlockup set {name}`."
|
|
119
|
+
) from None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class SealedFile:
|
|
123
|
+
"""A parsed envlockup (or plain dotenv) file."""
|
|
124
|
+
|
|
125
|
+
def __init__(self, text: str) -> None:
|
|
126
|
+
lines = split_lines(text)
|
|
127
|
+
self.sealed = any(_is_version_line(line) for line in lines)
|
|
128
|
+
self.stanzas: list[Stanza] = []
|
|
129
|
+
self.agent_recipients = 0
|
|
130
|
+
self._recipient_lines: list[str] = []
|
|
131
|
+
macs = [line[len(MAC_LINE) :].strip() for line in lines if line.startswith(MAC_LINE)]
|
|
132
|
+
if len(macs) > 1:
|
|
133
|
+
raise FormatError("envlockup file has more than one #envlockup:mac line")
|
|
134
|
+
self._mac = macs[0] if macs else None
|
|
135
|
+
for line in lines:
|
|
136
|
+
if line.startswith(RECIPIENT_LINE):
|
|
137
|
+
self.stanzas.append(_parse_recipient_line(line))
|
|
138
|
+
self._recipient_lines.append(line)
|
|
139
|
+
if line[len(RECIPIENT_LINE) :].lstrip().startswith("ssh-agent "):
|
|
140
|
+
self.agent_recipients += 1
|
|
141
|
+
elif line.startswith(HEADER) and not _is_version_line(line) and not line.startswith(MAC_LINE):
|
|
142
|
+
raise FormatError(f"unsupported envlockup header line (is envlockup up to date?): {line}")
|
|
143
|
+
if self.sealed and not self.stanzas:
|
|
144
|
+
raise FormatError("envlockup file has no recipients")
|
|
145
|
+
self.entries = parse_entries(lines)
|
|
146
|
+
self._data_key: bytes | None = None
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def has_encrypted_values(self) -> bool:
|
|
150
|
+
return any(is_encrypted_value(v) for _, v in self.entries)
|
|
151
|
+
|
|
152
|
+
def unlock(self, identity: Identity) -> None:
|
|
153
|
+
"""Recover the data key with `identity`, check that every value decrypts and
|
|
154
|
+
that the recipient list is the one last signed with the data key."""
|
|
155
|
+
# Anyone can write a stanza for a public key, so a key that unwraps but does
|
|
156
|
+
# not decrypt the values may be a forgery placed before the real stanza.
|
|
157
|
+
failure: Exception | None = None
|
|
158
|
+
list_failure: RecipientListError | None = None
|
|
159
|
+
for stanza in self.stanzas:
|
|
160
|
+
key = identity.unwrap(stanza)
|
|
161
|
+
if key is None:
|
|
162
|
+
continue
|
|
163
|
+
try:
|
|
164
|
+
if len(key) != 32:
|
|
165
|
+
raise FormatError("malformed data key")
|
|
166
|
+
for name, value in self.entries:
|
|
167
|
+
if is_encrypted_value(value):
|
|
168
|
+
_decrypt_value(key, name, value)
|
|
169
|
+
except (FormatError, DecryptError) as err:
|
|
170
|
+
failure = failure or err
|
|
171
|
+
continue
|
|
172
|
+
expected = _recipients_mac(key, self._recipient_lines)
|
|
173
|
+
if self._mac is None or not hmac.compare_digest(self._mac.encode(), expected.encode()):
|
|
174
|
+
list_failure = list_failure or RecipientListError(missing=self._mac is None)
|
|
175
|
+
continue
|
|
176
|
+
self._data_key = key
|
|
177
|
+
return
|
|
178
|
+
if list_failure is not None:
|
|
179
|
+
raise list_failure
|
|
180
|
+
if failure is not None:
|
|
181
|
+
raise failure
|
|
182
|
+
raise NotARecipientError(identity.fingerprint)
|
|
183
|
+
|
|
184
|
+
def plaintext_overrides(self) -> list[str]:
|
|
185
|
+
"""Names defined in plaintext after an encrypted definition of the same name."""
|
|
186
|
+
encrypted: set[str] = set()
|
|
187
|
+
found: list[str] = []
|
|
188
|
+
for name, value in self.entries:
|
|
189
|
+
if is_encrypted_value(value):
|
|
190
|
+
encrypted.add(name)
|
|
191
|
+
elif name in encrypted and name not in found:
|
|
192
|
+
found.append(name)
|
|
193
|
+
return found
|
|
194
|
+
|
|
195
|
+
def decrypt_all(self) -> dict[str, str]:
|
|
196
|
+
"""All variables, decrypted. Later definitions win, as in dotenv, except that a
|
|
197
|
+
plaintext definition after an encrypted one is refused: it needs no key to write."""
|
|
198
|
+
for name in self.plaintext_overrides():
|
|
199
|
+
raise FormatError(
|
|
200
|
+
f"{name} is encrypted, then set again in plaintext further down the file. "
|
|
201
|
+
"Anyone who can edit the file can add such a line, so it is not used. "
|
|
202
|
+
f"Delete the line you don't want, or run `envlockup set {name}`."
|
|
203
|
+
)
|
|
204
|
+
out: dict[str, str] = {}
|
|
205
|
+
for name, value in self.entries:
|
|
206
|
+
if is_encrypted_value(value):
|
|
207
|
+
if self._data_key is None:
|
|
208
|
+
raise DecryptError("the file is locked: a private key is needed to read encrypted values")
|
|
209
|
+
value = _decrypt_value(self._data_key, name, value)
|
|
210
|
+
out[name] = value
|
|
211
|
+
return out
|
envlockup/errors.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class EnvlockupError(Exception):
|
|
5
|
+
"""Base class for every envlockup error."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FormatError(EnvlockupError):
|
|
9
|
+
"""The file's envlockup lines are malformed, or from a newer version."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DecryptError(EnvlockupError):
|
|
13
|
+
"""A value does not decrypt: it was changed, or moved from another variable."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RecipientListError(EnvlockupError):
|
|
17
|
+
"""The recipient lines are not the ones last signed by someone who could decrypt the file."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, missing: bool = False) -> None:
|
|
20
|
+
super().__init__(
|
|
21
|
+
(
|
|
22
|
+
"the file has no #envlockup:mac line, so nobody can tell whether its recipient list was changed. "
|
|
23
|
+
if missing
|
|
24
|
+
else "the recipient list was changed by someone who could not decrypt the file "
|
|
25
|
+
"(a #envlockup:recipient line was added, removed or edited). "
|
|
26
|
+
)
|
|
27
|
+
+ "Compare `envlockup recipients` with a version you trust; "
|
|
28
|
+
"if every key is one you expect, run `envlockup recipients --accept`."
|
|
29
|
+
)
|
|
30
|
+
self.missing = missing
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class NotARecipientError(EnvlockupError):
|
|
34
|
+
"""The private key is not one the file is encrypted for."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, fingerprint: str) -> None:
|
|
37
|
+
super().__init__(
|
|
38
|
+
f"this file is not encrypted for your key ({fingerprint}). "
|
|
39
|
+
"Run `npx envlockup pubkey` and send the line it prints to someone who can open the file, "
|
|
40
|
+
"so they can run `npx envlockup add` with it."
|
|
41
|
+
)
|
|
42
|
+
self.fingerprint = fingerprint
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class NoKeyError(EnvlockupError):
|
|
46
|
+
"""The file has encrypted values and no private key was found."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class PassphraseRequiredError(EnvlockupError):
|
|
50
|
+
"""The SSH key is protected by a passphrase and none was given."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, source: str) -> None:
|
|
53
|
+
super().__init__(f"the SSH key {source} is protected by a passphrase: set ENVLOCKUP_SSH_PASSPHRASE")
|
|
54
|
+
self.source = source
|
envlockup/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: envlockup
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Read envlockup files from Python: encrypted .env files you can commit, decrypted with your SSH or age key.
|
|
5
|
+
Project-URL: Homepage, https://github.com/denisbilli/envlockup
|
|
6
|
+
Author: denisbilli
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: age,dotenv,encryption,env,secrets,ssh
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Topic :: Security :: Cryptography
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Requires-Dist: bcrypt>=4
|
|
14
|
+
Requires-Dist: cryptography>=41
|
|
15
|
+
Provides-Extra: test
|
|
16
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# envlockup for Python
|
|
20
|
+
|
|
21
|
+
Read [envlockup](https://github.com/denisbilli/envlockup) files from Python, without
|
|
22
|
+
Node.js. envlockup files are `.env` files whose values are encrypted, so they can be
|
|
23
|
+
committed; they are decrypted with your SSH key or a dedicated age key.
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
pip install envlockup
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The package is imported as `envlockup`. It only reads files: create and edit them
|
|
30
|
+
with the envlockup CLI from npm (`npx envlockup init`, `npx envlockup set NAME`, ...).
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
At the top of your entry point (`manage.py`, `wsgi.py`, `asgi.py`, `celery.py`,
|
|
35
|
+
...), before anything reads settings:
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
import envlockup
|
|
39
|
+
envlockup.config() # loads .env into os.environ, decrypting it
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Variables already in the environment win, as with python-dotenv; pass
|
|
43
|
+
`override=True` to change that. A missing file is ignored; a file that cannot be
|
|
44
|
+
decrypted raises, because starting without secrets is worse.
|
|
45
|
+
|
|
46
|
+
Other options:
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
envlockup.config(BASE_DIR / ".env") # explicit path (default: $ENVLOCKUP_FILE or ./.env)
|
|
50
|
+
values = envlockup.load(".env") # a dict, os.environ untouched
|
|
51
|
+
envlockup.load(".env", identity="/etc/app/key", passphrase="...")
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**python-decouple** reads `os.environ` before the `.env` file, so calling
|
|
55
|
+
`envlockup.config()` first is all it takes: `config("SECRET_KEY")` returns the
|
|
56
|
+
decrypted value.
|
|
57
|
+
|
|
58
|
+
Or, without touching the code, run the app through envlockup:
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
python -m envlockup run -- gunicorn app.wsgi
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
On Linux and macOS the command replaces the envlockup process (same PID, signals go
|
|
65
|
+
straight to it), which suits systemd, supervisord and Docker.
|
|
66
|
+
|
|
67
|
+
## Keys
|
|
68
|
+
|
|
69
|
+
The private key is looked for in this order:
|
|
70
|
+
|
|
71
|
+
1. `ENVLOCKUP_PRIVATE_KEY`: the key itself. For servers and CI, create one with
|
|
72
|
+
`npx envlockup keygen` and store the printed `AGE-SECRET-KEY-1...`.
|
|
73
|
+
2. `ENVLOCKUP_IDENTITY`: path to a key file.
|
|
74
|
+
3. `~/.ssh/id_ed25519`.
|
|
75
|
+
|
|
76
|
+
When 1 or 2 is set, nothing else is tried. A passphrase-protected SSH key is
|
|
77
|
+
unlocked with `ENVLOCKUP_SSH_PASSPHRASE` (or a prompt, in `python -m envlockup`).
|
|
78
|
+
`config()` and `run` remove `ENVLOCKUP_PRIVATE_KEY` and `ENVLOCKUP_SSH_PASSPHRASE`
|
|
79
|
+
from the environment once the file is decrypted.
|
|
80
|
+
|
|
81
|
+
ssh-agent is not supported by the Python package yet.
|
|
82
|
+
|
|
83
|
+
## Command line
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
python -m envlockup [-f FILE] [-i KEY] get NAME
|
|
87
|
+
python -m envlockup [-f FILE] [-i KEY] decrypt [--format json]
|
|
88
|
+
python -m envlockup [-f FILE] [-i KEY] run [--override] -- COMMAND [ARGS]...
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Compatibility
|
|
92
|
+
|
|
93
|
+
Files are parsed exactly like Node's `dotenv` package (and the envlockup CLI), not
|
|
94
|
+
like python-dotenv. The test suite runs the same vectors as the Node
|
|
95
|
+
implementation (`test/vectors.json` in the repository), generated with the real
|
|
96
|
+
`dotenv` package.
|
|
97
|
+
|
|
98
|
+
The file format is described in
|
|
99
|
+
[docs/DESIGN.md](https://github.com/denisbilli/envlockup/blob/main/docs/DESIGN.md), and
|
|
100
|
+
what it protects against in
|
|
101
|
+
[docs/THREAT_MODEL.md](https://github.com/denisbilli/envlockup/blob/main/docs/THREAT_MODEL.md).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
envlockup/__init__.py,sha256=Y-q_JX1uZzo7b5M3-agqm9PURr_cwjciMZXMo60rP6M,4883
|
|
2
|
+
envlockup/__main__.py,sha256=KDqL6ZnYDkb7xLd79-Ap3UlR5N8AixgkmViisOKAmF0,4935
|
|
3
|
+
envlockup/_bech32.py,sha256=oiUukNVIYRYTvaYUigi7zFXNpV26t4VPRymAxSzf1QQ,1717
|
|
4
|
+
envlockup/_dotenv.py,sha256=elxO60Oru3wDSuxPpcPMTrY8pL4KGW7xJk9yGB_klz4,3413
|
|
5
|
+
envlockup/_keys.py,sha256=lIPM0vRLqd9KFF3-tKw5ibM2oZIyojUyuXRnd-W3NFA,9449
|
|
6
|
+
envlockup/_vault.py,sha256=ZDmVNplSt5t_shOJRE3Rceb1-odo-CTeka7MwJwicDg,8706
|
|
7
|
+
envlockup/errors.py,sha256=n-XqK1SLC4HRmIENQrSpBbqinw9vyPV2IbUoIUeRWjU,2002
|
|
8
|
+
envlockup/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
envlockup-0.2.0.dist-info/METADATA,sha256=7g4zajKkVzqrvi-IVY9c2pycL_nLjn8tvbTPkSzBpAY,3586
|
|
10
|
+
envlockup-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
11
|
+
envlockup-0.2.0.dist-info/licenses/LICENSE,sha256=xxl9tDe-E_kjwmMMOD_PERRidS8vTfU4UFuJk9vb0aA,1067
|
|
12
|
+
envlockup-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 denisbilli
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|