Files
Emanuele Cesena 5745148664 add examples
2026-06-19 15:00:14 -07:00

3.9 KiB

Post-Quantum signing with a SoloKey (ML-DSA-44)

Works on: PIV ML-DSA — Secure + Hacker; FIDO2 ML-DSA (-50) — Hacker only. ML-DSA needs the ML-DSA firmware; the FIDO2 -50 algorithm is pre-standard CTAP, so it's excluded from the certified Secure build, while PIV ML-DSA is part of the Secure baseline.

Solo 2 supports ML-DSA-44 (FIPS-204) signatures via both FIDO2 (COSE alg -50) and PIV, powered by libcrux.

The key signs; standard tooling verifies — the signatures are plain FIPS-204 ML-DSA (empty context), so OpenSSL ≥ 3.5 and liboqs verify them directly.

Prerequisites

  • Firmware with ML-DSA: PIV (mldsa44-piv) is in the Secure baseline; FIDO2 -50 (mldsa44-fido) is Hacker only.
  • Host tools: python-fido2, pyscard, liboqs-python (oqs), and OpenSSL ≥ 3.5 (native ML-DSA — openssl list -signature-algorithms | grep ML-DSA).
  • Scripts: pq_fido2_demo.py, pq_piv_demo.py.

FIDO2 demo (alg -50) — Hacker only

FIDO2 mandates user-presence, so MakeCredential and GetAssertion each require a physical touch. The script: confirms the authenticator advertises -50, makes an ML-DSA credential, gets an assertion, and verifies the assertion signature — computed over authData || SHA256(clientDataJSON) — with liboqs and openssl.

python post_quantum/pq_fido2_demo.py
authenticator algorithms: [-7, -8, -50]
>> MakeCredential (alg -50) — TOUCH the Solo ...
   credential_id: 159 bytes; ML-DSA pubkey: 1312 bytes
>> GetAssertion — TOUCH the Solo ...
   signature: 2420 bytes over (authData || clientDataHash)
   liboqs verify: PASS
   openssl verify: Signature Verified Successfully

The 1312-byte public key and 2420-byte signature are the ML-DSA-44 sizes. To do it by hand, a MakeCredential with pubKeyCredParams = [{"type":"public-key","alg":-50}] returns the ML-DSA COSE key in authData; a GetAssertion returns the raw ML-DSA signature.


PIV demo — Secure + Hacker

PIV signing is PIN-protected: we set a PIN first, then every signature requires VERIFY PIN. The script: authenticates the management key, sets a PIN (changes the default), generates an ML-DSA-44 key in slot 9A, verifies the PIN, signs, and verifies with liboqs + openssl.

python post_quantum/pq_piv_demo.py 31415926   # arg = the PIN to set
Connected: SoloKeys Solo 2 Security Key
  SELECT PIV: SW=9000 len=107
  mgmt challenge: SW=9000 len=12
  mgmt auth: SW=9000 len=0
  set PIN (change ref data): SW=9000 len=0
  GENERATE ML-DSA-44: SW=9000 len=1321
  ML-DSA-44 public key: 1312 bytes
  VERIFY PIN: SW=9000 len=0
  SIGN: SW=9000 len=2428
  signature: 2420 bytes
  liboqs verify: PASS
  openssl verify: Signature Verified Successfully

Verify with openssl directly

The card exports a raw 1312-byte public key. Wrap it in a SubjectPublicKeyInfo (AlgorithmIdentifier{id-ml-dsa-44} + BIT STRING) and OpenSSL loads it as a native key:

openssl pkey -pubin -inform DER -in pub.der -text -noout     # -> "ML-DSA-44 Public-Key:"
openssl pkeyutl -verify -pubin -inkey pub.der -keyform DER \
        -rawin -in msg.bin -sigfile sig.bin                  # -> Signature Verified Successfully

-rawin makes OpenSSL run pure ML-DSA.Verify over the message with the (default) empty context — byte-for-byte what the SoloKey computes.

Verify with liboqs (Python)

import oqs
with oqs.Signature("ML-DSA-44") as v:
    assert v.verify(message, signature, raw_pubkey)   # True

Both paths use the same on-device ML-DSA-44 implementation; the difference is only the protocol surface (FIDO2 vs PIV) and how the signed message is framed.

For a real-world use of the PIV ML-DSA path, see TLOG.md: the Solo as a post-quantum transparency-log witness.