All posts
6 min readDavid Nussio

Sharing secrets with a teammate, the GPG way

A new teammate needs the staging database password and a Stripe test key. The fastest way to hand them over is to paste them into a chat message. That message is then stored, synced to every device both of you are signed in on, searchable, and kept for as long as your workspace keeps history, which is usually much longer than the onboarding.

The alternative people reach for next is a password manager with shared vaults, and for a team that shares secrets every week it's the right answer. But sometimes it's just two people, once, with no shared account. For that case envsec has envsec share, which encrypts a context with GPG for one recipient. This post explains exactly what it does, how the receiver imports the result, and where GPG falls short.

What envsec share does

envsec share takes one context and one recipient. It reads every secret of the context from the OS credential store, builds a plaintext payload in memory, and pipes it into gpg on standard input. This is the call, from packages/cli/src/cli/share.ts:

ts
execFileSync("gpg", [
  "--batch", "--yes",
  "--trust-model", "always",
  "--encrypt", "--armor",
  "--recipient", recipient,
], { encoding: "utf-8", input: plaintext });

A few details follow from that call:

The payload inside is the same format envsec env-file writes: one KEY="value" line per secret, the key upper-cased with dots turned into underscores, and backslashes, double quotes and newlines escaped:

bash
DB_PASSWORD="p@ss\"w0rd"
STRIPE_SECRET_KEY="sk_test_123"
TLS_CERT="-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----"

With the global --json flag the payload is a JSON object instead, { context, secrets: [{ key, value }] }, with the original key names.

Why GPG

I didn't want envsec to implement any cryptography of its own, and I wanted the receiver to be able to decrypt the file without envsec installed. GPG fits both:

A complete example

Alice has the myapp.dev secrets; Bob needs them. Both have gpg and envsec installed. Bob starts, because Alice needs his public key:

Terminal
# Bob: create a key pair (skip this if you already have one)
$gpg --quick-gen-key "Bob Example <bob@example.com>" default default 1y
# Export the public key and send it to Alice; any channel is fine
$gpg --armor --export bob@example.com > bob.pub.asc
# Show the fingerprint, to read out to Alice on a call
$gpg --fingerprint bob@example.com

--quick-gen-key asks for a passphrase to protect the private key. The fingerprint check is the step people skip, and it is the one that matters: it's how Alice knows the key she received is Bob's and not someone else's. Alice imports the key, compares fingerprints, and encrypts:

Terminal
# Alice: import Bob's key and compare the fingerprint with what he read out
$gpg --import bob.pub.asc
$gpg --fingerprint bob@example.com
# Encrypt the whole context to that exact key
$envsec -c myapp.dev share --encrypt-to 1DB04155CDCADCE4B08ED3035648FD56E16C4BB8 -o myapp-dev.asc
text
◈ Encrypted 3 secrets from "myapp.dev" for 1DB04155CDCADCE4B08ED3035648FD56E16C4BB8 → myapp-dev.asc

She sends myapp-dev.asc however she likes; it's encrypted. Bob imports it with envsec load. There is no dedicated import command: load reads the .env format that share produces. It takes a file path (-i, default .env) rather than standard input, so on macOS and Linux the pipe goes through /dev/stdin and the decrypted text never lands on disk:

Terminal
# Bob: decrypt straight into envsec, no plaintext file
$gpg --quiet --decrypt myapp-dev.asc | envsec -c myapp.dev load -i /dev/stdin
# Done with the file
$rm myapp-dev.asc
text
✔ Done: 3 added, 0 overwritten, 0 skipped

Process substitution works as well: -i <(gpg -q -d …) in bash and zsh, -i (gpg -q -d … | psub --fifo) in fish. Note the --fifo: plain psub writes the output to a temporary file. On Windows there is no /dev/stdin, so you would decrypt to a file and delete it after the import. If myapp.dev already has one of the keys, load skips it and tells you; add --force to overwrite.

What doesn't survive the trip

share and load are two separate commands joined by the .env format, and that format is lossy for envsec keys:

The --json payload keeps the exact key names, but there is no command that imports it today.

The downsides of GPG

The user experience

Keyrings, trust models, subkeys, the agent, pinentry, expiry dates: GPG asks a lot of people who only want to send a password. The example above is the short path, and it still takes six commands across two people before anything is encrypted.

Key management

If Bob loses his private key or forgets the passphrase, the file is unreadable and Alice has to share again. If his key expires or is revoked, Alice needs the updated key before the next share. None of this is hard, but all of it is manual.

No forward secrecy

The file is encrypted to Bob's long-term key. Anyone who keeps a copy of it, in a mailbox, a chat upload or a backup, and later gets Bob's private key and passphrase can decrypt it. That's why the example ends with rm, and why the file should travel through something you can delete it from.

Metadata

The content is encrypted; the envelope is not. The message carries the ID of the key it was encrypted to (gpg --list-packets shows it), its size hints at how much is inside, and the channel you send it through records who sent what to whom, and when.

No proof of who sent it

envsec encrypts but doesn't sign. Anyone with Bob's public key can produce a file that looks the same, and Bob can't tell from the file alone that Alice made it. If that matters, Alice can sign the encrypted file separately:

Terminal
# Alice: sign the encrypted file with her own key
$gpg --armor --detach-sign myapp-dev.asc
# Bob: check the signature before importing (needs Alice's public key)
$gpg --verify myapp-dev.asc.asc myapp-dev.asc

Trusting the wrong key

envsec passes --trust-model always, so gpg encrypts to whatever key matches the recipient without checking whether anyone certified it. Without that flag, gpg in batch mode refuses a freshly imported key that nobody you trust has certified, with "There is no assurance this key belongs to the named user". That would stop most first-time users. The cost is that the fingerprint check is entirely up to you. Pass the full fingerprint, not an email: when I tried an email that wasn't in my keyring, gpg went looking for a key over the network.

The alternatives

In short

  1. The receiver sends a public key and reads out its fingerprint on a separate channel.
  2. The sender runs envsec -c ctx share --encrypt-to FINGERPRINT -o file.asc.
  3. The receiver runs gpg -q -d file.asc | envsec -c ctx load -i /dev/stdin and deletes the file.

It's a handover, not a sync: nothing updates when the value changes, and nothing tells you who still has a copy. For two people and one context, that's often all you need. For anything recurring, use a shared vault. The docs list every share and load flag, and What envsec does not protect you from covers the rest of the threat model.