Making ‘PQC in a TPM is C-only’ a half-truth — the first Rust-native TPM 2.0 v1.85 PQC (ML-KEM/ML-DSA), byte-checked against C and shipped on crates.io

Raspberry Pi 4 に載せた実機 Infineon SLB9670 — v1.85 PQC を TPM_RC_COMMAND_CODE で拒否したチップ

🇯🇵 この記事の日本語版 → TPMに、Rustだけでポスト量子を喋らせた

TL;DR

  • What this is: the first Rust-native implementation of the TPM 2.0 v1.85 post-quantum commands (ML-KEM / ML-DSA) — on crates.io as hyde-tpm 0.2.0, with no libtss2 and no C.
  • What it is NOT: not the first PQC in a TPM — wolfTPM did that first, in C. And not on real hardware: no shipping silicon implements v1.85 PQC yet, so it runs against a firmware TPM (a real Infineon SLB9670 rejects the commands — see the last section).
  • Evidence: command marshalling is checked byte-for-byte against the C implementation, and a TPM-produced ML-DSA signature is independently verified by RustCrypto’s ml-dsa (FIPS 204 VALID). Both run in CI with no TPM.
  • Status: experimental 0.2.x proof-of-concept — some parameters provisional, response decoding hand-written. Use at your own risk.

TPM 2.0 spec v1.85 finally brings post-quantum (ML-KEM / ML-DSA) into the TPM’s standard command set. Let me be honest up front: I was not the first to run PQC in a TPM. wolfTPM (in C) got there first — PR #445, this March. What I did is a different thing: run v1.85 PQC Rust-natively, with zero dependency on libtss2 or any C, in a form that ships on crates.io. That lane was empty. I planted the first marker in it — and I did it while using the C implementation as an oracle, checking that my own bytes matched it to the byte.

Why the Rust lane was empty — tss-esapi and the non_exhaustive wall

Rust’s go-to crate tss-esapi is a thin wrapper over C’s libtss2, and libtss2 has no v1.85 PQC yet — so that path simply can’t speak it. The pure-Rust, no_std tpm2-protocol (Jarkko Sakkinen) is beautifully designed, but its command and algorithm-ID enums are #[non_exhaustive]: downstream code cannot add new commands — touching them requires a fork. Both doors were shut. That is why the lane was empty.

Using C as an oracle — checking to the byte

Read the spec, marshal the v1.85 commands in pure Rust. But “probably spec-conformant” isn’t trust. Precision is trust. So I made the already-working C implementation the oracle: capture the exact bytes wolfTPM puts on the wire, and diff them against the bytes my Rust emits.

// for encapsulate / decapsulate / sign / verify …
assert_eq!(rust_marshalled, c_reference_wire);  // A == B, not one byte off

The command marshalling is checked byte-for-byte against reference wire from an independent C client. This check runs without a TPM (it’s baked into the tests) — reproducible in CI, no hardware required.

Grafting it on, and actually talking — round-trips against a firmware TPM

I forked tpm2-protocol and grafted the v1.85 layer onto it (command codes 0x1A30x1AA; algorithm IDs ML-KEM=0x00A0 / ML-DSA=0x00A1), then ran round-trips against a firmware TPM over the mssim socket (127.0.0.1:2321).

  • ML-KEM: the shared secret encapsulated on the host matched the one the TPM decapsulated (encap == decap).
  • ML-DSA: the TPM generated a signing key and signed a message, returning a 1312-byte verifying key and a 2420-byte signature (ML-DSA-44). The TPM’s own Verify command then returned a validation ticket.

So not just “it marshals” — the TPM accepts it and returns a signature. The public API is this thin:

use hyde_tpm::pqc::{MlDsa, MlKem, PqcTpm};

let mut tpm   = PqcTpm::connect("127.0.0.1:2321")?;
let secret    = tpm.ml_kem_roundtrip(MlKem::K512)?;      // KEM: encap == decap
let (pk, sig) = tpm.ml_dsa_sign(MlDsa::D44, message)?;   // hardware-rooted signature

Checking again with an independent implementation — FIPS 204 VALID

I verified the TPM-produced signature with a completely separate implementation (RustCrypto’s ml-dsa). Result: VALID — a third-party implementation confirms the signature is correct under FIPS 204.

ml_dsa::verify_with_context(msg, &[], sig)  // ctx = empty (pure ML-DSA)  => VALID

But I draw the line honestly: this is evidence, not attestation. Verifying a TPM signature on the host does not replace TPM-rooted attestation verification. So the independent verifier is confined to tests and examples, never the public API. Evidence travels with the crate — but evidence is treated as evidence.

Shipping it — hyde-tpm 0.2.0 on crates.io

Published as hyde-tpm 0.2.0, split by feature gate: the default tss path (classic tss-esapi) is left completely unchanged, and a pqc feature carries the pure-Rust v1.85 path — buildable with no libtss2, no C, std only.

# PQC path, builds & tests without libtss2
cargo test -p hyde-tpm --no-default-features --features pqc

Rust-native v1.85 PQC is now something anyone can pull with cargothe first published Rust implementation. The priority marker is planted.

And real silicon still says no — which is the proof of the frontier

I sent the same v1.85 commands to a real chip — an Infineon SLB9670 (/dev/tpmrm0). It answered TPM_RC_COMMAND_CODE: “I don’t know that command.” Yet a classic GetRandom to the same chip succeeds — the chip is alive; there just isn’t any silicon yet that implements v1.85 PQC. That refusal is the proof of where we stand: right past the leading edge where an implementation exists (the firmware TPM), on the edge silicon hasn’t reached. Not a weakness — the frontier.

The real Infineon SLB9670 TPM HAT on a Raspberry Pi 4 that rejected v1.85 PQC
The real Infineon SLB9670 (Iridium 9670 TPM 2.0) on a Raspberry Pi 4 — the very chip that rejected v1.85 PQC with TPM_RC_COMMAND_CODE while accepting classic commands. The frontier, in the flesh.

Why the urgency? CNSA 2.0 puts firmware signing at the earliest deadline (exclusive use by 2030, with a procurement gate in 2027). PQC in the TPM feeds directly into that. Paving the Rust road before the silicon arrives is the point.

Limitations, not diluted

  • Firmware TPM only. No hardware TPM implements v1.85 PQC yet, so this is demonstrated against the only existing implementation — a firmware TPM. Real-silicon validation awaits shipping hardware. The transport is TCTI-shaped, so the port is expected to be unchanged, but this is unverified.
  • Some parameters are provisional. A few Sequence-Start fields are pinned to observed wire. They are functionally successful (the TPM accepts them and Verify returns a ticket); what’s outstanding is cross-referencing their byte layout against the TCG Part 3 spec — documentation, not whether they work.
  • Experimental (0.2.x), proof-of-concept. Response decoding is hand-written; the v1.85 additions are maintained as a fork-derived layer. Use at your own risk.

A different coordinate

C got there first. That’s a fact, and I won’t hide it. What I planted is a different coordinate — Rust only, no C, in a form that ships on crates.io, checked byte-for-byte against C as the oracle. With the same certainty I felt writing “byte-identical TS came out, not one byte off” in the one-seg series, I got a TPM to speak post-quantum. Next, it’s silicon’s turn.

コメント

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

IP: 取得中...
216.73.217.165216.73.217.165