From 9ca56881127c9b481d4075d82749fed2d4b1c35e Mon Sep 17 00:00:00 2001 From: vietddude Date: Wed, 30 Sep 2026 14:50:01 +0700 Subject: [PATCH 01/12] chore(dkls): add DKLs23 as a git submodule and ignore its build output --- .gitignore | 7 ++++++- .gitmodules | 4 ++++ third_party/dkls23 | 1 + 3 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 .gitmodules create mode 160000 third_party/dkls23 diff --git a/.gitignore b/.gitignore index 90d18835..1dc535ce 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,9 @@ node2 config.yaml .vscode .vagrant -.chain_code \ No newline at end of file +.chain_code +# DKLs23 build output and local artifacts +/mpcium +/mpcium-cli +*.test +benchmark/output.txt diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..92d3c5d6 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "third_party/dkls23"] + path = third_party/dkls23 + url = git@github.com:fystack/DKLs23.git + branch = dev diff --git a/third_party/dkls23 b/third_party/dkls23 new file mode 160000 index 00000000..47448195 --- /dev/null +++ b/third_party/dkls23 @@ -0,0 +1 @@ +Subproject commit 47448195296c6d6adfb04f29cd2dafd338c97168 From e19565267d3e2f83d8355a66311f0b0abacd7c55 Mon Sep 17 00:00:00 2001 From: vietddude Date: Wed, 30 Sep 2026 14:50:01 +0700 Subject: [PATCH 02/12] feat(types): add DKLs23 protocol selector, bundle wire format and signing identity - Protocol field on GenerateKeyMessage/SignTxMessage; client CreateWalletWithProtocol - DklsMessage binary frame; signature covers route, recipient, sender, phase and payload - identity Sign/VerifyDklsMessage; keygen result carries the DKLs23 public key and chain code --- pkg/client/client.go | 14 ++++++ pkg/event/keygen.go | 4 ++ pkg/identity/identity.go | 32 +++++++++++++ pkg/types/dkls.go | 94 ++++++++++++++++++++++++++++++++++++++ pkg/types/dkls_test.go | 59 ++++++++++++++++++++++++ pkg/types/initiator_msg.go | 18 ++++++++ 6 files changed, 221 insertions(+) create mode 100644 pkg/types/dkls.go create mode 100644 pkg/types/dkls_test.go diff --git a/pkg/client/client.go b/pkg/client/client.go index 1926e0ec..ce482daa 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -17,6 +17,7 @@ import ( type MPCClient interface { CreateWallet(walletID string) error CreateWalletWithAuthorizers(walletID string, authorizerSignatures []types.AuthorizerSignature) error + CreateWalletWithProtocol(walletID string, protocol types.Protocol, authorizerSignatures []types.AuthorizerSignature) error OnWalletCreationResult(callback func(event event.KeygenResultEvent)) error SignTransaction(msg *types.SignTxMessage) error @@ -120,10 +121,23 @@ func (c *mpcClient) CreateWallet(walletID string) error { // CreateWalletWithAuthorizers generates a GenerateKeyMessage with authorizer signatures, signs it, and publishes it. func (c *mpcClient) CreateWalletWithAuthorizers(walletID string, authorizerSignatures []types.AuthorizerSignature) error { + return c.createWallet(walletID, "", authorizerSignatures) +} + +// CreateWalletWithProtocol is like CreateWalletWithAuthorizers but additionally +// requests keygen from an alternate MPC backend (currently only +// types.ProtocolDkls23) alongside the default tss-lib ECDSA/EdDSA keys. The +// target cluster must be built with `-tags dkls` for this to succeed. +func (c *mpcClient) CreateWalletWithProtocol(walletID string, protocol types.Protocol, authorizerSignatures []types.AuthorizerSignature) error { + return c.createWallet(walletID, protocol, authorizerSignatures) +} + +func (c *mpcClient) createWallet(walletID string, protocol types.Protocol, authorizerSignatures []types.AuthorizerSignature) error { // build the message msg := &types.GenerateKeyMessage{ WalletID: walletID, AuthorizerSignatures: authorizerSignatures, + Protocol: protocol, } // compute the canonical raw bytes raw, err := msg.Raw() diff --git a/pkg/event/keygen.go b/pkg/event/keygen.go index 78ab6319..7a83b0b6 100644 --- a/pkg/event/keygen.go +++ b/pkg/event/keygen.go @@ -10,6 +10,10 @@ type KeygenResultEvent struct { WalletID string `json:"wallet_id"` ECDSAPubKey []byte `json:"ecdsa_pub_key"` EDDSAPubKey []byte `json:"eddsa_pub_key"` + // DKLSPubKey is set only when the request opted in via Protocol=dkls23. + DKLSPubKey []byte `json:"dkls_pub_key,omitempty"` + // DKLSChainCode lets clients derive non-hardened child public keys offline. + DKLSChainCode []byte `json:"dkls_chain_code,omitempty"` ResultType ResultType `json:"result_type"` ErrorReason string `json:"error_reason"` diff --git a/pkg/identity/identity.go b/pkg/identity/identity.go index 94241464..63233538 100644 --- a/pkg/identity/identity.go +++ b/pkg/identity/identity.go @@ -43,6 +43,9 @@ type Store interface { SignMessage(msg *types.TssMessage) ([]byte, error) VerifyMessage(msg *types.TssMessage) error + SignDklsMessage(msg *types.DklsMessage) ([]byte, error) + VerifyDklsMessage(msg *types.DklsMessage) error + SignEcdhMessage(msg *types.ECDHMessage) ([]byte, error) VerifySignature(msg *types.ECDHMessage) error @@ -500,6 +503,35 @@ func (s *fileStore) VerifyMessage(msg *types.TssMessage) error { return nil } +func (s *fileStore) SignDklsMessage(msg *types.DklsMessage) ([]byte, error) { + msgBytes, err := msg.MarshalForSigning() + if err != nil { + return nil, fmt.Errorf("failed to marshal dkls message for signing: %w", err) + } + return ed25519.Sign(s.privateKey, msgBytes), nil +} + +func (s *fileStore) VerifyDklsMessage(msg *types.DklsMessage) error { + if msg.Signature == nil { + return fmt.Errorf("message has no signature") + } + + publicKey, err := s.GetPublicKey(msg.From) + if err != nil { + return fmt.Errorf("failed to get sender's public key: %w", err) + } + + msgBytes, err := msg.MarshalForSigning() + if err != nil { + return fmt.Errorf("failed to marshal dkls message for verification: %w", err) + } + + if !ed25519.Verify(publicKey, msgBytes, msg.Signature) { + return fmt.Errorf("invalid signature") + } + return nil +} + func (s *fileStore) EncryptMessage(plaintext []byte, peerID string) ([]byte, error) { key, err := s.GetSymmetricKey(peerID) if err != nil { diff --git a/pkg/types/dkls.go b/pkg/types/dkls.go new file mode 100644 index 00000000..b4866764 --- /dev/null +++ b/pkg/types/dkls.go @@ -0,0 +1,94 @@ +package types + +import ( + "encoding/binary" + "errors" +) + +// Route and To are not on the wire: the receiver takes them from the subject, and the signature covers both. +type DklsMessage struct { + Route string `json:"-"` + To string `json:"-"` + From string + Phase uint8 + Payload []byte + Signature []byte +} + +const dklsWireVersion = 1 + +var errDklsWireTruncated = errors.New("dkls wire message truncated") + +func (m *DklsMessage) MarshalForSigning() ([]byte, error) { + buf := make([]byte, 0, 10+len(m.Route)+len(m.To)+len(m.From)+len(m.Payload)) + buf = appendBytes16(buf, []byte(m.Route)) + buf = appendBytes16(buf, []byte(m.To)) + buf = appendBytes16(buf, []byte(m.From)) + buf = append(buf, m.Phase) + return append(buf, m.Payload...), nil +} + +func (m *DklsMessage) MarshalWire() []byte { + buf := make([]byte, 0, 8+len(m.From)+len(m.Payload)+len(m.Signature)) + buf = append(buf, dklsWireVersion, m.Phase) + buf = appendBytes16(buf, []byte(m.From)) + buf = binary.BigEndian.AppendUint32(buf, uint32(len(m.Payload))) + buf = append(buf, m.Payload...) + return appendBytes16(buf, m.Signature) +} + +func (m *DklsMessage) UnmarshalWire(data []byte) error { + r := wireReader{data: data} + if r.u8() != dklsWireVersion { + return errors.New("unsupported dkls wire version") + } + m.Phase = r.u8() + m.From = string(r.bytes16()) + m.Payload = r.bytes32() + m.Signature = r.bytes16() + return r.err +} + +func appendBytes16(buf, b []byte) []byte { + buf = binary.BigEndian.AppendUint16(buf, uint16(len(b))) + return append(buf, b...) +} + +type wireReader struct { + data []byte + err error +} + +func (r *wireReader) take(n int) []byte { + if r.err != nil || n < 0 || len(r.data) < n { + r.err = errDklsWireTruncated + return nil + } + out := r.data[:n] + r.data = r.data[n:] + return out +} + +func (r *wireReader) u8() uint8 { + b := r.take(1) + if b == nil { + return 0 + } + return b[0] +} + +func (r *wireReader) bytes16() []byte { + b := r.take(2) + if b == nil { + return nil + } + return r.take(int(binary.BigEndian.Uint16(b))) +} + +func (r *wireReader) bytes32() []byte { + b := r.take(4) + if b == nil { + return nil + } + return r.take(int(binary.BigEndian.Uint32(b))) +} diff --git a/pkg/types/dkls_test.go b/pkg/types/dkls_test.go new file mode 100644 index 00000000..68c47a88 --- /dev/null +++ b/pkg/types/dkls_test.go @@ -0,0 +1,59 @@ +package types + +import ( + "bytes" + "reflect" + "testing" +) + +func TestDklsMessageWireRoundTrip(t *testing.T) { + in := DklsMessage{ + From: "node-a", + Phase: 3, + Payload: bytes.Repeat([]byte{0xAB}, 70000), + Signature: []byte("signature"), + } + var out DklsMessage + if err := out.UnmarshalWire(in.MarshalWire()); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(in, out) { + t.Fatal("round trip mismatch") + } +} + +func TestDklsMessageWireRejectsTruncated(t *testing.T) { + in := DklsMessage{From: "a", Phase: 1, Payload: []byte{1, 2, 3}, Signature: []byte{9}} + wire := in.MarshalWire() + for n := 0; n < len(wire); n++ { + var out DklsMessage + if err := out.UnmarshalWire(wire[:n]); err == nil { + t.Fatalf("expected error for %d-byte prefix", n) + } + } +} + +func TestDklsMessageSigningBytesBindRouteAndRecipient(t *testing.T) { + base := DklsMessage{Route: "sign.w:tx1", To: "node-b", From: "node-a", Phase: 1, Payload: []byte("p")} + baseBytes, _ := base.MarshalForSigning() + + for name, mutate := range map[string]func(*DklsMessage){ + "route": func(m *DklsMessage) { m.Route = "sign.w:tx2" }, + "to": func(m *DklsMessage) { m.To = "node-c" }, + "from": func(m *DklsMessage) { m.From = "node-x" }, + "phase": func(m *DklsMessage) { m.Phase = 2 }, + "payload": func(m *DklsMessage) { m.Payload = []byte("q") }, + } { + changed := base + mutate(&changed) + if got, _ := changed.MarshalForSigning(); bytes.Equal(got, baseBytes) { + t.Fatalf("signing bytes do not cover %s", name) + } + } + + withSig := base + withSig.Signature = []byte("sig") + if got, _ := withSig.MarshalForSigning(); !bytes.Equal(got, baseBytes) { + t.Fatal("signing bytes must not depend on the signature") + } +} diff --git a/pkg/types/initiator_msg.go b/pkg/types/initiator_msg.go index f781be1c..abddc1ee 100644 --- a/pkg/types/initiator_msg.go +++ b/pkg/types/initiator_msg.go @@ -9,6 +9,16 @@ const ( KeyTypeEd25519 KeyType = "ed25519" ) +// Protocol selects which MPC backend runs a keygen/signing request. +// Empty (the zero value) means the existing tss-lib backend — every message +// without this field (all pre-existing clients) keeps behaving exactly as +// before. +type Protocol string + +const ( + ProtocolDkls23 Protocol = "dkls23" +) + type EventInitiatorKeyType string const ( @@ -38,6 +48,9 @@ type GenerateKeyMessage struct { WalletID string `json:"wallet_id"` Signature []byte `json:"signature"` AuthorizerSignatures []AuthorizerSignature `json:"authorizer_signatures,omitempty"` + // Protocol, when set to ProtocolDkls23, additionally runs a DKLs23 keygen + // alongside the default ECDSA+EdDSA (tss-lib) keys. Omitted/empty is a no-op. + Protocol Protocol `json:"protocol,omitempty"` } type SignTxMessage struct { @@ -49,6 +62,9 @@ type SignTxMessage struct { Signature []byte `json:"signature"` DerivationPath []uint32 `json:"derivation_path"` AuthorizerSignatures []AuthorizerSignature `json:"authorizer_signatures,omitempty"` + // Protocol, when set to ProtocolDkls23, signs with the wallet's DKLs23 key + // instead of the tss-lib key selected by KeyType. Omitted/empty is a no-op. + Protocol Protocol `json:"protocol,omitempty"` } type ResharingMessage struct { @@ -70,6 +86,7 @@ func (m *SignTxMessage) Raw() ([]byte, error) { TxID string `json:"tx_id"` Tx []byte `json:"tx"` DerivationPath []uint32 `json:"derivation_path,omitempty"` + Protocol Protocol `json:"protocol,omitempty"` }{ KeyType: m.KeyType, WalletID: m.WalletID, @@ -77,6 +94,7 @@ func (m *SignTxMessage) Raw() ([]byte, error) { TxID: m.TxID, Tx: m.Tx, DerivationPath: m.DerivationPath, + Protocol: m.Protocol, } return json.Marshal(payload) } From 924fc30301a56968afd2824dc83652c8b427b730 Mon Sep 17 00:00:00 2001 From: vietddude Date: Wed, 30 Sep 2026 14:50:01 +0700 Subject: [PATCH 03/12] feat(dkls): add DKLs23 threshold-ECDSA backend - Session runs keygen/sign rounds; Service wires sessions to a node's pubsub, key store and peers - Router gives one wildcard subscription per node and buffers early bundles, so no peer-ready barrier - Per-recipient binary bundles, HD derivation for non-hardened paths, typed signatures - Behind the dkls build tag; make targets for the Rust library, tests and benchmarks --- Makefile | 32 +++ go.mod | 3 + go.sum | 2 + pkg/mpc/dkls/nats_test.go | 115 +++++++++++ pkg/mpc/dkls/round.go | 236 ++++++++++++++++++++++ pkg/mpc/dkls/round_test.go | 66 ++++++ pkg/mpc/dkls/router.go | 161 +++++++++++++++ pkg/mpc/dkls/router_test.go | 78 +++++++ pkg/mpc/dkls/service.go | 156 ++++++++++++++ pkg/mpc/dkls/service_test.go | 128 ++++++++++++ pkg/mpc/dkls/session.go | 287 ++++++++++++++++++++++++++ pkg/mpc/dkls/session_test.go | 138 +++++++++++++ pkg/mpc/dkls/testutil_test.go | 370 ++++++++++++++++++++++++++++++++++ pkg/mpc/dkls/transport.go | 72 +++++++ 14 files changed, 1844 insertions(+) create mode 100644 pkg/mpc/dkls/nats_test.go create mode 100644 pkg/mpc/dkls/round.go create mode 100644 pkg/mpc/dkls/round_test.go create mode 100644 pkg/mpc/dkls/router.go create mode 100644 pkg/mpc/dkls/router_test.go create mode 100644 pkg/mpc/dkls/service.go create mode 100644 pkg/mpc/dkls/service_test.go create mode 100644 pkg/mpc/dkls/session.go create mode 100644 pkg/mpc/dkls/session_test.go create mode 100644 pkg/mpc/dkls/testutil_test.go create mode 100644 pkg/mpc/dkls/transport.go diff --git a/Makefile b/Makefile index 6f94ebc0..fc47208e 100644 --- a/Makefile +++ b/Makefile @@ -79,6 +79,38 @@ else @echo "Successfully installed mpcium and mpcium-cli to $(INSTALL_DIR)/" endif +# Build the DKLs23 Rust cgo library required by the (opt-in, build-tag "dkls") DKLs23 backend +dkls-lib: + cd third_party/dkls23/wrapper/go-ll && cargo build --release + +# Run DKLs23 backend tests (requires dkls-lib built first) +test-dkls: dkls-lib + CGO_ENABLED=1 go test -tags dkls ./pkg/mpc/dkls/... + +# Run DKLs23 tests against a real (throwaway, dockerized) NATS server instead +# of the in-process MemoryTransport used by test-dkls. +test-dkls-nats: dkls-lib + docker rm -f mpcium-dkls-test-nats >/dev/null 2>&1 || true + docker run -d --name mpcium-dkls-test-nats -p 14222:4222 nats:latest -js + CGO_ENABLED=1 go test -tags 'dkls dklsnats' ./pkg/mpc/dkls/... ; \ + status=$$?; \ + docker rm -f mpcium-dkls-test-nats >/dev/null 2>&1; \ + exit $$status + +# Run the DKLs23 vs tss-lib in-process benchmark (requires dkls-lib built first) +bench-dkls: dkls-lib + CGO_ENABLED=1 go test -tags dklsbench -bench . -benchtime=5x -run '^$$' ./benchmark/dklsvstss/... + +# Run the DKLs23 e2e benchmark (real NATS, real ed25519 message signing) instead +# of the pure library-level bench-dkls above. +bench-dkls-nats: dkls-lib + docker rm -f mpcium-dkls-bench-nats >/dev/null 2>&1 || true + docker run -d --name mpcium-dkls-bench-nats -p 14222:4222 nats:latest -js + CGO_ENABLED=1 go test -tags 'dkls dklsnats' -bench . -benchtime=3x -run '^$$' ./pkg/mpc/dkls/... ; \ + status=$$?; \ + docker rm -f mpcium-dkls-bench-nats >/dev/null 2>&1; \ + exit $$status + # Run all tests test: go test ./... diff --git a/go.mod b/go.mod index 3d1875e8..3ccf003d 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.8 require ( filippo.io/age v1.3.1 + github.com/fystack/DKLs23/wrapper/go-ll v0.0.0-00010101000000-000000000000 github.com/avast/retry-go v3.0.0+incompatible github.com/aws/aws-sdk-go-v2/config v1.32.7 github.com/aws/aws-sdk-go-v2/credentials v1.19.7 @@ -105,3 +106,5 @@ require ( replace github.com/agl/ed25519 => github.com/binance-chain/edwards25519 v0.0.0-20200305024217-f36fc4b53d43 replace github.com/bnb-chain/tss-lib/v3 => github.com/fystack/tss-lib/v3 v3.0.1 + +replace github.com/fystack/DKLs23/wrapper/go-ll => ./third_party/dkls23/wrapper/go-ll diff --git a/go.sum b/go.sum index 8a4cb4ad..2817e643 100644 --- a/go.sum +++ b/go.sum @@ -81,6 +81,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= +github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= github.com/decred/dcrd/crypto/rand v1.0.1 h1:pYMgDRmRv1z1RNgAAs8izJstm4B+fLFiqGD5btOt2Wg= github.com/decred/dcrd/crypto/rand v1.0.1/go.mod h1:MsA2XySk/4KpCOYW6vsNYTGuOYRK1wpvulaWCuW7RyI= github.com/decred/dcrd/dcrec/edwards/v2 v2.0.4 h1:xmmdtnGxF/Od2doiP56zBv5a3LgJ3PA6mlx3Luf622I= diff --git a/pkg/mpc/dkls/nats_test.go b/pkg/mpc/dkls/nats_test.go new file mode 100644 index 00000000..17ad322d --- /dev/null +++ b/pkg/mpc/dkls/nats_test.go @@ -0,0 +1,115 @@ +//go:build dkls && dklsnats + +// Needs a NATS server at DKLS_TEST_NATS_URL (default 127.0.0.1:14222); `make test-dkls-nats` starts one. +package dkls + +import ( + "crypto/sha256" + "fmt" + "os" + "sync" + "testing" + + "github.com/fystack/mpcium/pkg/messaging" + "github.com/nats-io/nats.go" +) + +func natsURL() string { + if v := os.Getenv("DKLS_TEST_NATS_URL"); v != "" { + return v + } + return "nats://127.0.0.1:14222" +} + +func newNATSServices(t testing.TB, nodeIDs []string) map[string]*Service { + t.Helper() + conn, err := nats.Connect(natsURL()) + if err != nil { + t.Skipf("no NATS server at %s: %v", natsURL(), err) + } + t.Cleanup(conn.Close) + + services := newTestServices(t, messaging.NewNATSPubSub(conn), nodeIDs, nodeIDs) + if err := conn.Flush(); err != nil { + t.Fatal(err) + } + return services +} + +func keygenAll(t testing.TB, services map[string]*Service, walletID string, threshold int) KeyResult { + t.Helper() + var wg sync.WaitGroup + results := make(chan KeyResult, len(services)) + errs := make(chan error, len(services)) + for _, svc := range services { + wg.Add(1) + go func() { + defer wg.Done() + res, err := svc.Keygen(walletID, threshold) + results <- res + errs <- err + }() + } + wg.Wait() + for range services { + if err := <-errs; err != nil { + t.Fatalf("keygen failed: %v", err) + } + } + return <-results +} + +func signAll(t testing.TB, services map[string]*Service, signers []string, walletID, txID string, hash []byte, path []uint32) []Signature { + t.Helper() + sigs := make([]Signature, len(signers)) + errs := make([]error, len(signers)) + var wg sync.WaitGroup + for i, id := range signers { + wg.Add(1) + go func() { + defer wg.Done() + sigs[i], errs[i] = services[id].Sign(walletID, txID, hash, path) + }() + } + wg.Wait() + for _, err := range errs { + if err != nil { + t.Fatalf("sign failed: %v", err) + } + } + return sigs +} + +func TestKeygenAndSign_RealNATS_3of3(t *testing.T) { + nodeIDs := []string{"node-a", "node-b", "node-c"} + services := newNATSServices(t, nodeIDs) + master := keygenAll(t, services, "nats-wallet-1", 2) + + hash := sha256.Sum256([]byte("real-nats-integration-test")) + for _, sig := range signAll(t, services, nodeIDs, "nats-wallet-1", "tx-1", hash[:], nil) { + verifySignature(t, sig, hash[:], parsePubKey(t, master.PubKey)) + } + + child := bip32DerivePublic(t, compressXY(t, master.PubKey), master.ChainCode, []uint32{3, 9}) + for _, sig := range signAll(t, services, nodeIDs, "nats-wallet-1", "tx-2", hash[:], []uint32{3, 9}) { + verifySignature(t, sig, hash[:], child) + } +} + +func BenchmarkKeygen_RealNATS_3of3(b *testing.B) { + services := newNATSServices(b, []string{"node-a", "node-b", "node-c"}) + for i := 0; b.Loop(); i++ { + keygenAll(b, services, fmt.Sprintf("bench-kg-wallet-%d", i), 2) + } +} + +func BenchmarkSign_RealNATS_3of3(b *testing.B) { + nodeIDs := []string{"node-a", "node-b", "node-c"} + services := newNATSServices(b, nodeIDs) + keygenAll(b, services, "bench-sign-wallet", 2) + + for i := 0; b.Loop(); i++ { + hash := sha256.Sum256(fmt.Appendf(nil, "bench-sign-msg-%d", i)) + signAll(b, services, nodeIDs, "bench-sign-wallet", fmt.Sprintf("tx-%d", i), hash[:], nil) + } +} diff --git a/pkg/mpc/dkls/round.go b/pkg/mpc/dkls/round.go new file mode 100644 index 00000000..47eee1e9 --- /dev/null +++ b/pkg/mpc/dkls/round.go @@ -0,0 +1,236 @@ +//go:build dkls + +package dkls + +import ( + "encoding/binary" + "errors" + "fmt" + "time" + + dklsll "github.com/fystack/DKLs23/wrapper/go-ll/dkls" + "github.com/fystack/mpcium/pkg/types" +) + +const phaseTimeout = 60 * time.Second + +type Phase uint8 + +const ( + PhaseKG1 Phase = iota + 1 + PhaseKG2 + PhaseKG3 + PhaseSG1 + PhaseSG2 + PhaseSG3 +) + +// Point-to-point payloads are encrypted for their recipient; broadcast entries stay public. +type MessageCipher interface { + EncryptMessage(plaintext []byte, peerID string) ([]byte, error) + DecryptMessage(cipher []byte, peerID string) ([]byte, error) +} + +type receivedBundle struct { + from string + payload []byte +} + +type round struct { + selfNodeID string + self uint8 + partyIDs map[string]uint8 + transport Transport + cipher MessageCipher + timeout time.Duration + early map[Phase][]receivedBundle +} + +func newRound(selfNodeID string, self uint8, partyIDs map[string]uint8, transport Transport, cipher MessageCipher) *round { + return &round{ + selfNodeID: selfNodeID, + self: self, + partyIDs: partyIDs, + transport: transport, + cipher: cipher, + timeout: phaseTimeout, + early: make(map[Phase][]receivedBundle), + } +} + +func (r *round) exchange(phase Phase, out []dklsll.Message, peers []string) ([]dklsll.Message, error) { + if err := r.send(phase, out, peers); err != nil { + return nil, err + } + received, err := r.receive(phase, peers) + if err != nil { + return nil, err + } + all := make([]dklsll.Message, 0, len(out)+len(received)) + all = append(all, out...) + return append(all, received...), nil +} + +func (r *round) send(phase Phase, msgs []dklsll.Message, peers []string) error { + for _, peer := range peers { + peerParty := r.partyIDs[peer] + entries := make([]dklsll.Message, 0, len(msgs)) + for _, m := range msgs { + switch m.ToID { + case broadcastToID: + entries = append(entries, m) + case peerParty: + ciphertext, err := r.cipher.EncryptMessage(m.Payload, peer) + if err != nil { + return fmt.Errorf("encrypt dkls payload for %s: %w", peer, err) + } + m.Payload = ciphertext + entries = append(entries, m) + } + } + msg := types.DklsMessage{From: r.selfNodeID, Phase: uint8(phase), Payload: encodeBundle(entries)} + if err := r.transport.Send(peer, msg); err != nil { + return err + } + } + return nil +} + +func (r *round) receive(phase Phase, peers []string) ([]dklsll.Message, error) { + expected := make(map[string]bool, len(peers)) + for _, peer := range peers { + expected[peer] = true + } + + var all []dklsll.Message + accept := func(b receivedBundle) error { + if !expected[b.from] { + return nil + } + delete(expected, b.from) + msgs, err := r.open(b) + if err != nil { + return err + } + all = append(all, msgs...) + return nil + } + + for _, b := range r.early[phase] { + if err := accept(b); err != nil { + return nil, err + } + } + delete(r.early, phase) + + timeout := time.NewTimer(r.timeout) + defer timeout.Stop() + for len(expected) > 0 { + select { + case msg, ok := <-r.transport.Inbox(): + if !ok { + return nil, errors.New("dkls transport closed while waiting for messages") + } + b := receivedBundle{from: msg.From, payload: msg.Payload} + if Phase(msg.Phase) != phase { + r.early[Phase(msg.Phase)] = append(r.early[Phase(msg.Phase)], b) + continue + } + if err := accept(b); err != nil { + return nil, err + } + case <-timeout.C: + return nil, fmt.Errorf("timeout waiting for dkls phase %d: %d of %d peers missing", phase, len(expected), len(peers)) + } + } + return all, nil +} + +func (r *round) open(b receivedBundle) ([]dklsll.Message, error) { + msgs, err := decodeBundle(b.payload) + if err != nil { + return nil, fmt.Errorf("decode dkls bundle from %s: %w", b.from, err) + } + for i, m := range msgs { + if m.ToID != r.self { + continue + } + plaintext, err := r.cipher.DecryptMessage(m.Payload, b.from) + if err != nil { + return nil, fmt.Errorf("decrypt dkls payload from %s: %w", b.from, err) + } + msgs[i].Payload = plaintext + } + return msgs, nil +} + +const broadcastToID = 255 + +var errBundleTruncated = errors.New("dkls bundle truncated") + +func encodeBundle(entries []dklsll.Message) []byte { + size := 2 + for _, m := range entries { + size += 7 + len(m.Payload) + } + buf := make([]byte, 0, size) + buf = binary.BigEndian.AppendUint16(buf, uint16(len(entries))) + for _, m := range entries { + buf = append(buf, m.FromID, m.ToID, byte(m.Kind)) + buf = binary.BigEndian.AppendUint32(buf, uint32(len(m.Payload))) + buf = append(buf, m.Payload...) + } + return buf +} + +func decodeBundle(data []byte) ([]dklsll.Message, error) { + if len(data) < 2 { + return nil, errBundleTruncated + } + count := int(binary.BigEndian.Uint16(data)) + data = data[2:] + msgs := make([]dklsll.Message, 0, count) + for range count { + if len(data) < 7 { + return nil, errBundleTruncated + } + n := int(binary.BigEndian.Uint32(data[3:7])) + if len(data) < 7+n { + return nil, errBundleTruncated + } + msgs = append(msgs, dklsll.Message{ + FromID: data[0], + ToID: data[1], + Kind: dklsll.MessageKind(data[2]), + Payload: data[7 : 7+n], + }) + data = data[7+n:] + } + return msgs, nil +} + +func selectMessages(msgs []dklsll.Message, keep func(dklsll.Message) bool) []dklsll.Message { + out := make([]dklsll.Message, 0, len(msgs)) + for _, m := range msgs { + if keep(m) { + out = append(out, m) + } + } + return out +} + +func addressedTo(msgs []dklsll.Message, self uint8) []dklsll.Message { + return selectMessages(msgs, func(m dklsll.Message) bool { return m.ToID == self || m.ToID == broadcastToID }) +} + +func ofKind(msgs []dklsll.Message, kind dklsll.MessageKind, to uint8) []dklsll.Message { + return selectMessages(msgs, func(m dklsll.Message) bool { return m.Kind == kind && m.ToID == to }) +} + +func broadcasts(msgs []dklsll.Message) []dklsll.Message { + return selectMessages(msgs, func(m dklsll.Message) bool { return m.ToID == broadcastToID }) +} + +func broadcastsOfKind(msgs []dklsll.Message, kind dklsll.MessageKind) []dklsll.Message { + return selectMessages(msgs, func(m dklsll.Message) bool { return m.Kind == kind && m.ToID == broadcastToID }) +} diff --git a/pkg/mpc/dkls/round_test.go b/pkg/mpc/dkls/round_test.go new file mode 100644 index 00000000..04ed7a7f --- /dev/null +++ b/pkg/mpc/dkls/round_test.go @@ -0,0 +1,66 @@ +//go:build dkls + +package dkls + +import ( + "strings" + "testing" + "time" + + dklsll "github.com/fystack/DKLs23/wrapper/go-ll/dkls" + "github.com/fystack/mpcium/pkg/types" +) + +func newTestRound(t *testing.T) (*round, *memoryTransport) { + t.Helper() + nodeIDs := []string{"node-a", "node-b"} + transport := newMemoryCluster(nodeIDs)["node-a"] + _, partyIDs := assignPartyIDs(nodeIDs) + return newRound("node-a", partyIDs["node-a"], partyIDs, transport, sharedTestKeys(nodeIDs)["node-a"]), transport +} + +func broadcastBundle(payload string, kind dklsll.MessageKind) []byte { + return encodeBundle([]dklsll.Message{{FromID: 2, ToID: broadcastToID, Kind: kind, Payload: []byte(payload)}}) +} + +func TestRound_BuffersBundlesFromLaterPhase(t *testing.T) { + r, transport := newTestRound(t) + transport.inbox <- types.DklsMessage{From: "node-b", Phase: uint8(PhaseSG2), Payload: broadcastBundle("two", dklsll.MsgSignPhase2)} + transport.inbox <- types.DklsMessage{From: "node-b", Phase: uint8(PhaseSG1), Payload: broadcastBundle("one", dklsll.MsgSignPhase1)} + + got1, err := r.receive(PhaseSG1, []string{"node-b"}) + if err != nil || len(got1) != 1 || string(got1[0].Payload) != "one" { + t.Fatalf("phase 1: %v %v", got1, err) + } + got2, err := r.receive(PhaseSG2, []string{"node-b"}) + if err != nil || len(got2) != 1 || string(got2[0].Payload) != "two" { + t.Fatalf("phase 2 (buffered): %v %v", got2, err) + } +} + +func TestRound_IgnoresDuplicateAndUnexpectedSenders(t *testing.T) { + r, transport := newTestRound(t) + bundle := broadcastBundle("one", dklsll.MsgSignPhase1) + transport.inbox <- types.DklsMessage{From: "node-b", Phase: uint8(PhaseSG1), Payload: bundle} + transport.inbox <- types.DklsMessage{From: "node-b", Phase: uint8(PhaseSG1), Payload: bundle} + transport.inbox <- types.DklsMessage{From: "node-z", Phase: uint8(PhaseSG1), Payload: bundle} + + got, err := r.receive(PhaseSG1, []string{"node-b"}) + if err != nil || len(got) != 1 { + t.Fatalf("got %d messages, err %v; want exactly the one expected bundle", len(got), err) + } +} + +func TestRound_TimesOutWhenPeerIsSilent(t *testing.T) { + r, _ := newTestRound(t) + r.timeout = 50 * time.Millisecond + + start := time.Now() + _, err := r.receive(PhaseSG1, []string{"node-b"}) + if err == nil || !strings.Contains(err.Error(), "timeout") { + t.Fatalf("err = %v, want timeout", err) + } + if time.Since(start) > 2*time.Second { + t.Fatal("receive did not honor the round timeout") + } +} diff --git a/pkg/mpc/dkls/router.go b/pkg/mpc/dkls/router.go new file mode 100644 index 00000000..44599669 --- /dev/null +++ b/pkg/mpc/dkls/router.go @@ -0,0 +1,161 @@ +//go:build dkls + +package dkls + +import ( + "strings" + "sync" + "time" + + "github.com/fystack/mpcium/pkg/logger" + "github.com/fystack/mpcium/pkg/messaging" + "github.com/fystack/mpcium/pkg/types" + "github.com/nats-io/nats.go" +) + +const ( + earlyBufferTTL = 60 * time.Second + earlyBufferSweep = 15 * time.Second + earlyBufferPerKey = 64 + earlyBufferMaxKeys = 200000 + sessionInboxBacklog = 64 +) + +type earlyBuffer struct { + msgs []types.DklsMessage + created time.Time +} + +// Bundles that beat the local session are buffered, so sessions need no peer-ready barrier. +type Router struct { + selfID string + auth MessageAuthenticator + prefix string + + mu sync.Mutex + sessions map[string]chan types.DklsMessage + early map[string]*earlyBuffer + + sub messaging.Subscription + stop chan struct{} +} + +func NewRouter(selfID string, pubsub messaging.PubSub, auth MessageAuthenticator) (*Router, error) { + r := &Router{ + selfID: selfID, + auth: auth, + prefix: subjectPrefix(selfID), + sessions: make(map[string]chan types.DklsMessage), + early: make(map[string]*earlyBuffer), + stop: make(chan struct{}), + } + sub, err := pubsub.Subscribe(r.prefix+">", func(msg *nats.Msg) { + go r.handle(msg.Subject, msg.Data) + }) + if err != nil { + return nil, err + } + r.sub = sub + go r.sweepLoop() + return r, nil +} + +func subjectPrefix(nodeID string) string { return "dkls.to." + nodeID + "." } + +func sessionSubject(to string, act Act, sessionKey string) string { + return subjectPrefix(to) + string(act) + "." + sessionKey +} + +func routeKey(act Act, sessionKey string) string { return string(act) + "." + sessionKey } + +func (r *Router) Close() { + close(r.stop) + if r.sub != nil { + _ = r.sub.Unsubscribe() + } +} + +func (r *Router) register(key string) chan types.DklsMessage { + inbox := make(chan types.DklsMessage, sessionInboxBacklog) + r.mu.Lock() + defer r.mu.Unlock() + r.sessions[key] = inbox + if buf, ok := r.early[key]; ok { + delete(r.early, key) + for _, m := range buf.msgs { + select { + case inbox <- m: + default: + } + } + } + return inbox +} + +func (r *Router) unregister(key string, inbox chan types.DklsMessage) { + r.mu.Lock() + defer r.mu.Unlock() + if r.sessions[key] == inbox { + delete(r.sessions, key) + } + close(inbox) +} + +func (r *Router) handle(subject string, data []byte) { + key := strings.TrimPrefix(subject, r.prefix) + var msg types.DklsMessage + if err := msg.UnmarshalWire(data); err != nil { + logger.Warn("failed to decode dkls message", "err", err.Error()) + return + } + if msg.From == r.selfID { + return + } + msg.Route = key + msg.To = r.selfID + if err := r.auth.VerifyDklsMessage(&msg); err != nil { + logger.Warn("failed to verify dkls message", "err", err.Error()) + return + } + + r.mu.Lock() + defer r.mu.Unlock() + if inbox, ok := r.sessions[key]; ok { + select { + case inbox <- msg: + default: + logger.Warn("dropping inbound dkls message, inbox full", "key", key) + } + return + } + buf, ok := r.early[key] + if !ok { + if len(r.early) >= earlyBufferMaxKeys { + return + } + buf = &earlyBuffer{created: time.Now()} + r.early[key] = buf + } + if len(buf.msgs) < earlyBufferPerKey { + buf.msgs = append(buf.msgs, msg) + } +} + +func (r *Router) sweepLoop() { + ticker := time.NewTicker(earlyBufferSweep) + defer ticker.Stop() + for { + select { + case <-r.stop: + return + case now := <-ticker.C: + r.mu.Lock() + for key, buf := range r.early { + if now.Sub(buf.created) > earlyBufferTTL { + delete(r.early, key) + } + } + r.mu.Unlock() + } + } +} diff --git a/pkg/mpc/dkls/router_test.go b/pkg/mpc/dkls/router_test.go new file mode 100644 index 00000000..068dfed7 --- /dev/null +++ b/pkg/mpc/dkls/router_test.go @@ -0,0 +1,78 @@ +//go:build dkls + +package dkls + +import ( + "testing" + "time" + + "github.com/fystack/mpcium/pkg/types" + "github.com/nats-io/nats.go" +) + +func newTestRouters(t *testing.T, bus *fakePubSub, nodeIDs []string) (map[string]*Router, map[string]ed25519Authenticator) { + t.Helper() + auths := newEd25519Authenticators(nodeIDs) + routers := make(map[string]*Router, len(nodeIDs)) + for _, id := range nodeIDs { + r, err := NewRouter(id, bus, auths[id]) + if err != nil { + t.Fatal(err) + } + t.Cleanup(r.Close) + routers[id] = r + } + return routers, auths +} + +func receiveWithin(t *testing.T, inbox <-chan types.DklsMessage, d time.Duration) (types.DklsMessage, bool) { + t.Helper() + select { + case msg := <-inbox: + return msg, true + case <-time.After(d): + return types.DklsMessage{}, false + } +} + +func TestRouter_DeliversVerifiedBundleAndBuffersEarlyOnes(t *testing.T) { + bus := &fakePubSub{} + nodeIDs := []string{"node-a", "node-b"} + routers, auths := newTestRouters(t, bus, nodeIDs) + + sender := NewNATSTransport("w:tx1", ActSign, routers["node-a"], bus, auths["node-a"]) + if err := sender.Send("node-b", types.DklsMessage{From: "node-a", Phase: 1, Payload: []byte("early")}); err != nil { + t.Fatal(err) + } + + receiver := NewNATSTransport("w:tx1", ActSign, routers["node-b"], bus, auths["node-b"]) + msg, ok := receiveWithin(t, receiver.Inbox(), time.Second) + if !ok || string(msg.Payload) != "early" { + t.Fatalf("early bundle was not delivered after the session registered: %v %v", msg, ok) + } +} + +func TestRouter_RejectsBundleReplayedOnAnotherSession(t *testing.T) { + bus := &fakePubSub{} + routers, auths := newTestRouters(t, bus, []string{"node-a", "node-b"}) + + var captured []byte + tap, _ := bus.Subscribe(sessionSubject("node-b", ActSign, "w:tx1"), func(m *nats.Msg) { captured = m.Data }) + defer tap.Unsubscribe() + + sender := NewNATSTransport("w:tx1", ActSign, routers["node-a"], bus, auths["node-a"]) + if err := sender.Send("node-b", types.DklsMessage{From: "node-a", Phase: 1, Payload: []byte("real")}); err != nil { + t.Fatal(err) + } + if captured == nil { + t.Fatal("did not capture the wire message") + } + + victim := NewNATSTransport("w:tx2", ActSign, routers["node-b"], bus, auths["node-b"]) + if err := bus.Publish(sessionSubject("node-b", ActSign, "w:tx2"), captured, nil); err != nil { + t.Fatal(err) + } + if msg, ok := receiveWithin(t, victim.Inbox(), 200*time.Millisecond); ok { + t.Fatalf("replayed bundle was accepted on another session: %v", msg) + } +} diff --git a/pkg/mpc/dkls/service.go b/pkg/mpc/dkls/service.go new file mode 100644 index 00000000..7e31330c --- /dev/null +++ b/pkg/mpc/dkls/service.go @@ -0,0 +1,156 @@ +//go:build dkls + +package dkls + +import ( + "fmt" + "slices" + "sync" + + "github.com/fystack/mpcium/pkg/keyinfo" + "github.com/fystack/mpcium/pkg/kvstore" + "github.com/fystack/mpcium/pkg/messaging" +) + +type PeerRegistry interface { + ArePeersReady() bool + GetReadyPeersIncludeSelf() []string +} + +type Identity interface { + MessageAuthenticator + MessageCipher +} + +type Config struct { + NodeID string + PubSub messaging.PubSub + Identity Identity + KV kvstore.KVStore + KeyInfo keyinfo.Store + Peers PeerRegistry +} + +type Service struct { + cfg Config + router *Router + keys KeyShareStore +} + +// Start it before the node is marked ready so peers never publish to an unsubscribed node. +func NewService(cfg Config) (*Service, error) { + router, err := NewRouter(cfg.NodeID, cfg.PubSub, cfg.Identity) + if err != nil { + return nil, fmt.Errorf("start dkls router: %w", err) + } + return &Service{cfg: cfg, router: router, keys: NewKeyStore(cfg.KV, cfg.KeyInfo)}, nil +} + +func (s *Service) Close() { s.router.Close() } + +func (s *Service) Keygen(walletID string, threshold int) (KeyResult, error) { + if !s.cfg.Peers.ArePeersReady() { + return KeyResult{}, ErrPeersNotReady + } + if _, err := s.keys.Info(walletID); err == nil { + return KeyResult{}, fmt.Errorf("%w: %s", ErrKeyExists, walletID) + } + + sess, err := s.newSession(walletID, walletID, ActKeygen, s.cfg.Peers.GetReadyPeersIncludeSelf(), threshold) + if err != nil { + return KeyResult{}, err + } + defer sess.Close() + return sess.Keygen() +} + +func (s *Service) Sign(walletID, txID string, msgHash []byte, derivationPath []uint32) (Signature, error) { + info, err := s.keys.Info(walletID) + if err != nil { + return Signature{}, err + } + if !slices.Contains(info.ParticipantPeerIDs, s.cfg.NodeID) { + return Signature{}, ErrNotParticipant + } + + ready := s.cfg.Peers.GetReadyPeersIncludeSelf() + var readyParticipants []string + for _, id := range info.ParticipantPeerIDs { + if slices.Contains(ready, id) { + readyParticipants = append(readyParticipants, id) + } + } + if len(readyParticipants) < info.Threshold+1 { + return Signature{}, fmt.Errorf("%w: need %d, have %d", ErrNotEnoughSigners, info.Threshold+1, len(readyParticipants)) + } + signers := readyParticipants[:info.Threshold+1] + if !slices.Contains(signers, s.cfg.NodeID) { + return Signature{}, ErrNotSelected + } + + sess, err := s.newSession(walletID, walletID+":"+txID, ActSign, info.ParticipantPeerIDs, info.Threshold) + if err != nil { + return Signature{}, err + } + defer sess.Close() + return sess.Sign(msgHash, signers, derivationPath) +} + +func (s *Service) newSession(walletID, sessionKey string, act Act, participants []string, threshold int) (*Session, error) { + return NewSession(SessionConfig{ + WalletID: walletID, + SelfNodeID: s.cfg.NodeID, + Participants: participants, + Threshold: threshold, + SessionKey: sessionKey, + Transport: NewNATSTransport(sessionKey, act, s.router, s.cfg.PubSub, s.cfg.Identity), + Cipher: s.cfg.Identity, + Keys: s.keys, + }) +} + +type KeyShareStore interface { + LoadShare(walletID string) ([]byte, error) + Save(walletID string, share []byte, info *keyinfo.KeyInfo) error + Info(walletID string) (*keyinfo.KeyInfo, error) +} + +type keyStore struct { + kv kvstore.KVStore + info keyinfo.Store + cache sync.Map +} + +func NewKeyStore(kv kvstore.KVStore, info keyinfo.Store) KeyShareStore { + return &keyStore{kv: kv, info: info} +} + +func keyID(walletID string) string { return "dkls:" + walletID } + +func (s *keyStore) LoadShare(walletID string) ([]byte, error) { + return s.kv.Get(keyID(walletID)) +} + +func (s *keyStore) Save(walletID string, share []byte, info *keyinfo.KeyInfo) error { + if err := s.kv.Put(keyID(walletID), share); err != nil { + return fmt.Errorf("store key share: %w", err) + } + if err := s.info.Save(keyID(walletID), info); err != nil { + return fmt.Errorf("store key info: %w", err) + } + s.cache.Store(walletID, info) + return nil +} + +// Cached because participants and threshold never change after keygen. +func (s *keyStore) Info(walletID string) (*keyinfo.KeyInfo, error) { + if cached, ok := s.cache.Load(walletID); ok { + return cached.(*keyinfo.KeyInfo), nil + } + info, err := s.info.Get(keyID(walletID)) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrWalletNotFound, err) + } + s.cache.Store(walletID, info) + return info, nil +} diff --git a/pkg/mpc/dkls/service_test.go b/pkg/mpc/dkls/service_test.go new file mode 100644 index 00000000..f94ec4b7 --- /dev/null +++ b/pkg/mpc/dkls/service_test.go @@ -0,0 +1,128 @@ +//go:build dkls + +package dkls + +import ( + "crypto/sha256" + "errors" + "testing" + + "github.com/fystack/mpcium/pkg/keyinfo" + "github.com/fystack/mpcium/pkg/messaging" +) + +func newTestServices(t testing.TB, bus messaging.PubSub, nodeIDs []string, ready []string) map[string]*Service { + t.Helper() + identities := newTestIdentities(nodeIDs) + services := make(map[string]*Service, len(nodeIDs)) + for _, id := range nodeIDs { + store := newFakeStore() + svc, err := NewService(Config{ + NodeID: id, + PubSub: bus, + Identity: identities[id], + KV: store, + KeyInfo: keyInfoView{store}, + Peers: fakePeers{ready: ready}, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(svc.Close) + services[id] = svc + } + return services +} + +func TestService_KeygenAndSignOverPubSub(t *testing.T) { + nodeIDs := []string{"node-a", "node-b", "node-c"} + services := newTestServices(t, &fakePubSub{}, nodeIDs, nodeIDs) + + type keygenResult struct { + res KeyResult + err error + } + kgCh := make(chan keygenResult, len(nodeIDs)) + for _, svc := range services { + go func() { + res, err := svc.Keygen("wallet-svc", 1) + kgCh <- keygenResult{res, err} + }() + } + var master KeyResult + for range nodeIDs { + r := <-kgCh + if r.err != nil { + t.Fatalf("keygen: %v", r.err) + } + master = r.res + } + + hash := sha256.Sum256([]byte("service-sign")) + type signResult struct { + sig Signature + err error + } + signCh := make(chan signResult, len(nodeIDs)) + for _, id := range nodeIDs[:2] { + go func() { + sig, err := services[id].Sign("wallet-svc", "tx-1", hash[:], []uint32{7}) + signCh <- signResult{sig, err} + }() + } + var sig Signature + for range nodeIDs[:2] { + r := <-signCh + if r.err != nil && !errors.Is(r.err, ErrNotSelected) { + t.Fatalf("sign: %v", r.err) + } + if r.err == nil { + sig = r.sig + } + } + + child := bip32DerivePublic(t, compressXY(t, master.PubKey), master.ChainCode, []uint32{7}) + verifySignature(t, sig, hash[:], child) +} + +func TestService_SignErrors(t *testing.T) { + nodeIDs := []string{"node-a", "node-b", "node-c"} + hash := sha256.Sum256([]byte("x")) + + t.Run("unknown wallet", func(t *testing.T) { + svc := newTestServices(t, &fakePubSub{}, nodeIDs[:1], nodeIDs)["node-a"] + if _, err := svc.Sign("nope", "tx", hash[:], nil); !errors.Is(err, ErrWalletNotFound) { + t.Fatalf("err = %v, want ErrWalletNotFound", err) + } + }) + + seed := func(svc *Service, participants []string, threshold int) { + if err := svc.keys.Save("w", []byte("share"), &keyinfo.KeyInfo{ParticipantPeerIDs: participants, Threshold: threshold, Version: 1}); err != nil { + t.Fatal(err) + } + } + + t.Run("node is not a participant", func(t *testing.T) { + svc := newTestServices(t, &fakePubSub{}, nodeIDs[:1], nodeIDs)["node-a"] + seed(svc, []string{"node-b", "node-c"}, 1) + if _, err := svc.Sign("w", "tx", hash[:], nil); !errors.Is(err, ErrNotParticipant) { + t.Fatalf("err = %v, want ErrNotParticipant", err) + } + }) + + t.Run("too few ready participants retries later", func(t *testing.T) { + svc := newTestServices(t, &fakePubSub{}, nodeIDs[:1], []string{"node-a"})["node-a"] + seed(svc, nodeIDs, 1) + if _, err := svc.Sign("w", "tx", hash[:], nil); !errors.Is(err, ErrNotEnoughSigners) { + t.Fatalf("err = %v, want ErrNotEnoughSigners", err) + } + }) + + t.Run("node outside the selected signer set", func(t *testing.T) { + svc := newTestServices(t, &fakePubSub{}, nodeIDs[2:], nodeIDs)["node-c"] + seed(svc, nodeIDs, 1) + if _, err := svc.Sign("w", "tx", hash[:], nil); !errors.Is(err, ErrNotSelected) { + t.Fatalf("err = %v, want ErrNotSelected", err) + } + }) +} diff --git a/pkg/mpc/dkls/session.go b/pkg/mpc/dkls/session.go new file mode 100644 index 00000000..a377153f --- /dev/null +++ b/pkg/mpc/dkls/session.go @@ -0,0 +1,287 @@ +//go:build dkls + +// Package dkls is the opt-in DKLs23 threshold-ECDSA backend (Rust via cgo, build tag dkls). +package dkls + +import ( + "crypto/sha256" + "encoding/binary" + "errors" + "fmt" + "slices" + "sort" + "strconv" + + "github.com/btcsuite/btcd/btcec/v2" + dklsll "github.com/fystack/DKLs23/wrapper/go-ll/dkls" + "github.com/fystack/mpcium/pkg/encoding" + "github.com/fystack/mpcium/pkg/keyinfo" +) + +var ( + ErrNotEnoughSigners = errors.New("not enough ready participants to sign") + ErrNotParticipant = errors.New("node is not a participant of this wallet") + ErrNotSelected = errors.New("node is not in the selected signer set") + ErrWalletNotFound = errors.New("dkls wallet not found") + ErrKeyExists = errors.New("dkls key already exists") + ErrPeersNotReady = errors.New("not all peers are ready") +) + +type Signature = dklsll.Signature + +type KeyResult struct { + PubKey []byte // 64-byte X||Y, the tss-lib ECDSA key format + ChainCode []byte +} + +type SessionConfig struct { + WalletID string + SelfNodeID string + Participants []string + Threshold int // t; t+1 signers are required + SessionKey string // keeps sign IDs unique per request + Transport Transport + Cipher MessageCipher + Keys KeyShareStore +} + +type Session struct { + cfg SessionConfig + participants []string + partyIDs map[string]uint8 + self uint8 + round *round +} + +func NewSession(cfg SessionConfig) (*Session, error) { + participants, partyIDs := assignPartyIDs(cfg.Participants) + self, ok := partyIDs[cfg.SelfNodeID] + if !ok { + return nil, ErrNotParticipant + } + return &Session{ + cfg: cfg, + participants: participants, + partyIDs: partyIDs, + self: self, + round: newRound(cfg.SelfNodeID, self, partyIDs, cfg.Transport, cfg.Cipher), + }, nil +} + +func (s *Session) Close() error { return s.cfg.Transport.Close() } + +func (s *Session) peersOf(nodeIDs []string) []string { + return slices.DeleteFunc(slices.Clone(nodeIDs), func(id string) bool { return id == s.cfg.SelfNodeID }) +} + +func sessionID(parts ...string) []byte { + h := sha256.New() + for _, p := range parts { + h.Write(binary.BigEndian.AppendUint32(nil, uint32(len(p)))) + h.Write([]byte(p)) + } + return h.Sum(nil) +} + +func (s *Session) Keygen() (KeyResult, error) { + peers := s.peersOf(s.participants) + id := sessionID(append([]string{"dkls-dkg", s.cfg.WalletID, strconv.Itoa(s.cfg.Threshold)}, s.participants...)...) + + dkg, err := dklsll.NewDkgSession(uint8(s.cfg.Threshold+1), uint8(len(s.participants)), s.self, id) + if err != nil { + return KeyResult{}, fmt.Errorf("new dkg session: %w", err) + } + defer dkg.Free() + + out1, err := dkg.Phase1() + if err != nil { + return KeyResult{}, fmt.Errorf("dkg phase1: %w", err) + } + all1, err := s.round.exchange(PhaseKG1, out1, peers) + if err != nil { + return KeyResult{}, err + } + + out2, err := dkg.Phase2(ofKind(all1, dklsll.MsgPolyFragment, s.self)) + if err != nil { + return KeyResult{}, fmt.Errorf("dkg phase2: %w", err) + } + all2, err := s.round.exchange(PhaseKG2, out2, peers) + if err != nil { + return KeyResult{}, err + } + + out3, err := dkg.Phase3() + if err != nil { + return KeyResult{}, fmt.Errorf("dkg phase3: %w", err) + } + all3, err := s.round.exchange(PhaseKG3, out3, peers) + if err != nil { + return KeyResult{}, err + } + + incoming := addressedTo(all3, s.self) + incoming = append(incoming, ofKind(all2, dklsll.MsgDkgZeroP2, s.self)...) + incoming = append(incoming, broadcasts(all2)...) + party, err := dkg.Phase4(incoming) + if err != nil { + return KeyResult{}, fmt.Errorf("dkg phase4: %w", err) + } + defer party.Free() + + share, err := party.ToBytes() + if err != nil { + return KeyResult{}, fmt.Errorf("serialize key share: %w", err) + } + pubKey, err := partyPubKey(party) + if err != nil { + return KeyResult{}, err + } + chainCode, err := party.ChainCode() + if err != nil { + return KeyResult{}, fmt.Errorf("get chain code: %w", err) + } + + info := &keyinfo.KeyInfo{ParticipantPeerIDs: s.participants, Threshold: s.cfg.Threshold, Version: 1} + if err := s.cfg.Keys.Save(s.cfg.WalletID, share, info); err != nil { + return KeyResult{}, err + } + return KeyResult{PubKey: pubKey, ChainCode: chainCode}, nil +} + +func (s *Session) Sign(msgHash []byte, signers []string, derivationPath []uint32) (Signature, error) { + signers = sortedCopy(signers) + if !slices.Contains(signers, s.cfg.SelfNodeID) { + return Signature{}, ErrNotSelected + } + + party, err := s.loadParty(derivationPath) + if err != nil { + return Signature{}, err + } + defer party.Free() + + peers := s.peersOf(signers) + counterparties := make([]uint8, len(peers)) + for i, peer := range peers { + counterparties[i] = s.partyIDs[peer] + } + + id := sessionID(append([]string{"dkls-sign", s.cfg.WalletID, s.cfg.SessionKey, derivationPathLabel(derivationPath)}, signers...)...) + sess, err := dklsll.NewSignSession(party, id, counterparties, msgHash) + if err != nil { + return Signature{}, fmt.Errorf("new sign session: %w", err) + } + defer sess.Free() + + out1, err := sess.Phase1() + if err != nil { + return Signature{}, fmt.Errorf("sign phase1: %w", err) + } + all1, err := s.round.exchange(PhaseSG1, out1, peers) + if err != nil { + return Signature{}, err + } + + out2, err := sess.Phase2(ofKind(all1, dklsll.MsgSignPhase1, s.self)) + if err != nil { + return Signature{}, fmt.Errorf("sign phase2: %w", err) + } + all2, err := s.round.exchange(PhaseSG2, out2, peers) + if err != nil { + return Signature{}, err + } + + out3, err := sess.Phase3(ofKind(all2, dklsll.MsgSignPhase2, s.self)) + if err != nil { + return Signature{}, fmt.Errorf("sign phase3: %w", err) + } + all3, err := s.round.exchange(PhaseSG3, out3, peers) + if err != nil { + return Signature{}, err + } + + raw, err := sess.Phase4(broadcastsOfKind(all3, dklsll.MsgSignBroadcast), true) + if err != nil { + return Signature{}, fmt.Errorf("sign phase4: %w", err) + } + return dklsll.ParseSignature(raw) +} + +func sortedCopy(ids []string) []string { + out := slices.Clone(ids) + slices.Sort(out) + return out +} + +const hardenedOffset = 0x80000000 + +// Only non-hardened indices work: no party holds the full key. +func (s *Session) loadParty(derivationPath []uint32) (*dklsll.Party, error) { + for _, index := range derivationPath { + if index >= hardenedOffset { + return nil, fmt.Errorf("hardened derivation index %d is not supported by dkls23", index) + } + } + share, err := s.cfg.Keys.LoadShare(s.cfg.WalletID) + if err != nil { + return nil, fmt.Errorf("load key share: %w", err) + } + party, err := dklsll.PartyFromBytes(share) + if err != nil { + return nil, fmt.Errorf("deserialize key share: %w", err) + } + if len(derivationPath) == 0 { + return party, nil + } + defer party.Free() + child, err := party.Derive(derivationPath) + if err != nil { + return nil, fmt.Errorf("derive child share: %w", err) + } + return child, nil +} + +func (s *Session) DerivePublicKey(derivationPath []uint32) ([]byte, error) { + party, err := s.loadParty(derivationPath) + if err != nil { + return nil, err + } + defer party.Free() + return partyPubKey(party) +} + +func partyPubKey(party *dklsll.Party) ([]byte, error) { + compressed, err := party.PublicKey() + if err != nil { + return nil, fmt.Errorf("get public key: %w", err) + } + return encodeCompressedPubKey(compressed) +} + +func encodeCompressedPubKey(compressed []byte) ([]byte, error) { + pub, err := btcec.ParsePubKey(compressed) + if err != nil { + return nil, fmt.Errorf("parse compressed public key: %w", err) + } + return encoding.EncodeS256PubKey(pub.ToECDSA()) +} + +func derivationPathLabel(path []uint32) string { + if len(path) == 0 { + return "" + } + return fmt.Sprint(path) +} + +// Sorting makes every node compute the same party IDs without coordination. +func assignPartyIDs(nodeIDs []string) (sorted []string, partyIDs map[string]uint8) { + sorted = append([]string(nil), nodeIDs...) + sort.Strings(sorted) + + partyIDs = make(map[string]uint8, len(sorted)) + for i, id := range sorted { + partyIDs[id] = uint8(i + 1) + } + return sorted, partyIDs +} diff --git a/pkg/mpc/dkls/session_test.go b/pkg/mpc/dkls/session_test.go new file mode 100644 index 00000000..a135cff8 --- /dev/null +++ b/pkg/mpc/dkls/session_test.go @@ -0,0 +1,138 @@ +//go:build dkls + +package dkls + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "crypto/sha512" + "encoding/binary" + "errors" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" +) + +func TestKeygenAndSign_3of3(t *testing.T) { + nodeIDs := []string{"node-a", "node-b", "node-c"} + sessions := newMemorySessions(t, "wallet-1", nodeIDs, 2) + + keys := runKeygen(t, sessions) + for _, id := range nodeIDs[1:] { + if !bytes.Equal(keys[id].PubKey, keys[nodeIDs[0]].PubKey) { + t.Fatalf("public keys diverge between %s and %s", nodeIDs[0], id) + } + } + if len(keys[nodeIDs[0]].PubKey) != 64 { + t.Fatalf("public key length = %d, want 64 (tss-lib X||Y format)", len(keys[nodeIDs[0]].PubKey)) + } + + hash := sha256.Sum256([]byte("integration-test")) + sig := runSign(t, sessions, nodeIDs, hash[:], nil) + verifySignature(t, sig, hash[:], parsePubKey(t, keys[nodeIDs[0]].PubKey)) +} + +func TestSign_2of3Subset(t *testing.T) { + nodeIDs := []string{"node-a", "node-b", "node-c"} + sessions := newMemorySessions(t, "wallet-2", nodeIDs, 1) + keys := runKeygen(t, sessions) + + hash := sha256.Sum256([]byte("subset-signing-test")) + sig := runSign(t, sessions, []string{"node-a", "node-b"}, hash[:], nil) + verifySignature(t, sig, hash[:], parsePubKey(t, keys["node-a"].PubKey)) +} + +func TestSign_RejectsSignerSetWithoutSelf(t *testing.T) { + nodeIDs := []string{"node-a", "node-b", "node-c"} + sessions := newMemorySessions(t, "wallet-3", nodeIDs, 1) + + hash := sha256.Sum256([]byte("x")) + if _, err := sessions["node-c"].Sign(hash[:], []string{"node-a", "node-b"}, nil); !errors.Is(err, ErrNotSelected) { + t.Fatalf("err = %v, want ErrNotSelected", err) + } +} + +func TestNewSession_RejectsNonParticipant(t *testing.T) { + _, err := NewSession(SessionConfig{SelfNodeID: "node-x", Participants: []string{"node-a", "node-b"}}) + if !errors.Is(err, ErrNotParticipant) { + t.Fatalf("err = %v, want ErrNotParticipant", err) + } +} + +func TestDerivation_ChildKeyMatchesBIP32AndSigns(t *testing.T) { + nodeIDs := []string{"node-a", "node-b", "node-c"} + sessions := newMemorySessions(t, "wallet-hd", nodeIDs, 1) + keys := runKeygen(t, sessions) + master := keys["node-a"] + if len(master.ChainCode) != 32 { + t.Fatalf("chain code length = %d, want 32", len(master.ChainCode)) + } + + path := []uint32{1, 5} + childPubs := make(map[string][]byte, len(nodeIDs)) + for id, sess := range sessions { + pub, err := sess.DerivePublicKey(path) + if err != nil { + t.Fatalf("derive public key on %s: %v", id, err) + } + childPubs[id] = pub + } + for _, id := range nodeIDs[1:] { + if !bytes.Equal(childPubs[id], childPubs["node-a"]) { + t.Fatalf("child public keys diverge between node-a and %s", id) + } + } + if bytes.Equal(childPubs["node-a"], master.PubKey) { + t.Fatal("child public key equals master public key") + } + + expected := bip32DerivePublic(t, compressXY(t, master.PubKey), master.ChainCode, path) + expectedXY, err := encodeCompressedPubKey(expected.SerializeCompressed()) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(childPubs["node-a"], expectedXY) { + t.Fatal("derived public key does not match BIP-32 CKDpub of master key + chain code") + } + + signers := []string{"node-a", "node-b"} + hash := sha256.Sum256([]byte("hd-signing-test")) + verifySignature(t, runSign(t, sessions, signers, hash[:], path), hash[:], expected) + verifySignature(t, runSign(t, sessions, signers, hash[:], nil), hash[:], parsePubKey(t, master.PubKey)) +} + +func TestDerivation_HardenedIndexRejected(t *testing.T) { + sessions := newMemorySessions(t, "w", []string{"node-a", "node-b"}, 1) + if _, err := sessions["node-a"].DerivePublicKey([]uint32{hardenedOffset}); err == nil { + t.Fatal("expected hardened index to be rejected") + } +} + +// Deliberately independent of the library under test. +func bip32DerivePublic(t *testing.T, compressedPub, chainCode []byte, path []uint32) *btcec.PublicKey { + t.Helper() + pub, err := btcec.ParsePubKey(compressedPub) + if err != nil { + t.Fatal(err) + } + for _, index := range path { + mac := hmac.New(sha512.New, chainCode) + mac.Write(pub.SerializeCompressed()) + mac.Write(binary.BigEndian.AppendUint32(nil, index)) + sum := mac.Sum(nil) + + var tweak btcec.ModNScalar + if overflow := tweak.SetByteSlice(sum[:32]); overflow { + t.Fatal("tweak overflow") + } + var tweakPoint, parent, child btcec.JacobianPoint + btcec.ScalarBaseMultNonConst(&tweak, &tweakPoint) + pub.AsJacobian(&parent) + btcec.AddNonConst(&tweakPoint, &parent, &child) + child.ToAffine() + pub = btcec.NewPublicKey(&child.X, &child.Y) + chainCode = sum[32:] + } + return pub +} diff --git a/pkg/mpc/dkls/testutil_test.go b/pkg/mpc/dkls/testutil_test.go new file mode 100644 index 00000000..54ebb3de --- /dev/null +++ b/pkg/mpc/dkls/testutil_test.go @@ -0,0 +1,370 @@ +//go:build dkls + +package dkls + +import ( + "crypto/ed25519" + "crypto/sha256" + "errors" + "fmt" + "strings" + "sync" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + btcecdsa "github.com/btcsuite/btcd/btcec/v2/ecdsa" + "github.com/fystack/mpcium/pkg/encryption" + "github.com/fystack/mpcium/pkg/keyinfo" + "github.com/fystack/mpcium/pkg/messaging" + "github.com/fystack/mpcium/pkg/types" + "github.com/nats-io/nats.go" +) + +var errNotFound = errors.New("not found") + +type testCipher struct { + keys map[string][]byte +} + +func (c testCipher) EncryptMessage(plaintext []byte, peerID string) ([]byte, error) { + return encryption.EncryptAESGCMWithNonceEmbed(plaintext, c.keys[peerID]) +} + +func (c testCipher) DecryptMessage(cipher []byte, peerID string) ([]byte, error) { + return encryption.DecryptAESGCMWithNonceEmbed(cipher, c.keys[peerID]) +} + +func sharedTestKeys(nodeIDs []string) map[string]testCipher { + keys := make(map[string]map[string][]byte, len(nodeIDs)) + for _, id := range nodeIDs { + keys[id] = make(map[string][]byte, len(nodeIDs)-1) + } + for i, a := range nodeIDs { + for _, b := range nodeIDs[i+1:] { + h := sha256.Sum256([]byte("test-pair-key:" + a + ":" + b)) + keys[a][b] = h[:] + keys[b][a] = h[:] + } + } + ciphers := make(map[string]testCipher, len(nodeIDs)) + for id, k := range keys { + ciphers[id] = testCipher{keys: k} + } + return ciphers +} + +type ed25519Authenticator struct { + privKey ed25519.PrivateKey + publicKeys map[string]ed25519.PublicKey +} + +func (a ed25519Authenticator) SignDklsMessage(msg *types.DklsMessage) ([]byte, error) { + b, err := msg.MarshalForSigning() + if err != nil { + return nil, err + } + return ed25519.Sign(a.privKey, b), nil +} + +func (a ed25519Authenticator) VerifyDklsMessage(msg *types.DklsMessage) error { + pub, ok := a.publicKeys[msg.From] + if !ok { + return fmt.Errorf("unknown sender %s", msg.From) + } + b, err := msg.MarshalForSigning() + if err != nil { + return err + } + if !ed25519.Verify(pub, b, msg.Signature) { + return fmt.Errorf("invalid signature from %s", msg.From) + } + return nil +} + +func newEd25519Authenticators(nodeIDs []string) map[string]ed25519Authenticator { + pubKeys := make(map[string]ed25519.PublicKey, len(nodeIDs)) + privKeys := make(map[string]ed25519.PrivateKey, len(nodeIDs)) + for _, id := range nodeIDs { + pub, priv, err := ed25519.GenerateKey(nil) + if err != nil { + panic(err) + } + pubKeys[id], privKeys[id] = pub, priv + } + auths := make(map[string]ed25519Authenticator, len(nodeIDs)) + for _, id := range nodeIDs { + auths[id] = ed25519Authenticator{privKey: privKeys[id], publicKeys: pubKeys} + } + return auths +} + +type testIdentity struct { + ed25519Authenticator + testCipher +} + +func newTestIdentities(nodeIDs []string) map[string]testIdentity { + auths, ciphers := newEd25519Authenticators(nodeIDs), sharedTestKeys(nodeIDs) + ids := make(map[string]testIdentity, len(nodeIDs)) + for _, id := range nodeIDs { + ids[id] = testIdentity{auths[id], ciphers[id]} + } + return ids +} + +type fakeStore struct { + mu sync.Mutex + data map[string][]byte + infos map[string]*keyinfo.KeyInfo +} + +func newFakeStore() *fakeStore { + return &fakeStore{data: make(map[string][]byte), infos: make(map[string]*keyinfo.KeyInfo)} +} + +func (f *fakeStore) Put(key string, value []byte) error { + f.mu.Lock() + defer f.mu.Unlock() + f.data[key] = value + return nil +} + +func (f *fakeStore) Get(key string) ([]byte, error) { + f.mu.Lock() + defer f.mu.Unlock() + v, ok := f.data[key] + if !ok { + return nil, errNotFound + } + return v, nil +} + +func (f *fakeStore) Delete(key string) error { + f.mu.Lock() + defer f.mu.Unlock() + delete(f.data, key) + return nil +} + +func (f *fakeStore) Close() error { return nil } +func (f *fakeStore) Backup() error { return nil } + +func (f *fakeStore) Save(walletID string, info *keyinfo.KeyInfo) error { + f.mu.Lock() + defer f.mu.Unlock() + f.infos[walletID] = info + return nil +} + +func (f *fakeStore) GetKeyInfo(walletID string) (*keyinfo.KeyInfo, error) { + f.mu.Lock() + defer f.mu.Unlock() + info, ok := f.infos[walletID] + if !ok { + return nil, errNotFound + } + return info, nil +} + +// keyinfo.Store.Get clashes with kvstore.KVStore.Get, so fakeStore needs this view. +type keyInfoView struct{ *fakeStore } + +func (v keyInfoView) Get(walletID string) (*keyinfo.KeyInfo, error) { return v.GetKeyInfo(walletID) } + +func newTestKeys() KeyShareStore { + store := newFakeStore() + return NewKeyStore(store, keyInfoView{store}) +} + +type memoryTransport struct { + selfID string + peers map[string]*memoryTransport + inbox chan types.DklsMessage +} + +func newMemoryCluster(nodeIDs []string) map[string]*memoryTransport { + transports := make(map[string]*memoryTransport, len(nodeIDs)) + for _, id := range nodeIDs { + transports[id] = &memoryTransport{selfID: id, peers: transports, inbox: make(chan types.DklsMessage, 256)} + } + return transports +} + +func (m *memoryTransport) Send(to string, msg types.DklsMessage) error { + msg.From = m.selfID + m.peers[to].inbox <- msg + return nil +} + +func (m *memoryTransport) Inbox() <-chan types.DklsMessage { return m.inbox } +func (m *memoryTransport) Close() error { return nil } + +func newMemorySessions(t *testing.T, walletID string, nodeIDs []string, threshold int) map[string]*Session { + t.Helper() + transports := newMemoryCluster(nodeIDs) + ciphers := sharedTestKeys(nodeIDs) + sessions := make(map[string]*Session, len(nodeIDs)) + for _, id := range nodeIDs { + sess, err := NewSession(SessionConfig{ + WalletID: walletID, + SelfNodeID: id, + Participants: nodeIDs, + Threshold: threshold, + Transport: transports[id], + Cipher: ciphers[id], + Keys: newTestKeys(), + }) + if err != nil { + t.Fatal(err) + } + sessions[id] = sess + } + return sessions +} + +type fakePubSub struct { + mu sync.Mutex + subs []*fakeSub +} + +type fakeSub struct { + pattern string + handler func(*nats.Msg) + bus *fakePubSub +} + +func (s *fakeSub) Unsubscribe() error { + s.bus.mu.Lock() + defer s.bus.mu.Unlock() + for i, sub := range s.bus.subs { + if sub == s { + s.bus.subs = append(s.bus.subs[:i], s.bus.subs[i+1:]...) + break + } + } + return nil +} + +func (b *fakePubSub) Publish(topic string, message []byte, _ map[string]string) error { + b.mu.Lock() + var handlers []func(*nats.Msg) + for _, sub := range b.subs { + if sub.pattern == topic || (strings.HasSuffix(sub.pattern, ">") && strings.HasPrefix(topic, strings.TrimSuffix(sub.pattern, ">"))) { + handlers = append(handlers, sub.handler) + } + } + b.mu.Unlock() + for _, h := range handlers { + h(&nats.Msg{Subject: topic, Data: message}) + } + return nil +} + +func (b *fakePubSub) PublishWithReply(topic, _ string, data []byte, headers map[string]string) error { + return b.Publish(topic, data, headers) +} + +func (b *fakePubSub) Subscribe(topic string, handler func(*nats.Msg)) (messaging.Subscription, error) { + b.mu.Lock() + defer b.mu.Unlock() + sub := &fakeSub{pattern: topic, handler: handler, bus: b} + b.subs = append(b.subs, sub) + return sub, nil +} + +type fakePeers struct{ ready []string } + +func (p fakePeers) ArePeersReady() bool { return true } +func (p fakePeers) GetReadyPeersIncludeSelf() []string { return p.ready } + +func runKeygen(t *testing.T, sessions map[string]*Session) map[string]KeyResult { + t.Helper() + type result struct { + id string + res KeyResult + err error + } + ch := make(chan result, len(sessions)) + for id, sess := range sessions { + go func() { + res, err := sess.Keygen() + ch <- result{id, res, err} + }() + } + out := make(map[string]KeyResult, len(sessions)) + for range sessions { + r := <-ch + if r.err != nil { + t.Fatalf("keygen failed for %s: %v", r.id, r.err) + } + out[r.id] = r.res + } + return out +} + +func runSign(t *testing.T, sessions map[string]*Session, signers []string, hash []byte, path []uint32) Signature { + t.Helper() + type result struct { + sig Signature + err error + } + ch := make(chan result, len(signers)) + for _, id := range signers { + go func() { + sig, err := sessions[id].Sign(hash, signers, path) + ch <- result{sig, err} + }() + } + var first Signature + for i := range signers { + r := <-ch + if r.err != nil { + t.Fatalf("sign failed: %v", r.err) + } + if i == 0 { + first = r.sig + } else if r.sig != first { + t.Fatal("signers produced different signatures") + } + } + return first +} + +func compressXY(t *testing.T, xy []byte) []byte { + t.Helper() + if len(xy) != 64 { + t.Fatalf("public key length = %d, want 64", len(xy)) + } + compressed := make([]byte, 33) + compressed[0] = 0x02 | (xy[63] & 1) + copy(compressed[1:], xy[:32]) + return compressed +} + +func parsePubKey(t *testing.T, xy []byte) *btcec.PublicKey { + t.Helper() + pub, err := btcec.ParsePubKey(compressXY(t, xy)) + if err != nil { + t.Fatal(err) + } + return pub +} + +func verifySignature(t *testing.T, sig Signature, hash []byte, pub *btcec.PublicKey) { + t.Helper() + var r, s btcec.ModNScalar + r.SetBytes(&sig.R) + s.SetBytes(&sig.S) + if !btcecdsa.NewSignature(&r, &s).Verify(hash, pub) { + t.Fatal("signature does not verify against the expected public key") + } + compact := append([]byte{27 + 4 + sig.Recovery}, sig.R[:]...) + compact = append(compact, sig.S[:]...) + recovered, _, err := btcecdsa.RecoverCompact(compact, hash) + if err != nil { + t.Fatalf("recover public key: %v", err) + } + if !recovered.IsEqual(pub) { + t.Fatal("recovery id does not recover the signing public key") + } +} diff --git a/pkg/mpc/dkls/transport.go b/pkg/mpc/dkls/transport.go new file mode 100644 index 00000000..32478835 --- /dev/null +++ b/pkg/mpc/dkls/transport.go @@ -0,0 +1,72 @@ +//go:build dkls + +package dkls + +import ( + "fmt" + "sync" + + "github.com/fystack/mpcium/pkg/messaging" + "github.com/fystack/mpcium/pkg/types" +) + +type Act string + +const ( + ActKeygen Act = "keygen" + ActSign Act = "sign" +) + +type Transport interface { + Send(to string, msg types.DklsMessage) error + Inbox() <-chan types.DklsMessage + Close() error +} + +type MessageAuthenticator interface { + SignDklsMessage(msg *types.DklsMessage) ([]byte, error) + VerifyDklsMessage(msg *types.DklsMessage) error +} + +type NATSTransport struct { + act Act + sessionKey string + route string + router *Router + pubsub messaging.PubSub + auth MessageAuthenticator + inbox chan types.DklsMessage + closeOnce sync.Once +} + +// sessionKey is the walletID for keygen and walletID:txID for signing, since signs run concurrently. +func NewNATSTransport(sessionKey string, act Act, router *Router, pubsub messaging.PubSub, auth MessageAuthenticator) *NATSTransport { + route := routeKey(act, sessionKey) + return &NATSTransport{ + act: act, + sessionKey: sessionKey, + route: route, + router: router, + pubsub: pubsub, + auth: auth, + inbox: router.register(route), + } +} + +func (t *NATSTransport) Send(to string, msg types.DklsMessage) error { + msg.Route = t.route + msg.To = to + sig, err := t.auth.SignDklsMessage(&msg) + if err != nil { + return fmt.Errorf("sign dkls message: %w", err) + } + msg.Signature = sig + return t.pubsub.Publish(sessionSubject(to, t.act, t.sessionKey), msg.MarshalWire(), nil) +} + +func (t *NATSTransport) Inbox() <-chan types.DklsMessage { return t.inbox } + +func (t *NATSTransport) Close() error { + t.closeOnce.Do(func() { t.router.unregister(t.route, t.inbox) }) + return nil +} From b22f125bbf38f16e381155f43fa5b9c2268eeb0c Mon Sep 17 00:00:00 2001 From: vietddude Date: Wed, 30 Sep 2026 14:50:01 +0700 Subject: [PATCH 04/12] feat(eventconsumer): serve DKLs23 requests on dedicated subjects - Signing/keygen wrapper consumers publish DKLs23 requests to their own subjects - DklsConsumer handles verify, dedupe with TTL, result publishing and retry-later - DKLs23 signing runs concurrently; tss-lib keeps its serial handling - cmd/mpcium starts the backend through a dkls build-tag pair --- cmd/mpcium/dkls_off.go | 24 +++ cmd/mpcium/dkls_on.go | 45 +++++ cmd/mpcium/main.go | 3 + pkg/eventconsumer/dkls_consumer.go | 256 ++++++++++++++++++++++++ pkg/eventconsumer/dkls_consumer_test.go | 44 ++++ pkg/eventconsumer/event_consumer.go | 6 +- pkg/eventconsumer/keygen_consumer.go | 8 + pkg/eventconsumer/sign_consumer.go | 24 ++- pkg/eventconsumer/sign_consumer_test.go | 21 ++ 9 files changed, 428 insertions(+), 3 deletions(-) create mode 100644 cmd/mpcium/dkls_off.go create mode 100644 cmd/mpcium/dkls_on.go create mode 100644 pkg/eventconsumer/dkls_consumer.go create mode 100644 pkg/eventconsumer/dkls_consumer_test.go create mode 100644 pkg/eventconsumer/sign_consumer_test.go diff --git a/cmd/mpcium/dkls_off.go b/cmd/mpcium/dkls_off.go new file mode 100644 index 00000000..7b6f8b6e --- /dev/null +++ b/cmd/mpcium/dkls_off.go @@ -0,0 +1,24 @@ +//go:build !dkls + +package main + +import ( + "github.com/fystack/mpcium/pkg/identity" + "github.com/fystack/mpcium/pkg/keyinfo" + "github.com/fystack/mpcium/pkg/kvstore" + "github.com/fystack/mpcium/pkg/messaging" + "github.com/fystack/mpcium/pkg/mpc" +) + +func startDkls( + string, + messaging.PubSub, + identity.Store, + kvstore.KVStore, + keyinfo.Store, + mpc.PeerRegistry, + messaging.MessageQueue, + messaging.MessageQueue, +) (stop func()) { + return func() {} +} diff --git a/cmd/mpcium/dkls_on.go b/cmd/mpcium/dkls_on.go new file mode 100644 index 00000000..5500921e --- /dev/null +++ b/cmd/mpcium/dkls_on.go @@ -0,0 +1,45 @@ +//go:build dkls + +package main + +import ( + "github.com/fystack/mpcium/pkg/eventconsumer" + "github.com/fystack/mpcium/pkg/identity" + "github.com/fystack/mpcium/pkg/keyinfo" + "github.com/fystack/mpcium/pkg/kvstore" + "github.com/fystack/mpcium/pkg/logger" + "github.com/fystack/mpcium/pkg/messaging" + "github.com/fystack/mpcium/pkg/mpc" + "github.com/fystack/mpcium/pkg/mpc/dkls" + "github.com/spf13/viper" +) + +// Must start before the node is marked ready, so its router is subscribed when peers send. +func startDkls( + nodeID string, + pubsub messaging.PubSub, + identityStore identity.Store, + kv kvstore.KVStore, + keyInfo keyinfo.Store, + peers mpc.PeerRegistry, + genKeyResultQueue messaging.MessageQueue, + signingResultQueue messaging.MessageQueue, +) (stop func()) { + service, err := dkls.NewService(dkls.Config{ + NodeID: nodeID, + PubSub: pubsub, + Identity: identityStore, + KV: kv, + KeyInfo: keyInfo, + Peers: peers, + }) + if err != nil { + logger.Fatal("Failed to start dkls service", err) + } + consumer := eventconsumer.NewDklsConsumer(service, pubsub, identityStore, genKeyResultQueue, signingResultQueue, viper.GetInt("mpc_threshold")) + consumer.Run() + return func() { + _ = consumer.Close() + service.Close() + } +} diff --git a/cmd/mpcium/main.go b/cmd/mpcium/main.go index b8127ea3..22670658 100644 --- a/cmd/mpcium/main.go +++ b/cmd/mpcium/main.go @@ -267,6 +267,9 @@ func runNode(ctx context.Context, c *cli.Command) error { eventConsumer.Run() defer eventConsumer.Close() + stopDkls := startDkls(nodeID, pubsub, identityStore, badgerKV, keyinfoStore, peerRegistry, genKeyResultQueue, singingResultQueue) + defer stopDkls() + timeoutConsumer := eventconsumer.NewTimeOutConsumer( natsConn, singingResultQueue, diff --git a/pkg/eventconsumer/dkls_consumer.go b/pkg/eventconsumer/dkls_consumer.go new file mode 100644 index 00000000..53be41ea --- /dev/null +++ b/pkg/eventconsumer/dkls_consumer.go @@ -0,0 +1,256 @@ +//go:build dkls + +package eventconsumer + +import ( + "encoding/json" + "errors" + "slices" + "sync" + "time" + + "github.com/fystack/mpcium/pkg/event" + "github.com/fystack/mpcium/pkg/identity" + "github.com/fystack/mpcium/pkg/logger" + "github.com/fystack/mpcium/pkg/messaging" + "github.com/fystack/mpcium/pkg/mpc/dkls" + "github.com/fystack/mpcium/pkg/types" + "github.com/nats-io/nats.go" +) + +const dklsSessionTTL = 10 * time.Minute + +type DklsConsumer struct { + service *dkls.Service + pubsub messaging.PubSub + identityStore identity.Store + genKeyResultQueue messaging.MessageQueue + signingResultQueue messaging.MessageQueue + threshold int + sessions *sessionTracker + + keygenSub messaging.Subscription + signingSub messaging.Subscription +} + +func NewDklsConsumer( + service *dkls.Service, + pubsub messaging.PubSub, + identityStore identity.Store, + genKeyResultQueue messaging.MessageQueue, + signingResultQueue messaging.MessageQueue, + threshold int, +) *DklsConsumer { + return &DklsConsumer{ + service: service, + pubsub: pubsub, + identityStore: identityStore, + genKeyResultQueue: genKeyResultQueue, + signingResultQueue: signingResultQueue, + threshold: threshold, + sessions: newSessionTracker(dklsSessionTTL), + } +} + +func (c *DklsConsumer) Run() { + // Handlers block for the whole protocol and NATS runs one subscription's callbacks serially. + c.keygenSub = c.subscribe(MPCDklsGenerateEvent, c.handleKeygen) + c.signingSub = c.subscribe(MPCDklsSignEvent, c.handleSign) + logger.Info("DklsConsumer: subscribed to keygen/signing events") +} + +func (c *DklsConsumer) subscribe(subject string, handle func(*nats.Msg)) messaging.Subscription { + sub, err := c.pubsub.Subscribe(subject, func(natMsg *nats.Msg) { go handle(natMsg) }) + if err != nil { + logger.Fatal("DklsConsumer: failed to subscribe to "+subject, err) + } + return sub +} + +func (c *DklsConsumer) Close() error { + for _, sub := range []messaging.Subscription{c.keygenSub, c.signingSub} { + if sub != nil { + _ = sub.Unsubscribe() + } + } + return nil +} + +func (c *DklsConsumer) authorized(msg types.InitiatorMessage) bool { + if err := c.identityStore.VerifyInitiatorMessage(msg); err != nil { + logger.Error("DklsConsumer: failed to verify initiator message", err) + return false + } + if err := c.identityStore.AuthorizeInitiatorMessage(msg); err != nil { + logger.Error("DklsConsumer: failed to authorize initiator message", err) + return false + } + return true +} + +func (c *DklsConsumer) handleKeygen(natMsg *nats.Msg) { + var msg types.GenerateKeyMessage + if err := json.Unmarshal(natMsg.Data, &msg); err != nil || !c.authorized(&msg) { + return + } + + walletID := msg.WalletID + sessionKey := "keygen:" + walletID + if !c.sessions.begin(sessionKey) { + logger.Warn("DklsConsumer: duplicate keygen request", "walletID", walletID) + c.sendReply(natMsg) + return + } + + result := event.KeygenResultEvent{WalletID: walletID} + keys, err := c.service.Keygen(walletID, c.threshold) + if err != nil { + c.sessions.release(sessionKey) + logger.Error("DklsConsumer: keygen failed", err, "walletID", walletID) + result.ResultType = event.ResultTypeError + result.ErrorReason = err.Error() + result.ErrorCode = string(event.GetErrorCodeFromError(err)) + } else { + result.ResultType = event.ResultTypeSuccess + result.DKLSPubKey = keys.PubKey + result.DKLSChainCode = keys.ChainCode + } + + c.publishKeygenResult(natMsg, result) + c.sendReply(natMsg) +} + +func (c *DklsConsumer) handleSign(natMsg *nats.Msg) { + var msg types.SignTxMessage + if err := json.Unmarshal(natMsg.Data, &msg); err != nil || !c.authorized(&msg) { + return + } + + sessionKey := msg.WalletID + ":" + msg.TxID + if !c.sessions.begin(sessionKey) { + logger.Warn("DklsConsumer: duplicate signing request", "walletID", msg.WalletID, "txID", msg.TxID) + c.sendReply(natMsg) + return + } + + sig, err := c.service.Sign(msg.WalletID, msg.TxID, msg.Tx, msg.DerivationPath) + switch { + case errors.Is(err, dkls.ErrNotEnoughSigners): + // No reply, so the un-ACKed JetStream message is redelivered later. + c.sessions.release(sessionKey) + logger.Info("DklsConsumer: RETRY LATER: not enough participants to sign", "walletID", msg.WalletID, "txID", msg.TxID) + return + case errors.Is(err, dkls.ErrNotParticipant), errors.Is(err, dkls.ErrNotSelected): + c.sessions.release(sessionKey) + logger.Debug("DklsConsumer: this node does not take part in the signing", "walletID", msg.WalletID, "txID", msg.TxID) + return + case err != nil: + c.sessions.release(sessionKey) + logger.Error("DklsConsumer: sign failed", err, "walletID", msg.WalletID, "txID", msg.TxID) + c.publishSignResult(natMsg, event.SigningResultEvent{ + ResultType: event.ResultTypeError, + NetworkInternalCode: msg.NetworkInternalCode, + WalletID: msg.WalletID, + TxID: msg.TxID, + ErrorCode: event.GetErrorCodeFromError(err), + ErrorReason: err.Error(), + }) + c.sendReply(natMsg) + return + } + + c.publishSignResult(natMsg, event.SigningResultEvent{ + ResultType: event.ResultTypeSuccess, + NetworkInternalCode: msg.NetworkInternalCode, + WalletID: msg.WalletID, + TxID: msg.TxID, + R: sig.R[:], + S: sig.S[:], + SignatureRecovery: []byte{sig.Recovery}, + Signature: slices.Concat(sig.R[:], sig.S[:]), + }) + logger.Info("[SIGN] DKLS sign successfully", "walletID", msg.WalletID, "txID", msg.TxID) + c.sendReply(natMsg) +} + +func (c *DklsConsumer) publishKeygenResult(natMsg *nats.Msg, result event.KeygenResultEvent) { + payload, err := json.Marshal(result) + if err != nil { + logger.Error("DklsConsumer: failed to marshal keygen result", err) + return + } + subject := event.KeygenResultSubject(natMsg.Header.Get(event.ClientIDHeader), result.WalletID) + opts := &messaging.EnqueueOptions{IdempotententKey: composeKeygenIdempotentKey("dkls:"+result.WalletID, natMsg)} + if err := c.genKeyResultQueue.Enqueue(subject, payload, opts); err != nil { + logger.Error("DklsConsumer: failed to enqueue keygen result", err) + return + } + if result.ResultType == event.ResultTypeSuccess { + logger.Info("[COMPLETED KEY GEN] DKLS keygen completed", "walletID", result.WalletID) + } +} + +func (c *DklsConsumer) publishSignResult(natMsg *nats.Msg, result event.SigningResultEvent) { + payload, err := json.Marshal(result) + if err != nil { + logger.Error("DklsConsumer: failed to marshal signing result", err) + return + } + subject := event.SigningResultSubject(natMsg.Header.Get(event.ClientIDHeader)) + opts := &messaging.EnqueueOptions{IdempotententKey: composeSigningIdempotentKey(result.TxID, natMsg)} + if err := c.signingResultQueue.Enqueue(subject, payload, opts); err != nil { + logger.Error("DklsConsumer: failed to enqueue signing result", err) + } +} + +// Without a reply the JetStream wrapper consumer times out and redelivers. +func (c *DklsConsumer) sendReply(natMsg *nats.Msg) { + if natMsg.Reply == "" { + return + } + if err := c.pubsub.Publish(natMsg.Reply, natMsg.Data, nil); err != nil { + logger.Error("DklsConsumer: failed to reply message", err, "reply", natMsg.Reply) + } +} + +// Finished requests stay tracked for the TTL so JetStream redeliveries are still dropped. +type sessionTracker struct { + mu sync.Mutex + ttl time.Duration + seen map[string]time.Time + lastSweep time.Time +} + +func newSessionTracker(ttl time.Duration) *sessionTracker { + return &sessionTracker{ttl: ttl, seen: make(map[string]time.Time), lastSweep: time.Now()} +} + +func (t *sessionTracker) begin(key string) bool { + t.mu.Lock() + defer t.mu.Unlock() + now := time.Now() + t.sweep(now) + if _, dup := t.seen[key]; dup { + return false + } + t.seen[key] = now + return true +} + +func (t *sessionTracker) release(key string) { + t.mu.Lock() + defer t.mu.Unlock() + delete(t.seen, key) +} + +func (t *sessionTracker) sweep(now time.Time) { + if now.Sub(t.lastSweep) < t.ttl/2 { + return + } + t.lastSweep = now + for key, started := range t.seen { + if now.Sub(started) > t.ttl { + delete(t.seen, key) + } + } +} diff --git a/pkg/eventconsumer/dkls_consumer_test.go b/pkg/eventconsumer/dkls_consumer_test.go new file mode 100644 index 00000000..73b3e6f9 --- /dev/null +++ b/pkg/eventconsumer/dkls_consumer_test.go @@ -0,0 +1,44 @@ +//go:build dkls + +package eventconsumer + +import ( + "testing" + "time" +) + +func TestSessionTracker_DropsDuplicatesUntilReleasedOrExpired(t *testing.T) { + tracker := newSessionTracker(50 * time.Millisecond) + + if !tracker.begin("w:tx1") { + t.Fatal("first request must be accepted") + } + if tracker.begin("w:tx1") { + t.Fatal("duplicate while in flight or recently done must be rejected") + } + + tracker.release("w:tx1") + if !tracker.begin("w:tx1") { + t.Fatal("released request must be accepted again (retry after failure)") + } + + time.Sleep(80 * time.Millisecond) + if !tracker.begin("w:tx1") { + t.Fatal("request must be accepted again after the TTL") + } +} + +func TestSessionTracker_SweepBoundsMemory(t *testing.T) { + tracker := newSessionTracker(20 * time.Millisecond) + for i := range 100 { + tracker.begin(string(rune('a' + i))) + } + time.Sleep(60 * time.Millisecond) + tracker.begin("fresh") + + tracker.mu.Lock() + defer tracker.mu.Unlock() + if len(tracker.seen) != 1 { + t.Fatalf("tracker kept %d entries after the TTL, want 1", len(tracker.seen)) + } +} diff --git a/pkg/eventconsumer/event_consumer.go b/pkg/eventconsumer/event_consumer.go index 143578cb..b9e069f8 100644 --- a/pkg/eventconsumer/event_consumer.go +++ b/pkg/eventconsumer/event_consumer.go @@ -26,6 +26,9 @@ const ( MPCSignEvent = "mpc:sign" MPCReshareEvent = "mpc:reshare" + MPCDklsGenerateEvent = "mpc:dkls:generate" + MPCDklsSignEvent = "mpc:dkls:sign" + DefaultConcurrentKeygen = 2 DefaultConcurrentSigning = 20 KeyGenTimeOut = 30 * time.Second @@ -464,9 +467,10 @@ func (ec *eventConsumer) handleSigningEvent(natMsg *nats.Msg) { return } - var session mpc.SigningSession idempotentKey := composeSigningIdempotentKey(msg.TxID, natMsg) resultTopic := event.SigningResultSubject(natMsg.Header.Get(event.ClientIDHeader)) + + var session mpc.SigningSession var sessionErr error switch msg.KeyType { case types.KeyTypeSecp256k1: diff --git a/pkg/eventconsumer/keygen_consumer.go b/pkg/eventconsumer/keygen_consumer.go index 630ee133..0308805f 100644 --- a/pkg/eventconsumer/keygen_consumer.go +++ b/pkg/eventconsumer/keygen_consumer.go @@ -172,6 +172,14 @@ func (sc *keygenConsumer) handleKeygenEvent(msg jetstream.Msg) { if clientID != "" { headers[event.ClientIDHeader] = clientID } + // The DKLs23 keygen runs alongside the tss-lib one; only the tss-lib reply acks the request. + if keygenMsg.Protocol == types.ProtocolDkls23 { + if err := sc.pubsub.Publish(MPCDklsGenerateEvent, msg.Data(), headers); err != nil { + logger.Error("KeygenConsumer: Failed to publish dkls keygen event", err) + _ = msg.Nak() + return + } + } if err := sc.pubsub.PublishWithReply(MPCGenerateEvent, replyInbox, msg.Data(), headers); err != nil { logger.Error("KeygenConsumer: Failed to publish keygen event with reply", err) _ = msg.Nak() diff --git a/pkg/eventconsumer/sign_consumer.go b/pkg/eventconsumer/sign_consumer.go index cab1d4f7..0744f855 100644 --- a/pkg/eventconsumer/sign_consumer.go +++ b/pkg/eventconsumer/sign_consumer.go @@ -111,7 +111,7 @@ func (sc *signingConsumer) Run(ctx context.Context) error { ctx, event.SigningConsumerStream, event.SigningRequestTopic, - sc.handleSigningEvent, + sc.dispatchSigningEvent, ) if err != nil { if ctx.Err() == context.Canceled { @@ -147,6 +147,22 @@ func (sc *signingConsumer) Run(ctx context.Context) error { // The MPC signing sessions manage the distributed cryptographic operations across multiple nodes, handling message routing, party updates, and signature verification. // When signing completes, the session publishes the result to a queue and calls the onSuccess callback, which sends a reply to the inbox that the SigningConsumer is monitoring. // The reply signals completion, allowing the SigningConsumer to acknowledge the original message. +// Only DKLs23 runs concurrently; tss-lib keeps its serial handling so its behavior is unchanged. +func (sc *signingConsumer) dispatchSigningEvent(msg jetstream.Msg) { + if requestsDkls23(msg.Data()) { + go sc.handleSigningEvent(msg) + return + } + sc.handleSigningEvent(msg) +} + +func requestsDkls23(data []byte) bool { + var probe struct { + Protocol types.Protocol `json:"protocol"` + } + return json.Unmarshal(data, &probe) == nil && probe.Protocol == types.ProtocolDkls23 +} + func (sc *signingConsumer) handleSigningEvent(msg jetstream.Msg) { // Parse the signing request message to extract transaction details raw := msg.Data() @@ -192,7 +208,11 @@ func (sc *signingConsumer) handleSigningEvent(msg jetstream.Msg) { if clientID != "" { headers[event.ClientIDHeader] = clientID } - if err := sc.pubsub.PublishWithReply(MPCSignEvent, replyInbox, msg.Data(), headers); err != nil { + subject := MPCSignEvent + if signingMsg.Protocol == types.ProtocolDkls23 { + subject = MPCDklsSignEvent + } + if err := sc.pubsub.PublishWithReply(subject, replyInbox, msg.Data(), headers); err != nil { logger.Error("SigningConsumer: Failed to publish signing event with reply", err) _ = msg.Nak() return diff --git a/pkg/eventconsumer/sign_consumer_test.go b/pkg/eventconsumer/sign_consumer_test.go new file mode 100644 index 00000000..e7286bbe --- /dev/null +++ b/pkg/eventconsumer/sign_consumer_test.go @@ -0,0 +1,21 @@ +package eventconsumer + +import "testing" + +func TestRequestsDkls23(t *testing.T) { + cases := map[string]struct { + data string + want bool + }{ + "dkls23": {`{"wallet_id":"w","protocol":"dkls23"}`, true}, + "tss default": {`{"wallet_id":"w","key_type":"secp256k1"}`, false}, + "other protocol": {`{"protocol":"something-else"}`, false}, + "malformed": {`not json`, false}, + "empty": {``, false}, + } + for name, tc := range cases { + if got := requestsDkls23([]byte(tc.data)); got != tc.want { + t.Errorf("%s: requestsDkls23 = %v, want %v", name, got, tc.want) + } + } +} From 75d440d1cfd085dbca35907d27e47867266be293 Mon Sep 17 00:00:00 2001 From: vietddude Date: Wed, 30 Sep 2026 14:50:08 +0700 Subject: [PATCH 05/12] feat(cli): add DKLs23 keygen and signing benchmarks - keygen-dkls and sign-dkls23 commands; keygen-dkls prints the wallet ID and waits for the DKLs23 result --- cmd/mpcium-cli/benchmark.go | 81 ++++++++++++++++++++++++++++++++++--- 1 file changed, 75 insertions(+), 6 deletions(-) diff --git a/cmd/mpcium-cli/benchmark.go b/cmd/mpcium-cli/benchmark.go index 91679381..02bfc6f5 100644 --- a/cmd/mpcium-cli/benchmark.go +++ b/cmd/mpcium-cli/benchmark.go @@ -56,8 +56,10 @@ func benchmarkCommand() *cli.Command { Description: "Run benchmarks for keygen, signing (ECDSA/EdDSA), and resharing operations", Commands: []*cli.Command{ keygenBenchmarkCommand(), + keygenDklsBenchmarkCommand(), ecdsaSignBenchmarkCommand(), eddsaSignBenchmarkCommand(), + dklsSignBenchmarkCommand(), reshareBenchmarkCommand(), }, Flags: []cli.Flag{ @@ -125,6 +127,52 @@ func keygenBenchmarkCommand() *cli.Command { } } +func keygenDklsBenchmarkCommand() *cli.Command { + return &cli.Command{ + Name: "keygen-dkls", + Usage: "Benchmark DKLs23 keygen operations (requires a cluster built with -tags dkls)", + ArgsUsage: "", + Action: runKeygenDklsBenchmark, + Flags: []cli.Flag{ + &cli.IntFlag{ + Name: "timeout", + Usage: "Timeout per operation in seconds", + Value: 60, + Aliases: []string{"t"}, + }, + &cli.IntFlag{ + Name: "batch-size", + Usage: "Number of operations per batch", + Value: 10, + Aliases: []string{"b"}, + }, + }, + } +} + +func dklsSignBenchmarkCommand() *cli.Command { + return &cli.Command{ + Name: "sign-dkls23", + Usage: "Benchmark DKLs23 signing operations (requires a cluster built with -tags dkls)", + ArgsUsage: " ", + Action: runDklsSignBenchmark, + Flags: []cli.Flag{ + &cli.IntFlag{ + Name: "timeout", + Usage: "Timeout per operation in seconds", + Value: 60, + Aliases: []string{"t"}, + }, + &cli.IntFlag{ + Name: "batch-size", + Usage: "Number of operations per batch", + Value: 10, + Aliases: []string{"b"}, + }, + }, + } +} + func ecdsaSignBenchmarkCommand() *cli.Command { return &cli.Command{ Name: "sign-ecdsa", @@ -334,6 +382,14 @@ func getNATSConnection(environment string, appConfig *config.AppConfig) (*nats.C } func runKeygenBenchmark(ctx context.Context, cmd *cli.Command) error { + return runKeygenBenchmarkGeneric(ctx, cmd, "", "Keygen") +} + +func runKeygenDklsBenchmark(ctx context.Context, cmd *cli.Command) error { + return runKeygenBenchmarkGeneric(ctx, cmd, types.ProtocolDkls23, "DKLS23 Keygen") +} + +func runKeygenBenchmarkGeneric(ctx context.Context, cmd *cli.Command, protocol types.Protocol, label string) error { if cmd.Args().Len() < 1 { return fmt.Errorf("missing required argument: num_operations") } @@ -351,7 +407,7 @@ func runKeygenBenchmark(ctx context.Context, cmd *cli.Command) error { return err } - fmt.Printf("Starting keygen benchmark with %d operations...\n", n) + fmt.Printf("Starting %s benchmark with %d operations...\n", label, n) var results []OperationResult var wg sync.WaitGroup @@ -362,11 +418,19 @@ func runKeygenBenchmark(ctx context.Context, cmd *cli.Command) error { mu.Lock() defer mu.Unlock() + // A dkls23 request also yields a tss-lib result; only the DKLs23 one counts. + if protocol == types.ProtocolDkls23 && result.ResultType == event.ResultTypeSuccess && len(result.DKLSPubKey) == 0 { + return + } + for i := range results { if results[i].ID == result.WalletID && !results[i].Completed { results[i].EndTime = time.Now() results[i].Completed = true results[i].Success = result.ResultType == event.ResultTypeSuccess + if results[i].Success { + fmt.Printf("Wallet created: %s\n", result.WalletID) + } if !results[i].Success { results[i].ErrorReason = result.ErrorReason results[i].ErrorCode = result.ErrorCode @@ -397,7 +461,7 @@ func runKeygenBenchmark(ctx context.Context, cmd *cli.Command) error { wg.Add(1) - err := mpcClient.CreateWallet(walletID) + err := mpcClient.CreateWalletWithProtocol(walletID, protocol, nil) if err != nil { mu.Lock() results[i].Completed = true @@ -431,7 +495,7 @@ func runKeygenBenchmark(ctx context.Context, cmd *cli.Command) error { // Calculate results benchResult := calculateBenchmarkResult(results, totalTime, 1, []time.Duration{totalTime}) outputFile := cmd.String("output") - if err := printBenchmarkResult("Keygen", benchResult, outputFile); err != nil { + if err := printBenchmarkResult(label, benchResult, outputFile); err != nil { return fmt.Errorf("failed to write benchmark results: %w", err) } @@ -439,14 +503,18 @@ func runKeygenBenchmark(ctx context.Context, cmd *cli.Command) error { } func runECDSASignBenchmark(ctx context.Context, cmd *cli.Command) error { - return runSignBenchmark(ctx, cmd, types.KeyTypeSecp256k1, "ECDSA") + return runSignBenchmark(ctx, cmd, types.KeyTypeSecp256k1, "", "ECDSA") } func runEdDSASignBenchmark(ctx context.Context, cmd *cli.Command) error { - return runSignBenchmark(ctx, cmd, types.KeyTypeEd25519, "EdDSA") + return runSignBenchmark(ctx, cmd, types.KeyTypeEd25519, "", "EdDSA") +} + +func runDklsSignBenchmark(ctx context.Context, cmd *cli.Command) error { + return runSignBenchmark(ctx, cmd, types.KeyTypeSecp256k1, types.ProtocolDkls23, "DKLS23") } -func runSignBenchmark(ctx context.Context, cmd *cli.Command, keyType types.KeyType, keyTypeName string) error { +func runSignBenchmark(ctx context.Context, cmd *cli.Command, keyType types.KeyType, protocol types.Protocol, keyTypeName string) error { if cmd.Args().Len() < 2 { return fmt.Errorf("missing required arguments: num_operations and wallet_id") } @@ -546,6 +614,7 @@ func runSignBenchmark(ctx context.Context, cmd *cli.Command, keyType types.KeyTy NetworkInternalCode: "benchmark", TxID: txID, Tx: txData, + Protocol: protocol, } result := OperationResult{ From 8ac4ea13c39392c4f3bc49d04e8544674297e82b Mon Sep 17 00:00:00 2001 From: vietddude Date: Wed, 30 Sep 2026 14:50:08 +0700 Subject: [PATCH 06/12] docs(benchmark): add DKLs23 example and tss-lib comparison results - examples/dkls: keygen, sign with master and child key, verify both locally - benchmark/dklsvstss: in-process tss-lib vs DKLs23 benchmark - benchmark/results: report, CSV, chart and raw runs (1000 tx, VPS and local) --- benchmark/dklsvstss/bench_test.go | 124 +++++++++++ benchmark/dklsvstss/dkls_helpers.go | 194 +++++++++++++++++ benchmark/dklsvstss/tss_helpers.go | 146 +++++++++++++ benchmark/results/dkls-vs-tss.csv | 17 ++ benchmark/results/dkls-vs-tss.md | 91 ++++++++ benchmark/results/dkls-vs-tss.svg | 26 +++ .../results/raw/local-dkls23-1000-cap10.txt | 27 +++ .../results/raw/local-dkls23-1000-cap40.txt | 27 +++ .../results/raw/local-tss-ecdsa-200-cap10.txt | 27 +++ .../results/raw/vps-dkls23-1000-cap10.txt | 27 +++ .../results/raw/vps-dkls23-1000-cap40.txt | 27 +++ .../results/raw/vps-tss-ecdsa-1000-cap10.txt | 27 +++ examples/dkls/main.go | 198 ++++++++++++++++++ 13 files changed, 958 insertions(+) create mode 100644 benchmark/dklsvstss/bench_test.go create mode 100644 benchmark/dklsvstss/dkls_helpers.go create mode 100644 benchmark/dklsvstss/tss_helpers.go create mode 100644 benchmark/results/dkls-vs-tss.csv create mode 100644 benchmark/results/dkls-vs-tss.md create mode 100644 benchmark/results/dkls-vs-tss.svg create mode 100644 benchmark/results/raw/local-dkls23-1000-cap10.txt create mode 100644 benchmark/results/raw/local-dkls23-1000-cap40.txt create mode 100644 benchmark/results/raw/local-tss-ecdsa-200-cap10.txt create mode 100644 benchmark/results/raw/vps-dkls23-1000-cap10.txt create mode 100644 benchmark/results/raw/vps-dkls23-1000-cap40.txt create mode 100644 benchmark/results/raw/vps-tss-ecdsa-1000-cap10.txt create mode 100644 examples/dkls/main.go diff --git a/benchmark/dklsvstss/bench_test.go b/benchmark/dklsvstss/bench_test.go new file mode 100644 index 00000000..839647aa --- /dev/null +++ b/benchmark/dklsvstss/bench_test.go @@ -0,0 +1,124 @@ +//go:build dklsbench + +// Package dklsvstss benchmarks mpcium's tss-lib ECDSA backend against the DKLs23 +// implementation (github.com/fystack/DKLs23, vendored under third_party/dkls23), +// running both in-process with local message routing (no network). +// +// tss-lib's ECDSA keygen needs Paillier "safe prime" preparams per party; generating +// them is slow (tens of seconds each) but, exactly as mpcium's Node does in production +// (see pkg/mpc/node.go generatePreParams), they are precomputed once and reused across +// keys — so that cost is paid once here too, outside the timed loop. +// +// Run with: +// +// CGO_ENABLED=1 go test -tags dklsbench -bench . -benchtime=5x -run '^$' -timeout 30m ./benchmark/dklsvstss/... +package dklsvstss + +import ( + "crypto/sha256" + "math/big" + "sync" + "testing" + "time" + + "github.com/bnb-chain/tss-lib/v3/ecdsa/keygen" +) + +const ( + numParties = 2 + threshold = 1 // 2-of-2 +) + +var ( + preParamsOnce sync.Once + preParams []*keygen.LocalPreParams +) + +// sharedPreParams mirrors mpcium's Node: preparams are generated once and reused, +// so their (slow) generation cost is never part of a keygen benchmark. +func sharedPreParams(b *testing.B) []*keygen.LocalPreParams { + b.Helper() + preParamsOnce.Do(func() { + preParams = make([]*keygen.LocalPreParams, numParties) + errs := make([]error, numParties) + var wg sync.WaitGroup + for i := 0; i < numParties; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + preParams[i], errs[i] = keygen.GeneratePreParams(3 * time.Minute) + }(i) + } + wg.Wait() + for _, err := range errs { + if err != nil { + b.Fatalf("generate preparams: %v", err) + } + } + }) + return preParams +} + +func BenchmarkTssLibKeygen(b *testing.B) { + pp := sharedPreParams(b) + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := runTssKeygen(numParties, threshold, pp); err != nil { + b.Fatalf("tss-lib keygen failed: %v", err) + } + } +} + +func BenchmarkDklsKeygen(b *testing.B) { + for i := 0; i < b.N; i++ { + shares, err := runDklsDKG(uint8(numParties), uint8(threshold+1)) + if err != nil { + b.Fatalf("dkls keygen failed: %v", err) + } + for _, s := range shares { + s.Free() + } + } +} + +func BenchmarkTssLibSign(b *testing.B) { + pp := sharedPreParams(b) + keys, err := runTssKeygen(numParties, threshold, pp) + if err != nil { + b.Fatalf("tss-lib keygen (setup) failed: %v", err) + } + partyIDs := genTestPartyIDs(numParties) + hashBytes := sha256.Sum256([]byte("dkls-vs-tss-bench")) + msgHash := new(big.Int).SetBytes(hashBytes[:]) + + signers := keys[:threshold+1] + signerIDs := partyIDs[:threshold+1] + + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := runTssSign(signers, signerIDs, threshold, msgHash); err != nil { + b.Fatalf("tss-lib sign failed: %v", err) + } + } +} + +func BenchmarkDklsSign(b *testing.B) { + shares, err := runDklsDKG(uint8(numParties), uint8(threshold+1)) + if err != nil { + b.Fatalf("dkls keygen (setup) failed: %v", err) + } + defer func() { + for _, s := range shares { + s.Free() + } + }() + msgHash := sha256.Sum256([]byte("dkls-vs-tss-bench")) + signers := shares[:threshold+1] + + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := runDklsSign(signers, msgHash[:]); err != nil { + b.Fatalf("dkls sign failed: %v", err) + } + } +} diff --git a/benchmark/dklsvstss/dkls_helpers.go b/benchmark/dklsvstss/dkls_helpers.go new file mode 100644 index 00000000..b060ac19 --- /dev/null +++ b/benchmark/dklsvstss/dkls_helpers.go @@ -0,0 +1,194 @@ +//go:build dklsbench + +package dklsvstss + +import ( + "errors" + + "github.com/fystack/DKLs23/wrapper/go-ll/dkls" +) + +// runDklsDKG runs an in-process DKLs23 DKG for n parties, threshold t (t-of-n signers required). +func runDklsDKG(n, t uint8) ([]*dkls.Party, error) { + sessionID := make([]byte, 32) + for i := range sessionID { + sessionID[i] = byte(i) + } + + sessions := make([]*dkls.DkgSession, n) + for i := uint8(0); i < n; i++ { + var err error + sessions[i], err = dkls.NewDkgSession(t, n, i+1, sessionID) + if err != nil { + return nil, err + } + defer sessions[i].Free() + } + + msg1 := make([][]dkls.Message, n) + for i, s := range sessions { + var err error + msg1[i], err = s.Phase1() + if err != nil { + return nil, err + } + } + + msg2 := make([][]dkls.Message, n) + for i, s := range sessions { + incoming := make([]dkls.Message, 0) + for _, msgs := range msg1 { + for _, m := range msgs { + if m.Kind == dkls.MsgPolyFragment && m.ToID == uint8(i+1) { + incoming = append(incoming, m) + } + } + } + var err error + msg2[i], err = s.Phase2(incoming) + if err != nil { + return nil, err + } + } + + msg3 := make([][]dkls.Message, n) + for i, s := range sessions { + var err error + msg3[i], err = s.Phase3() + if err != nil { + return nil, err + } + } + + shares := make([]*dkls.Party, n) + for i, s := range sessions { + incoming := make([]dkls.Message, 0) + for _, msgs := range msg3 { + for _, m := range msgs { + if m.ToID == uint8(i+1) || m.ToID == 255 { + incoming = append(incoming, m) + } + } + } + for _, msgs := range msg2 { + for _, m := range msgs { + if m.Kind == dkls.MsgDkgZeroP2 && m.ToID == uint8(i+1) { + incoming = append(incoming, m) + } + } + } + for _, msgs := range msg2 { + for _, m := range msgs { + if m.ToID == 255 { + incoming = append(incoming, m) + } + } + } + var err error + shares[i], err = s.Phase4(incoming) + if err != nil { + return nil, err + } + } + + return shares, nil +} + +// runDklsSign runs an in-process DKLs23 threshold signature over exactly t signers. +func runDklsSign(signers []*dkls.Party, messageHash []byte) ([][]byte, error) { + n := len(signers) + if n == 0 { + return nil, errors.New("no signers") + } + t := int(signers[0].Threshold()) + if n != t { + return nil, errors.New("runDklsSign requires exactly threshold signers") + } + + signerIDs := make([]uint8, n) + for i := 0; i < n; i++ { + signerIDs[i] = signers[i].PartyID() + } + + sessions := make([]*dkls.SignSession, n) + signID := make([]byte, 32) + for i := 0; i < n; i++ { + counterparties := make([]uint8, 0, n-1) + for _, id := range signerIDs { + if id != signers[i].PartyID() { + counterparties = append(counterparties, id) + } + } + var err error + sessions[i], err = dkls.NewSignSession(signers[i], signID, counterparties, messageHash) + if err != nil { + return nil, err + } + defer sessions[i].Free() + } + + msg1 := make([][]dkls.Message, n) + for i, s := range sessions { + out, err := s.Phase1() + if err != nil { + return nil, err + } + msg1[i] = out + } + + msg2 := make([][]dkls.Message, n) + for i, s := range sessions { + in := collect(msg1, signers[i].PartyID(), dkls.MsgSignPhase1) + out, err := s.Phase2(in) + if err != nil { + return nil, err + } + msg2[i] = out + } + + msg3 := make([][]dkls.Message, n) + for i, s := range sessions { + in := collect(msg2, signers[i].PartyID(), dkls.MsgSignPhase2) + out, err := s.Phase3(in) + if err != nil { + return nil, err + } + msg3[i] = out + } + + in := collectBroadcast(msg3, dkls.MsgSignBroadcast) + signatures := make([][]byte, t) + for i := 0; i < t; i++ { + sig, err := sessions[i].Phase4(in, true) + if err != nil { + return nil, err + } + signatures[i] = sig + } + + return signatures, nil +} + +func collect(all [][]dkls.Message, receiver uint8, kind dkls.MessageKind) []dkls.Message { + out := make([]dkls.Message, 0) + for _, batch := range all { + for _, msg := range batch { + if msg.Kind == kind && msg.ToID == receiver { + out = append(out, msg) + } + } + } + return out +} + +func collectBroadcast(all [][]dkls.Message, kind dkls.MessageKind) []dkls.Message { + out := make([]dkls.Message, 0) + for _, batch := range all { + for _, msg := range batch { + if msg.Kind == kind && msg.ToID == 255 { + out = append(out, msg) + } + } + } + return out +} diff --git a/benchmark/dklsvstss/tss_helpers.go b/benchmark/dklsvstss/tss_helpers.go new file mode 100644 index 00000000..fb3be704 --- /dev/null +++ b/benchmark/dklsvstss/tss_helpers.go @@ -0,0 +1,146 @@ +//go:build dklsbench + +package dklsvstss + +import ( + "errors" + "math/big" + + "github.com/bnb-chain/tss-lib/v3/common" + "github.com/bnb-chain/tss-lib/v3/ecdsa/keygen" + "github.com/bnb-chain/tss-lib/v3/ecdsa/signing" + "github.com/bnb-chain/tss-lib/v3/tss" +) + +func genTestPartyIDs(n int) tss.SortedPartyIDs { + ids := make(tss.UnSortedPartyIDs, n) + for i := 0; i < n; i++ { + ids[i] = tss.NewPartyID(string(rune('1'+i)), "peer", big.NewInt(int64(i+1))) + } + return tss.SortPartyIDs(ids) +} + +// runTssKeygen runs an in-process tss-lib ECDSA keygen for n parties with the given threshold +// (threshold+1 parties are required to sign). preParams must be pre-generated per party. +func runTssKeygen(n, threshold int, preParams []*keygen.LocalPreParams) ([]*keygen.LocalPartySaveData, error) { + partyIDs := genTestPartyIDs(n) + ctx := tss.NewPeerContext(partyIDs) + + // Buffer generously: a small buffer can leave a trailing goroutine (spawned by + // routeTssMessage for a message produced after the round we're waiting on already + // completed) blocked on a send forever once this function returns and stops + // draining outCh. Those leaked, permanently-blocked goroutines then starve any + // process-global concurrency-limited resource tss-lib uses (e.g. its proof + // verification worker pool), hanging unrelated *later* keygen/sign calls in the + // same process even though this call itself finished successfully. + outCh := make(chan tss.Message, 256) + endCh := make(chan *keygen.LocalPartySaveData, n) + errCh := make(chan *tss.Error, n) + + parties := make([]tss.Party, n) + for i := 0; i < n; i++ { + params := tss.NewParameters(tss.S256(), ctx, partyIDs[i], n, threshold) + parties[i] = keygen.NewLocalParty(params, outCh, endCh, *preParams[i]) + } + + for _, p := range parties { + p := p + go func() { + if err := p.Start(); err != nil { + errCh <- err + } + }() + } + + results := make([]*keygen.LocalPartySaveData, 0, n) + for len(results) < n { + select { + case err := <-errCh: + return nil, err + case msg := <-outCh: + if err := routeTssMessage(parties, msg); err != nil { + return nil, err + } + case save := <-endCh: + results = append(results, save) + } + } + return results, nil +} + +// runTssSign runs an in-process tss-lib ECDSA signing round over all n key holders +// (n-of-n: the signer set must be exactly the parties that ran keygen together, same +// PartyID objects, to avoid tss-lib's LocalPartySaveData subset-remap edge cases). +func runTssSign(keys []*keygen.LocalPartySaveData, partyIDs tss.SortedPartyIDs, threshold int, msgHash *big.Int) ([]*common.SignatureData, error) { + n := len(keys) + ctx := tss.NewPeerContext(partyIDs) + + outCh := make(chan tss.Message, 256) + endCh := make(chan *common.SignatureData, n) + errCh := make(chan *tss.Error, n) + + parties := make([]tss.Party, n) + for i := 0; i < n; i++ { + params := tss.NewParameters(tss.S256(), ctx, partyIDs[i], n, threshold) + parties[i] = signing.NewLocalParty(msgHash, params, *keys[i], outCh, endCh) + } + + for _, p := range parties { + p := p + go func() { + if err := p.Start(); err != nil { + errCh <- err + } + }() + } + + results := make([]*common.SignatureData, 0, n) + for len(results) < n { + select { + case err := <-errCh: + return nil, err + case msg := <-outCh: + if err := routeTssMessage(parties, msg); err != nil { + return nil, err + } + case sig := <-endCh: + results = append(results, sig) + } + } + return results, nil +} + +func routeTssMessage(parties []tss.Party, msg tss.Message) error { + dest := msg.GetTo() + if dest == nil { + for _, p := range parties { + if p.PartyID().Index == msg.GetFrom().Index { + continue + } + go updateParty(p, msg) + } + return nil + } + for _, to := range dest { + found := false + for _, p := range parties { + if p.PartyID().Index == to.Index { + found = true + go updateParty(p, msg) + break + } + } + if !found { + return errors.New("destination party not found") + } + } + return nil +} + +func updateParty(p tss.Party, msg tss.Message) { + bz, _, err := msg.WireBytes() + if err != nil { + return + } + _, _ = p.UpdateFromBytes(bz, msg.GetFrom(), msg.IsBroadcast()) +} diff --git a/benchmark/results/dkls-vs-tss.csv b/benchmark/results/dkls-vs-tss.csv new file mode 100644 index 00000000..b13cbffb --- /dev/null +++ b/benchmark/results/dkls-vs-tss.csv @@ -0,0 +1,17 @@ +group,environment,protocol,tx,max_concurrent_signing,success_rate_pct,total_seconds,ops_per_min,avg_op_seconds,notes,raw_file +final,vps,dkls23,1000,10,100,51.38,1167.70,10.37,,raw/vps-dkls23-1000-cap10.txt +final,vps,dkls23,1000,40,100,50.77,1181.84,9.68,,raw/vps-dkls23-1000-cap40.txt +final,vps,tss-lib-ecdsa,1000,10,100,447.43,134.10,212.29,,raw/vps-tss-ecdsa-1000-cap10.txt +final,local,dkls23,1000,10,100,13.67,4388.55,0.042,,raw/local-dkls23-1000-cap10.txt +final,local,dkls23,1000,40,100,13.03,4604.07,0.039,,raw/local-dkls23-1000-cap40.txt +final,local,tss-lib-ecdsa,200,10,100,72.07,166.51,32.44,200 tx sample (not 1000),raw/local-tss-ecdsa-200-cap10.txt +history,vps,dkls23,1000,10,100,306.55,195.72,136.95,before optimizations, +history,vps,dkls23,1000,10,100,346.76,173.03,150.97,after concurrent wrapper + 20ms barrier poll, +history,vps,dkls23,1000,40,100,312.67,191.90,138.38,after concurrent wrapper + 20ms barrier poll, +history,vps,dkls23,1000,10,100,60.55,990.91,11.34,after per-recipient delivery + binary framing + router, +history,vps,dkls23,1000,40,100,54.82,1094.54,11.31,after per-recipient delivery + binary framing + router, +history,vps,dkls23,1000,100,100,59.21,1013.35,13.16,after per-recipient delivery + binary framing + router, +history,local,dkls23,1000,40,100,21.66,2770.03,0.163,before wire optimizations, +history,local,tss-lib-ecdsa,1000,40,100,345.09,173.87,,goroutine-per-request variant, +reference,in-process,tss-lib-ecdsa,1,,100,0.172,,,"2-of-2 sign, M1 Pro, benchmark/dklsvstss", +reference,in-process,dkls23,1,,100,0.0167,,,"2-of-2 sign, M1 Pro, benchmark/dklsvstss", diff --git a/benchmark/results/dkls-vs-tss.md b/benchmark/results/dkls-vs-tss.md new file mode 100644 index 00000000..416d36c9 --- /dev/null +++ b/benchmark/results/dkls-vs-tss.md @@ -0,0 +1,91 @@ +# DKLs23 vs tss-lib: signing benchmark + +![Signing throughput](./dkls-vs-tss.svg) + +1000 signing requests, 3 nodes (t+1 = 3 signers), client batch size 8. DKLs23 is threshold ECDSA on secp256k1 only, so the comparison is against tss-lib **ECDSA**. + +## Summary + +| | VPS (NATS over VPN) | Local (NATS in Docker) | +|---|---|---| +| **DKLs23**, 1000 tx | **1,168–1,182 ops/min** (51 s) | **4,389–4,604 ops/min** (13 s) | +| **tss-lib ECDSA** | 134 ops/min (7 m 27 s, 1000 tx) | 167 ops/min (200 tx sample) | +| DKLs23 vs tss-lib | **~8.7x faster** | **~26x faster** | + +- Every run finished at 100% success. +- tss-lib is bound by CPU (~170 ms of crypto per sign per node), so a faster network does not help it. +- DKLs23 is cheap on CPU (~17 ms per sign) and bound by the network and message size, so it gains the most from a fast link. + +## Setup + +- Machine: Apple M1 Pro (8 cores), macOS. The 3 nodes are local processes. +- VPS runs: NATS/JetStream and Consul on the VPS, reached over WireGuard (RTT about 10 ms for the final runs). +- Local runs: NATS and Consul in Docker on the same machine. +- `max_concurrent_signing` (JetStream `MaxAckPending`): 10 (default) and 40. DKLs23 barely changes between them because it is network-bound. tss-lib is measured at 10: its consumer is serial, so a larger cap only makes messages wait past the 30 s ack time and get redelivered (94% success at 40 in a test). +- Each run uses its own `--client-id`, so other consumers of the shared results consumer cannot take results. + +## Results + +Raw tool output for each row is in [`raw/`](./raw); the tidy table is [`dkls-vs-tss.csv`](./dkls-vs-tss.csv). + +### VPS, 1000 tx + +| Protocol | cap | Total | Ops/min | Avg latency (queued) | Success | +|---|---|---|---|---|---| +| DKLs23 | 10 | 51.4 s | 1,167.7 | 10.4 s | 100% | +| DKLs23 | 40 | 50.8 s | 1,181.8 | 9.7 s | 100% | +| tss-lib ECDSA | 10 | 447.4 s | 134.1 | 212.3 s | 100% | + +Latency is measured from submission, so it includes queueing behind the batch; compare throughput, not latency. + +### Local, NATS in Docker + +| Protocol | tx | cap | Total | Ops/min | Success | +|---|---|---|---|---|---| +| DKLs23 | 1000 | 10 | 13.7 s | 4,388.6 | 100% | +| DKLs23 | 1000 | 40 | 13.0 s | 4,604.1 | 100% | +| tss-lib ECDSA | **200** | 10 | 72.1 s | 166.5 | 100% | + +The local tss-lib number is a 200-tx sample. An earlier 1000-tx local run gave 173.9 ops/min. + +## Why DKLs23 is faster + +| Measurement | tss-lib ECDSA | DKLs23 | +|---|---|---| +| In-process sign (2-of-2, no network) | 172 ms | 16.7 ms (~10x) | +| Bytes on the wire per sign (3 nodes, both directions) | ~0.3 MB | ~1.05 MB (was ~3.7 MB before the wire changes) | + +DKLs23 uses OT-extension multiplication: little CPU, many bytes (about 0.48 MB of protocol payload per sign across the 3 parties). The remaining wire cost is roughly that payload; broadcast fan-out and JSON/base64 were removed. + +## How DKLs23 got here (VPS, 1000 tx) + +| Step | Ops/min | +|---|---| +| Before optimizations | 196 | +| Concurrent signing wrapper + 20 ms barrier poll | 173–192 | +| Per-recipient delivery, binary framing, barrier-free router | 991 (cap 10), 1,095 (cap 40) | +| Final (refactored, key-info cache, typed results) | 1,168 (cap 10), 1,182 (cap 40) | + +The network was not constant across these runs (ping to the VPS was about 95 ms for the early ones and about 10 ms for the final ones), so the ops/min steps are not a clean A/B. The robust result is the bytes per sign: 3.7 MB down to 1.05 MB. + +## Reproduce + +```bash +make dkls-lib # needs the submodule and cargo +go build -tags dkls -o mpcium ./cmd/mpcium +go build -tags dkls -o mpcium-cli ./cmd/mpcium-cli + +mpcium-cli benchmark --client-id run1 keygen-dkls 1 # prints "Wallet created: " +mpcium-cli benchmark --client-id run1 --output out.txt sign-dkls23 1000 --batch-size 8 +mpcium-cli benchmark --client-id run2 --output out.txt sign-ecdsa 1000 --batch-size 8 + +make bench-dkls # in-process library benchmark (tss-lib vs DKLs23) +make bench-dkls-nats # Session/Service benchmark over a Docker NATS +``` + +## Caveats + +- One machine runs all three nodes plus the client, so CPU is shared and these are not production numbers. +- tss-lib cap sensitivity: see Setup. +- DKLs23 supports ECDSA/secp256k1 only. EdDSA stays on tss-lib. +- The in-process numbers are from 10 iterations on an M1 Pro. diff --git a/benchmark/results/dkls-vs-tss.svg b/benchmark/results/dkls-vs-tss.svg new file mode 100644 index 00000000..b791d8bf --- /dev/null +++ b/benchmark/results/dkls-vs-tss.svg @@ -0,0 +1,26 @@ + + +Signing throughput: DKLs23 vs tss-lib ECDSA +Operations per minute, 3 nodes, higher is better +VPS (NATS over VPN), 1000 tx +tss-lib ECDSA + +134 +DKLs23, cap 10 + +1,168 +DKLs23, cap 40 + +1,182 +Local (NATS in Docker) +tss-lib ECDSA (200 tx) + +166 +DKLs23, cap 10 + +4,389 +DKLs23, cap 40 + +4,604 +Apple M1 Pro, all nodes on one machine. Source: benchmark/results/dkls-vs-tss.csv + \ No newline at end of file diff --git a/benchmark/results/raw/local-dkls23-1000-cap10.txt b/benchmark/results/raw/local-dkls23-1000-cap10.txt new file mode 100644 index 00000000..d86a48b9 --- /dev/null +++ b/benchmark/results/raw/local-dkls23-1000-cap10.txt @@ -0,0 +1,27 @@ + +=============================== +DKLS23 SIGNING BENCHMARK RESULTS SUMMARY +=============================== +Timestamp: 2026-09-30T13:52:52+07:00 +Operation Type: DKLS23 Signing +Total benchmark time: 13.671956041s +Total batches sent: 125 +Total requests sent: 1000 +Successful completions: 1000 +Failed operations: 0 +Success rate: 100.00% +Error rate: 0.00% +Average operations per minute: 4388.55 +Average operation time: 41.682814ms +Median operation time: 39.163937ms + +------------------------------ +8 REQUEST ANALYSIS +------------------------------ +Completed from first 8: 8/8 +Fastest (first 8): 27.632958ms +Slowest (first 8): 29.8025ms + + +================================================================================ + diff --git a/benchmark/results/raw/local-dkls23-1000-cap40.txt b/benchmark/results/raw/local-dkls23-1000-cap40.txt new file mode 100644 index 00000000..8e9a6337 --- /dev/null +++ b/benchmark/results/raw/local-dkls23-1000-cap40.txt @@ -0,0 +1,27 @@ + +=============================== +DKLS23 SIGNING BENCHMARK RESULTS SUMMARY +=============================== +Timestamp: 2026-09-30T13:49:29+07:00 +Operation Type: DKLS23 Signing +Total benchmark time: 13.031941625s +Total batches sent: 125 +Total requests sent: 1000 +Successful completions: 1000 +Failed operations: 0 +Success rate: 100.00% +Error rate: 0.00% +Average operations per minute: 4604.07 +Average operation time: 39.21624ms +Median operation time: 38.808812ms + +------------------------------ +8 REQUEST ANALYSIS +------------------------------ +Completed from first 8: 8/8 +Fastest (first 8): 26.829292ms +Slowest (first 8): 28.614625ms + + +================================================================================ + diff --git a/benchmark/results/raw/local-tss-ecdsa-200-cap10.txt b/benchmark/results/raw/local-tss-ecdsa-200-cap10.txt new file mode 100644 index 00000000..cbc149ca --- /dev/null +++ b/benchmark/results/raw/local-tss-ecdsa-200-cap10.txt @@ -0,0 +1,27 @@ + +=============================== +ECDSA SIGNING BENCHMARK RESULTS SUMMARY +=============================== +Timestamp: 2026-09-30T13:52:38+07:00 +Operation Type: ECDSA Signing +Total benchmark time: 1m12.068198458s +Total batches sent: 25 +Total requests sent: 200 +Successful completions: 200 +Failed operations: 0 +Success rate: 100.00% +Error rate: 0.00% +Average operations per minute: 166.51 +Average operation time: 32.439025891s +Median operation time: 31.548490166s + +------------------------------ +8 REQUEST ANALYSIS +------------------------------ +Completed from first 8: 8/8 +Fastest (first 8): 638.83775ms +Slowest (first 8): 2.922196458s + + +================================================================================ + diff --git a/benchmark/results/raw/vps-dkls23-1000-cap10.txt b/benchmark/results/raw/vps-dkls23-1000-cap10.txt new file mode 100644 index 00000000..fa36ea28 --- /dev/null +++ b/benchmark/results/raw/vps-dkls23-1000-cap10.txt @@ -0,0 +1,27 @@ + +=============================== +DKLS23 SIGNING BENCHMARK RESULTS SUMMARY +=============================== +Timestamp: 2026-09-30T14:26:48+07:00 +Operation Type: DKLS23 Signing +Total benchmark time: 51.383063375s +Total batches sent: 125 +Total requests sent: 1000 +Successful completions: 1000 +Failed operations: 0 +Success rate: 100.00% +Error rate: 0.00% +Average operations per minute: 1167.70 +Average operation time: 10.367025767s +Median operation time: 9.495356812s + +------------------------------ +8 REQUEST ANALYSIS +------------------------------ +Completed from first 8: 8/8 +Fastest (first 8): 299.478875ms +Slowest (first 8): 571.750833ms + + +================================================================================ + diff --git a/benchmark/results/raw/vps-dkls23-1000-cap40.txt b/benchmark/results/raw/vps-dkls23-1000-cap40.txt new file mode 100644 index 00000000..77de8810 --- /dev/null +++ b/benchmark/results/raw/vps-dkls23-1000-cap40.txt @@ -0,0 +1,27 @@ + +=============================== +DKLS23 SIGNING BENCHMARK RESULTS SUMMARY +=============================== +Timestamp: 2026-09-30T14:28:03+07:00 +Operation Type: DKLS23 Signing +Total benchmark time: 50.768152958s +Total batches sent: 125 +Total requests sent: 1000 +Successful completions: 1000 +Failed operations: 0 +Success rate: 100.00% +Error rate: 0.00% +Average operations per minute: 1181.84 +Average operation time: 9.677883054s +Median operation time: 9.669078083s + +------------------------------ +8 REQUEST ANALYSIS +------------------------------ +Completed from first 8: 8/8 +Fastest (first 8): 340.392667ms +Slowest (first 8): 605.5375ms + + +================================================================================ + diff --git a/benchmark/results/raw/vps-tss-ecdsa-1000-cap10.txt b/benchmark/results/raw/vps-tss-ecdsa-1000-cap10.txt new file mode 100644 index 00000000..bc78f863 --- /dev/null +++ b/benchmark/results/raw/vps-tss-ecdsa-1000-cap10.txt @@ -0,0 +1,27 @@ + +=============================== +ECDSA SIGNING BENCHMARK RESULTS SUMMARY +=============================== +Timestamp: 2026-09-30T14:36:01+07:00 +Operation Type: ECDSA Signing +Total benchmark time: 7m27.426573292s +Total batches sent: 125 +Total requests sent: 1000 +Successful completions: 1000 +Failed operations: 0 +Success rate: 100.00% +Error rate: 0.00% +Average operations per minute: 134.10 +Average operation time: 3m32.290703928s +Median operation time: 3m33.273736958s + +------------------------------ +8 REQUEST ANALYSIS +------------------------------ +Completed from first 8: 8/8 +Fastest (first 8): 1.102877334s +Slowest (first 8): 3.850252708s + + +================================================================================ + diff --git a/examples/dkls/main.go b/examples/dkls/main.go new file mode 100644 index 00000000..330be9b5 --- /dev/null +++ b/examples/dkls/main.go @@ -0,0 +1,198 @@ +// Needs nodes built with -tags dkls: creates a wallet, signs with the master and a child key, verifies both. +package main + +import ( + "crypto/hmac" + "crypto/sha256" + "crypto/sha512" + "encoding/binary" + "flag" + "fmt" + "os" + "strconv" + "strings" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + btcecdsa "github.com/btcsuite/btcd/btcec/v2/ecdsa" + "github.com/fystack/mpcium/pkg/client" + "github.com/fystack/mpcium/pkg/config" + "github.com/fystack/mpcium/pkg/event" + "github.com/fystack/mpcium/pkg/logger" + "github.com/fystack/mpcium/pkg/types" + "github.com/google/uuid" + "github.com/nats-io/nats.go" + "github.com/spf13/viper" +) + +func main() { + clientID := flag.String("client-id", "example-dkls", "Client ID used to scope result routing") + pathFlag := flag.String("path", "1,5", "Non-hardened child indices to derive, comma separated") + timeout := flag.Duration("timeout", 60*time.Second, "How long to wait for each result") + flag.Parse() + + config.InitViperConfig("") + logger.Init("development", false) + + path, err := parsePath(*pathFlag) + if err != nil { + logger.Fatal("Invalid --path", err) + } + + natsConn, err := nats.Connect(viper.GetString("nats.url")) + if err != nil { + logger.Fatal("Failed to connect to NATS", err) + } + defer natsConn.Drain() + + algorithm := viper.GetString("event_initiator_algorithm") + if algorithm == "" { + algorithm = string(types.EventInitiatorKeyTypeEd25519) + } + signer, err := client.NewLocalSigner(types.EventInitiatorKeyType(algorithm), client.LocalSignerOptions{ + KeyPath: "./event_initiator.key", + }) + if err != nil { + logger.Fatal("Failed to create signer", err) + } + mpcClient := client.NewMPCClient(client.Options{NatsConn: natsConn, Signer: signer, ClientID: *clientID}) + + walletID := uuid.New().String() + keys := make(chan event.KeygenResultEvent, 1) + sigs := make(chan event.SigningResultEvent, 2) + + if err := mpcClient.OnWalletCreationResult(func(evt event.KeygenResultEvent) { + // A dkls23 request also yields the tss-lib result; only the DKLs23 one carries DKLSPubKey. + if evt.WalletID == walletID && (evt.ResultType != event.ResultTypeSuccess || len(evt.DKLSPubKey) > 0) { + keys <- evt + } + }); err != nil { + logger.Fatal("Failed to subscribe to wallet results", err) + } + if err := mpcClient.OnSignResult(func(evt event.SigningResultEvent) { sigs <- evt }); err != nil { + logger.Fatal("Failed to subscribe to signing results", err) + } + + fmt.Printf("Creating DKLs23 wallet %s ...\n", walletID) + if err := mpcClient.CreateWalletWithProtocol(walletID, types.ProtocolDkls23, nil); err != nil { + logger.Fatal("CreateWalletWithProtocol failed", err) + } + + var keygen event.KeygenResultEvent + select { + case keygen = <-keys: + case <-time.After(*timeout): + fail("timed out waiting for the keygen result") + } + if keygen.ResultType != event.ResultTypeSuccess { + fail("keygen failed: " + keygen.ErrorReason) + } + fmt.Printf("Public key (X||Y): %x\nChain code: %x\n", keygen.DKLSPubKey, keygen.DKLSChainCode) + + masterPub, err := parseXY(keygen.DKLSPubKey) + if err != nil { + fail(err.Error()) + } + childPub, err := deriveChild(masterPub, keygen.DKLSChainCode, path) + if err != nil { + fail(err.Error()) + } + fmt.Printf("Child public key at %v: %x\n", path, childPub.SerializeCompressed()) + + hash := sha256.Sum256([]byte("hello dkls23")) + for _, tc := range []struct { + label string + path []uint32 + pub *btcec.PublicKey + }{ + {"master key", nil, masterPub}, + {"child key " + *pathFlag, path, childPub}, + } { + txID := uuid.New().String() + err := mpcClient.SignTransaction(&types.SignTxMessage{ + KeyType: types.KeyTypeSecp256k1, + Protocol: types.ProtocolDkls23, + WalletID: walletID, + NetworkInternalCode: "example", + TxID: txID, + Tx: hash[:], // DKLs23 signs a 32-byte hash + DerivationPath: tc.path, + }) + if err != nil { + fail("SignTransaction failed: " + err.Error()) + } + + select { + case res := <-sigs: + if res.ResultType != event.ResultTypeSuccess { + fail("signing failed: " + res.ErrorReason) + } + ok := verify(res, hash[:], tc.pub) + fmt.Printf("Signed with %-14s r=%x s=%x recovery=%d verified=%v\n", tc.label, res.R, res.S, res.SignatureRecovery[0], ok) + if !ok { + os.Exit(1) + } + case <-time.After(*timeout): + fail("timed out waiting for the signing result") + } + } +} + +func fail(msg string) { + fmt.Fprintln(os.Stderr, "error:", msg) + os.Exit(1) +} + +func parsePath(s string) ([]uint32, error) { + var path []uint32 + for _, part := range strings.Split(s, ",") { + n, err := strconv.ParseUint(strings.TrimSpace(part), 10, 32) + if err != nil || n >= 0x80000000 { + return nil, fmt.Errorf("%q is not a non-hardened index", part) + } + path = append(path, uint32(n)) + } + return path, nil +} + +func parseXY(xy []byte) (*btcec.PublicKey, error) { + if len(xy) != 64 { + return nil, fmt.Errorf("public key must be 64 bytes (X||Y), got %d", len(xy)) + } + return btcec.ParsePubKey(append([]byte{0x04}, xy...)) +} + +// deriveChild computes the BIP-32 public child key, so child addresses are known without the nodes. +func deriveChild(pub *btcec.PublicKey, chainCode []byte, path []uint32) (*btcec.PublicKey, error) { + for _, index := range path { + mac := hmac.New(sha512.New, chainCode) + mac.Write(pub.SerializeCompressed()) + mac.Write(binary.BigEndian.AppendUint32(nil, index)) + sum := mac.Sum(nil) + + var tweak btcec.ModNScalar + if tweak.SetByteSlice(sum[:32]) { + return nil, fmt.Errorf("invalid tweak at index %d", index) + } + var tweakPoint, parent, child btcec.JacobianPoint + btcec.ScalarBaseMultNonConst(&tweak, &tweakPoint) + pub.AsJacobian(&parent) + btcec.AddNonConst(&tweakPoint, &parent, &child) + child.ToAffine() + pub = btcec.NewPublicKey(&child.X, &child.Y) + chainCode = sum[32:] + } + return pub, nil +} + +func verify(res event.SigningResultEvent, hash []byte, pub *btcec.PublicKey) bool { + var r, s btcec.ModNScalar + r.SetByteSlice(res.R) + s.SetByteSlice(res.S) + if !btcecdsa.NewSignature(&r, &s).Verify(hash, pub) { + return false + } + compact := append([]byte{27 + 4 + res.SignatureRecovery[0]}, append(res.R, res.S...)...) + recovered, _, err := btcecdsa.RecoverCompact(compact, hash) + return err == nil && recovered.IsEqual(pub) +} From 9436b01108b99d05a6c85bda7f119fdb6eabd44d Mon Sep 17 00:00:00 2001 From: vietddude Date: Wed, 30 Sep 2026 14:50:08 +0700 Subject: [PATCH 07/12] docs: document the DKLs23 backend, build, usage and benchmarks --- README.md | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/README.md b/README.md index fb51fa5c..c4f36a11 100644 --- a/README.md +++ b/README.md @@ -471,6 +471,63 @@ mpcium generate-authorizer \ - `--encrypt` stores the private key as `.authorizer.key.age`; omit it to write the plain hex key. - The identity JSON is saved as `.authorizer.identity.json`; copy the `public_key` into `authorization.authorizer_public_keys`. +## DKLs23 (optional ECDSA backend) + +Besides tss-lib, Mpcium can sign with [DKLs23](https://eprint.iacr.org/2023/765) threshold ECDSA (secp256k1 only). It uses far less CPU per signature and is much faster at scale; see [benchmark results](./benchmark/results/dkls-vs-tss.md). + +![DKLs23 vs tss-lib signing throughput](./benchmark/results/dkls-vs-tss.svg) + +It is opt-in: the Rust library is built through cgo behind the `dkls` build tag, and the default build does not include it. + +### Build + +```bash +git submodule update --init third_party/dkls23 # needs read access to fystack/DKLs23 +make dkls-lib # cargo build --release (Rust toolchain required) +go build -tags dkls -o mpcium ./cmd/mpcium +``` + +- Clone with `--recurse-submodules` to fetch the submodule in one step. +- The binary loads the library built in `third_party/dkls23/target/release`, so run it on the machine that built it. +- All nodes must run a `-tags dkls` build of the same version; upgrade them together. +- `mpcium-cli` and Go clients need no build tag. + +### Use + +Nodes start as usual (`mpcium start -n node0`). Clients opt in per request with `types.ProtocolDkls23`: + +```go +// Keygen also creates the tss-lib ECDSA/EdDSA keys; the DKLs23 result carries DKLSPubKey and DKLSChainCode. +mpcClient.CreateWalletWithProtocol(walletID, types.ProtocolDkls23, nil) + +mpcClient.SignTransaction(&types.SignTxMessage{ + KeyType: types.KeyTypeSecp256k1, + Protocol: types.ProtocolDkls23, + WalletID: walletID, + TxID: txID, + Tx: hash32[:], // DKLs23 signs a 32-byte hash + DerivationPath: []uint32{1, 5}, // optional, see below +}) +``` + +- The signing result has `R`, `S`, `SignatureRecovery`, and `Signature` (`r || s`). +- `dkls_pub_key` is the 64-byte `X||Y` public key, the same format as tss-lib ECDSA keys. +- **HD derivation:** set `DerivationPath` to sign with a BIP-32 child key. Only non-hardened indices (below 2^31) work, because no party holds the full key. The chain code comes from the DKG, not from `chain_code` in the config. With the public key and `dkls_chain_code`, clients derive child public keys offline. +- Not supported for DKLs23 wallets: EdDSA and resharing. + +A runnable end-to-end example (keygen, sign with the master and a child key, verify both signatures): + +```bash +go run ./examples/dkls --path 1,5 +``` + +### Tests + +```bash +make test-dkls # in-memory and in-process tests +make test-dkls-nats # same, plus real NATS (starts a Docker container) +``` + ### Testing ## 1. Unit tests @@ -515,3 +572,21 @@ mpcium-cli benchmark --config config.yaml --output signing-results.txt \ ``` Use `--prompt-password` for secure key password input and `--help` for all options. + +### DKLs23 Benchmark + +```bash +# Create a DKLs23 wallet; the wallet ID is printed as "Wallet created: " +mpcium-cli benchmark --client-id run1 keygen-dkls 1 + +# Sign 1000 requests and write the summary to a file +mpcium-cli benchmark --client-id run1 --output dkls.txt sign-dkls23 1000 --batch-size 8 + +# Library-level comparison (tss-lib vs DKLs23, no network) and a real-NATS run +make bench-dkls +make bench-dkls-nats +``` + +- Give every run its own `--client-id`. Without it, all clients share one results consumer and another consumer on the same NATS can take your results, so the benchmark waits until it times out. +- Keep `max_concurrent_signing` at its default for tss-lib: its consumer handles one request at a time, and a large value makes requests wait past the ack time and get redelivered. +- Saved results and a comparison with tss-lib are in [`benchmark/results`](./benchmark/results). From 98ea334a5b5cc8b44c910c22d35dfd7fdae9c412 Mon Sep 17 00:00:00 2001 From: vietddude Date: Thu, 15 Oct 2026 10:38:29 +0700 Subject: [PATCH 08/12] fix(dkls): wait for the local key share before signing Key info lives in shared Consul while shares are local, so a sign request could reach a node that had not finished saving its share after keygen. --- pkg/mpc/dkls/service.go | 25 +++++++++++++++++++++++++ pkg/mpc/dkls/service_test.go | 30 ++++++++++++++++++++++++++++++ pkg/mpc/dkls/session.go | 1 + 3 files changed, 56 insertions(+) diff --git a/pkg/mpc/dkls/service.go b/pkg/mpc/dkls/service.go index 7e31330c..b1aa21f3 100644 --- a/pkg/mpc/dkls/service.go +++ b/pkg/mpc/dkls/service.go @@ -6,6 +6,7 @@ import ( "fmt" "slices" "sync" + "time" "github.com/fystack/mpcium/pkg/keyinfo" "github.com/fystack/mpcium/pkg/kvstore" @@ -31,6 +32,11 @@ type Config struct { Peers PeerRegistry } +const ( + shareWaitTimeout = 10 * time.Second + sharePollInterval = 50 * time.Millisecond +) + type Service struct { cfg Config router *Router @@ -88,6 +94,10 @@ func (s *Service) Sign(walletID, txID string, msgHash []byte, derivationPath []u return Signature{}, ErrNotSelected } + if err := s.waitForShare(walletID, shareWaitTimeout); err != nil { + return Signature{}, err + } + sess, err := s.newSession(walletID, walletID+":"+txID, ActSign, info.ParticipantPeerIDs, info.Threshold) if err != nil { return Signature{}, err @@ -96,6 +106,21 @@ func (s *Service) Sign(walletID, txID string, msgHash []byte, derivationPath []u return sess.Sign(msgHash, signers, derivationPath) } +// Key info lives in shared Consul but shares are local, so a sign can arrive before this node's keygen has saved its share. +func (s *Service) waitForShare(walletID string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for { + _, err := s.keys.LoadShare(walletID) + if err == nil { + return nil + } + if time.Now().After(deadline) { + return fmt.Errorf("%w: %s: %v", ErrShareNotReady, walletID, err) + } + time.Sleep(sharePollInterval) + } +} + func (s *Service) newSession(walletID, sessionKey string, act Act, participants []string, threshold int) (*Session, error) { return NewSession(SessionConfig{ WalletID: walletID, diff --git a/pkg/mpc/dkls/service_test.go b/pkg/mpc/dkls/service_test.go index f94ec4b7..732b4ef0 100644 --- a/pkg/mpc/dkls/service_test.go +++ b/pkg/mpc/dkls/service_test.go @@ -6,6 +6,7 @@ import ( "crypto/sha256" "errors" "testing" + "time" "github.com/fystack/mpcium/pkg/keyinfo" "github.com/fystack/mpcium/pkg/messaging" @@ -126,3 +127,32 @@ func TestService_SignErrors(t *testing.T) { } }) } + +type lateShareStore struct { + KeyShareStore + failures int +} + +func (s *lateShareStore) LoadShare(string) ([]byte, error) { + if s.failures > 0 { + s.failures-- + return nil, errors.New("Key not found") + } + return []byte("share"), nil +} + +func TestService_WaitForShare(t *testing.T) { + t.Run("waits until the share is saved", func(t *testing.T) { + svc := &Service{keys: &lateShareStore{failures: 3}} + if err := svc.waitForShare("w", time.Second); err != nil { + t.Fatalf("waitForShare: %v", err) + } + }) + + t.Run("gives up with ErrShareNotReady", func(t *testing.T) { + svc := &Service{keys: &lateShareStore{failures: 1 << 30}} + if err := svc.waitForShare("w", 120*time.Millisecond); !errors.Is(err, ErrShareNotReady) { + t.Fatalf("err = %v, want ErrShareNotReady", err) + } + }) +} diff --git a/pkg/mpc/dkls/session.go b/pkg/mpc/dkls/session.go index a377153f..e0737a89 100644 --- a/pkg/mpc/dkls/session.go +++ b/pkg/mpc/dkls/session.go @@ -23,6 +23,7 @@ var ( ErrNotParticipant = errors.New("node is not a participant of this wallet") ErrNotSelected = errors.New("node is not in the selected signer set") ErrWalletNotFound = errors.New("dkls wallet not found") + ErrShareNotReady = errors.New("local key share is not available yet") ErrKeyExists = errors.New("dkls key already exists") ErrPeersNotReady = errors.New("not all peers are ready") ) From 95eecf7767c62481361b77f5337f5ef6b65cb59e Mon Sep 17 00:00:00 2001 From: vietddude Date: Thu, 15 Oct 2026 10:38:31 +0700 Subject: [PATCH 09/12] test(e2e): add DKLs23 keygen and signing e2e test Opt-in via E2E_DKLS=1; needs an mpcium binary built with -tags dkls. --- e2e/dkls_test.go | 99 ++++++++++++++++++++++++++++++++++++++++++++++++ e2e/go.mod | 17 +++++---- e2e/go.sum | 34 +++++++++-------- 3 files changed, 127 insertions(+), 23 deletions(-) create mode 100644 e2e/dkls_test.go diff --git a/e2e/dkls_test.go b/e2e/dkls_test.go new file mode 100644 index 00000000..a0d1a10f --- /dev/null +++ b/e2e/dkls_test.go @@ -0,0 +1,99 @@ +package e2e + +import ( + "crypto/sha256" + "os" + "testing" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + btcecdsa "github.com/btcsuite/btcd/btcec/v2/ecdsa" + "github.com/fystack/mpcium/pkg/event" + "github.com/fystack/mpcium/pkg/logger" + "github.com/fystack/mpcium/pkg/types" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +// Needs `mpcium` in PATH built with -tags dkls; run with E2E_DKLS=1. +func TestDklsSigning(t *testing.T) { + if os.Getenv("E2E_DKLS") == "" { + t.Skip("set E2E_DKLS=1 and put a -tags dkls mpcium build in PATH") + } + + suite := NewE2ETestSuite(".") + logger.Init("dev", true) + suite.CleanupTestEnvironment(t) + defer suite.Cleanup(t) + + keygens := make(chan event.KeygenResultEvent, 8) + signs := make(chan event.SigningResultEvent, 8) + walletID := uuid.NewString() + var masterPub *btcec.PublicKey + hash := sha256.Sum256([]byte("dkls e2e")) + + t.Run("Setup", func(t *testing.T) { + suite.SetupInfrastructure(t) + suite.SetupTestNodes(t) + require.NoError(t, suite.LoadConfig()) + suite.RegisterPeers(t) + suite.SetupMPCClient(t) + suite.SeedPreParams(t) + suite.StartNodes(t) + suite.WaitForNodesReady(t) + + require.NoError(t, suite.mpcClient.OnWalletCreationResult(func(e event.KeygenResultEvent) { keygens <- e })) + require.NoError(t, suite.mpcClient.OnSignResult(func(e event.SigningResultEvent) { signs <- e })) + time.Sleep(5 * time.Second) + }) + + t.Run("KeyGeneration", func(t *testing.T) { + require.NoError(t, suite.mpcClient.CreateWalletWithProtocol(walletID, types.ProtocolDkls23, nil)) + var res event.KeygenResultEvent + select { + case res = <-keygens: + case <-time.After(keygenTimeout): + t.Fatal("timed out waiting for keygen result") + } + require.Equal(t, event.ResultTypeSuccess, res.ResultType, res.ErrorReason) + require.Len(t, res.DKLSPubKey, 64) + require.NotEmpty(t, res.DKLSChainCode) + + var err error + masterPub, err = btcec.ParsePubKey(append([]byte{0x04}, res.DKLSPubKey...)) + require.NoError(t, err) + }) + + sign := func(t *testing.T, path []uint32) *btcec.PublicKey { + require.NoError(t, suite.mpcClient.SignTransaction(&types.SignTxMessage{ + KeyType: types.KeyTypeSecp256k1, + Protocol: types.ProtocolDkls23, + WalletID: walletID, + NetworkInternalCode: "e2e", + TxID: uuid.NewString(), + Tx: hash[:], + DerivationPath: path, + })) + var res event.SigningResultEvent + select { + case res = <-signs: + case <-time.After(signingTimeout): + t.Fatal("timed out waiting for signing result") + } + require.Equal(t, event.ResultTypeSuccess, res.ResultType, res.ErrorReason) + + compact := append([]byte{27 + 4 + res.SignatureRecovery[0]}, res.Signature...) + pub, _, err := btcecdsa.RecoverCompact(compact, hash[:]) + require.NoError(t, err) + return pub + } + + // Signs right after keygen: nodes may still be saving their share. + t.Run("Signing", func(t *testing.T) { + require.True(t, sign(t, nil).IsEqual(masterPub), "signature does not recover to the DKG public key") + }) + + t.Run("ChildKeySigning", func(t *testing.T) { + require.False(t, sign(t, []uint32{1, 5}).IsEqual(masterPub), "child signature recovered to the master key") + }) +} diff --git a/e2e/go.mod b/e2e/go.mod index 161efed4..51fae12d 100644 --- a/e2e/go.mod +++ b/e2e/go.mod @@ -1,9 +1,10 @@ module github.com/fystack/mpcium/e2e -go 1.25.8 +go 1.26 require ( github.com/bnb-chain/tss-lib/v3 v3.0.1 + github.com/btcsuite/btcd/btcec/v2 v2.3.6 github.com/dgraph-io/badger/v4 v4.9.0 github.com/fystack/mpcium v0.0.0-00010101000000-000000000000 github.com/google/uuid v1.6.0 @@ -36,7 +37,6 @@ require ( github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect github.com/aws/smithy-go v1.24.0 // indirect github.com/btcsuite/btcd v0.25.0 // indirect - github.com/btcsuite/btcd/btcec/v2 v2.3.6 // indirect github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect github.com/btcsuite/btcutil v1.0.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -48,6 +48,7 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/fatih/color v1.18.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fystack/DKLs23/wrapper/go-ll v0.0.0-00010101000000-000000000000 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect @@ -91,12 +92,12 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.48.0 // indirect + golang.org/x/crypto v0.50.0 // indirect golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect - golang.org/x/net v0.49.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/term v0.40.0 // indirect - golang.org/x/text v0.34.0 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/term v0.42.0 // indirect + golang.org/x/text v0.36.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) @@ -106,3 +107,5 @@ replace github.com/fystack/mpcium => ../ replace github.com/agl/ed25519 => github.com/binance-chain/edwards25519 v0.0.0-20200305024217-f36fc4b53d43 replace github.com/bnb-chain/tss-lib/v3 => github.com/fystack/tss-lib/v3 v3.0.1 + +replace github.com/fystack/DKLs23/wrapper/go-ll => ../third_party/dkls23/wrapper/go-ll diff --git a/e2e/go.sum b/e2e/go.sum index e8f37a16..4f2d446f 100644 --- a/e2e/go.sum +++ b/e2e/go.sum @@ -81,6 +81,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= +github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= github.com/decred/dcrd/crypto/rand v1.0.1 h1:pYMgDRmRv1z1RNgAAs8izJstm4B+fLFiqGD5btOt2Wg= github.com/decred/dcrd/crypto/rand v1.0.1/go.mod h1:MsA2XySk/4KpCOYW6vsNYTGuOYRK1wpvulaWCuW7RyI= github.com/decred/dcrd/dcrec/edwards/v2 v2.0.4 h1:xmmdtnGxF/Od2doiP56zBv5a3LgJ3PA6mlx3Luf622I= @@ -349,16 +351,16 @@ golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200115085410-6d4e4cb37c7d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -370,8 +372,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -380,8 +382,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -404,15 +406,15 @@ golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= -golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= @@ -421,8 +423,8 @@ golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From a3df7081f2bdb0d9207ab91a97644e79539ecdf0 Mon Sep 17 00:00:00 2001 From: vietddude Date: Thu, 15 Oct 2026 11:26:11 +0700 Subject: [PATCH 10/12] feat(dkls): add key refresh for DKLs23 wallets Rotates every share while keeping the public key and chain code. Refresh excludes signing per wallet so a sign never mixes old and new shares. Requests go through mpc:dkls:refresh and results arrive on the reshare result subject. --- README.md | 3 +- cmd/mpcium/dkls_off.go | 1 + cmd/mpcium/dkls_on.go | 3 +- cmd/mpcium/main.go | 2 +- pkg/client/client.go | 20 ++++++ pkg/eventconsumer/dkls_consumer.go | 60 +++++++++++++++++- pkg/eventconsumer/event_consumer.go | 1 + pkg/mpc/dkls/round.go | 3 + pkg/mpc/dkls/service.go | 39 ++++++++++++ pkg/mpc/dkls/service_test.go | 78 ++++++++++++++++++++++++ pkg/mpc/dkls/session.go | 94 +++++++++++++++++++++++++++-- pkg/mpc/dkls/transport.go | 5 +- pkg/types/initiator_msg.go | 23 +++++++ third_party/dkls23 | 2 +- 14 files changed, 322 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index c4f36a11..fcafc1bb 100644 --- a/README.md +++ b/README.md @@ -513,7 +513,8 @@ mpcClient.SignTransaction(&types.SignTxMessage{ - The signing result has `R`, `S`, `SignatureRecovery`, and `Signature` (`r || s`). - `dkls_pub_key` is the 64-byte `X||Y` public key, the same format as tss-lib ECDSA keys. - **HD derivation:** set `DerivationPath` to sign with a BIP-32 child key. Only non-hardened indices (below 2^31) work, because no party holds the full key. The chain code comes from the DKG, not from `chain_code` in the config. With the public key and `dkls_chain_code`, clients derive child public keys offline. -- Not supported for DKLs23 wallets: EdDSA and resharing. +- **Refresh:** `mpcClient.RefreshDkls(&types.RefreshMessage{WalletID: id, SessionID: uuid})` rotates every share and keeps the public key and chain code. All participants must be online, and the result arrives through `OnResharingResult`. +- Not supported for DKLs23 wallets: EdDSA and resharing (changing the committee or threshold). A runnable end-to-end example (keygen, sign with the master and a child key, verify both signatures): diff --git a/cmd/mpcium/dkls_off.go b/cmd/mpcium/dkls_off.go index 7b6f8b6e..cb087bb0 100644 --- a/cmd/mpcium/dkls_off.go +++ b/cmd/mpcium/dkls_off.go @@ -19,6 +19,7 @@ func startDkls( mpc.PeerRegistry, messaging.MessageQueue, messaging.MessageQueue, + messaging.MessageQueue, ) (stop func()) { return func() {} } diff --git a/cmd/mpcium/dkls_on.go b/cmd/mpcium/dkls_on.go index 5500921e..8b756c76 100644 --- a/cmd/mpcium/dkls_on.go +++ b/cmd/mpcium/dkls_on.go @@ -24,6 +24,7 @@ func startDkls( peers mpc.PeerRegistry, genKeyResultQueue messaging.MessageQueue, signingResultQueue messaging.MessageQueue, + reshareResultQueue messaging.MessageQueue, ) (stop func()) { service, err := dkls.NewService(dkls.Config{ NodeID: nodeID, @@ -36,7 +37,7 @@ func startDkls( if err != nil { logger.Fatal("Failed to start dkls service", err) } - consumer := eventconsumer.NewDklsConsumer(service, pubsub, identityStore, genKeyResultQueue, signingResultQueue, viper.GetInt("mpc_threshold")) + consumer := eventconsumer.NewDklsConsumer(service, pubsub, identityStore, genKeyResultQueue, signingResultQueue, reshareResultQueue, viper.GetInt("mpc_threshold")) consumer.Run() return func() { _ = consumer.Close() diff --git a/cmd/mpcium/main.go b/cmd/mpcium/main.go index 22670658..677c8af3 100644 --- a/cmd/mpcium/main.go +++ b/cmd/mpcium/main.go @@ -267,7 +267,7 @@ func runNode(ctx context.Context, c *cli.Command) error { eventConsumer.Run() defer eventConsumer.Close() - stopDkls := startDkls(nodeID, pubsub, identityStore, badgerKV, keyinfoStore, peerRegistry, genKeyResultQueue, singingResultQueue) + stopDkls := startDkls(nodeID, pubsub, identityStore, badgerKV, keyinfoStore, peerRegistry, genKeyResultQueue, singingResultQueue, reshareResultQueue) defer stopDkls() timeoutConsumer := eventconsumer.NewTimeOutConsumer( diff --git a/pkg/client/client.go b/pkg/client/client.go index ce482daa..61b55a57 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -24,6 +24,8 @@ type MPCClient interface { OnSignResult(callback func(event event.SigningResultEvent)) error Resharing(msg *types.ResharingMessage) error + // RefreshDkls rotates the shares of a DKLs23 wallet; the result arrives through OnResharingResult. + RefreshDkls(msg *types.RefreshMessage) error OnResharingResult(callback func(event event.ResharingResultEvent)) error } @@ -245,6 +247,24 @@ func (c *mpcClient) Resharing(msg *types.ResharingMessage) error { return nil } +func (c *mpcClient) RefreshDkls(msg *types.RefreshMessage) error { + raw, err := msg.Raw() + if err != nil { + return fmt.Errorf("RefreshDkls: raw payload error: %w", err) + } + if msg.Signature, err = c.signer.Sign(raw); err != nil { + return fmt.Errorf("RefreshDkls: failed to sign message: %w", err) + } + bytes, err := json.Marshal(msg) + if err != nil { + return fmt.Errorf("RefreshDkls: marshal error: %w", err) + } + if err := c.pubsub.Publish(eventconsumer.MPCDklsRefreshEvent, bytes, c.requestHeaders()); err != nil { + return fmt.Errorf("RefreshDkls: publish error: %w", err) + } + return nil +} + func (c *mpcClient) OnResharingResult(callback func(event event.ResharingResultEvent)) error { err := c.reshareSuccessQueue.Dequeue(event.ReshareResultSubscriptionSubject(c.clientID), func(msg []byte) error { logger.Info("Received reshare success message", "raw", string(msg)) diff --git a/pkg/eventconsumer/dkls_consumer.go b/pkg/eventconsumer/dkls_consumer.go index 53be41ea..80785cf2 100644 --- a/pkg/eventconsumer/dkls_consumer.go +++ b/pkg/eventconsumer/dkls_consumer.go @@ -26,11 +26,13 @@ type DklsConsumer struct { identityStore identity.Store genKeyResultQueue messaging.MessageQueue signingResultQueue messaging.MessageQueue + reshareResultQueue messaging.MessageQueue threshold int sessions *sessionTracker keygenSub messaging.Subscription signingSub messaging.Subscription + refreshSub messaging.Subscription } func NewDklsConsumer( @@ -39,6 +41,7 @@ func NewDklsConsumer( identityStore identity.Store, genKeyResultQueue messaging.MessageQueue, signingResultQueue messaging.MessageQueue, + reshareResultQueue messaging.MessageQueue, threshold int, ) *DklsConsumer { return &DklsConsumer{ @@ -47,6 +50,7 @@ func NewDklsConsumer( identityStore: identityStore, genKeyResultQueue: genKeyResultQueue, signingResultQueue: signingResultQueue, + reshareResultQueue: reshareResultQueue, threshold: threshold, sessions: newSessionTracker(dklsSessionTTL), } @@ -56,7 +60,8 @@ func (c *DklsConsumer) Run() { // Handlers block for the whole protocol and NATS runs one subscription's callbacks serially. c.keygenSub = c.subscribe(MPCDklsGenerateEvent, c.handleKeygen) c.signingSub = c.subscribe(MPCDklsSignEvent, c.handleSign) - logger.Info("DklsConsumer: subscribed to keygen/signing events") + c.refreshSub = c.subscribe(MPCDklsRefreshEvent, c.handleRefresh) + logger.Info("DklsConsumer: subscribed to keygen/signing/refresh events") } func (c *DklsConsumer) subscribe(subject string, handle func(*nats.Msg)) messaging.Subscription { @@ -68,7 +73,7 @@ func (c *DklsConsumer) subscribe(subject string, handle func(*nats.Msg)) messagi } func (c *DklsConsumer) Close() error { - for _, sub := range []messaging.Subscription{c.keygenSub, c.signingSub} { + for _, sub := range []messaging.Subscription{c.keygenSub, c.signingSub, c.refreshSub} { if sub != nil { _ = sub.Unsubscribe() } @@ -173,6 +178,57 @@ func (c *DklsConsumer) handleSign(natMsg *nats.Msg) { c.sendReply(natMsg) } +func (c *DklsConsumer) handleRefresh(natMsg *nats.Msg) { + var msg types.RefreshMessage + if err := json.Unmarshal(natMsg.Data, &msg); err != nil || !c.authorized(&msg) { + return + } + + sessionKey := "refresh:" + msg.WalletID + ":" + msg.SessionID + if !c.sessions.begin(sessionKey) { + logger.Warn("DklsConsumer: duplicate refresh request", "walletID", msg.WalletID, "sessionID", msg.SessionID) + c.sendReply(natMsg) + return + } + + result := event.ResharingResultEvent{WalletID: msg.WalletID, NewThreshold: c.threshold, KeyType: types.KeyTypeSecp256k1} + keys, err := c.service.Refresh(msg.WalletID, msg.SessionID) + switch { + case errors.Is(err, dkls.ErrNotParticipant): + c.sessions.release(sessionKey) + return + case err != nil: + c.sessions.release(sessionKey) + logger.Error("DklsConsumer: refresh failed", err, "walletID", msg.WalletID) + result.ResultType = event.ResultTypeError + result.ErrorReason = err.Error() + result.ErrorCode = string(event.GetErrorCodeFromError(err)) + default: + result.ResultType = event.ResultTypeSuccess + result.PubKey = keys.PubKey + } + + c.publishRefreshResult(natMsg, msg.SessionID, result) + c.sendReply(natMsg) +} + +func (c *DklsConsumer) publishRefreshResult(natMsg *nats.Msg, sessionID string, result event.ResharingResultEvent) { + payload, err := json.Marshal(result) + if err != nil { + logger.Error("DklsConsumer: failed to marshal refresh result", err) + return + } + subject := event.ReshareResultSubject(natMsg.Header.Get(event.ClientIDHeader), sessionID) + opts := &messaging.EnqueueOptions{IdempotententKey: composeReshareIdempotentKey(sessionID, natMsg)} + if err := c.reshareResultQueue.Enqueue(subject, payload, opts); err != nil { + logger.Error("DklsConsumer: failed to enqueue refresh result", err) + return + } + if result.ResultType == event.ResultTypeSuccess { + logger.Info("[REFRESH] DKLS refresh completed", "walletID", result.WalletID) + } +} + func (c *DklsConsumer) publishKeygenResult(natMsg *nats.Msg, result event.KeygenResultEvent) { payload, err := json.Marshal(result) if err != nil { diff --git a/pkg/eventconsumer/event_consumer.go b/pkg/eventconsumer/event_consumer.go index b9e069f8..70656319 100644 --- a/pkg/eventconsumer/event_consumer.go +++ b/pkg/eventconsumer/event_consumer.go @@ -28,6 +28,7 @@ const ( MPCDklsGenerateEvent = "mpc:dkls:generate" MPCDklsSignEvent = "mpc:dkls:sign" + MPCDklsRefreshEvent = "mpc:dkls:refresh" DefaultConcurrentKeygen = 2 DefaultConcurrentSigning = 20 diff --git a/pkg/mpc/dkls/round.go b/pkg/mpc/dkls/round.go index 47eee1e9..119c93b9 100644 --- a/pkg/mpc/dkls/round.go +++ b/pkg/mpc/dkls/round.go @@ -23,6 +23,9 @@ const ( PhaseSG1 PhaseSG2 PhaseSG3 + PhaseRF1 + PhaseRF2 + PhaseRF3 ) // Point-to-point payloads are encrypted for their recipient; broadcast entries stay public. diff --git a/pkg/mpc/dkls/service.go b/pkg/mpc/dkls/service.go index b1aa21f3..8b7129a1 100644 --- a/pkg/mpc/dkls/service.go +++ b/pkg/mpc/dkls/service.go @@ -41,6 +41,13 @@ type Service struct { cfg Config router *Router keys KeyShareStore + locks sync.Map +} + +// A refresh rewrites the share, so signing with a half-rotated committee fails; refresh excludes signs per wallet. +func (s *Service) walletLock(walletID string) *sync.RWMutex { + lock, _ := s.locks.LoadOrStore(walletID, &sync.RWMutex{}) + return lock.(*sync.RWMutex) } // Start it before the node is marked ready so peers never publish to an unsubscribed node. @@ -70,6 +77,35 @@ func (s *Service) Keygen(walletID string, threshold int) (KeyResult, error) { return sess.Keygen() } +func (s *Service) Refresh(walletID, refreshID string) (KeyResult, error) { + info, err := s.keys.Info(walletID) + if err != nil { + return KeyResult{}, err + } + if !slices.Contains(info.ParticipantPeerIDs, s.cfg.NodeID) { + return KeyResult{}, ErrNotParticipant + } + ready := s.cfg.Peers.GetReadyPeersIncludeSelf() + for _, id := range info.ParticipantPeerIDs { + if !slices.Contains(ready, id) { + return KeyResult{}, fmt.Errorf("%w: %s", ErrPeersNotReady, id) + } + } + lock := s.walletLock(walletID) + lock.Lock() + defer lock.Unlock() + if err := s.waitForShare(walletID, shareWaitTimeout); err != nil { + return KeyResult{}, err + } + + sess, err := s.newSession(walletID, walletID+":refresh:"+refreshID, ActRefresh, info.ParticipantPeerIDs, info.Threshold) + if err != nil { + return KeyResult{}, err + } + defer sess.Close() + return sess.Refresh() +} + func (s *Service) Sign(walletID, txID string, msgHash []byte, derivationPath []uint32) (Signature, error) { info, err := s.keys.Info(walletID) if err != nil { @@ -94,6 +130,9 @@ func (s *Service) Sign(walletID, txID string, msgHash []byte, derivationPath []u return Signature{}, ErrNotSelected } + lock := s.walletLock(walletID) + lock.RLock() + defer lock.RUnlock() if err := s.waitForShare(walletID, shareWaitTimeout); err != nil { return Signature{}, err } diff --git a/pkg/mpc/dkls/service_test.go b/pkg/mpc/dkls/service_test.go index 732b4ef0..44647312 100644 --- a/pkg/mpc/dkls/service_test.go +++ b/pkg/mpc/dkls/service_test.go @@ -3,6 +3,7 @@ package dkls import ( + "bytes" "crypto/sha256" "errors" "testing" @@ -156,3 +157,80 @@ func TestService_WaitForShare(t *testing.T) { } }) } + +func TestService_RefreshKeepsKeyAndSigns(t *testing.T) { + nodeIDs := []string{"node-a", "node-b", "node-c"} + services := newTestServices(t, &fakePubSub{}, nodeIDs, nodeIDs) + + runAll := func(fn func(*Service) (KeyResult, error)) KeyResult { + t.Helper() + type out struct { + res KeyResult + err error + } + ch := make(chan out, len(nodeIDs)) + for _, svc := range services { + go func() { + res, err := fn(svc) + ch <- out{res, err} + }() + } + var res KeyResult + for range nodeIDs { + o := <-ch + if o.err != nil { + t.Fatal(o.err) + } + res = o.res + } + return res + } + + master := runAll(func(s *Service) (KeyResult, error) { return s.Keygen("wallet-rf", 1) }) + oldShare, err := services["node-a"].keys.LoadShare("wallet-rf") + if err != nil { + t.Fatal(err) + } + + refreshed := runAll(func(s *Service) (KeyResult, error) { return s.Refresh("wallet-rf", "r1") }) + if !bytes.Equal(refreshed.PubKey, master.PubKey) || !bytes.Equal(refreshed.ChainCode, master.ChainCode) { + t.Fatal("refresh changed the public key or chain code") + } + newShare, err := services["node-a"].keys.LoadShare("wallet-rf") + if err != nil { + t.Fatal(err) + } + if bytes.Equal(oldShare, newShare) { + t.Fatal("refresh did not change the stored share") + } + + hash := sha256.Sum256([]byte("after refresh")) + sigCh := make(chan Signature, len(nodeIDs)) + errCh := make(chan error, len(nodeIDs)) + for _, id := range nodeIDs[:2] { + go func() { + sig, err := services[id].Sign("wallet-rf", "tx-1", hash[:], nil) + sigCh <- sig + errCh <- err + }() + } + var sig Signature + for range nodeIDs[:2] { + sig = <-sigCh + if err := <-errCh; err != nil && !errors.Is(err, ErrNotSelected) { + t.Fatalf("sign after refresh: %v", err) + } + } + verifySignature(t, sig, hash[:], bip32DerivePublic(t, compressXY(t, master.PubKey), master.ChainCode, nil)) +} + +func TestService_RefreshNeedsAllParticipants(t *testing.T) { + nodeIDs := []string{"node-a", "node-b", "node-c"} + svc := newTestServices(t, &fakePubSub{}, nodeIDs[:1], nodeIDs[:2])["node-a"] + if err := svc.keys.Save("w", []byte("share"), &keyinfo.KeyInfo{ParticipantPeerIDs: nodeIDs, Threshold: 1, Version: 1}); err != nil { + t.Fatal(err) + } + if _, err := svc.Refresh("w", "r1"); !errors.Is(err, ErrPeersNotReady) { + t.Fatalf("err = %v, want ErrPeersNotReady", err) + } +} diff --git a/pkg/mpc/dkls/session.go b/pkg/mpc/dkls/session.go index e0737a89..2a3b80e0 100644 --- a/pkg/mpc/dkls/session.go +++ b/pkg/mpc/dkls/session.go @@ -134,19 +134,105 @@ func (s *Session) Keygen() (KeyResult, error) { if err != nil { return KeyResult{}, fmt.Errorf("serialize key share: %w", err) } - pubKey, err := partyPubKey(party) + result, err := s.keyResult(party) if err != nil { return KeyResult{}, err } - chainCode, err := party.ChainCode() + + info := &keyinfo.KeyInfo{ParticipantPeerIDs: s.participants, Threshold: s.cfg.Threshold, Version: 1} + if err := s.cfg.Keys.Save(s.cfg.WalletID, share, info); err != nil { + return KeyResult{}, err + } + return result, nil +} + +// Every participant must take part; the public key and chain code stay the same. +func (s *Session) Refresh() (KeyResult, error) { + party, err := s.loadParty(nil) if err != nil { - return KeyResult{}, fmt.Errorf("get chain code: %w", err) + return KeyResult{}, err } + defer party.Free() - info := &keyinfo.KeyInfo{ParticipantPeerIDs: s.participants, Threshold: s.cfg.Threshold, Version: 1} + peers := s.peersOf(s.participants) + id := sessionID(append([]string{"dkls-refresh", s.cfg.WalletID, s.cfg.SessionKey}, s.participants...)...) + refresh, err := dklsll.NewRefreshSession(party, id) + if err != nil { + return KeyResult{}, fmt.Errorf("new refresh session: %w", err) + } + defer refresh.Free() + + out1, err := refresh.Phase1() + if err != nil { + return KeyResult{}, fmt.Errorf("refresh phase1: %w", err) + } + all1, err := s.round.exchange(PhaseRF1, out1, peers) + if err != nil { + return KeyResult{}, err + } + + out2, err := refresh.Phase2(ofKind(all1, dklsll.MsgPolyFragment, s.self)) + if err != nil { + return KeyResult{}, fmt.Errorf("refresh phase2: %w", err) + } + all2, err := s.round.exchange(PhaseRF2, out2, peers) + if err != nil { + return KeyResult{}, err + } + + out3, err := refresh.Phase3() + if err != nil { + return KeyResult{}, fmt.Errorf("refresh phase3: %w", err) + } + all3, err := s.round.exchange(PhaseRF3, out3, peers) + if err != nil { + return KeyResult{}, err + } + + incoming := addressedTo(all3, s.self) + incoming = append(incoming, ofKind(all2, dklsll.MsgRefreshP2, s.self)...) + incoming = append(incoming, broadcasts(all2)...) + refreshed, err := refresh.Phase4(incoming) + if err != nil { + return KeyResult{}, fmt.Errorf("refresh phase4: %w", err) + } + defer refreshed.Free() + + result, err := s.keyResult(refreshed) + if err != nil { + return KeyResult{}, err + } + oldPub, err := partyPubKey(party) + if err != nil { + return KeyResult{}, err + } + if !slices.Equal(oldPub, result.PubKey) { + return KeyResult{}, errors.New("refresh changed the public key") + } + + share, err := refreshed.ToBytes() + if err != nil { + return KeyResult{}, fmt.Errorf("serialize key share: %w", err) + } + info, err := s.cfg.Keys.Info(s.cfg.WalletID) + if err != nil { + return KeyResult{}, err + } if err := s.cfg.Keys.Save(s.cfg.WalletID, share, info); err != nil { return KeyResult{}, err } + return result, nil +} + +func (s *Session) keyResult(party *dklsll.Party) (KeyResult, error) { + pubKey, err := partyPubKey(party) + if err != nil { + return KeyResult{}, err + } + chainCode, err := party.ChainCode() + if err != nil { + return KeyResult{}, fmt.Errorf("get chain code: %w", err) + } return KeyResult{PubKey: pubKey, ChainCode: chainCode}, nil } diff --git a/pkg/mpc/dkls/transport.go b/pkg/mpc/dkls/transport.go index 32478835..fe4fbcd4 100644 --- a/pkg/mpc/dkls/transport.go +++ b/pkg/mpc/dkls/transport.go @@ -13,8 +13,9 @@ import ( type Act string const ( - ActKeygen Act = "keygen" - ActSign Act = "sign" + ActKeygen Act = "keygen" + ActSign Act = "sign" + ActRefresh Act = "refresh" ) type Transport interface { diff --git a/pkg/types/initiator_msg.go b/pkg/types/initiator_msg.go index abddc1ee..62fa74c2 100644 --- a/pkg/types/initiator_msg.go +++ b/pkg/types/initiator_msg.go @@ -77,6 +77,29 @@ type ResharingMessage struct { AuthorizerSignatures []AuthorizerSignature `json:"authorizer_signatures,omitempty"` } +// RefreshMessage rotates every share of a DKLs23 wallet without changing its public key. +type RefreshMessage struct { + SessionID string `json:"session_id"` + WalletID string `json:"wallet_id"` + Signature []byte `json:"signature,omitempty"` + AuthorizerSignatures []AuthorizerSignature `json:"authorizer_signatures,omitempty"` +} + +func (m *RefreshMessage) Raw() ([]byte, error) { + copy := *m + copy.Signature = nil + copy.AuthorizerSignatures = nil + return json.Marshal(©) +} + +func (m *RefreshMessage) Sig() []byte { return m.Signature } + +func (m *RefreshMessage) InitiatorID() string { return m.WalletID } + +func (m *RefreshMessage) GetAuthorizerSignatures() []AuthorizerSignature { + return m.AuthorizerSignatures +} + func (m *SignTxMessage) Raw() ([]byte, error) { // omit the Signature field itself when computing the signed‐over data payload := struct { diff --git a/third_party/dkls23 b/third_party/dkls23 index 47448195..17d3b91c 160000 --- a/third_party/dkls23 +++ b/third_party/dkls23 @@ -1 +1 @@ -Subproject commit 47448195296c6d6adfb04f29cd2dafd338c97168 +Subproject commit 17d3b91c9ad7ff60881e5af1a62a0be973c5c5ed From 2732566a373f708321f758701f7c5c63747b1b6b Mon Sep 17 00:00:00 2001 From: vietddude Date: Thu, 15 Oct 2026 11:26:11 +0700 Subject: [PATCH 11/12] test(e2e): cover DKLs23 refresh and its failure cases --- e2e/dkls_test.go | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/e2e/dkls_test.go b/e2e/dkls_test.go index a0d1a10f..8b783dde 100644 --- a/e2e/dkls_test.go +++ b/e2e/dkls_test.go @@ -28,6 +28,7 @@ func TestDklsSigning(t *testing.T) { keygens := make(chan event.KeygenResultEvent, 8) signs := make(chan event.SigningResultEvent, 8) + refreshes := make(chan event.ResharingResultEvent, 8) walletID := uuid.NewString() var masterPub *btcec.PublicKey hash := sha256.Sum256([]byte("dkls e2e")) @@ -44,6 +45,7 @@ func TestDklsSigning(t *testing.T) { require.NoError(t, suite.mpcClient.OnWalletCreationResult(func(e event.KeygenResultEvent) { keygens <- e })) require.NoError(t, suite.mpcClient.OnSignResult(func(e event.SigningResultEvent) { signs <- e })) + require.NoError(t, suite.mpcClient.OnResharingResult(func(e event.ResharingResultEvent) { refreshes <- e })) time.Sleep(5 * time.Second) }) @@ -96,4 +98,43 @@ func TestDklsSigning(t *testing.T) { t.Run("ChildKeySigning", func(t *testing.T) { require.False(t, sign(t, []uint32{1, 5}).IsEqual(masterPub), "child signature recovered to the master key") }) + + t.Run("Refresh", func(t *testing.T) { + sessionID := uuid.NewString() + require.NoError(t, suite.mpcClient.RefreshDkls(&types.RefreshMessage{WalletID: walletID, SessionID: sessionID})) + var res event.ResharingResultEvent + select { + case res = <-refreshes: + case <-time.After(keygenTimeout): + t.Fatal("timed out waiting for refresh result") + } + require.Equal(t, event.ResultTypeSuccess, res.ResultType, res.ErrorReason) + require.Equal(t, append([]byte{0x04}, res.PubKey...), masterPub.SerializeUncompressed(), "refresh changed the public key") + }) + + t.Run("SigningAfterRefresh", func(t *testing.T) { + require.True(t, sign(t, nil).IsEqual(masterPub), "signature does not recover to the DKG public key") + }) + + // A killed node never resigns, so refresh only fails once the 60s phase timeout expires. + refreshFails := func(t *testing.T, id string) event.ResharingResultEvent { + require.NoError(t, suite.mpcClient.RefreshDkls(&types.RefreshMessage{WalletID: id, SessionID: uuid.NewString()})) + select { + case res := <-refreshes: + require.Equal(t, event.ResultTypeError, res.ResultType) + return res + case <-time.After(90 * time.Second): + t.Fatal("timed out waiting for refresh error result") + return event.ResharingResultEvent{} + } + } + + t.Run("RefreshUnknownWallet", func(t *testing.T) { + refreshFails(t, uuid.NewString()) + }) + + t.Run("RefreshWithNodeOffline", func(t *testing.T) { + suite.StopNode(t, 2) + refreshFails(t, walletID) + }) } From a20aa4fa701196e528958c765a9e8290fe7914d4 Mon Sep 17 00:00:00 2001 From: vietddude Date: Thu, 15 Oct 2026 13:57:10 +0700 Subject: [PATCH 12/12] docs: document the DKLs23 e2e test --- README.md | 8 ++++++++ e2e/README.md | 11 +++++++++++ 2 files changed, 19 insertions(+) diff --git a/README.md b/README.md index fcafc1bb..65a44d93 100644 --- a/README.md +++ b/README.md @@ -529,6 +529,14 @@ make test-dkls # in-memory and in-process tests make test-dkls-nats # same, plus real NATS (starts a Docker container) ``` +End-to-end test on 3 real nodes (keygen, sign, child-key sign, refresh, and failure cases). It needs `mpcium` and `mpcium-cli` in `PATH`, with `mpcium` built using `-tags dkls`: + +```bash +cd e2e && CGO_ENABLED=1 E2E_DKLS=1 go test -run TestDklsSigning -timeout 10m -v . +``` + +Without `E2E_DKLS=1` the test is skipped. + ### Testing ## 1. Unit tests diff --git a/e2e/README.md b/e2e/README.md index 739ba822..73a1897b 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -303,9 +303,20 @@ If you encounter signature verification errors or other mysterious test failures - `base_test.go` - Core test infrastructure and cleanup functions - `keygen_test.go` - Key generation tests +- `dkls_test.go` - DKLs23 keygen, signing and refresh tests (opt-in, see below) - `cleanup_test_env.sh` - Standalone cleanup script - `docker-compose.test.yaml` - Test infrastructure (NATS, Consul) +### DKLs23 + +`TestDklsSigning` is skipped unless `E2E_DKLS=1` is set, and needs a `mpcium` binary built with `-tags dkls` in `PATH` (see the DKLs23 section in the root README): + +```bash +GOWORK=off CGO_ENABLED=1 E2E_DKLS=1 go test -run TestDklsSigning -timeout 10m -v . +``` + +The node-offline case takes about a minute: a killed node never resigns, so refresh fails only after the 60s phase timeout. + ### Common Issues and Solutions | Issue | Cause | Solution |