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.
This commit is contained in:
Sarah Jamie Lewis 2021-05-14 11:26:04 -07:00
parent 6b0d9827fb
commit 08bb2f907f
7 changed files with 199 additions and 52 deletions

View File

@ -66,6 +66,10 @@ const (
//TimestampReceived, TimestampSent, Data(Message), GroupID, Signature, PreviousSignature, RemotePeer //TimestampReceived, TimestampSent, Data(Message), GroupID, Signature, PreviousSignature, RemotePeer
NewMessageFromGroup = Type("NewMessageFromGroup") 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 // an error was encountered trying to send a particular Message to a group
// attributes: // attributes:
// GroupServer: The server the Message was sent to // GroupServer: The server the Message was sent to

View File

@ -1,29 +1,38 @@
package model package model
import ( import (
"crypto/ed25519"
"crypto/rand" "crypto/rand"
"crypto/sha512"
"cwtch.im/cwtch/model/attr" "cwtch.im/cwtch/model/attr"
"cwtch.im/cwtch/protocol/groups" "cwtch.im/cwtch/protocol/groups"
"encoding/base32"
"encoding/base64" "encoding/base64"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"git.openprivacy.ca/openprivacy/connectivity/tor" "git.openprivacy.ca/openprivacy/connectivity/tor"
"git.openprivacy.ca/openprivacy/log" "git.openprivacy.ca/openprivacy/log"
"golang.org/x/crypto/nacl/secretbox" "golang.org/x/crypto/nacl/secretbox"
"golang.org/x/crypto/pbkdf2"
"io" "io"
"strings"
"sync" "sync"
"time" "time"
) )
// CurrentGroupVersion is used to set the version of newly created groups and make sure group structs stored are correct and up to date // 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 // 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. // tied to a server under a given group key. Each group has a set of Messages.
type Group struct { type Group struct {
// GroupID is now derived from the GroupKey and the GroupServer
GroupID string GroupID string
SignedGroupID []byte
GroupKey [32]byte GroupKey [32]byte
GroupServer string GroupServer string
Timeline Timeline `json:"-"` Timeline Timeline `json:"-"`
@ -62,6 +71,11 @@ func NewGroup(server string) (*Group, error) {
return nil, err return nil, err
} }
copy(group.GroupKey[:], groupKey[:]) 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) 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 // 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. // codes around invite, we assume that this is always set.
@ -69,13 +83,13 @@ func NewGroup(server string) (*Group, error) {
return group, nil return group, nil
} }
// SignGroup adds a signature to the group. func deriveGroupID(groupKey []byte, serverHostname string) string {
func (g *Group) SignGroup(signature []byte) { data, _ := base32.StdEncoding.DecodeString(strings.ToUpper(serverHostname))
g.SignedGroupID = signature pubkey := data[0:ed25519.PublicKeySize]
copy(g.Timeline.SignedGroupID[:], g.SignedGroupID) 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() { func (g *Group) Compromised() {
g.IsCompromised = true g.IsCompromised = true
} }
@ -83,20 +97,15 @@ func (g *Group) Compromised() {
// Invite generates a invitation that can be sent to a cwtch peer // Invite generates a invitation that can be sent to a cwtch peer
func (g *Group) Invite() (string, error) { func (g *Group) Invite() (string, error) {
if g.SignedGroupID == nil {
return "", errors.New("group isn't signed")
}
gci := &groups.GroupInvite{ gci := &groups.GroupInvite{
GroupID: g.GroupID, GroupID: g.GroupID,
GroupName: g.Attributes[attr.GetLocalScope("name")], GroupName: g.Attributes[attr.GetLocalScope("name")],
SharedKey: g.GroupKey[:], SharedKey: g.GroupKey[:],
ServerHost: g.GroupServer, ServerHost: g.GroupServer,
SignedGroupID: g.SignedGroupID[:],
} }
invite, err := json.Marshal(gci) 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 return serializedInvite, err
} }
@ -221,3 +230,41 @@ func (g *Group) GetAttribute(name string) (value string, exists bool) {
value, exists = g.Attributes[name] value, exists = g.Attributes[name]
return 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")
}

View File

@ -1,7 +1,10 @@
package model package model
import ( import (
"crypto/sha256"
"cwtch.im/cwtch/protocol/groups" "cwtch.im/cwtch/protocol/groups"
"strings"
"sync"
"testing" "testing"
"time" "time"
) )
@ -16,6 +19,23 @@ func TestGroup(t *testing.T) {
PreviousMessageSig: []byte{}, PreviousMessageSig: []byte{},
Padding: []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) encMessage, _ := g.EncryptMessage(dgm)
ok, message := g.DecryptMessage(encMessage) ok, message := g.DecryptMessage(encMessage)
if !ok || message.Text != "Hello World!" { if !ok || message.Text != "Hello World!" {
@ -36,3 +56,60 @@ func TestGroupErr(t *testing.T) {
t.Errorf("Group Setup Should Have Failed") 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)
}

View File

@ -4,7 +4,6 @@ import (
"crypto/rand" "crypto/rand"
"cwtch.im/cwtch/protocol/groups" "cwtch.im/cwtch/protocol/groups"
"encoding/base32" "encoding/base32"
"encoding/base64"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
@ -304,20 +303,34 @@ func (p *Profile) GetContact(onion string) (*PublicProfile, bool) {
return contact, ok return contact, ok
} }
// VerifyGroupMessage confirms the authenticity of a message given an onion, message and signature. // VerifyGroupMessage confirms the authenticity of a message given an sender onion, ciphertext and signature.
func (p *Profile) VerifyGroupMessage(onion string, groupID string, message string, timestamp int32, ciphertext []byte, signature []byte) bool { // 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) group := p.GetGroup(groupID)
if group == nil { if group == nil {
return false 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 { if onion == p.Onion {
m := groupID + group.GroupServer + string(ciphertext)
return ed25519.Verify(p.Ed25519PublicKey, []byte(m), signature) 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)) decodedPub, err := base32.StdEncoding.DecodeString(strings.ToUpper(onion))
if err == nil && len(decodedPub) >= 32 { if err == nil && len(decodedPub) >= 32 {
return ed25519.Verify(decodedPub[:32], []byte(m), signature) 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 return "", "", err
} }
groupID = group.GroupID groupID = group.GroupID
signedGroupID := p.SignMessage(groupID + server)
group.SignGroup(signedGroupID)
invite, err = group.Invite() invite, err = group.Invite()
p.lock.Lock() p.lock.Lock()
defer p.lock.Unlock() defer p.lock.Unlock()
@ -356,29 +367,23 @@ func (p *Profile) GetGroup(groupID string) (g *Group) {
return 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) { func (p *Profile) ProcessInvite(invite string) (string, error) {
if strings.HasPrefix(invite, "torv3") { gci, err := ValidateInvite(invite)
data, err := base64.StdEncoding.DecodeString(invite[5:]) if err == nil {
if err == nil { group := new(Group)
var gci groups.GroupInvite group.Version = CurrentGroupVersion
err := json.Unmarshal(data, &gci) group.GroupID = gci.GroupID
if err == nil { group.LocalID = GenerateRandomID()
group := new(Group) copy(group.GroupKey[:], gci.SharedKey[:])
group.Version = CurrentGroupVersion group.GroupServer = gci.ServerHost
group.GroupID = gci.GroupID group.Accepted = false
group.LocalID = GenerateRandomID() group.Attributes = make(map[string]string)
group.SignedGroupID = gci.SignedGroupID p.AddGroup(group)
copy(group.GroupKey[:], gci.SharedKey[:]) return gci.GroupID, nil
group.GroupServer = gci.ServerHost
group.Accepted = false
group.Attributes = make(map[string]string)
p.AddGroup(group)
return group.GroupID, nil
}
}
} }
return "", errors.New("unsupported exported group type") return "", err
} }
// AddGroup is a convenience method for adding a group to a profile. // 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 { for _, group := range p.Groups {
success, dgm := group.DecryptMessage(ciphertext) success, dgm := group.DecryptMessage(ciphertext)
if success { 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. // 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) // 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 { if group != nil {
timestamp := time.Now().Unix() timestamp := time.Now().Unix()
// Select the latest message from the timeline as a reference point.
var prevSig []byte var prevSig []byte
if len(group.Timeline.Messages) > 0 { if len(group.Timeline.Messages) > 0 {
prevSig = group.Timeline.Messages[len(group.Timeline.Messages)-1].Signature prevSig = group.Timeline.Messages[len(group.Timeline.Messages)-1].Signature
} else { } else {
prevSig = group.SignedGroupID prevSig = []byte(group.GroupID)
} }
lenPadding := MaxGroupMessageLength - len(message) lenPadding := MaxGroupMessageLength - len(message)
padding := make([]byte, lenPadding) padding := make([]byte, lenPadding)
getRandomness(&padding) getRandomness(&padding)
hexGroupID, err := hex.DecodeString(group.GroupID)
if err != nil {
return nil, nil, err
}
dm := &groups.DecryptedGroupMessage{ dm := &groups.DecryptedGroupMessage{
Onion: p.Onion, Onion: p.Onion,
Text: message, Text: message,
SignedGroupID: group.SignedGroupID[:], SignedGroupID: hexGroupID,
Timestamp: uint64(timestamp), Timestamp: uint64(timestamp),
PreviousMessageSig: prevSig, PreviousMessageSig: prevSig,
Padding: padding[:], Padding: padding[:],

View File

@ -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})) 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 case event.NewMessageFromPeer: //event.TimestampReceived, event.RemotePeer, event.Data
ts, _ := time.Parse(time.RFC3339Nano, ev.Data[event.TimestampReceived]) ts, _ := time.Parse(time.RFC3339Nano, ev.Data[event.TimestampReceived])
cp.StoreMessage(ev.Data[event.RemotePeer], ev.Data[event.Data], ts) cp.StoreMessage(ev.Data[event.RemotePeer], ev.Data[event.Data], ts)

View File

@ -21,9 +21,13 @@ type GroupInvite struct {
// DecryptedGroupMessage is the main encapsulation of group message data // DecryptedGroupMessage is the main encapsulation of group message data
type DecryptedGroupMessage struct { type DecryptedGroupMessage struct {
Text string Text string
Onion string Onion string
Timestamp uint64 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 SignedGroupID []byte
PreviousMessageSig []byte PreviousMessageSig []byte
Padding []byte Padding []byte

View File

@ -73,7 +73,6 @@ func main() {
// TODO create a random group for testing // TODO create a random group for testing
group, _ := model.NewGroup(tor.GetTorV3Hostname(serverConfig.PublicKey)) group, _ := model.NewGroup(tor.GetTorV3Hostname(serverConfig.PublicKey))
group.SignGroup([]byte{})
invite, err := group.Invite() invite, err := group.Invite()
if err != nil { if err != nil {
panic(err) panic(err)