Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions constantine/math/extension_fields/square_root_fp2.nim
Original file line number Diff line number Diff line change
Expand Up @@ -157,8 +157,22 @@ func sqrt_if_square_generic(a: var Fp2): SecretBool =
# Gora Adj, Francisco Rodríguez-Henríquez, 2012,
# https://eprint.iacr.org/2012/685
# Made constant-time and optimized to fuse sqrt and inverse sqrt
#
# Note on the purely-real-input edge case:
# Adj-Rodríguez Algorithm 8 (and Scott §6.3) has an unstated precondition
# that a.c1 ≠ 0. For input (a0, 0) with a0 a non-residue in Fp, the
# algorithm silently picks t2 = 0 below, sqrt_invsqrt(0) = (0, 0), and the
# function reports `result = true` with `a` rewritten to (0, 0). But
# (a0, 0) is *always* a square in Fp² (a0^((p²−1)/2) = (a0^(p−1))^((p+1)/2)
# = 1), and the correct sqrt is (0, sqrt(a0 / β)) where β = u² is the
# quadratic non-residue defining Fp². We compute that fallback
# unconditionally in constant time and ccopy it in iff a.c1 was zero.
var t1{.noInit.}, t2{.noInit.}, t3{.noInit.}: typeof(a.c0)

# Save what we need for the purely-real-input fallback before mutating a.
let a0_orig = a.c0
let a1_isZero = a.c1.isZero()

t1.square(a.c0) # a0²
t2.square(a.c1) # - β a1² with β = 𝑖² in a complex extension field
when a.fromComplexExtension():
Expand All @@ -185,6 +199,48 @@ func sqrt_if_square_generic(a: var Fp2): SecretBool =
t3 *= a.c1
a.c1.ccopy(t3, result)

# Purely-real-input fallback (see the comment at the top of this function).
# Compute the two candidate purely-real roots of (a0_orig, 0):
# - candidateA = (sqrt_fp(a0), 0) valid iff a0 is a QR in Fp
# - candidateB = (0, sqrt_fp(a0 / β)) valid iff a0/β is a QR in Fp
# For any nonzero a0 in Fp, at least one of {a0, a0/β} is a QR (since β
# itself is a non-residue: QR × non-QR = non-QR, non-QR × non-QR = QR).
# We always run both candidate computations for constant time, then
# constant-time-select.
var fbC0{.noInit.}, fbC1{.noInit.}: typeof(a.c0)
fbC0.setZero()
fbC1.setZero()

var candA = a0_orig
let candAOk = candA.sqrt_if_square() # candA = sqrt(a0) iff a0 ∈ QR(Fp)
fbC0.ccopy(candA, candAOk)

var candB{.noInit.}: typeof(a.c0)
var candBOk{.noInit.}: SecretBool
when a.fromComplexExtension():
# β = −1, so a0/β = −a0. Compute candidateB = (0, sqrt(−a0)).
candB = a0_orig
candB.neg()
candBOk = candB.sqrt_if_square()
else:
# General case: use sqrt_ratio_if_square to compute √(a0/β) without an
# explicit field inversion of β (the fused routine handles the ratio
# via a single invsqrt, saving ~70-100 Fp muls per call).
var beta{.noInit.}: typeof(a.c0)
beta.setOne()
beta *= NonResidue # beta = β
candBOk = candB.sqrt_ratio_if_square(a0_orig, beta)
# Use candidateB iff candidateA was not a QR but candidateB is.
fbC1.ccopy(candB, (not candAOk) and candBOk)

# Override the output with the fallback iff the input was purely-real and
# is a square. The `and result` guard upholds the "a unmodified on failure"
# contract; for (a0, 0) with a0 ≠ 0 the norm a0² is a square in Fp so
# `result` is always true here, but the guard makes that explicit.
let useFallback = a1_isZero and result
a.c0.ccopy(fbC0, useFallback)
a.c1.ccopy(fbC1, useFallback)

func sqrt_if_square*(a: var Fp2): SecretBool =
## If ``a`` is a square, compute the square root of ``a``
## if not, ``a`` is unmodified.
Expand Down
44 changes: 44 additions & 0 deletions tests/math_extension_fields/t_fp2_sqrt.nim
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,43 @@ proc randomSqrtCheck(Name: static Algebra, gen: RandomGen) =
# bool(r == s)
bool(s == a or s == na)

proc purelyRealNonResidueSqrtCheck(Name: static Algebra) =
## Regression test for the silent-failure bug in sqrt_if_square on
## purely-real inputs whose Fp coordinate is a non-residue.
##
## For any nonzero a0 ∈ Fp, the element (a0, 0) is always a square in Fp²
## (Euler's criterion: a0^((p²−1)/2) = (a0^(p−1))^((p+1)/2) = 1). When a0 is
## a non-residue in Fp, the actual square root lies in Fp² \ Fp and has the
## shape (0, sqrt_fp(a0/β)) where β = u² is the QNR defining Fp².
##
## Adj-Rodríguez Algorithm 8 ("complex method", ePrint 2012/685) and Scott
## §6.3 (ePrint 2020/1497) implicitly assume a.c1 ≠ 0 and silently return
## (0, 0) on this stratum. The optimized path used by other curves
## (sqrt_if_square_opt with rotation extension) handles it correctly via
## the alignment step, but BLS12_377 cannot use it (sqrt of QNR is not in
## Fp²) and was therefore vulnerable on this input shape.
var tested = 0
for v in 2'u32 ..< 20'u32:
var x: Fp2[Name]
x.fromUint(v) # (v, 0)
if bool(x.c0.isSquare()):
continue # only test non-QR a0 values

# (non-QR, 0) is *always* a square in Fp²
check: bool x.isSquare()

var root = x
let ok = root.sqrt_if_square()
check: bool ok

var sq = root
sq.square()
check: bool(sq == x)
check: not bool(root.isZero())
inc tested

doAssert tested > 0, "no small non-residue found in Fp[" & $Name & "]"

proc main() =
suite "Modular square root" & " [" & $WordBitWidth & "-bit words]":
staticFor(curve, TestCurves):
Expand All @@ -77,6 +114,13 @@ proc main() =
randomSqrtCheck(curve, gen = HighHammingWeight)
randomSqrtCheck(curve, gen = Long01Sequence)

test "[𝔽p2] (non-QR, 0) sqrt regression for " & $curve:
# Exercises the purely-real-input edge case that the Adj-Rodríguez
# Algorithm 8 complex method (used by BLS12_377) silently mishandled
# prior to the purely-real fallback added to sqrt_if_square_generic.
# Pre-fix behaviour on BLS12_377: returns (0, 0) with `ok = true`.
purelyRealNonResidueSqrtCheck(curve)

suite "Modular square root - 32-bit bugs highlighted by property-based testing " & " [" & $WordBitWidth & "-bit words]":
test "sqrt_if_square invalid square BLS12_381 - #64":
var a: Fp2[BLS12_381]
Expand Down
Loading