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.
@@ -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)