├── .gitignore ├── KLPT.py ├── LICENCE ├── README.md ├── SQISign.py ├── benchmarks ├── benchmark_BiDLP.py ├── benchmark_cornacchia.py ├── benchmark_deuring.py ├── benchmark_kernelfromisogeny.py ├── benchmark_keygen.py ├── benchmark_response.py ├── benchmark_sqisign.py └── benchmark_torsionbasis.py ├── compression.py ├── deuring.py ├── example_SQISign.sage ├── example_signing.sage ├── ideals.py ├── isogenies.py ├── lattices.py ├── mitm.py ├── parameters.py ├── pari_interface.py ├── setup.py ├── test_SQISign.sage ├── tests ├── test_KLPT.sage ├── test_cvp.sage ├── test_ideals.sage ├── test_isogenies.sage ├── test_isogenies_and_ideals.sage └── test_mitm.sage └── utilities.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.DS_Store 2 | *.sage.py 3 | *.cProfile 4 | *.json 5 | 6 | __pycache__/ 7 | 8 | -------------------------------------------------------------------------------- /KLPT.py: -------------------------------------------------------------------------------- 1 | """ 2 | A KLPT implementation specialised for use for SQISign. 3 | 4 | Many algorithms are taken from the from the SQISign paper: 5 | 6 | SQISign: compact post-quantum signatures from quaternions and isogenies 7 | Luca De Feo, David Kohel, Antonin Leroux, Christophe Petit, and Benjamin Wesolowski, 8 | https://ia.cr/2020/1240 9 | 10 | With additional understanding and sub-algorithms from the original KLPT 11 | paper: 12 | 13 | On the quaternion l-isogeny path problem 14 | David Kohel, Kristin Lauter, Christophe Petit, Jean-Pierre Tignol 15 | https://ia.cr/2014/505 16 | 17 | Generally, we expect only to import EquivalentSmoothIdealHeuristic 18 | and SigningKLPT which are different forms of the KLPT algorithm used 19 | throughout SQISign. 20 | 21 | As an implementation note, we try and append "Heuristic" to function 22 | names when we only know solutions can be derived heuristically. If no 23 | solution is found, `None` is returned. 24 | """ 25 | 26 | # Python imports 27 | from random import choice 28 | 29 | # Sage imports 30 | from sage.all import ( 31 | gcd, 32 | ZZ, 33 | Zmod, 34 | log, 35 | ceil, 36 | floor, 37 | sqrt, 38 | flatten, 39 | factor, 40 | Matrix, 41 | vector, 42 | randint, 43 | inverse_mod, 44 | prod, 45 | choice, 46 | CRT, 47 | ) 48 | 49 | # Local imports 50 | from ideals import ( 51 | chi, 52 | is_integral, 53 | is_cyclic, 54 | reduced_basis, 55 | pullback_ideal, 56 | pushforward_ideal, 57 | equivalent_left_ideals, 58 | quaternion_basis_gcd, 59 | eichler_order_from_ideal, 60 | ideal_generator, 61 | make_cyclic, 62 | small_equivalent_ideal, 63 | quadratic_norm, 64 | ) 65 | from utilities import Cornacchia, generate_bounded_divisor, is_inert 66 | from lattices import generate_close_vectors, generate_small_norm_quat 67 | from setup import ( 68 | O0, 69 | p, 70 | q, 71 | j, 72 | ω, 73 | l, 74 | e, 75 | logp, 76 | loglogp, 77 | prime_norm_heuristic, 78 | represent_heuristic, 79 | ) 80 | 81 | # ========================================== # 82 | # Functions for solving finding equivalent # 83 | # prime norm ideals # 84 | # ========================================== # 85 | 86 | 87 | def generate_small_norm_quat_random(Ibasis, coeff_bound, search_bound): 88 | """ 89 | Pick a random linear combination from Ibasis to compute an element 90 | α ∈ B_{0, ∞} 91 | """ 92 | for _ in range(search_bound): 93 | xs = [randint(-coeff_bound, coeff_bound) for _ in range(len(Ibasis))] 94 | if gcd(xs) != 1: 95 | continue 96 | α = sum(αi * x for αi, x in zip(Ibasis, xs)) 97 | 98 | yield α 99 | 100 | 101 | def prime_norm_algebra_element( 102 | nI, 103 | Ibasis, 104 | coeff_bound, 105 | search_bound, 106 | previous=set(), 107 | allowed_factors=None, 108 | random_elements=False, 109 | ): 110 | """ 111 | Find an element α ∈ B_{0, ∞} with small, 112 | prime scaled norm. 113 | 114 | Optional: `allowed_factors` allows the norm to 115 | be composite, where it is expected that 116 | the result is a large prime multiplied by 117 | small factors dividing allowed_factors. 118 | """ 119 | 120 | if random_elements: 121 | small_elements = generate_small_norm_quat_random( 122 | Ibasis, coeff_bound, search_bound 123 | ) 124 | else: 125 | max_norm_bound = prime_norm_heuristic 126 | small_elements = generate_small_norm_quat( 127 | Ibasis, max_norm_bound, count=search_bound 128 | ) 129 | 130 | for α in small_elements: 131 | α_norm = ZZ(α.reduced_norm()) // nI 132 | 133 | # Even norms can be rejected early 134 | # as allowed_factors is either odd 135 | # or None. 136 | if α_norm % 2 == 0: 137 | continue 138 | 139 | # We can allow α to have composite norm 140 | # if small factors are within the KLPT 141 | # target norm T. 142 | α_norm_reduced = α_norm 143 | if allowed_factors: 144 | g = gcd(α_norm, allowed_factors) 145 | if g != 1: 146 | α_norm_reduced //= g 147 | 148 | # If we've failed with this norm before 149 | # continue 150 | if α_norm_reduced in previous: 151 | continue 152 | 153 | # Check if the element has prime norm 154 | if α_norm_reduced.is_pseudoprime(): 155 | # Check if the prime small enough 156 | if α_norm_reduced < prime_norm_heuristic: 157 | return α, α_norm 158 | return None, None 159 | 160 | 161 | def EquivalentPrimeIdealHeuristic( 162 | I, previous=set(), allowed_factors=None, random_elements=False 163 | ): 164 | """ 165 | Given an ideal I with norm nI, attempts 166 | to find an equivalent ideal J with prime norm. 167 | 168 | If unsuccessful, returns None 169 | """ 170 | # TODO: what's a good initial small search? 171 | coeff_bound = max((floor(logp / 10)), 7) 172 | 173 | # TODO: what's a good search bound? 174 | search_bound = max(coeff_bound**4, 4096) 175 | 176 | # Norm of Ideal 177 | nI = ZZ(I.norm()) 178 | 179 | # Compute the Minkowski reduced basis 180 | Ibasis = reduced_basis(I) 181 | 182 | # Find an element with small prime norm 183 | α, N = prime_norm_algebra_element( 184 | nI, 185 | Ibasis, 186 | coeff_bound, 187 | search_bound, 188 | previous=previous, 189 | allowed_factors=allowed_factors, 190 | random_elements=random_elements, 191 | ) 192 | 193 | if α is None: 194 | print(f"DEBUG [EquivalentPrimeIdealHeuristic] No equivalent prime found") 195 | return None, None, None 196 | 197 | assert ZZ(α.reduced_norm()) // nI == N 198 | assert α in I 199 | 200 | # Compute the ideal given α 201 | J = chi(α, I) 202 | 203 | return J, N, α 204 | 205 | 206 | def RepresentIntegerHeuristic(M, parity=False): 207 | """ 208 | Algorithm 1 (Page 8) 209 | 210 | Given an integer M, with M > p, attempts to 211 | find a random element γ with norm M. 212 | 213 | If no element is found after `bound` tries, 214 | returns none 215 | """ 216 | 217 | def RepresentInteger(M, z, t, parity=False): 218 | M_prime = M - p * quadratic_norm(z, t) 219 | two_squares = Cornacchia(M_prime, -ZZ(ω**2)) 220 | if two_squares: 221 | x, y = two_squares 222 | if parity and (x + t) % 2 == 0 and (y + z) % 2 == 0: 223 | return None 224 | return x + ω * y + j * (z + ω * t) 225 | # No solution for the given M 226 | return None 227 | 228 | if M <= p: 229 | raise ValueError(f"Can only represent integers M > p.") 230 | m = max(floor(sqrt(M / (p * (1 + q)))), 5) 231 | 232 | # TODO: how many times should we try? 233 | for _ in range(m**2): 234 | z = randint(-m, m) 235 | t = randint(-m, m) 236 | γ = RepresentInteger(M, z, t, parity=parity) 237 | 238 | if γ is not None: 239 | # Found a valid solution, return 240 | assert γ.reduced_norm() == M, "The norm is incorrect" 241 | assert γ in O0, "The element is not contained in O0" 242 | return γ 243 | 244 | # No solution found, return None 245 | print(f"DEBUG [RepresentIntegerHeuristic]: No solution found") 246 | return None 247 | 248 | 249 | def EquivalentRandomEichlerIdeal(I, Nτ): 250 | """ 251 | Algorithm 6 (SQISign paper) 252 | 253 | Input: I a left O-ideal 254 | Output: K ∼ I of norm coprime with Nτ 255 | """ 256 | nI = I.norm() 257 | # TODO: what should the size of `bound` be 258 | bound = 10 259 | 260 | # Step 1: find an element ωS such that Nτ is inert in ℤ[ωS] 261 | O = I.left_order() 262 | while True: 263 | ωS = sum([randint(-bound, bound) * b for b in O.basis()]) 264 | if is_inert(ωS, Nτ): 265 | break 266 | 267 | # Step 2: find a random element γ in I such that n(γ)/n(I) is coprime with Nτ 268 | while True: 269 | γ = sum([randint(-bound, bound) * b for b in I.basis()]) 270 | if gcd(γ.reduced_norm() // nI, Nτ) == 1: 271 | break 272 | 273 | # Step 3: select a random class (C : D) ∈ P1(Z/Nτ Z). 274 | x = randint(0, Nτ) 275 | if x == p: 276 | C, D = 1, 0 277 | else: 278 | C, D = x, 1 279 | 280 | # Step 4: set β = (C + ωSD)γ. 281 | β = (C + ωS * D) * γ 282 | 283 | # Step 5: return K = χI(β) 284 | return chi(β, I) 285 | 286 | 287 | # ========================================== # 288 | # Functions for solving the (Ideal/Eichler) # 289 | # Mod Constraint # 290 | # ========================================== # 291 | 292 | 293 | def solve_mod_constraint_kernel(quat_list): 294 | """ 295 | Helper function which given a list of 8 quaternions 296 | constructs a matrix and computes its kernel. 297 | 298 | Used in `IdealModConstraint` and `EichlerModConstraint` 299 | to compute C, D. 300 | """ 301 | # Split each element of B into its coefficients 302 | # a ∈ B = t + x i + y j + z k: t,x,y,x ∈ Q 303 | matrix_coefficients = flatten([x.coefficient_tuple() for x in quat_list]) 304 | 305 | # We now have 32 elements, four coefficients from each 306 | # element 307 | assert len(matrix_coefficients) == 8 * 4 308 | 309 | # Create an 8x4 matrix over QQ from the coefficients 310 | mat = Matrix(8, 4, matrix_coefficients) 311 | 312 | # Clear the denominator and work over ZZ 313 | mat, _ = mat._clear_denom() 314 | 315 | # Compute its kernel 316 | kernel = mat.kernel() 317 | 318 | # Following the SQISign MAGMA code, we pick 319 | # the third row. 320 | row = kernel.basis()[2] 321 | C, D = row[2:4] # row = A,B,C,D,_,_,_,_ 322 | 323 | return C, D 324 | 325 | 326 | def IdealModConstraint(I, γ): 327 | """ 328 | Section 4.3 329 | 330 | Given an ideal I of norm N and an element 331 | γ of a maximal order O0 of norm NM finds 332 | (C0 : D0) in P^1(Z/NZ) such that 333 | 334 | μ0 = j(C0 + ωD0) 335 | 336 | Then verifies that γ*μ0 in I 337 | """ 338 | 339 | N = I.norm() 340 | # First four elements constructed from γ 341 | matrix_quaternions = [γ * N, γ * N * ω, γ * j, γ * j * ω] 342 | 343 | # Next four elements are the basis of the ideal I 344 | matrix_quaternions += list(I.basis()) 345 | 346 | # We should now have a list of 8 elements of B 347 | assert len(matrix_quaternions) == 8 348 | 349 | # Generate a 8x4 matrix matrix from these quaternions 350 | C0, D0 = solve_mod_constraint_kernel(matrix_quaternions) 351 | μ0 = j * (C0 + ω * D0) 352 | 353 | # Check we made it correctly 354 | assert γ * μ0 in I 355 | 356 | if C0 == 0 or D0 == 0: 357 | print(f"DEBUG [IdealModConstraint]: Something is maybe going to break...") 358 | print(f"{C0 = }") 359 | print(f"{D0 = }") 360 | return C0, D0 361 | 362 | 363 | def EichlerModConstraint(L, EichlerL, γ1, γ2): 364 | """ 365 | Input: An ideal L, its corresponding Eichler order 366 | ℤ + L and two elements of a quaternion algebra 367 | γ1, γ2 368 | Output: (C : D) ∈ P1(Z/n(L)Z) such that the element 369 | μ1 = j(C + ωD) 370 | γ1*μ1*γ2 ∈ Z + L 371 | 372 | Taken from Section 6.2 373 | 374 | Given an ideal L of norm N and two elements 375 | γ1, γ2 we find μ1 such that γ1*μ1*γ2 is in 376 | the Eichler Order. 377 | """ 378 | nL = ZZ(L.norm()) 379 | assert gcd(γ1.reduced_norm(), nL) == 1 380 | assert gcd(γ2.reduced_norm(), nL) == 1 381 | 382 | # Construct the matrix, our C,D are extracted from the kernel. 383 | # First four elements constructed from γ 384 | matrix_quaternions = [γ1 * nL, γ1 * nL * ω, γ1 * j, γ1 * j * ω] 385 | 386 | # Next four elements are the basis of the ideal I 387 | matrix_quaternions += [b * γ2.conjugate() for b in EichlerL.basis()] 388 | 389 | # We should now have a list of 8 elements of B 390 | assert len(matrix_quaternions) == 8 391 | 392 | C1, D1 = solve_mod_constraint_kernel(matrix_quaternions) 393 | μ1 = j * (C1 + ω * D1) 394 | 395 | assert γ1 * μ1 * γ2 in EichlerL 396 | return C1, D1 397 | 398 | 399 | def check_ideal_mod_constraint(L, γ, C, D): 400 | """ 401 | For the case when we work with prime 402 | power norm (we currently only support 2^k) 403 | we need to make sure either C or D can be 404 | invertible. 405 | 406 | This function tries to correct for even factors 407 | while maintaining γ*μ0 is in L. 408 | """ 409 | # When both C and D are even we can 410 | # try and scale to make at most one 411 | # odd. 412 | while C % 2 == 0 and D % 2 == 0: 413 | C = C // 2 414 | D = D // 2 415 | μ0 = j * (C + ω * D) 416 | if γ * μ0 not in L: 417 | # Could not halve C, D and 418 | # maintain γ*μ in L 419 | return False, None, None 420 | 421 | # Nrd(μ0) must be coprime with N for 422 | # inversion 423 | μ0 = j * (C + ω * D) 424 | if gcd(μ0.reduced_norm(), L.norm()) != 1: 425 | return False, None, None 426 | 427 | return True, C, D 428 | 429 | 430 | # ============================================== # 431 | # Functions for solving the Strong Approximation # 432 | # ============================================== # 433 | 434 | 435 | def strong_approximation_construct_lattice(N, C, D, λ, L, small_power_of_two=False): 436 | """ 437 | Constructing the lattice basis and target vector following PS18 438 | 439 | When N is a power of two, we have `small_power_of_two` 440 | set to True. When this is the case we double the 441 | modulus and halve coeff_z, coeff_t 442 | """ 443 | 444 | if small_power_of_two: 445 | coeff_z = p * λ * C 446 | coeff_t = p * λ * D 447 | else: 448 | coeff_z = 2 * p * λ * C 449 | coeff_t = 2 * p * λ * D 450 | 451 | cst_term = L - p * λ**2 * quadratic_norm(C, D) 452 | 453 | if small_power_of_two: 454 | cst_term, check = cst_term.quo_rem(2 * N) 455 | else: 456 | cst_term, check = cst_term.quo_rem(N) 457 | assert check == 0 458 | 459 | coeff_t_inv = inverse_mod(coeff_t, N) 460 | 461 | zp0 = 0 462 | tp0 = ZZ(cst_term * coeff_t_inv % N) 463 | 464 | # Construct the lattice basis 465 | lattice_basis = N * Matrix(ZZ, [[1, ZZ((-coeff_z * coeff_t_inv) % N)], [0, N]]) 466 | 467 | # Construct the target vector 468 | target = λ * vector([ZZ(C), ZZ(D)]) + N * vector([zp0, tp0]) 469 | return lattice_basis, target, zp0, tp0 470 | 471 | 472 | def strong_approximation_lattice_heuristic(N, C, D, λ, L, small_power_of_two=False): 473 | """ 474 | Constructs a lattice basis and then looks for 475 | close vectors to the target. 476 | 477 | Allows for optimising output from pN^4 to pN^3, 478 | which helps keep the norm small and hence the 479 | degree of the isogenies small 480 | """ 481 | 482 | # We really only expect this for the case when N = 2^k 483 | swap = False 484 | if D == 0 or gcd(D, N) != 1: 485 | C, D = D, C 486 | swap = True 487 | 488 | # Construct the lattice 489 | lattice_basis, target, zp0, tp0 = strong_approximation_construct_lattice( 490 | N, C, D, λ, L, small_power_of_two=small_power_of_two 491 | ) 492 | 493 | # Generate vectors close to target 494 | close_vectors = generate_close_vectors(lattice_basis, -target, p, L) 495 | 496 | xp, yp = None, None 497 | for close_v in close_vectors: 498 | zp, tp = close_v 499 | assert zp % N == 0, "Can't divide zp by N" 500 | assert tp % N == 0, "Can't divide tp by N" 501 | 502 | zp = ZZ(zp / N) + zp0 503 | tp = ZZ(tp / N) + tp0 504 | M = L - p * quadratic_norm(λ * C + zp * N, λ * D + tp * N) 505 | M, check = M.quo_rem(N**2) 506 | assert check == 0, "Cant divide by N^2" 507 | 508 | if M < 0: 509 | continue 510 | 511 | # Try and find a solution to 512 | # M = x^2 + y^2 513 | two_squares = Cornacchia(ZZ(M), -ZZ(ω**2)) 514 | if two_squares: 515 | xp, yp = two_squares 516 | break 517 | 518 | if xp is None: 519 | # Never found vector which had a valid solution 520 | return None 521 | 522 | # Use solution to construct element μ 523 | # μ = λ*j*(C + D*ω) + N*(xp + ω*yp + j*(zp + ω*tp)) 524 | 525 | # If we swapped earlier, swap again! 526 | if swap: 527 | C, D = D, C 528 | tp, zp = zp, tp 529 | 530 | μ = N * xp + N * yp * ω + (λ * C + N * zp) * j + (λ * D + N * tp) * j * ω 531 | 532 | # Check that Nrd(μ) == L 533 | # and that μ is in O0 534 | assert μ.reduced_norm() == L 535 | assert μ in O0 536 | return μ 537 | 538 | 539 | def StrongApproximationHeuristic( 540 | N, C, D, facT, composite_factors=None, small_power_of_two=False 541 | ): 542 | """ 543 | Algorithm 2 (Page 9) 544 | 545 | Given an N such and two integers C, D such that 546 | μ0 = j(C + ωD), we find a 547 | μ = λμ0 + N μ1 548 | such that the norm of μ is a divisor of facT 549 | 550 | This function works when: 551 | - Case 1: When N is prime 552 | - Case 2: When N is a power of two 553 | - Case 3: When N is composite. 554 | 555 | For Case 2, the optional bool small_power_of_two must be True 556 | For Case 3, the factors of N must be included in the 557 | optional argument: composite_factors 558 | """ 559 | 560 | # For Case 2 561 | if small_power_of_two: 562 | # When we work with N = 2^k, we 563 | # work mod 2N and halve the lattice 564 | K = Zmod(2 * N) 565 | 566 | # For Case 1 and Case 3 567 | else: 568 | K = Zmod(N) 569 | 570 | # Check whether T is large enough to find 571 | # solutions 572 | T = prod([l**e for l, e in facT]) 573 | 574 | # Case 3: N = N1*N2 575 | # For the case in SigningKLPT when N is the product of two 576 | # primes, we have a fixed L2. So, we try this and return 577 | # None if it fails 578 | if composite_factors: 579 | NL, Nτ = composite_factors 580 | assert NL * Nτ == N, "Supplied factors are incorrect" 581 | 582 | # Bound is fixed 583 | L2 = T 584 | 585 | # Make sure we can compute λ^2 586 | try: 587 | λλ = K(L2) / K(p * quadratic_norm(C, D)) 588 | except Exception as e: 589 | # p*(C^2 + D^2) is not invertible mod N 590 | print(f"ERROR [StrongApproximationHeuristic]: {e}") 591 | return None 592 | 593 | # Recover the square root with CRT 594 | # We supply C, D such that we can take the sqrt 595 | # no need to check this is possible 596 | λL = ZZ(Zmod(NL)(λλ).sqrt()) 597 | λτ = ZZ(Zmod(Nτ)(λλ).sqrt()) 598 | λ = CRT([λL, λτ], [NL, Nτ]) 599 | 600 | # Not sure why, but this sometimes happens... 601 | if λ == 0: 602 | return None 603 | 604 | # If the lattice computation fails this returns None 605 | return strong_approximation_lattice_heuristic( 606 | N, C, D, λ, L2, small_power_of_two=small_power_of_two 607 | ) 608 | 609 | # Case 1: N is prime 610 | # Case 2: N = l^2 611 | # Otherwise, we can pick a bunch of L2 and check if 612 | # any of them work. 613 | tested = set() 614 | 615 | # Debugging make sure the bound is ok for given T 616 | bound = ceil((p * N**3)) 617 | if bound > T: 618 | print( 619 | f"DEBUG [StrongApproximationHeuristic] The prime norm is too large, " 620 | f"no valid divisors. N ~ 2^{N.nbits()}, T ~ 2^{T.nbits()}" 621 | ) 622 | return None 623 | 624 | # Try and find μ by testing a bunch of different L2 | T 625 | for _ in range(100): 626 | L2 = generate_bounded_divisor(bound, T, facT) 627 | if L2 in tested: 628 | continue 629 | tested.add(L2) 630 | 631 | # Check given L2 whether we can compute λ 632 | try: 633 | λλ = K(L2) / K(p * quadratic_norm(C, D)) 634 | except: 635 | # p*(C^2 + D^2) is not invertible mod N 636 | # Not sure why this is happening... 637 | return None 638 | 639 | # Ensure we can compute λ from λ^2 640 | if not λλ.is_square(): 641 | continue 642 | 643 | λ = ZZ(λλ.sqrt()) 644 | 645 | # Not sure why, but this sometimes happens... 646 | if λ == 0: 647 | continue 648 | 649 | μ = strong_approximation_lattice_heuristic( 650 | N, C, D, λ, L2, small_power_of_two=small_power_of_two 651 | ) 652 | # Sometimes we have no valid solutions from the lattice, 653 | # so we try again with a new L2. 654 | if μ: 655 | return μ 656 | # All attempts for L2 failed. Pick a new N by regenerating γ 657 | return None 658 | 659 | 660 | # ====================================== # 661 | # KLPT algorithm, specialised for SQISign # 662 | # ====================================== # 663 | 664 | # ================ # 665 | # Helper functions # 666 | # ================ # 667 | 668 | 669 | def compute_L1_KLPT(N, facT): 670 | """ 671 | Helper function for KLPT which is 672 | used to find necessary output size of 673 | `RepresentIntegerHeuristic`. 674 | """ 675 | L1 = 1 676 | while N * L1 < represent_heuristic * p: 677 | l, e = choice(facT) 678 | L1 *= l 679 | facT.remove((l, e)) 680 | if e > 1: 681 | facT.append((l, e - 1)) 682 | return L1, facT 683 | 684 | 685 | def equivalent_prime_ideal_wrapper_KLPT( 686 | I, 687 | T, 688 | previously_seen, 689 | small_power_of_two=False, 690 | equivalent_prime_ideal=None, 691 | allowed_factors=None, 692 | ): 693 | """ 694 | Handles various cases for KLPT algorithm. 695 | 696 | Case 1: when the input has a small power of two norm, 697 | we can skip computing a prime ideal altogether 698 | 699 | Case 2: we already know a prime norm ideal, so we 700 | just return this 701 | 702 | Case 3: we need to compute a prime norm ideal, 703 | run EquivalentPrimeIdealHeuristic() 704 | 705 | Optional: when we supply allowed_factors as input, 706 | we allow the output to be a composite. We expect 707 | this to be some large prime with some other smaller 708 | factors which must divide allowed_factors. 709 | This makes it easier to find L, and we can correct 710 | for these small factors later. 711 | """ 712 | L0 = 1 713 | # When we are working with nI = 2^k, with 2^k << p 714 | # we need to proceed differently. 715 | # Instead of computing an equivalent prime norm ideal L 716 | # we instead work with `I` directly. 717 | if small_power_of_two: 718 | # We make sure the ideal is a fixed point for the 719 | # action of (R/2R)^* 720 | i = I.quaternion_algebra().gens()[0] 721 | if I + O0 * 2 != O0 * (i + 1) + O0 * 2: 722 | I = O0.left_ideal([b * (i + 1) for b in I.basis()]) 723 | 724 | # Use the input I as L 725 | L = I 726 | N = ZZ(L.norm()) 727 | return L, N, L0, previously_seen 728 | 729 | # During keygen, we already know a good small norm ideal 730 | # which we can pass in to skip the below block. 731 | if equivalent_prime_ideal: 732 | L = equivalent_prime_ideal 733 | N = ZZ(L.norm()) 734 | return L, N, L0, previously_seen 735 | 736 | # TODO: how many times should we try? 737 | # Could we just run this once by setting good inner bounds? 738 | for _ in range(10): 739 | L, N, _ = EquivalentPrimeIdealHeuristic( 740 | I, previous=previously_seen, allowed_factors=allowed_factors 741 | ) 742 | 743 | # Found a suitable equivalent ideal 744 | if L is not None: 745 | break 746 | 747 | # If we never find a prime norm ideal, throw an error 748 | if L is None: 749 | raise ValueError( 750 | "Could not find a prime norm ideal, need bigger T or smaller norm output..." 751 | ) 752 | 753 | previously_seen.add(N) 754 | 755 | if allowed_factors: 756 | # Absorb the small primes from D(T^2) into L0 757 | L0 = gcd(N, allowed_factors) 758 | # Restore N to be prime 759 | N //= L0 760 | assert N.is_pseudoprime(), "N is not prime" 761 | 762 | return L, N, L0, previously_seen 763 | 764 | 765 | def strong_approximation_wrapper_KLPT(L, N, L0, facT, small_power_of_two=False): 766 | """ 767 | Helper function: 768 | 769 | Given a prime norm ideal L, tries to solve 770 | the StrongApproximationPrime while obeying 771 | various bounds and edge cases for SQISign 772 | """ 773 | 774 | # Check L, N, L0 all align 775 | assert L.norm() == N * L0 776 | 777 | # Find the prime ideal 778 | if L0 != 1: 779 | O = L.left_order() 780 | α = ideal_generator(L) 781 | L_prime = O * α + O * N 782 | assert L_prime.norm() == N, "Made the ideal in the wrong way..." 783 | else: 784 | L_prime = L 785 | 786 | # For the case of N = l^e, we often need 787 | # to pick many γ. Remember what we've seen 788 | # to avoid repeating bad choices. 789 | seen_γ = set() 790 | 791 | # TODO: how many times to try? 792 | for _ in range(10): 793 | # Find a factor L1 such that N*L1 > p to ensure 794 | # we can compute γ 795 | L1, facTupdated = compute_L1_KLPT(N, facT.copy()) 796 | 797 | # Make sure that L1 isn't too big 798 | L2_max = prod([p**e for p, e in facTupdated]) 799 | if L2_max < p * N**3: 800 | print( 801 | f"DEBUG [strong_approximation_wrapper_KLPT]:" 802 | "L1 is too big, likely no lattice solutions" 803 | ) 804 | 805 | γ = RepresentIntegerHeuristic(N * L1) 806 | if γ is None: 807 | continue 808 | 809 | if γ in seen_γ: 810 | continue 811 | seen_γ.add(γ) 812 | 813 | C0, D0 = IdealModConstraint(L_prime, γ) 814 | 815 | if N % 2 == 0: 816 | # We are going to have to invert the element 817 | # p*(C^2 + D^2) mod N 818 | # As a result, we need to remove factors of 2 819 | # and ultimately reject some valid C0,D0 solutions 820 | check, C0, D0 = check_ideal_mod_constraint(L_prime, γ, C0, D0) 821 | # C0, D0 are bad, pick new γ 822 | if check == False: 823 | continue 824 | 825 | μ0 = j * (C0 + ω * D0) 826 | assert μ0 in O0, "μ0 is not contained within O0" 827 | assert γ * μ0 in L_prime, "The product γ * μ0 is not contained within L" 828 | 829 | ν = StrongApproximationHeuristic( 830 | N, C0, D0, facTupdated, small_power_of_two=small_power_of_two 831 | ) 832 | if ν is not None: 833 | 834 | β = γ * ν 835 | if L0 == 1: 836 | # Dumb checks... 837 | assert L == L_prime 838 | assert β in L, "β is not in prime norm ideal: L" 839 | assert β.reduced_norm() % N == 0 840 | 841 | # Compute the equivalent ideal 842 | return chi(β, L) 843 | 844 | # For the near prime case we need to 845 | # adjust everything so that we have an element 846 | # contained in L, not L_prime 847 | 848 | # Li_product = L0 * L1 * L2 849 | Li_product = L0 * (β.reduced_norm() // N) 850 | δ = ideal_generator(L, coprime_factor=Li_product) 851 | β = δ * β.conjugate() 852 | O = L.left_order() 853 | J = O * β + O * Li_product 854 | 855 | return J 856 | # Never found a solution, give up so we can pick a new N 857 | return None 858 | 859 | 860 | # ==================== # 861 | # End Helper functions # 862 | # ==================== # 863 | 864 | 865 | def EquivalentSmoothIdealHeuristic(I, T, equivalent_prime_ideal=None, near_prime=False): 866 | """ 867 | Algorithm 3 (KLPT Algorithm) 868 | 869 | Given an ideal I with left order O0 and a smooth integer T, 870 | computes an equivalent ideal J with norm dividing T together 871 | with the quaternion algebra element β. 872 | """ 873 | # TODO: we could pass in the factors, rather than factoring? 874 | facT = list(factor(T)) 875 | 876 | # Remember N we have found and skip them 877 | previously_seen = set() 878 | 879 | # Make I as small as possible: 880 | I = small_equivalent_ideal(I) 881 | 882 | # For case distinction for Inorm = 2^* and otherwise 883 | nI = ZZ(I.norm()) 884 | 885 | small_power_of_two = False 886 | if nI % 2 == 0 and len(factor(nI)) == 1 and nI < prime_norm_heuristic: 887 | small_power_of_two = True 888 | 889 | allowed_factors = None 890 | if near_prime: 891 | allowed_factors = T 892 | 893 | # TODO: how many times should we try? 894 | for _ in range(40): 895 | # Find a prime norm, or prime power norm ideal L with norm N 896 | L, N, L0, previously_seen = equivalent_prime_ideal_wrapper_KLPT( 897 | I, 898 | T, 899 | previously_seen, 900 | small_power_of_two=small_power_of_two, 901 | equivalent_prime_ideal=equivalent_prime_ideal, 902 | allowed_factors=allowed_factors, 903 | ) 904 | 905 | if L0 != 1: 906 | print( 907 | f"DEBUG [EquivalentSmoothIdealHeuristic]: " 908 | f"Working with a nearprime with L0 = {factor(L0)}" 909 | ) 910 | # We've allowed non-prime ideals, make sure we made 911 | # good choices! 912 | assert N * L0 == L.norm(), "Something went wrong computing L0" 913 | assert T % L0 == 0, "Allowed factors do not divide the KLPT target norm" 914 | 915 | # Now we remove the factors from the available ones 916 | T = prod([p**e for p, e in facT]) 917 | facT_updated = list(factor(T // L0)) 918 | 919 | else: 920 | facT_updated = facT.copy() 921 | 922 | # We pass the strong approximation for N, 923 | # regardless of whether N is prime or 2^k 924 | # Logic for differences is inside the wrapper. 925 | J = strong_approximation_wrapper_KLPT( 926 | L, N, L0, facT_updated, small_power_of_two=small_power_of_two 927 | ) 928 | 929 | if J is None: 930 | # Could not find a ν, pick a new N or just try again? 931 | print( 932 | f"DEBUG [EquivalentSmoothIdeal]:" 933 | "No solution found, trying again with new starting ideal" 934 | ) 935 | continue 936 | 937 | # Some last minute checks... 938 | assert is_integral(J), "Output ideal is not integral" 939 | assert T % ZZ(J.norm()) == 0, "Ideal does not have target norm" 940 | assert equivalent_left_ideals(I, J) 941 | 942 | # J is an ideal equivalent to I with norm dividing the target T 943 | 944 | # Ensure that J is cyclic 945 | J, _ = make_cyclic(J) 946 | return J 947 | 948 | print( 949 | f"DEBUG [EquivalentSmoothIdeal]: No Equivalent Smooth Ideal could be computed..." 950 | ) 951 | return None 952 | 953 | 954 | # =================================================== # 955 | # Signing KLPT, fixed norm output for SQISign Signing # 956 | # =================================================== # 957 | 958 | # ====================== # 959 | # Begin Helper functions # 960 | # ====================== # 961 | 962 | 963 | def derive_L(I, Iτ, Nτ, O0, O1): 964 | """ 965 | Given an ideal I with left order O1 and an ideal 966 | Iτ with left order O0 of norm Nτ computes an ideal 967 | equivalent to the pullback of I under Iτ with prime 968 | norm. 969 | 970 | Input: I with left order O1 971 | Iτ with left order O0 and norm Nτ 972 | 973 | Output L ~ [Iτ]^* I with prime norm 974 | N = n(L) 975 | δ such that L = χ(K', δ) 976 | """ 977 | for _ in range(20): 978 | # The PoC implementation skips this, but it's 979 | # not computationally expensive, so we include 980 | # it anyway 981 | K = EquivalentRandomEichlerIdeal(I, Nτ) 982 | 983 | # Make K as small as possible 984 | K = small_equivalent_ideal(K) 985 | 986 | # K' = [Iτ]^* K 987 | K_prime = pullback_ideal(O0, O1, K, Iτ) 988 | 989 | L, N, δ = EquivalentPrimeIdealHeuristic(K_prime) 990 | 991 | # Bad delta, this will cause EichlerModConstraint to break 992 | if gcd(δ.reduced_norm(), Nτ) != 1: 993 | print(f"DEBUG [SigningKLPT]: Not sure why this is happening...") 994 | print(f"{factor(δ.reduced_norm()) = }") 995 | print(f"{factor(Nτ.reduced_norm()) = }") 996 | print(f"{gcd(δ.reduced_norm(), Nτ) = }") 997 | continue 998 | 999 | if L is not None: 1000 | return L, N, δ 1001 | 1002 | # If we get here, something is likely broken 1003 | raise ValueError(f"Never found an equivalent prime norm ideal") 1004 | 1005 | 1006 | def derive_L2_SigningKLPT(γ, L1, e1): 1007 | """ 1008 | Given L1 = l^e1 and γ try and compute L2 1009 | so that the output of SigningKLPT has norm 1010 | exactly 2^e 1011 | """ 1012 | g = quaternion_basis_gcd(γ, O0) 1013 | extra = 2 * (floor(loglogp / 4) + ZZ(gcd(g, L1).valuation(l))) 1014 | e2 = e - e1 + extra 1015 | return l**e2 1016 | 1017 | 1018 | def derive_C_and_D_SigningKLPT(L, N, Iτ, Nτ, EichlerIτ, γ, δ, L2): 1019 | """ 1020 | Solves IdealModConstraint and EichlerModConstraint 1021 | for a given γ and returns when we find an element 1022 | μ such that L2 / Nrd(μ) is a square mod N*Nτ 1023 | 1024 | Input: Ideals L, Iτ of prime norm N, Nτ 1025 | EichlerIτ the Eichler order of Iτ 1026 | γ, δ, elements of B 1027 | L2, a divisor of T 1028 | 1029 | Output C, D such that L2 / p*(C^2 + D^2) is a square mod N*Nτ 1030 | """ 1031 | 1032 | C0, D0 = IdealModConstraint(L, γ) 1033 | C1, D1 = EichlerModConstraint(Iτ, EichlerIτ, γ, δ) 1034 | 1035 | # Compute CRT 1036 | C = CRT([C0, C1], [N, Nτ]) 1037 | D = CRT([D0, D1], [N, Nτ]) 1038 | 1039 | # We need to take a sqrt of this 1040 | μ_norm = p * quadratic_norm(C, D) 1041 | 1042 | KL = Zmod(N) 1043 | Kτ = Zmod(Nτ) 1044 | 1045 | # TODO: Sometimes μ_norm is 0 mod N or Kτ 1046 | # Catch this earlier so this never happens... 1047 | try: 1048 | square_mod_N = (KL(L2) / KL(μ_norm)).is_square() 1049 | square_mod_Nτ = (Kτ(L2) / Kτ(μ_norm)).is_square() 1050 | except: 1051 | return None, None 1052 | # To compute the square root mod N*Nτ 1053 | # Both of these must be true. Will happen 1054 | # about 1/4 of the time. 1055 | if square_mod_N and square_mod_Nτ: 1056 | return C, D 1057 | 1058 | return None, None 1059 | 1060 | 1061 | # ====================== # 1062 | # End Helper functions # 1063 | # ====================== # 1064 | 1065 | 1066 | def SigningKLPT(I, Iτ): 1067 | """ 1068 | Algorithm 5 (SQISign paper) 1069 | 1070 | Input: Iτ a left O0-ideal and right O-ideal of norm Nτ, 1071 | I, a left O-ideal 1072 | 1073 | Output: J ∼ I of norm l^e, where e is fixed (global param) 1074 | """ 1075 | assert is_cyclic(I), "I is not cyclic" 1076 | assert is_cyclic(Iτ), "Iτ is not cyclic" 1077 | 1078 | # Prime norm ideal 1079 | Nτ = ZZ(Iτ.norm()) 1080 | 1081 | # Make I as small as possible 1082 | I = small_equivalent_ideal(I) 1083 | 1084 | # Orders needed for pushback and pullforward 1085 | O1 = I.left_order() 1086 | assert Iτ.left_order() == O0 1087 | assert Iτ.right_order() == O1 1088 | 1089 | # Compute the pullback K of I with left order O0, and 1090 | # find an equivalent prime norm ideal L ~ K. 1091 | L, N, δ = derive_L(I, Iτ, Nτ, O0, O1) 1092 | 1093 | # We want L1 to be big enough that we sensibly find solutions 1094 | # for RepresentIntegerHeuristic(N * L1) but not so big that we 1095 | # find no solutions to the lattice problem in the strong approx. 1096 | e1 = floor(logp - log(N, l) + 1.74 * loglogp) 1097 | L1 = l**e1 1098 | 1099 | # EichlerIτ = ℤ + Iτ = OL(I) ∩ OR(I) 1100 | EichlerIτ = eichler_order_from_ideal(Iτ) 1101 | 1102 | # Store γ which appear to stop checking the element twice 1103 | seen_γ = set() 1104 | 1105 | # TODO how many times should we try to solve the strong approx? 1106 | for _ in range(2000): 1107 | γ = RepresentIntegerHeuristic(N * L1) 1108 | if γ is None: 1109 | print(f"DEBUG [SigningKLPT]: Unable to compute a γ, trying again.") 1110 | continue 1111 | 1112 | # No point trying the same element twice 1113 | if γ in seen_γ: 1114 | print(f"DEBUG [SigningKLPT]: Already tried γ, trying again.") 1115 | continue 1116 | seen_γ.add(γ) 1117 | 1118 | # If this GCD is non-trivial, EichlerModConstraint will break 1119 | if gcd(γ.reduced_norm(), Nτ) != 1: 1120 | continue 1121 | 1122 | # Given L1 and γ derive the bound L2. We can estimate how non-cyclic 1123 | # the end result will be from the gcd of the elements of γ in the basis 1124 | # of O0, but it's not perfect, so sometimes we need to run SigningKLPT 1125 | # many times to ensure that n(J) = 2^1000 1126 | L2 = derive_L2_SigningKLPT(γ, L1, e1) 1127 | 1128 | # Look for Given L1 = l^e1 and γ try and compute L2 1129 | # so that the output of SigningKLPT has norm 1130 | # exactly 2^eμ = j(C + ωD) such that L2 / Nrd(μ) is a square mod N*Nτ 1131 | C, D = derive_C_and_D_SigningKLPT(L, N, Iτ, Nτ, EichlerIτ, γ, δ, L2) 1132 | if C is None: 1133 | print(f"DEBUG [SigningKLPT]: Unable to compute a C,D, given γ.") 1134 | continue 1135 | 1136 | # Search for a solution to the strong approximation. As the L2 is 1137 | # fixed, we only try once. 1138 | μ = StrongApproximationHeuristic( 1139 | N * Nτ, C, D, factor(L2), composite_factors=(N, Nτ) 1140 | ) 1141 | 1142 | # No solution found, try another γ 1143 | if not μ: 1144 | continue 1145 | 1146 | # Strong approximation norm check 1147 | assert μ.reduced_norm() == L2 1148 | print(f"INFO [SigningKLPT]: Found a solution to the StrongApproximation!") 1149 | 1150 | # Now construct the equivalent ideal J 1151 | β = γ * μ 1152 | J_prime = chi(β, L) 1153 | 1154 | # J = [Iτ]_* J' 1155 | J = pushforward_ideal(O0, O1, J_prime, Iτ) 1156 | 1157 | # Check things are actually equivalent 1158 | assert equivalent_left_ideals(I, J) 1159 | 1160 | # Make sure the output is cyclic 1161 | J, _ = make_cyclic(J) 1162 | 1163 | # Rarely, we will have estimated L2 wrong and 1164 | # in the process of making J cyclic, computed 1165 | # an ideal J with n(J) != l^e 1166 | if J.norm() != l**e: 1167 | print(f"DEBUG [SigningKLPT]: J has the wrong norm, trying again") 1168 | print(f"DEBUG [SigningKLPT]: {factor(J.norm()) = }") 1169 | continue 1170 | 1171 | return J 1172 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Maria Corte-Real Santos, Jonathan Komada Eriksen, Michael Meyer and Giacomo Pope 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SQISign-SageMath 2 | 3 | A SageMath implementation of SQISign following the paper 4 | [SQISign: compact post-quantum signatures from quaternions and isogenies](https://eprint.iacr.org/2020/1240), 5 | by Luca De Feo, David Kohel, Antonin Leroux, Christophe Petit, and Benjamin Wesolowski (2020). 6 | 7 | ## Learning to SQI 8 | 9 | Accompanying our code, we have written a blog, [Learning to SQI](https://learningtosqi.github.io), which includes detailed 10 | write-ups of many of the (sub-)algorithms of SQISign. It has not been written to be self-contained, but rather as 11 | supplementary material to the original [SQISign paper](https://eprint.iacr.org/2020/1240). 12 | 13 | We do not claim novelty in any of the posts, but rather hope that this resource will be valuable to whoever is thinking about implementing SQISign, or some other 14 | protocol which relies on the Deuring correspondence. 15 | 16 | ## Example Usage 17 | 18 | Example of SQISign as a one-round interactive identification protocol between two parties: 19 | 20 | ```python 21 | sage: from SQISign import SQISign 22 | sage: prover, verifier = SQISign(), SQISign() 23 | sage: prover.keygen() 24 | sage: EA = prover.export_public_key() 25 | sage: E1 = prover.commitment() 26 | sage: phi_ker = verifier.challenge(E1) 27 | sage: S = prover.response(phi_ker) 28 | sage: assert verifier.verify_response(EA, E1, S, phi_ker) 29 | ``` 30 | 31 | Example of signing a message `msg` with SQISign: 32 | 33 | ```python 34 | sage: from SQISign import SQISign 35 | sage: signer, verifier = SQISign(), SQISign() 36 | sage: msg = b"Learning to SQI!" 37 | sage: signer.keygen() 38 | sage: EA = signer.export_public_key() 39 | sage: sig = signer.sign(msg) 40 | sage: assert verifier.verify(EA, sig, msg) 41 | ``` 42 | 43 | ## Project Overview 44 | 45 | SQISign itself is implemented in the file [`SQISign.py`](SQISign.py). Reading this code is enough to see 46 | a high-level description of how the main functions: `keygen()`, `commitment()`, `challenge()`, `response()` 47 | and `verify_response()` have been implemented. 48 | 49 | If you wish to run SQISign yourself, we include two examples following the above snippets with additional comments: 50 | 51 | - [`example_SQISign.sage`](example_SQISign.sage) 52 | - [`example_signing.sage`](example_signing.sage) 53 | 54 | ### Performance 55 | 56 | For the SQISign prime $p_{6983}$, it is expected that our implementation of SQISign will successfully run in about 15 minutes. 57 | If you want to see everything work more quickly, we also include $p_{\text{toy}}$, which allows SQISign to run in approximately 58 | 30 seconds. By default, SQISign will run with the intended 256-bit prime. 59 | 60 | If you wish to run SQISign with the smaller toy parameters, then simply change the last lines of [`parameters.py`](parameters.py) 61 | such that `params = p_toy`. 62 | 63 | **A Note on Slowness**: 64 | Our implementation of SQISign is particularly slow as we have not optimised the isogeny computations. 65 | For a full run of the SQISign protocol, we find that about 75% of the total running time is spent computing odd-degree isogenies. 66 | To use the in-built SageMath functions to compute isogenies, (either with with Vélu's formula or the optimised $\sqrt{elu}$), 67 | we must work with projective coordinates. SQISign is intended to be implemented with $x$-only arithmetic, allowing simultaneous 68 | access to the torsion sub-groups of both an elliptic curve and it's quadratic twist when performing isogeny computations. 69 | Rather than implement $x$-only isogenies, we instead work with supersingular elliptic curves over the extension field 70 | $E / \mathbb{F}_{p^4}$. This allows us to work with curves with maximal available torsion $(p+1)(p-1)$, and avoid the need 71 | for computing over the twist. 72 | This has the benefit of allowing us to directly work with the SageMath class, `EllipticCurveIsogeny`, but results 73 | in relatively slow isogenies due to the extension field. 74 | 75 | We discuss this, and plans for future work on the page [Future Work](https://learningtosqi.github.io/posts/future-work/) on the 76 | Learning to SQI blog. 77 | 78 | ### Helper functions 79 | 80 | - The file [`ideals.py`](ideals.py) contains helper functions for working with the quaternion algebra $\mathcal{B}_{p, \infty}$, 81 | and ideals and orders of the quaternion algebra. 82 | - Similarly, [`isogenies.py`](isogenies.py) contains helper functions for computing and working with isogenies between 83 | supersingular elliptic curves $E / \mathbb{F}_{p^4}$. 84 | - One step in SQISign requires computing a brute force search for a degree $2^\Delta$ isogeny. 85 | We implemented this using a meet-in-the-middle algorithm in [`mitm.py`](mitm.py). 86 | - We implement both the naïve KLPT and SigningKLPT algorithms in [`KLPT.py`](KLPT.py). Although this generally follows 87 | the seminal paper [On the quaternion l-isogeny path problem](https://arxiv.org/abs/1406.0981), by David Kohel, Kristin Lauter, 88 | Christophe Petit, Jean-Pierre Tignol, we include many adjustments and edge cases such that our algorithms work as is needed 89 | for SQISign. 90 | - At certain points in the KLPT algorithm, we need to enumerate short vectors of a lattice to find good solutions to 91 | various problems. We use [`fpylll`](https://github.com/fplll/fpylll) 92 | to achieve this and the code implementing it is contained in [`lattices.py`](lattices.py). 93 | - The functions responsible for translating between ideals and isogenies following the Deuring correspondence is contained 94 | in [`deuring.py`](deuring.py). This includes the standard `ideal_to_isogeny()`, as well as the specialised algorithms 95 | introduced in the SQISign paper: `IdealToIsogenyFromKLPT()`. 96 | - Suitable SQISign parameters are stored as dictionaries in [`parameters.py`](parameters.py). These are then imported 97 | into [`setup.py`](setup.py), which then computes all the global parameters which are needed for various sub-algorithms 98 | of SQISign. 99 | - SQISign computes a large $\ell^e$ degree isogeny during `response()`. Before sending this to the verifier, the isogeny is 100 | compressed and then similarly decompressed by the verifier. The file [`compression.py`](compression.py) file handles these 101 | functions. 102 | - Anything else we used a lot but didn't seem to belong anywhere else is stored in [`utilities.py`](utilities.py). 103 | 104 | 105 | ## Future Work 106 | 107 | - Implement the new algorithms from [New algorithms for the Deuring correspondence: toward practical and secure SQISign signatures](https://eprint.iacr.org/2022/234), 108 | by Luca De Feo, Antonin Leroux, Patrick Longa and Benjamin Wesolowski. 109 | - Once we have the new SQISign algorithms, we can start benchmarking various SQISign parameter sets, such as the ones recently suggested in 110 | [Cryptographic Smooth Neighbors](https://eprint.iacr.org/2022/1439), by 111 | Giacomo Bruno, Maria Corte-Real Santos, Craig Costello, Jonathan Komada Eriksen, Michael Naehrig, Michael Meyer and Bruno Sterner. 112 | - Currently all isogenies are computed between curves $E / \mathbb{F}\_{p^4}$. We can make our implementation more 113 | efficient by using $x$-only arithmetic, such that we can access the $(p+1)$ torsion of $E / \mathbb{F}\_{p^2}$ and 114 | the $(p-1)$ torsion of its quadratic twist without needing to perform expensive computations on the extension 115 | field $\mathbb{F}\_{p^4}$. 116 | - As computing and evaluating isogenies consume most of the computation time, we could write specialised isogeny methods using 117 | Montgomery and Edwards curves. These are not suitable for all curves, but should lead to significant performance improvements 118 | for our SQISign implementation 119 | - Finish implementing the more efficient variation of `ideal_to_kernel()` from 120 | [Deuring for the People: Supersingular Elliptic Curves with Prescribed Endomorphism Ring in General Characteristic](https://ia.cr/2023/106), 121 | by Jonathan Komada Eriksen, Lorenz Panny, Jana Sotáková, and Mattia Veroni. 122 | 123 | ### References 124 | 125 | - [SQISign: compact post-quantum signatures from quaternions and isogenies](https://eprint.iacr.org/2020/1240), Luca De Feo, David Kohel, Antonin Leroux, Christophe Petit, and Benjamin Wesolowski (2020). 126 | - [New algorithms for the Deuring correspondence: toward practical and secure SQISign signatures](https://eprint.iacr.org/2022/234), Luca De Feo, Antonin Leroux, Patrick Longa and Benjamin Wesolowski (2022). 127 | - [Quaternion algebras and isogeny-based cryptography](https://www.lix.polytechnique.fr/Labo/Antonin.LEROUX/manuscrit_these.pdf), Antonin Leroux (PhD Thesis) (2022). 128 | - [On the quaternion $\ell$-isogeny path problem](https://arxiv.org/abs/1406.0981), David Kohel, Kristin Lauter, Christophe Petit, Jean-Pierre Tignol (2014). 129 | - [Supersingular isogeny graphs and endomorphism rings: reductions and solutions](https://eprint.iacr.org/2018/371), Kirsten Eisenträger, Sean Hallgren, Kristin Lauter, Travis Morrison Christophe Petit (2018). 130 | - [An improvement to the quaternion analogue of the l-isogeny path problem](https://crypto.iacr.org/2018/affevents/mathcrypt/page.html), Christophe Petit and Spike Smith (2018). 131 | - [Deuring for the People: Supersingular Elliptic Curves with Prescribed Endomorphism Ring in General Characteristic](https://ia.cr/2023/106) Jonathan Komada Eriksen, Lorenz Panny, Jana Sotáková, and Mattia Veroni (2023). 132 | -------------------------------------------------------------------------------- /SQISign.py: -------------------------------------------------------------------------------- 1 | """ 2 | SageMath implementation of SQISign 3 | 4 | SQISign: compact post-quantum signatures from quaternions and isogenies 5 | Luca De Feo, David Kohel, Antonin Leroux, Christophe Petit, and Benjamin Wesolowski, 6 | https://ia.cr/2020/1240 7 | 8 | # ========================== # 9 | # Proof of knowledge example # 10 | # ========================== # 11 | 12 | sage: from SQISign import SQISign 13 | sage: prover, verifier = SQISign(), SQISign() 14 | sage: prover.keygen() 15 | sage: EA = prover.export_public_key() 16 | sage: E1 = prover.commitment() 17 | sage: ϕ_ker = verifier.challenge(E1) 18 | sage: S = prover.response(ϕ_ker) 19 | sage: assert verifier.verify_response(EA, E1, S, ϕ_ker) 20 | 21 | # ========================== # 22 | # Signing example # 23 | # ========================== # 24 | 25 | sage: from SQISign import SQISign 26 | sage: signer, verifier = SQISign(), SQISign() 27 | sage: msg = b"Learning to SQI!" 28 | sage: signer.keygen() 29 | sage: EA = signer.export_public_key() 30 | sage: sig = signer.sign(msg) 31 | sage: assert verifier.verify(EA, sig, msg) 32 | 33 | # ========================== # 34 | # SQISign Functions # 35 | # ========================== # 36 | 37 | keygen(): Generates two equivalent ideals Iτ (prime norm) 38 | and Jτ (smooth norm). Computes the isogeny 39 | τ_prime : E0 → EA = E0 / . 40 | 41 | (τ_prime, Iτ, Jτ) are secret values, 42 | EA is the public value. 43 | 44 | All values are stored in `self` 45 | 46 | export_public_key(): Returns the public key EA from self. 47 | Requires that .keygen() has been run. 48 | 49 | commitment(): Computes a secret isogeny ψ : E0 → E1 of degree 50 | T_prime, together with the corresponding ideal Iψ. 51 | (ψ, Iψ) are stored in self. 52 | Returns the public value E1 for use in generating a 53 | challenge. 54 | 55 | challenge(): Computes a public isogeny ϕ : E1 → E2 of degree 56 | Dc. Returns ϕ_ker. 57 | 58 | challenge_from_message(): Given a message `msg` and curve E1, 59 | computes an isogeny ϕ : E1 → E2 deterministically of degree 60 | Dc and returns ϕ_ker 61 | 62 | response(): Given an isogeny ϕ : E1 → EA and the secret values 63 | from .keygen() and .commitment() computes an isogeny 64 | σ : EA → E2 of fixed degree l^e. 65 | 66 | sign(): Given a message `msg` computes a random commitment ψ and 67 | then generates a challenge from the commitment and the 68 | message using `challenge_from_message()`. A response to 69 | the generated challenge is computed and returned. 70 | 71 | verify_response(): Given the public key EA and the response σ 72 | check whether σ has the right degree and codomains and 73 | whether ϕ_dual ∘ σ is a cyclic isogeny : EA → E1. 74 | 75 | verify(): Given a message, and the signature (E1, σ) generates a 76 | challenge ϕ and runs `verify_response()` to verify the 77 | signature. 78 | """ 79 | 80 | # Python imports 81 | from hashlib import shake_128 82 | 83 | # SageMath imports 84 | from sage.all import randint, ZZ, factor, proof 85 | 86 | # Local imports 87 | from ideals import ( 88 | is_integral, 89 | is_cyclic, 90 | multiply_ideals, 91 | equivalent_left_ideals, 92 | left_isomorphism, 93 | ) 94 | from isogenies import torsion_basis, dual_isogeny, EllipticCurveIsogenyFactored 95 | from deuring import IdealToIsogenyFromKLPT, kernel_to_ideal 96 | from KLPT import RepresentIntegerHeuristic, SigningKLPT 97 | from compression import compression, decompression 98 | from utilities import inert_prime, has_order_D 99 | from setup import E0, O0, Bτ, eτ, p, l, Dc, T_prime, ω, e, f_step_max 100 | 101 | proof.all(False) 102 | 103 | 104 | class SQISign: 105 | def __init__(self): 106 | """ 107 | TODO 108 | 109 | We only use the object to store intermediate values, 110 | we could also init this with parameters and stop using 111 | globals. 112 | """ 113 | # Key pair 114 | # pk = EA 115 | # sk = (τ_prime, Iτ, Jτ) 116 | self.pk = None 117 | self.sk = None 118 | 119 | # Secret commitment values 120 | # commitment_secrets = (ψ_ker, ψ, Iψ) 121 | self.commitment_secrets = None 122 | 123 | def keygen(self): 124 | """ 125 | Efficient keygen as described in Appendix D 126 | of the SQISign paper 127 | 128 | Input: None 129 | Output: None 130 | 131 | Stores to self: 132 | self.pk = EA 133 | self.sk = (τ_prime, Iτ, Jτ) 134 | 135 | EA: the codomain of the isogeny τ_prime 136 | τ_prime: the secret isogeny from E0 → EA 137 | Iτ: ideal with prime norm equivalent to Jτ 138 | Jτ: ideal with smooth norm, equivalent to Iτ and to 139 | τ_prime under the Deuring correspondence 140 | 141 | Note: 142 | To send the public key use the function 143 | `self.export_public_key()` 144 | """ 145 | # Compute a random prime ≤ Bτ which is inert 146 | # in R[ω]. 147 | # Note: this is the same as picking p ≡ 3 mod 4 148 | Nl = l**eτ 149 | 150 | # Stop infinite loops 151 | for _ in range(1000): 152 | Nτ = inert_prime(Bτ, -ZZ(ω**2)) 153 | # We need the product to be large enough for 154 | # RepresentIntegerHeuristic. 155 | if Nτ * Nl > 2 * p: 156 | break 157 | 158 | # Compute an endomorphism γ of norm Nτ l^eτ 159 | # Nτ < Bτ 160 | γ = None 161 | 162 | # Stop infinite loops 163 | for _ in range(1000): 164 | γ = RepresentIntegerHeuristic(Nτ * Nl, parity=True) 165 | if γ is not None: 166 | break 167 | 168 | if γ is None: 169 | exit("Never found an alg element with norm (Nτ * Nl), Exiting...") 170 | 171 | Iτ = O0 * γ + O0 * Nτ 172 | Jτ = O0 * γ.conjugate() + O0 * Nl 173 | 174 | # Iτ has prime norm 175 | assert Iτ.norm() == Nτ, f"{Iτ.norm() = }, {Nτ = }, {Nl = }" 176 | # Jτ has smooth norm l^e 177 | assert Jτ.norm() == Nl, f"{Jτ.norm() = }, {Nτ = }, {Nl = }" 178 | 179 | # Iτ is an integral ideal: Iτ ⊂ O0 180 | assert is_integral(Iτ), "Iτ is not integral" 181 | 182 | # Jτ is an integral ideal: Iτ ⊂ O0 183 | assert is_integral(Jτ), "Jτ is not integral" 184 | 185 | # Jτ is a cyclic isogeny 186 | assert is_cyclic(Jτ), "Jτ is not cyclic" 187 | 188 | # Compute the secret isogeny τ 189 | I_trivial = O0.unit_ideal() 190 | ϕ_trivial = E0.isogeny(E0(0)) 191 | τ_prime = IdealToIsogenyFromKLPT( 192 | Jτ, I_trivial, ϕ_trivial, I_prime=Iτ, end_close_to_E0=True 193 | ) 194 | EA = τ_prime.codomain() 195 | 196 | # The isogeny τ_prime should have degree = n(Jτ) 197 | assert ( 198 | τ_prime.degree() == Jτ.norm() 199 | ), f"{factor(τ_prime.degree()) = } {factor(Jτ.norm()) = }" 200 | 201 | self.pk = EA 202 | self.sk = (τ_prime, Iτ, Jτ) 203 | 204 | return None 205 | 206 | def export_public_key(self): 207 | """ 208 | Helper function to return the public key 209 | 210 | TODO: this could be compressed, probably. 211 | """ 212 | if self.pk is None: 213 | raise ValueError(f"Must first generate a keypair with `self.keygen()`") 214 | return self.pk 215 | 216 | def commitment(self): 217 | """ 218 | Compute the challenge isogeny and corresponding ideal 219 | of degree / norm T_prime 220 | 221 | Input: None 222 | Output: E1: the codomain of the commitment isogeny 223 | 224 | Stores to self: 225 | self.commitment_secrets = (ψ_ker, ψ, Iψ)) 226 | ψ_ker: the kernel of ψ 227 | ψ: the secret commitment isogeny ψ : E0 → E1 228 | Iψ: the ideal equivalent to ψ. 229 | """ 230 | # Generate a random kernel 231 | # of order T_prime 232 | P, Q = torsion_basis(E0, T_prime) 233 | x = randint(1, T_prime) 234 | ψ_ker = P + x * Q 235 | 236 | # Generate the ideal Iψ from ψ_ker 237 | Iψ = kernel_to_ideal(ψ_ker, T_prime) 238 | assert Iψ.norm() == T_prime, "Iψ has the wrong norm" 239 | 240 | # Generate the ideal ψ and its codomain 241 | ψ = EllipticCurveIsogenyFactored(E0, ψ_ker, order=T_prime) 242 | E1 = ψ.codomain() 243 | 244 | # Store secret results 245 | self.commitment_secrets = (ψ_ker, ψ, Iψ) 246 | 247 | return E1 248 | 249 | @staticmethod 250 | def challenge(E1, x=None): 251 | """ 252 | Compute the challenge isogenys 253 | 254 | Input: E1 the codomain of the commitment and domain 255 | of the challenge isogeny 256 | Output: ϕ_ker: The kernel isogeny ϕ : E1 → E2 of degree Dc 257 | """ 258 | # Generate a random kernel ∈ E1[Dc] 259 | E1.set_order((p**2 - 1) ** 2) 260 | P, Q = torsion_basis(E1, Dc, canonical=True) 261 | 262 | # If x isn't supplied, generate a random x 263 | if x is None: 264 | x = randint(1, Dc) 265 | 266 | # Compute the kernel of the challenge isogeny 267 | ϕ_ker = P + x * Q 268 | 269 | return ϕ_ker 270 | 271 | def challenge_from_message(self, E1, msg): 272 | """ 273 | Compute a challenge deterministically from a 274 | message 275 | 276 | Input: E1: the codomain of the commitment and domain 277 | of the challenge isogeny 278 | msg: the message to be signed 279 | 280 | Output: ϕ_ker: The kernel isogeny ϕ : E1 → E2 of degree Dc 281 | 282 | TODO: this was just thrown together, almost certainly not 283 | what we should be doing here. 284 | """ 285 | # Compute a scalar from the message 286 | h = shake_128(msg).digest(128) 287 | x = int.from_bytes(h, "big") 288 | 289 | # Reduce modulo Dc 290 | x = ZZ(x % Dc) 291 | 292 | # Compute a challenge using a kernel 293 | # K = P + [x]Q ∈ E1[Dc] 294 | return self.challenge(E1, x=x) 295 | 296 | def response(self, ϕ_ker): 297 | """ 298 | Compute the isogeny σ : EA → E2 of degree l^e where 299 | e is a SQISign parameter. Does this by via the Deuring 300 | correspondence from an ideal of norm l^e. 301 | 302 | Input: ϕ_ker: The kernel isogeny ϕ : E1 → E2 of degree Dc 303 | Output: S: a bitstring corresponding to an isogeny σ : EA → E2 304 | """ 305 | if self.pk is None or self.sk is None: 306 | raise ValueError(f"Must first generate a keypair with `self.keygen()`") 307 | 308 | if self.commitment_secrets is None: 309 | raise ValueError( 310 | f"Must first generate a commitment with `self.commitment()`" 311 | ) 312 | 313 | # Extract secret values from keygen 314 | EA = self.pk 315 | τ_prime, Iτ, Jτ = self.sk 316 | 317 | # Extract values from commitment 318 | ψ_ker, ψ, Iψ = self.commitment_secrets 319 | 320 | # Recover the dual of ψ from ψ and its kernel 321 | ψ_dual = dual_isogeny(ψ, ψ_ker, order=T_prime) 322 | 323 | # Deviation from paper time! 324 | # We are asked to first compute Iϕ 325 | # Then compute: Iτ_bar * Iψ * Iϕ 326 | # But we don't actually do this. 327 | # Instead, we directly compute 328 | # Iψ * Iϕ = Iψ ∩ I_([ψ]^* ϕ) 329 | # = Iψ ∩ I_([ψ_dual]_* ϕ) 330 | # 331 | 332 | # First compute the ideal from the pullback 333 | # I_([ψ_dual]_* ϕ) 334 | Iϕ_pullback = kernel_to_ideal(ψ_dual(ϕ_ker), Dc) 335 | IψIϕ = Iψ.intersection(Iϕ_pullback) 336 | assert IψIϕ.norm() == Iψ.norm() * Iϕ_pullback.norm() 337 | 338 | # Compute the product of ideals 339 | # I = Iτ_bar * Iψ * Iϕ 340 | Iτ_bar = Iτ.conjugate() 341 | I = multiply_ideals(Iτ_bar, IψIϕ) 342 | assert I.norm() == Iτ_bar.norm() * IψIϕ.norm() 343 | 344 | print(f"INFO [SQISign Response]: Running SigningKLPT") 345 | J = SigningKLPT(I, Iτ) 346 | assert J.norm() == l**e, "SigningKLPT produced an ideal with incorrect norm" 347 | print(f"INFO [SQISign Response]: Finished SigningKLPT") 348 | 349 | assert equivalent_left_ideals( 350 | I, J 351 | ), "Signing KLPT did not produce an equivalent ideal!" 352 | assert is_cyclic(J), "SigningKLPT produced a non-cyclic ideal" 353 | 354 | # Ensure that the left and right orders match 355 | α = left_isomorphism(Iτ, Jτ) 356 | J = α ** (-1) * J * α 357 | assert J.left_order() == Jτ.right_order() 358 | 359 | print(f"INFO [SQISign Response]: Computing the corresponding isogeny") 360 | σ = IdealToIsogenyFromKLPT(J, Jτ, τ_prime, K_prime=Iτ) 361 | print(f"INFO [SQISign Response]: Computed the isogeny EA → E2") 362 | 363 | print(f"INFO [SQISign Response]: Compressing the isogeny σ to a bitstring") 364 | S = compression(EA, σ, l, f_step_max) 365 | print( 366 | f"INFO [SQISign Response]:" 367 | f"Compressed the isogeny σ to a bitstring of length {len(S)}" 368 | ) 369 | 370 | return S 371 | 372 | def sign(self, msg): 373 | """ 374 | Use SQISign to sign a message by creating a challenge 375 | isogeny from the message and generating a response S 376 | from the challenge. 377 | 378 | Input: msg: the message to be signed 379 | 380 | Output: sig: a signature tuple (E1, S) 381 | E1 : the codomain of the commitment 382 | S: a compressed bitstring of the response isogeny EA → E2 383 | """ 384 | # Make a commitment 385 | E1 = self.commitment() 386 | 387 | # Use the message to find a challenge 388 | ϕ_ker = self.challenge_from_message(E1, msg) 389 | 390 | # Compute a response for the challenge 391 | S = self.response(ϕ_ker) 392 | 393 | return (E1, S) 394 | 395 | def verify_response(self, EA, E1, S, ϕ_ker): 396 | """ 397 | Verify that the compressed bitstring S corresponds to 398 | an isogeny σ EA → E2 of degree l^e such that ϕ_dual ∘ σ 399 | is cyclic 400 | 401 | Input: EA: the public key, and codomain of the secret isogeny τ_prime 402 | E1: the codomain of the secret commitment ψ : E0 → E1 403 | S: a compressed bitstring of the response isogeny EA → E2 404 | ϕ_ker: the kernel of the challenge isogeny ϕ : E1 → E2 405 | Output: True if the response is value, False otherwise 406 | """ 407 | # Compute the challenge isogeny from the challenge kernel 408 | ϕ = EllipticCurveIsogenyFactored(E1, ϕ_ker, order=Dc) 409 | E2 = ϕ.codomain() 410 | E2.set_order((p**2 - 1) ** 2) 411 | 412 | # Decompress σ 413 | print(f"INFO [SQISign Verify]: Decompressing the isogeny σ from a bitstring") 414 | σ = decompression(EA, E2, S, l, f_step_max, e) 415 | 416 | print(f"INFO [SQISign Verify]: Verifying the degree and (co)domains of σ") 417 | # Ensure that the domain of σ is EA 418 | if not σ.domain() == EA: 419 | print(f"DEBUG [SQISign Verify]: The domain of σ is not EA") 420 | return False 421 | 422 | if not σ.codomain() == E2: 423 | print(f"DEBUG [SQISign Verify]: The codomain of σ is not E2") 424 | return False 425 | 426 | # Check the degree of σ is as expected 427 | if ZZ(σ.degree()) != l**e: 428 | print( 429 | f"DEBUG [SQISign Verify]:" 430 | f"The degree σ is {factor(σ.degree())}, expected {l}^{e}" 431 | ) 432 | return False 433 | 434 | # Check that the isogeny ϕ_dual ∘ σ is cyclic 435 | print(f"INFO [SQISign Verify]: Verifying that ϕ_dual * σ is cyclic") 436 | 437 | # Compute torsion basis EA[2^f] 438 | D = l**f_step_max 439 | P, Q = torsion_basis(EA, D) 440 | ϕ_dual = dual_isogeny(ϕ, ϕ_ker) 441 | 442 | # Compute ϕ_dual ∘ σ : EA → E1 443 | ϕ_dual_σ = ϕ_dual * σ 444 | imP = ϕ_dual_σ(P) 445 | assert imP.curve() == E1, "Mapping is incorrect" 446 | 447 | # Check if ϕ_dual ∘ σ is cyclic 448 | if has_order_D(imP, D): 449 | return True 450 | 451 | print( 452 | f"DEBUG [SQISign Verify]: ϕ_dual_σ(P) does not have full order, checking Q" 453 | ) 454 | 455 | imQ = ϕ_dual_σ(Q) 456 | assert imQ.curve() == E1, "Mapping is incorrect" 457 | if has_order_D(imQ, D): 458 | return True 459 | 460 | print(f"DEBUG [SQISign Verify]: ϕ_dual_σ is not cyclic!") 461 | return False 462 | 463 | def verify(self, EA, sig, msg): 464 | """ 465 | Wrapper for verify for when the challenge must be 466 | generated from a message 467 | 468 | Input: EA: the public key, and codomain of the secret isogeny τ_prime 469 | sig: a signature tuple (E1, S) 470 | E1: the codomain of the secret commitment ψ : E0 → E1 471 | S: a compressed bitstring of the response isogeny EA → E2 472 | msg: the message which has been signed 473 | Output: True if the response is value, False otherwise 474 | """ 475 | # Extract pieces from signature 476 | E1, S = sig 477 | 478 | # Generate ϕ_ker from the message 479 | ϕ_ker = self.challenge_from_message(E1, msg) 480 | 481 | # Verify signature 482 | return self.verify_response(EA, E1, S, ϕ_ker) 483 | -------------------------------------------------------------------------------- /benchmarks/benchmark_BiDLP.py: -------------------------------------------------------------------------------- 1 | """ 2 | Run with: 3 | sage -python benchmarks/benchmark_BiDLP.py 4 | If you want to skip debugging asserts, run 5 | sage -python -O benchmarks/benchmark_BiDLP.py 6 | 7 | If you get an error about modules not being found 8 | you may have to edit the $PYTHONPATH variable. 9 | 10 | I fixed it by adding 11 | 12 | export PYTHONPATH=${PWD} 13 | 14 | To the file ~/.sage/sagerc 15 | """ 16 | 17 | # Python imports 18 | import cProfile 19 | import pstats 20 | 21 | # Sagemath imports 22 | from sage.all import randint 23 | 24 | # Local imports 25 | from setup import * 26 | from isogenies import torsion_basis, BiDLP 27 | 28 | P, Q = torsion_basis(E0, T_prime) 29 | 30 | cProfile.run("torsion_basis(E0, T_prime)", "torsion_basis.cProfile") 31 | p = pstats.Stats("torsion_basis.cProfile") 32 | p.strip_dirs().sort_stats("cumtime").print_stats(int(40)) 33 | 34 | x = randint(0, T_prime) 35 | R = x * P + Q 36 | 37 | cProfile.run("BiDLP(R, P, Q, T_prime)", "BiDLP.cProfile") 38 | p = pstats.Stats("BiDLP.cProfile") 39 | p.strip_dirs().sort_stats("cumtime").print_stats(int(40)) 40 | -------------------------------------------------------------------------------- /benchmarks/benchmark_cornacchia.py: -------------------------------------------------------------------------------- 1 | """ 2 | Run with: 3 | sage -python benchmarks/benchmark_cornacchia.py 4 | If you want to skip debugging asserts, run 5 | sage -python -O benchmarks/benchmark_cornacchia.py 6 | 7 | If you get an error about modules not being found 8 | you may have to edit the $PYTHONPATH variable. 9 | 10 | I fixed it by adding 11 | 12 | export PYTHONPATH=${PWD} 13 | 14 | To the file ~/.sage/sagerc 15 | """ 16 | 17 | # Python imports 18 | import cProfile 19 | import pstats 20 | import time 21 | 22 | # Sagemath imports 23 | from sage.all import randint 24 | 25 | # Local imports 26 | from utilities import Cornacchia 27 | 28 | total_time = 0 29 | iter_count = 10000 30 | for _ in range(iter_count): 31 | x = randint(0, 2**256) 32 | t0 = time.time() 33 | sol = Cornacchia(x, 1) 34 | total_time += (time.time() - t0) 35 | 36 | print(f"Average time: {total_time / iter_count:5f}") 37 | -------------------------------------------------------------------------------- /benchmarks/benchmark_deuring.py: -------------------------------------------------------------------------------- 1 | """ 2 | Run with: 3 | sage -python benchmarks/benchmark_idealtoisogeny.py 4 | If you want to skip debugging asserts, run 5 | sage -python -O benchmarks/benchmark_idealtoisogeny.py 6 | 7 | If you get an error about modules not being found 8 | you may have to edit the $PYTHONPATH variable. 9 | 10 | I fixed it by adding 11 | 12 | export PYTHONPATH=${PWD} 13 | 14 | To the file ~/.sage/sagerc 15 | """ 16 | 17 | # Python imports 18 | import cProfile 19 | import pstats 20 | 21 | # SageMath imports 22 | from sage.all import ZZ 23 | 24 | # Local imports 25 | from deuring import IdealToIsogenyFromKLPT 26 | from KLPT import RepresentIntegerHeuristic 27 | from utilities import inert_prime 28 | from setup import E0, O0, Bτ, eτ, p, l, ω 29 | 30 | 31 | # =============================# 32 | # This is just SQISign keygen # 33 | # =============================# 34 | 35 | Nl = l**eτ 36 | while True: 37 | Nτ = inert_prime(Bτ, -ZZ(ω**2)) 38 | # We need the product to be large enough for 39 | # RepresentIntegerHeuristic. 40 | if Nτ * Nl > 2 * p: 41 | break 42 | 43 | # Compute an endomorphism γ of norm Nτ l^eτ 44 | # Nτ < Bτ = 45 | γ = None 46 | g = 0 47 | while γ is None: 48 | γ = RepresentIntegerHeuristic(Nτ * Nl, parity=True) 49 | Iτ = O0 * γ + O0 * Nτ 50 | Jτ = O0 * γ.conjugate() + O0 * Nl 51 | 52 | # Compute the secret isogeny τ 53 | I_trivial = O0.unit_ideal() 54 | ϕ_trivial = E0.isogeny(E0(0)) 55 | 56 | cProfile.run( 57 | "IdealToIsogenyFromKLPT(Jτ, I_trivial, ϕ_trivial, I_prime=Iτ)", 58 | "IdealToIsogenyFromKLPT.cProfile", 59 | ) 60 | p = pstats.Stats("IdealToIsogenyFromKLPT.cProfile") 61 | p.strip_dirs().sort_stats("cumtime").print_stats(int(40)) 62 | -------------------------------------------------------------------------------- /benchmarks/benchmark_kernelfromisogeny.py: -------------------------------------------------------------------------------- 1 | """ 2 | Run with: 3 | sage -python benchmark_kernelfromisogeny.py 4 | If you want to skip debugging asserts, run 5 | sage -python -O benchmark_kernelfromisogeny.py 6 | """ 7 | 8 | # Python imports 9 | import cProfile 10 | import pstats 11 | import time 12 | 13 | # Sage imports 14 | from sage.all import randint, factor 15 | 16 | # local imports 17 | from setup import * 18 | from isogenies import ( 19 | torsion_basis, 20 | EllipticCurveIsogenyFactored, 21 | kernel_from_isogeny_prime_power 22 | ) 23 | 24 | # Benchmark new isogeny computation 25 | for D in [Dc, T_prime]: 26 | print(f"Degree = {factor(D)}") 27 | 28 | torsion_time = time.time() 29 | P, Q = torsion_basis(E0, D) 30 | print(f"Basis took: {time.time() - torsion_time:.5f}") 31 | 32 | x = randint(0, D) 33 | K = P + Q 34 | K._order = D 35 | 36 | isogeny_time = time.time() 37 | ϕ = E0.isogeny(K, algorithm="factored") 38 | print(f"Old Isogeny took: {time.time() - isogeny_time:.5f}") 39 | 40 | isogeny_time = time.time() 41 | ϕ_new = EllipticCurveIsogenyFactored(E0, K, order=D) 42 | print(f"New Isogeny took: {time.time() - isogeny_time:.5f}") 43 | 44 | print() 45 | 46 | assert ϕ.codomain().is_isomorphic(ϕ_new.codomain()) 47 | 48 | cProfile.run("kernel_from_isogeny_prime_power(ϕ)", "kernel_from_isogeny_prime_power.cProfile") 49 | p = pstats.Stats("kernel_from_isogeny_prime_power.cProfile") 50 | p.strip_dirs().sort_stats("cumtime").print_stats(int(40)) 51 | -------------------------------------------------------------------------------- /benchmarks/benchmark_keygen.py: -------------------------------------------------------------------------------- 1 | """ 2 | Run with: 3 | sage -python benchmarks/benchmark_keygen.py 4 | If you want to skip debugging asserts, run 5 | sage -python -O benchmarks/benchmark_keygen.py 6 | 7 | If you get an error about modules not being found 8 | you may have to edit the $PYTHONPATH variable. 9 | 10 | I fixed it by adding 11 | 12 | export PYTHONPATH=${PWD} 13 | 14 | To the file ~/.sage/sagerc 15 | """ 16 | 17 | # Python imports 18 | import cProfile 19 | import pstats 20 | 21 | # Local imports 22 | from SQISign import SQISign 23 | 24 | sqisign = SQISign() 25 | 26 | cProfile.run("sqisign.keygen()", "sqisign_keygen.cProfile") 27 | p = pstats.Stats("sqisign_keygen.cProfile") 28 | p.strip_dirs().sort_stats("cumtime").print_stats(int(40)) 29 | -------------------------------------------------------------------------------- /benchmarks/benchmark_response.py: -------------------------------------------------------------------------------- 1 | """ 2 | Run with: 3 | sage -python benchmarks/benchmark_response.py 4 | If you want to skip debugging asserts, run 5 | sage -python -O benchmarks/benchmark_response.py 6 | 7 | If you get an error about modules not being found 8 | you may have to edit the $PYTHONPATH variable. 9 | 10 | I fixed it by adding 11 | 12 | export PYTHONPATH=${PWD} 13 | 14 | To the file ~/.sage/sagerc 15 | """ 16 | 17 | import cProfile 18 | import pstats 19 | 20 | # Local imports 21 | from SQISign import SQISign 22 | from setup import * 23 | 24 | sqisign = SQISign() 25 | 26 | # Keygen 27 | keypair, keypair_ideals = sqisign.keygen() 28 | Iτ, Jτ = keypair_ideals 29 | EA, τ_prime = keypair 30 | 31 | # Commitment 32 | Iψ, ψ_ker = sqisign.commitment() 33 | ϕ_ker = sqisign.challenge(ψ_ker) 34 | 35 | # Response 36 | cProfile.run( 37 | "sqisign.response(keypair, Iτ, Jτ, Iψ, ψ_ker, ϕ_ker)", "sqisign_response.cProfile" 38 | ) 39 | p = pstats.Stats("sqisign_response.cProfile") 40 | p.strip_dirs().sort_stats("cumtime").print_stats(int(100)) 41 | -------------------------------------------------------------------------------- /benchmarks/benchmark_sqisign.py: -------------------------------------------------------------------------------- 1 | """ 2 | Run with: 3 | sage -python benchmarks/benchmark_sqisign.py 4 | If you want to skip debugging asserts, run 5 | sage -python -O benchmarks/benchmark_sqisign.py 6 | 7 | If you get an error about modules not being found 8 | you may have to edit the $PYTHONPATH variable. 9 | 10 | I fixed it by adding 11 | 12 | export PYTHONPATH=${PWD} 13 | 14 | To the file ~/.sage/sagerc 15 | """ 16 | 17 | import cProfile 18 | import pstats 19 | import time 20 | 21 | 22 | from SQISign import SQISign 23 | 24 | 25 | def print_info(str): 26 | print("="*80) 27 | print(f"{str}".center(80)) 28 | print("="*80) 29 | 30 | 31 | # Start the profiler 32 | pr = cProfile.Profile() 33 | pr.enable() 34 | 35 | # 36 | # 37 | # 38 | 39 | prover = SQISign() 40 | verifier = SQISign() 41 | 42 | print_info("Starting SQISign") 43 | sqisign_time = time.time() 44 | 45 | # Keygen 46 | print_info("Starting Keygen") 47 | keygen_time = time.time() 48 | prover.keygen() 49 | EA = prover.export_public_key() 50 | print_info(f"Keygen took {time.time() - keygen_time:5f}") 51 | 52 | # Commitment 53 | print_info("Starting Commitment") 54 | commitment_time = time.time() 55 | E1 = prover.commitment() 56 | print_info(f"Commitment took {time.time() - commitment_time:5f}") 57 | 58 | # Challenge 59 | print_info("Starting Challenge") 60 | challenge_time = time.time() 61 | ϕ_ker = verifier.challenge(E1) 62 | print_info(f"Challenge took {time.time() - challenge_time:5f}") 63 | 64 | # Response 65 | print_info("Starting Response") 66 | response_time = time.time() 67 | S = prover.response(ϕ_ker) 68 | print_info(f"Response took {time.time() - response_time:5f}") 69 | 70 | # Verification 71 | print_info("Starting Verification") 72 | verify_time = time.time() 73 | response_valid = verifier.verify_response(EA, E1, S, ϕ_ker) 74 | 75 | # Check verification 76 | print_info(f"Verification took {time.time() - verify_time:5f}") 77 | assert response_valid, "SQISign response was not valid" 78 | print(f"INFO [SQISign]: SQISign was successful!") 79 | 80 | # All finished! 81 | print_info(f"SQISign took {time.time() - sqisign_time:5f}") 82 | 83 | pr.disable() 84 | pr.dump_stats("sqisign.cProfile") 85 | p = pstats.Stats('sqisign.cProfile') 86 | p.strip_dirs().sort_stats("cumtime").print_stats(250) 87 | -------------------------------------------------------------------------------- /benchmarks/benchmark_torsionbasis.py: -------------------------------------------------------------------------------- 1 | """ 2 | Run with: 3 | sage -python benchmarks/benchmark_torsionbasis.py 4 | If you want to skip debugging asserts, run 5 | sage -python -O benchmarks/benchmark_torsionbasis.py 6 | 7 | If you get an error about modules not being found 8 | you may have to edit the $PYTHONPATH variable. 9 | 10 | I fixed it by adding 11 | 12 | export PYTHONPATH=${PWD} 13 | 14 | To the file ~/.sage/sagerc 15 | """ 16 | 17 | # Python imports 18 | import cProfile 19 | import pstats 20 | import time 21 | 22 | # Local imports 23 | from isogenies import generate_point_order_D, torsion_basis 24 | from setup import * 25 | 26 | 27 | t_new = 0 28 | iteration = 50 29 | for _ in range(iteration): 30 | tmp = time.time() 31 | generate_point_order_D(E0, T_prime) 32 | t_new += time.time() - tmp 33 | 34 | t_new = t_new / iteration 35 | 36 | print(f"New time generate_point_order_D: {t_new:.5f}") 37 | 38 | t_new = 0 39 | iteration = 50 40 | for _ in range(iteration): 41 | tmp = time.time() 42 | torsion_basis(E0, T_prime) 43 | t_new += time.time() - tmp 44 | 45 | t_new = t_new / iteration 46 | 47 | print(f"New time torsion_basis: {t_new:.5f}") 48 | 49 | 50 | # cProfile.run("generate_point_order_D_old(E0, T_prime)", 'generate_point_order_D_old.cProfile') 51 | # p = pstats.Stats('generate_point_order_D_old.cProfile') 52 | # p.strip_dirs().sort_stats("cumtime").print_stats(int(10)) 53 | 54 | # cProfile.run("generate_point_order_D(E0, T_prime)", 'generate_point_order_D.cProfile') 55 | # p = pstats.Stats('generate_point_order_D.cProfile') 56 | # p.strip_dirs().sort_stats("cumtime").print_stats(int(10)) 57 | -------------------------------------------------------------------------------- /compression.py: -------------------------------------------------------------------------------- 1 | """ 2 | Functions for the compression and decompression of the 3 | response isogeny for SQISign. 4 | 5 | --- 6 | 7 | Note: 8 | 9 | For SQISign, the suggested compressed representation is 10 | 11 | S = S1 || s2 || S2 || ... || sv || Sv 12 | 13 | where Si are bit strings representing solutions to the 14 | kernels Ki = Ri + [Si]Qi for Ri,Qi ∈ Ei[D] and si are 15 | integers which hint to computing the Ri orthogonal to Qi 16 | in some deterministic manner. 17 | 18 | In order for there to always be kernels P + xQ, we need that 19 | the image of σ1(Q1) has full order. As σi have degrees of 20 | prime power, then we are guaranteed that either σ1(P1) or 21 | σ1(Q2) has full order, but not both. 22 | 23 | When σ1(Q1) has order != 2^f we perform a swap P,Q = Q,P 24 | ensuring that the kernel can be written as P + [x]Q. However, 25 | this has to be communicated to decompression, so we increase the 26 | size of the compression by 1-bit and append a "1" if we swapped 27 | the first torsion basis and "0" otherwise: 28 | 29 | S = swap_bit || S1 || s2 || S2 || ... || sv || Sv 30 | 31 | Note, as this fixed σ1(Q1) to have full order, and the kernel 32 | has K = P + [x]Q, then Qi is always orthogonal to Ki and σi(Qi) 33 | will always have full order. So we only need this swap bit for 34 | the first run, when we compute a random (but deterministic) torsion 35 | basis for EA[2^f]. 36 | """ 37 | 38 | # Sage imports 39 | from sage.all import ZZ 40 | from sage.schemes.elliptic_curves.hom_composite import EllipticCurveHom_composite 41 | 42 | # Local imports 43 | from isogenies import ( 44 | torsion_basis, 45 | generate_random_point, 46 | EllipticCurveIsogenyFactored, 47 | DLP 48 | ) 49 | from utilities import has_order_D 50 | 51 | # ========================================= # 52 | # Functions to pack an isogeny into blocks # 53 | # ========================================= # 54 | 55 | def isogeny_into_blocks(σ, block_size): 56 | """ 57 | This is a bit of a hack to deal with nested 58 | composite isogenies. 59 | 60 | The problem is, σ is of type EllipticCurveHom_composite 61 | which has as elements, other isogenies of type EllipticCurveHom_composite 62 | which contains prime degree isogenies and could also contain isomorphisms 63 | of degree 1. 64 | 65 | This function recursively looks for prime isogenies / morphisms 66 | and then puts them into blocks of at size block_size, then as 67 | output gives an array of isogenies of degree dividing block_size 68 | (Only the last element should have degree < block_size). 69 | """ 70 | 71 | def update_blocks(σ_block, σ_blocks): 72 | """ 73 | Take an array of isogenies of combined degree 74 | block_size and make a single composite isogeny 75 | """ 76 | σi = EllipticCurveHom_composite.from_factors(σ_block) 77 | σ_blocks.append(σi) 78 | return 1, [] 79 | 80 | def rec_prime_degree(σ, block_degree, σ_block, σ_blocks): 81 | """ 82 | Recursively look for prime degree isogenies and morphisms 83 | and update blocks when appropriate 84 | """ 85 | # Is the factor also a composite? 86 | for σi in σ.factors(): 87 | if isinstance(σi, EllipticCurveHom_composite): 88 | block_degree, σ_block, σ_blocks = rec_prime_degree( 89 | σi, block_degree, σ_block, σ_blocks 90 | ) 91 | 92 | else: 93 | σ_block.append(σi) 94 | block_degree *= σi.degree() 95 | if block_degree == block_size: 96 | block_degree, σ_block = update_blocks(σ_block, σ_blocks) 97 | 98 | return block_degree, σ_block, σ_blocks 99 | 100 | σ_blocks = [] 101 | σ_block = [] 102 | block_degree = 1 103 | 104 | block_degree, σ_block, σ_blocks = rec_prime_degree( 105 | σ, block_degree, σ_block, σ_blocks 106 | ) 107 | 108 | # Update for the last block 109 | if σ_block: 110 | update_blocks(σ_block, σ_blocks) 111 | 112 | # Make sure the blocks line up with the input 113 | assert σ_blocks[0].domain() == σ.domain() 114 | assert σ_blocks[-1].codomain() == σ.codomain() 115 | 116 | # Make sure the chain σv ∘ ... ∘ σ1 connects 117 | for v in range(len(σ_block) - 1): 118 | assert σ_blocks[v].codomain() == σ_blocks[v + 1].domain() 119 | 120 | return σ_blocks 121 | 122 | # ========================================= # 123 | # Convert between bit strings and data # 124 | # ========================================= # 125 | 126 | def bitstring_to_data(σ_compressed, f): 127 | """ 128 | Given a bit string of data in the following form: 129 | 130 | S = swap_bit || S1 || s2 || S2 || ... || sv || Sv 131 | 132 | Compute the swap_bit as an integer and two arrays 133 | of integers [S1 ... Sv] and [s2 ... sv] 134 | """ 135 | # Extract out the swap_bit and first dlog 136 | swap_and_S1, σ_compressed = σ_compressed[: f + 1], σ_compressed[f + 1 :] 137 | swap_bit, S1 = swap_and_S1[0], swap_and_S1[1:] 138 | 139 | # Arrays to store values as Integers 140 | swap_bit = ZZ(swap_bit, 2) 141 | dlogs = [ZZ(S1, 2)] 142 | hints = [] 143 | 144 | # Each chunk si || Si has 4 + f bits 145 | bit_count = 4 + f 146 | 147 | # TODO 148 | # I'm parsing the string twice here, which is annoying, 149 | # This could be made more efficient... 150 | # Split the bitstring into blocks of size (4 + f) 151 | siSi_bitstrings = [ 152 | σ_compressed[i : i + bit_count] for i in range(0, len(σ_compressed), bit_count) 153 | ] 154 | # Extract out si, Si and parse as integers 155 | for siSi_bits in siSi_bitstrings: 156 | si_bits, Si_bits = siSi_bits[:4], siSi_bits[4:] 157 | 158 | # Parse bitstrings to integers 159 | dlogs.append(ZZ(Si_bits, 2)) 160 | hints.append(ZZ(si_bits, 2)) 161 | 162 | return swap_bit, dlogs, hints 163 | 164 | 165 | def int_to_bitstring(x, n): 166 | """ 167 | Returns a bit string representing x 168 | of length exactly n by left padding 169 | with zeros. 170 | 171 | TODO: 172 | Assumes that x < 2^n. We could add a check 173 | for this. 174 | """ 175 | return bin(x)[2:].zfill(n) 176 | 177 | 178 | def hint_to_bitstring(hint): 179 | """ 180 | If the hint is smaller than 15, store 181 | the binary representation of hint. Else 182 | set the bit string to "1111", which 183 | communicates that the first 15 184 | generated points can be skipped. 185 | """ 186 | # Only use 4 bits, so the max hint is 187 | # 15 188 | hint = min(hint, 15) 189 | return int_to_bitstring(hint, 4) 190 | 191 | 192 | # ========================================= # 193 | # Helpers for compression and decompression # 194 | # ========================================= # 195 | 196 | def compute_R(E, Q, D, hint=None): 197 | """ 198 | Deterministically generate a point R linearly 199 | independent from Q. If `hint` is given, we can 200 | skip known bad points. 201 | 202 | Input: E an elliptic curves 203 | a point Q ∈ E[D] 204 | D the order of Q 205 | 206 | Output: A point R ∈ E[D] such that 207 | E[D] = 208 | A hint stating the number of 209 | iterations taken to find R 210 | """ 211 | # We need R to have order D, so 212 | # compute the cofactor n 213 | p = E.base().characteristic() 214 | n = (p**2 - 1) // D 215 | 216 | # If the hint is smaller than 2^k 217 | # then we know the `hint` point is 218 | # correct 219 | if hint is not None and hint < 15: 220 | return n * generate_random_point(E, seed=hint), None 221 | 222 | # If hint is not none, then we know we can 223 | # skip the first 15 values 224 | k_start = 0 225 | if hint is not None: 226 | k_start = 15 227 | 228 | for k in range(k_start, 2000): 229 | # Find a random point 230 | R = n * generate_random_point(E, seed=k) 231 | 232 | # Point is not in E[D] 233 | if R.is_zero() or not has_order_D(R, D): 234 | continue 235 | 236 | # We now have a point in E[D] 237 | # check it's linearly independent to Q 238 | pair = R.weil_pairing(Q, D, algorithm="pari") 239 | if has_order_D(pair, D, multiplicative=True): 240 | R._order = ZZ(D) 241 | return R, k 242 | 243 | raise ValueError(f"Never found a point Q") 244 | 245 | 246 | def compute_S_and_next_Q(σ, P, Q, f, first_step=False): 247 | """ 248 | Given a torsion basis P, Q finds x 249 | such that the kernel of σ is P + xQ 250 | 251 | Additionally computes S, which is 252 | the bitstring of fixed length f 253 | representing x and the image σ(Q) 254 | """ 255 | # Recover degree of isogeny 256 | D = σ.degree() 257 | 258 | # Map through points 259 | imP, imQ = σ(P), σ(Q) 260 | 261 | # For the first step, imQ may not have order D 262 | # which means there will be no solution to the 263 | # dlog. 264 | # 265 | # We deal with this by swapping P,Q (as D is a prime 266 | # power, imP has order D when imQ doesn't). 267 | # We must communicate to decompression that this swap 268 | # occurred, which we do with the `swap_bit`. 269 | swap_bit = 0 270 | 271 | if first_step and not has_order_D(imQ, D): 272 | print(f"DEBUG [compute_S_and_next_Q]: swapped the basis around") 273 | P, Q = Q, P 274 | imP, imQ = imQ, imP 275 | swap_bit = 1 276 | 277 | x = DLP(-imP, imQ, D) 278 | S = int_to_bitstring(x, f) 279 | 280 | # The isogeny with kernel will only be the same as 281 | # σ up to isomorphism. To ensure that Q_next is the 282 | # correct point, we must make σ_new from and then 283 | # re-evaluate σ_new(Q) to compute the image of a point 284 | # linearly independent from ker(σ_new). 285 | K = P + x * Q 286 | Eσ = K.curve() 287 | p = Eσ.base().characteristic() 288 | Eσ.set_order((p**2 - 1)**2, num_checks=0) 289 | σ_new = EllipticCurveIsogenyFactored(Eσ, K, order=D) 290 | 291 | # Now compute the new point Q_(i+1) 292 | Q_next = σ_new(Q) 293 | return S, Q_next, swap_bit 294 | 295 | # ============================= # 296 | # Compression and Decompression # 297 | # ============================= # 298 | 299 | def compression(E, σ, l, f): 300 | """ 301 | Given an isogeny σ of degree l^e = l^vf compute a 302 | compressed representation of the isogeny as a bit string 303 | in the form: 304 | 305 | σ_compressed = swap_bit || S1 || s2 || S2 || ... || sv || Sv 306 | 307 | Note: swap_bit denotes when the torsion basis of the first 308 | step must be swapped: R, Q = Q, R to ensure a solution to 309 | the discrete log. This bit is needed to communicate to decom. 310 | to do the same swap. 311 | """ 312 | σ_compressed = "" 313 | 314 | # Split σ into v isogenies of degree f 315 | σ_chain = isogeny_into_blocks(σ, l**f) 316 | assert E == σ_chain[0].domain(), "Supplied curve is incorrect" 317 | 318 | # Step 1, we need to compute the ker(σ1) and 319 | # a point linearly dependent ker(σ1) 320 | # To do this we need the torsion basis 321 | # E0[D]. 322 | σ1 = σ_chain[0] 323 | D = σ1.degree() 324 | 325 | R1, Q1 = torsion_basis(E, D, canonical=True) 326 | S1, Qi, swap_bit = compute_S_and_next_Q(σ1, R1, Q1, f, first_step=True) 327 | 328 | # Update compressed bitstring 329 | σ_compressed += str(swap_bit) 330 | σ_compressed += S1 331 | 332 | # For the remaining steps, we can use Qi as one 333 | # basis element so we only need to compute some 334 | # Ri linearly independent to Qi. There will always 335 | # be a solution to the dlog as Qi has order D. 336 | for σi in σ_chain[1:]: 337 | Ei = Qi.curve() 338 | 339 | # We need to align the next step in the chain 340 | # with σ_new computed previously, which means 341 | # mapping the domain of the next step with the 342 | # codomain of the last step. 343 | assert Ei.is_isomorphic(σi.domain()) 344 | iso = Ei.isomorphism_to(σi.domain()) 345 | σi = σi * iso 346 | assert Ei == σi.domain() 347 | 348 | D = σi.degree() 349 | # The last element of the chain has degree D | l^f 350 | # So we can ensure Qi ∈ E[D] by multiplying by a 351 | # cofactor 352 | if D != l**f: 353 | cofactor = l**f // D 354 | Qi = cofactor * Qi 355 | 356 | # Add hint to compression to help decompression 357 | # recover Ri 358 | Ri, hint = compute_R(Ei, Qi, D) 359 | σ_compressed += hint_to_bitstring(hint) 360 | 361 | # Add dlog to compression and derive next Qi 362 | Si, Qi, _ = compute_S_and_next_Q(σi, Ri, Qi, f) 363 | σ_compressed += Si 364 | 365 | # Our compressed rep. is 1 bit longer as we encode the swap bit 366 | v = len(σ_chain) 367 | assert len(σ_compressed) == (v - 1) * (f + 4) + f + 1 368 | 369 | return σ_compressed 370 | 371 | 372 | def decompression(E_start, E_end, σ_compressed, l, f, σ_length): 373 | """ 374 | Given a bit string: 375 | 376 | σ_compressed = swap_bit || S1 || s2 || S2 || ... || sv || Sv 377 | 378 | Compute the isogeny σ : E_start → E_end of degree l^σ_length isogeny 379 | """ 380 | # Extract integers from the encoded bitstring 381 | swap_bit, dlogs, hints = bitstring_to_data(σ_compressed, f) 382 | 383 | # Compute canonical torsion basis E[D] 384 | D = l**f 385 | Ri, Qi = torsion_basis(E_start, D, canonical=True) 386 | 387 | # In compression, if im(Q) does not have full order, 388 | # we swap R,Q so we can solve the discrete log. If 389 | # this happened, we need to also swap R,Q in decom. 390 | if swap_bit == 1: 391 | Qi, Ri = Ri, Qi 392 | 393 | σ_factors = [] 394 | Ei = E_start 395 | for Si, hint in zip(dlogs, hints): 396 | Ki = Ri + Si * Qi 397 | σi = EllipticCurveIsogenyFactored(Ei, Ki, order=D) 398 | σ_factors.append(σi) 399 | Ei = σi.codomain() 400 | Qi = σi(Qi) 401 | Ri, _ = compute_R(Ei, Qi, D, hint=hint) 402 | 403 | # The last step has length D | 2^f. 404 | # I can't see a way to derive it, but as the response length 405 | # is public, I think we just have to include it as a param... 406 | # Anyway... 407 | # When the last step != D, we need to clear the cofactor 408 | # to make sure Ri, Qi have order = σv.degree() 409 | last_step_length = σ_length % f 410 | if last_step_length != 0: 411 | cofactor = D // l**last_step_length 412 | Ri = cofactor * Ri 413 | Qi = cofactor * Qi 414 | D = l**last_step_length 415 | 416 | Si = dlogs[-1] 417 | Ki = Ri + Si * Qi 418 | σi = EllipticCurveIsogenyFactored(Ei, Ki, order=D) 419 | σ_factors.append(σi) 420 | 421 | σ = EllipticCurveHom_composite.from_factors(σ_factors) 422 | Eσ = σ.codomain() 423 | 424 | assert Eσ.is_isomorphic( 425 | E_end 426 | ), "The isogeny σ does not end at a curve isomorphic to E_end" 427 | # Use an isomorphism to ensure the codomain 428 | # of σ is E_end 429 | iso = Eσ.isomorphism_to(E_end) 430 | return iso * σ 431 | -------------------------------------------------------------------------------- /deuring.py: -------------------------------------------------------------------------------- 1 | """ 2 | Functions which implement the Deuring correspondence specialised for 3 | SQISign. 4 | 5 | The main functions which are used are: 6 | 7 | EvalEndomorphism(): Given an alg element α ∈ B_{p, ∞} compute the action 8 | α(P) for P ∈ E_0 using knowledge of mapping End(E0) and O0 9 | 10 | IdealToKernel(): Given an ideal I, compute the kernel generator K ∈ E 11 | such that ϕ_I : E / E ⟨K⟩. We follow ia.cr/2023/106 12 | for a more efficient algorithm than presented in SQISign, but 13 | include SQISign's impl. too. 14 | 15 | IdealToIsogenyCoprime(): Given two equivalent ideals J, K with coprime norm and the 16 | isogeny ϕK, compute ϕJ 17 | 18 | IdealToIsogenyFromKLPT(): Given an ideal I with norm l^* and left order O, the connecting 19 | ideal K with norm l^*, left order O0 and right order O and the 20 | corresponding isogeny ϕK, find the isogeny ϕI. 21 | 22 | Note: much of this function is handled iteratively by the 23 | function IdealToIsogenySmallFromKLPT() which does something 24 | similar, but for input I with norm dividing the available 25 | torsion. 26 | 27 | """ 28 | 29 | # Sage imports 30 | from sage.all import gcd, ZZ, factor, floor 31 | from sage.schemes.elliptic_curves.hom_composite import EllipticCurveHom_composite 32 | 33 | # Local imports 34 | from ideals import ( 35 | equivalent_left_ideals, 36 | left_isomorphism, 37 | chi, 38 | chi_inverse, 39 | is_cyclic, 40 | is_integral, 41 | multiply_ideals, 42 | ideal_generator, 43 | quaternion_basis_gcd, 44 | ideal_filtration 45 | ) 46 | from isogenies import ( 47 | torsion_basis, 48 | dual_isogeny_and_kernel, 49 | dual_isogeny, 50 | EllipticCurveIsogenyFactored, 51 | BiDLP 52 | ) 53 | from KLPT import EquivalentSmoothIdealHeuristic 54 | from mitm import meet_in_the_middle_with_kernel 55 | from utilities import has_order_D, print_info 56 | 57 | from setup import E0, O0, l, T, f_step_max, Δ, p, sqrt_minus_one, prime_norm_heuristic 58 | 59 | # ================================ # 60 | # Compute the action of End(E0) # 61 | # ================================ # 62 | 63 | def E01(P): 64 | """ 65 | Identity map, does nothing 66 | """ 67 | return P 68 | 69 | 70 | def E0ι(P): 71 | """ 72 | Returns ι(P) = (-x, √-1 y) 73 | """ 74 | if P.curve() != E0: 75 | raise ValueError("The endomorphism ι is defined on the curve E0") 76 | 77 | return E0(-P[0], sqrt_minus_one * P[1], P[2]) 78 | 79 | 80 | def E0π(P): 81 | """ 82 | Returns π(P) = (X^p, Y^p, Z^p) 83 | """ 84 | if P.curve() != E0: 85 | raise ValueError("The endomorphism π is defined on the curve E0") 86 | 87 | return E0(P[0] ** p, P[1] ** p, P[2] ** p) 88 | 89 | 90 | def E0ιπ(P): 91 | """ 92 | Returns ιπ(P) = (-X^p, √-1 Y^p, Z^p) 93 | """ 94 | if P.curve() != E0: 95 | raise ValueError("The endomorphism ιπ is defined on the curve E0") 96 | 97 | return E0(-P[0] ** p, sqrt_minus_one * P[1] ** p, P[2] ** p) 98 | 99 | # Store End(E0) action as an array 100 | EndE0 = [E01, E0ι, E0π, E0ιπ] 101 | 102 | # ===================== # 103 | # Evaluation of End(E) # 104 | # ===================== # 105 | 106 | def _check_connecting_isogenies(P, connecting_isogenies): 107 | """ 108 | Helper function for eval_endomorphism() 109 | 110 | Test whether the curves and isogenies are as expected 111 | 112 | If P ∈ E != E0 then first we map from E → E0 with ϕ_dual. 113 | This is achieved by supplying the optional argument 114 | connecting_isogenies = (ϕ, ϕ_dual) 115 | 116 | ϕ : E0 → E, ϕ_dual : E → E0 117 | 118 | Note: this feature is unused for SQISign, but may be 119 | useful in other circumstances. 120 | """ 121 | # Initialise empty values to handle when connecting_isogenies 122 | # is None 123 | ϕ, ϕ_dual = None, None 124 | 125 | # Curve of the input point 126 | E = P.curve() 127 | 128 | # Curve has unknown End(E) 129 | if E != E0: 130 | if connecting_isogenies is None: 131 | raise ValueError( 132 | f"To work on a curve E != E0, a connecting isogeny ϕ : E0 -> E must be known." 133 | ) 134 | 135 | # Check we have both the isogeny and its dual 136 | if len(connecting_isogenies) != 2: 137 | raise ValueError( 138 | "EvalEndomorphism requires both the connecting isogeny ϕ : E0 → E and its dual" 139 | ) 140 | 141 | # Check the domain and codomains line up 142 | ϕ, ϕ_dual = connecting_isogenies 143 | if ϕ.domain() != ϕ_dual.codomain() or ϕ.codomain() != ϕ_dual.domain(): 144 | raise ValueError( 145 | "The connecting isogeny ϕ : E0 → E is incompatible with supplied dual" 146 | ) 147 | 148 | if ϕ.domain() != E0: 149 | raise ValueError( 150 | "The connecting isogeny must have to domain of the curve E0 with known End(E0)" 151 | ) 152 | 153 | if ϕ.codomain() != E: 154 | raise ValueError( 155 | "The connecting isogeny must have to codomain of the supplied curve E" 156 | ) 157 | 158 | # Now, map the point P so it's on the curve E0 159 | P = ϕ_dual(P) 160 | return P, ϕ 161 | 162 | def eval_endomorphism(α, P, D, connecting_isogenies=None): 163 | """ 164 | Evaluates the action of an endomorphism 165 | f ∈ End(E0) on a point P ∈ E. 166 | 167 | If E is not E0, this can still be done, 168 | but we need to know the connecting isogeny 169 | ϕ : E → E0. 170 | """ 171 | # Verify connecting isogenies are correct, if present 172 | if connecting_isogenies: 173 | P, ϕ = _check_connecting_isogenies(P, connecting_isogenies) 174 | 175 | # Unpack the coefficients of the generator α, `d` is the lcm of the denominators 176 | # of the elements. 177 | d, *α_coeffs = α.denominator_and_integer_coefficient_tuple() 178 | 179 | # For integral ideals, we expect the denominator of elements to be at most 2 180 | assert d in (1, 2), "Something is wrong with the input ideal" 181 | if d == 2: 182 | # Divide out by two before evaluation if needed 183 | # TODO: we can avoid this with the Deuring friends paper trick 184 | P = P.division_points(d)[0] 185 | 186 | # Compute the image of α(P) 187 | P = sum(c * θ(P) for c, θ in zip(α_coeffs, EndE0)) 188 | 189 | # If P ∈ E ≠ E0 then we need to map back 190 | # from E0 to E using the connecting isogeny 191 | if connecting_isogenies: 192 | P = ϕ(P) 193 | return P 194 | 195 | 196 | # =============================== # 197 | # Ideal to Kernel Functions # 198 | # =============================== # 199 | 200 | def derive_cyclic_generator(P, Q, D): 201 | """ 202 | Given generators of a cyclic group 203 | of order D, find K such that G = 204 | 205 | Heuristically, it seems easy to randomly 206 | find a K this way, and is about 10x faster 207 | than the deterministic method as we do not 208 | need to compute the order of P or Q. 209 | """ 210 | K = P + Q 211 | for _ in range(1000): 212 | if has_order_D(K, D): 213 | return K 214 | K += Q 215 | raise ValueError(f"Never found a cyclic generator!") 216 | 217 | 218 | def ideal_to_kernel(E, I, connecting_isogenies=None): 219 | """ 220 | Given a supersingular elliptic curve E 221 | and a cyclic ideal I produces a generator 222 | P_I of E[I]. 223 | 224 | Optional: If E is not E0, we can still 225 | find a generator provided a connecting 226 | isogeny ϕ : E → E0. 227 | 228 | Implementation follows ia.cr/2023/106 229 | which directly computes the kernel E[I] from the 230 | action of α_bar, rather than computing the kernel 231 | via discrete logs from the action of α. 232 | 233 | ker(ϕ) = ⟨α_bar(P), α_bar(Q)⟩ for E[n(I)] = ⟨P,Q⟩ 234 | """ 235 | assert is_cyclic(I), "Input ideal is not cyclic" 236 | 237 | # Degree of the isogeny we will to compute 238 | D = ZZ(I.norm()) 239 | 240 | # Compute a generator such that I = O<α, D> 241 | α = ideal_generator(I) 242 | 243 | # Compute the torsion basis of E[D] 244 | P, Q = torsion_basis(E, D) 245 | 246 | # Evaluate R = α_bar(P) 247 | α_bar = α.conjugate() 248 | 249 | # If this has full order, we can stop here as R = α_bar(P) 250 | # generates the kernel 251 | R = eval_endomorphism(α_bar, P, D, connecting_isogenies=connecting_isogenies) 252 | if has_order_D(R, D): 253 | return R 254 | 255 | # Same again for S = α_bar(Q) 256 | S = eval_endomorphism(α_bar, Q, D, connecting_isogenies=connecting_isogenies) 257 | if has_order_D(S, D): 258 | return S 259 | 260 | # Neither R or S had full order, so we find a 261 | # linear combination of R, S which has order D 262 | return derive_cyclic_generator(R, S, D) 263 | 264 | 265 | # ========================================= # 266 | # SQISign Ideal to Isogeny Functions # 267 | # ========================================= # 268 | 269 | def IdealToIsogenyCoprime(J, K, ϕK): 270 | """ 271 | Input: Two equivalent left ideals J,K of O0 272 | where: J has norm dividing T^2 273 | K has norm l^∙ 274 | The isogeny ϕK : E0 → E0 / 275 | 276 | Output ϕJ : E0 → E0 / 277 | """ 278 | 279 | # Make sure the left orders are O0 280 | assert J.left_order() == O0 281 | assert K.left_order() == O0 282 | 283 | # Ensure the ϕK starts on E0 284 | assert ϕK.domain() == E0 285 | 286 | # Make sure the norms are correct 287 | nJ, nK = ZZ(J.norm()), ZZ(K.norm()) 288 | assert gcd(nJ, nK) == 1 289 | assert nJ.divides(T**2) 290 | assert nK % l == 0 291 | 292 | # Assert the orders are equivalent 293 | assert equivalent_left_ideals(J, K) 294 | 295 | # Compute the element α 296 | α = chi_inverse(K, J) 297 | assert J == chi(α, K) 298 | 299 | # Compute the ideals Hi 300 | H1 = J + O0 * T 301 | H2 = O0 * α + O0 * (nJ / H1.norm()) 302 | assert T**2 % H1.norm() == 0, "Norm of H1 does not divide T^2" 303 | assert T**2 % H2.norm() == 0, "Norm of H2 does not divide T^2" 304 | 305 | # Compute isogenies from Hi 306 | # ϕH1 : E0 → E1 = E0 /

