diff --git a/.github/workflows/client.yml b/.github/workflows/client.yml index 8fa563f0cf..b0c9bfed86 100644 --- a/.github/workflows/client.yml +++ b/.github/workflows/client.yml @@ -145,6 +145,19 @@ jobs: path: coverage/coverage.out if-no-files-found: warn + - name: Check coverage gate + run: | + docker run --rm \ + -v "${{ github.workspace }}/coverage:/coverage" \ + go-build-env \ + go tool cover -func /coverage/coverage.out > /tmp/cover-func.txt + TOTAL=$(grep '^total:' /tmp/cover-func.txt | awk '{print $3}' | tr -d '%') + echo "Total coverage: ${TOTAL}%" + PASS=$(awk -v t="$TOTAL" 'BEGIN { print (t+0 >= 14) ? "yes" : "no" }') + if [ "$PASS" != "yes" ]; then + echo "::error::Coverage ${TOTAL}% is below the 14% minimum threshold" + exit 1 + fi - name: Build Docker Runtime Image if: github.event_name != 'workflow_dispatch' @@ -311,6 +324,85 @@ jobs: install-go: false checks: "-SA1019" + client-bench: + needs: [client-build-test-publish] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + actions: read + steps: + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.24" + cache: false + + - name: Download Docker Build Image + uses: actions/download-artifact@v4 + with: + name: go-build-env-image + path: /tmp + + - name: Load Docker Build Image + run: | + docker load --input /tmp/go-build-env-image.tar + + - name: Download previous benchmark results + id: download-prev + uses: dawidd6/action-download-artifact@v6 + continue-on-error: true + with: + name: go-bench + path: bench-prev + workflow: client.yml + branch: main + if_no_artifact_found: warn + + - name: Run benchmarks + run: | + docker run \ + --workdir /go/src/github.com/keep-network/keep-core \ + go-build-env \ + go test -bench=. -benchmem -count=10 -run='^$' ./pkg/... \ + > bench.txt + cat bench.txt + + - name: Install benchstat + run: go install golang.org/x/perf/cmd/benchstat@latest + + - name: Compare benchmarks + if: steps.download-prev.outcome == 'success' && hashFiles('bench-prev/**') != '' + run: | + benchstat bench-prev/*.txt bench.txt | tee benchstat-results.txt + python3 - <<'EOF' + import sys, re + content = open('benchstat-results.txt').read() + regressions = [] + for line in content.splitlines(): + if '~' in line or not line.strip(): + continue + m = re.search(r'\+(\d+\.\d+)%', line) + if m and float(m.group(1)) > 20: + regressions.append(line) + if regressions: + print('Performance regressions >20% detected:') + for r in regressions: + print(' ', r) + sys.exit(1) + EOF + + - name: Upload benchmark results + if: always() + uses: actions/upload-artifact@v4 + with: + name: go-bench + path: bench.txt + overwrite: true + if-no-files-found: warn + client-integration-test: needs: [client-detect-changes, electrum-integration-detect-changes, client-build-test-publish] if: | diff --git a/Makefile b/Makefile index ab468ae08f..6dc591d2f3 100644 --- a/Makefile +++ b/Makefile @@ -146,4 +146,7 @@ cmd-help: build @echo '$$ $(app_name) start --help' > docs/resources/client-start-help ./$(app_name) start --help >> docs/resources/client-start-help -.PHONY: all development sepolia download_artifacts generate gen_proto build cmd-help release build_multi +bench: + go test -bench=. -benchmem -count=10 -run='^$$' ./pkg/... + +.PHONY: all development sepolia mainnet local get_artifacts generate gen_proto build cmd-help release build_multi bench diff --git a/cmd/start.go b/cmd/start.go index cfaece274c..4ec236aebf 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -233,7 +233,7 @@ func initializeClientInfo( signing chain.Signing, blockCounter chain.BlockCounter, ) *clientinfo.Registry { - registry, isConfigured := clientinfo.Initialize(ctx, config.ClientInfo.Port) + registry, isConfigured := clientinfo.Initialize(ctx, config.ClientInfo) if !isConfigured { logger.Infof("client info endpoint not configured") return nil diff --git a/docs/profiling.md b/docs/profiling.md new file mode 100644 index 0000000000..e3a1e30b19 --- /dev/null +++ b/docs/profiling.md @@ -0,0 +1,135 @@ +# Go Profiling Runbook + +## Overview + +The keep-core binary exposes Go runtime profiling endpoints via the +`clientinfo` HTTP server when `EnablePprof: true` is set in configuration. +Profiles are served at `/debug/pprof/` on the same port as metrics and +diagnostics (`ClientInfo.Port`). + +## Security Warning + +The clientinfo HTTP server binds to all interfaces (`0.0.0.0`). **Never +enable pprof on a production node that is reachable from untrusted networks.** +CPU profiles, heap dumps, and goroutine traces can expose sensitive runtime +state. + +Safe access patterns: +- Run on a private/firewalled network +- Use an SSH tunnel: `ssh -L 9601:localhost:9601 node-host` +- Restrict at the network layer (security group, firewall rule) + +## Enabling Profiling + +In your config file (TOML example): + +```toml +[ClientInfo] + Port = 9601 + EnablePprof = true +``` + +Or pass via environment / flag if your deployment uses those overrides. + +## Standard Commands + +Replace `9601` with your configured `ClientInfo.Port`. + +### CPU profile (30 seconds) + +```sh +go tool pprof http://localhost:9601/debug/pprof/profile?seconds=30 +``` + +### Heap profile + +```sh +go tool pprof http://localhost:9601/debug/pprof/heap +``` + +### Goroutine dump (text) + +```sh +curl -s http://localhost:9601/debug/pprof/goroutine?debug=2 +``` + +### Trace (5 seconds) + +```sh +curl -o /tmp/trace.out http://localhost:9601/debug/pprof/trace?seconds=5 +go tool trace /tmp/trace.out +``` + +### Mutex contention + +```sh +# Enable mutex profiling first (runtime call or startup flag): +# runtime.SetMutexProfileFraction(1) +go tool pprof http://localhost:9601/debug/pprof/mutex +``` + +## Benchmark + Profile Workflow + +To identify hot paths found by benchmarks: + +```sh +# Run benchmark and write CPU profile +go test ./pkg/tbtc/... -run=^$ -bench=BenchmarkGetRecentWindows \ + -cpuprofile=/tmp/cpu.pprof -benchtime=5s + +# Inspect interactively +go tool pprof /tmp/cpu.pprof +(pprof) top10 +(pprof) web # requires graphviz +``` + +For memory allocation hot paths: + +```sh +go test ./pkg/bitcoin/... -run=^$ -bench=BenchmarkComputeSignatureHashes \ + -memprofile=/tmp/mem.pprof -benchtime=5s +go tool pprof /tmp/mem.pprof +(pprof) alloc_space +(pprof) top10 +``` + +## Comparing Benchmarks Across Commits + +```sh +# Baseline (main branch) +git stash +go test ./pkg/... -run=^$ -bench=. -count=6 | tee /tmp/baseline.txt + +# Candidate (your branch) +git stash pop +go test ./pkg/... -run=^$ -bench=. -count=6 | tee /tmp/candidate.txt + +benchstat /tmp/baseline.txt /tmp/candidate.txt +``` + +Install `benchstat`: `go install golang.org/x/perf/cmd/benchstat@latest` + +## Available Endpoints + +| Endpoint | Description | +|----------|-------------| +| `/debug/pprof/` | Index of available profiles | +| `/debug/pprof/cmdline` | Process command line | +| `/debug/pprof/profile` | CPU profile (30s default) | +| `/debug/pprof/symbol` | Symbol lookup | +| `/debug/pprof/trace` | Execution trace | +| `/debug/pprof/goroutine` | Goroutine stacks | +| `/debug/pprof/heap` | Heap allocations | +| `/debug/pprof/allocs` | Allocation samples | +| `/debug/pprof/block` | Goroutine blocking events | +| `/debug/pprof/mutex` | Mutex contention | + +## Notes + +- CPU profiling adds ~5% overhead to the profiled binary during the sampling + window. It is safe to run against a live node for short durations. +- Heap and goroutine profiles are sampled snapshots; a single sample may + miss transient allocations. Take multiple profiles under load. +- pprof registers on `http.DefaultServeMux`. If `EnablePprof: false`, the + handlers are still compiled in but no log message is emitted and they will + not be documented in operator runbooks as intentionally exposed. diff --git a/pkg/altbn128/altbn128_test.go b/pkg/altbn128/altbn128_test.go index 304eff948e..27563fcf96 100644 --- a/pkg/altbn128/altbn128_test.go +++ b/pkg/altbn128/altbn128_test.go @@ -81,3 +81,52 @@ func assertEqual(t *testing.T, n int, n2 int, msg string) { t.Errorf("%v: [%v] != [%v]", msg, n, n2) } } + +// --- Benchmarks --- + +func BenchmarkCompressG1(b *testing.B) { + _, p, err := bn256.RandomG1(rand.Reader) + if err != nil { + b.Fatal(err) + } + b.ResetTimer() + for range b.N { + G1Point{p}.Compress() + } +} + +func BenchmarkDecompressG1(b *testing.B) { + _, p, err := bn256.RandomG1(rand.Reader) + if err != nil { + b.Fatal(err) + } + buf := G1Point{p}.Compress() + b.ResetTimer() + for range b.N { + _, _ = DecompressToG1(buf) + } +} + +func BenchmarkCompressDecompressRoundTripG1(b *testing.B) { + _, p, err := bn256.RandomG1(rand.Reader) + if err != nil { + b.Fatal(err) + } + b.ResetTimer() + for range b.N { + buf := G1Point{p}.Compress() + _, _ = DecompressToG1(buf) + } +} + +func BenchmarkCompressDecompressRoundTripG2(b *testing.B) { + _, p, err := bn256.RandomG2(rand.Reader) + if err != nil { + b.Fatal(err) + } + b.ResetTimer() + for range b.N { + buf := G2Point{p}.Compress() + _, _ = DecompressToG2(buf) + } +} diff --git a/pkg/beacon/gjkr/marshaling_test.go b/pkg/beacon/gjkr/marshaling_test.go index d7694bface..12a4516694 100644 --- a/pkg/beacon/gjkr/marshaling_test.go +++ b/pkg/beacon/gjkr/marshaling_test.go @@ -434,3 +434,91 @@ func TestFuzzMisbehavedEphemeralKeysMessageRoundtrip(t *testing.T) { func TestFuzzMisbehavedEphemeralKeysMessageUnmarshaler(t *testing.T) { pbutils.FuzzUnmarshaler(&MisbehavedEphemeralKeysMessage{}) } + +// --- Benchmarks --- + +// buildEphemeralKeyMap generates n key pairs and returns the public key map as +// it would appear in a real EphemeralPublicKeyMessage (one entry per peer). +func buildEphemeralKeyMap(b *testing.B, n int) map[group.MemberIndex]*ephemeral.PublicKey { + b.Helper() + m := make(map[group.MemberIndex]*ephemeral.PublicKey, n) + for i := 0; i < n; i++ { + kp, err := ephemeral.GenerateKeyPair() + if err != nil { + b.Fatal(err) + } + m[group.MemberIndex(i+1)] = kp.PublicKey + } + return m +} + +func BenchmarkMarshalEphemeralPublicKeyMessage(b *testing.B) { + kp1, _ := ephemeral.GenerateKeyPair() + kp2, _ := ephemeral.GenerateKeyPair() + msg := &EphemeralPublicKeyMessage{ + senderID: group.MemberIndex(38), + ephemeralPublicKeys: map[group.MemberIndex]*ephemeral.PublicKey{ + group.MemberIndex(211): kp1.PublicKey, + group.MemberIndex(19): kp2.PublicKey, + }, + sessionID: "session-1", + } + b.ResetTimer() + for range b.N { + _, _ = msg.Marshal() + } +} + +func BenchmarkUnmarshalEphemeralPublicKeyMessage(b *testing.B) { + kp1, _ := ephemeral.GenerateKeyPair() + kp2, _ := ephemeral.GenerateKeyPair() + msg := &EphemeralPublicKeyMessage{ + senderID: group.MemberIndex(38), + ephemeralPublicKeys: map[group.MemberIndex]*ephemeral.PublicKey{ + group.MemberIndex(211): kp1.PublicKey, + group.MemberIndex(19): kp2.PublicKey, + }, + sessionID: "session-1", + } + data, err := msg.Marshal() + if err != nil { + b.Fatal(err) + } + b.ResetTimer() + for range b.N { + _ = new(EphemeralPublicKeyMessage).Unmarshal(data) + } +} + +// BenchmarkMarshalEphemeralPublicKeyMessage_64Keys benchmarks marshaling with +// the beacon group size (64 members = 63 peer keys per message). +func BenchmarkMarshalEphemeralPublicKeyMessage_64Keys(b *testing.B) { + msg := &EphemeralPublicKeyMessage{ + senderID: group.MemberIndex(1), + ephemeralPublicKeys: buildEphemeralKeyMap(b, 63), + sessionID: "session-1", + } + b.ResetTimer() + for range b.N { + _, _ = msg.Marshal() + } +} + +// BenchmarkUnmarshalEphemeralPublicKeyMessage_64Keys benchmarks unmarshaling +// with the beacon group size. Each btcec.ParsePubKey call dominates; with 63 +// peers this represents the real per-participant beacon DKG cost. +func BenchmarkUnmarshalEphemeralPublicKeyMessage_64Keys(b *testing.B) { + msg := &EphemeralPublicKeyMessage{ + senderID: group.MemberIndex(1), + ephemeralPublicKeys: buildEphemeralKeyMap(b, 63), + sessionID: "session-1", + } + data, err := msg.Marshal() + if err != nil { + b.Fatal(err) + } + b.ResetTimer() + for range b.N { + _ = new(EphemeralPublicKeyMessage).Unmarshal(data) + } +} diff --git a/pkg/bitcoin/transaction_builder_test.go b/pkg/bitcoin/transaction_builder_test.go index 246e70cd51..04845bbe93 100644 --- a/pkg/bitcoin/transaction_builder_test.go +++ b/pkg/bitcoin/transaction_builder_test.go @@ -1,6 +1,7 @@ package bitcoin import ( + "encoding/hex" "fmt" "math/big" "reflect" @@ -504,3 +505,71 @@ func assertInternalOutput( testutils.AssertBytesEqual(t, expected.PublicKeyScript, internalOutput.PkScript) } + +// --- Benchmarks --- + +// witnessP2WPKHTxHex is a P2WPKH transaction whose output[0] (value=35400) +// is used as the UTXO source for ComputeSignatureHashes benchmarks. +// https://live.blockcypher.com/btc-testnet/tx/f8eaf242a55ea15e602f9f990e33f67f99dfbe25d1802bbde63cc1caabf99668 +const witnessP2WPKHTxHex = "01000000000102bc187be612bc3db8cfcdec56b75e9bc0262ab6eacfe27cc1a699bacd53e3d07400000000c948304502210089a89aaf3fec97ac9ffa91cdff59829f0cb3ef852a468153e2c0e2b473466d2e022072902bb923ef016ac52e941ced78f816bf27991c2b73211e227db27ec200bc0a012103989d253b17a6a0f41838b84ff0d20e8898f9d7b1a98f2564da4cc29dcf8581d94c5c14934b98637ca318a4d6e7ca6ffd1690b8e77df6377508f9f0c90d000395237576a9148db50eb52063ea9d98b3eac91489a90f738986f68763ac6776a914e257eccafbc07c381642ce6e7e55120fb077fbed8804e0250162b175ac68ffffffffdc557e737b6688c5712649b86f7757a722dc3d42786f23b2fa826394dfec545c0000000000ffffffff01488a0000000000001600148db50eb52063ea9d98b3eac91489a90f738986f6000347304402203747f5ee31334b11ebac6a2a156b1584605de8d91a654cd703f9c8438634997402202059d680211776f93c25636266b02e059ed9fcc6209f7d3d9926c49a0d8750ed012103989d253b17a6a0f41838b84ff0d20e8898f9d7b1a98f2564da4cc29dcf8581d95c14934b98637ca318a4d6e7ca6ffd1690b8e77df6377508f9f0c90d000395237576a9148db50eb52063ea9d98b3eac91489a90f738986f68763ac6776a914e257eccafbc07c381642ce6e7e55120fb077fbed8804e0250162b175ac6800000000" + +// buildSigHashBuilder constructs a TransactionBuilder with n inputs all +// pointing to the same P2WPKH UTXO. ComputeSignatureHashes is non-mutating so +// the same builder can be reused across b.N iterations. +func buildSigHashBuilder(b *testing.B, n int) *TransactionBuilder { + b.Helper() + + txBytes, err := hex.DecodeString(witnessP2WPKHTxHex) + if err != nil { + b.Fatal(err) + } + + tx := new(Transaction) + if err := tx.Deserialize(txBytes); err != nil { + b.Fatal(err) + } + + localChain := newLocalChain() + if err := localChain.addTransaction(tx); err != nil { + b.Fatal(err) + } + + builder := NewTransactionBuilder(localChain) + utxo := &UnspentTransactionOutput{ + Outpoint: &TransactionOutpoint{ + TransactionHash: tx.Hash(), + OutputIndex: 0, + }, + Value: 35400, + } + for i := 0; i < n; i++ { + if err := builder.AddPublicKeyHashInput(utxo); err != nil { + b.Fatal(err) + } + } + return builder +} + +func BenchmarkComputeSignatureHashes_1Input(b *testing.B) { + builder := buildSigHashBuilder(b, 1) + b.ResetTimer() + for range b.N { + _, _ = builder.ComputeSignatureHashes() + } +} + +func BenchmarkComputeSignatureHashes_5Inputs(b *testing.B) { + builder := buildSigHashBuilder(b, 5) + b.ResetTimer() + for range b.N { + _, _ = builder.ComputeSignatureHashes() + } +} + +func BenchmarkComputeSignatureHashes_20Inputs(b *testing.B) { + builder := buildSigHashBuilder(b, 20) + b.ResetTimer() + for range b.N { + _, _ = builder.ComputeSignatureHashes() + } +} diff --git a/pkg/bls/bls_test.go b/pkg/bls/bls_test.go index 0d3a6da03a..330dbf8ce3 100644 --- a/pkg/bls/bls_test.go +++ b/pkg/bls/bls_test.go @@ -2,6 +2,7 @@ package bls import ( "crypto/rand" + "fmt" "math/big" "testing" @@ -185,3 +186,95 @@ func TestThresholdBLS(t *testing.T) { } } + +// --- Benchmarks --- + +func BenchmarkSign(b *testing.B) { + pi, _ := new(big.Int).SetString( + "31415926535897932384626433832795028841971693993751058209749445923078164062862", 10) + message := pi.Bytes() + secretKey := big.NewInt(123) + b.ResetTimer() + for range b.N { + Sign(secretKey, message) + } +} + +func BenchmarkVerify(b *testing.B) { + pi, _ := new(big.Int).SetString( + "31415926535897932384626433832795028841971693993751058209749445923078164062862", 10) + message := pi.Bytes() + secretKey := big.NewInt(123) + publicKey := new(bn256.G2).ScalarBaseMult(secretKey) + signature := Sign(secretKey, message) + b.ResetTimer() + for range b.N { + Verify(publicKey, message, signature) + } +} + +// BenchmarkAggregateBLS benchmarks aggregate signature verification for group +// sizes representative of small committees (10), medium (50), and production +// random beacon groups (100). +func BenchmarkAggregateBLS(b *testing.B) { + pi, _ := new(big.Int).SetString( + "31415926535897932384626433832795028841971693993751058209749445923078164062862", 10) + message := new(bn256.G1).ScalarBaseMult(pi) + + for _, n := range []int{10, 50, 100} { + n := n + var signatures []*bn256.G1 + var publicKeys []*bn256.G2 + for i := 0; i < n; i++ { + k, _, err := bn256.RandomG1(rand.Reader) + if err != nil { + b.Fatal(err) + } + pub := new(bn256.G2).ScalarBaseMult(k) + publicKeys = append(publicKeys, pub) + signatures = append(signatures, SignG1(k, message)) + } + b.Run(fmt.Sprintf("N=%d", n), func(b *testing.B) { + b.ResetTimer() + for range b.N { + aggSig := AggregateG1Points(signatures) + aggPub := AggregateG2Points(publicKeys) + VerifyG1(aggPub, message, aggSig) + } + }) + } +} + +// BenchmarkThresholdVerify benchmarks threshold signature recovery with a +// 51-of-100 configuration representative of production beacon groups. +func BenchmarkThresholdVerify(b *testing.B) { + pi, _ := new(big.Int).SetString( + "31415926535897932384626433832795028841971693993751058209749445923078164062862", 10) + message := new(bn256.G1).ScalarBaseMult(pi) + + const numPlayers = 100 + const threshold = 51 + + var masterSecretKey []*big.Int + var signatureShares []*SignatureShare + + for i := 0; i < threshold; i++ { + sk, _, err := bn256.RandomG2(rand.Reader) + if err != nil { + b.Fatal(err) + } + masterSecretKey = append(masterSecretKey, sk) + } + for i := 1; i <= numPlayers; i++ { + share := GetSecretKeyShare(masterSecretKey, i) + signatureShares = append(signatureShares, &SignatureShare{ + I: i, + V: SignG1(share.V, message), + }) + } + + b.ResetTimer() + for range b.N { + _, _ = RecoverSignature(signatureShares[:threshold], threshold) + } +} diff --git a/pkg/clientinfo/clientinfo.go b/pkg/clientinfo/clientinfo.go index 7848aa0ec7..5007891b9f 100644 --- a/pkg/clientinfo/clientinfo.go +++ b/pkg/clientinfo/clientinfo.go @@ -2,6 +2,7 @@ package clientinfo import ( "context" + _ "net/http/pprof" // #nosec G108 -- opt-in profiling; registered on DefaultServeMux intentionally "time" "github.com/ipfs/go-log" @@ -18,6 +19,10 @@ type Config struct { EthereumMetricsTick time.Duration BitcoinMetricsTick time.Duration RPCHealthCheckInterval time.Duration + // EnablePprof exposes Go runtime profiling endpoints at /debug/pprof/ on + // the clientinfo port. Requires Port != 0. Never expose to untrusted + // networks; bind behind a firewall or restrict with an SSH tunnel. + EnablePprof bool } // Registry wraps keep-common clientinfo registry and exposes additional @@ -32,15 +37,19 @@ type Registry struct { // diagnostics server. func Initialize( ctx context.Context, - port int, + cfg Config, ) (*Registry, bool) { - if port == 0 { + if cfg.Port == 0 { return nil, false } registry := &Registry{clientinfo.NewRegistry(), ctx} - registry.EnableServer(port) + if cfg.EnablePprof { + logger.Infof("pprof profiling endpoints enabled at /debug/pprof/") + } + + registry.EnableServer(cfg.Port) return registry, true } diff --git a/pkg/net/libp2p/channel_test.go b/pkg/net/libp2p/channel_test.go index 116c5da73d..916a337595 100644 --- a/pkg/net/libp2p/channel_test.go +++ b/pkg/net/libp2p/channel_test.go @@ -610,3 +610,62 @@ func (ms *mockSubscription) Next(ctx context.Context) (*pubsub.Message, error) { } func (ms *mockSubscription) Cancel() {} + +// --- Benchmarks --- + +// BenchmarkChannelDeliver_SingleHandler measures deliver() latency with a +// single registered handler. The handler's buffer fills after messageHandlerThrottle +// calls; subsequent iterations take the non-blocking default branch. Both paths +// exercise the same mutex lock and snapshot copy overhead. +func BenchmarkChannelDeliver_SingleHandler(b *testing.B) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + ch := &channel{} + ch.messageHandlers = []*messageHandler{ + {ctx: ctx, channel: make(chan net.Message, messageHandlerThrottle)}, + } + msg := &mockNetMessage{} + b.ResetTimer() + for range b.N { + ch.deliver(msg) + } +} + +// BenchmarkChannelDeliver_10Handlers measures deliver() fan-out cost across 10 +// concurrent handlers -- representative of a node with multiple active protocol +// subscriptions on the same channel. +func BenchmarkChannelDeliver_10Handlers(b *testing.B) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + ch := &channel{} + handlers := make([]*messageHandler, 10) + for i := range handlers { + handlers[i] = &messageHandler{ + ctx: ctx, + channel: make(chan net.Message, messageHandlerThrottle), + } + } + ch.messageHandlers = handlers + msg := &mockNetMessage{} + b.ResetTimer() + for range b.N { + ch.deliver(msg) + } +} + +// BenchmarkProcessPubsubMessage measures the raw throughput of +// processPubsubMessage with an empty message. proto.Unmarshal succeeds on empty +// input; the call returns early with "couldn't find unmarshaler", giving a +// baseline for the per-message overhead before any application logic runs. +func BenchmarkProcessPubsubMessage(b *testing.B) { + ch := &channel{ + unmarshalersByType: make(map[string]func() net.TaggedUnmarshaler), + } + msg := &pubsub.Message{Message: &pubsubpb.Message{}} + b.ResetTimer() + for range b.N { + _ = ch.processPubsubMessage(msg) + } +} diff --git a/pkg/net/retransmission/strategy_test.go b/pkg/net/retransmission/strategy_test.go index 6032de09b0..3bf9a0c897 100644 --- a/pkg/net/retransmission/strategy_test.go +++ b/pkg/net/retransmission/strategy_test.go @@ -106,3 +106,25 @@ func TestBackoffStrategy_TickSequence(t *testing.T) { ) } } + +// --- Benchmarks --- + +func BenchmarkBackoffStrategyTick(b *testing.B) { + strategy := WithBackoffStrategy() + noop := func() error { return nil } + b.ResetTimer() + for range b.N { + _ = strategy.Tick(noop) + } +} + +func BenchmarkStandardStrategyTick(b *testing.B) { + strategy := WithStandardStrategy() + var calls int + fn := func() error { calls++; return nil } + b.ResetTimer() + for range b.N { + _ = strategy.Tick(fn) + } + _ = calls +} diff --git a/pkg/tbtc/coordination_window_metrics_test.go b/pkg/tbtc/coordination_window_metrics_test.go index 274613f765..8d5ceb92fc 100644 --- a/pkg/tbtc/coordination_window_metrics_test.go +++ b/pkg/tbtc/coordination_window_metrics_test.go @@ -346,3 +346,91 @@ func TestCoordinationWindowMetrics_Concurrent(t *testing.T) { _ = cwm.GetSummary() _ = cwm.GetRecentWindows(5) } + +// TestCleanupOldWindows_BoundsMapSize inserts 2000 windows into a store capped +// at 100 and asserts the map never exceeds the cap. This guards against a +// regression where cleanupOldWindows stops enforcing the bound, causing +// unbounded memory growth on long-running nodes. +func TestCleanupOldWindows_BoundsMapSize(t *testing.T) { + const maxWindows = 100 + cwm := newTestWindowMetrics(maxWindows) + leader := chain.Address("0xleader") + + for i := uint64(1); i <= 2000; i++ { + window := newCoordinationWindow(i * 900) + cwm.recordWalletCoordination(window, [20]byte{byte(i % 256)}, leader, "Heartbeat", true, 0, nil, nil) + } + + summary := cwm.GetSummary() + if int(summary.TotalWindows) > maxWindows { + t.Errorf( + "cleanupOldWindows not enforcing bound: got %d windows, want <= %d", + summary.TotalWindows, maxWindows, + ) + } +} + +// --- Benchmarks --- + +func populateWindowMetrics(b *testing.B, cwm *coordinationWindowMetrics, n int) { + b.Helper() + leader := chain.Address("0xleader") + for i := uint64(1); i <= uint64(n); i++ { + window := newCoordinationWindow(i * 900) + cwm.recordWalletCoordination(window, [20]byte{}, leader, "Heartbeat", true, 0, nil, nil) + } +} + +func BenchmarkGetRecentWindows_100Windows(b *testing.B) { + cwm := newTestWindowMetrics(200) + populateWindowMetrics(b, cwm, 100) + b.ResetTimer() + for range b.N { + _ = cwm.GetRecentWindows(100) + } +} + +func BenchmarkGetRecentWindows_1000Windows(b *testing.B) { + cwm := newTestWindowMetrics(2000) + populateWindowMetrics(b, cwm, 1000) + b.ResetTimer() + for range b.N { + _ = cwm.GetRecentWindows(1000) + } +} + +func BenchmarkGetSummary_100Windows(b *testing.B) { + cwm := newTestWindowMetrics(200) + populateWindowMetrics(b, cwm, 100) + b.ResetTimer() + for range b.N { + _ = cwm.GetSummary() + } +} + +func BenchmarkGetSummary_1000Windows(b *testing.B) { + cwm := newTestWindowMetrics(2000) + populateWindowMetrics(b, cwm, 1000) + b.ResetTimer() + for range b.N { + _ = cwm.GetSummary() + } +} + +// BenchmarkCleanupOldWindows_1000Windows measures the O(n^2) bubble-sort +// cleanup pass when the store holds 1000 windows and needs to evict down to +// 900. This catches regressions in the cleanup algorithm before they affect +// long-running nodes. +func BenchmarkCleanupOldWindows_1000Windows(b *testing.B) { + const maxWindows uint64 = 900 + + for range b.N { + b.StopTimer() + cwm := newTestWindowMetrics(maxWindows) + for i := uint64(1); i <= 1000; i++ { + cwm.windows[i] = &windowMetrics{WindowIndex: i} + } + b.StartTimer() + cwm.cleanupOldWindows() + } +} diff --git a/pkg/tecdsa/dkg/marshaling.go b/pkg/tecdsa/dkg/marshaling.go index 4e2815d62e..1f74b77a64 100644 --- a/pkg/tecdsa/dkg/marshaling.go +++ b/pkg/tecdsa/dkg/marshaling.go @@ -9,7 +9,6 @@ import ( "google.golang.org/protobuf/proto" timestamppb "google.golang.org/protobuf/types/known/timestamppb" - "github.com/keep-network/keep-core/pkg/crypto/ephemeral" "github.com/keep-network/keep-core/pkg/protocol/group" "github.com/keep-network/keep-core/pkg/tecdsa/dkg/gen/pb" ) @@ -17,14 +16,9 @@ import ( // Marshal converts this ephemeralPublicKeyMessage to a byte array suitable for // network communication. func (epkm *ephemeralPublicKeyMessage) Marshal() ([]byte, error) { - ephemeralPublicKeys, err := marshalPublicKeyMap(epkm.ephemeralPublicKeys) - if err != nil { - return nil, err - } - return proto.Marshal(&pb.EphemeralPublicKeyMessage{ SenderID: uint32(epkm.senderID), - EphemeralPublicKeys: ephemeralPublicKeys, + EphemeralPublicKeys: marshalPublicKeyMap(epkm.ephemeralPublicKeys), SessionID: epkm.sessionID, }) } @@ -190,37 +184,28 @@ func validateMemberIndex(protoIndex uint32) error { } func marshalPublicKeyMap( - publicKeys map[group.MemberIndex]*ephemeral.PublicKey, -) (map[uint32][]byte, error) { + publicKeys map[group.MemberIndex][]byte, +) map[uint32][]byte { marshalled := make(map[uint32][]byte, len(publicKeys)) - for id, publicKey := range publicKeys { - if publicKey == nil { - return nil, fmt.Errorf("nil public key for member [%v]", id) - } - - marshalled[uint32(id)] = publicKey.Marshal() + for id, keyBytes := range publicKeys { + marshalled[uint32(id)] = keyBytes } - return marshalled, nil + return marshalled } +// unmarshalPublicKeyMap converts the wire-format map to an internal byte map, +// validating member indices but deferring EC point parsing to use-time so that +// only the one key per message actually needed for ECDH is ever parsed. func unmarshalPublicKeyMap( publicKeys map[uint32][]byte, -) (map[group.MemberIndex]*ephemeral.PublicKey, error) { - var unmarshalled = make(map[group.MemberIndex]*ephemeral.PublicKey, len(publicKeys)) +) (map[group.MemberIndex][]byte, error) { + unmarshalled := make(map[group.MemberIndex][]byte, len(publicKeys)) for memberID, publicKeyBytes := range publicKeys { if err := validateMemberIndex(memberID); err != nil { return nil, err } - - publicKey, err := ephemeral.UnmarshalPublicKey(publicKeyBytes) - if err != nil { - return nil, fmt.Errorf("could not unmarshal public key [%v]", err) - } - - unmarshalled[group.MemberIndex(memberID)] = publicKey - + unmarshalled[group.MemberIndex(memberID)] = publicKeyBytes } - return unmarshalled, nil } diff --git a/pkg/tecdsa/dkg/marshaling_test.go b/pkg/tecdsa/dkg/marshaling_test.go index 314f19376c..810280d6c0 100644 --- a/pkg/tecdsa/dkg/marshaling_test.go +++ b/pkg/tecdsa/dkg/marshaling_test.go @@ -23,9 +23,10 @@ func TestEphemeralPublicKeyMessage_MarshalingRoundtrip(t *testing.T) { t.Fatal(err) } - publicKeys := make(map[group.MemberIndex]*ephemeral.PublicKey) - publicKeys[group.MemberIndex(211)] = keyPair1.PublicKey - publicKeys[group.MemberIndex(19)] = keyPair2.PublicKey + publicKeys := map[group.MemberIndex][]byte{ + group.MemberIndex(211): keyPair1.PublicKey.Marshal(), + group.MemberIndex(19): keyPair2.PublicKey.Marshal(), + } msg := &ephemeralPublicKeyMessage{ senderID: group.MemberIndex(38), @@ -48,7 +49,7 @@ func TestFuzzEphemeralPublicKeyMessage_MarshalingRoundtrip(t *testing.T) { for i := 0; i < 10; i++ { var ( senderID group.MemberIndex - ephemeralPublicKeys map[group.MemberIndex]*ephemeral.PublicKey + ephemeralPublicKeys map[group.MemberIndex][]byte sessionID string ) @@ -351,3 +352,120 @@ func TestPreParamsMarshalling(t *testing.T) { t.Errorf("unmarshaled pre params data are invalid") } } + +// --- Benchmarks --- + +func BenchmarkMarshalEphemeralPublicKeyMessage(b *testing.B) { + kp1, err := ephemeral.GenerateKeyPair() + if err != nil { + b.Fatal(err) + } + kp2, err := ephemeral.GenerateKeyPair() + if err != nil { + b.Fatal(err) + } + msg := &ephemeralPublicKeyMessage{ + senderID: group.MemberIndex(38), + ephemeralPublicKeys: map[group.MemberIndex][]byte{ + group.MemberIndex(211): kp1.PublicKey.Marshal(), + group.MemberIndex(19): kp2.PublicKey.Marshal(), + }, + sessionID: "session-1", + } + b.ResetTimer() + for range b.N { + _, _ = msg.Marshal() + } +} + +func BenchmarkUnmarshalEphemeralPublicKeyMessage(b *testing.B) { + kp1, err := ephemeral.GenerateKeyPair() + if err != nil { + b.Fatal(err) + } + kp2, err := ephemeral.GenerateKeyPair() + if err != nil { + b.Fatal(err) + } + msg := &ephemeralPublicKeyMessage{ + senderID: group.MemberIndex(38), + ephemeralPublicKeys: map[group.MemberIndex][]byte{ + group.MemberIndex(211): kp1.PublicKey.Marshal(), + group.MemberIndex(19): kp2.PublicKey.Marshal(), + }, + sessionID: "session-1", + } + data, err := msg.Marshal() + if err != nil { + b.Fatal(err) + } + b.ResetTimer() + for range b.N { + _ = new(ephemeralPublicKeyMessage).Unmarshal(data) + } +} + +// buildEphemeralKeyMap generates n key pairs and returns the serialized public +// key map as it would appear in a real EphemeralPublicKeyMessage (one entry per peer). +func buildEphemeralKeyMap(b *testing.B, n int) map[group.MemberIndex][]byte { + b.Helper() + m := make(map[group.MemberIndex][]byte, n) + for i := 0; i < n; i++ { + kp, err := ephemeral.GenerateKeyPair() + if err != nil { + b.Fatal(err) + } + m[group.MemberIndex(i+1)] = kp.PublicKey.Marshal() + } + return m +} + +// BenchmarkMarshalEphemeralPublicKeyMessage_100Keys benchmarks marshaling with +// a realistic group size (100 members = 99 peer keys per message). +func BenchmarkMarshalEphemeralPublicKeyMessage_100Keys(b *testing.B) { + msg := &ephemeralPublicKeyMessage{ + senderID: group.MemberIndex(1), + ephemeralPublicKeys: buildEphemeralKeyMap(b, 99), + sessionID: "session-1", + } + b.ResetTimer() + for range b.N { + _, _ = msg.Marshal() + } +} + +// BenchmarkUnmarshalEphemeralPublicKeyMessage_100Keys benchmarks unmarshaling +// with a realistic group size. Each btcec.ParsePubKey call dominates; with 99 +// peers this represents the real per-participant DKG cost. +func BenchmarkUnmarshalEphemeralPublicKeyMessage_100Keys(b *testing.B) { + msg := &ephemeralPublicKeyMessage{ + senderID: group.MemberIndex(1), + ephemeralPublicKeys: buildEphemeralKeyMap(b, 99), + sessionID: "session-1", + } + data, err := msg.Marshal() + if err != nil { + b.Fatal(err) + } + b.ResetTimer() + for range b.N { + _ = new(ephemeralPublicKeyMessage).Unmarshal(data) + } +} + +func BenchmarkRoundTripDKGMessage(b *testing.B) { + msg := &tssRoundTwoMessage{ + senderID: group.MemberIndex(50), + broadcastPayload: []byte{1, 2, 3, 4, 5}, + peersPayload: map[group.MemberIndex][]byte{ + 1: {6, 7, 8, 9, 10}, + 2: {11, 12, 13, 14, 15}, + }, + sessionID: "session-1", + } + b.ResetTimer() + for range b.N { + data, _ := msg.Marshal() + _ = new(tssRoundTwoMessage).Unmarshal(data) + } +} diff --git a/pkg/tecdsa/dkg/message.go b/pkg/tecdsa/dkg/message.go index ca9364ac57..fd87992e03 100644 --- a/pkg/tecdsa/dkg/message.go +++ b/pkg/tecdsa/dkg/message.go @@ -1,7 +1,6 @@ package dkg import ( - "github.com/keep-network/keep-core/pkg/crypto/ephemeral" "github.com/keep-network/keep-core/pkg/protocol/group" ) @@ -26,7 +25,7 @@ type message interface { type ephemeralPublicKeyMessage struct { senderID group.MemberIndex - ephemeralPublicKeys map[group.MemberIndex]*ephemeral.PublicKey + ephemeralPublicKeys map[group.MemberIndex][]byte sessionID string } diff --git a/pkg/tecdsa/dkg/protocol.go b/pkg/tecdsa/dkg/protocol.go index de333b62e7..9deecdfcfa 100644 --- a/pkg/tecdsa/dkg/protocol.go +++ b/pkg/tecdsa/dkg/protocol.go @@ -17,7 +17,7 @@ func (ekpgm *ephemeralKeyPairGeneratingMember) generateEphemeralKeyPair() ( *ephemeralPublicKeyMessage, error, ) { - ephemeralKeys := make(map[group.MemberIndex]*ephemeral.PublicKey) + ephemeralKeys := make(map[group.MemberIndex][]byte) // Calculate ephemeral key pair for every other group member for _, member := range ekpgm.group.MemberIndexes() { @@ -34,8 +34,8 @@ func (ekpgm *ephemeralKeyPairGeneratingMember) generateEphemeralKeyPair() ( // save the generated ephemeral key to our state ekpgm.ephemeralKeyPairs[member] = ephemeralKeyPair - // store the public key to the map for the message - ephemeralKeys[member] = ephemeralKeyPair.PublicKey + // store the serialized public key to the map for the message + ephemeralKeys[member] = ephemeralKeyPair.PublicKey.Marshal() } return &ephemeralPublicKeyMessage{ @@ -78,9 +78,18 @@ func (skgm *symmetricKeyGeneratingMember) generateSymmetricKeys( thisMemberEphemeralPrivateKey := ephemeralKeyPair.PrivateKey // Get the ephemeral public key broadcasted by the other group member, - // which was intended for this group member. - otherMemberEphemeralPublicKey := - ephemeralPubKeyMessage.ephemeralPublicKeys[skgm.id] + // which was intended for this group member, and parse it. Only this + // one key per message is needed for ECDH; the rest are validated for + // presence in isValidEphemeralPublicKeyMessage but never parsed. + otherMemberEphemeralPublicKey, err := ephemeral.UnmarshalPublicKey( + ephemeralPubKeyMessage.ephemeralPublicKeys[skgm.id], + ) + if err != nil { + return fmt.Errorf( + "could not unmarshal ephemeral public key from member [%v]: [%v]", + otherMember, err, + ) + } // Create symmetric key for the current group member and the other // group member by ECDH'ing the public and private key. diff --git a/pkg/tecdsa/dkg/protocol_test.go b/pkg/tecdsa/dkg/protocol_test.go index ebef238f0d..90705fb2bd 100644 --- a/pkg/tecdsa/dkg/protocol_test.go +++ b/pkg/tecdsa/dkg/protocol_test.go @@ -166,16 +166,16 @@ func TestGenerateSymmetricKeys(t *testing.T) { // Assert all symmetric keys stored by this member are correct. for otherMemberID, actualKey := range member.symmetricKeys { - var otherMemberEphemeralPublicKey *ephemeral.PublicKey + var otherMemberEphemeralPublicKeyBytes []byte for _, message := range messages { if message.senderID == otherMemberID { - if ephemeralPublicKey, ok := message.ephemeralPublicKeys[member.id]; ok { - otherMemberEphemeralPublicKey = ephemeralPublicKey + if keyBytes, ok := message.ephemeralPublicKeys[member.id]; ok { + otherMemberEphemeralPublicKeyBytes = keyBytes } } } - if otherMemberEphemeralPublicKey == nil { + if otherMemberEphemeralPublicKeyBytes == nil { t.Errorf( "[member:%v] no ephemeral public key from member [%v]", member.id, @@ -183,6 +183,13 @@ func TestGenerateSymmetricKeys(t *testing.T) { ) } + otherMemberEphemeralPublicKey, err := ephemeral.UnmarshalPublicKey( + otherMemberEphemeralPublicKeyBytes, + ) + if err != nil { + t.Fatalf("could not unmarshal ephemeral public key: %v", err) + } + expectedKey := ephemeral.SymmetricKey( member.ephemeralKeyPairs[otherMemberID].PrivateKey.Ecdh( otherMemberEphemeralPublicKey, @@ -248,6 +255,58 @@ func TestGenerateSymmetricKeys_InvalidEphemeralPublicKeyMessage(t *testing.T) { } } +func TestGenerateSymmetricKeys_CorruptEphemeralPublicKeyBytes(t *testing.T) { + members, messages, err := initializeSymmetricKeyGeneratingMembersGroup( + dishonestThreshold, + groupSize, + ) + if err != nil { + t.Fatal(err) + } + + // Replace member 2's ephemeral public key for member 1 with garbage. + // The key is still present so isValidEphemeralPublicKeyMessage passes; + // only member 1 encounters the parse error during ECDH. + misbehavingMemberID := group.MemberIndex(2) + victimMemberID := group.MemberIndex(1) + messages[misbehavingMemberID-1].ephemeralPublicKeys[victimMemberID] = []byte{0x00, 0x01, 0x02} + + for _, member := range members { + var receivedMessages []*ephemeralPublicKeyMessage + for _, message := range messages { + if message.senderID != member.id { + receivedMessages = append(receivedMessages, message) + } + } + + err := member.generateSymmetricKeys(receivedMessages) + + if member.id == victimMemberID { + expectedErrPrefix := fmt.Sprintf( + "could not unmarshal ephemeral public key from member [%v]:", + misbehavingMemberID, + ) + if err == nil { + t.Errorf( + "[member:%v] expected error, got nil", + member.id, + ) + } else if !strings.HasPrefix(err.Error(), expectedErrPrefix) { + t.Errorf( + "[member:%v] unexpected error\nexpected prefix: %v\nactual: %v", + member.id, + expectedErrPrefix, + err.Error(), + ) + } + } else { + if err != nil { + t.Errorf("[member:%v] unexpected error: %v", member.id, err) + } + } + } +} + func TestTssRoundOne(t *testing.T) { members, err := initializeTssRoundOneMembersGroup( dishonestThreshold, diff --git a/pkg/tecdsa/signing/marshaling.go b/pkg/tecdsa/signing/marshaling.go index 98040ca91c..9257b8cdd6 100644 --- a/pkg/tecdsa/signing/marshaling.go +++ b/pkg/tecdsa/signing/marshaling.go @@ -5,7 +5,6 @@ import ( "google.golang.org/protobuf/proto" - "github.com/keep-network/keep-core/pkg/crypto/ephemeral" "github.com/keep-network/keep-core/pkg/protocol/group" "github.com/keep-network/keep-core/pkg/tecdsa/signing/gen/pb" ) @@ -13,14 +12,9 @@ import ( // Marshal converts this ephemeralPublicKeyMessage to a byte array suitable for // network communication. func (epkm *ephemeralPublicKeyMessage) Marshal() ([]byte, error) { - ephemeralPublicKeys, err := marshalPublicKeyMap(epkm.ephemeralPublicKeys) - if err != nil { - return nil, err - } - return proto.Marshal(&pb.EphemeralPublicKeyMessage{ SenderID: uint32(epkm.senderID), - EphemeralPublicKeys: ephemeralPublicKeys, + EphemeralPublicKeys: marshalPublicKeyMap(epkm.ephemeralPublicKeys), SessionID: epkm.sessionID, }) } @@ -341,36 +335,27 @@ func validateMemberIndex(protoIndex uint32) error { } func marshalPublicKeyMap( - publicKeys map[group.MemberIndex]*ephemeral.PublicKey, -) (map[uint32][]byte, error) { + publicKeys map[group.MemberIndex][]byte, +) map[uint32][]byte { marshalled := make(map[uint32][]byte, len(publicKeys)) - for id, publicKey := range publicKeys { - if publicKey == nil { - return nil, fmt.Errorf("nil public key for member [%v]", id) - } - - marshalled[uint32(id)] = publicKey.Marshal() + for id, keyBytes := range publicKeys { + marshalled[uint32(id)] = keyBytes } - return marshalled, nil + return marshalled } +// unmarshalPublicKeyMap converts the wire-format map to an internal byte map, +// validating member indices but deferring EC point parsing to use-time so that +// only the one key per message actually needed for ECDH is ever parsed. func unmarshalPublicKeyMap( publicKeys map[uint32][]byte, -) (map[group.MemberIndex]*ephemeral.PublicKey, error) { - var unmarshalled = make(map[group.MemberIndex]*ephemeral.PublicKey, len(publicKeys)) +) (map[group.MemberIndex][]byte, error) { + unmarshalled := make(map[group.MemberIndex][]byte, len(publicKeys)) for memberID, publicKeyBytes := range publicKeys { if err := validateMemberIndex(memberID); err != nil { return nil, err } - - publicKey, err := ephemeral.UnmarshalPublicKey(publicKeyBytes) - if err != nil { - return nil, fmt.Errorf("could not unmarshal public key [%v]", err) - } - - unmarshalled[group.MemberIndex(memberID)] = publicKey - + unmarshalled[group.MemberIndex(memberID)] = publicKeyBytes } - return unmarshalled, nil } diff --git a/pkg/tecdsa/signing/marshaling_test.go b/pkg/tecdsa/signing/marshaling_test.go index 19dd00f858..40948350c3 100644 --- a/pkg/tecdsa/signing/marshaling_test.go +++ b/pkg/tecdsa/signing/marshaling_test.go @@ -20,9 +20,10 @@ func TestEphemeralPublicKeyMessage_MarshalingRoundtrip(t *testing.T) { t.Fatal(err) } - publicKeys := make(map[group.MemberIndex]*ephemeral.PublicKey) - publicKeys[group.MemberIndex(211)] = keyPair1.PublicKey - publicKeys[group.MemberIndex(19)] = keyPair2.PublicKey + publicKeys := map[group.MemberIndex][]byte{ + group.MemberIndex(211): keyPair1.PublicKey.Marshal(), + group.MemberIndex(19): keyPair2.PublicKey.Marshal(), + } msg := &ephemeralPublicKeyMessage{ senderID: group.MemberIndex(38), @@ -45,7 +46,7 @@ func TestFuzzEphemeralPublicKeyMessage_MarshalingRoundtrip(t *testing.T) { for i := 0; i < 10; i++ { var ( senderID group.MemberIndex - ephemeralPublicKeys map[group.MemberIndex]*ephemeral.PublicKey + ephemeralPublicKeys map[group.MemberIndex][]byte sessionID string ) @@ -512,3 +513,166 @@ func TestFuzzTssRoundNineMessage_MarshalingRoundtrip(t *testing.T) { func TestFuzzTssRoundNineMessage_Unmarshaler(t *testing.T) { pbutils.FuzzUnmarshaler(&tssRoundNineMessage{}) } + +// --- Benchmarks --- + +func BenchmarkMarshalEphemeralPublicKeyMessage(b *testing.B) { + kp1, err := ephemeral.GenerateKeyPair() + if err != nil { + b.Fatal(err) + } + kp2, err := ephemeral.GenerateKeyPair() + if err != nil { + b.Fatal(err) + } + msg := &ephemeralPublicKeyMessage{ + senderID: group.MemberIndex(38), + ephemeralPublicKeys: map[group.MemberIndex][]byte{ + group.MemberIndex(211): kp1.PublicKey.Marshal(), + group.MemberIndex(19): kp2.PublicKey.Marshal(), + }, + sessionID: "session-1", + } + b.ResetTimer() + for range b.N { + _, _ = msg.Marshal() + } +} + +func BenchmarkUnmarshalEphemeralPublicKeyMessage(b *testing.B) { + kp1, err := ephemeral.GenerateKeyPair() + if err != nil { + b.Fatal(err) + } + kp2, err := ephemeral.GenerateKeyPair() + if err != nil { + b.Fatal(err) + } + msg := &ephemeralPublicKeyMessage{ + senderID: group.MemberIndex(38), + ephemeralPublicKeys: map[group.MemberIndex][]byte{ + group.MemberIndex(211): kp1.PublicKey.Marshal(), + group.MemberIndex(19): kp2.PublicKey.Marshal(), + }, + sessionID: "session-1", + } + data, err := msg.Marshal() + if err != nil { + b.Fatal(err) + } + b.ResetTimer() + for range b.N { + _ = new(ephemeralPublicKeyMessage).Unmarshal(data) + } +} + +// buildEphemeralKeyMap generates n key pairs and returns the serialized public +// key map as it would appear in a real EphemeralPublicKeyMessage (one entry per peer). +func buildEphemeralKeyMap(b *testing.B, n int) map[group.MemberIndex][]byte { + b.Helper() + m := make(map[group.MemberIndex][]byte, n) + for i := 0; i < n; i++ { + kp, err := ephemeral.GenerateKeyPair() + if err != nil { + b.Fatal(err) + } + m[group.MemberIndex(i+1)] = kp.PublicKey.Marshal() + } + return m +} + +// BenchmarkMarshalEphemeralPublicKeyMessage_100Keys benchmarks marshaling with +// a realistic group size (100 members = 99 peer keys per message). +func BenchmarkMarshalEphemeralPublicKeyMessage_100Keys(b *testing.B) { + msg := &ephemeralPublicKeyMessage{ + senderID: group.MemberIndex(1), + ephemeralPublicKeys: buildEphemeralKeyMap(b, 99), + sessionID: "session-1", + } + b.ResetTimer() + for range b.N { + _, _ = msg.Marshal() + } +} + +// BenchmarkUnmarshalEphemeralPublicKeyMessage_100Keys benchmarks unmarshaling +// with a realistic group size. Each btcec.ParsePubKey call dominates; with 99 +// peers this represents the real per-participant signing-round cost. +func BenchmarkUnmarshalEphemeralPublicKeyMessage_100Keys(b *testing.B) { + msg := &ephemeralPublicKeyMessage{ + senderID: group.MemberIndex(1), + ephemeralPublicKeys: buildEphemeralKeyMap(b, 99), + sessionID: "session-1", + } + data, err := msg.Marshal() + if err != nil { + b.Fatal(err) + } + b.ResetTimer() + for range b.N { + _ = new(ephemeralPublicKeyMessage).Unmarshal(data) + } +} + +// BenchmarkMarshalSigningShareMessage benchmarks the heaviest per-member +// message in a signing round: round-one carries both broadcast and peer +// payloads. +func BenchmarkMarshalSigningShareMessage(b *testing.B) { + msg := &tssRoundOneMessage{ + senderID: group.MemberIndex(50), + broadcastPayload: []byte{1, 2, 3, 4, 5}, + peersPayload: map[group.MemberIndex][]byte{ + 1: {6, 7, 8, 9, 10}, + 2: {11, 12, 13, 14, 15}, + }, + sessionID: "session-1", + } + b.ResetTimer() + for range b.N { + _, _ = msg.Marshal() + } +} + +func BenchmarkUnmarshalSigningShareMessage(b *testing.B) { + msg := &tssRoundOneMessage{ + senderID: group.MemberIndex(50), + broadcastPayload: []byte{1, 2, 3, 4, 5}, + peersPayload: map[group.MemberIndex][]byte{ + 1: {6, 7, 8, 9, 10}, + 2: {11, 12, 13, 14, 15}, + }, + sessionID: "session-1", + } + data, err := msg.Marshal() + if err != nil { + b.Fatal(err) + } + b.ResetTimer() + for range b.N { + _ = new(tssRoundOneMessage).Unmarshal(data) + } +} + +func BenchmarkRoundTripEphemeralKey(b *testing.B) { + kp1, err := ephemeral.GenerateKeyPair() + if err != nil { + b.Fatal(err) + } + kp2, err := ephemeral.GenerateKeyPair() + if err != nil { + b.Fatal(err) + } + msg := &ephemeralPublicKeyMessage{ + senderID: group.MemberIndex(38), + ephemeralPublicKeys: map[group.MemberIndex][]byte{ + group.MemberIndex(211): kp1.PublicKey.Marshal(), + group.MemberIndex(19): kp2.PublicKey.Marshal(), + }, + sessionID: "session-1", + } + b.ResetTimer() + for range b.N { + data, _ := msg.Marshal() + _ = new(ephemeralPublicKeyMessage).Unmarshal(data) + } +} diff --git a/pkg/tecdsa/signing/message.go b/pkg/tecdsa/signing/message.go index df2980e4bf..7b7c6d6d38 100644 --- a/pkg/tecdsa/signing/message.go +++ b/pkg/tecdsa/signing/message.go @@ -1,7 +1,6 @@ package signing import ( - "github.com/keep-network/keep-core/pkg/crypto/ephemeral" "github.com/keep-network/keep-core/pkg/protocol/group" ) @@ -26,7 +25,7 @@ type message interface { type ephemeralPublicKeyMessage struct { senderID group.MemberIndex - ephemeralPublicKeys map[group.MemberIndex]*ephemeral.PublicKey + ephemeralPublicKeys map[group.MemberIndex][]byte sessionID string } diff --git a/pkg/tecdsa/signing/protocol.go b/pkg/tecdsa/signing/protocol.go index 9814a0c1a9..ebe88cdf73 100644 --- a/pkg/tecdsa/signing/protocol.go +++ b/pkg/tecdsa/signing/protocol.go @@ -17,7 +17,7 @@ func (ekpgm *ephemeralKeyPairGeneratingMember) generateEphemeralKeyPair() ( *ephemeralPublicKeyMessage, error, ) { - ephemeralKeys := make(map[group.MemberIndex]*ephemeral.PublicKey) + ephemeralKeys := make(map[group.MemberIndex][]byte) // Calculate ephemeral key pair for every other group member for _, member := range ekpgm.group.MemberIndexes() { @@ -34,8 +34,8 @@ func (ekpgm *ephemeralKeyPairGeneratingMember) generateEphemeralKeyPair() ( // save the generated ephemeral key to our state ekpgm.ephemeralKeyPairs[member] = ephemeralKeyPair - // store the public key to the map for the message - ephemeralKeys[member] = ephemeralKeyPair.PublicKey + // store the serialized public key to the map for the message + ephemeralKeys[member] = ephemeralKeyPair.PublicKey.Marshal() } return &ephemeralPublicKeyMessage{ @@ -78,9 +78,18 @@ func (skgm *symmetricKeyGeneratingMember) generateSymmetricKeys( thisMemberEphemeralPrivateKey := ephemeralKeyPair.PrivateKey // Get the ephemeral public key broadcasted by the other group member, - // which was intended for this group member. - otherMemberEphemeralPublicKey := - ephemeralPubKeyMessage.ephemeralPublicKeys[skgm.id] + // which was intended for this group member, and parse it. Only this + // one key per message is needed for ECDH; the rest are validated for + // presence in isValidEphemeralPublicKeyMessage but never parsed. + otherMemberEphemeralPublicKey, err := ephemeral.UnmarshalPublicKey( + ephemeralPubKeyMessage.ephemeralPublicKeys[skgm.id], + ) + if err != nil { + return fmt.Errorf( + "could not unmarshal ephemeral public key from member [%v]: [%v]", + otherMember, err, + ) + } // Create symmetric key for the current group member and the other // group member by ECDH'ing the public and private key. diff --git a/pkg/tecdsa/signing/protocol_test.go b/pkg/tecdsa/signing/protocol_test.go index d5bc520379..c3d693030d 100644 --- a/pkg/tecdsa/signing/protocol_test.go +++ b/pkg/tecdsa/signing/protocol_test.go @@ -179,16 +179,16 @@ func TestGenerateSymmetricKeys(t *testing.T) { // Assert all symmetric keys stored by this member are correct. for otherMemberID, actualKey := range member.symmetricKeys { - var otherMemberEphemeralPublicKey *ephemeral.PublicKey + var otherMemberEphemeralPublicKeyBytes []byte for _, message := range messages { if message.senderID == otherMemberID { - if ephemeralPublicKey, ok := message.ephemeralPublicKeys[member.id]; ok { - otherMemberEphemeralPublicKey = ephemeralPublicKey + if keyBytes, ok := message.ephemeralPublicKeys[member.id]; ok { + otherMemberEphemeralPublicKeyBytes = keyBytes } } } - if otherMemberEphemeralPublicKey == nil { + if otherMemberEphemeralPublicKeyBytes == nil { t.Errorf( "[member:%v] no ephemeral public key from member [%v]", member.id, @@ -196,6 +196,13 @@ func TestGenerateSymmetricKeys(t *testing.T) { ) } + otherMemberEphemeralPublicKey, err := ephemeral.UnmarshalPublicKey( + otherMemberEphemeralPublicKeyBytes, + ) + if err != nil { + t.Fatalf("could not unmarshal ephemeral public key: %v", err) + } + expectedKey := ephemeral.SymmetricKey( member.ephemeralKeyPairs[otherMemberID].PrivateKey.Ecdh( otherMemberEphemeralPublicKey, @@ -261,6 +268,58 @@ func TestGenerateSymmetricKeys_InvalidEphemeralPublicKeyMessage(t *testing.T) { } } +func TestGenerateSymmetricKeys_CorruptEphemeralPublicKeyBytes(t *testing.T) { + members, messages, err := initializeSymmetricKeyGeneratingMembersGroup( + dishonestThreshold, + groupSize, + ) + if err != nil { + t.Fatal(err) + } + + // Replace member 2's ephemeral public key for member 1 with garbage. + // The key is still present so isValidEphemeralPublicKeyMessage passes; + // only member 1 encounters the parse error during ECDH. + misbehavingMemberID := group.MemberIndex(2) + victimMemberID := group.MemberIndex(1) + messages[misbehavingMemberID-1].ephemeralPublicKeys[victimMemberID] = []byte{0x00, 0x01, 0x02} + + for _, member := range members { + var receivedMessages []*ephemeralPublicKeyMessage + for _, message := range messages { + if message.senderID != member.id { + receivedMessages = append(receivedMessages, message) + } + } + + err := member.generateSymmetricKeys(receivedMessages) + + if member.id == victimMemberID { + expectedErrPrefix := fmt.Sprintf( + "could not unmarshal ephemeral public key from member [%v]:", + misbehavingMemberID, + ) + if err == nil { + t.Errorf( + "[member:%v] expected error, got nil", + member.id, + ) + } else if !strings.HasPrefix(err.Error(), expectedErrPrefix) { + t.Errorf( + "[member:%v] unexpected error\nexpected prefix: %v\nactual: %v", + member.id, + expectedErrPrefix, + err.Error(), + ) + } + } else { + if err != nil { + t.Errorf("[member:%v] unexpected error: %v", member.id, err) + } + } + } +} + func TestTssRoundOne(t *testing.T) { members, err := initializeTssRoundOneMembersGroup( dishonestThreshold,