A Go library for threshold signatures with a curve-agnostic design.
fy provides implementations of threshold signature schemes including FROST (Schnorr) and DKLs23 (ECDSA), enabling t-of-n threshold signing where any t participants can collaboratively sign a message without reconstructing the private key.
go get github.com/f3rmion/fy
- Curve-agnostic design: All protocols work with any elliptic curve implementing the
group.Groupinterface - FROST: Flexible Round-Optimized Schnorr Threshold signatures
- DKLs23: Paillier-free threshold ECDSA (faster than GG20)
- Multiple curves: Baby Jubjub (zkSNARK-friendly) and secp256k1 (Bitcoin/Ethereum)
- Multiple hashers: SHA-256, Blake2b, Poseidon (zkSNARK-optimized)
- Railgun integration: Threshold signatures compatible with
eddsa.verifyPoseidon
fy/
├── group/ # Abstract interfaces for cryptographic groups
├── frost/ # FROST threshold Schnorr signatures
├── dkls23/ # DKLs23 threshold ECDSA (Paillier-free)
│ ├── dkg/ # Distributed key generation
│ ├── sign/ # Threshold signing
│ ├── ot/ # Oblivious transfer
│ └── mta/ # Multiplicative-to-additive conversion
├── bjj/ # Baby Jubjub curve implementations
├── secp256k1/ # secp256k1 curve implementation
├── railgun/ # Railgun protocol adapter
└── session/ # High-level session management
FROST enables threshold Schnorr signatures. Use this for EdDSA-style signatures on curves like Baby Jubjub.
package main
import (
"crypto/rand"
"github.com/f3rmion/fy/bjj"
"github.com/f3rmion/fy/frost"
)
func main() {
// Create a 2-of-3 threshold scheme on Baby Jubjub
g := &bjj.BJJ{}
f, _ := frost.New(g, 2, 3)
// Each participant creates their state
participants := make([]*frost.Participant, 3)
for i := range participants {
participants[i], _ = f.NewParticipant(rand.Reader, i+1)
}
// Round 1: Broadcast commitments
broadcasts := make([]*frost.Round1Data, 3)
for i, p := range participants {
broadcasts[i] = p.Round1Broadcast()
}
// Round 1: Send private shares to each other participant
for i, sender := range participants {
for j := 0; j < 3; j++ {
if i == j {
continue
}
privateData := f.Round1PrivateSend(sender, j+1)
f.Round2ReceiveShare(participants[j], privateData, broadcasts[i].Commitments)
}
}
// Finalize: Each participant computes their key share
keyShares := make([]*frost.KeyShare, 3)
for i, p := range participants {
keyShares[i], _ = f.Finalize(p, broadcasts)
}
// All participants now have the same group public key
groupKey := keyShares[0].GroupKey
}// Any 2 participants can sign
message := []byte("hello FROST")
signers := []*frost.KeyShare{keyShares[0], keyShares[1]}
// Round 1: Generate nonces and commitments
nonces := make([]*frost.SigningNonce, 2)
commitments := make([]*frost.SigningCommitment, 2)
for i, ks := range signers {
nonces[i], commitments[i], _ = f.SignRound1(rand.Reader, ks)
}
// Round 2: Generate signature shares
sigShares := make([]*frost.SignatureShare, 2)
for i, ks := range signers {
sigShares[i], _ = f.SignRound2(ks, nonces[i], message, commitments)
}
// Aggregate into final signature
sig, _ := f.Aggregate(message, commitments, sigShares)
// Anyone can verify with the group public key
valid := f.Verify(message, sig, groupKey)The session package provides a simpler interface with built-in nonce safety:
import "github.com/f3rmion/fy/session"
// Create participants
p1, _ := session.NewParticipant(g, 2, 3, 1)
p2, _ := session.NewParticipant(g, 2, 3, 2)
p3, _ := session.NewParticipant(g, 2, 3, 3)
// DKG Round 1
r1_1, _ := p1.GenerateRound1(rand.Reader, []int{1, 2, 3})
r1_2, _ := p2.GenerateRound1(rand.Reader, []int{1, 2, 3})
r1_3, _ := p3.GenerateRound1(rand.Reader, []int{1, 2, 3})
// Exchange broadcasts and private shares...
// Finalize DKG
result1, _ := p1.ProcessRound1(&session.Round1Input{...})
// result1.KeyShare, result1.GroupKey
// Signing with session (prevents nonce reuse)
sess, _ := p1.NewSigningSession(rand.Reader, message)
// sess.Commitment() - broadcast to other signers
share, _ := sess.Sign(allCommitments) // session consumed after this
// Aggregate
sig, _ := session.Aggregate(f, message, allCommitments, allShares)DKLs23 is a Paillier-free threshold ECDSA protocol based on eprint.iacr.org/2023/765. It uses oblivious transfer instead of Paillier encryption, resulting in faster key generation.
Use this for ECDSA signatures on secp256k1 (Bitcoin/Ethereum compatible).
The session package provides a high-level API that handles protocol phases:
package main
import (
"github.com/f3rmion/fy/session"
"github.com/f3rmion/fy/dkls23/sign"
)
func main() {
threshold := uint8(2)
total := uint8(3)
sessionID := []byte("unique-session-id")
// Each participant creates their state
p1, _ := session.NewDKLS23Participant(threshold, total, 1, sessionID)
p2, _ := session.NewDKLS23Participant(threshold, total, 2, sessionID)
p3, _ := session.NewDKLS23Participant(threshold, total, 3, sessionID)
// Phase 1: Generate polynomials
out1_1, _ := p1.DKGPhase1()
out1_2, _ := p2.DKGPhase1()
out1_3, _ := p3.DKGPhase1()
// Exchange PolyPoints between participants...
// Phase 2: Generate commitments and zero seeds
// Phase 3: Reveal seeds and init multiplication
// Phase 4: Finalize - each participant now has a signing Party
party1 := p1.Party()
party2 := p2.Party()
// party1.PublicKey == party2.PublicKey (same ECDSA public key)
}// Create signing sessions for 2-of-3 signing
messageHash := session.DKLS23MessageHash([]byte("hello ECDSA"))
signID := []byte("sign-session-1")
// Party 1 and Party 2 will sign (threshold = 2)
sess1, _ := session.NewDKLS23SigningSession(party1, messageHash, signID, []uint8{2})
sess2, _ := session.NewDKLS23SigningSession(party2, messageHash, signID, []uint8{1})
// Phase 1: Generate and exchange
out1_1, _ := sess1.Phase1()
out1_2, _ := sess2.Phase1()
// Phase 2: Exchange Phase1 outputs
out2_1, _ := sess1.Phase2(map[uint8]*sign.Phase1ToPhase2Transmit{2: out1_2.ToTransmit[2]})
out2_2, _ := sess2.Phase2(map[uint8]*sign.Phase1ToPhase2Transmit{1: out1_1.ToTransmit[1]})
// Phase 3: Exchange Phase2 outputs
out3_1, _ := sess1.Phase3(map[uint8]*sign.Phase2ToPhase3Transmit{2: out2_2.ToTransmit[2]})
out3_2, _ := sess2.Phase3(map[uint8]*sign.Phase2ToPhase3Transmit{1: out2_1.ToTransmit[1]})
// Phase 4: Aggregate broadcasts into final signature
allBroadcasts := []*sign.Phase3Broadcast{out3_1.Broadcast, out3_2.Broadcast}
sig, _ := sess1.Phase4(allBroadcasts, true) // true = normalize S for Bitcoin/Ethereum
// sig.R, sig.S, sig.RecoveryIDFor local testing with all parties in one process:
parties := []*sign.Party{party1, party2} // threshold parties
sig, _ := session.DKLS23QuickSign(parties, messageHash, signID, true)For Railgun protocol compatibility, use the railgun package which produces signatures verifiable by circomlibjs eddsa.verifyPoseidon:
import (
"crypto/rand"
"github.com/f3rmion/fy/railgun"
)
func main() {
// Create a 2-of-3 threshold wallet
tw, _ := railgun.NewThresholdWallet(2, 3)
// Run DKG to generate shares
shares, _ := tw.GenerateShares(rand.Reader)
// Get the circomlibjs-compatible public key (A = Y/8)
pkX, pkY, _ := shares[0].SpendingPublicKey()
// Sign a message (e.g., Railgun sighash)
message := sighash.Bytes() // 32-byte Poseidon hash
sig, _ := tw.Sign(shares[:2], message)
// Get signature components for circomlibjs verification
rx, ry, s := sig.Components()
}The signature can be verified in TypeScript:
import { eddsa } from '@railgun-community/circomlibjs';
const pubkey: [bigint, bigint] = [pkX, pkY];
const signature = { R8: [rx, ry], S: s };
const isValid = eddsa.verifyPoseidon(message, signature, pubkey);Two implementations for different use cases:
| Type | Parameters | Use Case |
|---|---|---|
bjj.BJJ |
A=-1 (gnark-crypto) | General FROST signing |
bjj.CircomBJJ |
A=168700, D=168696 | circomlibjs/Railgun compatibility |
Standard Bitcoin/Ethereum curve for ECDSA signatures:
import "github.com/f3rmion/fy/secp256k1"
g := &secp256k1.Secp256k1{}FROST supports multiple hashers for different use cases:
// Default: SHA-256
f, _ := frost.New(g, 2, 3)
// Ledger compatible: Blake2b-512
f, _ := frost.NewWithHasher(g, 2, 3, frost.NewBlake2bHasher())
// zkSNARK optimized: Poseidon
f, _ := frost.NewWithHasher(g, 2, 3, frost.NewPoseidonHasher())
// Railgun compatible
f, _ := frost.NewWithHasher(g, 2, 3, frost.NewRailgunHasher())| Hasher | Use Case |
|---|---|
SHA256Hasher |
General purpose, default |
Blake2bHasher |
Ledger hardware wallet compatibility |
PoseidonHasher |
zkSNARK circuit verification |
RailgunHasher |
circomlibjs eddsa.verifyPoseidon compatibility |
To use threshold signatures with a different elliptic curve, implement the group.Group interface:
- Implement
group.Scalarfor field elements - Implement
group.Pointfor curve points - Implement
group.Groupas a factory
See the bjj or secp256k1 packages for reference implementations.
-
FROST: Flexible Round-Optimized Schnorr Threshold Signatures https://eprint.iacr.org/2020/852
-
DKLs23: Threshold ECDSA in Three Rounds https://eprint.iacr.org/2023/765
-
Baby Jubjub Elliptic Curve https://eips.ethereum.org/EIPS/eip-2494
MIT