handing off data without trusting anyone
handing off data without trusting anyone

For the last couple of years I've been building privacy-first software - the kind where the whole selling point is that no server ever sees your data. It lives on your device, encrypted, and only you hold the keys.
That sounds great right up until you hit a very normal problem: what happens when you need to give access to that data to someone else?
You can't just upload it somewhere - that breaks the entire promise. There might not even be internet in the room. And whatever you send has to stay safe not just today, but years from now, when the computers trying to crack it are a lot more capable than today's.
This post is about a system that can do this: moving sensitive data from one device to another with no server in the middle, in a way that's safe at rest, safe in transit, provable, and revocable. Four properties that we'll discuss one at a time. I'll keep it high-level - enough to see how things work.
keeping data safe
Before you can send data anywhere, it has to be safe in your own device. This is the easy part, but everything later is built on top of it, so it's worth taking a look at.
The workhorse is AES-256-GCM. It's symmetric encryption - the same key locks and unlocks - and the "GCM" part means it's authenticated: it doesn't just scramble the data, it also stamps it so any later tampering is detectable.
But one key for everything is fragile - lose it and you lose the whole vault at once. So instead you give each item its own key and keep those small keys under one master key. One leak doesn't burn down the house.
And where does the master key come from? Not from a file sitting on disk. It's derived from your passkey / biometrics through a deliberately slow function (Scrypt). Slow on purpose - it makes brute-force guessing hopeless.
# each item gets its own key; a derived master guards them all
dek = random(32)
ciphertext = aes_256_gcm.encrypt(dek, data) # scrambled + tamper-evident
master = scrypt(passkey_secret, salt) # slow on purpose
wrapped_dek = aes_256_gcm.encrypt(master, dek) # the small key, locked awayThe shape of it: the master key never unlocks your data directly - it only guards the small per-item keys, and it comes from you.
future-proofing attacks
Here's the uncomfortable bit. AES is fine. But the way two devices agree on a key in the first place usually leans on classical public-key crypto - RSA, elliptic curves - and that stuff has an expiry date.
A big enough quantum computer breaks it. Nobody has one yet, but attackers don't need one yet either, because they can run a patient attack called harvest now, decrypt later: copy your encrypted data today, sit on it for a decade, and crack it once the hardware shows up.

If your data stops being sensitive in a week, who cares. But plenty of data is still sensitive ten years from today. For that data, "secure today" simply isn't good enough.
two locks on one door
The fix is post-quantum cryptography - newer algorithms (like ML-KEM, which you might know by its old name, Kyber) designed to survive a quantum attack.
But there's a catch, and it's a fun one: this new crypto is new. It hasn't taken the decades of public beating that RSA has. One of the post-quantum finalists, SIKE, got broken on a single laptop in 2022. So trusting it alone is its own kind of gamble.
That's why we'll put two locks on one door - the classic one and the quantum-safe one - from two completely unrelated branches of math. An attacker now has to pick both. Break one, the other still holds. This is called a hybrid scheme, and it's how you ship post-quantum crypto without betting everything on the new stuff being flawless.

