Skip to main content

Git-friendly encrypted .env files with cleartext keys and sealed values (SOPS-inspired structural encryption).

Project description

dotseal

Tests Lint codecov PyPI Python License: MIT

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. No age, gpg, sops, openssl CLI, 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.
  • Two modes. A simple symmetric master key (default) for solo/small trusted teams, or asymmetric multi-recipient envelope encryption (X25519, opt-in) so a team shares one file without ever exchanging a secret.

dotseal demo


Installation

pip install dotseal

Requires Python 3.8+. Using uv? uv add dotseal.

VS Code / Cursor extension: download the latest .vsix from GitHub Releases and install via Extensions: Install from VSIX.


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 symmetric master key (auto-added to .gitignore)
.dotseal.prv Never Your asymmetric private key (auto-added to .gitignore)
dsk-pub-... ✅ Yes Recipient public keys are safe to commit/share

Python Usage

Drop-in replacement for python-dotenv — call it once at startup and your secrets are available through os.environ:

from dotseal import load_env

load_env()  # reads .env.enc, decrypts into os.environ — no cleartext file on disk
os.getenv("DATABASE_URL")

The key is resolved automatically from DOTSEAL_MASTER_KEY or .dotseal.key. See Runtime Loader for the full API.


CLI Reference

dotseal init

Generates a new cryptographically secure master key (symmetric mode), 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 keygen

Generates an X25519 recipient key pair (asymmetric mode). Writes the private key to .dotseal.prv (mode 0600, auto-gitignored) and prints the public key (dsk-pub-...) to share with whoever encrypts for you. Options: --out <path> to choose where the private key is written, --force to overwrite, and --print to print both halves to stdout instead of touching disk.

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. Pass -r/--recipient <dsk-pub-...> (repeatable) or --recipients-file <path> to use asymmetric multi-recipient mode; otherwise it uses the symmetric master key.

dotseal decrypt [input] [output]

Decrypts values back to cleartext. Defaults: .env.enc.env. Auto-detects symmetric vs. asymmetric from the file. 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. For asymmetric files the original data key and recipient list are preserved automatically. The temp file is securely overwritten and deleted afterward. If the file doesn't exist yet, you get a fresh template to start from.

dotseal add-recipient <pubkey> [file]

Grants a new recipient access to an existing asymmetric file by wrapping its data key for the new public key. Requires a private key that is already a recipient (to unwrap the data key). Does not re-encrypt any values.

dotseal rm-recipient <pubkey-or-fingerprint> [file]

Removes a recipient's wrapped-key slot from an asymmetric file. Does not rotate the data key (see the revocation caveat above).

Common options

encrypt, decrypt, and edit accept the symmetric key options:

  • -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.

decrypt, edit, and the recipient commands accept the asymmetric key options:

  • --private-key <dsk-prv-...> — provide the recipient private key directly.
  • --private-key-file <path> — use a specific private key file instead of auto-discovery.

Key Management

The master key is resolved in this order (first match wins):

  1. An explicit --key argument (CLI) or master_key= argument (loader).
  2. The DOTSEAL_MASTER_KEY environment variable.
  3. A local .dotseal.key file (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())

Asymmetric mode (multi-recipient, share without sharing a secret)

The symmetric master key is great for one person or a small trusted team — but it has to be distributed out-of-band, and revoking one person means rotating the key for everyone. For teams, dotseal offers an opt-in asymmetric mode modeled on SOPS + age, using X25519 (the same elliptic-curve primitive age uses) — still pure Python, still zero extra dependencies.

How it works (envelope encryption)

  1. A single random data key (DEK) encrypts every value in the file (exactly like the symmetric path).
  2. The DEK is then wrapped once per recipient using their X25519 public key (ephemeral-static ECDH → HKDF-SHA256 → AES-256-GCM).
  3. Each developer unwraps the DEK with their own private key, then decrypts the values.

The body grows O(variables); the recipient header grows O(developers). Adding a teammate appends one small header line — no value re-encryption, and no secret is ever transferred between people.

Workflow

# 1. Each developer generates their own key pair, once.
#    Writes .dotseal.prv (gitignored, mode 0600) and prints the PUBLIC key.
dotseal keygen

# 2. Encrypt for one or more recipients (their public keys, dsk-pub-...).
dotseal encrypt --recipient dsk-pub-ALICE... --recipient dsk-pub-BOB...

# 3. Each recipient decrypts with their own private key (auto-discovered
#    from .dotseal.prv, or pass it explicitly).
dotseal decrypt --private-key-file .dotseal.prv

# 4. Grant a new teammate access later (you must already be a recipient).
dotseal add-recipient dsk-pub-CAROL... .env.enc --private-key-file .dotseal.prv

# 5. Revoke a teammate's slot.
dotseal rm-recipient dsk-pub-CAROL... .env.enc

