Skip to content
hekla

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:

  1. Start or restart the server with the new key as HEKLA_MASTER_KEY and the old one in HEKLA_MASTER_KEY_PREVIOUS.
  2. Run hekla rotate with that same environment.
    rewrapped 412 subject key(s) under the primary master
  3. Drop HEKLA_MASTER_KEY_PREVIOUS once a second rotate reports rewrapped 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