The clever part is how you combine them. You run both key exchanges, get two shared secrets, and blend them into one final key through a KDF:
# wrap the key with TWO locks - classical + post-quantum
ss_classical = x25519(eph_sk, device_pk) # battle-tested
ct, ss_quantum = ml_kem_768.encapsulate(device_pq_pk) # quantum-safe
wrap_key = hkdf(ss_classical + ss_quantum) # safe if EITHER holdsYou concatenate the two secrets and run them through HKDF. The whole point is that the result is only as breakable as the stronger of the two. If one secret turns to garbage because its algorithm got broken, the other still makes the output impossible to guess.
sending it: two strangers, one secret
Now let's talk about the actual handoff part, since the data's been locked down locally.
The core problem is almost philosophical: two devices that have never met need to end up holding the exact same secret key, and they have to manage it over a channel anyone could be watching. How do you agree on a secret out in the open?
This is one of the oldest tricks in modern crypto. Both sides generate a keypair, swap the public halves, and each mixes their own private half with the other's public half. The math works out so they both land on the identical shared secret - but someone watching the exchange can't reconstruct it. (It's the same hybrid handshake from before, just run live between two devices instead of against data at rest.)
And you make the keys temporary - brand new ones for every single transfer. So even if someone steals today's key, it's useless against anything you sent yesterday.
# both sides reach the SAME session key; a watcher learns nothing
ss_c = x25519(my_eph_sk, their_pk)
ct, ss_q = ml_kem_768.encapsulate(their_pq_pk)
session_key = hkdf(ss_c + ss_q) # ephemeral keys -> forward secrecyLaid out as a back-and-forth, the handshake looks like this - and notice the session key itself is never sent across:
but who are you actually talking to?
There's a hole in that story. The handshake guarantees you share a secret with someone - it doesn't prove that someone is the right person. An attacker could sit in the middle, run a handshake with each side, and quietly relay everything between them. The classic man-in-the-middle attack.
The defense is wonderfully low-tech. After the handshake, both devices show a short code derived from the shared secret - say six digits. The two humans glance at each other's screens. Same number? Good - there's no one in the middle, because a middleman would have produced two different secrets, and therefore two different codes.
verify_code = sha256(session_transcript)[:6] # both screens show it
# numbers match on both phones -> no middlemanOne more cheap win: the handshake carries a timestamp and expires fast. A stolen QR code or a replayed message is useless 30 seconds later.
the part with no internet
Now we can actually move bytes - and this is my favorite part, because the data never has to touch the internet at all.
The system tries channels in order, most-private first:
- direct Wi-Fi / LAN - the two devices talk straight to each other over a fast QUIC connection. No third party ever sees a packet.
- a relay - if they can't reach each other directly, bounce through a dumb public relay. It only ever sees encrypted bytes, never the key.
- and if there's no network at all - two genuinely wild fallbacks:
- animated QR codes. One phone flashes a stream of QR frames, the other films them. It uses fountain codes, which are "rateless" - the sender just keeps pouring out frames and the receiver drinks until its cup is full, no back-channel needed. Like filling a glass from a tap you never have to ask to stop.
- sound. The data gets encoded as audio tones - think of an old modem screech, or frequencies too high to hear - and the other phone simply listens. Two devices, no network, no cables, passing secrets through the air.
Whatever channel it takes, the rule never changes: every chunk is encrypted with the session key, and signed, so the receiver knows it genuinely came from the sender and wasn't swapped out mid-flight.
for chunk in data: # over wifi, QR frames, or sound
frame = aes_256_gcm.encrypt(session_key, chunk)
send(frame, sig = ml_dsa_65.sign(sender_sk, frame)) # signed = authenticdid all of it actually arrive?
Sending is one thing. Knowing the whole thing landed - intact and untampered - is another.
Two layers handle this.
First, integrity per chunk. Remember the "GCM" in AES-256-GCM: every chunk carries an authentication tag. Flip a single bit in transit and the tag check fails, so the chunk is rejected rather than silently accepted. And a hash over the entire payload catches the other failure mode - a chunk that quietly went missing.
assert aes_256_gcm.verify_tag(frame) # one flipped bit -> rejected
assert sha256(all_chunks) == expected_hash # one missing chunk -> caughtSo corruption and drops are both impossible to miss. But there's a subtler question: can you prove, later, that the transfer happened - the right data, between the right two parties, at a specific time?
proving it happened
For that you build a receipt. You hash the facts that matter - who sent, who received, a hash of the data, the timestamp - into a Merkle tree and sign the root.
root = merkle_root(sender_id, receiver_id, data_hash, time)
receipt = sign(receiver_sk, root) # tamper-evident, undeniableThat signature buys two things. It's tamper-evident - change any single detail and the root no longer matches. And it gives you non-repudiation - the sender can't later claim "that wasn't me", because only their key could have produced that signature.
proving without revealing
Sometimes you need to prove a fact about the data without showing the data. Normally, proving a statement means handing over the evidence - which is exactly the thing you were trying not to leak.
Zero-knowledge proofs let you prove the statement is true while revealing nothing else. The receiver gets a small cryptographic proof they can check that says "yes, all of that holds" - and learns precisely zero about the underlying data along the way.
prove("data came from an authorized, attested device")
# the proof verifies as true - and reveals nothing about the data itselfIt's the closest thing cryptography has to a party trick, and it's genuinely useful: trust, without disclosure.
the time bomb
Last property: revocable. The data moved to the other device, so the sender's old copy shouldn't live forever.
The elegant way to kill data isn't to delete the data. It's to delete its key.
Remember, everything was encrypted under a per-item key. Destroy that one small key and the ciphertext instantly becomes a blob of meaningless noise - unrecoverable, by anyone, forever. This is called crypto-shredding, and it's far stronger than hitting delete.
expire_at = now + ttl
if now > expire_at:
secure_wipe(dek) # ciphertext is now permanent noiseWhy stronger? Because "delete the file" is a lie we tell ourselves. There are backups, there's undelete, there's forensic recovery - a deleted file leaves a trail. But a keyless blob of AES-256 ciphertext isn't recoverable by any of them. No key, no data. You can even put it on a timer, so the sender's copy expires on its own and ownership has truly, permanently moved.
four properties, one pipeline
Step back and that's the whole thing:
- at rest - every item under its own AES key, with the master key derived from your passkey.
- in transit - a hybrid classical + post-quantum handshake, fresh keys every time, a human check against middlemen, and channels that work with zero internet.
- provable - per-chunk integrity, a signed Merkle receipt, and zero-knowledge proofs for the things you can't show.
- revocable - shred the key, and the old copy is gone for good.
The whole journey, end to end:
One honest catch: going post-quantum isn't free. The keys and signatures are chunky - a quantum-safe key runs over a kilobyte and a signature can be ~3KB, where the classical versions are measured in dozens of bytes. That's a real cost when you're trying to cram a handshake into a QR code or a burst of sound. But it's the price of data that's still safe in 2040, and for the stuff that matters, it's worth paying.
None of the individual pieces are exotic. The interesting part is how they stack - each one covering a gap the last one left open - until you've got something genuinely solid: data you can hand to someone across a table, trusting nobody and nothing in between.
Thanks for reading!