파손 시점 및 파손 시점: 양자 후 마이그레이션 트레이드오프

작성자

카테고리:

← 피드로
DEV Community · Matías Denda · 2026-07-21 개발(SW)

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.

The two subsystems

Anyhide has two layers that touch asymmetric cryptography:

  • Anyhide codes. Steganographic codes that get written to disk, embedded in carriers, shared via QR. Long-lived. Backwards compatibility matters. There are codes already in the wild — created by users, sitting in their backups — that need to keep decoding after the migration.
  • Chat protocol. P2P chat over Tor with a Double Ratchet. Entirely RAM-only — sessions live and die with the connection, nothing is persisted, no historical material exists once the chat ends.

Same crypto operations, totally different data lifecycles. So the migration strategies diverged: codes had to bend, chat could break.

Strategy 1: bend (anyhide codes)

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:

// 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
    }
}

Enter fullscreen mode Exit fullscreen mode

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.

Strategy 2: break (chat protocol)

For chat, the calculation is different. Chat is RAM-only:

  • No persisted ciphertext. Once the session closes, the messages are gone.
  • No long-term wire format compatibility constraint. There’s nothing on disk to preserve.
  • The handshake runs at every new connection.

So a clean break is cheaper than backwards compatibility. The chat protocol bumped from v1 to v2 with a hard rejection:

// 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,
        });
    }
    // ...
}

Enter fullscreen mode Exit fullscreen mode

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.

The hybrid KEM itself

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::<Sha256>::new(None, &ikm);
    let mut out = [0u8; SHARED_KEY_SIZE];
    hk.expand(b"ANYHIDE-HYBRID-KEM-V1", &mut out)
        .expect("32 bytes is valid output length");

    ikm.zeroize();
    SharedKey::new(out)
}

Enter fullscreen mode Exit fullscreen mode

The shared secret is HKDF-SHA256(classical_ss || pq_ss) with an info string for domain separation. The IKM buffer is zeroized after the HKDF call to avoid lingering key material in memory.

The 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.

This matters because the RustCrypto ml-kem crate 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.

The PQXDH-shape handshake

Inside 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.

// Initiator -> Responder:    HandshakeInit { eph_pubkey_hybrid }
// Responder -> Initiator:    HandshakeResponse { eph_pubkey_hybrid, kem_ct_to_init }
// Initiator -> Responder:    HandshakeComplete { kem_ct_to_resp }

// Both sides derive:
let master = derive_master_secret(&ss_resp_to_init, &ss_init_to_resp);
//                                  ^ HKDF info ANYHIDE-CHAT-V2-MASTER

Enter fullscreen mode Exit fullscreen mode

This 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.

The KEM ratchet (per-message key rotation, like the classical Double Ratchet but with kem_ratchet_send / kem_ratchet_receive instead of DH ratchet steps) builds on top of this master secret.

The 96-byte mnemonic problem

There’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.

Hybrid secrets do not fit. They are 96 bytes:

  • 32 bytes X25519 secret
  • 32 bytes ML-KEM seed d
  • 32 bytes ML-KEM seed z

(The ML-KEM key is stored as the FIPS 203 seed d || z rather 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.)

I considered three options for the backup format:

  • 27-word custom phrase. Encodes 297 bits, enough to fit 96 bytes plus a checksum. Rejected because no BIP39 library supports non-standard word counts, and adopting a custom encoder means anyone restoring my backup needs my exact decoder.
  • Larger custom wordlist. Could encode 96 bytes in fewer words. Rejected for the same reason — interoperability dies if the wordlist isn’t standard.
  • Three independent 24-word BIP39 phrases. Each phrase encodes one of the three components. Each has its own checksum, validated independently.

Option three won. The implementation is two functions:

// src/crypto/mnemonic.rs

pub fn hybrid_key_to_mnemonics(secret_bytes: &[u8; 96]) -> [Vec<String>; 3] {
    let mut classical = [0u8; 32];
    classical.copy_from_slice(&secret_bytes[0..32]);
    let mut pq_d = [0u8; 32];
    pq_d.copy_from_slice(&secret_bytes[32..64]);
    let mut pq_z = [0u8; 32];
    pq_z.copy_from_slice(&secret_bytes[64..96]);

    [key_to_mnemonic(&classical), key_to_mnemonic(&pq_d), key_to_mnemonic(&pq_z)]
}

pub fn mnemonics_to_hybrid_key(
    phrases: &[Vec<String>; 3],
) -> Result<[u8; 96], MnemonicError> {
    let classical = mnemonic_to_key(&phrases[0])?;
    let pq_d = mnemonic_to_key(&phrases[1])?;
    let pq_z = mnemonic_to_key(&phrases[2])?;

    let mut out = [0u8; 96];
    out[0..32].copy_from_slice(&classical);
    out[32..64].copy_from_slice(&pq_d);
    out[64..96].copy_from_slice(&pq_z);
    Ok(out)
}

Enter fullscreen mode Exit fullscreen mode

A typo in any one phrase fails the matching checksum independently. The user sees MnemonicError::InvalidWord("typo") for 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.

I 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.

Auto-detect at the CLI

The last piece is the user-facing experience. I wanted migration to be a single command:

anyhide keygen --hybrid -o mykeys

Enter fullscreen mode Exit fullscreen mode

After running that, every other command behaves the same. No --pq flag at encode time, no --hybrid at decode time. The CLI sniffs the PEM header and routes accordingly:

// commands/encode.rs

enum RecipientKeyMaterial {
    Classical(x25519_dalek::PublicKey),
    Hybrid(HybridPublicKey),
}

fn load_recipient_pubkey(path: &Path) -> Result<RecipientKeyMaterial> {
    let pem = std::fs::read_to_string(path)?;
    match detect_key_type(&pem) {
        Some(kt) if kt.is_hybrid() => {
            let pk = load_hybrid_public_key(path)?;
            Ok(RecipientKeyMaterial::Hybrid(pk))
        }
        _ => {
            let pk = load_public_key(path)?;
            Ok(RecipientKeyMaterial::Classical(pk))
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

encode then matches on the variant and calls either encode_with_carrier_config (classical) or encode_with_carrier_config_hybrid (hybrid). The library exposes 12 new public hybrid entry points, each paralleling its classical counterpart. Internally, both paths delegate to the same *_with_recipient private helpers — the only divergence is the final dispatch_encrypt call that picks which encrypt_with_passphrase* to invoke.

Wire-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.

What I deferred

A few pieces are deliberately not migrated yet:

  • Forward-secrecy ratchet on encoded codes. EncodedMessage.next_keypair is 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 for hybrid recipients with EncoderError::RatchetUnsupportedForHybrid rather than degrading silently.
  • Ed25519 signing identities. Signature authenticity is a different threat model from harvest-now-decrypt-later confidentiality. A signature commits the signer at signing time; even if Ed25519 is broken later, the historical signature does not retroactively become forgeable for messages that were already accepted in good faith. Migrating to Dilithium or Falcon is its own project with its own tradeoffs.
  • External audit of ml-kem 0.3. This is not in my hands. The hybrid mode is the mitigation; if the upstream crate gets audited (or replaced by an audited alternative), Anyhide can pin to that.

What I’d tell another project considering this

Three things, in priority order.

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.

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.

Auto-detect at the user surface. Adding a --pq flag 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.”

Closing

The 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.

If 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?

Code’s at github.com/matutetandil/anyhide. The hybrid track shipped in v0.14 — cargo install anyhide and try keygen --hybrid to play with it.

Thanks for reading.

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다