Duy T. Nguyen

Transferable Asset Encryption

Have you ever tried to encrypt something that also has to change hands? Encrypting a file at rest is the easy part - pick an algorithm, pick a key, done. The part that actually took us weeks of arguing was that these files aren't just data, they're assets. A user uploads one, someone above them in the org needs to be able to open it, and ownership itself can move - get reassigned, get revoked - without the file ever being decrypted and re-encrypted along the way. Three different designs got seriously built out before we picked one, and the two we didn't pick are worth telling honestly, because both were wrong in genuinely interesting ways, not obviously-wrong ways.

The starting constraints were fixed. Files land in object storage, metadata lands in a database, and a dedicated service sits in front of both - call it upload-service and download-service here, they're really the read and write sides of the same thing. Master key material lives in an HSM. Every asset needs its own encryption key, not a key it shares with a thousand other files, because a compromise should cost you one file, not your history.

Here is the whole argument on one page before any of the detail, because the three candidates are easiest to hold side by side:

Where the key comes fromCost of one leakCost of a transferCost of an org change
1. Shared tenant DEKone key per tenantevery file, everfreefree
2. Derived from the org chartHKDF down the reporting lineone subtreeneeds a second mechanismre-encrypt the whole subtree
3. Per-asset DEKfresh random key per fileone filefreefree

Candidate 3 is the one that shipped. The two columns on the right are where candidate 2 loses, and they're the interesting part - because candidate 2 is the one that looks best on a whiteboard.


The first question wasn't about keys at all

Before any of the three candidates, there was a smaller fight: which service actually does the encrypting? The textbook-correct answer is to have security-service - the one component that talks to the HSM - do the encryption itself, so key material never has any reason to leave it. We tried that on paper and rejected it fast, for a boring but real reason: every file upload and download would have to stream its full bytes through security-service too, on top of whatever it was already doing. A 500MB upload doesn't care how elegant your key isolation is if it now has to make two hops through a service that was never sized for file traffic. We moved encryption into upload-service/download-service themselves - they touch the file anyway - and left security-service responsible only for key material. That decision is what made the next three candidates possible to compare fairly: all three assume encryption happens at the edge, and differ only in how the key gets there.


Candidate 1: one key per tenant

The simplest version: every user in an enterprise shares one DEK. Moving a file between two people in the same enterprise is just a permission check - no re-encryption, nothing crypto-related at all, since the same key opens everything either of them owns. It's honestly a good design if the only requirement were "cheap and simple."

It fails the requirement that actually mattered to me: one DEK per asset. Everything in this candidate is one DEK per tenant, and that's a very different blast radius. security-service is the only thing that ever knows the key - but if security-service itself is ever compromised, or that one key leaks any other way, every file that enterprise has ever uploaded is exposed at once. The lab linked below proves this isn't hypothetical: it leaks a single shared DEK on purpose, decrypts 500 files with it, and 500 comes back. Not "would probably work" - it works, every time, because that's exactly what a shared key does.


Candidate 2: derive the key from the org chart

This one came from one of our more senior people, someone with a real background in cryptographic research - and it's the candidate I respect the most even though we didn't ship it. The idea: instead of storing a key per user, derive it. A user's key comes from a chain running down the org tree - root, then each ancestor, HKDF'd together - so a manager can reach a subordinate's key by walking the same path the tree itself defines. No separate access-control lookup needed for "can this person read that file" in the read direction; the hierarchy is the key derivation.

It's elegant on a whiteboard. Two things kill it in practice. First, deriving from something static about the file - its hash, say - breaks the moment two users upload the same file at the same time and get the same hash, which means the same derived key, which violates "each asset gets its own key" all over again. Second, and worse: since a node's derived key depends on who's occupying that seat, changing who occupies it - a promotion, someone leaving, a plain reorg - has to rotate that node's contribution to the chain. And rotating one node's contribution invalidates every derived key beneath it, because they all chained through the old value. Reassigning a single manager can mean re-encrypting every file that manager's entire reporting line ever touched.

