Keys and secrets
The master key that wraps every subject key, how to rotate it, and why a deployment credential is a different mechanism.
Two mechanisms that look similar and are not. A master key wraps stored data. A credential wraps nothing: it is a value an effect hands to somebody else.
The key store
One subject key per (subject_field, subject_value) pair, minted on first write, stored in
data/hekla.db wrapped under the master key, with the wrapping master’s id recorded on
each row.
The cipher is AES-SIV, and it is deterministic. It has to be, because a sealed value is also a tag and a tag has to be matchable. See Personal data for what that leaks and what it means for modelling.
| Variable | |
|---|---|
HEKLA_MASTER_KEY |
base64 of 32 bytes |
HEKLA_MASTER_KEY_PREVIOUS |
comma-separated prior masters, for unwrapping mid-rotation |
HEKLA_MASTER_KEY=$(head -c 32 /dev/urandom | base64)
Read by serve, verify, rotate, and by plan only with --replay. A project where
no field declares a @subject needs no key at all.
Losing one
hekla fails at boot rather than starting and serving holes:
stored subject data was wrapped under master key(s) not configured now: <ids>.
Set HEKLA_MASTER_KEY (and HEKLA_MASTER_KEY_PREVIOUS, comma-separated, for masters
mid-rotation) to the master(s) that wrapped this data. Losing a master is permanent,
unrecoverable loss of every subject it wrapped
Erasing a subject
hekla erase customer_id 7
erased subject `customer_id` = `7`
Deletes one key row. No master key is needed and no lock is taken, so it works against a running server; the next request sees it, because the decrypt cache lives for one request only.
A subject with no key answers no key for subject ... (already erased or never created),
and both exit 0.
The per-surface consequences are in Personal data.
Rotating the master
hekla rotate rewraps every subject key under the primary master. Ciphertext is
untouched, so reads keep working throughout.
The order matters:
- Start or restart the server with the new key as
HEKLA_MASTER_KEYand the old one inHEKLA_MASTER_KEY_PREVIOUS. - Run
hekla rotatewith that same environment.rewrapped 412 subject key(s) under the primary master - Drop
HEKLA_MASTER_KEY_PREVIOUSonce a secondrotatereportsrewrapped 0.
GET /admin/system reports keystore.master_key_ids. More than one entry means a rotation
started and has not finished.
hekla rotate takes no lock, so it runs against a live server.
Deployment credentials
A secret is declared in the source and never valued there:
secret DISCORD_WEBHOOK
secret SENTRY_DSN?
The trailing ? marks it optional, so serving starts without it.
Supply it in hekla.toml:
[secrets]
DISCORD_WEBHOOK = { env = "DISCORD_WEBHOOK_URL" }
STRIPE_KEY = { file = "/run/secrets/stripe" }
or fall back to HEKLA_SECRET_<NAME> in the environment when a declaration is not named in
[secrets].
env and file are the only two forms. There is deliberately no third carrying the value,
and { value = "sk_live_..." } is a parse error, because writing a credential into the
source tree is the exact mistake the feature exists to prevent. A relative file path
resolves against the project root, and exactly one trailing newline is trimmed.
Checking before you deploy
hekla secrets .
DISCORD_WEBHOOK set 2633c771 env DISCORD_WEBHOOK_URL
SENTRY_DSN unset (optional) env HEKLA_SECRET_SENTRY_DSN
STRIPE_KEY MISSING file /run/secrets/stripe
failed: 1 of 3 credential(s) unset; serving would refuse to start
It never prints a value. The eight-character fingerprint is a hash domain-separated by the declared name, which is enough to tell two deployments apart without revealing either.
It reads no data directory, opens no log and takes no lock, and exits non-zero when a required credential is unset. That is what makes it a pre-deploy gate on its own.
Secret is a taint
The type of a secret is Secret. It can be handed to an outbound call and cannot be
interpolated into a log line, a refusal message or a fail.
Values are also scrubbed from every surface on the way out, error text included. That is not belt and braces: an HTTP client writing a failing request’s URL into its own error message is exactly how a webhook URL ends up in a log.
Rotating a credential
Change the source of the value and restart. There is deliberately no _PREVIOUS list for
credentials, because a master wraps stored data and a credential wraps nothing: once the new
value is live, the old one is simply not used again.
Which subcommands need what
| project | data dir | lock | master key | |
|---|---|---|---|---|
check |
yes | no | no | no |
test |
yes | no | no | no |
secrets |
yes | no | no | no |
openapi |
yes | no | no | no |
plan |
yes | yes | no | with --replay |
serve |
yes | yes | yes | if any @subject |
verify |
yes | yes | yes | if any @subject |
rewind |
yes | yes | yes | no |
erase |
no | yes | no | no |
rotate |
no | yes | no | yes |