From 0ff72151e5837e0cac2992d6243f92f95d1210f3 Mon Sep 17 00:00:00 2001 From: Piotr Dyraga Date: Thu, 10 Oct 2024 15:29:21 +0200 Subject: [PATCH 01/14] GJKR protocol group code Group structure 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. This code has been ported from keep-network/keep-core random beacon's GJKR with small tweaks. --- gjkr/group.go | 78 ++++++++++++++++++ gjkr/group_test.go | 194 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 272 insertions(+) create mode 100644 gjkr/group.go create mode 100644 gjkr/group_test.go diff --git a/gjkr/group.go b/gjkr/group.go new file mode 100644 index 0000000..b95656f --- /dev/null +++ b/gjkr/group.go @@ -0,0 +1,78 @@ +package gjkr + +// 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 { + for _, inactiveMemberIndex := range g.inactiveMemberIndexes { + if memberIndex == inactiveMemberIndex { + return true + } + } + + return false +} + +func (g *group) isDisqualified(memberIndex memberIndex) bool { + for _, disqualifiedMemberIndex := range g.disqualifiedMemberIndexes { + if memberIndex == disqualifiedMemberIndex { + return true + } + } + + return false +} 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), + ) +} From 60227fa1c744bf1b37dca026848aa53597969c34 Mon Sep 17 00:00:00 2001 From: Piotr Dyraga Date: Thu, 10 Oct 2024 15:34:05 +0200 Subject: [PATCH 02/14] Logger interface for GJKR protocol GJKR protocol allows the key generation to continue even if some misbehavior inside of the group is detected. We do not want to stop protocols returning an error yet we want to log all the misbehavior. To not complicate the API of GJKR, a Logger interface implementation will have to be provided when instantiating GJKR protocol members. The current implementation is aligned with the Cosmos SDK logger interface but the client code for the library can implements their own adapters. --- gjkr/logger.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 gjkr/logger.go 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) +} From f81defc33af30270e74b9a6286e021c19947a4f6 Mon Sep 17 00:00:00 2001 From: Piotr Dyraga Date: Thu, 10 Oct 2024 15:55:21 +0200 Subject: [PATCH 03/14] GJKR message evidence log GJKR protocol requires group members to have access to messages exchanged between the accuser and the accused party for the stake of complaint resolution. This is what the evidence log provides. For now, only functions for the ephemeral public key message are included. The code has been ported from keep-network/keep-core GJKR implementation for the random beacon with small changes. --- gjkr/evidence_log.go | 112 ++++++++++++++++++++++++++++++++++++++ gjkr/evidence_log_test.go | 61 +++++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 gjkr/evidence_log.go create mode 100644 gjkr/evidence_log_test.go diff --git a/gjkr/evidence_log.go b/gjkr/evidence_log.go new file mode 100644 index 0000000..a2bc12a --- /dev/null +++ b/gjkr/evidence_log.go @@ -0,0 +1,112 @@ +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 interface { + // getEphemeralPublicKeyMessage returns the `ephemeralPublicKeyMessage` + // broadcast in the first protocol round by the given sender. + getEphemeralPublicKeyMessage(sender memberIndex) *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. + putEphemeralPublicKeyMessage(pubKeyMessage *ephemeralPublicKeyMessage) error +} + +// dkgEvidenceLog is the default implementation of an evidenceLog. +type dkgEvidenceLog struct { + // senderIndex -> *ephemeralPublicKeyMessage + pubKeyMessageLog *messageStorage +} + +func newDkgEvidenceLog() *dkgEvidenceLog { + return &dkgEvidenceLog{ + pubKeyMessageLog: newMessageStorage(), + } +} + +func (d *dkgEvidenceLog) putEphemeralPublicKeyMessage( + pubKeyMessage *ephemeralPublicKeyMessage, +) error { + return d.pubKeyMessageLog.putMessage( + pubKeyMessage.senderIndex, + pubKeyMessage, + ) +} + +func (d *dkgEvidenceLog) getEphemeralPublicKeyMessage( + sender memberIndex, +) *ephemeralPublicKeyMessage { + storedMessage := d.pubKeyMessageLog.getMessage(sender) + switch message := storedMessage.(type) { + case *ephemeralPublicKeyMessage: + return message + } + return nil +} + +type messageStorage struct { + cache map[memberIndex]interface{} + cacheLock sync.Mutex +} + +func newMessageStorage() *messageStorage { + return &messageStorage{ + cache: make(map[memberIndex]interface{}), + } +} + +func (ms *messageStorage) getMessage(sender memberIndex) interface{} { + ms.cacheLock.Lock() + defer ms.cacheLock.Unlock() + + message, ok := ms.cache[sender] + if !ok { + return nil + } + + return message +} + +func (ms *messageStorage) putMessage( + sender memberIndex, message interface{}, +) 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..3ad0187 --- /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) { + dkgEvidenceLog := newDkgEvidenceLog() + err := dkgEvidenceLog.putEphemeralPublicKeyMessage( + &ephemeralPublicKeyMessage{ + senderIndex: memberIndex(1), + }) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + err = dkgEvidenceLog.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) { + dkgEvidenceLog := newDkgEvidenceLog() + + message := &ephemeralPublicKeyMessage{ + senderIndex: memberIndex(1), + } + + m := dkgEvidenceLog.getEphemeralPublicKeyMessage(memberIndex(1)) + if m != nil { + t.Fatalf("expected message not to be found but has [%v]", m) + } + + err := dkgEvidenceLog.putEphemeralPublicKeyMessage(message) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + m = dkgEvidenceLog.getEphemeralPublicKeyMessage(memberIndex(1)) + if !reflect.DeepEqual(message, m) { + t.Fatalf( + "unexpected message\nexpected: %v\nactual: %v", + message, + m, + ) + } +} From 64205f44140e1b3f277590aad8333c8fe28491c1 Mon Sep 17 00:00:00 2001 From: Piotr Dyraga Date: Fri, 11 Oct 2024 13:22:05 +0200 Subject: [PATCH 04/14] The first two steps for the key generation protocol The first two steps generate a symmetric key between every two group members for the further communication over the broadcast channel. 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. 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. The code was ported from keep-network/keep-core random beacon with small changes. There are two important parts missing that will be addressed in separate commits: - No member inactivity marking, - Unit tests for the two steps. --- gjkr/member.go | 42 +++++++++++++ gjkr/message.go | 26 ++++++++ gjkr/protocol.go | 155 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 223 insertions(+) create mode 100644 gjkr/member.go create mode 100644 gjkr/message.go create mode 100644 gjkr/protocol.go diff --git a/gjkr/member.go b/gjkr/member.go new file mode 100644 index 0000000..0895558 --- /dev/null +++ b/gjkr/member.go @@ -0,0 +1,42 @@ +package gjkr + +import "threshold.network/roast/ephemeral" + +type memberIndex uint16 + +// member includes the core pieces of GJKR protocol member important for every +// phase of the protocol. +type member struct { + memberIndex memberIndex + group *group + + evidenceLog evidenceLog + + logger Logger +} + +// 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 +} + +// 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..fb23a94 --- /dev/null +++ b/gjkr/message.go @@ -0,0 +1,26 @@ +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 + + ephemeralPublicKeys map[memberIndex]*ephemeral.PublicKey // j -> Y_ij +} + +func (m *ephemeralPublicKeyMessage) senderIdx() memberIndex { + return m.senderIndex +} diff --git a/gjkr/protocol.go b/gjkr/protocol.go new file mode 100644 index 0000000..d92e8ca --- /dev/null +++ b/gjkr/protocol.go @@ -0,0 +1,155 @@ +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, + 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 deduplicateBySender( + 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 +} + +// 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{ senderIdx() memberIndex }]( + list []T, +) []T { + senders := make(map[memberIndex]bool) + result := make([]T, 0) + + for _, item := range list { + if _, exists := senders[item.senderIdx()]; !exists { + senders[item.senderIdx()] = true + result = append(result, item) + } + } + + return result +} From 20254f7538f83b9167140c6170331b575be51887 Mon Sep 17 00:00:00 2001 From: Piotr Dyraga Date: Mon, 21 Oct 2024 09:15:41 +0200 Subject: [PATCH 05/14] Removed separate dkgEvidenceLog implementation struct It does not make sense to separate the interface from the implementation as we keep them in the same package and will use the concrete implementation for the code and tests. --- gjkr/evidence_log.go | 33 +++++++++++++-------------------- gjkr/evidence_log_test.go | 14 +++++++------- 2 files changed, 20 insertions(+), 27 deletions(-) diff --git a/gjkr/evidence_log.go b/gjkr/evidence_log.go index a2bc12a..8d344af 100644 --- a/gjkr/evidence_log.go +++ b/gjkr/evidence_log.go @@ -27,43 +27,36 @@ import ( // 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 interface { - // getEphemeralPublicKeyMessage returns the `ephemeralPublicKeyMessage` - // broadcast in the first protocol round by the given sender. - getEphemeralPublicKeyMessage(sender memberIndex) *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. - putEphemeralPublicKeyMessage(pubKeyMessage *ephemeralPublicKeyMessage) error -} - -// dkgEvidenceLog is the default implementation of an evidenceLog. -type dkgEvidenceLog struct { +type evidenceLog struct { // senderIndex -> *ephemeralPublicKeyMessage pubKeyMessageLog *messageStorage } -func newDkgEvidenceLog() *dkgEvidenceLog { - return &dkgEvidenceLog{ +func newEvidenceLog() *evidenceLog { + return &evidenceLog{ pubKeyMessageLog: newMessageStorage(), } } -func (d *dkgEvidenceLog) putEphemeralPublicKeyMessage( +// 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 d.pubKeyMessageLog.putMessage( + return e.pubKeyMessageLog.putMessage( pubKeyMessage.senderIndex, pubKeyMessage, ) } -func (d *dkgEvidenceLog) getEphemeralPublicKeyMessage( +// getEphemeralPublicKeyMessage returns the `ephemeralPublicKeyMessage` +// broadcast in the first protocol round by the given sender. +func (e *evidenceLog) getEphemeralPublicKeyMessage( sender memberIndex, ) *ephemeralPublicKeyMessage { - storedMessage := d.pubKeyMessageLog.getMessage(sender) + storedMessage := e.pubKeyMessageLog.getMessage(sender) switch message := storedMessage.(type) { case *ephemeralPublicKeyMessage: return message diff --git a/gjkr/evidence_log_test.go b/gjkr/evidence_log_test.go index 3ad0187..a7c86b4 100644 --- a/gjkr/evidence_log_test.go +++ b/gjkr/evidence_log_test.go @@ -8,8 +8,8 @@ import ( ) func TestPutEphemeralPublicKeyMessageTwice(t *testing.T) { - dkgEvidenceLog := newDkgEvidenceLog() - err := dkgEvidenceLog.putEphemeralPublicKeyMessage( + evidenceLog := newEvidenceLog() + err := evidenceLog.putEphemeralPublicKeyMessage( &ephemeralPublicKeyMessage{ senderIndex: memberIndex(1), }) @@ -17,7 +17,7 @@ func TestPutEphemeralPublicKeyMessageTwice(t *testing.T) { t.Fatalf("unexpected error: [%v]", err) } - err = dkgEvidenceLog.putEphemeralPublicKeyMessage( + err = evidenceLog.putEphemeralPublicKeyMessage( &ephemeralPublicKeyMessage{ senderIndex: memberIndex(1), }) @@ -34,23 +34,23 @@ func TestPutEphemeralPublicKeyMessageTwice(t *testing.T) { } func TestPutGetEphemeralPublicKeyMessage(t *testing.T) { - dkgEvidenceLog := newDkgEvidenceLog() + evidenceLog := newEvidenceLog() message := &ephemeralPublicKeyMessage{ senderIndex: memberIndex(1), } - m := dkgEvidenceLog.getEphemeralPublicKeyMessage(memberIndex(1)) + m := evidenceLog.getEphemeralPublicKeyMessage(memberIndex(1)) if m != nil { t.Fatalf("expected message not to be found but has [%v]", m) } - err := dkgEvidenceLog.putEphemeralPublicKeyMessage(message) + err := evidenceLog.putEphemeralPublicKeyMessage(message) if err != nil { t.Fatalf("unexpected error: [%v]", err) } - m = dkgEvidenceLog.getEphemeralPublicKeyMessage(memberIndex(1)) + m = evidenceLog.getEphemeralPublicKeyMessage(memberIndex(1)) if !reflect.DeepEqual(message, m) { t.Fatalf( "unexpected message\nexpected: %v\nactual: %v", From baff3adfa326fcd3b9ad0f18d9b4661c761502d5 Mon Sep 17 00:00:00 2001 From: Piotr Dyraga Date: Mon, 21 Oct 2024 09:42:08 +0200 Subject: [PATCH 06/14] RWMutex and generic type used for messageStore The messageStore structure used internally for the evidenceLog can use generics for a shorter code. We could also use RWMutex to separate read and write locks. --- gjkr/evidence_log.go | 40 ++++++++++++++-------------------------- 1 file changed, 14 insertions(+), 26 deletions(-) diff --git a/gjkr/evidence_log.go b/gjkr/evidence_log.go index 8d344af..2f11c08 100644 --- a/gjkr/evidence_log.go +++ b/gjkr/evidence_log.go @@ -29,12 +29,12 @@ import ( // accused party. The key is publicly revealed by the accuser. type evidenceLog struct { // senderIndex -> *ephemeralPublicKeyMessage - pubKeyMessageLog *messageStorage + pubKeyMessageLog *messageStorage[*ephemeralPublicKeyMessage] } func newEvidenceLog() *evidenceLog { return &evidenceLog{ - pubKeyMessageLog: newMessageStorage(), + pubKeyMessageLog: newMessageStorage[*ephemeralPublicKeyMessage](), } } @@ -56,40 +56,28 @@ func (e *evidenceLog) putEphemeralPublicKeyMessage( func (e *evidenceLog) getEphemeralPublicKeyMessage( sender memberIndex, ) *ephemeralPublicKeyMessage { - storedMessage := e.pubKeyMessageLog.getMessage(sender) - switch message := storedMessage.(type) { - case *ephemeralPublicKeyMessage: - return message - } - return nil + return e.pubKeyMessageLog.getMessage(sender) } -type messageStorage struct { - cache map[memberIndex]interface{} - cacheLock sync.Mutex +type messageStorage[T interface{}] struct { + cache map[memberIndex]T + cacheLock sync.RWMutex } -func newMessageStorage() *messageStorage { - return &messageStorage{ - cache: make(map[memberIndex]interface{}), +func newMessageStorage[T interface{}]() *messageStorage[T] { + return &messageStorage[T]{ + cache: make(map[memberIndex]T), } } -func (ms *messageStorage) getMessage(sender memberIndex) interface{} { - ms.cacheLock.Lock() - defer ms.cacheLock.Unlock() +func (ms *messageStorage[T]) getMessage(sender memberIndex) T { + ms.cacheLock.RLock() + defer ms.cacheLock.RUnlock() - message, ok := ms.cache[sender] - if !ok { - return nil - } - - return message + return ms.cache[sender] } -func (ms *messageStorage) putMessage( - sender memberIndex, message interface{}, -) error { +func (ms *messageStorage[T]) putMessage(sender memberIndex, message T) error { ms.cacheLock.Lock() defer ms.cacheLock.Unlock() From 2ea14cd3186f29fa647588b8f4222c6d74af43fe Mon Sep 17 00:00:00 2001 From: Piotr Dyraga Date: Mon, 21 Oct 2024 09:49:16 +0200 Subject: [PATCH 07/14] Use slices.Contains for isInactive and isDiqualified With this version of Go we can use slices.Contains instead of reinventing the wheel. --- gjkr/group.go | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/gjkr/group.go b/gjkr/group.go index b95656f..3550b30 100644 --- a/gjkr/group.go +++ b/gjkr/group.go @@ -1,5 +1,7 @@ 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. @@ -58,21 +60,9 @@ func (g *group) isInGroup(memberIndex memberIndex) bool { } func (g *group) isInactive(memberIndex memberIndex) bool { - for _, inactiveMemberIndex := range g.inactiveMemberIndexes { - if memberIndex == inactiveMemberIndex { - return true - } - } - - return false + return slices.Contains(g.inactiveMemberIndexes, memberIndex) } func (g *group) isDisqualified(memberIndex memberIndex) bool { - for _, disqualifiedMemberIndex := range g.disqualifiedMemberIndexes { - if memberIndex == disqualifiedMemberIndex { - return true - } - } - - return false + return slices.Contains(g.disqualifiedMemberIndexes, memberIndex) } From 78a4eaedfbadad89a2d1909e524233713f3acb74 Mon Sep 17 00:00:00 2001 From: Piotr Dyraga Date: Mon, 21 Oct 2024 10:02:45 +0200 Subject: [PATCH 08/14] Better documentation for the memberIndex type --- gjkr/member.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/gjkr/member.go b/gjkr/member.go index 0895558..d21199c 100644 --- a/gjkr/member.go +++ b/gjkr/member.go @@ -2,6 +2,9 @@ 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 From f7fe459572b0b7b71a2f27d0658b15b21a14f24d Mon Sep 17 00:00:00 2001 From: Piotr Dyraga Date: Tue, 22 Oct 2024 23:40:49 +0200 Subject: [PATCH 09/14] Bumped up Go version to 1.22.2 This will allow us to use slices.Equal (to be used in the next commit). Also, 1.22.2 is the version used by mezo-org/mezod that will probably be the first integration of this library. --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 59be37b..98c3b22 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module threshold.network/roast -go 1.21.0 +go 1.22.2 require ( github.com/btcsuite/btcd v0.20.1-beta From 12980f7f0a6cce697e56bbe44a1817a0ed695825 Mon Sep 17 00:00:00 2001 From: Piotr Dyraga Date: Tue, 22 Oct 2024 23:42:33 +0200 Subject: [PATCH 10/14] Detect and mark inactive members after the ephemeral pubkey generation The phase 2 of the GJKR protocol generating symmetric keys should find members inactive in phase 1 and mark them accordingly. I introduced this logic in a new file, message_filter.go and moved there the deduplication logic as well. The goal is to have all the message pre-processing for all phases in one place. For phase 2, the pre-processing includes deduplication, inactive member detection, and session ID handling (to be added in the next commits). For further phases, we will also have to filter out members marked as inactive or disqualified in previous phases of the protocol. --- gjkr/message_filter.go | 55 +++++++++++++++++++++ gjkr/message_filter_test.go | 93 ++++++++++++++++++++++++++++++++++++ gjkr/protocol.go | 21 +------- go.mod | 1 + go.sum | 9 ++++ internal/testutils/assert.go | 18 +++++++ 6 files changed, 177 insertions(+), 20 deletions(-) create mode 100644 gjkr/message_filter.go create mode 100644 gjkr/message_filter_test.go diff --git a/gjkr/message_filter.go b/gjkr/message_filter.go new file mode 100644 index 0000000..7d553f1 --- /dev/null +++ b/gjkr/message_filter.go @@ -0,0 +1,55 @@ +package gjkr + +// 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{ senderIdx() memberIndex }]( + groupSize uint16, list []T, +) []memberIndex { + senders := make(map[memberIndex]bool) + for _, item := range list { + senders[item.senderIdx()] = 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{ senderIdx() memberIndex }]( + list []T, +) []T { + senders := make(map[memberIndex]bool) + result := make([]T, 0) + + for _, item := range list { + if _, exists := senders[item.senderIdx()]; !exists { + senders[item.senderIdx()] = true + result = append(result, item) + } + } + + return result +} + +func (m *symmetricKeyGeneratingMember) preProcessMessages( + ephemeralPubKeyMessages []*ephemeralPublicKeyMessage, +) []*ephemeralPublicKeyMessage { + inactiveMembers := findInactive(m.group.groupSize, ephemeralPubKeyMessages) + for _, ia := range inactiveMembers { + m.group.markMemberAsInactive(ia) + } + + // TODO: validate session ID + + return deduplicateBySender(ephemeralPubKeyMessages) +} diff --git a/gjkr/message_filter_test.go b/gjkr/message_filter_test.go new file mode 100644 index 0000000..7f4f6f4 --- /dev/null +++ b/gjkr/message_filter_test.go @@ -0,0 +1,93 @@ +package gjkr + +import ( + "testing" + + "threshold.network/roast/internal/testutils" +) + +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.senderIdx()) + } + testutils.AssertUint16SlicesEqual( + t, + "deduplicated senders", + test.expectedDeduplicated, + deduplicatedSenders, + ) + }) + } +} diff --git a/gjkr/protocol.go b/gjkr/protocol.go index d92e8ca..b2e5ec8 100644 --- a/gjkr/protocol.go +++ b/gjkr/protocol.go @@ -52,7 +52,7 @@ func (m *ephemeralKeyPairGeneratingMember) generateEphemeralKeyPair() ( func (m *symmetricKeyGeneratingMember) generateSymmetricKeys( ephemeralPubKeyMessages []*ephemeralPublicKeyMessage, ) error { - for _, ephemeralPubKeyMessage := range deduplicateBySender( + for _, ephemeralPubKeyMessage := range m.preProcessMessages( ephemeralPubKeyMessages, ) { otherMember := ephemeralPubKeyMessage.senderIndex @@ -134,22 +134,3 @@ func (m *symmetricKeyGeneratingMember) isValidEphemeralPublicKeyMessage( return true } - -// 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{ senderIdx() memberIndex }]( - list []T, -) []T { - senders := make(map[memberIndex]bool) - result := make([]T, 0) - - for _, item := range list { - if _, exists := senders[item.senderIdx()]; !exists { - senders[item.senderIdx()] = true - result = append(result, item) - } - } - - return result -} diff --git a/go.mod b/go.mod index 98c3b22..13455e9 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ 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..8c0a0b5 100644 --- a/internal/testutils/assert.go +++ b/internal/testutils/assert.go @@ -4,6 +4,8 @@ import ( "fmt" "math/big" "testing" + + "golang.org/x/exp/slices" ) // AssertBigIntNonZero checks if the provided not-nil big integer is non-zero. @@ -116,3 +118,19 @@ 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, + ) + } +} From 02c828c3c1e7507bfa37f7690ea2a3004c565a3d Mon Sep 17 00:00:00 2001 From: Piotr Dyraga Date: Wed, 23 Oct 2024 09:08:30 +0200 Subject: [PATCH 11/14] Filter out ephemeral pub key messages by session ID Added message pre-processing step ensuring messages with a session ID different from the current one are filtered out. This protects against the replay attacks on the protocol level. --- gjkr/member.go | 2 +- gjkr/message.go | 7 +++++- gjkr/message_filter.go | 42 +++++++++++++++++++++++++----------- gjkr/message_filter_test.go | 20 ++++++++++++++++- internal/testutils/assert.go | 17 +++++++++++++++ 5 files changed, 73 insertions(+), 15 deletions(-) diff --git a/gjkr/member.go b/gjkr/member.go index d21199c..3ee9c64 100644 --- a/gjkr/member.go +++ b/gjkr/member.go @@ -11,8 +11,8 @@ type memberIndex uint16 // phase of the protocol. type member struct { memberIndex memberIndex + sessionID string group *group - evidenceLog evidenceLog logger Logger diff --git a/gjkr/message.go b/gjkr/message.go index fb23a94..8779c82 100644 --- a/gjkr/message.go +++ b/gjkr/message.go @@ -17,10 +17,15 @@ import "threshold.network/roast/ephemeral" // within the group. type ephemeralPublicKeyMessage struct { senderIndex memberIndex // i + sessionID string ephemeralPublicKeys map[memberIndex]*ephemeral.PublicKey // j -> Y_ij } -func (m *ephemeralPublicKeyMessage) senderIdx() memberIndex { +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 index 7d553f1..1c8ea77 100644 --- a/gjkr/message_filter.go +++ b/gjkr/message_filter.go @@ -1,15 +1,33 @@ 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{ senderIdx() memberIndex }]( - groupSize uint16, list []T, +func findInactive[T interface{ getSenderIndex() memberIndex }]( + groupSize uint16, + list []T, ) []memberIndex { senders := make(map[memberIndex]bool) for _, item := range list { - senders[item.senderIdx()] = true + senders[item.getSenderIndex()] = true } inactive := make([]memberIndex, 0) @@ -25,16 +43,16 @@ func findInactive[T interface{ senderIdx() memberIndex }]( // 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{ senderIdx() memberIndex }]( +func deduplicateBySender[T interface{ getSenderIndex() memberIndex }]( list []T, ) []T { senders := make(map[memberIndex]bool) result := make([]T, 0) - for _, item := range list { - if _, exists := senders[item.senderIdx()]; !exists { - senders[item.senderIdx()] = true - result = append(result, item) + for _, msg := range list { + if _, exists := senders[msg.getSenderIndex()]; !exists { + senders[msg.getSenderIndex()] = true + result = append(result, msg) } } @@ -44,12 +62,12 @@ func deduplicateBySender[T interface{ senderIdx() memberIndex }]( func (m *symmetricKeyGeneratingMember) preProcessMessages( ephemeralPubKeyMessages []*ephemeralPublicKeyMessage, ) []*ephemeralPublicKeyMessage { - inactiveMembers := findInactive(m.group.groupSize, ephemeralPubKeyMessages) + forThisSession := filterForSession(m.sessionID, ephemeralPubKeyMessages) + + inactiveMembers := findInactive(m.group.groupSize, forThisSession) for _, ia := range inactiveMembers { m.group.markMemberAsInactive(ia) } - // TODO: validate session ID - - return deduplicateBySender(ephemeralPubKeyMessages) + return deduplicateBySender(forThisSession) } diff --git a/gjkr/message_filter_test.go b/gjkr/message_filter_test.go index 7f4f6f4..74e0224 100644 --- a/gjkr/message_filter_test.go +++ b/gjkr/message_filter_test.go @@ -6,6 +6,24 @@ import ( "threshold.network/roast/internal/testutils" ) +func TestFilterForSession(t *testing.T) { + msg0 := &ephemeralPublicKeyMessage{sessionID: "session-2", senderIndex: 1} + msg1 := &ephemeralPublicKeyMessage{sessionID: "session-1", senderIndex: 1} + msg2 := &ephemeralPublicKeyMessage{sessionID: "session-1", senderIndex: 2} + msg3 := &ephemeralPublicKeyMessage{sessionID: "session-2", senderIndex: 3} + + filtered := filterForSession("session-1", []*ephemeralPublicKeyMessage{ + msg0, msg1, msg2, msg3, + }) + + testutils.AssertDeepEqual( + t, + "filtered messages", + []*ephemeralPublicKeyMessage{msg1, msg2}, + filtered, + ) +} + func TestFindInactive(t *testing.T) { var tests = map[string]struct { groupSize uint16 @@ -80,7 +98,7 @@ func TestDeduplicateBySender(t *testing.T) { deduplicatedSenders := make([]memberIndex, 0) for _, msg := range deduplicateBySender(messages) { - deduplicatedSenders = append(deduplicatedSenders, msg.senderIdx()) + deduplicatedSenders = append(deduplicatedSenders, msg.getSenderIndex()) } testutils.AssertUint16SlicesEqual( t, diff --git a/internal/testutils/assert.go b/internal/testutils/assert.go index 8c0a0b5..e8523ce 100644 --- a/internal/testutils/assert.go +++ b/internal/testutils/assert.go @@ -3,6 +3,7 @@ package testutils import ( "fmt" "math/big" + "reflect" "testing" "golang.org/x/exp/slices" @@ -134,3 +135,19 @@ func AssertUint16SlicesEqual[T ~uint16]( ) } } + +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, + ) + } +} From ec297fd2b78b45010dd4618ef7bfdab67078856e Mon Sep 17 00:00:00 2001 From: Piotr Dyraga Date: Fri, 25 Oct 2024 16:55:53 +0200 Subject: [PATCH 12/14] Include the session ID when constructing ephemeral pubkey message In the previous commit a filtering by session ID was implemented but I missed adding the session ID to the ephemeralPublicKeyMessage generated by the first phase of the protocol. Doing it now. --- gjkr/protocol.go | 1 + 1 file changed, 1 insertion(+) diff --git a/gjkr/protocol.go b/gjkr/protocol.go index b2e5ec8..d83f212 100644 --- a/gjkr/protocol.go +++ b/gjkr/protocol.go @@ -38,6 +38,7 @@ func (m *ephemeralKeyPairGeneratingMember) generateEphemeralKeyPair() ( return &ephemeralPublicKeyMessage{ senderIndex: m.memberIndex, + sessionID: m.sessionID, ephemeralPublicKeys: ephemeralKeys, }, nil } From 8449f5d23b30d188a2b8ac3b65fbdc98f99b2b97 Mon Sep 17 00:00:00 2001 From: Piotr Dyraga Date: Fri, 25 Oct 2024 16:59:21 +0200 Subject: [PATCH 13/14] Filter out from-self messages in GJKR When we pre-process messages before executing GJKR protocol phase, we now also remove the from-self message from the list. --- gjkr/message_filter.go | 21 ++++++++++++++++++++- gjkr/message_filter_test.go | 28 +++++++++++++++++++++++----- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/gjkr/message_filter.go b/gjkr/message_filter.go index 1c8ea77..9cbfd7a 100644 --- a/gjkr/message_filter.go +++ b/gjkr/message_filter.go @@ -59,15 +59,34 @@ func deduplicateBySender[T interface{ getSenderIndex() memberIndex }]( 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 deduplicateBySender(forThisSession) + return withoutSelf } diff --git a/gjkr/message_filter_test.go b/gjkr/message_filter_test.go index 74e0224..f7fc4eb 100644 --- a/gjkr/message_filter_test.go +++ b/gjkr/message_filter_test.go @@ -7,19 +7,37 @@ import ( ) func TestFilterForSession(t *testing.T) { - msg0 := &ephemeralPublicKeyMessage{sessionID: "session-2", senderIndex: 1} + 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-2", senderIndex: 3} + msg3 := &ephemeralPublicKeyMessage{sessionID: "session-1", senderIndex: 3} + msg4 := &ephemeralPublicKeyMessage{sessionID: "session-1", senderIndex: 4} - filtered := filterForSession("session-1", []*ephemeralPublicKeyMessage{ - msg0, msg1, msg2, msg3, + filtered := removeFromSelf(memberIndex(2), []*ephemeralPublicKeyMessage{ + msg1, msg2, msg3, msg4, }) testutils.AssertDeepEqual( t, "filtered messages", - []*ephemeralPublicKeyMessage{msg1, msg2}, + []*ephemeralPublicKeyMessage{msg1, msg3, msg4}, filtered, ) } From c8a5ae46347821251557cd0e2c4e9b500ca576d9 Mon Sep 17 00:00:00 2001 From: Piotr Dyraga Date: Fri, 25 Oct 2024 17:08:07 +0200 Subject: [PATCH 14/14] Test for the first two phases of GJKR protocol The test executes phase 1 and phase 2 and ensures the expected number of symmetric keys has been generated. --- gjkr/member.go | 28 ++++++++++- gjkr/protocol_ecdh_test.go | 99 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 gjkr/protocol_ecdh_test.go diff --git a/gjkr/member.go b/gjkr/member.go index 3ee9c64..d5068e7 100644 --- a/gjkr/member.go +++ b/gjkr/member.go @@ -13,11 +13,30 @@ type member struct { memberIndex memberIndex sessionID string group *group - evidenceLog evidenceLog + 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. // @@ -30,6 +49,13 @@ type ephemeralKeyPairGeneratingMember struct { 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. // 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) + } + } +}