Skip to content

feat(dev): share the local credentials via sops+age, encrypted in internal - #842

Merged
choraria merged 5 commits into
mainfrom
lane/sops-vault
Jul 31, 2026
Merged

feat(dev): share the local credentials via sops+age, encrypted in internal#842
choraria merged 5 commits into
mainfrom
lane/sops-vault

Conversation

@choraria

Copy link
Copy Markdown
Contributor

The last local-dev gap

A second machine could generate every generated value and had every local literal committed — but the 11 real third-party credentials (Google + GitHub OAuth pairs, Resend, Turnstile, Stripe test-mode) meant hunting five vendor dashboards.

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

Why encrypted, not a plain file in a private repo

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'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.

⚠️ 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 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_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. Only a byte-for-byte diff of .dev.vars before and after exposed it.

The shared set now excludes any name scoped local anywhere, with a test pinning it. Mode flags are excluded too — distributing OAUTH_MODE or EMAIL_MODE would push a degraded stack onto the next machine.

Test plan

Verified end to end against a throwaway vault (keys destroyed afterwards):

  • 0 plaintext-looking secrets in the ciphertext; 8 ENC[AES256_GCM] values (per-value, so diffs stay readable)
  • decryption with the wrong key is refused
  • no plaintext temp left behind — including on failure, which I confirmed by making it fail
  • pushpull leaves every .dev.vars byte-identical
  • pnpm lint — 936/936 (was 926; +10 new)

🤖 Generated with Claude Code

https://claude.ai/code/session_01BRmGUnxeYsQoG9c8BCZcae

…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
Comment thread scripts/dev-secrets-vault.mjs Fixed
choraria and others added 4 commits July 31, 2026 08:58
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
@choraria
choraria merged commit 4524cb1 into main Jul 31, 2026
29 checks passed
@choraria
choraria deleted the lane/sops-vault branch July 31, 2026 08:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants