DEV Community

SEN LLC
SEN LLC

Posted on

Envelope Encryption for .env Files, the Right Way (PHP CLI, Stdlib Only)

Envelope Encryption for .env Files, the Right Way (PHP CLI, Stdlib Only)

Every team eventually wants to commit their secrets to git. Laravel shipped artisan env:encrypt back in 9.x, but the format is Laravel-internal. Here's a 400-line, stdlib-only PHP CLI that does the same job with an interoperable envelope — and an explanation of why every single number in the envelope matters.

📦 GitHub: https://github.com/sen-ltd/env-encrypt

env-encrypt CLI session

The problem nobody has cleanly solved

If you've been on more than two engineering teams you've had this conversation:

"How does a new hire get the production .env?"

— "1Password. Or Vault. Or this Slack DM from six months ago. Or just run
sops -d — wait, did we install age on CI yet?"

Commercial secret managers (Vault, AWS Secrets Manager, Doppler) solve this for you if you're willing to pay a runtime dependency. For a side project, a demo repo, a staging environment, or a single CLI tool you want to ship, they're massive overkill.

The lightweight answer is to commit an encrypted blob to the repo and share a single passphrase out of band. This is what git-crypt, sops, blackbox, ansible-vault, and Laravel 9's php artisan env:encrypt all do, just with different file formats and different crypto choices.

Laravel's implementation is actually pretty good — but its output is tied to Laravel's APP_KEY format and its own cipher enum. If your monorepo has a Node service, a Python worker, and a plain PHP API, you can't share one encrypted .env across them. You end up hand-rolling something, usually badly.

I wanted to see what it would take to build a clean, portable version in PHP 8.2 using only the standard library: openssl_encrypt, openssl_decrypt, openssl_pbkdf2, random_bytes, json_encode. The result is env-encrypt, about 400 lines of PHP across four pure modules. 46 PHPUnit tests. No Composer runtime deps.

More interesting than the CLI itself, though, is the list of choices you have to make when you design one of these things. Get any of them wrong and you've built something that looks secure but isn't. Let me walk through each one.

Design choice 1: the envelope format

The on-disk format is a single JSON file:

{
  "version": 1,
  "kdf": "pbkdf2",
  "hash": "sha256",
  "iterations": 310000,
  "algorithm": "aes-256-gcm",
  "salt": "67UE1u6pIyricCbDBKPVnA==",
  "nonce": "sXjAQI7CP7lzU9n4",
  "ciphertext": "I0kWzhfVf1GFP1uI9rOQHt...",
  "tag": "UgsRAtqzHdKMHYnxn/GbrA=="
}
Enter fullscreen mode Exit fullscreen mode

This is sometimes called an envelope because the actual ciphertext is wrapped in self-describing metadata. Every parameter the decrypter needs to authenticate and decrypt is right there in the file.

You could argue this is wasteful — "why not hard-code the KDF and algorithm in the decoder?" — and some tools do exactly that. Ansible Vault, for example, uses a binary-ish custom header that's effectively pinned to one algorithm. It works, right up until the day you need to migrate.

The self-describing approach is how everyone who's been burned ends up designing these. Age does it. JWE does it. The NIST recommendations explicitly call for algorithm agility. The moment PBKDF2 is officially deprecated in favour of Argon2id (which is inevitable), you want a format that lets you encrypt new files under Argon2id while still decrypting the old PBKDF2 files. A version field plus a kdf field gives you that.

The gotcha: the metadata fields are not authenticated. An attacker who can modify the envelope can bump iterations from 310,000 to 100,000,000 and cause your decrypt to take an hour. If you care about that specific DoS, you need to either sign the whole envelope or pin the expected iterations in code. For .env files in git, I think it's an acceptable gap — if someone can modify your repo contents, you have worse problems.

Design choice 2: PBKDF2 and why 310,000 iterations

People have strong opinions about KDFs. Let me walk through the tree.

You need a password-based KDF because humans type passphrases, not 256-bit keys. You could not use one and just sha256(passphrase) — and if you do, attackers with a consumer GPU will try a trillion guesses per second against your envelope. A KDF's whole job is to make each guess slow enough that brute-force is infeasible.

The three serious contenders today are:

  1. PBKDF2 — old, ubiquitous, available in every language's stdlib, CPU-bound. NIST-approved.
  2. bcrypt — also old, CPU-bound, capped at 72-byte passphrases, no explicit parallelism control.
  3. Argon2id — won the Password Hashing Competition in 2015, memory-hard (GPU-resistant), tunable parallelism. The modern best answer.

