Skip to content

clbrge/dotseal

Repository files navigation

dotseal

Seal individual values in .env files with scoped local keys.

dotseal set . API_SUPER_KEY
dotseal set . -s prod API_SUPER_KEY
dotseal get . -s prod API_SUPER_KEY
dotseal doctor . -s prod
dotseal -s prod exec . npm run deploy

dotseal set prompts without terminal echo when no plaintext source is provided. For non-interactive use, prefer stdin or a file:

printf '%s' "$API_SUPER_KEY" | dotseal set . -s prod API_SUPER_KEY --stdin
dotseal set . -s prod API_SUPER_KEY --file /run/secrets/api_super_key

dotseal set --value ... is supported for convenience, but the plaintext is visible through argv (ps, shell history, sudo/audit logs, and similar tooling).

No scope writes to .env and uses the default key. A scope writes to .env.<scope> and uses masterkey.<scope>.

API_SUPER_KEY=enc:v1:...

By default keys are stored under:

~/.config/dotseal/masterkey.default
~/.config/dotseal/masterkey.prod

Automatic key-file creation is currently hardened on Unix only. On Windows, use --key-cmd or provide an externally protected --key-file until Dotseal sets current-user-only DACLs itself.

Use --key-file or --key-cmd to override key loading:

dotseal set . -s prod API_SUPER_KEY \
  --key-cmd "secret-tool lookup app dotseal scope prod"

--key-cmd reads the command's stdout as UTF-8 text and trims surrounding whitespace before parsing the key.

dotseal protects values at rest in dotenv files. It does not hide a secret from a process that can read both the encrypted file and the matching master key.

When dotseal fits

Good fit when you want to:

  • back up a .env.production to a sync service (iCloud, Dropbox, a NAS, a 1Password attachment) without keeping plaintext in that channel
  • distribute .env.production to teammates via channels you wouldn't trust with plaintext (chat, S3, an internal artifact store)
  • protect values at rest on a deployment host where the binary needs them but other tenants on the host shouldn't see them
  • rotate a single secret without re-encrypting everything around it
  • run a binary with decrypted secrets via dotseal exec without writing the plaintext to disk
  • mix encrypted and plaintext values in the same env file (PORT=3000 stays plain; only sensitive values are sealed)
  • give a sandboxed process access to a project tree without granting it the means to decrypt those secrets (see § Coding-agent sandboxing)

Not a fit for:

  • centrally-managed secret distribution to fleets (use Vault, AWS Secrets Manager, Doppler, Infisical)
  • per-user / per-role access control on individual values — dotseal is a symmetric scheme, anyone with the master key can read every sealed value in that scope

Not a substitute for .gitignore. Encryption does not make .env.* safe to commit. Master-key compromise — leaked from a teammate's password manager, a stolen laptop, an over-shared vault — makes every past commit retroactively decryptable, and git's append-only history means rotating the key cannot rescind a leaked blob already in the log. Treat encrypted .env files the same as plaintext ones for source control: keep them out. Dotseal is for moving and storing the file, not for shipping it through history.

Use cases

1. Encrypted backup of .env

You want your .env.production to survive a laptop loss without keeping a plaintext copy in iCloud, Dropbox, or a shared drive.

dotseal -s production init-key
dotseal -s production set . OPENAI_API_KEY      # prompts; no echo
dotseal -s production set . STRIPE_SECRET_KEY
dotseal -s production set . DATABASE_URL --stdin <<< "postgres://..."

The resulting file:

NODE_ENV=production
PORT=3000
OPENAI_API_KEY=enc:v1:Cy2Wxt...
STRIPE_SECRET_KEY=enc:v1:Y8q4dK...
DATABASE_URL=enc:v1:mAk9p4...

This file is now safe to drop into iCloud Drive / Dropbox / a NAS / a 1Password attachment. The master key file (~/.config/dotseal/masterkey.production) does not go through the same channel — back it up to your password manager separately.

Recover on a new machine:

# 1. Restore the master key (from password manager, gpg-encrypted backup, etc.)
install -m 0600 /path/to/recovered/masterkey.production \
  ~/.config/dotseal/masterkey.production

# 2. The synced .env.production is already in place — just run.
dotseal -s production exec . npm run start

The encrypted file by itself reveals variable names and value lengths (approximately, via the encoded ciphertext length) but not the values.

2. Per-secret rotation

A vendor leaks an API key — rotate it without touching anything else:

dotseal -s production set . STRIPE_SECRET_KEY --stdin < new-stripe.key
dotseal -s production doctor . --all

doctor --all re-decrypts every encrypted entry and reports failures one by one. It also warns on duplicate names.

3. Multi-environment setup

dotseal -s development init-key
dotseal -s staging     init-key
dotseal -s production  init-key   # only on the prod machine

Each scope writes to its own .env.<scope> and uses its own master key. The AAD binding means a value sealed under staging cannot be decrypted with the production key — even if a developer accidentally swaps files.

Pick at runtime:

dotseal -s "$NODE_ENV" exec . node server.js

4. CI/CD: decrypt-on-deploy

