From 08bb2f907f4e0275d74a9c46c0221a1c5c53a844 Mon Sep 17 00:00:00 2001 From: Sarah Jamie Lewis Date: Fri, 14 May 2021 11:26:04 -0700 Subject: [PATCH] Replace old GroupID with new Dervied GroupID As we move towards a group model that allows for different management constitutions we need to deprecate our old group security model that relied on "owners" and transitive signing/verification checks. This new model derives GroupID from the GroupKey and the GroupServer binding it both. This allows participants to know if a message was intended for the same group they are apart of (as GroupID is included in every encrypted/signed message to Groups) while allowing more dynamic management protocols to be built on top of the (now agnostic) group protocols. This PR also adds more validation logic to invites and provides the ValidateInvite function to allow the UI to validate invites separately from processing them. --- event/common.go | 4 ++ model/group.go | 81 +++++++++++++++++++++++++++++++-------- model/group_test.go | 77 +++++++++++++++++++++++++++++++++++++ model/profile.go | 72 +++++++++++++++++++--------------- peer/cwtch_peer.go | 6 +++ protocol/groups/common.go | 10 +++-- server/app/main.go | 1 - 7 files changed, 199 insertions(+), 52 deletions(-) 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)