Argon2id is better. I didn't use it. The reason is dead practical: PHP's Argon2 support depends on libsodium or the argon2 extension, and the availability of those in the wild is patchy enough that "stdlib only" would become a lie. openssl_pbkdf2 ships with every PHP 8.2 install. For a tool whose selling point is "one small Docker image, no external deps," that matters.

PBKDF2 at 310,000 iterations is not broken. It's slow. On my laptop each guess takes ~100 ms of pure CPU, which means an attacker with a GPU rig gets maybe 10,000x that, i.e. around a million guesses per second. Against a 24-character random passphrase, that's still infeasible for centuries. Against correctpassword123, you're dead in a minute — and the tool can't save you from that. Argon2id can't save you from that either.

The 310,000 number itself comes from OWASP's 2023 password storage guidance, which says PBKDF2-HMAC-SHA256 should use ≥600,000 iterations for new designs, but 310,000 is the floor. I picked the floor because each env-encrypt decrypt already takes noticeable wall time at 310k (about 120 ms cold on an M2), and going to 600k makes the UX worse without a meaningful threat-model change for the shared-team-passphrase use case.

Here's the derivation:

public static function deriveKey(string $passphrase, string $salt, int $iterations): string
{
    $key = openssl_pbkdf2(
        $passphrase,
        $salt,
        Envelope::KEY_BYTES,   // 32 bytes → AES-256
        $iterations,
        Envelope::HASH         // 'sha256'
    );
    if ($key === false || strlen($key) !== Envelope::KEY_BYTES) {
        throw new CryptoException('openssl_pbkdf2 failed');
    }
    return $key;
}
Enter fullscreen mode Exit fullscreen mode

Boring, which is exactly what you want.

Design choice 3: AES-256-GCM, not AES-CBC + HMAC

Every homegrown encryption tool I've ever reviewed gets this bit wrong.

The tempting thing to do is AES-256-CBC with a separate HMAC for authentication. It works. It's called encrypt-then-MAC and it's been the right answer for two decades. But it requires you to:

  • Derive two keys from the passphrase (one for encryption, one for MAC)
  • Pick the right order (MAC over ciphertext, never over plaintext)
  • Verify the MAC in constant time before decrypting
  • Handle padding errors without leaking whether it was a padding error or a MAC error (Vaudenay-style padding oracle attacks)

Every one of those is a way to build something subtly broken. AES-256-GCM fuses encryption and authentication into one primitive. You pass it a key, a nonce, and a plaintext, and you get back a ciphertext plus a 16-byte authentication tag. On decrypt, if the tag doesn't match, openssl_decrypt refuses to return the plaintext at all. One primitive, one key, one failure mode.

The encrypt path is literally this:

$salt = random_bytes(Envelope::SALT_BYTES);    // 16
$nonce = random_bytes(Envelope::NONCE_BYTES);  // 12
$key = self::deriveKey($passphrase, $salt, $iter);

$tag = '';
$ciphertext = openssl_encrypt(
    $plaintext,
    Envelope::ALGORITHM,   // 'aes-256-gcm'
    $key,
    OPENSSL_RAW_DATA,
    $nonce,
    $tag,                  // populated by openssl
    '',                    // no additional authenticated data
    Envelope::TAG_BYTES
);

self::wipe($key);

if ($ciphertext === false) {
    throw new CryptoException('openssl_encrypt failed');
}
Enter fullscreen mode Exit fullscreen mode

random_bytes throws if the OS RNG fails, so you can't accidentally generate a weak nonce. openssl_encrypt fills in $tag by reference. The key is wiped as soon as we're done with it (as much as you can wipe anything in a language with immutable strings — more on that below).

Design choice 4: nonce uniqueness is not optional

Here is the part that scares me about every roll-your-own GCM implementation.

If you ever encrypt two different plaintexts under the same (key, nonce) pair, GCM is catastrophically broken. Not weakened, not reduced by a few bits — broken in the "the attacker recovers the authentication key and can forge arbitrary messages" sense. Some papers call this a forbidden attack.

This is why the envelope has a fresh 12-byte nonce per encryption. Even if you re-encrypt the same .env with the same passphrase, you get a fresh salt (so a fresh key) and a fresh nonce. There's no way to accidentally reuse them.

You could be tempted to derive the nonce from a counter, or to skip storing it and derive it from the passphrase. Don't. A counter-based nonce is fine for a single process that owns all its state, but it is not fine for a CLI that two developers might run in parallel on the same file. Random 96-bit nonces have a birthday-bound collision probability of around 2⁻⁴⁸ after 2²⁴ encryptions, which is fine for tool usage; deterministic nonces are a trap.

There's one test I wrote specifically to demonstrate this, with hard-coded salt and nonce values:

