Skip to content

Env Manager: a secrets store that cannot read its own secrets

A security review pointed out that our secrets manager trusted its own administrators. The fix was zero-knowledge end-to-end encryption — and the honest part of the design is the list of three places where the server still sees plaintext, and why we accepted each one.

Patrick Lehmann
6 min read
The server holds every value, serves every value, authorises every read — and cannot tell one row from another.

Env Manager started as the least ambitious application in this series. Workspaces, projects, environments, variables; a Rails app, a React front end, a small Thor CLI that writes a .env file into a checkout and syncs variables to GitLab CI. Self-hosted Doppler, roughly. The interesting problems were supposed to be in the role ladder and the CLI ergonomics.

Then a security review asked one question: who can read a variable?

The answer at the time was Active Record Encryption with an application-wide key. So: anyone holding that key. Which meant the application server, anything that could read the application server’s environment, any backup of it — and me. Not “me, if I abuse my access”. Me, structurally, by design, with no mechanism that could have told anyone.

A secrets manager whose threat model does not include its own operator is a filing cabinet with a good login page.

Zero-knowledge, and what that actually constrains

The redesign moved every cryptographic operation to the client — libsodium-wasm in the browser, RbNaCl in the CLI. The server stores opaque blobs and public keys, enforces authorisation on who may fetch or store which blob, and coordinates queues. It never receives a passphrase, never derives a key-encryption key, never wraps or unwraps a key, and never decrypts a value.

That is the invariant, and it is written as the first thing in the specification, because it is the only sentence that has to be checked against every future change:

The key hierarchy that falls out of it is unremarkable once you have seen one:

passphrase ──Argon2id──▶ KEK ──secretbox──▶ personal X25519 private key   [blob on server]
personal private key ──seal_open──▶ environment private key (per generation)
environment private key ──seal_open──▶ variable value

One property of that chain does more work than the rest: writing a variable needs only a public key. A value is sealed to the environment’s public key, so creating or updating a variable requires no unlock at all. Only reading needs key material.

That is not a cryptographic subtlety, it is the thing that makes the product usable. It means CI can write. It means a teammate can add a variable without ceremony. And it means the server itself can seal — which is what makes importing from GitLab possible at all.

The three places the server still sees plaintext

This is the section I would want to read first in someone else’s write-up, and the one most likely to be missing.

Leak Why it is accepted
Variable names and flags are plaintext A database dump reveals which services we integrate — STRIPE_SECRET_KEY — never the credential. Buys server-side search, browse-without-unlock, GitLab conflict detection by key, and readable audit references. The same trade SOPS makes.
Push proxy: plaintext in server RAM for one request GitLab push is user-initiated. The client decrypts, POSTs the bundle, the server forwards it via the bot token and discards it. The values become maintainer-readable inside GitLab anyway; the browser cannot call the GitLab API directly, and the bot token has to stay server-side.
Pull import: inbound plaintext before sealing Values imported from GitLab pass through server RAM on their way to being sealed. They originate in GitLab’s trust domain; we never persist them unsealed.

Two more are worth naming even though they are not really “the server”. A weak passphrase plus a database leak is crackable offline — mitigated with a zxcvbn score floor and Argon2id at moderate cost, not eliminated. And every web-delivered end-to-end product, this one included, shares the caveat that a compromised server could ship malicious JavaScript that exfiltrates keys at unlock time. Bitwarden has it. EnvKey’s web client has it. The locally installed CLI is the higher-assurance path, and saying so is more useful than pretending otherwise.

The asymmetry in the GitLab sync

The push and pull paths look like they should mirror each other. They cannot, and the reason is a direct consequence of the invariant.

Pull is server-side: the server fetches from GitLab with the bot token, and can seal each value to the environment’s public key without holding any private key. It is the write-needs-only-a-public-key property doing exactly what it was designed for.

Push cannot be server-side, because producing the plaintext for GitLab requires decryption, and the server cannot decrypt. So push is client-initiated: the client unlocks, decrypts the bundle, validates GitLab’s masking rules locally so the user gets an actionable warning instead of a rejection, and posts the bundle to a proxy endpoint that forwards and discards it.

Neither direction is a distributed transaction, and the specification says so rather than implying it:

  • Paginated reads give no project-wide snapshot, so remote variables can change during a fetch.
  • Push issues one remote request per variable; GitLab writes that completed before a later error cannot be rolled back locally.
  • Retries therefore reconcile at key level. The local import transaction is atomic; the GitLab side is not.

Revocation, and refusing to perform security

The part of the design I am most attached to is the part that does less than you would expect.

Environment keys are generational. Removing a member deletes their wrapped copies across every generation, revokes their tokens, and queues a rotation; the next unlocked maintainer mints generation g+1, wrapped to the remaining recipients. Future versions seal to the new generation.

Old versions are not re-encrypted. That is deliberate. A revoked member could read those values while they were authorised, so re-sealing ciphertext they had every opportunity to copy protects nobody. It is a progress bar that produces a feeling.

So the UI does the only thing that actually helps: it names the environments, lists the variables, and tells you to rotate the underlying credentials at their sources. That prompt is a first-class step in the specification, not a footnote — because it is the only true mitigation, and a system that quietly performs the fake one makes it less likely you will do the real one.

The cheapest possible time to do this

One honest note about why this redesign was affordable. The application had been deployed exactly once, with no production data. The schema was rebuilt greenfield: no value migration machinery exists, because none was needed.

Six months and one real customer later, the same decision would have meant a re-encryption path, a dual-read period, a cutover, and a rollback plan for a system whose whole point is that the server cannot read the data it would be migrating.

The lesson is not “design for zero-knowledge from day one” — plenty of products correctly never need it. It is that the cost curve for a cryptographic architecture is much steeper than for most refactors, and the review that triggers it is far cheaper to invite early than to receive late.

What I would keep

  • Write the invariant as one testable sentence and check every endpoint against it. “The server never touches a private key” catches design errors that a threat-model table does not.
  • Publish the residual risks next to the guarantees. A zero-knowledge product with no accepted-risk list is a marketing page.
  • Let the asymmetry be asymmetric. Push and pull are shaped differently because the crypto makes them differently shaped; forcing symmetry would have meant giving the server a key.
  • Refuse the security theatre and prompt for the real remedy instead. Re-encrypting history on revocation looks like diligence and is not.

What connects this to the rest of the series is smaller than it looked when I started writing them down: each post turned on a single sentence that got written before the code, and each one is honest about the thing it did not close.

Patrick Lehmann

Architecture & Governance Lead

Squibble GmbH

Has spent twenty years bringing structure to IT landscapes that grew rather than were designed — as architect, developer, and operator. Writes here about the systems actually running at Squibble and the decisions behind them.

Read more

Workbench: a delivery index that is allowed to be wrong

We built a multi-tenant view across several GitLab instances without becoming a second source of truth. The design rule that made it work: the local database is disposable, and reconciliation — not webhooks — is what makes it correct.

Patrick Lehmann
6 min read

Observability: the read model that acts

Workbench could drop its database and rebuild it. This one polls Graylog and files GitLab issues — and a side effect has no upstream. Why dry-run became a database column, why the dashboard has no delete button, and why the safety guard sits at the HTTP boundary rather than in configuration.

Patrick Lehmann
6 min read