<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Cho Garcia</title>
    <description>The latest articles on DEV Community by Cho Garcia (@chogarcia).</description>
    <link>https://dev.to/chogarcia</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3979442%2F8970f6a5-4cab-49e8-a6fa-a72c8950f8c7.png</url>
      <title>DEV Community: Cho Garcia</title>
      <link>https://dev.to/chogarcia</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/chogarcia"/>
    <language>en</language>
    <item>
      <title>Making post-quantum the default in a file format, not a toggle</title>
      <dc:creator>Cho Garcia</dc:creator>
      <pubDate>Thu, 11 Jun 2026 11:38:16 +0000</pubDate>
      <link>https://dev.to/chogarcia/making-post-quantum-the-default-in-a-file-format-not-a-toggle-59c0</link>
      <guid>https://dev.to/chogarcia/making-post-quantum-the-default-in-a-file-format-not-a-toggle-59c0</guid>
      <description>&lt;p&gt;Most "post-quantum" features I run into are a switch you have to find and flip. A&lt;br&gt;
checkbox in settings, an opt-in beta, a separate "secure mode." I wanted the&lt;br&gt;
opposite: the default at-rest cipher for every new file is post-quantum, and you&lt;br&gt;
have to go out of your way to get anything weaker. This is a write-up of what&lt;br&gt;
that actually took, the parts that were clean, and the parts that bit me. The&lt;br&gt;
format is the on-disk format for ShieldFive, an encrypted storage thing I build,&lt;br&gt;
but none of what follows is specific to the product. It's a file format, and the&lt;br&gt;
whole spec and test vectors are public, so you can check every claim here against&lt;br&gt;
the source.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why bother, when nobody has a quantum computer
&lt;/h2&gt;

&lt;p&gt;The honest reason is "harvest now, decrypt later." An adversary who can store&lt;br&gt;
ciphertext today can decrypt it whenever a cryptographically relevant quantum&lt;br&gt;
computer shows up. For data with a long secret lifetime, the clock that matters&lt;br&gt;
is not "when does the quantum computer exist" but "how long does this file need&lt;br&gt;
to stay secret." A file you upload today might need to stay private for fifteen&lt;br&gt;
years. You don't get to make the post-quantum decision then. You make it now, or&lt;br&gt;
not at all.&lt;/p&gt;

&lt;p&gt;So the bar I set was: a file written today should survive a future quantum&lt;br&gt;
computer for as long as its classical AEAD stays secure, with no action from the&lt;br&gt;
user. That means post-quantum has to be the default suite, not a mode.&lt;/p&gt;
&lt;h2&gt;
  
  
  The part everyone gets wrong: hybrid is not "encrypt twice"
&lt;/h2&gt;

&lt;p&gt;The most common misconception I see, including from smart people, is that hybrid&lt;br&gt;
crypto means encrypting the data twice, once with a classical algorithm and once&lt;br&gt;
with a post-quantum one. Two locks in series.&lt;/p&gt;

&lt;p&gt;That intuition is right about the security you want and wrong about the&lt;br&gt;
mechanism. You don't encrypt twice. You run two key exchanges and combine their&lt;br&gt;
outputs into one key.&lt;/p&gt;

&lt;p&gt;Here is what actually happens for a new file. The default suite (call it suite&lt;br&gt;
&lt;code&gt;0x03&lt;/code&gt;) does two things to establish a key:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A classical share. A random 32-byte secret &lt;code&gt;S_c&lt;/code&gt;, wrapped to the recipient's
classical key.&lt;/li&gt;
&lt;li&gt;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
&lt;code&gt;S_pq&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Neither secret encrypts the file directly. Both go into a KDF along with the&lt;br&gt;
file's random identifier:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;K = HKDF-SHA-256(
      ikm  = S_c || S_pq,
      salt = file_id,
      info = "shieldfive/v1/pq-hybrid/combine",
      L    = 32)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The file is then encrypted once, with &lt;code&gt;K&lt;/code&gt;, using a normal AEAD&lt;br&gt;
(XChaCha20-Poly1305). The security falls out of the KDF. Its output is&lt;br&gt;
unpredictable as long as at least one of the two inputs stays secret. An&lt;br&gt;
attacker with a quantum computer breaks the classical side and recovers &lt;code&gt;S_c&lt;/code&gt;,&lt;br&gt;
but without &lt;code&gt;S_pq&lt;/code&gt; they cannot compute &lt;code&gt;K&lt;/code&gt;. If ML-KEM turns out to have a flaw,&lt;br&gt;
the classical share still covers you. Either leg standing holds the whole thing&lt;br&gt;
up. Formally the construction is IND-CCA2 against an adversary who breaks one&lt;br&gt;
primitive but not both.&lt;/p&gt;

