diff --git a/gjkr/evidence_log.go b/gjkr/evidence_log.go new file mode 100644 index 0000000..2f11c08 --- /dev/null +++ b/gjkr/evidence_log.go @@ -0,0 +1,93 @@ +package gjkr + +import ( + "fmt" + "sync" +) + +// For complaint resolution, group members need to have access to messages +// exchanged between the accuser and the accused party. There are two situations +// in the DKG protocol where group members generate values individually for +// every other group member: +// +// - Ephemeral ECDH (phase 2) - after each group member generates an ephemeral +// keypair for each other group member and broadcasts those ephemeral public keys +// in the clear (phase 1), group members must ECDH those public keys with the +// ephemeral private key for that group member to derive a symmetric key. +// In the case of an accusation, members performing compliant resolution need to +// validate the private ephemeral key revealed by the accuser. To perform the +// validation, members need to compare public ephemeral key published by the +// accuser in phase 1 with the private ephemeral key published by the accuser. +// +// - Polynomial generation (phase 3) - each group member generates two sharing +// polynomials, and calculates shares as points on these polynomials individually +// for each other group member. Shares are publicly broadcast, encrypted with a +// symmetric key established between the sender and receiver. In the case of an +// accusation, members performing compliant resolution need to look at the shares +// sent by the accused party. To do this, they read the round 3 message from the +// log, and decrypt it using the symmetric key used between the accuser and +// accused party. The key is publicly revealed by the accuser. +type evidenceLog struct { + // senderIndex -> *ephemeralPublicKeyMessage + pubKeyMessageLog *messageStorage[*ephemeralPublicKeyMessage] +} + +func newEvidenceLog() *evidenceLog { + return &evidenceLog{ + pubKeyMessageLog: newMessageStorage[*ephemeralPublicKeyMessage](), + } +} + +// putEphemeralMessage is a function that takes a single +// EphemeralPubKeyMessage, and stores that as evidence for future +// accusation trials for a given (sender, receiver) pair. If a message +// already exists for the given sender, we return an error to the user. +func (e *evidenceLog) putEphemeralPublicKeyMessage( + pubKeyMessage *ephemeralPublicKeyMessage, +) error { + return e.pubKeyMessageLog.putMessage( + pubKeyMessage.senderIndex, + pubKeyMessage, + ) +} + +// getEphemeralPublicKeyMessage returns the `ephemeralPublicKeyMessage` +// broadcast in the first protocol round by the given sender. +func (e *evidenceLog) getEphemeralPublicKeyMessage( + sender memberIndex, +) *ephemeralPublicKeyMessage { + return e.pubKeyMessageLog.getMessage(sender) +} + +type messageStorage[T interface{}] struct { + cache map[memberIndex]T + cacheLock sync.RWMutex +} + +func newMessageStorage[T interface{}]() *messageStorage[T] { + return &messageStorage[T]{ + cache: make(map[memberIndex]T), + } +} + +func (ms *messageStorage[T]) getMessage(sender memberIndex) T { + ms.cacheLock.RLock() + defer ms.cacheLock.RUnlock() + + return ms.cache[sender] +} + +func (ms *messageStorage[T]) putMessage(sender memberIndex, message T) error { + ms.cacheLock.Lock() + defer ms.cacheLock.Unlock() + + if _, ok := ms.cache[sender]; ok { + return fmt.Errorf( + "message exists for sender %v", + sender, + ) + } + + ms.cache[sender] = message + return nil +} diff --git a/gjkr/evidence_log_test.go b/gjkr/evidence_log_test.go new file mode 100644 index 0000000..a7c86b4 --- /dev/null +++ b/gjkr/evidence_log_test.go @@ -0,0 +1,61 @@ +package gjkr + +import ( + "reflect" + "testing" + + "threshold.network/roast/internal/testutils" +) + +func TestPutEphemeralPublicKeyMessageTwice(t *testing.T) { + evidenceLog := newEvidenceLog() + err := evidenceLog.putEphemeralPublicKeyMessage( + &ephemeralPublicKeyMessage{ + senderIndex: memberIndex(1), + }) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + err = evidenceLog.putEphemeralPublicKeyMessage( + &ephemeralPublicKeyMessage{ + senderIndex: memberIndex(1), + }) + if err == nil { + t.Fatal("expected an error") + } + + testutils.AssertStringsEqual( + t, + "error", + "message exists for sender 1", + err.Error(), + ) +} + +func TestPutGetEphemeralPublicKeyMessage(t *testing.T) { + evidenceLog := newEvidenceLog() + + message := &ephemeralPublicKeyMessage{ + senderIndex: memberIndex(1), + } + + m := evidenceLog.getEphemeralPublicKeyMessage(memberIndex(1)) + if m != nil { + t.Fatalf("expected message not to be found but has [%v]", m) + } + + err := evidenceLog.putEphemeralPublicKeyMessage(message) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + m = evidenceLog.getEphemeralPublicKeyMessage(memberIndex(1)) + if !reflect.DeepEqual(message, m) { + t.Fatalf( + "unexpected message\nexpected: %v\nactual: %v", + message, + m, + ) + } +} diff --git a/gjkr/group.go b/gjkr/group.go new file mode 100644 index 0000000..3550b30 --- /dev/null +++ b/gjkr/group.go @@ -0,0 +1,68 @@ +package gjkr + +import "slices" + +// group represents the current state of information about the GJKR key +// generation group. Each GJKR protocol participant should have the same group +// state at the end of each protocol step. +type group struct { + dishonestThreshold uint16 + groupSize uint16 + + allMemberIndexes []memberIndex + inactiveMemberIndexes []memberIndex + disqualifiedMemberIndexes []memberIndex +} + +func newGroup(dishonestThreshold uint16, groupSize uint16) *group { + allMemberIndexes := make([]memberIndex, groupSize) + for i := uint16(0); i < groupSize; i++ { + allMemberIndexes[i] = memberIndex(i + 1) + } + + return &group{ + dishonestThreshold: dishonestThreshold, + groupSize: groupSize, + allMemberIndexes: allMemberIndexes, + inactiveMemberIndexes: []memberIndex{}, + disqualifiedMemberIndexes: []memberIndex{}, + } +} + +// markMemberAsDisqualified adds the member with the given index to the list of +// disqualified members. If the member is not a part of the group, is already +// disqualified or marked as inactive, the function does nothing. +func (g *group) markMemberAsDisqualified(memberIndex memberIndex) { + if g.isOperating(memberIndex) { + g.disqualifiedMemberIndexes = append(g.disqualifiedMemberIndexes, memberIndex) + } +} + +// markMemberAsInactive adds the member with the given index to the list of +// inactive members. If the member is not a part of the group, is already +// disqualified or marked as inactive, the function does nothing. +func (g *group) markMemberAsInactive(memberIndex memberIndex) { + if g.isOperating(memberIndex) { + g.inactiveMemberIndexes = append(g.inactiveMemberIndexes, memberIndex) + } +} + +// isOperating returns true if member with the given index belongs to the group +// and has not been marked as inactive or disqualified. +func (g *group) isOperating(memberIndex memberIndex) bool { + return g.isInGroup(memberIndex) && + !g.isInactive(memberIndex) && + !g.isDisqualified(memberIndex) +} + +func (g *group) isInGroup(memberIndex memberIndex) bool { + return memberIndex > 0 && uint16(memberIndex) <= g.groupSize +} + +func (g *group) isInactive(memberIndex memberIndex) bool { + return slices.Contains(g.inactiveMemberIndexes, memberIndex) +} + +func (g *group) isDisqualified(memberIndex memberIndex) bool { + return slices.Contains(g.disqualifiedMemberIndexes, memberIndex) +} diff --git a/gjkr/group_test.go b/gjkr/group_test.go new file mode 100644 index 0000000..7bd3f3e --- /dev/null +++ b/gjkr/group_test.go @@ -0,0 +1,194 @@ +package gjkr + +import ( + "fmt" + "slices" + "testing" + + "threshold.network/roast/internal/testutils" +) + +func TestMarkMemberAsDisqualified(t *testing.T) { + var tests = map[string]struct { + updateFunc func(g *group) + expectedDisqualifiedMembers []memberIndex + expectedInactiveMembers []memberIndex + }{ + "mark member as disqualified": { + updateFunc: func(g *group) { + g.markMemberAsDisqualified(2) + }, + expectedDisqualifiedMembers: []memberIndex{2}, + }, + "mark member as disqualified twice": { + updateFunc: func(g *group) { + g.markMemberAsDisqualified(3) + g.markMemberAsDisqualified(3) + }, + expectedDisqualifiedMembers: []memberIndex{3}, + }, + "mark member from out of the group as disqualified": { + updateFunc: func(g *group) { + g.markMemberAsDisqualified(102) + }, + expectedDisqualifiedMembers: []memberIndex{}, + }, + "mark all members as disqualified": { + updateFunc: func(g *group) { + g.markMemberAsDisqualified(1) + g.markMemberAsDisqualified(2) + g.markMemberAsDisqualified(3) + g.markMemberAsDisqualified(4) + g.markMemberAsDisqualified(5) + }, + expectedDisqualifiedMembers: []memberIndex{1, 2, 3, 4, 5}, + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + groupSize := uint16(5) + group := newGroup(2, groupSize) + test.updateFunc(group) + + for i := uint16(1); i <= groupSize; i++ { + idx := memberIndex(i) + shouldBeDisqualified := slices.Contains( + test.expectedDisqualifiedMembers, + idx, + ) + testutils.AssertBoolsEqual( + t, + fmt.Sprintf("disqualification state for %v", idx), + shouldBeDisqualified, + group.isDisqualified(idx), + ) + testutils.AssertBoolsEqual( + t, + fmt.Sprintf("inactivity state for %v", idx), + false, + group.isInactive(idx), + ) + if !shouldBeDisqualified { + testutils.AssertBoolsEqual( + t, + "operating state", + true, + group.isOperating(idx), + ) + } + } + }) + } +} + +func TestMarkMemberAsInactive(t *testing.T) { + var tests = map[string]struct { + updateFunc func(g *group) + expectedDisqualifiedMembers []memberIndex + expectedInactiveMembers []memberIndex + }{ + "mark member as inactive": { + updateFunc: func(g *group) { + g.markMemberAsInactive(1) + g.markMemberAsInactive(3) + }, + expectedInactiveMembers: []memberIndex{1, 3}, + }, + "mark member as inactive twice": { + updateFunc: func(g *group) { + g.markMemberAsInactive(2) + g.markMemberAsInactive(2) + }, + expectedInactiveMembers: []memberIndex{2}, + }, + "mark member from out of the group as inactive": { + updateFunc: func(g *group) { + g.markMemberAsInactive(6) + }, + expectedInactiveMembers: []memberIndex{}, + }, + "mark all members as inactive": { + updateFunc: func(g *group) { + g.markMemberAsInactive(1) + g.markMemberAsInactive(2) + g.markMemberAsInactive(3) + g.markMemberAsInactive(4) + g.markMemberAsInactive(5) + }, + expectedInactiveMembers: []memberIndex{1, 2, 3, 4, 5}, + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + groupSize := uint16(5) + group := newGroup(2, groupSize) + test.updateFunc(group) + + for i := uint16(1); i <= groupSize; i++ { + idx := memberIndex(i) + shouldBeInactive := slices.Contains( + test.expectedInactiveMembers, + idx, + ) + testutils.AssertBoolsEqual( + t, + fmt.Sprintf("inactivity state for %v", idx), + shouldBeInactive, + group.isInactive(idx), + ) + testutils.AssertBoolsEqual( + t, + fmt.Sprintf("disqualification state for %v", idx), + false, + group.isDisqualified(idx), + ) + + if !shouldBeInactive { + testutils.AssertBoolsEqual( + t, + "operating state", + true, + group.isOperating(idx), + ) + } + } + }) + } +} + +func TestIsInGroup(t *testing.T) { + group := newGroup(2, 3) + + testutils.AssertBoolsEqual( + t, + "is in group state for 0", + false, + group.isInGroup(0), + ) + testutils.AssertBoolsEqual( + t, + "is in group state for 1", + true, + group.isInGroup(1), + ) + testutils.AssertBoolsEqual( + t, + "is in group state for 2", + true, + group.isInGroup(2), + ) + testutils.AssertBoolsEqual( + t, + "is in group state for 3", + true, + group.isInGroup(3), + ) + testutils.AssertBoolsEqual( + t, + "is in group state for 4", + false, + group.isInGroup(4), + ) +} diff --git a/gjkr/logger.go b/gjkr/logger.go new file mode 100644 index 0000000..e750eb1 --- /dev/null +++ b/gjkr/logger.go @@ -0,0 +1,18 @@ +package gjkr + +// Logger is an interface compatible with the Cosmos SDK logger interface. +// This interface is used by GJKR protocol members for logging and the +// application should provider a logger implementation matching this interface. +type Logger interface { + // Info takes a message and a set of key/value pairs and logs with level INFO. + // The key of the tuple must be a string. + Info(msg string, keyVals ...any) + + // Warn takes a message and a set of key/value pairs and logs with level WARN. + // The key of the tuple must be a string. + Warn(msg string, keyVals ...any) + + // Error takes a message and a set of key/value pairs and logs with level ERR. + // The key of the tuple must be a string. + Error(msg string, keyVals ...any) +} diff --git a/gjkr/member.go b/gjkr/member.go new file mode 100644 index 0000000..d5068e7 --- /dev/null +++ b/gjkr/member.go @@ -0,0 +1,71 @@ +package gjkr + +import "threshold.network/roast/ephemeral" + +// memberIndex represents a unique index of a member of GJKR protocol. +// The values start from 1. For a group size of N, the memberIndex is +// 1 <= memberIndex <= N. +type memberIndex uint16 + +// member includes the core pieces of GJKR protocol member important for every +// phase of the protocol. +type member struct { + memberIndex memberIndex + sessionID string + group *group + evidenceLog *evidenceLog + + logger Logger +} + +func newEphemeralKeyPairGeneratingMember( + index memberIndex, + sessionID string, + dishonestThreshold uint16, + groupSize uint16, + logger Logger, +) *ephemeralKeyPairGeneratingMember { + return &ephemeralKeyPairGeneratingMember{ + member: &member{ + memberIndex: index, + sessionID: sessionID, + group: newGroup(dishonestThreshold, groupSize), + evidenceLog: newEvidenceLog(), + logger: logger, + }, + ephemeralKeyPairs: make(map[memberIndex]*ephemeral.KeyPair), + } +} + +// ephemeralKeyPairGeneratingMember represents one member in a distributed key +// generating group performing ephemeral key pair generation. +// +// Executes Phase 1 of the GJKR protocol. +type ephemeralKeyPairGeneratingMember struct { + *member + + // Ephemeral key pairs used to create symmetric keys, + // generated individually for each other group member. + ephemeralKeyPairs map[memberIndex]*ephemeral.KeyPair +} + +func (e *ephemeralKeyPairGeneratingMember) next() *symmetricKeyGeneratingMember { + return &symmetricKeyGeneratingMember{ + ephemeralKeyPairGeneratingMember: e, + symmetricKeys: make(map[memberIndex]ephemeral.SymmetricKey), + } +} + +// symmetricKeyGeneratingMember represents one member in a distributed key +// generating group performing ephemeral symmetric key generation. +// +// Executes Phase 2 of the GJKR protocol. +type symmetricKeyGeneratingMember struct { + *ephemeralKeyPairGeneratingMember + + // Symmetric keys used to encrypt confidential information, + // generated individually for each other group member by ECDH'ing the + // broadcasted ephemeral public key intended for this member and the + // ephemeral private key generated for the other member. + symmetricKeys map[memberIndex]ephemeral.SymmetricKey +} diff --git a/gjkr/message.go b/gjkr/message.go new file mode 100644 index 0000000..8779c82 --- /dev/null +++ b/gjkr/message.go @@ -0,0 +1,31 @@ +package gjkr + +import "threshold.network/roast/ephemeral" + +// ephemeralPublicKeyMessage is a message payload that carries the sender's +// ephemeral public keys generated for all other group members. +// +// The receiver performs ECDH on a sender's ephemeral public key intended for +// the receiver and on the receiver's private ephemeral key, creating a symmetric +// key used for encrypting a conversation between the sender and the receiver. +// In case of an accusation for malicious behavior, the accusing party reveals +// its private ephemeral key so that all the other group members can resolve the +// accusation looking at messages exchanged between accuser and accused party. +// To validate correctness of accuser's private ephemeral key, all group members +// must know its ephemeral public key prior to exchanging any messages. Hence, +// this message contains all the generated public keys and it is broadcast +// within the group. +type ephemeralPublicKeyMessage struct { + senderIndex memberIndex // i + sessionID string + + ephemeralPublicKeys map[memberIndex]*ephemeral.PublicKey // j -> Y_ij +} + +func (m *ephemeralPublicKeyMessage) getSenderIndex() memberIndex { + return m.senderIndex +} + +func (m *ephemeralPublicKeyMessage) getSessionID() string { + return m.sessionID +} diff --git a/gjkr/message_filter.go b/gjkr/message_filter.go new file mode 100644 index 0000000..9cbfd7a --- /dev/null +++ b/gjkr/message_filter.go @@ -0,0 +1,92 @@ +package gjkr + +// filterForSession goes through the messages passed as a parameter and finds +// all messages sent for the given session ID. +func filterForSession[T interface{ getSessionID() string }]( + sessionID string, + list []T, +) []T { + result := make([]T, 0) + + for _, msg := range list { + if msg.getSessionID() == sessionID { + result = append(result, msg) + } + } + + return result +} + +// findInactive goes through the messages passed as a parameter and finds all +// inactive members for this set of messages. The function does not care if +// the given member was already marked as inactive before. The function makes no +// assumptions about the ordering of the list elements. +func findInactive[T interface{ getSenderIndex() memberIndex }]( + groupSize uint16, + list []T, +) []memberIndex { + senders := make(map[memberIndex]bool) + for _, item := range list { + senders[item.getSenderIndex()] = true + } + + inactive := make([]memberIndex, 0) + for i := uint16(1); i <= groupSize; i++ { + if !senders[memberIndex(i)] { + inactive = append(inactive, memberIndex(i)) + } + } + + return inactive +} + +// deduplicateBySender removes duplicated items for the given sender. It always +// takes the first item that occurs for the given sender and ignores the +// subsequent ones. +func deduplicateBySender[T interface{ getSenderIndex() memberIndex }]( + list []T, +) []T { + senders := make(map[memberIndex]bool) + result := make([]T, 0) + + for _, msg := range list { + if _, exists := senders[msg.getSenderIndex()]; !exists { + senders[msg.getSenderIndex()] = true + result = append(result, msg) + } + } + + return result +} + +// removeFromSelf filters out the from-self message from the provided message +// list. +func removeFromSelf[T interface{ getSenderIndex() memberIndex }]( + selfMemberIndex memberIndex, + list []T, +) []T { + result := make([]T, 0) + + for _, msg := range list { + if msg.getSenderIndex() != selfMemberIndex { + result = append(result, msg) + } + } + + return result +} + +func (m *symmetricKeyGeneratingMember) preProcessMessages( + ephemeralPubKeyMessages []*ephemeralPublicKeyMessage, +) []*ephemeralPublicKeyMessage { + forThisSession := filterForSession(m.sessionID, ephemeralPubKeyMessages) + deduplicated := deduplicateBySender(forThisSession) + withoutSelf := removeFromSelf(m.memberIndex, deduplicated) + + inactiveMembers := findInactive(m.group.groupSize, forThisSession) + for _, ia := range inactiveMembers { + m.group.markMemberAsInactive(ia) + } + + return withoutSelf +} diff --git a/gjkr/message_filter_test.go b/gjkr/message_filter_test.go new file mode 100644 index 0000000..f7fc4eb --- /dev/null +++ b/gjkr/message_filter_test.go @@ -0,0 +1,129 @@ +package gjkr + +import ( + "testing" + + "threshold.network/roast/internal/testutils" +) + +func TestFilterForSession(t *testing.T) { + msg1 := &ephemeralPublicKeyMessage{sessionID: "session-2", senderIndex: 1} + msg2 := &ephemeralPublicKeyMessage{sessionID: "session-1", senderIndex: 1} + msg3 := &ephemeralPublicKeyMessage{sessionID: "session-1", senderIndex: 2} + msg4 := &ephemeralPublicKeyMessage{sessionID: "session-2", senderIndex: 3} + + filtered := filterForSession("session-1", []*ephemeralPublicKeyMessage{ + msg1, msg2, msg3, msg4, + }) + + testutils.AssertDeepEqual( + t, + "filtered messages", + []*ephemeralPublicKeyMessage{msg2, msg3}, + filtered, + ) +} + +func TestRemoveFromSelf(t *testing.T) { + msg1 := &ephemeralPublicKeyMessage{sessionID: "session-1", senderIndex: 1} + msg2 := &ephemeralPublicKeyMessage{sessionID: "session-1", senderIndex: 2} + msg3 := &ephemeralPublicKeyMessage{sessionID: "session-1", senderIndex: 3} + msg4 := &ephemeralPublicKeyMessage{sessionID: "session-1", senderIndex: 4} + + filtered := removeFromSelf(memberIndex(2), []*ephemeralPublicKeyMessage{ + msg1, msg2, msg3, msg4, + }) + + testutils.AssertDeepEqual( + t, + "filtered messages", + []*ephemeralPublicKeyMessage{msg1, msg3, msg4}, + filtered, + ) +} + +func TestFindInactive(t *testing.T) { + var tests = map[string]struct { + groupSize uint16 + senders []memberIndex + expectedIA []memberIndex + }{ + "with no inactive senders": { + groupSize: 5, + senders: []memberIndex{1, 4, 3, 2, 5}, + expectedIA: []memberIndex{}, + }, + "with inactivity and senders ordered": { + groupSize: 5, + senders: []memberIndex{1, 3, 5}, + expectedIA: []memberIndex{2, 4}, + }, + "with inactivity and senders not ordered": { + groupSize: 5, + senders: []memberIndex{5, 1, 3}, + expectedIA: []memberIndex{2, 4}, + }, + "with all senders inactive": { + groupSize: 5, + senders: []memberIndex{}, + expectedIA: []memberIndex{1, 2, 3, 4, 5}, + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + messages := make([]*ephemeralPublicKeyMessage, len(test.senders)) + for i, senderIndex := range test.senders { + messages[i] = &ephemeralPublicKeyMessage{senderIndex: senderIndex} + } + + ia := findInactive(test.groupSize, messages) + testutils.AssertUint16SlicesEqual( + t, + "inactive members", + test.expectedIA, + ia, + ) + }) + } +} + +func TestDeduplicateBySender(t *testing.T) { + var tests = map[string]struct { + senders []memberIndex + expectedDeduplicated []memberIndex + }{ + "with no duplicates": { + senders: []memberIndex{1, 4, 3, 2, 5}, + expectedDeduplicated: []memberIndex{1, 4, 3, 2, 5}, + }, + "with duplicates and senders ordered": { + senders: []memberIndex{1, 1, 2, 3, 3, 4, 5, 5}, + expectedDeduplicated: []memberIndex{1, 2, 3, 4, 5}, + }, + "with duplicates and senders not ordered": { + senders: []memberIndex{5, 2, 5, 3, 1, 3, 3, 2, 5, 4, 5}, + expectedDeduplicated: []memberIndex{5, 2, 3, 1, 4}, + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + messages := make([]*ephemeralPublicKeyMessage, len(test.senders)) + for i, senderIndex := range test.senders { + messages[i] = &ephemeralPublicKeyMessage{senderIndex: senderIndex} + } + + deduplicatedSenders := make([]memberIndex, 0) + for _, msg := range deduplicateBySender(messages) { + deduplicatedSenders = append(deduplicatedSenders, msg.getSenderIndex()) + } + testutils.AssertUint16SlicesEqual( + t, + "deduplicated senders", + test.expectedDeduplicated, + deduplicatedSenders, + ) + }) + } +} diff --git a/gjkr/protocol.go b/gjkr/protocol.go new file mode 100644 index 0000000..d83f212 --- /dev/null +++ b/gjkr/protocol.go @@ -0,0 +1,137 @@ +package gjkr + +import ( + "fmt" + + "threshold.network/roast/ephemeral" +) + +// generateEphemeralKeyPair takes the group member list and generates an +// ephemeral ECDH keypair for every other group member. Generated public +// ephemeral keys are broadcasted within the group. +// +// See Phase 1 of the protocol specification. +func (m *ephemeralKeyPairGeneratingMember) generateEphemeralKeyPair() ( + *ephemeralPublicKeyMessage, + error, +) { + ephemeralKeys := make(map[memberIndex]*ephemeral.PublicKey) + + // Calculate ephemeral key pair for every other group member + for _, member := range m.group.allMemberIndexes { + if member == m.memberIndex { + // don’t actually generate a key with ourselves + continue + } + + ephemeralKeyPair, err := ephemeral.GenerateKeyPair() + if err != nil { + return nil, err + } + + // save the generated ephemeral key to our state + m.ephemeralKeyPairs[member] = ephemeralKeyPair + + // store the public key to the map for the message + ephemeralKeys[member] = ephemeralKeyPair.PublicKey + } + + return &ephemeralPublicKeyMessage{ + senderIndex: m.memberIndex, + sessionID: m.sessionID, + ephemeralPublicKeys: ephemeralKeys, + }, nil +} + +// generateSymmetricKeys attempts to generate symmetric keys for all remote group +// members via ECDH. It generates this symmetric key for each remote group member +// by doing an ECDH between the ephemeral private key generated for a remote +// group member, and the public key for this member, generated and broadcasted by +// the remote group member. +// +// See Phase 2 of the protocol specification. +func (m *symmetricKeyGeneratingMember) generateSymmetricKeys( + ephemeralPubKeyMessages []*ephemeralPublicKeyMessage, +) error { + for _, ephemeralPubKeyMessage := range m.preProcessMessages( + ephemeralPubKeyMessages, + ) { + otherMember := ephemeralPubKeyMessage.senderIndex + + if !m.isValidEphemeralPublicKeyMessage(ephemeralPubKeyMessage) { + m.logger.Warn( + "other member disqualified for sending invalid "+ + "ephemeral public key message", + "thisMember", m.memberIndex, + "otherMember", otherMember, + ) + m.group.markMemberAsDisqualified(otherMember) + continue + } + + err := m.evidenceLog.putEphemeralPublicKeyMessage(ephemeralPubKeyMessage) + if err != nil { + m.logger.Error( + "could not put ephemeral key message from other member "+ + "to the evidence log", + "thisMember", m.memberIndex, + "otherMember", otherMember, + "error", err, + ) + } + + // Find the ephemeral key pair generated by this group member for + // the other group member. + ephemeralKeyPair, ok := m.ephemeralKeyPairs[otherMember] + if !ok { + return fmt.Errorf( + "ephemeral key pair does not exist for member %v", + otherMember, + ) + } + + // Get the ephemeral private key generated by this group member for + // the other group member. + thisMemberEphemeralPrivateKey := ephemeralKeyPair.PrivateKey + + // Get the ephemeral public key broadcasted by the other group member, + // which was intended for this group member. + otherMemberEphemeralPublicKey := ephemeralPubKeyMessage.ephemeralPublicKeys[m.memberIndex] + + // Create symmetric key for the current group member and the other + // group member by ECDH'ing the public and private key. + symmetricKey := thisMemberEphemeralPrivateKey.Ecdh( + otherMemberEphemeralPublicKey, + ) + m.symmetricKeys[otherMember] = symmetricKey + } + + return nil +} + +// isValidEphemeralPublicKeyMessage validates a given EphemeralPublicKeyMessage. +// Message is considered valid if it contains ephemeral public keys for +// all other group members. +func (m *symmetricKeyGeneratingMember) isValidEphemeralPublicKeyMessage( + message *ephemeralPublicKeyMessage, +) bool { + for _, index := range m.group.allMemberIndexes { + if index == message.senderIndex { + // Message contains ephemeral public keys only for other group members + continue + } + + if _, ok := message.ephemeralPublicKeys[index]; !ok { + m.logger.Warn( + "ephemeral public key message from other member "+ + "does not contain required public key", + "thisMember", m.memberIndex, + "otherMember", message.senderIndex, + "missingPubKeyMember", index, + ) + return false + } + } + + return true +} diff --git a/gjkr/protocol_ecdh_test.go b/gjkr/protocol_ecdh_test.go new file mode 100644 index 0000000..dd124ee --- /dev/null +++ b/gjkr/protocol_ecdh_test.go @@ -0,0 +1,99 @@ +package gjkr + +import ( + "fmt" + "reflect" + "testing" + + "threshold.network/roast/internal/testutils" +) + +func TestGenerateSymmetricKeys(t *testing.T) { + groupSize := 100 + + members := initializeGroup(70, uint16(groupSize)) + ephemeralPublicKeyMessages, symmetricKeyGeneratingMembers := executePhase1( + t, + members, + ) + executePhase2(t, symmetricKeyGeneratingMembers, ephemeralPublicKeyMessages) + + // Ensure that for each member, we generated the correct number of + // symmetric keys (groupSize - 1 keys) + for _, member := range symmetricKeyGeneratingMembers { + symmetricKeys := member.symmetricKeys + keySlice := reflect.ValueOf(symmetricKeys).MapKeys() + testutils.AssertIntsEqual( + t, + "number of generated symmetric keys", + groupSize-1, + len(keySlice), + ) + } +} + +type mockLogger struct{} + +func (ml *mockLogger) Info(msg string, keyVals ...any) { + fmt.Printf("INFO: %s {%v}", msg, keyVals) +} + +func (ml *mockLogger) Warn(msg string, keyVals ...any) { + fmt.Printf("WARN: %s {%v}", msg, keyVals) +} + +func (ml *mockLogger) Error(msg string, keyVals ...any) { + fmt.Printf("ERROR: %s {%v}", msg, keyVals) +} + +func initializeGroup( + dishonestThreshold uint16, + groupSize uint16, +) []*ephemeralKeyPairGeneratingMember { + members := make([]*ephemeralKeyPairGeneratingMember, 0) + + for idx := uint16(1); idx <= groupSize; idx++ { + members = append(members, newEphemeralKeyPairGeneratingMember( + memberIndex(idx), + "test-dkg-session", + dishonestThreshold, + groupSize, + &mockLogger{}, + )) + } + + return members +} + +func executePhase1( + t *testing.T, + members []*ephemeralKeyPairGeneratingMember, +) ([]*ephemeralPublicKeyMessage, []*symmetricKeyGeneratingMember) { + messages := make([]*ephemeralPublicKeyMessage, len(members)) + nextMembers := make([]*symmetricKeyGeneratingMember, len(members)) + + for i, member := range members { + message, err := member.generateEphemeralKeyPair() + if err != nil { + t.Fatal(err) + } + messages[i] = message + + nextMembers[i] = member.next() + } + + return messages, nextMembers +} + +func executePhase2( + t *testing.T, + members []*symmetricKeyGeneratingMember, + messages []*ephemeralPublicKeyMessage, +) { + for _, member := range members { + err := member.generateSymmetricKeys(messages) + if err != nil { + t.Fatal(err) + } + } +} diff --git a/go.mod b/go.mod index 59be37b..13455e9 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,11 @@ module threshold.network/roast -go 1.21.0 +go 1.22.2 require ( github.com/btcsuite/btcd v0.20.1-beta golang.org/x/crypto v0.14.0 + golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c ) require ( diff --git a/go.sum b/go.sum index be81ad2..5a77338 100644 --- a/go.sum +++ b/go.sum @@ -13,6 +13,7 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= @@ -23,12 +24,20 @@ github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1Cpa golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY= +golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8= +golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= diff --git a/internal/testutils/assert.go b/internal/testutils/assert.go index 1c29bc1..e8523ce 100644 --- a/internal/testutils/assert.go +++ b/internal/testutils/assert.go @@ -3,7 +3,10 @@ package testutils import ( "fmt" "math/big" + "reflect" "testing" + + "golang.org/x/exp/slices" ) // AssertBigIntNonZero checks if the provided not-nil big integer is non-zero. @@ -116,3 +119,35 @@ func testBytesEqual(expectedBytes []byte, actualBytes []byte) error { return nil } + +func AssertUint16SlicesEqual[T ~uint16]( + t *testing.T, + description string, + expected []T, + actual []T, +) { + if !slices.Equal(expected, actual) { + t.Errorf( + "unexpected %s\nexpected: %v\nactual: %v\n", + description, + expected, + actual, + ) + } +} + +func AssertDeepEqual( + t *testing.T, + description string, + expected any, + actual any, +) { + if !reflect.DeepEqual(expected, actual) { + t.Errorf( + "unexpected %s\nexpected: %v\nactual: %v\n", + description, + expected, + actual, + ) + } +}