From e9942371472eba03ac4a88be589b6569491af3b5 Mon Sep 17 00:00:00 2001 From: aman035 Date: Tue, 18 Aug 2026 19:19:25 +0530 Subject: [PATCH 1/6] fix: bind verified signing hash to the DKLS setup message before ACK (F-2026-18199) --- universalClient/tss/dkls/sign.go | 11 ++++ universalClient/tss/dkls/sign_test.go | 54 +++++++++++++++++++ .../tss/sessionmanager/sessionmanager.go | 34 ++++++++++++ .../tss/sessionmanager/sessionmanager_test.go | 43 +++++++++++++++ 4 files changed, 142 insertions(+) diff --git a/universalClient/tss/dkls/sign.go b/universalClient/tss/dkls/sign.go index 4a656f4c9..50e0e56f6 100644 --- a/universalClient/tss/dkls/sign.go +++ b/universalClient/tss/dkls/sign.go @@ -226,3 +226,14 @@ func (s *signSession) Close() { s.handle = 0 } } + +// SetupMessageHash returns the message hash embedded in a sign setup blob. +// The setup is what DklsSignSessionFromSetup actually signs over, so callers +// must confirm it matches the hash they independently verified. Otherwise a +// coordinator can present one hash for verification and embed another here. +func SetupMessageHash(setupData []byte) ([]byte, error) { + if len(setupData) == 0 { + return nil, fmt.Errorf("setupData is required") + } + return session.DklsDecodeMessage(setupData) +} diff --git a/universalClient/tss/dkls/sign_test.go b/universalClient/tss/dkls/sign_test.go index bd377ddf8..3af2673e3 100644 --- a/universalClient/tss/dkls/sign_test.go +++ b/universalClient/tss/dkls/sign_test.go @@ -1,6 +1,7 @@ package dkls import ( + "bytes" "strings" "testing" @@ -130,3 +131,56 @@ func TestSignSession_EndToEnd(t *testing.T) { t.Errorf("expected 2 participants, got %d", len(result.Participants)) } } + +// DKLS signs the hash embedded in the setup blob, not the hash a follower +// verified separately. SetupMessageHash exposes the embedded one so callers can +// bind the two, which is what stops a coordinator presenting one hash for +// verification and embedding another in the setup it distributes. +func TestSetupMessageHash(t *testing.T) { + participantIDs := encodeParticipantIDs([]string{"party1", "party2"}) + keyID := make([]byte, 32) + + legitHash := make([]byte, 32) + copy(legitHash, "legitimate-outbound-hash-32bytes") + attackerHash := make([]byte, 32) + copy(attackerHash, "attacker-chosen-vault-call-digest") + + t.Run("returns the hash embedded in the setup", func(t *testing.T) { + setup, err := session.DklsSignSetupMsgNew(keyID, nil, legitHash, participantIDs) + if err != nil { + t.Fatalf("failed to build sign setup: %v", err) + } + got, err := SetupMessageHash(setup) + if err != nil { + t.Fatalf("SetupMessageHash() error = %v", err) + } + if !bytes.Equal(got, legitHash) { + t.Errorf("SetupMessageHash() = %x, want %x", got, legitHash) + } + }) + + // The attack: a setup built over attackerHash must not report legitHash, so + // a caller comparing against its verified hash detects the substitution. + t.Run("substituted setup reports the attacker hash", func(t *testing.T) { + setup, err := session.DklsSignSetupMsgNew(keyID, nil, attackerHash, participantIDs) + if err != nil { + t.Fatalf("failed to build sign setup: %v", err) + } + got, err := SetupMessageHash(setup) + if err != nil { + t.Fatalf("SetupMessageHash() error = %v", err) + } + if bytes.Equal(got, legitHash) { + t.Fatal("substituted setup must not report the legitimate hash") + } + if !bytes.Equal(got, attackerHash) { + t.Errorf("SetupMessageHash() = %x, want %x", got, attackerHash) + } + }) + + t.Run("rejects empty setup", func(t *testing.T) { + if _, err := SetupMessageHash(nil); err == nil { + t.Error("SetupMessageHash(nil) should error") + } + }) +} diff --git a/universalClient/tss/sessionmanager/sessionmanager.go b/universalClient/tss/sessionmanager/sessionmanager.go index c48162260..ebc81f3e1 100644 --- a/universalClient/tss/sessionmanager/sessionmanager.go +++ b/universalClient/tss/sessionmanager/sessionmanager.go @@ -196,6 +196,22 @@ func (sm *SessionManager) handleSetupMessage(ctx context.Context, senderPeerID s } } + // 6c. Bind the verified hash to the setup blob we are about to sign with. + // DKLS signs the hash embedded in the setup, not the one we checked above, + // and the two arrive unbound. Without this a coordinator can present a + // legitimate hash for verification and embed an attacker-chosen one in + // Payload, harvesting honest shares over it. Checked before the ACK so no + // shares are ever produced for a mismatched hash. + if event.Type == store.EventTypeSignOutbound || event.Type == store.EventTypeSignFundMigrate { + if err := verifySetupBindsHash(msg.Payload, msg.UnsignedSigningReq.SigningHash); err != nil { + sm.logger.Error().Err(err). + Str("event_id", msg.EventID). + Str("coordinator", senderPeerID). + Msg("setup message does not sign the verified hash - rejecting") + return err + } + } + // 7. Create session based on protocol type session, err := sm.createSession(ctx, event, msg) if err != nil { @@ -976,6 +992,24 @@ func (sm *SessionManager) verifyOutboundSigningRequest(ctx context.Context, even return nil } +// verifySetupBindsHash requires the DKLS setup blob to embed exactly the hash +// the caller already verified. The setup is what actually gets signed, so +// without this check the verified hash is decorative. +func verifySetupBindsHash(setupData, verifiedHash []byte) error { + if len(verifiedHash) == 0 { + return fmt.Errorf("no verified signing hash to bind setup message to") + } + embedded, err := dkls.SetupMessageHash(setupData) + if err != nil { + return fmt.Errorf("cannot decode setup message to check signing hash: %w", err) + } + if !bytes.Equal(embedded, verifiedHash) { + return fmt.Errorf("setup message signs hash %s but verified hash is %s", + hex.EncodeToString(embedded), hex.EncodeToString(verifiedHash)) + } + return nil +} + // maxNonceGap bounds how far above the ceiling base a coordinator may assign. // An honest coordinator starts at the pending nonce and increments at most // coordinator.PerChainCap times per poll, so pending+PerChainCap is the true diff --git a/universalClient/tss/sessionmanager/sessionmanager_test.go b/universalClient/tss/sessionmanager/sessionmanager_test.go index 0a76ab6d8..4206aa941 100644 --- a/universalClient/tss/sessionmanager/sessionmanager_test.go +++ b/universalClient/tss/sessionmanager/sessionmanager_test.go @@ -13,6 +13,8 @@ import ( "time" "unsafe" + session "go-wrapper/go-dkls/sessions" + "github.com/rs/zerolog" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -1453,3 +1455,44 @@ func TestNonceBounds(t *testing.T) { require.Error(t, err) }) } + +// A follower verifies UnsignedSigningReq.SigningHash, but DKLS signs the hash +// embedded in Message.Payload, and the two arrive unbound. Without this check a +// coordinator can present a legitimate hash for verification and embed an +// attacker-chosen one in the setup, harvesting honest shares over it. +func TestVerifySetupBindsHash(t *testing.T) { + participantIDs := []byte("party1\x00party2") + keyID := make([]byte, 32) + + legitHash := make([]byte, 32) + copy(legitHash, "legitimate-outbound-hash-32bytes") + attackerHash := make([]byte, 32) + copy(attackerHash, "attacker-chosen-vault-call-digest") + + legitSetup, err := session.DklsSignSetupMsgNew(keyID, nil, legitHash, participantIDs) + require.NoError(t, err) + attackerSetup, err := session.DklsSignSetupMsgNew(keyID, nil, attackerHash, participantIDs) + require.NoError(t, err) + + t.Run("accepts setup that signs the verified hash", func(t *testing.T) { + require.NoError(t, verifySetupBindsHash(legitSetup, legitHash)) + }) + + // The reported attack. + t.Run("rejects setup embedding a different hash", func(t *testing.T) { + err := verifySetupBindsHash(attackerSetup, legitHash) + require.Error(t, err) + assert.Contains(t, err.Error(), "setup message signs hash") + }) + + t.Run("rejects undecodable setup", func(t *testing.T) { + require.Error(t, verifySetupBindsHash([]byte("not-a-dkls-setup"), legitHash)) + require.Error(t, verifySetupBindsHash(nil, legitHash)) + }) + + t.Run("rejects missing verified hash", func(t *testing.T) { + err := verifySetupBindsHash(legitSetup, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "no verified signing hash") + }) +} From cc7d305f3e2814baeb8677cb8d86a5531dede0e4 Mon Sep 17 00:00:00 2001 From: aman035 Date: Wed, 19 Aug 2026 14:15:24 +0530 Subject: [PATCH 2/6] fix: bind setup participants for keygen, keyrefresh and quorumchange too (F-2026-18199) --- universalClient/tss/dkls/utils.go | 28 +++++++++ .../tss/sessionmanager/sessionmanager.go | 34 ++++++++++ .../tss/sessionmanager/sessionmanager_test.go | 62 ++++++++++++++++--- 3 files changed, 117 insertions(+), 7 deletions(-) diff --git a/universalClient/tss/dkls/utils.go b/universalClient/tss/dkls/utils.go index c77c62415..01c07028b 100644 --- a/universalClient/tss/dkls/utils.go +++ b/universalClient/tss/dkls/utils.go @@ -2,6 +2,9 @@ package dkls import ( "crypto/sha256" + "fmt" + + session "go-wrapper/go-dkls/sessions" ) // deriveKeyID derives a key ID bytes from a string key ID. @@ -22,3 +25,28 @@ func encodeParticipantIDs(participants []string) []byte { } return ids } + +// SetupParticipants returns the participant list embedded in a DKLS setup blob, +// in index order. The setup is what actually drives the session, so callers must +// confirm it matches the participants they validated. Otherwise a coordinator +// can present one list for validation and run the session over another. +// +// Party names decode by index and come back empty past the end, which is how the +// list terminates. +func SetupParticipants(setupData []byte) ([]string, error) { + if len(setupData) == 0 { + return nil, fmt.Errorf("setupData is required") + } + var participants []string + for i := 0; ; i++ { + name, err := session.DklsDecodePartyName(setupData, i) + if err != nil { + return nil, fmt.Errorf("failed to decode party name at index %d: %w", i, err) + } + if len(name) == 0 { + break + } + participants = append(participants, string(name)) + } + return participants, nil +} diff --git a/universalClient/tss/sessionmanager/sessionmanager.go b/universalClient/tss/sessionmanager/sessionmanager.go index ebc81f3e1..2b8ed26d4 100644 --- a/universalClient/tss/sessionmanager/sessionmanager.go +++ b/universalClient/tss/sessionmanager/sessionmanager.go @@ -8,6 +8,7 @@ import ( "encoding/json" "fmt" "math/big" + "slices" "sync" "time" @@ -212,6 +213,17 @@ func (sm *SessionManager) handleSetupMessage(ctx context.Context, senderPeerID s } } + // 6d. Same split for the participant list: we validate msg.Participants above + // but the session runs on the list embedded in Payload, so an unbound setup + // could run over a different set than the one we approved. + if err := verifySetupBindsParticipants(msg.Payload, msg.Participants); err != nil { + sm.logger.Error().Err(err). + Str("event_id", msg.EventID). + Str("coordinator", senderPeerID). + Msg("setup message participants do not match the validated list - rejecting") + return err + } + // 7. Create session based on protocol type session, err := sm.createSession(ctx, event, msg) if err != nil { @@ -1010,6 +1022,28 @@ func verifySetupBindsHash(setupData, verifiedHash []byte) error { return nil } +// verifySetupBindsParticipants requires the DKLS setup blob to embed exactly the +// participant list the caller already validated, in the same order. Index order +// is part of the protocol, so a reorder is as consequential as a substitution. +// +// Note this cannot cover the threshold: the setup embeds one, the wrapper exposes +// no decoder for it, and the threshold argument the session constructors take is +// unused. So the coordinator's embedded threshold is authoritative and unchecked. +func verifySetupBindsParticipants(setupData []byte, validated []string) error { + if len(validated) == 0 { + return fmt.Errorf("no validated participants to bind setup message to") + } + embedded, err := dkls.SetupParticipants(setupData) + if err != nil { + return fmt.Errorf("cannot decode setup message participants: %w", err) + } + if !slices.Equal(embedded, validated) { + return fmt.Errorf("setup message participants %v do not match validated participants %v", + embedded, validated) + } + return nil +} + // maxNonceGap bounds how far above the ceiling base a coordinator may assign. // An honest coordinator starts at the pending nonce and increments at most // coordinator.PerChainCap times per poll, so pending+PerChainCap is the true diff --git a/universalClient/tss/sessionmanager/sessionmanager_test.go b/universalClient/tss/sessionmanager/sessionmanager_test.go index 4206aa941..c3af0b004 100644 --- a/universalClient/tss/sessionmanager/sessionmanager_test.go +++ b/universalClient/tss/sessionmanager/sessionmanager_test.go @@ -9,6 +9,7 @@ import ( "fmt" "math/big" "reflect" + "strings" "testing" "time" "unsafe" @@ -469,18 +470,17 @@ func TestSessionManager_Integration(t *testing.T) { Type: "setup", EventID: event.EventID, Participants: []string{"validator1", "validator2", "validator3"}, - Payload: []byte("invalid setup data"), // Will fail when creating session + Payload: []byte("invalid setup data"), // rejected before a session is created } - // This will fail at session creation or GetLatestBlockNum, but validation should pass + // Rejected at the setup-binding check (the payload is not a decodable DKLS + // setup), or earlier at GetLatestBlockNum. Either way validation must not + // let an unbound payload reach session creation. err := sm.HandleIncomingMessage(ctx, "peer1", &msg) - // We expect an error because we can't create a real DKLS session with invalid data - // or because GetLatestBlockNum fails assert.Error(t, err) - // Error should be about session creation, DKLS library, or no endpoints assert.True(t, - containsAny(err.Error(), []string{"failed to create session", "DKLS", "dkls", "session", "no endpoints"}), - "error should be about session creation or endpoints, got: %s", err.Error()) + containsAny(err.Error(), []string{"failed to create session", "DKLS", "dkls", "session", "setup message", "no endpoints"}), + "error should be about setup binding, session creation or endpoints, got: %s", err.Error()) } func TestVerifySigningRequest_OutboundDisabled(t *testing.T) { @@ -1496,3 +1496,51 @@ func TestVerifySetupBindsHash(t *testing.T) { assert.Contains(t, err.Error(), "no verified signing hash") }) } + +// Keygen, keyrefresh and quorumchange have the same split as the sign path: we +// validate msg.Participants, but the session runs on the list embedded in +// Payload. The threshold cannot be bound this way, see verifySetupBindsParticipants. +func TestVerifySetupBindsParticipants(t *testing.T) { + validated := []string{"validator1", "validator2", "validator3"} + encode := func(ids []string) []byte { + return []byte(strings.Join(ids, "\x00")) + } + + legitSetup, err := session.DklsKeygenSetupMsgNew(2, nil, encode(validated)) + require.NoError(t, err) + + t.Run("accepts setup with the validated participants", func(t *testing.T) { + require.NoError(t, verifySetupBindsParticipants(legitSetup, validated)) + }) + + t.Run("rejects setup with a substituted participant", func(t *testing.T) { + swapped, err := session.DklsKeygenSetupMsgNew(2, nil, + encode([]string{"validator1", "validator2", "attacker"})) + require.NoError(t, err) + err = verifySetupBindsParticipants(swapped, validated) + require.Error(t, err) + assert.Contains(t, err.Error(), "do not match validated participants") + }) + + t.Run("rejects setup with a dropped participant", func(t *testing.T) { + fewer, err := session.DklsKeygenSetupMsgNew(2, nil, + encode([]string{"validator1", "validator2"})) + require.NoError(t, err) + require.Error(t, verifySetupBindsParticipants(fewer, validated)) + }) + + // Index order is part of the protocol, so a reorder is as consequential as + // a substitution. + t.Run("rejects reordered participants", func(t *testing.T) { + reordered, err := session.DklsKeygenSetupMsgNew(2, nil, + encode([]string{"validator3", "validator2", "validator1"})) + require.NoError(t, err) + require.Error(t, verifySetupBindsParticipants(reordered, validated)) + }) + + t.Run("rejects undecodable setup and missing validated list", func(t *testing.T) { + require.Error(t, verifySetupBindsParticipants([]byte("not-a-setup"), validated)) + require.Error(t, verifySetupBindsParticipants(nil, validated)) + require.Error(t, verifySetupBindsParticipants(legitSetup, nil)) + }) +} From d4e86bd21dc3e412913dc453782ec6f6333ab952 Mon Sep 17 00:00:00 2001 From: aman035 Date: Wed, 19 Aug 2026 14:24:01 +0530 Subject: [PATCH 3/6] refactor: consolidate setup binding into one entry point and one helper file (F-2026-18199) --- universalClient/tss/dkls/sign.go | 11 --- universalClient/tss/dkls/utils.go | 15 ++++ .../tss/sessionmanager/sessionmanager.go | 68 ++++++++++--------- .../tss/sessionmanager/sessionmanager_test.go | 28 ++++---- 4 files changed, 64 insertions(+), 58 deletions(-) diff --git a/universalClient/tss/dkls/sign.go b/universalClient/tss/dkls/sign.go index 50e0e56f6..4a656f4c9 100644 --- a/universalClient/tss/dkls/sign.go +++ b/universalClient/tss/dkls/sign.go @@ -226,14 +226,3 @@ func (s *signSession) Close() { s.handle = 0 } } - -// SetupMessageHash returns the message hash embedded in a sign setup blob. -// The setup is what DklsSignSessionFromSetup actually signs over, so callers -// must confirm it matches the hash they independently verified. Otherwise a -// coordinator can present one hash for verification and embed another here. -func SetupMessageHash(setupData []byte) ([]byte, error) { - if len(setupData) == 0 { - return nil, fmt.Errorf("setupData is required") - } - return session.DklsDecodeMessage(setupData) -} diff --git a/universalClient/tss/dkls/utils.go b/universalClient/tss/dkls/utils.go index 01c07028b..54264ae6f 100644 --- a/universalClient/tss/dkls/utils.go +++ b/universalClient/tss/dkls/utils.go @@ -26,6 +26,21 @@ func encodeParticipantIDs(participants []string) []byte { return ids } +// --- Setup decoding ------------------------------------------------------- +// The coordinator supplies the setup blob and the values a follower validates +// separately. DKLS runs on the blob, so these expose what it actually contains +// and let callers bind the two. Both return an error on a malformed blob. + +// SetupMessageHash returns the message hash embedded in a sign setup blob. +// DklsSignSessionFromSetup signs over the setup, not over any hash passed +// alongside it, so callers must confirm the two agree. +func SetupMessageHash(setupData []byte) ([]byte, error) { + if len(setupData) == 0 { + return nil, fmt.Errorf("setupData is required") + } + return session.DklsDecodeMessage(setupData) +} + // SetupParticipants returns the participant list embedded in a DKLS setup blob, // in index order. The setup is what actually drives the session, so callers must // confirm it matches the participants they validated. Otherwise a coordinator diff --git a/universalClient/tss/sessionmanager/sessionmanager.go b/universalClient/tss/sessionmanager/sessionmanager.go index 2b8ed26d4..4ee410e91 100644 --- a/universalClient/tss/sessionmanager/sessionmanager.go +++ b/universalClient/tss/sessionmanager/sessionmanager.go @@ -197,30 +197,15 @@ func (sm *SessionManager) handleSetupMessage(ctx context.Context, senderPeerID s } } - // 6c. Bind the verified hash to the setup blob we are about to sign with. - // DKLS signs the hash embedded in the setup, not the one we checked above, - // and the two arrive unbound. Without this a coordinator can present a - // legitimate hash for verification and embed an attacker-chosen one in - // Payload, harvesting honest shares over it. Checked before the ACK so no - // shares are ever produced for a mismatched hash. - if event.Type == store.EventTypeSignOutbound || event.Type == store.EventTypeSignFundMigrate { - if err := verifySetupBindsHash(msg.Payload, msg.UnsignedSigningReq.SigningHash); err != nil { - sm.logger.Error().Err(err). - Str("event_id", msg.EventID). - Str("coordinator", senderPeerID). - Msg("setup message does not sign the verified hash - rejecting") - return err - } - } - - // 6d. Same split for the participant list: we validate msg.Participants above - // but the session runs on the list embedded in Payload, so an unbound setup - // could run over a different set than the one we approved. - if err := verifySetupBindsParticipants(msg.Payload, msg.Participants); err != nil { + // 6c. Everything validated above came from message fields, but the DKLS + // session runs on msg.Payload, and the two arrive unbound. Require the setup + // blob to carry exactly what we approved, before the ACK, so no shares are + // ever produced for a setup we did not verify. + if err := verifySetupMatchesValidated(msg, event.Type); err != nil { sm.logger.Error().Err(err). Str("event_id", msg.EventID). Str("coordinator", senderPeerID). - Msg("setup message participants do not match the validated list - rejecting") + Msg("setup message does not match the validated request - rejecting") return err } @@ -1004,10 +989,31 @@ func (sm *SessionManager) verifyOutboundSigningRequest(ctx context.Context, even return nil } -// verifySetupBindsHash requires the DKLS setup blob to embed exactly the hash -// the caller already verified. The setup is what actually gets signed, so -// without this check the verified hash is decorative. -func verifySetupBindsHash(setupData, verifiedHash []byte) error { +// verifySetupMatchesValidated requires the coordinator's DKLS setup blob to +// carry exactly the values the follower validated from the message fields. +// DKLS runs on the blob, so without this the validated values are decorative: +// a coordinator can present legitimate ones for checking and embed different +// ones in Payload. +// +// Participants are checked for every protocol, the signing hash additionally for +// sign types. The threshold cannot be checked: the setup embeds one, the wrapper +// exposes no decoder for it, and the threshold argument the session constructors +// take is unused, so the embedded value is authoritative and unverifiable. +func verifySetupMatchesValidated(msg *coordinator.Message, eventType string) error { + if err := setupBindsParticipants(msg.Payload, msg.Participants); err != nil { + return err + } + if eventType != store.EventTypeSignOutbound && eventType != store.EventTypeSignFundMigrate { + return nil + } + if msg.UnsignedSigningReq == nil { + return fmt.Errorf("sign setup has no signing request to bind against") + } + return setupBindsHash(msg.Payload, msg.UnsignedSigningReq.SigningHash) +} + +// setupBindsHash requires the setup blob to embed exactly the verified hash. +func setupBindsHash(setupData, verifiedHash []byte) error { if len(verifiedHash) == 0 { return fmt.Errorf("no verified signing hash to bind setup message to") } @@ -1022,14 +1028,10 @@ func verifySetupBindsHash(setupData, verifiedHash []byte) error { return nil } -// verifySetupBindsParticipants requires the DKLS setup blob to embed exactly the -// participant list the caller already validated, in the same order. Index order -// is part of the protocol, so a reorder is as consequential as a substitution. -// -// Note this cannot cover the threshold: the setup embeds one, the wrapper exposes -// no decoder for it, and the threshold argument the session constructors take is -// unused. So the coordinator's embedded threshold is authoritative and unchecked. -func verifySetupBindsParticipants(setupData []byte, validated []string) error { +// setupBindsParticipants requires the setup blob to embed exactly the validated +// participants, in the same order. Index order is part of the protocol, so a +// reorder is as consequential as a substitution. +func setupBindsParticipants(setupData []byte, validated []string) error { if len(validated) == 0 { return fmt.Errorf("no validated participants to bind setup message to") } diff --git a/universalClient/tss/sessionmanager/sessionmanager_test.go b/universalClient/tss/sessionmanager/sessionmanager_test.go index c3af0b004..25fbd676e 100644 --- a/universalClient/tss/sessionmanager/sessionmanager_test.go +++ b/universalClient/tss/sessionmanager/sessionmanager_test.go @@ -1460,7 +1460,7 @@ func TestNonceBounds(t *testing.T) { // embedded in Message.Payload, and the two arrive unbound. Without this check a // coordinator can present a legitimate hash for verification and embed an // attacker-chosen one in the setup, harvesting honest shares over it. -func TestVerifySetupBindsHash(t *testing.T) { +func TestSetupBindsHash(t *testing.T) { participantIDs := []byte("party1\x00party2") keyID := make([]byte, 32) @@ -1475,23 +1475,23 @@ func TestVerifySetupBindsHash(t *testing.T) { require.NoError(t, err) t.Run("accepts setup that signs the verified hash", func(t *testing.T) { - require.NoError(t, verifySetupBindsHash(legitSetup, legitHash)) + require.NoError(t, setupBindsHash(legitSetup, legitHash)) }) // The reported attack. t.Run("rejects setup embedding a different hash", func(t *testing.T) { - err := verifySetupBindsHash(attackerSetup, legitHash) + err := setupBindsHash(attackerSetup, legitHash) require.Error(t, err) assert.Contains(t, err.Error(), "setup message signs hash") }) t.Run("rejects undecodable setup", func(t *testing.T) { - require.Error(t, verifySetupBindsHash([]byte("not-a-dkls-setup"), legitHash)) - require.Error(t, verifySetupBindsHash(nil, legitHash)) + require.Error(t, setupBindsHash([]byte("not-a-dkls-setup"), legitHash)) + require.Error(t, setupBindsHash(nil, legitHash)) }) t.Run("rejects missing verified hash", func(t *testing.T) { - err := verifySetupBindsHash(legitSetup, nil) + err := setupBindsHash(legitSetup, nil) require.Error(t, err) assert.Contains(t, err.Error(), "no verified signing hash") }) @@ -1500,7 +1500,7 @@ func TestVerifySetupBindsHash(t *testing.T) { // Keygen, keyrefresh and quorumchange have the same split as the sign path: we // validate msg.Participants, but the session runs on the list embedded in // Payload. The threshold cannot be bound this way, see verifySetupBindsParticipants. -func TestVerifySetupBindsParticipants(t *testing.T) { +func TestSetupBindsParticipants(t *testing.T) { validated := []string{"validator1", "validator2", "validator3"} encode := func(ids []string) []byte { return []byte(strings.Join(ids, "\x00")) @@ -1510,14 +1510,14 @@ func TestVerifySetupBindsParticipants(t *testing.T) { require.NoError(t, err) t.Run("accepts setup with the validated participants", func(t *testing.T) { - require.NoError(t, verifySetupBindsParticipants(legitSetup, validated)) + require.NoError(t, setupBindsParticipants(legitSetup, validated)) }) t.Run("rejects setup with a substituted participant", func(t *testing.T) { swapped, err := session.DklsKeygenSetupMsgNew(2, nil, encode([]string{"validator1", "validator2", "attacker"})) require.NoError(t, err) - err = verifySetupBindsParticipants(swapped, validated) + err = setupBindsParticipants(swapped, validated) require.Error(t, err) assert.Contains(t, err.Error(), "do not match validated participants") }) @@ -1526,7 +1526,7 @@ func TestVerifySetupBindsParticipants(t *testing.T) { fewer, err := session.DklsKeygenSetupMsgNew(2, nil, encode([]string{"validator1", "validator2"})) require.NoError(t, err) - require.Error(t, verifySetupBindsParticipants(fewer, validated)) + require.Error(t, setupBindsParticipants(fewer, validated)) }) // Index order is part of the protocol, so a reorder is as consequential as @@ -1535,12 +1535,12 @@ func TestVerifySetupBindsParticipants(t *testing.T) { reordered, err := session.DklsKeygenSetupMsgNew(2, nil, encode([]string{"validator3", "validator2", "validator1"})) require.NoError(t, err) - require.Error(t, verifySetupBindsParticipants(reordered, validated)) + require.Error(t, setupBindsParticipants(reordered, validated)) }) t.Run("rejects undecodable setup and missing validated list", func(t *testing.T) { - require.Error(t, verifySetupBindsParticipants([]byte("not-a-setup"), validated)) - require.Error(t, verifySetupBindsParticipants(nil, validated)) - require.Error(t, verifySetupBindsParticipants(legitSetup, nil)) + require.Error(t, setupBindsParticipants([]byte("not-a-setup"), validated)) + require.Error(t, setupBindsParticipants(nil, validated)) + require.Error(t, setupBindsParticipants(legitSetup, nil)) }) } From 79c7dec9fbaa0c83f724c269beffcbb14b8a7ac7 Mon Sep 17 00:00:00 2001 From: aman035 Date: Wed, 19 Aug 2026 15:31:49 +0530 Subject: [PATCH 4/6] test: move setup decoder tests to utils_test and cover both symmetrically (F-2026-18199) --- universalClient/tss/dkls/sign_test.go | 54 ------------- universalClient/tss/dkls/utils_test.go | 107 +++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 54 deletions(-) diff --git a/universalClient/tss/dkls/sign_test.go b/universalClient/tss/dkls/sign_test.go index 3af2673e3..bd377ddf8 100644 --- a/universalClient/tss/dkls/sign_test.go +++ b/universalClient/tss/dkls/sign_test.go @@ -1,7 +1,6 @@ package dkls import ( - "bytes" "strings" "testing" @@ -131,56 +130,3 @@ func TestSignSession_EndToEnd(t *testing.T) { t.Errorf("expected 2 participants, got %d", len(result.Participants)) } } - -// DKLS signs the hash embedded in the setup blob, not the hash a follower -// verified separately. SetupMessageHash exposes the embedded one so callers can -// bind the two, which is what stops a coordinator presenting one hash for -// verification and embedding another in the setup it distributes. -func TestSetupMessageHash(t *testing.T) { - participantIDs := encodeParticipantIDs([]string{"party1", "party2"}) - keyID := make([]byte, 32) - - legitHash := make([]byte, 32) - copy(legitHash, "legitimate-outbound-hash-32bytes") - attackerHash := make([]byte, 32) - copy(attackerHash, "attacker-chosen-vault-call-digest") - - t.Run("returns the hash embedded in the setup", func(t *testing.T) { - setup, err := session.DklsSignSetupMsgNew(keyID, nil, legitHash, participantIDs) - if err != nil { - t.Fatalf("failed to build sign setup: %v", err) - } - got, err := SetupMessageHash(setup) - if err != nil { - t.Fatalf("SetupMessageHash() error = %v", err) - } - if !bytes.Equal(got, legitHash) { - t.Errorf("SetupMessageHash() = %x, want %x", got, legitHash) - } - }) - - // The attack: a setup built over attackerHash must not report legitHash, so - // a caller comparing against its verified hash detects the substitution. - t.Run("substituted setup reports the attacker hash", func(t *testing.T) { - setup, err := session.DklsSignSetupMsgNew(keyID, nil, attackerHash, participantIDs) - if err != nil { - t.Fatalf("failed to build sign setup: %v", err) - } - got, err := SetupMessageHash(setup) - if err != nil { - t.Fatalf("SetupMessageHash() error = %v", err) - } - if bytes.Equal(got, legitHash) { - t.Fatal("substituted setup must not report the legitimate hash") - } - if !bytes.Equal(got, attackerHash) { - t.Errorf("SetupMessageHash() = %x, want %x", got, attackerHash) - } - }) - - t.Run("rejects empty setup", func(t *testing.T) { - if _, err := SetupMessageHash(nil); err == nil { - t.Error("SetupMessageHash(nil) should error") - } - }) -} diff --git a/universalClient/tss/dkls/utils_test.go b/universalClient/tss/dkls/utils_test.go index df57610e8..25ba960ed 100644 --- a/universalClient/tss/dkls/utils_test.go +++ b/universalClient/tss/dkls/utils_test.go @@ -1,8 +1,11 @@ package dkls import ( + "bytes" "crypto/sha256" "testing" + + session "go-wrapper/go-dkls/sessions" ) func TestDeriveKeyID(t *testing.T) { @@ -58,3 +61,107 @@ func TestEncodeParticipantIDs(t *testing.T) { }) } } + +// The setup blob is what DKLS actually runs on, so these decoders are what let a +// follower bind it to the values it validated separately. Both must report what +// the blob really contains, and must error rather than guess on a malformed one. + +func TestSetupMessageHash(t *testing.T) { + participantIDs := encodeParticipantIDs([]string{"party1", "party2"}) + keyID := make([]byte, 32) + + legitHash := make([]byte, 32) + copy(legitHash, "legitimate-outbound-hash-32bytes") + attackerHash := make([]byte, 32) + copy(attackerHash, "attacker-chosen-vault-call-digest") + + t.Run("returns the hash embedded in the setup", func(t *testing.T) { + setup, err := session.DklsSignSetupMsgNew(keyID, nil, legitHash, participantIDs) + if err != nil { + t.Fatalf("failed to build sign setup: %v", err) + } + got, err := SetupMessageHash(setup) + if err != nil { + t.Fatalf("SetupMessageHash() error = %v", err) + } + if !bytes.Equal(got, legitHash) { + t.Errorf("SetupMessageHash() = %x, want %x", got, legitHash) + } + }) + + // A substituted setup must report the hash it really signs, which is what + // makes the mismatch detectable. + t.Run("substituted setup reports the attacker hash", func(t *testing.T) { + setup, err := session.DklsSignSetupMsgNew(keyID, nil, attackerHash, participantIDs) + if err != nil { + t.Fatalf("failed to build sign setup: %v", err) + } + got, err := SetupMessageHash(setup) + if err != nil { + t.Fatalf("SetupMessageHash() error = %v", err) + } + if bytes.Equal(got, legitHash) { + t.Fatal("substituted setup must not report the legitimate hash") + } + if !bytes.Equal(got, attackerHash) { + t.Errorf("SetupMessageHash() = %x, want %x", got, attackerHash) + } + }) + + t.Run("errors on empty and malformed setup", func(t *testing.T) { + if _, err := SetupMessageHash(nil); err == nil { + t.Error("SetupMessageHash(nil) should error") + } + if _, err := SetupMessageHash([]byte("not-a-dkls-setup")); err == nil { + t.Error("SetupMessageHash(malformed) should error") + } + }) +} + +func TestSetupParticipants(t *testing.T) { + t.Run("returns the participants in index order", func(t *testing.T) { + want := []string{"alice", "bob", "carol"} + setup, err := session.DklsKeygenSetupMsgNew(2, nil, encodeParticipantIDs(want)) + if err != nil { + t.Fatalf("failed to build keygen setup: %v", err) + } + got, err := SetupParticipants(setup) + if err != nil { + t.Fatalf("SetupParticipants() error = %v", err) + } + if len(got) != len(want) { + t.Fatalf("SetupParticipants() = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("participant %d = %q, want %q", i, got[i], want[i]) + } + } + }) + + // Enumeration terminates on the first empty name rather than an error, which + // is the contract this relies on to find the end of the list. + t.Run("terminates at the end of a two party list", func(t *testing.T) { + want := []string{"first", "second"} + setup, err := session.DklsKeygenSetupMsgNew(2, nil, encodeParticipantIDs(want)) + if err != nil { + t.Fatalf("failed to build keygen setup: %v", err) + } + got, err := SetupParticipants(setup) + if err != nil { + t.Fatalf("SetupParticipants() error = %v", err) + } + if len(got) != 2 || got[0] != "first" || got[1] != "second" { + t.Errorf("SetupParticipants() = %v, want %v", got, want) + } + }) + + t.Run("errors on empty and malformed setup", func(t *testing.T) { + if _, err := SetupParticipants(nil); err == nil { + t.Error("SetupParticipants(nil) should error") + } + if _, err := SetupParticipants([]byte("not-a-dkls-setup")); err == nil { + t.Error("SetupParticipants(malformed) should error") + } + }) +} From c438e6d9072ab54197a232d368c5e6e677eb16e0 Mon Sep 17 00:00:00 2001 From: aman035 Date: Wed, 19 Aug 2026 15:41:23 +0530 Subject: [PATCH 5/6] test: end-to-end proof that a mismatched payload hash refuses session and emits no shares (F-2026-18199) --- .../tss/sessionmanager/sessionmanager_test.go | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/universalClient/tss/sessionmanager/sessionmanager_test.go b/universalClient/tss/sessionmanager/sessionmanager_test.go index 25fbd676e..5840f3470 100644 --- a/universalClient/tss/sessionmanager/sessionmanager_test.go +++ b/universalClient/tss/sessionmanager/sessionmanager_test.go @@ -1544,3 +1544,94 @@ func TestSetupBindsParticipants(t *testing.T) { require.Error(t, setupBindsParticipants(legitSetup, nil)) }) } + +// The regression the finding asks for: a Payload whose embedded hash differs +// from the verified SigningHash must refuse session creation and produce no +// shares. Driven through handleSetupMessage so it covers the wiring, not just +// the comparison helper. +func TestHandleSetupMessage_RejectsPayloadHashMismatch(t *testing.T) { + _, coord, evtStore, keyshareMgr, _, testDB := setupTestSessionManager(t) + ctx := context.Background() + + // Sign-eligible validators are the ACTIVE ones in the fixture. + participants := []string{"validator1", "validator2"} + participantIDs := []byte(strings.Join(participants, "\x00")) + keyID := make([]byte, 32) + + legitHash := make([]byte, 32) + copy(legitHash, "legitimate-outbound-hash-32bytes") + attackerHash := make([]byte, 32) + copy(attackerHash, "attacker-chosen-vault-call-digest") + + newRecordingSM := func() (*SessionManager, *int) { + sends := 0 + sm := NewSessionManager( + evtStore, coord, keyshareMgr, nil, nil, + func(context.Context, string, []byte) error { sends++; return nil }, + "validator1", 3*time.Minute, 30*time.Second, 60, zerolog.Nop(), nil, + ) + return sm, &sends + } + + newEvent := func(t *testing.T, id string) { + t.Helper() + require.NoError(t, testDB.Create(&store.Event{ + EventID: id, BlockHeight: 100, + Type: store.EventTypeSignOutbound, + Status: store.StatusConfirmed, + EventData: []byte(`{"destination_chain":"eip155:11155111"}`), + }).Error) + } + + t.Run("substituted payload hash is refused, no session, no shares", func(t *testing.T) { + newEvent(t, "sign-mismatch") + sm, sends := newRecordingSM() + + // Coordinator shows the legitimate hash but ships a setup over its own. + attackerSetup, err := session.DklsSignSetupMsgNew(keyID, nil, attackerHash, participantIDs) + require.NoError(t, err) + + err = sm.HandleIncomingMessage(ctx, "peer1", &coordinator.Message{ + Type: coordinator.MessageTypeSetup, + EventID: "sign-mismatch", + Participants: participants, + Payload: attackerSetup, + UnsignedSigningReq: &common.UnsignedSigningReq{SigningHash: legitHash, Nonce: 1}, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "setup message signs hash") + + sm.mu.RLock() + sessionCount := len(sm.sessions) + sm.mu.RUnlock() + assert.Zero(t, sessionCount, "no session may be created for a mismatched setup") + assert.Zero(t, *sends, "no ACK or share may be emitted for a mismatched setup") + }) + + // Positive control: with the same wiring, a setup over the verified hash must + // get past the binding check, so the rejection above is the binding and not + // some earlier validation failing. + t.Run("matching payload hash passes the binding check", func(t *testing.T) { + newEvent(t, "sign-match") + sm, _ := newRecordingSM() + + legitSetup, err := session.DklsSignSetupMsgNew(keyID, nil, legitHash, participantIDs) + require.NoError(t, err) + + err = sm.HandleIncomingMessage(ctx, "peer1", &coordinator.Message{ + Type: coordinator.MessageTypeSetup, + EventID: "sign-match", + Participants: participants, + Payload: legitSetup, + UnsignedSigningReq: &common.UnsignedSigningReq{SigningHash: legitHash, Nonce: 1}, + }) + + if err != nil { + assert.NotContains(t, err.Error(), "setup message signs hash", + "matching setup must not be rejected by the hash binding") + assert.NotContains(t, err.Error(), "do not match validated participants", + "matching setup must not be rejected by the participant binding") + } + }) +} From ceded638af0e6836170fcd5156368251157cffe9 Mon Sep 17 00:00:00 2001 From: aman035 Date: Wed, 19 Aug 2026 15:47:21 +0530 Subject: [PATCH 6/6] fix: bind setup threshold by parsing the setup TLV, closing the downgrade gap (F-2026-18199) --- universalClient/tss/dkls/utils.go | 41 ++++++++++++++ universalClient/tss/dkls/utils_test.go | 55 +++++++++++++++++++ .../tss/sessionmanager/sessionmanager.go | 34 +++++++++--- .../tss/sessionmanager/sessionmanager_test.go | 30 ++++++++++ 4 files changed, 151 insertions(+), 9 deletions(-) diff --git a/universalClient/tss/dkls/utils.go b/universalClient/tss/dkls/utils.go index 54264ae6f..4a42b8137 100644 --- a/universalClient/tss/dkls/utils.go +++ b/universalClient/tss/dkls/utils.go @@ -2,6 +2,7 @@ package dkls import ( "crypto/sha256" + "encoding/binary" "fmt" session "go-wrapper/go-dkls/sessions" @@ -65,3 +66,43 @@ func SetupParticipants(setupData []byte) ([]string, error) { } return participants, nil } + +// Setup blobs are a tag-length-value list after a fixed header. The wrapper +// exposes decoders for the key ID, message and party names but not the +// threshold, so that one is read here. Values are laid out as: +// +// tag uint16 little endian +// length uint16 little endian, stored as length-1 +// value length bytes +// +// The header is MESSAGE_ID_SIZE(32) + 2 + 2. This mirrors the library's internal +// encoding, so TestSetupThreshold pins it: if the format changes, that test +// fails rather than this silently reading the wrong byte. +const ( + setupHeaderSize = 36 + setupTagThreshold = 1 +) + +// SetupThreshold returns the threshold embedded in a keygen, keyrefresh or +// quorumchange setup blob. Sign setups carry no threshold and return an error. +func SetupThreshold(setupData []byte) (int, error) { + if len(setupData) < setupHeaderSize { + return 0, fmt.Errorf("setup message too short to contain a threshold") + } + for offset := setupHeaderSize; offset+4 <= len(setupData); { + tag := binary.LittleEndian.Uint16(setupData[offset : offset+2]) + length := int(binary.LittleEndian.Uint16(setupData[offset+2:offset+4])) + 1 + valueStart := offset + 4 + if valueStart+length > len(setupData) { + return 0, fmt.Errorf("setup message is malformed: tag %d claims %d bytes past the end", tag, length) + } + if tag == setupTagThreshold { + if length != 1 { + return 0, fmt.Errorf("threshold tag has unexpected length %d", length) + } + return int(setupData[valueStart]), nil + } + offset = valueStart + length + } + return 0, fmt.Errorf("setup message carries no threshold") +} diff --git a/universalClient/tss/dkls/utils_test.go b/universalClient/tss/dkls/utils_test.go index 25ba960ed..29f432859 100644 --- a/universalClient/tss/dkls/utils_test.go +++ b/universalClient/tss/dkls/utils_test.go @@ -165,3 +165,58 @@ func TestSetupParticipants(t *testing.T) { } }) } + +// Pins the setup TLV layout this package parses directly. If the library +// changes its encoding, this fails loudly instead of SetupThreshold silently +// reading the wrong byte. +func TestSetupThreshold(t *testing.T) { + participants := []string{"alice", "bob", "carol"} + + t.Run("reads the embedded keygen threshold", func(t *testing.T) { + for _, want := range []int{2, 3} { + setup, err := session.DklsKeygenSetupMsgNew(want, nil, encodeParticipantIDs(participants)) + if err != nil { + t.Fatalf("failed to build keygen setup with threshold %d: %v", want, err) + } + got, err := SetupThreshold(setup) + if err != nil { + t.Fatalf("SetupThreshold() error = %v", err) + } + if got != want { + t.Errorf("SetupThreshold() = %d, want %d", got, want) + } + } + }) + + // A downgraded setup must report the weaker threshold it really carries, + // which is what makes the mismatch detectable. + t.Run("downgraded setup reports the weaker threshold", func(t *testing.T) { + setup, err := session.DklsKeygenSetupMsgNew(2, nil, encodeParticipantIDs(participants)) + if err != nil { + t.Fatalf("failed to build keygen setup: %v", err) + } + got, err := SetupThreshold(setup) + if err != nil { + t.Fatalf("SetupThreshold() error = %v", err) + } + if got == 3 { + t.Fatal("downgraded setup must not report the expected threshold") + } + if got != 2 { + t.Errorf("SetupThreshold() = %d, want 2", got) + } + }) + + t.Run("errors on short, malformed and thresholdless setups", func(t *testing.T) { + if _, err := SetupThreshold(nil); err == nil { + t.Error("SetupThreshold(nil) should error") + } + if _, err := SetupThreshold([]byte("too-short")); err == nil { + t.Error("SetupThreshold(short) should error") + } + // Header present but no tags at all. + if _, err := SetupThreshold(make([]byte, setupHeaderSize)); err == nil { + t.Error("SetupThreshold(no tags) should error") + } + }) +} diff --git a/universalClient/tss/sessionmanager/sessionmanager.go b/universalClient/tss/sessionmanager/sessionmanager.go index 4ee410e91..47db05ec9 100644 --- a/universalClient/tss/sessionmanager/sessionmanager.go +++ b/universalClient/tss/sessionmanager/sessionmanager.go @@ -995,21 +995,37 @@ func (sm *SessionManager) verifyOutboundSigningRequest(ctx context.Context, even // a coordinator can present legitimate ones for checking and embed different // ones in Payload. // -// Participants are checked for every protocol, the signing hash additionally for -// sign types. The threshold cannot be checked: the setup embeds one, the wrapper -// exposes no decoder for it, and the threshold argument the session constructors -// take is unused, so the embedded value is authoritative and unverifiable. +// Participants are checked for every protocol. Sign types additionally bind the +// signing hash; key-lifecycle types additionally bind the threshold, which the +// session constructors accept but ignore, so the embedded value is what the +// protocol actually runs with. func verifySetupMatchesValidated(msg *coordinator.Message, eventType string) error { if err := setupBindsParticipants(msg.Payload, msg.Participants); err != nil { return err } - if eventType != store.EventTypeSignOutbound && eventType != store.EventTypeSignFundMigrate { - return nil + if eventType == store.EventTypeSignOutbound || eventType == store.EventTypeSignFundMigrate { + if msg.UnsignedSigningReq == nil { + return fmt.Errorf("sign setup has no signing request to bind against") + } + return setupBindsHash(msg.Payload, msg.UnsignedSigningReq.SigningHash) } - if msg.UnsignedSigningReq == nil { - return fmt.Errorf("sign setup has no signing request to bind against") + return setupBindsThreshold(msg.Payload, msg.Participants) +} + +// setupBindsThreshold requires the setup blob to embed the threshold the +// follower derives from the validated participants. Without it a coordinator can +// embed a lower one and elicit help producing a weaker key than was agreed. +func setupBindsThreshold(setupData []byte, validated []string) error { + expected := coordinator.CalculateThreshold(len(validated)) + embedded, err := dkls.SetupThreshold(setupData) + if err != nil { + return fmt.Errorf("cannot decode setup message threshold: %w", err) + } + if embedded != expected { + return fmt.Errorf("setup message threshold %d does not match expected %d for %d participants", + embedded, expected, len(validated)) } - return setupBindsHash(msg.Payload, msg.UnsignedSigningReq.SigningHash) + return nil } // setupBindsHash requires the setup blob to embed exactly the verified hash. diff --git a/universalClient/tss/sessionmanager/sessionmanager_test.go b/universalClient/tss/sessionmanager/sessionmanager_test.go index 5840f3470..73f0dd2a9 100644 --- a/universalClient/tss/sessionmanager/sessionmanager_test.go +++ b/universalClient/tss/sessionmanager/sessionmanager_test.go @@ -1635,3 +1635,33 @@ func TestHandleSetupMessage_RejectsPayloadHashMismatch(t *testing.T) { } }) } + +// The session constructors accept a threshold and ignore it, so the setup blob's +// embedded threshold is what the protocol runs with. A coordinator embedding a +// lower one would elicit help producing a weaker key than the participants +// agreed to, which is worse than a bad signature since it persists. +func TestSetupBindsThreshold(t *testing.T) { + validated := []string{"validator1", "validator2", "validator3"} + expected := coordinator.CalculateThreshold(len(validated)) + encode := func(ids []string) []byte { return []byte(strings.Join(ids, "\x00")) } + + t.Run("accepts the expected threshold", func(t *testing.T) { + setup, err := session.DklsKeygenSetupMsgNew(expected, nil, encode(validated)) + require.NoError(t, err) + require.NoError(t, setupBindsThreshold(setup, validated)) + }) + + t.Run("rejects a downgraded threshold", func(t *testing.T) { + require.Greater(t, expected, 1, "fixture must allow a strictly lower threshold") + downgraded, err := session.DklsKeygenSetupMsgNew(expected-1, nil, encode(validated)) + require.NoError(t, err) + err = setupBindsThreshold(downgraded, validated) + require.Error(t, err) + assert.Contains(t, err.Error(), "does not match expected") + }) + + t.Run("rejects undecodable setup", func(t *testing.T) { + require.Error(t, setupBindsThreshold([]byte("not-a-setup"), validated)) + require.Error(t, setupBindsThreshold(nil, validated)) + }) +}