Hi,
I was looking at the latest specification of HQC from February 2025 [HQC]. The specification claims IND-CCA2 security by instantiating a [HHK17] - style implicit-rejection Fujisaki-Okamoto transform -- see Section 1.4. and 5.2 of [HQC].
I have some notes about this.
1. Firstly, on the implementation level, the code does not try to do implicit rejection but offers an explicit true/fail return code on decryption failure. Application code could of course choose to ignore this "oracle" --- but it's not in the spec and the behavior differs from, say, most ML-KEM code that implements same type of FO transform. Here's the relevant line in the PQClean codebase -- same can be found in the latest reference implementation:
https://github.com/PQClean/PQClean/blob/448c71a8f590343e681d0d0cec94f29947b0ff18/crypto_kem/hqc-128/clean/kem.c#L1372. The FO transform itself appears to have been instantiated a bit badly in HQC.KEM:
There is one crucial difference in how the same [HHK17] transform is implemented in HQC compared to ML-KEM -- the use of "salt," which appears to be a late-stage addition to the scheme (Sect 1.5 [HQC]).
Turning the "salt" ciphertext component into a two-query decryption failure oracle is trivial, and I've tested this against the reference code:
Figure 3 in the [HQC] serves as the main description of HQC.KEM in the spec. Confusingly, "salt" in that pseudocode is a separate value, but it is a part of the ciphertext as noted elsewhere in the spec.
Here's my high-level description of HQC.KEM Encapsulation and Decapsulation. I've split it into discrete steps and abstracted out the "PKE" functions. Below G and K are hash functions (built from SHAKE256 with a domain separator byte.)
HQC KEM Encaps(pk) function:
e1.
Pick a random {16,24,32}-byte random message "m"
e2.
Pick a random 16-byte "salt"
e3.
Compute encryption seed: theta <- G( m | pk[:32] | salt )
e4.
"PKE" Encrypt: c = (u,v) <- Encrypt(m, theta, pk)
e5.
Ciphertext is: ct = c | salt
e6.
Shared secret is: ss <- K( m | c )
HQC KEM Decaps(ct, sk):
d1.
Unpack ciphertext: (u, v, salt) <- ct. We write c = (u,v)
d2.
Unpack secret key: (sk_seed, sigma, pk) <- sk
d3.
"PKE" Decrypt: m' <- Decrypt( c, sk_seed )
d4.
Compute theta' <- G( m' | pk[:32] | salt )
d5.
FO Re-encryption: c' = (u',v') <- Encrypt(m', theta', pk)
d6.
If c != c' then return ss <- K( sigma | c )
d7.
else return ss <- K( m | c )
Observations:
- Full ciphertext ct is not used to compute shared secret ss in steps e6 and d7, just the c = (u | v) component.
- In step d6, the comparison does not include the full ciphertext. It is not ct == ct' but just c == c', missing salt from ct = c | salt
So, here's the two-query decryption failure oracle:
In the CCA "game," the adversary can do two decapsulation queries, one with ct and a second with ct', where only the "salt" value has been changed. You can, for example, flip the last bit of ciphertext ct and use that as ct' as the salt is in the end.
Now, if Decaps(ct) = Decaps(ct') i.e. ss == ss', then the adversary learns that decryption failed for ct for sure: The PKE decryption step (d3) will yield the same result m' for both ct and ct'. The re-encryption will surely fail for ct' as a different theta value is computed. If the shared secret computations match ss==ss', then re-encryption failed for ct too.
Conversely, if Decaps(ct) != Decaps(ct') i.e. ss != ss', then ct was a valid ciphertext.
One of the implications is that the "sigma" part (equivalent to "z" in ML-KEM) of the current HQC secret key serves no meaningful purpose as far as I can see.
This is easy to fix just by treating "salt" as a part of the ciphertext -- since it is a part of the ciphertext.
References:
[HQC] (HQC Team.) "Hamming Quasi-Cyclic (HQC)."
Fourth round version. Updated version 19/02/2025
https://pqc-hqc.org/doc/hqc-specification_2025-02-19.pdf[HHK17] Dennis Hofheinz, Kathrin Hövelmanns, and Eike Kiltz.
"A modular analysis of the Fujisaki-Okamoto transformation." TCC 2017
https://eprint.iacr.org/2017/604https://doi.org/10.1007/978-3-319-70500-2_12
Cheers,
-markku