dotseal 0.1.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.
- dotseal-0.1.0/.github/workflows/test.yml +34 -0
- dotseal-0.1.0/.gitignore +13 -0
- dotseal-0.1.0/.python-version +1 -0
- dotseal-0.1.0/LICENSE +21 -0
- dotseal-0.1.0/PKG-INFO +288 -0
- dotseal-0.1.0/README.md +251 -0
- dotseal-0.1.0/dotseal/__init__.py +56 -0
- dotseal-0.1.0/dotseal/cli.py +240 -0
- dotseal-0.1.0/dotseal/core.py +200 -0
- dotseal-0.1.0/dotseal/crypto.py +160 -0
- dotseal-0.1.0/dotseal/exceptions.py +44 -0
- dotseal-0.1.0/dotseal/loader.py +79 -0
- dotseal-0.1.0/dotseal/parser.py +170 -0
- dotseal-0.1.0/pyproject.toml +59 -0
- dotseal-0.1.0/renovate.json +98 -0
- dotseal-0.1.0/tests/__init__.py +0 -0
- dotseal-0.1.0/tests/test_cli.py +134 -0
- dotseal-0.1.0/tests/test_crypto.py +82 -0
- dotseal-0.1.0/tests/test_loader.py +71 -0
- dotseal-0.1.0/tests/test_parser.py +62 -0
- dotseal-0.1.0/uv.lock +665 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
name: Tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
test:
|
|
13
|
+
name: Python ${{ matrix.python-version }}
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
strategy:
|
|
16
|
+
fail-fast: false
|
|
17
|
+
matrix:
|
|
18
|
+
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
|
|
19
|
+
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@v4
|
|
22
|
+
|
|
23
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
24
|
+
uses: actions/setup-python@v5
|
|
25
|
+
with:
|
|
26
|
+
python-version: ${{ matrix.python-version }}
|
|
27
|
+
|
|
28
|
+
- name: Install dependencies
|
|
29
|
+
run: |
|
|
30
|
+
python -m pip install --upgrade pip
|
|
31
|
+
python -m pip install -e ".[test]"
|
|
32
|
+
|
|
33
|
+
- name: Run tests
|
|
34
|
+
run: python -m pytest
|
dotseal-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.13
|
dotseal-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dotseal contributors
|
|
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.
|
dotseal-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dotseal
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Git-friendly encrypted .env files with cleartext keys and sealed values (SOPS-inspired structural encryption).
|
|
5
|
+
Project-URL: Homepage, https://github.com/Jastchi/dotseal
|
|
6
|
+
Project-URL: Repository, https://github.com/Jastchi/dotseal
|
|
7
|
+
Author: dotseal contributors
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: aes-gcm,configuration,dotenv,dotseal,encryption,env,secrets,sops
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
24
|
+
Classifier: Topic :: Security :: Cryptography
|
|
25
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
26
|
+
Requires-Python: >=3.8
|
|
27
|
+
Requires-Dist: cryptography>=42.0.0
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest<9,>=8.0.0; (python_version < '3.10') and extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest>=9.0.0; (python_version >= '3.10') and extra == 'dev'
|
|
31
|
+
Requires-Dist: ruff>=0.15; (python_version >= '3.9') and extra == 'dev'
|
|
32
|
+
Requires-Dist: ty>=0.0.47; (python_version >= '3.9') and extra == 'dev'
|
|
33
|
+
Provides-Extra: test
|
|
34
|
+
Requires-Dist: pytest<9,>=8.0.0; (python_version < '3.10') and extra == 'test'
|
|
35
|
+
Requires-Dist: pytest>=9.0.0; (python_version >= '3.10') and extra == 'test'
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
# dotseal
|
|
39
|
+
|
|
40
|
+
Git-friendly encrypted `.env` files with cleartext keys and sealed values — an offline-first environment-variable manager for Python, inspired by [Mozilla SOPS](https://github.com/getsops/sops) but built natively for the Python ecosystem.
|
|
41
|
+
|
|
42
|
+
`dotseal` performs **structural encryption**: it leaves your `.env` **keys in cleartext** and encrypts only the **values**. The result is a `.env.enc` file you can safely commit, review in pull requests, and merge — because the diff still shows *which* variables changed, just not their secret contents.
|
|
43
|
+
|
|
44
|
+
```diff
|
|
45
|
+
DATABASE_URL=ENC[AES_GCM,data:Zm9vYmFy...]
|
|
46
|
+
- DEBUG=ENC[AES_GCM,data:TXVzaWM=]
|
|
47
|
+
+ DEBUG=ENC[AES_GCM,data:b3RoZXI=]
|
|
48
|
+
API_KEY=ENC[AES_GCM,data:c2VjcmV0...]
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
- **No OS dependencies.** Pure Python on top of [`cryptography`](https://cryptography.io). No `age`, `gpg`, `sops`, `openssl` CLI, or Go binaries required.
|
|
52
|
+
- **Authenticated encryption.** AES-256-GCM (AEAD) with a fresh nonce per value.
|
|
53
|
+
- **Tamper-evident & swap-proof.** Each value is bound to its variable name as Additional Authenticated Data (AAD), so ciphertext can't be moved between keys.
|
|
54
|
+
- **Runtime loader.** Decrypt straight into `os.environ` — no cleartext file ever touches disk.
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Installation
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pip install dotseal
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Requires Python 3.8+.
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Quickstart
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
# 1. Generate a master key (saved to .dotseal.key and gitignored)
|
|
72
|
+
dotseal init
|
|
73
|
+
|
|
74
|
+
# 2. Write a normal .env file
|
|
75
|
+
cat > .env <<'EOF'
|
|
76
|
+
DATABASE_URL=postgres://user:pass@localhost:5432/db
|
|
77
|
+
DEBUG=True
|
|
78
|
+
API_KEY=super-secret
|
|
79
|
+
EOF
|
|
80
|
+
|
|
81
|
+
# 3. Encrypt it → .env.enc (commit this; never commit .env or the key)
|
|
82
|
+
dotseal encrypt
|
|
83
|
+
|
|
84
|
+
# 4. Decrypt when you need it back
|
|
85
|
+
dotseal decrypt
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### What gets committed?
|
|
89
|
+
|
|
90
|
+
| File | Commit it? | Contents |
|
|
91
|
+
| --------------------- | ---------- | ----------------------------------------- |
|
|
92
|
+
| `.env.enc` | ✅ Yes | Keys in cleartext, values encrypted |
|
|
93
|
+
| `.env` | ❌ No | Full cleartext secrets |
|
|
94
|
+
| `.dotseal.key` | ❌ **Never**| The master key (auto-added to `.gitignore`) |
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## CLI Reference
|
|
99
|
+
|
|
100
|
+
### `dotseal init`
|
|
101
|
+
Generates a new cryptographically secure master key, writes it to `.dotseal.key` (mode `0600`), and adds it to `.gitignore` (creating one if needed). Prints the key **fingerprint** (not the key) so you can verify which key encrypted a file. Use `--force` to replace an existing key (this makes existing `.env.enc` files undecryptable).
|
|
102
|
+
|
|
103
|
+
### `dotseal encrypt [input] [output]`
|
|
104
|
+
Encrypts the values of a cleartext env file. Defaults: `.env` → `.env.enc`. Idempotent — values that are already encrypted are left untouched.
|
|
105
|
+
|
|
106
|
+
### `dotseal decrypt [input] [output]`
|
|
107
|
+
Decrypts values back to cleartext. Defaults: `.env.enc` → `.env`. The output is written with owner-only (`0600`) permissions since it contains secrets.
|
|
108
|
+
|
|
109
|
+
### `dotseal edit [file]`
|
|
110
|
+
SOPS-style editing. Decrypts `.env.enc` to a temporary file (mode `0600`), opens it in `$EDITOR` (falling back to `nano`), and re-encrypts on save. The temp file is securely overwritten and deleted afterward. If the file doesn't exist yet, you get a fresh template to start from.
|
|
111
|
+
|
|
112
|
+
### Common options
|
|
113
|
+
All commands except `init` accept:
|
|
114
|
+
|
|
115
|
+
- `-k, --key <base64>` — provide the master key directly (overrides env var and key file).
|
|
116
|
+
- `--key-file <path>` — use a specific key file instead of auto-discovery.
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## Key Management
|
|
121
|
+
|
|
122
|
+
The master key is resolved in this order (first match wins):
|
|
123
|
+
|
|
124
|
+
1. An explicit `--key` argument (CLI) or `master_key=` argument (loader).
|
|
125
|
+
2. The `DOTSEAL_MASTER_KEY` environment variable.
|
|
126
|
+
3. A local `.dotseal.key` file (searched for in the current directory and upward through parent directories).
|
|
127
|
+
|
|
128
|
+
The key is a base64-encoded 32-byte (AES-256) value. Generate one programmatically with:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
from dotseal import generate_master_key
|
|
132
|
+
print(generate_master_key())
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## Runtime Loader (no cleartext on disk)
|
|
138
|
+
|
|
139
|
+
`load_env` is a **drop-in replacement for `python-dotenv`'s `load_dotenv`** — it just reads an encrypted `.env.enc` instead of a cleartext `.env`. Call it once at startup and your secrets are available as ordinary environment variables through the `os` module:
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
import os
|
|
143
|
+
from dotseal import load_env
|
|
144
|
+
|
|
145
|
+
# Resolves the key from DOTSEAL_MASTER_KEY or .dotseal.key
|
|
146
|
+
load_env() # reads ".env.enc" by default
|
|
147
|
+
|
|
148
|
+
os.getenv("DATABASE_URL") # now available, like any env var
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Signature:
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
def load_env(
|
|
155
|
+
dotenv_path: str = ".env.enc",
|
|
156
|
+
*,
|
|
157
|
+
master_key: str | None = None,
|
|
158
|
+
override: bool = False,
|
|
159
|
+
encoding: str = "utf-8",
|
|
160
|
+
) -> bool:
|
|
161
|
+
...
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
- `override=False` (default): existing process env vars win (12-factor friendly).
|
|
165
|
+
- `override=True`: decrypted values overwrite anything already in `os.environ`.
|
|
166
|
+
- Returns `True` if at least one variable was set (matching `load_dotenv`). Want the values as a `dict` instead? Use `decrypt_to_dict` (below).
|
|
167
|
+
|
|
168
|
+
Other programmatic helpers:
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
from dotseal import encrypt_text, decrypt_text, decrypt_to_dict, load_key_bytes
|
|
172
|
+
|
|
173
|
+
key = load_key_bytes("BASE64KEY==")
|
|
174
|
+
enc = encrypt_text("FOO=bar\n", key) # -> ".env.enc" text
|
|
175
|
+
cleartext = decrypt_text(enc, key) # -> ".env" text
|
|
176
|
+
mapping = decrypt_to_dict(enc, key) # -> {"FOO": "bar"}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## File Format
|
|
182
|
+
|
|
183
|
+
```env
|
|
184
|
+
# Generated by dotseal. DO NOT EDIT VALUES MANUALLY.
|
|
185
|
+
DATABASE_URL=ENC[AES_GCM,data:<base64(nonce ‖ ciphertext ‖ tag)>]
|
|
186
|
+
DEBUG=ENC[AES_GCM,data:...]
|
|
187
|
+
# dotseal: v=1 alg=AES_GCM key_fp=7ef08b59e6a945e4
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
- Each value's payload is `base64(12-byte nonce ‖ ciphertext ‖ GCM tag)`.
|
|
191
|
+
- The variable name is bound as AAD, so values cannot be swapped between keys.
|
|
192
|
+
- The trailing `# dotseal:` metadata line records the algorithm and a **key fingerprint** (a one-way hash of the key). On decrypt, the fingerprint is checked first so a wrong key fails fast with a clear message instead of a cryptic crypto error.
|
|
193
|
+
- Comments and blank lines are preserved. Values containing spaces, `#`, or newlines are safely quoted/escaped on decryption.
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
## CI/CD Integration
|
|
198
|
+
|
|
199
|
+
The pattern is always the same: provide the master key via the `DOTSEAL_MASTER_KEY` environment variable (from your platform's secret store), commit only `.env.enc`, and either decrypt to a file or load at runtime.
|
|
200
|
+
|
|
201
|
+
### GitHub Actions
|
|
202
|
+
|
|
203
|
+
Store the key as a repository/environment **secret** named `DOTSEAL_MASTER_KEY`.
|
|
204
|
+
|
|
205
|
+
```yaml
|
|
206
|
+
jobs:
|
|
207
|
+
deploy:
|
|
208
|
+
runs-on: ubuntu-latest
|
|
209
|
+
env:
|
|
210
|
+
DOTSEAL_MASTER_KEY: ${{ secrets.DOTSEAL_MASTER_KEY }}
|
|
211
|
+
steps:
|
|
212
|
+
- uses: actions/checkout@v4
|
|
213
|
+
- uses: actions/setup-python@v5
|
|
214
|
+
with:
|
|
215
|
+
python-version: "3.12"
|
|
216
|
+
- run: pip install dotseal
|
|
217
|
+
|
|
218
|
+
# Option A: decrypt to a real .env for tools that expect a file
|
|
219
|
+
- run: dotseal decrypt .env.enc .env
|
|
220
|
+
|
|
221
|
+
# Option B: load at runtime inside your app (no cleartext file)
|
|
222
|
+
- run: python -c "from dotseal import load_env; load_env(); import app"
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### Docker
|
|
226
|
+
|
|
227
|
+
Bake only the encrypted file into the image and pass the key at runtime:
|
|
228
|
+
|
|
229
|
+
```dockerfile
|
|
230
|
+
FROM python:3.12-slim
|
|
231
|
+
WORKDIR /app
|
|
232
|
+
RUN pip install dotseal
|
|
233
|
+
COPY .env.enc .
|
|
234
|
+
COPY . .
|
|
235
|
+
# App calls load_env() on startup.
|
|
236
|
+
CMD ["python", "main.py"]
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
```bash
|
|
240
|
+
docker run -e DOTSEAL_MASTER_KEY="$(cat .dotseal.key)" my-image
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
```python
|
|
244
|
+
# main.py
|
|
245
|
+
from dotseal import load_env
|
|
246
|
+
load_env() # picks up DOTSEAL_MASTER_KEY from the container env
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### Kubernetes
|
|
250
|
+
|
|
251
|
+
Store the master key in a `Secret` and expose it as `DOTSEAL_MASTER_KEY`:
|
|
252
|
+
|
|
253
|
+
```yaml
|
|
254
|
+
env:
|
|
255
|
+
- name: DOTSEAL_MASTER_KEY
|
|
256
|
+
valueFrom:
|
|
257
|
+
secretKeyRef:
|
|
258
|
+
name: dotseal
|
|
259
|
+
key: master-key
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
---
|
|
263
|
+
|
|
264
|
+
## Security Notes & Limitations
|
|
265
|
+
|
|
266
|
+
- **AES-256-GCM** provides confidentiality *and* integrity. Tampered ciphertext or a wrong key is rejected rather than silently producing garbage.
|
|
267
|
+
- **AAD binding** prevents an attacker who can edit the committed `.env.enc` from relocating a high-privilege secret onto a low-privilege variable name.
|
|
268
|
+
- **Key fingerprint** is a domain-separated SHA-256 hash truncated to 8 bytes; it reveals nothing about the key itself.
|
|
269
|
+
- **Memory hygiene is best-effort.** dotseal overwrites the mutable key buffers it controls, but Python's immutable `str`/`bytes` and garbage collector mean secrets can still linger in memory. Do not rely on this for protection against an attacker with live process access.
|
|
270
|
+
- **The master key is the whole ballgame.** Anyone with the key can decrypt everything. Rotate it by re-encrypting with `dotseal init --force` followed by `encrypt`, and store it only in trusted secret managers.
|
|
271
|
+
- This tool is a single-key symmetric scheme. It does **not** implement multi-recipient/asymmetric key sharing (a SOPS + `age`/KMS feature).
|
|
272
|
+
|
|
273
|
+
---
|
|
274
|
+
|
|
275
|
+
## Development
|
|
276
|
+
|
|
277
|
+
```bash
|
|
278
|
+
uv venv && uv pip install -e ".[dev]"
|
|
279
|
+
uv run pytest
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
CI runs the full test suite on Python 3.8 through 3.14 (see `.github/workflows/test.yml`).
|
|
283
|
+
|
|
284
|
+
The test suite covers crypto round-trips, edge-case values (empty strings, `!!@#$%=`, unicode, multi-line, large), structural parsing, the runtime loader (asserting no side-effect files are written), and the full CLI lifecycle including `edit`.
|
|
285
|
+
|
|
286
|
+
## License
|
|
287
|
+
|
|
288
|
+
MIT
|
dotseal-0.1.0/README.md
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# dotseal
|
|
2
|
+
|
|
3
|
+
Git-friendly encrypted `.env` files with cleartext keys and sealed values — an offline-first environment-variable manager for Python, inspired by [Mozilla SOPS](https://github.com/getsops/sops) but built natively for the Python ecosystem.
|
|
4
|
+
|
|
5
|
+
`dotseal` performs **structural encryption**: it leaves your `.env` **keys in cleartext** and encrypts only the **values**. The result is a `.env.enc` file you can safely commit, review in pull requests, and merge — because the diff still shows *which* variables changed, just not their secret contents.
|
|
6
|
+
|
|
7
|
+
```diff
|
|
8
|
+
DATABASE_URL=ENC[AES_GCM,data:Zm9vYmFy...]
|
|
9
|
+
- DEBUG=ENC[AES_GCM,data:TXVzaWM=]
|
|
10
|
+
+ DEBUG=ENC[AES_GCM,data:b3RoZXI=]
|
|
11
|
+
API_KEY=ENC[AES_GCM,data:c2VjcmV0...]
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
- **No OS dependencies.** Pure Python on top of [`cryptography`](https://cryptography.io). No `age`, `gpg`, `sops`, `openssl` CLI, or Go binaries required.
|
|
15
|
+
- **Authenticated encryption.** AES-256-GCM (AEAD) with a fresh nonce per value.
|
|
16
|
+
- **Tamper-evident & swap-proof.** Each value is bound to its variable name as Additional Authenticated Data (AAD), so ciphertext can't be moved between keys.
|
|
17
|
+
- **Runtime loader.** Decrypt straight into `os.environ` — no cleartext file ever touches disk.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install dotseal
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Requires Python 3.8+.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Quickstart
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# 1. Generate a master key (saved to .dotseal.key and gitignored)
|
|
35
|
+
dotseal init
|
|
36
|
+
|
|
37
|
+
# 2. Write a normal .env file
|
|
38
|
+
cat > .env <<'EOF'
|
|
39
|
+
DATABASE_URL=postgres://user:pass@localhost:5432/db
|
|
40
|
+
DEBUG=True
|
|
41
|
+
API_KEY=super-secret
|
|
42
|
+
EOF
|
|
43
|
+
|
|
44
|
+
# 3. Encrypt it → .env.enc (commit this; never commit .env or the key)
|
|
45
|
+
dotseal encrypt
|
|
46
|
+
|
|
47
|
+
# 4. Decrypt when you need it back
|
|
48
|
+
dotseal decrypt
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### What gets committed?
|
|
52
|
+
|
|
53
|
+
| File | Commit it? | Contents |
|
|
54
|
+
| --------------------- | ---------- | ----------------------------------------- |
|
|
55
|
+
| `.env.enc` | ✅ Yes | Keys in cleartext, values encrypted |
|
|
56
|
+
| `.env` | ❌ No | Full cleartext secrets |
|
|
57
|
+
| `.dotseal.key` | ❌ **Never**| The master key (auto-added to `.gitignore`) |
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## CLI Reference
|
|
62
|
+
|
|
63
|
+
### `dotseal init`
|
|
64
|
+
Generates a new cryptographically secure master key, writes it to `.dotseal.key` (mode `0600`), and adds it to `.gitignore` (creating one if needed). Prints the key **fingerprint** (not the key) so you can verify which key encrypted a file. Use `--force` to replace an existing key (this makes existing `.env.enc` files undecryptable).
|
|
65
|
+
|
|
66
|
+
### `dotseal encrypt [input] [output]`
|
|
67
|
+
Encrypts the values of a cleartext env file. Defaults: `.env` → `.env.enc`. Idempotent — values that are already encrypted are left untouched.
|
|
68
|
+
|
|
69
|
+
### `dotseal decrypt [input] [output]`
|
|
70
|
+
Decrypts values back to cleartext. Defaults: `.env.enc` → `.env`. The output is written with owner-only (`0600`) permissions since it contains secrets.
|
|
71
|
+
|
|
72
|
+
### `dotseal edit [file]`
|
|
73
|
+
SOPS-style editing. Decrypts `.env.enc` to a temporary file (mode `0600`), opens it in `$EDITOR` (falling back to `nano`), and re-encrypts on save. The temp file is securely overwritten and deleted afterward. If the file doesn't exist yet, you get a fresh template to start from.
|
|
74
|
+
|
|
75
|
+
### Common options
|
|
76
|
+
All commands except `init` accept:
|
|
77
|
+
|
|
78
|
+
- `-k, --key <base64>` — provide the master key directly (overrides env var and key file).
|
|
79
|
+
- `--key-file <path>` — use a specific key file instead of auto-discovery.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Key Management
|
|
84
|
+
|
|
85
|
+
The master key is resolved in this order (first match wins):
|
|
86
|
+
|
|
87
|
+
1. An explicit `--key` argument (CLI) or `master_key=` argument (loader).
|
|
88
|
+
2. The `DOTSEAL_MASTER_KEY` environment variable.
|
|
89
|
+
3. A local `.dotseal.key` file (searched for in the current directory and upward through parent directories).
|
|
90
|
+
|
|
91
|
+
The key is a base64-encoded 32-byte (AES-256) value. Generate one programmatically with:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from dotseal import generate_master_key
|
|
95
|
+
print(generate_master_key())
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Runtime Loader (no cleartext on disk)
|
|
101
|
+
|
|
102
|
+
`load_env` is a **drop-in replacement for `python-dotenv`'s `load_dotenv`** — it just reads an encrypted `.env.enc` instead of a cleartext `.env`. Call it once at startup and your secrets are available as ordinary environment variables through the `os` module:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
import os
|
|
106
|
+
from dotseal import load_env
|
|
107
|
+
|
|
108
|
+
# Resolves the key from DOTSEAL_MASTER_KEY or .dotseal.key
|
|
109
|
+
load_env() # reads ".env.enc" by default
|
|
110
|
+
|
|
111
|
+
os.getenv("DATABASE_URL") # now available, like any env var
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Signature:
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
def load_env(
|
|
118
|
+
dotenv_path: str = ".env.enc",
|
|
119
|
+
*,
|
|
120
|
+
master_key: str | None = None,
|
|
121
|
+
override: bool = False,
|
|
122
|
+
encoding: str = "utf-8",
|
|
123
|
+
) -> bool:
|
|
124
|
+
...
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
- `override=False` (default): existing process env vars win (12-factor friendly).
|
|
128
|
+
- `override=True`: decrypted values overwrite anything already in `os.environ`.
|
|
129
|
+
- Returns `True` if at least one variable was set (matching `load_dotenv`). Want the values as a `dict` instead? Use `decrypt_to_dict` (below).
|
|
130
|
+
|
|
131
|
+
Other programmatic helpers:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
from dotseal import encrypt_text, decrypt_text, decrypt_to_dict, load_key_bytes
|
|
135
|
+
|
|
136
|
+
key = load_key_bytes("BASE64KEY==")
|
|
137
|
+
enc = encrypt_text("FOO=bar\n", key) # -> ".env.enc" text
|
|
138
|
+
cleartext = decrypt_text(enc, key) # -> ".env" text
|
|
139
|
+
mapping = decrypt_to_dict(enc, key) # -> {"FOO": "bar"}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## File Format
|
|
145
|
+
|
|
146
|
+
```env
|
|
147
|
+
# Generated by dotseal. DO NOT EDIT VALUES MANUALLY.
|
|
148
|
+
DATABASE_URL=ENC[AES_GCM,data:<base64(nonce ‖ ciphertext ‖ tag)>]
|
|
149
|
+
DEBUG=ENC[AES_GCM,data:...]
|
|
150
|
+
# dotseal: v=1 alg=AES_GCM key_fp=7ef08b59e6a945e4
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
- Each value's payload is `base64(12-byte nonce ‖ ciphertext ‖ GCM tag)`.
|
|
154
|
+
- The variable name is bound as AAD, so values cannot be swapped between keys.
|
|
155
|
+
- The trailing `# dotseal:` metadata line records the algorithm and a **key fingerprint** (a one-way hash of the key). On decrypt, the fingerprint is checked first so a wrong key fails fast with a clear message instead of a cryptic crypto error.
|
|
156
|
+
- Comments and blank lines are preserved. Values containing spaces, `#`, or newlines are safely quoted/escaped on decryption.
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## CI/CD Integration
|
|
161
|
+
|
|
162
|
+
The pattern is always the same: provide the master key via the `DOTSEAL_MASTER_KEY` environment variable (from your platform's secret store), commit only `.env.enc`, and either decrypt to a file or load at runtime.
|
|
163
|
+
|
|
164
|
+
### GitHub Actions
|
|
165
|
+
|
|
166
|
+
Store the key as a repository/environment **secret** named `DOTSEAL_MASTER_KEY`.
|
|
167
|
+
|
|
168
|
+
```yaml
|
|
169
|
+
jobs:
|
|
170
|
+
deploy:
|
|
171
|
+
runs-on: ubuntu-latest
|
|
172
|
+
env:
|
|
173
|
+
DOTSEAL_MASTER_KEY: ${{ secrets.DOTSEAL_MASTER_KEY }}
|
|
174
|
+
steps:
|
|
175
|
+
- uses: actions/checkout@v4
|
|
176
|
+
- uses: actions/setup-python@v5
|
|
177
|
+
with:
|
|
178
|
+
python-version: "3.12"
|
|
179
|
+
- run: pip install dotseal
|
|
180
|
+
|
|
181
|
+
# Option A: decrypt to a real .env for tools that expect a file
|
|
182
|
+
- run: dotseal decrypt .env.enc .env
|
|
183
|
+
|
|
184
|
+
# Option B: load at runtime inside your app (no cleartext file)
|
|
185
|
+
- run: python -c "from dotseal import load_env; load_env(); import app"
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### Docker
|
|
189
|
+
|
|
190
|
+
Bake only the encrypted file into the image and pass the key at runtime:
|
|
191
|
+
|
|
192
|
+
```dockerfile
|
|
193
|
+
FROM python:3.12-slim
|
|
194
|
+
WORKDIR /app
|
|
195
|
+
RUN pip install dotseal
|
|
196
|
+
COPY .env.enc .
|
|
197
|
+
COPY . .
|
|
198
|
+
# App calls load_env() on startup.
|
|
199
|
+
CMD ["python", "main.py"]
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
docker run -e DOTSEAL_MASTER_KEY="$(cat .dotseal.key)" my-image
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
```python
|
|
207
|
+
# main.py
|
|
208
|
+
from dotseal import load_env
|
|
209
|
+
load_env() # picks up DOTSEAL_MASTER_KEY from the container env
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### Kubernetes
|
|
213
|
+
|
|
214
|
+
Store the master key in a `Secret` and expose it as `DOTSEAL_MASTER_KEY`:
|
|
215
|
+
|
|
216
|
+
```yaml
|
|
217
|
+
env:
|
|
218
|
+
- name: DOTSEAL_MASTER_KEY
|
|
219
|
+
valueFrom:
|
|
220
|
+
secretKeyRef:
|
|
221
|
+
name: dotseal
|
|
222
|
+
key: master-key
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## Security Notes & Limitations
|
|
228
|
+
|
|
229
|
+
- **AES-256-GCM** provides confidentiality *and* integrity. Tampered ciphertext or a wrong key is rejected rather than silently producing garbage.
|
|
230
|
+
- **AAD binding** prevents an attacker who can edit the committed `.env.enc` from relocating a high-privilege secret onto a low-privilege variable name.
|
|
231
|
+
- **Key fingerprint** is a domain-separated SHA-256 hash truncated to 8 bytes; it reveals nothing about the key itself.
|
|
232
|
+
- **Memory hygiene is best-effort.** dotseal overwrites the mutable key buffers it controls, but Python's immutable `str`/`bytes` and garbage collector mean secrets can still linger in memory. Do not rely on this for protection against an attacker with live process access.
|
|
233
|
+
- **The master key is the whole ballgame.** Anyone with the key can decrypt everything. Rotate it by re-encrypting with `dotseal init --force` followed by `encrypt`, and store it only in trusted secret managers.
|
|
234
|
+
- This tool is a single-key symmetric scheme. It does **not** implement multi-recipient/asymmetric key sharing (a SOPS + `age`/KMS feature).
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
## Development
|
|
239
|
+
|
|
240
|
+
```bash
|
|
241
|
+
uv venv && uv pip install -e ".[dev]"
|
|
242
|
+
uv run pytest
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
CI runs the full test suite on Python 3.8 through 3.14 (see `.github/workflows/test.yml`).
|
|
246
|
+
|
|
247
|
+
The test suite covers crypto round-trips, edge-case values (empty strings, `!!@#$%=`, unicode, multi-line, large), structural parsing, the runtime loader (asserting no side-effect files are written), and the full CLI lifecycle including `edit`.
|
|
248
|
+
|
|
249
|
+
## License
|
|
250
|
+
|
|
251
|
+
MIT
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""dotseal: Git-friendly encrypted env var manager with cleartext keys and sealed values.
|
|
2
|
+
|
|
3
|
+
Public API
|
|
4
|
+
----------
|
|
5
|
+
* :func:`load_env` -- runtime loader (decrypt into ``os.environ``); drop-in for
|
|
6
|
+
``python-dotenv``'s ``load_dotenv``.
|
|
7
|
+
* :func:`encrypt_text` / :func:`decrypt_text` -- whole-file transforms.
|
|
8
|
+
* :func:`decrypt_to_dict` -- decrypt into a mapping, in memory.
|
|
9
|
+
* :func:`generate_master_key` / :func:`resolve_master_key` -- key helpers.
|
|
10
|
+
* The exception hierarchy rooted at :class:`DotsealError`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from .core import (
|
|
16
|
+
ENV_VAR_NAME,
|
|
17
|
+
KEY_FILE_NAME,
|
|
18
|
+
decrypt_text,
|
|
19
|
+
decrypt_to_dict,
|
|
20
|
+
encrypt_text,
|
|
21
|
+
resolve_master_key,
|
|
22
|
+
)
|
|
23
|
+
from .crypto import generate_master_key, key_fingerprint, load_key_bytes
|
|
24
|
+
from .exceptions import (
|
|
25
|
+
DecryptionError,
|
|
26
|
+
EncryptionError,
|
|
27
|
+
InvalidMasterKeyError,
|
|
28
|
+
KeyFingerprintMismatchError,
|
|
29
|
+
MasterKeyNotFoundError,
|
|
30
|
+
ParseError,
|
|
31
|
+
DotsealError,
|
|
32
|
+
)
|
|
33
|
+
from .loader import load_env
|
|
34
|
+
|
|
35
|
+
__version__ = "0.1.0"
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"__version__",
|
|
39
|
+
"load_env",
|
|
40
|
+
"encrypt_text",
|
|
41
|
+
"decrypt_text",
|
|
42
|
+
"decrypt_to_dict",
|
|
43
|
+
"generate_master_key",
|
|
44
|
+
"resolve_master_key",
|
|
45
|
+
"load_key_bytes",
|
|
46
|
+
"key_fingerprint",
|
|
47
|
+
"ENV_VAR_NAME",
|
|
48
|
+
"KEY_FILE_NAME",
|
|
49
|
+
"DotsealError",
|
|
50
|
+
"MasterKeyNotFoundError",
|
|
51
|
+
"InvalidMasterKeyError",
|
|
52
|
+
"KeyFingerprintMismatchError",
|
|
53
|
+
"DecryptionError",
|
|
54
|
+
"EncryptionError",
|
|
55
|
+
"ParseError",
|
|
56
|
+
]
|