How Encryption Works
Overview
dotsec uses per-value envelope encryption: each secret is encrypted individually with a data encryption key (DEK), so .sec files are git-mergeable — changing one secret only affects that line.
Two providers are supported:
- Local (default) — age (X25519 + ChaCha20-Poly1305) keypair, no cloud account needed
- AWS KMS — IAM-controlled access, CloudTrail audit logs, enterprise teams
Local encryption (default)
dotsec uses age for key management. Each .sec file has a corresponding keypair.
Why age?
- Small, audited surface. age is a deliberately minimal design (Cure53 audit, 2021) — one curve (X25519), one AEAD (ChaCha20-Poly1305), no parameter choices to footgun.
- Standard interchange format. The wrapped DEK is a plain age envelope. If dotsec ever broke or disappeared, the
age/rageCLI can decrypt it — your secrets are never locked into a bespoke format. - Plugin protocol for hardware-backed identities. The age plugin protocol means future support for YubiKey, Secure Enclave, TPM, and FIDO2 identities comes from the age ecosystem rather than dotsec-specific code.
- Maintained Rust implementation by the spec's author.
GPG was the alternative considered: too much surface area, web-of-trust we don't need.
How it works
- On first use, generate an X25519 keypair → store private key in
.sec.key - Generate a random AES-256 DEK
- Wrap the DEK using age (X25519 + ChaCha20-Poly1305) with the public key
- Encrypt each secret value locally with AES-256-GCM using the DEK
- Store the age-wrapped DEK in the
dek=field of the@dotsec(...)directive
On decryption, load the private key (from DOTSEC_PRIVATE_KEY env var or .sec.key file), unwrap the DEK, decrypt each ENC[...] value locally.
What the .sec file looks like
The first non-banner line is the @dotsec(...) directive: a single file-level directive carrying the format tag, the file-level integrity tag, and the wrapped DEK. It uses the same @name syntax as every other directive in .sec, so there's no second mini-grammar to learn — but its three params are paren-grouped to signal "this whole blob belongs together, don't edit by hand."
Key file
The private key is stored in .sec.key as a plain age identity string:
Key discovery order (checked in this order):
DOTSEC_PRIVATE_KEYenvironment variable<sec-file>.keyfile alongside the.secfile
For CI/CD, use the env var — no file writes needed:
AWS KMS
When you have an AWS account and want IAM-controlled access plus CloudTrail audit logs — and no local key file at all.
How it works
dotsec uses AWS KMS envelope encryption:
- Request a data key from KMS (
GenerateDataKeywith AES-256) - KMS returns both a plaintext DEK and a KMS-wrapped copy
- Encrypt each secret value locally with AES-256-GCM using the plaintext DEK
- Store the KMS-wrapped DEK in the
dek=field of the@dotsec(...)directive - Discard the plaintext DEK
On decryption, KMS unwraps the DEK first (Decrypt), then each ENC[...] value is decrypted locally. The actual secret data never leaves your machine — only the wrapped key touches KMS.
Setup
See Setup → AWS KMS for configuration steps.
What the .sec file looks like
Ciphertext format
Every ENC[...] value contains:
- Commitment — HMAC-SHA256 of the DEK, verified before decryption to detect wrong-key attempts early
- Nonce — random 12 bytes per value (no nonce reuse even if the value is unchanged)
- Padding — plaintext is padded to 64-byte blocks with 0–1 random extra blocks to hide length
File-level integrity tag
In addition to per-value AEAD (which authenticates each ENC[...] against its key name), the @dotsec(...) directive carries a file-level integrity tag: HMAC-SHA256 of the DEK over a canonical serialization of the file.
What the MAC covers
The scope is deliberately split: structure (what entries exist, what they're called, what's encrypted and how) is integrity-protected by the MAC. Plaintext value content is integrity-protected by the schema — when one exists.
What this defeats
- Directive tampering on encrypted entries: flipping
@encryptoff, redirecting@pushto an attacker-owned target, weakening@typeon an encrypted entry to bypass validation, swapping@key-idto an attacker's KMS key. - Ciphertext rollback: substituting
DB_PASSWORD=ENC[old-value]from a git history (per-value AEAD doesn't catch this because it only binds to the key name; the MAC over ENC bytes does). - Entry add / remove / rename / reorder: an attacker can't inject
EXFIL_URL=https://attacker.example, drop a sensitive key, or reorder entries to confuse downstream consumers — even for plaintext entries, the name is covered. - Schema tampering: editing
dotsec.schemato drop@max=65535or weaken@typeinvalidates every.secfile's MAC. The schema hash is canonicalized — adding@descriptionor reordering keys is a no-op, only semantic changes flip the hash.
What this does not defeat — and how to compensate
- Editing a plaintext value in place.
PORT=3000→PORT=4000passes through. The threat model: an attacker who can write the file can already rewrite a plaintext value to something that influences your app's runtime behavior (a path, a URL, a hostname). Compensate by putting validation rules indotsec.schema(@type=enum(...),@pattern=...,@max=...) —dotsec validateruns them on every load and catches tampered values. - Editing an inline directive on a plaintext entry. If you write
# @type=enum("prod","staging")\nENV=prodinline in.sec, an attacker can flip the directive to@type=stringwithout tripping the MAC. Compensate by moving plaintext validation directives intodotsec.schema— schema directives ARE bound viaschema_hash, so semantic schema changes invalidate every file's MAC. - An attacker who controls both the
.secfile and the DEK. Defense in depth ends at key compromise.
When the MAC fails
You'll see something like this on dotsec run, dotsec show, dotsec validate, etc.:
If you legitimately changed something (added a variable, edited a directive, edited the schema), run dotsec encrypt to re-MAC. If you didn't change anything, treat it as tampering and restore from git first.
Git mergeability
Because each value is encrypted independently, two developers can change different secrets in the same .sec file and merge without conflicts:
Only the lines that were actually modified show up in the diff. The wrapped DEK in the @dotsec(...) directive stays the same as long as the key isn't rotated; the mac= field updates on every write to reflect the new file state.
Key rotation
Rotate the DEK without changing any plaintext values:
This decrypts all values with the old DEK, generates a new DEK (local: new random DEK wrapped with the same age key; KMS: new data key from KMS), and re-encrypts everything. The dek= and mac= fields in the @dotsec(...) directive are both refreshed.
Use this periodically or after a suspected key compromise. For a full key compromise (private key leaked), generate a new keypair first:
Next: the Security model covers what this defends against and where the honest limits sit.