Encryption at rest
Published
An envelope-encryption client is configured almost entirely by narrowing what it would otherwise do: a specific master key rather than the ring's default, an authenticated cipher rather than a confidentiality-only one, a context that binds a ciphertext to the tenant it belongs to, a ceiling on how many messages share a data key, and a provider that fetches keys from a vault. All of those are property names on an options object. We protected a file that configures one, renamed the names a group at a time, and let real node crypto decide what happened.
What the sample actually does
The file seals an invoice for one tenant and then tries four things against it. A second tenant asks the same client to open it, which is the multi-tenant case and the reason an encryption context exists. The ciphertext is edited in transit in the least exotic way available - two bits of the initialisation vector, which under a chained block cipher rewrites known bytes of the first plaintext block and leaves everything after it untouched. The legitimate owner opens it. And five more invoices are written, to see how many data keys the ceiling actually buys.
There is no simulation in the cryptography. The client calls node's own crypto with a fixed key and a fixed initialisation vector so the run reproduces exactly, and the acceptance or refusal of a tampered ciphertext is decided by the platform rather than by the fixture. The client itself is copied in unprotected, with the defaults this family of libraries ships: the ring's default key, no context, a confidentiality-only cipher, one data key reused forever, and a key compiled into the package.
Protection alone was applied first on five presets and all five behaved identically to the original, so nothing below is caused by protection on its own.
One renamed option turned an authenticated cipher into an unauthenticated one
This is the sharpest result of the pass and it is worth reading twice. Renaming the option that names the algorithm took the client from AES-256-GCM to its default, AES-256-CBC. Both are AES. Both are 256-bit. One of them tells you when a ciphertext has been edited and the other cannot.
The edited ciphertext was accepted. It decrypted to valid JSON. The amount field, which the original run refuses to produce at all, came out as 900 instead of 100 - the exact change the two flipped bits were aimed at. The client's own report moved from an authenticated verdict to integrity=unverified, which is an honest thing for it to say and not a thing anybody reads. The application's summary line went from tamper detected to not detected, and the payment it would have made went up by a factor of nine.
Nothing about this requires the attacker to hold a key. Editing a ciphertext is not decryption. It is the reason authenticated encryption exists, and the reason every modern guide says to use a mode that authenticates. The application in this measurement did use one. It stopped using one because a property name moved.
The same rename also switched off a guard it has nothing to do with
The encryption context is what binds a ciphertext to the tenant it belongs to, and this application configures one. In the unprotected run the second tenant's attempt to open the first tenant's invoice is refused.
On the algorithm arm, that refusal disappeared too. The second tenant read the whole record - the amount, the reference, and the bank account it pays into. The context option was never touched. It was still set, still populated, still visible in the code. It simply has nothing to attach to in an unauthenticated mode, because additional authenticated data is a property of authenticated ciphers.
This is the shape that an option-by-option audit cannot see. Asking what the default of each option we set is will tell you that losing the algorithm costs you integrity. It will not tell you that losing the algorithm also costs you the tenant binding, because that cost is paid by a different option that you did not lose. Losing one name deactivated a guard that was still correctly configured.
Renaming the context option on its own does the same disclosure by the direct route: the reported context went from the tenant and purpose pair to bound to nothing, and the cross-tenant read succeeded with the tag still verifying, because there was nothing left to disagree about.
The key you get when you do not name one
Renaming the key identifier moved the client onto the ring's default, which in this measurement is exactly what it is in most real rings: an old shared key kept alive so that historical ciphertexts still open. New records were sealed under it. That key is held by a decommissioned partner integration in the sample and by whoever held it in yours.
Renaming the key PROVIDER is the caller-supplied-guard shape this series has now measured in five separate libraries. The application hands the client a function that fetches the master key from a vault and refuses a key the platform has rotated out. With the name gone, the client used its own fallback: a key compiled into the package, identical in every deployment that installed the same version, never rotated. The reported source moved from the vault to an embedded package default, and the reported rotation enforcement moved from true to false. Everything still encrypts. Everything still decrypts. The key is now a constant somebody can read out of a published tarball.
The data-key ceiling is the mildest arm and still worth pinning. With it, six messages used six data keys. Without it, six messages used one, and the client reported reuse forever. A ceiling on data-key reuse is what limits the blast radius of one compromised key to one message.
A detector that read false while the system was safe
The arm that renames the client's RESULT fields - the ok flag, the reason, the reported integrity - produced something worth keeping. Every one of them read undefined, so the application's tamper check, written as a comparison against false, printed that no tampering had been detected.
The tampering had been detected. The decrypt genuinely refused, the plaintext genuinely never appeared, and the application genuinely did not pay. The system was safe and its own alarm said it had nothing to report.
That is the mirror image of a result from the previous pass, where a pipeline reported that it was carrying metadata into an output that in fact had none - a report wrong in the safe direction. Both are the same lesson from opposite sides: once a result field's name has moved, the summary line you built out of it is no longer evidence about anything, in either direction. If you keep one assertion from this article, make it the one that reads the value the dependency returns rather than a boolean your own code derived from it.
One option in this area was pinned to a value identical to the library default as a control, and renaming it changed nothing, exactly as predicted.
What to do about it
The mechanism is the same one this series keeps measuring. Member renaming rewrites property names inside the code it is given; the client reading those names came out of a package that was not rebuilt, so a renamed option is simply absent and the documented default applies. What makes cryptography different is that the default is not merely weaker - it is a different security property, and the code that asks whether encryption is enabled gets a yes on every arm here.
Scope the renaming. The options object handed to an encryption client, the context object it carries, and the field names of anything you seal all belong outside the MemberRegexp. If you would rather not maintain that list, build the options with quoted string keys and read them back with literal bracket access.
Then assert the algorithm at start-up, by name, from what the client reports rather than from what you passed. Add a self-test that seals a known record, edits one byte, and requires the decrypt to refuse. That single test fails on the algorithm arm, on the paired arm and on the whole-block arm, and it costs a few milliseconds at boot.
Finally, treat an encryption context as a required argument rather than an option. A helper that will not encrypt without one cannot silently start producing unbound ciphertexts, whatever happens to the property name it eventually becomes.
Frequently asked questions
Does protecting my JavaScript break encryption on its own?
Not in this measurement. The sample was protected on five profiles covering both output targets, the gate profile and the compressed profile, and all five behaved identically to the unprotected file. Every result required member renaming pointed at the option names.
What exactly happened on the algorithm arm?
The client fell back to its default, an unauthenticated block cipher. A ciphertext edited in transit was accepted and decrypted to an attacker-chosen amount, nine times the original, and the application's tamper check reported nothing wrong.
Why did the encryption context stop working when only the algorithm name was lost?
Because a context is additional authenticated data, and only an authenticated cipher authenticates it. In an unauthenticated mode there is nothing to bind the context to, so it is silently ignored even though the option is still set correctly.
Is this a weakness in AES?
No. It is the difference between an authenticated mode and a confidentiality-only one, which is exactly why current guidance says to use the former. The application had chosen correctly; it stopped applying that choice because a property name moved.
What happens if the key identifier is renamed?
New records are sealed under the ring's default key, which in most rings is an older shared key kept alive so that historical ciphertexts still open. In the sample that key is one a decommissioned partner still holds.
Our monitoring says encryption is enabled. Is that enough?
No. Every arm in this article reports encryption as enabled, because it is. What distinguishes them is the mode, the key and the binding, so the assertion has to name those specifically and read them back from the client rather than from your own configuration object.
What is the recommended fix?
Exclude the encryption options object, the context it carries and the field names of sealed payloads from member renaming, then add a boot-time self-test that seals a record, edits one byte and requires the decrypt to refuse. Make the encryption context a required argument rather than an option.
Related reading