Security model

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:

  1. A new data encryption key (DEK) is generated for every secret you save — 32 random bytes from the OS CSPRNG.
  2. Your secret value is encrypted with that DEK using AES-256-GCM — authenticated encryption, so tampering is detectable.
  3. 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.

key

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 .env file at deploy time.
  • If you self-host, you generate one with openssl rand -base64 32 and 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

lock

Passwords

Hashed with bcrypt (cost 12). Never reversible.

block

Brute-force lockout

5 wrong passwords → 15-minute account lock.

speed

Rate limiting

Per-IP and per-credential throttles via Rack::Attack — 429 with Retry-After.

policy

CSP & HSTS

default-src 'self', no inline scripts, HSTS 1 year, preload-eligible.

schedule

Short-lived JWTs

CLI access tokens expire in 15 min. Refresh tokens rotate and are revocable.

memory

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 namesYes
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/brakeman and bin/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.
mail

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.