modularot 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- modularot/__init__.py +49 -0
- modularot/cli.py +381 -0
- modularot/custom_kems/__init__.py +0 -0
- modularot/custom_kems/ggh_fo/__init__.py +0 -0
- modularot/custom_kems/ggh_fo/ggh_fo.py +280 -0
- modularot/custom_kems/ntru_fo/__init__.py +0 -0
- modularot/custom_kems/ntru_fo/ntru_fo.py +347 -0
- modularot/kem_adapter/__init__.py +0 -0
- modularot/kem_adapter/adapter.py +58 -0
- modularot/oblivious_sampling.py +207 -0
- modularot/ot_protocol.py +332 -0
- modularot-0.1.0.dist-info/METADATA +63 -0
- modularot-0.1.0.dist-info/RECORD +17 -0
- modularot-0.1.0.dist-info/WHEEL +5 -0
- modularot-0.1.0.dist-info/entry_points.txt +2 -0
- modularot-0.1.0.dist-info/licenses/LICENSE +21 -0
- modularot-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import hashlib
|
|
3
|
+
import math
|
|
4
|
+
import secrets
|
|
5
|
+
import struct
|
|
6
|
+
from typing import Dict, List, Optional, Tuple
|
|
7
|
+
|
|
8
|
+
from flint import fmpz_mat, fmpq_mat
|
|
9
|
+
|
|
10
|
+
from GGH_crypto import GGHHNFCryptosystem
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# ── domain-separation tags ────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
_TAG_KEY = b"GGH-HNF-FO-KEY-V1\x00"
|
|
16
|
+
_TAG_REJECT = b"GGH-HNF-FO-REJECT-V1\x00"
|
|
17
|
+
|
|
18
|
+
_R_ENTRY = 8 # bytes per private-basis entry (|R[i,j]| << 2^63)
|
|
19
|
+
_MAX_TRIES = 25
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ── public parameters (functions of n only) ───────────────────────────────────
|
|
23
|
+
|
|
24
|
+
def sigma_e(n: int) -> int:
|
|
25
|
+
"""Public error magnitude; key-independent so nothing leaks about R."""
|
|
26
|
+
return max(1, n // 2)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def keygen_k(n: int) -> int:
|
|
30
|
+
"""The library's GGH_private=True diagonal shift k = n*ceil(sqrt(n)+1)."""
|
|
31
|
+
return n * math.ceil(math.sqrt(n) + 1)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ── serialisation ─────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
def _entry_width(d: int) -> int:
|
|
37
|
+
"""Fixed byte width for values in [0, d), d > 1."""
|
|
38
|
+
return ((d - 1).bit_length() + 7) // 8
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _ser_pk(n: int, diag: List[int], cols: Dict[int, List[int]]) -> bytes:
|
|
42
|
+
out = bytearray(struct.pack(">i", n))
|
|
43
|
+
for d in diag:
|
|
44
|
+
db = d.to_bytes((d.bit_length() + 7) // 8 or 1, "big")
|
|
45
|
+
out += struct.pack(">H", len(db)) + db
|
|
46
|
+
for i in range(n):
|
|
47
|
+
if diag[i] > 1:
|
|
48
|
+
w = _entry_width(diag[i])
|
|
49
|
+
for v in cols[i]:
|
|
50
|
+
out += v.to_bytes(w, "big")
|
|
51
|
+
return bytes(out)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _des_pk(buf: bytes) -> Tuple[int, List[int], Dict[int, List[int]], int]:
|
|
55
|
+
"""Parse a serialised pk (or the pk prefix of an sk).
|
|
56
|
+
Returns (n, diag, cols, total_pk_length)."""
|
|
57
|
+
(n,) = struct.unpack_from(">i", buf, 0)
|
|
58
|
+
if not 1 <= n <= 1 << 20:
|
|
59
|
+
raise ValueError("GGH-HNF pk: implausible dimension")
|
|
60
|
+
off = 4
|
|
61
|
+
diag = []
|
|
62
|
+
for _ in range(n):
|
|
63
|
+
(ln,) = struct.unpack_from(">H", buf, off)
|
|
64
|
+
off += 2
|
|
65
|
+
diag.append(int.from_bytes(buf[off:off + ln], "big"))
|
|
66
|
+
off += ln
|
|
67
|
+
cols: Dict[int, List[int]] = {}
|
|
68
|
+
for i in range(n):
|
|
69
|
+
if diag[i] > 1:
|
|
70
|
+
w = _entry_width(diag[i])
|
|
71
|
+
col = []
|
|
72
|
+
for _ in range(i):
|
|
73
|
+
col.append(int.from_bytes(buf[off:off + w], "big"))
|
|
74
|
+
off += w
|
|
75
|
+
cols[i] = col
|
|
76
|
+
return n, diag, cols, off
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _ct_len(n: int, diag: List[int]) -> int:
|
|
80
|
+
return sum(_entry_width(diag[i]) for i in range(n) if diag[i] > 1)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _ser_ct(c: List[int], n: int, diag: List[int]) -> bytes:
|
|
84
|
+
out = bytearray()
|
|
85
|
+
for i in range(n):
|
|
86
|
+
if diag[i] > 1:
|
|
87
|
+
out += c[i].to_bytes(_entry_width(diag[i]), "big")
|
|
88
|
+
return bytes(out)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _des_ct(ct: bytes, n: int, diag: List[int]) -> Optional[List[int]]:
|
|
92
|
+
if len(ct) != _ct_len(n, diag):
|
|
93
|
+
return None
|
|
94
|
+
c, off = [], 0
|
|
95
|
+
for i in range(n):
|
|
96
|
+
if diag[i] > 1:
|
|
97
|
+
w = _entry_width(diag[i])
|
|
98
|
+
v = int.from_bytes(ct[off:off + w], "big")
|
|
99
|
+
if v >= diag[i]:
|
|
100
|
+
return None # outside the fundamental region: malformed
|
|
101
|
+
c.append(v)
|
|
102
|
+
off += w
|
|
103
|
+
else:
|
|
104
|
+
c.append(0)
|
|
105
|
+
return c
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _ser_R(R: fmpz_mat, n: int) -> bytes:
|
|
109
|
+
return b"".join(int(R[i, j]).to_bytes(_R_ENTRY, "big", signed=True)
|
|
110
|
+
for i in range(n) for j in range(n))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _des_R(buf: bytes, off: int, n: int) -> fmpz_mat:
|
|
114
|
+
return fmpz_mat([
|
|
115
|
+
[int.from_bytes(buf[off + (i * n + j) * _R_ENTRY:
|
|
116
|
+
off + (i * n + j + 1) * _R_ENTRY], "big", signed=True)
|
|
117
|
+
for j in range(n)]
|
|
118
|
+
for i in range(n)
|
|
119
|
+
])
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _hnf_to_diag_cols(H: fmpz_mat, n: int) -> Tuple[List[int], Dict[int, List[int]]]:
|
|
123
|
+
diag = [int(H[i, i]) for i in range(n)]
|
|
124
|
+
cols = {i: [int(H[k, i]) for k in range(i)] for i in range(n) if diag[i] > 1}
|
|
125
|
+
return diag, cols
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ── HNF arithmetic ────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
def _reduce_mod_hnf(e: List[int], n: int, diag: List[int],
|
|
131
|
+
cols: Dict[int, List[int]]) -> List[int]:
|
|
132
|
+
"""Reduce a vector into the fundamental region of an upper-triangular HNF
|
|
133
|
+
(flint's form): ascending sweep c -= floor(c_i / d_i) * row_i. The
|
|
134
|
+
library's reduce_mod_B assumes a lower-triangular HNF, hence this copy.
|
|
135
|
+
Result: c_i in [0, d_i), so c_i = 0 wherever d_i = 1."""
|
|
136
|
+
c = list(e)
|
|
137
|
+
dense = [i for i in range(n) if diag[i] > 1]
|
|
138
|
+
for i in range(n):
|
|
139
|
+
q = c[i] // diag[i]
|
|
140
|
+
if q:
|
|
141
|
+
c[i] -= q * diag[i]
|
|
142
|
+
for j in dense:
|
|
143
|
+
if j > i:
|
|
144
|
+
c[j] -= q * cols[j][i]
|
|
145
|
+
return c
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _sigma_ok(R: fmpz_mat, n: int) -> bool:
|
|
149
|
+
"""Zero-failure condition for Babai rounding: for every j,
|
|
150
|
+
2*sigma_e * sum_i |R^-1[i,j]| < 1 (column L1, as e is a row vector)."""
|
|
151
|
+
s = sigma_e(n)
|
|
152
|
+
Rinv = fmpq_mat(R).inv()
|
|
153
|
+
for j in range(n):
|
|
154
|
+
col = sum(abs(Rinv[i, j]) for i in range(n))
|
|
155
|
+
if 2 * s * col >= 1:
|
|
156
|
+
return False
|
|
157
|
+
return True
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ── cryptographic helpers ─────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
def _pack_signs(e: List[int]) -> bytes:
|
|
163
|
+
"""Pack sign(e_i) as bit i (LSB-first within each byte): the KEM coin m."""
|
|
164
|
+
out = bytearray((len(e) + 7) // 8)
|
|
165
|
+
for i, v in enumerate(e):
|
|
166
|
+
if v > 0:
|
|
167
|
+
out[i // 8] |= 1 << (i % 8)
|
|
168
|
+
return bytes(out)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _h_key(m_bytes: bytes, ct: bytes) -> bytes:
|
|
172
|
+
return hashlib.sha3_256(_TAG_KEY + m_bytes + ct).digest()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _h_reject(reject_key: bytes, ct: bytes) -> bytes:
|
|
176
|
+
return hashlib.sha3_256(_TAG_REJECT + reject_key + ct).digest()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ── oblivious-sampling support ────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
def draw_lattice_diag(n: int, randint=None) -> List[int]:
|
|
182
|
+
"""One accepted keygen draw, returning only diag(HNF(R)). Follows the
|
|
183
|
+
GGH_private=True law plus gen()'s rejection rules, so det and cokernel match
|
|
184
|
+
keygen exactly; R itself is discarded and unrelated to the published key.
|
|
185
|
+
`randint(a, b)` defaults to the crypto RNG."""
|
|
186
|
+
if randint is None:
|
|
187
|
+
rng = secrets.SystemRandom()
|
|
188
|
+
randint = rng.randint
|
|
189
|
+
k = keygen_k(n)
|
|
190
|
+
for _ in range(_MAX_TRIES):
|
|
191
|
+
R = fmpz_mat([[randint(-n, n - 1) for _ in range(n)] for _ in range(n)])
|
|
192
|
+
for i in range(n):
|
|
193
|
+
R[i, i] = int(R[i, i]) + k
|
|
194
|
+
if R.det() == 0 or not _sigma_ok(R, n):
|
|
195
|
+
continue
|
|
196
|
+
H = R.hnf()
|
|
197
|
+
diag = [int(H[i, i]) for i in range(n)]
|
|
198
|
+
if all(d == 1 for d in diag):
|
|
199
|
+
continue # same rejection rule as gen()
|
|
200
|
+
return diag
|
|
201
|
+
raise ValueError(f"no accepted GGH-HNF draw in {_MAX_TRIES} tries (n={n} too small?)")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ── KEM primitives ────────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
def gen(dimension: int = 200) -> Tuple[bytes, bytes]:
|
|
207
|
+
"""Generate (pk, sk) via GGH_private=True keygen, rejecting bases that fail
|
|
208
|
+
the sigma bound."""
|
|
209
|
+
n = dimension
|
|
210
|
+
for _ in range(_MAX_TRIES):
|
|
211
|
+
ggh = GGHHNFCryptosystem(dimension=n, GGH_private=True)
|
|
212
|
+
R = ggh.private_basis
|
|
213
|
+
if not _sigma_ok(R, n):
|
|
214
|
+
continue
|
|
215
|
+
H = ggh.public_basis # already R.hnf()
|
|
216
|
+
diag, cols = _hnf_to_diag_cols(H, n)
|
|
217
|
+
if all(d == 1 for d in diag):
|
|
218
|
+
continue # det = +-1: nothing to hide in
|
|
219
|
+
pk = _ser_pk(n, diag, cols)
|
|
220
|
+
sk = pk + _ser_R(R, n) + secrets.token_bytes(32)
|
|
221
|
+
return pk, sk
|
|
222
|
+
raise ValueError(
|
|
223
|
+
f"no sigma-accepted GGH-HNF key in {_MAX_TRIES} tries; "
|
|
224
|
+
f"increase the dimension (sigma_max ~ 0.7*n must exceed n//2)."
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def encaps(pk: bytes) -> Tuple[bytes, bytes]:
|
|
229
|
+
"""Return (shared_secret, ciphertext)."""
|
|
230
|
+
n, diag, cols, _ = _des_pk(pk)
|
|
231
|
+
s = sigma_e(n)
|
|
232
|
+
|
|
233
|
+
raw = secrets.token_bytes((n + 7) // 8)
|
|
234
|
+
e = [s if (raw[i // 8] >> (i % 8)) & 1 else -s for i in range(n)]
|
|
235
|
+
|
|
236
|
+
c = _reduce_mod_hnf(e, n, diag, cols)
|
|
237
|
+
ct = _ser_ct(c, n, diag)
|
|
238
|
+
return _h_key(_pack_signs(e), ct), ct
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@functools.lru_cache(maxsize=16)
|
|
242
|
+
def _load_sk(sk: bytes) -> Tuple[int, List[int], Dict[int, List[int]],
|
|
243
|
+
fmpz_mat, bytes]:
|
|
244
|
+
"""Parse an sk into (n, diag, cols, R, reject_key) (memoised)."""
|
|
245
|
+
n, diag, cols, pk_len = _des_pk(sk)
|
|
246
|
+
R = _des_R(sk, pk_len, n)
|
|
247
|
+
reject_key = sk[pk_len + n * n * _R_ENTRY: pk_len + n * n * _R_ENTRY + 32]
|
|
248
|
+
return n, diag, cols, R, reject_key
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def decaps(sk: bytes, ct: bytes) -> bytes:
|
|
252
|
+
"""Return shared_secret (or the implicit-rejection value on failure)."""
|
|
253
|
+
n, diag, cols, R, reject_key = _load_sk(sk)
|
|
254
|
+
s = sigma_e(n)
|
|
255
|
+
|
|
256
|
+
c = _des_ct(ct, n, diag)
|
|
257
|
+
if c is None:
|
|
258
|
+
return _h_reject(reject_key, ct)
|
|
259
|
+
|
|
260
|
+
# Babai rounding: solve x R = c (cheaper than forming R^-1), then
|
|
261
|
+
# e = c - round(x) R
|
|
262
|
+
try:
|
|
263
|
+
xt = fmpq_mat(R).transpose().solve(fmpq_mat(n, 1, [int(v) for v in c]))
|
|
264
|
+
rounded = fmpz_mat([[round(xt[j, 0]) for j in range(n)]])
|
|
265
|
+
closest = rounded * R
|
|
266
|
+
e = [c[j] - int(closest[0, j]) for j in range(n)]
|
|
267
|
+
except Exception:
|
|
268
|
+
return _h_reject(reject_key, ct)
|
|
269
|
+
|
|
270
|
+
# FO re-encryption check; accumulate both conditions before branching
|
|
271
|
+
valid = 0
|
|
272
|
+
for v in e:
|
|
273
|
+
valid |= (abs(v) != s)
|
|
274
|
+
c2 = _reduce_mod_hnf(e, n, diag, cols)
|
|
275
|
+
for a, b in zip(c, c2):
|
|
276
|
+
valid |= (a != b)
|
|
277
|
+
|
|
278
|
+
if valid == 0:
|
|
279
|
+
return _h_key(_pack_signs(e), ct)
|
|
280
|
+
return _h_reject(reject_key, ct)
|
|
File without changes
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import logging
|
|
3
|
+
import hashlib
|
|
4
|
+
import secrets
|
|
5
|
+
import hmac
|
|
6
|
+
from typing import Tuple, Optional, List
|
|
7
|
+
from flint import nmod_poly, fmpz_poly
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import math
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
log = logging.getLogger("ntru_fo")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def center_lift(poly, modulus):
|
|
17
|
+
"""Center lift coefficients to range [-(q-1)/2, (q-1)/2]."""
|
|
18
|
+
half_mod = modulus // 2
|
|
19
|
+
coeffs = poly.coeffs()
|
|
20
|
+
centered = [((int(c) + half_mod) % modulus) - half_mod for c in coeffs]
|
|
21
|
+
return fmpz_poly(centered)
|
|
22
|
+
|
|
23
|
+
def poly_mod_coeffs(poly, modulus):
|
|
24
|
+
"""Reduce polynomial coefficients modulo 'modulus'."""
|
|
25
|
+
coeffs = [int(c) % modulus for c in poly.coeffs()]
|
|
26
|
+
return fmpz_poly(coeffs)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def is_prime(n):
|
|
30
|
+
if n < 2:
|
|
31
|
+
return False
|
|
32
|
+
for i in range(2, int(n ** 0.5) + 1):
|
|
33
|
+
if n % i == 0:
|
|
34
|
+
return False
|
|
35
|
+
return True
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def is_2_power(n):
|
|
39
|
+
return n != 0 and (n & (n - 1)) == 0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def random_poly_flint(length, d, neg_ones_diff=0):
|
|
43
|
+
coeffs = np.random.permutation(
|
|
44
|
+
np.concatenate((
|
|
45
|
+
np.zeros(length - 2 * d - neg_ones_diff),
|
|
46
|
+
np.ones(d),
|
|
47
|
+
-np.ones(d + neg_ones_diff)
|
|
48
|
+
))
|
|
49
|
+
)
|
|
50
|
+
return fmpz_poly([int(x) for x in coeffs])
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def invert_nmod_poly(f, R, p):
|
|
54
|
+
"""Inverse of f mod R over Z/pZ (p prime) via xgcd: s*f + t*R = g, so
|
|
55
|
+
f^-1 = g^-1 * s. Raises if f is not invertible."""
|
|
56
|
+
g, s, _ = f.xgcd(R)
|
|
57
|
+
|
|
58
|
+
# f is invertible mod R iff gcd(f, R) is a nonzero constant.
|
|
59
|
+
if g.degree() != 0:
|
|
60
|
+
raise Exception("Polynomial is not invertible")
|
|
61
|
+
const = int(g.coeffs()[0]) if g.length() > 0 else 0
|
|
62
|
+
if const % p == 0:
|
|
63
|
+
raise Exception("Polynomial is not invertible")
|
|
64
|
+
|
|
65
|
+
# xgcd does not force g monic; invert g by Fermat
|
|
66
|
+
const_inv = pow(const % p, p - 2, p)
|
|
67
|
+
return (s * nmod_poly([const_inv], p)) % R
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def invert_poly_flint(f_poly, R_poly, p):
|
|
71
|
+
"""Inverse of f_poly mod R_poly (x^N - 1) over Z_p, p prime or a power of 2."""
|
|
72
|
+
|
|
73
|
+
if is_prime(p):
|
|
74
|
+
log.debug(f"Inverting mod prime p={p}")
|
|
75
|
+
|
|
76
|
+
f_mod = nmod_poly(f_poly.coeffs(), p)
|
|
77
|
+
R_mod = nmod_poly(R_poly.coeffs(), p)
|
|
78
|
+
|
|
79
|
+
inv = invert_nmod_poly(f_mod, R_mod, p)
|
|
80
|
+
|
|
81
|
+
return fmpz_poly([int(c) for c in inv.coeffs()])
|
|
82
|
+
|
|
83
|
+
elif is_2_power(p):
|
|
84
|
+
log.debug(f"Inverting mod 2-power p={p}")
|
|
85
|
+
|
|
86
|
+
e = p.bit_length() - 1 # p = 2^e
|
|
87
|
+
|
|
88
|
+
# seed: inverse mod 2
|
|
89
|
+
f_mod2 = nmod_poly(f_poly.coeffs(), 2)
|
|
90
|
+
R_mod2 = nmod_poly(R_poly.coeffs(), 2)
|
|
91
|
+
inv = invert_nmod_poly(f_mod2, R_mod2, 2)
|
|
92
|
+
|
|
93
|
+
# Newton lift: inv' = inv*(2 - f*inv) mod R doubles the precision each step
|
|
94
|
+
f_mod = nmod_poly([int(c) for c in f_poly.coeffs()], p)
|
|
95
|
+
R_mod = nmod_poly([int(c) for c in R_poly.coeffs()], p)
|
|
96
|
+
inv = nmod_poly([int(c) for c in inv.coeffs()], p)
|
|
97
|
+
two = nmod_poly([2], p)
|
|
98
|
+
|
|
99
|
+
prec = 1
|
|
100
|
+
while prec < e:
|
|
101
|
+
inv = (inv * (two - (f_mod * inv) % R_mod)) % R_mod
|
|
102
|
+
prec *= 2
|
|
103
|
+
|
|
104
|
+
return fmpz_poly([int(c) for c in inv.coeffs()])
|
|
105
|
+
|
|
106
|
+
else:
|
|
107
|
+
raise Exception(f"Cannot invert polynomial in Z_{p}")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class NTRU_FO:
|
|
111
|
+
"""
|
|
112
|
+
Stable NTRU-FO-CCA2 KEM (research-grade).
|
|
113
|
+
|
|
114
|
+
Fixes:
|
|
115
|
+
- Proper invertibility rejection loop
|
|
116
|
+
- Canonical polynomial ordering (no reversals)
|
|
117
|
+
- SHAKE-based deterministic randomness
|
|
118
|
+
- Implicit rejection
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
TAG_RANDOMNESS = b"NTRU-FO-RAND-V1"
|
|
122
|
+
TAG_SESSION_KEY = b"NTRU-FO-KEY-V1"
|
|
123
|
+
TAG_MESSAGE = b"NTRU-FO-MSG-V1"
|
|
124
|
+
TAG_IMPLICIT_REJECT = b"NTRU-FO-REJECT-V1"
|
|
125
|
+
|
|
126
|
+
def __init__(self, N: int = 701, p: int = 3, q: int = 8192):
|
|
127
|
+
self.N = N
|
|
128
|
+
self.p = p
|
|
129
|
+
self.q = q
|
|
130
|
+
self.sigma_bytes = 32
|
|
131
|
+
|
|
132
|
+
coeffs = [0] * (N + 1)
|
|
133
|
+
coeffs[0] = -1
|
|
134
|
+
coeffs[N] = 1
|
|
135
|
+
self.R_poly = fmpz_poly(coeffs)
|
|
136
|
+
|
|
137
|
+
self.f_poly = None
|
|
138
|
+
self.f_p_poly = None
|
|
139
|
+
self.f_q_poly = None
|
|
140
|
+
self.g_poly = None
|
|
141
|
+
self.h_poly = None
|
|
142
|
+
# placeholder until generate_keys(); keeps decapsulate() from crashing keyless
|
|
143
|
+
self._implicit_reject_key = secrets.token_bytes(32)
|
|
144
|
+
|
|
145
|
+
logging.info(f"NTRU-FO initialized (N={N}, p={p}, q={q})")
|
|
146
|
+
|
|
147
|
+
# ============================================================
|
|
148
|
+
# KEY GENERATION (FIXED)
|
|
149
|
+
# ============================================================
|
|
150
|
+
|
|
151
|
+
def generate_keys(self) -> Tuple[fmpz_poly, fmpz_poly]:
|
|
152
|
+
logging.info("Generating keys...")
|
|
153
|
+
start = time.perf_counter()
|
|
154
|
+
|
|
155
|
+
while True:
|
|
156
|
+
try:
|
|
157
|
+
self.g_poly = random_poly_flint(self.N, self.N // 4)
|
|
158
|
+
self.f_poly = random_poly_flint(
|
|
159
|
+
self.N, self.N // 3, neg_ones_diff=-1
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
self.f_p_poly = invert_poly_flint(
|
|
163
|
+
self.f_poly, self.R_poly, self.p
|
|
164
|
+
)
|
|
165
|
+
self.f_q_poly = invert_poly_flint(
|
|
166
|
+
self.f_poly, self.R_poly, self.q
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
if self.f_p_poly is None or self.f_q_poly is None:
|
|
170
|
+
continue
|
|
171
|
+
|
|
172
|
+
# Verify inversion correctness
|
|
173
|
+
test_p = poly_mod_coeffs(
|
|
174
|
+
(self.f_poly * self.f_p_poly) % self.R_poly,
|
|
175
|
+
self.p,
|
|
176
|
+
)
|
|
177
|
+
test_q = poly_mod_coeffs(
|
|
178
|
+
(self.f_poly * self.f_q_poly) % self.R_poly,
|
|
179
|
+
self.q,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
if list(test_p.coeffs())[:1] != [1]:
|
|
183
|
+
continue
|
|
184
|
+
if list(test_q.coeffs())[:1] != [1]:
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
break
|
|
188
|
+
|
|
189
|
+
except Exception:
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
p_f_q = poly_mod_coeffs(self.p * self.f_q_poly, self.q)
|
|
193
|
+
h_tmp = poly_mod_coeffs(p_f_q * self.g_poly, self.q)
|
|
194
|
+
self.h_poly = poly_mod_coeffs(h_tmp % self.R_poly, self.q)
|
|
195
|
+
|
|
196
|
+
self._implicit_reject_key = self._derive_reject_key()
|
|
197
|
+
|
|
198
|
+
logging.info(f"Keygen done in {time.perf_counter() - start:.4f}s")
|
|
199
|
+
return self._poly_to_bytes(self.h_poly), b"" # sk stays internal
|
|
200
|
+
|
|
201
|
+
# ============================================================
|
|
202
|
+
# ENCAPSULATION
|
|
203
|
+
# ============================================================
|
|
204
|
+
|
|
205
|
+
def encapsulate(self, public_key=None):
|
|
206
|
+
if public_key is None:
|
|
207
|
+
public_key = self.h_poly
|
|
208
|
+
elif isinstance(public_key, bytes):
|
|
209
|
+
public_key = self._bytes_to_poly(public_key)
|
|
210
|
+
|
|
211
|
+
message = np.array(
|
|
212
|
+
[secrets.randbelow(self.p) for _ in range(self.N)]
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
sigma = secrets.token_bytes(self.sigma_bytes)
|
|
216
|
+
m_bytes = self._encode_message(message)
|
|
217
|
+
|
|
218
|
+
r_poly = self._hash_to_randomness(m_bytes, sigma)
|
|
219
|
+
|
|
220
|
+
msg_poly = fmpz_poly([int(c) for c in message])
|
|
221
|
+
|
|
222
|
+
c_poly = poly_mod_coeffs(
|
|
223
|
+
(poly_mod_coeffs(r_poly * public_key, self.q) + msg_poly)
|
|
224
|
+
% self.R_poly,
|
|
225
|
+
self.q,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
c_bytes = self._poly_to_bytes(c_poly)
|
|
229
|
+
ciphertext = c_bytes + sigma
|
|
230
|
+
|
|
231
|
+
session_key = self._hash_h(m_bytes, ciphertext)
|
|
232
|
+
return session_key, ciphertext # (shared_secret, ciphertext)
|
|
233
|
+
|
|
234
|
+
# ============================================================
|
|
235
|
+
# DECAPSULATION
|
|
236
|
+
# ============================================================
|
|
237
|
+
|
|
238
|
+
def decapsulate(self, sk, ciphertext: bytes) -> bytes:
|
|
239
|
+
c_len = self.N * 4
|
|
240
|
+
if len(ciphertext) != c_len + self.sigma_bytes:
|
|
241
|
+
return self._implicit_rejection(ciphertext)
|
|
242
|
+
|
|
243
|
+
c_bytes = ciphertext[:c_len]
|
|
244
|
+
sigma = ciphertext[c_len:]
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
c_poly = self._bytes_to_poly(c_bytes)
|
|
248
|
+
except Exception:
|
|
249
|
+
return self._implicit_rejection(ciphertext)
|
|
250
|
+
|
|
251
|
+
try:
|
|
252
|
+
decrypted = self._decrypt(c_poly)
|
|
253
|
+
except Exception:
|
|
254
|
+
return self._implicit_rejection(ciphertext)
|
|
255
|
+
|
|
256
|
+
message = np.array(decrypted[: self.N])
|
|
257
|
+
m_bytes = self._encode_message(message)
|
|
258
|
+
|
|
259
|
+
r_prime = self._hash_to_randomness(m_bytes, sigma)
|
|
260
|
+
|
|
261
|
+
msg_poly = fmpz_poly([int(c) for c in message])
|
|
262
|
+
|
|
263
|
+
c_prime = poly_mod_coeffs(
|
|
264
|
+
(poly_mod_coeffs(r_prime * self.h_poly, self.q) + msg_poly)
|
|
265
|
+
% self.R_poly,
|
|
266
|
+
self.q,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
c_prime_bytes = self._poly_to_bytes(c_prime)
|
|
270
|
+
|
|
271
|
+
if hmac.compare_digest(c_bytes, c_prime_bytes):
|
|
272
|
+
return self._hash_h(m_bytes, ciphertext)
|
|
273
|
+
else:
|
|
274
|
+
return self._implicit_rejection(ciphertext)
|
|
275
|
+
|
|
276
|
+
# ============================================================
|
|
277
|
+
# INTERNALS
|
|
278
|
+
# ============================================================
|
|
279
|
+
|
|
280
|
+
def _decrypt(self, ciphertext_poly: fmpz_poly) -> List[int]:
|
|
281
|
+
a = poly_mod_coeffs(self.f_poly * ciphertext_poly, self.q)
|
|
282
|
+
a = poly_mod_coeffs(a % self.R_poly, self.q)
|
|
283
|
+
a = center_lift(a, self.q)
|
|
284
|
+
b = poly_mod_coeffs(a, self.p)
|
|
285
|
+
|
|
286
|
+
result = poly_mod_coeffs(
|
|
287
|
+
(self.f_p_poly * b) % self.R_poly, self.p
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
# .coeffs() drops trailing zeros; pad to N so m_bytes stays canonical
|
|
291
|
+
coeffs = list(result.coeffs())
|
|
292
|
+
return [int(coeffs[i]) if i < len(coeffs) else 0 for i in range(self.N)]
|
|
293
|
+
|
|
294
|
+
def _hash_to_randomness(self, m_bytes: bytes, sigma: bytes):
|
|
295
|
+
# digest(n) returns a prefix of the XOF stream, so longer draws stay
|
|
296
|
+
# deterministic (the re-encryption check in decapsulate() needs this)
|
|
297
|
+
shake = hashlib.shake_256()
|
|
298
|
+
shake.update(self.TAG_RANDOMNESS + m_bytes + sigma)
|
|
299
|
+
|
|
300
|
+
take = self.N * 2
|
|
301
|
+
while True:
|
|
302
|
+
buf = shake.digest(take)
|
|
303
|
+
coeffs = []
|
|
304
|
+
for b in buf:
|
|
305
|
+
if b >= 255: # reject 255 so 0..254 maps to {0,1,2} unbiased
|
|
306
|
+
continue
|
|
307
|
+
v = b % 3
|
|
308
|
+
coeffs.append(0 if v == 0 else (1 if v == 1 else -1))
|
|
309
|
+
if len(coeffs) == self.N:
|
|
310
|
+
return fmpz_poly(coeffs)
|
|
311
|
+
take *= 2 # practically unreachable
|
|
312
|
+
|
|
313
|
+
def _encode_message(self, message: np.ndarray) -> bytes:
|
|
314
|
+
out = bytearray(self.TAG_MESSAGE)
|
|
315
|
+
for c in message:
|
|
316
|
+
out.extend(int(c % self.p).to_bytes(2, "big"))
|
|
317
|
+
return bytes(out)
|
|
318
|
+
|
|
319
|
+
def _hash_h(self, m_bytes: bytes, data: bytes) -> bytes:
|
|
320
|
+
return hashlib.sha3_256(self.TAG_SESSION_KEY + m_bytes + data).digest()
|
|
321
|
+
|
|
322
|
+
def _derive_reject_key(self) -> bytes:
|
|
323
|
+
return hashlib.sha3_256(
|
|
324
|
+
self.TAG_IMPLICIT_REJECT + self._poly_to_bytes(self.f_poly)
|
|
325
|
+
).digest()
|
|
326
|
+
|
|
327
|
+
def _implicit_rejection(self, ciphertext: bytes) -> bytes:
|
|
328
|
+
return hashlib.sha3_256(
|
|
329
|
+
self._implicit_reject_key + ciphertext
|
|
330
|
+
).digest()
|
|
331
|
+
|
|
332
|
+
def _poly_to_bytes(self, poly: fmpz_poly) -> bytes:
|
|
333
|
+
coeffs = list(poly.coeffs())
|
|
334
|
+
out = bytearray()
|
|
335
|
+
for i in range(self.N):
|
|
336
|
+
c = int(coeffs[i]) if i < len(coeffs) else 0
|
|
337
|
+
out.extend((c % self.q).to_bytes(4, "big"))
|
|
338
|
+
return bytes(out)
|
|
339
|
+
|
|
340
|
+
def _bytes_to_poly(self, data: bytes) -> fmpz_poly:
|
|
341
|
+
if len(data) != self.N * 4:
|
|
342
|
+
raise ValueError("Invalid polynomial encoding")
|
|
343
|
+
coeffs = [
|
|
344
|
+
int.from_bytes(data[i:i + 4], "big") % self.q
|
|
345
|
+
for i in range(0, len(data), 4)
|
|
346
|
+
]
|
|
347
|
+
return fmpz_poly(coeffs)
|
|
File without changes
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Tuple
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class KEMInfo:
|
|
8
|
+
name: str
|
|
9
|
+
is_quantum_safe: bool
|
|
10
|
+
security_level: int # classical security bits
|
|
11
|
+
pk_bytes: int
|
|
12
|
+
ct_bytes: int
|
|
13
|
+
ss_bytes: int
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class KEM(ABC):
|
|
17
|
+
"""Abstract Key Encapsulation Mechanism interface."""
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def info(self) -> KEMInfo: ...
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def generate_keypair(self) -> Tuple[bytes, bytes]:
|
|
25
|
+
"""Return (public_key, secret_key)."""
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def encapsulate(self, pk: bytes) -> Tuple[bytes, bytes]:
|
|
29
|
+
"""Return (shared_secret, ciphertext)."""
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def decapsulate(self, sk: bytes, ct: bytes) -> bytes:
|
|
33
|
+
"""Return shared_secret."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class KEMAdapter(KEM):
|
|
37
|
+
|
|
38
|
+
def __init__(self, gen, encap, decap, info: KEMInfo = None):
|
|
39
|
+
self._gen = gen
|
|
40
|
+
self._encap = encap
|
|
41
|
+
self._decap = decap
|
|
42
|
+
self._info = info or KEMInfo("generic", False, 0, 0, 0, 0)
|
|
43
|
+
self.Gen = gen
|
|
44
|
+
self.encaps = encap
|
|
45
|
+
self.decaps = decap
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def info(self) -> KEMInfo:
|
|
49
|
+
return self._info
|
|
50
|
+
|
|
51
|
+
def generate_keypair(self) -> Tuple[bytes, bytes]:
|
|
52
|
+
return self._gen()
|
|
53
|
+
|
|
54
|
+
def encapsulate(self, pk: bytes) -> Tuple[bytes, bytes]:
|
|
55
|
+
return self._encap(pk)
|
|
56
|
+
|
|
57
|
+
def decapsulate(self, sk: bytes, ct: bytes) -> bytes:
|
|
58
|
+
return self._decap(sk, ct)
|