How your secrets stay yours.
Every secret you store is encrypted twice — once with a random key unique to that secret, and again with a master key the database never sees in plaintext. The longer version, with the actual code, is below.
The 90-second version
locked.sh uses envelope encryption with AES-256-GCM — the same construction that AWS KMS, Google Cloud KMS, and HashiCorp Vault use. It works in three layers:
- A new data encryption key (DEK) is generated for every secret you save — 32 random bytes from the OS CSPRNG.
- Your secret value is encrypted with that DEK using AES-256-GCM — authenticated encryption, so tampering is detectable.
- The DEK itself is then encrypted with the server's master key. Only the ciphertext of the DEK is written to disk.
The plaintext value, the plaintext DEK, and the master key are never written to disk together. To decrypt one secret, you need the master key (in process memory, loaded from env var) plus the row.
Why two keys instead of one?
Because key rotation gets easier. If you ever need to rotate the master key, you re-encrypt the (small) DEKs, not the (potentially large) secret values. And if a single DEK is somehow leaked, only that one secret is exposed — not the whole database.
What happens when you save a secret
POST /environments/:id/secrets { "key": "API_KEY", "value": "sk-…" }
┌───────────────────────────────────────────────────────────────┐
│ app/services/envelope_crypto.rb │
│ │
│ dek = SecureRandom.random_bytes(32) # new DEK │
│ nonce = SecureRandom.random_bytes(12) │
│ cipher = AES-256-GCM(dek, nonce, plaintext) # encrypt │
│ │
│ dek_nonce = SecureRandom.random_bytes(12) │
│ dek_ct = AES-256-GCM(master_key, dek_nonce, dek) │
│ │
│ store(ciphertext = cipher, │
│ encrypted_dek = dek_nonce || dek_ct, │
│ nonce = nonce) │
└───────────────────────────────────────────────────────────────┘
Three values get persisted to Postgres as BYTEA columns: encrypted_value, encrypted_dek, and nonce.
Nothing else. The plaintext value exists for the duration of the request and is then garbage-collected.
What happens when you read one back
GET /environments/:id/secrets/export
Authorize: bearer JWT → load user, check RBAC, audit-log access
│
▼
Load row from DB ┬─→ encrypted_value
├─→ encrypted_dek = dek_nonce(12) || dek_ct
└─→ nonce(12)
│
▼
dek = AES-256-GCM-decrypt(master_key, dek_ct, dek_nonce)
plaintext = AES-256-GCM-decrypt(dek, encrypted_value, nonce)
│
▼
return plaintext
If anyone has tampered with the ciphertext or the encrypted DEK on disk, the GCM authentication tag check fails and decryption raises — you get an error, not bogus plaintext. Every reveal and export is logged in the audit table with the actor, IP, and timestamp.
The master key
The master key is a 32-byte value loaded from the MASTER_KEY environment variable at process startup. It is never logged, never stored in the database, and never returned by any HTTP endpoint.
- On a managed deployment, it lives in a Kamal secret loaded from your
.envfile at deploy time. - If you self-host, you generate one with
openssl rand -base64 32and keep it in your own secret store. - Rotating it requires re-encrypting every DEK; the codebase has a tested helper for this.
Other things we do
Passwords
Hashed with bcrypt (cost 12). Never reversible.
Brute-force lockout
5 wrong passwords → 15-minute account lock.
Rate limiting
Per-IP and per-credential throttles via Rack::Attack — 429 with Retry-After.
CSP & HSTS
default-src 'self', no inline scripts, HSTS 1 year, preload-eligible.
Short-lived JWTs
CLI access tokens expire in 15 min. Refresh tokens rotate and are revocable.
CLI never writes to disk
locked run -- cmd injects secrets as env vars into the subprocess.
What we can and can't see
| Data | Visible to operators? |
|---|---|
| Secret values | No — encrypted at rest |
| Secret keys | Yes — keys are plaintext (used for indexing) |
| Project / environment names | Yes |
| Audit log | Yes — and you can read your own |
| Passwords | No — bcrypt hashes only |
| Master key | No — env var only, never persisted |
Be honest about this: on a managed deployment, a sufficiently
privileged operator with shell access to the server can in principle dump the
MASTER_KEY env var and the database, then decrypt your secrets.
That's why we publish the source — you can self-host and never give anyone else that level of access.
How to verify it yourself
Don't trust this page — read the code. The whole stack is MIT-licensed.
- The encryption implementation is one file: app/services/envelope_crypto.rb — 90 lines.
- The tests: envelope_crypto_test.rb — round-trip, distinct ciphertexts, tamper detection, key generation.
- Run the test suite locally:
bin/rails test. - Audit the rest of the security surface:
bin/brakemanandbin/bundler-audit.
Known caveats
- Secret keys (the variable names) are not encrypted — they're used for uniqueness constraints and indexed lookups.
- Encryption happens on the server. We don't (yet) offer client-side encryption with per-user keys.
- No external audit yet. We'd love one — open an issue if you're a security firm interested in pro-bono review.
Report a vulnerability
Email security@locked.sh with a description and reproduction steps. We aim to respond within 48 hours. Please don't open a public GitHub issue for security bugs.