When to Break and When to Bend: Post-Quantum Migration Tradeoffs in Rust 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. 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. Most 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. I 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. What 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. Anyhide has two layers that touch asymmetric cryptography: Same crypto operations, totally different data lifecycles. So the migration strategies diverged: codes had to bend , chat could break . For 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. The mechanism is a magic prefix on the wire format: js // src/crypto/mod.rs pub const HYBRID WIRE MAGIC: u8; 4 = b"AHV7"; pub const HYBRID WIRE VERSION: u8 = 1; pub const HYBRID WIRE PREFIX LEN: usize = HYBRID WIRE MAGIC.len + 1; derive Debug, Clone, Copy, PartialEq, Eq pub enum WireFormat { ClassicalV6, HybridV7, } pub fn detect wire format ciphertext: & u8 - WireFormat { if ciphertext.len = HYBRID WIRE PREFIX LEN && ciphertext ..HYBRID WIRE MAGIC.len == HYBRID WIRE MAGIC { WireFormat::HybridV7 } else { WireFormat::ClassicalV6 } } The 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. The 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 is exactly 1 / 2^32 , or roughly one in four billion. Even 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. This 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. For chat, the calculation is different. Chat is RAM-only: So a clean break is cheaper than backwards compatibility. The chat protocol bumped from v1 to v2 with a hard rejection: js // src/chat/config.rs pub const CHAT PROTOCOL VERSION: u8 = 2; // src/chat/session.rs pub fn receive message &mut self, wire: WireMessage - Result<... { if wire.version = CHAT PROTOCOL VERSION { return Err ChatError::VersionMismatch { expected: CHAT PROTOCOL VERSION, got: wire.version, } ; } // ... } v2 peers refuse v1 connections, and vice versa. Migration UX: regenerate your chat identity with anyhide keygen --hybrid , re-share your QR with peers, done. No data is lost because there was no persisted data to lose. The 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. Both strategies use the same underlying primitive: a hybrid KEM that combines X25519 with ML-KEM-768. The combiner is straightforward: // src/crypto/hybrid kem.rs fn combine secrets classical ss: & u8; 32 , pq ss: & u8; 32 - SharedKey { let mut ikm = 0u8; 64 ; ikm ..32 .copy from slice classical ss ; ikm 32.. .copy from slice pq ss ; let hk = Hkdf::