Cryptography is necessary; packaging is where you get hurt

Parts 1 and 2 described a tidy design: CEK, envelopes, ML-KEM, ML-DSA, XChaCha, CBOR.

Then we reviewed the crate the way an attacker (or a careful user) would. Several issues were not “wrong algorithms.” They were wrong assumptions about encodings, dependencies, and documentation—the glue that turns primitives into a protocol.

This post is that cleanup story, written so you can steal the lessons even if you never use tholos-pq.


Lesson 1: Sign the bytes on the wire

The bug-shaped assumption

An early design effectively treated the signed object as a structure: decode the bundle, re-encode the inner fields, verify the signature over the re-encoding.

That feels elegant. It is also how you invent malleability.

CBOR (like many binary encodings) can represent “the same” value with different byte strings—optional self-describe tags, and historically more flexibility depending on the decoder. If verification re-encodes, then:

The fix

BundleSigned.inner is now opaque CBOR bytes. ML-DSA verifies over those bytes exactly as received. Only after a successful verify do we deserialize BundleUnsigned and decrypt.

We also require the outer self-describe tag on the wire object, reject unknown CBOR fields (deny_unknown_fields), and stopped claiming “canonical CBOR” when we meant “deterministic for this encoder.”

Takeaway for your own protocols: If the signature does not cover the transmitted representation, you are authenticating a dream, not a message.


Lesson 2: Unmaintained codecs are a security dependency

The CBOR stack originally used serde_cbor. It works until it doesn’t: the crate is unmaintained, shows up in cargo audit, and freezes your encoder behavior in amber.

We migrated to ciborium. That was a breaking wire/dependency change, which is fine when you have few users and a suite/version gate. It would have been painful after wide deployment—another argument for getting the codec right before you promise stability.

Takeaway: In a crypto crate, your serializer is in the TCB. Treat “archived on GitHub” as a vulnerability class.


Lesson 3: “Pure Rust” must be true when you say it

Documentation once implied a pure-Rust story while Dilithium came through pqcrypto-dilithium / PQClean C. That is a legitimate engineering choice—and a dishonest README bullet.

We moved signatures to dilithium-rs (ML-DSA-65), kept ml-kem for KEM, and made zeroization of secret material a first-class concern (CEK/KEK via Zeroizing, ML-KEM decapsulation keys, ML-DSA keypairs on drop).

The suite string now says what we ship: Kyber1024+XChaCha20P1305+MlDsa65.

Takeaway: Security marketing is part of the attack surface. If the docs lie, users mis-estimate supply-chain and audit scope.


Lesson 4: Test the format, not just the round trip

Encrypt-then-decrypt in the same process proves the code agrees with itself. It does not prove the wire format is stable.

What helps:

When the encoder drifts, the golden vector fails loudly. That is the point.

Takeaway: For versioned ciphertext formats, vectors are not a nice-to-have. They are how you notice you broke the world.


Lesson 5: Fail closed on policy edges

Small API choices matter:

None of these replace a threat model. They keep accidental misuse from looking like success.


What we still don’t claim (read this twice)

The README’s limitations are not fine print. They are the product boundary.

No forward secrecy

Recipient ML-KEM keys are long-lived. If Bob’s private key leaks, an attacker with archived ciphertext for Bob can decrypt those messages. There is no ratchet, no ephemeral handshake, no “compromise tomorrow doesn’t open yesterday” story.

If you need FS, you want a session protocol (or at least ephemeral KEMs and disciplined key lifetime)—not only envelope encryption to static directories.

No built-in replay protection

msg_id and timestamp_unix are authenticated (they sit under the signed inner content and in AEAD AAD). They are not enforced.

Use verify_header, then record msg_id (and maybe a timestamp window) in your application. Cryptography can bind metadata; only your state can reject “I already saw this.”

Encoder determinism ≠ canonical CBOR

We encode with ciborium and sign the result. We do not implement full RFC 8949 canonicalization. Interoperability means “same suite + compatible encoder behavior,” backed by vectors—not “any CBOR library in any language will byte-identical round-trip.”

Allowlists are your key management

tholos-pq will happily reject a bad signature. It will not invent a PKI. Distribution of sender ML-DSA public keys and recipient ML-KEM public keys is application territory.

Size and CPU are PQ-sized

See part 2. If your constraint is 300-byte BLE packets, this suite is the wrong tool.


A short “should I use this?” checklist

Consider tholos-pq if you want:

Choose something else (or build more) if you need:


Where the project stands

As of 0.3.x, the headline hardening work is in:

The algorithms from part 2 did not magically become “safe” because of that work. They became harder to misuse at the packaging layer, and the docs got closer to the truth.

That is most of what a young crypto library can responsibly claim.


Closing the series

  1. Part 1: HNDL and multi-recipient envelopes explain why the crate exists
  2. Part 2: CEK + ML-KEM wraps + ML-DSA + XChaCha explain how it works
  3. Part 3: Wire-byte signatures, dependency honesty, and explicit non-goals explain how not to fool yourself

If you try it, break it, or need a different suite (smaller KEM, hybrid KEM, AES-GCM), open an issue—or better, a failing vector.

cargo add tholos-pq
cargo run --example demo

Thanks for reading.


tholos-pq is Apache-2.0.
GitHub · crates.io · docs.rs