feat: init docs

This commit is contained in:
Prad Nukala
2026-09-02 15:29:51 -04:00
parent d2390a8aad
commit 69425e2b7a
45 changed files with 11790 additions and 18 deletions
+452
View File
@@ -0,0 +1,452 @@
---
title: Accumulator
description: Pairing-based ECC accumulator — a constant-size commitment to a set, constant-size membership witnesses, and a zero-knowledge membership proof that hides which element is held.
sidebar:
label: Accumulator
order: 3
icon: list-checks
---
The `accumulator` package implements the pairing-based accumulator of
[eprint 2020/777](https://eprint.iacr.org/2020/777.pdf), together with the zero-knowledge
proof of knowledge from section 7 of that paper. Its own package doc states the scope limit
up front: **only the membership-witness case is implemented**. Non-membership witnesses, and
the accumulator initialisation those would require, are deliberately absent —
`Accumulator.New` simply sets the initial value to the G1 generator.
## The value proposition
Three properties, and they are the entire reason to reach for this instead of a Merkle tree
or a plain list:
- **The accumulator is one curve point** regardless of how many elements it holds. On
BLS12-381 that is a 48-byte compressed G1 point; `Accumulator.MarshalBinary` returns 60
bytes with its BARE framing, at 1 element and at 5000 alike.
- **A membership witness is one curve point plus its element.**
`MembershipWitness.MarshalBinary` is 92 bytes, again independent of set size.
- **The membership proof hides the element.** A verifier learns that the prover holds a valid
witness for *some* accumulated element, not which one.
That last property is what makes this a privacy-preserving revocation mechanism. An issuer
accumulates one element per valid credential and publishes the accumulator. A holder proves
its credential is still accumulated without identifying the credential, and therefore without
being linkable across presentations. Revocation is a `Remove` by the manager.
## When not to use it
Only the holder of the `SecretKey` can mutate the set or issue a witness — `Add`, `Remove`,
`AddElements`, `Update`, and `MembershipWitness.New` all take `*SecretKey`. If you need a
publicly-updatable set, or set membership without a trusted manager, this is the wrong tool.
If you need non-membership proofs, they are not implemented. If you need proofs cheaper than
a multi-pairing per verification, look elsewhere.
Also note the operational cost: every update invalidates every outstanding witness. See the
[witness staleness](#witness-staleness-is-the-hard-part) warning below before committing to
this design.
## Types
Everything here is over a **pairing** curve — in practice `curves.BLS12381(&curves.PointBls12381G1{})`.
The accumulator value lives in G1; the public key lives in G2.
| Type | Definition | Notes |
| --- | --- | --- |
| `Element` | `curves.Scalar` | A set member. Callers hash application data into it, e.g. `curve.Scalar.Hash([]byte("credential-id"))`. |
| `Coefficient` | `curves.Point` | Batch-update polynomial coefficients published by the manager alongside an `Update`. |
| `Accumulator` | struct, unexported `value curves.Point` | The set commitment. |
| `SecretKey` | struct, unexported `value curves.Scalar` | The manager's alpha. |
| `PublicKey` | struct, unexported `value curves.PairingPoint` | `alpha · G2`. |
| `Delta` | struct, unexported `d curves.Scalar`, `p curves.Point` | Witness-update material. See the caveat — you cannot build one. |
| `MembershipWitness` | struct, unexported `c curves.Point`, `y curves.Scalar` | A holder's witness for element `y`. |
Every one of these types implements `MarshalBinary() ([]byte, error)` and
`UnmarshalBinary([]byte) error` — the encoding is BARE (`git.sr.ht/~sircmpwn/go-bare`). Note
that all fields are unexported, so binary marshalling is the *only* way to move these values
across a process boundary; there is no JSON codec and no field access.
## Keys
<TypeTable
type={{
"SecretKey.New": {
type: "func(curve *curves.PairingCurve, seed []byte) (*SecretKey, error)",
required: true,
description: "Derives alpha as curve.Scalar.Hash(seed). Fully deterministic in the seed; performs no validation of seed quality or length.",
},
"SecretKey.GetPublicKey": {
type: "func(curve *curves.PairingCurve) (*PublicKey, error)",
required: true,
description: "Returns alpha times the G2 generator. Errors if the key or curve is nil.",
},
"SecretKey.BatchAdditions": {
type: "func(additions []Element) (Element, error)",
description: "product(y + alpha) over the additions. The multiplier applied to the accumulator on a batch add.",
},
"SecretKey.BatchDeletions": {
type: "func(deletions []Element) (Element, error)",
description: "1/product(y + alpha) over the deletions.",
},
"SecretKey.CreateCoefficients": {
type: "func(additions, deletions []Element) ([]Element, error)",
description: "Batch polynomial coefficients per page 7 of the paper. Update calls this for you; call it directly only if you are reimplementing Update.",
},
}}
/>
:::warning[SecretKey.New is a bare hash of the seed]
`SecretKey.New` is `sk.value = curve.Scalar.Hash(seed)` and always returns a nil error. It
does not check seed length or entropy. A short or predictable seed yields a guessable alpha,
and alpha is total control over the set. Feed it at least 32 bytes from a CSPRNG, or a
properly derived key — see [Key derivation](/symmetric/key-derivation).
:::
## Accumulator operations
<TypeTable
type={{
"Accumulator.New": {
type: "func(curve *curves.PairingCurve) (*Accumulator, error)",
required: true,
description: "Sets the value to the G1 generator. Called on the receiver, so the idiom is new(Accumulator).New(curve).",
},
"Accumulator.WithElements": {
type: "func(curve *curves.PairingCurve, key *SecretKey, m []Element) (*Accumulator, error)",
description: "New plus a batch add: V = product(y + alpha) · V0. The usual way to bootstrap a populated set.",
},
"Accumulator.Add": {
type: "func(key *SecretKey, e Element) (*Accumulator, error)",
description: "V' = (y + alpha) · V. Errors if the accumulator value is nil or the identity.",
},
"Accumulator.AddElements": {
type: "func(key *SecretKey, m []Element) (*Accumulator, error)",
description: "Batch add. Does not emit coefficients, so holders cannot update from it — use Update if witnesses are outstanding.",
},
"Accumulator.Remove": {
type: "func(key *SecretKey, e Element) (*Accumulator, error)",
description: "V' = 1/(y + alpha) · V. Does not verify the element was ever added; removing an absent element silently produces a different accumulator.",
},
"Accumulator.Update": {
type: "func(key *SecretKey, additions, deletions []Element) (*Accumulator, []Coefficient, error)",
description: "Batch add and delete in one step, returning the coefficients holders need for BatchUpdate. This is the update method to use in production.",
},
}}
/>
:::danger[Every mutator mutates the receiver in place]
`Add`, `AddElements`, `Remove`, `Update`, `WithElements`, and `New` all assign to
`acc.value` and then return the same pointer. The returned `*Accumulator` is **not** a new
object — it is the receiver. This idiom compiles cleanly and reads like a functional API:
```go
newAcc, _, err := acc.Update(sk, additions, deletions) // newAcc == acc
```
but `acc` has already changed. If you need the previous state — to serve an older epoch, or
to roll back — call `MarshalBinary()` **before** the mutation and keep the bytes.
`MembershipWitness.New`, `ApplyDelta`, `BatchUpdate`, and `MultiBatchUpdate` behave the same
way on their receivers.
:::
## Witnesses
<TypeTable
type={{
"MembershipWitness.New": {
type: "func(y Element, acc *Accumulator, sk *SecretKey) (*MembershipWitness, error)",
required: true,
description: "Issues a witness for y against the current accumulator: C = 1/(y + alpha) · V. Requires the secret key, so only the manager can issue.",
},
"MembershipWitness.Verify": {
type: "func(pk *PublicKey, acc *Accumulator) error",
required: true,
description: "Multi-pairing check e(C, y·P̃ + Q̃) · e(-V, P̃) == 1. Returns nil on success. Public — any holder or verifier can run it.",
},
"MembershipWitness.BatchUpdate": {
type: "func(additions, deletions []Element, coefficients []Coefficient) (*MembershipWitness, error)",
required: true,
description: "Refreshes a stale witness against one published Update. This is the update path callers should use.",
},
"MembershipWitness.MultiBatchUpdate": {
type: "func(A [][]Element, D [][]Element, C [][]Coefficient) (*MembershipWitness, error)",
description: "Catches up across several epochs at once. All three outer slices must have the same length; index i is the i-th epoch's additions, deletions, and coefficients.",
},
"MembershipWitness.ApplyDelta": {
type: "func(delta *Delta) (*MembershipWitness, error)",
description: "Applies precomputed update material. Effectively unreachable — see caveats.",
},
}}
/>
### Witness staleness is the hard part
:::danger[Updating the set invalidates every outstanding witness]
The witness for element `y` is `1/(y + alpha) · V`, defined *relative to a specific
accumulator value*. The moment the manager adds or removes anything, `V` changes and every
holder's `Verify(pk, acc)` against the new accumulator fails. This is not a bug; it is
inherent to the construction, and it is the dominant operational cost of deploying it.
The manager must therefore publish, for each update, the `[]Coefficient` returned by
`Update` along with the exact `additions` and `deletions` element lists. Holders call
`BatchUpdate(additions, deletions, coefficients)` to move their witness forward. A holder
that misses epochs uses `MultiBatchUpdate` with the per-epoch slices.
Consequences you must design for:
- Coefficients and element lists are **not secret**, but they *do* reveal exactly which
elements were added and removed. Batch your updates if that leakage matters.
- A holder that skips an epoch and cannot obtain that epoch's coefficients can never repair
its witness without the manager reissuing via `MembershipWitness.New`.
- `AddElements` returns no coefficients at all. Use `Update` — even with an empty deletion
slice — whenever witnesses are outstanding.
- A stale witness does not report itself as stale. `Verify` returns the generic
`"invalid result"` from the failed pairing check, which is indistinguishable from a forged
witness. Track the accumulator epoch alongside the witness in your own state.
:::
## Zero-knowledge membership proof
The proof protocol from section 7 of the paper. Unlike the witness check, this hides `y`.
It is a three-move sigma protocol compiled with Fiat-Shamir, and — unusually — verification
is expressed as *recomputing the challenge and comparing it*, not as a `Verify` method.
<TypeTable
type={{
"ProofParams.New": {
type: "func(curve *curves.PairingCurve, pk *PublicKey, entropy []byte) (*ProofParams, error)",
required: true,
description: "Samples the public G1 generators X, Y, Z (and K) from the entropy, the public key, and the curve. Both prover and verifier must use identical params.",
},
"MembershipProofCommitting.New": {
type: "func(witness *MembershipWitness, acc *Accumulator, pp *ProofParams, pk *PublicKey) (*MembershipProofCommitting, error)",
required: true,
description: "Prover's commit phase. Holds all the blinding values; keep it private and short-lived.",
},
"MembershipProofCommitting.GetChallengeBytes": {
type: "func() []byte",
required: true,
description: "The transcript to hash for the challenge: V || Ec || T_sigma || T_rho || R_E || R_sigma || R_rho || R_delta_sigma || R_delta_rho.",
},
"MembershipProofCommitting.GenProof": {
type: "func(c curves.Scalar) *MembershipProof",
required: true,
description: "Computes the s values for the given challenge. Returns the proof to send. No error return.",
},
"MembershipProof.Finalize": {
type: "func(acc *Accumulator, pp *ProofParams, pk *PublicKey, challenge curves.Scalar) (*MembershipProofFinal, error)",
required: true,
description: "Verifier side: recomputes the commitment values from the proof, the accumulator, and the params.",
},
"MembershipProofFinal.GetChallenge": {
type: "func(curve *curves.PairingCurve) curves.Scalar",
required: true,
description: "Recomputes the Fiat-Shamir challenge from the finalized values. Verification succeeds iff this equals the challenge the prover used.",
},
}}
/>
## Full lifecycle
<Steps>
<Step title="Set up the manager">
Derive a `SecretKey` from a strong seed and publish the `PublicKey`.
</Step>
<Step title="Accumulate the initial members">
`new(Accumulator).WithElements(curve, sk, elements)` and publish the accumulator bytes.
</Step>
<Step title="Issue a witness">
For each holder, `new(MembershipWitness).New(element, acc, sk)` and deliver the witness
bytes privately. This step needs the secret key.
</Step>
<Step title="Prove membership">
The holder builds `ProofParams`, runs `MembershipProofCommitting`, hashes
`GetChallengeBytes()` into a challenge, and sends the challenge plus `GenProof(challenge)`.
</Step>
<Step title="Verify">
The verifier calls `Finalize` then `GetChallenge`, and accepts iff the recomputed
challenge equals the one it was given.
</Step>
<Step title="Update the set">
The manager calls `Update(sk, additions, deletions)` and publishes the new accumulator,
the element lists, and the coefficients.
</Step>
<Step title="Refresh witnesses">
Every holder calls `BatchUpdate(additions, deletions, coefficients)`, then can prove again
against the new accumulator.
</Step>
</Steps>
### Membership proof end to end
Grounded in `accumulator/proof_test.go` (`TestMembershipProof`) and
`accumulator/witness_test.go` (`Test_Membership`, `Test_Membership_Batch_Update`).
```go membership.go
package main
import (
"fmt"
"github.com/sonr-io/crypto/accumulator"
"github.com/sonr-io/crypto/core/curves"
)
func main() {
curve := curves.BLS12381(&curves.PointBls12381G1{})
// --- Manager setup -----------------------------------------------------
sk, err := new(accumulator.SecretKey).New(curve, []byte("32-plus-bytes-of-real-entropy..."))
if err != nil {
panic(err)
}
pk, err := sk.GetPublicKey(curve)
if err != nil {
panic(err)
}
// Application data is hashed into set elements.
elements := []accumulator.Element{
curve.Scalar.Hash([]byte("credential-3")),
curve.Scalar.Hash([]byte("credential-4")),
curve.Scalar.Hash([]byte("credential-5")),
curve.Scalar.Hash([]byte("credential-6")),
}
acc, err := new(accumulator.Accumulator).WithElements(curve, sk, elements)
if err != nil {
panic(err)
}
// --- Issue a witness (manager only, needs sk) --------------------------
wit, err := new(accumulator.MembershipWitness).New(elements[3], acc, sk)
if err != nil {
panic(err)
}
// The plain witness check reveals which element is held. Use it for
// self-diagnosis, not as a privacy-preserving presentation.
if err := wit.Verify(pk, acc); err != nil {
panic(err)
}
// --- Zero-knowledge membership proof -----------------------------------
// Both sides must derive identical ProofParams.
params, err := new(accumulator.ProofParams).New(curve, pk, []byte("proof-params/v1"))
if err != nil {
panic(err)
}
mpc, err := new(accumulator.MembershipProofCommitting).New(wit, acc, params, pk)
if err != nil {
panic(err)
}
challenge := curve.Scalar.Hash(mpc.GetChallengeBytes())
proof := mpc.GenProof(challenge)
// Verifier: it has acc, pk, params, the proof, and the challenge.
final, err := proof.Finalize(acc, params, pk, challenge)
if err != nil {
panic(err)
}
if final.GetChallenge(curve).Cmp(challenge) != 0 {
panic("membership proof rejected")
}
fmt.Println("membership proved without revealing which element")
// --- Manager revokes and adds ------------------------------------------
additions := []accumulator.Element{curve.Scalar.Hash([]byte("credential-7"))}
deletions := []accumulator.Element{curve.Scalar.Hash([]byte("credential-5"))}
// Note: this mutates acc in place and also returns it.
_, coefficients, err := acc.Update(sk, additions, deletions)
if err != nil {
panic(err)
}
// --- Holder refreshes its now-stale witness ----------------------------
if _, err := wit.BatchUpdate(additions, deletions, coefficients); err != nil {
panic(err)
}
if err := wit.Verify(pk, acc); err != nil {
panic(err) // would fail without the BatchUpdate above
}
}
```
Note that a fresh `ProofParams` per presentation is fine and is what the test does — proof
params are public and only need to agree between the two parties for that one exchange.
### Catching up across epochs
`MultiBatchUpdate` takes three parallel outer slices, one entry per epoch, and errors with
`"a, d, c should have same length"` if they disagree. From
`Test_Membership_Multi_Batch_Update`:
```go catchup.go
_, coeffs1, _ := acc.Update(sk, adds1, dels1)
_, coeffs2, _ := acc.Update(sk, []accumulator.Element{}, dels2)
_, coeffs3, _ := acc.Update(sk, []accumulator.Element{}, dels3)
a := [][]accumulator.Element{adds1, {}, {}}
d := [][]accumulator.Element{dels1, dels2, dels3}
c := [][]accumulator.Coefficient{coeffs1, coeffs2, coeffs3}
if _, err := wit.MultiBatchUpdate(a, d, c); err != nil {
panic(err)
}
if err := wit.Verify(pk, acc); err != nil {
panic(err)
}
```
## Caveats
:::danger[ApplyDelta is unreachable from outside the package]
`ApplyDelta(delta *Delta)` is exported, but `Delta`'s fields are unexported and the only
constructors — `evaluateDelta` and `evaluateDeltas` — are unexported. No exported function
anywhere in the package returns a `*Delta`. From another package you can therefore obtain one
only by `UnmarshalBinary`-ing bytes that some in-package code produced, and no in-package code
hands them to you. Treat `ApplyDelta` as an internal helper and use `BatchUpdate` /
`MultiBatchUpdate`, which construct the delta for you.
:::
:::warning[Remove does not check membership]
`Remove` applies `1/(y + alpha)` unconditionally. Removing an element that was never added
yields a mathematically valid but semantically meaningless accumulator, and every outstanding
witness silently stops verifying with no diagnostic. The package keeps no member list — the
manager must track set contents itself.
:::
:::warning[No length or duplicate checks on batch inputs]
`BatchAdditions`, `BatchDeletions`, and `Update` accept whatever elements you pass, including
duplicates. Adding the same element twice multiplies the accumulator by `(y + alpha)` twice,
and one `Remove` will not undo both.
:::
:::note[Non-membership is not implemented]
The paper's non-membership witnesses require a different accumulator initialisation
(`V0 = product(y + alpha) · P` over a designated set). This package's `New` sets `V0` to the
plain G1 generator and its own doc comment flags this as the reason non-membership is out of
scope. Do not attempt to derive non-membership proofs from this API.
:::
:::info[There is no MembershipProof.Verify]
Verification is a two-call sequence — `Finalize` then `GetChallenge` — followed by a scalar
comparison you write yourself. Forgetting the comparison, or comparing against a challenge
the *prover* supplied without deriving it from a transcript you control, defeats the proof.
The prover's challenge in the test is `curve.Scalar.Hash(mpc.GetChallengeBytes())`; a verifier
that wants soundness against a chosen-challenge prover should recompute the challenge from the
proof transcript rather than trusting a transmitted scalar.
:::
:::warning[A revoked holder gets a cryptic error, not a clean rejection]
`BatchUpdate` inverts `product(yD_i - y)` over the deletion list. If the holder's own element
`y` appears in `deletions`, that product is zero and the call fails with
`"no inverse exists"`. That is the revoked-credential path, and it surfaces as an internal
arithmetic error rather than a "you were revoked" signal. Handle it explicitly.
:::
On ordering: the `additions` and `deletions` slices a holder passes to `BatchUpdate` enter
only as products, so their internal order is irrelevant — but they must be the same *sets*
the manager passed to `Update`. The `[]Coefficient` slice is different: it is evaluated as a
polynomial by index, so it must be passed exactly as `Update` returned it, unreordered and
untruncated.
+367
View File
@@ -0,0 +1,367 @@
---
title: Bulletproofs
description: Logarithmic-size inner-product argument and the single and batched range proofs built on top of it — plus the exported-API gap that currently makes the range layer callable only from inside the package.
sidebar:
label: Bulletproofs
order: 4
icon: ruler
---
`bulletproof` implements the protocol of [eprint 2017/1066](https://eprint.iacr.org/2017/1066.pdf)
in two layers, and the distinction between them is the most important thing on this page.
The **inner-product argument** (IPP) is the engine. It proves knowledge of two scalar vectors
whose dot product is a claimed value, in proof size logarithmic in the vector length: a
length-256 pair of vectors yields 8 pairs of `L`/`R` points, not 256.
The **range proof** is the application. It encodes a secret value as its bit vector, expresses
"every bit is 0 or 1, and the bits sum to the committed value" as a single inner-product
relation, and then delegates to the IPP. That is why one prover constructor takes *two*
domain separators — the range layer and the IPP layer each need their own generator vectors.
:::danger[The range-proof API cannot be called from another package today]
`RangeProver.Prove`, `BatchProve`, `RangeVerifier.Verify`, and `VerifyBatched` all take a
`RangeProofGenerators` value. That struct's three fields — `g`, `h`, `u` — are unexported,
and the package exports no constructor, setter, or default for it. From outside
`package bulletproof` the compiler rejects any attempt to populate it:
```text
cannot refer to unexported field g in struct literal of type bulletproof.RangeProofGenerators
cannot refer to unexported field h in struct literal of type bulletproof.RangeProofGenerators
cannot refer to unexported field u in struct literal of type bulletproof.RangeProofGenerators
```
A zero-valued `RangeProofGenerators{}` does compile, but its points are `nil`, so `Prove`
panics on the first `proofGenerators.h.Mul(alpha)`. The verifier side has a second gap: it
needs the Pedersen commitment `capV`, and the helper that builds it (`getcapV`) is
unexported too.
The IPP layer has the mirror-image problem: `InnerProductVerifier.Verify` needs `capP`, and
the only thing that computes it is `InnerProductProver.getP`, whose own doc comment says
"This method should only be used for testing" — and which is unexported regardless.
The mathematics in this package is complete and its tests pass. The Go surface is not
finished. Until `RangeProofGenerators` gains an exported constructor and the commitment
helpers are exported, treat `bulletproof` as an in-repo building block rather than a public
API, and read the examples below as descriptions of the in-package tests.
:::
## When to use it
Range proofs are the right tool when a committed number must be shown to be well-formed
without being revealed — confidential amounts that must be non-negative and non-overflowing,
bounded bids, reserve proofs. The batched variant is the right tool when several such values
are proved at once by the same party, because it amortises them into a single proof.
They are the wrong tool for set membership (use the [accumulator](/zero-knowledge/accumulator)),
for proving knowledge of a discrete log (use [Schnorr](/zero-knowledge/schnorr), which is
orders of magnitude smaller and simpler), or for arbitrary statements — there is no
general-purpose circuit layer here.
## Layer 1: the inner-product argument
<TypeTable
type={{
"NewInnerProductProver": {
type: "func(maxVectorLength int, domain []byte, curve curves.Curve) (*InnerProductProver, error)",
required: true,
description: "Derives 2·maxVectorLength generator points by hashing Shake256(domain) to the curve, split into the G and H vectors. Note the curve is passed by value, not pointer.",
},
"InnerProductProver.Prove": {
type: "func(a, b []curves.Scalar, u curves.Point, transcript *merlin.Transcript) (*InnerProductProof, error)",
required: true,
description: "Proves knowledge of a and b with the inner product blinded into P by u. len(a) must equal len(b), be a power of two, and be at most maxVectorLength.",
},
"NewInnerProductVerifier": {
type: "func(maxVectorLength int, domain []byte, curve curves.Curve) (*InnerProductVerifier, error)",
required: true,
description: "Must be constructed with the same maxVectorLength, domain, and curve as the prover, or the generators differ and verification fails.",
},
"InnerProductVerifier.Verify": {
type: "func(capP, u curves.Point, proof *InnerProductProof, transcript *merlin.Transcript) (bool, error)",
required: true,
description: "capP is the commitment ⟨G,a⟩ + ⟨H,b⟩ + ⟨a,b⟩·u. Returns (false, nil) — not an error — when the proof simply does not check out.",
},
"InnerProductVerifier.VerifyFromRangeProof": {
type: "func(proofG, proofH []curves.Point, capPhmuinv, u curves.Point, tHat curves.Scalar, proof *InnerProductProof, transcript *merlin.Transcript) (bool, error)",
description: "The entry point RangeVerifier.Verify uses. It takes explicit generator slices and the range proof's P·h^-mu instead of a plain capP.",
},
"NewInnerProductProof": {
type: "func(curve *curves.Curve) *InnerProductProof",
description: "An empty proof to unmarshal into. Pair it with UnmarshalBinary; do not use it for anything else.",
},
}}
/>
`MarshalBinary() []byte` on both `InnerProductProof` and `RangeProof` returns **no error** —
an unusual signature that does not satisfy `encoding.BinaryMarshaler`. `UnmarshalBinary` does
return an error, and must be called on a proof from the matching `New…Proof(curve)`
constructor so that the curve is set.
```go ipp.go
// From bulletproof/ipp_verifier_test.go (TestIPPVerifyHappyPath).
curve := curves.ED25519()
vecLength := 256
prover, err := bulletproof.NewInnerProductProver(vecLength, []byte("test"), *curve)
if err != nil {
panic(err)
}
a := randScalarVec(vecLength, *curve) // in-package test helper
b := randScalarVec(vecLength, *curve)
u := curve.Point.Random(crand.Reader)
transcriptProver := merlin.NewTranscript("test")
proof, err := prover.Prove(a, b, u, transcriptProver)
if err != nil {
panic(err)
}
// len(proof.capLs) == log2(256) == 8
verifier, err := bulletproof.NewInnerProductVerifier(vecLength, []byte("test"), *curve)
if err != nil {
panic(err)
}
capP, err := prover.getP(a, b, u) // unexported: see the danger callout
if err != nil {
panic(err)
}
transcriptVerifier := merlin.NewTranscript("test")
verified, err := verifier.Verify(capP, u, proof, transcriptVerifier)
// verified == true
```
The Fiat-Shamir transcript is [merlin](https://github.com/gtank/merlin). Both sides construct
it with the *same label* — `merlin.NewTranscript("test")` in the tests — and each side must
start from a fresh transcript in the same state. A transcript is consumed by proving or
verifying; you cannot reuse one.
## Layer 2: range proofs
<TypeTable
type={{
"NewRangeProver": {
type: "func(maxVectorLength int, rangeDomain, ippDomain []byte, curve curves.Curve) (*RangeProver, error)",
required: true,
description: "Two domains: rangeDomain seeds the bit-vector generators, ippDomain seeds the inner-product generators. They must differ from each other, and must match the verifier's exactly.",
},
"RangeProver.Prove": {
type: "func(v, gamma curves.Scalar, n int, proofGenerators RangeProofGenerators, transcript *merlin.Transcript) (*RangeProof, error)",
required: true,
description: "Proves the value v committed as gamma·h + v·g lies in [0, 2^n). gamma is the Pedersen blinding factor and must be kept secret.",
},
"RangeProver.BatchProve": {
type: "func(v, gamma []curves.Scalar, n int, proofGenerators RangeProofGenerators, transcript *merlin.Transcript) (*RangeProof, error)",
required: true,
description: "Aggregated proof for len(v) values, each in [0, 2^n). Requires n·len(v) ≤ maxVectorLength. Output is one RangeProof, not a slice.",
},
"NewRangeVerifier": {
type: "func(maxVectorLength int, rangeDomain, ippDomain []byte, curve curves.Curve) (*RangeVerifier, error)",
required: true,
description: "Same four arguments as the prover, or verification fails.",
},
"RangeVerifier.Verify": {
type: "func(proof *RangeProof, capV curves.Point, proofGenerators RangeProofGenerators, n int, transcript *merlin.Transcript) (bool, error)",
required: true,
description: "capV is the single Pedersen commitment gamma·h + v·g. n must equal the prover's n.",
},
"RangeVerifier.VerifyBatched": {
type: "func(proof *RangeProof, capV []curves.Point, proofGenerators RangeProofGenerators, n int, transcript *merlin.Transcript) (bool, error)",
description: "Takes one commitment per proved value, in the same order the prover passed v.",
},
"NewRangeProof": {
type: "func(curve *curves.Curve) *RangeProof",
description: "An empty proof to unmarshal into.",
},
}}
/>
### What the parameters actually mean
**`n` is a bit width, and it defines the range.** `Prove` decodes `v` into an `n`-element bit
vector via `getaL`, so the provable range is the set of values representable in `n` bits:
`[0, 2^n)`. `n = 256` on ED25519 is the whole scalar field; `n = 64` is a u64-shaped amount.
`n` must be a power of two, because the inner-product recursion halves the vectors at every
step. The range prover does not check this up front — unlike `InnerProductProver.Prove`,
which has an explicit `isPowerOfTwo` gate — so a non-power-of-two `n` surfaces late as
`"length of scalars must be even"` from inside the recursion.
**`maxVectorLength` is a generator budget, not the range.** The constructor precomputes
`2 · maxVectorLength` curve points by hashing the domain. `Prove` requires
`n <= maxVectorLength` and trims the generator vectors to `n`. `BatchProve` requires
`n · len(v) <= maxVectorLength` — proving four 256-bit values needs
`NewRangeProver(256*4, …)`, exactly as `range_batch_prover_test.go` does.
**The two domains seed two independent generator sets.** `rangeDomain` produces the `G`/`H`
vectors that commit to the bit vectors; `ippDomain` produces the generators for the nested
inner-product argument. They must be distinct strings — reusing one string for both would
make the two generator sets identical, which is not a configuration the protocol's security
argument covers. The tests use `[]byte("rangeDomain")` and `[]byte("ippDomain")`.
**`proofGenerators` holds `g`, `h`, `u`, and both sides must use the same three points.**
`g` and `h` are the Pedersen bases for the value commitment; `u` blinds the inner product.
Because the commitment `capV = gamma·h + v·g` is computed from `g` and `h`, a verifier with
different points is verifying a commitment to a different value and gets `false`.
### Single-value range proof
Grounded in `bulletproof/range_prover_test.go` and `range_verifier_test.go`. The unexported
identifiers are marked; they are why this cannot be lifted verbatim into your own package.
```go range.go
curve := curves.ED25519()
n := 256
prover, err := bulletproof.NewRangeProver(n, []byte("rangeDomain"), []byte("ippDomain"), *curve)
if err != nil {
panic(err)
}
v := curve.Scalar.Random(crand.Reader) // the secret value
gamma := curve.Scalar.Random(crand.Reader) // the secret blinding factor
g := curve.Point.Random(crand.Reader)
h := curve.Point.Random(crand.Reader)
u := curve.Point.Random(crand.Reader)
proofGenerators := RangeProofGenerators{g: g, h: h, u: u} // unexported fields
transcript := merlin.NewTranscript("test")
proof, err := prover.Prove(v, gamma, n, proofGenerators, transcript)
if err != nil {
panic(err)
}
// Verifier: same n, same domains, same curve, same g/h/u, fresh transcript
// with the same label.
verifier, err := bulletproof.NewRangeVerifier(n, []byte("rangeDomain"), []byte("ippDomain"), *curve)
if err != nil {
panic(err)
}
transcriptVerifier := merlin.NewTranscript("test")
capV := getcapV(v, gamma, g, h) // unexported: h.Mul(gamma).Add(g.Mul(v))
verified, err := verifier.Verify(proof, capV, proofGenerators, n, transcriptVerifier)
// verified == true
```
In production the verifier receives `capV` over the wire; it never learns `v` or `gamma`.
The commitment is a plain Pedersen commitment, `h·gamma + g·v`, so you can compute it
yourself with `core/curves` point arithmetic without touching this package.
### Batched range proof
`BatchProve` proves `m` values in one proof. From `range_batch_prover_test.go`, four
256-bit values:
```go batch.go
curve := curves.ED25519()
n := 256
// maxVectorLength must cover n * m = 1024.
prover, err := bulletproof.NewRangeProver(n*4, []byte("rangeDomain"), []byte("ippDomain"), *curve)
if err != nil {
panic(err)
}
v := []curves.Scalar{
curve.Scalar.Random(crand.Reader),
curve.Scalar.Random(crand.Reader),
curve.Scalar.Random(crand.Reader),
curve.Scalar.Random(crand.Reader),
}
gamma := []curves.Scalar{
curve.Scalar.Random(crand.Reader),
curve.Scalar.Random(crand.Reader),
curve.Scalar.Random(crand.Reader),
curve.Scalar.Random(crand.Reader),
}
transcript := merlin.NewTranscript("test")
proof, err := prover.BatchProve(v, gamma, n, proofGenerators, transcript)
if err != nil {
panic(err)
}
// One proof, log2(1024) == 10 L/R pairs, covering all four values.
verifier, _ := bulletproof.NewRangeVerifier(n*4, []byte("rangeDomain"), []byte("ippDomain"), *curve)
capV := getcapVBatched(v, gamma, g, h) // unexported; one commitment per value
verified, err := verifier.VerifyBatched(proof, capV, proofGenerators, n, merlin.NewTranscript("test"))
// verified == true
```
Note that the batch call still takes the *per-value* bit width `n`, while the prover was
constructed with `n*4`. Mixing those two up is the easiest way to get a confusing failure.
`VerifyBatched` returns an error (rather than `false`) if a commitment in `capV` is tampered
with in a way that breaks the point arithmetic — `range_batch_verifier_test.go` exercises
that path — but a merely *wrong* proof still comes back as `(false, nil)`.
## Caveats
:::warning[Parameter mismatches fail silently]
`Verify` and `VerifyBatched` return `(false, nil)`. There is no descriptive error and no
distinction between "the prover was dishonest" and "we disagree about the parameters". All of
the following produce an indistinguishable `false`:
- different `rangeDomain` or `ippDomain` between prover and verifier
- different `maxVectorLength` (different generator counts, hence different trimmed vectors)
- different `n`
- different `g`, `h`, or `u`
- a different merlin transcript label, or a transcript that was already consumed
- a `capV` computed from a different `g`/`h` pair
Pin all of these in one shared configuration value. Do not let two sides derive them
independently.
:::
:::danger[n larger than the scalar's byte width panics]
`getaL` reads bit `i` of the value as `vBytes[i>>3] >> (i & 0x07) & 0x01`, where `vBytes` is
`v.Bytes()`. It performs no bounds check against `len(vBytes)`. With a 32-byte scalar
encoding, any `n > 256` indexes past the end of the slice and panics with an index-out-of-range
runtime error rather than returning an error. `NewRangeProver` will happily accept
`maxVectorLength` above 256, so nothing stops you from reaching this. Keep `n <= 256` on the
curves in this module.
:::
:::warning[Bit extraction assumes little-endian scalar bytes]
`getaL` treats byte 0 of `Scalar.Bytes()` as holding the least significant bits. That is
correct for Ed25519 scalars, which is the only curve any bulletproof test exercises. If a
curve's `Scalar.Bytes()` is big-endian, the bit vector is reversed and the proof commits to a
different number than `capV` does — verification fails, or worse, succeeds for the wrong
range. Do not assume this package works on a curve until you have checked that curve's scalar
byte order in [`core/curves`](/foundations/curves).
:::
:::note[Off-by-one in the input range check]
`Prove` rejects `v < 0` and `v > 2^n`, so a value of exactly `2^n` passes the input check —
but `2^n` is not representable in `n` bits, so `getaL` encodes it as `0` and the resulting
proof does not match `capV`. The unexported `checkRange` helper (used by `BatchProve`) has
the same `> 2^n` comparison despite a doc comment claiming it enforces `[0, 2^n - 1]`.
Treat the usable range as `[0, 2^n)` and validate the boundary yourself. `checkRange` also
does not reject negative values, unlike the inline check in `Prove`.
:::
:::note[Stale generator documentation]
`getGeneratorPoints`' comment claims it returns `2·lenVector + 1` points "split between a
single u generator and G and H lists". It returns `2·lenVector` points split evenly into `G`
and `H`; there is no `u` generator in the output. `u` is always caller-supplied. Similarly
`RangeProver.Prove`'s comment says the range is `[0, 2^n]`; the encoding makes it `[0, 2^n)`.
:::
:::info[isPowerOfTwo accepts zero]
The helper is `i&(i-1) == 0`, which is true for `i == 0`. A zero-length vector therefore
passes the power-of-two gate in `InnerProductProver.Prove` and fails later, or recurses
oddly. Validate non-empty inputs before calling.
:::
## Related
<CardGroup cols={2}>
<Card title="Curves" href="/foundations/curves" icon="git-branch">
The `Curve`, `Point`, and `Scalar` types every signature here is generic over, plus the
scalar byte-order details the bit extraction depends on.
</Card>
<Card title="Schnorr proofs" href="/zero-knowledge/schnorr" icon="badge-check">
Far simpler and fully usable from outside its package. Prefer it whenever the statement is
just discrete-log knowledge.
</Card>
</CardGroup>
+80
View File
@@ -0,0 +1,80 @@
---
title: Zero-Knowledge
description: Four unrelated tools that all let a verifier learn a fact without learning the secret behind it — discrete-log proofs, set-membership accumulators, range proofs, and additively homomorphic encryption.
sidebar:
label: Overview
order: 1
icon: eye-off
---
This section covers four packages that have almost nothing in common structurally, but which
solve the same shape of problem: a party holds a secret and needs a counterparty to accept a
statement about it without seeing it.
They are not interchangeable, and picking the wrong one is expensive. `zkp/schnorr` proves
you know a discrete log and nothing else. `accumulator` commits to a *set* and proves
membership. `bulletproof` proves a committed number lies in a range. `paillier` is not a
proof system at all — it is an encryption scheme that lets a third party compute on
ciphertexts, and it ships with one proof (`PsfProof`) about the shape of its own public key,
which is why it lives on this page rather than under symmetric or signature primitives.
## Which one do I need
| Goal | Package | What the verifier learns | What stays hidden |
| --- | --- | --- | --- |
| "I know the private key behind this public point" | `zkp/schnorr` | that some `x` with `Statement = x·B` exists and the prover knows it | `x` itself |
| "My credential is in the issuer's current set" | `accumulator` | that the holder possesses a valid witness for *some* accumulated element | which element, and the rest of the set |
| "This committed amount is between 0 and 2^n" | `bulletproof` | that the value behind a Pedersen commitment is in range | the value and the blinding factor |
| "I know two vectors whose dot product is c" | `bulletproof` (inner-product layer) | the claimed inner product | both vectors |
| "Compute on my data without seeing it" | `paillier` | nothing about the plaintexts | every plaintext |
| "Your Paillier modulus is not malformed" | `paillier` (`PsfProof`) | that `N` is square-free | the factorization of `N` |
## Maturity is uneven
These four packages are at very different levels of usability, and this matters more than the
cryptography when you are choosing between them.
`zkp/schnorr` is the most mature: small, exercised across eight curves, and load-bearing
inside this repo's own threshold ECDSA and oblivious-transfer stacks. `accumulator` is
complete and well tested, with one dead branch in its API. `paillier` is complete for
encryption but its proof layer has a missing length check. `bulletproof` implements the full
protocol correctly but does not export enough of its own types to be callable from another
package.
:::warning[Read the caveats sections]
Every page below ends with a caveats section that names concrete defects found in the source
— unconstructible parameter structs, panics on malformed input, off-by-one range checks, and
endianness assumptions. None of these are theoretical. Check them before you build on a
package. Nothing in this module carries a security audit.
:::
## Shared foundation
Every package here except `paillier` is generic over the curve abstraction in `core/curves`.
`accumulator` additionally requires a *pairing* curve (`*curves.PairingCurve`, in practice
BLS12-381), because its witness check is a pairing equation. `paillier` is the odd one out:
it works over `math/big` integers modulo a composite, and its PSF proof takes a
`crypto/elliptic` curve rather than a `core/curves` one.
See [Curves](/foundations/curves) for the `Curve` / `Point` / `Scalar` types that appear in
nearly every signature on these pages, and [Arithmetic](/foundations/arithmetic) for the
`core` modular-arithmetic helpers that `paillier` is built on.
<CardGroup cols={2}>
<Card title="Schnorr proofs" href="/zero-knowledge/schnorr" icon="badge-check">
Non-interactive proof of knowledge of a discrete log, with an optional commit-then-reveal
variant. The building block used by this module's own MPC protocols.
</Card>
<Card title="Accumulator" href="/zero-knowledge/accumulator" icon="list-checks">
Constant-size commitment to a set, constant-size membership witnesses, and a
zero-knowledge membership proof. Built for revocation lists.
</Card>
<Card title="Bulletproofs" href="/zero-knowledge/bulletproof" icon="ruler">
Logarithmic-size inner-product argument, and the range proof built on top of it.
Single and batched.
</Card>
<Card title="Paillier" href="/zero-knowledge/paillier" icon="calculator">
Additively homomorphic encryption over a composite modulus, plus the square-free proof
that keeps a malicious key from breaking protocols above it.
</Card>
</CardGroup>
+8
View File
@@ -0,0 +1,8 @@
import { defineMeta } from "blume";
export default defineMeta({
title: "Zero-Knowledge",
icon: "eye-off",
order: 6,
pages: ["index", "schnorr", "accumulator", "bulletproof", "paillier"],
});
+431
View File
@@ -0,0 +1,431 @@
---
title: Paillier
description: Additively homomorphic public-key encryption over a composite modulus, plus the PSF proof that a Paillier modulus is square-free — the check that keeps a malformed key from breaking protocols above it.
sidebar:
label: Paillier
order: 5
icon: calculator
---
`paillier` implements Paillier's 1999 cryptosystem. Its package doc cites the original paper —
*Public-Key Cryptosystems Based on Composite Degree Residuosity Class* — and states that all
routines follow the pseudocode of §2.5, Fig. 1. Unlike everything else in this section it is
not built on `core/curves`: plaintexts, ciphertexts, and keys are all `*math/big.Int` values
modulo a composite `N = PQ`.
It is here rather than under encryption because of what it is *used for*. Paillier's value in
this module is that a party can compute on data it cannot read, which is the multiplication
primitive underpinning several MPC protocols — and the accompanying `PsfProof` is a
zero-knowledge proof about the public key itself.
## Additively homomorphic, and only additively
Two operations work on ciphertexts:
| Operation | Call | Plaintext effect | Implementation |
| --- | --- | --- | --- |
| Ciphertext + ciphertext | `pk.Add(c, d)` | `Dec(result) = a + b` | `c · d mod N²` |
| Known scalar × ciphertext | `pk.Mul(a, c)` | `Dec(result) = a · b` | `c^a mod N²` |
That is the whole homomorphic surface, and the boundary is hard:
:::danger[You cannot multiply two ciphertexts' plaintexts]
There is no operation, and no combination of the available operations, that takes `Enc(a)`
and `Enc(b)` and produces `Enc(a·b)`. Paillier is additively homomorphic — a group
homomorphism from addition mod `N` to multiplication mod `N²`, nothing more. `pk.Mul` takes a
*plaintext* `*big.Int` as its first argument, not a second ciphertext; its name refers to
multiplying the plaintext by a known constant.
If you need ciphertext-ciphertext multiplication you need a different primitive — a
fully-homomorphic scheme, or an interactive multiplication protocol such as the
oblivious-transfer-based multiplier in this repo. See
[Oblivious transfer](/threshold/oblivious-transfer).
:::
Note also that both operands must be in range. `Add` requires `c, d ∈ Z_N²`; `Mul` requires
`a ∈ Z_N` and `c ∈ Z_N²`. In particular `a` must be non-negative and less than `N` — you
cannot pass a negative scalar to subtract. To subtract, add the modular negation
`new(big.Int).Sub(pk.N, x)`.
## Keys
<TypeTable
type={{
"NewKeys": {
type: "func() (*PublicKey, *SecretKey, error)",
required: true,
description: "Generates a fresh keypair with two PaillierPrimeBits-sized safe primes. Slow: see the note below.",
},
"NewSecretKey": {
type: "func(p, q *big.Int) (*SecretKey, error)",
required: true,
description: "Derives lambda, totient, and U from primes you supply. Performs no primality or safety check on p and q.",
},
"NewPubkey": {
type: "func(n *big.Int) (*PublicKey, error)",
required: true,
description: "Wraps a modulus received from a counterparty and caches N².",
},
"PaillierPrimeBits": {
type: "int constant = 1024",
required: true,
description: "Bit size of each safe prime, so N is 2048 bits. Not configurable through the exported API.",
},
}}
/>
`PublicKey` exposes `N` (the modulus) and `N2` (`N²`, cached to avoid recomputation).
`SecretKey` embeds `PublicKey` and adds:
<TypeTable
type={{
Lambda: {
type: "*big.Int",
required: true,
description: "lcm(P-1, Q-1), the decryption exponent.",
},
Totient: {
type: "*big.Int",
required: true,
description: "Euler's totient (P-1)(Q-1). Used by the PSF proof, not by decryption.",
},
U: {
type: "*big.Int",
required: true,
description: "L((N+1)^lambda mod N²)^-1 mod N, the precomputed decryption multiplier.",
},
}}
/>
Both key types implement `MarshalJSON`/`UnmarshalJSON`. `PublicKeyJson` and `SecretKeyJson`
are the exported-but-internal wire shapes. `P` and `Q` are **not** retained on the secret key
and are not serialized — only the derived values are, so you cannot recover the factors from a
marshalled `SecretKey`.
`Ciphertext` is a defined type over `*big.Int`, so it serializes as an integer with no
wrapper.
:::note[NewKeys is slow, by construction]
`NewKeys` calls `core.GenerateSafePrime` twice at 1024 bits. A safe prime `p` requires
`(p-1)/2` to also be prime, which makes them rare — safe-prime search is orders of magnitude
slower than ordinary prime generation, and the cost is highly variable run to run. Generate
keys once at setup and persist them; never generate inside a request path. `NewKeys` is the
only function in the package that performs a search — everything else is a bounded number of
modular operations.
:::
:::warning[NewSecretKey trusts its inputs completely]
`NewSecretKey(p, q)` computes `lcm(p-1, q-1)`, `(p-1)(q-1)`, `N`, `N²`, and `U` and returns.
It does not test whether `p` and `q` are prime, whether they are safe primes, whether they are
distinct, or whether they are large enough. Passing composites yields a key whose `Decrypt`
returns garbage without error. It exists so that callers with pre-generated primes (and the
package's own tests) can skip the expensive search — not as a general constructor.
:::
## Encryption
`Encrypt` returns **three** values, and the middle one is easy to discard by accident:
```go
func (pk *PublicKey) Encrypt(msg *big.Int) (Ciphertext, *big.Int, error)
```
The second return is `r`, the randomness the ciphertext was built with. Internally
`c = (N+1)^msg · r^N mod N²`, where `r` is drawn uniformly from `Z_N` and rejected if zero.
You need to keep `r` whenever a later step must prove something about *this* ciphertext.
`r` and `msg` together are the witness for essentially every zero-knowledge statement about a
Paillier ciphertext ("this encrypts a value in range", "these two ciphertexts encrypt the same
value", "this encrypts the plaintext behind that commitment"). Without `r` you cannot produce
such a proof and cannot recompute the ciphertext deterministically. If you are only encrypting
and never proving, discard it with `_`.
```go paillier.go
package main
import (
"fmt"
"math/big"
"github.com/sonr-io/crypto/paillier"
)
func main() {
// One-time, slow: two 1024-bit safe primes.
pk, sk, err := paillier.NewKeys()
if err != nil {
panic(err)
}
a := big.NewInt(1234)
b := big.NewInt(5678)
// The second return is the encryption randomness r. Keep it if you will
// later need to prove a statement about this ciphertext.
ca, ra, err := pk.Encrypt(a)
if err != nil {
panic(err)
}
_ = ra
cb, _, err := pk.Encrypt(b)
if err != nil {
panic(err)
}
// Add plaintexts by multiplying ciphertexts.
csum, err := pk.Add(ca, cb)
if err != nil {
panic(err)
}
sum, err := sk.Decrypt(csum)
if err != nil {
panic(err)
}
fmt.Println(sum) // 6912
// Scale a plaintext by a known constant.
cscaled, err := pk.Mul(big.NewInt(3), ca)
if err != nil {
panic(err)
}
scaled, err := sk.Decrypt(cscaled)
if err != nil {
panic(err)
}
fmt.Println(scaled) // 3702
// A counterparty that only received N can encrypt but not decrypt.
remote, err := paillier.NewPubkey(pk.N)
if err != nil {
panic(err)
}
_, _, _ = remote.Encrypt(big.NewInt(1))
}
```
Plaintexts must satisfy `msg ∈ Z_N`. Arithmetic wraps modulo `N`, so a sum of two large
plaintexts that exceeds `N` decrypts to the reduced value, not the integer sum. If you are
encoding signed or fixed-point quantities, choose a representation with headroom and check it
yourself — the package does not.
## The PSF proof: proving a modulus is square-free
A protocol that accepts a Paillier public key from an untrusted party accepts an arbitrary
integer `N`. If `N` is malformed — not square-free, or sharing a factor with the ambient group
order — the homomorphic structure the protocol relies on breaks down, and a malicious key
holder can extract information or force a decryption to a value of its choosing. The PSF
(Paillier square-free) proof forces `N` to be well-formed before anything is encrypted under it.
The implementation cites its spec as `[spec] §10.2` and `fig. 15` (`ProvePSF`, `VerifyPSF`),
and the source carries explicit notes where it deviates from that pseudocode to fix errors in
it — the modulus for the exponentiation in step 5, and the inclusion of `N` in the challenge
commitment.
### What it actually asserts
The prover, who knows the factorization, computes `M = N⁻¹ mod φ(N)` and returns
`y_i = x_i^M mod N` for 13 deterministically derived challenges `x_i`. The verifier checks
`y_i^N ≡ x_i mod N` for every `i`.
That check passes for all `i` only if raising to the `N`-th power is a bijection on `Z_N`,
which holds exactly when `gcd(N, φ(N)) = 1` — that is, when `N` is **square-free**. The
verifier additionally rejects `N` if the curve subgroup order `q` divides `N`.
Be precise about the limits of this:
- It proves `N` is square-free.
- It does **not** prove `N` is a product of exactly two primes.
- It does **not** prove the factors are safe primes, or of equal size, or large.
- It does **not** prove the prover knows the factorization beyond what square-freeness needs.
If your protocol needs a biprime or safe-prime guarantee, PSF alone is insufficient.
<TypeTable
type={{
"PsfProofParams.Prove": {
type: "func() (PsfProof, error)",
required: true,
description: "Returns 13 big.Ints. Errors with ErrNilArguments if Curve, SecretKey, or Y is nil, or if Pi is zero.",
},
"PsfProof.Verify": {
type: "func(psf *PsfVerifyParams) error",
required: true,
description: "Returns nil on success. Same nil/zero argument validation as Prove.",
},
"PsfProofLength": {
type: "int constant = 13",
required: true,
description: "The number of challenges, and therefore the exact length of a valid PsfProof.",
},
}}
/>
`PsfProof` is `[]*big.Int`, so `encoding/json` round-trips it directly with no custom codec —
`psf_test.go` marshals and unmarshals it that way.
### Parameters
The prover's and verifier's parameter structs are deliberately near-identical: the only
difference is that the prover holds the `*SecretKey` while the verifier holds only the
`*PublicKey`. Every other field is a public value both sides must agree on, because all three
are hashed into the challenge derivation.
<TypeTable
type={{
Curve: {
type: "elliptic.Curve",
required: true,
description: "A crypto/elliptic curve — not a core/curves one. Its Params() supply the generator and subgroup order for challenge derivation. Tests use btcec.S256() and elliptic.P256().",
},
SecretKey: {
type: "*SecretKey",
required: true,
description: "Prover only (PsfProofParams). Supplies N and Totient for computing M.",
},
PublicKey: {
type: "*PublicKey",
required: true,
description: "Verifier only (PsfVerifyParams). Supplies N.",
},
Pi: {
type: "uint32",
required: true,
description: "Party index bound into the challenges. Must be non-zero — zero is rejected as a nil argument on both sides.",
},
Y: {
type: "*curves.EcPoint",
required: true,
description: "A public point bound into the challenges, tying the proof to a protocol-specific value.",
},
}}
/>
### Prove and verify
Grounded in `paillier/psf_test.go` (`TestPsfProofWorks`).
```go psf.go
package main
import (
"crypto/elliptic"
"fmt"
"math/big"
"github.com/sonr-io/crypto/core/curves"
"github.com/sonr-io/crypto/paillier"
)
// Y is an ordinary EC point. In psf_test.go it is built with
// curves.NewScalarBaseMult over the same crypto/elliptic curve.
func bindingPoint(k *big.Int) (*curves.EcPoint, error) {
return curves.NewScalarBaseMult(elliptic.P256(), k)
}
func provePSF(sk *paillier.SecretKey, pi uint32, y *curves.EcPoint) (paillier.PsfProof, error) {
return (&paillier.PsfProofParams{
Curve: elliptic.P256(),
SecretKey: sk,
Pi: pi, // must be non-zero
Y: y,
}).Prove()
}
func verifyPSF(
proof paillier.PsfProof,
pk *paillier.PublicKey,
pi uint32,
y *curves.EcPoint,
) error {
// Do this length check yourself: Verify does not, and indexes 13 elements.
if len(proof) != paillier.PsfProofLength {
return fmt.Errorf(
"malformed psf proof: want %d elements, got %d",
paillier.PsfProofLength, len(proof),
)
}
return proof.Verify(&paillier.PsfVerifyParams{
Curve: elliptic.P256(),
PublicKey: pk,
Pi: pi,
Y: y,
})
}
```
Wiring it into a key exchange:
<Steps>
<Step title="Generate once">
Each party runs `paillier.NewKeys()` at setup and persists the keypair.
</Step>
<Step title="Agree on the binding values">
Both sides fix the `elliptic.Curve`, the non-zero party index `Pi`, and the public point
`Y` from protocol state. A mismatch in any of the three produces different challenges and
a failed verification.
</Step>
<Step title="Publish key plus proof">
Send `pk.N` (via `MarshalJSON`) together with the 13-element `PsfProof`.
</Step>
<Step title="Verify before use">
The receiver rebuilds the public key with `paillier.NewPubkey(n)`, checks the proof length,
calls `proof.Verify(...)`, and only then encrypts anything under that key.
</Step>
</Steps>
## Caveats
:::danger[Verify panics on a short proof]
`PsfProof.Verify` validates that the *challenge* array has length `PsfProofLength`, but never
checks `len(p)` — the proof itself. It then indexes `p[j]` for `j` in `0..12`. A proof shorter
than 13 elements — for instance one decoded from attacker-supplied JSON — panics inside
`Verify` instead of returning an error. A three-element proof produces
`runtime error: index out of range [3] with length 3`.
Check `len(proof) == paillier.PsfProofLength` yourself immediately after deserialization,
before calling `Verify`. This is unconditional: any code path where the proof arrives from
outside your process needs the guard.
:::
:::warning[The proof does not authenticate the sender]
`Pi` and `Y` bind the proof to a protocol position and a public point, but the PSF proof is not
a signature over `N`. A relayed valid `(N, proof)` pair from an honest party remains valid.
If you need to know *who* sent a modulus, authenticate the transport or sign the key material
separately.
:::
:::warning[Constant-time behaviour is partial]
Some paths use constant-time helpers — `Add` and `Mul` accumulate their two range-check errors
before branching, and `encrypt` uses `core.ConstantTimeEq` to reject a zero nonce. Others are
plain `math/big` operations, and `math/big` is not constant-time. Do not treat this package as
side-channel hardened. No timing analysis of this code exists in the repository.
:::
:::note[No key-size configurability, and no key validation on import]
`NewKeys` is hardcoded to `PaillierPrimeBits = 1024` per prime; the parameterised generator is
unexported. `NewPubkey(n)` accepts any modulus you hand it — it caches `N²` and returns. It
performs no size check, no square-free check, and no primality-related check. Validating an
imported modulus is exactly what the PSF proof is for, and it is your responsibility to run it.
:::
:::info[Decryption does not authenticate]
Paillier is not an authenticated encryption scheme. `Decrypt` will happily return a plaintext
for any `c ∈ Z_N²`, including one an adversary derived homomorphically from a ciphertext you
sent. There is no integrity tag. If you need to know that a ciphertext is the one you expected,
you need an additional proof or a MAC over a separate channel. For authenticated symmetric
encryption see [AEAD](/symmetric/aead).
:::
## Related
<CardGroup cols={2}>
<Card title="Arithmetic" href="/foundations/arithmetic" icon="binary">
The `core` modular-arithmetic layer this package is built on — `Inv`, `Exp`, `Mul`, `In`,
`Rand`, and safe-prime generation.
</Card>
<Card title="Oblivious transfer" href="/threshold/oblivious-transfer" icon="shuffle">
The interactive multiplication primitive this module actually uses where Paillier's
additive-only homomorphism is not enough.
</Card>
</CardGroup>
+275
View File
@@ -0,0 +1,275 @@
---
title: Schnorr proofs
description: Non-interactive proof of knowledge of a discrete log over any curve in core/curves, with an optional commit-then-reveal variant used by this module's DKG and OT protocols.
sidebar:
label: Schnorr
order: 2
icon: badge-check
---
`zkp/schnorr` implements a single, small, well-scoped thing: a Fiat-Shamir-compiled proof
that you know the scalar behind a curve point. Its package doc names its source — Doerner et
al., [eprint 2018/499](https://eprint.iacr.org/2018/499.pdf) — and implements Functionality 6
(the plain proof) and Functionality 7 (the committed variant) from that paper.
This is the most heavily used primitive in the repository. It is the proof that Alice and Bob
exchange in DKLs threshold-ECDSA key generation, and the proof the sender uses to convince the
receiver it knows its own base-OT secret key.
## What is actually proved
Given a base point `B` and a witness scalar `x`, the prover publishes the statement
`X = x·B` together with a challenge/response pair `(C, S)`. Writing `k` for a fresh random
nonce and `sid` for `uniqueSessionId`:
$$
C = H(\text{sid} \parallel B \parallel X \parallel k \cdot B), \qquad
S = C \cdot x + k
$$
The verifier never sees `k`. It recovers the nonce point from the response and re-derives the
challenge:
$$
C' = H(\text{sid} \parallel B \parallel X \parallel (S \cdot B - C \cdot X))
$$
and accepts only if `C'` equals `C`, compared with `crypto/subtle.ConstantTimeCompare`. The
hash is SHA3-256; the digest is widened to a scalar with `Scalar.SetBytesWide`. A verifier
learns that *some* `x` satisfying `X = x·B` is known to the prover, and learns nothing else
about it.
## When to use it
Reach for this when a protocol participant must demonstrate honest generation of a public
value derived from a secret it keeps — a key share, an OT secret key, a nonce commitment.
It is the standard defence against a party contributing a public point whose discrete log it
does not know.
Do **not** reach for it as a signature scheme. The statement is not bound to a message, only
to `uniqueSessionId` and the base point, so it authenticates nothing about payload data. For
signing use [ECDSA](/signatures/ecdsa) or [BLS](/signatures/bls). Do not reach for it to prove
anything other than discrete-log knowledge: there is no range, no set membership, and no
relation between multiple statements here.
## API
<TypeTable
type={{
"NewProver": {
type: "func(curve *curves.Curve, basepoint curves.Point, uniqueSessionId []byte) *Prover",
required: true,
description: "Binds a curve, a base point, and a domain separator. Never returns an error.",
},
"Prover.Prove": {
type: "func(x curves.Scalar) (*Proof, error)",
required: true,
description: "Computes Statement = x·basepoint and the (c, s) pair. One curve multiplication for the statement plus one for the nonce point.",
},
"Prover.ProveCommit": {
type: "func(x curves.Scalar) (*Proof, Commitment, error)",
description: "Same proof, plus SHA3-256(c || s) as a commitment to open later.",
},
"Verify": {
type: "func(proof *Proof, curve *curves.Curve, basepoint curves.Point, uniqueSessionId []byte) error",
required: true,
description: "Returns nil on success, an error on failure. There is no boolean return.",
},
"DecommitVerify": {
type: "func(proof *Proof, commitment Commitment, curve *curves.Curve, basepoint curves.Point, uniqueSessionId []byte) error",
description: "Checks the proof opens the commitment, then verifies the proof.",
},
}}
/>
### Types
`Commitment` is a plain type alias for `[]byte` — no wrapper, no methods.
<TypeTable
type={{
Statement: {
type: "curves.Point",
required: true,
description: "The point whose discrete log is proved: x · basepoint. Constructed by Prove, not supplied by the caller.",
},
C: {
type: "curves.Scalar",
required: true,
description: "The Fiat-Shamir challenge scalar.",
},
S: {
type: "curves.Scalar",
required: true,
description: "The response scalar, c·x + k.",
},
}}
/>
All three `Proof` fields are exported, so the struct serializes directly. `tecdsa/dklsv1`
transmits it with `encoding/gob` in `dkgserializers.go`.
### The `basepoint == nil` shorthand
Both `NewProver` and `Verify` accept `basepoint == nil` and substitute
`curve.NewGeneratorPoint()`. Passing `nil` on one side and an explicit generator on the other
is safe because it resolves to the same point. Passing a *different* point on the two sides is
not: the base point is hashed into the challenge, so verification simply fails.
Proving with respect to a non-generator base point is a real use case, not a curiosity.
`tecdsa/dklsv1/sign` proves knowledge of Alice's nonce `kA` with respect to Bob's point `DB`,
so that the statement is exactly `R = kA · DB`:
```go
rSchnorrProver := schnorr.NewProver(alice.curve, round2Output.DB, uniqueSessionId[:])
round3Output.RSchnorrProof, err = rSchnorrProver.Prove(kA)
```
## Basic proof and verification
Grounded in `zkp/schnorr/schnorr_test.go`, which runs this exact flow over K256, P256,
PALLAS, BLS12-377 G1/G2, BLS12-381 G1/G2, and ED25519.
```go proof.go
package main
import (
"crypto/rand"
"fmt"
"golang.org/x/crypto/sha3"
"github.com/sonr-io/crypto/core/curves"
"github.com/sonr-io/crypto/zkp/schnorr"
)
func main() {
curve := curves.K256()
// Both sides must agree on these bytes, byte for byte.
uniqueSessionId := sha3.New256().Sum([]byte("my-protocol/dkg/round-3"))
// Prover side: nil basepoint means the curve's default generator.
prover := schnorr.NewProver(curve, nil, uniqueSessionId)
secret := curve.Scalar.Random(rand.Reader)
proof, err := prover.Prove(secret)
if err != nil {
panic(err)
}
// proof.Statement == secret * G, and is what the verifier will treat
// as the public key.
fmt.Println("statement:", proof.Statement.ToAffineCompressed())
// Verifier side: same curve, same basepoint convention, same session id.
if err := schnorr.Verify(proof, curve, nil, uniqueSessionId); err != nil {
panic(err) // "schnorr verification failed"
}
}
```
## The committed variant
`ProveCommit` returns the proof *and* `SHA3-256(C.Bytes() || S.Bytes())`. A protocol sends
the commitment first, waits for the counterparty to commit to its own contribution, and only
then reveals the proof, which `DecommitVerify` checks against the earlier commitment before
verifying it.
The reason is ordering, not secrecy. Without it, whichever party speaks second can choose its
key share *after* seeing the first party's public point, and bias the combined public key.
Committing first removes that freedom.
This is precisely how DKLs 2-of-2 DKG is wired in `tecdsa/dklsv1/dkg`:
<Steps>
<Step title="Alice commits">
Alice builds a prover over her session id and calls `ProveCommit(alice.secretKeyShare)`.
She keeps the `*schnorr.Proof` in memory and sends only the `schnorr.Commitment`.
</Step>
<Step title="Bob proves in the clear">
Bob stores `round2Output.Commitment`, builds his own prover, and calls
`Prove(bob.secretKeyShare)`, sending the full proof.
</Step>
<Step title="Alice verifies and reveals">
`Round4VerifyAndReveal` calls `schnorr.Verify` on Bob's proof, then returns Alice's
previously withheld proof.
</Step>
<Step title="Bob decommits and verifies">
`Round5DecommitmentAndStartOt` calls
`schnorr.DecommitVerify(proof, bob.aliceCommitment, bob.curve, nil, bob.aliceSalt[:])`.
Only after this does Bob derive `bob.publicKey = proof.Statement.Mul(bob.secretKeyShare)`.
</Step>
</Steps>
```go committed.go
prover := schnorr.NewProver(curve, nil, uniqueSessionId)
proof, commitment, err := prover.ProveCommit(secret)
if err != nil {
panic(err)
}
// ... round trip: send `commitment`, receive the peer's contribution ...
// ... then send `proof` ...
if err := schnorr.DecommitVerify(proof, commitment, curve, nil, uniqueSessionId); err != nil {
panic(err) // "initial hash decommitment failed" or "schnorr verification failed"
}
```
## Caveats
:::warning[uniqueSessionId is load-bearing]
`uniqueSessionId` is the first thing hashed into the challenge. It is the domain separator
that binds a proof to one execution of one protocol, and prover and verifier **must** pass
byte-identical values or verification fails with a generic
`"schnorr verification failed"` — you get no hint that the session ids diverged.
Two failure modes matter:
- **Reuse across contexts.** A proof made under session id `S` verifies under session id `S`
anywhere. If you use a constant, a proof captured from one sub-protocol replays into
another. Derive it from a live transcript. The repo's own callers do: `dklsv1` and
`simplest` build it from a hash of protocol-specific salts and seeds.
- **Attacker-chosen ids.** If a remote party picks the session id you verify under, it picks
the domain the proof is bound to. Derive it from data both sides contributed, never from
one side's unilateral input.
:::
:::note[The commitment covers only (C, S)]
`ProveCommit` hashes `C.Bytes()` and `S.Bytes()` — it does **not** hash `Statement`. The
statement is still bound, but indirectly: `Verify` recomputes the challenge from the statement,
so an opened proof only verifies against the statement it was made for. Do not, however,
treat the `Commitment` as a standalone commitment to the public point; it is not one, and it
carries no information about which statement will be revealed.
:::
:::warning[No message binding]
The challenge covers `uniqueSessionId`, the base point, the statement, and the nonce point.
It does not cover any application message. This is a proof of knowledge, not a signature. If
you need to bind a payload, fold that payload into `uniqueSessionId` before constructing the
prover.
:::
:::info[Error shape]
`Verify` and `DecommitVerify` return `error`, not `(bool, error)`. A `nil` return is the only
success signal. Do not ignore the error value; there is no other output to inspect.
:::
The `Prover` struct itself is stateless with respect to the witness — it holds only the curve,
base point, and session id, so a single prover can produce proofs for many different witnesses
under the same domain. Each `Prove` call draws a fresh nonce `k` from `crypto/rand`.
## Where this is used in the module
<CardGroup cols={2}>
<Card title="Threshold ECDSA" href="/threshold/threshold-ecdsa" icon="users">
`tecdsa/dklsv1` uses the committed variant in DKG rounds 35, and the plain variant with a
custom base point during signing.
</Card>
<Card title="Oblivious transfer" href="/threshold/oblivious-transfer" icon="shuffle">
`ot/base/simplest` has the sender prove knowledge of its base-OT secret key in round 1,
which the receiver verifies before any transfer.
</Card>
</CardGroup>