diff --git a/event/common.go b/event/common.go index 7616359..02460df 100644 --- a/event/common.go +++ b/event/common.go @@ -66,6 +66,10 @@ const ( //TimestampReceived, TimestampSent, Data(Message), GroupID, Signature, PreviousSignature, RemotePeer NewMessageFromGroup = Type("NewMessageFromGroup") + // Sent if a Group Key is detected as being used outside of expected parameters (e.g. with tampered signatures) + // GroupID: The ID of the Group that is presumed compromised + GroupCompromised = Type("GroupCompromised") + // an error was encountered trying to send a particular Message to a group // attributes: // GroupServer: The server the Message was sent to diff --git a/model/group.go b/model/group.go index 0fa13fc..d901b87 100644 --- a/model/group.go +++ b/model/group.go @@ -1,29 +1,38 @@ package model import ( + "crypto/ed25519" "crypto/rand" + "crypto/sha512" "cwtch.im/cwtch/model/attr" "cwtch.im/cwtch/protocol/groups" + "encoding/base32" "encoding/base64" + "encoding/hex" "encoding/json" "errors" "fmt" "git.openprivacy.ca/openprivacy/connectivity/tor" "git.openprivacy.ca/openprivacy/log" "golang.org/x/crypto/nacl/secretbox" + "golang.org/x/crypto/pbkdf2" "io" + "strings" "sync" "time" ) // CurrentGroupVersion is used to set the version of newly created groups and make sure group structs stored are correct and up to date -const CurrentGroupVersion = 2 +const CurrentGroupVersion = 3 + +// GroupInvitePrefix identifies a particular string as being a serialized group invite. +const GroupInvitePrefix = "torv3" // Group defines and encapsulates Cwtch's conception of group chat. Which are sessions // tied to a server under a given group key. Each group has a set of Messages. type Group struct { + // GroupID is now derived from the GroupKey and the GroupServer GroupID string - SignedGroupID []byte GroupKey [32]byte GroupServer string Timeline Timeline `json:"-"` @@ -62,6 +71,11 @@ func NewGroup(server string) (*Group, error) { return nil, err } copy(group.GroupKey[:], groupKey[:]) + + // Derive Group ID from the group key and the server public key. This binds the group to a particular server + // and key. + group.GroupID = deriveGroupID(groupKey[:], server) + group.Attributes = make(map[string]string) // By default we set the "name" of the group to a random string, we can override this later, but to simplify the // codes around invite, we assume that this is always set. @@ -69,13 +83,13 @@ func NewGroup(server string) (*Group, error) { return group, nil } -// SignGroup adds a signature to the group. -func (g *Group) SignGroup(signature []byte) { - g.SignedGroupID = signature - copy(g.Timeline.SignedGroupID[:], g.SignedGroupID) +func deriveGroupID(groupKey []byte, serverHostname string) string { + data, _ := base32.StdEncoding.DecodeString(strings.ToUpper(serverHostname)) + pubkey := data[0:ed25519.PublicKeySize] + return hex.EncodeToString(pbkdf2.Key(groupKey, pubkey, 4, 16, sha512.New)) } -// Compromised should be called if we detect a a groupkey leak. +// Compromised should be called if we detect a a groupkey leak func (g *Group) Compromised() { g.IsCompromised = true } @@ -83,20 +97,15 @@ func (g *Group) Compromised() { // Invite generates a invitation that can be sent to a cwtch peer func (g *Group) Invite() (string, error) { - if g.SignedGroupID == nil { - return "", errors.New("group isn't signed") - } - gci := &groups.GroupInvite{ - GroupID: g.GroupID, - GroupName: g.Attributes[attr.GetLocalScope("name")], - SharedKey: g.GroupKey[:], - ServerHost: g.GroupServer, - SignedGroupID: g.SignedGroupID[:], + GroupID: g.GroupID, + GroupName: g.Attributes[attr.GetLocalScope("name")], + SharedKey: g.GroupKey[:], + ServerHost: g.GroupServer, } invite, err := json.Marshal(gci) - serializedInvite := fmt.Sprintf("torv3%v", base64.StdEncoding.EncodeToString(invite)) + serializedInvite := fmt.Sprintf("%v%v", GroupInvitePrefix, base64.StdEncoding.EncodeToString(invite)) return serializedInvite, err } @@ -221,3 +230,41 @@ func (g *Group) GetAttribute(name string) (value string, exists bool) { value, exists = g.Attributes[name] return } + +// ValidateInvite takes in a serialized invite and returns the invite structure if it is cryptographically valid +// and an error if it is not +func ValidateInvite(invite string) (*groups.GroupInvite, error) { + // We prefix invites for groups with torv3 + if strings.HasPrefix(invite, GroupInvitePrefix) { + data, err := base64.StdEncoding.DecodeString(invite[5:]) + if err == nil { + // First attempt to unmarshal the json... + var gci groups.GroupInvite + err := json.Unmarshal(data, &gci) + if err == nil { + + // Validate the Invite by first checking that the server is a valid v3 onion + if tor.IsValidHostname(gci.ServerHost) == false { + return nil, errors.New("server is not a valid v3 onion") + } + + // Validate the length of the shared key... + if len(gci.SharedKey) != 32 { + return nil, errors.New("key length is not 32 bytes") + } + + // Derive the servers public key (we can ignore the error checking here because it's already been + // done by IsValidHostname, and check that we derive the same groupID... + derivedGroupID := deriveGroupID(gci.SharedKey, gci.ServerHost) + if derivedGroupID != gci.GroupID { + return nil, errors.New("group id is invalid") + } + + // Replace the original with the derived, this should be a no-op at this point but defense in depth... + gci.GroupID = derivedGroupID + return &gci, nil + } + } + } + return nil, errors.New("invite has invalid structure") +} diff --git a/model/group_test.go b/model/group_test.go index 782e364..904887f 100644 --- a/model/group_test.go +++ b/model/group_test.go @@ -1,7 +1,10 @@ package model import ( + "crypto/sha256" "cwtch.im/cwtch/protocol/groups" + "strings" + "sync" "testing" "time" ) @@ -16,6 +19,23 @@ func TestGroup(t *testing.T) { PreviousMessageSig: []byte{}, Padding: []byte{}, } + + invite, err := g.Invite() + + if err != nil { + t.Fatalf("error creating group invite: %v", err) + } + + validatedInvite, err := ValidateInvite(invite) + + if err != nil { + t.Fatalf("error validating group invite: %v", err) + } + + if validatedInvite.GroupID != g.GroupID { + t.Fatalf("after validate group invite id should be identical to original: %v", err) + } + encMessage, _ := g.EncryptMessage(dgm) ok, message := g.DecryptMessage(encMessage) if !ok || message.Text != "Hello World!" { @@ -36,3 +56,60 @@ func TestGroupErr(t *testing.T) { t.Errorf("Group Setup Should Have Failed") } } + +// Test various group invite validation failures... +func TestGroupValidation(t *testing.T) { + + group := &Group{ + GroupID: "", + GroupKey: [32]byte{}, + GroupServer: "", + Timeline: Timeline{}, + Accepted: false, + IsCompromised: false, + Attributes: nil, + lock: sync.Mutex{}, + LocalID: "", + State: "", + UnacknowledgedMessages: nil, + Version: 0, + } + + invite, _ := group.Invite() + _, err := ValidateInvite(invite) + + if err == nil { + t.Fatalf("Group with empty group id should have been an error") + } + t.Logf("Error: %v", err) + + // Generate a valid group but replace the group server... + group, _ = NewGroup("2c3kmoobnyghj2zw6pwv7d57yzld753auo3ugauezzpvfak3ahc4bdyd") + group.GroupServer = "tcnkoch4nyr3cldkemejtkpqok342rbql6iclnjjs3ndgnjgufzyxvqd" + invite, _ = group.Invite() + _, err = ValidateInvite(invite) + + if err == nil { + t.Fatalf("Group with empty group id should have been an error") + } + t.Logf("Error: %v", err) + + // Generate a valid group but replace the group key... + group, _ = NewGroup("2c3kmoobnyghj2zw6pwv7d57yzld753auo3ugauezzpvfak3ahc4bdyd") + group.GroupKey = sha256.Sum256([]byte{}) + invite, _ = group.Invite() + _, err = ValidateInvite(invite) + + if err == nil { + t.Fatalf("Group with different group key should have errored") + } + t.Logf("Error: %v", err) + + // mangle the invite + _, err = ValidateInvite(strings.ReplaceAll(invite, GroupInvitePrefix, "")) + if err == nil { + t.Fatalf("Group with different group key should have errored") + } + t.Logf("Error: %v", err) + +} diff --git a/model/profile.go b/model/profile.go index fceac0a..b326989 100644 --- a/model/profile.go +++ b/model/profile.go @@ -4,7 +4,6 @@ import ( "crypto/rand" "cwtch.im/cwtch/protocol/groups" "encoding/base32" - "encoding/base64" "encoding/hex" "encoding/json" "errors" @@ -304,20 +303,34 @@ func (p *Profile) GetContact(onion string) (*PublicProfile, bool) { return contact, ok } -// VerifyGroupMessage confirms the authenticity of a message given an onion, message and signature. -func (p *Profile) VerifyGroupMessage(onion string, groupID string, message string, timestamp int32, ciphertext []byte, signature []byte) bool { +// VerifyGroupMessage confirms the authenticity of a message given an sender onion, ciphertext and signature. +// The goal of this function is 2-fold: +// 1. We confirm that the sender referenced in the group text is the actual sender of the message (or at least +// knows the senders private key) +// 2. Secondly, we confirm that the sender sent the message to a particular group id on a specific server (it doesn't +// matter if we actually received this message from the server or from a hybrid protocol, all that matters is +// that the sender and receivers agree that this message was intended for the group +// The 2nd point is important as it prevents an attack documented in the original Cwtch paper (and later at +// https://docs.openprivacy.ca/cwtch-security-handbook/groups.html) in which a malicious profile sets up 2 groups +// on two different servers with the same key and then forwards messages between them to convince the parties in +// each group that they are actually in one big group (with the intent to later censor and/or selectively send messages +// to each group). +func (p *Profile) VerifyGroupMessage(onion string, groupID string, ciphertext []byte, signature []byte) bool { group := p.GetGroup(groupID) if group == nil { return false } + // We use our group id, a known reference server and the ciphertext of the message. + m := groupID + group.GroupServer + string(ciphertext) + + // If the message is ostensibly from us then we check it against our public key... if onion == p.Onion { - m := groupID + group.GroupServer + string(ciphertext) return ed25519.Verify(p.Ed25519PublicKey, []byte(m), signature) } - m := groupID + group.GroupServer + string(ciphertext) + // Otherwise we derive the public key from the sender and check it against that. decodedPub, err := base32.StdEncoding.DecodeString(strings.ToUpper(onion)) if err == nil && len(decodedPub) >= 32 { return ed25519.Verify(decodedPub[:32], []byte(m), signature) @@ -339,8 +352,6 @@ func (p *Profile) StartGroup(server string) (groupID string, invite string, err return "", "", err } groupID = group.GroupID - signedGroupID := p.SignMessage(groupID + server) - group.SignGroup(signedGroupID) invite, err = group.Invite() p.lock.Lock() defer p.lock.Unlock() @@ -356,29 +367,23 @@ func (p *Profile) GetGroup(groupID string) (g *Group) { return } -// ProcessInvite adds a new group invite to the profile. returns the new group ID +// ProcessInvite validates a group invite and adds a new group invite to the profile if it is valid. +// returns the new group ID on success, error on fail. func (p *Profile) ProcessInvite(invite string) (string, error) { - if strings.HasPrefix(invite, "torv3") { - data, err := base64.StdEncoding.DecodeString(invite[5:]) - if err == nil { - var gci groups.GroupInvite - err := json.Unmarshal(data, &gci) - if err == nil { - group := new(Group) - group.Version = CurrentGroupVersion - group.GroupID = gci.GroupID - group.LocalID = GenerateRandomID() - group.SignedGroupID = gci.SignedGroupID - copy(group.GroupKey[:], gci.SharedKey[:]) - group.GroupServer = gci.ServerHost - group.Accepted = false - group.Attributes = make(map[string]string) - p.AddGroup(group) - return group.GroupID, nil - } - } + gci, err := ValidateInvite(invite) + if err == nil { + group := new(Group) + group.Version = CurrentGroupVersion + group.GroupID = gci.GroupID + group.LocalID = GenerateRandomID() + copy(group.GroupKey[:], gci.SharedKey[:]) + group.GroupServer = gci.ServerHost + group.Accepted = false + group.Attributes = make(map[string]string) + p.AddGroup(group) + return gci.GroupID, nil } - return "", errors.New("unsupported exported group type") + return "", err } // AddGroup is a convenience method for adding a group to a profile. @@ -397,7 +402,7 @@ func (p *Profile) AttemptDecryption(ciphertext []byte, signature []byte) (bool, for _, group := range p.Groups { success, dgm := group.DecryptMessage(ciphertext) if success { - verified := p.VerifyGroupMessage(dgm.Onion, group.GroupID, dgm.Text, int32(dgm.Timestamp), ciphertext, signature) + verified := p.VerifyGroupMessage(dgm.Onion, group.GroupID, ciphertext, signature) // So we have a message that has a valid group key, but the signature can't be verified. // The most obvious explanation for this is that the group key has been compromised (or we are in an open group and the server is being malicious) @@ -437,21 +442,26 @@ func (p *Profile) EncryptMessageToGroup(message string, groupID string) ([]byte, if group != nil { timestamp := time.Now().Unix() + // Select the latest message from the timeline as a reference point. var prevSig []byte if len(group.Timeline.Messages) > 0 { prevSig = group.Timeline.Messages[len(group.Timeline.Messages)-1].Signature } else { - prevSig = group.SignedGroupID + prevSig = []byte(group.GroupID) } lenPadding := MaxGroupMessageLength - len(message) padding := make([]byte, lenPadding) getRandomness(&padding) + hexGroupID, err := hex.DecodeString(group.GroupID) + if err != nil { + return nil, nil, err + } dm := &groups.DecryptedGroupMessage{ Onion: p.Onion, Text: message, - SignedGroupID: group.SignedGroupID[:], + SignedGroupID: hexGroupID, Timestamp: uint64(timestamp), PreviousMessageSig: prevSig, Padding: padding[:], diff --git a/peer/cwtch_peer.go b/peer/cwtch_peer.go index 3e4d16a..7ac59eb 100644 --- a/peer/cwtch_peer.go +++ b/peer/cwtch_peer.go @@ -688,6 +688,12 @@ func (cp *cwtchPeer) eventHandler() { cp.eventBus.Publish(event.NewEvent(event.NewMessageFromGroup, map[event.Field]string{event.TimestampReceived: message.Received.Format(time.RFC3339Nano), event.TimestampSent: message.Timestamp.Format(time.RFC3339Nano), event.Data: message.Message, event.GroupID: groupID, event.Signature: base64.StdEncoding.EncodeToString(message.Signature), event.PreviousSignature: base64.StdEncoding.EncodeToString(message.PreviousMessageSig), event.RemotePeer: message.PeerID})) } + // The group has been compromised + if !ok && groupID != "" { + if cp.Profile.GetGroup(groupID).IsCompromised { + cp.eventBus.Publish(event.NewEvent(event.GroupCompromised, map[event.Field]string{event.GroupID: groupID})) + } + } case event.NewMessageFromPeer: //event.TimestampReceived, event.RemotePeer, event.Data ts, _ := time.Parse(time.RFC3339Nano, ev.Data[event.TimestampReceived]) cp.StoreMessage(ev.Data[event.RemotePeer], ev.Data[event.Data], ts) diff --git a/protocol/groups/common.go b/protocol/groups/common.go index d4e83de..718765c 100644 --- a/protocol/groups/common.go +++ b/protocol/groups/common.go @@ -21,9 +21,13 @@ type GroupInvite struct { // DecryptedGroupMessage is the main encapsulation of group message data type DecryptedGroupMessage struct { - Text string - Onion string - Timestamp uint64 + Text string + Onion string + Timestamp uint64 + // NOTE: SignedGroupID is now a misnomer, the only way this is signed is indirectly via the signed encrypted group messages + // We now treat GroupID as binding to a server/key rather than an "owner" - additional validation logic (to e.g. + // respect particular group constitutions) can be built on top of group messages, but the underlying groups are + // now agnostic to those models. SignedGroupID []byte PreviousMessageSig []byte Padding []byte diff --git a/server/app/main.go b/server/app/main.go index f25871e..fd16517 100644 --- a/server/app/main.go +++ b/server/app/main.go @@ -73,7 +73,6 @@ func main() { // TODO create a random group for testing group, _ := model.NewGroup(tor.GetTorV3Hostname(serverConfig.PublicKey)) - group.SignGroup([]byte{}) invite, err := group.Invite() if err != nil { panic(err)