&lt;p&gt;Why not literally encrypt twice? It's slower, it doubles what can go wrong in&lt;br&gt;
the bulk-cipher layer, and the combiner has actual security proofs behind it&lt;br&gt;
while ad-hoc nesting does not. The IETF's X-Wing (&lt;code&gt;mlkem768x25519&lt;/code&gt;) is the&lt;br&gt;
standardized version of this idea. My format uses the same shape with ML-KEM-1024&lt;br&gt;
for NIST level 5.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wire format decisions that actually mattered
&lt;/h2&gt;

&lt;p&gt;The combiner is the interesting cryptography. The format is where most of the&lt;br&gt;
real engineering went, and where I made mistakes I had to design around.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Self-describing or it isn't real.&lt;/strong&gt; The first goal was that a reviewer with&lt;br&gt;
only the encrypted blob and the key, and no out-of-band metadata, can decrypt the&lt;br&gt;
file. If you need a database row to know how to read your own ciphertext, your&lt;br&gt;
"export" is a hostage situation. So the header carries everything: a 5-byte magic&lt;br&gt;
(&lt;code&gt;SF5\x01\x00&lt;/code&gt;), a one-byte suite identifier, the file id, chunk sizing, and the&lt;br&gt;
suite-specific payload. One byte picks the cipher suite, so adding a suite later&lt;br&gt;
never changes the parser.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Position has to be authenticated, not checked in application code.&lt;/strong&gt; Each chunk&lt;br&gt;
is an AEAD encryption whose associated data includes the chunk index, the total&lt;br&gt;
chunk count, and a final-chunk flag. That means truncation, reordering, and&lt;br&gt;
splicing chunks between two different files are caught by the authenticator&lt;br&gt;
itself, not by some &lt;code&gt;if&lt;/code&gt; statement you might forget. If someone lops off the last&lt;br&gt;
chunk, the decrypt fails, it doesn't silently return a shorter file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The size cost is real and you should say so.&lt;/strong&gt; This is the part PQ advocates&lt;br&gt;
tend to skip. An ML-KEM-1024 ciphertext is 1568 bytes. The full suite payload for&lt;br&gt;
a hybrid file is 1664 bytes of key material in the header of every single file.&lt;br&gt;
For a 4 KB text file that's a noticeable tax. For the photos and documents most&lt;br&gt;
people store it's noise. I decided the tax was worth paying by default, but&lt;br&gt;
"version it and move on" stops being free once your key material is measured in&lt;br&gt;
kilobytes, and a self-describing format earns its keep faster than you'd expect&lt;br&gt;
once the header is that big.&lt;/p&gt;

&lt;h2&gt;
  
  
  The subtle one: decrypting bytes you haven't authenticated yet
&lt;/h2&gt;

&lt;p&gt;Here's the problem that took the most thought. The header has a MAC over its own&lt;br&gt;
contents, keyed by the combined key &lt;code&gt;K&lt;/code&gt;. But to compute &lt;code&gt;K&lt;/code&gt; you have to&lt;br&gt;
decapsulate the ML-KEM ciphertext, and that ciphertext is sitting right there in&lt;br&gt;
the header as unauthenticated, attacker-influenced bytes. You are running a&lt;br&gt;
cryptographic operation on input you have not verified. That should make you&lt;br&gt;
nervous.&lt;/p&gt;

&lt;p&gt;It's safe, and the reason is worth spelling out, because the safety rests&lt;br&gt;
entirely on three properties holding at once:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;The classical share is unwrapped with an AEAD. Tamper with those bytes and the
tag check fails before any plaintext comes out.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So an attacker who pokes at the header either gets a different &lt;code&gt;K&lt;/code&gt; and fails the&lt;br&gt;
MAC, or fails the AEAD tag check on the classical share, or learns nothing thanks&lt;br&gt;
to IND-CCA2. In no path does plaintext leak. The lesson I took from this: "don't&lt;br&gt;
process unauthenticated input" is a good rule of thumb, but the precise version&lt;br&gt;
is "don't process unauthenticated input with a primitive that leaks under chosen&lt;br&gt;
ciphertext." IND-CCA2 is exactly the property that lets you decapsulate first and&lt;br&gt;
authenticate after.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sharing without re-encrypting the file
&lt;/h2&gt;

