Skip to content

pkg/math/polynomial: add LagrangeAtZeroBigInt for tfhe combine (issue #20 precursor)#24

Open
abhicris wants to merge 1 commit into
mainfrom
feat/2026-06-02-lagrange-bigint-for-tfhe-combine
Open

pkg/math/polynomial: add LagrangeAtZeroBigInt for tfhe combine (issue #20 precursor)#24
abhicris wants to merge 1 commit into
mainfrom
feat/2026-06-02-lagrange-bigint-for-tfhe-combine

Conversation

@abhicris
Copy link
Copy Markdown
Contributor

@abhicris abhicris commented Jun 1, 2026

Problem

protocols/tfhe/tfhe.go is gated by ALLOW_FAKE_TFHE_FOR_TESTING_ONLY=1 and CombineShares runs single-party decryption while every party holds the full master key — the entire point of "threshold" is missing. Closing issue #20 requires (a) real PartialDecrypt that produces noise-added LWE partial shares and (b) real CombineShares that Lagrange-interpolates those partials in the LWE ciphertext modulus.

The (b) side is testable in isolation: given (x_i, y_i) points in F_q, return p(0). That's what this PR ships, so the eventual real CombineShares can call it without re-inventing the math under time pressure.

The existing pkg/math/polynomial/Lagrange operates over curve.Scalar (for FROST / CMP signing). TFHE combine needs the same shape but over a *big.Int modulus — the LWE/RLWE coefficient modulus is not an elliptic-curve scalar field.

What this changes

Adds two files in pkg/math/polynomial/:

File Lines Purpose
lagrange_bigint.go 106 LagrangeAtZeroBigInt(shares map[party.ID]*big.Int, modulus *big.Int) (*big.Int, error)
lagrange_bigint_test.go 225 constant-polynomial, secret-recovery round-trip ((3,3), (3,5), (11,21)), subset independence, canonical range, empty/invalid/duplicate error paths

Behaviour:

  • p(0) = Σᵢ yᵢ · Lᵢ(0) with Lᵢ(0) = Πⱼ≠ᵢ xⱼ · (xⱼ - xᵢ)⁻¹, all arithmetic in F_modulus.
  • xᵢ = BigEndianBytes(id) mod modulus — same shape curve.Scalar.SetBytes uses, so the x-coordinate semantics match the existing Lagrange for the same party.ID.
  • Errors are caught before any contribution is summed: empty shares, modulus ≤ 1, duplicate x-coordinates (collisions under the modulus), zero denominators, non-invertible denominators (composite modulus diagnostic).
  • Result is canonicalised to 0 ≤ result < modulus.

What this does NOT close

Issue #20 is fully closed when protocols/tfhe/tfhe.go::PartialDecrypt stops being an HMAC stub and actually produces noise-added LWE shares, and CombineShares calls LagrangeAtZeroBigInt on those shares. That's a separate PR — the math here is the half that doesn't depend on the LWE plumbing and is testable independently against hand-rolled polynomials.

Acceptance

Cost

  • Build: one new file in pkg/math/polynomial/, no new external imports beyond math/big (stdlib).
  • Runtime: O(t²) field multiplies per call where t is the share count — same complexity class as the existing Lagrange. At t=21 and a near-2^64 modulus the test runs in well under 100 ms.
  • Maintenance: matched against the established sibling pattern (FROST uses Lagrange/LagrangeFor/LagrangeSingle — same package, same names, no policy surprise).

Issue #20 (Threshold-FHE: replace fake partial-decrypt stub with real
Lagrange-interpolated distributed decryption) is the goal; this PR is
the precursor primitive — a big.Int sibling of the existing curve.Scalar
Lagrange. It is the function the real CombineShares will call once the
PartialDecrypt side lands.

What this adds:

  LagrangeAtZeroBigInt(shares, modulus) -> p(0), error

  Returns p(0) where p is the polynomial of minimal degree passing
  through (x_i = bigEndianBytes(id_i) mod modulus, y_i = shares[id_i])
  for every party.ID in shares. All arithmetic in F_modulus; modulus is
  expected prime (Lagrange combine requires modular inverses to exist).

  Error contract is explicit and deterministic:
    - empty shares
    - modulus <= 1 or nil
    - two party IDs reducing to the same x-coordinate (caught up front
      before any contribution is summed, so failures don't taint state)
    - zero denominator (x_j == x_i mod modulus)
    - non-invertible denominator (composite modulus path)

  Result is always canonicalized to 0 <= result < modulus.

Why a big.Int sibling and not reuse the existing curve.Scalar version:
the LWE/RLWE ciphertext modulus that TFHE decryption combines in is not
an elliptic-curve scalar field — it's a ring/lattice modulus (the
PN9QP28_STD128 set referenced in issue #20 uses 28-bit and 54-bit
coefficient moduli). curve.Scalar can't represent those without
shoehorning, and reusing it would conflate two distinct algebraic
contexts in the same call site. Keeping them sibling functions in the
same package matches the structure FROST / CMP already uses.

Test coverage:

  - constant polynomial (every share == c → p(0) == c)
  - random degree-(t-1) polynomial, recover p(0) for (3,3), (3,5), and
    (11,21) thresholds; uses a near-2^64 prime for the (11,21) case to
    exercise the multi-word path
  - subset independence: two disjoint t-sized subsets of a (t,n) sharing
    recover the same secret
  - canonical range invariant (0 <= result < modulus)
  - empty shares, invalid modulus, duplicate x-coordinate error paths

What this does NOT close:

Issue #20 is fully closed only when protocols/tfhe/tfhe.go stops being
an HMAC stub and PartialDecrypt produces real LWE partial-decryption
shares. That's the PartialDecrypt side. This PR is the Combine side —
the math the real CombineShares will call. They can land independently;
the Combine is testable in isolation against a hand-rolled polynomial,
which is what the test file does.

Refs:
  - issue #20 (this repo)
  - LP-137 (TFHE Real Threshold Spec, referenced in protocols/tfhe/tfhe.go header)
  - LP-181 Magnetar (prerequisite chain per project memory)
  - luxfi/fhe PR #21 (prior art, closed; partial-decrypt API shape)
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.

1 participant