This document describes the security model, cryptographic design, and operational security practices of dotenc.
- Threat Model
- Cryptographic Design
- Key Material Handling
- Input Validation and Injection Prevention
- Access Control Model
- Operational Flow
- Installation Script Trust Model
- GitHub Actions Trust Model
- Known Limitations
- Vulnerability Reporting
dotenc is designed to protect secrets at rest in a Git repository. Its security model assumes:
Protected against:
- An attacker who can read the repository (including all
.encfiles and public keys in.dotenc/) but does not have access to any authorized SSH private key - Accidental secret exposure through environment variable leakage to child processes
- Tampering with encrypted files (authenticated encryption detects modification)
- Path traversal or command injection via user-supplied names and editor configuration
Not protected against:
- An attacker who has already obtained an authorized SSH private key
- Secrets that were previously exposed before being stored in dotenc
- Secrets known to a user before their access was revoked (see Access Control Model)
- A compromised machine where decryption takes place (memory forensics, malicious processes)
- Passphrase-protected keys when no passphrase source is provided — dotenc does not prompt interactively for passphrases; see Known Limitations
dotenc uses envelope encryption: each environment has a single randomly generated data key, and that data key is individually encrypted for each authorized user using their SSH public key.
Environment secrets
│
▼ AES-256-GCM (data key + env name as AAD)
│
Encrypted ciphertext (.env.*.enc)
Data key
│
├─▶ ECIES encrypt (Ed25519 public key) → stored in .env.*.enc
└─▶ RSA-OAEP encrypt (RSA public key) → stored in .env.*.enc
This means:
- Only authorized users can decrypt the data key, and therefore the environment
- Re-keying an environment (adding or revoking access) only re-encrypts the data key, not the environment contents
- Rotating the data key generates a new random key and re-encrypts all environment contents
| Operation | Algorithm | Parameters |
|---|---|---|
| Environment encryption | AES-256-GCM | 96-bit random IV, 128-bit auth tag |
| Additional Authenticated Data | Environment name bound to ciphertext | Prevents ciphertext swap across environments |
| Data key encryption (Ed25519 keys) | ECIES (eciesjs v0.4+) |
X25519 ECDH + AES-GCM |
| Data key encryption (RSA keys) | RSA-OAEP | SHA-256 |
| Supported public key types | Ed25519, RSA ≥ 2048-bit | ECDSA and DSA are rejected |
IV generation: A fresh 12-byte random IV is generated for every encryption operation using Node.js crypto.randomBytes(). IVs are never reused.
Authentication: AES-256-GCM provides authenticated encryption. Any modification to the ciphertext, auth tag, or IV is detected during decryption and results in an error. The environment name is included as Additional Authenticated Data (AAD), preventing a ciphertext from one environment from being replayed against another.
- On
dotenc env createordotenc env edit, a new 32-byte random data key is generated - The data key is encrypted for each authorized public key and stored in the
.encfile header - The data key is never written to disk in plaintext
- On decryption, the data key is held in memory only for the duration of the operation, then explicitly zeroed
SSH private keys stay in ~/.ssh/ — dotenc reads them in place and never copies, moves, or stores them elsewhere. When dotenc key add --from-private-key <name> or an interactive key-selection flow is used, dotenc loads the selected private key only long enough to derive and store its public key in .dotenc/<name>.pub; the private key itself is not written to the repository.
In-memory zeroing: After the private key is used to decrypt the data key, the raw key bytes are explicitly overwritten with zeros before being released:
// cli/src/helpers/decryptDataKey.ts
try {
return eciesDecrypt(rawSeed, encryptedDataKey)
} finally {
rawSeed.fill(0) // zero Ed25519 seed bytes
privDer.fill(0) // zero DER-encoded private key buffer
}Provider bootstrap keys: CI and provider runners should store bootstrap
private keys as DOTENC_PRIVATE_KEY_BASE64, a base64-encoded private key file.
DOTENC_PRIVATE_KEY with raw private key text remains supported for backwards
compatibility. Passphrase-protected bootstrap keys use
DOTENC_PRIVATE_KEY_PASSPHRASE with either format.
Child process isolation: When running commands with dotenc run or
dotenc dev, the DOTENC_PRIVATE_KEY_BASE64 and DOTENC_PRIVATE_KEY
environment variables are explicitly stripped from the child process
environment before launch. Injected secrets are limited to the decrypted
variables only:
// cli/src/commands/run.ts
const {
DOTENC_PRIVATE_KEY_BASE64: _privateKeyBase64,
DOTENC_PRIVATE_KEY: _privateKey,
...baseEnv
} = process.env
const mergedEnv = { ...baseEnv, ...decryptedEnv }
spawn(command, args, { env: mergedEnv })dotenc env edit decrypts the environment into a temporary file for editing. This file is handled securely:
- Created in a temporary directory with mode
0o600(readable only by the current user) - Overwritten with zeros before deletion, preventing recovery from the filesystem:
// cli/src/commands/env/edit.ts
const stat = await fs.stat(tempFilePath)
await fs.writeFile(tempFilePath, Buffer.alloc(stat.size, 0))- Signal handlers for
SIGINTandSIGTERMensure secure erasure even if the process is interrupted mid-edit
| Resource | Mode | Notes |
|---|---|---|
SSH key directory (~/.ssh/) |
0o700 |
Created if absent |
| Temporary plaintext files | 0o600 |
Zeroed before deletion |
.env.*.enc files |
Default umask | Encrypted; safe to be world-readable |
.dotenc/*.pub files |
Default umask | Public keys; intentionally public |
Environment and key names are validated with a strict whitelist — only alphanumeric characters, dots, hyphens, and underscores are accepted. The values . and .. and Windows reserved names (CON, NUL, COM1, etc.) are explicitly rejected.
Public keys are validated before use:
- RSA keys shorter than 2048 bits are rejected
- ECDSA and DSA keys are rejected (unsupported)
- Ed25519 keys are accepted as preferred
Editor commands (from $EDITOR, $VISUAL, or dotenc config editor) are checked against a shell metacharacter denylist ($, `, (, ), ;, |, <, >, &, !, newlines) before use. The editor is then executed via spawnSync with arguments as an array — not through a shell — so no shell interpolation occurs.
Child command execution (dotenc run, dotenc dev) uses spawn() with the command and arguments as separate values, never concatenated into a shell string.
Decrypted environment content is parsed with Node's built-in node:util.parseEnv parser before variables are passed to child processes.
Access in dotenc is enforced cryptographically, not by policy:
- A user who is not in the authorized list for an environment cannot decrypt that environment's data key, and therefore cannot read the secrets
- Granting access re-encrypts the data key for the new user's public key; no re-encryption of the environment contents is required
- Revoking access removes the user's encrypted data key copy and re-encrypts the data key for all remaining users (requires the revoking user to have decrypt access)
Important limitation: Revoking access prevents future decryption but does not invalidate knowledge of secrets already seen by the revoked user. For full offboarding, rotate the affected external secrets (API keys, database passwords, etc.) and optionally run dotenc env rotate <environment> to generate a new data key.
All grant and revoke operations are reflected in Git-tracked files, providing a full audit trail in repository history.
When any dotenc command runs, it resolves the project root by walking ancestor directories from the current working directory, looking for a .dotenc/ folder. Key material (public keys) is always read from and written to this resolved root, regardless of where the command was invoked. If no .dotenc/ folder is found at any ancestor level, the command falls back to the current directory (which applies during dotenc init flows).
dotenc run and dotenc dev support a hierarchical merge model for monorepo projects:
- The ancestor chain from the project root to the invocation directory is computed.
- For each requested environment name, dotenc scans every directory in the chain (root → local) for a
.env.${name}.encfile. - Variables from deeper (more local) files override variables from shallower (root) files for the same name.
- Missing files at any level are silently skipped — only existing files that fail to decrypt cause an error.
The --local-only flag narrows decryption scope to the current directory only, bypassing ancestor scanning entirely.
Batch operations (env rotate --all, auth purge) recursively walk the project tree to find all .env.*.enc files. The following directories are explicitly excluded from this walk to avoid processing build artifacts or dependency caches: node_modules, .git, dist, build, .next, coverage, vendor.
The Additional Authenticated Data (AAD) used during AES-256-GCM encryption is the environment name only — not the file path. This means same-named environments at different directory levels (e.g., a staging environment at the project root and one in packages/web) use the same AAD value. They are treated as independent encrypted files that happen to share a logical name, consistent with the hierarchical merge semantics described above.
The VS Code extension offers an installation helper that downloads and runs the dotenc install script:
curl -fsSL https://dotenc.org/install.sh | shThis is a standard pattern used by many developer tools (Homebrew, Rust, Node.js version managers, etc.). Security properties:
- HTTPS only — the connection is encrypted and the server's identity is verified by TLS certificate
- User-initiated — the script runs only when you explicitly trigger the install action; nothing runs automatically
- Domain controlled by the project —
dotenc.orgis under project ownership
If you prefer to audit the script before running it, download it first:
curl -fsSL https://dotenc.org/install.sh -o install.sh
# review install.sh
sh install.shAlternatively, install via Homebrew, Scoop, npm, or a standalone binary from the GitHub Releases page — none of these methods use the install script.
The reusable GitHub Actions exposed as dotenc/*-action@v1 delegate to the
implementation actions in actions/, which are thin wrappers around the dotenc
CLI:
actions/setupinstalls@dotenc/clithrough npm. Pin the action ref and CLI version when workflows need fully reproducible installs.actions/runwrites the requested command to a temporary script and executes it throughdotenc run --strictby default. The CLI still stripsDOTENC_PRIVATE_KEY_BASE64andDOTENC_PRIVATE_KEYbefore launching the child command, and the action wrappers unsetDOTENC_PRIVATE_KEY_BASE64,DOTENC_PRIVATE_KEY, andDOTENC_PRIVATE_KEY_PASSPHRASEbefore running user commands.actions/exportdecrypts an environment throughdotenc run, then writes only explicitly allowlisted variable names to$GITHUB_ENV. Values are registered with GitHub log masking before export.actions/write-filedecrypts one named variable and writes it to a file with mode0600by default. This is intended for file-shaped credentials such as service account JSON.
These actions intentionally do not provide a "decrypt everything" mode. Values
exported through $GITHUB_ENV remain available to later steps in the same job,
so grant CI keys narrowly and keep allowlists short.
For provider pipelines, the dotenc identity belongs to the runner that actually needs decrypted values. Use the reusable GitHub Actions only when GitHub Actions runs the command that needs those values; otherwise, follow the provider-specific runbook for that provider's own runner.
- dotenc does not prompt for passphrases. To use passphrase-protected SSH keys, provide
DOTENC_PRIVATE_KEY_PASSPHRASEin the environment. In interactive key selection flows (dotenc init, interactivedotenc key add), dotenc can also create an optional passwordless copy (for exampleid_ed25519_passwordless) after explicit user confirmation. - No HSM or hardware key support. Private keys must be accessible as files in
~/.ssh/, via the recommendedDOTENC_PRIVATE_KEY_BASE64environment variable, or via the legacyDOTENC_PRIVATE_KEYenvironment variable. Explicit key selection flags such as--private-keyand--from-private-keyselect from those file-backed keys by name. - Revocation is not retroactive. See Access Control Model.
- No centralized policy engine. Access control is enforced per-environment and per-repository, not across an organization.
If you discover a security vulnerability in dotenc, please report it responsibly.
Do not open a public GitHub issue for security vulnerabilities.
Instead, report via GitHub Security Advisories. You will receive a response as soon as possible. Please include:
- A description of the vulnerability and its potential impact
- Steps to reproduce or a proof-of-concept
- Any relevant environment details (OS, dotenc version, key type)