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, id) pair, minted on first write, stored in data/hekla.db.
A root subject’s key is wrapped under the master key, and its row records which master.
A subject declared under a parent has its key derived from that parent’s secret instead,
and its row records the parent rather than a master: exactly one of the two, never both.
That is what makes deleting the parent’s one row take every key beneath it.
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, adopt, by plan only with --replay, and by
project only when the projection seals a column. A project that declares no 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 7
subject `Customer` = `7`
key present, and about to be deleted
beneath it no other keys
This is irreversible. Every value scoped to these keys becomes unreadable
across the log and every read model at once.
Continue? [y/N] y
erased subject `Customer` = `7`
The first argument is the subject’s declared name, which is what a key row is filed under. Deletes exactly one row. Every key beneath it stays on disk and becomes unopenable at the same instant, because the secret their wrapping was derived from is the one that went, and that is what the summary counts before it asks. 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.
--yes answers the prompt without silencing the summary, and off a terminal it is required.
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 root subject key under the primary master. Ciphertext is
untouched, so reads keep working throughout. A subject with a parent is never counted,
because its key comes from that parent’s secret and a rotation does not change it.
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.
Adopting keys under a new parent
Giving a subject a parent it did not have is a declaration change, and the keys already in
the store have to move under it. hekla serve does that at boot and refuses to serve
without it, so the work is never half done.
hekla adopt .
adopted 3 subject key(s) under their declared parent, reading 3 event(s)
Point it at the new project and the live data directory to do it ahead of the deploy. It reads the log through a follower and takes no lock, so the server still serving the old declaration is unaffected, and the boot that follows finds nothing left to do. It changes no ciphertext and destroys nothing, because the wrapping moves and the key does not.
It needs HEKLA_MASTER_KEY, because moving a wrapping means opening it.
hekla adopt has the full output and the failure modes.
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 |
project |
yes | yes | no | if the projection seals a column |
serve |
yes | yes | yes | if it declares a subject |
verify |
yes | yes | yes | if it declares a subject |
rewind |
yes | yes | yes | no |
erase |
no | yes | no | no |
rotate |
no | yes | no | yes |
adopt |
yes | yes | no | yes |