-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathHerradura cryptographic suite.py
More file actions
4607 lines (3940 loc) · 197 KB
/
Copy pathHerradura cryptographic suite.py
File metadata and controls
4607 lines (3940 loc) · 197 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
'''
Herradura Cryptographic Suite v1.9.77
Copyright (C) 2024-2026 Omar Alejandro Herrera Reyna
This program is free software: you can redistribute it and/or modify
it under the terms of the MIT License or the GNU General Public License
as published by the Free Software Foundation, either version 3 of the License,
or (at your option) any later version.
Under the terms of the GNU General Public License, please also consider that:
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
--- v1.9.77: HCRED commit binds statement hash — deterministic replay rejection (TODO #128 Batch 4a) ---
--- v1.9.76: HCRED-KKW — preprocessing-model MPCitH transcript, ~11x smaller (TODO #128 Batch 3) ---
--- v1.9.75: HCRED unified circuit — in-circuit Ring-LWR check, same-s linkage (TODO #128 Batch 2) ---
--- v1.9.74: HCRED — hybrid Ring-LWR + Stern-F credential, Batch 1 (TODO #128) ---
--- v1.9.16: HPKS-Stern-Ring — OR-composed Stern ring signature (TODO #78.I) ---
--- v1.8.0: KDF domain constant — seed = ROL(K,n/8) XOR _RNL_KDF_DC_256 for HSKE-NL-A1 and HKEX-RNL (TODO #38) ---
--- v1.7.3: NumPy NTT acceleration — ~10× speedup on _rnl_poly_mul (TODO #40) ---
--- v1.5.41: rnl_lift centered rounding across all targets (TODO #37) ---
--- v1.5.40: Constant-time audit — branchless stern_apply_perm + non-CT docs (TODO #41) ---
--- v1.5.23: HerraduraCli — OpenSSL-style Python CLI (TODO #25); CliTest shell test suite ---
--- v1.5.20: HPKE-Stern-F N=256 known-e' demo; multi-size standardization ---
--- v1.5.18: HPKS-Stern-F / HPKE-Stern-F — code-based PQC (SD + NL-FSCX v1 PRF) ---
Adds HPKS-Stern-F (Stern identification + Fiat-Shamir, §11.8.4) and HPKE-Stern-F
(Niederreiter KEM). Security of HPKS-Stern-F reduces to SD(N,t) [NP-complete,
BMvT 1978] plus NL-FSCX v1 PRF — the only complete chain to a studied hard
problem in the suite (Theorem 17, SecurityProofs-4.md §11.8.4).
Replaces the GF(2^n)* discrete-log base that Shor's algorithm breaks in HPKS-NL
and HPKE-NL. Parameters: N=n, n_rows=n/2, t=n/16 (16 at n=256), SDFR=32 rounds
(demo; production requires ≥219 for 128-bit soundness).
--- v1.5.13: HSKE-NL-A1 seed fix — ROL(base, n/8) breaks counter=0 step-1 degeneracy ---
HSKE-NL-A1 keystream: seed = base.rotated(n/8); ks = nl_fscx_revolve_v1(seed, base^ctr, n/4).
When A=B=base (counter=0), fscx(base,base)=0 so step 1 was a pure rotation (linear).
ROL(base,n/8) ensures seed!=base, activating full carry non-linearity from step 1.
Same degeneracy pattern fixed for HKEX-RNL KDF in v1.5.10; now applied consistently.
--- v1.5.10: HKEX-RNL KDF seed fix — ROL(K, n/8) breaks step-1 degeneracy ---
HKEX-RNL KDF now derives the initial state as seed = ROL(K, n/8) instead of
using K directly:
seed = ROL(K, n/8)
sk = nl_fscx_revolve_v1(seed, K, n/4)
When A0 = B = K, fscx(K,K) = 0, making step 1 a pure rotation (linear in K).
ROL(K, n/8) ensures seed != K, so fscx(seed, K) != 0 and full non-linear carry
mixing is active from the very first step.
--- v1.5.9: HSKE-NL-A1 per-session nonce; nl_fscx_revolve_v2_inv delta precompute ---
HSKE-NL-A1 now generates a random per-session nonce N and derives the session
key base as K XOR N (transmitted alongside ciphertext). Eliminates keystream
reuse when the same long-term key K is used across sessions.
nl_fscx_revolve_v2_inv precomputes delta(B) once before the loop; loop body:
z = y - delta; y = B XOR _m_inv(z). Eliminates per-step multiply-and-rotate.
--- v1.5.7: precomputed M^{-1} for nl_fscx_v2_inv ---
_m_inv now computes the rotation table for M^{-1} = M^{n/2-1} once on first call
(bootstrapping from fscx_revolve(1, 0, n/2-1)), caches it per bit-size, then applies
M^{-1}(X) as XOR of ROL(X,k) for each k in the table. Reduces each inverse step
from n/2-1 FSCX iterations to ~2n/3 XOR-rotation pairs.
--- v1.5.6: rnl_rand_poly bias fix — 3-byte rejection sampling ---
_rnl_rand_poly now uses 24-bit rejection sampling (threshold = (1<<24) - (1<<24)%q)
to eliminate the ~1/2^32 modular bias introduced by the previous 4-byte draw.
--- v1.5.4: NTT-based negacyclic polynomial multiplication (O(n log n)) ---
_rnl_poly_mul now uses a Cooley-Tukey NTT over Z_{65537} (a Fermat prime, 2^16+1)
with a negacyclic twist (ψ = 3^((q-1)/(2n)), primitive 2n-th root of unity).
Replaces the O(n²) schoolbook multiply: ~32× speedup at n=256.
--- v1.5.3: HKEX-RNL secret sampler upgraded to CBD(eta=1) ---
HKEX-RNL secret polynomial now uses a centered binomial distribution CBD(eta=1)
instead of the previous uniform {0,1} sampler. CBD(1) produces coefficients in
{-1, 0, 1} with zero mean and probabilities {1/4, 1/2, 1/4}, matching the Kyber
baseline for proper Ring-LWR hardness. The max coefficient magnitude is unchanged
(1), so the noise budget and parameter set are unaffected.
--- v1.5.0: NL-FSCX non-linear extension and PQC protocols ---
Adds two NL-FSCX primitives and five PQC-hardened protocol variants
alongside the existing classical (non-PQC) algorithms (kept for reference).
NL-FSCX v1: nl_fscx_v1(A,B) = fscx(A,B) XOR ROL((A+B) mod 2^n, n/4)
Injects integer-carry non-linearity. Not bijective in A — for one-way
use only (counter-mode HSKE, HKEX KDF, HPKS challenge hash).
NL-FSCX v2: nl_fscx_v2(A,B) = (fscx(A,B) + ROL(B*(B+1)//2, n/4)) mod 2^n
B-only additive offset; bijective in A with closed-form inverse.
Used for revolve-mode HSKE and HPKE where decryption is required.
PQC protocol variants (C3 hybrid assignment):
HSKE-NL-A1 — counter-mode HSKE with NL-FSCX v1 keystream
HSKE-NL-A2 — revolve-mode HSKE with NL-FSCX v2 (invertible)
HKEX-RNL — Ring-LWR key exchange (quantum-resistant; replaces HKEX-GF)
HPKS-NL — Schnorr with NL-FSCX v1 challenge (linear preimage hardened)
HPKE-NL — El Gamal with NL-FSCX v2 encryption/decryption
Classical protocols (not PQC — kept for reference and comparison):
HKEX-GF — Diffie-Hellman over GF(2^n)* (broken by Shor's algorithm)
HSKE — fscx_revolve symmetric encryption (linear key recovery)
HPKS — Schnorr with fscx_revolve challenge (linear challenge)
HPKE — El Gamal + fscx_revolve (linear encryption)
--- v1.4.0: HKEX-GF (Diffie-Hellman over GF(2^n)*) ---
The broken fscx_revolve_n-based HKEX is replaced with HKEX-GF: a correct
Diffie-Hellman key exchange over the multiplicative group GF(2^n)*.
HKEX-GF protocol:
- Pre-agreed: generator g=3 (polynomial x+1), irreducible poly p(x)
- Alice: private scalar a -> public C = g^a in GF(2^n)*
- Bob: private scalar b -> public C2 = g^b
- Shared key: sk = C2^a = C^b = g^{ab} (DH commutativity in GF(2^n)*)
Security rests on the hardness of DLP in GF(2^n)*, not on orbit structure.
NOTE: DLP in GF(2^n)* is vulnerable to Shor's algorithm on quantum computers.
--- v1.3.2: performance and readability ---
--- v1.3: BitArray (multi-byte parameter support) ---
Library usage
─────────────
This file is importable as a Python module. Because the filename contains
spaces, use importlib to load it:
import importlib.util, pathlib
_spec = importlib.util.spec_from_file_location(
"herradura",
pathlib.Path(__file__).parent / "Herradura cryptographic suite.py")
h = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(h)
Public API (no underscore prefix):
BitArray, fscx, fscx_revolve
gf_mul, gf_pow
nl_fscx_v1, nl_fscx_revolve_v1
nl_fscx_v2, nl_fscx_v2_inv, nl_fscx_revolve_v2, nl_fscx_revolve_v2_inv
hfscx_256
hske_nl_aead_encrypt, hske_nl_aead_decrypt (HSKE-NL-AEAD, TODO #95)
hske_nl_v2_duplex_encrypt, hske_nl_v2_duplex_decrypt (HSKE-NL-V2-Duplex, TODO #95 Option 2)
drbg_seed, drbg_generate, drbg_reseed (HDRBG forward-secure DRBG, TODO #96)
stern_f_keygen, hpks_stern_f_sign, hpks_stern_f_verify
hpke_stern_f_encap, hpke_stern_f_decap
hkex_rnl_keygen, hkex_rnl_agree (public aliases added in v1.7.4)
rnl_sigma_sign, rnl_sigma_verify (ZKP-RNL: Ring-LWR Σ-protocol)
zkp_nl_keygen, zkp_nl_prove, zkp_nl_verify (ZKP-NL: NL-FSCX ZKBoo)
hcred_phi, hcred_user_keygen, hcred_syndrome, hcred_issue,
hcred_cred_verify, hcred_prove, hcred_verify (HCRED hybrid credential, TODO #128)
oprf_keygen, oprf_blind, oprf_eval, oprf_unblind, oprf_direct (OPRF: 2HashDH over GF(2^n)*)
hpake_register, hpake_login_demo (aPAKE: HKEX-RNL + ZKBoo + OPRF augmented PAKE)
Key module constants: KEYBITS, I_VALUE, R_VALUE, GF_POLY, GF_GEN, ORD,
RNLQ, RNLP, RNLPP, RNLB, SDFNR, SDFT, SDFR.
See docs/TUTORIAL.md for complete per-protocol code examples.
'''
import hmac
import itertools
import math
import os
import random
import secrets
import warnings
try:
import numpy as _np
_NUMPY = True
_NTT_CACHE = {} # (q, n) -> (rev, fwd_tw, inv_tw, inv_n, psi_pows, psi_inv_pows)
def _ntt_tables(q, n):
key = (q, n)
if key in _NTT_CACHE:
return _NTT_CACHE[key]
bits = n.bit_length() - 1
tmp = _np.arange(n, dtype=_np.int32)
rev = _np.zeros(n, dtype=_np.int32)
for _ in range(bits):
rev = (rev << 1) | (tmp & 1)
tmp >>= 1
def _twiddles(invert):
tables, length = [], 2
while length <= n:
w = pow(3, (q - 1) // length, q)
if invert:
w = pow(w, q - 2, q)
half = length >> 1
tw = _np.empty(half, dtype=_np.int64)
wn = 1
for k in range(half):
tw[k] = wn
wn = wn * w % q
tables.append(tw)
length <<= 1
return tables
fwd_tw = _twiddles(False)
inv_tw = _twiddles(True)
inv_n = pow(n, q - 2, q)
psi = pow(3, (q - 1) // (2 * n), q)
psi_inv = pow(psi, q - 2, q)
pw, pw_inv = 1, 1
psi_pows = _np.empty(n, dtype=_np.int64)
psi_inv_pows = _np.empty(n, dtype=_np.int64)
for i in range(n):
psi_pows[i] = pw
psi_inv_pows[i] = pw_inv
pw = pw * psi % q
pw_inv = pw_inv * psi_inv % q
_NTT_CACHE[key] = (rev, fwd_tw, inv_tw, inv_n, psi_pows, psi_inv_pows)
return _NTT_CACHE[key]
def _ntt_np(arr, q, invert):
n = len(arr)
rev, fwd_tw, inv_tw, inv_n, _, _ = _ntt_tables(q, n)
tables = inv_tw if invert else fwd_tw
arr[:] = arr[rev]
stage, length = 0, 2
while length <= n:
half = length >> 1
A = arr.reshape(n // length, length)
U = A[:, :half].copy()
V = A[:, half:] * tables[stage] % q
A[:, :half] = (U + V) % q
A[:, half:] = (U - V + q) % q
length <<= 1
stage += 1
if invert:
arr *= inv_n
arr %= q
except ImportError:
_NUMPY = False
# ---------------------------------------------------------------------------
# Global parameters
# ---------------------------------------------------------------------------
# Key size in bits — must be a positive multiple of 8.
# Change to use a different parameter width; I_VALUE and R_VALUE scale automatically.
KEYBITS = 256
I_VALUE = KEYBITS // 4 # 64 for 256-bit
R_VALUE = 3 * KEYBITS // 4 # 192 for 256-bit
ORD = (1 << KEYBITS) - 1 # order of GF(2^n)* (for Schnorr integer arithmetic)
# HKEX-RNL Ring-LWR parameters (see SecurityProofs-3.md §11.4)
# q=65537 (Fermat prime, fast arithmetic) gives lower noise-to-margin ratio than
# q=3329 (Kyber), ensuring reliable single-block agreement at the cost of larger
# keys. 2-bit Peikert reconciliation doubles extracted bits per coefficient.
RNLQ = 65537 # prime modulus (2^16 + 1)
RNLP = 4096 # public-key rounding modulus
RNLPP = 4 # reconciliation modulus (2 bits extracted per ring coefficient)
RNLB = 1 # centered-binomial eta=1: secret coefficients drawn from CBD(1) in {-1,0,1}
# HPKS-Stern-F / HPKE-Stern-F code-based PQC parameters (SecurityProofs-4.md §11.8.4)
SDFNR = KEYBITS // 2 # parity-check rows (syndrome bits; [N, N/2, t] code, N=KEYBITS)
SDFT = max(2, KEYBITS // 16) # error weight t (= 16 at n=256; ≥ 2 at all widths)
SDFR = 32 # ⚠ DEMO ONLY: Fiat-Shamir rounds (~19-bit soundness).
# Production deployments MUST use rounds ≥ 219 for
# 128-bit soundness (⌈λ / log2(3/2)⌉ at λ=128).
# Signing emits a RuntimeWarning when called below
# the production threshold.
# ZKP-RNL (Ring-LWR Σ-protocol) parameters (SecurityProofs-5.md §11.10.2)
_SIGMA_GAMMA = {32: 4096, 64: 8192, 128: 8192, 256: 8192} # mask bound γ per n
_SIGMA_T = {32: 4, 64: 8, 128: 12, 256: 16} # challenge weight t per n
_SIGMA_MAX_ATTEMPTS = 1000 # rejection-sampling attempts before RuntimeError
# ZKBoo (NL-FSCX MPC-in-the-head) parameters (SecurityProofs-5.md §11.10.3)
_ZKP_NL_DEFAULT_N = 8 # default bit-width for CLI (proof ≈35 KB at R=219)
_ZKP_NL_DEMO_ROUNDS = 4 # illustration only: soundness ≈ (2/3)^4 ≈ 20%
_ZKP_NL_PROD_ROUNDS = 219 # ⌈128 / log₂(3/2)⌉ — required for 128-bit soundness
# ---------------------------------------------------------------------------
# BitArray class
# ---------------------------------------------------------------------------
class BitArray:
"""Fixed-width bit string backed by a Python int.
Supports XOR, rotation, equality, and hex/bytes/uint I/O.
Size must be a positive multiple of 8.
"""
__slots__ = ('_val', '_size', '_mask')
def __init__(self, size: int, value: int = 0):
self._size = size
self._mask = (1 << size) - 1
self._val = int(value) & self._mask
@property
def uint(self) -> int:
return self._val
@uint.setter
def uint(self, value: int):
self._val = int(value) & self._mask
@property
def bytes(self) -> bytes:
return self._val.to_bytes(self._size // 8, 'big')
@bytes.setter
def bytes(self, data: bytes):
self._val = int.from_bytes(data, 'big') & self._mask
@property
def hex(self) -> str:
return f'{self._val:0{self._size // 4}x}'
def copy(self) -> 'BitArray':
return BitArray(self._size, self._val)
def rotated(self, n: int) -> 'BitArray':
"""Return a new BitArray rotated left by n bits (right if n < 0)."""
n %= self._size
if n == 0:
return BitArray(self._size, self._val)
return BitArray(self._size,
((self._val << n) | (self._val >> (self._size - n))) & self._mask)
def rol(self, n: int) -> None:
"""Rotate left in-place by n bits."""
n %= self._size
if n:
self._val = ((self._val << n) | (self._val >> (self._size - n))) & self._mask
def ror(self, n: int) -> None:
"""Rotate right in-place by n bits."""
n %= self._size
if n:
self._val = ((self._val >> n) | (self._val << (self._size - n))) & self._mask
def __xor__(self, other: 'BitArray') -> 'BitArray':
return BitArray(self._size, self._val ^ other._val)
def __ixor__(self, other: 'BitArray') -> 'BitArray':
self._val ^= other._val
return self
def __eq__(self, other: object) -> bool:
if isinstance(other, BitArray):
return self._size == other._size and self._val == other._val
return NotImplemented
def __str__(self) -> str:
return f'0x{self.hex}'
def __repr__(self) -> str:
return f'BitArray({self._size}, 0x{self.hex})'
@classmethod
def random(cls, size: int) -> 'BitArray':
"""Return a random BitArray of *size* bits using os.urandom."""
ba = cls(size)
ba.bytes = os.urandom(size // 8)
return ba
# ---------------------------------------------------------------------------
# FSCX functions (classical — linear map M = I + ROL + ROR over GF(2))
# ---------------------------------------------------------------------------
def fscx(A: BitArray, B: BitArray) -> BitArray:
"""Full Surroundings Cyclic XOR: A ^ B ^ ROL(A) ^ ROL(B) ^ ROR(A) ^ ROR(B).
Uses rotated() — does not mutate its inputs."""
return A ^ B ^ A.rotated(1) ^ B.rotated(1) ^ A.rotated(-1) ^ B.rotated(-1)
def fscx_revolve(A: BitArray, B: BitArray, steps: int, verbose: bool = False) -> BitArray:
result = A.copy()
for step in range(steps):
result = fscx(result, B)
if verbose:
print(f"Step {step + 1}: {result.hex}")
return result
# ---------------------------------------------------------------------------
# GF(2^n) field arithmetic — XOR + left-shift only (classical)
# ---------------------------------------------------------------------------
# Primitive polynomials (lower n bits; the x^n coefficient is implicit).
GF_POLY = {32: 0x00400007, 64: 0x0000001B, 128: 0x00000087, 256: 0x00000425}
GF_GEN = 3 # g = x+1 in GF(2^n)[x]; DH correctness holds for any non-zero g
def gf_mul(a: int, b: int, poly: int, n: int) -> int:
"""Carryless polynomial multiply mod p(x) in GF(2^n). O(n) XOR+shift ops."""
result = 0; mask = (1 << n) - 1; hb = 1 << (n - 1)
for _ in range(n):
if b & 1: result ^= a
carry = bool(a & hb)
a = (a << 1) & mask
if carry: a ^= poly
b >>= 1
return result
def gf_pow(base: int, exp: int, poly: int, n: int) -> int:
"""base^exp in GF(2^n)* via repeated squaring.
SA-02/06: iterates exactly n times — no early exit on leading zero bits of
exp so loop count does not leak exp's bit-length. Residual per-bit branch
is a known Python/arbitrary-precision int limitation."""
result = 1; base &= (1 << n) - 1
for _ in range(n): # fixed n iterations
if exp & 1: result = gf_mul(result, base, poly, n)
base = gf_mul(base, base, poly, n)
exp >>= 1
return result
# ---------------------------------------------------------------------------
# Guarded classical protocol API (TODO #144; mirrors herradura.h's
# gf_pub_is_valid + hkex_gf_agree/hpks_verify/hpke_encrypt/hpke_decrypt,
# TODO #131). Downstream callers should use these instead of the raw
# gf_pow/fscx_revolve math directly, since they reject a degenerate
# GF(2^n)* public element (additive zero or the multiplicative identity
# g^0=1) that would otherwise let an attacker trivially forge Schnorr
# signatures or decrypt/exchange keys.
# ---------------------------------------------------------------------------
def gf_pub_is_valid(pub: int) -> bool:
"""Rejects the additive zero and the multiplicative identity (g^0=1):
a degenerate GF(2^n)* public element that collapses HKEX-GF/HPKS/HPKE
to trivially forgeable/decryptable cases."""
return pub not in (0, 1)
def hkex_gf_agree(my_priv: int, their_pub: int, poly: int, n: int):
"""Computes the HKEX-GF shared secret their_pub^my_priv, rejecting a
degenerate peer public key before agreement. Returns the shared secret
int, or None if their_pub is degenerate."""
if not gf_pub_is_valid(their_pub):
return None
return gf_pow(their_pub, my_priv, poly, n)
def hpks_verify(msg: 'BitArray', pub: int, R: 'BitArray', s: int,
poly: int, n: int) -> bool:
"""Verifies an HPKS Schnorr signature (R, s) on msg under pub, rejecting
a degenerate pub before evaluating the raw Schnorr equation (pub=1
would make pub^e == 1 for any e, letting an attacker-chosen (s,
R=g^s) pair verify trivially against any message)."""
if not gf_pub_is_valid(pub):
return False
e = fscx_revolve(R, msg, n // 4).uint
lhs = gf_mul(gf_pow(GF_GEN, s, poly, n), gf_pow(pub, e, poly, n), poly, n)
return lhs == R.uint
def hpke_encrypt(pt: 'BitArray', pub: int, poly: int, n: int):
"""Performs HPKE (El Gamal + fscx_revolve) encryption of pt under the
recipient's public key pub, rejecting a degenerate pub rather than
silently producing ciphertext whose enc_key = pub^r would be a
constant independent of r. Returns (R, ct) as BitArrays, or None."""
if not gf_pub_is_valid(pub):
return None
r = BitArray.random(n).uint
R = gf_pow(GF_GEN, r, poly, n)
enc_key = gf_pow(pub, r, poly, n)
ct = fscx_revolve(pt, BitArray(n, enc_key), n // 4)
return BitArray(n, R), ct
def hpke_decrypt(ct: 'BitArray', R: 'BitArray', priv: int, poly: int, n: int):
"""Performs HPKE decryption of ct using the ephemeral R and the
recipient's private key priv, rejecting a degenerate R rather than
deriving a dec_key = R^priv that is a constant independent of priv.
Returns the plaintext BitArray, or None."""
if not gf_pub_is_valid(R.uint):
return None
dec_key = gf_pow(R.uint, priv, poly, n)
return fscx_revolve(ct, BitArray(n, dec_key), 3 * n // 4)
# ---------------------------------------------------------------------------
# NL-FSCX primitives (v1.5.0 — non-linear; for PQC-hardened protocols)
# ---------------------------------------------------------------------------
# Rotation-table cache: maps bit-size n to tuple of rotation offsets k such that
# M^{-1}(X) = XOR of ROL(X, k) for k in the tuple. Populated lazily on first call.
_m_inv_rotations: dict[int, tuple[int, ...]] = {}
def _m_inv(X: BitArray) -> BitArray:
"""M^{-1}(X): apply precomputed rotation table for M^{n/2-1}.
Table is bootstrapped once from fscx_revolve(1, 0, n/2-1) and cached per bit-size."""
n = X._size
if n not in _m_inv_rotations:
unit = BitArray(n, 1)
zero = BitArray(n, 0)
v = fscx_revolve(unit, zero, n // 2 - 1)
_m_inv_rotations[n] = tuple(k for k in range(n) if (v.uint >> k) & 1)
result = BitArray(n, 0)
for k in _m_inv_rotations[n]:
result = result ^ X.rotated(k)
return result
def nl_fscx_v1(A: BitArray, B: BitArray) -> BitArray:
"""NL-FSCX v1: injects integer-carry non-linearity from A+B into FSCX.
nl_fscx_v1(A,B) = fscx(A,B) XOR ROL((A+B) mod 2^n, n/4)
Properties: non-linear over GF(2); NOT bijective in A (collisions exist).
Use for: HSKE counter-mode keystream, HKEX-RNL KDF, HPKS-NL challenge hash.
"""
n = A._size
mix = BitArray(n, (A.uint + B.uint) & A._mask)
return fscx(A, B) ^ mix.rotated(n // 4)
def nl_fscx_revolve_v1(A: BitArray, B: BitArray, steps: int) -> BitArray:
"""Iterate nl_fscx_v1 *steps* times (B held constant)."""
result = A.copy()
for _ in range(steps):
result = nl_fscx_v1(result, B)
return result
def nl_fscx_v2(A: BitArray, B: BitArray) -> BitArray:
"""NL-FSCX v2: B-only additive offset; bijective in A with closed-form inverse.
delta(B) = ROL(B * floor((B+1)/2) mod 2^n, n/4)
nl_fscx_v2(A,B) = (fscx(A,B) + delta(B)) mod 2^n
Properties: non-linear over GF(2); bijective in A for all B; exact inverse.
Use for: HSKE revolve-mode encryption/decryption, HPKE-NL encryption.
"""
n = A._size
mask = A._mask
delta = BitArray(n, (B.uint * ((B.uint + 1) >> 1)) & mask).rotated(n // 4)
return BitArray(n, (fscx(A, B).uint + delta.uint) & mask)
def nl_fscx_v2_inv(Y: BitArray, B: BitArray) -> BitArray:
"""Exact inverse of one nl_fscx_v2 step: A = B XOR M^{-1}((Y - delta(B)) mod 2^n).
Derivation: Y = M(A XOR B) + delta(B) => A XOR B = M^{-1}(Y - delta(B))
Applying M^{-1} = M^{n/2-1} recovers A XOR B, then XOR with B gives A.
"""
n = Y._size
mask = Y._mask
delta = BitArray(n, (B.uint * ((B.uint + 1) >> 1)) & mask).rotated(n // 4)
Z = BitArray(n, (Y.uint - delta.uint) & mask)
return B ^ _m_inv(Z)
def nl_fscx_revolve_v2(A: BitArray, B: BitArray, steps: int) -> BitArray:
"""Iterate nl_fscx_v2 *steps* times (B held constant).
delta(B) is precomputed once before the loop (mirrors nl_fscx_revolve_v2_inv);
the inner step body becomes one fscx + one integer add. Saves one bigint
multiply and one rotation per iteration vs. calling nl_fscx_v2 in the loop.
"""
n = A._size
mask = A._mask
delta = BitArray(n, (B.uint * ((B.uint + 1) >> 1)) & mask).rotated(n // 4)
result = A.copy()
for _ in range(steps):
result = BitArray(n, (fscx(result, B).uint + delta.uint) & mask)
return result
def nl_v2_key_is_valid(B: BitArray) -> bool:
"""Rejects NL-FSCX v2 keys for which the permutation degenerates to affine.
delta(B) enters nl_fscx_v2 as an additive *constant*, and addition of a constant
c is GF(2)-affine for every input exactly when c == 0 or c == 2^(n-1) (the top
carry is discarded mod 2^n, making the addition pure XOR). Since M is invertible
at every power-of-two n, this gives the exact characterisation
pi_B(A) = (fscx(A,B) + delta(B)) mod 2^n is GF(2)-affine
<=> delta(B) in {0, 2^(n-1)}
For such a key HSKE-NL-A2 and HPKE-NL collapse to an affine map recoverable in
full from a handful of known plaintexts by linear algebra. At n=256 the class
is every B divisible by 2^129 (delta=0), plus e.g. B=2^96 (delta=2^255) — about
2^-129 of the key space, so a uniformly random key is not at risk, but the check
is cheap and the class is otherwise silently accepted.
See SecurityProofs-5.md §11.19.2 and
SecurityProofsCode/nl_fscx_carry_degeneracy_2026.py (TODO #159, #168).
"""
n = B._size
delta = BitArray(n, (B.uint * ((B.uint + 1) >> 1)) & B._mask).rotated(n // 4).uint
return delta not in (0, 1 << (n - 1))
def nl_fscx_revolve_v2_inv(Y: BitArray, B: BitArray, steps: int) -> BitArray:
"""Invert nl_fscx_revolve_v2: apply nl_fscx_v2_inv *steps* times.
delta(B) is precomputed once — B is constant throughout the revolve."""
n = Y._size
mask = Y._mask
delta = BitArray(n, (B.uint * ((B.uint + 1) >> 1)) & mask).rotated(n // 4)
result = Y.copy()
for _ in range(steps):
z = BitArray(n, (result.uint - delta.uint) & mask)
result = B ^ _m_inv(z)
return result
# ---------------------------------------------------------------------------
# HFSCX-256-DM: Merkle-Damgård hash over NL-FSCX v1, Davies-Meyer compression (v1.9.0)
# ---------------------------------------------------------------------------
# 32-byte ASCII domain constant for the default IV.
_HFSCX256_IV_BYTES = b'HFSCX-256/HERRADURA-SUITE\x00\x00\x00\x00\x00\x00\x00'
# NUMS constant for KDF domain separation (SHA-256 initial hash values H0..H7
# concatenated as big-endian 32-bit words). For n<256 use top n bits.
# Prevents KDF degeneracy when K is rotation-periodic (TODO #38, v1.8.0).
_RNL_KDF_DC_256 = 0x6A09E667BB67AE853C6EF372A54FF53A510E527F9B05688C1F83D9AB5BE0CD19
def hfscx_256(data: bytes, *, iv: BitArray | None = None) -> bytes:
"""HFSCX-256-DM: 256-bit Merkle-Damgård hash built on NL-FSCX v1, Davies-Meyer compression.
Compression function (Davies-Meyer):
state_{i+1} = nl_fscx_revolve_v1(state_i, block_i, 64) ⊕ state_i
Padding (ISO 7816-4 + Merkle-Damgård strengthening):
1. Append 0x80 to the message.
2. Zero-fill until total length is a multiple of 32 bytes.
3. Append a final 32-byte block: (bit_length_64bit XOR init_state)
where init_state is the initial chaining value (IV or key^IV).
XORing the initial state into the length block binds the key into
the last block's content, preventing fixed-point collapse when the
message compresses all initial chaining states to a single value
(which occurs for empty input with B=0 in the length block).
Bare hash: iv=None — initial state is the domain IV constant.
Keyed MAC: pass iv = BitArray(256, key.uint ^
int.from_bytes(_HFSCX256_IV_BYTES,'big'))
The key is incorporated into both the initial chaining state
and the final length block, so different keys always produce
different outputs even for empty input.
Returns 32 bytes (256-bit digest).
"""
n = 256
blen = 32 # bytes per block
iv_int = int.from_bytes(_HFSCX256_IV_BYTES, 'big')
init_int = iv_int if iv is None else iv.uint
state = BitArray(n, init_int)
# Padding: 0x80, then zeros to reach a multiple of 32 bytes
padded = bytearray(data) + b'\x80'
rem = len(padded) % blen
if rem:
padded += b'\x00' * (blen - rem)
# MD-strengthening: length block XOR'd with the initial state to bind the
# key into the final block and prevent fixed-point collapse on short inputs.
len_raw = int.from_bytes(b'\x00' * (blen - 8) + (len(data) * 8).to_bytes(8, 'big'), 'big')
padded += (len_raw ^ init_int).to_bytes(blen, 'big')
# Chain blocks: C_DM(s, m) = F_1^{64}(s, m) ⊕ s (Davies-Meyer feed-forward)
steps = n // 4 # 64
for off in range(0, len(padded), blen):
prev = state
block = BitArray(n, int.from_bytes(padded[off:off + blen], 'big'))
state = nl_fscx_revolve_v1(state, block, steps)
state = BitArray(n, state.uint ^ prev.uint)
return state.uint.to_bytes(blen, 'big')
def hfscx_256_ds(ds: int, data: bytes, *, iv: 'BitArray | None' = None) -> bytes:
"""HFSCX-256-DS: domain-separated variant — prepends a 1-byte tag before hashing.
ds=0x01 for generic digest, 0x02 for sign pre-hash, 0x03 for AEAD-MAC.
Wire-format option (§11.9.7 future hardening, TODO #93).
"""
return hfscx_256(bytes([ds & 0xFF]) + data, iv=iv)
def hmac_hfscx_256(key: bytes, data: bytes) -> bytes:
"""HMAC-HFSCX-256-DM: HMAC construction over HFSCX-256-DM (§11.9.6).
Recommended for cross-protocol key reuse.
HMAC(K, D) = HFSCX-256((K^opad) || HFSCX-256((K^ipad) || D))
ipad = 0x36 * 32, opad = 0x5C * 32. Key must be exactly 32 bytes.
"""
if len(key) != 32:
raise ValueError("hmac_hfscx_256: key must be 32 bytes")
ipad = bytes(b ^ 0x36 for b in key)
opad = bytes(b ^ 0x5C for b in key)
inner = hfscx_256(ipad + data)
return hfscx_256(opad + inner)
# ---------------------------------------------------------------------------
# HKEX-RNL ring-arithmetic helpers (negacyclic Z_q[x]/(x^n+1))
# ---------------------------------------------------------------------------
def _ntt_inplace(a, q, invert):
"""Cooley-Tukey iterative NTT over Z_q (in-place). len(a) must be a power of 2.
Uses primitive root 3; works for q=65537 (Fermat prime, ord(3)=2^16=q-1)."""
n = len(a)
j = 0
for i in range(1, n):
bit = n >> 1
while j & bit:
j ^= bit
bit >>= 1
j ^= bit
if i < j:
a[i], a[j] = a[j], a[i]
length = 2
while length <= n:
w = pow(3, (q - 1) // length, q)
if invert:
w = pow(w, q - 2, q)
for i in range(0, n, length):
wn = 1
for k in range(length >> 1):
u = a[i + k]
v = a[i + k + (length >> 1)] * wn % q
a[i + k] = (u + v) % q
a[i + k + (length >> 1)] = (u - v) % q
wn = wn * w % q
length <<= 1
if invert:
inv_n = pow(n, q - 2, q)
for i in range(n):
a[i] = a[i] * inv_n % q
def _rnl_poly_mul(f, g, q, n):
"""Multiply f*g in Z_q[x]/(x^n+1) via negacyclic NTT. O(n log n).
ψ = 3^((q-1)/(2n)) is a primitive 2n-th root of unity; ψ^n ≡ -1 (mod q)
encodes the negacyclic wrap without explicit branch logic."""
if _NUMPY:
_, _, _, _, psi_pows, psi_inv_pows = _ntt_tables(q, n)
fa = _np.array(f, dtype=_np.int64) * psi_pows % q
ga = _np.array(g, dtype=_np.int64) * psi_pows % q
_ntt_np(fa, q, False)
_ntt_np(ga, q, False)
ha = fa * ga % q
_ntt_np(ha, q, True)
return (ha * psi_inv_pows % q).tolist()
psi = pow(3, (q - 1) // (2 * n), q)
psi_inv = pow(psi, q - 2, q)
fa, ga = list(f), list(g)
pw = 1
for i in range(n):
fa[i] = fa[i] * pw % q
ga[i] = ga[i] * pw % q
pw = pw * psi % q
_ntt_inplace(fa, q, False)
_ntt_inplace(ga, q, False)
ha = [fa[i] * ga[i] % q for i in range(n)]
_ntt_inplace(ha, q, True)
pw_inv = 1
for i in range(n):
ha[i] = ha[i] * pw_inv % q
pw_inv = pw_inv * psi_inv % q
return ha
def _rnl_poly_add(f, g, q):
return [(a + b) % q for a, b in zip(f, g)]
def _rnl_round(poly, from_q, to_p):
"""Round each coefficient from Z_{from_q} to Z_{to_p} (nearest integer)."""
return [(c * to_p + from_q // 2) // from_q % to_p for c in poly]
def _rnl_lift(poly, from_p, to_q):
"""Lift from Z_{from_p} to Z_{to_q} with centered rounding (c -> (c*to_q + from_p//2) // from_p)."""
return [(c * to_q + from_p // 2) // from_p % to_q for c in poly]
def _rnl_m_poly(n):
"""FSCX polynomial m(x) = 1 + x + x^{n-1} as a coefficient list in Z_q."""
p = [0] * n
p[0] = p[1] = p[n - 1] = 1
return p
def _rnl_rand_poly(n, q):
"""Uniform random polynomial in Z_q^n (bias-free: 3-byte rejection sampling)."""
threshold = (1 << 24) - (1 << 24) % q
out = []
while len(out) < n:
v = int.from_bytes(os.urandom(3), 'big')
if v < threshold:
out.append(v % q)
return out
def _rnl_cbd_poly(n, eta, q):
"""Centered binomial distribution CBD(eta): each coefficient = a - b (mod q).
For eta=1: 4 coefficients per byte, bit-pairs (0-1),(2-3),(4-5),(6-7).
For eta>1: general path — popcount of eta bits each side."""
if eta == 1:
raw = os.urandom((n + 3) // 4)
out = []
for i in range(n):
shift = (i & 3) * 2
a = (raw[i >> 2] >> shift) & 1
b = (raw[i >> 2] >> (shift + 1)) & 1
out.append((a - b) % q)
return out
mask = (1 << eta) - 1
byte_count = (2 * eta + 7) // 8
out = []
for _ in range(n):
raw = int.from_bytes(os.urandom(byte_count), 'big')
a = bin(raw & mask).count('1')
b = bin((raw >> eta) & mask).count('1')
out.append((a - b) % q)
return out
def _rnl_bits_to_bitarray(poly, pp, size):
"""Extract 1 bit per coefficient (coeff >= pp//2 → bit=1) and pack into BitArray."""
val = 0
threshold = pp // 2
for i, c in enumerate(poly[:size]):
if c >= threshold:
val |= (1 << i)
return BitArray(size, val)
def _rnl_hint(K_poly, q):
"""2-bit Peikert cross-rounding hint per coefficient.
h[i] = floor((8*c + q/4) / q) % 4 (eighth-bucket index with 1/8-cycle bias)"""
return [((8 * c + q // 4) // q) % 4 for c in K_poly]
def _rnl_reconcile_bits(K_poly, hint, q, pp, key_bits):
"""Extract key_bits key bits: 2 bits per coefficient from key_bits//2 coefficients.
Both parties call with the same hint and their own K_poly to guarantee agreement."""
val = 0
qq = q // 4
for i, (c, h) in enumerate(zip(K_poly[:key_bits // 2], hint[:key_bits // 2])):
b = ((4 * c + (2 * h + 1) * qq) // q) % pp # pp=4 → b ∈ {0,1,2,3}
val |= (b << (2 * i))
return val
def _rnl_keygen(m_blind, n, q, p, b):
"""Generate one party's (s, C) key pair for HKEX-RNL.
s: private CBD(b) polynomial; C: public rounded polynomial."""
s = _rnl_cbd_poly(n, b, q)
ms = _rnl_poly_mul(m_blind, s, q, n)
C = _rnl_round(ms, q, p)
return s, C
def _rnl_agree(s, C_other, q, p, pp, n, key_bits, hint=None):
"""Compute raw key bits with Peikert cross-rounding reconciliation.
Reconciler path (hint=None): generate hint, return (K_raw, hint).
Receiver path (hint provided): use hint, return K_raw.
SECURITY: the hint vector is transmitted unauthenticated. An active
adversary who tampers with the hint can steer the reconciled key.
HKEX-RNL provides key agreement only; the caller must authenticate the
transcript (e.g. via HPKS-NL or a MAC over b_pub||hint) before use."""
C_lifted = _rnl_lift(C_other, p, q)
K_poly = _rnl_poly_mul(s, C_lifted, q, n)
if hint is None:
hint = _rnl_hint(K_poly, q)
return BitArray(key_bits, _rnl_reconcile_bits(K_poly, hint, q, pp, key_bits)), hint
return BitArray(key_bits, _rnl_reconcile_bits(K_poly, hint, q, pp, key_bits))
# ---------------------------------------------------------------------------
# HPKS-Stern-F / HPKE-Stern-F — Code-Based PQC (Syndrome Decoding + NL-FSCX PRF)
# Security reduces to SD(N,t) [NP-complete] + NL-FSCX v1 PRF. See §11.8.4.
#
# TIMING NOTE — this Python implementation is a REFERENCE ONLY and is NOT
# constant-time. _stern_apply_perm and _stern_syndrome_H branch on secret
# bit values, leaking Hamming-weight via timing. Production deployments must
# use the C or assembly targets, which use branchless bit-mask operations.
# ---------------------------------------------------------------------------
# Stern-F soundness threshold: ⌈λ / log2(3/2)⌉ for λ=128-bit security.
_STERN_F_PRODUCTION_ROUNDS = 219
def _csprng_weight_t(n: int, t: int) -> int:
"""Sample a uniform weight-t bit vector on n positions using os.urandom.
Replaces random.sample() (Mersenne Twister — predictable from observed
outputs, unsuitable for sampling secret error vectors). Used for HPKS-Stern-F
private keys, per-round Fiat-Shamir blinding, and HPKE-Stern-F encapsulated
errors. 4-byte rejection sampling eliminates modular bias for any n ≤ 2^32.
"""
chosen = set()
threshold = (1 << 32) - (1 << 32) % n
while len(chosen) < t:
v = int.from_bytes(os.urandom(4), 'big')
if v < threshold:
chosen.add(v % n)
return sum(1 << p for p in chosen)
def _stern_hash(n: int, *items: 'BitArray', ds: int = 0) -> 'BitArray':
"""Chain-hash items to n bits via NL-FSCX v1, finalized with HFSCX-256 (v1.6.0).
ds: domain-separation tag initialising the chain state (0=challenge/default,
1=c0, 2=c1, 3=c2, 4=KEM-key). Prevents cross-slot collisions (TODO #36)."""
mask = (1 << n) - 1
h = BitArray(n, ds & mask)
for item in items:
v = item if isinstance(item, BitArray) else BitArray(n, int(item) & mask)
h = nl_fscx_revolve_v1(h ^ v, v.rotated(n // 8), n // 4)
digest = hfscx_256(h.bytes)
return BitArray(n, int.from_bytes(digest, 'big') >> (256 - n))
def _stern_matrix_row(seed_int: int, row: int, n: int) -> 'BitArray':
"""Row *row* of public parity-check matrix H: F_seed(row) via NL-FSCX v1 PRF,
finalized with HFSCX-256 to remove range compression (TODO #88, v1.9.35)."""
seed = BitArray(n, seed_int)
A0 = BitArray(n, seed_int ^ row).rotated(n // 8)
raw = nl_fscx_revolve_v1(A0, seed, n // 4)
digest = hfscx_256(raw.bytes)
return BitArray(n, int.from_bytes(digest, 'big') >> (256 - n))
def _stern_build_H(seed_int: int, n: int, n_rows: int) -> list:
"""Build all n_rows of the public parity matrix once, returned as int row words.
Hot paths (sign/verify/keygen/encap) call _stern_syndrome many times against
the same seed; building H once and reusing it eliminates the rounds × n_rows
per-call PRF evaluations the original implementation incurred.
"""
return [_stern_matrix_row(seed_int, i, n).uint for i in range(n_rows)]
def _stern_syndrome_H(H_rows: list, e_int: int) -> int:
"""Compute syndrome H·e^T mod 2 from a precomputed matrix (list of row ints).
NOT constant-time: bin().count() is variable-time over int size and the
bit-test inside the loop branches on e_int bits. Reference only.
"""
s = 0
for i, row in enumerate(H_rows):
s |= (bin(row & e_int).count('1') & 1) << i
return s
def _stern_syndrome(seed_int: int, e_int: int, n: int, n_rows: int) -> int:
"""Compute n_rows-bit syndrome s = H·e^T mod 2.
Convenience wrapper that builds H on each call. Hot paths should instead call
_stern_build_H once and reuse the result via _stern_syndrome_H.
"""
return _stern_syndrome_H(_stern_build_H(seed_int, n, n_rows), e_int)
def _stern_gen_perm(pi_seed: 'BitArray', N: int) -> list:
"""Fisher-Yates shuffle of [0..N-1] driven by NL-FSCX v1 PRNG.
Counter-mode extraction: all n/8 bytes of each state block are consumed as
sequential 32-bit draws before advancing the state (no entropy wasted).
CT-01 (TODO #129 Batch 3): draws exactly one 32-bit word per swap and maps
it to [0, range) via Lemire's multiply-shift (k = (v * range) >> 32)
instead of rejection sampling, so the loop/state-advance count no longer
depends on pi_seed -- closes the timing leak dudect measured in the C
implementation's prior rejection-sampling version (SecurityProofs-5.md
S11.11). Relative modulo bias is < range/2^32, negligible at range <=
KEYBITS. Must stay bit-identical with the C and Go implementations.
"""
n = pi_seed._size
nb = n // 8
key = pi_seed.rotated(n // 8)
perm = list(range(N))
st = pi_seed.copy()
buf = b'\x00' * nb
cursor = nb # force state advance on first draw
for i in range(N - 1, 0, -1):
range_ = i + 1
if cursor + 4 > nb:
st = nl_fscx_v1(st, key)
buf = st.bytes
cursor = 0
v = int.from_bytes(buf[cursor:cursor + 4], 'big')
cursor += 4
k = (v * range_) >> 32
perm[i], perm[k] = perm[k], perm[i]
return perm
def _stern_apply_perm(perm: list, v_int: int, N: int) -> int:
"""Apply permutation perm to N-bit integer v: result[perm[i]] = v[i].
NOT constant-time: the inner `if` branches on each secret bit of v,
leaking its Hamming weight via timing. Reference only; C/asm targets
use a branchless mask: result |= (-(bit) & (1 << perm[i])).
"""
result = 0
for i in range(N):
if (v_int >> i) & 1: