The encryption context: the canonical vocabulary, the four-layer composition, and the bounds the engine does not check.
The context is a flat map of String.t() to String.t() that rides every
message in the clear and is covered by the header authentication tag. Two
properties follow, and both are load-bearing:
- every pair is public to anyone holding a ciphertext, so putting a key in the context is a disclosure decision, and
- no pair can be edited without breaking the tag, which is what binds a message to the row, column and scope it was written for.
This module owns the vocabulary and the composition. It does not enforce the
required set - that is the vault's :required_context plus the
required-context CMM (ADR-0004 decisions 3 and 5) - and it does not compare a
reproduced context against a stored one, which is the decrypt path's
(decision 6).
The canonical vocabulary
Six host-facing keys, spelled bare and in snake_case, and two reserved
prefixes. canonical_keys/0 and reserved_prefixes/0 are the one place the
strings live, so a sibling package matches against this module rather than
against a copy of a literal.
| Key | Value | Supplied by |
|---|---|---|
scope_ref | the keyed scope reference derived from the :key selector | the vault, never the caller |
table | logical relation name, frozen at declaration | the caller |
column | logical field name, frozen at declaration | the caller |
blob | logical name for a payload with no table | the caller |
purpose | coarse classification of the value ("pii", "oauth_token") | vault configuration |
app | the host application's own name | vault configuration |
The vocabulary is open at the edges and closed in the middle: a host adds
its own keys freely, and it may neither redefine one of the six nor write
under aws-crypto- (the engine's, and it may grow) or encryptor- (this
package's). Adding a key to the table above is an ADR, not a call site.
scope_ref is a pinned v2 wire constant: it is written into every
message's authenticated context, so respelling it again would leave every
stored message unreadable (ADR-0009 Amendment A, A1 row 1).
scope_ref_key/0 returns it. Its retired v1 spelling, tenant_ref, is
not in the vocabulary and stays reserved (A4).
The four layers
compose/3 merges them, lowest precedence first, and refuses rather than
silently overriding:
| Layer | Source | Precedence |
|---|---|---|
| Static | :static_encryption_context from vault configuration | above nothing |
| Per-call | the caller's :encryption_context option | merged over static |
| Vault-supplied | keys the vault derives from the call's own arguments | above config, refuses a caller conflict |
| Package-reserved | encryptor-* pairs this package sets | highest, never overridable |
The refusals, in the order they are checked:
- a non-string, empty, or non-UTF-8 key or value is
{:invalid_context_value, key}, before the engine is called - the engine's serializer would otherwise either raise deep inside aFormatmodule or accept something whose serialization a reader cannot reproduce; - a per-call or static key that is reserved, or that collides with a
vault-supplied or package-reserved key, is
{:reserved_context_key, key}; - a per-call key that collides with a static key at a different value is
{:encryption_context_conflict, key}. The same value is not a conflict, so a call site that spells out what configuration already says is redundant rather than broken.
On a :scoped vault the scope pair is the vault's alone: scope_ref,
scope_id and tenant_id are all refused from a caller, because :key is
the whole of per-scope routing and a scope named twice is a scope that can
disagree with itself. tenant_id stays refused beside scope_id because a
host that followed earlier docs may still send it (ADR-0009 decision 3).
scope_ref is refused on a :single vault too, where there is no scope
to name at all. The retired v1 spelling tenant_ref is refused on both
profiles, so a caller cannot put a pair spelled like the v1 scope
reference into a context (ADR-0009 Amendment A, A4).
The bounds
The engine performs no size validation, and the context is written into every message, so its serialized size is a per-row storage cost paid forever. This module refuses, per call:
- more than 32 pairs, as
{:invalid_context_value, :count}, - a serialized context over 4 KiB, as
{:invalid_context_value, :too_large}, - a key or value that is empty or not valid UTF-8.
The static half is bounded once, at start, by Encryptor.Vault.Config, which
reads its numbers from max_pairs/0 and max_bytes/0 here. The numbers are
conservative and are stated rather than measured (ADR-0004 open question 4).
Nothing that varies per row may go in the context
A primary key, a row id, a timestamp, a request id, or a user id does not belong in a context. This is a correctness-adjacent rule rather than a style preference: the serialized context is hashed into the materials cache id, so each distinct context is its own cache entry. The provider round trip behind it - a key-store read plus a root-vault decrypt - is paid on every call in any case, per row, forever.
table and column are per-column, which is bounded by the schema. A host
with 200 scopes and 40 encrypted columns holds up to 8,000 cache entries;
the same host with a row id in the context holds one per row.
The vault cannot enforce this - it cannot tell a column name from a row id - so it is a documented rule, and the size cap above is the only mechanical backstop it has.
Records: ADR-0004 decisions 1, 2, 7 and 9, with decision 4's refusal of a caller-supplied scope pair.
Summary
Types
An encryption context: a flat map of string to string.
A context, or the pair list an already-decomposed one arrives as.
Functions
The six canonical host-facing keys, in the order ADR-0004 decision 2 tables them.
Composes the four layers into the context a message will carry.
The most bytes a composed context may serialize to.
The most pairs a composed context may carry.
Whether a key is refused from a caller or from static configuration on a vault of this profile.
The prefixes no host key may start with.
The key the vault writes a scope reference under.
The size the engine serializes a context to, in bytes.
Whether a term is usable as a context key or value: a non-empty binary that is valid UTF-8.
Types
Functions
@spec canonical_keys() :: [String.t()]
The six canonical host-facing keys, in the order ADR-0004 decision 2 tables them.
iex> Encryptor.Context.canonical_keys()
["scope_ref", "table", "column", "blob", "purpose", "app"]
@spec compose(Encryptor.Vault.Config.t(), term(), keyword()) :: {:ok, context()} | {:error, Encryptor.Error.t()}
Composes the four layers into the context a message will carry.
config supplies the static layer and the profile. per_call is the
caller's :encryption_context. The two upper layers arrive as options,
because both are the vault's own and neither is ever a caller's to pass:
:supplied- keys the vault derives from the call's own arguments, which today isscope_refon a:scopedvault. Defaults to%{}.:reserved-encryptor-*pairs this package sets on its own messages, which is howEncryptor.Envelopemarks a wrapped key. Defaults to%{}.:operation- what to record on a failure. Defaults to:encrypt.
A caller key that collides with either upper layer is refused rather than overridden, which is the whole reason they are separate arguments and not a pre-merged map.
iex> config = %Encryptor.Vault.Config{
...> vault: MyApp.Vault,
...> context_profile: :single,
...> static_encryption_context: %{"app" => "my_app"}
...> }
iex> Encryptor.Context.compose(config, %{"table" => "customers"})
{:ok, %{"app" => "my_app", "table" => "customers"}}
iex> config = %Encryptor.Vault.Config{
...> vault: MyApp.Vault,
...> context_profile: :scoped,
...> static_encryption_context: %{}
...> }
iex> {:error, error} = Encryptor.Context.compose(config, %{"scope_ref" => "mine"})
iex> error.reason
{:reserved_context_key, "scope_ref"}
@spec max_bytes() :: pos_integer()
The most bytes a composed context may serialize to.
iex> Encryptor.Context.max_bytes()
4096
@spec max_pairs() :: pos_integer()
The most pairs a composed context may carry.
iex> Encryptor.Context.max_pairs()
32
@spec reserved_key?(String.t(), Encryptor.Vault.Config.profile()) :: boolean()
Whether a key is refused from a caller or from static configuration on a vault of this profile.
Both reserved prefixes are refused on either profile. The scope pair is
profile-sensitive: scope_ref is the vault's on a :scoped vault and
meaningless on a :single one, so it is refused on both, as is its
retired v1 spelling tenant_ref; scope_id and tenant_id are refused
only where a scope exists to be named twice.
iex> Encryptor.Context.reserved_key?("aws-crypto-public-key", :single)
true
iex> Encryptor.Context.reserved_key?("scope_ref", :single)
true
iex> Encryptor.Context.reserved_key?("tenant_ref", :single)
true
iex> Encryptor.Context.reserved_key?("tenant_ref", :scoped)
true
iex> Encryptor.Context.reserved_key?("scope_id", :scoped)
true
iex> Encryptor.Context.reserved_key?("tenant_id", :scoped)
true
iex> Encryptor.Context.reserved_key?("scope_id", :single)
false
iex> Encryptor.Context.reserved_key?("tenant_id", :single)
false
iex> Encryptor.Context.reserved_key?("table", :scoped)
false
@spec reserved_prefixes() :: [String.t()]
The prefixes no host key may start with.
aws-crypto- is the engine's, and the engine is of two minds about it:
Format.EncryptionContext.validate/1 refuses the whole prefix, while
Cmm.Behaviour.validate_encryption_context_for_encrypt/1 refuses exactly
"aws-crypto-public-key", the one key it uses today. This package refuses
the prefix, on the stricter of the two readings, because a later engine
version may put another reserved key under it and a host that has been
writing one is then unable to encrypt.
iex> Encryptor.Context.reserved_prefixes()
["aws-crypto-", "encryptor-"]
@spec scope_ref_key() :: String.t()
The key the vault writes a scope reference under.
The key is a pinned v2 wire constant, "scope_ref": it is authenticated
in every message, so it is a constant rather than a name (ADR-0009
Amendment A, A1 row 1).
iex> Encryptor.Context.scope_ref_key()
"scope_ref"
@spec serialized_size(pairs()) :: non_neg_integer()
The size the engine serializes a context to, in bytes.
A non-empty context is a 16-bit pair count followed by one entry per pair,
each a 16-bit length prefix and the bytes of the key then the same for the
value. An empty context serializes to nothing at all - not to a
zero-valued count - which is Format.EncryptionContext.serialize/1's own
first clause and the reason this is not simply 2 + entries.
Computed arithmetically rather than by calling the engine's serializer, so a size check does not become a second place this package depends on the message format.
iex> Encryptor.Context.serialized_size(%{})
0
iex> Encryptor.Context.serialized_size(%{"app" => "my_app"})
15
Whether a term is usable as a context key or value: a non-empty binary that is valid UTF-8.
iex> Encryptor.Context.valid_string?("customers")
true
iex> Encryptor.Context.valid_string?("")
false
iex> Encryptor.Context.valid_string?(:customers)
false