feat(dev): share the local credentials via sops+age, encrypted in internal - #842
Merged
Conversation
…ernal The last gap in local-dev parity: a second machine could generate every `generated` value and had every `local` literal committed, but the 11 real third-party credentials — the Google and GitHub OAuth pairs, Resend, Turnstile, and the Stripe test-mode values — meant hunting five vendor dashboards. They now live encrypted in the private `internal` repo: pnpm dev:secrets:vault --init once ever: keypair, wrapped with a passphrase pnpm dev:secrets:vault --unlock once per machine: unwrap the key here pnpm dev:secrets:vault --push encrypt local credentials into internal pnpm dev:secrets:vault --pull decrypt into every app's .dev.vars ENCRYPTED rather than a plain file in a private repo, for a specific reason: RESEND_API_KEY is the SAME key production uses, GitHub secret scanning runs on this org with push protection on, and vendors participate in that programme — so committing a live key can get it revoked BY THE VENDOR. A production incident caused by a convenience commit. It is also what AGENTS.md names outright: secrets never in source or plaintext config. The age PUBLIC key is a recipient in internal/.sops.yaml. The PRIVATE key is wrapped with a passphrase and committed alongside it, so everything is in the one repo and the only thing carried between machines is a passphrase. That trade is stated in the docs rather than implied: repo read access yields both the ciphertext and the wrapped key, so the passphrase is doing all the work. --init and --unlock are INTERACTIVE. age reads the passphrase from the terminal — never from a flag, an env var, or this script — so it is never observable in a process list, a shell history, or a transcript. No secret VALUE is ever printed; only names. A ROUND-TRIP DIFF CAUGHT A REAL BUG that inspection would not have. STRIPE_METER_EVENT_NAME is scoped `local` for api (a literal the generator writes) and `external` for engine (a value from your own Stripe sandbox). Sharing it copied api's literal over engine's independent value — both are plausible strings, so nothing looked wrong. The shared set now excludes any name scoped `local` ANYWHERE, and a test pins it. Mode flags are excluded too: distributing OAUTH_MODE or EMAIL_MODE would push a DEGRADED stack onto the next machine. Verified end to end against a throwaway vault: 0 plaintext-looking secrets in the ciphertext, no plaintext temp left behind even on failure, wrong key refused, and push→pull leaves every .dev.vars byte-identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRmGUnxeYsQoG9c8BCZcae
Two review findings, both real, and the first is the serious one. 1) --push wrote the live credentials to a temp file INSIDE the internal repo and deleted it afterwards. That is precisely what this tool exists to prevent: a crash between write and delete, a failed unlink (whose exit status I ignored), or a `git add` running concurrently would leave live production credentials — including the Resend key the docs themselves call out as shared with prod — sitting next to the ciphertext meant to protect them. A temp file elsewhere would be better but still writes them out. sops now encrypts from STDIN, so the plaintext touches no filesystem at all. `--filename-override` is load-bearing: sops picks its creation rule from the file PATH, and /dev/stdin matches nothing, so without it sops refuses with "no matching creation rules found". 2) CodeQL flagged a HIGH check-then-use (TOCTOU): existsSync() followed by readFileSync()/writeFileSync(). Removed both windows — read first and catch, since a missing file throws anyway, which is the answer the check was asking for. The remaining existsSync calls are presence checks NOT followed by a read in this process (sops and age open those paths themselves), and are commented as such. This is the third time today I have written this pattern after fixing it twice. Reading before checking is the habit, not a special case. Re-verified end to end against a throwaway vault: only the ciphertext is ever created, 0 plaintext-looking secrets in it, and push→pull leaves every .dev.vars byte-identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRmGUnxeYsQoG9c8BCZcae
Both review findings were right, and the second is a real defect rather than a hardening nit. Push filters through sharedSecretNames(); pull applied whatever the vault happened to contain, as long as the app declared that name. Those are not equivalent, because THE CIPHERTEXT IS COMMITTED and therefore outlives the allowlist that produced it. A vault pushed before a name was reclassified still carries that name, so pull would re-apply it — reintroducing the exact STRIPE_METER_EVENT_NAME bug (api's generated literal overwriting engine's independent Stripe value) from a file, long after the code that caused it was fixed. Pull also treated "" as present, so a blank entry could silently blank a working credential; the only symptom is a provider vanishing from /login. pullSet() now applies the same three filters push does, and NAMES what it refuses rather than dropping it silently — a stale entry is something to clear with a fresh --push, not something to hide. The completion tally counts what was applied rather than what the file held, so an ignored entry can no longer read as delivered. Second finding: the "shared names" test asserted six names with a `>= 6` floor, while discovery actually returns 11. Every STRIPE_* name could have dropped out and the suite would still have been green — the tool reporting success while quietly sharing half of what it claims to. Pinned to the exact sorted list. Verified on the WIRED path, not just the helper: a throwaway vault poisoned with OAUTH_MODE, a stale STRIPE_METER_EVENT_NAME and a blank RESEND_API_KEY. Both poison entries were named and refused, the blank clobbered nothing, the allowlisted value still landed, and the live .dev.vars restored byte-identical afterwards. A unit test on pullSet alone would have passed even if cmdPull never called it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRmGUnxeYsQoG9c8BCZcae
The review is right, and this one is a real escalation THIS PR introduces. Before this change the prod Resend key sat only in a gitignored .dev.vars. After it, the key would be committed as ciphertext next to a passphrase-wrapped private key — so repo read access yields both halves and the passphrase becomes the only thing between a repo reader and a production send-as-webhook.co capability, attackable offline at leisure. AGENTS.md says production secrets live in a KMS; a git blob behind a human-memorable passphrase is not that. That trade is perfectly fine for what the vault is actually FOR — Stripe test-mode values, OAuth clients whose callbacks are localhost. Losing one of those costs a dev environment. It is not fine for a prod credential, and the difference is blast radius, not secrecy. So NOT_SHAREABLE excludes it in BOTH directions: push will not collect it, and pull will not apply it even from an older vault that predates this commit — because the ciphertext is committed and outlives the rule that produced it. Ten credentials still travel; Resend stays a documented manual step. The actual fix is a dev-scoped Resend key: same provider, same domain, same code path, so it is parity rather than a substitute, and then it joins the vault. I could not check whether one already exists — the Resend MCP's own credential is rejected as invalid — and minting a key in the founder's account is theirs to authorise, so I have written down what it needs and left it. Also corrected the docs, which had cited the shared-with-prod Resend key as the REASON to encrypt. It was really an argument for not putting it here at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRmGUnxeYsQoG9c8BCZcae
This was referenced Jul 31, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The last local-dev gap
A second machine could generate every
generatedvalue and had everylocalliteral committed — but the 11 real third-party credentials (Google + GitHub OAuth pairs, Resend, Turnstile, Stripe test-mode) meant hunting five vendor dashboards.Why encrypted, not a plain file in a private repo
RESEND_API_KEYis the same key production uses. GitHub secret scanning runs on this org with push protection on, and vendors participate in that programme — so committing a live key can get it revoked by the vendor. A production incident caused by a convenience commit. It's also what AGENTS.md names outright: secrets never in source or plaintext config.Everything in one repo
The age public key is a recipient in
internal/.sops.yaml. The private key is wrapped with a passphrase and committed alongside it, so the only thing carried between machines is a passphrase.--initand--unlockare interactive — age reads the passphrase from the terminal, never from a flag, an env var, or this script, so it never appears in a process list, shell history, or transcript. No secret value is ever printed; only names.A round-trip diff caught a real bug
STRIPE_METER_EVENT_NAMEis scopedlocalfor api (a literal the generator writes) andexternalfor engine (a value from your own Stripe sandbox). Sharing it copied api's literal over engine's independent value — both are plausible strings, so nothing looked wrong. Only a byte-for-byte diff of.dev.varsbefore and after exposed it.The shared set now excludes any name scoped
localanywhere, with a test pinning it. Mode flags are excluded too — distributingOAUTH_MODEorEMAIL_MODEwould push a degraded stack onto the next machine.Test plan
Verified end to end against a throwaway vault (keys destroyed afterwards):
ENC[AES256_GCM]values (per-value, so diffs stay readable)push→pullleaves every.dev.varsbyte-identicalpnpm lint— 936/936 (was 926; +10 new)🤖 Generated with Claude Code
https://claude.ai/code/session_01BRmGUnxeYsQoG9c8BCZcae