The lab makes this concrete instead of asserting it: build a small org tree, derive one key per node with real HKDF, then rotate a single node near the root and count what breaks. In a 121-node tree, four levels deep, changing who sits at a node one level down from root invalidates 40 nodes' worth of keys - a third of the whole tenant, from one occupancy change. Change a leaf instead, and it's exactly 1. Same operation, wildly different blast radius, and the difference has nothing to do with what actually changed - only where in the tree it happened to be.

There's a category mistake underneath this, and it took me a while to see it clearly: this design put an authorization concern - who reports to whom - inside the cryptographic key-derivation layer. Org structure changes constantly and for reasons that have nothing to do with security. Wiring it directly into how keys are derived means every org-chart change becomes a crypto operation, cascading through however much of the tree sits below it. That's backwards. Whether someone is allowed to read a file is a completely different question from how that file's bytes get decrypted, and conflating them is what makes this candidate expensive in exactly the situations where you'd want it to be cheap.

There's a second gap in this design that's worth naming on its own, because it's not the same problem as the cascade above. Deriving a key by walking down the tree only ever gives you a path from an ancestor to a descendant - it has no natural answer for two people who aren't in each other's reporting line at all. Alice hands a file to Bob, a peer in a different department: there's no path connecting them for the derivation to walk, so this scheme alone can't produce a key Bob can use. It would need a second mechanism bolted on just for lateral transfer, on top of the mechanism that already needed a second look for reassignment. A design that needs two different answers for "read up the hierarchy" and "hand this to a peer" is carrying more moving parts than a design that treats both the same way - which candidate 3 does, because it never tried to make the hierarchy do the crypto's job in the first place.


Candidate 3: per-asset DEK, and the key never crosses the network unwrapped

The one that shipped keeps the two questions separate. Every file gets its own DEK, generated fresh in upload-service's memory and never stored anywhere in plaintext. security-service never touches it either - the DEK is wrapped with the Master KEK's public half entirely inside upload-service, no HSM call needed for that part at all, since wrapping with a public key doesn't require the private half. Only unwrapping needs the HSM, and only security-service can do it.

This is the same shape as the envelope encryption in the cryptographic-trust-engine case study - a long-lived key inside a hardware boundary, protecting short-lived keys that do the actual work outside it - just applied to a file's content key instead of a JWT signing key. Once you've built that pattern once, reaching for it again here wasn't really a new decision.

Written out, the two halves are deliberately lopsided - the write path never touches the HSM at all, and only the read path does:

Rendering diagram…

The asymmetry is the point: wrapping needs only a public key, so uploads never queue behind the HSM. Only unwrapping needs the private half, and only security-service has it.

What is new here is a piece the JWT case never needed: the DEK has to travel from security-service back to whichever service is decrypting a file, and I was genuinely obsessed with the idea that it should never do that as plaintext - not even though mTLS already protects that link. So the download path does one more thing: download-service generates a throwaway RSA keypair just for this request, sends the Encrypted_DEK plus its ephemeral public key to security-service, and security-service unwraps the DEK with the Master KEK - without ever reading its raw bytes into a variable - then immediately re-wraps that same opaque key object with the ephemeral public key before sending anything back. What comes back over the network is Session_Wrapped_DEK, decryptable only by the ephemeral private key that never left download-service's own memory. The Master KEK's private half is the only thing that ever unwraps a real DEK, and it does so without the DEK ever existing as bytes security-service could read, log, or leak.

Transferring an asset, under this design, costs nothing cryptographic at all - reassign the owner field, done. Reading up the hierarchy works exactly the way it does in permission-cache: the relationship graph that already knows who reports to whom decides whether a read is allowed; the crypto layer just answers "here's the wrapped key" to whoever auth already said could ask. Same separation candidate 2 was missing, just drawn in the right place this time.


Was the ephemeral rewrap actually necessary?