Public keys (dsk-pub-...) are safe to commit/share; private keys (dsk-prv-...) must stay secret. You can keep a list of recipients in a file (one dsk-pub-... per line, # comments allowed) and pass it with --recipients-file.

Key resolution for asymmetric files

The private key is resolved in this order (first match wins):

  1. An explicit --private-key argument (CLI) or private_key= argument (loader).
  2. The DOTSEAL_PRIVATE_KEY environment variable.
  3. A local .dotseal.prv file (searched for in the current directory and upward).

dotseal decrypt, dotseal edit, and load_env() auto-detect whether a file is symmetric or asymmetric from its metadata — you just supply the matching key material.

Same name, different value per developer?

Envelope encryption gives every recipient the same value for a variable. If you genuinely need different developers to receive different values for the same variable name, that requires storing one ciphertext per developer for that variable — a separate "per-recipient override" concept that is not part of this release.

Revocation caveat: rm-recipient drops a recipient's wrapped-DEK slot but does not rotate the DEK, so a removed recipient can still decrypt older committed versions from git history. To fully revoke, re-encrypt from cleartext (which generates a fresh DEK).

Programmatic API

from dotseal import (
    generate_recipient_keypair,
    encrypt_text_asymmetric,
    decrypt_text_asymmetric,
)

priv, pub = generate_recipient_keypair()
enc = encrypt_text_asymmetric("FOO=bar\n", [pub])   # -> ".env.enc" text
cleartext = decrypt_text_asymmetric(enc, priv)       # -> ".env" text

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,
    private_key: str | None = None,
    override: bool = False,
    encoding: str = "utf-8",
) -> bool:
    ...

The mode is auto-detected: pass master_key for symmetric files, or private_key for asymmetric ones (both fall back to their respective env vars / key files).

  • override=False (default): existing process env vars win (12-factor friendly).
  • override=True: decrypted values overwrite anything already in os.environ.
  • Returns True if at least one variable was set (matching load_dotenv). Want the values as a dict instead? Use decrypt_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

Symmetric (v=1)

# 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.

Asymmetric (v=2, multi-recipient)

# Generated by dotseal. DO NOT EDIT VALUES MANUALLY.
DATABASE_URL=ENC[AES_GCM,data:...]
DEBUG=ENC[AES_GCM,data:...]
# dotseal:recipient fp=<fp> ephem=<base64 ephemeral pubkey> enc=<base64 wrapped DEK>
# dotseal:recipient fp=<fp> ephem=<base64 ephemeral pubkey> enc=<base64 wrapped DEK>
# dotseal: v=2 alg=AES_GCM+X25519
  • Values are encrypted once with a shared data key (DEK), so the body is identical regardless of how many recipients there are.
  • Each # dotseal:recipient line is the DEK wrapped for one recipient: fp is that recipient's public-key fingerprint, ephem is the per-wrap ephemeral X25519 public key, and enc is base64(nonce ‖ AES-GCM(wrapped DEK)).
  • The footer's alg=AES_GCM+X25519 / v=2 is how dotseal auto-detects the mode on decrypt.

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.enc from 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/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.
  • The master key is the whole ballgame (symmetric mode). 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.
  • Asymmetric mode uses X25519 ECDH + HKDF-SHA256 + AES-256-GCM envelope encryption (the age construction). Multiple recipients can share one file without exchanging a secret; revocation via rm-recipient does not rotate the data key, so re-encrypt from cleartext for full revocation.
  • All recipients of an asymmetric file decrypt the same value for a given variable. Per-recipient different values for the same variable name are not supported.
  • dotseal does not integrate cloud KMS / Vault / PGP backends (a SOPS feature); it stays offline-first and pure Python.

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

dotseal-0.2.0.tar.gz (675.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

dotseal-0.2.0-py3-none-any.whl (27.9 kB view details)

Uploaded Python 3

File details

Details for the file dotseal-0.2.0.tar.gz.

File metadata

  • Download URL: dotseal-0.2.0.tar.gz
  • Upload date:
  • Size: 675.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for dotseal-0.2.0.tar.gz
Algorithm Hash digest
SHA256 3587f82f3130e0783572393604335c899c7512d3ce5a7899cbad5f3fad935d81
MD5 3381eae9ed5e985e6ce3b92364ffbf37
BLAKE2b-256 70b7e18238bc0ac814f4fdd8bba7509f7c4df93fb04779e617ecaa576e63a22f

See more details on using hashes here.

Provenance

The following attestation bundles were made for dotseal-0.2.0.tar.gz:

Publisher: publish.yml on Jastchi/dotseal

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file dotseal-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: dotseal-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 27.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for dotseal-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 db9816be313ff24ac001d2f05a8874e17eedad9adb741055c7605dbf3f76a5c2
MD5 eb5b9fcfb761866aba627e69818d4a55
BLAKE2b-256 f929d2cfb804b06cb7b31f2f76e0a429137cd6e51a18cca705533daff240353d

See more details on using hashes here.

Provenance

The following attestation bundles were made for dotseal-0.2.0-py3-none-any.whl:

Publisher: publish.yml on Jastchi/dotseal

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page