Skip to content

Fix silent (0, 0) return in Fp2 sqrt for purely-real non-residue inputs - #620

Merged
mratsim merged 4 commits into
mratsim:masterfrom
yelhousni:fix/fp2-sqrt-bls12377
Jun 9, 2026
Merged

Fix silent (0, 0) return in Fp2 sqrt for purely-real non-residue inputs#620
mratsim merged 4 commits into
mratsim:masterfrom
yelhousni:fix/fp2-sqrt-bls12377

Conversation

@yelhousni

@yelhousni yelhousni commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Summary

sqrt_if_square over Fp2[BLS12_377] (and the sqrt_if_square_generic code path more generally) silently returns (0, 0) with result = true when fed an input of shape (a0, 0) where a0 is a non-residue in Fp. The input is a valid square in Fp² and has a non-zero square root — the algorithm just doesn't find it.

The optimized code path (sqrt_if_square_opt, used by BLS12_381, BN254, and every other curve constantine supports) is not affected because its sqrt_rotate_extension alignment step handles this stratum structurally. Only BLS12_377 uses the generic path, because sqrt(sqrt(QNR)) ∉ Fp²[BLS12_377] and so the rotation trick cannot be implemented there.

Reproduction (pre-fix)

import constantine/math/{arithmetic, extension_fields},
       constantine/named/algebras