# .github/workflows/deploy.yml
- name: Deploy
  env:
    DOTSEAL_KEY: ${{ secrets.DOTSEAL_PRODUCTION_KEY }}
  run: |
    dotseal -s production --key-cmd 'printf "%s" "$DOTSEAL_KEY"' \
      exec . ./scripts/deploy.sh

--key-cmd reads the master key from a shell command's stdout — the key never lands in a file.

5. systemd unit with sealed env

[Service]
ExecStart=/usr/local/bin/dotseal -s production \
  --key-file /etc/dotseal/masterkey.production \
  exec /etc/myapp /usr/local/bin/myapp
User=myapp

dotseal exec forwards SIGINT, SIGTERM, SIGHUP to the child, so systemctl stop myapp propagates correctly. Plaintext lives only in the process's env page — never on disk.

6. Coding-agent sandboxing

You're running a coding agent (Claude Code, Cursor, an LLM-driven CI bot) inside a sandbox over a project that contains .env.production. The sandbox profile already restricts the agent to the project tree.

Because the master key file lives outside the project tree (default ~/.config/dotseal/masterkey.<scope>), the agent reads the encrypted values but cannot decrypt them. There's nothing to move, nothing to re-key, nothing to mask in source.

To revoke an agent's access to a scope:

# firejail: blacklist the dotseal config dir
firejail --blacklist=~/.config/dotseal --whitelist=~/projects/myapp \
  claude-code

# bubblewrap: simply don't bind ~/.config/dotseal into the sandbox
bwrap --ro-bind ~/projects/myapp /work \
      --bind /tmp /tmp \
      --proc /proc --dev /dev \
      claude-code

# Apple Seatbelt / sandbox-exec: omit ~/.config/dotseal from the
# allow-list of readable paths.

To grant an agent access to a specific scope while denying others, point it at one explicit key file rather than letting it scan the default location:

dotseal -s staging \
  --key-file ~/.config/dotseal/masterkey.staging \
  exec . claude-code

Inside the sandbox, only the staging key is reachable; production remains opaque even though .env.production is in the project tree.

Per-scope blacklist/whitelist is a path-policy concern, not a dotseal one — that's the point. You don't have to rearrange your repo to revoke.

7. Local dev: master key from a password manager

Don't keep a long-lived key file on your laptop. Pull on demand:

# 1Password CLI
dotseal -s production --key-cmd 'op item get "dotseal prod" --format json | jq -r .fields.key.value' \
  exec . npm run start

# pass(1)
dotseal -s production --key-cmd 'pass dotseal/production' exec . npm run start

If the manager prompts for a touch / unlock, you'll get one prompt per dotseal invocation.

8. Validation in an existing toolchain

Detect malformed sealed values at boot, even outside the dotseal load path:

// Node: from 'dotseal-env'; Deno: from '@dotseal/env' (JSR).
import { isEncryptedValue, isSafeEnvName } from 'dotseal-env'

for (const [name, value] of Object.entries(process.env)) {
  if (!isSafeEnvName(name)) continue
  if (isEncryptedValue(value)) {
    throw new Error(`refusing to start: ${name} is sealed but no scope was supplied`)
  }
}

isEncryptedValue, isSafeEnvName, isValidScope are pure predicates exported by every loader.

9. Migration from a plaintext .env

dotseal init-key
for var in OPENAI_API_KEY STRIPE_SECRET_KEY DATABASE_URL; do
  current=$(grep -E "^${var}=" .env | cut -d= -f2-)
  dotseal set . "$var" --stdin <<< "$current"
done
dotseal doctor . --all

Plaintext entries stay plaintext until you set on them. Migration is incremental — one secret per PR is fine.

Packages

This repo intentionally keeps the CLI and runtime loaders separate:

dotseal                 Rust CLI and Rust loader crate
dotseal-env             JavaScript loader primitives (npm)
dotseal-env             Python loader primitives (PyPI)
@dotseal/env            Deno loader primitives (JSR)
dotseal-go              Go loader primitives
packages/shell          POSIX shell helpers over the dotseal CLI

The loaders decrypt the same enc:v1:<payload> envelope and do not implement project-specific config merging.

Rust usage:

use dotseal::{decrypt_env, parse_env, parse_key};

let key = parse_key("AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8")?;
let env = parse_env("API_SUPER_KEY=enc:v1:...\n");
let decrypted = decrypt_env(&env, &key, "prod")?;

Shell usage:

. ./packages/shell/dotseal.sh
dotseal_load . prod
dotseal_exec . prod npm run deploy

dotseal print-env emits shell-quoted assignments. It is useful but should be treated as sensitive output. Prefer dotseal exec when possible.

Format

See FORMAT.md.

About

Seal individual dotenv values with scope-bound keys (AES-256-GCM, AAD-bound to (scope, name))

Resources

License

Apache-2.0, MIT licenses found

Licenses found

Apache-2.0
LICENSE-APACHE
MIT
LICENSE-MIT

Stars

Watchers

Forks

Packages

 
 
 

Contributors