|
| 1 | +# ML-KEM OT Protocol |
| 2 | + |
| 3 | +Based on MR19 (Masny-Rindal, ePrint 2019/706), Figure 8, instantiated with ML-KEM per Section D.3. |
| 4 | + |
| 5 | +Reference implementation: [libOTe KyberOT]([](https://github.com/osu-crypto/libOTe/blob/d0e499206d1d4d16c6b4ca6c0e712490e0632f80/thirdparty/KyberOT/KyberOT.c#L40-L41)). |
| 6 | + |
| 7 | +ML-KEM implementation: [ML-KEM]([](https://github.com/RustCrypto/KEMs/blob/5a7f3ab7af5420cacca9befc9212532e4c7f6ca1/ml-kem/src/)). |
| 8 | + |
| 9 | +## Notation |
| 10 | + |
| 11 | +### Field and Ring |
| 12 | + |
| 13 | +- `q = 3329` (ML-KEM prime) |
| 14 | +- `Z_q = {0, 1, ..., 3328}` (integers mod q) |
| 15 | +- `R_q = Z_q[X] / (X^256 + 1)` (polynomial ring; each element has 256 coefficients in Z_q) |
| 16 | +- `T_q`: the NTT domain representation of `R_q` (256 elements of Z_q, stored as `NttPolynomial`) |
| 17 | + |
| 18 | +### Vectors and Keys |
| 19 | + |
| 20 | +- `k`: determines the module dimension (k=2 for ML-KEM-512, k=3 for ML-KEM-768, k=4 for ML-KEM-1024) |
| 21 | +- `NttVector<k>`: a vector of `k` `NttPolynomial`s in `T_q^k` |
| 22 | + |
| 23 | +An ML-KEM encapsulation key (public key) consists of two parts: |
| 24 | + |
| 25 | +``` |
| 26 | +ek = (t_hat, rho) |
| 27 | +``` |
| 28 | + |
| 29 | +where: |
| 30 | +- `t_hat` is an `NttVector<k>`: the public key vector in NTT domain (`t_hat = A_hat * s + e` in NTT form) |
| 31 | +- `rho` is a 32-byte seed used to derive the public matrix `A_hat` |
| 32 | + |
| 33 | +Note that the ML-KEM encapsulation key is the same as the K-PKE encryption key in our simplified outline above. |
| 34 | + |
| 35 | +The serialized form is: |
| 36 | + |
| 37 | +``` |
| 38 | +ek_bytes = ByteEncode_12(t_hat) || rho |
| 39 | +``` |
| 40 | + |
| 41 | +where `ByteEncode_12` encodes each of the `256*k` coefficients using 12 bits (FIPS 203, Algorithm 5 ByteEncode_d). |
| 42 | + |
| 43 | +We write `ek.t_hat` and `ek.rho` to refer to the two components of an encapsulation key. |
| 44 | + |
| 45 | +A decapsulation key `dk` contains the secret vector `s` and some additional data (FIPS 203, Algorithm 16 KeyGen_internal). |
| 46 | + |
| 47 | +### Operations |
| 48 | + |
| 49 | +- `+` and `-` on `NttVector<k>`: component-wise addition and subtraction in `T_q^k` |
| 50 | +- `SampleNTT(B)`: Algorithm 7 from FIPS 203. Reads from a byte stream `B` and produces a pseudorandom element in `T_q`, i.e. an `NttPolynomial` |
| 51 | + |
| 52 | +### Helper Functions |
| 53 | + |
| 54 | +**`SampleNTTVector(seed, rho) -> (t_hat, rho)`** |
| 55 | + |
| 56 | +Our helper (not from FIPS 203 or MR19). Produces a pseudorandom `NttVector<k>` from a 32-byte |
| 57 | +`seed` by calling `SampleNTT` (FIPS 203, Algorithm 7) `k` times, once per polynomial. Each |
| 58 | +`SampleNTT` call produces a pseudorandom element of `T_q`. The resulting `NttVector<k>` |
| 59 | +is indistinguishable from a real `t_hat`, since a real `t_hat = A_hat * s + e` is computationally |
| 60 | +indistinguishable from a pseudorandom vector in `T_q^k`. |
| 61 | + |
| 62 | +``` |
| 63 | +for j in 0..k: |
| 64 | + t_hat[j] = SampleNTT(seed || j || 0) // 34 bytes: 32-byte seed + 2 index bytes |
| 65 | +``` |
| 66 | + |
| 67 | +Each call uses different index bytes `(j, 0)` in FIPS 203 Algorithm 7 for domain separation. |
| 68 | +In libOTe, this corresponds to `randomPK`, where it instead generates `A_hat` and takes a single |
| 69 | +row or column from it. |
| 70 | + |
| 71 | +Output: `(t_hat, rho)`. The `rho` is passed through unchanged. |
| 72 | + |
| 73 | +**`H(ek) -> (h, ek.rho)`** |
| 74 | + |
| 75 | +Hash-to-key (corresponds to libOTe's `pkHash`). Maps an encapsulation key to another |
| 76 | +encapsulation key. Takes an element of `T_q^k`, hashes it |
| 77 | +to a 32-byte seed, and uses that seed to sample a new element of `T_q^k`. |
| 78 | + |
| 79 | +Given an encapsulation key `ek = (t_hat, rho)`: |
| 80 | + |
| 81 | +``` |
| 82 | +seed = SHA3-256(ByteEncode_12(ek.t_hat)) // hash only the t_hat bytes, not rho |
| 83 | +h = SampleNTTVector(seed, ek.rho) // sample a new NttVector<k> from the seed |
| 84 | +``` |
| 85 | + |
| 86 | +Output: `(h, ek.rho)` where `h` is an `NttVector<k>` in `T_q^k`. |
| 87 | + |
| 88 | +**`RandomEK(seed, rho) -> (r_hat, rho)`** |
| 89 | + |
| 90 | +Generate a random encapsulation key from the given random 32 byte `seed` and `rho`: |
| 91 | + |
| 92 | +Output: `SampleNTTVector(seed, rho)` |
| 93 | + |
| 94 | +This is identical to `H` except the seed is random rather than derived from a hash. |
| 95 | + |
| 96 | +## Protocol |
| 97 | + |
| 98 | +### Receiver (choice bit `b`) |
| 99 | + |
| 100 | +1. **Generate real keypair:** |
| 101 | + ``` |
| 102 | + (dk, ek) = ML-KEM.KeyGen() |
| 103 | + ``` |
| 104 | + where `ek = (t_hat, rho)`. |
| 105 | + |
| 106 | +2. **Sample random key for position `1-b`:** |
| 107 | + ``` |
| 108 | + seed = 32 random bytes |
| 109 | + r_{1-b} = RandomEK(seed, ek.rho) |
| 110 | + ``` |
| 111 | + |
| 112 | +3. **Compute the correlated key for position `b`:** |
| 113 | + ``` |
| 114 | + r_b.t_hat = ek.t_hat - H(r_{1-b}).t_hat |
| 115 | + r_b.rho = ek.rho |
| 116 | + ``` |
| 117 | + |
| 118 | +4. **Send to sender:** |
| 119 | + ``` |
| 120 | + Receiver -> Sender: (r_0, r_1) |
| 121 | + ``` |
| 122 | + Each serialized as `ByteEncode_12(r_j.t_hat) || rho`. |
| 123 | + |
| 124 | +### Sender |
| 125 | + |
| 126 | +1. **Receive `(r_0, r_1)` from the receiver.** |
| 127 | + |
| 128 | +2. **For each `j in {0, 1}`, reconstruct the encapsulation key:** |
| 129 | + ``` |
| 130 | + pk_j.t_hat = r_j.t_hat + H(r_{1-j}).t_hat |
| 131 | + pk_j.rho = rho // same rho from both r_0 and r_1 |
| 132 | + ``` |
| 133 | + |
| 134 | +3. **Encapsulate to both reconstructed keys:** |
| 135 | + ``` |
| 136 | + (ct_0, k_0) = ML-KEM.Encaps(pk_0) |
| 137 | + (ct_1, k_1) = ML-KEM.Encaps(pk_1) |
| 138 | + ``` |
| 139 | + |
| 140 | +4. **Derive OT keys:** |
| 141 | + ``` |
| 142 | + key_j = RO(domain_sep || k_j || j) |
| 143 | + ``` |
| 144 | + |
| 145 | +5. **Send to receiver:** |
| 146 | + ``` |
| 147 | + Sender -> Receiver: (ct_0, ct_1) |
| 148 | + ``` |
| 149 | + |
| 150 | +### Receiver (continued) |
| 151 | + |
| 152 | +6. **Decapsulate the chosen ciphertext:** |
| 153 | + ``` |
| 154 | + k_b = ML-KEM.Decaps(dk, ct_b) |
| 155 | + ``` |
| 156 | + |
| 157 | +7. **Derive OT key:** |
| 158 | + ``` |
| 159 | + key_b = RO(domain_sep || k_b || b) |
| 160 | + ``` |
| 161 | + |
| 162 | +## Why This Works |
| 163 | + |
| 164 | +**Correctness:** For the chosen side `b`, the sender reconstructs: |
| 165 | +``` |
| 166 | +pk_b.t_hat = r_b.t_hat + H(r_{1-b}).t_hat |
| 167 | + = (ek.t_hat - H(r_{1-b}).t_hat) + H(r_{1-b}).t_hat |
| 168 | + = ek.t_hat |
| 169 | +``` |
| 170 | +So `pk_b = ek`, the real public key. `ML-KEM.Decaps(dk, ct_b)` recovers the same shared key `k_b` that the sender computed via `ML-KEM.Encaps(pk_b)`. |
| 171 | + |
| 172 | +**Security:** For the other side `1-b`, the sender reconstructs: |
| 173 | +``` |
| 174 | +pk_{1-b}.t_hat = r_{1-b}.t_hat + H(r_b).t_hat |
| 175 | +``` |
| 176 | +The receiver does not have the secret key for `pk_{1-b}`, so they cannot decapsulate `ct_{1-b}`. |
| 177 | + |
| 178 | +The choice bit `b` is hidden because `r_b.t_hat = ek.t_hat - H(r_{1-b}).t_hat`. Since `ek.t_hat` is indistinguishable from uniform under MLWE, `r_b` looks like a random key regardless of `b`. Both `r_0` and `r_1` appear uniform to the sender. |
0 commit comments