307 | 308 | ϕH1_ker = ideal_to_kernel(E0, H1) 309 | ϕH1_ker_order = ZZ(H1.norm()) 310 | ϕH1 = EllipticCurveIsogenyFactored(E0, ϕH1_ker, order=ϕH1_ker_order) 311 | E1 = ϕH1.codomain() 312 | E1.set_order((p**2 - 1)**2, num_checks=0) 313 | 314 | # We only need the kernel of ϕH2 315 | ϕH2_ker = ideal_to_kernel(E0, H2) 316 | 317 | # Construct EK, the codomain of ϕK 318 | EK = ϕK.codomain() 319 | EK.set_order((p**2 - 1)**2, num_checks=0) 320 | 321 | # ψ: EK → EK / ϕK (ker ϕH2) 322 | ψ_ker = ϕK(ϕH2_ker) 323 | ψ_ker_order = ZZ(H2.norm()) 324 | ψ = EllipticCurveIsogenyFactored(EK, ψ_ker, order=ψ_ker_order) 325 | 326 | # Construct the curve Eψ which should be isomorphic to E1 327 | Eψ = ψ.codomain() 328 | Eψ.set_order((p**2 - 1)**2, num_checks=0) 329 | 330 | # Check Eψ is isomorphic to E1 331 | assert Eψ.is_isomorphic(E1) 332 | 333 | # Ensure the codomains match 334 | iso = Eψ.isomorphism_to(E1) 335 | ψ = iso * ψ 336 | ψ_dual = dual_isogeny(ψ, ψ_ker, order=H2.norm()) 337 | 338 | # ψ_dual * ϕH1 : E0 → E1 → EK ≃ EJ = E0 / 339 | ϕJ = ψ_dual * ϕH1 340 | 341 | assert ϕJ.domain() == ϕK.domain(), "ϕJ domain is wrong" 342 | assert ϕJ.codomain().is_isomorphic(ϕK.codomain()), "ϕJ codomain is wrong" 343 | assert ϕJ.degree() == nJ, "ϕJ degree is wrong" 344 | 345 | return ϕJ 346 | 347 | 348 | # ====================================================== # 349 | # IdealToIsogenySmallFromKLPT. Warning: Big function! # 350 | # ====================================================== # 351 | 352 | def IdealToIsogenySmallFromKLPT(I, J, K, ϕJ, ϕK, equivalent_prime_ideal=None): 353 | """ 354 | Input: I a left O0-ideal of norm dividing T^2 l^(2f+Δ), 355 | an O0-ideal in J containing I of norm dividing T^2, 356 | and an ideal K ∼ J of norm a power of l 357 | The isogenies ϕJ = E0 / and 358 | ϕK = E0 / 359 | 360 | Optional: I_prime allows us to speed up the KLPT 361 | algorithm for deriving L by including an 362 | ideal with small prime norm equivalent to 363 | I 364 | 365 | Output: ϕ = ϕ2 ◦ θ ◦ ϕ1 : E1 → E2 of degree l^(2f+Δ) such that 366 | ϕI = ϕ ◦ ϕJ, L ∼ I of norm dividing T^2, ϕL = E / . 367 | 368 | NOTE: There are some typos in the diagram / algorithm of the SQISign paper 369 | I hope these corrections help 370 | 371 | - Step 7, Nrd(γ) only has to divide T^2 l^(2f+Δ) n(K) 372 | - Step 9, using the Figure 4.1 we decompose ϕH2 as ψ2 ∘ ρ_dual_2 as the 373 | diagram shows ρ2 as the isogeny ρ2 : E5 → Eψ 374 | - Step 9, φ2 should be replaced with ρ_dual_2 375 | - Step 11, ψ1' is computed from [ρ_2 η]_* ψ1_dual, not [ϕ2_2 η]_* ψ1_dual 376 | 377 | 378 | We want to compute ϕ: E1 → E2 379 | We'll do this as: 380 | ϕ = ψ1' ∘ ρ2 ∘ η ∘ ψ1 ∘ ϕ1 381 | 382 | Figure 4 from SQISign 383 | 384 | ψ1' 385 | ┌────>Eψ ───────>E2 386 | │ ^ ^ 387 | │ │ρ2 │ϕ2 388 | │ │ │ 389 | │ E6 E4 390 | │ ^ ^ 391 | │ψ2 ┊η ┊θ 392 | │ ┊ ψ1 ┊ 393 | │ E5<────────E3 394 | │ ^ 395 | │ │ϕ1 396 | │ ϕJ │ 397 | E0══════════════>E1 398 | ϕK 399 | """ 400 | 401 | # ==================================== # 402 | # Ensure that the input is as expected 403 | # ==================================== # 404 | 405 | # Check I is as expected 406 | assert I.left_order() == O0 407 | assert T**2 * l ** (2 * f_step_max + Δ) % I.norm() == 0 408 | 409 | # Check J, ϕJ are as expected 410 | assert J.left_order() == O0 411 | assert ZZ(J.norm()).divides(T**2) 412 | assert ϕJ.degree() == J.norm() 413 | assert ϕJ.domain() == E0 414 | 415 | # Check K, ϕK are as expected 416 | assert K.left_order() == O0 417 | assert K.norm() % l == 0 or K.norm() == 1 418 | assert ϕK.degree() == K.norm() 419 | assert ϕK.domain() == E0 420 | 421 | # Make sure ϕJ and ϕK start and end up on isomorphic curves 422 | assert ϕJ.domain().is_isomorphic(ϕK.domain()) 423 | assert ϕJ.codomain().is_isomorphic(ϕK.codomain()) 424 | 425 | # ==================================================== # 426 | # Helper Functions for IdealToIsogenySmallFromKLPT # 427 | # ==================================================== # 428 | r""" 429 | derive_ϕ1(): Used to compute ϕ1 from I 430 | the isogeny ϕ1 : E1 → E3 431 | 432 | derive_L_and_gamma(): Given the ideals I,K,L 433 | compute the ideal L and γ. Runs 434 | until gcd(γ) = 2^k for k >= 0 435 | 436 | derive_isogenies_from_H1(), 437 | derive_isogenies_from_H2(): 438 | Given the ideals H1,H2 find the 439 | (factored) isogenies with Hi as 440 | the isogenies kernel. 441 | 442 | derive_final_pushforwards(): 443 | Given ψ1, ρ2_dual, and, η compute 444 | ψ1_prime, λ1 and λ3 used to derive 445 | the final isogenies. 446 | """ 447 | 448 | def derive_ϕ1(I, ϕJ, E1, step_size): 449 | """ 450 | Given an ideal I, compute an ideal I1 of 451 | norm l^f and then using the pushforward 452 | from ϕJ, compute a degree l^f isogeny 453 | ϕ1: E1 → E3 454 | 455 | """ 456 | # Compute the ideal I1 457 | I1 = I + O0 * l**step_size 458 | assert l**step_size % I1.norm() == 0 459 | 460 | # Compute the isogeny from ϕ1': E0 -> E0 / 461 | ϕ1_prime_ker = ideal_to_kernel(E0, I1) 462 | assert ( 463 | ϕ1_prime_ker.order() == l**step_size 464 | ), "The degree of the kernel ϕ1_prime_ker is incorrect" 465 | 466 | # Now we compute the push forward to compute 467 | # ϕ1 : E1 → E3 468 | # 469 | # Compute the isogeny ϕ1 : E1 → E3 470 | ϕ1_ker = ϕJ(ϕ1_prime_ker) 471 | ϕ1_ker_order = ZZ(I1.norm()) 472 | ϕ1 = EllipticCurveIsogenyFactored(E1, ϕ1_ker, order=ϕ1_ker_order) 473 | E3 = ϕ1.codomain() 474 | E3.set_order((p**2 - 1)**2, num_checks=0) 475 | assert ( 476 | ϕ1.degree().divides(l**f_step) 477 | ), f"Degree of {factor(ϕ1.degree()) = } does not divide {l^f_step}" 478 | return ϕ1, E1, E3 479 | 480 | def derive_L_and_gamma(I, J, K, step_size, equivalent_prime_ideal=None): 481 | """ 482 | Given ideals I,J,K find a prime norm ideal L equivalent to 483 | I and then compute γ, used to generate the ideals H1 and H2 484 | 485 | Optional: equivalent_prime_ideal allows us to supply an ideal 486 | I' equivalent to I with prime norm. 487 | """ 488 | # g keeps track of backtracking in γ 489 | g = -1 490 | while g != 1 and not (len(factor(g)) == 1 and g % l == 0): 491 | 492 | # If the ideal K has small l-valuation, we can send in M as a 493 | # power of two to KLPT and skip prime ideal generation 494 | if l ** (step_size + ZZ(K.norm()).valuation(l)) < prime_norm_heuristic: 495 | α = left_isomorphism(J, K) 496 | assert J * α == K 497 | 498 | # Send in an ideal with norm a power of two 499 | M = I * α 500 | L = EquivalentSmoothIdealHeuristic( 501 | M, 502 | T**2, 503 | equivalent_prime_ideal=equivalent_prime_ideal, 504 | near_prime=True, 505 | ) 506 | 507 | else: 508 | L = EquivalentSmoothIdealHeuristic( 509 | I, 510 | T**2, 511 | equivalent_prime_ideal=equivalent_prime_ideal, 512 | near_prime=True, 513 | ) 514 | if L is None: 515 | print( 516 | f"DEBUG: [IdealToIsogenySmallFromKLPT]" 517 | "EquivalentSmoothIdealHeuristic failed... trying again" 518 | ) 519 | continue 520 | nL = ZZ(L.norm()) 521 | assert nL.divides(T**2), "The norm of the ideal L does not divide T^2" 522 | 523 | # Compute the elements α,β 524 | 525 | # If K has norm 1 then ChiInverse(K, J) 526 | # will return i resulting in the kernel being 527 | # twisted by the automorphism E0ι. 528 | # To avoid this, manually set α = 1 529 | if K.norm() == 1: 530 | α = 1 531 | else: 532 | α = chi_inverse(K, J) 533 | β = chi_inverse(I, L) 534 | 535 | # Check we have the correct elements α, β 536 | assert J == chi(α, K) and α in K 537 | assert L == chi(β, I) and β in I 538 | 539 | # Compute gamma and check its properties 540 | nJ = J.norm() 541 | γ = (β * α) / nJ 542 | g = quaternion_basis_gcd(γ, O0) 543 | 544 | if g != 1: 545 | # g is a power of l, so we can easily correct 546 | # for backtracking by extending the mitm 547 | if g % l == 0 and len(factor(g)) == 1: 548 | # Make gamma primitive 549 | γ = γ / g 550 | else: 551 | # The gcd is bad, so we need to compute L again 552 | print( 553 | (f"DEBUG: [IdealToIsogenySmallFromKLPT]:" 554 | "gcd(γ) = {g}, edge case not currently supported, generating L again...") 555 | ) 556 | continue 557 | 558 | return L, nL, γ, g 559 | 560 | def derive_isogenies_from_H1(H1_odd, ϕ1, ϕK, E3): 561 | """ 562 | ϕH1 : E0 -> E5 563 | ϕH1 = ψ1 ◦ ϕ1 ◦ ϕK where ψ1 has degree T 564 | ϕK has degree nK 565 | ϕ1 has degree l^f_step 566 | 567 | ϕH1 = ϕodd ∘ ϕeven where ϕodd has degree T 568 | ϕeven has degree nK*l^f_step 569 | 570 | Our goal is to compute ψ1 571 | 572 | ϕeven 573 | E0 ─────────> Eeven 574 | │ │ 575 | │ │ 576 | ϕ1∘ϕk │ │ ϕodd 577 | │ │ 578 | │ │ 579 | v v 580 | E3 ─────────> E5 581 | ψ1 582 | 583 | """ 584 | print(f"INFO [IdealToIsogenySmallFromKLPT]: Computing isogenies from H1...") 585 | 586 | # First compute the kernel from the ideal 587 | ϕH1_ker_odd = ideal_to_kernel(E0, H1_odd) 588 | 589 | # Compute the pushforward 590 | ψ1_ker = ϕ1(ϕK(ϕH1_ker_odd)) 591 | ψ1_order = ZZ(H1_odd.norm()) 592 | 593 | ψ1 = EllipticCurveIsogenyFactored(E3, ψ1_ker, order=ψ1_order) 594 | E5 = ψ1.codomain() 595 | E5.set_order((p**2 - 1)**2, num_checks=0) 596 | return ψ1, ψ1_ker, ψ1_order, E5 597 | 598 | def derive_isogenies_from_H2(H2): 599 | """ 600 | Want to derive isogenies ψ2 : E0 → Eψ 601 | and ρ̂2 : Eψ → E6 from ϕH2 : E0 -> E6 where 602 | ϕH2 = ρ̂2 ◦ ψ2 where ψ2 has degree dividing T 603 | ρ̂2 has degree 2^f 604 | 605 | """ 606 | print(f"INFO [IdealToIsogenySmallFromKLPT]: Computing isogenies from H2...") 607 | # First compute the kernel from H2 608 | ϕH2_ker = ideal_to_kernel(E0, H2) 609 | H2_l_val = (H2.norm()).valuation(l) 610 | 611 | # Compute even and odd kernel orders 612 | ρ2_dual_order = l**H2_l_val 613 | ψ2_order = H2.norm() // ρ2_dual_order 614 | 615 | # Find the subgroup of order (dividing) T within ϕH2_ker 616 | ψ2_ker = ρ2_dual_order * ϕH2_ker 617 | 618 | # Compute the isogeny ψ2 and the curve Eψ 619 | # Note: Eψ is not named in the SQISign paper 620 | ψ2 = EllipticCurveIsogenyFactored(E0, ψ2_ker, order=ψ2_order) 621 | Eψ = ψ2.codomain() 622 | Eψ.set_order((p**2 - 1)**2, num_checks=0) 623 | 624 | # Compute the subgroup of order 2^f 625 | # on the curve Eψ by pushing through 626 | # a point of order 2^f on E0 627 | ρ2_dual_ker = ψ2_order * ϕH2_ker 628 | ρ2_dual_ker = ψ2(ρ2_dual_ker) 629 | 630 | # Compute the isogeny ρ̂2 : Eψ -> E6 631 | ρ2_dual = EllipticCurveIsogenyFactored(Eψ, ρ2_dual_ker, order=ρ2_dual_order) 632 | E6 = ρ2_dual.codomain() 633 | E6.set_order((p**2 - 1)**2, num_checks=0) 634 | 635 | # Check the end points all match up 636 | assert ψ2.domain() == E0 637 | assert ψ2.codomain() == ρ2_dual.domain() 638 | 639 | return ψ2, Eψ, ρ2_dual, ρ2_dual_ker, ρ2_dual_order, E6 640 | 641 | def derive_final_pushforwards( 642 | ψ1, ψ1_ker, ψ1_order, ρ2_dual, ρ2_dual_ker, ρ2_dual_order, η, η_ker, η_order 643 | ): 644 | """ 645 | Compute the pushforwards 646 | 647 | We derive two isogenies from the following 648 | isogeny square 649 | 650 | ψ1' = [ρ2 ∘ η]_* ψ1_dual 651 | = (ρ2 ∘ η) ker(ψ1_dual) 652 | 653 | ϕ2 ∘ θ = [ψ1_dual]_* ρ2 ∘ η 654 | = ψ1_dual(ker(ρ2 ∘ η)) 655 | 656 | We do not know ker(ρ2 ∘ η) so instead 657 | 658 | ϕ2 ∘ θ = λ3 * λ1 659 | 660 | Where: 661 | 662 | λ1 : E3 -> Eλ has kernel ψ1_dual(ker(η)) 663 | λ2 : E6 -> Eλ has kernel η(ker(ψ1_dual)) 664 | λ3 : Eλ -> E2 has kernel λ2(ker(ρ2)) 665 | 666 | ψ1' 667 | Eψ ───────────>E2<────┐ 668 | ^ ^ │ 669 | │ │ │ 670 | │ │ │ 671 | ρ2 │ │ λ3 │ ϕ2 672 | │ │ │ 673 | │ λ2 │ │ 674 | E6 ───────────>Eλ E4 675 | ^ ^ ^ 676 | ┊ │ ┊ 677 | η ┊ │ λ1 ┊ θ 678 | ┊ │ ┊ 679 | ┊ │ ┊ 680 | E5 ──────────> E3 ╌╌╌╌┘ 681 | ψ1_dual 682 | """ 683 | 684 | # Compute the dual of ψ1, ψ2, ρ2 together with a point generating 685 | # the dual isogeny's kernel 686 | print( 687 | f"INFO [IdealToIsogenySmallFromKLPT]: Computing the duals of ψ1 and ρ2_dual" 688 | ) 689 | ψ1_dual, ψ1_dual_ker = dual_isogeny_and_kernel(ψ1, ψ1_ker, order=ψ1_order) 690 | ρ2, ρ2_ker = dual_isogeny_and_kernel(ρ2_dual, ρ2_dual_ker, order=ρ2_dual_order) 691 | 692 | # As we know ψ1_dual_ker, this is easy 693 | # ψ1' = [ρ2 ∘ η]_* ψ1_dual 694 | print(f"INFO [IdealToIsogenySmallFromKLPT]: Computing the isogeny ψ1_prime") 695 | ψ1_prime_ker = ρ2(η(ψ1_dual_ker)) 696 | ψ1_prime = EllipticCurveIsogenyFactored(Eψ, ψ1_prime_ker, order=ψ1_order) 697 | 698 | # ϕ2 ∘ θ = [ψ1_dual]_* ρ2 ∘ η 699 | # We do not know the kernel of 700 | # (ρ2 ∘ η), so here's a work around 701 | 702 | # λ1 : E3 → Eλ 703 | λ1_ker = ψ1_dual(η_ker) 704 | print(f"INFO [IdealToIsogenySmallFromKLPT]: Computing the isogeny λ1") 705 | λ1 = EllipticCurveIsogenyFactored(E3, λ1_ker, order=η_order) 706 | Eλ = λ1.codomain() 707 | Eλ.set_order((p**2 - 1)**2, num_checks=0) 708 | 709 | # λ2 : E6 → Eλ 710 | λ2_ker = η(ψ1_dual_ker) 711 | print(f"INFO [IdealToIsogenySmallFromKLPT]: Computing the isogeny λ2") 712 | λ2 = EllipticCurveIsogenyFactored(E6, λ2_ker, order=ψ1_order) 713 | Eλ2 = λ2.codomain() 714 | iso = Eλ2.isomorphism_to(Eλ) 715 | λ2 = iso * λ2 716 | assert λ2.codomain() == Eλ 717 | 718 | # λ3 : Eλ → E2 719 | λ3_ker = λ2(ρ2_ker) 720 | print(f"INFO [IdealToIsogenySmallFromKLPT]: Computing the isogeny λ3") 721 | λ3 = EllipticCurveIsogenyFactored(Eλ, λ3_ker, order=ρ2_dual_order) 722 | 723 | return ψ1_prime, λ1, λ3 724 | 725 | # ======================================================== # 726 | # End Helper Functions for IdealToIsogenySmallFromKLPT # 727 | # ======================================================== # 728 | 729 | # Set ϕK to have the same codomain as ϕJ 730 | iso = ϕK.codomain().isomorphism_to(ϕJ.codomain()) 731 | ϕK = iso * ϕK 732 | assert ϕK.codomain() == ϕJ.codomain() 733 | 734 | # Accounts for last step where Δ may be smaller 735 | nI = I.norm() 736 | step_size = nI.valuation(l) 737 | 738 | f_step = min(f_step_max, floor(step_size / 2)) 739 | Δ_actual = max(step_size - 2 * f_step, 0) 740 | assert 2 * f_step + Δ_actual == step_size 741 | 742 | # Norms will be useful later 743 | nJ = ZZ(J.norm()) 744 | nK = ZZ(K.norm()) 745 | 746 | # First, find the domain of the isogeny ϕ1 747 | E1 = ϕJ.codomain() 748 | E1.set_order((p**2 - 1)**2, num_checks=0) 749 | assert E1.is_isomorphic(ϕK.codomain()), "ϕJ and ϕK do not end on the same curve!" 750 | 751 | # When the step size is small enough, we can skip the complicated 752 | # steps and directly derive the needed isogeny. This assumes that 753 | # this is performed as the last step in `IdealToIsogenyFromKLPT()` 754 | if step_size < f_step: 755 | ϕ1, _, _ = derive_ϕ1(I, ϕJ, E1, step_size) 756 | L = EquivalentSmoothIdealHeuristic(I, T**2) 757 | nL = ZZ(L.norm()) 758 | assert T**2 % nL == 0, "The norm of the ideal L does not divide T^2" 759 | 760 | # Early return 761 | return ϕ1, L, None 762 | 763 | # Derive ϕ1 764 | ϕ1, E1, E3 = derive_ϕ1(I, ϕJ, E1, f_step) 765 | 766 | # To continue, we first need to do some KLPT magic 767 | # Compute the ideal L equivalent to I with norm dividing T^2 768 | L, nL, γ, g = derive_L_and_gamma( 769 | I, J, K, step_size, equivalent_prime_ideal=equivalent_prime_ideal 770 | ) 771 | 772 | # Check γ is in the correct ideals 773 | assert g * γ in K and g * γ.conjugate() in L 774 | # Check γ has correct reduced norm 775 | assert γ.reduced_norm() == (nI * nL * nK // g**2) / (nJ) 776 | 777 | # Check γ has reduced norm that divides (nI T^2 nK) / nJ 778 | assert T**2 * l ** (2 * f_step + Δ_actual) * nK % γ.reduced_norm() == 0 779 | 780 | # Compute the ideals H1, H2 781 | # TODO: we can remove this, but we use 782 | # n(H1) when computing H2 783 | H1 = O0 * γ + O0 * (nK * l**f_step * T) 784 | 785 | # We only will need the odd part to compute ψ1 786 | H1_odd = O0 * γ + O0 * T 787 | 788 | # Note: 789 | # The algorithm in the paper states: 790 | # H2 = O0*γ.conjugate() + O0*l^f_step*T 791 | # But this is only true when 792 | # Nrd(γ) = T^2*l^(2*f_step+Δ)*nK 793 | # As γ will only divide this, we use the following: 794 | H2 = O0 * γ.conjugate() + O0 * (γ.reduced_norm() / ((l**Δ_actual * H1.norm()))) 795 | 796 | assert is_cyclic(H1), "H1 is not cyclic" 797 | # TODO: this bug sometimes appears. What is the cause? 798 | if not is_cyclic(H2): 799 | print(f"{is_integral(H1) = }") 800 | print(f"{is_integral(H2) = }") 801 | print(f"{factor(H1.norm()) = }") 802 | print(f"{factor(H2.norm()) = }") 803 | print(f"{factor(γ.conjugate().reduced_norm()) = }") 804 | 805 | ψ1, ψ1_ker, ψ1_order, E5 = derive_isogenies_from_H1(H1_odd, ϕ1, ϕK, E3) 806 | ψ2, Eψ, ρ2_dual, ρ2_dual_ker, ρ2_dual_order, E6 = derive_isogenies_from_H2(H2) 807 | 808 | # =============================== # 809 | # Meet in the middle step (😬) # 810 | # =============================== # 811 | 812 | # We expect there to be an isogeny degree l^Δ linking these. 813 | # However, if we had a non-trivial gcd, we have a little 814 | # further to brute-force 815 | gap = Δ_actual + 2 * ZZ(g.valuation(l)) 816 | η_order = l**gap 817 | print( 818 | f"DEBUG [IdealToIsogenySmallFromKLPT]: Attempting a mitm with: {factor(η_order)}" 819 | ) 820 | η, η_ker = meet_in_the_middle_with_kernel(E5, E6, l, gap) 821 | print( 822 | f"INFO [IdealToIsogenySmallFromKLPT]: Found the meet in the middle isogeny" 823 | ) 824 | 825 | # ================================= # 826 | # Compute the final pushforwards # 827 | # ================================= # 828 | 829 | ψ1_prime, λ1, λ3 = derive_final_pushforwards( 830 | ψ1, ψ1_ker, ψ1_order, ρ2_dual, ρ2_dual_ker, ρ2_dual_order, η, η_ker, η_order 831 | ) 832 | 833 | # Compute ϕ2θ : E3 → E2 834 | ϕ2θ = λ3 * λ1 835 | 836 | # ϕ : E1 → E2 837 | ϕ = ϕ2θ * ϕ1 838 | 839 | # ψ : E0 → E2 840 | ψ = ψ1_prime * ψ2 841 | 842 | # Do the curves start/end in the right spot 843 | assert ϕ.codomain().is_isomorphic( 844 | ψ.codomain() 845 | ), "ϕ and ψ do not end on isomorphic curves" 846 | assert ϕ.domain().is_isomorphic(ϕ1.domain()), "ϕ does not start on E1" 847 | assert ψ.domain().is_isomorphic(E0), "ψ does not start on E0" 848 | 849 | # Do the degrees / norms line up 850 | assert ψ.degree() == nL, "degree of ψ is incorrect" 851 | 852 | return ϕ, L, ψ 853 | 854 | # ======================= # 855 | # IdealToIsogenyFromKLPT # 856 | # ======================= # 857 | 858 | def IdealToIsogenyFromKLPT(I, K, ϕK, I_prime=None, K_prime=None, end_close_to_E0=False): 859 | """ 860 | Computes the isogeny ϕI whose kernel corresponds to the ideal I. 861 | 862 | Input: A left O-ideal I of norm a power of l, 863 | K a left O0-ideal and right O-ideal of norm l^• 864 | The corresponding ϕK : E / . 865 | 866 | Optional: 867 | I_prime: an ideal equivalent to I with small prime norm 868 | K_prime: an ideal equivalent to K with small prime norm 869 | 870 | Explanation: 871 | If we know an ideal with small prime norm which is equivalent 872 | to I or K we can speed up this algorithm by skipping part of 873 | the KLPT step inside IdealToIsogenySmallFromKLPT or 874 | IdealToIsogenyCoprime respectively. 875 | 876 | Output: ϕI : E / 877 | """ 878 | # Ensure the norms are as expected 879 | assert I.norm() % l == 0 or I.norm() == 1 880 | assert K.norm() % l == 0 or K.norm() == 1 881 | 882 | # Ensure the orders are as expected 883 | assert I.left_order() == K.right_order() 884 | assert K.left_order() == O0 885 | 886 | # If we supply equivalent_prime_ideal, make sure it 887 | # is of the right form 888 | if I_prime: 889 | assert equivalent_left_ideals( 890 | I, I_prime 891 | ), "Input I_prime is not equivalent to I" 892 | assert ZZ(I_prime.norm()).is_prime(), "Input I_prime does not have prime order" 893 | 894 | if K_prime: 895 | assert equivalent_left_ideals( 896 | K, K_prime 897 | ), "Input K_prime is not equivalent to I" 898 | assert ZZ(K_prime.norm()).is_prime(), "Input K_prime does not have prime order" 899 | 900 | # =============================================== # 901 | # Helper Functions for IdealToIsogenyFromKLPT # 902 | # =============================================== # 903 | 904 | def derive_J_and_phi_J(K, ϕK, K_prime=None): 905 | """ 906 | Given a connecting ideal K and corresponding isogeny 907 | compute an equivalent ideal J with norm coprime to 908 | K and the equivalent isogeny ϕJ 909 | 910 | Optional: K_prime is an equivalent ideal to K with 911 | prime norm 912 | """ 913 | # In keygen we send 914 | # - K = O0.unit_ideal() 915 | # - ϕK = E0.isogeny(E0(0)) 916 | # Which allows us to set 'J' to be the unit 917 | # ideal, and ϕJ to be a trivial isogeny too 918 | if K.norm() == 1: 919 | J = O0.unit_ideal() 920 | ϕJ = E0.isogeny(E0(0)) 921 | return J, ϕJ 922 | 923 | # Sometimes we already know an equivalent prime 924 | # norm ideal, so we use this and save some time 925 | # within KLPT 926 | if K_prime: 927 | # TODO: is there any point in lopping this?? 928 | for _ in range(10): 929 | J = EquivalentSmoothIdealHeuristic( 930 | K, T**2, equivalent_prime_ideal=K_prime 931 | ) 932 | if J: 933 | break 934 | ϕJ = IdealToIsogenyCoprime(J, K, ϕK) 935 | return J, ϕJ 936 | 937 | # Generic case 938 | # Compute a smooth norm ideal J 939 | J = None 940 | for _ in range(10): 941 | J = EquivalentSmoothIdealHeuristic(K, T**2) 942 | if J is not None: 943 | break 944 | 945 | if J is None: 946 | exit("Was unable to compute an equivalent ideal J ~ K") 947 | 948 | # Compute the isogeny ϕJ : E0 / 949 | ϕJ = IdealToIsogenyCoprime(J, K, ϕK) 950 | 951 | assert equivalent_left_ideals(J, K) 952 | return J, ϕJ 953 | 954 | # ==================== # 955 | # End Helper Functions # 956 | # ==================== # 957 | 958 | J, ϕJ = derive_J_and_phi_J(K, ϕK, K_prime=K_prime) 959 | 960 | # Compute a chain: 961 | # I = Iv ⊂ ... ⊂ I1 ⊂ I0 962 | # I_short is the quotient of these elements 963 | I_short = ideal_filtration( 964 | I, l, (2 * f_step_max + Δ), small_step_first=end_close_to_E0 965 | ) 966 | 967 | # Create a list to store the isogeny factors 968 | ϕi_factors = [] 969 | 970 | # For the last step, we can use JIi_prime = I_prime 971 | # To avoid problems computing equivalent prime norm ideals 972 | JIi_prime = None 973 | 974 | # First element in Iv is the unit ideal of O 975 | # We don't need this. 976 | for ind, Ii in enumerate(I_short[1:], start=1): 977 | print_info( 978 | f"STARTING WITH ELEMENT {ind}/{len(I_short) - 1} OF FILTRATION CHAIN...", 979 | banner="-" 980 | ) 981 | # On last loop, use the trick that we know an equivalent 982 | # prime norm ideal 983 | if ind == len(I_short) - 1: 984 | JIi_prime = I_prime 985 | 986 | # J * Ii 987 | alpha = left_isomorphism(K, J) # K*α = J 988 | JIi = multiply_ideals(J, Ii, beta=alpha) 989 | 990 | # Compute 991 | # ϕ = ϕ2 ◦ θ ◦ ϕ1 : E1 → E2 of degree l^(2f+Δ) such that ϕJIi = ϕ ◦ ϕJ, 992 | # J ∼ JIi of norm dividing T^2, 993 | # ϕJ = E / (output J, not input J) 994 | 995 | ϕi, J, ϕJ = IdealToIsogenySmallFromKLPT( 996 | JIi, J, K, ϕJ, ϕK, equivalent_prime_ideal=JIi_prime 997 | ) 998 | 999 | # Add the isogeny ϕi to the list of factors 1000 | # May need to correct it with an isomorphism 1001 | if ind > 1: 1002 | ϕi_prev = ϕi_factors[ind - 2] 1003 | ι = ((ϕi_prev).codomain()).isomorphism_to(ϕi.domain()) 1004 | ϕi = ϕi * ι 1005 | ϕi_factors.append(ϕi) 1006 | 1007 | # If we're not in the last loop 1008 | if ind != len(I_short) - 1: 1009 | # Update the ideal K and the isogeny ϕK 1010 | K = K * Ii 1011 | ϕK = ϕi * ϕK 1012 | 1013 | ϕI = EllipticCurveHom_composite.from_factors(ϕi_factors) 1014 | 1015 | return ϕI 1016 | 1017 | 1018 | # ============================================ # 1019 | # Functions for Kernel to Isogeny Functions # 1020 | # ============================================ # 1021 | 1022 | 1023 | def compute_coprime_basis(D): 1024 | """ 1025 | Start with basis <1, i, (i + j) / 2, (1 + k) / 2> 1026 | and find a new basis such that the norm of each basis 1027 | element is coprime to `D`. 1028 | 1029 | TODO: is this the best method? 1030 | """ 1031 | O0_basis = O0.basis() 1032 | θs = [] 1033 | for f in O0_basis: 1034 | while True: 1035 | if gcd(f.reduced_norm(), D) == 1: 1036 | θs.append(f) 1037 | break 1038 | f += O0_basis[0] + O0_basis[1] 1039 | return θs 1040 | 1041 | 1042 | def find_torsion_basis_EndE(θPs, D): 1043 | """ 1044 | Looks for θi, θj such that θi(P), θj(P) generates E[D] 1045 | """ 1046 | for i in range(4): 1047 | for j in range(i + 1, 4): 1048 | eθiθj = θPs[i].weil_pairing(θPs[j], D, algorithm="pari") 1049 | if has_order_D(eθiθj, D, multiplicative=True): 1050 | return i, j 1051 | raise ValueError(f"No basis for E[D] found with given point") 1052 | 1053 | 1054 | def kernel_to_ideal(P, D, connecting_isogenies=None): 1055 | """ 1056 | Given a point P ∈ E[D] compute the 1057 | ideal I(

)) 1058 | 1059 | Optional: If E is not E0, we can still 1060 | find an ideal provided a connecting 1061 | isogeny ϕ : E → E0. 1062 | """ 1063 | # Compute a basis β1,β2,β3,β4 of O0 1064 | # with norm coprime to D 1065 | βs = compute_coprime_basis(D) 1066 | 1067 | # Compute the image of all the points 1068 | # β(P) by acting with θ ≅ β 1069 | θs = [eval_endomorphism(β, P, D, connecting_isogenies=connecting_isogenies) for β in βs] 1070 | 1071 | # Find θi, θj which generates E[D] 1072 | i, j = find_torsion_basis_EndE(θs, D) 1073 | θi, θj = θs[i], θs[j] 1074 | 1075 | # Pick k ≠ i,j such that 1076 | k = set([0, 1, 2, 3]).difference([i, j]).pop() 1077 | θk = θs[k] 1078 | 1079 | # Solve the discrete log 1080 | a, b = BiDLP(θk, θi, θj, D) 1081 | assert a * θi + b * θj == θk 1082 | 1083 | # Create the Quaternion Algebra element 1084 | α = βs[k] - a * βs[i] - b * βs[j] 1085 | return O0 * α + O0 * D 1086 | -------------------------------------------------------------------------------- /example_SQISign.sage: -------------------------------------------------------------------------------- 1 | """ 2 | Example of SQISign as a one-round interactive identification 3 | protocol. 4 | 5 | We imagine two parties, `prover` and `verifier`. The `prover` 6 | demonstrates knowledge of the endomorphism ring End(EA) in the 7 | following way: 8 | 9 | - The prover's public key is an elliptic curve EA, and their secret 10 | is the isogeny E0 → EA, where End(E0) is known to everyone. 11 | 12 | - The prover then makes a commitment by computing a second secret 13 | isogeny ψ : E0 → E1 and sends the codomain to the verifier. 14 | 15 | - The verifier makes a challenge ϕ: E1 → E2 and sends ϕ to the prover 16 | 17 | - The prover responds with σ : EA → E2, which is done via knowledge 18 | of End(EA) (through knowing End(E0) and τ : E0 → EA). The prover 19 | sends σ to the verifier. 20 | 21 | - If ϕ_dual ∘ σ : EA → E1 is a cyclic isogeny, the verifier returns 22 | true and false otherwise 23 | """ 24 | 25 | # Python imports 26 | import time 27 | 28 | # Local imports 29 | from SQISign import SQISign 30 | from utilities import print_info 31 | 32 | # SQISign is a protocol between a prover and verifier 33 | prover = SQISign() 34 | verifier = SQISign() 35 | 36 | print_info("Starting SQISign") 37 | sqisign_time = time.time() 38 | 39 | # The prover generates their keypair and makes a commitment 40 | # which is a secret isogeny ψ : E0 → E1 and sends the codomain 41 | # of ψ to the verifier 42 | print_info("Computing Keypair") 43 | prover.keygen() 44 | EA = prover.export_public_key() 45 | 46 | print_info("Computing Commitment") 47 | E1 = prover.commitment() 48 | 49 | # The verifier receives the commitment and makes a random 50 | # challenge. Rather than sending the isogeny, the verifier 51 | # simply sends the generator of the isogeny ϕ : E1 → E2 52 | print_info("Computing Challenge") 53 | phi_ker = verifier.challenge(E1) 54 | 55 | # The verifier makes a response to the challenge, which is 56 | # an isogeny σ : EA → E2 of degree l^e. The prover compresses 57 | # σ to a bitstring S and sends this to the verifier 58 | print_info("Computing Response") 59 | S = prover.response(phi_ker) 60 | 61 | # The verifier uses the prover's public key and their response 62 | # S and checks if σ is an isogeny EA → E2 of degree l^e and 63 | # whether ϕ_dual ∘ σ : EA → E1 is cyclic 64 | print_info("Validating response") 65 | valid = verifier.verify_response(EA, E1, S, phi_ker) 66 | 67 | print_info(f"SQISign example worked: {valid}") 68 | print_info(f"SQISign took {time.time() - sqisign_time:5f}") 69 | -------------------------------------------------------------------------------- /example_signing.sage: -------------------------------------------------------------------------------- 1 | """ 2 | Example usage of signing a message using SQISign. 3 | 4 | We imagine a two-party protocol, where a `signer` takes 5 | some message and signs it with the private key. A verifier 6 | then requests the public key EA and verifies the signature 7 | which is a tuple sig = E1, S. Where E1 is the commitment 8 | codomain and S is the compressed bitstring corresponding 9 | to the response isogeny σ : EA → E2. 10 | 11 | The challenge isogeny ϕ : E1 → E2 is derived from the msg 12 | by both the signer and verifier and so does not need to be 13 | sent between parties. 14 | """ 15 | 16 | # Python imports 17 | import time 18 | 19 | # Local imports 20 | from SQISign import SQISign 21 | from utilities import print_info 22 | 23 | 24 | # SQISign is a protocol between a signer and verifier 25 | signer = SQISign() 26 | verifier = SQISign() 27 | 28 | # Message that we want to sign 29 | msg = b"Learning to SQI!" 30 | 31 | print_info("Starting SQISign Signing") 32 | sqisign_time = time.time() 33 | 34 | # The signer generates a keypair and sends 35 | # their public key to the verifier 36 | print_info("Computing Keypair") 37 | signer.keygen() 38 | EA = signer.export_public_key() 39 | 40 | # Given a message, the signer makes a commitment and sends 41 | # the codomain of the commitment isogeny ψ : E0 → E1. Then, 42 | # a challenge is derived deterministically from the message 43 | # and a response is made to this challenge 44 | print_info("Signing Message") 45 | sig = signer.sign(msg) 46 | 47 | # The verifier deterministically creates the same challenge 48 | # and then validates the signature S 49 | print_info("Validating signature") 50 | valid = verifier.verify(EA, sig, msg) 51 | 52 | print_info(f"Signing example worked: {valid}") 53 | print_info(f"Signing took {time.time() - sqisign_time:5f}") 54 | -------------------------------------------------------------------------------- /ideals.py: -------------------------------------------------------------------------------- 1 | """ 2 | Helper functions for various computations associated to 3 | the quaternion algebra, and ideals and orders of the 4 | quaternion algebra. 5 | 6 | Some of these functions could be ported to SageMath. It's a TODO 7 | for when SQISign is being less actively worked on. 8 | """ 9 | 10 | # Sage Imports 11 | from sage.all import ( 12 | factor, 13 | randint, 14 | ZZ, 15 | round, 16 | ceil, 17 | log, 18 | gcd, 19 | Matrix, 20 | vector, 21 | prod, 22 | ) 23 | 24 | # Local imports 25 | from setup import p, O0, ω 26 | from utilities import cornacchia_friendly 27 | 28 | # ================================================ # 29 | # Helpers for elements of the quaternion algebra # 30 | # ================================================ # 31 | 32 | def quadratic_norm(x, y): 33 | """ 34 | Given two integers x,y, which correspond 35 | to the element x + ωy ∈ R[ω], returns 36 | Nrd(x + ωy) 37 | 38 | 39 | Note: This function implements the norm 40 | function f(x,y) in the SQISign papers. 41 | 42 | For SQISign, we have ω = i and i^2 = -1 43 | so f(x,y) = x**2 + y**2 which is more 44 | efficient 45 | """ 46 | if ω**2 != -1: 47 | raise ValueError(f"quadratic_norm() requires Z[ω] with ω^2 = -1. {ω = }") 48 | return ZZ(x) ** 2 + ZZ(y) ** 2 49 | 50 | 51 | def quaternion_change_basis(γ, O): 52 | """ 53 | Computes the coefficients of a quaternion γ 54 | in the basis of a given order O 55 | """ 56 | O_matrix = Matrix([b.coefficient_tuple() for b in O.basis()]) 57 | γ_vector = vector(γ.coefficient_tuple()) 58 | γO_coeffs = γ_vector * O_matrix.inverse() 59 | 60 | assert γ == sum([a * b for a, b in zip(γO_coeffs, O.basis())]) 61 | return γO_coeffs 62 | 63 | 64 | def quaternion_basis_gcd(γ, O): 65 | """ 66 | Computes the gcd of the coefficients of a 67 | quaternion γ in the basis of a given order O 68 | """ 69 | γO_coeffs = quaternion_change_basis(γ, O) 70 | return gcd(γO_coeffs) 71 | 72 | # ============================================== # 73 | # Helpers for ideals of the quaternion algebra # 74 | # ============================================== # 75 | 76 | def multiply_ideals(I, J, beta=None): 77 | """ 78 | Computes I*J when O_R(I) ≃ O_L(J) 79 | 80 | If these orders do not match, we must provide 81 | an isomorphism which takes O_L(J) to O_R(I) 82 | """ 83 | if I.right_order() != J.left_order(): 84 | if beta is None: 85 | raise ValueError( 86 | "Right and left orders, do not match. Must supply an automorphism, beta" 87 | ) 88 | 89 | J = beta ** (-1) * J * beta 90 | assert I.right_order() == J.left_order(), "Orders still do not match after applying automorphism" 91 | return I * J 92 | 93 | 94 | def is_integral(I): 95 | """ 96 | Checks whether the input ideal is integral. 97 | """ 98 | return all([b in I.left_order() for b in I.basis()]) 99 | 100 | 101 | def ideal_basis_gcd(I): 102 | """ 103 | Computes the gcd of the coefficients of 104 | the ideal written as a linear combination 105 | of the basis of its left order. 106 | """ 107 | I_basis = I.basis_matrix() 108 | O_basis = I.left_order().unit_ideal().basis_matrix() 109 | 110 | # Write I in the basis of its left order 111 | M = I_basis * O_basis.inverse() 112 | return gcd((gcd(M_row) for M_row in M)) 113 | 114 | 115 | def is_cyclic(I): 116 | """ 117 | Computes whether the input ideal is cyclic, 118 | all the work is done by the helper function 119 | `ideal_basis_gcd()`. 120 | """ 121 | return ideal_basis_gcd(I) == 1 122 | 123 | 124 | def remove_2_endo(J): 125 | """ 126 | Helper function for `make_cyclic`. Not currently 127 | useful, but will be when we need to do SQISign2. 128 | """ 129 | i, _, _ = J.quaternion_algebra().gens() 130 | two_endo = O0.left_ideal([i + 1, 2]) 131 | while all(b in two_endo for b in J.basis()): 132 | J = J * J.right_order([(i - 1) / 2]) 133 | return J 134 | 135 | 136 | def make_cyclic(I, full=False): 137 | """ 138 | Given an ideal I, returns a cyclic ideal by dividing 139 | out the scalar factor g = ideal_basis_gcd(I) 140 | """ 141 | g = ideal_basis_gcd(I) 142 | # Ideal was already cyclic 143 | if g == 1: 144 | return I, g 145 | 146 | print(f"DEBUG [make_cyclic]: Ideal is not cyclic, removing scalar factor: {g = }") 147 | J = I.scale(1/g) 148 | 149 | if full: 150 | # TODO: will remove_2_endo change g? 151 | # not an issue currently, as we don't 152 | # use this. 153 | return remove_2_endo(J), g 154 | return J, g 155 | 156 | 157 | def reduced_basis(I, check=False): 158 | """ 159 | Computes the Minkowski reduced basis of the 160 | input ideal. Note: this produces the same 161 | result for all ideals in the equivalence class 162 | so corresponds to the reduced basis of the 163 | smallest equivalent ideal to I 164 | 165 | Input: an ideal 166 | Output: A Minkowski-reduced basis 167 | 168 | Optional: when check is True, the basis is 169 | checked against the Minkowski bounds 170 | """ 171 | 172 | def _matrix_to_gens(M, B): 173 | """ 174 | Converts from a matrix to generators in the quat. 175 | algebra 176 | """ 177 | return [sum(c * g for c, g in zip(row, B)) for row in M] 178 | 179 | B = I.basis() 180 | G = I.gram_matrix() 181 | U = G.LLL_gram().transpose() 182 | 183 | reduced_basis_elements = _matrix_to_gens(U, B) 184 | 185 | if check: 186 | norm_product = 16 * prod([x.reduced_norm() for x in reduced_basis_elements]) 187 | tmp = p**2 * I.norm() ** 4 188 | assert norm_product <= 4 * tmp, "Minkowski reduced basis is too large" 189 | assert norm_product >= tmp, "Minkowski reduced basis is too small" 190 | 191 | return reduced_basis_elements 192 | 193 | 194 | def small_equivalent_ideal(I, reduced_basis_elements=None): 195 | """ 196 | Computes the Minkowski reduced basis of the 197 | ideal and returns the smallest equivalent 198 | ideal J = Iα ~ I. 199 | """ 200 | nI = I.norm() 201 | 202 | if not reduced_basis_elements: 203 | reduced_basis_elements = reduced_basis(I) 204 | 205 | b0 = reduced_basis_elements[0] 206 | if b0.reduced_norm() == nI**2: 207 | return I 208 | return I * I.right_order().left_ideal([b0.conjugate() / nI]) 209 | 210 | 211 | def equivalent_left_ideals(I, J): 212 | """ 213 | SageMath has this impl. for right ideals 214 | only. To work around this, we can first 215 | take the conjugate of I and J. 216 | 217 | TODO: write a function which does this without 218 | conjugates? 219 | """ 220 | return I.conjugate().is_equivalent(J.conjugate()) 221 | 222 | 223 | def equivalent_right_ideals(I, J): 224 | """ 225 | Sage can do this for us already only making a 226 | wrapped so it matches the above. 227 | """ 228 | return I.is_equivalent(J) 229 | 230 | 231 | def invert_ideal(I): 232 | """ 233 | Computes the inverse of the ideal which is 234 | the conjugate of I divided by its norm 235 | """ 236 | return I.conjugate().scale(1 / I.norm()) 237 | 238 | 239 | def left_isomorphism(I, J): 240 | """ 241 | Given two isomorphic left ideals I, J computes 242 | α such that J = I*α 243 | """ 244 | B = I.quaternion_algebra() 245 | 246 | if B != J.quaternion_algebra(): 247 | raise ValueError("Arguments must be ideals in the same algebra.") 248 | 249 | if I.left_order() != J.left_order(): 250 | raise ValueError("Arguments must have the same left order.") 251 | 252 | IJ = I.conjugate() * J 253 | L = reduced_basis(IJ) 254 | for t in L: 255 | α = t / I.norm() 256 | if J == I * α: 257 | return α 258 | 259 | raise ValueError("Could not find a left isomorphism...") 260 | 261 | 262 | def chi(a, I): 263 | r""" 264 | From section 3.2 in Antonin's thesis. 265 | Calculates the equivalent ideal of I, of norm(a) 266 | Based on the surjection from I \ {0} to the set of equivalent ideals of I 267 | Obtained by a → I * (a_bar / n(I)) 268 | """ 269 | return I * (a.conjugate() / I.norm()) 270 | 271 | 272 | def chi_inverse(I, J): 273 | """ 274 | Computes the element α such that 275 | 276 | J = Chi(α, I) 277 | """ 278 | # Compute the element α 279 | a = left_isomorphism(I, J) 280 | assert J == I * a, "Left isomorphism to find the element 'a' failed... why?" 281 | α = (a * I.norm()).conjugate() 282 | assert J == chi(α, I), "Something about α is wrong!" 283 | return α 284 | 285 | 286 | def scaled_norm(a, I): 287 | """ 288 | Returns Nrd(a) / n(I), the norm of chi(a, I) 289 | """ 290 | N = I.norm() 291 | return a.reduced_norm() / N 292 | 293 | 294 | def ideal_generator(I, coprime_factor=1): 295 | """ 296 | Given an ideal I of norm D, finds a generator 297 | α such that I = O(α,D) = Oα + OD 298 | 299 | Optional: Enure the norm of the generator is coprime 300 | to the integer coprime_factor 301 | """ 302 | OI = I.left_order() 303 | D = ZZ(I.norm()) 304 | bound = ceil(4 * log(p)) 305 | 306 | gcd_norm = coprime_factor * D**2 307 | 308 | # Stop infinite loops. 309 | for _ in range(1000): 310 | α = sum([b * randint(-bound, bound) for b in I.basis()]) 311 | if gcd(ZZ(α.reduced_norm()), gcd_norm) == D: 312 | assert I == OI * α + OI * D 313 | return α 314 | raise ValueError(f"Cannot find a good α for D = {D}, I = {I}, n(I) = {D}") 315 | 316 | 317 | def eichler_order_from_ideal(I): 318 | """ 319 | The Eichler order is the intersection 320 | of two orders. 321 | 322 | Given an ideal I, we compute the Eichler 323 | order ℤ + I from the intersection of the 324 | left and right orders of I 325 | 326 | Proposition 1 (SQISign paper): 327 | 328 | EichlerOrder = O0 ∩ O = OL(I) ∩ OR(I) = ℤ + I. 329 | """ 330 | return I.left_order().intersection(I.right_order()) 331 | 332 | 333 | # ========================================= # 334 | # Pushforward and Pullback of ideals to O0 # 335 | # ========================================= # 336 | 337 | def pullback_ideal(O0, O1, I, Iτ): 338 | """ 339 | Input: Ideal I with left order O1 340 | Connecting ideal Iτ with left order O0 341 | and right order O1 342 | Output The ideal given by the pullback [Iτ]^* I 343 | """ 344 | assert I.left_order() == O1 345 | assert Iτ.left_order() == O0 346 | assert Iτ.right_order() == O1 347 | 348 | N = ZZ(I.norm()) 349 | Nτ = ZZ(Iτ.norm()) 350 | 351 | α = ideal_generator(I) 352 | return O0 * N + O0 * α * Nτ 353 | 354 | 355 | def pushforward_ideal(O0, O1, I, Iτ): 356 | """ 357 | Input: Ideal I left order O0 358 | Connecting ideal Iτ with left order O0 359 | and right order O1 360 | Output The ideal given by the pushforward [Iτ]_* I 361 | """ 362 | assert I.left_order() == O0 363 | assert Iτ.left_order() == O0 364 | assert Iτ.right_order() == O1 365 | 366 | N = ZZ(I.norm()) 367 | Nτ = ZZ(Iτ.norm()) 368 | 369 | K = I.intersection(O1 * Nτ) 370 | α = ideal_generator(K) 371 | return O1 * N + O1 * (α / Nτ) 372 | 373 | 374 | # ================== # 375 | # Ideal Filtration # 376 | # ================== # 377 | 378 | def ideal_filtration_steps(I, l, step_size, small_step_first=False): 379 | """ 380 | Computes the step sizes for the IdealFiltration. 381 | 382 | When an ideal has a left order close to O0 then KLPT can fail as it's 383 | hard to find equivalent prime norm ideals. 384 | 385 | To remedy this, we can either set the last steps (which may be small) 386 | near the beginning or end of the filtration. 387 | """ 388 | # Compute the exp in the ideal's norm 389 | v = ZZ(I.norm()).valuation(l) 390 | 391 | # We make the first step the shortest 392 | remaining_steps, small_step = divmod(v, step_size) 393 | 394 | # Edge case when all steps are the same size 395 | if small_step == 0: 396 | return [step_size] * remaining_steps 397 | 398 | # When the end is close to E0, we need to make the 399 | # small step first. 400 | if small_step_first: 401 | step_lengths = [small_step] + [step_size] * remaining_steps 402 | else: 403 | step_lengths = [step_size] * remaining_steps + [small_step] 404 | 405 | # Make sure we split this up OK 406 | assert sum(step_lengths) == v 407 | 408 | return step_lengths 409 | 410 | 411 | def ideal_filtration(I, l, step_size, small_step_first=False): 412 | """ 413 | Given an ideal I of norm l^* compute 414 | a chain, length e, of ideals I_i with norm step = l^f 415 | """ 416 | # ideal_filtration expects a cyclic ideal 417 | assert is_cyclic(I), "Input ideal is not cyclic!" 418 | 419 | O = I.left_order() 420 | I_long = O.unit_ideal() 421 | I_short = O.unit_ideal() 422 | 423 | # Compute appropriate step lengths given I 424 | step_lengths = ideal_filtration_steps( 425 | I, l, step_size, small_step_first=small_step_first 426 | ) 427 | 428 | # I = Ik ⊂ ... ⊂ I1 ⊂ I0 429 | # I_chain is the quotient of the contained ideals 430 | # I = Ik ⊂ ... ⊂ I1 ⊂ I0 431 | # with norm step_length 432 | 433 | # Build up a chain of the quotients 434 | I_chain = [I_short] 435 | for step_length in step_lengths: 436 | I_short_i = invert_ideal(I_long) * I + I_long.right_order() * l**step_length 437 | I_long = I_long * I_short_i 438 | I_chain.append(I_short_i) 439 | 440 | return I_chain 441 | 442 | # ================================================= # 443 | # Helper functions for tests, not used in main code # 444 | # ================================================= # 445 | 446 | 447 | def non_principal_ideal(O): 448 | """ 449 | Computes a random non-principal ideal. 450 | Note: Only used for testing. 451 | """ 452 | B = O.quaternion_algebra() 453 | p = -B.invariants()[1] 454 | 455 | # A slightly random ideal (not principal!) 456 | while True: 457 | beta = O.random_element(round(p)) 458 | while not cornacchia_friendly(int(beta.reduced_norm())): 459 | beta = O.random_element(round(p)) 460 | facNorm = factor(beta.reduced_norm()) 461 | if facNorm[-1][1] > 1: 462 | continue 463 | N = facNorm[-1][0] 464 | I = O.left_ideal( 465 | [N * a for a in O.basis()] + [a * beta.conjugate() for a in O.basis()] 466 | ) 467 | if I.conjugate().is_equivalent(O.unit_ideal()): 468 | continue 469 | return I -------------------------------------------------------------------------------- /isogenies.py: -------------------------------------------------------------------------------- 1 | """ 2 | Helper functions for isogeny computations. Mainly, functions to 3 | compute torsion basis, isogenies, kernels generators of kernels 4 | and dual isogenies. 5 | 6 | Mainly `EllipticCurveIsogenyFactored` is a wrapper around the usual 7 | factored isogeny in SageMath but also allows a call to the velusqrt 8 | algorithm when the prime isogeny is large. In practice, we seem to 9 | need l > 2000 to see a benefit (probably because we work in Fp4?) 10 | 11 | We also include the sparse strat for isogenies of prime powers, this 12 | should be in Sage by default soon (TM). 13 | 14 | The fix in ticket #34732 should help with Velu performance, so need 15 | to retest once working with 9.8. 16 | 17 | TODO: 18 | 19 | Throughout this code we assume that we are working on E / Fp^4. 20 | When we move back to Fp2, we will need to modify this code 21 | """ 22 | 23 | # Sage Imports 24 | from sage.all import ( 25 | ZZ, 26 | cached_function, 27 | EllipticCurveIsogeny, 28 | factor, 29 | set_random_seed, 30 | ) 31 | from sage.schemes.elliptic_curves.hom_velusqrt import EllipticCurveHom_velusqrt 32 | from sage.schemes.elliptic_curves.hom_composite import EllipticCurveHom_composite 33 | 34 | # Local imports 35 | from utilities import has_order_D 36 | from pari_interface import discrete_log_pari 37 | 38 | # =========================================== # 39 | # Compute points of order D and Torsion Bases # 40 | # =========================================== # 41 | 42 | def generate_random_point(E, seed=None): 43 | """ 44 | E0.random_point() returns either P 45 | or -P with equal probability. 46 | 47 | We always select the element with 48 | smaller y-coordinate to make this 49 | deterministic. 50 | """ 51 | # Allow a setting of the seed to 52 | # ensure the same point is always returned 53 | if seed is not None: 54 | set_random_seed(seed) 55 | 56 | P = E.random_element() 57 | return min(P, -P) 58 | 59 | 60 | def generate_point_order_D(E, D): 61 | """ 62 | Input: An elliptic curve E / Fp4 63 | An integer D dividing (p^2 - 1) 64 | Output: A point P of order D. 65 | """ 66 | p = E.base().characteristic() 67 | n = (p**2 - 1) // D 68 | for _ in range(1000): 69 | P = n * generate_random_point(E) 70 | 71 | # Case when we randomly picked 72 | # a point in the n-torsion 73 | if P.is_zero(): 74 | continue 75 | 76 | # Check that P has order exactly D 77 | if has_order_D(P, D): 78 | P._order = ZZ(D) 79 | return P 80 | 81 | raise ValueError(f"Never found a point of order D...") 82 | 83 | 84 | def generate_linearly_independent_point(E, P, D, canonical=False): 85 | """ 86 | Input: An elliptic curve E / Fp4 87 | A point P ∈ E[D] 88 | An integer D dividing (p^2 - 1) 89 | Output: A point Q such that E[D] = 90 | """ 91 | # This ensures all random points are deterministically 92 | # generated 93 | if canonical: 94 | set_random_seed(0) 95 | 96 | for _ in range(2000): 97 | # Generate a random point of order D 98 | Q = generate_point_order_D(E, D) 99 | 100 | # Make sure the point is linearly independent 101 | pair = P.weil_pairing(Q, D, algorithm="pari") 102 | if has_order_D(pair, D, multiplicative=True): 103 | Q._order = ZZ(D) 104 | return Q 105 | 106 | raise ValueError(f"Never found a linearly independent point...") 107 | 108 | 109 | @cached_function 110 | def torsion_basis(E, D, canonical=False): 111 | """ 112 | Generate basis of E(Fp^4)[D] of supersingular curve 113 | 114 | Optional canonical: bool 115 | Makes torsion generation deterministic. 116 | """ 117 | p = E.base().characteristic() 118 | 119 | # Ensure D divides the curve's order 120 | if (p**2 - 1) % D != 0: 121 | print(f"{factor(D) = }") 122 | print(f"{factor(p**2 - 1) = }") 123 | raise ValueError(f"D must divide the point's order") 124 | 125 | # This ensures all random points are deterministically 126 | # generated 127 | if canonical: 128 | set_random_seed(0) 129 | 130 | P = generate_point_order_D(E, D) 131 | Q = generate_linearly_independent_point(E, P, D) 132 | 133 | return P, Q 134 | 135 | # ================================== # 136 | # Compute composite degree isogenies # 137 | # ================================== # 138 | 139 | def EllipticCurveIsogenyFactored(E, P, order=None, velu_bound=400): 140 | """ 141 | Works similarly to EllipticCurveHom_composite 142 | but with two main additions: 143 | 144 | Introduces a sparse strategy for prime power 145 | isogenies, taken from 146 | https://trac.sagemath.org/ticket/34239 147 | This should be default soon (9.8 maybe) 148 | 149 | For primes l > 400, we use velusqrt as 150 | the algorithm. This bound was found by testing 151 | in tests/test_isogenies.sage 152 | 153 | Additionally, we allow `order` as an optional parameter 154 | and `velu_bound` controls when sqrtvelu kicks in 155 | """ 156 | 157 | def EllipticCurveHom_velusqrt_setorder(P): 158 | """ 159 | To speed things up, we manually set the order 160 | assuming all curves have order (p^2 - 1)^2 161 | 162 | I think this is fixed for 9.8, but not everyone 163 | will be running the latest SageMath version. 164 | """ 165 | E = P.curve() 166 | p = E.base().characteristic() 167 | E._order = ZZ((p**2 - 1) ** 2) 168 | return EllipticCurveHom_velusqrt(E, P) 169 | 170 | def evaluate_factored_isogeny(phi_list, P): 171 | """ 172 | Given a list of isogenies, evaluates the 173 | point for each isogeny in the list 174 | """ 175 | for phi in phi_list: 176 | P = phi(P) 177 | return P 178 | 179 | def sparse_isogeny_prime_power(P, l, e, split=0.8, velu_bound=2000): 180 | """ 181 | Compute chain of isogenies quotienting 182 | out a point P of order l**e 183 | https://trac.sagemath.org/ticket/34239 184 | """ 185 | if l > velu_bound: 186 | isogeny_algorithm = lambda Q, l: EllipticCurveHom_velusqrt_setorder(Q) 187 | else: 188 | isogeny_algorithm = lambda Q, l: EllipticCurveIsogeny( 189 | Q.curve(), Q, degree=l, check=False 190 | ) 191 | 192 | def recursive_sparse_isogeny(Q, k): 193 | assert k 194 | if k == 1: # base case 195 | return [isogeny_algorithm(Q, l)] 196 | 197 | k1 = int(k * split + 0.5) 198 | k1 = max(1, min(k - 1, k1)) # clamp to [1, k-1] 199 | 200 | Q1 = l**k1 * Q 201 | L = recursive_sparse_isogeny(Q1, k - k1) 202 | 203 | Q2 = evaluate_factored_isogeny(L, Q) 204 | R = recursive_sparse_isogeny(Q2, k1) 205 | 206 | return L + R 207 | 208 | return recursive_sparse_isogeny(P, e) 209 | 210 | # Ensure P is a point on E 211 | if P.curve() != E: 212 | raise ValueError(f"The supplied kernel must be a point on the curve E") 213 | 214 | if order: 215 | P._order = ZZ(order) 216 | cofactor = P.order() 217 | 218 | # Deal with isomorphisms 219 | if cofactor == 1: 220 | return EllipticCurveIsogeny(P.curve(), P) 221 | 222 | ϕ_list = [] 223 | for l, e in cofactor.factor(): 224 | # Compute point Q of order l^e 225 | D = ZZ(l**e) 226 | cofactor //= D 227 | Q = cofactor * P 228 | 229 | # Manually setting the order means 230 | # Sage won't try and do it for each 231 | # l-isogeny in the iteration 232 | Q._order = D 233 | 234 | # Use Q as kernel of degree l^e isogeny 235 | ψ_list = sparse_isogeny_prime_power(Q, l, e, velu_bound=velu_bound) 236 | 237 | # Map P through chain length e of l-isogenies 238 | P = evaluate_factored_isogeny(ψ_list, P) 239 | ϕ_list += ψ_list 240 | 241 | return EllipticCurveHom_composite.from_factors(ϕ_list) 242 | 243 | 244 | # ===================================== # 245 | # Fast computation of dual given kernel # 246 | # ===================================== # 247 | 248 | def dual_isogeny_and_kernel(ϕ, R, order=None): 249 | """ 250 | Compute the dual isogeny given an 251 | isogeny and its kernel. 252 | Inspired by ia.cr/2019/499 253 | 254 | Input: An isogeny ϕ : E -> E / of degree D 255 | The generator R of the ker(ϕ) 256 | 257 | Output: The dual of ϕ and its kernel 258 | """ 259 | E1 = ϕ.domain() 260 | E2 = ϕ.codomain() 261 | 262 | if not order: 263 | D = ZZ(ϕ.degree()) 264 | else: 265 | D = ZZ(order) 266 | 267 | S = generate_linearly_independent_point(E1, R, D) 268 | 269 | ker_phi_dual = ϕ(S) 270 | ϕ_dual = EllipticCurveIsogenyFactored(E2, ker_phi_dual, order=D) 271 | Eϕ = ϕ_dual.codomain() 272 | 273 | # Correcting isomorphism 274 | if Eϕ != E1: 275 | iso = Eϕ.isomorphism_to(E1) 276 | ϕ_dual = iso * ϕ_dual 277 | 278 | return ϕ_dual, ker_phi_dual 279 | 280 | 281 | def dual_isogeny(ϕ, R, order=None): 282 | """ 283 | Wrapper function for when we only want the 284 | dual isogeny but not the dual isogeny's 285 | kernel 286 | """ 287 | ϕ_dual, _ = dual_isogeny_and_kernel(ϕ, R, order=order) 288 | return ϕ_dual 289 | 290 | 291 | # ===================================== # 292 | # Compute kernel generators of degree D # 293 | # ===================================== # 294 | 295 | def generate_kernels_prime_power(E, l, e): 296 | """ 297 | Compute a list of points of order 298 | exactly l**e which should generate 299 | all isogenies of degree l**e 300 | """ 301 | D = l**e 302 | P, Q = torsion_basis(E, D) 303 | 304 | # Send points of the form 305 | # [x]P + Q 306 | K = Q 307 | yield K 308 | for _ in range(D - 1): 309 | K += P 310 | K._order = ZZ(D) 311 | yield K 312 | 313 | # Send points of the form 314 | # P + [lx]Q 315 | K = P 316 | yield K 317 | lQ = l * Q 318 | for _ in range(D // l - 1): 319 | K += lQ 320 | K._order = ZZ(D) 321 | yield K 322 | 323 | 324 | def generate_two_torsion_kernels(E): 325 | """ 326 | Generates the kernels of order 2 327 | by generating a point of order 328 | dividing 2^k, and performing at most k 329 | doublings until we reach a point of order 2. 330 | 331 | Additionally, the only points of order 332 | 2 are P, Q and P + Q 333 | 334 | So if we find P, and another point K 335 | of order 2 then K is either Q or P + Q 336 | such that K + P is either Q + P or Q 337 | 338 | This skips needing to compute pairings 339 | for linear independence 340 | """ 341 | p = E.base().characteristic() 342 | 343 | # Compute odd cofactor 344 | n = p**2 - 1 345 | f = n.valuation(2) 346 | n = n // 2**f 347 | 348 | for _ in range(1000): 349 | P = n * E.random_point() 350 | if not P.is_zero(): 351 | Pdbl = 2 * P 352 | while not Pdbl.is_zero(): 353 | P = Pdbl 354 | Pdbl = 2 * P 355 | yield P 356 | break 357 | 358 | for _ in range(1000): 359 | Q = n * E.random_point() 360 | if not Q.is_zero(): 361 | Qdbl = 2 * Q 362 | while not Qdbl.is_zero(): 363 | Q = Qdbl 364 | Qdbl = 2 * Q 365 | if Q != P: 366 | yield Q 367 | break 368 | yield P + Q 369 | 370 | 371 | def generate_kernels_division_polynomial(E, l): 372 | """ 373 | Generate all kernels which generate cyclic isogenies 374 | of degree l from the curve E. 375 | 376 | Kernel generators are found by computing the roots 377 | of the l-th division polynomial and lifting these values 378 | to points on the elliptic curve. 379 | """ 380 | f = E.division_polynomial(l) 381 | xs = [x for x, _ in f.roots()] 382 | 383 | for x in xs: 384 | K = E.lift_x(x) 385 | K._order = ZZ(l) 386 | yield K 387 | 388 | # ===================================== # 389 | # Fast DLP solving using Weil pairing # 390 | # ===================================== # 391 | 392 | def DLP(P, G, D): 393 | """ 394 | Given two elliptic curve points G, P 395 | such that P = [x]G find x by using 396 | Weil pairings to allow the dlog to 397 | be performed over Fp4 398 | 399 | This can be between 30-100% faster than 400 | calling P.discrete_log(Q). 401 | 402 | TODO: 403 | Only supported for prime powers. 404 | """ 405 | # Find a linearly independent point for 406 | # Weil pairing 407 | Q = generate_linearly_independent_point(G.curve(), G, D) 408 | 409 | # Map from elliptic curves to Fp4 410 | g = G.weil_pairing(Q, D, algorithm="pari") 411 | p = P.weil_pairing(Q, D, algorithm="pari") 412 | 413 | return discrete_log_pari(p, g, D) 414 | 415 | 416 | def BiDLP(R, P, Q, D): 417 | """ 418 | Given a basis P,Q of E[D] finds 419 | a,b such that R = [a]P + [b]Q. 420 | 421 | Uses the fact that 422 | e([a]P + [b]Q, [c]P + [d]Q) = e(P,Q)^(ad-bc) 423 | """ 424 | # e(P,Q) 425 | pair_PQ = P.weil_pairing(Q, D, algorithm="pari") 426 | 427 | # Write R = aP + bQ for unknown a,b 428 | # e(R, Q) = e(P, Q)^a 429 | pair_a = R.weil_pairing(Q, D, algorithm="pari") 430 | 431 | # e(R,-P) = e(P, Q)^b 432 | pair_b = R.weil_pairing(-P, D, algorithm="pari") 433 | 434 | # Now solve the dlog on Fq 435 | a = discrete_log_pari(pair_a, pair_PQ, D) 436 | b = discrete_log_pari(pair_b, pair_PQ, D) 437 | 438 | return a, b 439 | 440 | # ===================================== # 441 | # Compute a kernel from an isogeny # 442 | # ===================================== # 443 | 444 | def kernel_from_isogeny_prime_power(ϕ): 445 | """ 446 | Given a prime-power degree isogeny ϕ 447 | computes a generator of its kernel 448 | """ 449 | E = ϕ.domain() 450 | D = ϕ.degree() 451 | 452 | # Deal with isomorphisms 453 | if D == 1: 454 | return E(0) 455 | 456 | # Generate a torsion basis of E[D] 457 | P, Q = torsion_basis(E, D) 458 | 459 | # Compute the image of P,Q 460 | imP, imQ = ϕ(P), ϕ(Q) 461 | 462 | # Ensure we can use Q as a base 463 | # for the discrete log 464 | # TODO: 465 | # Here we assume D is a prime power, 466 | # this could fail for the general case 467 | if not has_order_D(imQ, D): 468 | P, Q = Q, P 469 | imP, imQ = imQ, imP 470 | 471 | # Solve the discrete log such that 472 | # imP = -[x]imQ. `DLP` uses Weil 473 | # pairing to shift the dlog to Fp4. 474 | x = DLP(-imP, imQ, D) 475 | 476 | return P + x * Q 477 | -------------------------------------------------------------------------------- /lattices.py: -------------------------------------------------------------------------------- 1 | """ 2 | Functions which solve lattice problems for use in subalgorithms of KLPT. 3 | 4 | For KLPT there are a few spots where we need to enumerate short vectors 5 | of lattices to ensure the smallest possible solutions to Diophantine equations. 6 | 7 | Namely, we need close vectors to a special lattice for the strong approximation 8 | to ensure the output bound is ~pN^3. This is accomplished with GenerateCloseVectors. 9 | We use FPYLLL for the underlying lattice computations, which seem to outperform 10 | Pari. We also have the ability to enumerate rather than precompute all vectors, 11 | which is better than Pari's qfminim. 12 | 13 | For the generation of equivalent prime norm ideals, we have an ideal basis and 14 | we find short norm vectors of this and immediately output algebra elements. 15 | There's probably ways to reuse the GenerateShortVectors, but there's a few 16 | things about the prime norm elements which require special treatment so we 17 | chose to suffer code duplication for clearer specific functions. 18 | """ 19 | 20 | # Sage Imports 21 | from sage.all import vector, floor, ZZ, Matrix, randint 22 | 23 | # fpylll imports 24 | import fpylll 25 | from fpylll import IntegerMatrix, CVP 26 | from fpylll.fplll.gso import MatGSO 27 | 28 | 29 | def solve_closest_vector_problem(lattice_basis, target): 30 | """ 31 | Use the fpylll library to solve the CVP problem for a given 32 | lattice basis and target vector 33 | """ 34 | L = IntegerMatrix.from_matrix(lattice_basis.LLL()) 35 | v = CVP.closest_vector(L, target) 36 | # fpylll returns a type `tuple` object 37 | return vector(v) 38 | 39 | 40 | def generate_short_vectors_fpyll(L, bound, count=2000): 41 | """ 42 | Helper function for GenerateShortVectors and 43 | generate_small_norm_quat which builds an iterator 44 | for short norm vectors of an LLL reduced lattice 45 | basis. 46 | """ 47 | # # Move from Sage world to Fypll world 48 | A = IntegerMatrix.from_matrix(L) 49 | 50 | # Gram-Schmidt Othogonalization 51 | G = MatGSO(A) 52 | _ = G.update_gso() 53 | 54 | # Enumeration class on G with `count`` solutions 55 | # BEST_N_SOLUTIONS: 56 | # Starting with the nr_solutions-th solution, every time a new solution is found 57 | # the enumeration bound is updated to the length of the longest solution. If more 58 | # than nr_solutions were found, the longest is dropped. 59 | E = fpylll.Enumeration( 60 | G, nr_solutions=count, strategy=fpylll.EvaluatorStrategy.BEST_N_SOLUTIONS 61 | ) 62 | 63 | # We need the row count when we call enumerate 64 | r = L.nrows() 65 | 66 | # If enumerate finds no solutions it raises an error, so we 67 | # wrap it in a try block 68 | try: 69 | # The arguments of enumerate are: 70 | # E.enumerate(first_row, last_row, max_dist, max_dist_expo) 71 | short_vectors = E.enumerate(0, r, bound, 0) 72 | except Exception as e: 73 | print(f"DEBUG [generate_short_vectors_fpyll]: No short vectors could be found...") 74 | print(f"{e}") 75 | short_vectors = [] 76 | 77 | return short_vectors 78 | 79 | def generate_short_vectors(lattice_basis, bound, count=2000): 80 | """ 81 | Generate a generator of short vectors with norm <= `bound` 82 | returns at most `count` vectors. 83 | 84 | Most of the heavy lifting of this function is done by 85 | generate_short_vectors_fpyll 86 | """ 87 | L = lattice_basis.LLL() 88 | short_vectors = generate_short_vectors_fpyll(L, bound, count=count) 89 | for _, xis in short_vectors: 90 | # Returns values x1,x2,...xr such that 91 | # x0*row[0] + ... + xr*row[r] = short vector 92 | v3 = vector([ZZ(xi) for xi in xis]) 93 | v = v3 * L 94 | yield v 95 | 96 | 97 | def generate_close_vectors(lattice_basis, target, p, L, count=2000): 98 | """ 99 | Generate a generator of vectors which are close, without 100 | bound determined by N to the `target`. The first 101 | element of the list is the solution of the CVP. 102 | """ 103 | # Compute the closest element 104 | closest = solve_closest_vector_problem(lattice_basis, target) 105 | yield closest 106 | 107 | # Now use short vectors below a bound to find 108 | # close enough vectors 109 | 110 | # Set the distance 111 | diff = target - closest 112 | distance = diff.dot_product(diff) 113 | 114 | # Compute the bound from L 115 | b0 = L // p 116 | bound = floor((b0 + distance) + (2 * (b0 * distance).sqrt())) 117 | 118 | short_vectors = generate_short_vectors(lattice_basis, bound, count=count) 119 | 120 | for v in short_vectors: 121 | yield closest + v 122 | 123 | 124 | def generate_small_norm_quat(Ibasis, bound, count=2000): 125 | """ 126 | Given an ideal I and an upper bound for the scaled 127 | norm Nrd(a) / n(I), finds elements a ∈ B such that 128 | a has small norm. 129 | """ 130 | # Before starting anything, just send out the basis 131 | # sometimes this works, and much quicker. 132 | for bi in Ibasis: 133 | yield bi 134 | 135 | # Recover Quaternion algebra from IBasis for use later 136 | B = Ibasis[0].parent() 137 | 138 | # Write Ibasis as a matrix 139 | Ibasis_matrix = Matrix([x.coefficient_tuple() for x in Ibasis]).transpose() 140 | 141 | # Can't do LLL in QQ, so we move to ZZ by clearing 142 | # the denominator 143 | lattice_basis, _ = Ibasis_matrix._clear_denom() 144 | L = lattice_basis.LLL() 145 | 146 | # Move from Sage world to Fypll world 147 | short_vectors = generate_short_vectors_fpyll(L, bound, count=count) 148 | 149 | for _, xis in short_vectors: 150 | # Returns values x1,x2,...xr such that 151 | # x0*row[0] + ... + xr*row[r] = short vector 152 | v3 = vector([ZZ(xi) for xi in xis]) 153 | 154 | # Often the very shortest are all composite 155 | # this forces some of the latter element? 156 | # Decide with a coin-flip? 157 | if randint(0, 1) == 1: 158 | v3[2] = 1 159 | 160 | v = Ibasis_matrix * v3 161 | 162 | yield B(v) 163 | 164 | print( 165 | f"WARNING [generate_small_norm_quat]: " 166 | "Exhausted all short vectors, if you're seeing this SQISign is likely about to fail." 167 | ) 168 | -------------------------------------------------------------------------------- /mitm.py: -------------------------------------------------------------------------------- 1 | """ 2 | Algorithms to derive an unknown isogeny between two elliptic curves 3 | of known degree 2^k. 4 | 5 | This is implemented by computing two isogeny graphs from each 6 | elliptic curve and looking for a collision in the leaves of the 7 | respective tree graphs. 8 | 9 | The graph is efficiently built by deriving j-invariants from the 10 | roots of modular polynomials and once a path through the graph is 11 | known, the isogeny is computed by checking all degree 2 isogenies 12 | from each graph. 13 | """ 14 | 15 | # Sage imports 16 | from sage.all import ( 17 | floor, 18 | PolynomialRing, 19 | factor, 20 | EllipticCurveIsogeny, 21 | EllipticCurve, 22 | ) 23 | from sage.schemes.elliptic_curves.hom_composite import EllipticCurveHom_composite 24 | 25 | # Local imports 26 | from isogenies import ( 27 | generate_kernels_division_polynomial, 28 | kernel_from_isogeny_prime_power, 29 | generate_kernels_prime_power, 30 | ) 31 | from setup import Fp2 32 | 33 | # ============================================= # 34 | # Compute j-invariants from modular polynomials # 35 | # ============================================= # 36 | 37 | # For faster quadratic root computation 38 | Fp2_inv_2 = Fp2(1) / 2 39 | 40 | def sqrt_Fp2(a): 41 | """ 42 | Efficiently computes the sqrt 43 | of an element in Fp2 using that 44 | we always have a prime p such that 45 | p ≡ 3 mod 4. 46 | """ 47 | p = Fp2.characteristic() 48 | i = Fp2.gens()[0] # i = √-1 49 | 50 | a1 = a ** ((p - 3) // 4) 51 | x0 = a1 * a 52 | α = a1 * x0 53 | 54 | if α == -1: 55 | x = i * x0 56 | else: 57 | b = (1 + α) ** ((p - 1) // 2) 58 | x = b * x0 59 | 60 | return x 61 | 62 | 63 | def quadratic_roots(b, c): 64 | """ 65 | Computes roots to the quadratic polynomial 66 | 67 | f = x^2 + b * x + c 68 | 69 | Using the quadratic formula 70 | 71 | Just like in school! 72 | """ 73 | d2 = b**2 - 4 * c 74 | d = sqrt_Fp2(d2) 75 | return ((-b + d) * Fp2_inv_2, -(b + d) * Fp2_inv_2) 76 | 77 | 78 | def generic_modular_polynomial_roots(j1): 79 | """ 80 | Compute the roots to the Modular polynomial 81 | Φ2, setting x to be the input j-invariant. 82 | 83 | When only one j-invariant is known, we 84 | find up to three new j-invariant values. 85 | 86 | This is fairly slow, but is only done 87 | once per graph. 88 | """ 89 | R = PolynomialRing(j1.parent(), "y") 90 | y = R.gens()[0] 91 | Φ2 = ( 92 | j1**3 93 | - j1**2 * y**2 94 | + 1488 * j1**2 * y 95 | - 162000 * j1**2 96 | + 1488 * j1 * y**2 97 | + 40773375 * j1 * y 98 | + 8748000000 * j1 99 | + y**3 100 | - 162000 * y**2 101 | + 8748000000 * y 102 | - 157464000000000 103 | ) 104 | 105 | return Φ2.roots(multiplicities=False) 106 | 107 | 108 | def quadratic_modular_polynomial_roots(jc, jp): 109 | """ 110 | When we have the current node's value as 111 | well as the parent node value then we can 112 | find the remaining roots by solving a 113 | quadratic polynomial following 114 | 115 | https://ia.cr/2021/1488 116 | """ 117 | jc_sqr = jc**2 118 | α = -jc_sqr + 1488 * jc + jp - 162000 119 | β = ( 120 | jp**2 121 | - jc_sqr * jp 122 | + 1488 * (jc_sqr + jc * jp) 123 | + 40773375 * jc 124 | - 162000 * jp 125 | + 8748000000 126 | ) 127 | # Find roots to x^2 + αx + β 128 | return quadratic_roots(α, β) 129 | 130 | 131 | def find_j_invs(j1, l, j_prev=None): 132 | """ 133 | Compute the j-invariants of the elliptic 134 | curves 2-isogenous to the elliptic curve 135 | with j(E) = j1 136 | 137 | The optional param j_prev is used to 138 | pass through the parent node in the 139 | graph. 140 | 141 | This is important to stop backtracking, 142 | but we can also use it to reduce the degree 143 | of the modular polynomial to make root 144 | derivation more efficient. 145 | """ 146 | # TODO: only l=2 supported 147 | if l != 2: 148 | raise ValueError("Currently, `find_j_invs` is only implemented for l=2") 149 | 150 | if j_prev: 151 | roots = quadratic_modular_polynomial_roots(j1, j_prev) 152 | 153 | else: 154 | roots = generic_modular_polynomial_roots(j1) 155 | 156 | # Dont include the the previous node to avoid backtracking 157 | return [j for j in roots if j != j_prev] 158 | 159 | 160 | # ============================================= # 161 | # Construct and find paths in an isogeny graph # 162 | # ============================================= # 163 | 164 | def j_invariant_isogeny_graph(j1, l, e, middle_j_vals=None): 165 | """ 166 | A depth-first search of the isogeny graph. 167 | 168 | For G1 where we compute the whole graph, dfs or bfs is 169 | the same. But when we are looking for a collision in 170 | the leaves, we can supply the end values from G1 as 171 | middle_j_vals, and stop as soon as we find a collision. 172 | 173 | This means for a isogeny of degree 2^k, the best case for 174 | G2 would be computing only (k/2) nodes rather than the whole 175 | graph! 176 | 177 | Actual graph is a list of dictionaries, with levels as elements 178 | of the list and nodes in the dictionary with node : parent_node 179 | as data pairs. 180 | """ 181 | isogeny_graph = [{} for _ in range(e + 1)] 182 | 183 | # Set the root of the graph 184 | isogeny_graph[0][j1] = None 185 | 186 | # Set stack for DFS 187 | stack = [(j1, 0)] 188 | 189 | while stack: 190 | # Get a new element from the stack 191 | node, level = stack.pop() 192 | 193 | # Parent of node (for backtracking) 194 | node_parent = isogeny_graph[level][node] 195 | 196 | # Where we will store the next nodes 197 | child_level = level + 1 198 | 199 | # Set a bool to see if we should check the middle 200 | check_middle = child_level == e and middle_j_vals is not None 201 | 202 | # Compute all child nodes, except the node which 203 | # goes back down the tree 204 | node_children = find_j_invs(node, l, j_prev=node_parent) 205 | 206 | # Compute a dictionary of child : parent nodes for this 207 | # level in the tree. 208 | for child in node_children: 209 | # Add children node to graph 210 | isogeny_graph[child_level][child] = node 211 | 212 | # Return early when DFS finds the middle j_invariant 213 | if check_middle and child in middle_j_vals: 214 | return isogeny_graph, child 215 | 216 | # New nodes to search through 217 | if child_level != e: 218 | stack.append((child, child_level)) 219 | 220 | return isogeny_graph, None 221 | 222 | 223 | def j_invariant_path(isogeny_graph, j1, j2, e, reversed_path=False): 224 | """ 225 | Compute a path through a graph with root j1 and 226 | last child j2. This is efficient because of our 227 | data structure for the graph (simply e look ups 228 | in a dictionary). 229 | """ 230 | # Make sure the end node is where we expect 231 | assert j1 in isogeny_graph[0] 232 | assert j2 in isogeny_graph[e] 233 | 234 | j_path = [j2] 235 | j = j2 236 | for k in reversed(range(1, e + 1)): 237 | j = isogeny_graph[k][j] 238 | j_path.append(j) 239 | 240 | if not reversed_path: 241 | j_path.reverse() 242 | return j_path 243 | 244 | 245 | def isogeny_from_j_invariant_path(E1, j_invariant_path, l): 246 | """ 247 | Given a starting curve E1 and a path of j-invariants 248 | of elliptic curves l-isogenous to its neighbour, compute 249 | an isogeny ϕ with domain E1 and codomain En with 250 | j(En) equal to the last element of the path 251 | """ 252 | # Check we're starting correctly 253 | assert E1.j_invariant() == j_invariant_path[0] 254 | 255 | # We will compute isogenies linking 256 | # Ei, Ej step by step 257 | ϕ_factors = [] 258 | Ei = E1 259 | 260 | for j_step in j_invariant_path[1:]: 261 | # Compute the isogeny between nodes 262 | ϕij = brute_force_isogeny_jinv(Ei, j_step, l, 1) 263 | 264 | # Store the factor 265 | ϕ_factors.append(ϕij) 266 | 267 | # Update the curve Ei 268 | Ei = ϕij.codomain() 269 | 270 | # Composite isogeny from factors 271 | ϕ = EllipticCurveHom_composite.from_factors(ϕ_factors) 272 | return ϕ 273 | 274 | # ======================================== # 275 | # Brute force ell isogenies between nodes # 276 | # ======================================== # 277 | 278 | def brute_force_isogeny_jinv(E1, j2, l, e): 279 | """ 280 | Finds an isogeny of degree l^e, between 281 | two curves E1, and an unknown curve 282 | with j-invariant j2 283 | 284 | TODO: write this to be combined with 285 | `BruteForceSearch` so we don't have so 286 | much code duplication? 287 | """ 288 | 289 | # Compute the j-invariants of the end 290 | # points 291 | jE1 = E1.j_invariant() 292 | 293 | # Degree of isogeny we search for 294 | D = l**e 295 | 296 | # Handle case when the curves are 297 | # isomorphic 298 | if D == 1: 299 | if jE1 == j2: 300 | F = E1.base() 301 | E2 = EllipticCurve(F, j=j2) 302 | return E1.isomorphism_to(E2) 303 | else: 304 | raise ValueError( 305 | f"A degree 1 isogeny cannot be found, as the curves are not isomorphic" 306 | ) 307 | 308 | # Enumerate through kernel generators 309 | if e == 1: 310 | kernels = generate_kernels_division_polynomial(E1, l) 311 | else: 312 | kernels = generate_kernels_prime_power(E1, l, e) 313 | 314 | for K in kernels: 315 | ϕ = EllipticCurveIsogeny(E1, K, degree=D, check=False) 316 | Eϕ = ϕ.codomain() 317 | jEϕ = Eϕ.j_invariant() 318 | if jEϕ == j2: 319 | return ϕ 320 | 321 | raise ValueError( 322 | f"No degree {D} isogeny found linking the curves E1 and curve with invariant j2" 323 | ) 324 | 325 | 326 | def brute_force_isogeny(E1, E2, l, e): 327 | """ 328 | Finds an isogeny of degree l^e, between 329 | two curves E1, and E2 330 | 331 | TODO: 332 | Currently only implemented for prime 333 | power degrees 334 | """ 335 | # Degree of isogeny we search for 336 | D = l**e 337 | 338 | # Handle case when the curves are 339 | # isomorphic 340 | if D == 1: 341 | if E1.is_isomorphic(E2): 342 | return E1.isomorphism_to(E2), E1(0) 343 | else: 344 | raise ValueError( 345 | f"A degree 1 isogeny cannot be found, as the curves are not isomorphic" 346 | ) 347 | 348 | # Enumerate through kernel generators 349 | if e == 1: 350 | kernels = generate_kernels_division_polynomial(E1, l) 351 | else: 352 | kernels = generate_kernels_prime_power(E1, l, e) 353 | 354 | for K in kernels: 355 | ϕ = EllipticCurveIsogeny(E1, K, degree=D, check=False) 356 | Eϕ = ϕ.codomain() 357 | if Eϕ.is_isomorphic(E2): 358 | iso = Eϕ.isomorphism_to(E2) 359 | ϕ = iso * ϕ 360 | return ϕ, K 361 | 362 | raise ValueError( 363 | f"[-] BruteForceSearch failed. No degree {D} isogeny found linking the curves E1 and E2" 364 | ) 365 | 366 | 367 | # ============================================ # 368 | # Claw-finding attack to recover mitm isogeny # 369 | # ============================================ # 370 | 371 | def claw_finding_attack(E1, E2, l, e): 372 | """ 373 | Finds a meet in the middle isogeny of 374 | degree D, between two curves E1, E2 375 | by searching along two halves of a 376 | graph of j invariants 377 | """ 378 | 379 | # We'll search sqrt(l**e) from E1 380 | # and E2 and find a curve in the 381 | # middle. 382 | 383 | # Let e2 >= e1, as dfs approach means 384 | # we'll probably not compute all of 385 | # the second graph. 386 | e1 = floor(e / 2) 387 | e2 = e - e1 388 | 389 | # Coerce the elements from Fp4 to Fp2 390 | # as Ei are supersingular, this is always 391 | # possible. 392 | j1 = Fp2(E1.j_invariant()) 393 | j2 = Fp2(E2.j_invariant()) 394 | 395 | # Compute the isogeny graph with root j1 396 | isogeny_graph_e1, _ = j_invariant_isogeny_graph(j1, l, e1) 397 | 398 | # The top level of the graph are the middle 399 | # j-invariants we want to find a collision with 400 | e1_middle_vals = set(isogeny_graph_e1[e1].keys()) 401 | 402 | # Compute the isogeny graph with root j1 403 | # `j_invariant_isogeny_graph` will terminate as soon as 404 | # it finds a j invariant with j_middle in e1_middle_vals 405 | isogeny_graph_e2, j_middle = j_invariant_isogeny_graph( 406 | j2, l, e2, middle_j_vals=e1_middle_vals 407 | ) 408 | 409 | # If we didn't find a value in the middle, then there's 410 | # no isogeny, or we have a bug! 411 | if not j_middle: 412 | raise ValueError(f"No isogeny of degree {factor(l**e)} linking E1 and E2.") 413 | 414 | # Given the middle j-inv, compute paths from Ei → Middle 415 | j1_path = j_invariant_path(isogeny_graph_e1, j1, j_middle, e1) 416 | j2_path = j_invariant_path(isogeny_graph_e2, j2, j_middle, e2, reversed_path=True) 417 | 418 | # Make sure both paths end in the middle 419 | assert j1_path[-1] == j2_path[0] 420 | 421 | # Construct a path from j(E1) to j(E2) 422 | j_path = j1_path + j2_path[1:] 423 | 424 | # Compute the isogeny from the j-inv path 425 | ϕ = isogeny_from_j_invariant_path(E1, j_path, l) 426 | 427 | # Fix the end of the isogeny with an isomorphism 428 | E2ϕ = ϕ.codomain() 429 | iso = E2ϕ.isomorphism_to(E2) 430 | 431 | return iso * ϕ 432 | 433 | 434 | def meet_in_the_middle_with_kernel(E1, E2, l, e): 435 | """ 436 | Wrapper for the Claw finding algorithm. 437 | Additionally computes the kernel of the 438 | recovered isogeny, which is needed in 439 | SQISign for other computations. 440 | 441 | Edge cases: 442 | 443 | When e = 0 the curves are isomorphic, 444 | so just compute the isomorphism and exit. 445 | 446 | When e = 1, we can't do a mitm, so just 447 | brute force all l-isogenies from one end. 448 | """ 449 | # Deal with isomorphisms 450 | if e == 0: 451 | ϕ = E1.isomorphism_to(E2) 452 | return ϕ, E1(0) 453 | 454 | # Mitm doesn't work if there's no middle to meet in 455 | if e == 1: 456 | ϕ, K = brute_force_isogeny(E1, E2, l, e) 457 | return ϕ, K 458 | 459 | ϕ = claw_finding_attack(E1, E2, l, e) 460 | if ϕ == None: 461 | raise ValueError( 462 | f"[-] ClawFindingAttack failed. No isogeny of degree {factor(l**e)} linking E1 and E2." 463 | ) 464 | K = kernel_from_isogeny_prime_power(ϕ) 465 | return ϕ, K 466 | -------------------------------------------------------------------------------- /parameters.py: -------------------------------------------------------------------------------- 1 | """ 2 | Parameter sets for SQISign stored as dictionaries to be imported 3 | into setup.py. 4 | 5 | p_toy: A toy 54-bit prime which is suitable for running SQISign and testing 6 | functions. 7 | 8 | p6983: The prime from the SQISign paper. 9 | 10 | Parameter set is decided by setting `params` on line 76. 11 | """ 12 | 13 | # Sage imports 14 | from sage.all import ZZ 15 | 16 | # A 54-bit toy value: 17 | # (p^2 - 1) = 2^17 * 3^2 * 17^2 * 29^2 * 37 * 41 * 43 * 59 * 71 * 79 * 97 * 101 * 103 * 107 * 137 18 | p_toy = { 19 | "p": ZZ(9568331647090687), 20 | "q": 1, # For constructing B(-q, -p) 21 | "l": 2, 22 | # All torsion is available for p_toy 23 | "available_torsion": ZZ(91552970508717179193151202131968), 24 | # We use all odd torsion p_toy 25 | # T = available_torsion // 2^17 26 | "T": ZZ(698493732518899377389154069), 27 | "e": 213, # Fixed signing exp. ~ log(p^4) 28 | "Δ": 5, # Meet in the middle exp 29 | "T_prime": 43 * 59 * 71 * 79 * 97 * 101 * 103 * 107 * 137, # Commitment torsion 30 | "Dc": 3**2 * 17**2 * 29**2 * 37 * 41, # Challenge torsion 31 | "f_step_max": 11, # Maximum step in IdealToIsogenyFromKLPT 32 | } 33 | 34 | p6983 = { 35 | "p": ZZ( 36 | 73743043621499797449074820543863456997944695372324032511999999999999999999999 37 | ), 38 | "q": 1, # For constructing B(-q, -p) 39 | "l": 2, 40 | # 2^34 * 3^53 * 5^21 * 7^2 * 11 * 31 * 43 * 83 * 103^2 * 107 * 109 * 137 * 199 * 227 * 41 | # 419 * 491 * 569 * 631 * 677 * 751 * 827 * 857 * 859 * 883 * 1019 * 1171 * 1879 * 2713 * 42 | # 3691 * 4019 * 4283 * 6983 43 | "available_torsion": ZZ( 44 | 395060348595898919675269756303091126739265710380751097355414818834918473504507691894531931983730526183038976000000000000000000000 45 | ), 46 | # 3^53 * 5^21 * 7^2 * 11 * 31 * 43 * 83 * 103^2 * 107 * 109 * 137 * 199 * 227 * 419 * 491 * 47 | # 569 * 631 * 677 * 751 * 827 * 857 * 859 * 883 * 1019 * 1171 * 1879 * 2713 * 3691 * 4019 * 48 | # 4283 * 6983 49 | "T": ZZ( 50 | 22995538811426314040743149601801480558890948909145727366873460055498783098563301138028731335870137332417011260986328125 51 | ), 52 | "e": 1000, # Fixed signing exp. ~ log(p^4) 53 | "Δ": 15, # Meet in the middle exp 54 | # Commitment torsion 55 | # 7^2 * 11 * 31 * 43 * 83 * 103^2 * 107 * 109 * 137 * 199 * 227 * 419 * 491 * 569 * 631 * 677 * 56 | # 751 * 827 * 857 * 859 * 883 * 1019 * 1171 * 1879 * 2713 * 3691 * 4019 * 4283 * 6983 57 | "T_prime": ZZ( 58 | 2487980652789837077620845135662275571903620147320463972934655988236926594121011 59 | ), 60 | "Dc": 3**53 * 5**21, # Challenge torsion, gcd(Dc, T_prime) = 1 61 | # Maximum step in IdealToIsogenyFromKLPT 62 | "f_step_max": 31, 63 | } 64 | 65 | # ************************* # 66 | # Pick parameter dictionary # 67 | # ************************* # 68 | 69 | # Picking params from the above constants will 70 | # select the parameters used in SQISign 71 | 72 | # Expect SQISign to take 30 seconds for p_toy 73 | # and about 15 minutes for p6983 74 | 75 | # params = p_toy 76 | params = p6983 77 | -------------------------------------------------------------------------------- /pari_interface.py: -------------------------------------------------------------------------------- 1 | """ 2 | Pari a.log(b) doesn't allow supplying the order of the base `b`. 3 | We write a new wrapper around `pari.fflog` to speed up dlogs in Fp4 4 | 5 | If Pari outperforms in other areas, we can add more functions here :) 6 | """ 7 | 8 | # Import pari interface 9 | import cypari2 10 | 11 | # SageMath imports 12 | from sage.all import ZZ 13 | 14 | # Make instance of Pari 15 | pari = cypari2.Pari() 16 | 17 | def discrete_log_pari(a, base, order): 18 | """ 19 | Wrapper around pari discrete log. 20 | Works like a.log(b), but allows 21 | us to use the optional argument 22 | order. 23 | """ 24 | x = pari.fflog(a, base, order) 25 | return ZZ(x) 26 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | Given parameter sets well designed for SQISign computes all the 3 | global variables needed for various sub-algorithms. 4 | 5 | TODO: making so many things global is ugly, we should be initialising 6 | classes and passing things around. Will take a bit of refactoring 7 | so could be done when we try and include the optimisations for 8 | E/Fp2 and its twist 9 | """ 10 | 11 | # Sage imports 12 | from sage.all import ( 13 | proof, 14 | GF, 15 | EllipticCurve, 16 | QuaternionAlgebra, 17 | ceil, 18 | log, 19 | round, 20 | var, 21 | gcd, 22 | ) 23 | 24 | # Local imports 25 | from parameters import params 26 | 27 | # Speedy and still (mostly) correct 28 | proof.all(False) 29 | 30 | 31 | def construct_fields_and_curves(p): 32 | """ 33 | Given the SQISign prime, compute the finite 34 | fields and elliptic curves we need. Additionally 35 | we make `sqrt_minus_one` ∈ Fp4 to allow us to 36 | compute the twisting endomorphism. 37 | """ 38 | # Construct finite fields 39 | x = var("x") 40 | Fp2 = GF(p**2, name="z2", modulus=x**2 + 1) 41 | Fp4 = Fp2.extension(2, name="z4") 42 | 43 | # √-1 in Fp4 is needed for automorphisms 44 | sqrt_minus_one = min(Fp4(-1).sqrt(all=True)) # Deterministic sqrt 45 | 46 | # Supersingular curve with End(E0) known 47 | E0 = EllipticCurve(Fp4, [1, 0]) 48 | E0.set_order((p**2 - 1) ** 2, num_checks=0) 49 | 50 | return Fp2, Fp4, E0, sqrt_minus_one 51 | 52 | 53 | def construct_quaternion_alg_and_order(p, q): 54 | """ 55 | Compute the quaternion algebra and maximal 56 | order O0. We (currently) will always have 57 | p ≡ 3 mod 4 and q = 1. 58 | """ 59 | # Quaternion algebra things 60 | B = QuaternionAlgebra(-q, -p) 61 | i, j, k = B.gens() 62 | 63 | # TODO: derive ω from function depending on p 64 | # R[ω] is the distinguished quadratic subring 65 | # of the algebra B_{p, ∞}. 66 | # When p ≡ 3 mod 4, we have ω = i 67 | assert p % 4 == 3, "Currently only p ≡ 3 mod 4 is supported" 68 | ω = i 69 | 70 | # Maximal order O, and a corresponding left ideal I 71 | O0 = B.quaternion_order([1, i, (i + j) / 2, (1 + k) / 2]) 72 | 73 | return B, i, j, k, ω, O0 74 | 75 | 76 | def construct_security_parameters(p): 77 | """ 78 | Given p, derive bounds for other 79 | security parameters 80 | 81 | p = 2^(2*λ) 82 | Bτ ≃ 2^(λ/2) 83 | eτ ≃ 2*λ 84 | """ 85 | λ_security = ceil(log(p, 2) / 2) 86 | Bτ = 2 ** (λ_security // 2) # 2^(λ/2) 87 | eτ = 2 * λ_security 88 | 89 | return λ_security, Bτ, eτ 90 | 91 | 92 | def construct_heuristics(p, l): 93 | """ 94 | Various KLPT sub-functions require elements 95 | to be larger or smaller than bounds. Derive 96 | them here and pass them through as globals. 97 | """ 98 | sqrt_p = round(p.sqrt()) 99 | logp = log(p, l) 100 | loglogp = log(logp, l) 101 | 102 | prime_norm_heuristic = l ** ceil(logp / 2 + loglogp) 103 | represent_heuristic = ceil(logp / 4) - 4 104 | 105 | return sqrt_p, logp, loglogp, prime_norm_heuristic, represent_heuristic 106 | 107 | 108 | """ 109 | Given security parameters generate everything we need 110 | """ 111 | # Extract values from dictionary 112 | p, q, l, available_torsion, T, e, Δ, T_prime, Dc, f_step_max = params.values() 113 | 114 | # Construct fields and curves 115 | Fp2, Fp4, E0, sqrt_minus_one = construct_fields_and_curves(p) 116 | 117 | # Construct Quaternion algebra and maximal order 118 | B, i, j, k, ω, O0 = construct_quaternion_alg_and_order(p, q) 119 | 120 | # Construct security parameters 121 | λ_security, Bτ, eτ = construct_security_parameters(p) 122 | 123 | # Construct some fixed constants for heuristics 124 | # (Mainly for KLPT) 125 | sqrt_p, logp, loglogp, prime_norm_heuristic, represent_heuristic = construct_heuristics( 126 | p, l 127 | ) 128 | 129 | # Check algebra invariants 130 | assert q == 1, "Only q = 1 is supported" 131 | assert p % 4 == 3, "p % 4 ≡ 1 is not currently supported" 132 | assert p % 12 == 7, "p % 12 ̸≡ 7 is not currently supported" 133 | 134 | # Check soundness of torsion subgroups 135 | assert available_torsion % l == 0, "l does not divide the available torsion" 136 | assert available_torsion % T == 0, "T does not divide the available torsion" 137 | assert available_torsion % T_prime == 0, "T_prime does not divide the available torsion" 138 | assert available_torsion % Dc == 0, "Dc does not divide the available torsion" 139 | assert ( 140 | available_torsion % 2**f_step_max == 0 141 | ), "2^f_step_max does not divide the available torsion" 142 | assert gcd(Dc, T_prime) == 1, "Challenge and commitment torsions are not coprime" 143 | 144 | # Check field and curves 145 | assert E0.a_invariants() == (0, 0, 0, 1, 0), "Only E : y^2 = x^3 + x is supported" 146 | assert E0.is_supersingular(), "E0 is not supersingular" 147 | assert sqrt_minus_one**2 == -1, "sqrt_minus_one is not the square root of minus one!" 148 | 149 | # Check Quaternion Algebra and Order 150 | assert O0.discriminant() == B.discriminant(), "O0 is not a maximal order" 151 | 152 | # Check security parameters 153 | assert Dc > l**λ_security, "Dc is not large enough" 154 | -------------------------------------------------------------------------------- /test_SQISign.sage: -------------------------------------------------------------------------------- 1 | """ 2 | Similar to how `example_SQISign.sage is written, but with more 3 | assertions and debugging prints. 4 | """ 5 | 6 | # Python imports 7 | import time 8 | 9 | # Local imports 10 | from SQISign import SQISign 11 | from utilities import print_info 12 | from setup import * 13 | 14 | prover = SQISign() 15 | verifier = SQISign() 16 | 17 | print_info("Starting SQISign") 18 | sqisign_time = time.time() 19 | 20 | # Keygen 21 | print_info("Starting Keygen") 22 | keygen_time = time.time() 23 | prover.keygen() 24 | print_info(f"Keygen took {time.time() - keygen_time:5f}") 25 | 26 | # Unpack and check keygen 27 | EA = prover.pk 28 | τ_prime, Iτ, Jτ = prover.sk 29 | assert τ_prime.degree() == Jτ.norm() 30 | assert gcd(Dc, T_prime) == 1 31 | 32 | # Commitment 33 | print_info("Starting Commitment") 34 | commitment_time = time.time() 35 | E1 = prover.commitment() 36 | print_info(f"Commitment took {time.time() - commitment_time:5f}") 37 | 38 | # Check commitment secret values 39 | ψ_ker, ψ, Iψ = prover.commitment_secrets 40 | assert Iψ.norm() == T_prime 41 | assert ψ_ker.order() == T_prime 42 | assert ψ.degree() == T_prime 43 | 44 | # Challenge 45 | print_info("Starting Challenge") 46 | challenge_time = time.time() 47 | ϕ_ker = verifier.challenge(E1) 48 | print_info(f"Challenge took {time.time() - challenge_time:5f}") 49 | 50 | # Check Challenge 51 | assert ϕ_ker.order() == Dc 52 | 53 | # Response 54 | print_info("Starting Response") 55 | response_time = time.time() 56 | S = prover.response(ϕ_ker) 57 | print_info(f"Response took {time.time() - response_time:5f}") 58 | 59 | # Verification 60 | print_info("Starting Verification") 61 | verify_time = time.time() 62 | EA = prover.export_public_key() 63 | response_valid = verifier.verify_response(EA, E1, S, ϕ_ker) 64 | 65 | # Check verification 66 | print_info(f"Verification took {time.time() - verify_time:5f}") 67 | assert response_valid, "SQISign response was not valid" 68 | print(f"INFO [SQISign]: SQISign was successful!") 69 | 70 | # All finished! 71 | print_info(f"SQISign took {time.time() - sqisign_time:5f}") 72 | -------------------------------------------------------------------------------- /tests/test_KLPT.sage: -------------------------------------------------------------------------------- 1 | import unittest 2 | from KLPT import * 3 | from ideals import equivalent_left_ideals, non_principal_ideal 4 | from setup import * 5 | 6 | I = non_principal_ideal(O0) 7 | 8 | def CyclicIdeal(l, e): 9 | """ 10 | Finds a cyclic Ideal I with norm l^e 11 | 12 | Note: I have no idea if this is a good method, 13 | I just grabbed it / adapted it from: 14 | https://hxp.io/blog/34/hxp-CTF-2017-crypto600-categorical-writeup/ 15 | """ 16 | while True: 17 | γ = RepresentIntegerHeuristic(l**e) 18 | I = O0.unit_ideal().scale(γ) 19 | if is_cyclic(I): 20 | return I 21 | 22 | class TestKLPT(unittest.TestCase): 23 | @staticmethod 24 | def compute_J_and_gamma(): 25 | """ 26 | Helper for tests 27 | """ 28 | previously_seen = set() 29 | facT = list(factor(T)) 30 | 31 | for _ in range(100): 32 | J, N, _ = EquivalentPrimeIdealHeuristic(I, previous=previously_seen) 33 | previously_seen.add(N) 34 | if not J: continue 35 | if kronecker(l, N) == -1: 36 | ex, _ = FindExtra(N, facT.copy()) 37 | γ = RepresentIntegerHeuristic(N*ex) 38 | if γ is not None: 39 | return J, N, γ 40 | raise ValueError("Never found a prime norm with a suitable γ...") 41 | 42 | def test_RepresentIntegerHeuristic(self): 43 | γ = None 44 | for _ in range(100): 45 | M = randint(p+2, p^2) 46 | γ = RepresentIntegerHeuristic(M) 47 | if γ: break 48 | 49 | if not γ: 50 | print(f"DEBUG: [test_RepresentIntegerHeuristic] Could not find a γ.") 51 | self.assertTrue(False) 52 | # Found a gamma, make sure it works! 53 | self.assertEqual(γ.reduced_norm(), M) 54 | 55 | def test_EquivalentPrimeIdealHeuristic(self): 56 | J = None 57 | for _ in range(100): 58 | J, N, α = EquivalentPrimeIdealHeuristic(I) 59 | if J: break 60 | 61 | if not J: 62 | print(f"DEBUG: [test_EquivalentPrimeIdealHeuristic] Could not find an ideal") 63 | self.assertTrue(False) 64 | 65 | # Found a prime ideal, make sure it has the right 66 | # properties. 67 | self.assertTrue(Chi(α, I) == J) 68 | self.assertTrue(ZZ(N).is_prime()) 69 | self.assertTrue(equivalent_left_ideals(I, J)) 70 | 71 | def test_IdealModConstraint(self): 72 | J, _, γ = self.compute_J_and_gamma() 73 | C0, D0 = IdealModConstraint(J, γ) 74 | # Ensure that γ*μ0 is an element of J 75 | μ0 = j*(C0 + ω*D0) 76 | self.assertTrue(γ*μ0, J) 77 | 78 | def test_StrongApproximationHeuristic(self): 79 | # Ensure that for a given l, 80 | # that l is a non-quadratic residue 81 | # mod N 82 | J, N, γ = self.compute_J_and_gamma() 83 | C0, D0 = IdealModConstraint(J, γ) 84 | ν = StrongApproximationHeuristic(N, C0, D0, factor(l^e)) 85 | if ν is None: 86 | print("Heuristic algorithm `StrongApproximationHeuristic` failed") 87 | self.assertTrue(False) 88 | ν_norm = ZZ(ν.reduced_norm()) 89 | self.assertTrue(ν_norm % l == 0) 90 | 91 | def test_EquivalentSmoothIdealHeuristic_le(self): 92 | norm_l = l^e 93 | J = EquivalentSmoothIdealHeuristic(I, norm_l) 94 | self.assertTrue(equivalent_left_ideals(I,J)) 95 | self.assertTrue(norm_l % ZZ(J.norm()) == 0) 96 | print(f"Norm for l^e: {factor(J.norm())}") 97 | 98 | def test_EquivalentSmoothIdealHeuristic_T(self): 99 | norm_T = T^2 100 | J = EquivalentSmoothIdealHeuristic(I, norm_T) 101 | print(f"Norm: {factor(J.norm())}") 102 | self.assertTrue(equivalent_left_ideals(I,J)) 103 | self.assertTrue(norm_T % ZZ(J.norm()) == 0) 104 | 105 | def test_EquivalentSmoothIdealHeuristic_small_norm(self): 106 | norm_T = T^2 107 | I = CyclicIdeal(2, 80) 108 | I_small = I + O0*2^10 109 | assert is_cyclic(I_small) 110 | 111 | J = EquivalentSmoothIdealHeuristic(I_small, norm_T) 112 | print(f"Norm: {factor(J.norm())}") 113 | self.assertTrue(equivalent_left_ideals(I_small,J)) 114 | self.assertTrue(norm_T % ZZ(J.norm()) == 0) 115 | 116 | if __name__ == '__main__' and '__file__' in globals(): 117 | unittest.main() -------------------------------------------------------------------------------- /tests/test_cvp.sage: -------------------------------------------------------------------------------- 1 | import unittest 2 | from lattices import * 3 | 4 | class TestCloseVectors(unittest.TestCase): 5 | def test_SolveCVP(self): 6 | """ 7 | Solution found from 8 | IntegerLattice(A).closest_vector() 9 | 10 | Which does what we need, but is slower. 11 | SolveCVP uses fpylll and is about 35x faster 12 | (2ms compared to 70ms) 13 | """ 14 | A = [(-8, 0, 0, -1), (10, -1, 0, 1), (14, 1, 2, -1), (-2, -1, 2, 2)] 15 | A = Matrix(ZZ, A) 16 | t = vector([1,2,3,4]) 17 | 18 | c = vector([0, 2, 2, 3]) 19 | self.assertEqual(SolveCVP(A,t), c) 20 | 21 | def test_generate_close_vectors(self): 22 | # Stupid example 23 | A = diagonal_matrix(ZZ, 4, [1,2,3,4]) 24 | assert A.is_positive_definite() 25 | bound = 100 26 | short_vectors = generate_close_vectors(A, bound) 27 | self.assertTrue(all([v.dot_product(v) <= bound for v in short_vectors])) 28 | 29 | if __name__ == '__main__' and '__file__' in globals(): 30 | unittest.main() -------------------------------------------------------------------------------- /tests/test_ideals.sage: -------------------------------------------------------------------------------- 1 | import unittest 2 | from ideals import * 3 | from setup import * 4 | 5 | class TestIdealHelpers(unittest.TestCase): 6 | def test_equivalent_right_ideals(self): 7 | I1 = O0.right_ideal([b for b in O0.basis()]) 8 | I2 = B.random_element() * I1 9 | self.assertTrue(equivalent_right_ideals(I1, I2)) 10 | 11 | def test_equivalent_left_ideals(self): 12 | I1 = O0.left_ideal([b for b in O0.basis()]) 13 | I2 = I1 * B.random_element() 14 | self.assertTrue(equivalent_left_ideals(I1, I2)) 15 | 16 | def test_LeftIsomorphism(self): 17 | I = non_principal_ideal(O0) 18 | for _ in range(10): 19 | β = B.random_element() 20 | J = I*β 21 | α = left_isomorphism(I,J) 22 | self.assertEqual(J, I*α) 23 | 24 | if __name__ == '__main__' and '__file__' in globals(): 25 | unittest.main() -------------------------------------------------------------------------------- /tests/test_isogenies.sage: -------------------------------------------------------------------------------- 1 | 2 | import time 3 | 4 | from setup import * 5 | from isogenies import EllipticCurveIsogenyFactored 6 | 7 | P, Q = E0.gens() 8 | 9 | n = (p^2 - 1) // T_prime 10 | K = n * P 11 | K.set_order(T_prime) 12 | 13 | t0 = time.time() 14 | phi = E0.isogeny(K, algorithm="factored") 15 | t1 = time.time() - t0 16 | print(f"Boring: {t1}") 17 | 18 | t0 = time.time() 19 | phi = EllipticCurveIsogenyFactored(E0, K, order=T_prime, velu_bound=10000000000) 20 | t1 = time.time() - t0 21 | print(f"Inf bound: {t1}") 22 | 23 | for bound in [200, 400, 800]: 24 | t0 = time.time() 25 | EllipticCurveIsogenyFactored(E0, K, order=T_prime, velu_bound=bound) 26 | t1 = time.time() - t0 27 | print(f"Bound: {bound}, Time: {t1}") -------------------------------------------------------------------------------- /tests/test_isogenies_and_ideals.sage: -------------------------------------------------------------------------------- 1 | import unittest 2 | from KLPT import RepresentIntegerHeuristic 3 | from deuring import * 4 | from setup import * 5 | 6 | # Speedy and still (mostly) correct 7 | proof.all(False) 8 | 9 | class Testideal_to_kernel(unittest.TestCase): 10 | def helper(self, E, I, connecting_isogenies=None): 11 | print(f"Testing an ideal of norm n(I) = {factor(I.norm())}") 12 | 13 | D = ZZ(I.norm()) 14 | K = ideal_to_kernel(E, I, connecting_isogenies=connecting_isogenies) 15 | self.assertEqual(K.order(), D), "Recovered kernel has the wrong order" 16 | 17 | ϕ = E.isogeny(K, algorithm="factored") 18 | self.assertEqual(ϕ.degree(), D), "ϕ has the wrong degree" 19 | 20 | J = kernel_to_ideal(K, D, connecting_isogenies=connecting_isogenies) 21 | self.assertEqual(J.norm(), D), "Recovered ideal has the wrong degree" 22 | self.assertEqual(I, J), "Ideals are not equal" 23 | 24 | def test_ideal_to_kernel(self): 25 | even_part = 2^10 26 | odd_part = T_prime 27 | 28 | γ = None 29 | while γ is None: 30 | γ = RepresentIntegerHeuristic(even_part * odd_part, parity=True) 31 | 32 | I_even = O0*γ + O0*even_part 33 | I_odd = O0*γ.conjugate() + O0*odd_part 34 | assert I_even.norm() == even_part 35 | assert I_odd.norm() == odd_part 36 | 37 | print(f"Simple test, using the curve E0") 38 | self.helper(E0, I_even) 39 | self.helper(E0, I_odd) 40 | 41 | print(f"Testing again, but using a curve != E0") 42 | P,Q = torsion_basis(E0, Dc) 43 | K = P + Q 44 | ϕ = E0.isogeny(K, algorithm="factored") 45 | E = ϕ.codomain() 46 | E.set_order((p^2 - 1)^2) 47 | ϕ_dual = dual_isogeny(ϕ, K, Dc) 48 | connecting_isogenies = (ϕ, ϕ_dual) 49 | 50 | self.helper(E, I_even, connecting_isogenies=connecting_isogenies) 51 | self.helper(E, I_odd, connecting_isogenies=connecting_isogenies) 52 | 53 | 54 | class Testkernel_to_ideal(unittest.TestCase): 55 | def helper(self, K, D, connecting_isogenies=None): 56 | assert K.order() == D 57 | 58 | E = K.curve() 59 | 60 | print(f"Testing kernel of degree {factor(D)}") 61 | ϕ = E.isogeny(K, algorithm="factored") 62 | self.assertEqual(ϕ.degree(), D), "ϕ has the wrong degree" 63 | 64 | I = kernel_to_ideal(K, D, connecting_isogenies=connecting_isogenies) 65 | self.assertEqual(I.norm(), D), "Ideal has the wrong degree" 66 | 67 | _K = ideal_to_kernel(E, I, connecting_isogenies=connecting_isogenies) 68 | self.assertEqual(_K.order(), D), "The ideal derived has the wrong degree" 69 | 70 | _ϕ = E.isogeny(_K, algorithm="factored") 71 | self.assertEqual(_ϕ.degree(), D), "The isogeny created from the kernel created from the ideal is wrong" 72 | self.assertEqual(ϕ.codomain().j_invariant(), _ϕ.codomain().j_invariant()), "The recovered isogeny does not have the same codomain up to isomorphism..." 73 | self.assertEqual(ϕ.codomain(), _ϕ.codomain()), "The recovered isogeny does not have the same codomain up..." 74 | 75 | 76 | def test_kernel_to_idealE0(self): 77 | even_part = 2^10 78 | odd_part = T_prime 79 | 80 | P_even, Q_even = torsion_basis(E0, even_part) 81 | P_odd, Q_odd = torsion_basis(E0, odd_part) 82 | 83 | self.helper(P_even + Q_even, even_part) 84 | self.helper(P_odd + Q_odd, odd_part) 85 | 86 | def test_kernel_to_ideal(self): 87 | # Same test, but with a connecting isogeny 88 | Pc,Qc = torsion_basis(E0, Dc) 89 | Kc = Pc + Qc 90 | ϕc = E0.isogeny(Kc, algorithm="factored") 91 | Ec = ϕc.codomain() 92 | Ec.set_order((p^2 - 1)^2) 93 | ϕc_dual = dual_isogeny(ϕc, Kc, Dc) 94 | connecting_isogenies = (ϕc, ϕc_dual) 95 | 96 | even_part = 2^10 97 | odd_part = T_prime 98 | 99 | P_even, Q_even = torsion_basis(Ec, even_part) 100 | P_odd, Q_odd = torsion_basis(Ec, odd_part) 101 | 102 | self.helper(P_even + Q_even, even_part, connecting_isogenies=connecting_isogenies) 103 | self.helper(P_odd + Q_odd, odd_part, connecting_isogenies=connecting_isogenies) 104 | 105 | 106 | if __name__ == '__main__' and '__file__' in globals(): 107 | unittest.main() 108 | 109 | -------------------------------------------------------------------------------- /tests/test_mitm.sage: -------------------------------------------------------------------------------- 1 | # Python imports 2 | import cProfile 3 | import pstats 4 | import time 5 | 6 | # Local imports 7 | from mitm import * 8 | from isogenies import * 9 | from setup import * 10 | 11 | # Speedy and still (mostly) correct 12 | proof.all(False) 13 | 14 | 15 | l, e = 2, 14 16 | D = l^e 17 | P, Q = torsion_basis(E0, D) 18 | 19 | total_time = 0 20 | iter_count = 100 21 | for _ in range(iter_count): 22 | K = P + randint(0, D) * Q 23 | K.set_order(D) 24 | ϕA = E0.isogeny(K, algorithm="factored") 25 | EA = ϕA.codomain() 26 | 27 | t0 = time.time() 28 | ϕ = claw_finding_attack(E0, EA, l, e) 29 | total_time += time.time() - t0 30 | assert ϕ.codomain() == EA 31 | 32 | print(f"Mitm time taken: {(total_time/iter_count):.5f}") 33 | 34 | 35 | # K = P + randint(0, D) * Q 36 | # ϕA = E0.isogeny(K, algorithm="factored") 37 | # EA = ϕA.codomain() 38 | 39 | # cProfile.run("ClawFindingAttack(E0, EA, l, e)", 'mitm.cProfile') 40 | # p = pstats.Stats('mitm.cProfile') 41 | # p.strip_dirs().sort_stats("cumtime").print_stats(int(25)) -------------------------------------------------------------------------------- /utilities.py: -------------------------------------------------------------------------------- 1 | """ 2 | Helper functions for SQISign which seemed general enough to 3 | be utilities. 4 | 5 | Includes: 6 | 7 | - Cornacchia's algorithm for expressing integers as x^2 + d*y^2, 8 | - Algorithms for finding inert primes 9 | - An efficient function for determining whether an element has order D, 10 | suitable for additive and multiplicative groups. 11 | """ 12 | 13 | # Sage imports 14 | from sage.all import ( 15 | random_prime, 16 | factor, 17 | gp, 18 | ZZ, 19 | prod, 20 | is_pseudoprime, 21 | kronecker, 22 | cached_function, 23 | gcd, 24 | randint, 25 | choice, 26 | two_squares 27 | ) 28 | 29 | # ======================================= # 30 | # Cornacchia's alg. and helper functions # 31 | # ======================================= # 32 | 33 | def sum_of_squares_friendly(n): 34 | """ 35 | We can write any n = x^2 + y^2 providing that there 36 | are no prime power factors p^k | n such that 37 | p ≡ 3 mod 4 and k odd. 38 | 39 | We can reject bad n by checking these conditions. 40 | """ 41 | # We don't want any prime factors in n such that p ≡ 3 mod 4 42 | # Technically, we could allow these if they appear with even 43 | # exp. but this seems good enough. 44 | # We take the produce p ≡ 3 mod 4 for p < 500 45 | bad_prime_prod = 9758835872005600424541432018720696484164386911251445952809873289484557900064682994157437552186699082331048989 46 | if gcd(bad_prime_prod, n) != 1: 47 | return False 48 | 49 | # Now we consider the odd part of n and try and determine 50 | # if there are bad factors 51 | n_val = n.valuation(2) 52 | n_odd = n // (2**n_val) 53 | 54 | # First we remove all good prime factors p ≡ 1 mod 4 55 | # We take the produce p ≡ 1 mod 4 for p < 500 56 | good_prime_prod = 6396589037802337253083696670901044588941174775898621074430463772770231888063102454871836215129505 57 | g = gcd(good_prime_prod, n_odd) 58 | while g != 1: 59 | n_odd = n_odd // g 60 | g = gcd(n_odd , g) 61 | 62 | # Whatever is left is either composite, or a large ish-prime 63 | # If n_odd % 4 == 3, then there is certainly a prime factor 64 | # p^k | n_odd such that p = 3 mod 4 and k is odd, so this is 65 | # no good 66 | if n_odd % 4 == 3: 67 | return False 68 | 69 | # We now have two cases, either n_odd is a prime p ≡ 1 mod 4 70 | # and we have a solution, or n_odd is a composite which is 71 | # good, or has an even number of primes p ≡ 3 mod 4. The second 72 | # case is bad and we will only know by fully factoring it, so 73 | # to avoid the expensive factoring, we will reject these cases 74 | return is_pseudoprime(n_odd) 75 | 76 | def cornacchia_friendly(n, B=2**20, d=None): 77 | """ 78 | Checks whether the input for Cornacchia 79 | is relatively easy to factor, to ensure it's 80 | fast to solve / reject. 81 | """ 82 | if n < 0: 83 | return False 84 | 85 | # When we have to find m = x^2 + y^2, which 86 | # is the case for SQISign, we can perform a 87 | # few additional checks which seems to help 88 | # with performance 89 | if d == 1: 90 | return sum_of_squares_friendly(n) 91 | 92 | if n < 2**160: 93 | return True 94 | 95 | # Factor n by trial division up to B. 96 | # p is the part that did not get factored 97 | p = factor(n, limit=B)[-1][0] 98 | return p < 2**160 or is_pseudoprime(p) 99 | 100 | 101 | def Cornacchia(M, d): 102 | """ 103 | Given integers M,d computes 104 | x,y such that 105 | 106 | M = x^2 + dy^2 107 | """ 108 | # First test if number is cornacchia friendly (cornacchia factors the number) 109 | if not cornacchia_friendly(M, d=d): 110 | # Empty list is what Q.qfbsolve 111 | # returns if it fails, so lets be 112 | # consistent I guess :) 113 | return [] 114 | 115 | # When d=1 we can use SageMath's two_squares 116 | # which seems faster than calling Q.qfbsolve(ZZ(M)) 117 | if d == 1: 118 | # two_squares raises value errors on bad 119 | # M, so we need to catch these 120 | try: 121 | sol = two_squares(M) 122 | except: 123 | sol = [] 124 | return sol 125 | 126 | # General case: 127 | # Use Pari to define quadratic binary form 128 | # f(x,y) = x^2 + d*y^2 129 | Q = gp.Qfb(ZZ(1), ZZ(0), ZZ(d)) 130 | sol = Q.qfbsolve(ZZ(M)) 131 | # Convert from Pari interface to Sage Integer 132 | return list(map(ZZ, sol)) 133 | 134 | # ======================================= # 135 | # Compute random inert primes in R[ω] # 136 | # ======================================= # 137 | 138 | def is_inert(ω, p): 139 | """ 140 | Given an element ω ∈ B, and a prime p, returns if 141 | p is inert in ℤ[ω] 142 | 143 | For ℤ[ω], a prime is inert when the Kronecker symbol: 144 | (-d / p) = -1 145 | """ 146 | 147 | def discriminant(ω): 148 | """ 149 | Computes the discriminant of an element of a 150 | quaternion algebra, denoted Δ(ω) 151 | """ 152 | Trd = ω.reduced_trace() 153 | Nrd = ω.reduced_norm() 154 | if Trd not in ZZ or Nrd not in ZZ: 155 | raise ValueError(f"The element ω must be integral") 156 | 157 | return ZZ(Trd**2 - 4 * Nrd) 158 | 159 | def recover_d(ω): 160 | """ 161 | Given an integral element of a Quaternion Algebra, 162 | and its discriminant Δ(ω), computes the integer d 163 | such that: 164 | 165 | Δ(ω) = -d if d ≡ 3 mod 4 166 | = -4d otherwise 167 | """ 168 | Δ = discriminant(ω) 169 | if Δ % 4 == 0: 170 | return -(Δ // 4) 171 | return -Δ 172 | 173 | d = recover_d(ω) 174 | return kronecker(-d, p) == -1 175 | 176 | 177 | def inert_prime(bound, d): 178 | """ 179 | Input: An upper bound for the output prime 180 | d, the integer such that ω^2 = -d for 181 | ℤ[ω] 182 | 183 | Output: A prime < bound which is inert in 184 | the ring ℤ[ω] 185 | """ 186 | while True: 187 | p = random_prime(bound) 188 | if kronecker(-d, p) == -1: 189 | return p 190 | 191 | 192 | # =============================================== # 193 | # Random selection, adapted from the Magma impl. # 194 | # https://github.com/SQISign/sqisign-magma # 195 | # =============================================== # 196 | 197 | def generate_bounded_divisor(bound, T, facT): 198 | """ 199 | Finds L2 > bound powersmooth 200 | We want the result to divide T 201 | """ 202 | L2, facL2 = 1, dict() 203 | 204 | # Compute a random factor L2 | T 205 | # and create dict to keep track 206 | # of exp. 207 | for pi, ei in facT: 208 | exp = ei - randint(0, ei) 209 | L2 *= pi**exp 210 | facL2[pi] = exp 211 | 212 | # Mul random factors from T until L2 213 | # is large enough 214 | while L2 <= bound: 215 | pi, ei = choice(facT) 216 | if facL2[pi] < ei: 217 | L2 *= pi 218 | facL2[pi] += 1 219 | 220 | return ZZ(L2) 221 | 222 | # ================================================== # 223 | # Code to check whether a group element has order D # 224 | # ================================================== # 225 | 226 | def batch_cofactor_mul_generic(G_list, pis, group_action, lower, upper): 227 | """ 228 | Input: A list of elements `G_list`, such that 229 | G is the first entry and the rest is empty 230 | in the sublist G_list[lower:upper] 231 | A list `pis` of primes p such that 232 | their product is D 233 | The `group_action` of the group 234 | Indices lower and upper 235 | Output: None 236 | ` 237 | NOTE: G_list is created in place 238 | """ 239 | 240 | # check that indices are valid 241 | if lower > upper: 242 | raise ValueError(f"Wrong input to cofactor_multiples()") 243 | 244 | # last recursion step does not need any further splitting 245 | if upper - lower == 1: 246 | return 247 | 248 | # Split list in two parts, 249 | # multiply to get new start points for the two sublists, 250 | # and call the function recursively for both sublists. 251 | mid = lower + (upper - lower + 1) // 2 252 | cl, cu = 1, 1 253 | for i in range(lower, mid): 254 | cu = cu * pis[i] 255 | for i in range(mid, upper): 256 | cl = cl * pis[i] 257 | # cl = prod(pis[lower:mid]) 258 | # cu = prod(pis[mid:upper]) 259 | 260 | G_list[mid] = group_action(G_list[lower], cu) 261 | G_list[lower] = group_action(G_list[lower], cl) 262 | 263 | batch_cofactor_mul_generic(G_list, pis, group_action, lower, mid) 264 | batch_cofactor_mul_generic(G_list, pis, group_action, mid, upper) 265 | 266 | 267 | @cached_function 268 | def has_order_constants(D): 269 | """ 270 | Helper function, finds constants to 271 | help with has_order_D 272 | """ 273 | D = ZZ(D) 274 | pis = [p for p, _ in factor(D)] 275 | D_radical = prod(pis) 276 | Dtop = D // D_radical 277 | return Dtop, pis 278 | 279 | 280 | def has_order_D(G, D, multiplicative=False): 281 | """ 282 | Given an element G in a group, checks if the 283 | element has order exactly D. This is much faster 284 | than determining its order, and is enough for 285 | many checks we need when computing the torsion 286 | bonus. 287 | 288 | We allow both additive and multiplicative groups 289 | """ 290 | # For the case when we work with elements of Fp^k 291 | if multiplicative: 292 | group_action = lambda a, k: a**k 293 | is_identity = lambda a: a.is_one() 294 | identity = G.parent()(1) 295 | # For the case when we work with elements of E / Fp^k 296 | else: 297 | group_action = lambda a, k: k * a 298 | is_identity = lambda a: a.is_zero() 299 | identity = G.curve()(0) 300 | 301 | if is_identity(G): 302 | return False 303 | 304 | D_top, pis = has_order_constants(D) 305 | 306 | # If G is the identity after clearing the top 307 | # factors, we can abort early 308 | Gtop = group_action(G, D_top) 309 | if is_identity(Gtop): 310 | return False 311 | 312 | G_list = [identity for _ in range(len(pis))] 313 | G_list[0] = Gtop 314 | 315 | # Lastly we have to determine whether removing any prime 316 | # factors of the order gives the identity of the group 317 | if len(pis) > 1: 318 | batch_cofactor_mul_generic(G_list, pis, group_action, 0, len(pis)) 319 | if not all([not is_identity(G) for G in G_list]): 320 | return False 321 | 322 | return True 323 | 324 | 325 | # ================ # 326 | # Print Debugging # 327 | # ================ # 328 | 329 | def print_info(str, banner="="): 330 | """ 331 | Print information with a banner to help 332 | with visibility during debug printing 333 | """ 334 | print(banner * 80) 335 | print(f"{str}".center(80)) 336 | print(banner * 80) --------------------------------------------------------------------------------