Git-friendly encrypted .env files with cleartext keys and sealed values (SOPS-inspired structural encryption).
Project description
dotseal
Git-friendly encrypted .env files with cleartext keys and sealed values — an offline-first environment-variable manager for Python, inspired by Mozilla SOPS but built natively for the Python ecosystem.
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.
DATABASE_URL=ENC[AES_GCM,data:Zm9vYmFy...]
- DEBUG=ENC[AES_GCM,data:TXVzaWM=]
+ DEBUG=ENC[AES_GCM,data:b3RoZXI=]
API_KEY=ENC[AES_GCM,data:c2VjcmV0...]
- No OS dependencies. Pure Python on top of
cryptography. Noage,gpg,sops,opensslCLI, or Go binaries required. - Authenticated encryption. AES-256-GCM (AEAD) with a fresh nonce per value.
- 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.
- Runtime loader. Decrypt straight into
os.environ— no cleartext file ever touches disk.
Installation
pip install dotseal
Requires Python 3.8+.
Quickstart
# 1. Generate a master key (saved to .dotseal.key and gitignored)
dotseal init
# 2. Write a normal .env file
cat > .env <<'EOF'
DATABASE_URL=postgres://user:pass@localhost:5432/db
DEBUG=True
API_KEY=super-secret
EOF
# 3. Encrypt it → .env.enc (commit this; never commit .env or the key)
dotseal encrypt
# 4. Decrypt when you need it back
dotseal decrypt
What gets committed?
| File | Commit it? | Contents |
|---|---|---|
.env.enc |
✅ Yes | Keys in cleartext, values encrypted |
.env |
❌ No | Full cleartext secrets |
.dotseal.key |
❌ Never | The master key (auto-added to .gitignore) |
CLI Reference
dotseal init
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).
dotseal encrypt [input] [output]
Encrypts the values of a cleartext env file. Defaults: .env → .env.enc. Idempotent — values that are already encrypted are left untouched.
dotseal decrypt [input] [output]
Decrypts values back to cleartext. Defaults: .env.enc → .env. The output is written with owner-only (0600) permissions since it contains secrets.
dotseal edit [file]
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.
Common options
All commands except init accept:
-k, --key <base64>— provide the master key directly (overrides env var and key file).--key-file <path>— use a specific key file instead of auto-discovery.
Key Management
The master key is resolved in this order (first match wins):
- An explicit
--keyargument (CLI) ormaster_key=argument (loader). - The
DOTSEAL_MASTER_KEYenvironment variable. - A local
.dotseal.keyfile (searched for in the current directory and upward through parent directories).
The key is a base64-encoded 32-byte (AES-256) value. Generate one programmatically with:
from dotseal import generate_master_key
print(generate_master_key())
Runtime Loader (no cleartext on disk)
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:
import os
from dotseal import load_env
# Resolves the key from DOTSEAL_MASTER_KEY or .dotseal.key
load_env() # reads ".env.enc" by default
os.getenv("DATABASE_URL") # now available, like any env var
Signature:
def load_env(
dotenv_path: str = ".env.enc",
*,
master_key: str | None = None,
override: bool = False,
encoding: str = "utf-8",
) -> bool:
...
override=False(default): existing process env vars win (12-factor friendly).override=True: decrypted values overwrite anything already inos.environ.- Returns
Trueif at least one variable was set (matchingload_dotenv). Want the values as adictinstead? Usedecrypt_to_dict(below).
Other programmatic helpers:
from dotseal import encrypt_text, decrypt_text, decrypt_to_dict, load_key_bytes
key = load_key_bytes("BASE64KEY==")
enc = encrypt_text("FOO=bar\n", key) # -> ".env.enc" text
cleartext = decrypt_text(enc, key) # -> ".env" text
mapping = decrypt_to_dict(enc, key) # -> {"FOO": "bar"}
File Format
# Generated by dotseal. DO NOT EDIT VALUES MANUALLY.
DATABASE_URL=ENC[AES_GCM,data:<base64(nonce ‖ ciphertext ‖ tag)>]
DEBUG=ENC[AES_GCM,data:...]
# dotseal: v=1 alg=AES_GCM key_fp=7ef08b59e6a945e4
- Each value's payload is
base64(12-byte nonce ‖ ciphertext ‖ GCM tag). - The variable name is bound as AAD, so values cannot be swapped between keys.
- 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. - Comments and blank lines are preserved. Values containing spaces,
#, or newlines are safely quoted/escaped on decryption.
CI/CD Integration
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.
GitHub Actions
Store the key as a repository/environment secret named DOTSEAL_MASTER_KEY.
jobs:
deploy:
runs-on: ubuntu-latest
env:
DOTSEAL_MASTER_KEY: ${{ secrets.DOTSEAL_MASTER_KEY }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install dotseal
# Option A: decrypt to a real .env for tools that expect a file
- run: dotseal decrypt .env.enc .env
# Option B: load at runtime inside your app (no cleartext file)
- run: python -c "from dotseal import load_env; load_env(); import app"
Docker
Bake only the encrypted file into the image and pass the key at runtime:
FROM python:3.12-slim
WORKDIR /app
RUN pip install dotseal
COPY .env.enc .
COPY . .
# App calls load_env() on startup.
CMD ["python", "main.py"]
docker run -e DOTSEAL_MASTER_KEY="$(cat .dotseal.key)" my-image
# main.py
from dotseal import load_env
load_env() # picks up DOTSEAL_MASTER_KEY from the container env
Kubernetes
Store the master key in a Secret and expose it as DOTSEAL_MASTER_KEY:
env:
- name: DOTSEAL_MASTER_KEY
valueFrom:
secretKeyRef:
name: dotseal
key: master-key
Security Notes & Limitations
- AES-256-GCM provides confidentiality and integrity. Tampered ciphertext or a wrong key is rejected rather than silently producing garbage.
- AAD binding prevents an attacker who can edit the committed
.env.encfrom relocating a high-privilege secret onto a low-privilege variable name. - Key fingerprint is a domain-separated SHA-256 hash truncated to 8 bytes; it reveals nothing about the key itself.
- Memory hygiene is best-effort. dotseal overwrites the mutable key buffers it controls, but Python's immutable
str/bytesand garbage collector mean secrets can still linger in memory. Do not rely on this for protection against an attacker with live process access. - The master key is the whole ballgame. Anyone with the key can decrypt everything. Rotate it by re-encrypting with
dotseal init --forcefollowed byencrypt, and store it only in trusted secret managers. - This tool is a single-key symmetric scheme. It does not implement multi-recipient/asymmetric key sharing (a SOPS +
age/KMS feature).
Development
uv venv && uv pip install -e ".[dev]"
uv run pytest
CI runs the full test suite on Python 3.8 through 3.14 (see .github/workflows/test.yml).
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.
License
MIT
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file dotseal-0.1.0.tar.gz.
File metadata
- Download URL: dotseal-0.1.0.tar.gz
- Upload date:
- Size: 58.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.8 {"installer":{"name":"uv","version":"0.11.8","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
96f74a4e4abde1f8c270f8c2d9cc784981e9628b8e63b8948cacef233f838e13
|
|
| MD5 |
460c68c17a8a962e26babc9c53384084
|
|
| BLAKE2b-256 |
da0ca72b5e1bc5f3d181cc1bb95abd7f7d761483b30f5558eaf7a6e7c36acdea
|
File details
Details for the file dotseal-0.1.0-py3-none-any.whl.
File metadata
- Download URL: dotseal-0.1.0-py3-none-any.whl
- Upload date:
- Size: 19.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.8 {"installer":{"name":"uv","version":"0.11.8","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
71d46880e10098e56fe92b5ba2c26989c74deaf783c3e3fd0e05534e0dd0cdf5
|
|
| MD5 |
b9313bc13a83a207ea928f5087ececae
|
|
| BLAKE2b-256 |
e3fd974784c72b2cb24137bc8517e79141adeefc1275a7b80404d6fdd62a36f3
|