← Back to home

Flagship case study · open source

EnvGuardian

An open-source Go tool that lets a team commit their .env to git — encrypted — so cloning the repo is all it takes to have working local config. It is a key-management and git-integration layer over age, not a new cipher.

Go · age · SSH · Cobra 2025–2026 GitHub repo ↗ Landing page ↗

SUMMARY

Git-native encrypted secret distribution for teams. Your .env lives in the repository as ciphertext; who can decrypt it is a plaintext, reviewable file of public keys; and every ciphertext carries a detached SSH signature so successful decryption is not mistaken for proof of authorship.

Problem

Local config drifts. Teams pass .env files around in DMs and password managers, someone edits a value, and now three machines disagree about the truth. The obvious fix — commit the file — is a trap: a plaintext secret committed once lives in git history forever. I wanted config to travel with the repository, safely, so that git clone was the entire onboarding step.

What I built

A CLI that encrypts .env to every developer's age/SSH public key and commits the ciphertext. Access is a reviewable recipients.toml — adding or removing a teammate is a normal pull request. Around that core it ships git hooks, semantic diff and merge drivers, a revoke command with a rotation ledger, and integrity checks for CI.

Architecture / system design

Three questions decide everything the tool does, and each is answered by one file: who can decrypt? (the recipient set), what is the secret? (the encrypted bytes), and is it authentic and current? (a detached signature plus a lock). Sealing is idempotent, and every state change is planned before it is written.

Failure modes / what broke

The load-bearing realization: age gives you confidentiality, not authorship. Because the recipients file is public, anyone can craft ciphertext that decrypts for the whole team — so decrypting a file proves nothing about who wrote it. That gap, plus a path-traversal hole in an early build, drove most of the hardening work.

Proof / tests

Every source file has a test sibling, plus a fuzz suite and a differential .env parser suite run against joho/godotenv. A custom coverage gate fails CI below 85% on the crypto-sensitive packages and 80% for the repository. Eight architecture decision records document the trade-offs; cross-platform binaries ship through GoReleaser.

Lessons learned

The cryptography was the easy part — age handles it. The hard part is naming trust boundaries and refusing to blur them: confidentiality is not authorship, revocation is not rotation, and no ownership change is safe until it is atomic and reviewable.

field notes

Expanded field notes

WHY THIS PROJECT EXISTS

EnvGuardian is an open-source tool I build in the open — CI, a changelog, a security policy, decision records, and a public roadmap. It is honest about its stage: the README leads with a warning that it is still in security hardening and should not hold real secrets yet. I would rather ship a tool that names its own sharp edges than one that hides them, and the point of the project is to get the trust model exactly right before it gets convenient.

The idea is small enough to state in a sentence: encrypt .env to every developer's age/SSH public key, commit the ciphertext, and let a plaintext recipients file decide who can read it. Cloning the repo then gives you working config. But the interesting engineering is entirely in the failure cases, and there were three that mattered.

First, authorship. age proves a file can be decrypted, never who sealed it — and since the recipients list is public, a malicious contributor can forge ciphertext that decrypts cleanly for everyone. So every ciphertext carries a detached SSH signature that is checked against current recipients before a single byte of plaintext is written. Invalid, non-recipient, and missing signatures all fail closed.

Second, diff churn. age is randomized, so re-encrypting unchanged content would produce a brand-new blob every time and bury the team in phantom merge conflicts. Sealing is therefore idempotent: it decrypt-compares first and only writes when the content or the recipient set truly changed.

Third, and most sobering, path traversal. An early development tag let a repository-controlled file mapping write decrypted output outside the repository. The fix confines every managed path to the repo, resolved through symlinks, before any read or transactional write happens — and the hole is documented openly in the security policy rather than quietly patched.

WHAT BROKE

The subtle one: a successful decrypt feels like proof the file is trustworthy, but with a public recipients list it proves nothing about authorship. Treating "it decrypted" as "it's authentic" would let a malicious contributor slip in forged secrets. The answer was a separate signature layer that fails closed — confidentiality and authorship are two different guarantees, and age only gives one of them.

12
CLI commands, each with a test sibling
8
architecture decision records
85 / 80
% coverage gate: crypto paths / repo
EnvGuardian encrypt, commit, verify, decrypt flow .env plaintext · gitignored git repo .env.age .env.age.sig lock.toml git pull verify sig decrypt → .env recipients.toml who can decrypt · reviewable seal teammate verify against current recipients before any plaintext
fig. 1 — encrypt to public keys, commit ciphertext + signature + lock, then verify-before-decrypt on the other side.

hover or tab through any node to trace the flow

QUICKSTART · commit encrypted, clone to decrypt
# repo owner — first-time setup
$ envguardian init                         # scaffold config, seed your key, update .gitignore
$ envguardian add-recipient --github alice # authorize a teammate by public key
$ envguardian encrypt                      # .env → .env.age (+ .env.age.sig)
$ git add .envguardian/ .env.age .env.age.sig   # commit the PUBLIC, encrypted files — never .env
$ git commit -m "chore: add encrypted env config"

# teammate — after cloning or pulling
$ envguardian decrypt                      # .env.age → local .env at mode 0600

fig. 1b — the full loop. If upstream changed the config, recipients, or ciphertext, decrypt refuses to silently overwrite your .env; you review the change and accept it with decrypt --accept-changes. Git hooks, and semantic diff/merge drivers that report key-level changes without ever printing a value, are installed separately.

contact

Open to backend systems, AI infrastructure, and product engineering roles.