Skip to content

Encryption

Secret values are encrypted with AES-256-GCM under a key derived from MASTER_KEY. Secret key names are not — see Threat model for that trade.

Everything on this page is implemented and tested. Source: packages/app/src/lib/server/crypto/.

Loading the master key

MASTER_KEY is base64 that decodes to exactly 32 bytes. It is validated when the key ring is built, before any route runs, so a bad value makes the Worker refuse every request — including /health — rather than serving happily until the first secret is read.

The decoded bytes are the key derivation input, not the base64 text. That is the specific failure this validation exists to make impossible, because it is completely silent: MASTER_KEY="hunter2" would be accepted, the derivation would happily stretch those seven bytes, and the result is a fully functional secrets manager protected by a password. Nothing observable at runtime would distinguish it from a correct deployment.

Derivation

kid(mk) = hex(first 8 bytes of HKDF-SHA256(ikm = mk, salt, "prick/v1/kid"))
DEK(mk) = HKDF-SHA256(ikm = mk, salt, "prick/v1/dek/secret.value")

with the fixed salt prick/v1/keyring.

Property Value
Salt prick/v1/keyring
Key id info prick/v1/kid
Data key info prick/v1/dek/secret.value
Key id 8 bytes, rendered as 16 lowercase hex characters
Data key AES-256-GCM, extractable: false

The key id is derived from the key material rather than configured, so it cannot drift from the key it names. Two deployments given the same MASTER_KEY compute the same id; a mistyped key computes a different one, and the resulting error names it.

The salt and the info strings are part of the storage format

Changing either changes every key id and every data key, which makes every stored row undecryptable. They are constants, not configuration.

The data key is imported with extractable: false, so it cannot be read back out of the runtime — not by later code in the same module, not by a bug elsewhere that gets hold of the CryptoKey. The raw master bytes are not retained either: once the id and the data key are derived, the decoded material goes out of scope. There is no field on any object a serialiser, a logger or a debugger could render into key material.

Derivation is memoised per isolate, keyed on a hash of the concatenated material rather than on the material itself. Re-deriving on every request is pure latency for no security gain.

The envelope

base64url( version ‖ alg ‖ kid[8] ‖ iv[12] ‖ ciphertext‖tag )
Field Bytes
version 1
alg 1 (0x01 = AES-256-GCM)
kid 8
iv 12
ciphertext‖tag remainder, at least 16 (the tag)

A v1 header is therefore 22 bytes, and the shortest legal v1 envelope is 38.

Byte 0 is read first, and it dispatches. An unknown format byte throws. It is never guessed at, never best-effort decoded, and never treated as the current format on the assumption that it probably is. The length checks happen after the dispatch and are specific to the format that byte named — a shared “is it long enough” check up front would be checking the wrong number for every format but one.

Format Meaning
0x01 Current. AES-256-GCM with full additional authenticated data
0x00 v0 — legacy, no AAD. Decrypt-only, never emitted

The v0 format exists so that a v0 export can be imported and immediately re-encrypted as 0x01. Its body is iv[12] ‖ ciphertext‖tag: no algorithm byte and no key id, so decrypting one has to try every key in the ring. Accepting it is opt-in per call, defaulting to refuse — a v0 row is bound to nothing and is exactly as transplantable as the ciphertexts the AAD exists to stop being, so accepting one by default would reintroduce that weakness per row, silently, on the normal read path.

The serialiser refuses every format byte except 0x01. That refusal is the structural half of “the legacy format is never emitted”: it is not a rule about how callers should behave, it is the absence of a code path.

The IV

96 fresh random bits per encryption. Nothing reuses, derives or counters an IV. A repeated (key, IV) pair in GCM is catastrophic — it leaks the XOR of the two plaintexts and, worse, the authentication subkey.

The additional authenticated data

This is the most important part of the design, and the one thing that genuinely cannot be retrofitted: once rows exist without it, every one of them has to be decrypted and re-encrypted to gain it.

AAD = "prick" ‖ 0x01
‖ len16(purpose) ‖ purpose ("secret.value")
‖ len16(environment_id) ‖ environment_id
‖ len16(key) ‖ key
‖ u32be(version)

The defect it closes: an AES-GCM ciphertext with no additional data is bound to nothing. Every blob in the table is interchangeable with every other, so anyone with write access to the database can transplant a production secret into a development environment they are allowed to read — and the decryption succeeds, because there is nothing to contradict.

With this AAD in place, that ciphertext fails GCM tag verification.

Why length-prefixed and not delimited

Every variable-length field is prefixed with its length as a big-endian u16. It is not delimiter-separated, and the difference is not stylistic.

A delimiter scheme becomes ambiguous the instant a field can contain the delimiter. “The key can’t contain a colon” is an assumption, not a guarantee — and a schema constraint that holds today is not the thing you want a cryptographic binding to depend on. Length-prefixing is unconditionally injective, for two bytes per field.

The property that is tested explicitly:

{ key: "AB", env: "C" }
{ key: "A", env: "BC" }

These must produce different AAD. A separator scheme fails exactly this: both serialise to the same A B : C byte string, and a ciphertext sealed for one row opens under the other.

Lengths are counted in UTF-8 bytes, not in JavaScript string units. A prefix counting UTF-16 code units while the payload was written as UTF-8 would not describe the bytes that follow it, which reintroduces the ambiguity by another route.

Bounds: a field may not be empty — it is part of the row’s identity — and may not exceed 65535 bytes. The version must be an integer in [0, 4294967295].

What each field buys

All of these are GCM tag failures, not application-level checks:

Field Attack it kills
environment_id Cross-environment transplant
key Cross-key transplant
version Rollback and roll-forward replay
purpose Reuse of a value blob in some future non-value context

Concretely: take the ciphertext for DATABASE_URL version 3 in production, write it into the DATABASE_URL row of a development environment you can read, and the read fails. Change the row’s key name, and it fails. Present it as version 2 or version 4, and it fails.

What is bound and what is not

Two fields are deliberately excluded, for opposite reasons.

project_id is excluded. Including it would promote “an environment can never be reparented” into a cryptographic invariant, so any future reparent would require re-encrypting every row in the project. Instead environments.id and environments.project_id are documented as immutable and there is no reparent operation — a schema constraint rather than a crypto one.

kid is excluded, and lives in the envelope instead. A rekey must change which key protects a row without changing the row’s identity. Because the key id is outside the AAD, re-encrypting under a new master key leaves the authenticated data — and therefore the version — untouched. If the key id were in the AAD, every rekey would be a version bump, and rotating a key would rewrite the history of every secret you own.

How mutations interact with the AAD

Operation Handling
Update a value New version, encrypt fresh. Never copy a blob
Rename a key Decrypt under the old identity, re-encrypt under the new one at the next version, both in one transaction. There is no cheap rename
Roll back to version N Decrypt N, re-encrypt as current + 1. The old blob is never resurrected
Rekey Re-encrypt under the identical AAD with a new key id. Version unchanged
Import a v0 row Accepted on decrypt only, then immediately re-encrypted as 0x01

Failure behaviour

Encryption and decryption either return a correct result or throw. There is no path that returns null, an empty string, a “skipped” marker or a partially decoded value. The caller cannot accidentally treat a failure as an absent row — which is how an environment quietly deploys without its DATABASE_URL.

Error Meaning
SERVER_MISCONFIGURED The master key material is absent, malformed or internally inconsistent. Raised while parsing configuration, so the Worker fails closed on every route
DECRYPT_FAILED The bytes were not sealed against the identity they are being opened under
UNKNOWN_KID The envelope names a key id the ring does not hold. Names the id, and lists the ones loaded
CRYPTO_FORMAT A stored blob is not parseable
CRYPTO_INPUT A caller supplied an identity that cannot be encoded
PAYLOAD_TOO_LARGE The plaintext exceeds the configured byte ceiling

An AEAD failure and a presented-under-the-wrong-identity failure are indistinguishable to AES-GCM and are reported the same way. That is correct, because they are the same event.

UNKNOWN_KID is deliberately a different error from DECRYPT_FAILED: “you removed MASTER_KEY_OLD too early” and “this row has been tampered with” need opposite responses, and one generic failure cannot tell them apart.

Every message may name a key, an environment id, a version, a key id, a byte limit or a format byte. None may contain a plaintext value, a ciphertext, or master key material — these strings reach logs, HTTP responses and audit rows.

One message is written to leak less than it could: the payload-too-large error names the limit and not the actual size. A value’s exact byte length is the most revealing metadata a value has, and naming the limit already tells the caller everything they need in order to act.

Nothing in this encoding may ever change

Every stored row’s tag was computed over these exact bytes. Altering the magic, the version byte, the field order or the prefix width makes every existing row undecryptable. A new binding requires a new envelope format byte, not an edit to the existing encoder.

Next steps