Current section
Files
Jump to
Current section
Files
docs/explanation/security-model.md
# The security model: keys, scopes and envelopes
This page is about how this package protects data at rest: the three levels of
keys it works with, why the middle level is a random key stored in wrapped form
rather than a key derived on demand, what the encryption context binds a
message to, and why so many different failures look exactly alike. It also
says, as plainly as the rest, what the model does not protect against. Nothing
here needs to be run; the [getting-started guide](../../guides/getting-started.md)
builds the vaults it talks about.
## Envelopes, and the level above them
Envelope encryption is the engine's idea, not this package's. Each message is
encrypted under a data key, the data key is encrypted ("wrapped") under a key
above it, and the wrapped data key travels inside the message. The engine
generates the data key, wraps it, and discards it; with the materials cache
off, which is the default, every message gets a fresh one. The message that
comes out is a standard AWS Encryption SDK message, which is why the
ciphertext you store is one self-describing binary and why another language's
official SDK can read it.
This package adds a level above that, so the hierarchy has three:
- **The root key**, one per deployment. It comes from your environment or a
secrets manager, and it never encrypts application data. Its job is to
protect the level below it.
- **The scope master key**, one per scope per version. A scope is whatever
your application keys by: an account, a workspace, a single deployment-wide
owner. This key wraps the data keys of that scope's messages, and it is
itself stored wrapped by the root.
- **The data key**, generated by the engine for each message, as above.
The middle level is the one that does the work. Without it, the root would
wrap every data key directly, every message would be readable by one key, and
the smallest unit you could ever erase would be the whole deployment. That
two-level shape is still the right one when there is only one owner, and it is
what a single-key vault is: the README's basic usage is exactly that. With the
middle level, each scope's data is cryptographically separate from every
other scope's, and the scope becomes the unit you can rotate, suspend and
erase. [Choosing the scope](../../guides/choosing-the-scope.md) is about where to
draw that boundary, because drawing it fixes all three.
All of this describes the providers that hand the vault key material to build
a keyring from, which is most of them (`Encryptor.Provider`). The AWS KMS
provider is the other shape: there the scope's key is a key inside AWS KMS,
data keys are generated there, and there is no scope master key to wrap or to
store (`Encryptor.Provider.Kms`). What follows about wrapped keys applies to
the first shape; what follows about the encryption context and about failures
applies to both.
The root key material is not used directly either. Two keys with different
jobs are expanded from it under fixed labels (`Encryptor.Kdf`): one wraps
scope master keys, the other derives the scope reference that stands in for
your scope identifier in stored rows and message headers. They are separate
because they age differently. A routine root rotation replaces the wrapping
key and rewraps every stored scope key, which is cheap; if the same key also
fed the scope reference, every stored reference would change with it and the
rotation would become a re-index of every row.
## Why a scope's key is random and stored, not derived
The obvious alternative is to compute each scope's key from the root and the
scope identifier, which needs no storage at all. This package does not,
because a derived key cannot be destroyed. Deleting the row that records a
derived key deletes a memo, not a secret: anyone holding the root can compute
the same key from the scope identifier again, forever. Every promise to erase
a scope would then be a promise about deleted ciphertext, which survives in
backups and replicas, rather than about a destroyed key.
So a scope master key is 32 bytes from the operating system's random number
generator, generated once when the scope is provisioned (`Encryptor.Envelope`).
It is a function of nothing, and the stored wrapping is its only durable form.
Destroy every copy of the wrapping and the scope's ciphertext is unreadable,
including by whoever holds the root. That is what makes a crypto-shred honest,
and it is worth the row of storage per scope per version.
Three smaller choices follow from the same reasoning:
- **The wrapping is an ordinary message, not a format of this package's own.**
The alternative is a hand-rolled wrapped-key layout: a version byte, a
nonce, a ciphertext, a tag, and a parser and a migration story to go with
them, written one layer below a dependency chosen precisely to avoid
hand-rolled formats. Instead the root vault encrypts the 32 bytes like any
other plaintext, so the wrapping inherits key commitment, context binding
and the error vocabulary, and moving the root to a key manager later is a
rewrap rather than a schema change.
- **Keys exist only when you provision them.** A provider that minted a key
the first time it saw an unknown scope would be convenient, and it would
mean a typo in a scope identifier silently creates a key, two concurrent
requests can create two keys for one scope, and a read path performs a
write. Resolving an unknown scope is an error instead.
- **The plaintext key is never handed back.** Provisioning returns the
wrapping and the identity needed to find it again; unwrapping returns a key
descriptor whose bytes are left out of `inspect/2`. The likeliest mistake
with an API like this is storing the plaintext beside the wrapping "for
convenience", and an API that never returns it makes that mistake take
effort.
## What the encryption context binds
Every message carries an encryption context: a small map of strings, such as
the table and column a value belongs to, which travels in the clear and is
covered by the message's authentication tag. A decrypt has to reproduce it.
The point is to bind a ciphertext to where it was written, so that bytes moved
from one column to another, or from one scope's row to another's, fail to
decrypt instead of decrypting into the wrong place.
The engine checks the context too, so it would be natural to rely on that.
The reason this package does not is that the engine's check sits below its
materials cache: on a warm decryption cache it is skipped, and a second read
of the same ciphertext under a disagreeing context would succeed. A guarantee
that holds on a cold cache and not on a warm one is not a guarantee worth
writing down, so the vault parses the message header itself and compares the
context before it calls the engine at all. The comparison holds the same way
with the cache cold, warm, or off.
The same binding protects the stored scope keys. A wrapped scope key carries
a context, owned by the package rather than by you, that names its scope
reference, its version and its namespace. A wrapping copied into another
scope's row, or from one version to another, does not unwrap. Without that
binding both copies would succeed and yield the wrong key, and the first sign
would be a failed decrypt of application data much later.
There is one thing the context should not carry: anything that varies per
row. The context is part of the materials cache key, so a row id in it turns
each row into its own cache entry and its own key resolution. Values bounded
by your schema, such as a table and a column, are the intended content.
## Why failures look alike
A wrong key, a failed authentication tag, a context that disagrees with the
message and a commitment rejection all come back as the same error reason,
`:decrypt_failed` (`Encryptor.Error`). That is deliberate. A caller who can
tell "wrong scope" from "wrong column" from "wrong key" holds an oracle over
the ciphertext: they can probe a message and learn what is in its header, or
which key would open it, one distinguishable failure at a time.
The line is drawn at whether a failure depends on the message. A failure that
depends only on what the caller passed and on the vault's own configuration,
such as a required context key the caller left out, discloses nothing about
the ciphertext and is the one failure a caller can actually fix, so it keeps
its own reason. Everything that depends on what is inside the message
collapses. An operator still sees the engine's own term in the error's
`:engine` field, so diagnosis does not suffer; application code branching on
the reason sees only the collapsed one.
The same instinct governs what the package will render. Plaintext, a data key
and wrapping key material never appear in an error message, an `inspect/2`
result or a telemetry event. A key descriptor's bytes are excluded from its
inspected form, and telemetry metadata is an allow-list rather than whatever
happened to be in scope.
## Where key material is allowed to come from
Key material reaches a vault through its `init/1` callback at start, and
nowhere else. A `use Encryptor.Vault` option named for key material is a
compile error, not a warning, because by the time the module is compiled the
secret would already be inside a `.beam` file, and from there in every build
artefact, cache and container image made from it. Reading the key at start
keeps it in the environment or the secrets manager it came from.
[How to source secrets at start](../../guides/secrets-at-start.md) shows the
shapes this takes.
Two settings are not secret but behave like part of the key: a vault's
derivation salt and its slow-hash parameters. Every subkey derived under the
salt, and every hash computed under the parameters, depends on their exact
value, so changing either one changes every derived value. A blind index
built under the old value stops matching, and the only way back is to rebuild
it from plaintext. In practice both are pinned for the life of a deployment.
## What the model does not protect against
The design's main return is that the root key and the wrapped scope keys live
in different places: the root in the environment or a key manager, the
wrappings in your database. A database compromise on its own, from a backup,
a replica, a dump or an injection, yields ciphertext and wrappings and nothing
readable. Ciphertext and the root on their own yield nothing either. Only
both together, or a scope's unwrapped key, reach plaintext.
Some limits are part of the same picture, and the rotation runbook's blast
radius tables set them out in full ([How to rotate, retire, shred and suspend
keys](../../guides/rotation-runbook.md)):
- **A running application process is not protected against.** A process that
can encrypt for a scope holds that scope's key in memory, and one that can
provision holds the root. Code execution or memory access in the
application defeats the hierarchy. A key manager for the root narrows this,
because the root material leaves application memory, but the unwrapped scope
keys are still there.
- **A compromised scope key is bounded by version, not by time.** One key
version covers every message written while it was current, which is why
rotation cadence is a security parameter rather than tidiness.
- **A shred is only as good as the copies.** Destroying a wrapping makes the
data unreadable wherever that wrapping was the last copy. A wrapping kept in
a backup or an export keeps the data readable for whoever holds it and the
root. [Choosing the scope](../../guides/choosing-the-scope.md) says what
cryptographic erasure can honestly be claimed to achieve.
## Where the decisions are recorded
Every cryptographic choice on this page is a decision in a record, with the
alternatives that were weighed at the time and the engine behaviour that
forced some of them. The [decision records](https://github.com/riddler/encryptor/tree/main/docs/adr)
hold them; the key hierarchy, the wrapping and the trust boundary are in the
envelope record, and the context binding and the comparison above the cache
are in the encryption context record.