{"slug": "when-to-break-and-when-to-bend-post-quantum-migration-tradeoffs-in-rust", "title": "When to Break and When to Bend: Post-Quantum Migration Tradeoffs in Rust", "summary": "A developer migrated the Anyhide project to post-quantum hybrid encryption using ML-KEM-768, adopting two opposite strategies for different subsystems: backward-compatible 'bend' for persistent codes and a clean 'break' for ephemeral chat. The migration addresses the 'harvest-now-decrypt-later' threat, with codes using a magic prefix for format detection while chat enforces a hard protocol version bump.", "body_md": "*Bonus post in the Anyhide series. The original 6-post arc closed in July; this post is about a meaty migration that landed in v0.14 — moving the whole stack to post-quantum hybrid encryption.*\n\nMost projects that touch cryptography are deferring the post-quantum migration. ML-KEM-768 became a NIST standard (FIPS 203) in 2024, but pure-Rust libraries are still young, audits are still incomplete, and the wire-size growth is uncomfortable. So almost everyone is waiting.\n\nI decided to do it anyway, for one specific reason: Anyhide codes are *persistent*. They get pasted into chat logs, embedded in QR codes that end up on paper, sometimes uploaded to public places. An adversary recording today's traffic can sit on it for ten years and decrypt it later when quantum computers mature. That's the \"harvest-now-decrypt-later\" threat, and it's the one threat post-quantum encryption actually addresses.\n\nWhat I didn't expect — and what this post is about — is how the migration ended up using *two opposite strategies* in the same project, depending on what kind of data each subsystem produces.\n\nAnyhide has two layers that touch asymmetric cryptography:\n\nSame crypto operations, totally different data lifecycles. So the migration strategies diverged: codes had to *bend*, chat could *break*.\n\nFor codes, the constraint is hard: every existing v6 code must keep decoding byte-identically with the new build. Users won't re-encode their backups. Their classical PEM keys still need to work. Migration has to be additive.\n\nThe mechanism is a magic prefix on the wire format:\n\n``` js\n// src/crypto/mod.rs\n\npub const HYBRID_WIRE_MAGIC: [u8; 4] = *b\"AHV7\";\npub const HYBRID_WIRE_VERSION: u8 = 1;\npub const HYBRID_WIRE_PREFIX_LEN: usize = HYBRID_WIRE_MAGIC.len() + 1;\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum WireFormat {\n    ClassicalV6,\n    HybridV7,\n}\n\npub fn detect_wire_format(ciphertext: &[u8]) -> WireFormat {\n    if ciphertext.len() >= HYBRID_WIRE_PREFIX_LEN\n        && ciphertext[..HYBRID_WIRE_MAGIC.len()] == HYBRID_WIRE_MAGIC\n    {\n        WireFormat::HybridV7\n    } else {\n        WireFormat::ClassicalV6\n    }\n}\n```\n\nThe hybrid encoder always emits this 5-byte prefix at the start of the encrypted payload. The classical encoder does not. The decoder peeks at the first bytes (post base64-decode) and routes to the right decryption layer — without first decrypting.\n\nThe non-obvious property here is *collision safety*. v6 codes begin with a 32-byte X25519 ephemeral public key, which is uniformly random. The probability that some legitimate v6 code happens to start with the bytes `AHV7`\n\nis exactly `1 / 2^32`\n\n, or roughly one in four billion.\n\nEven on that freak collision, nothing catastrophic happens. Anyhide's decoder is *never-fail* by design — it never returns an error, only deterministic garbage on bad inputs. So a misrouted decode produces nonsense, the user sees garbage, and there's no error signal an attacker could probe.\n\nThis is the kind of design that's only possible because Anyhide already had the \"never-fail\" property baked in for plausible deniability. If the decoder threw on bad inputs, the dispatcher would have to be more defensive, and the API surface would grow.\n\nFor chat, the calculation is different. Chat is RAM-only:\n\nSo a clean break is *cheaper* than backwards compatibility. The chat protocol bumped from v1 to v2 with a hard rejection:\n\n``` js\n// src/chat/config.rs\npub const CHAT_PROTOCOL_VERSION: u8 = 2;\n\n// src/chat/session.rs\npub fn receive_message(&mut self, wire: WireMessage) -> Result<...> {\n    if wire.version != CHAT_PROTOCOL_VERSION {\n        return Err(ChatError::VersionMismatch {\n            expected: CHAT_PROTOCOL_VERSION,\n            got: wire.version,\n        });\n    }\n    // ...\n}\n```\n\nv2 peers refuse v1 connections, and vice versa. Migration UX: regenerate your chat identity with `anyhide keygen --hybrid`\n\n, re-share your QR with peers, done. No data is lost because there was no persisted data to lose.\n\nThe lesson I keep coming back to: *the right migration strategy is dictated by the data lifecycle, not by aesthetics*. Two parts of the same project can legitimately use opposite strategies if they store data on different timescales.\n\nBoth strategies use the same underlying primitive: a hybrid KEM that combines X25519 with ML-KEM-768. The combiner is straightforward:\n\n```\n// src/crypto/hybrid_kem.rs\n\nfn combine_secrets(classical_ss: &[u8; 32], pq_ss: &[u8; 32]) -> SharedKey {\n    let mut ikm = [0u8; 64];\n    ikm[..32].copy_from_slice(classical_ss);\n    ikm[32..].copy_from_slice(pq_ss);\n\n    let hk = Hkdf::<Sha256>::new(None, &ikm);\n    let mut out = [0u8; SHARED_KEY_SIZE];\n    hk.expand(b\"ANYHIDE-HYBRID-KEM-V1\", &mut out)\n        .expect(\"32 bytes is valid output length\");\n\n    ikm.zeroize();\n    SharedKey::new(out)\n}\n```\n\nThe shared secret is `HKDF-SHA256(classical_ss || pq_ss)`\n\nwith an info string for domain separation. The IKM buffer is zeroized after the HKDF call to avoid lingering key material in memory.\n\nThe security argument: the combined key is at least as strong as the strongest of the two component KEMs. If ML-KEM-768 is broken in the future by some unforeseen cryptanalysis, X25519 still protects the message. If a quantum computer breaks X25519, ML-KEM-768 still protects it. You only lose confidentiality if *both* are broken simultaneously — a much higher bar than betting on either one alone.\n\nThis matters because the RustCrypto `ml-kem`\n\ncrate Anyhide depends on is at version 0.3 and *has not been audited*. Pure ML-KEM in 2026 is a faith-based system. Hybrid mode is the hedge that makes deploying it acceptable for a tool that handles real privacy concerns.\n\nInside the chat protocol, the handshake is a two-direction KEM exchange — the post-quantum analogue of mutual ECDH. One direction isn't enough: if only the responder encapsulated, the responder would unilaterally control all the entropy in the session secret. Classical ECDH is symmetric in its inputs by construction; for KEMs you have to *encapsulate in both directions* to recover that property.\n\n``` php\n// Initiator -> Responder:    HandshakeInit { eph_pubkey_hybrid }\n// Responder -> Initiator:    HandshakeResponse { eph_pubkey_hybrid, kem_ct_to_init }\n// Initiator -> Responder:    HandshakeComplete { kem_ct_to_resp }\n\n// Both sides derive:\nlet master = derive_master_secret(&ss_resp_to_init, &ss_init_to_resp);\n//                                  ^ HKDF info ANYHIDE-CHAT-V2-MASTER\n```\n\nThis is the same shape as Signal's PQXDH proposal — both sides encapsulate against the other's static (or in this case ephemeral) hybrid public key, and the master session secret mixes the two shared secrets. Symmetric entropy contribution, mutual binding to the transcript.\n\nThe KEM ratchet (per-message key rotation, like the classical Double Ratchet but with `kem_ratchet_send`\n\n/ `kem_ratchet_receive`\n\ninstead of DH ratchet steps) builds on top of this master secret.\n\nThere's one corner of the migration I had to design from scratch: BIP39 backup. Standard BIP39 caps at 24 words = 256 bits = 32 bytes of entropy + an 8-bit SHA-256 checksum. Classical encryption keys are 32 bytes, so they fit cleanly.\n\nHybrid secrets do not fit. They are 96 bytes:\n\n`d`\n\n`z`\n\n(The ML-KEM key is stored as the FIPS 203 seed `d || z`\n\nrather than the 2,400-byte expanded form, which is deprecated and panics in some libraries on serialization. Storing the seed and reconstructing the decapsulation key is always FIPS-correct.)\n\nI considered three options for the backup format:\n\nOption three won. The implementation is two functions:\n\n```\n// src/crypto/mnemonic.rs\n\npub fn hybrid_key_to_mnemonics(secret_bytes: &[u8; 96]) -> [Vec<String>; 3] {\n    let mut classical = [0u8; 32];\n    classical.copy_from_slice(&secret_bytes[0..32]);\n    let mut pq_d = [0u8; 32];\n    pq_d.copy_from_slice(&secret_bytes[32..64]);\n    let mut pq_z = [0u8; 32];\n    pq_z.copy_from_slice(&secret_bytes[64..96]);\n\n    [key_to_mnemonic(&classical), key_to_mnemonic(&pq_d), key_to_mnemonic(&pq_z)]\n}\n\npub fn mnemonics_to_hybrid_key(\n    phrases: &[Vec<String>; 3],\n) -> Result<[u8; 96], MnemonicError> {\n    let classical = mnemonic_to_key(&phrases[0])?;\n    let pq_d = mnemonic_to_key(&phrases[1])?;\n    let pq_z = mnemonic_to_key(&phrases[2])?;\n\n    let mut out = [0u8; 96];\n    out[0..32].copy_from_slice(&classical);\n    out[32..64].copy_from_slice(&pq_d);\n    out[64..96].copy_from_slice(&pq_z);\n    Ok(out)\n}\n```\n\nA typo in any one phrase fails the matching checksum independently. The user sees `MnemonicError::InvalidWord(\"typo\")`\n\nfor that specific phrase, not a single opaque \"your hybrid backup is corrupt\" error. Three labeled phrases (\"1/3\", \"2/3\", \"3/3\") on paper backups, written in any of the standard BIP39 formats, and the existing single-phrase machinery handles each one.\n\nI like this design. It reuses everything that already worked — wordlist, checksum, restore flow — and only adds the splitter and reassembler. Worst case, if someone restores phrase 1/3 successfully but corrupts phrase 2/3, they at least know exactly which fragment to re-enter.\n\nThe last piece is the user-facing experience. I wanted migration to be a single command:\n\n```\nanyhide keygen --hybrid -o mykeys\n```\n\nAfter running that, *every other command behaves the same*. No `--pq`\n\nflag at encode time, no `--hybrid`\n\nat decode time. The CLI sniffs the PEM header and routes accordingly:\n\n```\n// commands/encode.rs\n\nenum RecipientKeyMaterial {\n    Classical(x25519_dalek::PublicKey),\n    Hybrid(HybridPublicKey),\n}\n\nfn load_recipient_pubkey(path: &Path) -> Result<RecipientKeyMaterial> {\n    let pem = std::fs::read_to_string(path)?;\n    match detect_key_type(&pem) {\n        Some(kt) if kt.is_hybrid() => {\n            let pk = load_hybrid_public_key(path)?;\n            Ok(RecipientKeyMaterial::Hybrid(pk))\n        }\n        _ => {\n            let pk = load_public_key(path)?;\n            Ok(RecipientKeyMaterial::Classical(pk))\n        }\n    }\n}\n```\n\n`encode`\n\nthen matches on the variant and calls either `encode_with_carrier_config`\n\n(classical) or `encode_with_carrier_config_hybrid`\n\n(hybrid). The library exposes 12 new public hybrid entry points, each paralleling its classical counterpart. Internally, both paths delegate to the same `*_with_recipient`\n\nprivate helpers — the only divergence is the final `dispatch_encrypt`\n\ncall that picks which `encrypt_with_passphrase*`\n\nto invoke.\n\nWire-format / key-flavor mismatches at decode time (v7 code + classical secret, or v6 code + hybrid secret) flow through the never-fail decoder and yield deterministic garbage. There's no error signal an attacker could probe to learn whether they handed a classical key to a v7 code.\n\nA few pieces are deliberately *not* migrated yet:\n\n`EncodedMessage.next_keypair`\n\nis X25519-only by construction. Emitting a hybrid ephemeral here would misrepresent the ratchet shape and silently fail to provide PQ forward secrecy. The encoder rejects `--ratchet`\n\nfor hybrid recipients with `EncoderError::RatchetUnsupportedForHybrid`\n\nrather than degrading silently.`ml-kem`\n\n0.3.Three things, in priority order.\n\n*Decide additive vs forced break by data lifecycle, not by neatness.* If your data persists past the migration point (codes on disk, messages in a database, files in cloud storage), you have to be additive. If your data is ephemeral (live connections, in-flight requests, RAM-only sessions), forced break is fine and often cleaner. Mixing the two strategies in the same codebase is OK as long as each subsystem matches its own data lifecycle.\n\n*Hybrid KEM is the only sensible mode in 2026.* Pure post-quantum is a faith-based bet on cryptanalysis going one way. Hybrid is a hedge that doesn't trade much for the safety it provides. The wire-size growth (32 → 1216 byte pubkeys, ~1.1 KB extra per code in the encoder) is annoying but absorbable for almost any application that cares about long-term confidentiality.\n\n*Auto-detect at the user surface.* Adding a `--pq`\n\nflag would have been technically simpler but operationally worse. Users mess up flags. PEM headers don't lie. If the loader can read the file flavor unambiguously, dispatch on that, and the user never has to think about which track they're on. Migration becomes \"regenerate one keypair\" instead of \"rewrite all your scripts.\"\n\nThe thing I find most satisfying about this migration is that the two opposite strategies — bending for codes, breaking for chat — were not a compromise. Both were the *right* choice for their respective subsystem, and the project ended up cleaner for honoring the difference instead of forcing a single answer.\n\nIf you're sitting on a project that needs to make this jump, the work is mostly mechanical once you've made the strategic decisions. The interesting part is in those decisions, and they come down to one question: *what data do you owe backwards compatibility to?*\n\nCode's at [github.com/matutetandil/anyhide](https://github.com/matutetandil/anyhide). The hybrid track shipped in v0.14 — `cargo install anyhide`\n\nand try `keygen --hybrid`\n\nto play with it.\n\nThanks for reading.", "url": "https://wpnews.pro/news/when-to-break-and-when-to-bend-post-quantum-migration-tradeoffs-in-rust", "canonical_source": "https://dev.to/mdenda/when-to-break-and-when-to-bend-post-quantum-migration-tradeoffs-in-rust-2i4g", "published_at": "2026-07-21 14:00:00+00:00", "updated_at": "2026-07-21 14:21:27.690131+00:00", "lang": "en", "topics": ["artificial-intelligence", "ai-safety", "developer-tools"], "entities": ["Anyhide", "NIST", "ML-KEM-768", "FIPS 203"], "alternates": {"html": "https://wpnews.pro/news/when-to-break-and-when-to-bend-post-quantum-migration-tradeoffs-in-rust", "markdown": "https://wpnews.pro/news/when-to-break-and-when-to-bend-post-quantum-migration-tradeoffs-in-rust.md", "text": "https://wpnews.pro/news/when-to-break-and-when-to-bend-post-quantum-migration-tradeoffs-in-rust.txt", "jsonld": "https://wpnews.pro/news/when-to-break-and-when-to-bend-post-quantum-migration-tradeoffs-in-rust.jsonld"}}