Merge pull request 'Replace old GroupID with new Dervied GroupID' (#357) from groupwiring into master
Reviewed-on: #357
This commit is contained in:
commit
0957aefdff
|
@ -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
|
||||
|
|
2
go.mod
2
go.mod
|
@ -3,7 +3,7 @@ module cwtch.im/cwtch
|
|||
go 1.14
|
||||
|
||||
require (
|
||||
git.openprivacy.ca/cwtch.im/tapir v0.4.0
|
||||
git.openprivacy.ca/cwtch.im/tapir v0.4.1
|
||||
git.openprivacy.ca/openprivacy/connectivity v1.4.3
|
||||
git.openprivacy.ca/openprivacy/log v1.0.2
|
||||
github.com/gtank/ristretto255 v0.1.2
|
||||
|
|
2
go.sum
2
go.sum
|
@ -10,6 +10,8 @@ git.openprivacy.ca/cwtch.im/tapir v0.3.5 h1:AlqAhluY4ivznGoHh37Khyxy0u9IbtYskP93
|
|||
git.openprivacy.ca/cwtch.im/tapir v0.3.5/go.mod h1:eH6dZxXrhW0C4KZX18ksUa6XJCrEvtg8cJJ/Fy6gv+E=
|
||||
git.openprivacy.ca/cwtch.im/tapir v0.4.0 h1:clG8uORt0NKEhT4P+Dpw1pzyUuYzYBMevGqn2pciKk8=
|
||||
git.openprivacy.ca/cwtch.im/tapir v0.4.0/go.mod h1:eH6dZxXrhW0C4KZX18ksUa6XJCrEvtg8cJJ/Fy6gv+E=
|
||||
git.openprivacy.ca/cwtch.im/tapir v0.4.1 h1:9LMpQX41IzecNNlRc1FZKXHg6wlFss679tFsa3vzb3Y=
|
||||
git.openprivacy.ca/cwtch.im/tapir v0.4.1/go.mod h1:eH6dZxXrhW0C4KZX18ksUa6XJCrEvtg8cJJ/Fy6gv+E=
|
||||
git.openprivacy.ca/openprivacy/bine v0.0.4 h1:CO7EkGyz+jegZ4ap8g5NWRuDHA/56KKvGySR6OBPW+c=
|
||||
git.openprivacy.ca/openprivacy/bine v0.0.4/go.mod h1:13ZqhKyqakDsN/ZkQkIGNULsmLyqtXc46XBcnuXm/mU=
|
||||
git.openprivacy.ca/openprivacy/connectivity v1.4.0 h1:c7AANUCrlA4hIqXxIGDOWMtSe8CpDleD1877PShScbM=
|
||||
|
|
|
@ -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,20 @@ 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)
|
||||
// CheckGroup returns true only if the ID of the group is cryptographically valid.
|
||||
func (g *Group) CheckGroup() bool {
|
||||
return g.GroupID == deriveGroupID(g.GroupKey[:], g.GroupServer)
|
||||
}
|
||||
|
||||
// Compromised should be called if we detect a a groupkey leak.
|
||||
// deriveGroupID hashes together the key and the hostname to create a bound identifier that can later
|
||||
// be referenced and checked by profiles when they receive invites and messages.
|
||||
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, 4096, 16, sha512.New))
|
||||
}
|
||||
|
||||
// Compromised should be called if we detect a groupkey leak
|
||||
func (g *Group) Compromised() {
|
||||
g.IsCompromised = true
|
||||
}
|
||||
|
@ -83,20 +104,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[:],
|
||||
}
|
||||
|
||||
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 +237,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[len(GroupInvitePrefix):])
|
||||
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")
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
if onion == p.Onion {
|
||||
// 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 {
|
||||
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)
|
||||
gci, err := ValidateInvite(invite)
|
||||
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
|
||||
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[:],
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -24,6 +24,10 @@ type DecryptedGroupMessage struct {
|
|||
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
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue