Help me find the holes in my secrets vault before it ships
I'm building Eling, one memory for all your AI tools. You save a decision in Claude Code on your laptop, and Cursor on your desktop knows it the next morning. The newest feature hands a coding session from one machine t
I'm building Eling, one memory for all your AI tools. You save a decision in Claude Code on your laptop, and Cursor on your desktop knows it the next morning.
The newest feature hands a coding session from one machine to another. Git brings the code across. Your .env file stays behind, and it holds the things you least want in an AI chat: database URLs, API keys, webhook secrets.
So I built a small vault. You keep your project's values in it, and a CLI writes the .env file on the new machine. Your AI tools never see a value.
The vault stays switched off in production until someone other than me reviews its threat model. I wrote the model, so I'm the person least likely to spot what it misses. This post walks through all of it in plain language. If you have ten minutes and opinions about secrets, I'd love your eyes on it.
What it looks like for a developer
npx eling env sync # keep .env and the vault the same, both ways
npx eling env sync --all # every project this machine has synced
npx eling env pull # write .env from the vault
npx eling env push # store this .env in the vault
npx eling env list # which names have values
The first command signs you in. It opens a link and shows a code, and you approve it in the browser with a passkey. The CLI writes .env readable by you only, adds it to .gitignore, and prints only names and counts.
env sync works in both directions. When a value changes on one machine, the next sync stores it in the vault, and your other machines pick it up on their next sync. A name that is new in .env goes to the vault only after you tick it at a terminal. Hooks and CI never add one.
How the encryption works
It's envelope encryption with three layers:
Master key a Cloudflare Worker secret, with one offline copy
│ wraps
▼
Data key one random key per user, stored wrapped in Postgres
│ encrypts
▼
Your values AES-256-GCM, one ciphertext per value
The master key never touches the database, the backups, the logs or the repo.
Every ciphertext carries additional authenticated data that names its user, project, environment and variable. A ciphertext moved to another row or another user fails to decrypt. A flipped byte fails GCM authentication, and the pull errors out instead of returning a wrong value. Each data key is bound to its user's id the same way, so a wrapped key copied onto another account won't open.
Sync needs to know which values changed without reading all of them. Each stored value has a fingerprint: HMAC-SHA256 under a key derived with HKDF from the user's data key. The same value in the same place gives the same print. Without the data key, a print tells you nothing about the value. Only the CLI receives prints, and the CLI can read the values anyway.
Who can read a value
You, in the web app. You can add, replace and remove values. You see names, dates and a log of every pull. No endpoint returns a value, which is how GitHub's repository secrets behave too.
Your AI tools. They connect over MCP with tokens scoped to memories and account details. Vault scopes are never issued to them. The token check drops any scope it doesn't recognise, and no MCP tool touches the vault.
The eling CLI. It is the only reader. It gets its token through an OAuth device flow, and approving that flow needs a passkey sign-in from the last five minutes.
Me, as the operator. Reading a value takes a database dump plus the master key. Row-level security keeps every request to its own user's rows.
Threats I think are covered
- A database dump or backup leaks. Values and data keys are ciphertext, and the master key lives elsewhere.
- A query forgets its user filter. Row-level security on all three vault tables limits every query to the signed-in user's rows, and a test checks it.
- A value gets swapped between rows or users. The additional data makes it fail to decrypt.
- Someone tampers with a ciphertext. GCM authentication fails, and the pull fails with it.
- An AI tool asks for values. No MCP tool exists for it, and no MCP token can carry a vault scope. The CLI's API accepts only the CLI's own client.
- Secrets leak into memories. A secret guard refuses keys and tokens when an AI saves, updates or attaches a memory.
- Logs keep a value. Telemetry records the tool, latency, result class and user id. Vault events record names and counts.
-
Someone copies
sync.json. It holds keyed hashes only, and the.envit describes sits next to it on the same disk. - The master key is compromised. Set a new key and keep the old one as the previous key. Each data key is wrapped again the next time it's used.
- The master key is lost. The offline copy is the backup.
Gaps I know about
This is the part I most want opinions on.
-
Prompt injection plus
env pull. A malicious README could talk an AI agent into runningeling env pulland then reading.env. The CLI prints no values. The agent can still open the file it writes, because it runs as you.vercel env pullsits in the same spot, and I don't have a better answer yet. -
A stolen web session can write. It can overwrite values with wrong ones, or add a name like
NODE_OPTIONSthat the next sync writes into.envon every machine. It has no way to read a value. - A fresh session can add its own passkey. A session younger than ten minutes can register a passkey and use it to approve a CLI login. An email when a passkey is added isn't built yet.
- Device code phishing. Someone sends you a code they started, and you approve it. The approval page shows the code and asks you to approve only if your terminal shows the same one. Codes expire after ten minutes.
- A stolen CLI token works until you notice. Refresh tokens rotate, every pull is logged, and you can disconnect the CLI from your account page.
- A dump and the master key leak together. Then every value is readable.
- Idle users after a key rotation. Data keys are rewrapped when they're used. A job that rewraps keys for people who haven't come back isn't built, so their values stay readable with the old master key until they do.
- The secret guard works from patterns. It misses passwords people choose and secret formats it doesn't know.
What the pen test found
I ran a pen test against staging. It covered the vault's web API, the CLI's API, the device approval page, passkey registration and the token check. It found two low issues, and both are fixed:
-
eling env pullwould write through a symlinked.env. A link planted in a cloned repo could send your values wherever it pointed. The CLI now refuses symlinks. -
--urlaccepted plainhttp://for any host, which would send the token and values in clear text. It now takes https, or http for localhost only.
Seven smaller notes were accepted in writing. One example: the device approval page has no Content-Security-Policy yet. It does send X-Frame-Options: DENY and escapes everything it reflects.
What I'd like from you
Any of these helps:
- Tell me which gap above you'd block a launch on.
- Tell me what's missing from the list.
- Poke at the design. Is a master key in a Worker secret reasonable at this size, or would you insist on a KMS? Is a passkey-approved device flow the right gate for the only reader?
- If you want the original document with every table, email me and I'll send it.
Leave a comment, or email [email protected] for anything you'd rather not post in public. If you give it a proper review, I'd like to name you as the reviewer in the threat model, with your permission.
Eling is in invite-only beta at eling.io. This review is one of the last steps before the vault goes live.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.