This page is about the threats this package is built to resist and the ones it is not: what is worth protecting, who might try to reach it, which components the package has to trust to do its job, and, for each claim, the test or the decision record that holds the claim in place. It builds on the security model, which explains the key hierarchy and the encryption context; read that first if the words "scope master key" or "reproduced context" are new. Nothing here needs to be run.
A threat model is only as useful as it is honest, so this one also says where the claims stop: the limits of AES-GCM as the message format uses it, the defects known in the engine underneath, what the evidence was tested against, and what kind of review this page has and has not had.
What is worth protecting
Six things, and they are not equally valuable.
- Plaintext. The data you encrypt. Everything else on this list exists to protect it.
- The root key. One per deployment, from your environment or a secrets manager. It never encrypts application data. Two subkeys with different jobs are expanded from it under fixed labels: one wraps scope master keys, the other derives scope references. Whoever holds it, together with the stored wrappings, can read every scope.
- Scope master keys. One per scope per version, 32 random bytes generated when the scope is provisioned, stored only in wrapped form. Whoever holds one unwrapped can read every message that version covers, and nothing else.
- Data keys. One per message, generated by the engine, wrapped under the scope's key and carried inside the message. With the materials cache on, a data key is reused for a bounded number of messages. A data key is never used as a GCM key directly: each message derives its own key from it (see the limits of AES-GCM).
- Scope references. A keyed derivation of your scope identifier: the first
16 bytes of HMAC-SHA256 under the reference subkey. A reference is public:
it travels in the clear in the header of every message a
:scopedvault writes, and in the wrapped data key's name. What it protects is the identifier behind it. Two messages with the same reference visibly belong to the same scope; which scope that is stays hidden from anyone without the reference subkey. - Key-store rows. The rows your application stores for each scope key
version: the wrapping, plus the scope reference, version, namespace, name
and key size that identify it (
Encryptor.Envelope.WrappedKey). They are not secret on their own, but their integrity and their existence matter. A row moved to another scope or version must not unwrap, and deleting every copy of a row is what a crypto-shred is.
Scope master keys and key-store rows belong to the providers that hand the
vault key material. With the AWS KMS provider a scope's key lives inside AWS
KMS, data keys are generated there, and there is no scope master key to wrap
or row to store (Encryptor.Provider.Kms).
The asymmetry is the point of the design. The root key and the key-store rows live in different places, so the most common compromise, a copy of the database, yields wrappings and ciphertext and nothing readable.
The trust boundaries
This package does very little cryptography of its own. It composes a vault out of four things it has to trust, and the honest version of a threat model names them, says how far each is trusted, and says what checks it independently.
The engine
The aws_encryption_sdk engine builds and parses every message: the header, the data key wrap, the per-message key derivation, the framed AES-GCM body, the signature. This package trusts it for the message format and does not reimplement it.
It does not trust it blindly. Three places check the engine from outside. The GCM construction test parses the bytes the engine wrote and re-derives the message key itself, so the IV and tag layout is checked against the standard rather than against the engine's own account of it. Published vectors run through the engine's AES-GCM wrapper and its HKDF (see what was tested against what). And the vault keeps two checks of its own where the engine is known to fall short: the context comparison above the materials cache, and a mapping of the engine's decrypt result that accepts only the two shapes its contract promises (see known defects in the engine).
Pinned by:
test/encryptor/gcm_construction_test.exs,
test/encryptor/vectors/wycheproof_engine_test.exs,
and the decrypt tests in "the vault-side reproduced-context value check" and
"an engine return outside its contract"
(test/encryptor/vault/decrypt_test.exs).
OTP :crypto and OpenSSL
Every primitive bottoms out in OTP's :crypto module, and through it in
OpenSSL: AES-GCM, HMAC-SHA256, SHA-256 and SHA-512, and the operating system's
random number generator behind :crypto.strong_rand_bytes/1, which mints every
scope master key. This package trusts them as every Elixir application that
encrypts does, and it adds no primitive of its own. Its own HKDF in
Encryptor.Kdf is a few lines over :crypto.mac/4, checked against RFC 5869's
vectors and Wycheproof's. The optional Argon2id slow hash comes from
argon2_elixir and is checked against a pinned known-answer vector.
Pinned by: "extract/2 against RFC 5869" and "expand/3 against RFC 5869", and
"matches a pinned Argon2id known-answer vector at the record's minimum"
(test/encryptor/kdf_test.exs);
test/encryptor/vectors/wycheproof_hkdf_sha256_test.exs.
The key provider
A provider decides where key material comes from: config, a function of your own, AWS KMS or GCP KMS. It is the component a host is most likely to write itself, so it is the one designed to be unable to weaken anything. It sees a selector and its own state and returns key descriptors; it never sees plaintext, ciphertext or the encryption context, and it has no say over the algorithm suite, the commitment policy or the cache. A provider that returns the wrong key produces a failed decrypt, not a wrong plaintext. A provider that fails is reported as a provider failure, kept distinct from a bad message, so an outage does not look like tampering.
Pinned by: ADR-0002 decisions 6 and 7
(the key-provider record);
the conformance suite every shipped adapter runs (Encryptor.Provider.Conformance);
"a provider that cannot resolve the selector stays distinct from a bad message"
(test/encryptor/vault/decrypt_test.exs).
The host's secret source
The root key, and a single-key vault's key, reach the vault through its
init/1 callback when it starts, from wherever the host keeps secrets. This
package trusts that source completely: it cannot tell a leaked root from a
legitimate one. What it does refuse is the one place a secret should never be,
compiled into a .beam file through a use option. A :scoped vault can also
pin a known-answer value for its reference subkey, so a deployment that starts
with the wrong root material refuses to start instead of writing references no
correct reader will ever find.
Pinned by: ADR-0001 decision 5
(the vault record);
"every key-material option is a compile-time refusal" and "a pinned value the
subkey reproduces starts; one it does not, refuses"
(test/encryptor/vault/config_test.exs).
The adversaries
Six of them, from the most common to the most specialised. For each, what this package claims, and what holds the claim in place.
Someone who can read the database
A copy of the database, from a replica, a dump or an injection, holds ciphertext, key-store rows and encryption contexts. The claim: on its own it yields no plaintext. The wrappings are encrypted under a subkey of the root, and the root is not in the database.
What it does yield is worth stating plainly. Encryption contexts travel in the clear, so the reader learns which table and column each value belongs to, and which values share a scope reference. AES-GCM does not hide length, so the reader learns each plaintext's length, to the byte. And the key names in the key-store rows and the messages show how many key versions each scope has.
A reader who can also write gains little more, within one limit. A wrapping
moved to another scope's or version's row fails to unwrap, because the package
binds every wrapping to where it was written. A value moved to another column
fails to decrypt only when the message's context names the column it was
written for. The vault compares the keys present in both the message's stored
context and the reader's claim: a key the writer left out binds nothing, so a
value written without a column key decrypts under any column a reader
claims. The binding of a context key holds when the vault's :required_context
names it, which refuses a write that leaves the key out and a read of a message
written without it, or when every writer of that vault passes the key on every
call.
Pinned by: ADR-0003 decision 10, the blast radius table
(the envelope record);
"a wrapping moved to another scope's row does not unwrap" and "a wrapping moved
to another version's row does not unwrap"
(test/encryptor/envelope_test.exs);
"a column swap inside one scope fails, and the engine's term shape is ours",
"a message written without a column reads under any column claim" and "a vault
requiring the column refuses a message written without it"
(test/encryptor/vault/decrypt_test.exs);
"is ADR-0003 decision 5's derivation, byte for byte" for the keyed reference
(test/encryptor/envelope_test.exs).
Someone holding a backup
A backup is a database reader with a long memory, and everything above applies to it. The difference is time. A backup taken before a shred still holds the wrapping the shred destroyed, so whoever holds that backup and the root can still read the shredded scope. A backup taken before a root rotation holds wrappings under the old root, readable by whoever kept the old root material. The claim this package makes about erasure is therefore narrower than "deleted": a shred makes data unreadable wherever the destroyed wrapping was the last copy. Choosing the scope says what cryptographic erasure can honestly be claimed to achieve, and the rotation runbook sets out what each step destroys.
Pinned by: ADR-0003 decision 10, "Shredding is only as good as the copies"; ADR-0005 (the rotation and crypto-shred record).
Someone who controls the host process
Out of scope, as the security model
says. 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. What the package does do is refuse to make the
leak easier: no plaintext, data key or wrapping key material ever appears in an
error message, an inspect/2 result or a telemetry event, so a log or a crash
report is not a second copy of a secret.
Pinned by: ADR-0003 decision 10, "The running process is not protected
against, at all"; "no failure report and no descriptor renders anything
key-shaped"
(test/encryptor/envelope_test.exs);
"no event carries a key outside the record's allow-list"
(test/encryptor/telemetry_test.exs).
A caller with the wrong context
The most ordinary adversary is a bug: code that reads a value under the wrong
table, column or scope. The claim: a decrypt under a context that disagrees
with the message on a key the message carries fails, on a cold cache, a warm
cache and with caching off; a decrypt that leaves out a required key is a loud
error the caller can fix; and no caller can claim a scope through the context,
because the scope reference is the vault's to supply from the :key selector.
A key the message does not carry is not compared, so the claim covers a table
or a column only where the writer passed it, which :required_context is the
way to guarantee (see
someone who can read the database).
Two refusals at the edges keep that claim from leaking. A vault refuses to
start with a required context key no caller could ever supply, rather than
starting and failing every write. And derive/3 refuses the purposes the vault
reserves for its own subkeys, so the label space the root wrap and the scope
reference are derived under stays the package's alone. Both refusals are new
in the release after 0.7.0.
Pinned by: ADR-0004 decisions 4, 6 and 8
(the encryption context record);
"the same swap fails on a warm decryption cache, which is the whole point", "a
reader that supplies no context at all is a loud, fixable error" and "a caller
cannot claim a scope through the context"
(test/encryptor/vault/decrypt_test.exs);
"a scoped vault may not require scope_id" and its sibling rows in "the required
context list"
(test/encryptor/vault/config_test.exs);
"the root-wrap purpose is refused" and "derive/3 refuses exactly the purposes
Envelope.subkey/2 refuses"
(test/encryptor/vault/derive_test.exs).
Someone who supplies malicious ciphertext
A ciphertext column is input, and anyone who can write to it can hand the
vault arbitrary bytes. The claims are about what such bytes can learn and what
they can cost. Every failure that depends on the message itself, a wrong key, a
failed tag, a disagreeing context, a commitment rejection or bytes that do not
parse, comes back as the same reason, :decrypt_failed, so a forged message
cannot be used as an oracle over the key or the header. Key commitment
defaults to the engine's strictest policy, and the one policy that turns it
off is refused, so a message the vault writes cannot be made to decrypt to a
second plaintext under a second key. The number of encrypted data keys a
message may carry is bounded, so a message cannot make the vault try an
unbounded list of keys. And a valid message followed by trailing bytes is
refused rather than crashing the caller.
What such bytes can be made to say depends on who supplies them. Without the
key, nobody can write a message a vault accepts. With it, anybody can: a key
that reaches the vault as AES key material (an Encryptor.Key.Aes
descriptor, which is what the static provider answers, and what the GCP KMS
provider answers once it has unwrapped the scope's key) puts the key that
wraps data keys in every reader's hands, so a reader can write a message,
signed or not, that the vault decrypts like any other. The signing suite,
0x0578, stops a reader from forging only where that reader cannot wrap a
data key: a key that is an AWS KMS key (an Encryptor.Key.Kms descriptor),
with the reader's role granted kms:Decrypt and neither kms:GenerateDataKey
nor kms:Encrypt on it. Even there it holds only while no unsigned message
exists under that key, because decrypt does not check a message's suite
against the vault's :algorithm_suite_id: a 0x0578 vault reads a 0x0478
message under the same key, and a reader that can unwrap an unsigned
message's data key can write another message under it.
Pinned by: ADR-0001 decisions 8 and 10
(the vault record);
"every message-dependent decrypt failure collapses to :decrypt_failed"
(test/encryptor/error_test.exs);
"defaults to the strictest, relaxes by one step, and refuses the legacy policy"
and "defaults to 10, may be tightened, and may never be nil"
(test/encryptor/vault/config_test.exs);
"a message this package cannot parse is decrypt_failed, carrying the parse
term" and "a message followed by one trailing byte is decrypt_failed, carrying
no engine term"
(test/encryptor/vault/decrypt_test.exs,
and the same name in
test/encryptor/vault/rekey_test.exs);
Wycheproof's invalid AES-GCM cases, each refused by the engine's wrapper;
"a signing-suite vault reads an unsigned message under the same key"
(test/encryptor/vault/decrypt_test.exs).
The rest of the forgery paragraph rests on reading the providers and the
engine's KMS keyring, which calls Decrypt alone on the read path and
GenerateDataKey or Encrypt on the write path.
Someone who can time the vault
Timing reaches a vault in two ways, and the claims differ.
The first is the materials cache. A decrypt that finds its data key in the cache is faster than one that has to unwrap it, so an observer who can time decrypts can tell whether a key was recently used. The cache is off unless a vault turns it on. When it is on, its entries are partitioned by the vault and the scope selector, so a read in one scope never shares an entry with a read in another, and the timing a reader can observe is about its own scope.
The second is the processor's own caches, the classic side channel against table-based AES and against comparisons that stop at the first differing byte. The AES-GCM tag check, the HMAC and the key derivations all run inside OpenSSL, and this package claims nothing about them beyond what OpenSSL's implementation provides on the machine it runs on. The comparisons this package does in Elixir are over values that are public by design: the encryption context, which travels in the clear, and the scope reference, which is in every header. Nothing secret is compared byte by byte in Elixir.
Pinned by: ADR-0001 decisions 6 and 7
(the vault record);
"two selectors in one vault produce distinct ids" and "two vaults produce
distinct ids for the same selector"
(test/encryptor/vault/partition_test.exs).
No test measures timing: the second half of this section rests on reading the
code, and is stated as such.
The limits of AES-GCM, and why rotation is a security parameter
Every message is AES-GCM, and AES-GCM has hard limits that NIST SP 800-38D states and that no implementation can lift. It is worth knowing where this package sits against each one, because one of them turns into operational guidance.
The IV length (section 5.2.1.1). SP 800-38D recommends 96-bit IVs, and the engine's AES-GCM wrapper admits nothing else: Wycheproof's cases with any other IV length are asserted refused by its guard, not skipped. Every tag is 128 bits (section 5.2.1.2 lists the permitted lengths).
Deterministic IVs inside a message (section 8.2.1). The message body is encrypted in frames, and frame i is encrypted under IV i, counting from 1; the header tag is computed under the all-zero IV. Deterministic IVs are safe only if no IV repeats under one key, and that is why the key matters more than the IV here. Each message carries a fresh 256-bit message id, and the GCM key for the message is derived from the data key and that id with HKDF-SHA512. So every message has its own GCM key, even when the materials cache hands two messages the same data key, and the frame counters of two messages never meet.
Random IVs for the wrap (section 8.2.2). The scope's key wraps each data key with AES-GCM under a random 96-bit IV. That is the RBG-based construction, and it is where the next limit bites.
The invocation limits (section 8.3). Under the RBG-based construction, one key may be used for at most 2^32 invocations; past that, the chance of two random IVs colliding is no longer negligible, and two encryptions under one key and one IV leak the XOR of their plaintexts and let an attacker forge tags under that key. Every wrap under one scope key version is one invocation. With the cache off, which is the default, every encrypt wraps a fresh data key, so a scope key version must be rotated before it has encrypted 2^32 messages; with the cache on, each wrap covers up to the cache's message bound, which stretches the same limit. That is why the security model calls rotation cadence a security parameter rather than tidiness: a cadence that keeps every version far below 2^32 wraps is the guidance, and the rotation runbook is how to carry it out. For the deterministic frame IVs, section 8.3 lifts the invocation limit, and the bound is the IV space the format gives the frame counter: at most 2^32 - 1 frames per message, the 32-bit sequence number with its last value reserved to mark the final frame.
The engine has no guard on that frame count today. It is a known gap, and out
of practical reach through this package: encrypt/2 takes the whole plaintext
as one binary, and reaching the limit at the default 4096-byte frame would take
a plaintext of about 16 TiB.
Pinned by: the moduledoc and the tests of
test/encryptor/gcm_construction_test.exs:
"the raw AES provider info records a 12-byte IV and a 128-bit tag", "the header
tag verifies under the all-zero 96-bit IV", "frame i is encrypted under IV i,
with a 16-byte tag", "every message carries its own 256-bit message id and its
own GCM key", "cached data keys still give every message its own GCM key", and
"10_000 wraps show no repeated wrap IV", which is a smoke check that wrap IVs
are not repeating, not a proof of the 2^32 bound.
Known defects in the engine
Four defects in the 1.0.x engine bore on the claims above, and
aws_encryption_sdk 1.1, which this package requires, fixes all four. They
are listed here because a threat model that only lists strengths invites the
reader to find the weaknesses alone, and because messages written before the
fix are still read.
The materials cache skipped the context comparison
(issue #96).
The 1.0.x engine compared the reader's context with the message's inside its
default materials manager, which sits below its caching materials manager. On
a decryption cache hit the default manager was never called, so a second read
of the same message under a disagreeing context would succeed. This package
does not rely on that comparison: the vault parses the header and compares the
context itself, above the cache, before it calls the engine
(the agree step in
lib/encryptor/vault/decrypt.ex), for every key the header
stores. The 1.1 engine checks a cache hit against the reproduced context too,
so a read through a vault no longer shows which of the two refused a swap;
the test that calls the vault's comparison directly, "agree/4 refuses a stored
pair the reader disagrees with, before the engine", is the one that goes red
when it is removed. For a required key, which the 1.1 engine does not store,
the engine's check is the one that holds (ADR-0004 Amendment B, B2).
ADR-0004 decision 6 records why.
The engine stored required context keys in the header. The AWS Encryption
SDK specification says the header's stored context must not contain the keys
the materials list as required: a required key is authenticated, not stored.
The 1.0.x engine stored it as well, and read a conforming message's data key
under the stored context only. Every message a :scoped vault writes has a
required key, scope_ref, so :scoped messages did not interoperate with the
other SDKs, in either direction. The 1.1 engine stores no required key, and
appends a reproduced key the header does not store before it unwraps; this
package moved to it under ADR-0004 Amendment B, proposed: messages written
before the change keep decrypting and rekeying, the vault keeps its own
comparison for every stored key, and rekey/2 takes the required pairs the
header does not store from whoever owns the row. A 1.0.x reader cannot read
a message the 1.1 engine writes with required context.
The signature verification key was written uncompressed. On a signing suite, 0x0578, this package's default, the specification requires the ECDSA P-384 verification key in the message's context in compressed form; the 1.0.x engine wrote the uncompressed point, which another SDK that decodes only the compressed form cannot read. The 1.1 engine writes the compressed form and reads both. This was an interoperability defect, not a weakness in the signature: the key is the same point either way.
The engine returned a parsed message for trailing bytes. Given a valid
message followed by extra bytes, the 1.0.x engine returned the parsed message
and the rest, a shape outside its own contract. Through 0.7.0 the vault
crashed on it; from the release after 0.7.0 the vault refuses it with
:decrypt_failed, on decrypt/2 and on rekey/2, and the 1.1 engine answers
it inside its contract, with {:error, :trailing_bytes}.
The interop test asserted each of the two cross-SDK failures with its exact error while the engine had them, and asserts every direction passing now. The README's Compatibility section says the same for readers of the package page.
What was tested against what
The evidence is only as good as what it was compared with, so here it is with versions and pins.
Wycheproof, from C2SP/wycheproof at
commit 12fd3aaf33eb5fa1f52e026912ee00c054f9d984, four files checked in
unedited with their licence and their SHA-256 sums
(test/fixtures/wycheproof/README.md).
Every case runs, and each test writes its case counts literally, so a truncated
or swapped file fails rather than passing with fewer cases.
- HKDF-SHA256, 86 cases (83 valid, 3 invalid), through this package's own
Encryptor.Kdf.extract/2andexpand/3. - AES-GCM, 316 cases, through the engine's
AwsEncryptionSdk.Crypto.AesGcmencrypt/5anddecrypt/6: the 197 with a 96-bit IV run both ways (116 valid, 81 invalid), and the 119 with any other IV length are asserted refused by the wrapper's guard. This package has no AES-GCM code of its own; the primitive under the wrapper is OTP:crypto, and so OpenSSL. - HKDF-SHA384 and HKDF-SHA512, 83 cases each (80 valid, 3 invalid), through
the engine's
AwsEncryptionSdk.Crypto.HKDFextract/3andexpand/4. SHA-512 is what the committing suites derive each message key with.
The engine functions run at the aws_encryption_sdk version mix.lock pins.
Wycheproof cannot cover the message path itself, because the vault picks its
own IVs and a vector cannot fix them; the GCM construction test covers that
path instead, by reading the bytes.
The AWS Encryption SDK decrypt vectors, from
awslabs/aws-encryption-sdk-test-vectors
at commit b6a6c91e62cc67f891b5dc3d11b0f047d10baf76, the python-2.3.0 zip,
fetched by a CI job that checks the zip's SHA-256 before it unzips it and
fails, rather than skips, when the corpus is absent. Two sets are out of scope
and are excluded by name: the RSA vectors, because this package has no RSA key
descriptor, and the KMS vectors, because they need cloud credentials. The job
is the aws-vectors job in
.github/workflows/ci.yml,
and its test,
test/encryptor/vectors/aws_vectors_corpus_test.exs,
checks the corpus is the one reviewed.
Another SDK. The python-interop job runs the AWS Encryption SDK for
Python 4.0.7 with the Material Providers Library, on Python 3.12, every package
pinned by version and hash, with a key generated for the run and no AWS account
involved. Messages cross in four directions at suites 0x0478 and 0x0578, for a
:single and a :scoped vault, and every direction passes
(test/encryptor/interop/python_interop_test.exs).
The construction itself, against SP 800-38D, in the GCM construction test
above; the key derivations, against RFC 5869 and a pinned Argon2id
known-answer vector; and the HMAC and SHA-256 primitives under the scope
reference and the cache partition ids, which are OTP :crypto's and are not
vector-tested here.
The gate runs on Erlang/OTP 27, and the test suite also runs on Erlang/OTP 26.
A note on hackney 1.x
From aws_encryption_sdk 1.0.1, the engine's optional AWS KMS client stack
accepts hackney 1.x as well as hackney 4.x, so a host already on hackney 1.x
can resolve this package. That line carries published advisories that are
fixed only in hackney 4.0.1 and later: GHSA-gp9c-pm5m-5cxr (high: a SOCKS5
connection's TLS upgrade ignores the caller's timeout), GHSA-pj7v-xfvx-wmjq
(medium: an SSRF allowlist bypass through a percent-encoded host),
GHSA-j9wq-vxxc-94wf (medium: CR/LF injection in query parameters) and
GHSA-mp55-p8c9-rfw2 (low: CRLF injection through the cookie domain and
path options). mix deps.get reports them when a host resolves hackney 1.x.
They concern the host's own choice of HTTP client, and the engine's KMS
requests reach none of them: they use no cookie options, no caller-built query
strings, no proxy allowlist and no SOCKS5 proxy, as the engine's 1.0.1
changelog states under Security. Raw-keyring use pulls in no HTTP client at
all. Even so, hackney 4.x stays the recommended choice, and a host that can
move to it should. The hackney-1x CI job proves a hackney 1.x host still
resolves and compiles this package; it does not make the advisories go away.
What this is not
This threat model and the evidence behind it are a self-review: written and reviewed by the maintainer, who is the team's security lead, and checked by an LLM adversarial pass with fresh context. That is not a third-party audit and not an independent engineer's review. Tested against published vectors, self-reviewed, no formal third-party audit: that is the honest summary, and a reader whose risk calls for more than that should commission it. The decision records hold the reasoning behind every choice on this page, with the alternatives that were weighed, for whoever does.