var x: Fp2[BLS12_377]
x.fromUint(5'u32)              # x = (5, 0); 5 is a non-residue in Fp[BLS12_377]

assert bool x.isSquare()       # (5, 0) IS a square in Fp²

var root = x
let ok = root.sqrt_if_square() # ok = true

var sq = root
sq.square()
echo bool(sq == x)             # false — sqrt(x)² ≠ x
echo bool root.isZero()        # true — function silently returned (0, 0)

The same probe on BLS12_381 / BN254_Snarks returns the correct non-zero root because those curves go through the (already-correct) optimized path.

Why this happens

sqrt_if_square_generic is a faithful implementation of Adj-Rodríguez 2012/685 Algorithm 8 (the "complex method"), which Scott §6.3 (ePrint 2020/1497) and Aardal et al. Algorithm 3 (ePrint 2024/1563) all build on. All three papers omit the purely-real-input edge case. Scott's correctness argument (§6.3) reads:

"their product is equal to βb²/4. This is a QNR times a perfect square, and hence a QNR. If a QNR is the product of two other field elements then one of them must be a QR..."

This reasoning requires b ≠ 0 — when b = 0 the product is 0 (not a QNR) and the proof's conclusion fails. Tracing the algorithm with a = (a0, 0), a0 non-QR:

  1. t1 ← a0²−β·0² = a0².
  2. t1 ← sqrt(t1) = ±a0 (deterministic).
  3. t2 ← (a0 + t1)/2, t3 ← (a0 − t1)/2. One is a0, one is 0.
  4. quadResidTest ← t2.isSquare(). Since a0 is non-QR and 0 is a QR (by Fp.isSquare's convention), the algorithm picks t2 = 0.
  5. sqrt_invsqrt(t1, t3, 0) returns (t1, t3) = (0, 0).
  6. a.c0 ← 0, a.c1 ← 0.
  7. Returns result = true (set from step 2, since a0² is a QR), with a overwritten to (0, 0).

The actual square root in this case is (0, sqrt(a0/β)), which lives in Fp² (one of {a0, a0/β} is always a QR in Fp because β itself is a non-residue).

The fix

After the existing algorithm runs, compute two candidate purely-real-input roots in constant time:

  • Candidate A: (sqrt_fp(a0), 0) — valid iff a0 ∈ QR(Fp).
  • Candidate B: (0, sqrt_fp(a0/β)) — valid iff a0/β ∈ QR(Fp).

For nonzero a0 ∈ Fp, at least one of {a0, a0/β} is a QR (since β is itself a non-residue: QR × non-QR = non-QR, non-QR × non-QR = QR), so the fallback always finds the root.

Constant-time-select the fallback into a.c0 / a.c1 iff a.c1 was zero. The result flag is already correct — (a0, 0) with a0 ≠ 0 is a square in Fp², so result = true was set correctly by the existing t1.sqrt_if_square() on the norm.

# After the existing algorithm:
var fbC0, fbC1: typeof(a.c0)
fbC0.setZero(); fbC1.setZero()

var candA = a0_orig
let candAOk = candA.sqrt_if_square()
fbC0.ccopy(candA, candAOk)

var candB = a0_orig
when a.fromComplexExtension():
    candB.neg()                              # a0/β with β = −1
else:
    var betaInv: typeof(a.c0)
    betaInv.setOne(); betaInv *= NonResidue  # β
    betaInv.inv()                            # 1/β
    candB *= betaInv                         # a0/β
let candBOk = candB.sqrt_if_square()
fbC1.ccopy(candB, (not candAOk) and candBOk)

a.c0.ccopy(fbC0, a1_isZero)
a.c1.ccopy(fbC1, a1_isZero)

The patch:

  • Saves a.c0 and a.c1.isZero() at the top of the function (before the existing algorithm mutates a).
  • Adds the fallback block at the end.
  • Is fully constant-time: no data-dependent branches, only ccopy/*=/sqrt_if_square on unconditional code paths.

No change to the algorithm's correctness on a.c1 ≠ 0 inputs — the fallback ccopys only fire when a.c1 == 0.

Constant-time and performance impact

  • Constant-time: the fix consists of two Fp.sqrt_if_square calls, one Fp.inv (only in the else branch — i.e. BLS12_377), and three ccopys. No data-dependent branches.
  • Performance: the fallback runs unconditionally, adding ~2 Fp exponentiations + 1 Fp inversion per call to sqrt_if_square_generic. Since this path is only invoked for BLS12_377, the impact is BLS12_377-specific.
  • Optimization opportunity: for the when a.fromComplexExtension() branch, candidate B reduces to (0, sqrt(−a0)) and candA + candB are mutually exclusive — this branch never actually triggers in constantine today (complex-extension curves all use sqrt_if_square_opt), but we keep the code arm for safety in case the dispatcher ever sends complex extensions through the generic path.

What's tested

The regression test [𝔽p2] (non-QR, 0) sqrt regression for <Curve> exercises the bug shape across all TestCurves (BN254_Nogami, BN254_Snarks, BLS12_377, BLS12_381):

for v in 2'u32 ..< 20'u32:
    var x: Fp2[Name]
    x.fromUint(v)
    if bool(x.c0.isSquare()):
        continue
    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())
    return

Pre-fix this test fails for BLS12_377 (sqrt(x)^2 == x is false; root.isZero() is true) and passes for the other three curves. Post-fix all four pass.

Exploit assessment

Reachability of (non-QR, 0) shape from production callers:

Caller Reachable?
EC_ShortW_Aff/Jac/Proj.recoverY The source comment marks this as "For test case generation only, ... intended for testing purposes". Not in production verification paths.
hash_to_curve (h2c_map_to_isocurve_swu.nim) Uses its own inline rotation extension (sqrt_rotate_extension) — not affected by the generic path.
lowlevel_extension_fields.sqrt_if_square (public API) Reachable by external callers. Anyone using constantine's Fp²[BLS12_377] arithmetic directly (e.g., recursive SNARK proving stacks, Celo BLS sigs, the Zexe family of recursive proofs) is exposed if they ever call sqrt_if_square on an attacker-controlled input that ends up purely real.

Production constantine callers don't trigger the bug today, but the function is part of constantine's public API and external Nim/C/Go/Rust users via the lowlevel_* exports can hit it. Worth fixing for correctness and defense-in-depth.

Curves directly relevant to Ethereum L1 (BLS12_381, BN254_Snarks) were never affected because they use the optimized path.

Test plan

  • nim c -d:release -p:. tests/math_extension_fields/t_fp2_sqrt.nim && ./tests/math_extension_fields/t_fp2_sqrt — all 11 tests pass (4 random sqrt across curves + 4 new regression tests across curves + 3 historical bug regressions).
  • nim c -d:release -p:. tests/math_extension_fields/t_fp2.nim && ./... — full Fp2 arithmetic suite passes; no regression from the fallback's side effects.
  • Direct empirical probe via blst_fp2_sqrt-style standalone test reproduces the bug pre-fix and confirms the fix post-fix across BLS12_381, BN254_Snarks, BLS12_377.

References

Summary by CodeRabbit

  • Bug Fixes

    • Corrected square-root behavior for extension-field inputs that are purely real, adding a constant-time fallback to ensure correct, side-channel-resistant results.
  • Tests

    • Added regression tests that verify square-root correctness for non-residue purely-real elements across all supported curves.

@coderabbitai

coderabbitai Bot commented Jun 1, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 59fcbfc4-8b86-49f6-9f01-7bf3d2b7558a

📥 Commits

Reviewing files that changed from the base of the PR and between 3226a5e and 9198f76.

📒 Files selected for processing (2)
  • constantine/math/extension_fields/square_root_fp2.nim
  • tests/math_extension_fields/t_fp2_sqrt.nim
🚧 Files skipped from review as they are similar to previous changes (2)
  • constantine/math/extension_fields/square_root_fp2.nim
  • tests/math_extension_fields/t_fp2_sqrt.nim

📝 Walkthrough

Walkthrough

The PR fixes a purely-real-input edge case (a.c1 == 0) in the generic square-root algorithm for quadratic extension fields by adding a constant-time fallback path that computes two deterministic candidate roots and selects the correct one, paired with targeted regression test coverage for non-quadratic-residue inputs.

Changes

Purely-Real Square Root Edge Case Handling

Layer / File(s) Summary
Purely-Real Fallback Implementation
constantine/math/extension_fields/square_root_fp2.nim
sqrt_if_square_generic now detects purely-real inputs, snapshots the original real component, computes two deterministic candidate square roots (sqrt_fp(a0), 0) and (0, sqrt_fp(a0/β)), validates each candidate, and performs constant-time selection to overwrite a.c0/a.c1 only when input was purely real.
Purely-Real Regression Test Coverage
tests/math_extension_fields/t_fp2_sqrt.nim
New helper purelyRealNonResidueSqrtCheck searches for non-square purely-real Fp elements, asserts the element is square in Fp², validates sqrt_if_square() succeeds, and confirms the computed root squares back to input and is non-zero. Per-curve test cases invoke this helper for all curves in TestCurves.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A root that's purely real, at last we see,
Two candidates computed carefully,
Constant-time selection, constant, bright,
The fallback path keeps timing tight!
With tests that hunt the non-residue deep,
Our edge case now is sound and keeps.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely summarizes the main change: fixing a bug in the Fp2 sqrt function that returns (0, 0) incorrectly for purely-real non-residue inputs.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request adds a fallback path in sqrt_if_square_generic to correctly handle the purely-real-input edge case (a0, 0) where a0 is a non-residue in Fp, along with a corresponding regression test. Feedback is provided regarding a performance regression in the fallback path, where computing the modular inverse of NonResidue on every call is highly inefficient; using sqrt_ratio_if_square is suggested instead to avoid the expensive field inversion.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread constantine/math/extension_fields/square_root_fp2.nim Outdated
@greptile-apps

greptile-apps Bot commented Jun 1, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes a correctness bug in sqrt_if_square_generic (used exclusively by BLS12_377) that silently returns (0, 0) with result = true for purely-real inputs (a0, 0) where a0 is a non-residue in Fp — a case that the referenced Adj-Rodríguez 2012/685 and Scott 2020/1497 papers both omit from their correctness proofs.

  • Fix in square_root_fp2.nim: Saves a.c0 and the zero-ness of a.c1 before the existing algorithm runs, then appends a constant-time fallback that computes two candidate purely-real roots ((sqrt(a0), 0) and (0, sqrt(a0/β))), selects the valid one via ccopy, and applies it only when the original a.c1 was zero — leaving all non-purely-real inputs and all other curves fully unaffected.
  • Regression test in t_fp2_sqrt.nim: Adds purelyRealNonResidueSqrtCheck across TestCurves (BN254_Nogami, BN254_Snarks, BLS12_377, BLS12_381) that finds the first small non-QR value, exercises the bug shape, and asserts the square root round-trips correctly.

Confidence Score: 4/5

The fix is correct for all valid Fp2 inputs; the two suggestions are non-blocking hardening improvements.

The core algorithm change is sound: for every valid Fp2 input the fallback either correctly overrides a wrong (0,0) result or is a no-op. The two observations are style/hardening points that do not affect correctness on any reachable input today.

The fallback block in square_root_fp2.nim (lines 202-237) is the critical new logic; t_fp2_sqrt.nim warrants a second look for the early-return behaviour in purelyRealNonResidueSqrtCheck.

Important Files Changed

Filename Overview
constantine/math/extension_fields/square_root_fp2.nim Adds a constant-time purely-real-input fallback to sqrt_if_square_generic; the math is correct for all cases (QR a0, non-QR a0, zero input) and doesn't affect the non-generic (opt) path or any other curve; minor: fallback fires on a1_isZero independently of result, which is safe because the norm of a purely-real element is always a square, but is not defensively guarded.
tests/math_extension_fields/t_fp2_sqrt.nim Adds purelyRealNonResidueSqrtCheck covering the fixed edge case; test finds the first non-QR in [2,20) per curve and returns early — only one value is exercised per curve per test run.

Reviews (1): Last reviewed commit: "Fix silent (0, 0) return in Fp2 sqrt for..." | Re-trigger Greptile

Comment thread tests/math_extension_fields/t_fp2_sqrt.nim Outdated
Comment thread constantine/math/extension_fields/square_root_fp2.nim Outdated
@mratsim

mratsim commented Jun 9, 2026

Copy link
Copy Markdown
Owner

Excellent, thank you!

@mratsim

mratsim commented Jun 9, 2026

Copy link
Copy Markdown
Owner

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@mratsim

mratsim commented Jun 9, 2026

Copy link
Copy Markdown
Owner

The following is an automated LLM review ran locally. No action need, for informational purpose only. Some recommendations might be wrong (like skipping invsqrt if divisor is zero, defeating constant-time ... 🤷)


Review Summary

This PR fixes a silent correctness bug in sqrt_if_square_generic (BLS12-377's Fp2 square root path) where inputs (a0, 0) with a0 a non-residue in Fp returned (0, 0) with ok = true. The fix adds a purely-real-input fallback that computes two candidate roots — (sqrt(a0), 0) for QR inputs and (0, sqrt(a0/β)) for non-QR inputs — unconditionally for constant time, then selects the correct one via ccopy with SecretBool. Both math-crypto reviewers independently verified the mathematical correctness. A regression test was added covering the (non-QR, 0) edge case across all test curves.

  • P1 – Unconditional fallback doubles cost of every sqrt_if_square_generic call: The fallback block (lines 202-242) runs unconditionally for constant-time guarantees, adding ~800-900 Fp multiplications (~60-75% overhead) to every sqrt_if_square_generic call. This is a deliberate tradeoff for side-channel safety, but benchmarks the regression on BLS12-377 pairing and hash-to-curve workloads. Four independent reviewers agreed on severity.
  • P2 – Test coverage gaps leave candidateA (QR) branch and boundary inputs untested: The regression test skips QR inputs (continue for isSquare()), so the candidateA fallback path is entirely untested. Additionally, the test range 2..20 excludes boundary inputs (0, 0) and (1, 0). A ccopy malfunction in the QR path could produce incorrect results silently.

Important Files Changed

Filename Overview
constantine/math/extension_fields/square_root_fp2.nim Added purely-real-input fallback to sqrt_if_square_generic; fixes silent (0,0) return for (non-QR, 0) inputs (1 High, 10 Low findings)
tests/math_extension_fields/t_fp2_sqrt.nim Added regression test purelyRealNonResidueSqrtCheck for (non-QR, 0) edge case across all curves (4 Medium, 4 Low findings)

Context Diagrams

constantine/math/extension_fields/square_root_fp2.nim:

flowchart TD
    Start["sqrt_if_square_generic a: Fp2"] --> SaveState
    SaveState["Save a0_orig & a1_isZero"] --> MainPath
    MainPath["Adj-Rodríguez Algorithm 8<br/>Complex method unchanged"] --> MainResult
    MainResult["result = t1.sqrt_if_square()<br/>Compute via sqrt_invsqrt<br/>Write a.c0 a.c1 conditionally"] --> FallbackEntry
    FallbackEntry["Purely-real fallback<br/>runs unconditionally constant time"] --> InitFB
    InitFB["fbC0 = 0, fbC1 = 0"] --> CandA
    CandA["candA = a0_orig<br/>candAOk = candA.sqrt_if_square()"] --> Branch
    Branch{"fromComplexExtension?"}
    CandBComplex["candB = -a0_orig<br/>candBOk = candB.sqrt_if_square()"]
    CandBGeneral["beta = NonResidue<br/>candBOk = candB.sqrt_ratio_if_square a0_orig beta"]
    Branch -->|"Yes β=-1"| CandBComplex
    Branch -->|"No general β"| CandBGeneral
    CandBComplex --> SelectFB
    CandBGeneral --> SelectFB
    SelectFB["fbC0.ccopy(candA, candAOk)<br/>fbC1.ccopy(candB, !candAOk && candBOk)"] --> UseCheck
    UseCheck["useFallback = a1_isZero && result"] --> Override
    Override["a.c0.ccopy(fbC0, useFallback)<br/>a.c1.ccopy(fbC1, useFallback)"] --> ReturnPath
    ReturnPath["return result"]
Loading

Production Readiness & Safety Verdict

Production Readiness: 3/5

Safety Verdict: Needs changes before merge

The core fix is mathematically correct and constant-time safe — both math-crypto reviewers independently verified the Euler's criterion proof, the fallback formula for both complex and general extensions, and the absence of timing side channels. However, four reviewers independently flagged a ~60-75% performance regression on every sqrt_if_square_generic call (BLS12-377 only). Additionally, test coverage gaps leave the candidateA (QR) fallback path and boundary inputs (0,0) / (1,0) untested. No security vulnerabilities were found.


Summaries

Findings

Order by file path (alphabetical), then by line number. This enables deterministic comparison between runs and agents.

ID Severity Confidence File Issue
BUG-A-001, BUG-B-002 Low 0.9 square_root_fp2.nim:195 invsqrt(0) called for purely-real inputs (fallback corrects)
QA-005 Informational 0.7 square_root_fp2.nim:164 sqrt_invsqrt(0) shorthand notation could confuse readers
QA-001 Low 0.9 square_root_fp2.nim:166 Euler's criterion proof omits nonzero precondition
CONS-A-003 Low 0.7 square_root_fp2.nim:173 let a0_orig missing {.noInit.} per towers.nim convention
PERF-A-001, PERF-B-001, ARCH-A-001, ARCH-B-001 High 0.95 square_root_fp2.nim:202-242 Unconditional fallback adds ~60-75% overhead to every sqrt_if_square_generic call
PERF-A-003 Informational 0.9 square_root_fp2.nim:240-241 ccopy overhead on non-fallback path (12 limb moves)
BUG-B-001 Low 0.9 square_root_fp2.nim:219,234 {.noInit.} variable candBOk always read via bitwise AND
ARCH-A-003 Low 0.8 square_root_fp2.nim:218-234 Compile-time branching inside runtime constant-time path (acceptable pattern)
CONS-A-001, CONS-B-001, CONS-B-002 Low 0.9 square_root_fp2.nim:210-242 Naming convention drift in fallback block
BUG-A-003 Low 0.6 square_root_fp2.nim:240-242 Fallback overwrites main algorithm sign for purely-real QR inputs
COV-A-004 Low 0.9 square_root_fp2.nim:240-242 No BLS12_377-specific non-square rejection test
PERF-A-002 Low 0.7 square_root_fp2.nim:229-231 beta = NonResidue recomputed every call (1 Fp mul)
COV-A-003, QA-004 Low 1.0 square_root_fp2.nim:220-224 Complex extension fallback branch is dead code with current curves
ARCH-B-002 Low 0.4 square_root_fp2.nim:99-145 sqrt_if_square_opt may share same bug — correctness depends on sqrt() sign
COV-A-001 Medium 1.0 square_root_fp2.nim:214-216 Purely-real QR fallback path (candidateA) untested
COV-A-002, COV-B-004 Medium 1.0 t_fp2_sqrt.nim:88 Zero input (0, 0) not tested; test starts at v = 2
COV-B-001 Medium 0.9 t_fp2_sqrt.nim:101-103 Negated root not verified in regression test
COV-B-002 Medium 0.9 t_fp2_sqrt.nim:101-103 Structural shape of result not verified (c0==0 for non-QR)
QA-003 Low 0.9 t_fp2_sqrt.nim:84 Test docstring uses "alignment step" instead of "rotation step"
COV-B-003 Low 0.95 t_fp2_sqrt.nim:50-70 Existing randomSqrtCheck cannot trigger the (non-QR, 0) bug (informational)
ARCH-A-002 Low 0.9 t_fp2_sqrt.nim:117-122 Regression test runs on all curves but only BLS12_377 exercises the fix

Key takeaways:

  1. The core fix is mathematically correct — both math-crypto passes found no issues, and all security passes were clean.
  2. The ~60-75% performance overhead (4 reviewers agreed, 0.95 confidence) is the most significant concern — it is a deliberate constant-time tradeoff but should be benchmarked.
  3. Test coverage gaps are real but manageable — the candidateA QR branch and boundary inputs (0,0)/(1,0) need targeted test cases.
  4. Three reviewer claims were false positives (BUG-A-002, PERF-B-002, CONS-A-002), demonstrating the value of cross-reviewer verification.

Per-File Summary

File Critical High Medium Low Info Status
constantine/math/extension_fields/square_root_fp2.nim 0 1 1 9 2 ⚠️ NEEDS CHANGES
tests/math_extension_fields/t_fp2_sqrt.nim 0 0 4 3 0 ⚠️ NEEDS CHANGES

LGTM

These are positive changes in the diff, not issues. They are excluded from Detailed Findings, per-file severity counts, and Recommendations.

ID(s) File Improvement
Multiple square_root_fp2.nim Correct fix for the (non-QR, 0) → (0, √(a0/β)) edge case that previously silently returned (0, 0)
Multiple square_root_fp2.nim Constant-time implementation: both candidates computed unconditionally, selection via ccopy with SecretBool
Multiple square_root_fp2.nim Proper use of sqrt_ratio_if_square saves ~70-100 Fp multiplications vs explicit inversion
Multiple square_root_fp2.nim useFallback = a1_isZero and result guard correctly preserves "a unmodified on failure" contract
Multiple square_root_fp2.nim Well-documented mathematical reasoning (Euler's criterion, Adj-Rodríguez precondition gap)
Multiple square_root_fp2.nim Fix correctly scoped to sqrt_if_square_generic only (BLS12_377 path); public API unchanged
Multiple t_fp2_sqrt.nim Regression test iterates small integers, filters non-QRs, verifies roundtrip + non-zero root
Multiple t_fp2_sqrt.nim Test runs on all 4 curves for defense-in-depth, with doAssert tested > 0 guard
Multiple t_fp2_sqrt.nim Comprehensive test docstring explains mathematical context and which algorithm was affected
ARCH-B-003 square_root_fp2.nim Dispatch asymmetry properly contained — sqrt_if_square_generic is internal-only with single caller
Math-Crypto A/B square_root_fp2.nim Full mathematical verification: Euler's criterion proof, fallback formula correctness for both complex and general extensions
Security A/B square_root_fp2.nim No timing side channels — all ccopy use SecretBool, when is compile-time, both candidates unconditional
Math-Crypto B square_root_fp2.nim Verified (0, 0) input handled correctly by both main algorithm and fallback paths
Math-Crypto B square_root_fp2.nim Adj-Rodríguez path verified correct for purely-real QR inputs (no sign ambiguity)

Detailed Findings

High

Address Before Merge — These issues cause likely failures or significant vulnerabilities.

[PERF] PERF-A-001, PERF-B-001, ARCH-A-001, ARCH-B-001: Unconditional fallback doubles cost of every sqrt_if_square_generic call - square_root_fp2.nim:202-242

Location: constantine/math/extension_fields/square_root_fp2.nim:202-242
Severity: High
Confidence: 0.95 (4 independent reviewers agreed)

Diff Under Review:

+  # 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)

Issue: Unconditional fallback computation adds ~40-100% overhead to every sqrt_if_square_generic call

The fallback is computed unconditionally for constant-time guarantees. For BLS12_377 (the sole user of sqrt_if_square_generic), this adds:

  • Candidate A: candA.sqrt_if_square() → ~400-450 Fp multiplications (Tonelli-Shanks with 46-bit two-adicity)
  • Candidate B: candB.sqrt_ratio_if_square(a0_orig, beta) → ~400-450 Fp multiplications (1 mul + invsqrt_if_square + 1 mul)
  • Total added: ~800-900 Fp multiplications per call

The existing sqrt_if_square_generic cost is ~1200-1350 Fp multiplications (including the existing sqrt_if_square + sqrt_invsqrt + isSquare calls). The fallback adds ~60-75% overhead.

PERF-A-001 estimated ~100% increase (conservative ~6109 total vs ~3057 existing). PERF-B-001 estimated ~40% increase (more conservative on the base cost). Both are directionally correct; the true overhead is approximately 60-75% per call.

This only affects BLS12_377 since all other curves dispatch to sqrt_if_square_opt (line 250-260).

Impact: At realistic scale, a single BLS12_377 Fp2 sqrt_if_square call goes from ~30-60µs to ~50-100µs. Hash-to-curve for BLS12_377 G2 could add 10-30µs per point mapping.

Fact-check: Verified against source code (lines 202-242 of square_root_fp2.nim). The fallback block is indeed placed after the main algorithm and runs unconditionally. The ccopy at lines 241-242 discards the result when useFallback is false, but the computation has already been done.

Suggested Change: The constant-time unconditional approach is correct for a cryptographic library. Document the performance impact. Consider benchmarking the regression on BLS12_377 pairing/hash-to-curve performance. If purely-real inputs are rare in the application, consider a conditional branch with compensating delay.


Medium

Should Address — These issues cause failures on edge cases or represent technical debt.

[COVERAGE] COV-A-001: Purely-real QR fallback path (candidateA branch) untested - square_root_fp2.nim:214-216

Location: constantine/math/extension_fields/square_root_fp2.nim:214-216
Severity: Medium
Confidence: 1.0

Diff Under Review:

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

Issue: The candidateA (purely-real QR) fallback branch is entirely untested by the regression test.

The test purelyRealNonResidueSqrtCheck at t_fp2_sqrt.nim:91-92 explicitly skips QR values:

if bool(x.c0.isSquare()):
  continue                # only test non-QR a0 values

This means inputs like (4, 0) (where 4 = 2² is a QR) never exercise the fbC0.ccopy(candA, candAOk) path. If this ccopy malfunctioned, fbC0 would remain zero, producing (0, sqrt(a0/β)) instead of (sqrt(a0), 0).

Fact-check: Verified against test code (line 91-92 of t_fp2_sqrt.nim). The continue for QR inputs confirms the candidateA branch is untested.

Suggested Change: Add a test case for (QR, 0) input, e.g., (4, 0) or (9, 0), to exercise the candidateA fallback path and verify fbC0 is correctly populated.


[COVERAGE] COV-A-002, COV-B-004: Zero input (0, 0) not tested - square_root_fp2.nim:202-242, t_fp2_sqrt.nim:88

Location: constantine/math/extension_fields/square_root_fp2.nim:202-242
Severity: Medium
Confidence: 1.0

Diff Under Review:

+  for v in 2'u32 ..< 20'u32:
+    var x: Fp2[Name]
+    x.fromUint(v)            # (v, 0)

Issue: Test range 2..20 excludes v = 0 and v = 1, omitting boundary cases.

For (0, 0): the main algorithm produces (0, 0) (correct: √0 = 0), and the fallback also produces (0, 0). The sqrt_invsqrt(0) degenerate path and sqrt_ratio_if_square(0, β) zero-ratio path are both exercised but not asserted.

For (1, 0): 1 is always a QR, so it's skipped. The fallback should produce (1, 0) but is untested.

Fact-check: Verified against test code (line 88: for v in 2'u32 ..< 20'u32). Confirmed v = 0 and v = 1 are excluded.

Suggested Change: Add standalone test cases for (0, 0) and (1, 0) inputs.


[COVERAGE] COV-B-001: Negated root not verified in regression test - t_fp2_sqrt.nim:101-103

Location: tests/math_extension_fields/t_fp2_sqrt.nim:101-103
Severity: Medium
Confidence: 0.9

Diff Under Review:

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

Issue: The regression test only verifies root² == x, not (-root)² == x.

The existing randomSqrtCheck (line 70) checks bool(s == a or s == na), verifying that the returned root is one of the two valid roots. The new regression test only confirms one root works.

Fact-check: Verified against both test procs. randomSqrtCheck at line 70 checks bool(s == a or s == na). The new test at lines 101-103 only checks bool(sq == x).

Suggested Change: Add negated root verification: var nr: Fp2[Name]; nr.neg(root); var sq2 = nr; sq2.square(); check: bool(sq2 == x).


[COVERAGE] COV-B-002: Structural shape of result not verified - t_fp2_sqrt.nim:101-103

Location: tests/math_extension_fields/t_fp2_sqrt.nim:101-103
Severity: Medium
Confidence: 0.9

Issue: Test does not verify the root has the expected shape (0, sqrt(a0/β)) for non-QR inputs.

For (a0, 0) with a0 a QNR, the correct root shape is (0, √(a0/β)). The test checks root² == x but does not verify root.c0 == 0 and root.c1 ≠ 0. A component swap bug (returning (√(a0/β), 0) instead of (0, √(a0/β))) would still pass the roundtrip check since β·(a0/β) = a0 in Fp.

Fact-check: Verified against test code. No structural shape assertions exist in the regression test.

Suggested Change: Add check: bool(root.c0.isZero()) and check: not bool(root.c1.isZero()) for non-QR inputs.


Low

Consider Addressing — Defense-in-depth improvements and code quality enhancements.

[BUG] BUG-A-001, BUG-B-002: invsqrt(0) called in main algorithm for purely-real inputs - square_root_fp2.nim:195

Location: constantine/math/extension_fields/square_root_fp2.nim:195
Severity: Low
Confidence: 0.9 (2 reviewers agreed)

Diff Under Review:

   sqrt_invsqrt(sqrt = t1, invsqrt = t3, t2)
   a.c0.ccopy(t1, result)

Issue: sqrt_invsqrt called with t2 = 0 for all purely-real inputs, computing invsqrt(0)

For input (a0, 0) where a0 is a QNR:

  1. Main algorithm computes t2 = 0 (traced through lines 186-193)
  2. sqrt_invsqrt(sqrt=t1, invsqrt=t3, t2=0) → calls invsqrt(0)
  3. invsqrt(0) returns 0 (via modular exponentiation), producing (0, 0) output
  4. The fallback overwrites with the correct (0, √(a0/β)) result

For input (a0, 0) where a0 is a QR:

  1. t1.sqrt_if_square() returns +a0 (deterministic sign)
  2. t2 = (a0 + a0)/2 = a0, t3 = (a0 - a0)/2 = 0
  3. quadResidTest = isSquare(a0) → true, so t2 stays a0
  4. sqrt_invsqrt(sqrt=t1, invsqrt=t3, t2=a0) → valid call
  5. But the fallback still runs and overwrites the result

BUG-A-001 focused on the QR case where invsqrt(0) is triggered. BUG-B-002 focused on the non-QR case where invsqrt(0) is called. Both are valid but apply to different input strata. The fallback always corrects the result, so this is a code quality issue, not a functional bug.

Fact-check: Verified by tracing the Adj-Rodríguez algorithm in lines 176-200. For (a0, 0) QNR inputs: t2 becomes 0 at line 193 (t2.ccopy(t3, not quadResidTest) where quadResidTest = false). For QR inputs: t2 stays nonzero (valid call). BUG-A-001's claim about invsqrt(0) for QR inputs was partially overstated — t2 is nonzero for QR inputs when sqrt(a0²) = +a0. However, BUG-B-002's claim about the non-QR case is correct.

Suggested Change: Consider guarding the main algorithm's sqrt_invsqrt when t2 is zero. The current fallback already handles correctness, but the wasted invsqrt(0) is unnecessary computation.


[BUG] BUG-B-001: {.noInit.} variable candBOk always read via bitwise AND - square_root_fp2.nim:219,234

Location: constantine/math/extension_fields/square_root_fp2.nim:219,234
Severity: Low
Confidence: 0.9

Diff Under Review:

+  var candBOk{.noInit.}: SecretBool
...
+  fbC1.ccopy(candB, (not candAOk) and candBOk)

Issue: candBOk is declared with {.noInit.} and is always read due to bitwise AND semantics on SecretBool.

The and operator on SecretBool (CTBool[SecretWord]) is a bitwise AND that evaluates both operands. This means candBOk is always read regardless of candAOk's value. For a0 = 0 (where both candAOk and candBOk would be true), this is harmless since false and garbage = false for bitwise AND.

Fact-check: Verified: SecretBool is CTBool[SecretWord] and and on CTBool uses fmap(x, and, y) which evaluates both operands (from ct_routines.nim). The {.noInit.} on SecretBool is actually NOT unique to this code — it's used in ec_scalar_mul.nim:299,389, ec_scalar_mul_vartime.nim:419, gt_exponentiations.nim:72, and gt_exponentiations_vartime.nim:276. CONS-A-002's claim that this is the "only occurrence" is incorrect.

Suggested Change: Initialize candBOk to a safe default for clarity, or restructure to avoid reading an uninitialized variable.


[BUG] BUG-A-003: Fallback overwrites main algorithm sign for purely-real QR inputs - square_root_fp2.nim:240-242

Location: constantine/math/extension_fields/square_root_fp2.nim:240-242
Severity: Low
Confidence: 0.6

Diff Under Review:

+  let useFallback = a1_isZero and result
+  a.c0.ccopy(fbC0, useFallback)
+  a.c1.ccopy(fbC1, useFallback)

Issue: Fallback unconditionally overwrites main algorithm for ALL purely-real inputs when result = true.

For (a0, 0) QR inputs: useFallback = true (since a1_isZero and result are both true). The main algorithm already produces a correct result (±√a0, 0), but the fallback overwrites it with fbC0 = √a0 (positive sign). This changes the canonical sign for a specific subcase.

Fact-check: Verified. The useFallback condition is a1_isZero and result, which is true for all purely-real square inputs (both QR and non-QR). For QR inputs, the main algorithm's result is correct and gets overwritten by the fallback. Both paths produce the same value (the positive root), so this is a code quality observation, not a correctness bug.

Suggested Change: No change needed unless sign consistency across all code paths is required. The current behavior is deterministic and correct.


[COVERAGE] COV-A-003, QA-004: Complex extension fallback branch is dead code - square_root_fp2.nim:220-224

Location: constantine/math/extension_fields/square_root_fp2.nim:220-224
Severity: Low
Confidence: 1.0 (3 reviewers agreed: COV-A-003, QA-004, BUG-A-002)

Diff Under Review:

+  when a.fromComplexExtension():
+    # β = −1, so a0/β = −a0. Compute candidateB = (0, sqrt(−a0)).
+    candB = a0_orig
+    candB.neg()
+    candBOk = candB.sqrt_if_square()

Issue: The fromComplexExtension() branch in the fallback is dead code with current curves.

Only BLS12_377 uses sqrt_if_square_generic (line 250: when Fp2.Name == BLS12_377). BLS12_377 has nonresidue_fp = -5, so fromComplexExtension() returns false. All curves with nonresidue_fp = -1 (BLS12_381, BN254_Nogami, BN254_Snarks) use sqrt_if_square_opt instead.

Fact-check: Verified against curve config: BLS12_377 has nonresidue_fp: -5, BLS12_381/BN254 have nonresidue_fp: -1. The dispatch at line 250 routes only BLS12_377 to sqrt_if_square_generic. CONFIRMED dead code.

Note on BUG-A-002: BUG-A-002 claimed this branch fails for curves where −1 is a QR (p ≡ 1 mod 4). However, for curves using complex extension, β = -1 is a QNR by definition (it's the extension non-residue). Since QNR × QNR = QR, sqrt(-a0) exists when a0 is a QNR. BUG-A-002's mathematical concern was incorrect — the complex extension path is correct for its intended use case. The real issue is that the branch is simply dead code today.

Suggested Change: No change needed. The code is structurally correct for any future curve that might use complex extension AND sqrt_if_square_generic. Document that this branch is untested.


[COVERAGE] COV-A-004: No BLS12_377-specific non-square rejection test - square_root_fp2.nim:240-242

Location: constantine/math/extension_fields/square_root_fp2.nim:240-242
Severity: Low
Confidence: 0.9

Issue: All existing non-square rejection tests target BLS12_381, not BLS12_377.

The useFallback = a1_isZero and result guard relies on result being false for non-squares. If the new code somehow corrupted result, non-squares could be incorrectly reported as squares. A BLS12_377-specific regression test would close this gap.

Fact-check: Verified against test file (lines 125-143). Both non-square tests use Fp2[BLS12_381], which dispatches to sqrt_if_square_opt, not sqrt_if_square_generic.

Suggested Change: Add a BLS12_377-specific non-square rejection test.


[COVERAGE] COV-B-003: Existing randomSqrtCheck cannot trigger the (non-QR, 0) bug - t_fp2_sqrt.nim:50-70

Location: tests/math_extension_fields/t_fp2_sqrt.nim:50-70
Severity: Low (informational — confirms test was necessary)
Confidence: 0.95

Issue: randomSqrtCheck generates random Fp2 elements, squares them, and checks sqrt. The squared result always has c1 ≠ 0 (for random inputs), so the (a0, 0) stratum is never exercised.

For a random Fp2 element a, a².c1 = 2·a.c0·a.c1. This is zero only when a.c0 = 0 or a.c1 = 0, which occurs with probability 2/p — astronomically low for 254-bit fields. With 96 total tests (8 iterations × 3 generators × 4 curves), the chance of hitting (a0, 0) is effectively zero.

Fact-check: Verified against randomSqrtCheck code (lines 50-70). The procedure generates random Fp2, squares it, then checks sqrt — confirming the (a0, 0) stratum is not exercised.

Suggested Change: No change needed. This confirms the regression test was genuinely necessary.


[ARCHITECTURE] ARCH-A-003: Fallback interleaves compile-time branching inside runtime constant-time path - square_root_fp2.nim:218-234

Location: constantine/math/extension_fields/square_root_fp2.nim:218-234
Severity: Low
Confidence: 0.8

Issue: The when a.fromComplexExtension() compile-time branch produces two algorithmically different code paths within the runtime constant-time fallback.

Both paths compute candBOk but use different algorithms: sqrt_if_square() vs sqrt_ratio_if_square(). This is consistent with the existing pattern in the file (lines 178-182 for norm computation) and is appropriate since the branch is determined by curve type, not runtime data.

Fact-check: Verified against source. The when pattern is consistent with the rest of the function. No concerns raised.

Suggested Change: No change needed.


[ARCHITECTURE] ARCH-A-002: Regression test runs on all curves but only BLS12_377 exercises the fix - t_fp2_sqrt.nim:117-122

Location: tests/math_extension_fields/t_fp2_sqrt.nim:117-122
Severity: Low
Confidence: 0.9

Issue: The regression test runs on all 4 curves, but only BLS12_377 uses sqrt_if_square_generic (the fixed function).

The test comment at line 121 says "Pre-fix behaviour on BLS12_377" but the test runs for all curves. For non-BLS12_377 curves, the test passes because sqrt_if_square_opt handles the case correctly via rotation.

Fact-check: Verified. The staticFor(curve, TestCurves) loop at line 111 runs the test for all 4 curves. The comment is accurate but could be clearer.

Suggested Change: Clarify the test comment to note that only BLS12_377 was affected, while other curves are tested for defense-in-depth.


[ARCHITECTURE] ARCH-B-002: sqrt_if_square_opt may share the same bug — correctness depends on sqrt() sign - square_root_fp2.nim:99-145

Location: constantine/math/extension_fields/square_root_fp2.nim:99-145
Severity: Low
Confidence: 0.4

Issue: sqrt_if_square_opt's correctness on (a0, 0) with a0 a non-residue is contingent on sqrt() sign convention and invsqrt behavior on QNR inputs.

Tracing the opt path for (a0, 0) with QNR a0:

  1. t1 = a0², t1.sqrt() → ±a0 (sign depends on Fp sqrt convention)
  2. After the t1.ccopy(t2, t1.isZero()) + div2() dance: t1 = a0
  3. cand.c0.invsqrt(t1)invsqrt(a0) where a0 is a QNR — this is technically undefined behavior

However, sqrt_rotate_extension tests 4 rotation candidates, and the tests pass for all non-BLS12_377 curves. This suggests the rotation extension handles this case through one of its 4 candidates, or the QNR invsqrt produces a value that happens to work.

Fact-check: Verified against sqrt_if_square_opt source code (lines 99-145). The invsqrt(a0) call for QNR a0 is concerning in theory. The tests passing suggests the rotation extension provides adequate coverage, but the correctness is fragile and test-dependent.

Suggested Change: Add a dedicated comment in sqrt_if_square_opt explaining how the purely-real QNR case is handled, or confirm the rotation step provides the correct alignment.


[CONSISTENCY] CONS-A-001, CONS-B-001, CONS-B-002: Naming convention drift in fallback block - square_root_fp2.nim:210-242

Location: constantine/math/extension_fields/square_root_fp2.nim:210-242
Severity: Low
Confidence: 0.9 (3 reviewers agreed)

Issue: Mixed naming conventions within the fallback block and divergence from file convention.

  1. Within the fallback: candA/candB (readable) vs fbC0/fbC1 (terse) — inconsistent within the same 40-line block
  2. File-level: The file uses short names (t1, t2, t3, cand, coeff), while the fallback uses descriptive names (a0_orig, a1_isZero, useFallback, candAOk, candBOk)
  3. Comment style: Multi-paragraph academic comments (lines 161-169, 202-209) diverge from the file's terse inline comments (# a0²)

Fact-check: Verified against existing code conventions. The naming drift is real but the descriptive names are arguably better for the complex mathematical context of the fallback.

Suggested Change: Pick one naming style for the fallback block. The descriptive style is preferable given the mathematical complexity. Accept as-is or standardize internally.


[CONSISTENCY] CONS-A-003: let a0_orig missing {.noInit.} per towers.nim convention - square_root_fp2.nim:173

Location: constantine/math/extension_fields/square_root_fp2.nim:173
Severity: Low
Confidence: 0.7

Diff Under Review:

+  let a0_orig = a.c0

Issue: Convention in towers.nim uses {.noInit.} for let bindings of Fp field elements to avoid redundant zero-initialization.

Examples from towers.nim: let t {.noInit.} = a.c0 (lines 469, 582, 620, 643).

Fact-check: Verified against towers.nim. The {.noInit.} convention for let bindings of field elements is established.

Suggested Change: let a0_orig {.noInit.} = a.c0


[PERF] PERF-A-002: beta = NonResidue recomputed every call - square_root_fp2.nim:229-231

Location: constantine/math/extension_fields/square_root_fp2.nim:229-231
Severity: Low
Confidence: 0.7

Diff Under Review:

+    var beta{.noInit.}: typeof(a.c0)
+    beta.setOne()
+    beta *= NonResidue                        # beta = β

Issue: beta = 1 * NonResidue is a single unnecessary field multiplication. NonResidue is a compile-time constant.

Fact-check: Verified. NonResidue is a compile-time constant for each curve. The setOne() + *= dance could be replaced with beta = NonResidue or passing NonResidue directly.

Suggested Change: Replace with candBOk = candB.sqrt_ratio_if_square(a0_orig, NonResidue) if the function accepts it directly, or beta = NonResidue without the setOne() step.


[QA] QA-001: Euler's criterion proof omits nonzero precondition - square_root_fp2.nim:166

Location: constantine/math/extension_fields/square_root_fp2.nim:166
Severity: Low
Confidence: 0.9

Diff Under Review:

+  # (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

Issue: The Euler's criterion argument a0^((p²−1)/2) = 1 silently assumes a0 ≠ 0. When a0 = 0, the expression evaluates to 0^((p+1)/2) = 0, not 1. The code handles (0, 0) correctly, but the proof comment doesn't acknowledge this precondition.

Fact-check: Verified. The comment at line 166 presents the Euler's criterion argument without mentioning the a0 ≠ 0 precondition. The later comment at line 206 correctly states "For any nonzero a0 in Fp".

Suggested Change: Add "(for nonzero a0:)" prefix to the Euler's criterion proof in the comment.


[QA] QA-003: Test docstring uses "alignment step" — actual code uses "rotation step" - t_fp2_sqrt.nim:84

Location: tests/math_extension_fields/t_fp2_sqrt.nim:84-85
Severity: Low
Confidence: 0.9

Diff Under Review:

+  ## (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

Issue: The test docstring uses "alignment step" but the actual code uses sqrt_rotate_extension (a rotation step). The term "alignment step" does not appear anywhere in the codebase.

Fact-check: Verified. The function at line 45 is sqrt_rotate_extension. The test comment at line 85 says "alignment step" which is not used anywhere in the codebase.

Suggested Change: Replace "alignment step" with "rotation step".


Informational

Observational notes — No action required.

[QA] QA-005: sqrt_invsqrt(0) shorthand notation could confuse readers - square_root_fp2.nim:164

Location: constantine/math/extension_fields/square_root_fp2.nim:164
Severity: Informational
Confidence: 0.7

Diff Under Review:

+  # algorithm silently picks t2 = 0 below, sqrt_invsqrt(0) = (0, 0), and the

Issue: sqrt_invsqrt takes 3 parameters (sqrt, invsqrt, a), but the comment writes sqrt_invsqrt(0) as if it takes one.

Fact-check: Verified. The function signature is func sqrt_invsqrt*(sqrt, invsqrt: var Fp, a: Fp).

Suggested Change: Use sqrt_invsqrt(..., 0) for clarity.


[PERF] PERF-A-003: ccopy overhead on non-fallback path - square_root_fp2.nim:240-241

Location: constantine/math/extension_fields/square_root_fp2.nim:240-241
Severity: Informational
Consolidated confidence: 0.9

Issue: The ccopy operations at lines 240-241 execute on every call even when useFallback = false. This is the cost of constant-time selection — 12 limb moves are performed regardless of whether the fallback result is used. This is an inherent cost of the constant-time design and not actionable without sacrificing the timing guarantee.



Unverified Claims

These findings could not be confirmed but may warrant manual review:

ID Category File:Line Issue Reason Unverified
BUG-A-002 Bug square_root_fp2.nim:220-224 Dead-code fromComplexExtension path fails for curves where −1 is a QR False positive — For complex extensions, β = -1 is a QNR by definition, and QNR × QNR = QR, so sqrt(-a0) exists when a0 is QNR. The mathematical concern was incorrect. The real issue is that this branch is simply dead code (no curve uses both complex extension and sqrt_if_square_generic).
PERF-B-002 Performance square_root_fp2.nim:220-232 sqrt_ratio_if_square could be simplified for complex-extension curves False positive — The else branch is the only one that runs for BLS12_377 (which doesn't use complex extension). The claim about simplification is about the when branch which is dead code. The sqrt_ratio_if_square approach in the else branch is the correct and optimal choice.
CONS-A-002 Consistency square_root_fp2.nim:219 var candBOk{.noInit.}: SecretBool is unique pattern in entire codebase False positive{.noInit.} on SecretBool is used in 4+ other files: ec_scalar_mul.nim:299,389, ec_scalar_mul_vartime.nim:419, gt_exponentiations.nim:72, gt_exponentiations_vartime.nim:276. Not unique.
QA-002 QA square_root_fp2.nim:205 candidateB formula imprecise for generic extension Low confidence — The comment says candidateB = (0, sqrt_fp(a0 / β)) and the code computes sqrt_ratio_if_square(a0_orig, beta) where beta = NonResidue. This is correct. The concern about β = u² notation is a readability suggestion, not a factual error. Kept at Low severity.

Recommendations

Prioritize in this order:

  1. High findings — Fix before merge
  2. Medium findings — Fix in next iteration or track as technical debt
  3. Low findings — Address when convenient, or acknowledge as accepted trade-offs
  4. Informational findings — No action required

Immediate Actions (High)

  1. PERF-A-001/PERF-B-001/ARCH-A-001/ARCH-B-001 (square_root_fp2.nim:202-242) — Benchmark the ~60-75% performance regression on BLS12_377 pairing and hash-to-curve workloads. Document the constant-time tradeoff. Consider whether purely-real inputs are frequent enough to warrant a conditional branch with compensating delay.

Technical Debt (Medium)

  1. COV-A-001 (square_root_fp2.nim:214-216) — Add a test case for (QR, 0) input, e.g., (4, 0) or (9, 0), to exercise the candidateA fallback path.
  2. COV-A-002/COV-B-004 (t_fp2_sqrt.nim:88) — Add standalone test cases for (0, 0) and (1, 0) inputs.
  3. COV-B-001 (t_fp2_sqrt.nim:101-103) — Add negated root verification: nr.neg(root); nr.square(); check: bool(nr == x).
  4. COV-B-002 (t_fp2_sqrt.nim:101-103) — Add structural shape assertion: check: bool(root.c0.isZero()) and check: not bool(root.c1.isZero()) for non-QR inputs.

Technical Debt (Low)

  1. BUG-A-001/BUG-B-002 (square_root_fp2.nim:195) — Consider guarding sqrt_invsqrt when t2 is zero to avoid wasted invsqrt(0) computation.
  2. BUG-B-001 (square_root_fp2.nim:219,234) — Initialize candBOk to a safe default for clarity.
  3. BUG-A-003 (square_root_fp2.nim:240-242) — No change needed. Accept as-is (fallback overwrites QR result with same value).
  4. COV-A-003/QA-004 (square_root_fp2.nim:220-224) — No change needed. Document that the complex extension branch is dead code with current curves.
  5. COV-A-004 (square_root_fp2.nim:240-242) — Add a BLS12_377-specific non-square rejection test.
  6. COV-B-003 (t_fp2_sqrt.nim:50-70) — No change needed. Confirms regression test was necessary.
  7. ARCH-A-003 (square_root_fp2.nim:218-234) — No change needed. Pattern is consistent with the rest of the file.
  8. ARCH-A-002 (t_fp2_sqrt.nim:117-122) — Clarify the test comment to note that only BLS12_377 was affected.
  9. ARCH-B-002 (square_root_fp2.nim:99-145) — Add a comment in sqrt_if_square_opt explaining how the purely-real QNR case is handled.
  10. CONS-A-001/CONS-B-001/CONS-B-002 (square_root_fp2.nim:210-242) — Accept as-is or standardize naming within the fallback block.
  11. CONS-A-003 (square_root_fp2.nim:173) — Add {.noInit.} to let a0_orig per towers.nim convention.
  12. PERF-A-002 (square_root_fp2.nim:229-231) — Replace setOne() + *= with direct beta = NonResidue or pass NonResidue directly.
  13. QA-001 (square_root_fp2.nim:166) — Add "(for nonzero a0:)" prefix to the Euler's criterion proof comment.
  14. QA-003 (t_fp2_sqrt.nim:84) — Replace "alignment step" with "rotation step".

@mratsim
mratsim merged commit ea8c268 into mratsim:master Jun 9, 2026
9 checks passed
@yelhousni
yelhousni deleted the fix/fp2-sqrt-bls12377 branch June 9, 2026 15:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants