Skip to content
84 changes: 84 additions & 0 deletions universalClient/tss/dkls/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ package dkls

import (
"crypto/sha256"
"encoding/binary"
"fmt"

session "go-wrapper/go-dkls/sessions"
)

// deriveKeyID derives a key ID bytes from a string key ID.
Expand All @@ -22,3 +26,83 @@ 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
// 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
}

// 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")
}
162 changes: 162 additions & 0 deletions universalClient/tss/dkls/utils_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package dkls

import (
"bytes"
"crypto/sha256"
"testing"

session "go-wrapper/go-dkls/sessions"
)

func TestDeriveKeyID(t *testing.T) {
Expand Down Expand Up @@ -58,3 +61,162 @@ 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")
}
})
}

// 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")
}
})
}
86 changes: 86 additions & 0 deletions universalClient/tss/sessionmanager/sessionmanager.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"encoding/json"
"fmt"
"math/big"
"slices"
"sync"
"time"

Expand Down Expand Up @@ -196,6 +197,18 @@ func (sm *SessionManager) handleSetupMessage(ctx context.Context, senderPeerID s
}
}

// 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 does not match the validated request - rejecting")
return err
}

// 7. Create session based on protocol type
session, err := sm.createSession(ctx, event, msg)
if err != nil {
Expand Down Expand Up @@ -976,6 +989,79 @@ func (sm *SessionManager) verifyOutboundSigningRequest(ctx context.Context, even
return nil
}

// 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. 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 {
if msg.UnsignedSigningReq == nil {
return fmt.Errorf("sign setup has no signing request to bind against")
}
return setupBindsHash(msg.Payload, msg.UnsignedSigningReq.SigningHash)
}
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 nil
}

// 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")
}
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
}

// 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")
}
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
Expand Down
Loading
Loading