Threshold ECDSA
DKLs18 2-of-2 threshold ECDSA — the protocol.Iterator API, serialization, key refresh, the low-level round methods, and the trusted-dealer shortcut.
tecdsa/dklsv1 is two-party ECDSA: Alice and Bob each hold a multiplicative share of the private
key, and together they produce a signature that verifies under an ordinary ECDSA verifier. The
package doc names the paper it wraps — DKLs18 — and the
sub-packages cite specific protocols from it: DKG is “Protocol 2” page 7, signing is “Protocol 4”
page 9, the OT extension is “Protocol 9”.
The joint key is multiplicative: pk = (sk_A · sk_B) · G. That is why the protocol needs
oblivious transfer — multiplying two secret shares without revealing them is the hard part, and
OT is the machinery that does it.
Use the iterator API
tecdsa/dklsv1 exposes six constructors returning types that satisfy protocol.Iterator:
type Iterator interface {
Next(input *Message) (*Message, error)
Result(version uint) (*Message, error)
}
Each Next consumes the counterparty’s last message and produces the next one, until it returns
protocol.ErrProtocolFinished. Messages are *protocol.Message — a JSON-serialisable envelope of
payload bytes, metadata, a protocol name, and a version — so your transport never needs to know
what round it is on.
NewAliceDkg(curve, version)?*AliceDkg
DKG as Alice. Not an error return — construction cannot fail.
*AliceDkgNewBobDkg(curve, version)?*BobDkg
DKG as Bob. Bob moves first in DKG.
*BobDkgNewAliceSign(curve, hash, message, dkgResultMessage, version)?(*AliceSign, error)
Signing as Alice. Needs Alice's encoded DKG (or refresh) result. Alice moves first in signing.
(*AliceSign, error)NewBobSign(curve, hash, message, dkgResultMessage, version)?(*BobSign, error)
Signing as Bob. Bob is the party that ends up with the signature.
(*BobSign, error)NewAliceRefresh(curve, dkgResultMessage, version)?(*AliceRefresh, error)
Key refresh as Alice. Alice moves first.
(*AliceRefresh, error)NewBobRefresh(curve, dkgResultMessage, version)?(*BobRefresh, error)
Key refresh as Bob.
(*BobRefresh, error)The crank loop
Both parties advance in lockstep, each Next handing its output to the other. This is the harness
the package’s own tests use:
import (
"github.com/sonr-io/crypto/core/protocol"
)
// runIteratedProtocol cranks two parties alternately until both report
// ErrProtocolFinished. firstParty is whichever side moves first.
func runIteratedProtocol(firstParty, secondParty protocol.Iterator) (error, error) {
var (
message *protocol.Message
firstErr error
secondErr error
)
for firstErr != protocol.ErrProtocolFinished || secondErr != protocol.ErrProtocolFinished {
message, firstErr = firstParty.Next(message)
if firstErr != nil && firstErr != protocol.ErrProtocolFinished {
return nil, firstErr
}
message, secondErr = secondParty.Next(message)
if secondErr != nil && secondErr != protocol.ErrProtocolFinished {
return secondErr, nil
}
}
return firstErr, secondErr
}
The first Next is called with a nil message — that is how the mover-first party starts.
DKG
import (
"github.com/sonr-io/crypto/core/curves"
"github.com/sonr-io/crypto/core/protocol"
"github.com/sonr-io/crypto/tecdsa/dklsv1"
)
func runDkg() (*protocol.Message, *protocol.Message, error) {
curve := curves.K256()
alice := dklsv1.NewAliceDkg(curve, protocol.Version1)
bob := dklsv1.NewBobDkg(curve, protocol.Version1)
// Bob moves first in DKG.
aliceErr, bobErr := runIteratedProtocol(bob, alice)
if aliceErr != protocol.ErrProtocolFinished || bobErr != protocol.ErrProtocolFinished {
return nil, nil, fmt.Errorf("dkg did not complete: alice=%v bob=%v", aliceErr, bobErr)
}
// Both sides now agree on the public key:
// alice.Output().PublicKey.Equal(bob.Output().PublicKey) == true
aliceResult, err := alice.Result(protocol.Version1)
if err != nil {
return nil, nil, err
}
bobResult, err := bob.Result(protocol.Version1)
if err != nil {
return nil, nil, err
}
return aliceResult, bobResult, nil
}
Result returns the party’s own state, encoded, ready to be persisted and later fed to
NewAliceSign / NewBobSign. Alice’s result contains her SecretKeyShare and her seed-OT
receiver output; Bob’s contains his share and his seed-OT sender output. Both contain the shared
PublicKey.
PublicKeycurves.Point
The joint public key. Public; identical for Alice and Bob.
curves.PointSecretKeySharecurves.Scalar
This party's multiplicative share. Secret. Lose it and the key is unrecoverable.
curves.ScalarSeedOtResult*simplest.ReceiverOutput | *simplest.SenderOutput
Seed OT output — ReceiverOutput for Alice, SenderOutput for Bob. Secret, but replaceable by re-running OT (which is what refresh does).
*simplest.ReceiverOutput | *simplest.SenderOutputSigning
import "golang.org/x/crypto/sha3"
func runSign(curve *curves.Curve, aliceDkg, bobDkg *protocol.Message) (*curves.EcdsaSignature, error) {
msg := []byte("As soon as you trust yourself, you will know how to live.")
aliceSign, err := dklsv1.NewAliceSign(curve, sha3.New256(), msg, aliceDkg, protocol.Version1)
if err != nil {
return nil, err
}
bobSign, err := dklsv1.NewBobSign(curve, sha3.New256(), msg, bobDkg, protocol.Version1)
if err != nil {
return nil, err
}
// Alice moves first in signing.
aliceErr, bobErr := runIteratedProtocol(aliceSign, bobSign)
if aliceErr != protocol.ErrProtocolFinished || bobErr != protocol.ErrProtocolFinished {
return nil, fmt.Errorf("sign did not complete")
}
// Only Bob obtains the signature.
resultMessage, err := bobSign.Result(protocol.Version1)
if err != nil {
return nil, err
}
return dklsv1.DecodeSignature(resultMessage)
}
The result is a *curves.EcdsaSignature and verifies under curves.VerifyEcdsa — and under any
standard ECDSA verifier — against the joint public key. The hash hash.Hash argument is the digest
function; both parties must pass the same one, and both must pass the same message.
Key refresh
Refresh re-randomises both shares while leaving the public key untouched. The refresh package doc
describes the mechanism: Alice draws k_A, Bob draws k_B, the two are combined through a Merlin
transcript into a single k, Bob sets sk_B *= k and Alice sets sk_A *= k^{-1}. Since
sk_A · sk_B is unchanged, so is pk. Then the seed OT is redone from scratch.
func runRefresh(curve *curves.Curve, aliceDkg, bobDkg *protocol.Message) (*protocol.Message, *protocol.Message, error) {
aliceRefresh, err := dklsv1.NewAliceRefresh(curve, aliceDkg, protocol.Version1)
if err != nil {
return nil, nil, err
}
bobRefresh, err := dklsv1.NewBobRefresh(curve, bobDkg, protocol.Version1)
if err != nil {
return nil, nil, err
}
// Alice moves first in refresh.
aliceErr, bobErr := runIteratedProtocol(aliceRefresh, bobRefresh)
if aliceErr != protocol.ErrProtocolFinished || bobErr != protocol.ErrProtocolFinished {
return nil, nil, fmt.Errorf("refresh did not complete")
}
aliceOut, err := aliceRefresh.Result(protocol.Version1)
if err != nil {
return nil, nil, err
}
bobOut, err := bobRefresh.Result(protocol.Version1)
if err != nil {
return nil, nil, err
}
// These messages substitute for the DKG results in NewAliceSign / NewBobSign.
return aliceOut, bobOut, nil
}
The refresh outputs are the same *dkg.AliceOutput / *dkg.BobOutput shapes as DKG, so they drop
straight into the signing constructors.
Serialization
Every helper takes or returns a *protocol.Message, which marshals to JSON.
| Direction | Alice | Bob |
|---|---|---|
| DKG encode | EncodeAliceDkgOutput(*dkg.AliceOutput, version) |
EncodeBobDkgOutput(*dkg.BobOutput, version) |
| DKG decode | DecodeAliceDkgResult(*protocol.Message) |
DecodeBobDkgResult(*protocol.Message) |
| Refresh encode | EncodeAliceRefreshOutput(*dkg.AliceOutput, version) |
EncodeBobRefreshOutput(*dkg.BobOutput, version) |
| Refresh decode | DecodeAliceRefreshResult(*protocol.Message) |
DecodeBobRefreshResult(*protocol.Message) |
| Signature decode | — | DecodeSignature(*protocol.Message) |
Refresh outputs use the same dkg.AliceOutput / dkg.BobOutput structs as DKG; only the
protocol tag on the message differs (protocol.Dkls18Refresh versus protocol.Dkls18Dkg).
import "encoding/json"
// Persist Alice's DKG state.
msg, err := dklsv1.EncodeAliceDkgOutput(aliceDkg.Output(), protocol.Version1)
if err != nil {
return err
}
blob, err := json.Marshal(msg)
// ... store blob ...
// Restore it later.
restored := &protocol.Message{}
if err := json.Unmarshal(blob, restored); err != nil {
return err
}
aliceOutput, err := dklsv1.DecodeAliceDkgResult(restored)
protocol.EncodeMessage / protocol.DecodeMessage are also available and produce a
base64-of-JSON string if you want a single opaque token instead of a JSON object.
The version argument
version uint selects the serialization format. core/protocol defines exactly two constants, and
they are not the numbers you would guess:
// versions will increment in 100 intervals, to leave room for adding other versions in between them if it is
// ever needed in the future.
// Version0 is version 0!
Version0 = 100
// Version1 is version 2!
Version1 = 200
Pass protocol.Version1 (200). It is what every live test uses, and the only value the current
serializers are exercised with. The // Version1 is version 2! comment is in the source as
written — treat these as opaque tokens and never hardcode the integers.
The low-level round API
Underneath the iterators sit explicit round methods. Use them only when writing your own transport or auditing; they are the mechanism, not the interface.
tecdsa/dklsv1/dkg — 10 rounds
import "github.com/sonr-io/crypto/tecdsa/dklsv1/dkg"
alice := dkg.NewAlice(curve)
bob := dkg.NewBob(curve)
seed, err := bob.Round1GenerateRandomSeed()
round2Output, err := alice.Round2CommitToProof(seed)
proof, err := bob.Round3SchnorrProve(round2Output)
proof, err = alice.Round4VerifyAndReveal(proof)
proof, err = bob.Round5DecommitmentAndStartOt(proof)
compressedReceiversMaskedChoice, err := alice.Round6DkgRound2Ot(proof)
challenge, err := bob.Round7DkgRound3Ot(compressedReceiversMaskedChoice)
challengeResponse, err := alice.Round8DkgRound4Ot(challenge)
challengeOpenings, err := bob.Round9DkgRound5Ot(challengeResponse)
err = alice.Round10DkgRound6Ot(challengeOpenings)
// Only valid after round 10.
aliceOutput := alice.Output()
bobOutput := bob.Output()
Rounds 1–5 establish the joint public key with Schnorr proofs of knowledge of each share. Rounds
6–10 are the seed OT — simplest’s six rounds, driven through thin wrappers. Round 1 exists to
build a session identifier from 32 random bytes contributed by each side; the method’s own doc
comment notes this is not in the paper and is “secure if either party is honest”.
tecdsa/dklsv1/sign — 4 rounds
import "github.com/sonr-io/crypto/tecdsa/dklsv1/sign"
alice := sign.NewAlice(curve, sha3.New256(), aliceDkgOutput)
bob := sign.NewBob(curve, sha3.New256(), bobDkgOutput)
message := []byte("A message.")
seed, err := alice.Round1GenerateRandomSeed()
round2Output, err := bob.Round2Initialize(seed)
round3Output, err := alice.Round3Sign(message, round2Output)
err = bob.Round4Final(message, round3Output)
signature := bob.Signature // *curves.EcdsaSignature
Four rounds, and Bob’s Signature field is populated by Round4Final — which also verifies it.
Note the role reversal versus DKG: Alice contributes the seed here, Bob initialises.
The multiplication sub-protocol (“protocol 5 of the paper”) is exposed separately as
sign.MultiplySender and sign.MultiplyReceiver, constructed with
NewMultiplySender(seedOtResults *simplest.ReceiverOutput, curve, uniqueSessionId) and
NewMultiplyReceiver(seedOtResults *simplest.SenderOutput, curve, uniqueSessionId). Note the
crossed roles, which the constructor docs flag explicitly: the multiplication sender consumes the
seed-OT receiver’s output, and the multiplication receiver consumes the seed-OT sender’s.
tecdsa/dklsv1/refresh — 7 rounds
import "github.com/sonr-io/crypto/tecdsa/dklsv1/refresh"
alice := refresh.NewAlice(curve, aliceDkgOutput)
bob := refresh.NewBob(curve, bobDkgOutput)
round1Output := alice.Round1RefreshGenerateSeed() // no error return
round2Output, err := bob.Round2RefreshProduceSeedAndMultiplyAndStartOT(round1Output)
round3Output, err := alice.Round3RefreshMultiplyRound2Ot(round2Output)
round4Output, err := bob.Round4RefreshRound3Ot(round3Output)
round5Output, err := alice.Round5RefreshRound4Ot(round4Output)
round6Output, err := bob.Round6RefreshRound5Ot(round5Output)
err = alice.Round7DkgRound6Ot(round6Output)
newAliceOutput := alice.Output()
newBobOutput := bob.Output()
Rounds 1–2 do the share re-randomisation; 2–7 redo the seed OT. Round1RefreshGenerateSeed is the
only round method in the whole package with no error return.
Trusted dealer
tecdsa/dklsv1/dealer.GenerateAndDeal(curve) produces (*dkg.AliceOutput, *dkg.BobOutput, error)
in one call, with no interaction. The outputs are shape-identical to DKG’s and drop straight into
sign.NewAlice / sign.NewBob.
import "github.com/sonr-io/crypto/tecdsa/dklsv1/dealer"
aliceOutput, bobOutput, err := dealer.GenerateAndDeal(curves.K256())
if err != nil {
return err
}
alice := sign.NewAlice(curves.K256(), sha3.New256(), aliceOutput)
bob := sign.NewBob(curves.K256(), sha3.New256(), bobOutput)
// ... four signing rounds as above ...
Caveats
Next
MPC Enclave
The wrapper over this package that application code should actually call — key import/export, signing, and persistence without touching rounds.
Oblivious Transfer
The seed OT and cOT extension that rounds 6–10 are driving.
ECDSA
Single-party ECDSA, and the verifier this package’s output satisfies.
Distributed Key Generation
The other DKG protocols in the repository — none of which feed this one.