Encryptor.Context (Encryptor v0.7.0)

Copy Markdown View Source

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.

KeyValueSupplied by
scope_refthe keyed scope reference derived from the :key selectorthe vault, never the caller
tablelogical relation name, frozen at declarationthe caller
columnlogical field name, frozen at declarationthe caller
bloblogical name for a payload with no tablethe caller
purposecoarse classification of the value ("pii", "oauth_token")vault configuration
appthe host application's own namevault 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:

LayerSourcePrecedence
Static:static_encryption_context from vault configurationabove nothing
Per-callthe caller's :encryption_context optionmerged over static
Vault-suppliedkeys the vault derives from the call's own argumentsabove config, refuses a caller conflict
Package-reservedencryptor-* pairs this package setshighest, 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 a Format module 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

context()

@type context() :: %{optional(String.t()) => String.t()}

An encryption context: a flat map of string to string.

pairs()

@type pairs() :: context() | [{String.t(), String.t()}]

A context, or the pair list an already-decomposed one arrives as.

Functions

canonical_keys()

@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"]

compose(config, per_call, opts \\ [])

@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 is scope_ref on a :scoped vault. Defaults to %{}.
  • :reserved - encryptor-* pairs this package sets on its own messages, which is how Encryptor.Envelope marks 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"}

max_bytes()

@spec max_bytes() :: pos_integer()

The most bytes a composed context may serialize to.

iex> Encryptor.Context.max_bytes()
4096

max_pairs()

@spec max_pairs() :: pos_integer()

The most pairs a composed context may carry.

iex> Encryptor.Context.max_pairs()
32

reserved_key?(key, profile)

@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

reserved_prefixes()

@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-"]

scope_ref_key()

@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"

serialized_size(pairs)

@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

valid_string?(value)

@spec valid_string?(term()) :: boolean()

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