The threat model: what is protected, from whom, and how we know

Copy Markdown View Source

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 :scoped vault 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/2 and expand/3.
  • AES-GCM, 316 cases, through the engine's AwsEncryptionSdk.Crypto.AesGcm encrypt/5 and decrypt/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.HKDF extract/3 and expand/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.