diff --git a/api/send.go b/api/send.go
index 820261023bf0f8f3a0caff47d26857df9fd1f807..5ef62966daf88579091196898ce4e661583696e3 100644
--- a/api/send.go
+++ b/api/send.go
@@ -52,6 +52,14 @@ func (c *Client) SendCMIX(msg format.Message, recipientID *id.ID,
 	return c.network.SendCMIX(msg, recipientID, param)
 }
 
+// SendManyCMIX sends many "raw" CMIX message payloads to each of the
+// provided recipients. Used for group chat functionality. Returns the
+// round ID of the round the payload was sent or an error if it fails.
+func (c *Client) SendManyCMIX(messages map[id.ID]format.Message,
+	params params.CMIX) (id.Round, []ephemeral.Id, error) {
+	return c.network.SendManyCMIX(messages, params)
+}
+
 // NewCMIXMessage Creates a new cMix message with the right properties
 // for the current cMix network.
 // FIXME: this is weird and shouldn't be necessary, but it is.
diff --git a/api/utilsInterfaces_test.go b/api/utilsInterfaces_test.go
index b603066be89d019f2feb0643552c17b4a4f8b6a0..1faabce01353d23af8665858b4561e8aeb856836 100644
--- a/api/utilsInterfaces_test.go
+++ b/api/utilsInterfaces_test.go
@@ -105,6 +105,9 @@ func (t *testNetworkManagerGeneric) SendUnsafe(m message.Send, p params.Unsafe)
 func (t *testNetworkManagerGeneric) SendCMIX(message format.Message, rid *id.ID, p params.CMIX) (id.Round, ephemeral.Id, error) {
 	return id.Round(0), ephemeral.Id{}, nil
 }
+func (t *testNetworkManagerGeneric) SendManyCMIX(messages map[id.ID]format.Message, p params.CMIX) (id.Round, []ephemeral.Id, error) {
+	return 0, []ephemeral.Id{}, nil
+}
 func (t *testNetworkManagerGeneric) GetInstance() *network.Instance {
 	return t.instance
 }
diff --git a/auth/callback.go b/auth/callback.go
index 3d175c0623d6c8d982c944c8ee2ad990542bdb43..107887b5542ed6f4184f4e07de858444ca0750f4 100644
--- a/auth/callback.go
+++ b/auth/callback.go
@@ -133,7 +133,7 @@ func (m *Manager) handleRequest(cmixMsg format.Message,
 	// confirmation in case there are state issues.
 	// do not store
 	if _, err := m.storage.E2e().GetPartner(partnerID); err == nil {
-		jww.WARN.Printf("Recieved Auth request for %s, "+
+		jww.WARN.Printf("Received Auth request for %s, "+
 			"channel already exists. Ignoring", partnerID)
 		//exit
 		return
@@ -141,8 +141,8 @@ func (m *Manager) handleRequest(cmixMsg format.Message,
 		//check if the relationship already exists,
 		rType, sr2, _, err := m.storage.Auth().GetRequest(partnerID)
 		if err != nil && !strings.Contains(err.Error(), auth.NoRequest) {
-			// if another error is recieved, print it and exit
-			jww.WARN.Printf("Recieved new Auth request for %s, "+
+			// if another error is received, print it and exit
+			jww.WARN.Printf("Received new Auth request for %s, "+
 				"internal lookup produced bad result: %+v",
 				partnerID, err)
 			return
diff --git a/bindings/callback.go b/bindings/callback.go
index a6526d24d3ba961db2b79bdc2fbd6a1b950821dc..897ddcfc68ee19c9e967076ba52764f63fa5f667 100644
--- a/bindings/callback.go
+++ b/bindings/callback.go
@@ -16,7 +16,7 @@ import (
 
 // Listener provides a callback to hear a message
 // An object implementing this interface can be called back when the client
-// gets a message of the type that the regi    sterer specified at registration
+// gets a message of the type that the registerer specified at registration
 // time.
 type Listener interface {
 	// Hear is called to receive a message in the UI
diff --git a/bindings/group.go b/bindings/group.go
new file mode 100644
index 0000000000000000000000000000000000000000..8752c6665dac3461c8c452d9fbdb558a2f834899
--- /dev/null
+++ b/bindings/group.go
@@ -0,0 +1,298 @@
+///////////////////////////////////////////////////////////////////////////////
+// Copyright © 2020 xx network SEZC                                          //
+//                                                                           //
+// Use of this source code is governed by a license that can be found in the //
+// LICENSE file                                                              //
+///////////////////////////////////////////////////////////////////////////////
+
+package bindings
+
+import (
+	"github.com/pkg/errors"
+	gc "gitlab.com/elixxir/client/groupChat"
+	gs "gitlab.com/elixxir/client/groupChat/groupStore"
+	"gitlab.com/elixxir/crypto/group"
+	"gitlab.com/xx_network/primitives/id"
+)
+
+// GroupChat object contains the group chat manager.
+type GroupChat struct {
+	m *gc.Manager
+}
+
+// GroupRequestFunc contains a function callback that is called when a group
+// request is received.
+type GroupRequestFunc interface {
+	GroupRequestCallback(g Group)
+}
+
+// GroupReceiveFunc contains a function callback that is called when a group
+// message is received.
+type GroupReceiveFunc interface {
+	GroupReceiveCallback(msg GroupMessageReceive)
+}
+
+// NewGroupManager creates a new group chat manager.
+func NewGroupManager(client *Client, requestFunc GroupRequestFunc,
+	receiveFunc GroupReceiveFunc) (GroupChat, error) {
+
+	requestCallback := func(g gs.Group) {
+		requestFunc.GroupRequestCallback(Group{g})
+	}
+	receiveCallback := func(msg gc.MessageReceive) {
+		receiveFunc.GroupReceiveCallback(GroupMessageReceive{msg})
+	}
+
+	// Create a new group chat manager
+	m, err := gc.NewManager(&client.api, requestCallback, receiveCallback)
+	if err != nil {
+		return GroupChat{}, err
+	}
+
+	// Start group request and message retrieval workers
+	client.api.AddService(m.StartProcesses)
+
+	return GroupChat{m}, nil
+}
+
+// MakeGroup creates a new group and sends a group request to all members in the
+// group. The ID of the new group, the rounds the requests were sent on, and the
+// status of the send are contained in NewGroupReport.
+func (g GroupChat) MakeGroup(membership IdList, name, message []byte) (NewGroupReport, error) {
+	grp, rounds, status, err := g.m.MakeGroup(membership.list, name, message)
+	return NewGroupReport{Group{grp}, rounds, status}, err
+}
+
+// ResendRequest resends a group request to all members in the group. The rounds
+// they were sent on and the status of the send are contained in NewGroupReport.
+func (g GroupChat) ResendRequest(groupIdBytes []byte) (NewGroupReport, error) {
+	groupID, err := id.Unmarshal(groupIdBytes)
+	if err != nil {
+		return NewGroupReport{},
+			errors.Errorf("Failed to unmarshal group ID: %+v", err)
+	}
+
+	rounds, status, err := g.m.ResendRequest(groupID)
+
+	return NewGroupReport{Group{}, rounds, status}, nil
+}
+
+// JoinGroup allows a user to join a group when they receive a request. The
+// caller must pass in the serialized bytes of a Group.
+func (g GroupChat) JoinGroup(serializedGroupData []byte) error {
+	grp, err := gs.DeserializeGroup(serializedGroupData)
+	if err != nil {
+		return err
+	}
+	return g.m.JoinGroup(grp)
+}
+
+// LeaveGroup deletes a group so a user no longer has access.
+func (g GroupChat) LeaveGroup(groupIdBytes []byte) error {
+	groupID, err := id.Unmarshal(groupIdBytes)
+	if err != nil {
+		return errors.Errorf("Failed to unmarshal group ID: %+v", err)
+	}
+
+	return g.m.LeaveGroup(groupID)
+}
+
+// Send sends the message to the specified group. Returns the round the messages
+// were sent on.
+func (g GroupChat) Send(groupIdBytes, message []byte) (int64, error) {
+	groupID, err := id.Unmarshal(groupIdBytes)
+	if err != nil {
+		return 0, errors.Errorf("Failed to unmarshal group ID: %+v", err)
+	}
+
+	round, err := g.m.Send(groupID, message)
+	return int64(round), err
+}
+
+// GetGroups returns an IdList containing a list of group IDs that the user is a
+// part of.
+func (g GroupChat) GetGroups() IdList {
+	return IdList{g.m.GetGroups()}
+}
+
+// GetGroup returns the group with the group ID. If no group exists, then the
+// error "failed to find group" is returned.
+func (g GroupChat) GetGroup(groupIdBytes []byte) (Group, error) {
+	groupID, err := id.Unmarshal(groupIdBytes)
+	if err != nil {
+		return Group{}, errors.Errorf("Failed to unmarshal group ID: %+v", err)
+	}
+
+	grp, exists := g.m.GetGroup(groupID)
+	if !exists {
+		return Group{}, errors.New("failed to find group")
+	}
+
+	return Group{grp}, nil
+}
+
+// NumGroups returns the number of groups the user is a part of.
+func (g GroupChat) NumGroups() int {
+	return g.m.NumGroups()
+}
+
+// NewGroupReport is returned when creating a new group and contains the ID of
+// the group, a list of rounds that the group requests were sent on, and the
+// status of the send.
+type NewGroupReport struct {
+	group  Group
+	rounds []id.Round
+	status gc.RequestStatus
+}
+
+// GetGroup returns the Group.
+func (ngr NewGroupReport) GetGroup() Group {
+	return ngr.group
+}
+
+// GetRoundList returns the RoundList containing a list of rounds requests were
+// sent on.
+func (ngr NewGroupReport) GetRoundList() RoundList {
+	return RoundList{ngr.rounds}
+}
+
+// GetStatus returns the status of the requests sent when creating a new group.
+// status = 0   an error occurred before any requests could be sent
+//          1   all requests failed to send
+//          2   some request failed and some succeeded
+//          3,  all requests sent successfully
+func (ngr NewGroupReport) GetStatus() int {
+	return int(ngr.status)
+}
+
+////
+// Group Structure
+////
+
+// Group structure contains the identifying and membership information of a
+// group chat.
+type Group struct {
+	g gs.Group
+}
+
+// GetName returns the name set by the user for the group.
+func (g Group) GetName() []byte {
+	return g.g.Name
+}
+
+// GetID return the 33-byte unique group ID.
+func (g Group) GetID() []byte {
+	return g.g.ID.Bytes()
+}
+
+// GetMembership returns a list of contacts, one for each member in the group.
+// The list is in order; the first contact is the leader/creator of the group.
+// All subsequent members are ordered by their ID.
+func (g Group) GetMembership() GroupMembership {
+	return GroupMembership{g.g.Members}
+}
+
+// Serialize serializes the Group.
+func (g Group) Serialize() []byte {
+	return g.g.Serialize()
+}
+
+////
+// Membership Structure
+////
+
+// GroupMembership structure contains a list of members that are part of a
+// group. The first member is the group leader.
+type GroupMembership struct {
+	m group.Membership
+}
+
+// Len returns the number of members in the group membership.
+func (gm GroupMembership) Len() int {
+	return gm.Len()
+}
+
+// Get returns the member at the index. The member at index 0 is always the
+// group leader. An error is returned if the index is out of range.
+func (gm GroupMembership) Get(i int) (GroupMember, error) {
+	if i < 0 || i > gm.Len() {
+		return GroupMember{}, errors.Errorf("ID list index must be between %d "+
+			"and the last element %d.", 0, gm.Len())
+	}
+	return GroupMember{gm.m[i]}, nil
+}
+
+////
+// Member Structure
+////
+// GroupMember represents a member in the group membership list.
+type GroupMember struct {
+	group.Member
+}
+
+// GetID returns the 33-byte user ID of the member.
+func (gm GroupMember) GetID() []byte {
+	return gm.ID.Bytes()
+}
+
+// GetDhKey returns the byte representation of the public Diffie–Hellman key of
+// the member.
+func (gm GroupMember) GetDhKey() []byte {
+	return gm.DhKey.Bytes()
+}
+
+////
+// Message Receive Structure
+////
+
+// GroupMessageReceive contains a group message, its ID, and its data that a
+// user receives.
+type GroupMessageReceive struct {
+	gc.MessageReceive
+}
+
+// GetGroupID returns the 33-byte group ID.
+func (gmr GroupMessageReceive) GetGroupID() []byte {
+	return gmr.GroupID.Bytes()
+}
+
+// GetMessageID returns the message ID.
+func (gmr GroupMessageReceive) GetMessageID() []byte {
+	return gmr.ID.Bytes()
+}
+
+// GetPayload returns the message payload.
+func (gmr GroupMessageReceive) GetPayload() []byte {
+	return gmr.Payload
+}
+
+// GetSenderID returns the 33-byte user ID of the sender.
+func (gmr GroupMessageReceive) GetSenderID() []byte {
+	return gmr.SenderID.Bytes()
+}
+
+// GetRecipientID returns the 33-byte user ID of the recipient.
+func (gmr GroupMessageReceive) GetRecipientID() []byte {
+	return gmr.RecipientID.Bytes()
+}
+
+// GetEphemeralID returns the ephemeral ID of the recipient.
+func (gmr GroupMessageReceive) GetEphemeralID() int64 {
+	return gmr.EphemeralID.Int64()
+}
+
+// GetTimestampNano returns the message timestamp in nanoseconds.
+func (gmr GroupMessageReceive) GetTimestampNano() int64 {
+	return gmr.Timestamp.UnixNano()
+}
+
+// GetRoundID returns the ID of the round the message was sent on.
+func (gmr GroupMessageReceive) GetRoundID() int64 {
+	return int64(gmr.RoundID)
+}
+
+// GetRoundTimestampNano returns the timestamp, in nanoseconds, of the round the
+// message was sent on.
+func (gmr GroupMessageReceive) GetRoundTimestampNano() int64 {
+	return gmr.RoundTimestamp.UnixNano()
+}
diff --git a/bindings/list.go b/bindings/list.go
index c44fb1679fc0e6492c7c711e7d610bab01198c78..a97df24f299d994380d46de8ae9971ad9133c1e7 100644
--- a/bindings/list.go
+++ b/bindings/list.go
@@ -8,7 +8,7 @@
 package bindings
 
 import (
-	"errors"
+	"github.com/pkg/errors"
 	"gitlab.com/elixxir/crypto/contact"
 	"gitlab.com/elixxir/primitives/fact"
 	"gitlab.com/xx_network/primitives/id"
@@ -115,3 +115,41 @@ func (fl *FactList) Add(factData string, factType int) error {
 func (fl *FactList) Stringify() (string, error) {
 	return fl.c.Facts.Stringify(), nil
 }
+
+/* ID list */
+// IdList contains a list of IDs.
+type IdList struct {
+	list []*id.ID
+}
+
+// MakeIdList creates a new empty IdList.
+func MakeIdList() IdList {
+	return IdList{[]*id.ID{}}
+}
+
+// Len returns the number of IDs in the list.
+func (idl IdList) Len() int {
+	return len(idl.list)
+}
+
+// Add appends the ID bytes to the end of the list.
+func (idl IdList) Add(idBytes []byte) error {
+	newID, err := id.Unmarshal(idBytes)
+	if err != nil {
+		return err
+	}
+
+	idl.list = append(idl.list, newID)
+	return nil
+}
+
+// Get returns the ID at the index. An error is returned if the index is out of
+// range.
+func (idl IdList) Get(i int) ([]byte, error) {
+	if i < 0 || i > len(idl.list) {
+		return nil, errors.Errorf("ID list index must be between %d and the "+
+			"last element %d.", 0, len(idl.list))
+	}
+
+	return idl.list[i].Bytes(), nil
+}
diff --git a/bindings/message.go b/bindings/message.go
index 32947d5fc3b0fc7e87579bdacd39c1bc69edb404..44526fa2e3756868e1b895452d1484f1ff4366fa 100644
--- a/bindings/message.go
+++ b/bindings/message.go
@@ -17,33 +17,51 @@ type Message struct {
 	r message.Receive
 }
 
-//Returns the id of the message
+// GetID returns the id of the message
 func (m *Message) GetID() []byte {
 	return m.r.ID[:]
 }
 
-// Returns the message's sender ID, if available
+// GetSender returns the message's sender ID, if available
 func (m *Message) GetSender() []byte {
 	return m.r.Sender.Bytes()
 }
 
-// Returns the message's payload/contents
+// GetPayload returns the message's payload/contents
 func (m *Message) GetPayload() []byte {
 	return m.r.Payload
 }
 
-// Returns the message's type
+// GetMessageType returns the message's type
 func (m *Message) GetMessageType() int {
 	return int(m.r.MessageType)
 }
 
-// Returns the message's timestamp in ms
+// GetTimestampMS returns the message's timestamp in milliseconds
 func (m *Message) GetTimestampMS() int64 {
 	ts := m.r.Timestamp.UnixNano()
 	ts = (ts + 999999) / 1000000
 	return ts
 }
 
+// GetTimestampNano returns the message's timestamp in nanoseconds
 func (m *Message) GetTimestampNano() int64 {
 	return m.r.Timestamp.UnixNano()
 }
+
+// GetRoundTimestampMS returns the message's round timestamp in milliseconds
+func (m *Message) GetRoundTimestampMS() int64 {
+	ts := m.r.RoundTimestamp.UnixNano()
+	ts = (ts + 999999) / 1000000
+	return ts
+}
+
+// GetRoundTimestampNano returns the message's round timestamp in nanoseconds
+func (m *Message) GetRoundTimestampNano() int64 {
+	return m.r.RoundTimestamp.UnixNano()
+}
+
+// GetRoundId returns the message's round ID
+func (m *Message) GetRoundId() int64 {
+	return int64(m.r.RoundId)
+}
diff --git a/bindings/send.go b/bindings/send.go
index 886bf0ea819557c50ddef4b41846d0ae3d5a5d00..586100d051b0f4f0248b235630111476b87b930f 100644
--- a/bindings/send.go
+++ b/bindings/send.go
@@ -57,6 +57,53 @@ func (c *Client) SendCmix(recipient, contents []byte, parameters string) (int, e
 	return int(rid), nil
 }
 
+// SendManyCMIX sends many "raw" CMIX message payloads to each of the
+// provided recipients. Used for group chat functionality. Returns the
+// round ID of the round the payload was sent or an error if it fails.
+// This will return an error if:
+//  - any recipient ID is invalid
+//  - any of the the message contents are too long for the message structure
+//  - the message cannot be sent
+
+// This will return the round the message was sent on if it is successfully sent
+// This can be used to register a round event to learn about message delivery.
+// on failure a round id of -1 is returned
+// fixme: cannot use a slice of slices over bindings. Will need to modify this function once
+//  a proper input format has been specified
+//func (c *Client) SendManyCMIX(recipients, contents [][]byte, parameters string) (int, error) {
+//
+//	p, err := params.GetCMIXParameters(parameters)
+//	if err != nil {
+//		return -1, errors.New(fmt.Sprintf("Failed to sendCmix: %+v",
+//			err))
+//	}
+//
+//	// Build messages
+//	messages := make(map[id.ID]format.Message, len(contents))
+//	for i := 0; i < len(contents); i++ {
+//		msg, err := c.api.NewCMIXMessage(contents[i])
+//		if err != nil {
+//			return -1, errors.New(fmt.Sprintf("Failed to sendCmix: %+v",
+//				err))
+//		}
+//
+//		u, err := id.Unmarshal(recipients[i])
+//		if err != nil {
+//			return -1, errors.New(fmt.Sprintf("Failed to sendCmix: %+v",
+//				err))
+//		}
+//
+//		messages[*u] = msg
+//	}
+//
+//	rid, _, err := c.api.SendManyCMIX(messages, p)
+//	if err != nil {
+//		return -1, errors.New(fmt.Sprintf("Failed to sendCmix: %+v",
+//			err))
+//	}
+//	return int(rid), nil
+//}
+
 // SendUnsafe sends an unencrypted payload to the provided recipient
 // with the provided msgType. Returns the list of rounds in which parts
 // of the message were sent or an error if it fails.
diff --git a/cmd/group.go b/cmd/group.go
new file mode 100644
index 0000000000000000000000000000000000000000..722ec4aa5929631e7b3bad96c67e09db9a57d23d
--- /dev/null
+++ b/cmd/group.go
@@ -0,0 +1,345 @@
+///////////////////////////////////////////////////////////////////////////////
+// Copyright © 2020 xx network SEZC                                          //
+//                                                                           //
+// Use of this source code is governed by a license that can be found in the //
+// LICENSE file                                                              //
+///////////////////////////////////////////////////////////////////////////////
+
+// The group subcommand allows creation and sending messages to groups
+
+package cmd
+
+import (
+	"bufio"
+	"fmt"
+	"github.com/spf13/cobra"
+	jww "github.com/spf13/jwalterweatherman"
+	"github.com/spf13/viper"
+	"gitlab.com/elixxir/client/api"
+	"gitlab.com/elixxir/client/groupChat"
+	"gitlab.com/elixxir/client/groupChat/groupStore"
+	"gitlab.com/xx_network/primitives/id"
+	"os"
+	"time"
+)
+
+// groupCmd represents the base command when called without any subcommands
+var groupCmd = &cobra.Command{
+	Use:   "group",
+	Short: "Group commands for cMix client",
+	Args:  cobra.NoArgs,
+	Run: func(cmd *cobra.Command, args []string) {
+
+		client := initClient()
+
+		// Print user's reception ID
+		user := client.GetUser()
+		jww.INFO.Printf("User: %s", user.ReceptionID)
+
+		_, _ = initClientCallbacks(client)
+
+		// Initialize the group chat manager
+		groupManager, recChan, reqChan := initGroupManager(client)
+
+		_, err := client.StartNetworkFollower(5 * time.Second)
+		if err != nil {
+			jww.FATAL.Panicf("%+v", err)
+		}
+
+		// Wait until connected or crash on timeout
+		connected := make(chan bool, 10)
+		client.GetHealth().AddChannel(connected)
+		waitUntilConnected(connected)
+
+		// After connection, make sure we have registered with at least 85% of
+		// the nodes
+		for numReg, total := 1, 100; numReg < (total*3)/4; {
+			time.Sleep(1 * time.Second)
+			numReg, total, err = client.GetNodeRegistrationStatus()
+			if err != nil {
+				jww.FATAL.Panicf("%+v", err)
+			}
+
+			jww.INFO.Printf("Registering with nodes (%d/%d)...", numReg, total)
+		}
+
+		// Get group message and name
+		msgBody := []byte(viper.GetString("message"))
+		name := []byte(viper.GetString("name"))
+		timeout := viper.GetDuration("receiveTimeout")
+
+		if viper.IsSet("create") {
+			filePath := viper.GetString("create")
+			createGroup(name, msgBody, filePath, groupManager)
+		}
+
+		if viper.IsSet("resend") {
+			groupIdString := viper.GetString("resend")
+			resendRequests(groupIdString, groupManager)
+		}
+
+		if viper.GetBool("join") {
+			joinGroup(reqChan, timeout, groupManager)
+		}
+
+		if viper.IsSet("leave") {
+			groupIdString := viper.GetString("leave")
+			leaveGroup(groupIdString, groupManager)
+		}
+
+		if viper.IsSet("sendMessage") {
+			groupIdString := viper.GetString("sendMessage")
+			sendGroup(groupIdString, msgBody, groupManager)
+		}
+
+		if viper.IsSet("wait") {
+			numMessages := viper.GetUint("wait")
+			messageWait(numMessages, timeout, recChan)
+		}
+
+		if viper.GetBool("list") {
+			listGroups(groupManager)
+		}
+
+		if viper.IsSet("show") {
+			groupIdString := viper.GetString("show")
+			showGroup(groupIdString, groupManager)
+		}
+	},
+}
+
+// initGroupManager creates a new group chat manager and starts the process
+// service.
+func initGroupManager(client *api.Client) (*groupChat.Manager,
+	chan groupChat.MessageReceive, chan groupStore.Group) {
+	recChan := make(chan groupChat.MessageReceive, 10)
+	receiveCb := func(msg groupChat.MessageReceive) {
+		recChan <- msg
+	}
+
+	reqChan := make(chan groupStore.Group, 10)
+	requestCb := func(g groupStore.Group) {
+		reqChan <- g
+	}
+
+	jww.INFO.Print("Creating new group manager.")
+	manager, err := groupChat.NewManager(client, requestCb, receiveCb)
+	if err != nil {
+		jww.FATAL.Panicf("Failed to initialize group chat manager: %+v", err)
+	}
+
+	// Start group request and message receiver
+	client.AddService(manager.StartProcesses)
+
+	return manager, recChan, reqChan
+}
+
+// createGroup creates a new group with the provided name and sends out requests
+// to the list of user IDs found at the given file path.
+func createGroup(name, msg []byte, filePath string, gm *groupChat.Manager) {
+	userIdStrings := ReadLines(filePath)
+	userIDs := make([]*id.ID, 0, len(userIdStrings))
+	for _, userIdStr := range userIdStrings {
+		userID, _ := parseRecipient(userIdStr)
+		userIDs = append(userIDs, userID)
+	}
+
+	grp, rids, status, err := gm.MakeGroup(userIDs, name, msg)
+	if err != nil {
+		jww.FATAL.Panicf("Failed to create new group: %+v", err)
+	}
+
+	// Integration grabs the group ID from this line
+	jww.INFO.Printf("NewGroupID: b64:%s", grp.ID)
+	jww.INFO.Printf("Created Group: Requests:%s on rounds %#v, %v", status, rids, grp)
+	fmt.Printf("Created new group with name %q and message %q\n", grp.Name,
+		grp.InitMessage)
+}
+
+// resendRequests resends group requests for the group ID.
+func resendRequests(groupIdString string, gm *groupChat.Manager) {
+	groupID, _ := parseRecipient(groupIdString)
+	rids, status, err := gm.ResendRequest(groupID)
+	if err != nil {
+		jww.FATAL.Panicf("Failed to resend requests to group %s: %+v",
+			groupID, err)
+	}
+
+	jww.INFO.Printf("Resending requests to group %s: %v, %s", groupID, rids, status)
+	fmt.Println("Resending group requests to group.")
+}
+
+// joinGroup joins a group when a request is received on the group request
+// channel.
+func joinGroup(reqChan chan groupStore.Group, timeout time.Duration, gm *groupChat.Manager) {
+	jww.INFO.Print("Waiting for group request to be received.")
+	fmt.Println("Waiting for group request to be received.")
+
+	select {
+	case grp := <-reqChan:
+		err := gm.JoinGroup(grp)
+		if err != nil {
+			jww.FATAL.Panicf("%+v", err)
+		}
+
+		jww.INFO.Printf("Joined group: %s", grp.ID)
+		fmt.Printf("Joined group with name %q and message %q\n",
+			grp.Name, grp.InitMessage)
+	case <-time.NewTimer(timeout).C:
+		jww.INFO.Printf("Timed out after %s waiting for group request.", timeout)
+		fmt.Println("Timed out waiting for group request.")
+		return
+	}
+}
+
+// leaveGroup leaves the group.
+func leaveGroup(groupIdString string, gm *groupChat.Manager) {
+	groupID, _ := parseRecipient(groupIdString)
+	jww.INFO.Printf("Leaving group %s.", groupID)
+
+	err := gm.LeaveGroup(groupID)
+	if err != nil {
+		jww.FATAL.Panicf("Failed to leave group %s: %+v", groupID, err)
+	}
+
+	jww.INFO.Printf("Left group: %s", groupID)
+	fmt.Println("Left group.")
+}
+
+// sendGroup send the message to the group.
+func sendGroup(groupIdString string, msg []byte, gm *groupChat.Manager) {
+	groupID, _ := parseRecipient(groupIdString)
+
+	jww.INFO.Printf("Sending to group %s message %q", groupID, msg)
+
+	rid, err := gm.Send(groupID, msg)
+	if err != nil {
+		jww.FATAL.Panicf("Sending message to group %s: %+v", groupID, err)
+	}
+
+	jww.INFO.Printf("Sent to group %s on round %d", groupID, rid)
+	fmt.Printf("Sent message %q to group.\n", msg)
+}
+
+// messageWait waits for the given number of messages to be received on the
+// groupChat.MessageReceive channel.
+func messageWait(numMessages uint, timeout time.Duration, recChan chan groupChat.MessageReceive) {
+	jww.INFO.Printf("Waiting for %d group message(s) to be received.", numMessages)
+	fmt.Printf("Waiting for %d group message(s) to be received.\n", numMessages)
+
+	for i := uint(0); i < numMessages; {
+		select {
+		case msg := <-recChan:
+			i++
+			jww.INFO.Printf("Received group message %d/%d: %s", i, numMessages, msg)
+			fmt.Printf("Received group message: %q\n", msg.Payload)
+		case <-time.NewTimer(timeout).C:
+			jww.INFO.Printf("Timed out after %s waiting for group message.", timeout)
+			fmt.Printf("Timed out waiting for %d group message(s).\n", numMessages)
+			return
+		}
+	}
+}
+
+// listGroups prints a list of all groups.
+func listGroups(gm *groupChat.Manager) {
+	for i, gid := range gm.GetGroups() {
+		jww.INFO.Printf("Group %d: %s", i, gid)
+	}
+
+	fmt.Printf("Printed list of %d groups.\n", gm.NumGroups())
+}
+
+// showGroup prints all the information of the group.
+func showGroup(groupIdString string, gm *groupChat.Manager) {
+	groupID, _ := parseRecipient(groupIdString)
+
+	grp, ok := gm.GetGroup(groupID)
+	if !ok {
+		jww.FATAL.Printf("Could not find group: %s", groupID)
+	}
+
+	jww.INFO.Printf("Show group %#v", grp)
+	fmt.Printf("Got group with name %q and message %q\n", grp.Name, grp.InitMessage)
+}
+
+// ReadLines returns each line in a file as a string.
+func ReadLines(fileName string) []string {
+	file, err := os.Open(fileName)
+	if err != nil {
+		jww.FATAL.Panicf(err.Error())
+	}
+	defer file.Close()
+
+	var res []string
+
+	scanner := bufio.NewScanner(file)
+	for scanner.Scan() {
+		res = append(res, scanner.Text())
+	}
+
+	if err := scanner.Err(); err != nil {
+		jww.FATAL.Panicf(err.Error())
+	}
+	return res
+}
+
+func init() {
+	groupCmd.Flags().String("create", "",
+		"Create a group with from the list of contact file paths.")
+	err := viper.BindPFlag("create", groupCmd.Flags().Lookup("create"))
+	checkBindErr(err, "create")
+
+	groupCmd.Flags().String("name", "Group Name",
+		"The name of the new group to create.")
+	err = viper.BindPFlag("name", groupCmd.Flags().Lookup("name"))
+	checkBindErr(err, "name")
+
+	groupCmd.Flags().String("resend", "",
+		"Resend invites for all users in this group ID.")
+	err = viper.BindPFlag("resend", groupCmd.Flags().Lookup("resend"))
+	checkBindErr(err, "resend")
+
+	groupCmd.Flags().Bool("join", false,
+		"Waits for group request joins the group.")
+	err = viper.BindPFlag("join", groupCmd.Flags().Lookup("join"))
+	checkBindErr(err, "join")
+
+	groupCmd.Flags().String("leave", "",
+		"Leave this group ID.")
+	err = viper.BindPFlag("leave", groupCmd.Flags().Lookup("leave"))
+	checkBindErr(err, "leave")
+
+	groupCmd.Flags().String("sendMessage", "",
+		"Send message to this group ID.")
+	err = viper.BindPFlag("sendMessage", groupCmd.Flags().Lookup("sendMessage"))
+	checkBindErr(err, "sendMessage")
+
+	groupCmd.Flags().Uint("wait", 0,
+		"Waits for number of messages to be received.")
+	err = viper.BindPFlag("wait", groupCmd.Flags().Lookup("wait"))
+	checkBindErr(err, "wait")
+
+	groupCmd.Flags().Duration("receiveTimeout", time.Minute,
+		"Amount of time to wait for a group request or message before timing out.")
+	err = viper.BindPFlag("receiveTimeout", groupCmd.Flags().Lookup("receiveTimeout"))
+	checkBindErr(err, "receiveTimeout")
+
+	groupCmd.Flags().Bool("list", false,
+		"Prints list all groups to which this client belongs.")
+	err = viper.BindPFlag("list", groupCmd.Flags().Lookup("list"))
+	checkBindErr(err, "list")
+
+	groupCmd.Flags().String("show", "",
+		"Prints the members of this group ID.")
+	err = viper.BindPFlag("show", groupCmd.Flags().Lookup("show"))
+	checkBindErr(err, "show")
+
+	rootCmd.AddCommand(groupCmd)
+}
+
+func checkBindErr(err error, key string) {
+	if err != nil {
+		jww.ERROR.Printf("viper.BindPFlag failed for %s: %+v", key, err)
+	}
+}
diff --git a/cmd/root.go b/cmd/root.go
index a7161259a2898c1ab9383c7febbd24e221820719..1c495bae029e9a6b3251b3c6a893158280f69d5d 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -80,40 +80,18 @@ var rootCmd = &cobra.Command{
 			recipientContact = user.GetContact()
 		}
 
-		// Set up reception handler
-		swboard := client.GetSwitchboard()
-		recvCh := make(chan message.Receive, 10000)
-		listenerID := swboard.RegisterChannel("DefaultCLIReceiver",
-			switchboard.AnyUser(), message.Text, recvCh)
-		jww.INFO.Printf("Message ListenerID: %v", listenerID)
-
-		// Set up auth request handler, which simply prints the
-		// user id of the requester.
-		authMgr := client.GetAuthRegistrar()
-		authMgr.AddGeneralRequestCallback(printChanRequest)
-
-		// If unsafe channels, add auto-acceptor
+		confCh, recvCh := initClientCallbacks(client)
+
+		// The following block is used to check if the request from
+		// a channel authorization is from the recipient we intend in
+		// this run.
 		authConfirmed := false
-		authMgr.AddGeneralConfirmCallback(func(
-			partner contact.Contact) {
-			jww.INFO.Printf("Channel Confirmed: %s",
-				partner.ID)
-			authConfirmed = recipientID.Cmp(partner.ID)
-		})
-		if viper.GetBool("unsafe-channel-creation") {
-			authMgr.AddGeneralRequestCallback(func(
-				requestor contact.Contact, message string) {
-				jww.INFO.Printf("Channel Request: %s",
-					requestor.ID)
-				_, err := client.ConfirmAuthenticatedChannel(
-					requestor)
-				if err != nil {
-					jww.FATAL.Panicf("%+v", err)
-				}
-				authConfirmed = recipientID.Cmp(
-					requestor.ID)
-			})
-		}
+		go func() {
+			for {
+				requestor := <-confCh
+				authConfirmed = recipientID.Cmp(requestor)
+			}
+		}()
 
 		_, err := client.StartNetworkFollower(5 * time.Second)
 		if err != nil {
@@ -278,6 +256,44 @@ var rootCmd = &cobra.Command{
 	},
 }
 
+func initClientCallbacks(client *api.Client) (chan *id.ID,
+	chan message.Receive) {
+	// Set up reception handler
+	swboard := client.GetSwitchboard()
+	recvCh := make(chan message.Receive, 10000)
+	listenerID := swboard.RegisterChannel("DefaultCLIReceiver",
+		switchboard.AnyUser(), message.Text, recvCh)
+	jww.INFO.Printf("Message ListenerID: %v", listenerID)
+
+	// Set up auth request handler, which simply prints the
+	// user id of the requester.
+	authMgr := client.GetAuthRegistrar()
+	authMgr.AddGeneralRequestCallback(printChanRequest)
+
+	// If unsafe channels, add auto-acceptor
+	authConfirmed := make(chan *id.ID, 10)
+	authMgr.AddGeneralConfirmCallback(func(
+		partner contact.Contact) {
+		jww.INFO.Printf("Channel Confirmed: %s",
+			partner.ID)
+		authConfirmed <- partner.ID
+	})
+	if viper.GetBool("unsafe-channel-creation") {
+		authMgr.AddGeneralRequestCallback(func(
+			requestor contact.Contact, message string) {
+			jww.INFO.Printf("Channel Request: %s",
+				requestor.ID)
+			_, err := client.ConfirmAuthenticatedChannel(
+				requestor)
+			if err != nil {
+				jww.FATAL.Panicf("%+v", err)
+			}
+			authConfirmed <- requestor.ID
+		})
+	}
+	return authConfirmed, recvCh
+}
+
 // Helper function which prints the round resuls
 func printRoundResults(allRoundsSucceeded, timedOut bool,
 	rounds map[id.Round]api.RoundResult, roundIDs []id.Round, msg message.Send) {
@@ -347,7 +363,6 @@ func createClient() *api.Client {
 				err = api.NewClient(string(ndfJSON), storeDir,
 					[]byte(pass), regCode)
 			}
-
 		}
 
 		if err != nil {
diff --git a/go.mod b/go.mod
index 402ae1b22ca9b09dfbf5deed303fdc28172901f3..a534cac91c9efd0f75f3fcfd2d9c5f40587c06d3 100644
--- a/go.mod
+++ b/go.mod
@@ -17,13 +17,13 @@ require (
 	github.com/spf13/jwalterweatherman v1.1.0
 	github.com/spf13/viper v1.7.1
 	gitlab.com/elixxir/bloomfilter v0.0.0-20200930191214-10e9ac31b228
-	gitlab.com/elixxir/comms v0.0.4-0.20210603164716-b39b297bbb57
-	gitlab.com/elixxir/crypto v0.0.7-0.20210603164430-a52be8b1a3e8
+	gitlab.com/elixxir/comms v0.0.4-0.20210607222512-0f2e89b475b4
+	gitlab.com/elixxir/crypto v0.0.7-0.20210607221512-0a9bff216f7c
 	gitlab.com/elixxir/ekv v0.1.5
-	gitlab.com/elixxir/primitives v0.0.3-0.20210603164310-4bd6e45e65e1
+	gitlab.com/elixxir/primitives v0.0.3-0.20210607210820-afd1b028b558
 	gitlab.com/xx_network/comms v0.0.4-0.20210603164237-d0c36076d7f0
 	gitlab.com/xx_network/crypto v0.0.5-0.20210603164136-743cb9b0a967
-	gitlab.com/xx_network/primitives v0.0.4-0.20210603164056-0abf3f914f25
+	gitlab.com/xx_network/primitives v0.0.4-0.20210607221158-361a2cbc5529
 	golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2
 	golang.org/x/net v0.0.0-20210525063256-abc453219eb5
 	google.golang.org/genproto v0.0.0-20210105202744-fe13368bc0e1 // indirect
diff --git a/go.sum b/go.sum
index 94d7d1b1af2f749ed09387597fb5b6f2da3697e6..66a72049798930218404a8ba6a848ee93c29d687 100644
--- a/go.sum
+++ b/go.sum
@@ -247,20 +247,20 @@ github.com/zeebo/pcg v1.0.0 h1:dt+dx+HvX8g7Un32rY9XWoYnd0NmKmrIzpHF7qiTDj0=
 github.com/zeebo/pcg v1.0.0/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
 gitlab.com/elixxir/bloomfilter v0.0.0-20200930191214-10e9ac31b228 h1:Gi6rj4mAlK0BJIk1HIzBVMjWNjIUfstrsXC2VqLYPcA=
 gitlab.com/elixxir/bloomfilter v0.0.0-20200930191214-10e9ac31b228/go.mod h1:H6jztdm0k+wEV2QGK/KYA+MY9nj9Zzatux/qIvDDv3k=
-gitlab.com/elixxir/comms v0.0.4-0.20210603164716-b39b297bbb57 h1:7brO8ImMildCKPL1EXYgWPGz4GEIKBRSZmTGeGD33n8=
-gitlab.com/elixxir/comms v0.0.4-0.20210603164716-b39b297bbb57/go.mod h1:s+aHLLk2bJOajrTb237G3J3gRUFkFWMddnaGOgt5Deg=
+gitlab.com/elixxir/comms v0.0.4-0.20210607222512-0f2e89b475b4 h1:9h/Dkvu8t4/Q2LY81qoxjbO4GJtVanjWV3B9UMuZgDA=
+gitlab.com/elixxir/comms v0.0.4-0.20210607222512-0f2e89b475b4/go.mod h1:Ox1NgdvFRy4/AfWAIrKZR9W6O+PAYeNOZviGbhqH+eo=
 gitlab.com/elixxir/crypto v0.0.0-20200804182833-984246dea2c4/go.mod h1:ucm9SFKJo+K0N2GwRRpaNr+tKXMIOVWzmyUD0SbOu2c=
 gitlab.com/elixxir/crypto v0.0.3/go.mod h1:ZNgBOblhYToR4m8tj4cMvJ9UsJAUKq+p0gCp07WQmhA=
-gitlab.com/elixxir/crypto v0.0.7-0.20210603164430-a52be8b1a3e8 h1:3d4I+PxwyhvLwj6jInDO43kCGcSiybhrREiYH9jsFmc=
-gitlab.com/elixxir/crypto v0.0.7-0.20210603164430-a52be8b1a3e8/go.mod h1:WacVVYlNPYbeDR1gBRJHtAx6T5P6ZZZ0tTHW5GTdi5Q=
+gitlab.com/elixxir/crypto v0.0.7-0.20210607221512-0a9bff216f7c h1:G4IE4xEnoapQoZsMK1XRAB5QdOr2z+IyxZ6JOIXXuQ8=
+gitlab.com/elixxir/crypto v0.0.7-0.20210607221512-0a9bff216f7c/go.mod h1:HAW5sLSUHKgylen7C4YoVNr0kp5g21/eKJw6sCuBRFs=
 gitlab.com/elixxir/ekv v0.1.5 h1:R8M1PA5zRU1HVnTyrtwybdABh7gUJSCvt1JZwUSeTzk=
 gitlab.com/elixxir/ekv v0.1.5/go.mod h1:e6WPUt97taFZe5PFLPb1Dupk7tqmDCTQu1kkstqJvw4=
 gitlab.com/elixxir/primitives v0.0.0-20200731184040-494269b53b4d/go.mod h1:OQgUZq7SjnE0b+8+iIAT2eqQF+2IFHn73tOo+aV11mg=
 gitlab.com/elixxir/primitives v0.0.0-20200804170709-a1896d262cd9/go.mod h1:p0VelQda72OzoUckr1O+vPW0AiFe0nyKQ6gYcmFSuF8=
 gitlab.com/elixxir/primitives v0.0.0-20200804182913-788f47bded40/go.mod h1:tzdFFvb1ESmuTCOl1z6+yf6oAICDxH2NPUemVgoNLxc=
 gitlab.com/elixxir/primitives v0.0.1/go.mod h1:kNp47yPqja2lHSiS4DddTvFpB/4D9dB2YKnw5c+LJCE=
-gitlab.com/elixxir/primitives v0.0.3-0.20210603164310-4bd6e45e65e1 h1:RZFD4TbJCBO36WYGVH7gakUKbumFsAUsuEqR8ryYF/A=
-gitlab.com/elixxir/primitives v0.0.3-0.20210603164310-4bd6e45e65e1/go.mod h1:1EFCSsERjE5RagCjys/70sqkAZC5PimEkWHcljh4bwQ=
+gitlab.com/elixxir/primitives v0.0.3-0.20210607210820-afd1b028b558 h1:J8FllvIDv5RkON+Bg61NxNr78cYLOLifRrg/ugm5mW8=
+gitlab.com/elixxir/primitives v0.0.3-0.20210607210820-afd1b028b558/go.mod h1:1EFCSsERjE5RagCjys/70sqkAZC5PimEkWHcljh4bwQ=
 gitlab.com/xx_network/comms v0.0.0-20200805174823-841427dd5023/go.mod h1:owEcxTRl7gsoM8c3RQ5KAm5GstxrJp5tn+6JfQ4z5Hw=
 gitlab.com/xx_network/comms v0.0.4-0.20210603164237-d0c36076d7f0 h1:+/U+6Ra5pqDhIHCqMniESovsakooCFTv/omlpfvffU8=
 gitlab.com/xx_network/comms v0.0.4-0.20210603164237-d0c36076d7f0/go.mod h1:cpogFfWweZFzldGnRmgI9ilW2IFyeINDXpa1sAP950U=
@@ -271,8 +271,9 @@ gitlab.com/xx_network/crypto v0.0.5-0.20210603164136-743cb9b0a967/go.mod h1:qOOP
 gitlab.com/xx_network/primitives v0.0.0-20200803231956-9b192c57ea7c/go.mod h1:wtdCMr7DPePz9qwctNoAUzZtbOSHSedcK++3Df3psjA=
 gitlab.com/xx_network/primitives v0.0.0-20200804183002-f99f7a7284da/go.mod h1:OK9xevzWCaPO7b1wiluVJGk7R5ZsuC7pHY5hteZFQug=
 gitlab.com/xx_network/primitives v0.0.2/go.mod h1:cs0QlFpdMDI6lAo61lDRH2JZz+3aVkHy+QogOB6F/qc=
-gitlab.com/xx_network/primitives v0.0.4-0.20210603164056-0abf3f914f25 h1:SH3pO8rAj59B6LLFDYWNrgj5yUncT7/TBXrSyORbQRQ=
 gitlab.com/xx_network/primitives v0.0.4-0.20210603164056-0abf3f914f25/go.mod h1:9imZHvYwNFobxueSvVtHneZLk9wTK7HQTzxPm+zhFhE=
+gitlab.com/xx_network/primitives v0.0.4-0.20210607221158-361a2cbc5529 h1:zC1z2Pcxy+fQc3ZzRxz2lOorj1LqBms3xohIVmThPb0=
+gitlab.com/xx_network/primitives v0.0.4-0.20210607221158-361a2cbc5529/go.mod h1:9imZHvYwNFobxueSvVtHneZLk9wTK7HQTzxPm+zhFhE=
 gitlab.com/xx_network/ring v0.0.3-0.20210527191221-ce3f170aabd5 h1:FY+4Rh1Q2rgLyv10aKJjhWApuKRCR/054XhreudfAvw=
 gitlab.com/xx_network/ring v0.0.3-0.20210527191221-ce3f170aabd5/go.mod h1:aLzpP2TiZTQut/PVHR40EJAomzugDdHXetbieRClXIM=
 go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
diff --git a/groupChat/gcMessages.pb.go b/groupChat/gcMessages.pb.go
new file mode 100644
index 0000000000000000000000000000000000000000..37b23167e412866c8012fd9f8c95bd99adab88b3
--- /dev/null
+++ b/groupChat/gcMessages.pb.go
@@ -0,0 +1,117 @@
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// source: groupChat/gcMessages.proto
+
+package groupChat
+
+import (
+	fmt "fmt"
+	proto "github.com/golang/protobuf/proto"
+	math "math"
+)
+
+// Reference imports to suppress errors if they are not otherwise used.
+var _ = proto.Marshal
+var _ = fmt.Errorf
+var _ = math.Inf
+
+// This is a compile-time assertion to ensure that this generated file
+// is compatible with the proto package it is being compiled against.
+// A compilation error at this line likely means your copy of the
+// proto package needs to be updated.
+const _ = proto.ProtoPackageIsVersion3 // please upgrade the proto package
+
+// Request to join the group sent from leader to all members.
+type Request struct {
+	Name                 []byte   `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+	IdPreimage           []byte   `protobuf:"bytes,2,opt,name=idPreimage,proto3" json:"idPreimage,omitempty"`
+	KeyPreimage          []byte   `protobuf:"bytes,3,opt,name=keyPreimage,proto3" json:"keyPreimage,omitempty"`
+	Members              []byte   `protobuf:"bytes,4,opt,name=members,proto3" json:"members,omitempty"`
+	Message              []byte   `protobuf:"bytes,5,opt,name=message,proto3" json:"message,omitempty"`
+	XXX_NoUnkeyedLiteral struct{} `json:"-"`
+	XXX_unrecognized     []byte   `json:"-"`
+	XXX_sizecache        int32    `json:"-"`
+}
+
+func (m *Request) Reset()         { *m = Request{} }
+func (m *Request) String() string { return proto.CompactTextString(m) }
+func (*Request) ProtoMessage()    {}
+func (*Request) Descriptor() ([]byte, []int) {
+	return fileDescriptor_49d0b7a6ffb7e279, []int{0}
+}
+
+func (m *Request) XXX_Unmarshal(b []byte) error {
+	return xxx_messageInfo_Request.Unmarshal(m, b)
+}
+func (m *Request) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) {
+	return xxx_messageInfo_Request.Marshal(b, m, deterministic)
+}
+func (m *Request) XXX_Merge(src proto.Message) {
+	xxx_messageInfo_Request.Merge(m, src)
+}
+func (m *Request) XXX_Size() int {
+	return xxx_messageInfo_Request.Size(m)
+}
+func (m *Request) XXX_DiscardUnknown() {
+	xxx_messageInfo_Request.DiscardUnknown(m)
+}
+
+var xxx_messageInfo_Request proto.InternalMessageInfo
+
+func (m *Request) GetName() []byte {
+	if m != nil {
+		return m.Name
+	}
+	return nil
+}
+
+func (m *Request) GetIdPreimage() []byte {
+	if m != nil {
+		return m.IdPreimage
+	}
+	return nil
+}
+
+func (m *Request) GetKeyPreimage() []byte {
+	if m != nil {
+		return m.KeyPreimage
+	}
+	return nil
+}
+
+func (m *Request) GetMembers() []byte {
+	if m != nil {
+		return m.Members
+	}
+	return nil
+}
+
+func (m *Request) GetMessage() []byte {
+	if m != nil {
+		return m.Message
+	}
+	return nil
+}
+
+func init() {
+	proto.RegisterType((*Request)(nil), "gcRequestMessages.Request")
+}
+
+func init() {
+	proto.RegisterFile("groupChat/gcMessages.proto", fileDescriptor_49d0b7a6ffb7e279)
+}
+
+var fileDescriptor_49d0b7a6ffb7e279 = []byte{
+	// 186 bytes of a gzipped FileDescriptorProto
+	0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xe2, 0x92, 0x4a, 0x2f, 0xca, 0x2f,
+	0x2d, 0x70, 0xce, 0x48, 0x2c, 0xd1, 0x4f, 0x4f, 0xf6, 0x4d, 0x2d, 0x2e, 0x4e, 0x4c, 0x4f, 0x2d,
+	0xd6, 0x2b, 0x28, 0xca, 0x2f, 0xc9, 0x17, 0x12, 0x4c, 0x4f, 0x0e, 0x4a, 0x2d, 0x2c, 0x4d, 0x2d,
+	0x2e, 0x81, 0x49, 0x28, 0x4d, 0x66, 0xe4, 0x62, 0x87, 0x8a, 0x09, 0x09, 0x71, 0xb1, 0xe4, 0x25,
+	0xe6, 0xa6, 0x4a, 0x30, 0x2a, 0x30, 0x6a, 0xf0, 0x04, 0x81, 0xd9, 0x42, 0x72, 0x5c, 0x5c, 0x99,
+	0x29, 0x01, 0x45, 0xa9, 0x99, 0xb9, 0x89, 0xe9, 0xa9, 0x12, 0x4c, 0x60, 0x19, 0x24, 0x11, 0x21,
+	0x05, 0x2e, 0xee, 0xec, 0xd4, 0x4a, 0xb8, 0x02, 0x66, 0xb0, 0x02, 0x64, 0x21, 0x21, 0x09, 0x2e,
+	0xf6, 0xdc, 0xd4, 0xdc, 0xa4, 0xd4, 0xa2, 0x62, 0x09, 0x16, 0xb0, 0x2c, 0x8c, 0x0b, 0x91, 0x01,
+	0xbb, 0x43, 0x82, 0x15, 0x26, 0x03, 0xe6, 0x3a, 0xa9, 0x46, 0x29, 0xa7, 0x67, 0x96, 0xe4, 0x24,
+	0x26, 0xe9, 0x25, 0xe7, 0xe7, 0xea, 0xa7, 0xe6, 0x64, 0x56, 0x54, 0x64, 0x16, 0xe9, 0x27, 0xe7,
+	0x64, 0xa6, 0xe6, 0x95, 0xe8, 0xc3, 0x3d, 0x98, 0xc4, 0x06, 0xf6, 0x96, 0x31, 0x20, 0x00, 0x00,
+	0xff, 0xff, 0x6e, 0x63, 0x77, 0xd1, 0xf4, 0x00, 0x00, 0x00,
+}
diff --git a/groupChat/gcMessages.proto b/groupChat/gcMessages.proto
new file mode 100644
index 0000000000000000000000000000000000000000..7ce1f3b40c5f34f61367dcec2e477dd8040faae5
--- /dev/null
+++ b/groupChat/gcMessages.proto
@@ -0,0 +1,20 @@
+///////////////////////////////////////////////////////////////////////////////
+// Copyright © 2020 xx network SEZC                                          //
+//                                                                           //
+// Use of this source code is governed by a license that can be found in the //
+// LICENSE file                                                              //
+///////////////////////////////////////////////////////////////////////////////
+
+syntax = "proto3";
+package gcRequestMessages;
+option go_package = "gitlab.com/elixxir/client/groupChat";
+
+
+// Request to join the group sent from leader to all members.
+message Request {
+    bytes name = 1;
+    bytes idPreimage = 2;
+    bytes keyPreimage = 3;
+    bytes members = 4;
+    bytes message = 5;
+}
\ No newline at end of file
diff --git a/groupChat/generateProto.sh b/groupChat/generateProto.sh
new file mode 100644
index 0000000000000000000000000000000000000000..43968a4aa112270ffb38ea9a2c5da91e309871f1
--- /dev/null
+++ b/groupChat/generateProto.sh
@@ -0,0 +1,3 @@
+#!/bin/bash
+
+protoc --go_out=paths=source_relative:. groupChat/gcMessages.proto
diff --git a/groupChat/group.go b/groupChat/group.go
new file mode 100644
index 0000000000000000000000000000000000000000..5d67d19f1e52f80cf1628f2deece083b4d8f4568
--- /dev/null
+++ b/groupChat/group.go
@@ -0,0 +1,70 @@
+///////////////////////////////////////////////////////////////////////////////
+// Copyright © 2020 xx network SEZC                                          //
+//                                                                           //
+// Use of this source code is governed by a license that can be found in the //
+// LICENSE file                                                              //
+///////////////////////////////////////////////////////////////////////////////
+
+// Group chat is used to communicate the same content with multiple clients over
+// cMix. A group chat is controlled by a group leader who creates the group,
+// defines all group keys, and is responsible for key rotation. To create a
+// group, the group leader must have an authenticated channel with all members
+// of the group.
+//
+// Once a group is created, neither the leader nor other members can add or
+// remove users to the group. Only members can leave a group themselves.
+//
+// When a message is sent to the group, the sender will send an individual
+// message to every member of the group.
+
+package groupChat
+
+import (
+	gs "gitlab.com/elixxir/client/groupChat/groupStore"
+	"gitlab.com/xx_network/primitives/id"
+)
+
+// GroupChat is used to send and receive cMix messages to/from multiple users.
+type GroupChat interface {
+	// MakeGroup sends GroupChat requests to all members over an authenticated
+	// channel. The leader of a GroupChat must have an authenticated channel
+	// with each member of the GroupChat to add them to the GroupChat. It blocks
+	// until all the GroupChat requests are sent. Returns the new group and the
+	// round IDs the requests were sent on. Returns an error if at least one
+	// request to a member fails to send. Also returns the status of the sent
+	// requests.
+	MakeGroup(membership []*id.ID, name, message []byte) (gs.Group, []id.Round,
+		RequestStatus, error)
+
+	// ResendRequest allows a GroupChat request to be sent again. It returns
+	// the rounds that the requests were sent on and the status of the send.
+	ResendRequest(groupID *id.ID) ([]id.Round, RequestStatus, error)
+
+	// JoinGroup allows a user to accept a GroupChat request and stores the
+	// GroupChat as active to allow receiving and sending of messages from/to
+	// the GroupChat. A user can only join a GroupChat once.
+	JoinGroup(g gs.Group) error
+
+	// LeaveGroup removes a group from a list of groups the user is a part of.
+	LeaveGroup(groupID *id.ID) error
+
+	// Send sends a message to all GroupChat members using Client.SendManyCMIX.
+	// The send fails if the message is too long.
+	Send(groupID *id.ID, message []byte) (id.Round, error)
+
+	// GetGroups returns a list of all registered GroupChat IDs.
+	GetGroups() []*id.ID
+
+	// GetGroup returns the group with the matching ID or returns false if none
+	// exist.
+	GetGroup(groupID *id.ID) (gs.Group, bool)
+
+	// NumGroups returns the number of groups the user is a part of.
+	NumGroups() int
+}
+
+// RequestCallback is called when a GroupChat request is received.
+type RequestCallback func(g gs.Group)
+
+// ReceiveCallback is called when a GroupChat message is received.
+type ReceiveCallback func(msg MessageReceive)
diff --git a/groupChat/groupStore/dhKeyList.go b/groupChat/groupStore/dhKeyList.go
new file mode 100644
index 0000000000000000000000000000000000000000..50c405c426d7a27f2f99767208d2b2915a4f93b5
--- /dev/null
+++ b/groupChat/groupStore/dhKeyList.go
@@ -0,0 +1,139 @@
+///////////////////////////////////////////////////////////////////////////////
+// Copyright © 2020 xx network SEZC                                          //
+//                                                                           //
+// Use of this source code is governed by a license that can be found in the //
+// LICENSE file                                                              //
+///////////////////////////////////////////////////////////////////////////////
+
+package groupStore
+
+import (
+	"bytes"
+	"encoding/binary"
+	"github.com/pkg/errors"
+	"gitlab.com/elixxir/crypto/cyclic"
+	"gitlab.com/elixxir/crypto/diffieHellman"
+	"gitlab.com/elixxir/crypto/group"
+	"gitlab.com/xx_network/primitives/id"
+	"sort"
+	"strings"
+)
+
+// Error messages.
+const (
+	idUnmarshalErr = "failed to unmarshal member ID: %+v"
+	dhKeyDecodeErr = "failed to decode member DH key: %+v"
+)
+
+type DhKeyList map[id.ID]*cyclic.Int
+
+// GenerateDhKeyList generates the symmetric/DH key between the user and all
+// group members.
+func GenerateDhKeyList(userID *id.ID, privKey *cyclic.Int,
+	members group.Membership, grp *cyclic.Group) DhKeyList {
+	dkl := make(DhKeyList, len(members)-1)
+
+	for _, m := range members {
+		if !userID.Cmp(m.ID) {
+			dkl.Add(privKey, m, grp)
+		}
+	}
+
+	return dkl
+}
+
+// Add generates DH key between the user and the group member. The
+func (dkl DhKeyList) Add(privKey *cyclic.Int, m group.Member, grp *cyclic.Group) {
+	dkl[*m.ID] = diffieHellman.GenerateSessionKey(privKey, m.DhKey, grp)
+}
+
+// DeepCopy returns a copy of the DhKeyList.
+func (dkl DhKeyList) DeepCopy() DhKeyList {
+	newDkl := make(DhKeyList, len(dkl))
+	for uid, key := range dkl {
+		newDkl[uid] = key.DeepCopy()
+	}
+	return newDkl
+}
+
+// Serialize serializes the DhKeyList and returns the byte slice.
+func (dkl DhKeyList) Serialize() []byte {
+	buff := bytes.NewBuffer(nil)
+
+	for uid, key := range dkl {
+		// Write ID
+		buff.Write(uid.Marshal())
+
+		// Write DH key length
+		b := make([]byte, 8)
+		keyBytes := key.BinaryEncode()
+		binary.LittleEndian.PutUint64(b, uint64(len(keyBytes)))
+		buff.Write(b)
+
+		// Write DH key
+		buff.Write(keyBytes)
+	}
+
+	return buff.Bytes()
+}
+
+// DeserializeDhKeyList deserializes the bytes into a DhKeyList.
+func DeserializeDhKeyList(data []byte) (DhKeyList, error) {
+	if len(data) == 0 {
+		return nil, nil
+	}
+
+	buff := bytes.NewBuffer(data)
+	dkl := make(DhKeyList)
+
+	for n := buff.Next(id.ArrIDLen); len(n) == id.ArrIDLen; n = buff.Next(id.ArrIDLen) {
+		// Read and unmarshal ID
+		uid, err := id.Unmarshal(n)
+		if err != nil {
+			return nil, errors.Errorf(idUnmarshalErr, err)
+		}
+
+		// Get length of DH key
+		keyLen := int(binary.LittleEndian.Uint64(buff.Next(8)))
+
+		// Read and decode DH key
+		key := &cyclic.Int{}
+		err = key.BinaryDecode(buff.Next(keyLen))
+		if err != nil {
+			return nil, errors.Errorf(dhKeyDecodeErr, err)
+		}
+
+		dkl[*uid] = key
+	}
+
+	return dkl, nil
+}
+
+// GoString returns all the elements in the DhKeyList as text in sorted order.
+// This functions satisfies the fmt.GoStringer interface.
+func (dkl DhKeyList) GoString() string {
+	str := make([]string, 0, len(dkl))
+
+	unsorted := make([]struct {
+		uid *id.ID
+		key *cyclic.Int
+	}, 0, len(dkl))
+
+	for uid, key := range dkl {
+		unsorted = append(unsorted, struct {
+			uid *id.ID
+			key *cyclic.Int
+		}{uid: uid.DeepCopy(), key: key.DeepCopy()})
+	}
+
+	sort.Slice(unsorted, func(i, j int) bool {
+		return bytes.Compare(unsorted[i].uid.Bytes(),
+			unsorted[j].uid.Bytes()) == -1
+	})
+
+	for _, val := range unsorted {
+		str = append(str, val.uid.String()+": "+val.key.Text(10))
+	}
+
+	return "{" + strings.Join(str, ", ") + "}"
+}
diff --git a/groupChat/groupStore/dhKeyList_test.go b/groupChat/groupStore/dhKeyList_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..eca03f45a187c63a13276b7f11edfda2010e7b4d
--- /dev/null
+++ b/groupChat/groupStore/dhKeyList_test.go
@@ -0,0 +1,93 @@
+package groupStore
+
+import (
+	"math/rand"
+	"reflect"
+	"strings"
+	"testing"
+)
+
+// // Unit test of GenerateDhKeyList.
+// func TestGenerateDhKeyList(t *testing.T) {
+// 	prng := rand.New(rand.NewSource(42))
+// 	grp := getGroup()
+// 	userID := id.NewIdFromString("userID", id.User, t)
+// 	privKey := grp.NewInt(42)
+// 	pubKey := grp.ExpG(privKey, grp.NewInt(1))
+// 	members := createMembership(prng, 10, t)
+// 	members[2].ID = userID
+// 	members[2].DhKey = pubKey
+//
+// 	dkl := GenerateDhKeyList(userID, privKey, members, grp)
+//
+// 	t.Log(dkl)
+// }
+
+// Unit test of DhKeyList.DeepCopy.
+func TestDhKeyList_DeepCopy(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	dkl := createDhKeyList(prng, 10, t)
+	newDkl := dkl.DeepCopy()
+
+	if !reflect.DeepEqual(dkl, newDkl) {
+		t.Errorf("DeepCopy() failed to return a copy of the original."+
+			"\nexpected: %#v\nrecevied: %#v", dkl, newDkl)
+	}
+
+	if &dkl == &newDkl {
+		t.Errorf("DeepCopy returned a copy of the pointer."+
+			"\nexpected: %p\nreceived: %p", &dkl, &newDkl)
+	}
+}
+
+// Tests that a DhKeyList that is serialized and deserialized matches the
+// original.
+func TestDhKeyList_Serialize_DeserializeDhKeyList(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	dkl := createDhKeyList(prng, 10, t)
+
+	data := dkl.Serialize()
+	newDkl, err := DeserializeDhKeyList(data)
+	if err != nil {
+		t.Errorf("DeserializeDhKeyList returned an error: %+v", err)
+	}
+
+	if !reflect.DeepEqual(dkl, newDkl) {
+		t.Errorf("Failed to serialize and deserialize DhKeyList."+
+			"\nexpected: %#v\nreceived: %#v", dkl, newDkl)
+	}
+}
+
+// Error path: an error is returned when DeserializeDhKeyList encounters invalid
+// cyclic int.
+func TestDeserializeDhKeyList_DhKeyBinaryDecodeError(t *testing.T) {
+	expectedErr := strings.SplitN(dhKeyDecodeErr, "%", 2)[0]
+
+	_, err := DeserializeDhKeyList(make([]byte, 41))
+	if err == nil || !strings.Contains(err.Error(), expectedErr) {
+		t.Errorf("DeserializeDhKeyList failed to return the expected error."+
+			"\nexpected: %s\nreceived: %+v", expectedErr, err)
+	}
+}
+
+// Unit test of DhKeyList.GoString.
+func TestDhKeyList_GoString(t *testing.T) {
+	grp := createTestGroup(rand.New(rand.NewSource(42)), t)
+	expected := "{Grcjbkt1IWKQzyvrQsPKJzKFYPGqwGfOpui/RtSrK0YD: 5170411903... in GRP: 6SsQ/HAHUn..., QCxg8d6XgoPUoJo2+WwglBdG4+1NpkaprotPp7T8OiAD: 1754900790... in GRP: 6SsQ/HAHUn..., invD4ElbVxL+/b4MECiH4QDazS2IX2kstgfaAKEcHHAD: 2926033432... in GRP: 6SsQ/HAHUn..., wRYCP6iJdLrAyv2a0FaSsTYZ5ziWTf3Hno1TQ3NmHP0D: 2297312580... in GRP: 6SsQ/HAHUn..., 15ufnw07pVsMwNYUTIiFNYQay+BwmwdYCD9h03W8ArQD: 6199513233... in GRP: 6SsQ/HAHUn..., 3RqsBM4ux44bC6+uiBuCp1EQikLtPJA8qkNGWnhiBhYD: 4604475835... in GRP: 6SsQ/HAHUn..., 55ai4SlwXic/BckjJoKOKwVuOBdljhBhSYlH/fNEQQ4D: 9940605492... in GRP: 6SsQ/HAHUn..., 9PkZKU50joHnnku9b+NM3LqEPujWPoxP/hzr6lRtj6wD: 2451667393... in GRP: 6SsQ/HAHUn..., +hp17fHP0rO1EhnqeVM6v0SNLEedMmB1M5BZFMjMHPAD: 6029441980... in GRP: 6SsQ/HAHUn...}"
+
+	if grp.DhKeys.GoString() != expected {
+		t.Errorf("GoString failed to return the expected string."+
+			"\nexpected: %s\nreceived: %s", expected, grp.DhKeys.GoString())
+	}
+}
+
+// Tests that DhKeyList.GoString. returns the expected string for a nil map.
+func TestDhKeyList_GoString_NilMap(t *testing.T) {
+	dkl := DhKeyList{}
+	expected := "{}"
+
+	if dkl.GoString() != expected {
+		t.Errorf("GoString failed to return the expected string."+
+			"\nexpected: %s\nreceived: %s", expected, dkl.GoString())
+	}
+}
diff --git a/groupChat/groupStore/group.go b/groupChat/groupStore/group.go
new file mode 100644
index 0000000000000000000000000000000000000000..98d0b489de808cf1ed5ca31818f4bc7f203fe878
--- /dev/null
+++ b/groupChat/groupStore/group.go
@@ -0,0 +1,235 @@
+///////////////////////////////////////////////////////////////////////////////
+// Copyright © 2020 xx network SEZC                                          //
+//                                                                           //
+// Use of this source code is governed by a license that can be found in the //
+// LICENSE file                                                              //
+///////////////////////////////////////////////////////////////////////////////
+
+package groupStore
+
+import (
+	"bytes"
+	"encoding/binary"
+	"fmt"
+	"github.com/pkg/errors"
+	"gitlab.com/elixxir/client/storage/versioned"
+	"gitlab.com/elixxir/crypto/cyclic"
+	"gitlab.com/elixxir/crypto/group"
+	"gitlab.com/xx_network/primitives/id"
+	"gitlab.com/xx_network/primitives/netTime"
+	"strings"
+)
+
+// Storage values.
+const (
+	// Key that is prepended to group ID to create a unique key to identify a
+	// Group in storage.
+	groupStorageKey   = "GroupChat/"
+	groupStoreVersion = 0
+)
+
+// Error messages.
+const (
+	kvGetGroupErr = "failed to get group %s from storage: %+v"
+	membershipErr = "failed to deserialize member list: %+v"
+	dhKeyListErr  = "failed to deserialize DH key list: %+v"
+)
+
+// Group contains the membership list, the cryptographic information, and the
+// identifying information of a group chat.
+type Group struct {
+	Name        []byte            // Name of the group set by the user
+	ID          *id.ID            // Group ID
+	Key         group.Key         // Group key
+	IdPreimage  group.IdPreimage  // 256-bit value from CRNG
+	KeyPreimage group.KeyPreimage // 256-bit value from CRNG
+	InitMessage []byte            // The original invite message
+	Members     group.Membership  // Sorted list of members in group
+	DhKeys      DhKeyList         // List of shared DH keys
+}
+
+// NewGroup creates a new Group from copies of the given data.
+func NewGroup(name []byte, groupID *id.ID, groupKey group.Key,
+	idPreimage group.IdPreimage, keyPreimage group.KeyPreimage,
+	initMessage []byte, members group.Membership, dhKeys DhKeyList) Group {
+	g := Group{
+		Name:        make([]byte, len(name)),
+		ID:          groupID.DeepCopy(),
+		Key:         groupKey,
+		IdPreimage:  idPreimage,
+		KeyPreimage: keyPreimage,
+		InitMessage: make([]byte, len(initMessage)),
+		Members:     members.DeepCopy(),
+		DhKeys:      dhKeys,
+	}
+
+	copy(g.Name, name)
+	copy(g.InitMessage, initMessage)
+
+	return g
+}
+
+// DeepCopy returns a copy of the Group.
+func (g Group) DeepCopy() Group {
+	newGrp := Group{
+		Name:        make([]byte, len(g.Name)),
+		ID:          g.ID.DeepCopy(),
+		Key:         g.Key,
+		IdPreimage:  g.IdPreimage,
+		KeyPreimage: g.KeyPreimage,
+		InitMessage: make([]byte, len(g.InitMessage)),
+		Members:     g.Members.DeepCopy(),
+		DhKeys:      make(map[id.ID]*cyclic.Int, len(g.Members)-1),
+	}
+
+	copy(newGrp.Name, g.Name)
+	copy(newGrp.InitMessage, g.InitMessage)
+
+	for uid, key := range g.DhKeys {
+		newGrp.DhKeys[uid] = key.DeepCopy()
+	}
+
+	return newGrp
+}
+
+// store saves an individual Group to storage keying on the group ID.
+func (g Group) store(kv *versioned.KV) error {
+	obj := &versioned.Object{
+		Version:   groupStoreVersion,
+		Timestamp: netTime.Now(),
+		Data:      g.Serialize(),
+	}
+
+	return kv.Set(groupStoreKey(g.ID), groupStoreVersion, obj)
+}
+
+// loadGroup returns the group with the corresponding ID from storage.
+func loadGroup(groupID *id.ID, kv *versioned.KV) (Group, error) {
+	obj, err := kv.Get(groupStoreKey(groupID), groupStoreVersion)
+	if err != nil {
+		return Group{}, errors.Errorf(kvGetGroupErr, groupID, err)
+	}
+
+	return DeserializeGroup(obj.Data)
+}
+
+// removeGroup deletes the given group from storage.
+func removeGroup(groupID *id.ID, kv *versioned.KV) error {
+	return kv.Delete(groupStoreKey(groupID), groupStoreVersion)
+}
+
+// Serialize serializes the Group and returns the byte slice.
+func (g Group) Serialize() []byte {
+	buff := bytes.NewBuffer(nil)
+
+	// Write length of name and name
+	b := make([]byte, 8)
+	binary.LittleEndian.PutUint64(b, uint64(len(g.Name)))
+	buff.Write(b)
+	buff.Write(g.Name)
+
+	// Write group ID
+	if g.ID != nil {
+		buff.Write(g.ID.Marshal())
+	} else {
+		buff.Write(make([]byte, id.ArrIDLen))
+	}
+
+	// Write group key and preimages
+	buff.Write(g.Key[:])
+	buff.Write(g.IdPreimage[:])
+	buff.Write(g.KeyPreimage[:])
+
+	// Write length of InitMessage and InitMessage
+	b = make([]byte, 8)
+	binary.LittleEndian.PutUint64(b, uint64(len(g.InitMessage)))
+	buff.Write(b)
+	buff.Write(g.InitMessage)
+
+	// Write length of group membership and group membership
+	b = make([]byte, 8)
+	memberBytes := g.Members.Serialize()
+	binary.LittleEndian.PutUint64(b, uint64(len(memberBytes)))
+	buff.Write(b)
+	buff.Write(memberBytes)
+
+	// Write DH key list
+	buff.Write(g.DhKeys.Serialize())
+
+	return buff.Bytes()
+}
+
+// DeserializeGroup deserializes the bytes into a Group.
+func DeserializeGroup(data []byte) (Group, error) {
+	buff := bytes.NewBuffer(data)
+	var g Group
+	var err error
+
+	// Get name
+	nameLen := binary.LittleEndian.Uint64(buff.Next(8))
+	if nameLen > 0 {
+		g.Name = buff.Next(int(nameLen))
+	}
+
+	// Get group ID
+	var groupID id.ID
+	copy(groupID[:], buff.Next(id.ArrIDLen))
+	if groupID == [id.ArrIDLen]byte{} {
+		g.ID = nil
+	} else {
+		g.ID = &groupID
+	}
+
+	// Get group key and preimages
+	copy(g.Key[:], buff.Next(group.KeyLen))
+	copy(g.IdPreimage[:], buff.Next(group.IdPreimageLen))
+	copy(g.KeyPreimage[:], buff.Next(group.KeyPreimageLen))
+
+	// Get InitMessage
+	initMessageLength := binary.LittleEndian.Uint64(buff.Next(8))
+	if initMessageLength > 0 {
+		g.InitMessage = buff.Next(int(initMessageLength))
+	}
+
+	// Get member list
+	membersLength := binary.LittleEndian.Uint64(buff.Next(8))
+	g.Members, err = group.DeserializeMembership(buff.Next(int(membersLength)))
+	if err != nil {
+		return Group{}, errors.Errorf(membershipErr, err)
+	}
+
+	// Get DH key list
+	g.DhKeys, err = DeserializeDhKeyList(buff.Bytes())
+	if err != nil {
+		return Group{}, errors.Errorf(dhKeyListErr, err)
+	}
+
+	return g, err
+}
+
+// groupStoreKey generates a unique key to save and load a Group to/from storage.
+func groupStoreKey(groupID *id.ID) string {
+	return groupStorageKey + groupID.String()
+}
+
+// GoString returns all the Group's fields as text. This functions satisfies the
+// fmt.GoStringer interface.
+func (g Group) GoString() string {
+	idString := "<nil>"
+	if g.ID != nil {
+		idString = g.ID.String()
+	}
+
+	str := make([]string, 8)
+
+	str[0] = "Name:" + fmt.Sprintf("%q", g.Name)
+	str[1] = "ID:" + idString
+	str[2] = "Key:" + g.Key.String()
+	str[3] = "IdPreimage:" + g.IdPreimage.String()
+	str[4] = "KeyPreimage:" + g.KeyPreimage.String()
+	str[5] = "InitMessage:" + fmt.Sprintf("%q", g.InitMessage)
+	str[6] = "Members:" + g.Members.String()
+	str[7] = "DhKeys:" + g.DhKeys.GoString()
+
+	return "{" + strings.Join(str, ", ") + "}"
+}
diff --git a/groupChat/groupStore/group_test.go b/groupChat/groupStore/group_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..b0f84761b39c17059635c592e2f6f271471dbab1
--- /dev/null
+++ b/groupChat/groupStore/group_test.go
@@ -0,0 +1,289 @@
+///////////////////////////////////////////////////////////////////////////////
+// Copyright © 2020 xx network SEZC                                          //
+//                                                                           //
+// Use of this source code is governed by a license that can be found in the //
+// LICENSE file                                                              //
+///////////////////////////////////////////////////////////////////////////////
+
+package groupStore
+
+import (
+	"gitlab.com/elixxir/client/storage/versioned"
+	"gitlab.com/elixxir/crypto/group"
+	"gitlab.com/elixxir/ekv"
+	"gitlab.com/xx_network/primitives/id"
+	"math/rand"
+	"reflect"
+	"strings"
+	"testing"
+)
+
+// Unit test of NewGroup.
+func TestNewGroup(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	membership := createMembership(prng, 10, t)
+	dkl := GenerateDhKeyList(membership[0].ID, randCycInt(prng), membership, getGroup())
+
+	expectedGroup := Group{
+		Name:        []byte(groupName),
+		ID:          id.NewIdFromUInt(uint64(42), id.Group, t),
+		Key:         newKey(groupKey),
+		IdPreimage:  newIdPreimage(groupIdPreimage),
+		KeyPreimage: newKeyPreimage(groupKeyPreimage),
+		InitMessage: []byte(initMessage),
+		Members:     membership,
+		DhKeys:      dkl,
+	}
+
+	receivedGroup := NewGroup(
+		[]byte(groupName),
+		id.NewIdFromUInt(uint64(42), id.Group, t),
+		newKey(groupKey),
+		newIdPreimage(groupIdPreimage),
+		newKeyPreimage(groupKeyPreimage),
+		[]byte(initMessage),
+		membership,
+		dkl,
+	)
+
+	if !reflect.DeepEqual(receivedGroup, expectedGroup) {
+		t.Errorf("NewGroup did not return the expected Group."+
+			"\nexpected: %#v\nreceived: %#v", expectedGroup, receivedGroup)
+	}
+}
+
+// Unit test of Group.DeepCopy.
+func TestGroup_DeepCopy(t *testing.T) {
+	grp := createTestGroup(rand.New(rand.NewSource(42)), t)
+
+	newGrp := grp.DeepCopy()
+
+	if !reflect.DeepEqual(grp, newGrp) {
+		t.Errorf("DeepCopy did not return a copy of the original Group."+
+			"\nexpected: %#v\nreceived: %#v", grp, newGrp)
+	}
+
+	if &grp.Name[0] == &newGrp.Name[0] {
+		t.Errorf("DeepCopy returned a copy of the pointer of Name."+
+			"\nexpected: %p\nreceived: %p", &grp.Name[0], &newGrp.Name[0])
+	}
+
+	if &grp.ID[0] == &newGrp.ID[0] {
+		t.Errorf("DeepCopy returned a copy of the pointer of ID."+
+			"\nexpected: %p\nreceived: %p", &grp.ID[0], &newGrp.ID[0])
+	}
+
+	if &grp.Key[0] == &newGrp.Key[0] {
+		t.Errorf("DeepCopy returned a copy of the pointer of Key."+
+			"\nexpected: %p\nreceived: %p", &grp.Key[0], &newGrp.Key[0])
+	}
+
+	if &grp.IdPreimage[0] == &newGrp.IdPreimage[0] {
+		t.Errorf("DeepCopy returned a copy of the pointer of IdPreimage."+
+			"\nexpected: %p\nreceived: %p", &grp.IdPreimage[0], &newGrp.IdPreimage[0])
+	}
+
+	if &grp.KeyPreimage[0] == &newGrp.KeyPreimage[0] {
+		t.Errorf("DeepCopy returned a copy of the pointer of KeyPreimage."+
+			"\nexpected: %p\nreceived: %p", &grp.KeyPreimage[0], &newGrp.KeyPreimage[0])
+	}
+
+	if &grp.InitMessage[0] == &newGrp.InitMessage[0] {
+		t.Errorf("DeepCopy returned a copy of the pointer of InitMessage."+
+			"\nexpected: %p\nreceived: %p", &grp.InitMessage[0], &newGrp.InitMessage[0])
+	}
+
+	if &grp.Members[0] == &newGrp.Members[0] {
+		t.Errorf("DeepCopy returned a copy of the pointer of Members."+
+			"\nexpected: %p\nreceived: %p", &grp.Members[0], &newGrp.Members[0])
+	}
+}
+
+// Unit test of Group.store.
+func TestGroup_store(t *testing.T) {
+	kv := versioned.NewKV(make(ekv.Memstore))
+	g := createTestGroup(rand.New(rand.NewSource(42)), t)
+
+	err := g.store(kv)
+	if err != nil {
+		t.Errorf("store returned an error: %+v", err)
+	}
+
+	obj, err := kv.Get(groupStoreKey(g.ID), groupStoreVersion)
+	if err != nil {
+		t.Errorf("Failed to get group from storage: %+v", err)
+	}
+
+	newGrp, err := DeserializeGroup(obj.Data)
+	if err != nil {
+		t.Errorf("Failed to deserialize group: %+v", err)
+	}
+
+	if !reflect.DeepEqual(g, newGrp) {
+		t.Errorf("Failed to read correct group from storage."+
+			"\nexpected: %#v\nreceived: %#v", g, newGrp)
+	}
+}
+
+// Unit test of Group.loadGroup.
+func Test_loadGroup(t *testing.T) {
+	kv := versioned.NewKV(make(ekv.Memstore))
+	g := createTestGroup(rand.New(rand.NewSource(42)), t)
+
+	err := g.store(kv)
+	if err != nil {
+		t.Errorf("store returned an error: %+v", err)
+	}
+
+	newGrp, err := loadGroup(g.ID, kv)
+	if err != nil {
+		t.Errorf("loadGroup returned an error: %+v", err)
+	}
+
+	if !reflect.DeepEqual(g, newGrp) {
+		t.Errorf("loadGroup failed to return the expected group."+
+			"\nexpected: %#v\nreceived: %#v", g, newGrp)
+	}
+}
+
+// Error path: an error is returned when no group with the ID exists in storage.
+func Test_loadGroup_InvalidGroupIdError(t *testing.T) {
+	kv := versioned.NewKV(make(ekv.Memstore))
+	g := createTestGroup(rand.New(rand.NewSource(42)), t)
+	expectedErr := strings.SplitN(kvGetGroupErr, "%", 2)[0]
+
+	_, err := loadGroup(g.ID, kv)
+	if err == nil || !strings.Contains(err.Error(), expectedErr) {
+		t.Errorf("loadGroup failed to return the expected error."+
+			"\nexpected: %s\nreceived: %+v", expectedErr, err)
+	}
+}
+
+// Unit test of Group.removeGroup.
+func Test_removeGroup(t *testing.T) {
+	kv := versioned.NewKV(make(ekv.Memstore))
+	g := createTestGroup(rand.New(rand.NewSource(42)), t)
+
+	err := g.store(kv)
+	if err != nil {
+		t.Errorf("store returned an error: %+v", err)
+	}
+
+	err = removeGroup(g.ID, kv)
+	if err != nil {
+		t.Errorf("removeGroup returned an error: %+v", err)
+	}
+
+	foundGrp, err := loadGroup(g.ID, kv)
+	if err == nil {
+		t.Errorf("loadGroup found group that should have been removed: %#v",
+			foundGrp)
+	}
+}
+
+// Tests that a group that is serialized and deserialized matches the original.
+func TestGroup_Serialize_DeserializeGroup(t *testing.T) {
+	grp := createTestGroup(rand.New(rand.NewSource(42)), t)
+
+	grpBytes := grp.Serialize()
+
+	newGrp, err := DeserializeGroup(grpBytes)
+	if err != nil {
+		t.Errorf("DeserializeGroup returned an error: %+v", err)
+	}
+
+	if !reflect.DeepEqual(grp, newGrp) {
+		t.Errorf("Deserialized group does not match original."+
+			"\nexpected: %#v\nreceived: %#v", grp, newGrp)
+	}
+}
+
+// Tests that a group with nil fields that is serialized and deserialized
+// matches the original.
+func TestGroup_Serialize_DeserializeGroup_NilGroup(t *testing.T) {
+	grp := Group{Members: make(group.Membership, 3)}
+
+	grpBytes := grp.Serialize()
+
+	newGrp, err := DeserializeGroup(grpBytes)
+	if err != nil {
+		t.Errorf("DeserializeGroup returned an error: %+v", err)
+	}
+
+	if !reflect.DeepEqual(grp, newGrp) {
+		t.Errorf("Deserialized group does not match original."+
+			"\nexpected: %#v\nreceived: %#v", grp, newGrp)
+	}
+}
+
+// Error path: error returned when the group membership is too small.
+func TestDeserializeGroup_DeserializeMembershipError(t *testing.T) {
+	grp := Group{}
+	grpBytes := grp.Serialize()
+	expectedErr := strings.SplitN(membershipErr, "%", 2)[0]
+
+	_, err := DeserializeGroup(grpBytes)
+	if err == nil || !strings.Contains(err.Error(), expectedErr) {
+		t.Errorf("DeserializeGroup failed to return the expected error."+
+			"\nexpected: %s\nreceived: %+v", expectedErr, err)
+	}
+}
+
+func Test_groupStoreKey(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	expectedKeys := []string{
+		"GroupChat/U4x/lrFkvxuXu59LtHLon1sUhPJSCcnZND6SugndnVID",
+		"GroupChat/15tNdkKbYXoMn58NO6VbDMDWFEyIhTWEGsvgcJsHWAgD",
+		"GroupChat/YdN1vAK0HfT5GSnhj9qeb4LlTnSOgeeeS71v40zcuoQD",
+		"GroupChat/6NY+jE/+HOvqVG2PrBPdGqwEzi6ih3xVec+ix44bC68D",
+		"GroupChat/iBuCp1EQikLtPJA8qkNGWnhiBhaXiu0M48bE8657w+AD",
+		"GroupChat/W1cS/v2+DBAoh+EA2s0tiF9pLLYH2gChHBxwceeWotwD",
+		"GroupChat/wlpbdLLhKXBeJz8FySMmgo4rBW44F2WOEGFJiUf980QD",
+		"GroupChat/DtTBFgI/qONXa2/tJ/+JdLrAyv2a0FaSsTYZ5ziWTf0D",
+		"GroupChat/no1TQ3NmHP1m10/sHhuJSRq3I25LdSFikM8r60LDyicD",
+		"GroupChat/hWDxqsBnzqbov0bUqytGgEAsX7KCDohdMmDx3peCg9QD",
+	}
+	for i, expected := range expectedKeys {
+		newID, _ := id.NewRandomID(prng, id.User)
+
+		key := groupStoreKey(newID)
+
+		if key != expected {
+			t.Errorf("groupStoreKey did not return the expected key (%d)."+
+				"\nexpected: %s\nreceived: %s", i, expected, key)
+		}
+
+		// fmt.Printf("\"%s\",\n", key)
+	}
+}
+
+// Unit test of Group.GoString.
+func TestGroup_GoString(t *testing.T) {
+	grp := createTestGroup(rand.New(rand.NewSource(42)), t)
+	expected := "{Name:\"groupName\", ID:ISTkX+tNhfEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE, Key:a2V5AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=, IdPreimage:aWRQcmVpbWFnZQAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=, KeyPreimage:a2V5UHJlaW1hZ2UAAAAAAAAAAAAAAAAAAAAAAAAAAAA=, InitMessage:\"initMessage\", Members:{Leader: {U4x/lrFkvxuXu59LtHLon1sUhPJSCcnZND6SugndnVID, 3534334367... in GRP: 6SsQ/HAHUn...}, Participants: 0: {Grcjbkt1IWKQzyvrQsPKJzKFYPGqwGfOpui/RtSrK0YD, 5274380952... in GRP: 6SsQ/HAHUn...}, 1: {QCxg8d6XgoPUoJo2+WwglBdG4+1NpkaprotPp7T8OiAD, 1628829379... in GRP: 6SsQ/HAHUn...}, 2: {invD4ElbVxL+/b4MECiH4QDazS2IX2kstgfaAKEcHHAD, 4157513341... in GRP: 6SsQ/HAHUn...}, 3: {wRYCP6iJdLrAyv2a0FaSsTYZ5ziWTf3Hno1TQ3NmHP0D, 5785305945... in GRP: 6SsQ/HAHUn...}, 4: {15ufnw07pVsMwNYUTIiFNYQay+BwmwdYCD9h03W8ArQD, 2010156224... in GRP: 6SsQ/HAHUn...}, 5: {3RqsBM4ux44bC6+uiBuCp1EQikLtPJA8qkNGWnhiBhYD, 2643318057... in GRP: 6SsQ/HAHUn...}, 6: {55ai4SlwXic/BckjJoKOKwVuOBdljhBhSYlH/fNEQQ4D, 6482807720... in GRP: 6SsQ/HAHUn...}, 7: {9PkZKU50joHnnku9b+NM3LqEPujWPoxP/hzr6lRtj6wD, 6603068123... in GRP: 6SsQ/HAHUn...}, 8: {+hp17fHP0rO1EhnqeVM6v0SNLEedMmB1M5BZFMjMHPAD, 2628757933... in GRP: 6SsQ/HAHUn...}}, DhKeys:{Grcjbkt1IWKQzyvrQsPKJzKFYPGqwGfOpui/RtSrK0YD: 5170411903... in GRP: 6SsQ/HAHUn..., QCxg8d6XgoPUoJo2+WwglBdG4+1NpkaprotPp7T8OiAD: 1754900790... in GRP: 6SsQ/HAHUn..., invD4ElbVxL+/b4MECiH4QDazS2IX2kstgfaAKEcHHAD: 2926033432... in GRP: 6SsQ/HAHUn..., wRYCP6iJdLrAyv2a0FaSsTYZ5ziWTf3Hno1TQ3NmHP0D: 2297312580... in GRP: 6SsQ/HAHUn..., 15ufnw07pVsMwNYUTIiFNYQay+BwmwdYCD9h03W8ArQD: 6199513233... in GRP: 6SsQ/HAHUn..., 3RqsBM4ux44bC6+uiBuCp1EQikLtPJA8qkNGWnhiBhYD: 4604475835... in GRP: 6SsQ/HAHUn..., 55ai4SlwXic/BckjJoKOKwVuOBdljhBhSYlH/fNEQQ4D: 9940605492... in GRP: 6SsQ/HAHUn..., 9PkZKU50joHnnku9b+NM3LqEPujWPoxP/hzr6lRtj6wD: 2451667393... in GRP: 6SsQ/HAHUn..., +hp17fHP0rO1EhnqeVM6v0SNLEedMmB1M5BZFMjMHPAD: 6029441980... in GRP: 6SsQ/HAHUn...}}"
+
+	if grp.GoString() != expected {
+		t.Errorf("GoString failed to return the expected string."+
+			"\nexpected: %s\nreceived: %s", expected, grp.GoString())
+	}
+}
+
+// Test that Group.GoString returns the expected string for a nil group.
+func TestGroup_GoString_NilGroup(t *testing.T) {
+	grp := Group{}
+	expected := "{" +
+		"Name:\"\", " +
+		"ID:<nil>, " +
+		"Key:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=, " +
+		"IdPreimage:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=, " +
+		"KeyPreimage:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=, " +
+		"InitMessage:\"\", " +
+		"Members:{<nil>}, " +
+		"DhKeys:{}" +
+		"}"
+
+	if grp.GoString() != expected {
+		t.Errorf("GoString failed to return the expected string."+
+			"\nexpected: %s\nreceived: %s", expected, grp.GoString())
+	}
+}
diff --git a/groupChat/groupStore/store.go b/groupChat/groupStore/store.go
new file mode 100644
index 0000000000000000000000000000000000000000..88e980603e323114b2a0c39a138f0e2977627053
--- /dev/null
+++ b/groupChat/groupStore/store.go
@@ -0,0 +1,302 @@
+///////////////////////////////////////////////////////////////////////////////
+// Copyright © 2020 xx network SEZC                                          //
+//                                                                           //
+// Use of this source code is governed by a license that can be found in the //
+// LICENSE file                                                              //
+///////////////////////////////////////////////////////////////////////////////
+
+package groupStore
+
+import (
+	"bytes"
+	"github.com/pkg/errors"
+	jww "github.com/spf13/jwalterweatherman"
+	"gitlab.com/elixxir/client/storage/versioned"
+	"gitlab.com/elixxir/crypto/group"
+	"gitlab.com/elixxir/primitives/format"
+	"gitlab.com/xx_network/primitives/id"
+	"gitlab.com/xx_network/primitives/netTime"
+	"sync"
+	"testing"
+)
+
+// Storage values.
+const (
+	// Key used to identify the list of Groups in storage.
+	groupStoragePrefix  = "GroupChatListStore"
+	groupListStorageKey = "GroupChatList"
+	groupListVersion    = 0
+)
+
+// Error messages.
+const (
+	kvGetGroupListErr = "failed to get list of group IDs from storage: %+v"
+	groupLoadErr      = "failed to load group %d/%d: %+v"
+	groupSaveErr      = "failed to save group %s to storage: %+v"
+	maxGroupsErr      = "failed to add new group, max number of groups (%d) reached"
+	groupExistsErr    = "group with ID %s already exists"
+	groupRemoveErr    = "failed to remove group with ID %s, group not found in memory"
+	saveListRemoveErr = "failed to save new group ID list after removing group %s"
+	setUserPanic      = "Store.SetUser is for testing only. Got %T"
+)
+
+// The maximum number of group chats that a user can be a part of at once.
+const MaxGroupChats = 64
+
+// Store stores the list of Groups that a user is a part of.
+type Store struct {
+	list map[id.ID]Group
+	user group.Member
+	kv   *versioned.KV
+	mux  sync.RWMutex
+}
+
+// NewStore constructs a new Store object for the user and saves it to storage.
+func NewStore(kv *versioned.KV, user group.Member) (*Store, error) {
+	s := &Store{
+		list: make(map[id.ID]Group),
+		user: user.DeepCopy(),
+		kv:   kv.Prefix(groupStoragePrefix),
+	}
+
+	return s, s.save()
+}
+
+// NewOrLoadStore loads the group store from storage or makes a new one if it
+// does not exist.
+func NewOrLoadStore(kv *versioned.KV, user group.Member) (*Store, error) {
+	prefixKv := kv.Prefix(groupStoragePrefix)
+
+	// Load the list of group IDs from file if they exist
+	vo, err := prefixKv.Get(groupListStorageKey, groupListVersion)
+	if err == nil {
+		return loadStore(vo.Data, prefixKv, user)
+	}
+
+	// If there is no group list saved, then make a new one
+	return NewStore(kv, user)
+}
+
+// LoadStore loads all the Groups from storage into memory and return them in
+// a Store object.
+func LoadStore(kv *versioned.KV, user group.Member) (*Store, error) {
+	kv = kv.Prefix(groupStoragePrefix)
+
+	// Load the list of group IDs from file
+	vo, err := kv.Get(groupListStorageKey, groupListVersion)
+	if err != nil {
+		return nil, errors.Errorf(kvGetGroupListErr, err)
+	}
+
+	return loadStore(vo.Data, kv, user)
+}
+
+// loadStore builds the list of group IDs and loads the groups from storage.
+func loadStore(data []byte, kv *versioned.KV, user group.Member) (*Store, error) {
+	// Deserialize list of group IDs
+	groupIDs := deserializeGroupIdList(data)
+
+	// Initialize the Store
+	s := &Store{
+		list: make(map[id.ID]Group, len(groupIDs)),
+		user: user.DeepCopy(),
+		kv:   kv,
+	}
+
+	// Load each Group from storage into the map
+	for i, grpID := range groupIDs {
+		grp, err := loadGroup(grpID, kv)
+		if err != nil {
+			return nil, errors.Errorf(groupLoadErr, i, len(grpID), err)
+		}
+		s.list[*grpID] = grp
+	}
+
+	return s, nil
+}
+
+// saveGroupList saves a list of group IDs to storage.
+func (s *Store) saveGroupList() error {
+	// Create the versioned object
+	obj := &versioned.Object{
+		Version:   groupListVersion,
+		Timestamp: netTime.Now(),
+		Data:      serializeGroupIdList(s.list),
+	}
+
+	// Save to storage
+	return s.kv.Set(groupListStorageKey, groupListVersion, obj)
+}
+
+// serializeGroupIdList serializes the list of group IDs.
+func serializeGroupIdList(list map[id.ID]Group) []byte {
+	buff := bytes.NewBuffer(nil)
+	buff.Grow(id.ArrIDLen * len(list))
+
+	// Create list of IDs from map
+	for grpId := range list {
+		buff.Write(grpId.Marshal())
+	}
+
+	return buff.Bytes()
+}
+
+// deserializeGroupIdList deserializes data into a list of group IDs.
+func deserializeGroupIdList(data []byte) []*id.ID {
+	idLen := id.ArrIDLen
+	groupIDs := make([]*id.ID, 0, len(data)/idLen)
+	buff := bytes.NewBuffer(data)
+
+	// Copy each set of data into a new ID and append to list
+	for n := buff.Next(idLen); len(n) == idLen; n = buff.Next(idLen) {
+		var newID id.ID
+		copy(newID[:], n)
+		groupIDs = append(groupIDs, &newID)
+	}
+
+	return groupIDs
+}
+
+// save saves the group ID list and each group individually to storage.
+func (s *Store) save() error {
+	// Store group ID list
+	err := s.saveGroupList()
+	if err != nil {
+		return err
+	}
+
+	// Store individual groups
+	for grpID, grp := range s.list {
+		if err := grp.store(s.kv); err != nil {
+			return errors.Errorf(groupSaveErr, grpID, err)
+		}
+	}
+
+	return nil
+}
+
+// Len returns the number of groups stored.
+func (s *Store) Len() int {
+	s.mux.RLock()
+	defer s.mux.RUnlock()
+
+	return len(s.list)
+}
+
+// Add adds a new group to the group list and saves it to storage. An error is
+// returned if the user has the max number of groups (MaxGroupChats).
+func (s *Store) Add(g Group) error {
+	s.mux.Lock()
+	defer s.mux.Unlock()
+
+	// Check if the group list is full.
+	if len(s.list) >= MaxGroupChats {
+		return errors.Errorf(maxGroupsErr, MaxGroupChats)
+	}
+
+	// Return an error if the group already exists in the map
+	if _, exists := s.list[*g.ID]; exists {
+		return errors.Errorf(groupExistsErr, g.ID)
+	}
+
+	// Add the group to the map
+	s.list[*g.ID] = g.DeepCopy()
+
+	// Update the group list in storage
+	err := s.saveGroupList()
+	if err != nil {
+		return err
+	}
+
+	// Store the group to storage
+	return g.store(s.kv)
+}
+
+// Remove removes the group with the corresponding ID from memory and storage.
+// An error is returned if the group cannot be found in memory or storage.
+func (s *Store) Remove(groupID *id.ID) error {
+	s.mux.Lock()
+	defer s.mux.Unlock()
+
+	// Exit if the Group does not exist in memory
+	if _, exists := s.list[*groupID]; !exists {
+		return errors.Errorf(groupRemoveErr, groupID)
+	}
+
+	// Delete Group from memory
+	delete(s.list, *groupID)
+
+	// Remove group ID from list in memory
+	err := s.saveGroupList()
+	if err != nil {
+		return errors.Errorf(saveListRemoveErr, groupID)
+	}
+
+	// Delete Group from storage
+	return removeGroup(groupID, s.kv)
+}
+
+// GroupIDs returns a list of all group IDs.
+func (s *Store) GroupIDs() []*id.ID {
+	s.mux.RLock()
+	defer s.mux.RUnlock()
+
+	idList := make([]*id.ID, 0, len(s.list))
+	for gid := range s.list {
+		idList = append(idList, gid.DeepCopy())
+	}
+
+	return idList
+}
+
+// Get returns the Group for the given group ID. Returns false if no Group is
+// found.
+func (s *Store) Get(groupID *id.ID) (Group, bool) {
+	s.mux.RLock()
+	defer s.mux.RUnlock()
+
+	grp, exists := s.list[*groupID]
+	if !exists {
+		return Group{}, false
+	}
+
+	return grp.DeepCopy(), exists
+}
+
+// GetByKeyFp returns the group with the matching key fingerprint and salt.
+// Returns false if no group is found.
+func (s *Store) GetByKeyFp(keyFp format.Fingerprint, salt [group.SaltLen]byte) (Group, bool) {
+	s.mux.RLock()
+	defer s.mux.RUnlock()
+
+	// Iterate through each group to check if the key fingerprint matches
+	for _, grp := range s.list {
+		if group.CheckKeyFingerprint(keyFp, grp.Key, salt, s.user.ID) {
+			return grp.DeepCopy(), true
+		}
+	}
+
+	return Group{}, false
+}
+
+// GetUser returns the group member for the current user.
+func (s *Store) GetUser() group.Member {
+	s.mux.RLock()
+	defer s.mux.RUnlock()
+	return s.user.DeepCopy()
+}
+
+// SetUser allows a user to be set. This function is for testing purposes only.
+// It panics if the interface is not of a testing type.
+func (s *Store) SetUser(user group.Member, x interface{}) {
+	switch x.(type) {
+	case *testing.T, *testing.M, *testing.B, *testing.PB:
+		break
+	default:
+		jww.FATAL.Panicf(setUserPanic, x)
+	}
+
+	s.mux.Lock()
+	defer s.mux.Unlock()
+	s.user = user.DeepCopy()
+}
diff --git a/groupChat/groupStore/store_test.go b/groupChat/groupStore/store_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..0e0917ff38ce33b83cbecbfafb4f960ea8c38447
--- /dev/null
+++ b/groupChat/groupStore/store_test.go
@@ -0,0 +1,576 @@
+///////////////////////////////////////////////////////////////////////////////
+// Copyright © 2020 xx network SEZC                                          //
+//                                                                           //
+// Use of this source code is governed by a license that can be found in the //
+// LICENSE file                                                              //
+///////////////////////////////////////////////////////////////////////////////
+
+package groupStore
+
+import (
+	"bytes"
+	"fmt"
+	"gitlab.com/elixxir/client/storage/versioned"
+	"gitlab.com/elixxir/crypto/group"
+	"gitlab.com/elixxir/ekv"
+	"gitlab.com/xx_network/primitives/id"
+	"math/rand"
+	"reflect"
+	"sort"
+	"strings"
+	"testing"
+)
+
+// Unit test of NewStore.
+func TestNewStore(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	kv := versioned.NewKV(make(ekv.Memstore))
+	user := randMember(prng)
+
+	expectedStore := &Store{
+		list: make(map[id.ID]Group),
+		user: user,
+		kv:   kv.Prefix(groupStoragePrefix),
+	}
+
+	store, err := NewStore(kv, user)
+	if err != nil {
+		t.Fatalf("NewStore returned an error: %+v", err)
+	}
+
+	// Compare manually created object with NewUnknownRoundsStore
+	if !reflect.DeepEqual(expectedStore, store) {
+		t.Errorf("NewStore returned incorrect Store."+
+			"\nexpected: %+v\nreceived: %+v", expectedStore, store)
+	}
+
+	// Add information in store
+	testGroup := createTestGroup(prng, t)
+
+	store.list[*testGroup.ID] = testGroup
+
+	if err := store.save(); err != nil {
+		t.Fatalf("save() could not write to disk: %+v", err)
+	}
+
+	groupIds := make([]id.ID, 0, len(store.list))
+	for grpId := range store.list {
+		groupIds = append(groupIds, grpId)
+	}
+
+	// Check that stored group Id list is expected value
+	expectedData := serializeGroupIdList(store.list)
+
+	obj, err := store.kv.Get(groupListStorageKey, groupListVersion)
+	if err != nil {
+		t.Errorf("Could not get group list: %+v", err)
+	}
+
+	// Check that the stored data is the data outputted by marshal
+	if !bytes.Equal(expectedData, obj.Data) {
+		t.Errorf("NewStore() returned incorrect Store."+
+			"\nexpected: %+v\nreceived: %+v", expectedData, obj.Data)
+	}
+
+	obj, err = store.kv.Get(groupStoreKey(testGroup.ID), groupListVersion)
+	if err != nil {
+		t.Errorf("Could not get group: %+v", err)
+	}
+
+	newGrp, err := DeserializeGroup(obj.Data)
+	if err != nil {
+		t.Errorf("Failed to deserialize group: %+v", err)
+	}
+
+	if !reflect.DeepEqual(testGroup, newGrp) {
+		t.Errorf("NewStore() returned incorrect Store."+
+			"\nexpected: %#v\nreceived: %#v", testGroup, newGrp)
+	}
+}
+
+func TestNewOrLoadStore(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	kv := versioned.NewKV(make(ekv.Memstore))
+	user := randMember(prng)
+
+	store, err := NewOrLoadStore(kv, user)
+	if err != nil {
+		t.Fatalf("Failed to create new store: %+v", err)
+	}
+
+	// Add group to store
+	testGroup := createTestGroup(prng, t)
+	if err = store.Add(testGroup); err != nil {
+		t.Fatalf("Failed to add test group: %+v", err)
+	}
+
+	// Load the store from kv
+	receivedStore, err := NewOrLoadStore(kv, user)
+	if err != nil {
+		t.Fatalf("LoadStore returned an error: %+v", err)
+	}
+
+	// Check that state in loaded store matches store that was saved
+	if len(receivedStore.list) != len(store.list) {
+		t.Errorf("LoadStore returned Store with incorrect number of groups."+
+			"\nexpected len: %d\nreceived len: %d",
+			len(store.list), len(receivedStore.list))
+	}
+
+	if _, exists := receivedStore.list[*testGroup.ID]; !exists {
+		t.Fatalf("Failed to get group from loaded group map."+
+			"\nexpected: %#v\nreceived: %#v", testGroup, receivedStore.list)
+	}
+}
+
+// Unit test of LoadStore.
+func TestLoadStore(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	kv := versioned.NewKV(make(ekv.Memstore))
+	user := randMember(prng)
+
+	store, err := NewStore(kv, user)
+	if err != nil {
+		t.Fatalf("Failed to create new store: %+v", err)
+	}
+
+	// Add group to store
+	testGroup := createTestGroup(prng, t)
+	if err = store.Add(testGroup); err != nil {
+		t.Fatalf("Failed to add test group: %+v", err)
+	}
+
+	// Load the store from kv
+	receivedStore, err := LoadStore(kv, user)
+	if err != nil {
+		t.Fatalf("LoadStore returned an error: %+v", err)
+	}
+
+	// Check that state in loaded store matches store that was saved
+	if len(receivedStore.list) != len(store.list) {
+		t.Errorf("LoadStore returned Store with incorrect number of groups."+
+			"\nexpected len: %d\nreceived len: %d",
+			len(store.list), len(receivedStore.list))
+	}
+
+	if _, exists := receivedStore.list[*testGroup.ID]; !exists {
+		t.Fatalf("Failed to get group from loaded group map."+
+			"\nexpected: %#v\nreceived: %#v", testGroup, receivedStore.list)
+	}
+}
+
+// Error path: show that LoadStore returns an error when no group store can be
+// found in storage.
+func TestLoadStore_GetError(t *testing.T) {
+	kv := versioned.NewKV(make(ekv.Memstore))
+	user := randMember(rand.New(rand.NewSource(42)))
+	expectedErr := strings.SplitN(kvGetGroupListErr, "%", 2)[0]
+
+	// Load the store from kv
+	_, err := LoadStore(kv, user)
+	if err == nil || !strings.Contains(err.Error(), expectedErr) {
+		t.Errorf("LoadStore did not return the expected error."+
+			"\nexpected: %s\nreceived: %+v", expectedErr, err)
+	}
+}
+
+// Error path: show that loadStore returns an error when no group can be found
+// in storage.
+func Test_loadStore_GetGroupError(t *testing.T) {
+	kv := versioned.NewKV(make(ekv.Memstore))
+	user := randMember(rand.New(rand.NewSource(42)))
+	var idList []byte
+	for i := 0; i < 10; i++ {
+		idList = append(idList, id.NewIdFromUInt(uint64(i), id.Group, t).Marshal()...)
+	}
+	expectedErr := strings.SplitN(groupLoadErr, "%", 2)[0]
+
+	// Load the groups from kv
+	_, err := loadStore(idList, kv, user)
+	if err == nil || !strings.Contains(err.Error(), expectedErr) {
+		t.Errorf("loadStore did not return the expected error."+
+			"\nexpected: %s\nreceived: %+v", expectedErr, err)
+	}
+
+}
+
+// Tests that a map of groups can be serialized and deserialized into a list
+// that has the same group IDs.
+func Test_serializeGroupIdList_deserializeGroupIdList(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	n := 10
+	testMap := make(map[id.ID]Group, n)
+	expected := make([]*id.ID, n)
+	for i := 0; i < n; i++ {
+		grp := createTestGroup(prng, t)
+		expected[i] = grp.ID
+		testMap[*grp.ID] = grp
+	}
+
+	// Serialize and deserialize map
+	data := serializeGroupIdList(testMap)
+	newList := deserializeGroupIdList(data)
+
+	// Sort expected and received lists so they are in the same order
+	sort.Slice(expected, func(i, j int) bool {
+		return bytes.Compare(expected[i].Bytes(), expected[j].Bytes()) == -1
+	})
+	sort.Slice(newList, func(i, j int) bool {
+		return bytes.Compare(newList[i].Bytes(), newList[j].Bytes()) == -1
+	})
+
+	// Check if they match
+	if !reflect.DeepEqual(expected, newList) {
+		t.Errorf("Failed to serialize and deserilize group map into list."+
+			"\nexpected: %+v\nreceived: %+v", expected, newList)
+	}
+}
+
+// Unit test of Store.Len.
+func TestStore_Len(t *testing.T) {
+	s := Store{list: make(map[id.ID]Group)}
+
+	if s.Len() != 0 {
+		t.Errorf("Len returned the wrong length.\nexpected: %d\nreceived: %d",
+			0, s.Len())
+	}
+
+	n := 10
+	for i := 0; i < n; i++ {
+		s.list[*id.NewIdFromUInt(uint64(i), id.Group, t)] = Group{}
+	}
+
+	if s.Len() != n {
+		t.Errorf("Len returned the wrong length.\nexpected: %d\nreceived: %d",
+			n, s.Len())
+	}
+}
+
+// Unit test of Store.Add.
+func TestStore_Add(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	kv := versioned.NewKV(make(ekv.Memstore))
+	user := randMember(prng)
+
+	store, err := NewStore(kv, user)
+	if err != nil {
+		t.Fatalf("Failed to create store: %+v", err)
+	}
+
+	// Add maximum number of groups allowed
+	for i := 0; i < MaxGroupChats; i++ {
+		// Add group to store
+		grp := createTestGroup(prng, t)
+		err = store.Add(grp)
+		if err != nil {
+			t.Errorf("Add returned an error (%d): %v", i, err)
+		}
+
+		if _, exists := store.list[*grp.ID]; !exists {
+			t.Errorf("Group %s was not added to the map (%d)", grp.ID, i)
+		}
+	}
+
+	if len(store.list) != MaxGroupChats {
+		t.Errorf("Length of group map does not match number of groups added."+
+			"\nexpected: %d\nreceived: %d", MaxGroupChats, len(store.list))
+	}
+}
+
+// Error path: shows that an error is returned when trying to add too many
+// groups.
+func TestStore_Add_MapFullError(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	kv := versioned.NewKV(make(ekv.Memstore))
+	user := randMember(prng)
+	expectedErr := strings.SplitN(maxGroupsErr, "%", 2)[0]
+
+	store, err := NewStore(kv, user)
+	if err != nil {
+		t.Fatalf("Failed to create store: %+v", err)
+	}
+
+	// Add maximum number of groups allowed
+	for i := 0; i < MaxGroupChats; i++ {
+		err = store.Add(createTestGroup(prng, t))
+		if err != nil {
+			t.Errorf("Add returned an error (%d): %v", i, err)
+		}
+	}
+
+	err = store.Add(createTestGroup(prng, t))
+	if err == nil || !strings.Contains(err.Error(), expectedErr) {
+		t.Errorf("Add did not return the expected error."+
+			"\nexpected: %s\nreceived: %+v", expectedErr, err)
+	}
+}
+
+// Error path: show Store.Add returns an error when attempting to add a group
+// that is already in the map.
+func TestStore_Add_GroupExistsError(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	kv := versioned.NewKV(make(ekv.Memstore))
+	user := randMember(prng)
+	expectedErr := strings.SplitN(groupExistsErr, "%", 2)[0]
+
+	store, err := NewStore(kv, user)
+	if err != nil {
+		t.Fatalf("Failed to create store: %+v", err)
+	}
+
+	grp := createTestGroup(prng, t)
+	err = store.Add(grp)
+	if err != nil {
+		t.Errorf("Add returned an error: %+v", err)
+	}
+
+	err = store.Add(grp)
+	if err == nil || !strings.Contains(err.Error(), expectedErr) {
+		t.Errorf("Add did not return the expected error."+
+			"\nexpected: %s\nreceived: %+v", expectedErr, err)
+	}
+}
+
+// Unit test of Store.Remove.
+func TestStore_Remove(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	kv := versioned.NewKV(make(ekv.Memstore))
+	user := randMember(prng)
+
+	store, err := NewStore(kv, user)
+	if err != nil {
+		t.Fatalf("Failed to create store: %+v", err)
+	}
+
+	// Add maximum number of groups allowed
+	groups := make([]Group, MaxGroupChats)
+	for i := 0; i < MaxGroupChats; i++ {
+		groups[i] = createTestGroup(prng, t)
+		if err = store.Add(groups[i]); err != nil {
+			t.Errorf("Failed to add group (%d): %v", i, err)
+		}
+	}
+
+	// Remove all groups
+	for i, grp := range groups {
+		err = store.Remove(grp.ID)
+		if err != nil {
+			t.Errorf("Remove returned an error (%d): %+v", i, err)
+		}
+
+		if _, exists := store.list[*grp.ID]; exists {
+			t.Fatalf("Group %s still exists in map (%d).", grp.ID, i)
+		}
+	}
+
+	// Check that the list is empty now
+	if len(store.list) != 0 {
+		t.Fatalf("Remove failed to remove all groups.."+
+			"\nexpected: %d\nreceived: %d", 0, len(store.list))
+	}
+}
+
+// Error path: shows that Store.Remove returns an error when no group with the
+// given ID is found in the map.
+func TestStore_Remove_RemoveGroupNotInMemoryError(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	kv := versioned.NewKV(make(ekv.Memstore))
+	user := randMember(prng)
+	expectedErr := strings.SplitN(groupRemoveErr, "%", 2)[0]
+
+	store, err := NewStore(kv, user)
+	if err != nil {
+		t.Fatalf("Failed to create store: %+v", err)
+	}
+
+	grp := createTestGroup(prng, t)
+	err = store.Remove(grp.ID)
+	if err == nil || !strings.Contains(err.Error(), expectedErr) {
+		t.Errorf("Remove did not return the expected error."+
+			"\nexpected: %s\nreceived: %+v", expectedErr, err)
+	}
+}
+
+// Unit test of Store.GroupIDs.
+func TestStore_GroupIDs(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	n := 10
+	store := Store{list: make(map[id.ID]Group, n)}
+	expected := make([]*id.ID, n)
+	for i := 0; i < n; i++ {
+		grp := createTestGroup(prng, t)
+		expected[i] = grp.ID
+		store.list[*grp.ID] = grp
+	}
+
+	newList := store.GroupIDs()
+
+	// Sort expected and received lists so they are in the same order
+	sort.Slice(expected, func(i, j int) bool {
+		return bytes.Compare(expected[i].Bytes(), expected[j].Bytes()) == -1
+	})
+	sort.Slice(newList, func(i, j int) bool {
+		return bytes.Compare(newList[i].Bytes(), newList[j].Bytes()) == -1
+	})
+
+	// Check if they match
+	if !reflect.DeepEqual(expected, newList) {
+		t.Errorf("GroupIDs did not return the expected list."+
+			"\nexpected: %+v\nreceived: %+v", expected, newList)
+	}
+}
+
+// Unit test of Store.Get.
+func TestStore_Get(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	kv := versioned.NewKV(make(ekv.Memstore))
+	user := randMember(prng)
+
+	store, err := NewStore(kv, user)
+	if err != nil {
+		t.Fatalf("Failed to make new Store: %+v", err)
+	}
+
+	// Add group to store
+	grp := createTestGroup(prng, t)
+	if err = store.Add(grp); err != nil {
+		t.Errorf("Failed to add group to store: %+v", err)
+	}
+
+	// Attempt to get group
+	retrieved, exists := store.Get(grp.ID)
+	if !exists {
+		t.Errorf("Get failed to return the expected group: %#v", grp)
+	}
+
+	if !reflect.DeepEqual(grp, retrieved) {
+		t.Errorf("Get did not return the expected group."+
+			"\nexpected: %#v\nreceived: %#v", grp, retrieved)
+	}
+}
+
+// Error path: shows that Store.Get return false if no group is found.
+func TestStore_Get_NoGroupError(t *testing.T) {
+	kv := versioned.NewKV(make(ekv.Memstore))
+	user := randMember(rand.New(rand.NewSource(42)))
+
+	store, err := NewStore(kv, user)
+	if err != nil {
+		t.Fatalf("Failed to make new Store: %+v", err)
+	}
+
+	// Attempt to get group
+	retrieved, exists := store.Get(id.NewIdFromString("testID", id.Group, t))
+	if exists {
+		t.Errorf("Get returned a group that should not exist: %#v", retrieved)
+	}
+}
+
+// Unit test of Store.GetByKeyFp.
+func TestStore_GetByKeyFp(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	kv := versioned.NewKV(make(ekv.Memstore))
+	user := randMember(prng)
+
+	store, err := NewStore(kv, user)
+	if err != nil {
+		t.Fatalf("Failed to make new Store: %+v", err)
+	}
+
+	// Add group to store
+	grp := createTestGroup(prng, t)
+	if err = store.Add(grp); err != nil {
+		t.Fatalf("Failed to add group: %+v", err)
+	}
+
+	// Get group by fingerprint
+	salt := newSalt(groupSalt)
+	generatedFP := group.NewKeyFingerprint(grp.Key, salt, store.user.ID)
+	retrieved, exists := store.GetByKeyFp(generatedFP, salt)
+	if !exists {
+		t.Errorf("GetByKeyFp failed to find a group with the matching key "+
+			"fingerprint: %#v", grp)
+	}
+
+	// check that retrieved value match
+	if !reflect.DeepEqual(grp, retrieved) {
+		t.Errorf("GetByKeyFp failed to return the expected group."+
+			"\nexpected: %#v\nreceived: %#v", grp, retrieved)
+	}
+}
+
+// Error path: shows that Store.GetByKeyFp return false if no group is found.
+func TestStore_GetByKeyFp_NoGroupError(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	kv := versioned.NewKV(make(ekv.Memstore))
+	user := randMember(prng)
+
+	store, err := NewStore(kv, user)
+	if err != nil {
+		t.Fatalf("Failed to make new Store: %+v", err)
+	}
+
+	// Get group by fingerprint
+	grp := createTestGroup(prng, t)
+	salt := newSalt(groupSalt)
+	generatedFP := group.NewKeyFingerprint(grp.Key, salt, store.user.ID)
+	retrieved, exists := store.GetByKeyFp(generatedFP, salt)
+	if exists {
+		t.Errorf("GetByKeyFp found a group when none should exist: %#v",
+			retrieved)
+	}
+}
+
+// Unit test of Store.GetUser.
+func TestStore_GetUser(t *testing.T) {
+	kv := versioned.NewKV(make(ekv.Memstore))
+	user := randMember(rand.New(rand.NewSource(42)))
+
+	store, err := NewStore(kv, user)
+	if err != nil {
+		t.Fatalf("Failed to make new Store: %+v", err)
+	}
+
+	if !user.Equal(store.GetUser()) {
+		t.Errorf("GetUser() failed to return the expected member."+
+			"\nexpected: %#v\nreceived: %#v", user, store.GetUser())
+	}
+}
+
+// Unit test of Store.SetUser.
+func TestStore_SetUser(t *testing.T) {
+	kv := versioned.NewKV(make(ekv.Memstore))
+	prng := rand.New(rand.NewSource(42))
+	oldUser := randMember(prng)
+	newUser := randMember(prng)
+
+	store, err := NewStore(kv, oldUser)
+	if err != nil {
+		t.Fatalf("Failed to make new Store: %+v", err)
+	}
+
+	store.SetUser(newUser, t)
+
+	if !newUser.Equal(store.user) {
+		t.Errorf("SetUser() failed to set the correct user."+
+			"\nexpected: %#v\nreceived: %#v", newUser, store.user)
+	}
+}
+
+// Panic path: show that Store.SetUser panics when the interface is not of a
+// testing type.
+func TestStore_SetUser_NonTestingInterfacePanic(t *testing.T) {
+	user := randMember(rand.New(rand.NewSource(42)))
+	store := &Store{}
+	nonTestingInterface := struct{}{}
+	expectedErr := fmt.Sprintf(setUserPanic, nonTestingInterface)
+
+	defer func() {
+		if r := recover(); r == nil || r.(string) != expectedErr {
+			t.Errorf("SetUser failed to panic with the expected message."+
+				"\nexpected: %s\nreceived: %+v", expectedErr, r)
+		}
+	}()
+
+	store.SetUser(user, nonTestingInterface)
+}
diff --git a/groupChat/groupStore/utils_test.go b/groupChat/groupStore/utils_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..9a6460800498b11fdbcb8a4b9b5aa7d4e391b2f3
--- /dev/null
+++ b/groupChat/groupStore/utils_test.go
@@ -0,0 +1,139 @@
+///////////////////////////////////////////////////////////////////////////////
+// Copyright © 2020 xx network SEZC                                          //
+//                                                                           //
+// Use of this source code is governed by a license that can be found in the //
+// LICENSE file                                                              //
+///////////////////////////////////////////////////////////////////////////////
+
+package groupStore
+
+import (
+	"gitlab.com/elixxir/crypto/contact"
+	"gitlab.com/elixxir/crypto/cyclic"
+	"gitlab.com/elixxir/crypto/group"
+	"gitlab.com/xx_network/crypto/large"
+	"gitlab.com/xx_network/primitives/id"
+	"math/rand"
+	"testing"
+)
+
+const (
+	groupName        = "groupName"
+	groupSalt        = "salt"
+	groupKey         = "key"
+	groupIdPreimage  = "idPreimage"
+	groupKeyPreimage = "keyPreimage"
+	initMessage      = "initMessage"
+)
+
+// createTestGroup generates a new group for testing.
+func createTestGroup(rng *rand.Rand, t *testing.T) Group {
+	members := createMembership(rng, 10, t)
+	dkl := GenerateDhKeyList(members[0].ID, randCycInt(rng), members, getGroup())
+	return NewGroup(
+		[]byte(groupName),
+		id.NewIdFromUInt(rng.Uint64(), id.Group, t),
+		newKey(groupKey),
+		newIdPreimage(groupIdPreimage),
+		newKeyPreimage(groupKeyPreimage),
+		[]byte(initMessage),
+		members,
+		dkl,
+	)
+}
+
+// createMembership creates a new membership with the specified number of
+// randomly generated members.
+func createMembership(rng *rand.Rand, size int, t *testing.T) group.Membership {
+	contacts := make([]contact.Contact, size)
+	for i := range contacts {
+		contacts[i] = randContact(rng)
+	}
+
+	membership, err := group.NewMembership(contacts[0], contacts[1:]...)
+	if err != nil {
+		t.Errorf("Failed to create new membership: %+v", err)
+	}
+
+	return membership
+}
+
+// createDhKeyList creates a new DhKeyList with the specified number of randomly
+// generated members.
+func createDhKeyList(rng *rand.Rand, size int, _ *testing.T) DhKeyList {
+	dkl := make(DhKeyList, size)
+	for i := 0; i < size; i++ {
+		dkl[*randID(rng, id.User)] = randCycInt(rng)
+	}
+
+	return dkl
+}
+
+// randMember returns a Member with a random ID and DH public key.
+func randMember(rng *rand.Rand) group.Member {
+	return group.Member{
+		ID:    randID(rng, id.User),
+		DhKey: randCycInt(rng),
+	}
+}
+
+// randContact returns a contact with a random ID and DH public key.
+func randContact(rng *rand.Rand) contact.Contact {
+	return contact.Contact{
+		ID:       randID(rng, id.User),
+		DhPubKey: randCycInt(rng),
+	}
+}
+
+// randID returns a new random ID of the specified type.
+func randID(rng *rand.Rand, t id.Type) *id.ID {
+	newID, _ := id.NewRandomID(rng, t)
+	return newID
+}
+
+// randCycInt returns a random cyclic int.
+func randCycInt(rng *rand.Rand) *cyclic.Int {
+	return getGroup().NewInt(rng.Int63())
+}
+
+func getGroup() *cyclic.Group {
+	return cyclic.NewGroup(
+		large.NewIntFromString("E2EE983D031DC1DB6F1A7A67DF0E9A8E5561DB8E8D4941"+
+			"3394C049B7A8ACCEDC298708F121951D9CF920EC5D146727AA4AE535B0922C688"+
+			"B55B3DD2AEDF6C01C94764DAB937935AA83BE36E67760713AB44A6337C20E7861"+
+			"575E745D31F8B9E9AD8412118C62A3E2E29DF46B0864D0C951C394A5CBBDC6ADC"+
+			"718DD2A3E041023DBB5AB23EBB4742DE9C1687B5B34FA48C3521632C4A530E8FF"+
+			"B1BC51DADDF453B0B2717C2BC6669ED76B4BDD5C9FF558E88F26E5785302BEDBC"+
+			"A23EAC5ACE92096EE8A60642FB61E8F3D24990B8CB12EE448EEF78E184C7242DD"+
+			"161C7738F32BF29A841698978825B4111B4BC3E1E198455095958333D776D8B2B"+
+			"EEED3A1A1A221A6E37E664A64B83981C46FFDDC1A45E3D5211AAF8BFBC072768C"+
+			"4F50D7D7803D2D4F278DE8014A47323631D7E064DE81C0C6BFA43EF0E6998860F"+
+			"1390B5D3FEACAF1696015CB79C3F9C2D93D961120CD0E5F12CBB687EAB045241F"+
+			"96789C38E89D796138E6319BE62E35D87B1048CA28BE389B575E994DCA7554715"+
+			"84A09EC723742DC35873847AEF49F66E43873", 16),
+		large.NewIntFromString("2", 16))
+}
+
+func newSalt(s string) [group.SaltLen]byte {
+	var salt [group.SaltLen]byte
+	copy(salt[:], s)
+	return salt
+}
+
+func newKey(s string) group.Key {
+	var key group.Key
+	copy(key[:], s)
+	return key
+}
+
+func newIdPreimage(s string) group.IdPreimage {
+	var preimage group.IdPreimage
+	copy(preimage[:], s)
+	return preimage
+}
+
+func newKeyPreimage(s string) group.KeyPreimage {
+	var preimage group.KeyPreimage
+	copy(preimage[:], s)
+	return preimage
+}
diff --git a/groupChat/internalFormat.go b/groupChat/internalFormat.go
new file mode 100644
index 0000000000000000000000000000000000000000..2502a9c8c29c9f940a93bebb1101c5d5a5ae8ef2
--- /dev/null
+++ b/groupChat/internalFormat.go
@@ -0,0 +1,156 @@
+///////////////////////////////////////////////////////////////////////////////
+// Copyright © 2020 xx network SEZC                                          //
+//                                                                           //
+// Use of this source code is governed by a license that can be found in the //
+// LICENSE file                                                              //
+///////////////////////////////////////////////////////////////////////////////
+
+package groupChat
+
+import (
+	"encoding/binary"
+	"fmt"
+	"github.com/pkg/errors"
+	"gitlab.com/xx_network/primitives/id"
+	"strconv"
+	"time"
+)
+
+// Sizes of marshaled data, in bytes.
+const (
+	timestampLen           = 8
+	idLen                  = id.ArrIDLen
+	internalPayloadSizeLen = 2
+	internalMinLen         = timestampLen + idLen + internalPayloadSizeLen
+)
+
+// Error messages
+const (
+	newInternalSizeErr       = "max message size %d < %d minimum required"
+	unmarshalInternalSizeErr = "size of data %d < %d minimum required"
+)
+
+// internalMsg is the internal, unencrypted data in a group message.
+//
+// +-------------------------------------------+
+// |                    data                   |
+// +-----------+----------+---------+----------+
+// | timestamp | senderID |  size   | payload  |
+// |  8 bytes  | 32 bytes | 2 bytes | variable |
+// +-----------+----------+---------+----------+
+type internalMsg struct {
+	data      []byte // Serial of all the parts of the message
+	timestamp []byte // 64-bit Unix time timestamp stored in nanoseconds
+	senderID  []byte // 264-bit sender ID
+	size      []byte // Size of the payload
+	payload   []byte // Message contents
+}
+
+// newInternalMsg creates a new internalMsg of size maxDataSize. An error is
+// returned if the maxDataSize is smaller than the minimum internalMsg size.
+func newInternalMsg(maxDataSize int) (internalMsg, error) {
+	if maxDataSize < internalMinLen {
+		return internalMsg{},
+			errors.Errorf(newInternalSizeErr, maxDataSize, internalMinLen)
+	}
+
+	return mapInternalMsg(make([]byte, maxDataSize)), nil
+}
+
+// mapInternalMsg maps all the parts of the internalMsg to the passed in data.
+func mapInternalMsg(data []byte) internalMsg {
+	return internalMsg{
+		data:      data,
+		timestamp: data[:timestampLen],
+		senderID:  data[timestampLen : timestampLen+idLen],
+		size:      data[timestampLen+idLen : timestampLen+idLen+internalPayloadSizeLen],
+		payload:   data[timestampLen+idLen+internalPayloadSizeLen:],
+	}
+}
+
+// unmarshalInternalMsg unmarshal the data into an internalMsg. An error is
+// returned if the data length is smaller than the minimum allowed size.
+func unmarshalInternalMsg(data []byte) (internalMsg, error) {
+	if len(data) < internalMinLen {
+		return internalMsg{},
+			errors.Errorf(unmarshalInternalSizeErr, len(data), internalMinLen)
+	}
+
+	return mapInternalMsg(data), nil
+}
+
+// Marshal returns the serial of the internalMsg.
+func (im internalMsg) Marshal() []byte {
+	return im.data
+}
+
+// GetTimestamp returns the timestamp as a time.Time.
+func (im internalMsg) GetTimestamp() time.Time {
+	return time.Unix(0, int64(binary.LittleEndian.Uint64(im.timestamp)))
+}
+
+// SetTimestamp converts the time.Time to Unix nano and save as bytes.
+func (im internalMsg) SetTimestamp(t time.Time) {
+	binary.LittleEndian.PutUint64(im.timestamp, uint64(t.UnixNano()))
+}
+
+// GetSenderID returns the sender ID bytes as a id.ID.
+func (im internalMsg) GetSenderID() (*id.ID, error) {
+	return id.Unmarshal(im.senderID)
+}
+
+// SetSenderID sets the sender ID.
+func (im internalMsg) SetSenderID(sid *id.ID) {
+	copy(im.senderID, sid.Marshal())
+}
+
+// GetPayload returns the payload truncated to the correct size.
+func (im internalMsg) GetPayload() []byte {
+	return im.payload[:im.GetPayloadSize()]
+}
+
+// SetPayload sets the payload and saves it size.
+func (im internalMsg) SetPayload(payload []byte) {
+	// Save size of payload
+	binary.LittleEndian.PutUint16(im.size, uint16(len(payload)))
+
+	// Save payload
+	copy(im.payload, payload)
+}
+
+// GetPayloadSize returns the length of the content in the payload.
+func (im internalMsg) GetPayloadSize() int {
+	return int(binary.LittleEndian.Uint16(im.size))
+}
+
+// GetPayloadMaxSize returns the maximum size of the payload.
+func (im internalMsg) GetPayloadMaxSize() int {
+	return len(im.payload)
+}
+
+// String prints a string representation of internalMsg. This functions
+// satisfies the fmt.Stringer interface.
+func (im internalMsg) String() string {
+	timestamp := "<nil>"
+	if len(im.timestamp) > 0 {
+		timestamp = im.GetTimestamp().String()
+	}
+
+	senderID := "<nil>"
+	if sid, _ := im.GetSenderID(); sid != nil {
+		senderID = sid.String()
+	}
+
+	size := "<nil>"
+	if len(im.size) > 0 {
+		size = strconv.Itoa(im.GetPayloadSize())
+	}
+
+	payload := "<nil>"
+	if len(im.size) > 0 {
+		payload = fmt.Sprintf("%q", im.GetPayload())
+	}
+
+	return "{timestamp:" + timestamp + ", senderID:" + senderID +
+		", size:" + size + ", payload:" + payload + "}"
+}
diff --git a/groupChat/internalFormat_test.go b/groupChat/internalFormat_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..984d11b8f35ec44935eaea48b5bbd76eec80bdb1
--- /dev/null
+++ b/groupChat/internalFormat_test.go
@@ -0,0 +1,211 @@
+///////////////////////////////////////////////////////////////////////////////
+// Copyright © 2020 xx network SEZC                                          //
+//                                                                           //
+// Use of this source code is governed by a license that can be found in the //
+// LICENSE file                                                              //
+///////////////////////////////////////////////////////////////////////////////
+
+package groupChat
+
+import (
+	"bytes"
+	"encoding/binary"
+	"fmt"
+	"gitlab.com/xx_network/primitives/id"
+	"gitlab.com/xx_network/primitives/netTime"
+	"reflect"
+	"testing"
+	"time"
+)
+
+// Unit test of newInternalMsg.
+func Test_newInternalMsg(t *testing.T) {
+	maxDataSize := 2 * internalMinLen
+	im, err := newInternalMsg(maxDataSize)
+	if err != nil {
+		t.Errorf("newInternalMsg() returned an error: %+v", err)
+	}
+
+	if len(im.data) != maxDataSize {
+		t.Errorf("newInternalMsg() set data to the wrong length."+
+			"\nexpected: %d\nreceived: %d", maxDataSize, len(im.data))
+	}
+}
+
+// Error path: the maxDataSize is smaller than the minimum size.
+func Test_newInternalMsg_PayloadSizeError(t *testing.T) {
+	maxDataSize := internalMinLen - 1
+	expectedErr := fmt.Sprintf(newInternalSizeErr, maxDataSize, internalMinLen)
+
+	_, err := newInternalMsg(maxDataSize)
+	if err == nil || err.Error() != expectedErr {
+		t.Errorf("newInternalMsg() failed to return the expected error."+
+			"\nexpected: %s\nreceived: %+v", expectedErr, err)
+	}
+}
+
+// Unit test of mapInternalMsg.
+func Test_mapInternalMsg(t *testing.T) {
+	// Create all the expected data
+	timestamp := make([]byte, timestampLen)
+	binary.LittleEndian.PutUint64(timestamp, uint64(netTime.Now().UnixNano()))
+	senderID := id.NewIdFromString("test sender ID", id.User, t).Marshal()
+	payload := []byte("Sample payload contents.")
+	size := make([]byte, internalPayloadSizeLen)
+	binary.LittleEndian.PutUint16(size, uint16(len(payload)))
+
+	// Construct data into single slice
+	data := bytes.NewBuffer(nil)
+	data.Write(timestamp)
+	data.Write(senderID)
+	data.Write(size)
+	data.Write(payload)
+
+	// Map data
+	im := mapInternalMsg(data.Bytes())
+
+	// Check that the mapped values match the expected values
+	if !bytes.Equal(timestamp, im.timestamp) {
+		t.Errorf("mapInternalMsg() did not correctly map timestamp."+
+			"\nexpected: %+v\nreceived: %+v", timestamp, im.timestamp)
+	}
+
+	if !bytes.Equal(senderID, im.senderID) {
+		t.Errorf("mapInternalMsg() did not correctly map senderID."+
+			"\nexpected: %+v\nreceived: %+v", senderID, im.senderID)
+	}
+
+	if !bytes.Equal(size, im.size) {
+		t.Errorf("mapInternalMsg() did not correctly map size."+
+			"\nexpected: %+v\nreceived: %+v", size, im.size)
+	}
+
+	if !bytes.Equal(payload, im.payload) {
+		t.Errorf("mapInternalMsg() did not correctly map payload."+
+			"\nexpected: %+v\nreceived: %+v", payload, im.payload)
+	}
+}
+
+// Tests that a marshaled and unmarshalled internalMsg matches the original.
+func TestInternalMsg_Marshal_unmarshalInternalMsg(t *testing.T) {
+	im, _ := newInternalMsg(internalMinLen * 2)
+	im.SetTimestamp(netTime.Now())
+	im.SetSenderID(id.NewIdFromString("test sender ID", id.User, t))
+	im.SetPayload([]byte("Sample payload message."))
+
+	data := im.Marshal()
+
+	newIm, err := unmarshalInternalMsg(data)
+	if err != nil {
+		t.Errorf("unmarshalInternalMsg() returned an error: %+v", err)
+	}
+
+	if !reflect.DeepEqual(im, newIm) {
+		t.Errorf("unmarshalInternalMsg() did not return the expected internalMsg."+
+			"\nexpected: %s\nreceived: %s", im, newIm)
+	}
+}
+
+// Error path: error is returned when the data is too short.
+func Test_unmarshalInternalMsg_DataLengthError(t *testing.T) {
+	expectedErr := fmt.Sprintf(unmarshalInternalSizeErr, 0, internalMinLen)
+
+	_, err := unmarshalInternalMsg(nil)
+	if err == nil || err.Error() != expectedErr {
+		t.Errorf("unmarshalInternalMsg() failed to return the expected error."+
+			"\nexpected: %s\nreceived: %+v", expectedErr, err)
+	}
+}
+
+// Happy path.
+func TestInternalMsg_SetTimestamp_GetTimestamp(t *testing.T) {
+	im, _ := newInternalMsg(internalMinLen * 2)
+	timestamp := netTime.Now()
+	im.SetTimestamp(timestamp)
+	testTimestamp := im.GetTimestamp()
+
+	if !timestamp.Equal(testTimestamp) {
+		t.Errorf("Failed to get original timestamp."+
+			"\nexpected: %s\nreceived: %s", timestamp, testTimestamp)
+	}
+}
+
+// Happy path.
+func TestInternalMsg_SetSenderID_GetSenderID(t *testing.T) {
+	im, _ := newInternalMsg(internalMinLen * 2)
+	sid := id.NewIdFromString("testSenderID", id.User, t)
+	im.SetSenderID(sid)
+	testID, err := im.GetSenderID()
+	if err != nil {
+		t.Errorf("GetSenderID() returned an error: %+v", err)
+	}
+
+	if !sid.Cmp(testID) {
+		t.Errorf("Failed to get original sender ID."+
+			"\nexpected: %s\nreceived: %s", sid, testID)
+	}
+}
+
+// Tests that the original payload matches the saved one.
+func TestInternalMsg_SetPayload_GetPayload(t *testing.T) {
+	im, _ := newInternalMsg(internalMinLen * 2)
+	payload := []byte("Test payload message.")
+	im.SetPayload(payload)
+	testPayload := im.GetPayload()
+
+	if !bytes.Equal(payload, testPayload) {
+		t.Errorf("Failed to get original sender payload."+
+			"\nexpected: %s\nreceived: %s", payload, testPayload)
+	}
+}
+
+// Happy path.
+func TestInternalMsg_GetPayloadSize(t *testing.T) {
+	im, _ := newInternalMsg(internalMinLen * 2)
+	payload := []byte("Test payload message.")
+	im.SetPayload(payload)
+
+	if len(payload) != im.GetPayloadSize() {
+		t.Errorf("GetPayloadSize() failed to return the correct size."+
+			"\nexpected: %d\nreceived: %d", len(payload), im.GetPayloadSize())
+	}
+}
+
+// Happy path.
+func TestInternalMsg_GetPayloadMaxSize(t *testing.T) {
+	im, _ := newInternalMsg(internalMinLen * 2)
+
+	if internalMinLen != im.GetPayloadMaxSize() {
+		t.Errorf("GetPayloadSize() failed to return the correct size."+
+			"\nexpected: %d\nreceived: %d", internalMinLen, im.GetPayloadMaxSize())
+	}
+}
+
+// Happy path.
+func TestInternalMsg_String(t *testing.T) {
+	im, _ := newInternalMsg(internalMinLen * 2)
+	im.SetTimestamp(time.Date(1955, 11, 5, 12, 0, 0, 0, time.UTC))
+	im.SetSenderID(id.NewIdFromString("test sender ID", id.User, t))
+	payload := []byte("Sample payload message.")
+	payload = append(payload, 0, 1, 2)
+	im.SetPayload(payload)
+
+	expected := `{timestamp:` + im.GetTimestamp().String() + `, senderID:dGVzdCBzZW5kZXIgSUQAAAAAAAAAAAAAAAAAAAAAAAAD, size:26, payload:"Sample payload message.\x00\x01\x02"}`
+
+	if im.String() != expected {
+		t.Errorf("String() failed to return the expected value."+
+			"\nexpected: %s\nreceived: %s", expected, im.String())
+	}
+}
+
+// Happy path: tests that String returns the expected string for a nil internalMsg.
+func TestInternalMsg_String_NilInternalMessage(t *testing.T) {
+	im := internalMsg{}
+
+	expected := "{timestamp:<nil>, senderID:<nil>, size:<nil>, payload:<nil>}"
+
+	if im.String() != expected {
+		t.Errorf("String() failed to return the expected value."+
+			"\nexpected: %s\nreceived: %s", expected, im.String())
+	}
+}
diff --git a/groupChat/makeGroup.go b/groupChat/makeGroup.go
new file mode 100644
index 0000000000000000000000000000000000000000..fa230fadcc99f61a4b4a44d0f62aa549ec9ec306
--- /dev/null
+++ b/groupChat/makeGroup.go
@@ -0,0 +1,190 @@
+///////////////////////////////////////////////////////////////////////////////
+// Copyright © 2020 xx network SEZC                                          //
+//                                                                           //
+// Use of this source code is governed by a license that can be found in the //
+// LICENSE file                                                              //
+///////////////////////////////////////////////////////////////////////////////
+
+package groupChat
+
+import (
+	"github.com/pkg/errors"
+	gs "gitlab.com/elixxir/client/groupChat/groupStore"
+	"gitlab.com/elixxir/crypto/contact"
+	"gitlab.com/elixxir/crypto/fastRNG"
+	"gitlab.com/elixxir/crypto/group"
+	"gitlab.com/xx_network/primitives/id"
+	"strconv"
+)
+
+// Error messages.
+const (
+	maxInitMsgSizeErr = "new group request message length %d > %d maximum size"
+	getPrivKeyErr     = "failed to get private key from partner: %+v"
+	minMembersErr     = "length of membership list %d < %d minimum allowed"
+	maxMembersErr     = "length of membership list %d > %d maximum allowed"
+	getPartnerErr     = "failed to get partner %s: %+v"
+	makeMembershipErr = "failed to assemble group chat membership: %+v"
+	newIdPreimageErr  = "failed to create group ID preimage: %+v"
+	newKeyPreimageErr = "failed to create group key preimage: %+v"
+	addGroupErr       = "failed to save new group: %+v"
+)
+
+// MaxInitMessageSize is the maximum allowable length of the initial message
+// sent in a group request.
+const MaxInitMessageSize = 256
+
+// RequestStatus signals the status of the group requests on group creation.
+type RequestStatus int
+
+const (
+	NotSent     RequestStatus = iota // Error occurred before sending requests
+	AllFail                          // Sending of all requests failed
+	PartialSent                      // Sending of some request failed
+	AllSent                          // Sending of all request succeeded
+)
+
+// MakeGroup sends groupChat requests to all members over an authenticated
+// channel. The leader of a groupChat must have an authenticated channel with
+// each member of the groupChat to add them to the groupChat. It blocks until
+// all the groupChat requests are sent. Returns an error if at least one request
+// to a member fails to send.
+func (m Manager) MakeGroup(membership []*id.ID, name, msg []byte) (gs.Group,
+	[]id.Round, RequestStatus, error) {
+	// Return an error if the message is too long
+	if len(msg) > MaxInitMessageSize {
+		return gs.Group{}, nil, NotSent,
+			errors.Errorf(maxInitMsgSizeErr, len(msg), MaxInitMessageSize)
+	}
+
+	// Build membership and DH key list from list of IDs
+	mem, dkl, err := m.buildMembership(membership)
+	if err != nil {
+		return gs.Group{}, nil, NotSent, err
+	}
+
+	// Generate ID and key preimages
+	idPreimage, keyPreimage, err := getPreimages(m.rng)
+	if err != nil {
+		return gs.Group{}, nil, NotSent, err
+	}
+
+	// Create new group ID and key
+	groupID := group.NewID(idPreimage, mem)
+	groupKey := group.NewKey(keyPreimage, mem)
+
+	// Create new group and add to manager
+	g := gs.NewGroup(name, groupID, groupKey, idPreimage, keyPreimage, msg, mem, dkl)
+	if err := m.gs.Add(g); err != nil {
+		return gs.Group{}, nil, NotSent, errors.Errorf(addGroupErr, err)
+	}
+
+	// Send all group requests
+	roundIDs, status, err := m.sendRequests(g)
+
+	return g, roundIDs, status, err
+}
+
+// buildMembership retrieves the contact object for each member ID and creates a
+// new membership from them. The caller is set as the leader. For a member to be
+// added, the group leader must have an authenticated channel with the member.
+func (m Manager) buildMembership(members []*id.ID) (group.Membership, gs.DhKeyList, error) {
+	// Return an error if the membership list has too few or too many members
+	if len(members) < group.MinParticipants {
+		return nil, nil,
+			errors.Errorf(minMembersErr, len(members), group.MinParticipants)
+	} else if len(members) > group.MaxParticipants {
+		return nil, nil,
+			errors.Errorf(maxMembersErr, len(members), group.MaxParticipants)
+	}
+
+	grp := m.store.E2e().GetGroup()
+	dkl := make(gs.DhKeyList, len(members))
+
+	// Lookup partner contact objects from their ID
+	contacts := make([]contact.Contact, len(members))
+	var err error
+	for i, uid := range members {
+		partner, err := m.store.E2e().GetPartner(uid)
+		if err != nil {
+			return nil, nil, errors.Errorf(getPartnerErr, uid, err)
+		}
+
+		contacts[i] = contact.Contact{
+			ID:       partner.GetPartnerID(),
+			DhPubKey: partner.GetPartnerOriginPublicKey(),
+		}
+
+		dkl.Add(partner.GetMyOriginPrivateKey(), group.Member{
+			ID:    partner.GetPartnerID(),
+			DhKey: partner.GetPartnerOriginPublicKey(),
+		}, grp)
+	}
+
+	// Create new Membership from contact list and client's own contact.
+	user := m.gs.GetUser()
+	leader := contact.Contact{ID: user.ID, DhPubKey: user.DhKey}
+	mem, err := group.NewMembership(leader, contacts...)
+	if err != nil {
+		return nil, nil, errors.Errorf(makeMembershipErr, err)
+	}
+
+	return mem, dkl, nil
+}
+
+// getPreimages generates and returns the group ID preimage and the group key
+// preimage. This function allows the stream to
+func getPreimages(streamGen *fastRNG.StreamGenerator) (group.IdPreimage,
+	group.KeyPreimage, error) {
+
+	// Get new stream and defer its close
+	rng := streamGen.GetStream()
+	defer rng.Close()
+
+	idPreimage, err := group.NewIdPreimage(rng)
+	if err != nil {
+		return group.IdPreimage{}, group.KeyPreimage{},
+			errors.Errorf(newIdPreimageErr, err)
+	}
+
+	keyPreimage, err := group.NewKeyPreimage(rng)
+	if err != nil {
+		return group.IdPreimage{}, group.KeyPreimage{},
+			errors.Errorf(newKeyPreimageErr, err)
+	}
+
+	return idPreimage, keyPreimage, nil
+}
+
+// String prints the description of the status code. This functions satisfies
+// the fmt.Stringer interface.
+func (rs RequestStatus) String() string {
+	switch rs {
+	case NotSent:
+		return "NotSent"
+	case AllFail:
+		return "AllFail"
+	case PartialSent:
+		return "PartialSent"
+	case AllSent:
+		return "AllSent"
+	default:
+		return "INVALID STATUS"
+	}
+}
+
+// Message prints a full description of the status code.
+func (rs RequestStatus) Message() string {
+	switch rs {
+	case NotSent:
+		return "an error occurred before sending any group requests"
+	case AllFail:
+		return "all group requests failed to send"
+	case PartialSent:
+		return "some group requests failed to send"
+	case AllSent:
+		return "all groups requests successfully sent"
+	default:
+		return "INVALID STATUS " + strconv.Itoa(int(rs))
+	}
+}
diff --git a/groupChat/makeGroup_test.go b/groupChat/makeGroup_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..004bf452664984c8ec8651442ab10a7a64d48fee
--- /dev/null
+++ b/groupChat/makeGroup_test.go
@@ -0,0 +1,302 @@
+///////////////////////////////////////////////////////////////////////////////
+// Copyright © 2020 xx network SEZC                                          //
+//                                                                           //
+// Use of this source code is governed by a license that can be found in the //
+// LICENSE file                                                              //
+///////////////////////////////////////////////////////////////////////////////
+
+package groupChat
+
+import (
+	"bytes"
+	"fmt"
+	gs "gitlab.com/elixxir/client/groupChat/groupStore"
+	"gitlab.com/elixxir/client/interfaces/params"
+	"gitlab.com/elixxir/crypto/fastRNG"
+	"gitlab.com/elixxir/crypto/group"
+	"gitlab.com/xx_network/crypto/csprng"
+	"gitlab.com/xx_network/primitives/id"
+	"math/rand"
+	"reflect"
+	"strconv"
+	"strings"
+	"testing"
+)
+
+// Tests that Manager.MakeGroup adds a group and returns the expected status.
+func TestManager_MakeGroup(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	m, _ := newTestManagerWithStore(prng, 10, 0, nil, nil, t)
+	memberIDs, members, dkl := addPartners(m, t)
+	name := []byte("groupName")
+	message := []byte("Invite message.")
+
+	g, _, status, err := m.MakeGroup(memberIDs, name, message)
+	if err != nil {
+		t.Errorf("MakeGroup() returned an error: %+v", err)
+	}
+
+	if status != AllSent {
+		t.Errorf("MakeGroup() did not return the expected status."+
+			"\nexpected: %s\nreceived: %s", AllSent, status)
+	}
+
+	_, exists := m.gs.Get(g.ID)
+	if !exists {
+		t.Errorf("Failed to get group %#v.", g)
+	}
+
+	if !reflect.DeepEqual(members, g.Members) {
+		t.Errorf("New group does not have expected membership."+
+			"\nexpected: %s\nreceived: %s", members, g.Members)
+	}
+
+	if !reflect.DeepEqual(dkl, g.DhKeys) {
+		t.Errorf("New group does not have expected DH key list."+
+			"\nexpected: %#v\nreceived: %#v", dkl, g.DhKeys)
+	}
+
+	if !g.ID.Cmp(g.ID) {
+		t.Errorf("New group does not have expected ID."+
+			"\nexpected: %s\nreceived: %s", g.ID, g.ID)
+	}
+
+	if !bytes.Equal(name, g.Name) {
+		t.Errorf("New group does not have expected name."+
+			"\nexpected: %q\nreceived: %q", name, g.Name)
+	}
+
+	if !bytes.Equal(message, g.InitMessage) {
+		t.Errorf("New group does not have expected message."+
+			"\nexpected: %q\nreceived: %q", message, g.InitMessage)
+	}
+}
+
+// Error path: make sure an error and the correct status is returned when the
+// message is too large.
+func TestManager_MakeGroup_MaxMessageSizeError(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	m, _ := newTestManagerWithStore(prng, 10, 0, nil, nil, t)
+	expectedErr := fmt.Sprintf(maxInitMsgSizeErr, MaxInitMessageSize+1, MaxInitMessageSize)
+
+	_, _, status, err := m.MakeGroup(nil, nil, make([]byte, MaxInitMessageSize+1))
+	if err == nil || err.Error() != expectedErr {
+		t.Errorf("MakeGroup() did not return the expected error."+
+			"\nexpected: %s\nreceived: %+v", expectedErr, err)
+	}
+
+	if status != NotSent {
+		t.Errorf("MakeGroup() did not return the expected status."+
+			"\nexpected: %s\nreceived: %s", NotSent, status)
+	}
+}
+
+// Error path: make sure an error and the correct status is returned when the
+// membership list is too small.
+func TestManager_MakeGroup_MembershipSizeError(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	m, _ := newTestManagerWithStore(prng, 10, 0, nil, nil, t)
+	expectedErr := fmt.Sprintf(maxMembersErr, group.MaxParticipants+1, group.MaxParticipants)
+
+	_, _, status, err := m.MakeGroup(make([]*id.ID, group.MaxParticipants+1),
+		nil, []byte{})
+	if err == nil || err.Error() != expectedErr {
+		t.Errorf("MakeGroup() did not return the expected error."+
+			"\nexpected: %s\nreceived: %+v", expectedErr, err)
+	}
+
+	if status != NotSent {
+		t.Errorf("MakeGroup() did not return the expected status."+
+			"\nexpected: %s\nreceived: %s", NotSent, status)
+	}
+}
+
+// Error path: make sure an error and the correct status is returned when adding
+// a group failed because the user is a part of too many groups already.
+func TestManager_MakeGroup_AddGroupError(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	m, _ := newTestManagerWithStore(prng, gs.MaxGroupChats, 0, nil, nil, t)
+	memberIDs, _, _ := addPartners(m, t)
+	expectedErr := strings.SplitN(addGroupErr, "%", 2)[0]
+
+	_, _, _, err := m.MakeGroup(memberIDs, []byte{}, []byte{})
+	if err == nil || !strings.Contains(err.Error(), expectedErr) {
+		t.Errorf("MakeGroup() did not return the expected error."+
+			"\nexpected: %s\nreceived: %+v", expectedErr, err)
+	}
+}
+
+// Unit test of Manager.buildMembership.
+func TestManager_buildMembership(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	m, _ := newTestManager(prng, t)
+	memberIDs, expected, expectedDKL := addPartners(m, t)
+
+	membership, dkl, err := m.buildMembership(memberIDs)
+	if err != nil {
+		t.Errorf("buildMembership() returned an error: %+v", err)
+	}
+
+	if !reflect.DeepEqual(expected, membership) {
+		t.Errorf("buildMembership() failed to return the expected membership."+
+			"\nexpected: %s\nrecieved: %s", expected, membership)
+	}
+
+	if !reflect.DeepEqual(expectedDKL, dkl) {
+		t.Errorf("buildMembership() failed to return the expected DH key list."+
+			"\nexpected: %#v\nrecieved: %#v", expectedDKL, dkl)
+	}
+}
+
+// Error path: an error is returned when the number of members in the membership
+// list is too few.
+func TestManager_buildMembership_MinParticipantsError(t *testing.T) {
+	m, _ := newTestManager(rand.New(rand.NewSource(42)), t)
+	memberIDs := make([]*id.ID, group.MinParticipants-1)
+	expectedErr := fmt.Sprintf(minMembersErr, len(memberIDs), group.MinParticipants)
+
+	_, _, err := m.buildMembership(memberIDs)
+	if err == nil || !strings.Contains(err.Error(), expectedErr) {
+		t.Errorf("buildMembership() did not return the expected error."+
+			"\nexpected: %s\nreceived: %+v", expectedErr, err)
+	}
+}
+
+// Error path: an error is returned when the number of members in the membership
+// list is too many.
+func TestManager_buildMembership_MaxParticipantsError(t *testing.T) {
+	m, _ := newTestManager(rand.New(rand.NewSource(42)), t)
+	memberIDs := make([]*id.ID, group.MaxParticipants+1)
+	expectedErr := fmt.Sprintf(maxMembersErr, len(memberIDs), group.MaxParticipants)
+
+	_, _, err := m.buildMembership(memberIDs)
+	if err == nil || !strings.Contains(err.Error(), expectedErr) {
+		t.Errorf("buildMembership() did not return the expected error."+
+			"\nexpected: %s\nreceived: %+v", expectedErr, err)
+	}
+}
+
+// Error path: error returned when a partner cannot be found
+func TestManager_buildMembership_GetPartnerContactError(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	m, _ := newTestManager(prng, t)
+	memberIDs, _, _ := addPartners(m, t)
+	expectedErr := strings.SplitN(getPartnerErr, "%", 2)[0]
+
+	// Replace a partner ID
+	memberIDs[len(memberIDs)/2] = id.NewIdFromString("nonPartnerID", id.User, t)
+
+	_, _, err := m.buildMembership(memberIDs)
+	if err == nil || !strings.Contains(err.Error(), expectedErr) {
+		t.Errorf("buildMembership() did not return the expected error."+
+			"\nexpected: %s\nreceived: %+v", expectedErr, err)
+	}
+}
+
+// Error path: error returned when a member ID appears twice on the list.
+func TestManager_buildMembership_DuplicateContactError(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	m, _ := newTestManager(prng, t)
+	memberIDs, _, _ := addPartners(m, t)
+	expectedErr := strings.SplitN(makeMembershipErr, "%", 2)[0]
+
+	// Replace a partner ID
+	memberIDs[5] = memberIDs[4]
+
+	_, _, err := m.buildMembership(memberIDs)
+	if err == nil || !strings.Contains(err.Error(), expectedErr) {
+		t.Errorf("buildMembership() did not return the expected error."+
+			"\nexpected: %s\nreceived: %+v", expectedErr, err)
+	}
+}
+
+// Test that getPreimages produces unique preimages.
+func Test_getPreimages_Unique(t *testing.T) {
+	streamGen := fastRNG.NewStreamGenerator(1000, 10, csprng.NewSystemRNG)
+	n := 100
+	idPreimages := make(map[group.IdPreimage]bool, n)
+	keyPreimages := make(map[group.KeyPreimage]bool, n)
+
+	for i := 0; i < n; i++ {
+		idPreimage, keyPreimage, err := getPreimages(streamGen)
+		if err != nil {
+			t.Errorf("getPreimages() returned an error: %+v", err)
+		}
+
+		if idPreimages[idPreimage] {
+			t.Errorf("getPreimages() produced a duplicate idPreimage: %s", idPreimage)
+		} else {
+			idPreimages[idPreimage] = true
+		}
+
+		if keyPreimages[keyPreimage] {
+			t.Errorf("getPreimages() produced a duplicate keyPreimage: %s", keyPreimage)
+		} else {
+			keyPreimages[keyPreimage] = true
+		}
+	}
+}
+
+// Unit test of RequestStatus.String.
+func TestRequestStatus_String(t *testing.T) {
+	statusCodes := map[RequestStatus]string{
+		NotSent:     "NotSent",
+		AllFail:     "AllFail",
+		PartialSent: "PartialSent",
+		AllSent:     "AllSent",
+		AllSent + 1: "INVALID STATUS",
+	}
+
+	for status, expected := range statusCodes {
+		if status.String() != expected {
+			t.Errorf("String() failed to return the expected name."+
+				"\nexpected: %s\nreceived: %s", expected, status.String())
+		}
+	}
+}
+
+// Unit test of RequestStatus.Message.
+func TestRequestStatus_Message(t *testing.T) {
+	statusCodes := map[RequestStatus]string{
+		NotSent:     "an error occurred before sending any group requests",
+		AllFail:     "all group requests failed to send",
+		PartialSent: "some group requests failed to send",
+		AllSent:     "all groups requests successfully sent",
+		AllSent + 1: "INVALID STATUS " + strconv.Itoa(int(AllSent)+1),
+	}
+
+	for status, expected := range statusCodes {
+		if status.Message() != expected {
+			t.Errorf("Message() failed to return the expected message."+
+				"\nexpected: %s\nreceived: %s", expected, status.Message())
+		}
+	}
+}
+
+// addPartners returns a list of user IDs and their matching membership and adds
+// them as partners.
+func addPartners(m *Manager, t *testing.T) ([]*id.ID, group.Membership, gs.DhKeyList) {
+	memberIDs := make([]*id.ID, 10)
+	members := group.Membership{m.gs.GetUser()}
+	dkl := gs.DhKeyList{}
+
+	for i := range memberIDs {
+		// Build member data
+		uid := id.NewIdFromUInt(uint64(i), id.User, t)
+		dhKey := m.store.E2e().GetGroup().NewInt(int64(i + 42))
+
+		// Add to lists
+		memberIDs[i] = uid
+		members = append(members, group.Member{ID: uid, DhKey: dhKey})
+		dkl.Add(dhKey, group.Member{ID: uid, DhKey: dhKey}, m.store.E2e().GetGroup())
+
+		// Add partner
+		err := m.store.E2e().AddPartner(uid, dhKey, dhKey,
+			params.GetDefaultE2ESessionParams(), params.GetDefaultE2ESessionParams())
+		if err != nil {
+			t.Errorf("Failed to add partner %d: %+v", i, err)
+		}
+	}
+
+	return memberIDs, members, dkl
+}
diff --git a/groupChat/manager.go b/groupChat/manager.go
new file mode 100644
index 0000000000000000000000000000000000000000..f6044ed2269ed83e2ee96899f2768b27ce76e6f6
--- /dev/null
+++ b/groupChat/manager.go
@@ -0,0 +1,156 @@
+///////////////////////////////////////////////////////////////////////////////
+// Copyright © 2020 xx network SEZC                                          //
+//                                                                           //
+// Use of this source code is governed by a license that can be found in the //
+// LICENSE file                                                              //
+///////////////////////////////////////////////////////////////////////////////
+
+package groupChat
+
+import (
+	"github.com/pkg/errors"
+	"gitlab.com/elixxir/client/api"
+	gs "gitlab.com/elixxir/client/groupChat/groupStore"
+	"gitlab.com/elixxir/client/interfaces"
+	"gitlab.com/elixxir/client/interfaces/message"
+	"gitlab.com/elixxir/client/stoppable"
+	"gitlab.com/elixxir/client/storage"
+	"gitlab.com/elixxir/client/storage/versioned"
+	"gitlab.com/elixxir/crypto/cyclic"
+	"gitlab.com/elixxir/crypto/fastRNG"
+	"gitlab.com/elixxir/crypto/group"
+	"gitlab.com/xx_network/primitives/id"
+)
+
+const (
+	rawMessageBuffSize   = 100
+	receiveStoppableName = "GroupChatReceive"
+	receiveListenerName  = "GroupChatReceiveListener"
+	requestStoppableName = "GroupChatRequest"
+	requestListenerName  = "GroupChatRequestListener"
+	groupStoppableName   = "GroupChat"
+)
+
+// Error messages.
+const (
+	newGroupStoreErr = "failed to create new group store: %+v"
+	joinGroupErr     = "failed to join new group %s: %+v"
+	leaveGroupErr    = "failed to leave group %s: %+v"
+)
+
+// Manager handles the list of groups a user is a part of.
+type Manager struct {
+	client *api.Client
+	store  *storage.Session
+	swb    interfaces.Switchboard
+	net    interfaces.NetworkManager
+	rng    *fastRNG.StreamGenerator
+	gs     *gs.Store
+
+	requestFunc RequestCallback
+	receiveFunc ReceiveCallback
+}
+
+// NewManager generates a new group chat manager. This functions satisfies the
+// GroupChat interface.
+func NewManager(client *api.Client, requestFunc RequestCallback,
+	receiveFunc ReceiveCallback) (*Manager, error) {
+	return newManager(
+		client,
+		client.GetUser().ReceptionID.DeepCopy(),
+		client.GetStorage().E2e().GetDHPublicKey(),
+		client.GetStorage(),
+		client.GetSwitchboard(),
+		client.GetNetworkInterface(),
+		client.GetRng(),
+		client.GetStorage().GetKV(),
+		requestFunc,
+		receiveFunc,
+	)
+}
+
+// newManager creates a new group chat manager from api.Client parts for easier
+// testing.
+func newManager(client *api.Client, userID *id.ID, userDhKey *cyclic.Int,
+	store *storage.Session, swb interfaces.Switchboard,
+	net interfaces.NetworkManager, rng *fastRNG.StreamGenerator,
+	kv *versioned.KV, requestFunc RequestCallback,
+	receiveFunc ReceiveCallback) (*Manager, error) {
+
+	// Load the group chat storage or create one if one does not exist
+	gStore, err := gs.NewOrLoadStore(kv, group.Member{ID: userID, DhKey: userDhKey})
+	if err != nil {
+		return nil, errors.Errorf(newGroupStoreErr, err)
+	}
+
+	return &Manager{
+		client:      client,
+		store:       store,
+		swb:         swb,
+		net:         net,
+		rng:         rng,
+		gs:          gStore,
+		requestFunc: requestFunc,
+		receiveFunc: receiveFunc,
+	}, nil
+}
+
+// StartProcesses starts the reception worker.
+func (m *Manager) StartProcesses() stoppable.Stoppable {
+	// Start group reception worker
+	receiveStop := stoppable.NewSingle(receiveStoppableName)
+	receiveChan := make(chan message.Receive, rawMessageBuffSize)
+	m.swb.RegisterChannel(receiveListenerName, &id.ID{},
+		message.Raw, receiveChan)
+	go m.receive(receiveChan, receiveStop)
+
+	// Start group request worker
+	requestStop := stoppable.NewSingle(requestStoppableName)
+	requestChan := make(chan message.Receive, rawMessageBuffSize)
+	m.swb.RegisterChannel(requestListenerName, &id.ID{},
+		message.GroupCreationRequest, requestChan)
+	go m.receiveRequest(requestChan, requestStop)
+
+	// Create a multi stoppable
+	multiStoppable := stoppable.NewMulti(groupStoppableName)
+	multiStoppable.Add(receiveStop)
+	multiStoppable.Add(requestStop)
+
+	return multiStoppable
+}
+
+// JoinGroup adds the group to the list of group chats the user is a part of.
+// An error is returned if the user is already part of the group or if the
+// maximum number of groups have already been joined.
+func (m Manager) JoinGroup(g gs.Group) error {
+	if err := m.gs.Add(g); err != nil {
+		return errors.Errorf(joinGroupErr, g.ID, err)
+	}
+
+	return nil
+}
+
+// LeaveGroup removes a group from a list of groups the user is a part of.
+func (m Manager) LeaveGroup(groupID *id.ID) error {
+	if err := m.gs.Remove(groupID); err != nil {
+		return errors.Errorf(leaveGroupErr, groupID, err)
+	}
+
+	return nil
+}
+
+// GetGroups returns a list of all registered groupChat IDs.
+func (m Manager) GetGroups() []*id.ID {
+	return m.gs.GroupIDs()
+}
+
+// GetGroup returns the group with the matching ID or returns false if none
+// exist.
+func (m Manager) GetGroup(groupID *id.ID) (gs.Group, bool) {
+	return m.gs.Get(groupID)
+}
+
+// NumGroups returns the number of groups the user is a part of.
+func (m Manager) NumGroups() int {
+	return m.gs.Len()
+}
diff --git a/groupChat/manager_test.go b/groupChat/manager_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..0ea0f5341018fac4a24e72b999603d2a93086ed2
--- /dev/null
+++ b/groupChat/manager_test.go
@@ -0,0 +1,386 @@
+///////////////////////////////////////////////////////////////////////////////
+// Copyright © 2020 xx network SEZC                                          //
+//                                                                           //
+// Use of this source code is governed by a license that can be found in the //
+// LICENSE file                                                              //
+///////////////////////////////////////////////////////////////////////////////
+
+package groupChat
+
+import (
+	gs "gitlab.com/elixxir/client/groupChat/groupStore"
+	"gitlab.com/elixxir/client/storage/versioned"
+	"gitlab.com/elixxir/crypto/group"
+	"gitlab.com/elixxir/ekv"
+	"gitlab.com/xx_network/primitives/id"
+	"math/rand"
+	"reflect"
+	"strings"
+	"testing"
+	"time"
+)
+
+// Unit test of Manager.newManager.
+func Test_newManager(t *testing.T) {
+	kv := versioned.NewKV(make(ekv.Memstore))
+	user := group.Member{
+		ID:    id.NewIdFromString("userID", id.User, t),
+		DhKey: randCycInt(rand.New(rand.NewSource(42))),
+	}
+	requestChan := make(chan gs.Group)
+	requestFunc := func(g gs.Group) { requestChan <- g }
+	receiveChan := make(chan MessageReceive)
+	receiveFunc := func(msg MessageReceive) { receiveChan <- msg }
+	m, err := newManager(nil, user.ID, user.DhKey, nil, nil, nil, nil, kv, requestFunc, receiveFunc)
+	if err != nil {
+		t.Errorf("newManager() returned an error: %+v", err)
+	}
+
+	if !m.gs.GetUser().Equal(user) {
+		t.Errorf("newManager() failed to create a store with the correct user."+
+			"\nexpected: %s\nreceived: %s", user, m.gs.GetUser())
+	}
+
+	if m.gs.Len() != 0 {
+		t.Errorf("newManager() failed to create an empty store."+
+			"\nexpected: %d\nreceived: %d", 0, m.gs.Len())
+	}
+
+	// Check if requestFunc works
+	go m.requestFunc(gs.Group{})
+	select {
+	case <-requestChan:
+	case <-time.NewTimer(5 * time.Millisecond).C:
+		t.Errorf("Timed out waiting for requestFunc to be called.")
+	}
+
+	// Check if receiveFunc works
+	go m.receiveFunc(MessageReceive{})
+	select {
+	case <-receiveChan:
+	case <-time.NewTimer(5 * time.Millisecond).C:
+		t.Errorf("Timed out waiting for receiveFunc to be called.")
+	}
+}
+
+// Tests that Manager.newManager loads a group storage when it exists.
+func Test_newManager_LoadStorage(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	kv := versioned.NewKV(make(ekv.Memstore))
+	user := group.Member{
+		ID:    id.NewIdFromString("userID", id.User, t),
+		DhKey: randCycInt(rand.New(rand.NewSource(42))),
+	}
+
+	gStore, err := gs.NewStore(kv, user)
+	if err != nil {
+		t.Errorf("Failed to create new group storage: %+v", err)
+	}
+
+	for i := 0; i < 10; i++ {
+		err := gStore.Add(newTestGroup(getGroup(), getGroup().NewInt(42), prng, t))
+		if err != nil {
+			t.Errorf("Failed to add group %d: %+v", i, err)
+		}
+	}
+
+	m, err := newManager(nil, user.ID, user.DhKey, nil, nil, nil, nil, kv, nil, nil)
+	if err != nil {
+		t.Errorf("newManager() returned an error: %+v", err)
+	}
+
+	if !reflect.DeepEqual(gStore, m.gs) {
+		t.Errorf("newManager() failed to load the expected storage."+
+			"\nexpected: %+v\nreceived: %+v", gStore, m.gs)
+	}
+}
+
+// Error path: an error is returned when a group cannot be loaded from storage.
+func Test_newManager_LoadError(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	kv := versioned.NewKV(make(ekv.Memstore))
+	user := group.Member{
+		ID:    id.NewIdFromString("userID", id.User, t),
+		DhKey: randCycInt(rand.New(rand.NewSource(42))),
+	}
+
+	gStore, err := gs.NewStore(kv, user)
+	if err != nil {
+		t.Errorf("Failed to create new group storage: %+v", err)
+	}
+
+	g := newTestGroup(getGroup(), getGroup().NewInt(42), prng, t)
+	err = gStore.Add(g)
+	if err != nil {
+		t.Errorf("Failed to add group: %+v", err)
+	}
+	_ = kv.Prefix("GroupChatListStore").Delete("GroupChat/"+g.ID.String(), 0)
+
+	expectedErr := strings.SplitN(newGroupStoreErr, "%", 2)[0]
+
+	_, err = newManager(nil, user.ID, user.DhKey, nil, nil, nil, nil, kv, nil, nil)
+	if err == nil || !strings.Contains(err.Error(), expectedErr) {
+		t.Errorf("newManager() did not return the expected error."+
+			"\nexpected: %s\nreceived: %+v", expectedErr, err)
+	}
+}
+
+//
+// func TestManager_StartProcesses(t *testing.T) {
+// 	jww.SetLogThreshold(jww.LevelTrace)
+// 	jww.SetStdoutThreshold(jww.LevelTrace)
+// 	prng := rand.New(rand.NewSource(42))
+// 	requestChan1 := make(chan gs.Group)
+// 	requestFunc1 := func(g gs.Group) { requestChan1 <- g }
+// 	receiveChan1 := make(chan MessageReceive)
+// 	receiveFunc1 := func(msg MessageReceive) { receiveChan1 <- msg }
+// 	requestChan2 := make(chan gs.Group)
+// 	requestFunc2 := func(g gs.Group) { requestChan2 <- g }
+// 	receiveChan2 := make(chan MessageReceive)
+// 	receiveFunc2 := func(msg MessageReceive) { receiveChan2 <- msg }
+// 	requestChan3 := make(chan gs.Group)
+// 	requestFunc3 := func(g gs.Group) { requestChan3 <- g }
+// 	receiveChan3 := make(chan MessageReceive)
+// 	receiveFunc3 := func(msg MessageReceive) { receiveChan3 <- msg }
+//
+// 	m1, _ := newTestManagerWithStore(prng, 10, 0, requestFunc1, receiveFunc1, t)
+// 	m2, _ := newTestManagerWithStore(prng, 10, 0, requestFunc2, receiveFunc2, t)
+// 	m3, _ := newTestManagerWithStore(prng, 10, 0, requestFunc3, receiveFunc3, t)
+//
+// 	membership, err := group.NewMembership(m1.store.GetUser().GetContact(),
+// 		m2.store.GetUser().GetContact(), m3.store.GetUser().GetContact())
+// 	if err != nil {
+// 		t.Errorf("Failed to generate new membership: %+v", err)
+// 	}
+//
+// 	dhKeys := gs.GenerateDhKeyList(m1.gs.GetUser().ID,
+// 		m1.store.GetUser().E2eDhPrivateKey, membership, m1.store.E2e().GetGroup())
+//
+// 	grp1 := newTestGroup(m1.store.E2e().GetGroup(), m1.store.GetUser().E2eDhPrivateKey, prng, t)
+// 	grp1.Members = membership
+// 	grp1.DhKeys = dhKeys
+// 	grp1.ID = group.NewID(grp1.IdPreimage, grp1.Members)
+// 	grp1.Key = group.NewKey(grp1.KeyPreimage, grp1.Members)
+// 	grp2 := grp1.DeepCopy()
+// 	grp2.DhKeys = gs.GenerateDhKeyList(m2.gs.GetUser().ID,
+// 		m2.store.GetUser().E2eDhPrivateKey, membership, m2.store.E2e().GetGroup())
+// 	grp3 := grp1.DeepCopy()
+// 	grp3.DhKeys = gs.GenerateDhKeyList(m3.gs.GetUser().ID,
+// 		m3.store.GetUser().E2eDhPrivateKey, membership, m3.store.E2e().GetGroup())
+//
+// 	err = m1.gs.Add(grp1)
+// 	if err != nil {
+// 		t.Errorf("Failed to add group to member 1: %+v", err)
+// 	}
+// 	err = m2.gs.Add(grp2)
+// 	if err != nil {
+// 		t.Errorf("Failed to add group to member 2: %+v", err)
+// 	}
+// 	err = m3.gs.Add(grp3)
+// 	if err != nil {
+// 		t.Errorf("Failed to add group to member 3: %+v", err)
+// 	}
+//
+// 	_ = m1.StartProcesses()
+// 	_ = m2.StartProcesses()
+// 	_ = m3.StartProcesses()
+//
+// 	// Build request message
+// 	requestMarshaled, err := proto.Marshal(&Request{
+// 		Name:        grp1.Name,
+// 		IdPreimage:  grp1.IdPreimage.Bytes(),
+// 		KeyPreimage: grp1.KeyPreimage.Bytes(),
+// 		Members:     grp1.Members.Serialize(),
+// 		Message:     grp1.InitMessage,
+// 	})
+// 	if err != nil {
+// 		t.Errorf("Failed to proto marshal message: %+v", err)
+// 	}
+// 	msg := message.Receive{
+// 		Payload:     requestMarshaled,
+// 		MessageType: message.GroupCreationRequest,
+// 		Sender:      m1.gs.GetUser().ID,
+// 	}
+//
+// 	m2.swb.(*switchboard.Switchboard).Speak(msg)
+// 	m3.swb.(*switchboard.Switchboard).Speak(msg)
+//
+// 	select {
+// 	case received := <-requestChan2:
+// 		if !reflect.DeepEqual(grp2, received) {
+// 			t.Errorf("Failed to receive expected group on requestChan."+
+// 				"\nexpected: %#v\nreceived: %#v", grp2, received)
+// 		}
+// 	case <-time.NewTimer(5 * time.Millisecond).C:
+// 		t.Error("Timed out waiting for request callback.")
+// 	}
+//
+// 	select {
+// 	case received := <-requestChan3:
+// 		if !reflect.DeepEqual(grp3, received) {
+// 			t.Errorf("Failed to receive expected group on requestChan."+
+// 				"\nexpected: %#v\nreceived: %#v", grp3, received)
+// 		}
+// 	case <-time.NewTimer(5 * time.Millisecond).C:
+// 		t.Error("Timed out waiting for request callback.")
+// 	}
+//
+// 	contents := []byte("Test group message.")
+// 	timestamp := netTime.Now()
+//
+// 	// Create cMix message and get public message
+// 	cMixMsg, err := m1.newCmixMsg(grp1, contents, timestamp, m2.gs.GetUser(), prng)
+// 	if err != nil {
+// 		t.Errorf("Failed to create new cMix message: %+v", err)
+// 	}
+//
+// 	internalMsg, _ := newInternalMsg(cMixMsg.ContentsSize() - publicMinLen)
+// 	internalMsg.SetTimestamp(timestamp)
+// 	internalMsg.SetSenderID(m1.gs.GetUser().ID)
+// 	internalMsg.SetPayload(contents)
+// 	expectedMsgID := group.NewMessageID(grp1.ID, internalMsg.Marshal())
+//
+// 	expectedMsg := MessageReceive{
+// 		GroupID:        grp1.ID,
+// 		ID:             expectedMsgID,
+// 		Payload:        contents,
+// 		SenderID:       m1.gs.GetUser().ID,
+// 		RoundTimestamp: timestamp.Local(),
+// 	}
+//
+// 	msg = message.Receive{
+// 		Payload:        cMixMsg.Marshal(),
+// 		MessageType:    message.Raw,
+// 		Sender:         m1.gs.GetUser().ID,
+// 		RoundTimestamp: timestamp.Local(),
+// 	}
+// 	m2.swb.(*switchboard.Switchboard).Speak(msg)
+//
+// 	select {
+// 	case received := <-receiveChan2:
+// 		if !reflect.DeepEqual(expectedMsg, received) {
+// 			t.Errorf("Failed to receive expected group on receiveChan."+
+// 				"\nexpected: %+v\nreceived: %+v", expectedMsg, received)
+// 		}
+// 	case <-time.NewTimer(5 * time.Millisecond).C:
+// 		t.Error("Timed out waiting for receive callback.")
+// 	}
+// }
+
+// Unit test of Manager.JoinGroup.
+func TestManager_JoinGroup(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	m, _ := newTestManagerWithStore(prng, 10, 0, nil, nil, t)
+	g := newTestGroup(m.store.E2e().GetGroup(), m.store.GetUser().E2eDhPrivateKey, prng, t)
+
+	err := m.JoinGroup(g)
+	if err != nil {
+		t.Errorf("JoinGroup() returned an error: %+v", err)
+	}
+
+	if _, exists := m.gs.Get(g.ID); !exists {
+		t.Errorf("JoinGroup() failed to add the group %s.", g.ID)
+	}
+}
+
+// Error path: an error is returned when a group is joined twice.
+func TestManager_JoinGroup_AddErr(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	m, g := newTestManagerWithStore(prng, 10, 0, nil, nil, t)
+	expectedErr := strings.SplitN(joinGroupErr, "%", 2)[0]
+
+	err := m.JoinGroup(g)
+	if err == nil || !strings.Contains(err.Error(), expectedErr) {
+		t.Errorf("JoinGroup() failed to return the expected error."+
+			"\nexpected: %s\nreceived: %+v", expectedErr, err)
+	}
+}
+
+// Unit test of Manager.LeaveGroup.
+func TestManager_LeaveGroup(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	m, g := newTestManagerWithStore(prng, 10, 0, nil, nil, t)
+
+	err := m.LeaveGroup(g.ID)
+	if err != nil {
+		t.Errorf("LeaveGroup() returned an error: %+v", err)
+	}
+
+	if _, exists := m.GetGroup(g.ID); exists {
+		t.Error("LeaveGroup() failed to delete the group.")
+	}
+}
+
+// Error path: an error is returned when no group with the ID exists
+func TestManager_LeaveGroup_NoGroupError(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	m, _ := newTestManagerWithStore(prng, 10, 0, nil, nil, t)
+	expectedErr := strings.SplitN(leaveGroupErr, "%", 2)[0]
+
+	err := m.LeaveGroup(id.NewIdFromString("invalidID", id.Group, t))
+	if err == nil || !strings.Contains(err.Error(), expectedErr) {
+		t.Errorf("LeaveGroup() failed to return the expected error."+
+			"\nexpected: %s\nreceived: %+v", expectedErr, err)
+	}
+}
+
+// Unit test of Manager.GetGroups.
+func TestManager_GetGroups(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	m, _ := newTestManagerWithStore(prng, 10, 0, nil, nil, t)
+
+	list := m.GetGroups()
+	for i, gid := range list {
+		if err := m.gs.Remove(gid); err != nil {
+			t.Errorf("Group %s does not exist (%d): %+v", gid, i, err)
+		}
+	}
+
+	if m.gs.Len() != 0 {
+		t.Errorf("GetGroups() returned %d IDs, which is %d less than is in "+
+			"memory.", len(list), m.gs.Len())
+	}
+}
+
+// Unit test of Manager.GetGroup.
+func TestManager_GetGroup(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	m, g := newTestManagerWithStore(prng, 10, 0, nil, nil, t)
+
+	testGrp, exists := m.GetGroup(g.ID)
+	if !exists {
+		t.Error("GetGroup() failed to find a group that should exist.")
+	}
+
+	if !reflect.DeepEqual(g, testGrp) {
+		t.Errorf("GetGroup() failed to return the expected group."+
+			"\nexpected: %#v\nreceived: %#v", g, testGrp)
+	}
+
+	testGrp, exists = m.GetGroup(id.NewIdFromString("invalidID", id.Group, t))
+	if exists {
+		t.Errorf("GetGroup() returned a group that should not exist: %#v", testGrp)
+	}
+}
+
+// Unit test of Manager.NumGroups. First a manager is created with 10 groups
+// and the initial number is checked. Then the number of groups is checked after
+// leaving each until the number left is 0.
+func TestManager_NumGroups(t *testing.T) {
+	expectedNum := 10
+	m, _ := newTestManagerWithStore(rand.New(rand.NewSource(42)), expectedNum,
+		0, nil, nil, t)
+
+	groups := append([]*id.ID{{}}, m.GetGroups()...)
+
+	for i, gid := range groups {
+		_ = m.LeaveGroup(gid)
+
+		if m.NumGroups() != expectedNum-i {
+			t.Errorf("NumGroups() failed to return the expected number of "+
+				"groups (%d).\nexpected: %d\nreceived: %d",
+				i, expectedNum-i, m.NumGroups())
+		}
+	}
+
+}
diff --git a/groupChat/messageReceive.go b/groupChat/messageReceive.go
new file mode 100644
index 0000000000000000000000000000000000000000..e607e7f01fcd1aa2e6bb4cee11356e3ea71814f5
--- /dev/null
+++ b/groupChat/messageReceive.go
@@ -0,0 +1,69 @@
+///////////////////////////////////////////////////////////////////////////////
+// Copyright © 2020 xx network SEZC                                          //
+//                                                                           //
+// Use of this source code is governed by a license that can be found in the //
+// LICENSE file                                                              //
+///////////////////////////////////////////////////////////////////////////////
+
+package groupChat
+
+import (
+	"fmt"
+	"gitlab.com/elixxir/crypto/group"
+	"gitlab.com/xx_network/primitives/id"
+	"gitlab.com/xx_network/primitives/id/ephemeral"
+	"strconv"
+	"strings"
+	"time"
+)
+
+// MessageReceive contains the GroupChat message and associated data that a user
+// receives when getting a group message.
+type MessageReceive struct {
+	GroupID        *id.ID
+	ID             group.MessageID
+	Payload        []byte
+	SenderID       *id.ID
+	RecipientID    *id.ID
+	EphemeralID    ephemeral.Id
+	Timestamp      time.Time
+	RoundID        id.Round
+	RoundTimestamp time.Time
+}
+
+// String returns the MessageReceive as readable text. This functions satisfies
+// the fmt.Stringer interface.
+func (mr MessageReceive) String() string {
+	groupID := "<nil>"
+	if mr.GroupID != nil {
+		groupID = mr.GroupID.String()
+	}
+
+	payload := "<nil>"
+	if mr.Payload != nil {
+		payload = fmt.Sprintf("%q", mr.Payload)
+	}
+
+	senderID := "<nil>"
+	if mr.SenderID != nil {
+		senderID = mr.SenderID.String()
+	}
+
+	recipientID := "<nil>"
+	if mr.RecipientID != nil {
+		recipientID = mr.RecipientID.String()
+	}
+
+	str := make([]string, 0, 9)
+	str = append(str, "GroupID:"+groupID)
+	str = append(str, "ID:"+mr.ID.String())
+	str = append(str, "Payload:"+payload)
+	str = append(str, "SenderID:"+senderID)
+	str = append(str, "RecipientID:"+recipientID)
+	str = append(str, "EphemeralID:"+strconv.FormatInt(mr.EphemeralID.Int64(), 10))
+	str = append(str, "Timestamp:"+mr.Timestamp.String())
+	str = append(str, "RoundID:"+strconv.FormatUint(uint64(mr.RoundID), 10))
+	str = append(str, "RoundTimestamp:"+mr.RoundTimestamp.String())
+
+	return "{" + strings.Join(str, " ") + "}"
+}
diff --git a/groupChat/messageReceive_test.go b/groupChat/messageReceive_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..343f53774a08e59caed53a37d31a1a35f7047cd7
--- /dev/null
+++ b/groupChat/messageReceive_test.go
@@ -0,0 +1,70 @@
+///////////////////////////////////////////////////////////////////////////////
+// Copyright © 2020 xx network SEZC                                          //
+//                                                                           //
+// Use of this source code is governed by a license that can be found in the //
+// LICENSE file                                                              //
+///////////////////////////////////////////////////////////////////////////////
+package groupChat
+
+import (
+	"gitlab.com/elixxir/crypto/group"
+	"gitlab.com/xx_network/primitives/id"
+	"gitlab.com/xx_network/primitives/id/ephemeral"
+	"testing"
+	"time"
+)
+
+// Unit test of MessageReceive.String.
+func TestMessageReceive_String(t *testing.T) {
+	msg := MessageReceive{
+		GroupID:        id.NewIdFromString("GroupID", id.Group, t),
+		ID:             group.MessageID{0, 1, 2, 3},
+		Payload:        []byte("Group message."),
+		SenderID:       id.NewIdFromString("SenderID", id.User, t),
+		RecipientID:    id.NewIdFromString("RecipientID", id.User, t),
+		EphemeralID:    ephemeral.Id{0, 1, 2, 3},
+		Timestamp:      time.Date(1955, 11, 5, 12, 0, 0, 0, time.UTC),
+		RoundID:        42,
+		RoundTimestamp: time.Date(1955, 11, 5, 12, 1, 0, 0, time.UTC),
+	}
+
+	expected := "{" +
+		"GroupID:R3JvdXBJRAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE " +
+		"ID:AAECAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= " +
+		"Payload:\"Group message.\" " +
+		"SenderID:U2VuZGVySUQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD " +
+		"RecipientID:UmVjaXBpZW50SUQAAAAAAAAAAAAAAAAAAAAAAAAAAAAD " +
+		"EphemeralID:141843442434048 " +
+		"Timestamp:" + msg.Timestamp.String() + " " +
+		"RoundID:42 " +
+		"RoundTimestamp:" + msg.RoundTimestamp.String() +
+		"}"
+
+	if msg.String() != expected {
+		t.Errorf("String() returned the incorrect string."+
+			"\nexpected: %s\nreceived: %s", expected, msg.String())
+	}
+}
+
+// Tests that MessageReceive.String returns the expected value for a message
+// with nil values.
+func TestMessageReceive_String_NilMessageReceive(t *testing.T) {
+	msg := MessageReceive{}
+
+	expected := "{" +
+		"GroupID:<nil> " +
+		"ID:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= " +
+		"Payload:<nil> " +
+		"SenderID:<nil> " +
+		"RecipientID:<nil> " +
+		"EphemeralID:0 " +
+		"Timestamp:0001-01-01 00:00:00 +0000 UTC " +
+		"RoundID:0 " +
+		"RoundTimestamp:0001-01-01 00:00:00 +0000 UTC" +
+		"}"
+
+	if msg.String() != expected {
+		t.Errorf("String() returned the incorrect string."+
+			"\nexpected: %s\nreceived: %s", expected, msg.String())
+	}
+}
diff --git a/groupChat/publicFormat.go b/groupChat/publicFormat.go
new file mode 100644
index 0000000000000000000000000000000000000000..ab88d9e09f9c7e5110404fca5fc473070b45c088
--- /dev/null
+++ b/groupChat/publicFormat.go
@@ -0,0 +1,120 @@
+///////////////////////////////////////////////////////////////////////////////
+// Copyright © 2020 xx network SEZC                                          //
+//                                                                           //
+// Use of this source code is governed by a license that can be found in the //
+// LICENSE file                                                              //
+///////////////////////////////////////////////////////////////////////////////
+
+package groupChat
+
+import (
+	"encoding/base64"
+	"fmt"
+	"github.com/pkg/errors"
+	"gitlab.com/elixxir/crypto/group"
+)
+
+// Sizes of marshaled data, in bytes.
+const (
+	saltLen      = group.SaltLen
+	publicMinLen = saltLen
+)
+
+// Error messages
+const (
+	newPublicSizeErr       = "max message size %d < %d minimum required"
+	unmarshalPublicSizeErr = "size of data %d < %d minimum required"
+)
+
+// publicMsg is contains the salt and encrypted data in a group message.
+//
+// +---------------------+
+// |        data         |
+// +----------+----------+
+// |   salt   | payload  |
+// | 32 bytes | variable |
+// +----------+----------+
+type publicMsg struct {
+	data    []byte // Serial of all the parts of the message
+	salt    []byte // 256-bit sender salt
+	payload []byte // Encrypted internalMsg
+}
+
+// newPublicMsg creates a new publicMsg of size maxDataSize. An error is
+// returned if the maxDataSize is smaller than the minimum newPublicMsg size.
+func newPublicMsg(maxDataSize int) (publicMsg, error) {
+	if maxDataSize < publicMinLen {
+		return publicMsg{},
+			errors.Errorf(newPublicSizeErr, maxDataSize, publicMinLen)
+	}
+
+	return mapPublicMsg(make([]byte, maxDataSize)), nil
+}
+
+// mapPublicMsg maps all the parts of the publicMsg to the passed in data.
+func mapPublicMsg(data []byte) publicMsg {
+	return publicMsg{
+		data:    data,
+		salt:    data[:saltLen],
+		payload: data[saltLen:],
+	}
+}
+
+// unmarshalPublicMsg unmarshal the data into an publicMsg.  An error is
+// returned if the data length is smaller than the minimum allowed size.
+func unmarshalPublicMsg(data []byte) (publicMsg, error) {
+	if len(data) < publicMinLen {
+		return publicMsg{},
+			errors.Errorf(unmarshalPublicSizeErr, len(data), publicMinLen)
+	}
+
+	return mapPublicMsg(data), nil
+}
+
+// Marshal returns the serial of the publicMsg.
+func (pm publicMsg) Marshal() []byte {
+	return pm.data
+}
+
+// GetSalt returns the 256-bit salt.
+func (pm publicMsg) GetSalt() [group.SaltLen]byte {
+	var salt [group.SaltLen]byte
+	copy(salt[:], pm.salt)
+	return salt
+}
+
+// SetSalt sets the 256-bit salt.
+func (pm publicMsg) SetSalt(salt [group.SaltLen]byte) {
+	copy(pm.salt, salt[:])
+}
+
+// GetPayload returns the payload truncated to the correct size.
+func (pm publicMsg) GetPayload() []byte {
+	return pm.payload
+}
+
+// SetPayload sets the payload and saves it size.
+func (pm publicMsg) SetPayload(payload []byte) {
+	copy(pm.payload, payload)
+}
+
+// GetPayloadSize returns the maximum size of the payload.
+func (pm publicMsg) GetPayloadSize() int {
+	return len(pm.payload)
+}
+
+// String prints a string representation of publicMsg. This functions satisfies
+// the fmt.Stringer interface.
+func (pm publicMsg) String() string {
+	salt := "<nil>"
+	if len(pm.salt) > 0 {
+		salt = base64.StdEncoding.EncodeToString(pm.salt)
+	}
+
+	payload := "<nil>"
+	if len(pm.payload) > 0 {
+		payload = fmt.Sprintf("%q", pm.GetPayload())
+	}
+
+	return "{salt:" + salt + ", payload:" + payload + "}"
+}
diff --git a/groupChat/publicFormat_test.go b/groupChat/publicFormat_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..69884ff76856562e0d6f9ee3af03ad63e6eecb74
--- /dev/null
+++ b/groupChat/publicFormat_test.go
@@ -0,0 +1,162 @@
+package groupChat
+
+import (
+	"bytes"
+	"fmt"
+	"math/rand"
+	"reflect"
+	"testing"
+)
+
+// Unit test of newPublicMsg.
+func Test_newPublicMsg(t *testing.T) {
+	maxDataSize := 2 * publicMinLen
+	im, err := newPublicMsg(maxDataSize)
+	if err != nil {
+		t.Errorf("newPublicMsg() returned an error: %+v", err)
+	}
+
+	if len(im.data) != maxDataSize {
+		t.Errorf("newPublicMsg() set data to the wrong length."+
+			"\nexpected: %d\nreceived: %d", maxDataSize, len(im.data))
+	}
+}
+
+// Error path: the maxDataSize is smaller than the minimum size.
+func Test_newPublicMsg_PayloadSizeError(t *testing.T) {
+	maxDataSize := publicMinLen - 1
+	expectedErr := fmt.Sprintf(newPublicSizeErr, maxDataSize, publicMinLen)
+
+	_, err := newPublicMsg(maxDataSize)
+	if err == nil || err.Error() != expectedErr {
+		t.Errorf("newPublicMsg() failed to return the expected error."+
+			"\nexpected: %s\nreceived: %+v", expectedErr, err)
+	}
+}
+
+// Unit test of mapPublicMsg.
+func Test_mapPublicMsg(t *testing.T) {
+	// Create all the expected data
+	var salt [saltLen]byte
+	rand.New(rand.NewSource(42)).Read(salt[:])
+	payload := []byte("Sample payload contents.")
+
+	// Construct data into single slice
+	data := bytes.NewBuffer(nil)
+	data.Write(salt[:])
+	data.Write(payload)
+
+	// Map data
+	im := mapPublicMsg(data.Bytes())
+
+	// Check that the mapped values match the expected values
+	if !bytes.Equal(salt[:], im.salt) {
+		t.Errorf("mapPublicMsg() did not correctly map salt."+
+			"\nexpected: %+v\nreceived: %+v", salt, im.salt)
+	}
+
+	if !bytes.Equal(payload, im.payload) {
+		t.Errorf("mapPublicMsg() did not correctly map payload."+
+			"\nexpected: %+v\nreceived: %+v", payload, im.payload)
+	}
+}
+
+// Tests that a marshaled and unmarshalled publicMsg matches the original.
+func Test_publicMsg_Marshal_unmarshalPublicMsg(t *testing.T) {
+	pm, _ := newPublicMsg(publicMinLen * 2)
+	var salt [saltLen]byte
+	rand.New(rand.NewSource(42)).Read(salt[:])
+	pm.SetSalt(salt)
+	pm.SetPayload([]byte("Sample payload message."))
+
+	data := pm.Marshal()
+
+	newPm, err := unmarshalPublicMsg(data)
+	if err != nil {
+		t.Errorf("unmarshalPublicMsg() returned an error: %+v", err)
+	}
+
+	if !reflect.DeepEqual(pm, newPm) {
+		t.Errorf("unmarshalPublicMsg() did not return the expected publicMsg."+
+			"\nexpected: %s\nreceived: %s", pm, newPm)
+	}
+}
+
+// Error path: error is returned when the data is too short.
+func Test_unmarshalPublicMsg(t *testing.T) {
+	expectedErr := fmt.Sprintf(unmarshalPublicSizeErr, 0, publicMinLen)
+
+	_, err := unmarshalPublicMsg(nil)
+	if err == nil || err.Error() != expectedErr {
+		t.Errorf("unmarshalPublicMsg() failed to return the expected error."+
+			"\nexpected: %s\nreceived: %+v", expectedErr, err)
+	}
+}
+
+// Happy path.
+func Test_publicMsg_SetSalt_GetSalt(t *testing.T) {
+	pm, _ := newPublicMsg(publicMinLen * 2)
+	var salt [saltLen]byte
+	rand.New(rand.NewSource(42)).Read(salt[:])
+	pm.SetSalt(salt)
+
+	testSalt := pm.GetSalt()
+	if salt != testSalt {
+		t.Errorf("Failed to get original salt."+
+			"\nexpected: %+v\nreceived: %+v", salt, testSalt)
+	}
+}
+
+// Tests that the original payload matches the saved one.
+func Test_publicMsg_SetPayload_GetPayload(t *testing.T) {
+	pm, _ := newPublicMsg(publicMinLen * 2)
+	payload := make([]byte, pm.GetPayloadSize())
+	copy(payload, "Test payload message.")
+	pm.SetPayload(payload)
+	testPayload := pm.GetPayload()
+
+	if !bytes.Equal(payload, testPayload) {
+		t.Errorf("Failed to get original sender payload."+
+			"\nexpected: %q\nreceived: %q", payload, testPayload)
+	}
+}
+
+// Happy path.
+func Test_publicMsg_GetPayloadSize(t *testing.T) {
+	pm, _ := newPublicMsg(publicMinLen * 2)
+
+	if publicMinLen != pm.GetPayloadSize() {
+		t.Errorf("GetPayloadSize() failed to return the correct size."+
+			"\nexpected: %d\nreceived: %d", publicMinLen, pm.GetPayloadSize())
+	}
+}
+
+// Happy path.
+func Test_publicMsg_String(t *testing.T) {
+	pm, _ := newPublicMsg(publicMinLen * 2)
+	var salt [saltLen]byte
+	rand.New(rand.NewSource(42)).Read(salt[:])
+	pm.SetSalt(salt)
+	payload := []byte("Sample payload message.")
+	payload = append(payload, 0, 1, 2)
+	pm.SetPayload(payload)
+
+	expected := `{salt:U4x/lrFkvxuXu59LtHLon1sUhPJSCcnZND6SugndnVI=, payload:"Sample payload message.\x00\x01\x02\x00\x00\x00\x00\x00\x00"}`
+
+	if pm.String() != expected {
+		t.Errorf("String() failed to return the expected value."+
+			"\nexpected: %s\nreceived: %s", expected, pm.String())
+	}
+}
+
+// Happy path: tests that String returns the expected string for a nil publicMsg.
+func Test_publicMsg_String_NilInternalMessage(t *testing.T) {
+	pm := publicMsg{}
+
+	expected := "{salt:<nil>, payload:<nil>}"
+
+	if pm.String() != expected {
+		t.Errorf("String() failed to return the expected value."+
+			"\nexpected: %s\nreceived: %s", expected, pm.String())
+	}
+}
diff --git a/groupChat/receive.go b/groupChat/receive.go
new file mode 100644
index 0000000000000000000000000000000000000000..64cf10b789b3d07bf9d1dbd82d6d76b15c91fe53
--- /dev/null
+++ b/groupChat/receive.go
@@ -0,0 +1,168 @@
+///////////////////////////////////////////////////////////////////////////////
+// Copyright © 2020 xx network SEZC                                          //
+//                                                                           //
+// Use of this source code is governed by a license that can be found in the //
+// LICENSE file                                                              //
+///////////////////////////////////////////////////////////////////////////////
+
+package groupChat
+
+import (
+	"github.com/pkg/errors"
+	jww "github.com/spf13/jwalterweatherman"
+	gs "gitlab.com/elixxir/client/groupChat/groupStore"
+	"gitlab.com/elixxir/client/interfaces/message"
+	"gitlab.com/elixxir/client/stoppable"
+	"gitlab.com/elixxir/crypto/group"
+	"gitlab.com/elixxir/primitives/format"
+	"gitlab.com/xx_network/primitives/id"
+	"time"
+)
+
+// Error messages.
+const (
+	newDecryptKeyErr        = "failed to generate key for decrypting group payload: %+v"
+	unmarshalInternalMsgErr = "failed to unmarshal group internal message: %+v"
+	unmarshalSenderIdErr    = "failed to unmarshal sender ID: %+v"
+	unmarshalPublicMsgErr   = "failed to unmarshal group cMix message contents: %+v"
+	findGroupKeyFpErr       = "failed to find group with key fingerprint matching %s"
+	genCryptKeyMacErr       = "failed to generate encryption key for group " +
+		"cMix message because MAC verification failed (epoch %d could be off)"
+)
+
+// receive starts the group message reception worker that waits for new group
+// messages to arrive.
+func (m Manager) receive(rawMsgs chan message.Receive, stop *stoppable.Single) {
+	jww.DEBUG.Print("Starting group message reception worker.")
+
+	for {
+		select {
+		case <-stop.Quit():
+			jww.DEBUG.Print("Stopping group message reception worker.")
+			stop.ToStopped()
+			return
+		case receiveMsg := <-rawMsgs:
+			jww.DEBUG.Print("Group message reception received cMix message.")
+
+			// Attempt to read the message
+			g, msgID, timestamp, senderID, msg, err := m.readMessage(receiveMsg)
+			if err != nil {
+				jww.WARN.Printf("Group message reception failed to read cMix "+
+					"message: %+v", err)
+				continue
+			}
+
+			// If the message was read correctly, send it to the callback
+			go m.receiveFunc(MessageReceive{
+				GroupID:        g.ID,
+				ID:             msgID,
+				Payload:        msg,
+				SenderID:       senderID,
+				RecipientID:    receiveMsg.RecipientID,
+				EphemeralID:    receiveMsg.EphemeralID,
+				Timestamp:      receiveMsg.Timestamp,
+				RoundID:        receiveMsg.RoundId,
+				RoundTimestamp: timestamp,
+			})
+		}
+	}
+}
+
+// readMessage returns the group, message ID, timestamp, sender ID, and message
+// of a group message. The encrypted group message data is unmarshaled from a
+// cMix message in the message.Receive and then decrypted and the MAC is
+// verified. The group is found by finding the group with a matching key
+// fingerprint.
+func (m *Manager) readMessage(msg message.Receive) (gs.Group, group.MessageID,
+	time.Time, *id.ID, []byte, error) {
+	// Unmarshal payload into cMix message
+	cMixMsg := format.Unmarshal(msg.Payload)
+
+	// Unmarshal cMix message contents to get public message format
+	publicMsg, err := unmarshalPublicMsg(cMixMsg.GetContents())
+	if err != nil {
+		return gs.Group{}, group.MessageID{}, time.Time{}, nil, nil,
+			errors.Errorf(unmarshalPublicMsgErr, err)
+	}
+
+	// Get the group from storage via key fingerprint lookup
+	g, exists := m.gs.GetByKeyFp(cMixMsg.GetKeyFP(), publicMsg.GetSalt())
+	if !exists {
+		return gs.Group{}, group.MessageID{}, time.Time{}, nil, nil,
+			errors.Errorf(findGroupKeyFpErr, cMixMsg.GetKeyFP())
+	}
+
+	// Decrypt the payload and return the messages timestamp, sender ID, and
+	// message contents
+	messageID, timestamp, senderID, contents, err := m.decryptMessage(
+		g, cMixMsg, publicMsg, msg.RoundTimestamp)
+	return g, messageID, timestamp, senderID, contents, err
+}
+
+// decryptMessage decrypts the group message payload and returns its message ID,
+// timestamp, sender ID, and message contents.
+func (m *Manager) decryptMessage(g gs.Group, cMixMsg format.Message,
+	publicMsg publicMsg, roundTimestamp time.Time) (group.MessageID, time.Time,
+	*id.ID, []byte, error) {
+
+	key, err := getCryptKey(g.Key, publicMsg.GetSalt(), cMixMsg.GetMac(),
+		publicMsg.GetPayload(), g.DhKeys, roundTimestamp)
+	if err != nil {
+		return group.MessageID{}, time.Time{}, nil, nil, err
+	}
+
+	// Decrypt internal message
+	decryptedPayload := group.Decrypt(key, cMixMsg.GetKeyFP(),
+		publicMsg.GetPayload())
+
+	// Unmarshal internal message
+	internalMsg, err := unmarshalInternalMsg(decryptedPayload)
+	if err != nil {
+		return group.MessageID{}, time.Time{}, nil, nil,
+			errors.Errorf(unmarshalInternalMsgErr, err)
+	}
+
+	// Unmarshal sender ID
+	senderID, err := internalMsg.GetSenderID()
+	if err != nil {
+		return group.MessageID{}, time.Time{}, nil, nil,
+			errors.Errorf(unmarshalSenderIdErr, err)
+	}
+
+	messageID := group.NewMessageID(g.ID, internalMsg.Marshal())
+
+	return messageID, internalMsg.GetTimestamp(), senderID,
+		internalMsg.GetPayload(), nil
+}
+
+// getCryptKey generates the decryption key for a group internal message. The
+// key is generated using the group key, an epoch, and a salt. The epoch is
+// based off the round timestamp. So, to avoid missing the correct epoch, the
+// current, past, and next epochs are checked until one of them produces a key
+// that matches the message's MAC. The DH key is also unknown, so each member's
+// DH key is tried until there is a match.
+func getCryptKey(key group.Key, salt [group.SaltLen]byte, mac, payload []byte,
+	dhKeys gs.DhKeyList, roundTimestamp time.Time) (group.CryptKey, error) {
+	// Compute the current epoch
+	epoch := group.ComputeEpoch(roundTimestamp)
+
+	for _, dhKey := range dhKeys {
+
+		// Create a key with the correct epoch
+		for _, epoch := range []uint32{epoch, epoch - 1, epoch + 1} {
+			// Generate key
+			cryptKey, err := group.NewKdfKey(key, epoch, salt)
+			if err != nil {
+				return group.CryptKey{}, errors.Errorf(newDecryptKeyErr, err)
+			}
+
+			// Return the key if the MAC matches
+			if group.CheckMAC(mac, cryptKey, payload, dhKey) {
+				return cryptKey, nil
+			}
+		}
+	}
+
+	// Return an error if none of the epochs worked
+	return group.CryptKey{}, errors.Errorf(genCryptKeyMacErr, epoch)
+}
diff --git a/groupChat/receiveRequest.go b/groupChat/receiveRequest.go
new file mode 100644
index 0000000000000000000000000000000000000000..e5c7576f174f793d29ef337e092c96e4d782c371
--- /dev/null
+++ b/groupChat/receiveRequest.go
@@ -0,0 +1,111 @@
+///////////////////////////////////////////////////////////////////////////////
+// Copyright © 2020 xx network SEZC                                          //
+//                                                                           //
+// Use of this source code is governed by a license that can be found in the //
+// LICENSE file                                                              //
+///////////////////////////////////////////////////////////////////////////////
+
+package groupChat
+
+import (
+	"github.com/golang/protobuf/proto"
+	"github.com/pkg/errors"
+	jww "github.com/spf13/jwalterweatherman"
+	gs "gitlab.com/elixxir/client/groupChat/groupStore"
+	"gitlab.com/elixxir/client/interfaces/message"
+	"gitlab.com/elixxir/client/stoppable"
+	"gitlab.com/elixxir/crypto/group"
+)
+
+// Error message.
+const (
+	sendMessageTypeErr       = "message not of type GroupCreationRequest"
+	protoUnmarshalErr        = "failed to unmarshal request: %+v"
+	deserializeMembershipErr = "failed to deserialize membership: %+v"
+)
+
+// receiveRequest starts the group request reception worker that waits for new
+// group requests to arrive.
+func (m Manager) receiveRequest(rawMsgs chan message.Receive, stop *stoppable.Single) {
+	jww.DEBUG.Print("Starting group message request reception worker.")
+
+	for {
+		select {
+		case <-stop.Quit():
+			jww.DEBUG.Print("Stopping group message request reception worker.")
+			stop.ToStopped()
+			return
+		case sendMsg := <-rawMsgs:
+			jww.DEBUG.Print("Group message request received send message.")
+
+			// Generate the group from the request message
+			g, err := m.readRequest(sendMsg)
+			if err != nil {
+				jww.WARN.Printf("Failed to read message as group request: %+v",
+					err)
+				continue
+			}
+
+			// Call request callback with the new group if it does not already
+			// exist
+			if _, exists := m.GetGroup(g.ID); !exists {
+				go m.requestFunc(g)
+			}
+		}
+	}
+}
+
+// readRequest returns the group describes in the group request message. An
+// error is returned if the request is of the wrong type or cannot be read.
+func (m *Manager) readRequest(msg message.Receive) (gs.Group, error) {
+	// Return an error if the message is not of the right type
+	if msg.MessageType != message.GroupCreationRequest {
+		return gs.Group{}, errors.New(sendMessageTypeErr)
+	}
+
+	// Unmarshal the request message
+	request := &Request{}
+	err := proto.Unmarshal(msg.Payload, request)
+	if err != nil {
+		return gs.Group{}, errors.Errorf(protoUnmarshalErr, err)
+	}
+
+	// Deserialize membership list
+	membership, err := group.DeserializeMembership(request.Members)
+	if err != nil {
+		return gs.Group{}, errors.Errorf(deserializeMembershipErr, err)
+	}
+
+	// Get the relationship with the group leader
+	partner, err := m.store.E2e().GetPartner(membership[0].ID)
+	if err != nil {
+		return gs.Group{}, errors.Errorf(getPrivKeyErr, err)
+	}
+
+	// Replace leader's public key with the one from the partnership
+	leaderPubKey := membership[0].DhKey.DeepCopy()
+	membership[0].DhKey = partner.GetPartnerOriginPublicKey()
+
+	// Generate the DH keys with each group member
+	privKey := partner.GetMyOriginPrivateKey()
+	grp := m.store.E2e().GetGroup()
+	dkl := gs.GenerateDhKeyList(m.gs.GetUser().ID, privKey, membership, grp)
+
+	// Restore the original public key for the leader so that the membership
+	// digest generated later is correct
+	membership[0].DhKey = leaderPubKey
+
+	// Copy preimages
+	var idPreimage group.IdPreimage
+	copy(idPreimage[:], request.IdPreimage)
+	var keyPreimage group.KeyPreimage
+	copy(keyPreimage[:], request.KeyPreimage)
+
+	// Create group ID and key
+	groupID := group.NewID(idPreimage, membership)
+	groupKey := group.NewKey(keyPreimage, membership)
+
+	// Return the new group
+	return gs.NewGroup(request.Name, groupID, groupKey, idPreimage, keyPreimage,
+		request.Message, membership, dkl), nil
+}
diff --git a/groupChat/receiveRequest_test.go b/groupChat/receiveRequest_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..6925853a325948b005c7c38e11f2a28621ab2b11
--- /dev/null
+++ b/groupChat/receiveRequest_test.go
@@ -0,0 +1,241 @@
+///////////////////////////////////////////////////////////////////////////////
+// Copyright © 2020 xx network SEZC                                          //
+//                                                                           //
+// Use of this source code is governed by a license that can be found in the //
+// LICENSE file                                                              //
+///////////////////////////////////////////////////////////////////////////////
+
+package groupChat
+
+import (
+	"github.com/golang/protobuf/proto"
+	gs "gitlab.com/elixxir/client/groupChat/groupStore"
+	"gitlab.com/elixxir/client/interfaces/message"
+	"gitlab.com/elixxir/client/stoppable"
+	"math/rand"
+	"strings"
+	"testing"
+	"time"
+)
+
+// // Tests that the correct group is received from the request.
+// func TestManager_receiveRequest(t *testing.T) {
+// 	prng := rand.New(rand.NewSource(42))
+// 	requestChan := make(chan gs.Group)
+// 	requestFunc := func(g gs.Group) { requestChan <- g }
+// 	m, _ := newTestManagerWithStore(prng, 10, 0, requestFunc, nil, t)
+// 	g := newTestGroupWithUser(m.store.E2e().GetGroup(),
+// 		m.store.GetUser().ReceptionID, m.store.GetUser().E2eDhPublicKey,
+// 		m.store.GetUser().E2eDhPrivateKey, prng, t)
+//
+// 	requestMarshaled, err := proto.Marshal(&Request{
+// 		Name:        g.Name,
+// 		IdPreimage:  g.IdPreimage.Bytes(),
+// 		KeyPreimage: g.KeyPreimage.Bytes(),
+// 		Members:     g.Members.Serialize(),
+// 		Message:     g.InitMessage,
+// 	})
+// 	if err != nil {
+// 		t.Errorf("Failed to marshal proto message: %+v", err)
+// 	}
+//
+// 	msg := message.Receive{
+// 		Payload:     requestMarshaled,
+// 		MessageType: message.GroupCreationRequest,
+// 	}
+//
+// 	rawMessages := make(chan message.Receive)
+// 	quit := make(chan struct{})
+// 	go m.receiveRequest(rawMessages, quit)
+// 	rawMessages <- msg
+//
+// 	select {
+// 	case receivedGrp := <-requestChan:
+// 		if !reflect.DeepEqual(g, receivedGrp) {
+// 			t.Errorf("receiveRequest() failed to return the expected group."+
+// 				"\nexpected: %#v\nreceived: %#v", g, receivedGrp)
+// 		}
+// 	case <-time.NewTimer(5 * time.Millisecond).C:
+// 		t.Error("Timed out while waiting for callback.")
+// 	}
+// }
+
+// Tests that the callback is not called when the group already exists in the
+// manager.
+func TestManager_receiveRequest_GroupExists(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	requestChan := make(chan gs.Group)
+	requestFunc := func(g gs.Group) { requestChan <- g }
+	m, g := newTestManagerWithStore(prng, 10, 0, requestFunc, nil, t)
+
+	requestMarshaled, err := proto.Marshal(&Request{
+		Name:        g.Name,
+		IdPreimage:  g.IdPreimage.Bytes(),
+		KeyPreimage: g.KeyPreimage.Bytes(),
+		Members:     g.Members.Serialize(),
+		Message:     g.InitMessage,
+	})
+	if err != nil {
+		t.Errorf("Failed to marshal proto message: %+v", err)
+	}
+
+	msg := message.Receive{
+		Payload:     requestMarshaled,
+		MessageType: message.GroupCreationRequest,
+	}
+
+	rawMessages := make(chan message.Receive)
+	stop := stoppable.NewSingle("testStoppable")
+	go m.receiveRequest(rawMessages, stop)
+	rawMessages <- msg
+
+	select {
+	case <-requestChan:
+		t.Error("receiveRequest() called the callback when the group already " +
+			"exists in the list.")
+	case <-time.NewTimer(5 * time.Millisecond).C:
+	}
+}
+
+// Tests that the quit channel quits the worker.
+func TestManager_receiveRequest_QuitChan(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	requestChan := make(chan gs.Group)
+	requestFunc := func(g gs.Group) { requestChan <- g }
+	m, _ := newTestManagerWithStore(prng, 10, 0, requestFunc, nil, t)
+
+	rawMessages := make(chan message.Receive)
+	stop := stoppable.NewSingle("testStoppable")
+	done := make(chan struct{})
+	go func() {
+		m.receiveRequest(rawMessages, stop)
+		done <- struct{}{}
+	}()
+	if err := stop.Close(); err != nil {
+		t.Errorf("Failed to signal close to process: %+v", err)
+	}
+
+	select {
+	case <-done:
+	case <-time.NewTimer(5 * time.Millisecond).C:
+		t.Error("receiveRequest() failed to close when the quit.")
+	}
+}
+
+// Tests that the callback is not called when the send message is not of the
+// correct type.
+func TestManager_receiveRequest_SendMessageTypeError(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	requestChan := make(chan gs.Group)
+	requestFunc := func(g gs.Group) { requestChan <- g }
+	m, _ := newTestManagerWithStore(prng, 10, 0, requestFunc, nil, t)
+
+	msg := message.Receive{
+		MessageType: message.NoType,
+	}
+
+	rawMessages := make(chan message.Receive)
+	stop := stoppable.NewSingle("singleStoppable")
+	go m.receiveRequest(rawMessages, stop)
+	rawMessages <- msg
+
+	select {
+	case receivedGrp := <-requestChan:
+		t.Errorf("Callback called when the message should have been skipped: %#v",
+			receivedGrp)
+	case <-time.NewTimer(5 * time.Millisecond).C:
+	}
+}
+
+// // Unit test of readRequest.
+// func TestManager_readRequest(t *testing.T) {
+// 	m, g := newTestManager(rand.New(rand.NewSource(42)), t)
+// 	_ = m.store.E2e().AddPartner(
+// 		g.Members[0].ID,
+// 		g.Members[0].DhKey,
+// 		m.store.E2e().GetGroup().NewInt(43),
+// 		params.GetDefaultE2ESessionParams(),
+// 		params.GetDefaultE2ESessionParams(),
+// 	)
+//
+// 	requestMarshaled, err := proto.Marshal(&Request{
+// 		Name:        g.Name,
+// 		IdPreimage:  g.IdPreimage.Bytes(),
+// 		KeyPreimage: g.KeyPreimage.Bytes(),
+// 		Members:     g.Members.Serialize(),
+// 		Message:     g.InitMessage,
+// 	})
+// 	if err != nil {
+// 		t.Errorf("Failed to marshal proto message: %+v", err)
+// 	}
+//
+// 	msg := message.Receive{
+// 		Payload:     requestMarshaled,
+// 		MessageType: message.GroupCreationRequest,
+// 	}
+//
+// 	newGrp, err := m.readRequest(msg)
+// 	if err != nil {
+// 		t.Errorf("readRequest() returned an error: %+v", err)
+// 	}
+//
+// 	if !reflect.DeepEqual(g, newGrp) {
+// 		t.Errorf("readRequest() returned the wrong group."+
+// 			"\nexpected: %#v\nreceived: %#v", g, newGrp)
+// 	}
+// }
+
+// Error path: an error is returned if the message type is incorrect.
+func TestManager_readRequest_MessageTypeError(t *testing.T) {
+	m, _ := newTestManager(rand.New(rand.NewSource(42)), t)
+	expectedErr := sendMessageTypeErr
+	msg := message.Receive{
+		MessageType: message.NoType,
+	}
+
+	_, err := m.readRequest(msg)
+	if err == nil || !strings.Contains(err.Error(), expectedErr) {
+		t.Errorf("readRequest() did not return the expected error."+
+			"\nexpected: %s\nreceived: %+v", expectedErr, err)
+	}
+}
+
+// Error path: an error is returned if the proto message cannot be unmarshalled.
+func TestManager_readRequest_ProtoUnmarshalError(t *testing.T) {
+	expectedErr := strings.SplitN(deserializeMembershipErr, "%", 2)[0]
+	m, _ := newTestManager(rand.New(rand.NewSource(42)), t)
+
+	requestMarshaled, err := proto.Marshal(&Request{
+		Members: []byte("Invalid membership serial."),
+	})
+	if err != nil {
+		t.Errorf("Failed to marshal proto message: %+v", err)
+	}
+
+	msg := message.Receive{
+		Payload:     requestMarshaled,
+		MessageType: message.GroupCreationRequest,
+	}
+
+	_, err = m.readRequest(msg)
+	if err == nil || !strings.Contains(err.Error(), expectedErr) {
+		t.Errorf("readRequest() did not return the expected error."+
+			"\nexpected: %s\nreceived: %+v", expectedErr, err)
+	}
+}
+
+// Error path: an error is returned if the membership cannot be deserialized.
+func TestManager_readRequest_DeserializeMembershipError(t *testing.T) {
+	m, _ := newTestManager(rand.New(rand.NewSource(42)), t)
+	expectedErr := strings.SplitN(protoUnmarshalErr, "%", 2)[0]
+	msg := message.Receive{
+		Payload:     []byte("Invalid message."),
+		MessageType: message.GroupCreationRequest,
+	}
+
+	_, err := m.readRequest(msg)
+	if err == nil || !strings.Contains(err.Error(), expectedErr) {
+		t.Errorf("readRequest() did not return the expected error."+
+			"\nexpected: %s\nreceived: %+v", expectedErr, err)
+	}
+}
diff --git a/groupChat/receive_test.go b/groupChat/receive_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..36ea8ed2dbad4c10630630f198d07d4b6a96bf54
--- /dev/null
+++ b/groupChat/receive_test.go
@@ -0,0 +1,409 @@
+///////////////////////////////////////////////////////////////////////////////
+// Copyright © 2020 xx network SEZC                                          //
+//                                                                           //
+// Use of this source code is governed by a license that can be found in the //
+// LICENSE file                                                              //
+///////////////////////////////////////////////////////////////////////////////
+
+package groupChat
+
+import (
+	"bytes"
+	"gitlab.com/elixxir/client/interfaces/message"
+	"gitlab.com/elixxir/client/stoppable"
+	"gitlab.com/elixxir/crypto/e2e"
+	"gitlab.com/elixxir/crypto/group"
+	"gitlab.com/elixxir/primitives/format"
+	"gitlab.com/xx_network/primitives/netTime"
+	"math/rand"
+	"reflect"
+	"strings"
+	"testing"
+	"time"
+)
+
+// Tests that Manager.receive returns the correct message on the callback.
+func TestManager_receive(t *testing.T) {
+	// Setup callback
+	msgChan := make(chan MessageReceive)
+	receiveFunc := func(msg MessageReceive) { msgChan <- msg }
+
+	// Create new test Manager and Group
+	prng := rand.New(rand.NewSource(42))
+	m, g := newTestManagerWithStore(prng, 10, 0, nil, receiveFunc, t)
+
+	// Create test parameters
+	contents := []byte("Test group message.")
+	timestamp := netTime.Now()
+	sender := m.gs.GetUser()
+
+	expectedMsg := MessageReceive{
+		GroupID:        g.ID,
+		ID:             group.MessageID{0, 1, 2, 3},
+		Payload:        contents,
+		SenderID:       sender.ID,
+		RoundTimestamp: timestamp.Local(),
+	}
+
+	// Create cMix message and get public message
+	cMixMsg, err := m.newCmixMsg(g, contents, timestamp, g.Members[4], prng)
+	if err != nil {
+		t.Errorf("Failed to create new cMix message: %+v", err)
+	}
+
+	internalMsg, _ := newInternalMsg(cMixMsg.ContentsSize() - publicMinLen)
+	internalMsg.SetTimestamp(timestamp)
+	internalMsg.SetSenderID(m.gs.GetUser().ID)
+	internalMsg.SetPayload(contents)
+	expectedMsg.ID = group.NewMessageID(g.ID, internalMsg.Marshal())
+
+	receiveChan := make(chan message.Receive, 1)
+	stop := stoppable.NewSingle("singleStoppable")
+
+	m.gs.SetUser(g.Members[4], t)
+	go m.receive(receiveChan, stop)
+
+	receiveChan <- message.Receive{
+		Payload:        cMixMsg.Marshal(),
+		RoundTimestamp: timestamp,
+	}
+
+	select {
+	case msg := <-msgChan:
+		if !reflect.DeepEqual(expectedMsg, msg) {
+			t.Errorf("Failed to received expected message."+
+				"\nexpected: %+v\nreceived: %+v", expectedMsg, msg)
+		}
+	case <-time.NewTimer(10 * time.Millisecond).C:
+		t.Errorf("Timed out waiting to receive group message.")
+	}
+}
+
+// Tests that the callback is not called when the message cannot be read.
+func TestManager_receive_ReadMessageError(t *testing.T) {
+	// Setup callback
+	msgChan := make(chan MessageReceive)
+	receiveFunc := func(msg MessageReceive) { msgChan <- msg }
+
+	// Create new test Manager and Group
+	prng := rand.New(rand.NewSource(42))
+	m, _ := newTestManagerWithStore(prng, 10, 0, nil, receiveFunc, t)
+
+	receiveChan := make(chan message.Receive, 1)
+	stop := stoppable.NewSingle("singleStoppable")
+
+	go m.receive(receiveChan, stop)
+
+	receiveChan <- message.Receive{
+		Payload: make([]byte, format.MinimumPrimeSize*2),
+	}
+
+	select {
+	case <-msgChan:
+		t.Error("Callback called when message should have errored.")
+	case <-time.NewTimer(5 * time.Millisecond).C:
+	}
+}
+
+// Tests that the quit channel exits the function.
+func TestManager_receive_QuitChan(t *testing.T) {
+	// Create new test Manager and Group
+	prng := rand.New(rand.NewSource(42))
+	m, _ := newTestManagerWithStore(prng, 10, 0, nil, nil, t)
+
+	receiveChan := make(chan message.Receive, 1)
+	stop := stoppable.NewSingle("singleStoppable")
+	doneChan := make(chan struct{})
+
+	go func() {
+		m.receive(receiveChan, stop)
+		doneChan <- struct{}{}
+	}()
+
+	if err := stop.Close(); err != nil {
+		t.Errorf("Failed to signal close to process: %+v", err)
+	}
+
+	select {
+	case <-doneChan:
+	case <-time.NewTimer(10 * time.Millisecond).C:
+		t.Errorf("Timed out waiting for thread to quit.")
+	}
+}
+
+// Tests that Manager.readMessage returns the message data for the correct group.
+func TestManager_readMessage(t *testing.T) {
+	// Create new test Manager and Group
+	prng := rand.New(rand.NewSource(42))
+	m, expectedGrp := newTestManagerWithStore(prng, 10, 0, nil, nil, t)
+
+	// Create test parameters
+	expectedContents := []byte("Test group message.")
+	expectedTimestamp := netTime.Now()
+	sender := m.gs.GetUser()
+
+	// Create cMix message and get public message
+	cMixMsg, err := m.newCmixMsg(expectedGrp, expectedContents,
+		expectedTimestamp, expectedGrp.Members[4], prng)
+	if err != nil {
+		t.Errorf("Failed to create new cMix message: %+v", err)
+	}
+
+	internalMsg, _ := newInternalMsg(cMixMsg.ContentsSize() - publicMinLen)
+	internalMsg.SetTimestamp(expectedTimestamp)
+	internalMsg.SetSenderID(sender.ID)
+	internalMsg.SetPayload(expectedContents)
+	expectedMsgID := group.NewMessageID(expectedGrp.ID, internalMsg.Marshal())
+
+	// Build message.Receive
+	receiveMsg := message.Receive{
+		ID:             e2e.MessageID{},
+		Payload:        cMixMsg.Marshal(),
+		RoundTimestamp: expectedTimestamp,
+	}
+
+	m.gs.SetUser(expectedGrp.Members[4], t)
+	g, messageID, timestamp, senderID, contents, err := m.readMessage(receiveMsg)
+	if err != nil {
+		t.Errorf("readMessage() returned an error: %+v", err)
+	}
+
+	if !reflect.DeepEqual(expectedGrp, g) {
+		t.Errorf("readMessage() returned incorrect group."+
+			"\nexpected: %#v\nreceived: %#v", expectedGrp, g)
+	}
+
+	if expectedMsgID != messageID {
+		t.Errorf("readMessage() returned incorrect message ID."+
+			"\nexpected: %s\nreceived: %s", expectedMsgID, messageID)
+	}
+
+	if !expectedTimestamp.Equal(timestamp) {
+		t.Errorf("readMessage() returned incorrect timestamp."+
+			"\nexpected: %s\nreceived: %s", expectedTimestamp, timestamp)
+	}
+
+	if !sender.ID.Cmp(senderID) {
+		t.Errorf("readMessage() returned incorrect sender ID."+
+			"\nexpected: %s\nreceived: %s", sender.ID, senderID)
+	}
+
+	if !bytes.Equal(expectedContents, contents) {
+		t.Errorf("readMessage() returned incorrect message."+
+			"\nexpected: %s\nreceived: %s", expectedContents, contents)
+	}
+}
+
+// Error path: an error is returned when a group with a matching group
+// fingerprint cannot be found.
+func TestManager_readMessage_FindGroupKpError(t *testing.T) {
+	// Create new test Manager and Group
+	prng := rand.New(rand.NewSource(42))
+	m, g := newTestManagerWithStore(prng, 10, 0, nil, nil, t)
+
+	// Create test parameters
+	expectedContents := []byte("Test group message.")
+	expectedTimestamp := netTime.Now()
+
+	// Create cMix message and get public message
+	cMixMsg, err := m.newCmixMsg(g, expectedContents, expectedTimestamp, g.Members[4], prng)
+	if err != nil {
+		t.Errorf("Failed to create new cMix message: %+v", err)
+	}
+
+	cMixMsg.SetKeyFP(format.NewFingerprint([]byte("invalid Fingerprint")))
+
+	// Build message.Receive
+	receiveMsg := message.Receive{
+		ID:             e2e.MessageID{},
+		Payload:        cMixMsg.Marshal(),
+		RoundTimestamp: expectedTimestamp,
+	}
+
+	expectedErr := strings.SplitN(findGroupKeyFpErr, "%", 2)[0]
+
+	m.gs.SetUser(g.Members[4], t)
+	_, _, _, _, _, err = m.readMessage(receiveMsg)
+	if err == nil || !strings.Contains(err.Error(), expectedErr) {
+		t.Errorf("readMessage() failed to return the expected error."+
+			"\nexpected: %s\nreceived: %+v", expectedErr, err)
+	}
+}
+
+// Tests that a cMix message created by Manager.newCmixMsg can be read by
+// Manager.readMessage.
+func TestManager_decryptMessage(t *testing.T) {
+	// Create new test Manager and Group
+	prng := rand.New(rand.NewSource(42))
+	m, g := newTestManager(prng, t)
+
+	// Create test parameters
+	expectedContents := []byte("Test group message.")
+	expectedTimestamp := netTime.Now()
+
+	// Create cMix message and get public message
+	msg, err := m.newCmixMsg(g, expectedContents, expectedTimestamp, g.Members[4], prng)
+	if err != nil {
+		t.Errorf("Failed to create new cMix message: %+v", err)
+	}
+	publicMsg, err := unmarshalPublicMsg(msg.GetContents())
+	if err != nil {
+		t.Errorf("Failed to unmarshal publicMsg: %+v", err)
+	}
+
+	internalMsg, _ := newInternalMsg(publicMsg.GetPayloadSize())
+	internalMsg.SetTimestamp(expectedTimestamp)
+	internalMsg.SetSenderID(m.gs.GetUser().ID)
+	internalMsg.SetPayload(expectedContents)
+	expectedMsgID := group.NewMessageID(g.ID, internalMsg.Marshal())
+
+	// Read message and check if the outputs are correct
+	messageID, timestamp, senderID, contents, err := m.decryptMessage(g, msg,
+		publicMsg, expectedTimestamp)
+	if err != nil {
+		t.Errorf("decryptMessage() returned an error: %+v", err)
+	}
+
+	if expectedMsgID != messageID {
+		t.Errorf("decryptMessage() returned incorrect message ID."+
+			"\nexpected: %s\nreceived: %s", expectedMsgID, messageID)
+	}
+
+	if !expectedTimestamp.Equal(timestamp) {
+		t.Errorf("decryptMessage() returned incorrect timestamp."+
+			"\nexpected: %s\nreceived: %s", expectedTimestamp, timestamp)
+	}
+
+	if !m.gs.GetUser().ID.Cmp(senderID) {
+		t.Errorf("decryptMessage() returned incorrect sender ID."+
+			"\nexpected: %s\nreceived: %s", m.gs.GetUser().ID, senderID)
+	}
+
+	if !bytes.Equal(expectedContents, contents) {
+		t.Errorf("decryptMessage() returned incorrect message."+
+			"\nexpected: %s\nreceived: %s", expectedContents, contents)
+	}
+}
+
+// Error path: an error is returned when the wrong timestamp is passed in and
+// the decryption key cannot be generated because of the wrong epoch.
+func TestManager_decryptMessage_GetCryptKeyError(t *testing.T) {
+	// Create new test Manager and Group
+	prng := rand.New(rand.NewSource(42))
+	m, g := newTestManager(prng, t)
+
+	// Create test parameters
+	contents := []byte("Test group message.")
+	timestamp := netTime.Now()
+
+	// Create cMix message and get public message
+	msg, err := m.newCmixMsg(g, contents, timestamp, g.Members[4], prng)
+	if err != nil {
+		t.Errorf("Failed to create new cMix message: %+v", err)
+	}
+	publicMsg, err := unmarshalPublicMsg(msg.GetContents())
+	if err != nil {
+		t.Errorf("Failed to unmarshal publicMsg: %+v", err)
+	}
+
+	// Check if error is correct
+	expectedErr := strings.SplitN(genCryptKeyMacErr, "%", 2)[0]
+	_, _, _, _, err = m.decryptMessage(g, msg, publicMsg, timestamp.Add(time.Hour))
+	if err == nil || !strings.Contains(err.Error(), expectedErr) {
+		t.Errorf("decryptMessage() failed to return the expected error."+
+			"\nexpected: %s\nreceived: %+v", expectedErr, err)
+	}
+}
+
+// Error path: an error is returned when the decrypted payload cannot be
+// unmarshaled.
+func TestManager_decryptMessage_UnmarshalInternalMsgError(t *testing.T) {
+	// Create new test Manager and Group
+	prng := rand.New(rand.NewSource(42))
+	m, g := newTestManager(prng, t)
+
+	// Create test parameters
+	contents := []byte("Test group message.")
+	timestamp := netTime.Now()
+
+	// Create cMix message and get public message
+	msg, err := m.newCmixMsg(g, contents, timestamp, g.Members[4], prng)
+	if err != nil {
+		t.Errorf("Failed to create new cMix message: %+v", err)
+	}
+	publicMsg, err := unmarshalPublicMsg(msg.GetContents())
+	if err != nil {
+		t.Errorf("Failed to unmarshal publicMsg: %+v", err)
+	}
+
+	// Modify publicMsg to have invalid payload
+	publicMsg = mapPublicMsg(publicMsg.Marshal()[:33])
+	key, err := group.NewKdfKey(g.Key, group.ComputeEpoch(timestamp), publicMsg.GetSalt())
+	if err != nil {
+		t.Errorf("failed to create new key: %+v", err)
+	}
+	msg.SetMac(group.NewMAC(key, publicMsg.GetPayload(), g.DhKeys[*g.Members[4].ID]))
+
+	// Check if error is correct
+	expectedErr := strings.SplitN(unmarshalInternalMsgErr, "%", 2)[0]
+	_, _, _, _, err = m.decryptMessage(g, msg, publicMsg, timestamp)
+	if err == nil || !strings.Contains(err.Error(), expectedErr) {
+		t.Errorf("decryptMessage() failed to return the expected error."+
+			"\nexpected: %s\nreceived: %+v", expectedErr, err)
+	}
+}
+
+// Unit test of getCryptKey.
+func Test_getCryptKey(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	g := newTestGroup(getGroup(), getGroup().NewInt(42), prng, t)
+	salt, err := newSalt(prng)
+	if err != nil {
+		t.Errorf("failed to create new salt: %+v", err)
+	}
+	payload := []byte("payload")
+	ts := netTime.Now()
+
+	expectedKey, err := group.NewKdfKey(g.Key, group.ComputeEpoch(ts.Add(5*time.Minute)), salt)
+	if err != nil {
+		t.Errorf("failed to create new key: %+v", err)
+	}
+	mac := group.NewMAC(expectedKey, payload, g.DhKeys[*g.Members[4].ID])
+
+	key, err := getCryptKey(g.Key, salt, mac, payload, g.DhKeys, ts)
+	if err != nil {
+		t.Errorf("getCryptKey() returned an error: %+v", err)
+	}
+
+	if expectedKey != key {
+		t.Errorf("getCryptKey() did not return the expected key."+
+			"\nexpected: %v\nreceived: %v", expectedKey, key)
+	}
+}
+
+// Error path: return an error when the MAC cannot be verified because the
+// timestamp is incorrect and generates the wrong epoch.
+func Test_getCryptKey_EpochError(t *testing.T) {
+	expectedErr := strings.SplitN(genCryptKeyMacErr, "%", 2)[0]
+
+	prng := rand.New(rand.NewSource(42))
+	g := newTestGroup(getGroup(), getGroup().NewInt(42), prng, t)
+	salt, err := newSalt(prng)
+	if err != nil {
+		t.Errorf("failed to create new salt: %+v", err)
+	}
+	payload := []byte("payload")
+	ts := netTime.Now()
+
+	key, err := group.NewKdfKey(g.Key, group.ComputeEpoch(ts), salt)
+	if err != nil {
+		t.Errorf("getCryptKey() returned an error: %+v", err)
+	}
+	mac := group.NewMAC(key, payload, g.Members[4].DhKey)
+
+	_, err = getCryptKey(g.Key, salt, mac, payload, g.DhKeys, ts.Add(time.Hour))
+	if err == nil || !strings.Contains(err.Error(), expectedErr) {
+		t.Errorf("getCryptKey() failed to return the expected error."+
+			"\nexpected: %s\nreceived: %+v", expectedErr, err)
+	}
+}
diff --git a/groupChat/send.go b/groupChat/send.go
new file mode 100644
index 0000000000000000000000000000000000000000..f2baa0953555b13f335111af4d1dd7ae0e01731e
--- /dev/null
+++ b/groupChat/send.go
@@ -0,0 +1,222 @@
+///////////////////////////////////////////////////////////////////////////////
+// Copyright © 2020 xx network SEZC                                          //
+//                                                                           //
+// Use of this source code is governed by a license that can be found in the //
+// LICENSE file                                                              //
+///////////////////////////////////////////////////////////////////////////////
+
+package groupChat
+
+import (
+	"github.com/pkg/errors"
+	gs "gitlab.com/elixxir/client/groupChat/groupStore"
+	"gitlab.com/elixxir/client/interfaces/params"
+	"gitlab.com/elixxir/crypto/group"
+	"gitlab.com/elixxir/primitives/format"
+	"gitlab.com/xx_network/primitives/id"
+	"gitlab.com/xx_network/primitives/netTime"
+	"io"
+	"time"
+)
+
+// Error messages.
+const (
+	newCmixMsgErr     = "failed to generate cMix messages for group chat: %+v"
+	sendManyCmixErr   = "failed to send group chat message from member %s to group %s: %+v"
+	newCmixErr        = "failed to generate cMix message for member %d with ID %s in group %s: %+v"
+	messageLenErr     = "message length %d is greater than maximum message space %d"
+	newNoGroupErr     = "failed to create message for group %s that cannot be found"
+	newKeyErr         = "failed to generate key for encrypting group payload"
+	newPublicMsgErr   = "failed to create new public group message for cMix message: %+v"
+	newInternalMsgErr = "failed to create new internal group message for cMix message: %+v"
+	saltReadErr       = "failed to generate salt for group message: %+v"
+	saltReadLengthErr = "length of generated salt %d != %d required"
+)
+
+// Send sends a message to all group members using Client.SendManyCMIX. The
+// send fails if the message is too long.
+func (m *Manager) Send(groupID *id.ID, message []byte) (id.Round, error) {
+
+	// Create a cMix message for each group member
+	messages, err := m.createMessages(groupID, message)
+	if err != nil {
+		return 0, errors.Errorf(newCmixMsgErr, err)
+	}
+
+	rid, _, err := m.net.SendManyCMIX(messages, params.GetDefaultCMIX())
+	if err != nil {
+		return 0, errors.Errorf(sendManyCmixErr, m.gs.GetUser().ID, groupID, err)
+	}
+
+	return rid, nil
+}
+
+// createMessages generates a list of cMix messages and a list of corresponding
+// recipient IDs.
+func (m *Manager) createMessages(groupID *id.ID, msg []byte) (map[id.ID]format.Message, error) {
+	timeNow := netTime.Now()
+
+	g, exists := m.gs.Get(groupID)
+	if !exists {
+		return map[id.ID]format.Message{}, errors.Errorf(newNoGroupErr, groupID)
+	}
+
+	return m.newMessages(g, msg, timeNow)
+}
+
+// newMessages is a private function that allows the passing in of a timestamp
+// and streamGen instead of a fastRNG.StreamGenerator for easier testing.
+func (m *Manager) newMessages(g gs.Group, msg []byte,
+	timestamp time.Time) (map[id.ID]format.Message, error) {
+	// Create list of cMix messages
+	messages := make(map[id.ID]format.Message)
+
+	// Create channels to receive messages and errors on
+	type msgInfo struct {
+		msg format.Message
+		id  *id.ID
+	}
+	msgChan := make(chan msgInfo, len(g.Members)-1)
+	errChan := make(chan error, len(g.Members)-1)
+
+	// Create cMix messages in parallel
+	for i, member := range g.Members {
+		// Do not send to the sender
+		if m.gs.GetUser().ID.Cmp(member.ID) {
+			continue
+		}
+
+		// Start thread to build cMix message
+		go func(member group.Member, i int) {
+			// Create new stream
+			rng := m.rng.GetStream()
+			defer rng.Close()
+
+			// Add cMix message to list
+			cMixMsg, err := m.newCmixMsg(g, msg, timestamp, member, rng)
+			if err != nil {
+				errChan <- errors.Errorf(newCmixErr, i, member.ID, g.ID, err)
+			}
+			msgChan <- msgInfo{cMixMsg, member.ID}
+
+		}(member, i)
+	}
+
+	// Wait for messages or errors
+	for len(messages) < len(g.Members)-1 {
+		select {
+		case err := <-errChan:
+			// Return on the first error that occurs
+			return nil, err
+		case info := <-msgChan:
+			messages[*info.id] = info.msg
+		}
+	}
+
+	return messages, nil
+}
+
+// newCmixMsg generates a new cMix message to be sent to a group member.
+func (m *Manager) newCmixMsg(g gs.Group, msg []byte, timestamp time.Time,
+	mem group.Member, rng io.Reader) (format.Message, error) {
+
+	// Create three message layers
+	cmixMsg := format.NewMessage(m.store.Cmix().GetGroup().GetP().ByteLen())
+	publicMsg, internalMsg, err := newMessageParts(cmixMsg.ContentsSize())
+	if err != nil {
+		return cmixMsg, err
+	}
+
+	// Return an error if the message is too large to fit in the payload
+	if internalMsg.GetPayloadMaxSize() < len(msg) {
+		return cmixMsg, errors.Errorf(messageLenErr, len(msg),
+			internalMsg.GetPayloadMaxSize())
+	}
+
+	// Generate 256-bit salt
+	salt, err := newSalt(rng)
+	if err != nil {
+		return cmixMsg, err
+	}
+
+	// Generate key fingerprint
+	keyFp := group.NewKeyFingerprint(g.Key, salt, mem.ID)
+
+	// Generate key
+	key, err := group.NewKdfKey(g.Key, group.ComputeEpoch(timestamp), salt)
+	if err != nil {
+		return cmixMsg, errors.WithMessage(err, newKeyErr)
+	}
+
+	// Generate internal message
+	payload := setInternalPayload(internalMsg, timestamp, m.gs.GetUser().ID, msg)
+
+	// Encrypt internal message
+	encryptedPayload := group.Encrypt(key, keyFp, payload)
+
+	// Generate public message
+	publicPayload := setPublicPayload(publicMsg, salt, encryptedPayload)
+
+	// Generate MAC
+	mac := group.NewMAC(key, encryptedPayload, g.DhKeys[*mem.ID])
+
+	// Construct cMix message
+	cmixMsg.SetContents(publicPayload)
+	cmixMsg.SetKeyFP(keyFp)
+	cmixMsg.SetMac(mac)
+
+	return cmixMsg, nil
+}
+
+// newMessageParts generates a public payload message and the internal payload
+// message. An error is returned if the messages cannot fit in the payloadSize.
+func newMessageParts(payloadSize int) (publicMsg, internalMsg, error) {
+	publicMsg, err := newPublicMsg(payloadSize)
+	if err != nil {
+		return publicMsg, internalMsg{}, errors.Errorf(newPublicMsgErr, err)
+	}
+
+	internalMsg, err := newInternalMsg(publicMsg.GetPayloadSize())
+	if err != nil {
+		return publicMsg, internalMsg, errors.Errorf(newInternalMsgErr, err)
+	}
+
+	return publicMsg, internalMsg, nil
+}
+
+// newSalt generates a new salt of the specified size.
+func newSalt(rng io.Reader) ([group.SaltLen]byte, error) {
+	var salt [group.SaltLen]byte
+	n, err := rng.Read(salt[:])
+	if err != nil {
+		return salt, errors.Errorf(saltReadErr, err)
+	} else if n != group.SaltLen {
+		return salt, errors.Errorf(saltReadLengthErr, group.SaltLen, n)
+	}
+
+	return salt, nil
+}
+
+// setInternalPayload sets the timestamp, sender ID, and message of the
+// internalMsg and returns the marshal bytes.
+func setInternalPayload(internalMsg internalMsg, timestamp time.Time,
+	sender *id.ID, msg []byte) []byte {
+	// Set timestamp, sender ID, and message to the internalMsg
+	internalMsg.SetTimestamp(timestamp)
+	internalMsg.SetSenderID(sender)
+	internalMsg.SetPayload(msg)
+
+	// Return the payload marshaled
+	return internalMsg.Marshal()
+}
+
+// setPublicPayload sets the salt and encrypted payload of the publicMsg and
+// returns the marshal bytes.
+func setPublicPayload(publicMsg publicMsg, salt [group.SaltLen]byte,
+	encryptedPayload []byte) []byte {
+	// Set salt and payload
+	publicMsg.SetSalt(salt)
+	publicMsg.SetPayload(encryptedPayload)
+
+	return publicMsg.Marshal()
+}
diff --git a/groupChat/sendRequests.go b/groupChat/sendRequests.go
new file mode 100644
index 0000000000000000000000000000000000000000..3e4cc39f2995dbe870b4caa771cbd3a075c77eb7
--- /dev/null
+++ b/groupChat/sendRequests.go
@@ -0,0 +1,129 @@
+///////////////////////////////////////////////////////////////////////////////
+// Copyright © 2020 xx network SEZC                                          //
+//                                                                           //
+// Use of this source code is governed by a license that can be found in the //
+// LICENSE file                                                              //
+///////////////////////////////////////////////////////////////////////////////
+
+package groupChat
+
+import (
+	"github.com/golang/protobuf/proto"
+	"github.com/pkg/errors"
+	gs "gitlab.com/elixxir/client/groupChat/groupStore"
+	"gitlab.com/elixxir/client/interfaces/message"
+	"gitlab.com/elixxir/client/interfaces/params"
+	"gitlab.com/elixxir/crypto/group"
+	"gitlab.com/xx_network/primitives/id"
+	"strings"
+)
+
+// Error messages.
+const (
+	resendGroupIdErr      = "cannot resend request to nonexistent group with ID %s"
+	protoMarshalErr       = "failed to form outgoing group chat request: %+v"
+	sendE2eErr            = "failed to send group request via E2E to member %s: %+v"
+	sendRequestAllErr     = "failed to send all %d group request messages: %s"
+	sendRequestPartialErr = "failed to send %d/%d group request messages: %s"
+)
+
+// ResendRequest allows a groupChat request to be sent again.
+func (m Manager) ResendRequest(groupID *id.ID) ([]id.Round, RequestStatus, error) {
+	g, exists := m.gs.Get(groupID)
+	if !exists {
+		return nil, NotSent, errors.Errorf(resendGroupIdErr, groupID)
+	}
+
+	return m.sendRequests(g)
+}
+
+// sendRequests sends group requests to each member in the group except for the
+// leader/sender
+func (m Manager) sendRequests(g gs.Group) ([]id.Round, RequestStatus, error) {
+	// Build request message
+	requestMarshaled, err := proto.Marshal(&Request{
+		Name:        g.Name,
+		IdPreimage:  g.IdPreimage.Bytes(),
+		KeyPreimage: g.KeyPreimage.Bytes(),
+		Members:     g.Members.Serialize(),
+		Message:     g.InitMessage,
+	})
+	if err != nil {
+		return nil, NotSent, errors.Errorf(protoMarshalErr, err)
+	}
+
+	// Create channel to return the results of each send on
+	n := len(g.Members) - 1
+	type sendResults struct {
+		rounds []id.Round
+		err    error
+	}
+	resultsChan := make(chan sendResults, n)
+
+	// Send request to each member in the group except the leader/sender
+	for _, member := range g.Members[1:] {
+		go func(member group.Member) {
+			rounds, err := m.sendRequest(member.ID, requestMarshaled)
+			resultsChan <- sendResults{rounds, err}
+		}(member)
+	}
+
+	// Block until each send returns
+	roundIDs := make(map[id.Round]struct{})
+	var errs []string
+	for i := 0; i < n; {
+		select {
+		case results := <-resultsChan:
+			for _, rid := range results.rounds {
+				roundIDs[rid] = struct{}{}
+			}
+			if results.err != nil {
+				errs = append(errs, results.err.Error())
+			}
+			i++
+		}
+	}
+
+	// If all sends returned an error, then return AllFail with a list of errors
+	if len(errs) == n {
+		return nil, AllFail,
+			errors.Errorf(sendRequestAllErr, len(errs), strings.Join(errs, "\n"))
+	}
+
+	// If some sends returned an error, then return a list of round IDs for the
+	// successful sends and a list of errors for the failed ones
+	if len(errs) > 0 {
+		return roundIdMap2List(roundIDs), PartialSent,
+			errors.Errorf(sendRequestPartialErr, len(errs), n,
+				strings.Join(errs, "\n"))
+	}
+
+	// If all sends succeeded, return a list of roundIDs
+	return roundIdMap2List(roundIDs), AllSent, nil
+}
+
+// sendRequest sends the group request to the user via E2E.
+func (m Manager) sendRequest(memberID *id.ID, request []byte) ([]id.Round, error) {
+	sendMsg := message.Send{
+		Recipient:   memberID,
+		Payload:     request,
+		MessageType: message.GroupCreationRequest,
+	}
+
+	rounds, _, err := m.net.SendE2E(sendMsg, params.GetDefaultE2E(), nil)
+	if err != nil {
+		return nil, errors.Errorf(sendE2eErr, memberID, err)
+	}
+
+	return rounds, nil
+}
+
+// roundIdMap2List converts the map of round IDs to a list of round IDs.
+func roundIdMap2List(m map[id.Round]struct{}) []id.Round {
+	roundIDs := make([]id.Round, 0, len(m))
+	for rid := range m {
+		roundIDs = append(roundIDs, rid)
+	}
+
+	return roundIDs
+}
diff --git a/groupChat/sendRequests_test.go b/groupChat/sendRequests_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..56ca284fbc66cb78622388bd11d0454db3280bce
--- /dev/null
+++ b/groupChat/sendRequests_test.go
@@ -0,0 +1,274 @@
+///////////////////////////////////////////////////////////////////////////////
+// Copyright © 2020 xx network SEZC                                          //
+//                                                                           //
+// Use of this source code is governed by a license that can be found in the //
+// LICENSE file                                                              //
+///////////////////////////////////////////////////////////////////////////////
+
+package groupChat
+
+import (
+	"fmt"
+	"github.com/golang/protobuf/proto"
+	"gitlab.com/elixxir/client/interfaces/message"
+	"gitlab.com/xx_network/primitives/id"
+	"math/rand"
+	"reflect"
+	"sort"
+	"strings"
+	"testing"
+)
+
+// Tests that Manager.ResendRequest sends all expected requests successfully.
+func TestManager_ResendRequest(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	m, g := newTestManagerWithStore(prng, 10, 0, nil, nil, t)
+
+	expected := &Request{
+		Name:        g.Name,
+		IdPreimage:  g.IdPreimage.Bytes(),
+		KeyPreimage: g.KeyPreimage.Bytes(),
+		Members:     g.Members.Serialize(),
+		Message:     g.InitMessage,
+	}
+
+	_, status, err := m.ResendRequest(g.ID)
+	if err != nil {
+		t.Errorf("ResendRequest() returned an error: %+v", err)
+	}
+
+	if status != AllSent {
+		t.Errorf("ResendRequest() failed to return the expected status."+
+			"\nexpected: %s\nreceived: %s", AllSent, status)
+	}
+
+	if len(m.net.(*testNetworkManager).e2eMessages) < len(g.Members)-1 {
+		t.Errorf("ResendRequest() failed to send the correct number of requests."+
+			"\nexpected: %d\nreceived: %d", len(g.Members)-1,
+			len(m.net.(*testNetworkManager).e2eMessages))
+	}
+
+	for i := 0; i < len(m.net.(*testNetworkManager).e2eMessages); i++ {
+		msg := m.net.(*testNetworkManager).GetE2eMsg(i)
+
+		// Check if the message recipient is a member in the group
+		matchesMember := false
+		for j, m := range g.Members {
+			if msg.Recipient.Cmp(m.ID) {
+				matchesMember = true
+				g.Members = append(g.Members[:j], g.Members[j+1:]...)
+				break
+			}
+		}
+		if !matchesMember {
+			t.Errorf("Message %d has recipient ID %s that is not in membership.",
+				i, msg.Recipient)
+		}
+
+		testRequest := &Request{}
+		err = proto.Unmarshal(msg.Payload, testRequest)
+		if err != nil {
+			t.Errorf("Failed to unmarshal proto message (%d): %+v", i, err)
+		}
+
+		if expected.String() != testRequest.String() {
+			t.Errorf("Message %d has unexpected payload."+
+				"\nexpected: %s\nreceived: %s", i, expected, testRequest)
+		}
+	}
+}
+
+// Error path: an error is returned when no group with the corresponding group
+// ID exists.
+func TestManager_ResendRequest_GetGroupError(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	m, _ := newTestManagerWithStore(prng, 10, 0, nil, nil, t)
+	expectedErr := strings.SplitN(resendGroupIdErr, "%", 2)[0]
+
+	_, status, err := m.ResendRequest(id.NewIdFromString("invalidID", id.Group, t))
+	if err == nil || !strings.Contains(err.Error(), expectedErr) {
+		t.Errorf("ResendRequest() failed to return the expected error."+
+			"\nexpected: %s\nreceived: %+v", expectedErr, err)
+	}
+
+	if status != NotSent {
+		t.Errorf("ResendRequest() failed to return the expected status."+
+			"\nexpected: %s\nreceived: %s", NotSent, status)
+	}
+}
+
+// Tests that Manager.sendRequests sends all expected requests successfully.
+func TestManager_sendRequests(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	m, g := newTestManagerWithStore(prng, 10, 0, nil, nil, t)
+
+	expected := &Request{
+		Name:        g.Name,
+		IdPreimage:  g.IdPreimage.Bytes(),
+		KeyPreimage: g.KeyPreimage.Bytes(),
+		Members:     g.Members.Serialize(),
+		Message:     g.InitMessage,
+	}
+
+	_, status, err := m.sendRequests(g)
+	if err != nil {
+		t.Errorf("sendRequests() returned an error: %+v", err)
+	}
+
+	if status != AllSent {
+		t.Errorf("sendRequests() failed to return the expected status."+
+			"\nexpected: %s\nreceived: %s", AllSent, status)
+	}
+
+	if len(m.net.(*testNetworkManager).e2eMessages) < len(g.Members)-1 {
+		t.Errorf("sendRequests() failed to send the correct number of requests."+
+			"\nexpected: %d\nreceived: %d", len(g.Members)-1,
+			len(m.net.(*testNetworkManager).e2eMessages))
+	}
+
+	for i := 0; i < len(m.net.(*testNetworkManager).e2eMessages); i++ {
+		msg := m.net.(*testNetworkManager).GetE2eMsg(i)
+
+		// Check if the message recipient is a member in the group
+		matchesMember := false
+		for j, m := range g.Members {
+			if msg.Recipient.Cmp(m.ID) {
+				matchesMember = true
+				g.Members = append(g.Members[:j], g.Members[j+1:]...)
+				break
+			}
+		}
+		if !matchesMember {
+			t.Errorf("Message %d has recipient ID %s that is not in membership.",
+				i, msg.Recipient)
+		}
+
+		testRequest := &Request{}
+		err = proto.Unmarshal(msg.Payload, testRequest)
+		if err != nil {
+			t.Errorf("Failed to unmarshal proto message (%d): %+v", i, err)
+		}
+
+		if expected.String() != testRequest.String() {
+			t.Errorf("Message %d has unexpected payload."+
+				"\nexpected: %s\nreceived: %s", i, expected, testRequest)
+		}
+	}
+}
+
+// Tests that Manager.sendRequests returns the correct status when all sends
+// fail.
+func TestManager_sendRequests_SendAllFail(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	m, g := newTestManagerWithStore(prng, 10, 1, nil, nil, t)
+	expectedErr := fmt.Sprintf(sendRequestAllErr, len(g.Members)-1, "")
+
+	rounds, status, err := m.sendRequests(g)
+	if err == nil || !strings.Contains(err.Error(), expectedErr) {
+		t.Errorf("sendRequests() failed to return the expected error."+
+			"\nexpected: %s\nreceived: %+v", expectedErr, err)
+	}
+
+	if status != AllFail {
+		t.Errorf("sendRequests() failed to return the expected status."+
+			"\nexpected: %s\nreceived: %s", AllFail, status)
+	}
+
+	if rounds != nil {
+		t.Errorf("sendRequests() returned rounds on failure."+
+			"\nexpected: %v\nreceived: %v", nil, rounds)
+	}
+
+	if len(m.net.(*testNetworkManager).e2eMessages) != 0 {
+		t.Errorf("sendRequests() sent %d messages when sending should have failed.",
+			len(m.net.(*testNetworkManager).e2eMessages))
+	}
+}
+
+// Tests that Manager.sendRequests returns the correct status when some of the
+// sends fail.
+func TestManager_sendRequests_SendPartialSent(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	m, g := newTestManagerWithStore(prng, 10, 2, nil, nil, t)
+	expectedErr := fmt.Sprintf(sendRequestPartialErr, (len(g.Members)-1)/2,
+		len(g.Members)-1, "")
+
+	_, status, err := m.sendRequests(g)
+	if err == nil || !strings.Contains(err.Error(), expectedErr) {
+		t.Errorf("sendRequests() failed to return the expected error."+
+			"\nexpected: %s\nreceived: %+v", expectedErr, err)
+	}
+
+	if status != PartialSent {
+		t.Errorf("sendRequests() failed to return the expected status."+
+			"\nexpected: %s\nreceived: %s", PartialSent, status)
+	}
+
+	if len(m.net.(*testNetworkManager).e2eMessages) != (len(g.Members)-1)/2+1 {
+		t.Errorf("sendRequests() sent %d out of %d expected messages.",
+			len(m.net.(*testNetworkManager).e2eMessages), (len(g.Members)-1)/2+1)
+	}
+}
+
+// Unit test of Manager.sendRequest.
+func TestManager_sendRequest(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	m, g := newTestManagerWithStore(prng, 10, 0, nil, nil, t)
+
+	expected := message.Send{
+		Recipient:   g.Members[0].ID,
+		Payload:     []byte("request message"),
+		MessageType: message.GroupCreationRequest,
+	}
+	_, err := m.sendRequest(expected.Recipient, expected.Payload)
+	if err != nil {
+		t.Errorf("sendRequest() returned an error: %+v", err)
+	}
+
+	received := m.net.(*testNetworkManager).GetE2eMsg(0)
+
+	if !reflect.DeepEqual(expected, received) {
+		t.Errorf("sendRequest() did not send the correct message."+
+			"\nexpected: %+v\nreceived: %+v", expected, received)
+	}
+}
+
+// Error path: an error is returned when SendE2E fails
+func TestManager_sendRequest_SendE2eError(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	m, _ := newTestManagerWithStore(prng, 10, 1, nil, nil, t)
+	expectedErr := strings.SplitN(sendE2eErr, "%", 2)[0]
+
+	_, err := m.sendRequest(id.NewIdFromString("memberID", id.User, t), nil)
+	if err == nil || !strings.Contains(err.Error(), expectedErr) {
+		t.Errorf("sendRequest() failed to return the expected error."+
+			"\nexpected: %s\nreceived: %+v", expectedErr, err)
+	}
+}
+
+// Unit test of roundIdMap2List.
+func Test_roundIdMap2List(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+
+	// Construct map and expected list
+	n := 100
+	expected := make([]id.Round, n)
+	ridMap := make(map[id.Round]struct{}, n)
+	for i := 0; i < n; i++ {
+		expected[i] = id.Round(prng.Uint64())
+		ridMap[expected[i]] = struct{}{}
+	}
+
+	// Create list of IDs from map
+	ridList := roundIdMap2List(ridMap)
+
+	// Sort expected and received slices to see if they match
+	sort.Slice(expected, func(i, j int) bool { return expected[i] < expected[j] })
+	sort.Slice(ridList, func(i, j int) bool { return ridList[i] < ridList[j] })
+
+	if !reflect.DeepEqual(expected, ridList) {
+		t.Errorf("roundIdMap2List() failed to return the expected list."+
+			"\nexpected: %v\nreceived: %v", expected, ridList)
+	}
+
+}
diff --git a/groupChat/send_test.go b/groupChat/send_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..1db5cec791e7790bf519ab98030a088f423b076e
--- /dev/null
+++ b/groupChat/send_test.go
@@ -0,0 +1,557 @@
+///////////////////////////////////////////////////////////////////////////////
+// Copyright © 2020 xx network SEZC                                          //
+//                                                                           //
+// Use of this source code is governed by a license that can be found in the //
+// LICENSE file                                                              //
+///////////////////////////////////////////////////////////////////////////////
+
+package groupChat
+
+import (
+	"bytes"
+	"encoding/base64"
+	gs "gitlab.com/elixxir/client/groupChat/groupStore"
+	"gitlab.com/elixxir/client/storage"
+	"gitlab.com/elixxir/crypto/group"
+	"gitlab.com/elixxir/primitives/format"
+	"gitlab.com/xx_network/primitives/id"
+	"gitlab.com/xx_network/primitives/netTime"
+	"math/rand"
+	"strings"
+	"testing"
+	"time"
+)
+
+// Unit test of Manager.Send.
+func TestManager_Send(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	m, g := newTestManagerWithStore(prng, 10, 0, nil, nil, t)
+	message := []byte("Group chat message.")
+	sender := m.gs.GetUser().DeepCopy()
+
+	_, err := m.Send(g.ID, message)
+	if err != nil {
+		t.Errorf("Send() returned an error: %+v", err)
+	}
+
+	// Get messages sent with or return an error if no messages were sent
+	var messages map[id.ID]format.Message
+	if len(m.net.(*testNetworkManager).messages) > 0 {
+		messages = m.net.(*testNetworkManager).GetMsgMap(0)
+	} else {
+		t.Error("No group cMix messages received.")
+	}
+
+	timeNow := netTime.Now()
+
+	// Loop through each message and make sure the recipient ID matches a member
+	// in the group and that each message can be decrypted and have the expected
+	// values
+	for rid, msg := range messages {
+		// Check if recipient ID is in member list
+		var foundMember group.Member
+		for _, mem := range g.Members {
+			if rid.Cmp(mem.ID) {
+				foundMember = mem
+			}
+		}
+
+		// Error if the recipient ID is not found in the member list
+		if foundMember == (group.Member{}) {
+			t.Errorf("Failed to find ID %s in memorship list.", rid)
+			continue
+		}
+
+		publicMsg, err := unmarshalPublicMsg(msg.GetContents())
+		if err != nil {
+			t.Errorf("Failed to unmarshal publicMsg: %+v", err)
+		}
+		// Attempt to read the message
+		messageID, timestamp, senderID, readMsg, err := m.decryptMessage(
+			g, msg, publicMsg, timeNow)
+		if err != nil {
+			t.Errorf("Failed to read message for %s: %+v", rid.String(), err)
+		}
+
+		internalMsg, _ := newInternalMsg(publicMsg.GetPayloadSize())
+		internalMsg.SetTimestamp(timestamp)
+		internalMsg.SetSenderID(m.gs.GetUser().ID)
+		internalMsg.SetPayload(message)
+		expectedMsgID := group.NewMessageID(g.ID, internalMsg.Marshal())
+
+		if expectedMsgID != messageID {
+			t.Errorf("Message ID received for %s too different from expected."+
+				"\nexpected: %s\nreceived: %s", &rid, expectedMsgID, messageID)
+		}
+
+		if !timestamp.Round(5 * time.Second).Equal(timeNow.Round(5 * time.Second)) {
+			t.Errorf("Timestamp received for %s too different from expected."+
+				"\nexpected: %s\nreceived: %s", &rid, timeNow, timestamp)
+		}
+
+		if !senderID.Cmp(sender.ID) {
+			t.Errorf("Sender ID received for %s incorrect."+
+				"\nexpected: %s\nreceived: %s", &rid, sender.ID, senderID)
+		}
+
+		if !bytes.Equal(readMsg, message) {
+			t.Errorf("Message received for %s incorrect."+
+				"\nexpected: %q\nreceived: %q", &rid, message, readMsg)
+		}
+	}
+}
+
+// Error path: error is returned when the message is too large.
+func TestManager_Send_CmixMessageError(t *testing.T) {
+	// Set up new test manager that will make SendManyCMIX error
+	prng := rand.New(rand.NewSource(42))
+	m, g := newTestManagerWithStore(prng, 10, 0, nil, nil, t)
+	expectedErr := strings.SplitN(newCmixMsgErr, "%", 2)[0]
+
+	// Send message
+	_, err := m.Send(g.ID, make([]byte, 400))
+	if err == nil || !strings.Contains(err.Error(), expectedErr) {
+		t.Errorf("Send() failed to return the expected error."+
+			"\nexpected: %s\nreceived: %+v", expectedErr, err)
+	}
+}
+
+// Error path: SendManyCMIX returns an error.
+func TestManager_Send_SendManyCMIXError(t *testing.T) {
+	// Set up new test manager that will make SendManyCMIX error
+	prng := rand.New(rand.NewSource(42))
+	m, g := newTestManagerWithStore(prng, 10, 1, nil, nil, t)
+	expectedErr := strings.SplitN(sendManyCmixErr, "%", 2)[0]
+
+	// Send message
+	_, err := m.Send(g.ID, []byte("message"))
+	if err == nil || !strings.Contains(err.Error(), expectedErr) {
+		t.Errorf("Send() failed to return the expected error."+
+			"\nexpected: %s\nreceived: %+v", expectedErr, err)
+	}
+
+	// If messages were added, then error
+	if len(m.net.(*testNetworkManager).messages) > 0 {
+		t.Error("Group cMix messages received when SendManyCMIX errors.")
+	}
+}
+
+// Tests that Manager.createMessages generates the messages for the correct group.
+func TestManager_createMessages(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	m, g := newTestManagerWithStore(prng, 10, 0, nil, nil, t)
+
+	message := []byte("Test group message.")
+	sender := m.gs.GetUser()
+	messages, err := m.createMessages(g.ID, message)
+	if err != nil {
+		t.Errorf("createMessages() returned an error: %+v", err)
+	}
+
+	recipients := append(g.Members[:2], g.Members[3:]...)
+
+	i := 0
+	for rid, msg := range messages {
+		for _, recipient := range recipients {
+			if !rid.Cmp(recipient.ID) {
+				continue
+			}
+
+			publicMsg, err := unmarshalPublicMsg(msg.GetContents())
+			if err != nil {
+				t.Errorf("Failed to unmarshal publicMsg: %+v", err)
+			}
+
+			messageID, timestamp, testSender, testMessage, err := m.decryptMessage(
+				g, msg, publicMsg, netTime.Now())
+			if err != nil {
+				t.Errorf("Failed to find member to read message %d: %+v", i, err)
+			}
+
+			internalMsg, _ := newInternalMsg(publicMsg.GetPayloadSize())
+			internalMsg.SetTimestamp(timestamp)
+			internalMsg.SetSenderID(m.gs.GetUser().ID)
+			internalMsg.SetPayload(message)
+			expectedMsgID := group.NewMessageID(g.ID, internalMsg.Marshal())
+
+			if messageID != expectedMsgID {
+				t.Errorf("Failed to read correct message ID for message %d."+
+					"\nexpected: %s\nreceived: %s", i, expectedMsgID, messageID)
+			}
+
+			if !sender.ID.Cmp(testSender) {
+				t.Errorf("Failed to read correct sender ID for message %d."+
+					"\nexpected: %s\nreceived: %s", i, sender.ID, testSender)
+			}
+
+			if !bytes.Equal(message, testMessage) {
+				t.Errorf("Failed to read correct message for message %d."+
+					"\nexpected: %s\nreceived: %s", i, message, testMessage)
+			}
+		}
+		i++
+	}
+}
+
+// Error path: test that an error is returned when the group ID does not match a
+// group in storage.
+func TestManager_createMessages_InvalidGroupIdError(t *testing.T) {
+	expectedErr := strings.SplitN(newNoGroupErr, "%", 2)[0]
+
+	// Create new test Manager and Group
+	prng := rand.New(rand.NewSource(42))
+	m, _ := newTestManagerWithStore(prng, 10, 0, nil, nil, t)
+
+	// Read message and make sure the error is expected
+	_, err := m.createMessages(id.NewIdFromString("invalidID", id.Group, t), nil)
+	if err == nil || !strings.Contains(err.Error(), expectedErr) {
+		t.Errorf("createMessages() did not return the expected error."+
+			"\nexpected: %s\nreceived: %+v", expectedErr, err)
+	}
+}
+
+// Tests that Manager.newMessage returns messages with correct data.
+func TestGroup_newMessages(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	m, g := newTestManager(prng, t)
+
+	message := []byte("Test group message.")
+	sender := m.gs.GetUser()
+	timestamp := netTime.Now()
+	messages, err := m.newMessages(g, message, timestamp)
+	if err != nil {
+		t.Errorf("newMessages() returned an error: %+v", err)
+	}
+
+	recipients := append(g.Members[:2], g.Members[3:]...)
+
+	i := 0
+	for rid, msg := range messages {
+		for _, recipient := range recipients {
+			if !rid.Cmp(recipient.ID) {
+				continue
+			}
+
+			publicMsg, err := unmarshalPublicMsg(msg.GetContents())
+			if err != nil {
+				t.Errorf("Failed to unmarshal publicMsg: %+v", err)
+			}
+
+			messageID, testTimestamp, testSender, testMessage, err := m.decryptMessage(
+				g, msg, publicMsg, netTime.Now())
+			if err != nil {
+				t.Errorf("Failed to find member to read message %d.", i)
+			}
+
+			internalMsg, _ := newInternalMsg(publicMsg.GetPayloadSize())
+			internalMsg.SetTimestamp(timestamp)
+			internalMsg.SetSenderID(m.gs.GetUser().ID)
+			internalMsg.SetPayload(message)
+			expectedMsgID := group.NewMessageID(g.ID, internalMsg.Marshal())
+
+			if messageID != expectedMsgID {
+				t.Errorf("Failed to read correct message ID for message %d."+
+					"\nexpected: %s\nreceived: %s", i, expectedMsgID, messageID)
+			}
+
+			if !timestamp.Equal(testTimestamp) {
+				t.Errorf("Failed to read correct timeout for message %d."+
+					"\nexpected: %s\nreceived: %s", i, timestamp, testTimestamp)
+			}
+
+			if !sender.ID.Cmp(testSender) {
+				t.Errorf("Failed to read correct sender ID for message %d."+
+					"\nexpected: %s\nreceived: %s", i, sender.ID, testSender)
+			}
+
+			if !bytes.Equal(message, testMessage) {
+				t.Errorf("Failed to read correct message for message %d."+
+					"\nexpected: %s\nreceived: %s", i, message, testMessage)
+			}
+		}
+		i++
+	}
+}
+
+// Error path: an error is returned when Manager.neCmixMsg returns an error.
+func TestGroup_newMessages_NewCmixMsgError(t *testing.T) {
+	expectedErr := strings.SplitN(newCmixErr, "%", 2)[0]
+	prng := rand.New(rand.NewSource(42))
+	m, g := newTestManager(prng, t)
+
+	_, err := m.newMessages(g, make([]byte, 1000), netTime.Now())
+	if err == nil || !strings.Contains(err.Error(), expectedErr) {
+		t.Errorf("newMessages() failed to return the expected error."+
+			"\nexpected: %s\nreceived: %+v", expectedErr, err)
+	}
+}
+
+// Tests that the message returned by newCmixMsg has all the expected parts.
+func TestGroup_newCmixMsg(t *testing.T) {
+	// Create new test Manager and Group
+	prng := rand.New(rand.NewSource(42))
+	m, g := newTestManager(prng, t)
+
+	// Create test parameters
+	message := []byte("Test group message.")
+	mem := g.Members[3]
+	timeNow := netTime.Now()
+
+	// Create cMix message
+	prng = rand.New(rand.NewSource(42))
+	msg, err := m.newCmixMsg(g, message, timeNow, mem, prng)
+	if err != nil {
+		t.Errorf("newCmixMsg() returned an error: %+v", err)
+	}
+
+	// Create expected salt
+	prng = rand.New(rand.NewSource(42))
+	var salt [group.SaltLen]byte
+	prng.Read(salt[:])
+
+	// Create expected key
+	key, _ := group.NewKdfKey(g.Key, group.ComputeEpoch(timeNow), salt)
+
+	// Create expected messages
+	cmixMsg := format.NewMessage(m.store.Cmix().GetGroup().GetP().ByteLen())
+	publicMsg, _ := newPublicMsg(cmixMsg.ContentsSize())
+	internalMsg, _ := newInternalMsg(publicMsg.GetPayloadSize())
+	internalMsg.SetTimestamp(timeNow)
+	internalMsg.SetSenderID(m.gs.GetUser().ID)
+	internalMsg.SetPayload(message)
+	payload := internalMsg.Marshal()
+
+	// Check if key fingerprint is correct
+	expectedFp := group.NewKeyFingerprint(g.Key, salt, mem.ID)
+	if expectedFp != msg.GetKeyFP() {
+		t.Errorf("newCmixMsg() returned message with wrong key fingerprint."+
+			"\nexpected: %s\nreceived: %s", expectedFp, msg.GetKeyFP())
+	}
+
+	// Check if key MAC is correct
+	encryptedPayload := group.Encrypt(key, expectedFp, payload)
+	expectedMAC := group.NewMAC(key, encryptedPayload, g.DhKeys[*mem.ID])
+	if !bytes.Equal(expectedMAC, msg.GetMac()) {
+		t.Errorf("newCmixMsg() returned message with wrong MAC."+
+			"\nexpected: %+v\nreceived: %+v", expectedMAC, msg.GetMac())
+	}
+
+	// Attempt to unmarshal public group message
+	publicMsg, err = unmarshalPublicMsg(msg.GetContents())
+	if err != nil {
+		t.Errorf("Failed to unmarshal cMix message contents: %+v", err)
+	}
+
+	// Attempt to decrypt payload
+	decryptedPayload := group.Decrypt(key, expectedFp, publicMsg.GetPayload())
+	internalMsg, err = unmarshalInternalMsg(decryptedPayload)
+	if err != nil {
+		t.Errorf("Failed to unmarshal decrypted payload contents: %+v", err)
+	}
+
+	// Check for expected values in internal message
+	if !internalMsg.GetTimestamp().Equal(timeNow) {
+		t.Errorf("Internal message has wrong timestamp."+
+			"\nexpected: %s\nreceived: %s", timeNow, internalMsg.GetTimestamp())
+	}
+	sid, err := internalMsg.GetSenderID()
+	if err != nil {
+		t.Fatalf("Failed to get sender ID from internal message: %+v", err)
+	}
+	if !sid.Cmp(m.gs.GetUser().ID) {
+		t.Errorf("Internal message has wrong sender ID."+
+			"\nexpected: %s\nreceived: %s", m.gs.GetUser().ID, sid)
+	}
+	if !bytes.Equal(internalMsg.GetPayload(), message) {
+		t.Errorf("Internal message has wrong payload."+
+			"\nexpected: %s\nreceived: %s", message, internalMsg.GetPayload())
+	}
+}
+
+// Error path: reader returns an error.
+func TestGroup_newCmixMsg_SaltReaderError(t *testing.T) {
+	expectedErr := strings.SplitN(saltReadErr, "%", 2)[0]
+	m := &Manager{store: storage.InitTestingSession(t)}
+
+	_, err := m.newCmixMsg(gs.Group{}, []byte{}, time.Time{}, group.Member{}, strings.NewReader(""))
+	if err == nil || !strings.Contains(err.Error(), expectedErr) {
+		t.Errorf("newCmixMsg() failed to return the expected error"+
+			"\nexpected: %s\nreceived: %+v", expectedErr, err)
+	}
+}
+
+// Error path: size of message is too large for the internalMsg.
+func TestGroup_newCmixMsg_InternalMsgSizeError(t *testing.T) {
+	expectedErr := strings.SplitN(messageLenErr, "%", 2)[0]
+
+	// Create new test Manager and Group
+	prng := rand.New(rand.NewSource(42))
+	m, g := newTestManager(prng, t)
+
+	// Create test parameters
+	message := make([]byte, 341)
+	mem := group.Member{ID: id.NewIdFromString("memberID", id.User, t)}
+
+	// Create cMix message
+	prng = rand.New(rand.NewSource(42))
+	_, err := m.newCmixMsg(g, message, netTime.Now(), mem, prng)
+	if err == nil || !strings.Contains(err.Error(), expectedErr) {
+		t.Errorf("newCmixMsg() failed to return the expected error"+
+			"\nexpected: %s\nreceived: %+v", expectedErr, err)
+	}
+}
+
+// Error path: payload size too small to fit publicMsg.
+func Test_newMessageParts_PublicMsgSizeErr(t *testing.T) {
+	expectedErr := strings.SplitN(newPublicMsgErr, "%", 2)[0]
+
+	_, _, err := newMessageParts(publicMinLen - 1)
+	if err == nil || !strings.Contains(err.Error(), expectedErr) {
+		t.Errorf("newMessageParts() did not return the expected error."+
+			"\nexpected: %s\nreceived: %+v", expectedErr, err)
+	}
+}
+
+// Error path: payload size too small to fit internalMsg.
+func Test_newMessageParts_InternalMsgSizeErr(t *testing.T) {
+	expectedErr := strings.SplitN(newInternalMsgErr, "%", 2)[0]
+
+	_, _, err := newMessageParts(publicMinLen)
+	if err == nil || !strings.Contains(err.Error(), expectedErr) {
+		t.Errorf("newMessageParts() did not return the expected error."+
+			"\nexpected: %s\nreceived: %+v", expectedErr, err)
+	}
+}
+
+// Tests the consistency of newSalt.
+func Test_newSalt_Consistency(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	expectedSalts := []string{
+		"U4x/lrFkvxuXu59LtHLon1sUhPJSCcnZND6SugndnVI=",
+		"39ebTXZCm2F6DJ+fDTulWwzA1hRMiIU1hBrL4HCbB1g=",
+		"CD9h03W8ArQd9PkZKeGP2p5vguVOdI6B555LvW/jTNw=",
+		"uoQ+6NY+jE/+HOvqVG2PrBPdGqwEzi6ih3xVec+ix44=",
+		"GwuvrogbgqdREIpC7TyQPKpDRlp4YgYWl4rtDOPGxPM=",
+		"rnvD4ElbVxL+/b4MECiH4QDazS2IX2kstgfaAKEcHHA=",
+		"ceeWotwtwlpbdLLhKXBeJz8FySMmgo4rBW44F2WOEGE=",
+		"SYlH/fNEQQ7UwRYCP6jjV2tv7Sf/iXS6wMr9mtBWkrE=",
+		"NhnnOJZN/ceejVNDc2Yc/WbXT+weG4lJGrcjbkt1IWI=",
+	}
+
+	for i, expected := range expectedSalts {
+		salt, err := newSalt(prng)
+		if err != nil {
+			t.Errorf("newSalt() returned an error (%d): %+v", i, err)
+		}
+
+		saltString := base64.StdEncoding.EncodeToString(salt[:])
+
+		if expected != saltString {
+			t.Errorf("newSalt() did not return the expected salt (%d)."+
+				"\nexpected: %s\nreceived: %s", i, expected, saltString)
+		}
+
+		// fmt.Printf("\"%s\",\n", saltString)
+	}
+}
+
+// Error path: reader returns an error.
+func Test_newSalt_ReadError(t *testing.T) {
+	expectedErr := strings.SplitN(saltReadErr, "%", 2)[0]
+
+	_, err := newSalt(strings.NewReader(""))
+	if err == nil || !strings.Contains(err.Error(), expectedErr) {
+		t.Errorf("newSalt() failed to return the expected error"+
+			"\nexpected: %s\nreceived: %+v", expectedErr, err)
+	}
+}
+
+// Error path: reader fails to return enough bytes.
+func Test_newSalt_ReadLengthError(t *testing.T) {
+	expectedErr := strings.SplitN(saltReadLengthErr, "%", 2)[0]
+
+	_, err := newSalt(strings.NewReader("A"))
+	if err == nil || !strings.Contains(err.Error(), expectedErr) {
+		t.Errorf("newSalt() failed to return the expected error"+
+			"\nexpected: %s\nreceived: %+v", expectedErr, err)
+	}
+}
+
+// Tests that the marshaled internalMsg can be unmarshaled and has all the
+// original values.
+func Test_setInternalPayload(t *testing.T) {
+	internalMsg, err := newInternalMsg(internalMinLen * 2)
+	if err != nil {
+		t.Errorf("Failed to create a new internalMsg: %+v", err)
+	}
+
+	timestamp := netTime.Now()
+	sender := id.NewIdFromString("sender ID", id.User, t)
+	message := []byte("This is an internal message.")
+
+	payload := setInternalPayload(internalMsg, timestamp, sender, message)
+	if err != nil {
+		t.Errorf("setInternalPayload() returned an error: %+v", err)
+	}
+
+	// Attempt to unmarshal and check all values
+	unmarshalled, err := unmarshalInternalMsg(payload)
+	if err != nil {
+		t.Errorf("Failed to unmarshal internalMsg: %+v", err)
+	}
+
+	if !timestamp.Equal(unmarshalled.GetTimestamp()) {
+		t.Errorf("Timestamp does not match original.\nexpected: %s\nreceived: %s",
+			timestamp, unmarshalled.GetTimestamp())
+	}
+
+	testSender, err := unmarshalled.GetSenderID()
+	if err != nil {
+		t.Errorf("Failed to get sender ID: %+v", err)
+	}
+	if !sender.Cmp(testSender) {
+		t.Errorf("Sender ID does not match original.\nexpected: %s\nreceived: %s",
+			sender, testSender)
+	}
+
+	if !bytes.Equal(message, unmarshalled.GetPayload()) {
+		t.Errorf("Payload does not match original.\nexpected: %v\nreceived: %v",
+			message, unmarshalled.GetPayload())
+	}
+}
+
+// Tests that the marshaled publicMsg can be unmarshaled and has all the
+// original values.
+func Test_setPublicPayload(t *testing.T) {
+	prng := rand.New(rand.NewSource(42))
+	publicMsg, err := newPublicMsg(publicMinLen * 2)
+	if err != nil {
+		t.Errorf("Failed to create a new publicMsg: %+v", err)
+	}
+
+	var salt [group.SaltLen]byte
+	prng.Read(salt[:])
+	encryptedPayload := make([]byte, publicMsg.GetPayloadSize())
+	copy(encryptedPayload, "This is an internal message.")
+
+	payload := setPublicPayload(publicMsg, salt, encryptedPayload)
+	if err != nil {
+		t.Errorf("setPublicPayload() returned an error: %+v", err)
+	}
+
+	// Attempt to unmarshal and check all values
+	unmarshalled, err := unmarshalPublicMsg(payload)
+	if err != nil {
+		t.Errorf("Failed to unmarshal publicMsg: %+v", err)
+	}
+
+	if salt != unmarshalled.GetSalt() {
+		t.Errorf("Salt does not match original.\nexpected: %v\nreceived: %v",
+			salt, unmarshalled.GetSalt())
+	}
+
+	if !bytes.Equal(encryptedPayload, unmarshalled.GetPayload()) {
+		t.Errorf("Payload does not match original.\nexpected: %v\nreceived: %v",
+			encryptedPayload, unmarshalled.GetPayload())
+	}
+}
diff --git a/groupChat/utils_test.go b/groupChat/utils_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..eac797af004142994c37598337b4b98d79299b75
--- /dev/null
+++ b/groupChat/utils_test.go
@@ -0,0 +1,334 @@
+///////////////////////////////////////////////////////////////////////////////
+// Copyright © 2020 xx network SEZC                                          //
+//                                                                           //
+// Use of this source code is governed by a license that can be found in the //
+// LICENSE file                                                              //
+///////////////////////////////////////////////////////////////////////////////
+
+package groupChat
+
+import (
+	"encoding/base64"
+	"github.com/pkg/errors"
+	gs "gitlab.com/elixxir/client/groupChat/groupStore"
+	"gitlab.com/elixxir/client/interfaces"
+	"gitlab.com/elixxir/client/interfaces/message"
+	"gitlab.com/elixxir/client/interfaces/params"
+	"gitlab.com/elixxir/client/network/gateway"
+	"gitlab.com/elixxir/client/stoppable"
+	"gitlab.com/elixxir/client/storage"
+	"gitlab.com/elixxir/client/storage/versioned"
+	"gitlab.com/elixxir/client/switchboard"
+	"gitlab.com/elixxir/comms/network"
+	"gitlab.com/elixxir/crypto/contact"
+	"gitlab.com/elixxir/crypto/cyclic"
+	"gitlab.com/elixxir/crypto/e2e"
+	"gitlab.com/elixxir/crypto/fastRNG"
+	"gitlab.com/elixxir/crypto/group"
+	"gitlab.com/elixxir/ekv"
+	"gitlab.com/elixxir/primitives/format"
+	"gitlab.com/xx_network/comms/connect"
+	"gitlab.com/xx_network/crypto/csprng"
+	"gitlab.com/xx_network/crypto/large"
+	"gitlab.com/xx_network/primitives/id"
+	"gitlab.com/xx_network/primitives/id/ephemeral"
+	"gitlab.com/xx_network/primitives/ndf"
+	"math/rand"
+	"sync"
+	"testing"
+)
+
+// newTestManager creates a new Manager for testing.
+func newTestManager(rng *rand.Rand, t *testing.T) (*Manager, gs.Group) {
+	store := storage.InitTestingSession(t)
+	user := group.Member{
+		ID:    store.GetUser().ReceptionID,
+		DhKey: store.GetUser().E2eDhPublicKey,
+	}
+
+	g := newTestGroupWithUser(store.E2e().GetGroup(), user.ID, user.DhKey,
+		store.GetUser().E2eDhPrivateKey, rng, t)
+	gStore, err := gs.NewStore(versioned.NewKV(make(ekv.Memstore)), user)
+	if err != nil {
+		t.Fatalf("Failed to create new group store: %+v", err)
+	}
+	m := &Manager{
+		store: store,
+		rng:   fastRNG.NewStreamGenerator(1000, 10, csprng.NewSystemRNG),
+		gs:    gStore,
+	}
+	return m, g
+}
+
+// newTestManager creates a new Manager that has groups stored for testing. One
+// of the groups in the list is also returned.
+func newTestManagerWithStore(rng *rand.Rand, numGroups int, sendErr int,
+	requestFunc RequestCallback, receiveFunc ReceiveCallback,
+	t *testing.T) (*Manager, gs.Group) {
+
+	store := storage.InitTestingSession(t)
+
+	user := group.Member{
+		ID:    store.GetUser().ReceptionID,
+		DhKey: store.GetUser().E2eDhPublicKey,
+	}
+
+	gStore, err := gs.NewStore(versioned.NewKV(make(ekv.Memstore)), user)
+	if err != nil {
+		t.Fatalf("Failed to create new group store: %+v", err)
+	}
+
+	var g gs.Group
+	for i := 0; i < numGroups; i++ {
+		g = newTestGroupWithUser(store.E2e().GetGroup(), user.ID, user.DhKey,
+			store.GetUser().E2eDhPrivateKey, rng, t)
+		if err = gStore.Add(g); err != nil {
+			t.Fatalf("Failed to add group %d to group store: %+v", i, err)
+		}
+	}
+
+	m := &Manager{
+		store:       store,
+		swb:         switchboard.New(),
+		net:         newTestNetworkManager(sendErr, t),
+		rng:         fastRNG.NewStreamGenerator(1000, 10, csprng.NewSystemRNG),
+		gs:          gStore,
+		requestFunc: requestFunc,
+		receiveFunc: receiveFunc,
+	}
+	return m, g
+}
+
+// getMembership returns a Membership with random members for testing.
+func getMembership(size int, uid *id.ID, pubKey *cyclic.Int, grp *cyclic.Group, prng *rand.Rand, t *testing.T) group.Membership {
+	contacts := make([]contact.Contact, size)
+	for i := range contacts {
+		randId, _ := id.NewRandomID(prng, id.User)
+		contacts[i] = contact.Contact{
+			ID:       randId,
+			DhPubKey: grp.NewInt(int64(prng.Int31() + 1)),
+		}
+	}
+
+	contacts[2].ID = uid
+	contacts[2].DhPubKey = pubKey
+
+	membership, err := group.NewMembership(contacts[0], contacts[1:]...)
+	if err != nil {
+		t.Errorf("Failed to create new membership: %+v", err)
+	}
+
+	return membership
+}
+
+// newTestGroup generates a new group with random values for testing.
+func newTestGroup(grp *cyclic.Group, privKey *cyclic.Int, rng *rand.Rand, t *testing.T) gs.Group {
+	// Generate name from base 64 encoded random data
+	nameBytes := make([]byte, 16)
+	rng.Read(nameBytes)
+	name := []byte(base64.StdEncoding.EncodeToString(nameBytes))
+
+	// Generate the message from base 64 encoded random data
+	msgBytes := make([]byte, 128)
+	rng.Read(msgBytes)
+	msg := []byte(base64.StdEncoding.EncodeToString(msgBytes))
+
+	membership := getMembership(10, id.NewIdFromString("userID", id.User, t),
+		randCycInt(rng), grp, rng, t)
+
+	dkl := gs.GenerateDhKeyList(id.NewIdFromString("userID", id.User, t), privKey, membership, grp)
+
+	idPreimage, err := group.NewIdPreimage(rng)
+	if err != nil {
+		t.Fatalf("Failed to generate new group ID preimage: %+v", err)
+	}
+
+	keyPreimage, err := group.NewKeyPreimage(rng)
+	if err != nil {
+		t.Fatalf("Failed to generate new group key preimage: %+v", err)
+	}
+
+	groupID := group.NewID(idPreimage, membership)
+	groupKey := group.NewKey(keyPreimage, membership)
+
+	return gs.NewGroup(name, groupID, groupKey, idPreimage, keyPreimage, msg,
+		membership, dkl)
+}
+
+// newTestGroup generates a new group with random values for testing.
+func newTestGroupWithUser(grp *cyclic.Group, uid *id.ID, pubKey,
+	privKey *cyclic.Int, rng *rand.Rand, t *testing.T) gs.Group {
+	// Generate name from base 64 encoded random data
+	nameBytes := make([]byte, 16)
+	rng.Read(nameBytes)
+	name := []byte(base64.StdEncoding.EncodeToString(nameBytes))
+
+	// Generate the message from base 64 encoded random data
+	msgBytes := make([]byte, 128)
+	rng.Read(msgBytes)
+	msg := []byte(base64.StdEncoding.EncodeToString(msgBytes))
+
+	membership := getMembership(10, uid, pubKey, grp, rng, t)
+
+	dkl := gs.GenerateDhKeyList(uid, privKey, membership, grp)
+
+	idPreimage, err := group.NewIdPreimage(rng)
+	if err != nil {
+		t.Fatalf("Failed to generate new group ID preimage: %+v", err)
+	}
+
+	keyPreimage, err := group.NewKeyPreimage(rng)
+	if err != nil {
+		t.Fatalf("Failed to generate new group key preimage: %+v", err)
+	}
+
+	groupID := group.NewID(idPreimage, membership)
+	groupKey := group.NewKey(keyPreimage, membership)
+
+	return gs.NewGroup(name, groupID, groupKey, idPreimage, keyPreimage, msg,
+		membership, dkl)
+}
+
+// randCycInt returns a random cyclic int.
+func randCycInt(rng *rand.Rand) *cyclic.Int {
+	return getGroup().NewInt(int64(rng.Int31() + 1))
+}
+
+func getGroup() *cyclic.Group {
+	return cyclic.NewGroup(
+		large.NewIntFromString(getNDF().E2E.Prime, 16),
+		large.NewIntFromString(getNDF().E2E.Generator, 16))
+}
+
+func newTestNetworkManager(sendErr int, t *testing.T) interfaces.NetworkManager {
+	instanceComms := &connect.ProtoComms{
+		Manager: connect.NewManagerTesting(t),
+	}
+
+	thisInstance, err := network.NewInstanceTesting(instanceComms, getNDF(),
+		getNDF(), nil, nil, t)
+	if err != nil {
+		t.Fatalf("Failed to create new test instance: %v", err)
+	}
+
+	return &testNetworkManager{
+		instance: thisInstance,
+		messages: []map[id.ID]format.Message{},
+		sendErr:  sendErr,
+	}
+}
+
+// testNetworkManager is a test implementation of NetworkManager interface.
+type testNetworkManager struct {
+	instance    *network.Instance
+	messages    []map[id.ID]format.Message
+	e2eMessages []message.Send
+	errSkip     int
+	sendErr     int
+	sync.RWMutex
+}
+
+func (tnm *testNetworkManager) GetMsgMap(i int) map[id.ID]format.Message {
+	tnm.RLock()
+	defer tnm.RUnlock()
+	return tnm.messages[i]
+}
+
+func (tnm *testNetworkManager) GetE2eMsg(i int) message.Send {
+	tnm.RLock()
+	defer tnm.RUnlock()
+	return tnm.e2eMessages[i]
+}
+
+func (tnm *testNetworkManager) SendE2E(msg message.Send, _ params.E2E, _ *stoppable.Single) ([]id.Round, e2e.MessageID, error) {
+	tnm.Lock()
+	defer tnm.Unlock()
+
+	tnm.errSkip++
+	if tnm.sendErr == 1 {
+		return nil, e2e.MessageID{}, errors.New("SendE2E error")
+	} else if tnm.sendErr == 2 && tnm.errSkip%2 == 0 {
+		return nil, e2e.MessageID{}, errors.New("SendE2E error")
+	}
+
+	tnm.e2eMessages = append(tnm.e2eMessages, msg)
+
+	return []id.Round{0, 1, 2, 3}, e2e.MessageID{}, nil
+}
+
+func (tnm *testNetworkManager) SendUnsafe(message.Send, params.Unsafe) ([]id.Round, error) {
+	return []id.Round{}, nil
+}
+
+func (tnm *testNetworkManager) SendCMIX(format.Message, *id.ID, params.CMIX) (id.Round, ephemeral.Id, error) {
+	return 0, ephemeral.Id{}, nil
+}
+
+func (tnm *testNetworkManager) SendManyCMIX(messages map[id.ID]format.Message, _ params.CMIX) (id.Round, []ephemeral.Id, error) {
+	if tnm.sendErr == 1 {
+		return 0, nil, errors.New("SendManyCMIX error")
+	}
+
+	tnm.Lock()
+	defer tnm.Unlock()
+
+	tnm.messages = append(tnm.messages, messages)
+
+	return 0, nil, nil
+}
+
+func (tnm *testNetworkManager) GetInstance() *network.Instance             { return tnm.instance }
+func (tnm *testNetworkManager) GetHealthTracker() interfaces.HealthTracker { return nil }
+func (tnm *testNetworkManager) Follow(interfaces.ClientErrorReport) (stoppable.Stoppable, error) {
+	return nil, nil
+}
+func (tnm *testNetworkManager) CheckGarbledMessages()        {}
+func (tnm *testNetworkManager) InProgressRegistrations() int { return 0 }
+func (tnm *testNetworkManager) GetSender() *gateway.Sender   { return nil }
+func (tnm *testNetworkManager) GetAddressSize() uint8        { return 0 }
+func (tnm *testNetworkManager) RegisterAddressSizeNotification(string) (chan uint8, error) {
+	return nil, nil
+}
+func (tnm *testNetworkManager) UnregisterAddressSizeNotification(string) {}
+
+func getNDF() *ndf.NetworkDefinition {
+	return &ndf.NetworkDefinition{
+		E2E: ndf.Group{
+			Prime: "E2EE983D031DC1DB6F1A7A67DF0E9A8E5561DB8E8D49413394C049B7A" +
+				"8ACCEDC298708F121951D9CF920EC5D146727AA4AE535B0922C688B55B3D" +
+				"D2AEDF6C01C94764DAB937935AA83BE36E67760713AB44A6337C20E78615" +
+				"75E745D31F8B9E9AD8412118C62A3E2E29DF46B0864D0C951C394A5CBBDC" +
+				"6ADC718DD2A3E041023DBB5AB23EBB4742DE9C1687B5B34FA48C3521632C" +
+				"4A530E8FFB1BC51DADDF453B0B2717C2BC6669ED76B4BDD5C9FF558E88F2" +
+				"6E5785302BEDBCA23EAC5ACE92096EE8A60642FB61E8F3D24990B8CB12EE" +
+				"448EEF78E184C7242DD161C7738F32BF29A841698978825B4111B4BC3E1E" +
+				"198455095958333D776D8B2BEEED3A1A1A221A6E37E664A64B83981C46FF" +
+				"DDC1A45E3D5211AAF8BFBC072768C4F50D7D7803D2D4F278DE8014A47323" +
+				"631D7E064DE81C0C6BFA43EF0E6998860F1390B5D3FEACAF1696015CB79C" +
+				"3F9C2D93D961120CD0E5F12CBB687EAB045241F96789C38E89D796138E63" +
+				"19BE62E35D87B1048CA28BE389B575E994DCA755471584A09EC723742DC3" +
+				"5873847AEF49F66E43873",
+			Generator: "2",
+		},
+		CMIX: ndf.Group{
+			Prime: "9DB6FB5951B66BB6FE1E140F1D2CE5502374161FD6538DF1648218642" +
+				"F0B5C48C8F7A41AADFA187324B87674FA1822B00F1ECF8136943D7C55757" +
+				"264E5A1A44FFE012E9936E00C1D3E9310B01C7D179805D3058B2A9F4BB6F" +
+				"9716BFE6117C6B5B3CC4D9BE341104AD4A80AD6C94E005F4B993E14F091E" +
+				"B51743BF33050C38DE235567E1B34C3D6A5C0CEAA1A0F368213C3D19843D" +
+				"0B4B09DCB9FC72D39C8DE41F1BF14D4BB4563CA28371621CAD3324B6A2D3" +
+				"92145BEBFAC748805236F5CA2FE92B871CD8F9C36D3292B5509CA8CAA77A" +
+				"2ADFC7BFD77DDA6F71125A7456FEA153E433256A2261C6A06ED3693797E7" +
+				"995FAD5AABBCFBE3EDA2741E375404AE25B",
+			Generator: "5C7FF6B06F8F143FE8288433493E4769C4D988ACE5BE25A0E2480" +
+				"9670716C613D7B0CEE6932F8FAA7C44D2CB24523DA53FBE4F6EC3595892D" +
+				"1AA58C4328A06C46A15662E7EAA703A1DECF8BBB2D05DBE2EB956C142A33" +
+				"8661D10461C0D135472085057F3494309FFA73C611F78B32ADBB5740C361" +
+				"C9F35BE90997DB2014E2EF5AA61782F52ABEB8BD6432C4DD097BC5423B28" +
+				"5DAFB60DC364E8161F4A2A35ACA3A10B1C4D203CC76A470A33AFDCBDD929" +
+				"59859ABD8B56E1725252D78EAC66E71BA9AE3F1DD2487199874393CD4D83" +
+				"2186800654760E1E34C09E4D155179F9EC0DC4473F996BDCE6EED1CABED8" +
+				"B6F116F7AD9CF505DF0F998E34AB27514B0FFE7",
+		},
+	}
+}
diff --git a/interfaces/message/receiveMessage.go b/interfaces/message/receiveMessage.go
index fad6fb750ccc21cc9f03391056a8559a53cd5159..d11f8880865e8c6db95086c054f554126835b5c2 100644
--- a/interfaces/message/receiveMessage.go
+++ b/interfaces/message/receiveMessage.go
@@ -15,12 +15,14 @@ import (
 )
 
 type Receive struct {
-	ID          e2e.MessageID
-	Payload     []byte
-	MessageType Type
-	Sender      *id.ID
-	RecipientID *id.ID
-	EphemeralID ephemeral.Id
-	Timestamp   time.Time
-	Encryption  EncryptionType
+	ID             e2e.MessageID
+	Payload        []byte
+	MessageType    Type
+	Sender         *id.ID
+	RecipientID    *id.ID
+	EphemeralID    ephemeral.Id
+	RoundId        id.Round
+	RoundTimestamp time.Time
+	Timestamp      time.Time // Message timestamp of when the user sent
+	Encryption     EncryptionType
 }
diff --git a/interfaces/message/type.go b/interfaces/message/type.go
index 71a1c72ff431ab1c6164856fe892e72af8c21a68..5c8012fea55a6f7c6afcc953ef37dbf0daa7b6e0 100644
--- a/interfaces/message/type.go
+++ b/interfaces/message/type.go
@@ -49,4 +49,8 @@ const (
 	KeyExchangeTrigger = 30
 	// Rekey confirmation message. Sent by partner to confirm completion of a rekey
 	KeyExchangeConfirm = 31
+
+	/* Group chat message types */
+	// A group chat request message sent to all members in a group.
+	GroupCreationRequest = 40
 )
diff --git a/interfaces/networkManager.go b/interfaces/networkManager.go
index 69bd8aee8e65aaaa6f382788e611d3ff9d9e2c8c..710700144bc2641b0f3caf87c00b65879cc0cb1a 100644
--- a/interfaces/networkManager.go
+++ b/interfaces/networkManager.go
@@ -24,6 +24,7 @@ type NetworkManager interface {
 	SendE2E(m message.Send, p params.E2E, stop *stoppable.Single) ([]id.Round, e2e.MessageID, error)
 	SendUnsafe(m message.Send, p params.Unsafe) ([]id.Round, error)
 	SendCMIX(message format.Message, recipient *id.ID, p params.CMIX) (id.Round, ephemeral.Id, error)
+	SendManyCMIX(messages map[id.ID]format.Message, p params.CMIX) (id.Round, []ephemeral.Id, error)
 	GetInstance() *network.Instance
 	GetHealthTracker() HealthTracker
 	GetSender() *gateway.Sender
diff --git a/keyExchange/utils_test.go b/keyExchange/utils_test.go
index 84db8a67ca517eb36fd5f9a0982a996b5100c87f..876a16576bfd95871fca6e099fb77caff25f6c02 100644
--- a/keyExchange/utils_test.go
+++ b/keyExchange/utils_test.go
@@ -84,6 +84,10 @@ func (t *testNetworkManagerGeneric) SendCMIX(message format.Message, rid *id.ID,
 
 }
 
+func (t *testNetworkManagerGeneric) SendManyCMIX(messages map[id.ID]format.Message, p params.CMIX) (id.Round, []ephemeral.Id, error) {
+	return id.Round(0), []ephemeral.Id{}, nil
+}
+
 func (t *testNetworkManagerGeneric) GetInstance() *network.Instance {
 	return t.instance
 
@@ -189,18 +193,18 @@ func (t *testNetworkManagerFullExchange) SendE2E(message.Send, params.E2E, *stop
 	bobSwitchboard.Speak(confirmMessage)
 
 	return rounds, cE2e.MessageID{}, nil
-
 }
 
 func (t *testNetworkManagerFullExchange) SendUnsafe(m message.Send, p params.Unsafe) ([]id.Round, error) {
-
 	return nil, nil
 }
 
 func (t *testNetworkManagerFullExchange) SendCMIX(message format.Message, eid *id.ID, p params.CMIX) (id.Round, ephemeral.Id, error) {
-
 	return id.Round(0), ephemeral.Id{}, nil
+}
 
+func (t *testNetworkManagerFullExchange) SendManyCMIX(messages map[id.ID]format.Message, p params.CMIX) (id.Round, []ephemeral.Id, error) {
+	return id.Round(0), []ephemeral.Id{}, nil
 }
 
 func (t *testNetworkManagerFullExchange) GetInstance() *network.Instance {
diff --git a/network/ephemeral/addressSpace_test.go b/network/ephemeral/addressSpace_test.go
index 3761c3f55f080f61b90d9c1353143c57664b4011..03cca463bc829e9db4ff6bb916fb6738e9b12e19 100644
--- a/network/ephemeral/addressSpace_test.go
+++ b/network/ephemeral/addressSpace_test.go
@@ -137,7 +137,7 @@ func Test_addressSpace_update_GetAndChannels(t *testing.T) {
 					t.Errorf("Thread %d received unexpected size."+
 						"\nexpected: %d\nreceived: %d", i, expectedSize, size)
 				}
-			case <-time.NewTimer(15 * time.Millisecond).C:
+			case <-time.NewTimer(20 * time.Millisecond).C:
 				t.Errorf("Timed out waiting for Get to return on thread %d.", i)
 			}
 		}(i, waitChan)
@@ -166,7 +166,7 @@ func Test_addressSpace_update_GetAndChannels(t *testing.T) {
 			case size := <-notifyChan:
 				t.Errorf("Received size %d on channel %s when it should not have.",
 					size, chanID)
-			case <-time.NewTimer(15 * time.Millisecond).C:
+			case <-time.NewTimer(20 * time.Millisecond).C:
 			}
 		}(chanID, notifyChan)
 	}
@@ -196,7 +196,7 @@ func Test_addressSpace_update_GetAndChannels(t *testing.T) {
 					t.Errorf("Failed to receive expected size on channel %s."+
 						"\nexpected: %d\nreceived: %d", chanID, expectedSize, size)
 				}
-			case <-time.NewTimer(15 * time.Millisecond).C:
+			case <-time.NewTimer(20 * time.Millisecond).C:
 				t.Errorf("Timed out waiting on channel %s", chanID)
 			}
 		}(chanID, notifyChan)
@@ -211,7 +211,7 @@ func Test_addressSpace_update_GetAndChannels(t *testing.T) {
 		case size := <-notifyChan:
 			t.Errorf("Received size %d on channel %s when it should not have.",
 				size, chanID)
-		case <-time.NewTimer(15 * time.Millisecond).C:
+		case <-time.NewTimer(20 * time.Millisecond).C:
 		}
 	}()
 
diff --git a/network/ephemeral/testutil.go b/network/ephemeral/testutil.go
index fe34b4934ff2955658a3239f0dfcbe048a3a10d5..92eb68c408a0ecee0071651b5660c922d731934d 100644
--- a/network/ephemeral/testutil.go
+++ b/network/ephemeral/testutil.go
@@ -62,6 +62,10 @@ func (t *testNetworkManager) SendCMIX(format.Message, *id.ID, params.CMIX) (id.R
 	return 0, ephemeral.Id{}, nil
 }
 
+func (t *testNetworkManager) SendManyCMIX(messages map[id.ID]format.Message, p params.CMIX) (id.Round, []ephemeral.Id, error) {
+	return 0, []ephemeral.Id{}, nil
+}
+
 func (t *testNetworkManager) GetInstance() *network.Instance {
 	return t.instance
 }
diff --git a/network/message/bundle.go b/network/message/bundle.go
index 56f1618d643641da6e9c2550e998a37a1269344c..81c649bd3798d693cd451dd7322d9d83e95510d9 100644
--- a/network/message/bundle.go
+++ b/network/message/bundle.go
@@ -9,13 +9,15 @@ package message
 
 import (
 	"gitlab.com/elixxir/client/storage/reception"
+	pb "gitlab.com/elixxir/comms/mixmessages"
 	"gitlab.com/elixxir/primitives/format"
 	"gitlab.com/xx_network/primitives/id"
 )
 
 type Bundle struct {
-	Round    id.Round
-	Messages []format.Message
-	Finish   func()
-	Identity reception.IdentityUse
+	Round     id.Round
+	RoundInfo *pb.RoundInfo
+	Messages  []format.Message
+	Finish    func()
+	Identity  reception.IdentityUse
 }
diff --git a/network/message/handler.go b/network/message/handler.go
index 5c54e8308b73ed35f668620a731577f4cfe67271..060332e8cb982744e9925c539ac455a499bf2e18 100644
--- a/network/message/handler.go
+++ b/network/message/handler.go
@@ -11,10 +11,10 @@ import (
 	jww "github.com/spf13/jwalterweatherman"
 	"gitlab.com/elixxir/client/interfaces/message"
 	"gitlab.com/elixxir/client/stoppable"
-	"gitlab.com/elixxir/client/storage/reception"
 	"gitlab.com/elixxir/crypto/e2e"
 	fingerprint2 "gitlab.com/elixxir/crypto/fingerprint"
 	"gitlab.com/elixxir/primitives/format"
+	"gitlab.com/elixxir/primitives/states"
 	"gitlab.com/xx_network/primitives/id"
 	"time"
 )
@@ -27,7 +27,7 @@ func (m *Manager) handleMessages(stop *stoppable.Single) {
 			return
 		case bundle := <-m.messageReception:
 			for _, msg := range bundle.Messages {
-				m.handleMessage(msg, bundle.Identity)
+				m.handleMessage(msg, bundle)
 			}
 			bundle.Finish()
 		}
@@ -35,10 +35,11 @@ func (m *Manager) handleMessages(stop *stoppable.Single) {
 
 }
 
-func (m *Manager) handleMessage(ecrMsg format.Message, identity reception.IdentityUse) {
+func (m *Manager) handleMessage(ecrMsg format.Message, bundle Bundle) {
 	// We've done all the networking, now process the message
 	fingerprint := ecrMsg.GetKeyFP()
 	msgDigest := ecrMsg.Digest()
+	identity := bundle.Identity
 
 	e2eKv := m.Session.E2e()
 
@@ -91,18 +92,16 @@ func (m *Manager) handleMessage(ecrMsg format.Message, identity reception.Identi
 		// if it doesnt match any form of encrypted, hear it as a raw message
 		// and add it to garbled messages to be handled later
 		msg = ecrMsg
-		if err != nil {
-			jww.DEBUG.Printf("Failed to unmarshal ephemeral ID "+
-				"on unknown message: %+v", err)
-		}
 		raw := message.Receive{
-			Payload:     msg.Marshal(),
-			MessageType: message.Raw,
-			Sender:      &id.ID{},
-			EphemeralID: identity.EphId,
-			Timestamp:   time.Time{},
-			Encryption:  message.None,
-			RecipientID: identity.Source,
+			Payload:        msg.Marshal(),
+			MessageType:    message.Raw,
+			Sender:         &id.ID{},
+			EphemeralID:    identity.EphId,
+			Timestamp:      time.Time{},
+			Encryption:     message.None,
+			RecipientID:    identity.Source,
+			RoundId:        id.Round(bundle.RoundInfo.ID),
+			RoundTimestamp: time.Unix(0, int64(bundle.RoundInfo.Timestamps[states.QUEUED])),
 		}
 		jww.INFO.Printf("Garbled/RAW Message: keyFP: %v, msgDigest: %s",
 			msg.GetKeyFP(), msg.Digest())
@@ -125,6 +124,8 @@ func (m *Manager) handleMessage(ecrMsg format.Message, identity reception.Identi
 		xxMsg.RecipientID = identity.Source
 		xxMsg.EphemeralID = identity.EphId
 		xxMsg.Encryption = encTy
+		xxMsg.RoundId = id.Round(bundle.RoundInfo.ID)
+		xxMsg.RoundTimestamp = time.Unix(0, int64(bundle.RoundInfo.Timestamps[states.QUEUED]))
 		if xxMsg.MessageType == message.Raw {
 			jww.WARN.Panicf("Recieved a message of type 'Raw' from %s."+
 				"Message Ignored, 'Raw' is a reserved type. Message supressed.",
diff --git a/network/message/sendCmix.go b/network/message/sendCmix.go
index 12f9604a26cbd473753b81fd3b6707477d4c287b..d011a533cb05aad5ecdf801267cb0db7a5f6055d 100644
--- a/network/message/sendCmix.go
+++ b/network/message/sendCmix.go
@@ -18,25 +18,14 @@ import (
 	pb "gitlab.com/elixxir/comms/mixmessages"
 	"gitlab.com/elixxir/comms/network"
 	"gitlab.com/elixxir/crypto/fastRNG"
-	"gitlab.com/elixxir/crypto/fingerprint"
 	"gitlab.com/elixxir/primitives/format"
-	"gitlab.com/elixxir/primitives/states"
 	"gitlab.com/xx_network/comms/connect"
 	"gitlab.com/xx_network/primitives/id"
 	"gitlab.com/xx_network/primitives/id/ephemeral"
 	"gitlab.com/xx_network/primitives/netTime"
 	"strings"
-	"time"
 )
 
-// interface for SendCMIX comms; allows mocking this in testing
-type sendCmixCommsInterface interface {
-	SendPutMessage(host *connect.Host, message *pb.GatewaySlot) (*pb.GatewaySlotResponse, error)
-}
-
-// 1.5 seconds
-const sendTimeBuffer = 500 * time.Millisecond
-
 // WARNING: Potentially Unsafe
 // Public manager function to send a message over CMIX
 func (m *Manager) SendCMIX(sender *gateway.Sender, msg format.Message,
@@ -46,7 +35,8 @@ func (m *Manager) SendCMIX(sender *gateway.Sender, msg format.Message,
 		m.Session, m.nodeRegistration, m.Rng, m.TransmissionID, m.Comms, stop)
 }
 
-// Payloads send are not End to End encrypted, MetaData is NOT protected with
+// Helper function for sendCmix
+// NOTE: Payloads send are not End to End encrypted, MetaData is NOT protected with
 // this call, see SendE2E for End to End encryption and full privacy protection
 // Internal SendCmix which bypasses the network check, will attempt to send to
 // the network without checking state. It has a built in retry system which can
@@ -91,89 +81,24 @@ func sendCmixHelper(sender *gateway.Sender, msg format.Message,
 		//add the round on to the list of attempted so it is not tried again
 		attempted.Insert(bestRound)
 
-		//set the ephemeral ID
-		ephID, _, _, err := ephemeral.GetId(recipient,
-			uint(bestRound.AddressSpaceSize),
-			int64(bestRound.Timestamps[states.QUEUED]))
+		// Retrieve host and key information from round
+		firstGateway, roundKeys, err := processRound(instance, session, nodeRegistration, bestRound, recipient.String(), msg.Digest())
 		if err != nil {
-			jww.FATAL.Panicf("Failed to generate ephemeral ID when "+
-				"sending to %s (msgDigest: %s):  %+v", err, recipient,
-				msg.Digest())
+			jww.WARN.Printf("SendCmix failed to process round (will retry): %v", err)
+			continue
 		}
 
+		// Build the messages to send
 		stream := rng.GetStream()
-		ephIdFilled, err := ephID.Fill(uint(bestRound.AddressSpaceSize), stream)
-		if err != nil {
-			jww.FATAL.Panicf("Failed to obfuscate the ephemeralID when "+
-				"sending to %s (msgDigest: %s): %+v", recipient, msg.Digest(),
-				err)
-		}
-		stream.Close()
-
-		msg.SetEphemeralRID(ephIdFilled[:])
-
-		//set the identity fingerprint
-		ifp := fingerprint.IdentityFP(msg.GetContents(), recipient)
-		msg.SetIdentityFP(ifp)
 
-		//build the topology
-		idList, err := id.NewIDListFromBytes(bestRound.Topology)
+		wrappedMsg, encMsg, ephID, err := buildSlotMessage(msg, recipient,
+			firstGateway, stream, senderId, bestRound, roundKeys)
 		if err != nil {
-			jww.ERROR.Printf("Failed to use topology for round %d when "+
-				"sending to %s (msgDigest: %s): %+v", bestRound.ID,
-				recipient, msg.Digest(), err)
-			continue
-		}
-		topology := connect.NewCircuit(idList)
-		//get they keys for the round, reject if any nodes do not have
-		//keying relationships
-		roundKeys, missingKeys := session.Cmix().GetRoundKeys(topology)
-		if len(missingKeys) > 0 {
-			jww.WARN.Printf("Failed to send on round %d to %s "+
-				"(msgDigest: %s) due to missing relationships with nodes: %s",
-				bestRound.ID, recipient, msg.Digest(), missingKeys)
-			go handleMissingNodeKeys(instance, nodeRegistration, missingKeys)
-			time.Sleep(cmixParams.RetryDelay)
-			continue
+			stream.Close()
+			return 0, ephemeral.Id{}, err
 		}
-
-		//get the gateway to transmit to
-		firstGateway := topology.GetNodeAtIndex(0).DeepCopy()
-		firstGateway.SetType(id.Gateway)
-
-		//encrypt the message
-		stream = rng.GetStream()
-		salt := make([]byte, 32)
-		_, err = stream.Read(salt)
 		stream.Close()
 
-		if err != nil {
-			jww.ERROR.Printf("Failed to generate salt when sending to "+
-				"%s (msgDigest: %s): %+v", recipient, msg.Digest(), err)
-			return 0, ephemeral.Id{}, errors.WithMessage(err,
-				"Failed to generate salt, this should never happen")
-		}
-
-		encMsg, kmacs := roundKeys.Encrypt(msg, salt, id.Round(bestRound.ID))
-
-		//build the message payload
-		msgPacket := &pb.Slot{
-			SenderID: senderId.Bytes(),
-			PayloadA: encMsg.GetPayloadA(),
-			PayloadB: encMsg.GetPayloadB(),
-			Salt:     salt,
-			KMACs:    kmacs,
-		}
-
-		//create the wrapper to the gateway
-		wrappedMsg := &pb.GatewaySlot{
-			Message: msgPacket,
-			RoundID: bestRound.ID,
-		}
-		//Add the mac proving ownership
-		wrappedMsg.MAC = roundKeys.MakeClientGatewayKey(salt,
-			network.GenerateSlotDigest(wrappedMsg))
-
 		jww.INFO.Printf("Sending to EphID %d (%s) on round %d, "+
 			"(msgDigest: %s, ecrMsgDigest: %s) via gateway %s",
 			ephID.Int64(), recipient, bestRound.ID, msg.Digest(),
@@ -184,27 +109,12 @@ func sendCmixHelper(sender *gateway.Sender, msg format.Message,
 			wrappedMsg.Target = target.Marshal()
 			result, err := comms.SendPutMessage(host, wrappedMsg)
 			if err != nil {
-				if strings.Contains(err.Error(),
-					"try a different round.") {
-					jww.WARN.Printf("Failed to send to %s (msgDigest: %s) "+
-						"due to round error with round %d, retrying: %+v",
-						recipient, msg.Digest(), bestRound.ID, err)
-					return nil, true, err
-				} else if strings.Contains(err.Error(),
-					"Could not authenticate client. Is the client registered "+
-						"with this node?") {
-					jww.WARN.Printf("Failed to send to %s (msgDigest: %s) "+
-						"via %s due to failed authentication: %s",
-						recipient, msg.Digest(), firstGateway.String(), err)
-					//if we failed to send due to the gateway not recognizing our
-					// authorization, renegotiate with the node to refresh it
-					nodeID := firstGateway.DeepCopy()
-					nodeID.SetType(id.Node)
-					//delete the keys
-					session.Cmix().Remove(nodeID)
-					//trigger
-					go handleMissingNodeKeys(instance, nodeRegistration, []*id.ID{nodeID})
-					return nil, true, err
+				// fixme: should we provide as a slice the whole topology?
+				warn, err := handlePutMessageError(firstGateway, instance, session, nodeRegistration, recipient.String(), bestRound, err)
+				if warn {
+					jww.WARN.Printf("SendCmix Failed: %+v", err)
+				} else {
+					return result, false, errors.WithMessagef(err, "SendCmix %s", unrecoverableError)
 				}
 			}
 			return result, false, err
@@ -217,14 +127,18 @@ func sendCmixHelper(sender *gateway.Sender, msg format.Message,
 		}
 
 		//if the comm errors or the message fails to send, continue retrying.
-		//return if it sends properly
 		if err != nil {
-			jww.ERROR.Printf("Failed to send to EphID %d (%s) on "+
-				"round %d, trying a new round: %+v", ephID.Int64(), recipient,
-				bestRound.ID, err)
-			continue
+			if !strings.Contains(err.Error(), unrecoverableError) {
+				jww.ERROR.Printf("SendCmix failed to send to EphID %d (%s) on "+
+					"round %d, trying a new round: %+v", ephID.Int64(), recipient,
+					bestRound.ID, err)
+				continue
+			}
+
+			return 0, ephemeral.Id{}, err
 		}
 
+		// Return if it sends properly
 		gwSlotResp := result.(*pb.GatewaySlotResponse)
 		if gwSlotResp.Accepted {
 			jww.INFO.Printf("Successfully sent to EphID %v (source: %s) "+
@@ -233,29 +147,10 @@ func sendCmixHelper(sender *gateway.Sender, msg format.Message,
 		} else {
 			jww.FATAL.Panicf("Gateway %s returned no error, but failed "+
 				"to accept message when sending to EphID %d (%s) on round %d",
-				firstGateway.String(), ephID.Int64(), recipient, bestRound.ID)
+				firstGateway, ephID.Int64(), recipient, bestRound.ID)
 		}
+
 	}
 	return 0, ephemeral.Id{}, errors.New("failed to send the message, " +
 		"unknown error")
 }
-
-// Signals to the node registration thread to register a node if keys are
-// missing. Identity is triggered automatically when the node is first seen,
-// so this should on trigger on rare events.
-func handleMissingNodeKeys(instance *network.Instance,
-	newNodeChan chan network.NodeGateway, nodes []*id.ID) {
-	for _, n := range nodes {
-		ng, err := instance.GetNodeAndGateway(n)
-		if err != nil {
-			jww.ERROR.Printf("Node contained in round cannot be found: %s", err)
-			continue
-		}
-		select {
-		case newNodeChan <- ng:
-		default:
-			jww.ERROR.Printf("Failed to send node registration for %s", n)
-		}
-
-	}
-}
diff --git a/network/message/sendCmixUtils.go b/network/message/sendCmixUtils.go
new file mode 100644
index 0000000000000000000000000000000000000000..c1d8e295885b1dadaa018ea881d07b1896625d87
--- /dev/null
+++ b/network/message/sendCmixUtils.go
@@ -0,0 +1,231 @@
+///////////////////////////////////////////////////////////////////////////////
+// Copyright © 2020 xx network SEZC                                          //
+//                                                                           //
+// Use of this source code is governed by a license that can be found in the //
+// LICENSE file                                                              //
+///////////////////////////////////////////////////////////////////////////////
+
+package message
+
+import (
+	"github.com/pkg/errors"
+	jww "github.com/spf13/jwalterweatherman"
+	"gitlab.com/elixxir/client/storage"
+	"gitlab.com/elixxir/client/storage/cmix"
+	pb "gitlab.com/elixxir/comms/mixmessages"
+	"gitlab.com/elixxir/comms/network"
+	"gitlab.com/elixxir/crypto/fastRNG"
+	"gitlab.com/elixxir/crypto/fingerprint"
+	"gitlab.com/elixxir/primitives/format"
+	"gitlab.com/elixxir/primitives/states"
+	"gitlab.com/xx_network/comms/connect"
+	"gitlab.com/xx_network/primitives/id"
+	"gitlab.com/xx_network/primitives/id/ephemeral"
+	"strconv"
+	"strings"
+	"time"
+)
+
+// Interface for SendCMIX comms; allows mocking this in testing.
+type sendCmixCommsInterface interface {
+	SendPutMessage(host *connect.Host, message *pb.GatewaySlot) (*pb.GatewaySlotResponse, error)
+	SendPutManyMessages(host *connect.Host, messages *pb.GatewaySlots) (*pb.GatewaySlotResponse, error)
+}
+
+// 2.5 seconds
+const sendTimeBuffer = 2500 * time.Millisecond
+const unrecoverableError = "failed with an unrecoverable error"
+
+// handlePutMessageError handles errors received from a PutMessage or a
+// PutManyMessage network call. A printable error will be returned giving more
+// context. If the error is not among recoverable errors, then the recoverable
+// boolean will be returned false. If the error is among recoverable errors,
+// then the boolean will return true.
+func handlePutMessageError(firstGateway *id.ID, instance *network.Instance,
+	session *storage.Session, nodeRegistration chan network.NodeGateway,
+	recipientString string, bestRound *pb.RoundInfo,
+	err error) (recoverable bool, returnErr error) {
+
+	// If the comm errors or the message fails to send, then continue retrying;
+	// otherwise, return if it sends properly
+	if strings.Contains(err.Error(), "try a different round.") {
+		return true, errors.WithMessagef(err, "Failed to send to [%s] due to "+
+			"round error with round %d, retrying...",
+			recipientString, bestRound.ID)
+	} else if strings.Contains(err.Error(), "Could not authenticate client. "+
+		"Is the client registered with this node?") {
+		// If send failed due to the gateway not recognizing the authorization,
+		// then renegotiate with the node to refresh it
+		nodeID := firstGateway.DeepCopy()
+		nodeID.SetType(id.Node)
+
+		// Delete the keys
+		session.Cmix().Remove(nodeID)
+
+		// Trigger
+		go handleMissingNodeKeys(instance, nodeRegistration, []*id.ID{nodeID})
+
+		return true, errors.WithMessagef(err, "Failed to send to [%s] via %s "+
+			"due to failed authentication, retrying...",
+			recipientString, firstGateway)
+	}
+
+	return false, errors.WithMessage(err, "Failed to put cmix message")
+
+}
+
+// processRound is a helper function that determines the gateway to send to for
+// a round and retrieves the round keys.
+func processRound(instance *network.Instance, session *storage.Session,
+	nodeRegistration chan network.NodeGateway, bestRound *pb.RoundInfo,
+	recipientString, messageDigest string) (*id.ID, *cmix.RoundKeys, error) {
+
+	// Build the topology
+	idList, err := id.NewIDListFromBytes(bestRound.Topology)
+	if err != nil {
+		return nil, nil, errors.WithMessagef(err, "Failed to use topology for "+
+			"round %d when sending to [%s] (msgDigest(s): %s)",
+			bestRound.ID, recipientString, messageDigest)
+	}
+	topology := connect.NewCircuit(idList)
+
+	// Get the keys for the round, reject if any nodes do not have keying
+	// relationships
+	roundKeys, missingKeys := session.Cmix().GetRoundKeys(topology)
+	if len(missingKeys) > 0 {
+		go handleMissingNodeKeys(instance, nodeRegistration, missingKeys)
+
+		return nil, nil, errors.Errorf("Failed to send on round %d to [%s] "+
+			"(msgDigest(s): %s) due to missing relationships with nodes: %s",
+			bestRound.ID, recipientString, messageDigest, missingKeys)
+	}
+
+	// Get the gateway to transmit to
+	firstGateway := topology.GetNodeAtIndex(0).DeepCopy()
+	firstGateway.SetType(id.Gateway)
+
+	return firstGateway, roundKeys, nil
+}
+
+// buildSlotMessage is a helper function which forms a slotted message to send
+// to a gateway. It encrypts passed in message and generates an ephemeral ID for
+// the recipient.
+func buildSlotMessage(msg format.Message, recipient *id.ID, target *id.ID,
+	stream *fastRNG.Stream, senderId *id.ID, bestRound *pb.RoundInfo,
+	roundKeys *cmix.RoundKeys) (*pb.GatewaySlot, format.Message, ephemeral.Id,
+	error) {
+
+	// Set the ephemeral ID
+	ephID, _, _, err := ephemeral.GetId(recipient,
+		uint(bestRound.AddressSpaceSize),
+		int64(bestRound.Timestamps[states.QUEUED]))
+	if err != nil {
+		jww.FATAL.Panicf("Failed to generate ephemeral ID when sending to %s "+
+			"(msgDigest: %s):  %+v", err, recipient, msg.Digest())
+	}
+
+	ephIdFilled, err := ephID.Fill(uint(bestRound.AddressSpaceSize), stream)
+	if err != nil {
+		jww.FATAL.Panicf("Failed to obfuscate the ephemeralID when sending "+
+			"to %s (msgDigest: %s): %+v", recipient, msg.Digest(), err)
+	}
+
+	msg.SetEphemeralRID(ephIdFilled[:])
+
+	// Set the identity fingerprint
+	ifp := fingerprint.IdentityFP(msg.GetContents(), recipient)
+
+	msg.SetIdentityFP(ifp)
+
+	// Encrypt the message
+	salt := make([]byte, 32)
+	_, err = stream.Read(salt)
+	if err != nil {
+		jww.ERROR.Printf("Failed to generate salt when sending to %s "+
+			"(msgDigest: %s): %+v", recipient, msg.Digest(), err)
+		return nil, format.Message{}, ephemeral.Id{}, errors.WithMessage(err,
+			"Failed to generate salt, this should never happen")
+	}
+
+	encMsg, kmacs := roundKeys.Encrypt(msg, salt, id.Round(bestRound.ID))
+
+	// Build the message payload
+	msgPacket := &pb.Slot{
+		SenderID: senderId.Bytes(),
+		PayloadA: encMsg.GetPayloadA(),
+		PayloadB: encMsg.GetPayloadB(),
+		Salt:     salt,
+		KMACs:    kmacs,
+	}
+
+	// Create the wrapper to the gateway
+	slot := &pb.GatewaySlot{
+		Message: msgPacket,
+		RoundID: bestRound.ID,
+		Target:  target.Bytes(),
+	}
+
+	// Add the mac proving ownership
+	slot.MAC = roundKeys.MakeClientGatewayKey(salt,
+		network.GenerateSlotDigest(slot))
+
+	return slot, encMsg, ephID, nil
+}
+
+// handleMissingNodeKeys signals to the node registration thread to register a
+// node if keys are missing. Identity is triggered automatically when the node
+// is first seen, so this should on trigger on rare events.
+func handleMissingNodeKeys(instance *network.Instance,
+	newNodeChan chan network.NodeGateway, nodes []*id.ID) {
+	for _, n := range nodes {
+		ng, err := instance.GetNodeAndGateway(n)
+		if err != nil {
+			jww.ERROR.Printf("Node contained in round cannot be found: %s", err)
+			continue
+		}
+
+		select {
+		case newNodeChan <- ng:
+		default:
+			jww.ERROR.Printf("Failed to send node registration for %s", n)
+		}
+
+	}
+}
+
+// messageMapToStrings serializes a map of IDs and messages into a string of IDs
+// and a string of message digests. Intended for use in printing to logs.
+func messageMapToStrings(msgList map[id.ID]format.Message) (string, string) {
+	idStrings := make([]string, 0, len(msgList))
+	msgDigests := make([]string, 0, len(msgList))
+	for uid, msg := range msgList {
+		idStrings = append(idStrings, uid.String())
+		msgDigests = append(msgDigests, msg.Digest())
+	}
+
+	return strings.Join(idStrings, ","), strings.Join(msgDigests, ",")
+}
+
+// messagesToDigestString serializes a list of messages into a string of message
+// digests. Intended for use in printing to the logs.
+func messagesToDigestString(msgs []format.Message) string {
+	msgDigests := make([]string, 0, len(msgs))
+	for _, msg := range msgs {
+		msgDigests = append(msgDigests, msg.Digest())
+	}
+
+	return strings.Join(msgDigests, ",")
+}
+
+// ephemeralIdListToString serializes a list of ephemeral IDs into a human-
+// readable format. Intended for use in printing to logs.
+func ephemeralIdListToString(idList []ephemeral.Id) string {
+	idStrings := make([]string, 0, len(idList))
+
+	for i := 0; i < len(idList); i++ {
+		ephIdStr := strconv.FormatInt(idList[i].Int64(), 10)
+		idStrings = append(idStrings, ephIdStr)
+	}
+
+	return strings.Join(idStrings, ",")
+}
diff --git a/network/message/sendCmix_test.go b/network/message/sendCmix_test.go
index 87412834c6a33d4165a77f674c0fe58bdd648797..28acd56525e7393500819539561cda4f8160ba07 100644
--- a/network/message/sendCmix_test.go
+++ b/network/message/sendCmix_test.go
@@ -18,47 +18,15 @@ import (
 	"gitlab.com/elixxir/crypto/fastRNG"
 	"gitlab.com/elixxir/primitives/format"
 	"gitlab.com/elixxir/primitives/states"
-	"gitlab.com/xx_network/comms/connect"
 	"gitlab.com/xx_network/crypto/csprng"
 	"gitlab.com/xx_network/crypto/large"
 	"gitlab.com/xx_network/primitives/id"
-	"gitlab.com/xx_network/primitives/ndf"
 	"gitlab.com/xx_network/primitives/netTime"
 	"testing"
 	"time"
 )
 
-type MockSendCMIXComms struct {
-	t *testing.T
-}
-
-func (mc *MockSendCMIXComms) GetHost(hostId *id.ID) (*connect.Host, bool) {
-	nid1 := id.NewIdFromString("zezima", id.Node, mc.t)
-	gwid := nid1.DeepCopy()
-	gwid.SetType(id.Gateway)
-	h, _ := connect.NewHost(gwid, "0.0.0.0", []byte(""), connect.HostParams{
-		MaxRetries:  0,
-		AuthEnabled: false,
-	})
-	return h, true
-}
-
-func (mc *MockSendCMIXComms) AddHost(hid *id.ID, address string, cert []byte, params connect.HostParams) (host *connect.Host, err error) {
-	host, _ = mc.GetHost(nil)
-	return host, nil
-}
-
-func (mc *MockSendCMIXComms) RemoveHost(hid *id.ID) {
-
-}
-
-func (mc *MockSendCMIXComms) SendPutMessage(host *connect.Host, message *mixmessages.GatewaySlot) (*mixmessages.GatewaySlotResponse, error) {
-	return &mixmessages.GatewaySlotResponse{
-		Accepted: true,
-		RoundID:  3,
-	}, nil
-}
-
+// Unit test
 func Test_attemptSendCmix(t *testing.T) {
 	sess1 := storage.InitTestingSession(t)
 
@@ -152,59 +120,3 @@ func Test_attemptSendCmix(t *testing.T) {
 		return
 	}
 }
-
-func getNDF() *ndf.NetworkDefinition {
-	nodeId := id.NewIdFromString("zezima", id.Node, &testing.T{})
-	gwId := nodeId.DeepCopy()
-	gwId.SetType(id.Gateway)
-	return &ndf.NetworkDefinition{
-		E2E: ndf.Group{
-			Prime: "E2EE983D031DC1DB6F1A7A67DF0E9A8E5561DB8E8D49413394C049B" +
-				"7A8ACCEDC298708F121951D9CF920EC5D146727AA4AE535B0922C688B55B3DD2AE" +
-				"DF6C01C94764DAB937935AA83BE36E67760713AB44A6337C20E7861575E745D31F" +
-				"8B9E9AD8412118C62A3E2E29DF46B0864D0C951C394A5CBBDC6ADC718DD2A3E041" +
-				"023DBB5AB23EBB4742DE9C1687B5B34FA48C3521632C4A530E8FFB1BC51DADDF45" +
-				"3B0B2717C2BC6669ED76B4BDD5C9FF558E88F26E5785302BEDBCA23EAC5ACE9209" +
-				"6EE8A60642FB61E8F3D24990B8CB12EE448EEF78E184C7242DD161C7738F32BF29" +
-				"A841698978825B4111B4BC3E1E198455095958333D776D8B2BEEED3A1A1A221A6E" +
-				"37E664A64B83981C46FFDDC1A45E3D5211AAF8BFBC072768C4F50D7D7803D2D4F2" +
-				"78DE8014A47323631D7E064DE81C0C6BFA43EF0E6998860F1390B5D3FEACAF1696" +
-				"015CB79C3F9C2D93D961120CD0E5F12CBB687EAB045241F96789C38E89D796138E" +
-				"6319BE62E35D87B1048CA28BE389B575E994DCA755471584A09EC723742DC35873" +
-				"847AEF49F66E43873",
-			Generator: "2",
-		},
-		CMIX: ndf.Group{
-			Prime: "9DB6FB5951B66BB6FE1E140F1D2CE5502374161FD6538DF1648218642F0B5C48" +
-				"C8F7A41AADFA187324B87674FA1822B00F1ECF8136943D7C55757264E5A1A44F" +
-				"FE012E9936E00C1D3E9310B01C7D179805D3058B2A9F4BB6F9716BFE6117C6B5" +
-				"B3CC4D9BE341104AD4A80AD6C94E005F4B993E14F091EB51743BF33050C38DE2" +
-				"35567E1B34C3D6A5C0CEAA1A0F368213C3D19843D0B4B09DCB9FC72D39C8DE41" +
-				"F1BF14D4BB4563CA28371621CAD3324B6A2D392145BEBFAC748805236F5CA2FE" +
-				"92B871CD8F9C36D3292B5509CA8CAA77A2ADFC7BFD77DDA6F71125A7456FEA15" +
-				"3E433256A2261C6A06ED3693797E7995FAD5AABBCFBE3EDA2741E375404AE25B",
-			Generator: "5C7FF6B06F8F143FE8288433493E4769C4D988ACE5BE25A0E24809670716C613" +
-				"D7B0CEE6932F8FAA7C44D2CB24523DA53FBE4F6EC3595892D1AA58C4328A06C4" +
-				"6A15662E7EAA703A1DECF8BBB2D05DBE2EB956C142A338661D10461C0D135472" +
-				"085057F3494309FFA73C611F78B32ADBB5740C361C9F35BE90997DB2014E2EF5" +
-				"AA61782F52ABEB8BD6432C4DD097BC5423B285DAFB60DC364E8161F4A2A35ACA" +
-				"3A10B1C4D203CC76A470A33AFDCBDD92959859ABD8B56E1725252D78EAC66E71" +
-				"BA9AE3F1DD2487199874393CD4D832186800654760E1E34C09E4D155179F9EC0" +
-				"DC4473F996BDCE6EED1CABED8B6F116F7AD9CF505DF0F998E34AB27514B0FFE7",
-		},
-		Gateways: []ndf.Gateway{
-			{
-				ID:             gwId.Marshal(),
-				Address:        "0.0.0.0",
-				TlsCertificate: "",
-			},
-		},
-		Nodes: []ndf.Node{
-			{
-				ID:             nodeId.Marshal(),
-				Address:        "0.0.0.0",
-				TlsCertificate: "",
-			},
-		},
-	}
-}
diff --git a/network/message/sendManyCmix.go b/network/message/sendManyCmix.go
new file mode 100644
index 0000000000000000000000000000000000000000..e9d598182bb805fc3220804389af43456eccf022
--- /dev/null
+++ b/network/message/sendManyCmix.go
@@ -0,0 +1,184 @@
+///////////////////////////////////////////////////////////////////////////////
+// Copyright © 2020 xx network SEZC                                          //
+//                                                                           //
+// Use of this source code is governed by a license that can be found in the //
+// LICENSE file                                                              //
+///////////////////////////////////////////////////////////////////////////////
+
+package message
+
+import (
+	"github.com/golang-collections/collections/set"
+	"github.com/pkg/errors"
+	jww "github.com/spf13/jwalterweatherman"
+	"gitlab.com/elixxir/client/interfaces/params"
+	"gitlab.com/elixxir/client/network/gateway"
+	"gitlab.com/elixxir/client/storage"
+	pb "gitlab.com/elixxir/comms/mixmessages"
+	"gitlab.com/elixxir/comms/network"
+	"gitlab.com/elixxir/crypto/fastRNG"
+	"gitlab.com/elixxir/primitives/format"
+	"gitlab.com/xx_network/comms/connect"
+	"gitlab.com/xx_network/primitives/id"
+	"gitlab.com/xx_network/primitives/id/ephemeral"
+	"gitlab.com/xx_network/primitives/netTime"
+	"strings"
+)
+
+// SendManyCMIX sends many "raw" cMix message payloads to each of the provided
+// recipients. Used to send messages in group chats. Metadata is NOT protected
+// with this call and can leak data about yourself. Returns the round ID of the
+// round the payload was sent or an error if it fails.
+// WARNING: Potentially Unsafe
+func (m *Manager) SendManyCMIX(sender *gateway.Sender,
+	messages map[id.ID]format.Message, p params.CMIX) (id.Round, []ephemeral.Id,
+	error) {
+
+	// Create message copies
+	messagesCopy := make(map[id.ID]format.Message, len(messages))
+	for rid, msg := range messages {
+		messagesCopy[rid] = msg.Copy()
+	}
+
+	return sendManyCmixHelper(sender, messagesCopy, p, m.Instance, m.Session,
+		m.nodeRegistration, m.Rng, m.TransmissionID, m.Comms)
+}
+
+// sendManyCmixHelper is a helper function for Manager.SendManyCMIX.
+//
+// NOTE: Payloads sent are not end to end encrypted, metadata is NOT protected
+// with this call; see SendE2E for end to end encryption and full privacy
+// protection. Internal SendManyCMIX, which bypasses the network check, will
+// attempt to send to the network without checking state. It has a built in
+// retry system which can be configured through the params object.
+//
+// If the message is successfully sent, the ID of the round sent it is returned,
+// which can be registered with the network instance to get a callback on its
+// status.
+func sendManyCmixHelper(sender *gateway.Sender, msgs map[id.ID]format.Message,
+	param params.CMIX, instance *network.Instance, session *storage.Session,
+	nodeRegistration chan network.NodeGateway, rng *fastRNG.StreamGenerator,
+	senderId *id.ID, comms sendCmixCommsInterface) (id.Round, []ephemeral.Id, error) {
+
+	timeStart := netTime.Now()
+	attempted := set.New()
+	stream := rng.GetStream()
+	defer stream.Close()
+
+	recipientString, msgDigests := messageMapToStrings(msgs)
+
+	jww.INFO.Printf("Looking for round to send cMix messages to [%s] "+
+		"(msgDigest: %s)", recipientString, msgDigests)
+
+	for numRoundTries := uint(0); numRoundTries < param.RoundTries; numRoundTries++ {
+		elapsed := netTime.Now().Sub(timeStart)
+
+		if elapsed > param.Timeout {
+			jww.INFO.Printf("No rounds to send to %s (msgDigest: %s) were found "+
+				"before timeout %s", recipientString, msgDigests, param.Timeout)
+			return 0, []ephemeral.Id{},
+				errors.New("sending cMix message timed out")
+		}
+
+		if numRoundTries > 0 {
+			jww.INFO.Printf("Attempt %d to find round to send message to %s "+
+				"(msgDigest: %s)", numRoundTries+1, recipientString, msgDigests)
+		}
+
+		remainingTime := param.Timeout - elapsed
+
+		// Find the best round to send to, excluding attempted rounds
+		bestRound, _ := instance.GetWaitingRounds().GetUpcomingRealtime(
+			remainingTime, attempted, sendTimeBuffer)
+		if bestRound == nil {
+			continue
+		}
+
+		// Add the round on to the list of attempted so it is not tried again
+		attempted.Insert(bestRound)
+
+		// Retrieve host and key information from round
+		firstGateway, roundKeys, err := processRound(instance, session,
+			nodeRegistration, bestRound, recipientString, msgDigests)
+		if err != nil {
+			jww.WARN.Printf("SendManyCMIX failed to process round %d "+
+				"(will retry): %+v", bestRound.ID, err)
+			continue
+		}
+
+		// Build a slot for every message and recipient
+		slots := make([]*pb.GatewaySlot, len(msgs))
+		ephemeralIds := make([]ephemeral.Id, len(msgs))
+		encMsgs := make([]format.Message, len(msgs))
+		i := 0
+		for recipient, msg := range msgs {
+			slots[i], encMsgs[i], ephemeralIds[i], err = buildSlotMessage(
+				msg, &recipient, firstGateway, stream, senderId, bestRound, roundKeys)
+			if err != nil {
+				return 0, []ephemeral.Id{}, errors.Errorf("failed to build "+
+					"slot message for %s: %+v", recipient, err)
+			}
+			i++
+		}
+
+		// Serialize lists into a printable format
+		ephemeralIdsString := ephemeralIdListToString(ephemeralIds)
+		encMsgsDigest := messagesToDigestString(encMsgs)
+
+		jww.INFO.Printf("Sending to EphIDs [%s] (%s) on round %d, "+
+			"(msgDigest: %s, ecrMsgDigest: %s) via gateway %s",
+			ephemeralIdsString, recipientString, bestRound.ID, msgDigests,
+			encMsgsDigest, firstGateway)
+
+		// Wrap slots in the proper message type
+		wrappedMessage := &pb.GatewaySlots{
+			Messages: slots,
+			RoundID:  bestRound.ID,
+		}
+
+		// Send the payload
+		sendFunc := func(host *connect.Host, target *id.ID) (interface{}, bool, error) {
+			wrappedMessage.Target = target.Marshal()
+			result, err := comms.SendPutManyMessages(host, wrappedMessage)
+			if err != nil {
+				warn, err := handlePutMessageError(firstGateway, instance,
+					session, nodeRegistration, recipientString, bestRound, err)
+				if warn {
+					jww.WARN.Printf("SendManyCMIX Failed: %+v", err)
+				} else {
+					return result, false, errors.WithMessagef(err,
+						"SendManyCMIX %s", unrecoverableError)
+				}
+			}
+			return result, false, err
+		}
+		result, err := sender.SendToPreferred([]*id.ID{firstGateway}, sendFunc, nil)
+
+		// If the comm errors or the message fails to send, continue retrying
+		if err != nil {
+			if !strings.Contains(err.Error(), unrecoverableError) {
+				jww.ERROR.Printf("SendManyCMIX failed to send to EphIDs [%s] "+
+					"(sources: %s) on round %d, trying a new round %+v",
+					ephemeralIdsString, recipientString, bestRound.ID, err)
+				continue
+			}
+
+			return 0, []ephemeral.Id{}, err
+		}
+
+		// Return if it sends properly
+		gwSlotResp := result.(*pb.GatewaySlotResponse)
+		if gwSlotResp.Accepted {
+			jww.INFO.Printf("Successfully sent to EphIDs %v (sources: [%s]) in "+
+				"round %d", ephemeralIdsString, recipientString, bestRound.ID)
+			return id.Round(bestRound.ID), ephemeralIds, nil
+		} else {
+			jww.FATAL.Panicf("Gateway %s returned no error, but failed to "+
+				"accept message when sending to EphIDs [%s] (%s) on round %d",
+				firstGateway, ephemeralIdsString, recipientString, bestRound.ID)
+		}
+	}
+
+	return 0, []ephemeral.Id{},
+		errors.New("failed to send the message, unknown error")
+}
diff --git a/network/message/sendManyCmix_test.go b/network/message/sendManyCmix_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..2d07cf456e4177af469a44360132590bb4fc62d3
--- /dev/null
+++ b/network/message/sendManyCmix_test.go
@@ -0,0 +1,134 @@
+package message
+
+import (
+	"github.com/pkg/errors"
+	"gitlab.com/elixxir/client/interfaces/message"
+	"gitlab.com/elixxir/client/interfaces/params"
+	"gitlab.com/elixxir/client/network/gateway"
+	"gitlab.com/elixxir/client/network/internal"
+	"gitlab.com/elixxir/client/storage"
+	"gitlab.com/elixxir/client/switchboard"
+	"gitlab.com/elixxir/comms/client"
+	"gitlab.com/elixxir/comms/mixmessages"
+	"gitlab.com/elixxir/comms/network"
+	ds "gitlab.com/elixxir/comms/network/dataStructures"
+	"gitlab.com/elixxir/comms/testutils"
+	"gitlab.com/elixxir/crypto/cyclic"
+	"gitlab.com/elixxir/crypto/e2e"
+	"gitlab.com/elixxir/crypto/fastRNG"
+	"gitlab.com/elixxir/primitives/format"
+	"gitlab.com/elixxir/primitives/states"
+	"gitlab.com/xx_network/crypto/csprng"
+	"gitlab.com/xx_network/crypto/large"
+	"gitlab.com/xx_network/primitives/id"
+	"gitlab.com/xx_network/primitives/netTime"
+	"testing"
+	"time"
+)
+
+// Unit test
+func Test_attemptSendManyCmix(t *testing.T) {
+	sess1 := storage.InitTestingSession(t)
+
+	numRecipients := 3
+	recipients := make([]*id.ID, numRecipients)
+	sw := switchboard.New()
+	l := TestListener{
+		ch: make(chan bool),
+	}
+	for i := 0; i < numRecipients; i++ {
+		sess := storage.InitTestingSession(t)
+		sw.RegisterListener(sess.GetUser().TransmissionID, message.Raw, l)
+		recipients[i] = sess.GetUser().ReceptionID
+	}
+
+	comms, err := client.NewClientComms(sess1.GetUser().TransmissionID, nil, nil, nil)
+	if err != nil {
+		t.Errorf("Failed to start client comms: %+v", err)
+	}
+	inst, err := network.NewInstanceTesting(comms.ProtoComms, getNDF(), nil, nil, nil, t)
+	if err != nil {
+		t.Errorf("Failed to start instance: %+v", err)
+	}
+	now := netTime.Now()
+	nid1 := id.NewIdFromString("zezima", id.Node, t)
+	nid2 := id.NewIdFromString("jakexx360", id.Node, t)
+	nid3 := id.NewIdFromString("westparkhome", id.Node, t)
+	grp := cyclic.NewGroup(large.NewInt(7), large.NewInt(13))
+	sess1.Cmix().Add(nid1, grp.NewInt(1))
+	sess1.Cmix().Add(nid2, grp.NewInt(2))
+	sess1.Cmix().Add(nid3, grp.NewInt(3))
+
+	timestamps := []uint64{
+		uint64(now.Add(-30 * time.Second).UnixNano()), // PENDING
+		uint64(now.Add(-25 * time.Second).UnixNano()), // PRECOMPUTING
+		uint64(now.Add(-5 * time.Second).UnixNano()),  // STANDBY
+		uint64(now.Add(5 * time.Second).UnixNano()),   // QUEUED
+		0} // REALTIME
+
+	ri := &mixmessages.RoundInfo{
+		ID:                         3,
+		UpdateID:                   0,
+		State:                      uint32(states.QUEUED),
+		BatchSize:                  0,
+		Topology:                   [][]byte{nid1.Marshal(), nid2.Marshal(), nid3.Marshal()},
+		Timestamps:                 timestamps,
+		Errors:                     nil,
+		ClientErrors:               nil,
+		ResourceQueueTimeoutMillis: 0,
+		Signature:                  nil,
+		AddressSpaceSize:           4,
+	}
+
+	if err = testutils.SignRoundInfoRsa(ri, t); err != nil {
+		t.Errorf("Failed to sign mock round info: %v", err)
+	}
+
+	pubKey, err := testutils.LoadPublicKeyTesting(t)
+	if err != nil {
+		t.Errorf("Failed to load a key for testing: %v", err)
+	}
+	rnd := ds.NewRound(ri, pubKey, nil)
+	inst.GetWaitingRounds().Insert(rnd)
+	i := internal.Internal{
+		Session:          sess1,
+		Switchboard:      sw,
+		Rng:              fastRNG.NewStreamGenerator(1, 1, csprng.NewSystemRNG),
+		Comms:            comms,
+		Health:           nil,
+		TransmissionID:   sess1.GetUser().TransmissionID,
+		Instance:         inst,
+		NodeRegistration: nil,
+	}
+	p := gateway.DefaultPoolParams()
+	p.MaxPoolSize = 1
+	sender, err := gateway.NewSender(p, i.Rng, getNDF(), &MockSendCMIXComms{t: t}, i.Session, nil)
+	if err != nil {
+		t.Errorf("%+v", errors.New(err.Error()))
+		return
+	}
+	m := NewManager(i, params.Messages{
+		MessageReceptionBuffLen:        20,
+		MessageReceptionWorkerPoolSize: 20,
+		MaxChecksGarbledMessage:        20,
+		GarbledMessageWait:             time.Hour,
+	}, nil, sender)
+	msgCmix := format.NewMessage(m.Session.Cmix().GetGroup().GetP().ByteLen())
+	msgCmix.SetContents([]byte("test"))
+	e2e.SetUnencrypted(msgCmix, m.Session.User().GetCryptographicIdentity().GetTransmissionID())
+	messages := make([]format.Message, numRecipients)
+	for i := 0; i < numRecipients; i++ {
+		messages[i] = msgCmix
+	}
+
+	msgMap := make(map[id.ID]format.Message, numRecipients)
+	for i := 0; i < numRecipients; i++ {
+		msgMap[*recipients[i]] = msgCmix
+	}
+
+	_, _, err = sendManyCmixHelper(sender, msgMap, params.GetDefaultCMIX(), m.Instance,
+		m.Session, m.nodeRegistration, m.Rng, m.TransmissionID, &MockSendCMIXComms{t: t})
+	if err != nil {
+		t.Errorf("Failed to sendcmix: %+v", err)
+	}
+}
diff --git a/network/message/utils_test.go b/network/message/utils_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..18284ad0bb6b6693db062f2fb5b048b5e18ca08f
--- /dev/null
+++ b/network/message/utils_test.go
@@ -0,0 +1,106 @@
+package message
+
+import (
+	"gitlab.com/elixxir/comms/mixmessages"
+	"gitlab.com/xx_network/comms/connect"
+	"gitlab.com/xx_network/primitives/id"
+	"gitlab.com/xx_network/primitives/ndf"
+	"testing"
+)
+
+type MockSendCMIXComms struct {
+	t *testing.T
+}
+
+func (mc *MockSendCMIXComms) GetHost(*id.ID) (*connect.Host, bool) {
+	nid1 := id.NewIdFromString("zezima", id.Node, mc.t)
+	gwID := nid1.DeepCopy()
+	gwID.SetType(id.Gateway)
+	h, _ := connect.NewHost(gwID, "0.0.0.0", []byte(""), connect.HostParams{
+		MaxRetries:  0,
+		AuthEnabled: false,
+	})
+	return h, true
+}
+
+func (mc *MockSendCMIXComms) AddHost(*id.ID, string, []byte, connect.HostParams) (host *connect.Host, err error) {
+	host, _ = mc.GetHost(nil)
+	return host, nil
+}
+
+func (mc *MockSendCMIXComms) RemoveHost(*id.ID) {
+
+}
+
+func (mc *MockSendCMIXComms) SendPutMessage(*connect.Host, *mixmessages.GatewaySlot) (*mixmessages.GatewaySlotResponse, error) {
+	return &mixmessages.GatewaySlotResponse{
+		Accepted: true,
+		RoundID:  3,
+	}, nil
+}
+
+func (mc *MockSendCMIXComms) SendPutManyMessages(*connect.Host, *mixmessages.GatewaySlots) (*mixmessages.GatewaySlotResponse, error) {
+	return &mixmessages.GatewaySlotResponse{
+		Accepted: true,
+		RoundID:  3,
+	}, nil
+}
+
+func getNDF() *ndf.NetworkDefinition {
+	nodeId := id.NewIdFromString("zezima", id.Node, &testing.T{})
+	gwId := nodeId.DeepCopy()
+	gwId.SetType(id.Gateway)
+	return &ndf.NetworkDefinition{
+		E2E: ndf.Group{
+			Prime: "E2EE983D031DC1DB6F1A7A67DF0E9A8E5561DB8E8D49413394C049B7A" +
+				"8ACCEDC298708F121951D9CF920EC5D146727AA4AE535B0922C688B55B3D" +
+				"D2AEDF6C01C94764DAB937935AA83BE36E67760713AB44A6337C20E78615" +
+				"75E745D31F8B9E9AD8412118C62A3E2E29DF46B0864D0C951C394A5CBBDC" +
+				"6ADC718DD2A3E041023DBB5AB23EBB4742DE9C1687B5B34FA48C3521632C" +
+				"4A530E8FFB1BC51DADDF453B0B2717C2BC6669ED76B4BDD5C9FF558E88F2" +
+				"6E5785302BEDBCA23EAC5ACE92096EE8A60642FB61E8F3D24990B8CB12EE" +
+				"448EEF78E184C7242DD161C7738F32BF29A841698978825B4111B4BC3E1E" +
+				"198455095958333D776D8B2BEEED3A1A1A221A6E37E664A64B83981C46FF" +
+				"DDC1A45E3D5211AAF8BFBC072768C4F50D7D7803D2D4F278DE8014A47323" +
+				"631D7E064DE81C0C6BFA43EF0E6998860F1390B5D3FEACAF1696015CB79C" +
+				"3F9C2D93D961120CD0E5F12CBB687EAB045241F96789C38E89D796138E63" +
+				"19BE62E35D87B1048CA28BE389B575E994DCA755471584A09EC723742DC3" +
+				"5873847AEF49F66E43873",
+			Generator: "2",
+		},
+		CMIX: ndf.Group{
+			Prime: "9DB6FB5951B66BB6FE1E140F1D2CE5502374161FD6538DF1648218642" +
+				"F0B5C48C8F7A41AADFA187324B87674FA1822B00F1ECF8136943D7C55757" +
+				"264E5A1A44FFE012E9936E00C1D3E9310B01C7D179805D3058B2A9F4BB6F" +
+				"9716BFE6117C6B5B3CC4D9BE341104AD4A80AD6C94E005F4B993E14F091E" +
+				"B51743BF33050C38DE235567E1B34C3D6A5C0CEAA1A0F368213C3D19843D" +
+				"0B4B09DCB9FC72D39C8DE41F1BF14D4BB4563CA28371621CAD3324B6A2D3" +
+				"92145BEBFAC748805236F5CA2FE92B871CD8F9C36D3292B5509CA8CAA77A" +
+				"2ADFC7BFD77DDA6F71125A7456FEA153E433256A2261C6A06ED3693797E7" +
+				"995FAD5AABBCFBE3EDA2741E375404AE25B",
+			Generator: "5C7FF6B06F8F143FE8288433493E4769C4D988ACE5BE25A0E2480" +
+				"9670716C613D7B0CEE6932F8FAA7C44D2CB24523DA53FBE4F6EC3595892D" +
+				"1AA58C4328A06C46A15662E7EAA703A1DECF8BBB2D05DBE2EB956C142A33" +
+				"8661D10461C0D135472085057F3494309FFA73C611F78B32ADBB5740C361" +
+				"C9F35BE90997DB2014E2EF5AA61782F52ABEB8BD6432C4DD097BC5423B28" +
+				"5DAFB60DC364E8161F4A2A35ACA3A10B1C4D203CC76A470A33AFDCBDD929" +
+				"59859ABD8B56E1725252D78EAC66E71BA9AE3F1DD2487199874393CD4D83" +
+				"2186800654760E1E34C09E4D155179F9EC0DC4473F996BDCE6EED1CABED8" +
+				"B6F116F7AD9CF505DF0F998E34AB27514B0FFE7",
+		},
+		Gateways: []ndf.Gateway{
+			{
+				ID:             gwId.Marshal(),
+				Address:        "0.0.0.0",
+				TlsCertificate: "",
+			},
+		},
+		Nodes: []ndf.Node{
+			{
+				ID:             nodeId.Marshal(),
+				Address:        "0.0.0.0",
+				TlsCertificate: "",
+			},
+		},
+	}
+}
diff --git a/network/rounds/retrieve.go b/network/rounds/retrieve.go
index d69cc06b9a68a1391fa59440deaa5c6e3b6fad69..caf5ce0e70d2b688a2045feb8c0dd21bcecdee6a 100644
--- a/network/rounds/retrieve.go
+++ b/network/rounds/retrieve.go
@@ -128,6 +128,7 @@ func (m *Manager) processMessageRetrieval(comms messageRetrievalComms,
 
 				// If successful and there are messages, we send them to another thread
 				bundle.Identity = rl.identity
+				bundle.RoundInfo = rl.roundInfo
 				m.messageBundles <- bundle
 			}
 
diff --git a/network/send.go b/network/send.go
index d09390c6f3bbb97c729e2bed480bc3a68899d53c..d54e478c7c705f4995c451b1e302e5ae77d292f7 100644
--- a/network/send.go
+++ b/network/send.go
@@ -32,6 +32,15 @@ func (m *manager) SendCMIX(msg format.Message, recipient *id.ID, param params.CM
 	return m.message.SendCMIX(m.GetSender(), msg, recipient, param, nil)
 }
 
+// SendManyCMIX sends many "raw" CMIX message payloads to each of the
+// provided recipients. Used for group chat functionality. Returns the
+// round ID of the round the payload was sent or an error if it fails.
+func (m *manager) SendManyCMIX(messages map[id.ID]format.Message,
+	p params.CMIX) (id.Round, []ephemeral.Id, error) {
+
+	return m.message.SendManyCMIX(m.sender, messages, p)
+}
+
 // SendUnsafe sends an unencrypted payload to the provided recipient
 // with the provided msgType. Returns the list of rounds in which parts
 // of the message were sent or an error if it fails.
diff --git a/single/manager_test.go b/single/manager_test.go
index 8efc25fe0dcb94126cb374a2634e70fdc71be45c..4d570c35e900e2f36d1ee77bb76a298e927f09a7 100644
--- a/single/manager_test.go
+++ b/single/manager_test.go
@@ -306,6 +306,23 @@ func (tnm *testNetworkManager) SendCMIX(msg format.Message, _ *id.ID, _ params.C
 	return id.Round(rand.Uint64()), ephemeral.Id{}, nil
 }
 
+func (tnm *testNetworkManager) SendManyCMIX(messages map[id.ID]format.Message, p params.CMIX) (id.Round, []ephemeral.Id, error) {
+	if tnm.cmixTimeout != 0 {
+		time.Sleep(tnm.cmixTimeout)
+	} else if tnm.cmixErr {
+		return 0, []ephemeral.Id{}, errors.New("sendCMIX error")
+	}
+
+	tnm.Lock()
+	defer tnm.Unlock()
+
+	for _, msg := range messages {
+		tnm.msgs = append(tnm.msgs, msg)
+	}
+
+	return id.Round(rand.Uint64()), []ephemeral.Id{}, nil
+}
+
 func (tnm *testNetworkManager) GetInstance() *network.Instance {
 	return tnm.instance
 }
diff --git a/single/singleUseMap_test.go b/single/singleUseMap_test.go
index eb1f5ef5a4d3b2f942396be1ae72e6971081f5b4..1f91c755f048fa2a2444109a521931b21cdc6867 100644
--- a/single/singleUseMap_test.go
+++ b/single/singleUseMap_test.go
@@ -117,7 +117,7 @@ func Test_pending_addState_TimeoutError(t *testing.T) {
 			*expectedState, *state)
 	}
 
-	timer := time.NewTimer(timeout * 4)
+	timerTimeout := timeout * 4
 
 	select {
 	case results := <-callbackChan:
@@ -132,8 +132,8 @@ func Test_pending_addState_TimeoutError(t *testing.T) {
 		if results.err == nil || !strings.Contains(results.err.Error(), "timed out") {
 			t.Errorf("Callback did not return a time out error on return: %+v", results.err)
 		}
-	case <-timer.C:
-		t.Error("Failed to time out.")
+	case <-time.NewTimer(timerTimeout).C:
+		t.Errorf("Failed to time out after %s.", timerTimeout)
 	}
 }
 
diff --git a/storage/e2e/relationship.go b/storage/e2e/relationship.go
index a492a72ed51d0f48f08657d786bc8dc6a3c861ed..f92e12b6901b206dcdf2cae00200555eb762894b 100644
--- a/storage/e2e/relationship.go
+++ b/storage/e2e/relationship.go
@@ -71,7 +71,7 @@ func NewRelationship(manager *Manager, t RelationshipType,
 
 	if err := s.save(); err != nil {
 		jww.FATAL.Panicf("Failed to Send session after setting to "+
-			"confimred: %+v", err)
+			"confirmed: %+v", err)
 	}
 
 	r.addSession(s)
diff --git a/storage/e2e/session.go b/storage/e2e/session.go
index ad684725a04918389f706566e44642c6c1fbaf16..6e85d873e36768d2372b5f713c45527625233c3c 100644
--- a/storage/e2e/session.go
+++ b/storage/e2e/session.go
@@ -105,8 +105,8 @@ func newSession(ship *relationship, t RelationshipType, myPrivKey, partnerPubKey
 	negotiationStatus Negotiation, e2eParams params.E2ESessionParams) *Session {
 
 	if e2eParams.MinKeys < 10 {
-		jww.FATAL.Panicf("Cannot create a session with a minnimum number " +
-			"of keys less than 10")
+		jww.FATAL.Panicf("Cannot create a session with a minimum number "+
+			"of keys (%d) less than 10", e2eParams.MinKeys)
 	}
 
 	session := &Session{
diff --git a/storage/e2e/store.go b/storage/e2e/store.go
index e647cacab042388b667d383f670c8082c450b591..b6dc289c9b83edfa42e1468a015203d67dd7c1cd 100644
--- a/storage/e2e/store.go
+++ b/storage/e2e/store.go
@@ -14,6 +14,7 @@ import (
 	"gitlab.com/elixxir/client/interfaces/params"
 	"gitlab.com/elixxir/client/storage/utility"
 	"gitlab.com/elixxir/client/storage/versioned"
+	"gitlab.com/elixxir/crypto/contact"
 	"gitlab.com/elixxir/crypto/cyclic"
 	"gitlab.com/elixxir/crypto/diffieHellman"
 	"gitlab.com/elixxir/crypto/fastRNG"
@@ -180,7 +181,7 @@ func (s *Store) AddPartner(partnerID *id.ID, partnerPubKey, myPrivKey *cyclic.In
 
 	s.managers[*partnerID] = m
 	if err := s.save(); err != nil {
-		jww.FATAL.Printf("Failed to add Parter %s: Save of store failed: %s",
+		jww.FATAL.Printf("Failed to add Partner %s: Save of store failed: %s",
 			partnerID, err)
 	}
 
@@ -200,6 +201,28 @@ func (s *Store) GetPartner(partnerID *id.ID) (*Manager, error) {
 	return m, nil
 }
 
+// GetPartnerContact find the partner with the given ID and assembles and
+// returns a contact.Contact with their ID and DH key. An error is returned if
+// no partner exists for the given ID.
+func (s *Store) GetPartnerContact(partnerID *id.ID) (contact.Contact, error) {
+	s.mux.RLock()
+	defer s.mux.RUnlock()
+
+	// Get partner
+	m, exists := s.managers[*partnerID]
+	if !exists {
+		return contact.Contact{}, errors.New(NoPartnerErrorStr)
+	}
+
+	// Assemble Contact
+	c := contact.Contact{
+		ID:       m.GetPartnerID(),
+		DhPubKey: m.GetPartnerOriginPublicKey(),
+	}
+
+	return c, nil
+}
+
 // PopKey pops a key for use based upon its fingerprint.
 func (s *Store) PopKey(f format.Fingerprint) (*Key, bool) {
 	return s.fingerprints.Pop(f)
diff --git a/storage/e2e/store_test.go b/storage/e2e/store_test.go
index 944b2517df666e536db085d6ce3f0a001316aa2d..ef23f381f0370afec21f22c75d65dee892c93163 100644
--- a/storage/e2e/store_test.go
+++ b/storage/e2e/store_test.go
@@ -11,6 +11,7 @@ import (
 	"bytes"
 	"gitlab.com/elixxir/client/interfaces/params"
 	"gitlab.com/elixxir/client/storage/versioned"
+	"gitlab.com/elixxir/crypto/contact"
 	"gitlab.com/elixxir/crypto/cyclic"
 	"gitlab.com/elixxir/crypto/diffieHellman"
 	"gitlab.com/elixxir/crypto/fastRNG"
@@ -118,7 +119,7 @@ func TestStore_GetPartner(t *testing.T) {
 	p := params.GetDefaultE2ESessionParams()
 	expectedManager := newManager(s.context, s.kv, partnerID, s.dhPrivateKey,
 		pubKey, p, p)
-	s.AddPartner(partnerID, pubKey, s.dhPrivateKey, p, p)
+	_ = s.AddPartner(partnerID, pubKey, s.dhPrivateKey, p, p)
 
 	m, err := s.GetPartner(partnerID)
 	if err != nil {
@@ -147,6 +148,41 @@ func TestStore_GetPartner_Error(t *testing.T) {
 	}
 }
 
+// Tests happy path of Store.GetPartnerContact.
+func TestStore_GetPartnerContact(t *testing.T) {
+	s, _, _ := makeTestStore()
+	partnerID := id.NewIdFromUInt(rand.Uint64(), id.User, t)
+	pubKey := diffieHellman.GeneratePublicKey(s.dhPrivateKey, s.grp)
+	p := params.GetDefaultE2ESessionParams()
+	expected := contact.Contact{
+		ID:       partnerID,
+		DhPubKey: pubKey,
+	}
+	_ = s.AddPartner(partnerID, pubKey, s.dhPrivateKey, p, p)
+
+	c, err := s.GetPartnerContact(partnerID)
+	if err != nil {
+		t.Errorf("GetPartnerContact() produced an error: %+v", err)
+	}
+
+	if !reflect.DeepEqual(expected, c) {
+		t.Errorf("GetPartnerContact() returned wrong Contact."+
+			"\nexpected: %s\nreceived: %s", expected, c)
+	}
+}
+
+// Tests that Store.GetPartnerContact returns an error for non existent partnerID.
+func TestStore_GetPartnerContact_Error(t *testing.T) {
+	s, _, _ := makeTestStore()
+	partnerID := id.NewIdFromUInt(rand.Uint64(), id.User, t)
+
+	_, err := s.GetPartnerContact(partnerID)
+	if err == nil || err.Error() != NoPartnerErrorStr {
+		t.Errorf("GetPartnerContact() did not produce the expected error."+
+			"\nexpected: %s\nreceived: %+v", NoPartnerErrorStr, err)
+	}
+}
+
 // Tests happy path of Store.PopKey.
 func TestStore_PopKey(t *testing.T) {
 	s, _, _ := makeTestStore()
diff --git a/storage/session.go b/storage/session.go
index 0277166a98b9dd51f06509efb6f35e514aae249d..7fdeea29e807fac8de73b3e92b20da789682962f 100644
--- a/storage/session.go
+++ b/storage/session.go
@@ -315,6 +315,13 @@ func (s *Session) Delete(key string) error {
 	return s.kv.Delete(key, currentSessionVersion)
 }
 
+// GetKV returns the Session versioned.KV.
+func (s *Session) GetKV() *versioned.KV {
+	s.mux.RLock()
+	defer s.mux.RUnlock()
+	return s.kv
+}
+
 // Initializes a Session object wrapped around a MemStore object.
 // FOR TESTING ONLY
 func InitTestingSession(i interface{}) *Session {
@@ -343,7 +350,6 @@ func InitTestingSession(i interface{}) *Session {
 	}
 	u.SetRegistrationTimestamp(testTime.UnixNano())
 
-
 	s.user = u
 	cmixGrp := cyclic.NewGroup(
 		large.NewIntFromString("9DB6FB5951B66BB6FE1E140F1D2CE5502374161FD6538DF1648218642F0B5C48"+