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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.