The security model: keys, scopes and envelopes

Copy Markdown View Source

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 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 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 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):

  • 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 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 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.