envlockup 0.2.0__tar.gz
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-0.2.0/.gitignore +8 -0
- envlockup-0.2.0/LICENSE +21 -0
- envlockup-0.2.0/PKG-INFO +101 -0
- envlockup-0.2.0/README.md +83 -0
- envlockup-0.2.0/pyproject.toml +34 -0
- envlockup-0.2.0/src/envlockup/__init__.py +133 -0
- envlockup-0.2.0/src/envlockup/__main__.py +132 -0
- envlockup-0.2.0/src/envlockup/_bech32.py +56 -0
- envlockup-0.2.0/src/envlockup/_dotenv.py +95 -0
- envlockup-0.2.0/src/envlockup/_keys.py +270 -0
- envlockup-0.2.0/src/envlockup/_vault.py +211 -0
- envlockup-0.2.0/src/envlockup/errors.py +54 -0
- envlockup-0.2.0/src/envlockup/py.typed +0 -0
- envlockup-0.2.0/tests/test_api.py +154 -0
- envlockup-0.2.0/tests/test_vectors.py +66 -0
envlockup-0.2.0/LICENSE
ADDED
|
@@ -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.
|
envlockup-0.2.0/PKG-INFO
ADDED
|
@@ -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,83 @@
|
|
|
1
|
+
# envlockup for Python
|
|
2
|
+
|
|
3
|
+
Read [envlockup](https://github.com/denisbilli/envlockup) files from Python, without
|
|
4
|
+
Node.js. envlockup files are `.env` files whose values are encrypted, so they can be
|
|
5
|
+
committed; they are decrypted with your SSH key or a dedicated age key.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pip install envlockup
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The package is imported as `envlockup`. It only reads files: create and edit them
|
|
12
|
+
with the envlockup CLI from npm (`npx envlockup init`, `npx envlockup set NAME`, ...).
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
At the top of your entry point (`manage.py`, `wsgi.py`, `asgi.py`, `celery.py`,
|
|
17
|
+
...), before anything reads settings:
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
import envlockup
|
|
21
|
+
envlockup.config() # loads .env into os.environ, decrypting it
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Variables already in the environment win, as with python-dotenv; pass
|
|
25
|
+
`override=True` to change that. A missing file is ignored; a file that cannot be
|
|
26
|
+
decrypted raises, because starting without secrets is worse.
|
|
27
|
+
|
|
28
|
+
Other options:
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
envlockup.config(BASE_DIR / ".env") # explicit path (default: $ENVLOCKUP_FILE or ./.env)
|
|
32
|
+
values = envlockup.load(".env") # a dict, os.environ untouched
|
|
33
|
+
envlockup.load(".env", identity="/etc/app/key", passphrase="...")
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**python-decouple** reads `os.environ` before the `.env` file, so calling
|
|
37
|
+
`envlockup.config()` first is all it takes: `config("SECRET_KEY")` returns the
|
|
38
|
+
decrypted value.
|
|
39
|
+
|
|
40
|
+
Or, without touching the code, run the app through envlockup:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
python -m envlockup run -- gunicorn app.wsgi
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
On Linux and macOS the command replaces the envlockup process (same PID, signals go
|
|
47
|
+
straight to it), which suits systemd, supervisord and Docker.
|
|
48
|
+
|
|
49
|
+
## Keys
|
|
50
|
+
|
|
51
|
+
The private key is looked for in this order:
|
|
52
|
+
|
|
53
|
+
1. `ENVLOCKUP_PRIVATE_KEY`: the key itself. For servers and CI, create one with
|
|
54
|
+
`npx envlockup keygen` and store the printed `AGE-SECRET-KEY-1...`.
|
|
55
|
+
2. `ENVLOCKUP_IDENTITY`: path to a key file.
|
|
56
|
+
3. `~/.ssh/id_ed25519`.
|
|
57
|
+
|
|
58
|
+
When 1 or 2 is set, nothing else is tried. A passphrase-protected SSH key is
|
|
59
|
+
unlocked with `ENVLOCKUP_SSH_PASSPHRASE` (or a prompt, in `python -m envlockup`).
|
|
60
|
+
`config()` and `run` remove `ENVLOCKUP_PRIVATE_KEY` and `ENVLOCKUP_SSH_PASSPHRASE`
|
|
61
|
+
from the environment once the file is decrypted.
|
|
62
|
+
|
|
63
|
+
ssh-agent is not supported by the Python package yet.
|
|
64
|
+
|
|
65
|
+
## Command line
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
python -m envlockup [-f FILE] [-i KEY] get NAME
|
|
69
|
+
python -m envlockup [-f FILE] [-i KEY] decrypt [--format json]
|
|
70
|
+
python -m envlockup [-f FILE] [-i KEY] run [--override] -- COMMAND [ARGS]...
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Compatibility
|
|
74
|
+
|
|
75
|
+
Files are parsed exactly like Node's `dotenv` package (and the envlockup CLI), not
|
|
76
|
+
like python-dotenv. The test suite runs the same vectors as the Node
|
|
77
|
+
implementation (`test/vectors.json` in the repository), generated with the real
|
|
78
|
+
`dotenv` package.
|
|
79
|
+
|
|
80
|
+
The file format is described in
|
|
81
|
+
[docs/DESIGN.md](https://github.com/denisbilli/envlockup/blob/main/docs/DESIGN.md), and
|
|
82
|
+
what it protects against in
|
|
83
|
+
[docs/THREAT_MODEL.md](https://github.com/denisbilli/envlockup/blob/main/docs/THREAT_MODEL.md).
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.24"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "envlockup"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Read envlockup files from Python: encrypted .env files you can commit, decrypted with your SSH or age key."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "denisbilli" }]
|
|
13
|
+
requires-python = ">=3.10"
|
|
14
|
+
dependencies = [
|
|
15
|
+
"cryptography>=41",
|
|
16
|
+
"bcrypt>=4", # passphrase-protected OpenSSH keys
|
|
17
|
+
]
|
|
18
|
+
keywords = ["dotenv", "env", "secrets", "encryption", "ssh", "age"]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Topic :: Security :: Cryptography",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
test = ["pytest>=8"]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/denisbilli/envlockup"
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
packages = ["src/envlockup"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
testpaths = ["tests"]
|
|
@@ -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
|
|
@@ -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())
|
|
@@ -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)
|
|
@@ -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 `)")
|