public function testDeterministicOutputWithPinnedSaltAndNonce(): void
{
    $salt = str_repeat("\x01", 16);
    $nonce = str_repeat("\x02", 12);
    $a = Crypto::encrypt('hello', 'pass', 1000, $salt, $nonce);
    $b = Crypto::encrypt('hello', 'pass', 1000, $salt, $nonce);
    // Same salt + nonce + key + plaintext → same ciphertext.
    $this->assertSame($a->ciphertext, $b->ciphertext);
    $this->assertSame($a->tag, $b->tag);
}
Enter fullscreen mode Exit fullscreen mode

This test exists not because the property is useful (it isn't) but because it's a pinned reminder of the one thing you must never do in production code. The $saltOverride and $nonceOverride parameters only exist for that test; in every real call path they come from random_bytes.

Design choice 5: constant-time failure on decrypt

Here's the decrypt path:

$plaintext = @openssl_decrypt(
    $envelope->ciphertext,
    $envelope->algorithm,
    $key,
    OPENSSL_RAW_DATA,
    $envelope->nonce,
    $envelope->tag,
    ''
);

self::wipe($key);

while (openssl_error_string() !== false) {
    // drain the openssl error queue — don't surface these strings
}

if ($plaintext === false) {
    throw new DecryptException('authentication failed: wrong passphrase or corrupted envelope');
}
Enter fullscreen mode Exit fullscreen mode

Two things worth noticing. First: the error is generic. It does not say "wrong passphrase" vs "tampered ciphertext" vs "wrong tag." That's deliberate. An attacker feeding you crafted envelopes should not be able to tell the difference between these cases, because it would give them an oracle. The whole point of AES-GCM is that these three failure modes are indistinguishable — and your error message shouldn't un-distinguish them.

Second: we explicitly drain openssl_error_string(). If you don't, the next OpenSSL call in the same process can see leftover error strings that hint at what went wrong, and unlucky debug logging can leak them. Belt and braces.

Tradeoffs and the things this tool won't do

Three honest non-goals I put in the README:

No Argon2id. Covered above — PHP's support is patchy, and keeping the stdlib-only promise was worth more than the memory-hardness upgrade for this use case. The envelope version field leaves the door open.

No metadata authentication. The outer JSON fields (iterations, kdf, algorithm) are not signed. This creates a small attack: flip iterations from 310,000 to 100,000,000 and you have a slow-decrypt DoS. Fixing it would require signing the envelope with a second key derived from the passphrase — doable, but another layer to get wrong. For .env-in-git, if your attacker can modify repo contents, you have worse problems already.

Best-effort secret wiping, not a guarantee. PHP strings are immutable, and the VM is allowed to copy them at will. Crypto::wipe() overwrites the underlying buffer and falls through to sodium_memzero if available, but a motivated local memory attacker will win. If that's your threat model, you need a process boundary or a hardware enclave, not a PHP CLI.

Rotate isn't atomic. rotate reads, decrypts, re-encrypts, writes. The write happens last and replaces the original file in place. If the process is killed after the read but before the write, your original envelope is intact. If it's killed mid-write, well, that's why we have git.

Try it in 30 seconds

docker build -t env-encrypt https://github.com/sen-ltd/env-encrypt.git

cat > .env <<'EOT'
DB_HOST=localhost
DB_PASS=my_production_password
API_KEY=sk_live_abc123
EOT

# Encrypt — safe to commit the .enc file
docker run --rm -v "$PWD:/work" env-encrypt encrypt /work/.env \
    --key "team-shared-passphrase" --out /work/.env.enc

# Look at what's inside without decrypting
docker run --rm -v "$PWD:/work" env-encrypt inspect /work/.env.enc

# Decrypt
docker run --rm -v "$PWD:/work" env-encrypt decrypt /work/.env.enc \
    --key "team-shared-passphrase"

# Rotate the passphrase without plaintext hitting disk
docker run --rm -v "$PWD:/work" env-encrypt rotate /work/.env.enc \
    --old-key "team-shared-passphrase" --new-key "new-team-passphrase"
Enter fullscreen mode Exit fullscreen mode

Takeaways

If you take one thing away from this, it should be: the envelope format is the interesting part. Encryption tools look like they're about crypto, but most of the difficulty is in the metadata shape, the algorithm-agility story, the failure-mode design, and the non-goals you're willing to own up to in a README.

The crypto itself is 160 lines of PHP. The tests for the crypto plus all the ways it can go wrong are 400 lines. The thinking about what not to ship — Argon2id, metadata authentication, plaintext wiping, atomic rotate — is most of the work and all of the value.

If you want the full source, the tests, or just to steal the Dockerfile pattern for your own stdlib-only PHP tool: sen-ltd/env-encrypt on GitHub.

Top comments (0)