&lt;p&gt;When you share a file, you don't want to re-encrypt every chunk for the new&lt;br&gt;
recipient. So the owner recovers the file's combined key &lt;code&gt;K&lt;/code&gt; and re-wraps just&lt;br&gt;
&lt;code&gt;K&lt;/code&gt; to the recipient. The thing I had to be careful about was downgrade attacks.&lt;br&gt;
An early version of my share bundle had no version marker and authenticated only&lt;br&gt;
&lt;code&gt;K&lt;/code&gt;, not the post-quantum material around it. That's the kind of gap where an&lt;br&gt;
attacker strips or swaps the PQ part and you don't notice.&lt;/p&gt;

&lt;p&gt;The fixed version carries its own magic and version byte, and the AEAD that wraps&lt;br&gt;
&lt;code&gt;K&lt;/code&gt; authenticates the entire bundle prefix as associated data: the magic, the&lt;br&gt;
length field, the PQ payload, and the nonce. Now you cannot substitute, strip, or&lt;br&gt;
downgrade any of it without breaking the tag. The wrapping key is also&lt;br&gt;
domain-separated from the file key with its own HKDF label, so a share key can&lt;br&gt;
never accidentally equal a file's content key. None of this is exotic. It's just&lt;br&gt;
the kind of binding that's easy to leave out and painful to add later.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I haven't solved
&lt;/h2&gt;

&lt;p&gt;Being honest about the edges is the whole point of writing this, so:&lt;/p&gt;

&lt;p&gt;There is no post-quantum signature in production. The format defines an optional&lt;br&gt;
Ed25519 signature block for sender attribution, and reserves a slot for ML-DSA,&lt;br&gt;
but I haven't shipped the PQ one. This mirrors the wider state of things:&lt;br&gt;
hybrid KEMs are a clean, solved story, and hybrid signatures are genuinely hard&lt;br&gt;
(they lose BUFF security and none of the options are nice). Encryption got the&lt;br&gt;
easy migration. Authentication is still the hard part for everyone.&lt;/p&gt;

&lt;p&gt;Share links to anonymous recipients are classical-only. An anonymous recipient&lt;br&gt;
has no public key to encapsulate to, so there's nothing to make that path&lt;br&gt;
post-quantum. The owner's stored file is still hybrid; the anonymous share leg&lt;br&gt;
isn't. I'd rather state that than imply a guarantee I can't make.&lt;/p&gt;

&lt;p&gt;And there's no external audit yet. The review so far is my own. That's exactly&lt;br&gt;
why the crypto is a standalone Apache-2.0 library&lt;br&gt;
(&lt;a href="https://www.npmjs.com/package/@shieldfive/crypto" rel="noopener noreferrer"&gt;@shieldfive/crypto&lt;/a&gt; on npm,&lt;br&gt;
&lt;a href="https://github.com/shieldfive/crypto" rel="noopener noreferrer"&gt;source on GitHub&lt;/a&gt;), the same code that runs&lt;br&gt;
in the browser, so you can read it and diff the published package against what&lt;br&gt;
gets served to you instead of trusting my say-so.&lt;/p&gt;

&lt;h2&gt;
  
  
  Poke at it
&lt;/h2&gt;

&lt;p&gt;The full format spec&lt;br&gt;
(&lt;a href="https://github.com/shieldfive/crypto/blob/main/spec/format-v1.md" rel="noopener noreferrer"&gt;spec/format-v1.md&lt;/a&gt;),&lt;br&gt;
including the wire layout, the per-suite derivations, and the security argument&lt;br&gt;
for pre-MAC decapsulation, is public, and there are bit-for-bit test vectors&lt;br&gt;
committed to the repo (188 tests covering tampering, truncation, splicing, and&lt;br&gt;
reordering). If you want to break something, the two&lt;br&gt;
places I'd most like eyes on are the combiner and the share re-encapsulation.&lt;br&gt;
Real findings are worth more to me than polite ones.&lt;/p&gt;

&lt;p&gt;If you build crypto into products, I'd genuinely like to hear how you've handled&lt;br&gt;
the "post-quantum by default vs opt-in" call, and whether you think the size tax&lt;br&gt;
is worth it. That's the decision I'm least certain I got right.&lt;/p&gt;

</description>
      <category>cryptography</category>
      <category>security</category>
      <category>postquantum</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