We kept it. mTLS already encrypts the link between download-service and security-service, and the case against paying more on top of that is real: a fresh RSA-2048 keypair, an extra request, an unwrap-then-rewrap inside the HSM, on every single download. But mTLS protects the wire, not everything that happens at either end of it. A misconfigured service mesh sidecar that terminates TLS and forwards plaintext internally, a debug log or APM trace that captures a response body, a future refactor that adds a caching proxy in between without anyone re-auditing what it's allowed to see - none of those are exotic, and all of them would expose a plaintext DEK riding on an otherwise-correct mTLS connection. The ephemeral rewrap means none of that matters, because there's never a plaintext DEK on that wire for any of those failure modes to catch. I'd rather pay a cost I can measure than leave a gap I can't see the edges of.

What made the decision easier to actually commit to was measuring the cost properly instead of eyeballing it. A cold access on ordinary hardware, no tuning, over four clean runs: 41, 85, 121 and 152 ms.

That spread is worth a sentence on its own, because a single number here would be misleading. Almost all of it is the ephemeral RSA-2048 keygen, and generating an RSA key means searching for two random primes - so the cost depends on how quickly suitable candidates turn up. It is genuinely variable, by a factor of nearly four, and the honest summary is "somewhere in 40-150ms, dominated by keygen rather than by the HSM." The HSM call itself is not the expensive part.

Either way it is real, and it is paid on every download if nothing else changes. So download-service caches the recovered DEK for five seconds after a cold unwrap - a decision, not an afterthought, and one that narrows the guarantee slightly: plaintext DEK now exists in download-service's own process memory for up to five seconds, instead of never existing outside the wrap/unwrap boundary at all. In exchange a cache hit costs 0.04-0.06ms, which puts it somewhere between 680 and 3,000 times cheaper than the cold path depending on how unlucky that cold run was - and the property that actually mattered in the original debate, DEK plaintext never crossing the network unwrapped, holds on a hit exactly as much as it does on a miss.

That's the actual verdict: the rewrap stays, because the failure modes it closes are real and the cold cost, paid once per asset per short window instead of once per download, is one I'm comfortable asking the system to pay.


What the lab actually proves, not just claims

The claim that plaintext DEK never crosses the network isn't just prose in this article - the lab has a NetworkBoundary that every request and response actually passes through, and it's told in advance what value must never appear crossing it unwrapped. Run the real protocol through it and it passes clean. Then the lab deliberately builds the naive alternative - unwrap the DEK and send the raw bytes back instead of re-wrapping them - and routes that through the same boundary. It gets caught, every time. That's the difference between asserting a security property and having something that would actually notice if the property stopped holding.

Building that check caught a real bug in the lab itself, worth admitting to: the test harness's own leak-detection setup aliased a buffer instead of copying it, so upload-service's cleanup code - correctly zeroizing the DEK it generated - was also zeroizing the test's own reference to that same value out from under it, silently disabling the entire check. One line fixed it (copy, don't alias), but it's the same category of mistake this whole design exists to guard against, and it showed up in the test code before it showed up anywhere that would have mattered.


Lessons

Don't let an authorization concern leak into a key-derivation scheme. Candidate 2's org-chart-shaped keys looked elegant right up until the org chart changed, which it always eventually does.

A security property worth claiming in prose is worth building a check for. NetworkBoundary turning "the DEK never crosses the network unwrapped" from a sentence into something that actually throws when violated is what makes that sentence trustworthy.

Measure the amortized cost before deciding, not just the worst case. The rewrap looked expensive as "pay this on every download" and reasonable as "pay this once per asset per short window" - it's the same operation either way, and the decision to keep it only felt solid once the number I was looking at was the right one.


Runnable reproduction

A complete, runnable lab implementing all three candidates - including a real PKCS#11 HSM (SoftHSM2) for candidate 3 and a NetworkBoundary that actually checks the no-plaintext-DEK claim instead of asserting it - is available here:

Related Knowledge Nodes

Related Notebook