Most "post-quantum" features I run into are a switch you have to find and flip. A
checkbox in settings, an opt-in beta, a separate "secure mode." I wanted the
opposite: the default at-rest cipher for every new file is post-quantum, and you
have to go out of your way to get anything weaker. This is a write-up of what
that actually took, the parts that were clean, and the parts that bit me. The
format is the on-disk format for ShieldFive, an encrypted storage thing I build,
but none of what follows is specific to the product. It's a file format, and the
whole spec and test vectors are public, so you can check every claim here against
the source.
Why bother, when nobody has a quantum computer
The honest reason is "harvest now, decrypt later." An adversary who can store
ciphertext today can decrypt it whenever a cryptographically relevant quantum
computer shows up. For data with a long secret lifetime, the clock that matters
is not "when does the quantum computer exist" but "how long does this file need
to stay secret." A file you upload today might need to stay private for fifteen
years. You don't get to make the post-quantum decision then. You make it now, or
not at all.
So the bar I set was: a file written today should survive a future quantum
computer for as long as its classical AEAD stays secure, with no action from the
user. That means post-quantum has to be the default suite, not a mode.
The part everyone gets wrong: hybrid is not "encrypt twice"
The most common misconception I see, including from smart people, is that hybrid
crypto means encrypting the data twice, once with a classical algorithm and once
with a post-quantum one. Two locks in series.
That intuition is right about the security you want and wrong about the
mechanism. You don't encrypt twice. You run two key exchanges and combine their
outputs into one key.
Here is what actually happens for a new file. The default suite (call it suite
0x03) does two things to establish a key:
- A classical share. A random 32-byte secret
S_c, wrapped to the recipient's classical key. - A post-quantum share. An ML-KEM-1024 encapsulation against the recipient's
ML-KEM public key, which yields a ciphertext and a 32-byte shared secret
S_pq.
Neither secret encrypts the file directly. Both go into a KDF along with the
file's random identifier:
K = HKDF-SHA-256(
ikm = S_c || S_pq,
salt = file_id,
info = "shieldfive/v1/pq-hybrid/combine",
L = 32)
The file is then encrypted once, with K, using a normal AEAD
(XChaCha20-Poly1305). The security falls out of the KDF. Its output is
unpredictable as long as at least one of the two inputs stays secret. An
attacker with a quantum computer breaks the classical side and recovers S_c,
but without S_pq they cannot compute K. If ML-KEM turns out to have a flaw,
the classical share still covers you. Either leg standing holds the whole thing
up. Formally the construction is IND-CCA2 against an adversary who breaks one
primitive but not both.
Why not literally encrypt twice? It's slower, it doubles what can go wrong in
the bulk-cipher layer, and the combiner has actual security proofs behind it
while ad-hoc nesting does not. The IETF's X-Wing (mlkem768x25519) is the
standardized version of this idea. My format uses the same shape with ML-KEM-1024
for NIST level 5.
The wire format decisions that actually mattered
The combiner is the interesting cryptography. The format is where most of the
real engineering went, and where I made mistakes I had to design around.
Self-describing or it isn't real. The first goal was that a reviewer with
only the encrypted blob and the key, and no out-of-band metadata, can decrypt the
file. If you need a database row to know how to read your own ciphertext, your
"export" is a hostage situation. So the header carries everything: a 5-byte magic
(SF5\x01\x00), a one-byte suite identifier, the file id, chunk sizing, and the
suite-specific payload. One byte picks the cipher suite, so adding a suite later
never changes the parser.
Position has to be authenticated, not checked in application code. Each chunk
is an AEAD encryption whose associated data includes the chunk index, the total
chunk count, and a final-chunk flag. That means truncation, reordering, and
splicing chunks between two different files are caught by the authenticator
itself, not by some if statement you might forget. If someone lops off the last
chunk, the decrypt fails, it doesn't silently return a shorter file.
The size cost is real and you should say so. This is the part PQ advocates
tend to skip. An ML-KEM-1024 ciphertext is 1568 bytes. The full suite payload for
a hybrid file is 1664 bytes of key material in the header of every single file.
For a 4 KB text file that's a noticeable tax. For the photos and documents most
people store it's noise. I decided the tax was worth paying by default, but
"version it and move on" stops being free once your key material is measured in
kilobytes, and a self-describing format earns its keep faster than you'd expect
once the header is that big.
The subtle one: decrypting bytes you haven't authenticated yet
Here's the problem that took the most thought. The header has a MAC over its own
contents, keyed by the combined key K. But to compute K you have to
decapsulate the ML-KEM ciphertext, and that ciphertext is sitting right there in
the header as unauthenticated, attacker-influenced bytes. You are running a
cryptographic operation on input you have not verified. That should make you
nervous.
It's safe, and the reason is worth spelling out, because the safety rests
entirely on three properties holding at once:
- ML-KEM-1024 is IND-CCA2-secure (FIPS 203). Feeding it a malformed or adversarial ciphertext does not leak the encapsulated secret and does not leak your secret key. A modified ciphertext just produces a different shared secret that's indistinguishable from random.
- The classical share is unwrapped with an AEAD. Tamper with those bytes and the tag check fails before any plaintext comes out.
- The recombined key is verified by the header MAC. To pass it under a modified header you'd have to forge an HMAC-SHA-256 tag under a key you don't know, which contradicts HMAC's unforgeability.
So an attacker who pokes at the header either gets a different K and fails the
MAC, or fails the AEAD tag check on the classical share, or learns nothing thanks
to IND-CCA2. In no path does plaintext leak. The lesson I took from this: "don't
process unauthenticated input" is a good rule of thumb, but the precise version
is "don't process unauthenticated input with a primitive that leaks under chosen
ciphertext." IND-CCA2 is exactly the property that lets you decapsulate first and
authenticate after.
Sharing without re-encrypting the file
When you share a file, you don't want to re-encrypt every chunk for the new
recipient. So the owner recovers the file's combined key K and re-wraps just
K to the recipient. The thing I had to be careful about was downgrade attacks.
An early version of my share bundle had no version marker and authenticated only
K, not the post-quantum material around it. That's the kind of gap where an
attacker strips or swaps the PQ part and you don't notice.
The fixed version carries its own magic and version byte, and the AEAD that wraps
K authenticates the entire bundle prefix as associated data: the magic, the
length field, the PQ payload, and the nonce. Now you cannot substitute, strip, or
downgrade any of it without breaking the tag. The wrapping key is also
domain-separated from the file key with its own HKDF label, so a share key can
never accidentally equal a file's content key. None of this is exotic. It's just
the kind of binding that's easy to leave out and painful to add later.
What I haven't solved
Being honest about the edges is the whole point of writing this, so:
There is no post-quantum signature in production. The format defines an optional
Ed25519 signature block for sender attribution, and reserves a slot for ML-DSA,
but I haven't shipped the PQ one. This mirrors the wider state of things:
hybrid KEMs are a clean, solved story, and hybrid signatures are genuinely hard
(they lose BUFF security and none of the options are nice). Encryption got the
easy migration. Authentication is still the hard part for everyone.
Share links to anonymous recipients are classical-only. An anonymous recipient
has no public key to encapsulate to, so there's nothing to make that path
post-quantum. The owner's stored file is still hybrid; the anonymous share leg
isn't. I'd rather state that than imply a guarantee I can't make.
And there's no external audit yet. The review so far is my own. That's exactly
why the crypto is a standalone Apache-2.0 library
(@shieldfive/crypto on npm,
source on GitHub), the same code that runs
in the browser, so you can read it and diff the published package against what
gets served to you instead of trusting my say-so.
Poke at it
The full format spec
(spec/format-v1.md),
including the wire layout, the per-suite derivations, and the security argument
for pre-MAC decapsulation, is public, and there are bit-for-bit test vectors
committed to the repo (188 tests covering tampering, truncation, splicing, and
reordering). If you want to break something, the two
places I'd most like eyes on are the combiner and the share re-encapsulation.
Real findings are worth more to me than polite ones.
If you build crypto into products, I'd genuinely like to hear how you've handled
the "post-quantum by default vs opt-in" call, and whether you think the size tax
is worth it. That's the decision I'm least certain I got right.
Top comments (0)