Accumulator
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.
The accumulator package implements the pairing-based accumulator of
eprint 2020/777, 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.MarshalBinaryreturns 60 bytes with its BARE framing, at 1 element and at 5000 alike. - A membership witness is one curve point plus its element.
MembershipWitness.MarshalBinaryis 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 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
SecretKey.Newfunc(curve *curves.PairingCurve, seed []byte) (*SecretKey, error)
Derives alpha as curve.Scalar.Hash(seed). Fully deterministic in the seed; performs no validation of seed quality or length.
func(curve *curves.PairingCurve, seed []byte) (*SecretKey, error)SecretKey.GetPublicKeyfunc(curve *curves.PairingCurve) (*PublicKey, error)
Returns alpha times the G2 generator. Errors if the key or curve is nil.
func(curve *curves.PairingCurve) (*PublicKey, error)SecretKey.BatchAdditions?func(additions []Element) (Element, error)
product(y + alpha) over the additions. The multiplier applied to the accumulator on a batch add.
func(additions []Element) (Element, error)SecretKey.BatchDeletions?func(deletions []Element) (Element, error)
1/product(y + alpha) over the deletions.
func(deletions []Element) (Element, error)SecretKey.CreateCoefficients?func(additions, deletions []Element) ([]Element, error)
Batch polynomial coefficients per page 7 of the paper. Update calls this for you; call it directly only if you are reimplementing Update.
func(additions, deletions []Element) ([]Element, error)Accumulator operations
Accumulator.Newfunc(curve *curves.PairingCurve) (*Accumulator, error)
Sets the value to the G1 generator. Called on the receiver, so the idiom is new(Accumulator).New(curve).
func(curve *curves.PairingCurve) (*Accumulator, error)Accumulator.WithElements?func(curve *curves.PairingCurve, key *SecretKey, m []Element) (*Accumulator, error)
New plus a batch add: V = product(y + alpha) · V0. The usual way to bootstrap a populated set.
func(curve *curves.PairingCurve, key *SecretKey, m []Element) (*Accumulator, error)Accumulator.Add?func(key *SecretKey, e Element) (*Accumulator, error)
V' = (y + alpha) · V. Errors if the accumulator value is nil or the identity.
func(key *SecretKey, e Element) (*Accumulator, error)Accumulator.AddElements?func(key *SecretKey, m []Element) (*Accumulator, error)
Batch add. Does not emit coefficients, so holders cannot update from it — use Update if witnesses are outstanding.
func(key *SecretKey, m []Element) (*Accumulator, error)Accumulator.Remove?func(key *SecretKey, e Element) (*Accumulator, error)
V' = 1/(y + alpha) · V. Does not verify the element was ever added; removing an absent element silently produces a different accumulator.
func(key *SecretKey, e Element) (*Accumulator, error)Accumulator.Update?func(key *SecretKey, additions, deletions []Element) (*Accumulator, []Coefficient, error)
Batch add and delete in one step, returning the coefficients holders need for BatchUpdate. This is the update method to use in production.
func(key *SecretKey, additions, deletions []Element) (*Accumulator, []Coefficient, error)Witnesses
MembershipWitness.Newfunc(y Element, acc *Accumulator, sk *SecretKey) (*MembershipWitness, error)
Issues a witness for y against the current accumulator: C = 1/(y + alpha) · V. Requires the secret key, so only the manager can issue.
func(y Element, acc *Accumulator, sk *SecretKey) (*MembershipWitness, error)MembershipWitness.Verifyfunc(pk *PublicKey, acc *Accumulator) error
Multi-pairing check e(C, y·P̃ + Q̃) · e(-V, P̃) == 1. Returns nil on success. Public — any holder or verifier can run it.
func(pk *PublicKey, acc *Accumulator) errorMembershipWitness.BatchUpdatefunc(additions, deletions []Element, coefficients []Coefficient) (*MembershipWitness, error)
Refreshes a stale witness against one published Update. This is the update path callers should use.
func(additions, deletions []Element, coefficients []Coefficient) (*MembershipWitness, error)MembershipWitness.MultiBatchUpdate?func(A [][]Element, D [][]Element, C [][]Coefficient) (*MembershipWitness, error)
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.
func(A [][]Element, D [][]Element, C [][]Coefficient) (*MembershipWitness, error)MembershipWitness.ApplyDelta?func(delta *Delta) (*MembershipWitness, error)
Applies precomputed update material. Effectively unreachable — see caveats.
func(delta *Delta) (*MembershipWitness, error)Witness staleness is the hard part
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.
ProofParams.Newfunc(curve *curves.PairingCurve, pk *PublicKey, entropy []byte) (*ProofParams, error)
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.
func(curve *curves.PairingCurve, pk *PublicKey, entropy []byte) (*ProofParams, error)MembershipProofCommitting.Newfunc(witness *MembershipWitness, acc *Accumulator, pp *ProofParams, pk *PublicKey) (*MembershipProofCommitting, error)
Prover's commit phase. Holds all the blinding values; keep it private and short-lived.
func(witness *MembershipWitness, acc *Accumulator, pp *ProofParams, pk *PublicKey) (*MembershipProofCommitting, error)MembershipProofCommitting.GetChallengeBytesfunc() []byte
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.
func() []byteMembershipProofCommitting.GenProoffunc(c curves.Scalar) *MembershipProof
Computes the s values for the given challenge. Returns the proof to send. No error return.
func(c curves.Scalar) *MembershipProofMembershipProof.Finalizefunc(acc *Accumulator, pp *ProofParams, pk *PublicKey, challenge curves.Scalar) (*MembershipProofFinal, error)
Verifier side: recomputes the commitment values from the proof, the accumulator, and the params.
func(acc *Accumulator, pp *ProofParams, pk *PublicKey, challenge curves.Scalar) (*MembershipProofFinal, error)MembershipProofFinal.GetChallengefunc(curve *curves.PairingCurve) curves.Scalar
Recomputes the Fiat-Shamir challenge from the finalized values. Verification succeeds iff this equals the challenge the prover used.
func(curve *curves.PairingCurve) curves.ScalarFull lifecycle
Set up the manager
Derive a SecretKey from a strong seed and publish the PublicKey.
Accumulate the initial members
new(Accumulator).WithElements(curve, sk, elements) and publish the accumulator bytes.
Issue a witness
For each holder, new(MembershipWitness).New(element, acc, sk) and deliver the witness
bytes privately. This step needs the secret key.
Prove membership
The holder builds ProofParams, runs MembershipProofCommitting, hashes
GetChallengeBytes() into a challenge, and sends the challenge plus GenProof(challenge).
Verify
The verifier calls Finalize then GetChallenge, and accepts iff the recomputed
challenge equals the one it was given.
Update the set
The manager calls Update(sk, additions, deletions) and publishes the new accumulator,
the element lists, and the coefficients.
Refresh witnesses
Every holder calls BatchUpdate(additions, deletions, coefficients), then can prove again
against the new accumulator.
Membership proof end to end
Grounded in accumulator/proof_test.go (TestMembershipProof) and
accumulator/witness_test.go (Test_Membership, Test_Membership_Batch_Update).
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:
_, 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
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.