Repository: mautrix/signal (language: python)

Issue:
Unable to send messages to new contacts
When you send 'pm +xxxxx' to the signal bridge and you haven't spoke to the phone number before an error responds saying:


```Server only returned PNI (x-x-x-x-x) for +xxxxxxxxxxx, but the bridge doesn't know what to do with it```


My understanding is this is because of the recent Signal update. This stops the entire system working if Signal messages can no longer be sent.


Discussion / hints:

Fix patch:
diff --git a/README.md b/README.md
index 4e644f58..82e96246 100644
--- a/README.md
+++ b/README.md
@@ -15,8 +15,7 @@ Some quick links:
 * Basic usage: [Authentication](https://docs.mau.fi/bridges/go/signal/authentication.html)
 
 ### Features & Roadmap
-[ROADMAP.md](https://github.com/mautrix/signal/blob/main/ROADMAP.md)
-contains a general overview of what is supported by the bridge.
+[ROADMAP.md](ROADMAP.md) contains a general overview of what is supported by the bridge.
 
 ## Discussion
 Matrix room: [`#signal:maunium.net`](https://matrix.to/#/#signal:maunium.net)
diff --git a/ROADMAP.md b/ROADMAP.md
index a060c0de..85c7494f 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -16,15 +16,16 @@
   * [x] Message edits
   * [x] Message reactions
   * [x] Message redactions
-  * [ ] Group info changes
-    * [ ] Name
-    * [ ] Avatar
-    * [ ] Topic
+  * [x] Group info changes
+    * [x] Name
+    * [x] Avatar
+    * [x] Topic
   * [ ] Membership actions
     * [ ] Join (accepting invites)
-    * [ ] Invite
-    * [ ] Leave
-    * [ ] Kick/Ban/Unban
+    * [x] Invite
+    * [x] Leave
+    * [x] Kick/Ban/Unban
+  * [x] Group permissions
   * [x] Typing notifications
   * [x] Read receipts
   * [x] Delivery receipts (sent after message is bridged)
diff --git a/commands.go b/commands.go
index 3f3c80d4..68a0f9fc 100644
--- a/commands.go
+++ b/commands.go
@@ -18,18 +18,24 @@ package main
 
 import (
 	"context"
+	"crypto/sha256"
+	"encoding/hex"
+	"errors"
 	"fmt"
 	"strconv"
 	"strings"
 
 	"github.com/google/uuid"
+	"github.com/rs/zerolog"
 	"github.com/skip2/go-qrcode"
 	"maunium.net/go/mautrix"
 	"maunium.net/go/mautrix/bridge/commands"
 	"maunium.net/go/mautrix/event"
 	"maunium.net/go/mautrix/id"
 
+	"go.mau.fi/mautrix-signal/pkg/libsignalgo"
 	"go.mau.fi/mautrix-signal/pkg/signalmeow"
+	"go.mau.fi/mautrix-signal/pkg/signalmeow/types"
 )
 
 var (
@@ -62,6 +68,9 @@ func (br *SignalBridge) RegisterCommands() {
 		cmdDeletePortal,
 		cmdDeleteAllPortals,
 		cmdCleanupLostPortals,
+		cmdInviteLink,
+		cmdResetInviteLink,
+		cmdCreate,
 	)
 }
 
@@ -212,39 +221,49 @@ func fnPM(ce *WrappedCommandEvent) {
 	}
 
 	user := ce.User
-	var targetUUID uuid.UUID
+	var aci, pni uuid.UUID
+	e164 := fmt.Sprintf("+%d", number)
+	var recipient *types.Recipient
 
-	if contact, err := user.Client.ContactByE164(ce.Ctx, fmt.Sprintf("+%d", number)); err != nil {
+	if recipient, err = user.Client.ContactByE164(ce.Ctx, e164); err != nil {
 		ce.Reply("Error looking up number in local contact list: %v", err)
 		return
-	} else if contact != nil {
-		targetUUID = contact.UUID
+	} else if recipient != nil && (recipient.ACI != uuid.Nil || recipient.PNI != uuid.Nil) {
+		// TODO maybe lookup PNI if there's only ACI and E164 stored?
+		aci = recipient.ACI
+		pni = recipient.PNI
 	} else if resp, err := user.Client.LookupPhone(ce.Ctx, number); err != nil {
 		ce.ZLog.Err(err).Uint64("e164", number).Msg("Failed to lookup number on server")
 		ce.Reply("Error looking up number on server: %v", err)
 		return
-	} else if resp[number].ACI == uuid.Nil {
-		if resp[number].PNI == uuid.Nil {
+	} else {
+		aci = resp[number].ACI
+		pni = resp[number].PNI
+		if aci == uuid.Nil && pni == uuid.Nil {
 			ce.Reply("+%d doesn't seem to be on Signal", number)
-		} else {
-			ce.Reply("Server only returned PNI (%s) for +%d, but the bridge doesn't know what to do with it", resp[number].PNI, number)
+			return
 		}
-		return
-	} else {
-		targetUUID = resp[number].ACI
-		err = user.Client.Store.ContactStore.UpdatePhone(ce.Ctx, targetUUID, fmt.Sprintf("+%d", number))
+		recipient, err = user.Client.Store.RecipientStore.UpdateRecipientE164(ce.Ctx, aci, pni, e164)
 		if err != nil {
-			ce.ZLog.Warn().Err(err).Msg("Failed to update phone number in user's contact store")
+			ce.ZLog.Err(err).Msg("Failed to save recipient entry after looking up phone")
 		}
+		aci, pni = recipient.ACI, recipient.PNI
 	}
 	ce.ZLog.Debug().
 		Uint64("e164", number).
-		Stringer("uuid", targetUUID).
+		Stringer("aci", aci).
+		Stringer("pni", pni).
 		Msg("Found DM target user")
 
-	portal := user.GetPortalByChatID(targetUUID.String())
+	var targetServiceID libsignalgo.ServiceID
+	if aci != uuid.Nil {
+		targetServiceID = libsignalgo.NewACIServiceID(aci)
+	} else {
+		targetServiceID = libsignalgo.NewPNIServiceID(pni)
+	}
+	portal := user.GetPortalByChatID(targetServiceID.String())
 	if portal == nil {
-		ce.Reply("Couldn't get portal with %s/+%d", targetUUID, number)
+		ce.Reply("Couldn't get portal with %s/+%d", targetServiceID, number)
 		return
 	} else if portal.MXID != "" {
 		ok := portal.ensureUserInvited(ce.Ctx, ce.User)
@@ -295,12 +314,6 @@ func fnResolvePhone(ce *WrappedCommandEvent) {
 			result, found := resp[phone]
 			if found {
 				_, _ = fmt.Fprintf(&out, "+%d: %s / %s\n", phone, result.ACI, result.PNI)
-				if result.ACI != uuid.Nil {
-					err = ce.User.Client.Store.ContactStore.UpdatePhone(ce.Ctx, result.ACI, fmt.Sprintf("+%d", phone))
-					if err != nil {
-						ce.ZLog.Warn().Err(err).Msg("Failed to update phone number in user's contact store")
-					}
-				}
 			} else {
 				_, _ = fmt.Fprintf(&out, "+%d: not found\n", phone)
 			}
@@ -642,3 +655,264 @@ func fnCleanupLostPortals(ce *WrappedCommandEvent) {
 	}
 	ce.Reply("Finished cleaning up portals")
 }
+
+var cmdInviteLink = &commands.FullHandler{
+	Func: wrapCommand(fnInviteLink),
+	Name: "invite-link",
+	Help: commands.HelpMeta{
+		Section:     HelpSectionPortalManagement,
+		Description: "Get the invite link for the corresponding Signal Group",
+	},
+	RequiresLogin: true,
+}
+
+func fnInviteLink(ce *WrappedCommandEvent) {
+	if ce.Portal == nil {
+		ce.Reply("This is not a portal room")
+		return
+	}
+	if ce.Portal.IsPrivateChat() {
+		ce.Reply("Invite Links are not available for private chats")
+		return
+	}
+	inviteLinkPassword, err := ce.Portal.GetInviteLink(ce.Ctx, ce.User)
+	if err != nil {
+		ce.Reply("Error getting invite link %w", err)
+		return
+	}
+	ce.Reply(inviteLinkPassword)
+}
+
+var cmdResetInviteLink = &commands.FullHandler{
+	Func: wrapCommand(fnResetInviteLink),
+	Name: "reset-invite-link",
+	Help: commands.HelpMeta{
+		Section:     HelpSectionPortalManagement,
+		Description: "Generate a new invite link password",
+	},
+	RequiresLogin: true,
+}
+
+func fnResetInviteLink(ce *WrappedCommandEvent) {
+	if ce.Portal == nil {
+		ce.Reply("This is not a portal room")
+		return
+	}
+	if ce.Portal.IsPrivateChat() {
+		ce.Reply("Invite Links are not available for private chats")
+		return
+	}
+	err := ce.Portal.ResetInviteLink(ce.Ctx, ce.User)
+	if err != nil {
+		ce.Reply("Error setting new invite link %w", err)
+	}
+	inviteLink, err := ce.Portal.GetInviteLink(ce.Ctx, ce.User)
+	if err != nil {
+		ce.Reply("Error getting new invite link %w", err)
+		return
+	}
+	ce.Reply(inviteLink)
+}
+
+var cmdCreate = &commands.FullHandler{
+	Func: wrapCommand(fnCreate),
+	Name: "create",
+	Help: commands.HelpMeta{
+		Section:     HelpSectionCreatingPortals,
+		Description: "Create a Signal group chat for the current Matrix room.",
+	},
+	RequiresLogin: true,
+}
+
+func fnCreate(ce *WrappedCommandEvent) {
+	if ce.Portal != nil {
+		ce.Reply("This is already a portal room")
+		return
+	}
+
+	roomState, err := ce.Bot.State(ce.Ctx, ce.RoomID)
+	if err != nil {
+		ce.Reply("Failed to get room state: %w", err)
+		return
+	}
+	members := roomState[event.StateMember]
+	powerLevelsRaw, ok := roomState[event.StatePowerLevels][""]
+	if !ok {
+		ce.Reply("Failed to get room power levels")
+		return
+	}
+	powerLevelsRaw.Content.ParseRaw(event.StatePowerLevels)
+	powerLevels := powerLevelsRaw.Content.AsPowerLevels()
+	joinRulesRaw, ok := roomState[event.StateJoinRules][""]
+	if !ok {
+		ce.Reply("Failed to get join rules")
+		return
+	}
+	joinRulesRaw.Content.ParseRaw(event.StateJoinRules)
+	joinRule := joinRulesRaw.Content.AsJoinRules().JoinRule
+	roomNameEventRaw, ok := roomState[event.StateRoomName][""]
+	if !ok {
+		ce.Reply("Failed to get room name")
+		return
+	}
+	roomNameEventRaw.Content.ParseRaw(event.StateRoomName)
+	roomName := roomNameEventRaw.Content.AsRoomName().Name
+	if len(roomName) == 0 {
+		ce.Reply("Please set a name for the room first")
+		return
+	}
+	roomTopic := ""
+	roomTopicEvent, ok := roomState[event.StateTopic][""]
+	if ok {
+		roomTopicEvent.Content.ParseRaw(event.StateTopic)
+		roomTopic = roomTopicEvent.Content.AsTopic().Topic
+	}
+	roomAvatarEvent, ok := roomState[event.StateRoomAvatar][""]
+	var avatarHash string
+	var avatarURL id.ContentURI
+	var avatarBytes []byte
+	if ok {
+		roomAvatarEvent.Content.ParseRaw(event.StateRoomAvatar)
+		avatarURL = roomAvatarEvent.Content.AsRoomAvatar().URL
+		if !avatarURL.IsEmpty() {
+			avatarBytes, err = ce.Bot.DownloadBytes(ce.Ctx, avatarURL)
+			if err != nil {
+				ce.ZLog.Err(err).Stringer("Failed to download updated avatar %s", avatarURL)
+				return
+			}
+			hash := sha256.Sum256(avatarBytes)
+			avatarHash = hex.EncodeToString(hash[:])
+			ce.ZLog.Debug().Stringers("%s set the group avatar to %s", []fmt.Stringer{ce.User.MXID, avatarURL})
+		}
+	}
+	var encryptionEvent *event.EncryptionEventContent
+	encryptionEventContent, ok := roomState[event.StateEncryption][""]
+	if ok {
+		encryptionEventContent.Content.ParseRaw(event.StateEncryption)
+		encryptionEvent = encryptionEventContent.Content.AsEncryption()
+	}
+	var participants []*signalmeow.GroupMember
+	var bannedMembers []*signalmeow.BannedMember
+	participantDedup := make(map[uuid.UUID]bool)
+	participantDedup[uuid.Nil] = true
+	for key, member := range members {
+		mxid := id.UserID(key)
+		member.Content.ParseRaw(event.StateMember)
+		content := member.Content.AsMember()
+		membership := content.Membership
+		var uuid uuid.UUID
+		puppet := ce.Bridge.GetPuppetByMXID(mxid)
+		if puppet != nil {
+			uuid = puppet.SignalID
+		} else {
+			user := ce.Bridge.GetUserByMXID(mxid)
+			if user != nil && user.IsLoggedIn() {
+				uuid = user.SignalID
+			}
+		}
+		role := signalmeow.GroupMember_DEFAULT
+		if powerLevels.GetUserLevel(mxid) >= 50 {
+			role = signalmeow.GroupMember_ADMINISTRATOR
+		}
+		if !participantDedup[uuid] {
+			participantDedup[uuid] = true
+			// invites should be added on signal and then auto-joined
+			// joined members that need to be pending-Members should have their signal invite auto-accepted
+			if membership == event.MembershipJoin || membership == event.MembershipInvite {
+				participants = append(participants, &signalmeow.GroupMember{
+					UserID: uuid,
+					Role:   role,
+				})
+			} else if membership == event.MembershipBan {
+				bannedMembers = append(bannedMembers, &signalmeow.BannedMember{
+					UserID: uuid,
+				})
+			}
+		}
+	}
+	addFromInviteLinkAccess := signalmeow.AccessControl_UNSATISFIABLE
+	if joinRule == event.JoinRulePublic {
+		addFromInviteLinkAccess = signalmeow.AccessControl_ANY
+	} else if joinRule == event.JoinRuleKnock {
+		addFromInviteLinkAccess = signalmeow.AccessControl_ADMINISTRATOR
+	}
+	var inviteLinkPassword types.SerializedInviteLinkPassword
+	if addFromInviteLinkAccess != signalmeow.AccessControl_UNSATISFIABLE {
+		inviteLinkPassword = signalmeow.GenerateInviteLinkPassword()
+	}
+	membersAccess := signalmeow.AccessControl_MEMBER
+	if powerLevels.Invite() >= 50 {
+		membersAccess = signalmeow.AccessControl_ADMINISTRATOR
+	}
+	attributesAccess := signalmeow.AccessControl_MEMBER
+	if powerLevels.StateDefault() >= 50 {
+		attributesAccess = signalmeow.AccessControl_ADMINISTRATOR
+	}
+	announcementsOnly := false
+	if powerLevels.EventsDefault >= 50 {
+		announcementsOnly = true
+	}
+	ce.ZLog.Info().
+		Str("room_name", roomName).
+		Any("participants", participants).
+		Msg("Creating Signal group for Matrix room")
+	group, err := ce.User.Client.CreateGroupOnServer(ce.Ctx, &signalmeow.Group{
+		Title:       roomName,
+		Description: roomTopic,
+		Members:     participants,
+		AccessControl: &signalmeow.GroupAccessControl{
+			Members:           membersAccess,
+			Attributes:        attributesAccess,
+			AddFromInviteLink: addFromInviteLinkAccess,
+		},
+		InviteLinkPassword: &inviteLinkPassword,
+		BannedMembers:      bannedMembers,
+		AnnouncementsOnly:  announcementsOnly,
+	}, avatarBytes)
+	if err != nil {
+		ce.Reply("Failed to create group: %v", err)
+		return
+	}
+	gid := group.GroupIdentifier
+	ce.ZLog.UpdateContext(func(c zerolog.Context) zerolog.Context {
+		return c.Stringer("group_id", gid)
+	})
+	portal := ce.User.GetPortalByChatID(gid.String())
+	portal.roomCreateLock.Lock()
+	defer portal.roomCreateLock.Unlock()
+	if len(portal.MXID) != 0 {
+		ce.ZLog.Warn().Msg("Detected race condition in room creation")
+		// TODO race condition, clean up the old room
+	}
+	portal.MXID = ce.RoomID
+	portal.Name = roomName
+	portal.Encrypted = encryptionEvent.Algorithm == id.AlgorithmMegolmV1
+	if !portal.Encrypted && ce.Bridge.Config.Bridge.Encryption.Default {
+		_, err = portal.MainIntent().SendStateEvent(ce.Ctx, portal.MXID, event.StateEncryption, "", portal.GetEncryptionEventContent())
+		if err != nil {
+			ce.ZLog.Err(err).Msg("Failed to enable encryption in room")
+			if errors.Is(err, mautrix.MForbidden) {
+				ce.Reply("I don't seem to have permission to enable encryption in this room.")
+			} else {
+				ce.Reply("Failed to enable encryption in room: %v", err)
+			}
+		}
+		portal.Encrypted = true
+	}
+	revision, err := ce.User.Client.UpdateGroup(ce.Ctx, &signalmeow.GroupChange{}, gid)
+	if err != nil {
+		ce.Reply("Failed to update Group")
+		return
+	}
+	portal.Revision = revision
+	portal.AvatarHash = avatarHash
+	portal.AvatarURL = avatarURL
+	portal.AvatarPath = group.AvatarPath
+	portal.AvatarSet = true
+	err = portal.Update(ce.Ctx)
+	if err != nil {
+		ce.ZLog.Err(err).Msg("Failed to save portal after creating group")
+	}
+	portal.UpdateBridgeInfo(ce.Ctx)
+	ce.Reply("Successfully created Signal group %s", gid.String())
+}
diff --git a/config/bridge.go b/config/bridge.go
index 088d2869..2ce4bb9c 100644
--- a/config/bridge.go
+++ b/config/bridge.go
@@ -52,6 +52,7 @@ type BridgeConfig struct {
 	PublicPortals           bool `yaml:"public_portals"`
 	CaptionInMessage        bool `yaml:"caption_in_message"`
 	FederateRooms           bool `yaml:"federate_rooms"`
+	BridgeMatrixLeave       bool `yaml:"bridge_matrix_leave"`
 
 	DoublePuppetConfig bridgeconfig.DoublePuppetConfig `yaml:",inline"`
 
@@ -164,17 +165,21 @@ type DisplaynameParams struct {
 	Username    string
 	PhoneNumber string
 	UUID        string
+	ACI         string
+	PNI         string
 	AboutEmoji  string
 }
 
-func (bc BridgeConfig) FormatDisplayname(contact *types.Contact) string {
+func (bc BridgeConfig) FormatDisplayname(contact *types.Recipient) string {
 	var buffer strings.Builder
 	_ = bc.displaynameTemplate.Execute(&buffer, DisplaynameParams{
 		ProfileName: contact.Profile.Name,
 		ContactName: contact.ContactName,
 		//Username:    contact.Username,
 		PhoneNumber: contact.E164,
-		UUID:        contact.UUID.String(),
+		UUID:        contact.ACI.String(),
+		ACI:         contact.ACI.String(),
+		PNI:         contact.PNI.String(),
 		AboutEmoji:  contact.Profile.AboutEmoji,
 	})
 	return buffer.String()
diff --git a/config/upgrade.go b/config/upgrade.go
index cb0ab06c..0d943d67 100644
--- a/config/upgrade.go
+++ b/config/upgrade.go
@@ -125,6 +125,7 @@ func DoUpgrade(helper *up.Helper) {
 	helper.Copy(up.Int, "bridge", "encryption", "rotation", "milliseconds")
 	helper.Copy(up.Int, "bridge", "encryption", "rotation", "messages")
 	helper.Copy(up.Bool, "bridge", "encryption", "rotation", "disable_device_change_key_rotation")
+	helper.Copy(up.Bool, "bridge", "bridge_matrix_leave")
 
 	helper.Copy(up.Str, "bridge", "provisioning", "prefix")
 	if secret, ok := helper.Get(up.Str, "bridge", "provisioning", "shared_secret"); !ok || secret == "generate" {
diff --git a/database/portal.go b/database/portal.go
index 31132c67..786f084e 100644
--- a/database/portal.go
+++ b/database/portal.go
@@ -24,6 +24,7 @@ import (
 	"go.mau.fi/util/dbutil"
 	"maunium.net/go/mautrix/id"
 
+	"go.mau.fi/mautrix-signal/pkg/libsignalgo"
 	"go.mau.fi/mautrix-signal/pkg/signalmeow/types"
 )
 
@@ -56,6 +57,7 @@ const (
 		WHERE chat_id=$1 AND receiver=$2
 	`
 	deletePortalQuery = `DELETE FROM portal WHERE chat_id=$1 AND receiver=$2`
+	reIDPortalQuery   = `UPDATE portal SET chat_id=$2 WHERE chat_id=$1 AND receiver=$3`
 )
 
 type PortalQuery struct {
@@ -67,8 +69,8 @@ type PortalKey struct {
 	Receiver uuid.UUID
 }
 
-func (pk *PortalKey) UserID() uuid.UUID {
-	parsed, _ := uuid.Parse(pk.ChatID)
+func (pk *PortalKey) UserID() libsignalgo.ServiceID {
+	parsed, _ := libsignalgo.ServiceIDFromString(pk.ChatID)
 	return parsed
 }
 
@@ -198,3 +200,7 @@ func (p *Portal) Update(ctx context.Context) error {
 func (p *Portal) Delete(ctx context.Context) error {
 	return p.qh.Exec(ctx, deletePortalQuery, p.ChatID, p.Receiver)
 }
+
+func (p *Portal) ReID(ctx context.Context, newID string) error {
+	return p.qh.Exec(ctx, reIDPortalQuery, p.ChatID, newID, p.Receiver)
+}
diff --git a/example-config.yaml b/example-config.yaml
index a509a0c4..b61c355f 100644
--- a/example-config.yaml
+++ b/example-config.yaml
@@ -255,7 +255,8 @@ bridge:
             # Disable rotating keys when a user's devices change?
             # You should not enable this option unless you understand all the implications.
             disable_device_change_key_rotation: false
-
+    # Should leaving the room on Matrix make the user leave on Signal?
+    bridge_matrix_leave: true
     # Settings for provisioning API
     provisioning:
         # Prefix for the provisioning API paths.
diff --git a/go.mod b/go.mod
index 7359b165..949f87e8 100644
--- a/go.mod
+++ b/go.mod
@@ -20,7 +20,7 @@ require (
 	golang.org/x/exp v0.0.0-20240314144324-c7f7c6466f7f
 	golang.org/x/net v0.22.0
 	google.golang.org/protobuf v1.33.0
-	maunium.net/go/mautrix v0.18.0
+	maunium.net/go/mautrix v0.18.1-0.20240322180408-ade00e8603f9
 	nhooyr.io/websocket v1.8.10
 )
 
diff --git a/go.sum b/go.sum
index 3bb4666a..3916c4d4 100644
--- a/go.sum
+++ b/go.sum
@@ -95,7 +95,7 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
 maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
-maunium.net/go/mautrix v0.18.0 h1:sNsApeSWB8x0hLjGcdmi5JqO6Tvp2PVkiSStz+Yas6k=
-maunium.net/go/mautrix v0.18.0/go.mod h1:STwJZ+6CAeiEQs7fYCkd5aC12XR5DXANE6Swy/PBKGo=
+maunium.net/go/mautrix v0.18.1-0.20240322180408-ade00e8603f9 h1:Xl741d8hAFdBPJPT/ydc6zTQM4R4L+5/d1X+AevLdXY=
+maunium.net/go/mautrix v0.18.1-0.20240322180408-ade00e8603f9/go.mod h1:STwJZ+6CAeiEQs7fYCkd5aC12XR5DXANE6Swy/PBKGo=
 nhooyr.io/websocket v1.8.10 h1:mv4p+MnGrLDcPlBoWsvPP7XCzTYMXP9F9eIGoKbgx7Q=
 nhooyr.io/websocket v1.8.10/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
diff --git a/main.go b/main.go
index 4251d458..cd9e156e 100644
--- a/main.go
+++ b/main.go
@@ -59,7 +59,7 @@ type SignalBridge struct {
 	Config    *config.Config
 	DB        *database.Database
 	Metrics   *MetricsHandler
-	MeowStore *store.StoreContainer
+	MeowStore *store.Container
 
 	provisioning *ProvisioningAPI
 
diff --git a/msgconv/msgconv.go b/msgconv/msgconv.go
index 4e926ed5..b7f97718 100644
--- a/msgconv/msgconv.go
+++ b/msgconv/msgconv.go
@@ -19,7 +19,6 @@ package msgconv
 import (
 	"context"
 
-	"github.com/google/uuid"
 	"maunium.net/go/mautrix/event"
 	"maunium.net/go/mautrix/id"
 
@@ -58,5 +57,5 @@ type MessageConverter struct {
 }
 
 func (mc *MessageConverter) IsPrivateChat(ctx context.Context) bool {
-	return mc.GetData(ctx).UserID() != uuid.Nil
+	return !mc.GetData(ctx).UserID().IsEmpty()
 }
diff --git a/pkg/libsignalgo/address.go b/pkg/libsignalgo/address.go
index fc6efa28..9afacc00 100644
--- a/pkg/libsignalgo/address.go
+++ b/pkg/libsignalgo/address.go
@@ -23,8 +23,6 @@ package libsignalgo
 import "C"
 import (
 	"runtime"
-
-	"github.com/google/uuid"
 )
 
 type Address struct {
@@ -38,21 +36,12 @@ func wrapAddress(ptr *C.SignalProtocolAddress) *Address {
 	return address
 }
 
-func NewUUIDAddress(u uuid.UUID, deviceID uint) (*Address, error) {
-	return newAddress(u.String(), deviceID)
-}
-
 func NewUUIDAddressFromString(uuidStr string, deviceID uint) (*Address, error) {
-	parsed, err := uuid.Parse(uuidStr)
+	serviceID, err := ServiceIDFromString(uuidStr)
 	if err != nil {
 		return nil, err
 	}
-	return NewUUIDAddress(parsed, deviceID)
-}
-
-// Deprecated: phone addresses are not used anymore
-func NewPhoneAddress(phone string, deviceID uint) (*Address, error) {
-	return newAddress(phone, deviceID)
+	return serviceID.Address(deviceID)
 }
 
 func newAddress(name string, deviceID uint) (*Address, error) {
@@ -93,12 +82,12 @@ func (pa *Address) Name() (string, error) {
 	return CopyCStringToString(name), nil
 }
 
-func (pa *Address) NameUUID() (uuid.UUID, error) {
+func (pa *Address) NameServiceID() (ServiceID, error) {
 	name, err := pa.Name()
 	if err != nil {
-		return uuid.Nil, err
+		return ServiceID{}, err
 	}
-	return uuid.Parse(name)
+	return ServiceIDFromString(name)
 }
 
 func (pa *Address) DeviceID() (uint, error) {
diff --git a/pkg/libsignalgo/authcredential.go b/pkg/libsignalgo/authcredential.go
index c7c029ad..193f5b80 100644
--- a/pkg/libsignalgo/authcredential.go
+++ b/pkg/libsignalgo/authcredential.go
@@ -47,21 +47,13 @@ func ReceiveAuthCredentialWithPni(
 ) (*AuthCredentialWithPni, error) {
 	c_result := [C.SignalAUTH_CREDENTIAL_WITH_PNI_LEN]C.uchar{}
 	c_serverPublicParams := (*[C.SignalSERVER_PUBLIC_PARAMS_LEN]C.uchar)(unsafe.Pointer(&serverPublicParams[0]))
-	c_aci, err := SignalServiceIDFromUUID(aci)
-	if err != nil {
-		return nil, err
-	}
-	c_pni, err := SignalPNIServiceIDFromUUID(pni)
-	if err != nil {
-		return nil, err
-	}
 	c_authCredResponse := (*[C.SignalAUTH_CREDENTIAL_WITH_PNI_RESPONSE_LEN]C.uchar)(unsafe.Pointer(&authCredResponse[0]))
 
 	signalFfiError := C.signal_server_public_params_receive_auth_credential_with_pni_as_service_id(
 		&c_result,
 		c_serverPublicParams,
-		c_aci,
-		c_pni,
+		NewACIServiceID(aci).CFixedBytes(),
+		NewPNIServiceID(pni).CFixedBytes(),
 		C.uint64_t(redemptionTime),
 		c_authCredResponse,
 	)
diff --git a/pkg/libsignalgo/groupsecretparams.go b/pkg/libsignalgo/groupsecretparams.go
index 48618688..fed5477f 100644
--- a/pkg/libsignalgo/groupsecretparams.go
+++ b/pkg/libsignalgo/groupsecretparams.go
@@ -23,6 +23,7 @@ package libsignalgo
 import "C"
 import (
 	"crypto/rand"
+	"fmt"
 	"runtime"
 	"unsafe"
 
@@ -40,7 +41,9 @@ func GenerateRandomness() Randomness {
 	return randomness
 }
 
-type GroupMasterKey [C.SignalGROUP_MASTER_KEY_LEN]byte
+const GroupMasterKeyLength = C.SignalGROUP_MASTER_KEY_LEN
+
+type GroupMasterKey [GroupMasterKeyLength]byte
 type GroupSecretParams [C.SignalGROUP_SECRET_PARAMS_LEN]byte
 type GroupPublicParams [C.SignalGROUP_PUBLIC_PARAMS_LEN]byte
 type GroupIdentifier [C.SignalGROUP_IDENTIFIER_LEN]byte
@@ -116,7 +119,29 @@ func (gsp *GroupSecretParams) DecryptBlobWithPadding(blob []byte) ([]byte, error
 	return CopySignalOwnedBufferToBytes(plaintext), nil
 }
 
+func (gsp *GroupSecretParams) EncryptBlobWithPaddingDeterministic(randomness Randomness, plaintext []byte, padding_len uint32) ([]byte, error) {
+	var ciphertext C.SignalOwnedBuffer = C.SignalOwnedBuffer{}
+	borrowedPlaintext := BytesToBuffer(plaintext)
+	signalFfiError := C.signal_group_secret_params_encrypt_blob_with_padding_deterministic(
+		&ciphertext,
+		(*[C.SignalGROUP_SECRET_PARAMS_LEN]C.uint8_t)(unsafe.Pointer(gsp)),
+		(*[C.SignalRANDOMNESS_LEN]C.uint8_t)(unsafe.Pointer(&randomness)),
+		borrowedPlaintext,
+		(C.uint32_t)(padding_len),
+	)
+	runtime.KeepAlive(randomness)
+	runtime.KeepAlive(gsp)
+	runtime.KeepAlive(plaintext)
+	runtime.KeepAlive(padding_len)
+	if signalFfiError != nil {
+		return nil, wrapError(signalFfiError)
+	}
+	return CopySignalOwnedBufferToBytes(ciphertext), nil
+}
+
 func (gsp *GroupSecretParams) DecryptUUID(ciphertextUUID UUIDCiphertext) (uuid.UUID, error) {
+	// TODO this should probably be DecryptServiceID
+
 	u := C.SignalServiceIdFixedWidthBinaryBytes{}
 	signalFfiError := C.signal_group_secret_params_decrypt_service_id(
 		&u,
@@ -129,27 +154,40 @@ func (gsp *GroupSecretParams) DecryptUUID(ciphertextUUID UUIDCiphertext) (uuid.U
 		return uuid.Nil, wrapError(signalFfiError)
 	}
 
-	result, err := SignalServiceIDToUUID(&u)
-	if err != nil {
-		return uuid.Nil, err
+	serviceID := ServiceIDFromCFixedBytes(&u)
+	if serviceID.Type != ServiceIDTypeACI {
+		return uuid.Nil, fmt.Errorf("unexpected service ID type %d", serviceID.Type)
 	}
-	return result, nil
+	return serviceID.UUID, nil
+}
+
+func (gsp *GroupSecretParams) EncryptUUID(uuid uuid.UUID) (*UUIDCiphertext, error) {
+	var cipherTextUUID [C.SignalUUID_CIPHERTEXT_LEN]C.uchar
+	signalFfiError := C.signal_group_secret_params_encrypt_service_id(
+		&cipherTextUUID,
+		(*[C.SignalGROUP_SECRET_PARAMS_LEN]C.uint8_t)(unsafe.Pointer(gsp)),
+		NewACIServiceID(uuid).CFixedBytes(),
+	)
+	runtime.KeepAlive(gsp)
+	if signalFfiError != nil {
+		return nil, wrapError(signalFfiError)
+	}
+	var result UUIDCiphertext
+	copy(result[:], C.GoBytes(unsafe.Pointer(&cipherTextUUID), C.int(C.SignalUUID_CIPHERTEXT_LEN)))
+	return &result, nil
 }
 
 func (gsp *GroupSecretParams) DecryptProfileKey(ciphertextProfileKey ProfileKeyCiphertext, u uuid.UUID) (*ProfileKey, error) {
 	profileKey := [C.SignalPROFILE_KEY_LEN]C.uchar{}
-	serviceId, err := SignalServiceIDFromUUID(u)
-	if err != nil {
-		return nil, err
-	}
 	signalFfiError := C.signal_group_secret_params_decrypt_profile_key(
 		&profileKey,
 		(*[C.SignalGROUP_SECRET_PARAMS_LEN]C.uint8_t)(unsafe.Pointer(gsp)),
 		(*[C.SignalPROFILE_KEY_CIPHERTEXT_LEN]C.uint8_t)(unsafe.Pointer(&ciphertextProfileKey)),
-		serviceId,
+		NewACIServiceID(u).CFixedBytes(),
 	)
 	runtime.KeepAlive(gsp)
 	runtime.KeepAlive(ciphertextProfileKey)
+	runtime.KeepAlive(u)
 	if signalFfiError != nil {
 		return nil, wrapError(signalFfiError)
 	}
@@ -157,3 +195,58 @@ func (gsp *GroupSecretParams) DecryptProfileKey(ciphertextProfileKey ProfileKeyC
 	copy(result[:], C.GoBytes(unsafe.Pointer(&profileKey), C.int(C.SignalPROFILE_KEY_LEN)))
 	return &result, nil
 }
+
+func (gsp *GroupSecretParams) EncryptProfileKey(profileKey ProfileKey, u uuid.UUID) (*ProfileKeyCiphertext, error) {
+	ciphertextProfileKey := [C.SignalPROFILE_KEY_CIPHERTEXT_LEN]C.uchar{}
+	signalFfiError := C.signal_group_secret_params_encrypt_profile_key(
+		&ciphertextProfileKey,
+		(*[C.SignalGROUP_SECRET_PARAMS_LEN]C.uint8_t)(unsafe.Pointer(gsp)),
+		(*[C.SignalPROFILE_KEY_LEN]C.uint8_t)(unsafe.Pointer(&profileKey)),
+		NewACIServiceID(u).CFixedBytes(),
+	)
+	runtime.KeepAlive(gsp)
+	runtime.KeepAlive(profileKey)
+	if signalFfiError != nil {
+		return nil, wrapError(signalFfiError)
+	}
+	var result ProfileKeyCiphertext
+	copy(result[:], C.GoBytes(unsafe.Pointer(&ciphertextProfileKey), C.int(C.SignalPROFILE_KEY_CIPHERTEXT_LEN)))
+	return &result, nil
+}
+
+func (gsp *GroupSecretParams) CreateExpiringProfileKeyCredentialPresentation(spp ServerPublicParams, credential ExpiringProfileKeyCredential) (*ProfileKeyCredentialPresentation, error) {
+	var out C.SignalOwnedBuffer = C.SignalOwnedBuffer{}
+	randomness := GenerateRandomness()
+	signalFfiError := C.signal_server_public_params_create_expiring_profile_key_credential_presentation_deterministic(
+		&out,
+		(*[C.SignalSERVER_PUBLIC_PARAMS_LEN]C.uchar)(unsafe.Pointer(&spp)),
+		(*[C.SignalRANDOMNESS_LEN]C.uint8_t)(unsafe.Pointer(&randomness)),
+		(*[C.SignalGROUP_SECRET_PARAMS_LEN]C.uchar)(unsafe.Pointer(gsp)),
+		(*[C.SignalEXPIRING_PROFILE_KEY_CREDENTIAL_LEN]C.uchar)(unsafe.Pointer(&credential)),
+	)
+	runtime.KeepAlive(gsp)
+	runtime.KeepAlive(spp)
+	runtime.KeepAlive(credential)
+	runtime.KeepAlive(randomness)
+	if signalFfiError != nil {
+		return nil, wrapError(signalFfiError)
+	}
+	presentationBytes := CopySignalOwnedBufferToBytes(out)
+	presentation := ProfileKeyCredentialPresentation(presentationBytes)
+	return &presentation, nil
+}
+
+func (gsp *GroupSecretParams) GetMasterKey() (*GroupMasterKey, error) {
+	masterKeyBytes := [C.SignalGROUP_MASTER_KEY_LEN]C.uchar{}
+	signalFfiError := C.signal_group_secret_params_get_master_key(
+		&masterKeyBytes,
+		(*[C.SignalGROUP_SECRET_PARAMS_LEN]C.uchar)(unsafe.Pointer(gsp)),
+	)
+	runtime.KeepAlive(gsp)
+	if signalFfiError != nil {
+		return nil, wrapError(signalFfiError)
+	}
+	var groupMasterKey GroupMasterKey
+	copy(groupMasterKey[:], C.GoBytes(unsafe.Pointer(&masterKeyBytes), C.int(C.SignalGROUP_MASTER_KEY_LEN)))
+	return &groupMasterKey, nil
+}
diff --git a/pkg/libsignalgo/identitykey.go b/pkg/libsignalgo/identitykey.go
index 7ac9ec23..796e575b 100644
--- a/pkg/libsignalgo/identitykey.go
+++ b/pkg/libsignalgo/identitykey.go
@@ -41,6 +41,17 @@ func NewIdentityKeyFromBytes(bytes []byte) (*IdentityKey, error) {
 	return &IdentityKey{publicKey: publicKey}, nil
 }
 
+func (i *IdentityKey) TrySerialize() []byte {
+	if i == nil {
+		return nil
+	}
+	serialized, err := i.Serialize()
+	if err != nil {
+		return nil
+	}
+	return serialized
+}
+
 func (i *IdentityKey) Serialize() ([]byte, error) {
 	return i.publicKey.Serialize()
 }
diff --git a/pkg/libsignalgo/identitykeystore.go b/pkg/libsignalgo/identitykeystore.go
index 58c3cdf5..19c65207 100644
--- a/pkg/libsignalgo/identitykeystore.go
+++ b/pkg/libsignalgo/identitykeystore.go
@@ -45,9 +45,9 @@ const (
 type IdentityKeyStore interface {
 	GetIdentityKeyPair(ctx context.Context) (*IdentityKeyPair, error)
 	GetLocalRegistrationID(ctx context.Context) (uint32, error)
-	SaveIdentityKey(ctx context.Context, address *Address, identityKey *IdentityKey) (bool, error)
-	GetIdentityKey(ctx context.Context, address *Address) (*IdentityKey, error)
-	IsTrustedIdentity(ctx context.Context, address *Address, identityKey *IdentityKey, direction SignalDirection) (bool, error)
+	SaveIdentityKey(ctx context.Context, theirServiceID ServiceID, identityKey *IdentityKey) (bool, error)
+	GetIdentityKey(ctx context.Context, theirServiceID ServiceID) (*IdentityKey, error)
+	IsTrustedIdentity(ctx context.Context, theirServiceID ServiceID, identityKey *IdentityKey, direction SignalDirection) (bool, error)
 }
 
 //export signal_get_identity_key_pair_callback
@@ -90,9 +90,14 @@ func signal_save_identity_key_callback(storeCtx unsafe.Pointer, address *C.const
 		if err != nil {
 			return -1, err
 		}
+		addr := &Address{ptr: (*C.SignalProtocolAddress)(unsafe.Pointer(address))}
+		theirServiceID, err := addr.NameServiceID()
+		if err != nil {
+			return -1, err
+		}
 		replaced, err := store.SaveIdentityKey(
 			ctx,
-			&Address{ptr: (*C.SignalProtocolAddress)(unsafe.Pointer(address))},
+			theirServiceID,
 			&IdentityKey{cloned},
 		)
 		if err != nil {
@@ -109,7 +114,12 @@ func signal_save_identity_key_callback(storeCtx unsafe.Pointer, address *C.const
 //export signal_get_identity_key_callback
 func signal_get_identity_key_callback(storeCtx unsafe.Pointer, public_keyp **C.SignalPublicKey, address *C.const_address) C.int {
 	return wrapStoreCallback(storeCtx, func(store IdentityKeyStore, ctx context.Context) error {
-		key, err := store.GetIdentityKey(ctx, &Address{ptr: (*C.SignalProtocolAddress)(unsafe.Pointer(address))})
+		addr := &Address{ptr: (*C.SignalProtocolAddress)(unsafe.Pointer(address))}
+		theirServiceID, err := addr.NameServiceID()
+		if err != nil {
+			return err
+		}
+		key, err := store.GetIdentityKey(ctx, theirServiceID)
 		if err == nil && key != nil {
 			key.publicKey.CancelFinalizer()
 			*public_keyp = key.publicKey.ptr
@@ -121,7 +131,12 @@ func signal_get_identity_key_callback(storeCtx unsafe.Pointer, public_keyp **C.S
 //export signal_is_trusted_identity_callback
 func signal_is_trusted_identity_callback(storeCtx unsafe.Pointer, address *C.const_address, public_key *C.const_public_key, direction C.uint) C.int {
 	return wrapStoreCallbackCustomReturn(storeCtx, func(store IdentityKeyStore, ctx context.Context) (int, error) {
-		trusted, err := store.IsTrustedIdentity(ctx, &Address{ptr: (*C.SignalProtocolAddress)(unsafe.Pointer(address))}, &IdentityKey{&PublicKey{ptr: (*C.SignalPublicKey)(unsafe.Pointer(public_key))}}, SignalDirection(direction))
+		addr := &Address{ptr: (*C.SignalProtocolAddress)(unsafe.Pointer(address))}
+		theirServiceID, err := addr.NameServiceID()
+		if err != nil {
+			return -1, err
+		}
+		trusted, err := store.IsTrustedIdentity(ctx, theirServiceID, &IdentityKey{&PublicKey{ptr: (*C.SignalPublicKey)(unsafe.Pointer(public_key))}}, SignalDirection(direction))
 		if err != nil {
 			return -1, err
 		}
diff --git a/pkg/libsignalgo/kyberprekey.go b/pkg/libsignalgo/kyberprekey.go
index 63c9fc78..d049d0a3 100644
--- a/pkg/libsignalgo/kyberprekey.go
+++ b/pkg/libsignalgo/kyberprekey.go
@@ -187,14 +187,14 @@ func (kpkr *KyberPreKeyRecord) GetSignature() ([]byte, error) {
 	return CopySignalOwnedBufferToBytes(signature), nil
 }
 
-func (kpkr *KyberPreKeyRecord) GetID() (uint, error) {
+func (kpkr *KyberPreKeyRecord) GetID() (uint32, error) {
 	var id C.uint32_t
 	signalFfiError := C.signal_kyber_pre_key_record_get_id(&id, kpkr.ptr)
 	runtime.KeepAlive(kpkr)
 	if signalFfiError != nil {
 		return 0, wrapError(signalFfiError)
 	}
-	return uint(id), nil
+	return uint32(id), nil
 }
 
 func (kpkr *KyberPreKeyRecord) GetTimestamp() (time.Time, error) {
diff --git a/pkg/libsignalgo/prekey.go b/pkg/libsignalgo/prekey.go
index 7c4ed6ba..82b2cd18 100644
--- a/pkg/libsignalgo/prekey.go
+++ b/pkg/libsignalgo/prekey.go
@@ -117,14 +117,14 @@ func (pkr *PreKeyRecord) Serialize() ([]byte, error) {
 	return CopySignalOwnedBufferToBytes(serialized), nil
 }
 
-func (pkr *PreKeyRecord) GetID() (uint, error) {
+func (pkr *PreKeyRecord) GetID() (uint32, error) {
 	var id C.uint32_t
 	signalFfiError := C.signal_pre_key_record_get_id(&id, pkr.ptr)
 	runtime.KeepAlive(pkr)
 	if signalFfiError != nil {
 		return 0, wrapError(signalFfiError)
 	}
-	return uint(id), nil
+	return uint32(id), nil
 }
 
 func (pkr *PreKeyRecord) GetPublicKey() (*PublicKey, error) {
diff --git a/pkg/libsignalgo/profilekey.go b/pkg/libsignalgo/profilekey.go
index ba43a60a..8d4ba8f7 100644
--- a/pkg/libsignalgo/profilekey.go
+++ b/pkg/libsignalgo/profilekey.go
@@ -23,6 +23,7 @@ package libsignalgo
 */
 import "C"
 import (
+	"errors"
 	"runtime"
 	"unsafe"
 
@@ -30,11 +31,23 @@ import (
 	"go.mau.fi/util/random"
 )
 
-type ProfileKey [C.SignalPROFILE_KEY_LEN]byte
+const ProfileKeyLength = C.SignalPROFILE_KEY_LEN
+
+type ProfileKey [ProfileKeyLength]byte
 type ProfileKeyCommitment [C.SignalPROFILE_KEY_COMMITMENT_LEN]byte
 type ProfileKeyVersion [C.SignalPROFILE_KEY_VERSION_ENCODED_LEN]byte
 type AccessKey [C.SignalACCESS_KEY_LEN]byte
 
+func DeserializeProfileKey(bytes []byte) (*ProfileKey, error) {
+	if len(bytes) == 0 {
+		return nil, nil
+	} else if len(bytes) != ProfileKeyLength {
+		return nil, errors.New("invalid profile key length")
+	}
+	key := ProfileKey(bytes)
+	return &key, nil
+}
+
 var blankProfileKey ProfileKey
 
 func (pk *ProfileKey) IsEmpty() bool {
@@ -50,7 +63,7 @@ func (pv *ProfileKeyVersion) String() string {
 }
 
 func (pk *ProfileKey) Slice() []byte {
-	if pk == nil {
+	if pk.IsEmpty() {
 		return nil
 	}
 	return pk[:]
@@ -59,10 +72,7 @@ func (pk *ProfileKey) Slice() []byte {
 func (pk *ProfileKey) GetCommitment(u uuid.UUID) (*ProfileKeyCommitment, error) {
 	c_result := [C.SignalPROFILE_KEY_COMMITMENT_LEN]C.uchar{}
 	c_profileKey := (*[C.SignalPROFILE_KEY_LEN]C.uchar)(unsafe.Pointer(pk))
-	c_uuid, err := SignalServiceIDFromUUID(u)
-	if err != nil {
-		return nil, err
-	}
+	c_uuid := NewACIServiceID(u).CFixedBytes()
 
 	signalFfiError := C.signal_profile_key_get_commitment(
 		&c_result,
@@ -84,10 +94,7 @@ func (pk *ProfileKey) GetCommitment(u uuid.UUID) (*ProfileKeyCommitment, error)
 func (pk *ProfileKey) GetProfileKeyVersion(u uuid.UUID) (*ProfileKeyVersion, error) {
 	c_result := [C.SignalPROFILE_KEY_VERSION_ENCODED_LEN]C.uchar{}
 	c_profileKey := (*[C.SignalPROFILE_KEY_LEN]C.uchar)(unsafe.Pointer(pk))
-	c_uuid, err := SignalServiceIDFromUUID(u)
-	if err != nil {
-		return nil, err
-	}
+	c_uuid := NewACIServiceID(u).CFixedBytes()
 
 	signalFfiError := C.signal_profile_key_get_profile_key_version(
 		&c_result,
@@ -130,6 +137,8 @@ type ProfileKeyCredentialRequest [C.SignalPROFILE_KEY_CREDENTIAL_REQUEST_LEN]byt
 type ProfileKeyCredentialResponse []byte
 type ProfileKeyCredentialPresentation []byte
 type ServerPublicParams [C.SignalSERVER_PUBLIC_PARAMS_LEN]byte
+type ExpiringProfileKeyCredential [C.SignalEXPIRING_PROFILE_KEY_CREDENTIAL_LEN]byte
+type ExpiringProfileKeyCredentialResponse [C.SignalEXPIRING_PROFILE_KEY_CREDENTIAL_RESPONSE_LEN]byte
 
 func CreateProfileKeyCredentialRequestContext(serverPublicParams ServerPublicParams, u uuid.UUID, profileKey ProfileKey) (*ProfileKeyCredentialRequestContext, error) {
 	c_result := [C.SignalPROFILE_KEY_CREDENTIAL_REQUEST_CONTEXT_LEN]C.uchar{}
@@ -137,10 +146,7 @@ func CreateProfileKeyCredentialRequestContext(serverPublicParams ServerPublicPar
 	randBytes := [32]byte(random.Bytes(32))
 	c_random := (*[32]C.uchar)(unsafe.Pointer(&randBytes[0]))
 	c_profileKey := (*[C.SignalPROFILE_KEY_LEN]C.uchar)(unsafe.Pointer(&profileKey[0]))
-	c_uuid, err := SignalServiceIDFromUUID(u)
-	if err != nil {
-		return nil, err
-	}
+	c_uuid := NewACIServiceID(u).CFixedBytes()
 
 	signalFfiError := C.signal_server_public_params_create_profile_key_credential_request_context_deterministic(
 		&c_result,
@@ -176,14 +182,36 @@ func (p *ProfileKeyCredentialRequestContext) ProfileKeyCredentialRequestContextG
 	return &result, nil
 }
 
-func NewProfileKeyCredentialResponse(b []byte) (ProfileKeyCredentialResponse, error) {
+func NewExpiringProfileKeyCredentialResponse(b []byte) (*ExpiringProfileKeyCredentialResponse, error) {
 	borrowedBuffer := BytesToBuffer(b)
 	signalFfiError := C.signal_expiring_profile_key_credential_response_check_valid_contents(borrowedBuffer)
 	runtime.KeepAlive(b)
 	if signalFfiError != nil {
 		return nil, wrapError(signalFfiError)
 	}
-	return ProfileKeyCredentialResponse(b), nil
+	response := ExpiringProfileKeyCredentialResponse(b)
+	return &response, nil
+}
+
+func ReceiveExpiringProfileKeyCredential(spp ServerPublicParams, requestContext *ProfileKeyCredentialRequestContext, response *ExpiringProfileKeyCredentialResponse, currentTimeInSeconds uint64) (*ExpiringProfileKeyCredential, error) {
+	c_credential := [C.SignalEXPIRING_PROFILE_KEY_CREDENTIAL_LEN]C.uchar{}
+	signalFfiError := C.signal_server_public_params_receive_expiring_profile_key_credential(
+		&c_credential,
+		(*[C.SignalSERVER_PUBLIC_PARAMS_LEN]C.uchar)(unsafe.Pointer(&spp[0])),
+		(*[C.SignalPROFILE_KEY_CREDENTIAL_REQUEST_CONTEXT_LEN]C.uchar)(unsafe.Pointer(requestContext)),
+		(*[C.SignalEXPIRING_PROFILE_KEY_CREDENTIAL_RESPONSE_LEN]C.uchar)(unsafe.Pointer(response)),
+		(C.uint64_t)(currentTimeInSeconds),
+	)
+	runtime.KeepAlive(spp)
+	runtime.KeepAlive(requestContext)
+	runtime.KeepAlive(response)
+	runtime.KeepAlive(currentTimeInSeconds)
+	if signalFfiError != nil {
+		return nil, wrapError(signalFfiError)
+	}
+	credential := ExpiringProfileKeyCredential{}
+	copy(credential[:], C.GoBytes(unsafe.Pointer(&c_credential), C.int(C.SignalEXPIRING_PROFILE_KEY_CREDENTIAL_LEN)))
+	return &credential, nil
 }
 
 //func NewProfileKeyCredentialPresentation(b []byte) (ProfileKeyCredentialPresentation, error) {
diff --git a/pkg/libsignalgo/serviceid.go b/pkg/libsignalgo/serviceid.go
index 0e2eeb42..cd08f4da 100644
--- a/pkg/libsignalgo/serviceid.go
+++ b/pkg/libsignalgo/serviceid.go
@@ -1,5 +1,5 @@
 // mautrix-signal - A Matrix-signal puppeting bridge.
-// Copyright (C) 2023 Scott Weber
+// Copyright (C) 2024 Tulir Asokan
 //
 // This program is free software: you can redistribute it and/or modify
 // it under the terms of the GNU Affero General Public License as published by
@@ -23,11 +23,13 @@ package libsignalgo
 */
 import "C"
 import (
+	"database/sql/driver"
 	"fmt"
-	"runtime"
+	"strings"
 	"unsafe"
 
 	"github.com/google/uuid"
+	"github.com/rs/zerolog"
 )
 
 func init() {
@@ -36,38 +38,120 @@ func init() {
 	}
 }
 
-func SignalServiceIDFromUUID(uuid uuid.UUID) (cPNIType, error) {
-	var result C.SignalServiceIdFixedWidthBinaryBytes
-	signalFfiError := C.signal_service_id_parse_from_service_id_binary(&result, BytesToBuffer(uuid[:]))
-	runtime.KeepAlive(uuid)
-	if signalFfiError != nil {
-		return nil, wrapError(signalFfiError)
+type ServiceIDType byte
+
+const (
+	ServiceIDTypeACI ServiceIDType = 0
+	ServiceIDTypePNI ServiceIDType = 1
+)
+
+func (st ServiceIDType) String() string {
+	switch st {
+	case ServiceIDTypeACI:
+		return "ACI"
+	case ServiceIDTypePNI:
+		return "PNI"
+	default:
+		panic(fmt.Sprintf("invalid ServiceIDType: %d", st))
+	}
+}
+
+func (st ServiceIDType) GoString() string {
+	return fmt.Sprintf("libsignalgo.ServiceIDType%s", st.String())
+}
+
+type ServiceID struct {
+	Type ServiceIDType
+	UUID uuid.UUID
+}
+
+var EmptyServiceID ServiceID
+
+func NewPNIServiceID(uuid uuid.UUID) ServiceID {
+	return ServiceID{
+		Type: ServiceIDTypePNI,
+		UUID: uuid,
+	}
+}
+
+func NewACIServiceID(uuid uuid.UUID) ServiceID {
+	return ServiceID{
+		Type: ServiceIDTypeACI,
+		UUID: uuid,
+	}
+}
+
+func (s ServiceID) IsEmpty() bool {
+	return s.UUID == uuid.Nil
+}
+
+func (s ServiceID) Address(deviceID uint) (*Address, error) {
+	return newAddress(s.String(), deviceID)
+}
+
+func (s ServiceID) Bytes() []byte {
+	if s.Type == ServiceIDTypeACI {
+		return s.UUID[:]
 	}
-	return cPNIType(unsafe.Pointer(&result)), nil
+	return append([]byte{byte(s.Type)}, s.UUID[:]...)
 }
 
-func SignalPNIServiceIDFromUUID(uuid uuid.UUID) (cPNIType, error) {
-	var result C.SignalServiceIdFixedWidthBinaryBytes
-	// Prepend a 0x01 to the UUID to indicate that it is a PNI UUID
-	pniUUID := append([]byte{0x01}, uuid[:]...)
-	signalFfiError := C.signal_service_id_parse_from_service_id_binary(&result, BytesToBuffer(pniUUID))
-	runtime.KeepAlive(pniUUID)
-	if signalFfiError != nil {
-		return nil, wrapError(signalFfiError)
+func (s ServiceID) Value() (driver.Value, error) {
+	return s.String(), nil
+}
+
+func (s ServiceID) String() string {
+	if s.Type == ServiceIDTypeACI {
+		return s.UUID.String()
 	}
-	return cPNIType(unsafe.Pointer(&result)), nil
+	return fmt.Sprintf("%s:%s", s.Type, s.UUID)
+}
+
+func (s ServiceID) GoString() string {
+	return fmt.Sprintf(`libsignalgo.ServiceID{Type: %#v, UUID: uuid.MustParse("%s")}`, s.Type, s.UUID)
 }
 
-func SignalServiceIDToUUID(serviceId *C.SignalServiceIdFixedWidthBinaryBytes) (uuid.UUID, error) {
-	result := C.SignalOwnedBuffer{}
-	serviceIdBytes := cPNIType(unsafe.Pointer(serviceId)) // Hack around gcc bug, not needed for clang
-	signalFfiError := C.signal_service_id_service_id_binary(&result, serviceIdBytes)
-	if signalFfiError != nil {
-		return uuid.UUID{}, wrapError(signalFfiError)
+func (s ServiceID) MarshalZerologObject(e *zerolog.Event) {
+	e.Stringer("type", s.Type)
+	e.Stringer("uuid", s.UUID)
+}
+
+type ServiceIDFixedBytes [17]byte
+
+func (s ServiceID) FixedBytes() *ServiceIDFixedBytes {
+	var result ServiceIDFixedBytes
+	result[0] = byte(s.Type)
+	copy(result[1:], s.UUID[:])
+	return &result
+}
+
+func ServiceIDFromString(val string) (ServiceID, error) {
+	if len(val) < 36 {
+		return EmptyServiceID, fmt.Errorf("invalid UUID string: %s", val)
 	}
-	uuidBytes := CopySignalOwnedBufferToBytes(result)
-	if len(uuidBytes) != 16 {
-		return uuid.UUID{}, fmt.Errorf("invalid UUID length: %d. UUID: %x", len(uuidBytes), uuidBytes)
+	if strings.ToUpper(val[:4]) == "PNI:" {
+		parsed, err := uuid.Parse(val[4:])
+		if err != nil {
+			return EmptyServiceID, err
+		}
+		return NewPNIServiceID(parsed), nil
+	} else {
+		parsed, err := uuid.Parse(val)
+		if err != nil {
+			return EmptyServiceID, err
+		}
+		return NewACIServiceID(parsed), nil
 	}
-	return uuid.UUID(uuidBytes), nil
+}
+
+func ServiceIDFromCFixedBytes(serviceID *C.SignalServiceIdFixedWidthBinaryBytes) ServiceID {
+	var id ServiceID
+	fixedBytes := (*ServiceIDFixedBytes)(unsafe.Pointer(serviceID))
+	id.Type = ServiceIDType(fixedBytes[0])
+	copy(id.UUID[:], fixedBytes[1:])
+	return id
+}
+
+func (s ServiceID) CFixedBytes() cPNIType {
+	return cPNIType(unsafe.Pointer(s.FixedBytes()))
 }
diff --git a/pkg/libsignalgo/signedprekey.go b/pkg/libsignalgo/signedprekey.go
index 0b1daac7..e3aa9265 100644
--- a/pkg/libsignalgo/signedprekey.go
+++ b/pkg/libsignalgo/signedprekey.go
@@ -106,14 +106,14 @@ func (spkr *SignedPreKeyRecord) GetSignature() ([]byte, error) {
 	return CopySignalOwnedBufferToBytes(signature), nil
 }
 
-func (spkr *SignedPreKeyRecord) GetID() (uint, error) {
+func (spkr *SignedPreKeyRecord) GetID() (uint32, error) {
 	var id C.uint32_t
 	signalFfiError := C.signal_signed_pre_key_record_get_id(&id, spkr.ptr)
 	runtime.KeepAlive(spkr)
 	if signalFfiError != nil {
 		return 0, wrapError(signalFfiError)
 	}
-	return uint(id), nil
+	return uint32(id), nil
 }
 
 func (spkr *SignedPreKeyRecord) GetTimestamp() (time.Time, error) {
diff --git a/pkg/signalmeow/attachments.go b/pkg/signalmeow/attachments.go
index 2095d1ea..64054dd8 100644
--- a/pkg/signalmeow/attachments.go
+++ b/pkg/signalmeow/attachments.go
@@ -27,12 +27,16 @@ import (
 	"fmt"
 	"io"
 	"math"
+	"mime/multipart"
 	"net/http"
 
 	"github.com/rs/zerolog"
 	"go.mau.fi/util/random"
+	"google.golang.org/protobuf/proto"
 
+	"go.mau.fi/mautrix-signal/pkg/libsignalgo"
 	signalpb "go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf"
+	"go.mau.fi/mautrix-signal/pkg/signalmeow/types"
 	"go.mau.fi/mautrix-signal/pkg/signalmeow/web"
 )
 
@@ -200,6 +204,81 @@ func (cli *Client) UploadAttachment(ctx context.Context, body []byte) (*signalpb
 	return attachmentPointer, nil
 }
 
+func (cli *Client) UploadGroupAvatar(ctx context.Context, avatarBytes []byte, gid types.GroupIdentifier) (*string, error) {
+	log := zerolog.Ctx(ctx)
+	groupMasterKey, err := cli.Store.GroupStore.MasterKeyFromGroupIdentifier(ctx, gid)
+	if err != nil {
+		log.Err(err).Msg("Could not get master key from group id")
+		return nil, err
+	}
+	groupAuth, err := cli.GetAuthorizationForToday(ctx, masterKeyToBytes(groupMasterKey))
+	if err != nil {
+		log.Err(err).Msg("Failed to get Authorization for today")
+		return nil, err
+	}
+	groupSecretParams, err := libsignalgo.DeriveGroupSecretParamsFromMasterKey(masterKeyToBytes(groupMasterKey))
+	if err != nil {
+		log.Err(err).Msg("Could not get groupSecretParams from master key")
+		return nil, err
+	}
+	attributeBlob := signalpb.GroupAttributeBlob{Content: &signalpb.GroupAttributeBlob_Avatar{Avatar: avatarBytes}}
+	encryptedAvatar, err := encryptBlobIntoGroupProperty(groupSecretParams, &attributeBlob)
+	if err != nil {
+		log.Err(err).Msg("Could not encrypt avatar into Group Property")
+		return nil, err
+	}
+
+	// Get upload form from Signal server
+	formPath := "/v1/groups/avatar/form"
+	opts := &web.HTTPReqOpt{Username: &groupAuth.Username, Password: &groupAuth.Password, ContentType: web.ContentTypeProtobuf, Host: web.StorageHostname}
+	resp, err := web.SendHTTPRequest(ctx, http.MethodGet, formPath, opts)
+	if err != nil {
+		log.Err(err).Msg("Error sending request fetching avatar upload form")
+		return nil, err
+	}
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		log.Err(err).Msg("Error decoding response body fetching upload attributes")
+		return nil, err
+	}
+	uploadForm := signalpb.AvatarUploadAttributes{}
+	err = proto.Unmarshal(body, &uploadForm)
+	if err != nil {
+		log.Err(err).Msg("failed to unmarshal group avatar upload form")
+		return nil, err
+	}
+	requestBody := &bytes.Buffer{}
+	w := multipart.NewWriter(requestBody)
+	w.WriteField("key", uploadForm.Key)
+	w.WriteField("x-amz-credential", uploadForm.Credential)
+	w.WriteField("acl", uploadForm.Acl)
+	w.WriteField("x-amz-algorithm", uploadForm.Algorithm)
+	w.WriteField("x-amz-date", uploadForm.Date)
+	w.WriteField("policy", uploadForm.Policy)
+	w.WriteField("x-amz-signature", uploadForm.Signature)
+	w.WriteField("Content-Type", "application/octet-stream")
+	filewriter, _ := w.CreateFormFile("file", "file")
+	filewriter.Write(*encryptedAvatar)
+	w.Close()
+
+	// Upload avatar to CDN
+	resp, err = web.SendHTTPRequest(ctx, http.MethodPost, "", &web.HTTPReqOpt{
+		Body:        requestBody.Bytes(),
+		ContentType: web.ContentType(w.FormDataContentType()),
+		Host:        web.CDN1Hostname,
+	})
+	if err != nil {
+		log.Err(err).Msg("Error sending request uploading attachment")
+		return nil, err
+	}
+	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+		log.Error().Int("status_code", resp.StatusCode).Msg("Error uploading attachment")
+		return nil, fmt.Errorf("error uploading attachment: %s", resp.Status)
+	}
+
+	return &uploadForm.Key, nil
+}
+
 func verifyMAC(key, body, mac []byte) bool {
 	m := hmac.New(sha256.New, key)
 	m.Write(body)
diff --git a/pkg/signalmeow/client.go b/pkg/signalmeow/client.go
index 67573164..f311e9a8 100644
--- a/pkg/signalmeow/client.go
+++ b/pkg/signalmeow/client.go
@@ -21,6 +21,7 @@ import (
 	"errors"
 	"net/url"
 	"sync"
+	"time"
 
 	"github.com/rs/zerolog"
 
@@ -38,7 +39,7 @@ type Client struct {
 	GroupCache             *GroupCache
 	ProfileCache           *ProfileCache
 	GroupCallCache         *map[string]bool
-	LastContactRequestTime *int64
+	LastContactRequestTime time.Time
 
 	encryptionLock sync.Mutex
 
diff --git a/pkg/signalmeow/contact.go b/pkg/signalmeow/contact.go
index c4eacf2e..a8e82852 100644
--- a/pkg/signalmeow/contact.go
+++ b/pkg/signalmeow/contact.go
@@ -36,83 +36,51 @@ import (
 	"go.mau.fi/mautrix-signal/pkg/signalmeow/types"
 )
 
-func (cli *Client) StoreContactDetailsAsContact(ctx context.Context, contactDetails *signalpb.ContactDetails, avatar *[]byte) (*types.Contact, error) {
+func (cli *Client) StoreContactDetailsAsContact(ctx context.Context, contactDetails *signalpb.ContactDetails, avatar *[]byte) (*types.Recipient, error) {
 	parsedUUID, err := uuid.Parse(contactDetails.GetAci())
 	if err != nil {
 		return nil, err
 	}
-	log := zerolog.Ctx(ctx).With().
+	ctx = zerolog.Ctx(ctx).With().
 		Str("action", "store contact details as contact").
 		Stringer("uuid", parsedUUID).
-		Logger()
-	existingContact, err := cli.Store.ContactStore.LoadContact(ctx, parsedUUID)
-	if err != nil {
-		log.Err(err).Msg("Failed to load contact from database")
-		return nil, err
-	}
-	if existingContact == nil {
-		existingContact = &types.Contact{
-			UUID: parsedUUID,
-		}
-	}
-
-	existingContact.E164 = contactDetails.GetNumber()
-	existingContact.ContactName = contactDetails.GetName()
-	if profileKeyString := contactDetails.GetProfileKey(); profileKeyString != nil {
-		profileKey := libsignalgo.ProfileKey(profileKeyString)
-		existingContact.Profile.Key = profileKey
-		err = cli.Store.ProfileKeyStore.StoreProfileKey(ctx, existingContact.UUID, profileKey)
-		if err != nil {
-			log.Err(err).Msg("Failed to store profile key from contact")
+		Logger().WithContext(ctx)
+	return cli.Store.RecipientStore.LoadAndUpdateRecipient(ctx, parsedUUID, uuid.Nil, func(recipient *types.Recipient) (bool, error) {
+		if contactDetails.GetNumber() != "" {
+			recipient.E164 = contactDetails.GetNumber()
 		}
-	}
-
-	if avatar != nil && *avatar != nil && len(*avatar) > 0 {
-		rawHash := sha256.Sum256(*avatar)
-		avatarHash := hex.EncodeToString(rawHash[:])
-		var contentType string
-		if avatarDetails := contactDetails.GetAvatar(); avatarDetails != nil && !strings.HasSuffix(avatarDetails.GetContentType(), "/*") {
-			contentType = *avatarDetails.ContentType
-		} else {
-			contentType = http.DetectContentType(*avatar)
+		recipient.ContactName = contactDetails.GetName()
+		if profileKeyString := contactDetails.GetProfileKey(); profileKeyString != nil {
+			profileKey := libsignalgo.ProfileKey(profileKeyString)
+			recipient.Profile.Key = profileKey
 		}
-		existingContact.ContactAvatar = types.ContactAvatar{
-			Image:       *avatar,
-			ContentType: contentType,
-			Hash:        avatarHash,
+		if avatar != nil && *avatar != nil && len(*avatar) > 0 {
+			rawHash := sha256.Sum256(*avatar)
+			avatarHash := hex.EncodeToString(rawHash[:])
+			var contentType string
+			if avatarDetails := contactDetails.GetAvatar(); avatarDetails != nil && !strings.HasSuffix(avatarDetails.GetContentType(), "/*") {
+				contentType = *avatarDetails.ContentType
+			} else {
+				contentType = http.DetectContentType(*avatar)
+			}
+			recipient.ContactAvatar = types.ContactAvatar{
+				Image:       *avatar,
+				ContentType: contentType,
+				Hash:        avatarHash,
+			}
 		}
-	}
-
-	storeErr := cli.Store.ContactStore.StoreContact(ctx, *existingContact)
-	if storeErr != nil {
-		log.Err(storeErr).Msg("Failed to save contact")
-		return existingContact, storeErr
-	}
-	return existingContact, nil
+		return true, nil
+	})
 }
 
-func (cli *Client) fetchContactThenTryAndUpdateWithProfile(ctx context.Context, profileUUID uuid.UUID) (*types.Contact, error) {
+func (cli *Client) fetchContactThenTryAndUpdateWithProfile(ctx context.Context, aci uuid.UUID) (*types.Recipient, error) {
 	log := zerolog.Ctx(ctx).With().
 		Str("action", "fetch contact then try and update with profile").
-		Stringer("profile_uuid", profileUUID).
+		Stringer("profile_aci", aci).
 		Logger()
-	contactChanged := false
+	ctx = log.WithContext(ctx)
 
-	existingContact, err := cli.Store.ContactStore.LoadContact(ctx, profileUUID)
-	if err != nil {
-		log.Err(err).Msg("Failed to load contact from database")
-		return nil, err
-	}
-	if existingContact == nil {
-		log.Debug().Msg("creating new contact")
-		existingContact = &types.Contact{
-			UUID: profileUUID,
-		}
-		contactChanged = true
-	} else {
-		log.Debug().Msg("updating existing contact")
-	}
-	profile, err := cli.RetrieveProfileByID(ctx, profileUUID)
+	profile, err := cli.RetrieveProfileByID(ctx, aci)
 	if err != nil {
 		logLevel := zerolog.ErrorLevel
 		if errors.Is(err, errProfileKeyNotFound) {
@@ -121,55 +89,24 @@ func (cli *Client) fetchContactThenTryAndUpdateWithProfile(ctx context.Context,
 		log.WithLevel(logLevel).Err(err).Msg("Failed to fetch profile")
 		// Continue to return contact without profile
 	}
-
-	if profile != nil {
-		// Don't bother saving every fetched timestamp to the database, but save if anything else changed
-		if !existingContact.Profile.Equals(profile) || existingContact.Profile.FetchedAt.IsZero() {
-			contactChanged = true
-		}
-		existingContact.Profile = *profile
-	}
-
-	if contactChanged {
-		err = cli.Store.ContactStore.StoreContact(ctx, *existingContact)
-		if err != nil {
-			log.Err(err).Msg("Failed to save contact")
-			return nil, err
-		}
-	}
-	return existingContact, nil
-}
-
-func (cli *Client) UpdateContactE164(ctx context.Context, uuid uuid.UUID, e164 string) error {
-	log := zerolog.Ctx(ctx).With().
-		Str("action", "update contact e164").
-		Stringer("uuid", uuid).
-		Str("e164", e164).
-		Logger()
-	existingContact, err := cli.Store.ContactStore.LoadContact(ctx, uuid)
-	if err != nil {
-		log.Err(err).Msg("Failed to load contact from database")
-		return err
-	}
-	if existingContact == nil {
-		existingContact = &types.Contact{
-			UUID: uuid,
+	return cli.Store.RecipientStore.LoadAndUpdateRecipient(ctx, aci, uuid.Nil, func(recipient *types.Recipient) (changed bool, err error) {
+		if profile != nil {
+			// Don't bother saving every fetched timestamp to the database, but save if anything else changed
+			if !recipient.Profile.Equals(profile) || recipient.Profile.FetchedAt.IsZero() {
+				changed = true
+			}
+			recipient.Profile = *profile
 		}
-	}
-	if existingContact.E164 == e164 {
-		return nil
-	}
-	log.Debug().Msg("Contact phone number changed")
-	existingContact.E164 = e164
-	return cli.Store.ContactStore.StoreContact(ctx, *existingContact)
+		return
+	})
 }
 
-func (cli *Client) ContactByID(ctx context.Context, uuid uuid.UUID) (*types.Contact, error) {
-	return cli.fetchContactThenTryAndUpdateWithProfile(ctx, uuid)
+func (cli *Client) ContactByACI(ctx context.Context, aci uuid.UUID) (*types.Recipient, error) {
+	return cli.fetchContactThenTryAndUpdateWithProfile(ctx, aci)
 }
 
-func (cli *Client) ContactByE164(ctx context.Context, e164 string) (*types.Contact, error) {
-	contact, err := cli.Store.ContactStore.LoadContactByE164(ctx, e164)
+func (cli *Client) ContactByE164(ctx context.Context, e164 string) (*types.Recipient, error) {
+	contact, err := cli.Store.RecipientStore.LoadRecipientByE164(ctx, e164)
 	if err != nil {
 		zerolog.Ctx(ctx).Err(err).Msg("ContactByE164 error loading contact")
 		return nil, err
@@ -177,7 +114,9 @@ func (cli *Client) ContactByE164(ctx context.Context, e164 string) (*types.Conta
 	if contact == nil {
 		return nil, nil
 	}
-	contact, err = cli.fetchContactThenTryAndUpdateWithProfile(ctx, contact.UUID)
+	if contact.ACI != uuid.Nil {
+		contact, err = cli.fetchContactThenTryAndUpdateWithProfile(ctx, contact.ACI)
+	}
 	return contact, err
 }
 
diff --git a/pkg/signalmeow/contactdiscovery.go b/pkg/signalmeow/contactdiscovery.go
index 5d5bce27..660439c9 100644
--- a/pkg/signalmeow/contactdiscovery.go
+++ b/pkg/signalmeow/contactdiscovery.go
@@ -86,8 +86,6 @@ func (cli *Client) LookupPhone(ctx context.Context, e164s ...uint64) (ContactDis
 		//      (it's meant for old_e164s)
 		//Token:    cli.cdToken,
 		NewE164S: requestData,
-
-		ReturnAcisWithoutUaks: proto.Bool(true),
 	})
 	if token != nil {
 		cli.cdToken = token
diff --git a/pkg/signalmeow/events/message.go b/pkg/signalmeow/events/message.go
index 6fa73fbb..e8ba1cb1 100644
--- a/pkg/signalmeow/events/message.go
+++ b/pkg/signalmeow/events/message.go
@@ -19,6 +19,7 @@ package events
 import (
 	"github.com/google/uuid"
 
+	"go.mau.fi/mautrix-signal/pkg/libsignalgo"
 	signalpb "go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf"
 	"go.mau.fi/mautrix-signal/pkg/signalmeow/types"
 )
@@ -32,6 +33,7 @@ func (*Receipt) isSignalEvent()     {}
 func (*ReadSelf) isSignalEvent()    {}
 func (*Call) isSignalEvent()        {}
 func (*ContactList) isSignalEvent() {}
+func (*ACIFound) isSignalEvent()    {}
 
 type MessageInfo struct {
 	Sender uuid.UUID
@@ -61,5 +63,10 @@ type Call struct {
 }
 
 type ContactList struct {
-	Contacts []*types.Contact
+	Contacts []*types.Recipient
+}
+
+type ACIFound struct {
+	PNI libsignalgo.ServiceID
+	ACI libsignalgo.ServiceID
 }
diff --git a/pkg/signalmeow/groups.go b/pkg/signalmeow/groups.go
index ed9ed1d3..38cd2902 100644
--- a/pkg/signalmeow/groups.go
+++ b/pkg/signalmeow/groups.go
@@ -1,5 +1,5 @@
 // mautrix-signal - A Matrix-signal puppeting bridge.
-// Copyright (C) 2023 Scott Weber
+// Copyright (C) 2023 Scott Weber, Malte Eggers
 //
 // This program is free software: you can redistribute it and/or modify
 // it under the terms of the GNU Affero General Public License as published by
@@ -18,9 +18,11 @@ package signalmeow
 
 import (
 	"context"
+	"crypto/rand"
 	"encoding/base64"
 	"encoding/hex"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"io"
 	"net/http"
@@ -70,6 +72,10 @@ type GroupMember struct {
 	//Presentation     []byte
 }
 
+func (gm *GroupMember) UserServiceID() libsignalgo.ServiceID {
+	return libsignalgo.NewACIServiceID(gm.UserID)
+}
+
 type Group struct {
 	groupMasterKey  types.SerializedGroupMasterKey // We should keep this relatively private
 	GroupIdentifier types.GroupIdentifier          // This is what we should use to identify a group outside this file
@@ -85,8 +91,32 @@ type Group struct {
 	PendingMembers               []*PendingMember
 	RequestingMembers            []*RequestingMember
 	BannedMembers                []*BannedMember
+	InviteLinkPassword           *types.SerializedInviteLinkPassword
 	//PublicKey                  *libsignalgo.PublicKey
-	//InviteLinkPassword         []byte
+}
+
+func (group *Group) GetInviteLink() (string, error) {
+	if group.InviteLinkPassword == nil {
+		return "", fmt.Errorf("no invite link password set")
+	}
+	masterKeyBytes := masterKeyToBytes(group.groupMasterKey)
+	inviteLinkPasswordBytes, err := inviteLinkPasswordToBytes(*group.InviteLinkPassword)
+	if err != nil {
+		return "", fmt.Errorf("couldn't decode invite link password")
+	}
+	inviteLinkContents := signalpb.GroupInviteLink_V1Contents{
+		V1Contents: &signalpb.GroupInviteLink_GroupInviteLinkContentsV1{
+			GroupMasterKey:     masterKeyBytes[:],
+			InviteLinkPassword: inviteLinkPasswordBytes,
+		},
+	}
+	inviteLink := signalpb.GroupInviteLink{Contents: &inviteLinkContents}
+	inviteLinkEncoded, err := proto.Marshal(&inviteLink)
+	if err != nil {
+		return "", fmt.Errorf("failed to marshal invite link")
+	}
+	inviteLinkPath := base64.URLEncoding.EncodeToString(inviteLinkEncoded)
+	return "https://signal.group/#" + inviteLinkPath, nil
 }
 
 type GroupAccessControl struct {
@@ -168,7 +198,113 @@ type GroupChange struct {
 	AddBannedMembers                   []*BannedMember
 	DeleteBannedMembers                []*uuid.UUID
 	PromotePendingPniAciMembers        []*ProfileKeyMember
-	// ModifyInviteLinkPassword        []byte
+	ModifyInviteLinkPassword           *types.SerializedInviteLinkPassword
+}
+
+func (groupChange *GroupChange) isEmptpy() bool {
+	return len(groupChange.AddMembers) == 0 &&
+		len(groupChange.DeleteMembers) == 0 &&
+		len(groupChange.ModifyMemberRoles) == 0 &&
+		len(groupChange.ModifyMemberProfileKeys) == 0 &&
+		len(groupChange.AddPendingMembers) == 0 &&
+		len(groupChange.PromotePendingMembers) == 0 &&
+		groupChange.ModifyTitle == nil &&
+		groupChange.ModifyAvatar == nil &&
+		groupChange.ModifyDisappearingMessagesDuration == nil &&
+		groupChange.ModifyAttributesAccess == nil &&
+		groupChange.ModifyMemberAccess == nil &&
+		groupChange.ModifyAddFromInviteLinkAccess == nil &&
+		len(groupChange.AddRequestingMembers) == 0 &&
+		len(groupChange.DeleteRequestingMembers) == 0 &&
+		len(groupChange.PromoteRequestingMembers) == 0 &&
+		groupChange.ModifyDescription == nil &&
+		groupChange.ModifyAnnouncementsOnly == nil &&
+		len(groupChange.AddBannedMembers) == 0 &&
+		len(groupChange.DeleteMembers) == 0
+}
+
+func (groupChange *GroupChange) resolveConflict(group *Group) {
+	if *groupChange.ModifyTitle == group.Title {
+		groupChange.ModifyTitle = nil
+	}
+	if *groupChange.ModifyDescription == group.Description {
+		groupChange.ModifyDescription = nil
+	}
+	if *groupChange.ModifyAvatar == group.AvatarPath {
+		groupChange.ModifyAvatar = nil
+	}
+	if *groupChange.ModifyDisappearingMessagesDuration == group.DisappearingMessagesDuration {
+		groupChange.ModifyDisappearingMessagesDuration = nil
+	}
+	if *groupChange.ModifyAttributesAccess == group.AccessControl.Attributes {
+		groupChange.ModifyAttributesAccess = nil
+	}
+	if *groupChange.ModifyMemberAccess == group.AccessControl.Members {
+		groupChange.ModifyAttributesAccess = nil
+	}
+	if *groupChange.ModifyAddFromInviteLinkAccess == group.AccessControl.AddFromInviteLink {
+		groupChange.ModifyAddFromInviteLinkAccess = nil
+	}
+	if *groupChange.ModifyAnnouncementsOnly == group.AnnouncementsOnly {
+		groupChange.ModifyAnnouncementsOnly = nil
+	}
+	members := make(map[uuid.UUID]GroupMemberRole)
+	for _, member := range group.Members {
+		members[member.UserID] = member.Role
+	}
+	pendingMembers := make(map[uuid.UUID]bool)
+	for _, pendingMember := range group.PendingMembers {
+		pendingMembers[pendingMember.UserID] = true
+	}
+	requestingMembers := make(map[uuid.UUID]bool)
+	for _, requestingMember := range group.RequestingMembers {
+		requestingMembers[requestingMember.UserID] = true
+	}
+	for i, member := range groupChange.AddMembers {
+		if _, ok := members[member.GroupMember.UserID]; ok {
+			groupChange.AddMembers = append(groupChange.AddMembers[:i], groupChange.AddMembers[i+1:]...)
+		}
+	}
+	for i, promotePendingMember := range groupChange.PromotePendingMembers {
+		if _, ok := members[promotePendingMember.UserID]; ok {
+			groupChange.PromotePendingMembers = append(groupChange.PromotePendingMembers[:i], groupChange.PromotePendingMembers[i+1:]...)
+		}
+	}
+	for i, promoteRequestingMember := range groupChange.PromotePendingMembers {
+		if _, ok := members[promoteRequestingMember.UserID]; ok {
+			groupChange.PromoteRequestingMembers = append(groupChange.PromoteRequestingMembers[:i], groupChange.PromoteRequestingMembers[i+1:]...)
+		}
+	}
+	for i, pendingMember := range groupChange.AddPendingMembers {
+		if pendingMembers[pendingMember.GroupMember.UserID] {
+			groupChange.AddPendingMembers = append(groupChange.AddPendingMembers[:i], groupChange.AddPendingMembers[i+1:]...)
+		}
+	}
+	for i, requestingMember := range groupChange.AddRequestingMembers {
+		if pendingMembers[requestingMember.UserID] {
+			groupChange.AddRequestingMembers = append(groupChange.AddRequestingMembers[:i], groupChange.AddRequestingMembers[i+1:]...)
+		}
+	}
+	for i, deletePendingMember := range groupChange.DeletePendingMembers {
+		if !pendingMembers[*deletePendingMember] {
+			groupChange.DeletePendingMembers = append(groupChange.DeletePendingMembers[:i], groupChange.DeletePendingMembers[i+1:]...)
+		}
+	}
+	for i, deleteRequestingMember := range groupChange.DeleteRequestingMembers {
+		if !pendingMembers[*deleteRequestingMember] {
+			groupChange.DeleteRequestingMembers = append(groupChange.DeleteRequestingMembers[:i], groupChange.DeleteRequestingMembers[i+1:]...)
+		}
+	}
+	for i, deleteMember := range groupChange.DeleteMembers {
+		if _, ok := members[*deleteMember]; !ok {
+			groupChange.DeleteMembers = append(groupChange.DeleteMembers[:i], groupChange.DeleteMembers[i+1:]...)
+		}
+	}
+	for i, modifyMemberRole := range groupChange.ModifyMemberRoles {
+		if members[modifyMemberRole.UserID] == modifyMemberRole.Role {
+			groupChange.ModifyMemberRoles = append(groupChange.ModifyMemberRoles[:i], groupChange.ModifyMemberRoles[i+1:]...)
+		}
+	}
 }
 
 func (groupChange *GroupChange) getGroupMasterKey() types.SerializedGroupMasterKey {
@@ -312,6 +448,18 @@ func masterKeyFromBytes(masterKey libsignalgo.GroupMasterKey) types.SerializedGr
 	return types.SerializedGroupMasterKey(base64.StdEncoding.EncodeToString(masterKey[:]))
 }
 
+func inviteLinkPasswordToBytes(inviteLinkPassword types.SerializedInviteLinkPassword) ([]byte, error) {
+	inviteLinkPasswordBytes, err := base64.StdEncoding.DecodeString((string(inviteLinkPassword)))
+	if err != nil {
+		return nil, err
+	}
+	return inviteLinkPasswordBytes, nil
+}
+
+func InviteLinkPasswordFromBytes(inviteLinkPassword []byte) types.SerializedInviteLinkPassword {
+	return types.SerializedInviteLinkPassword(base64.StdEncoding.EncodeToString(inviteLinkPassword))
+}
+
 func groupIdentifierFromMasterKey(masterKey types.SerializedGroupMasterKey) (types.GroupIdentifier, error) {
 	groupSecretParams, err := libsignalgo.DeriveGroupSecretParamsFromMasterKey(masterKeyToBytes(masterKey))
 	if err != nil {
@@ -433,6 +581,10 @@ func decryptGroup(ctx context.Context, encryptedGroup *signalpb.Group, groupMast
 			AddFromInviteLink: (AccessControl)(encryptedGroup.AccessControl.AddFromInviteLink),
 		}
 	}
+	if len(encryptedGroup.InviteLinkPassword) > 0 {
+		inviteLinkPassword := InviteLinkPasswordFromBytes(encryptedGroup.InviteLinkPassword)
+		decryptedGroup.InviteLinkPassword = &inviteLinkPassword
+	}
 	return decryptedGroup, nil
 }
 
@@ -449,6 +601,18 @@ func decryptGroupPropertyIntoBlob(groupSecretParams libsignalgo.GroupSecretParam
 	return &propertyBlob, nil
 }
 
+func encryptBlobIntoGroupProperty(groupSecretParams libsignalgo.GroupSecretParams, attributeBlob *signalpb.GroupAttributeBlob) (*[]byte, error) {
+	decryptedProperty, err := proto.Marshal(attributeBlob)
+	if err != nil {
+		return nil, fmt.Errorf("error marshalling groupProperty: %w", err)
+	}
+	encryptedProperty, err := groupSecretParams.EncryptBlobWithPaddingDeterministic(libsignalgo.GenerateRandomness(), decryptedProperty, 0)
+	if err != nil {
+		return nil, fmt.Errorf("error encrypting blob with padding: %w", err)
+	}
+	return &encryptedProperty, nil
+}
+
 func cleanupStringProperty(property string) string {
 	// strip non-printable characters from the string
 	property = strings.Map(cleanupStringMapping, property)
@@ -495,6 +659,9 @@ func (cli *Client) fetchGroupByID(ctx context.Context, gid types.GroupIdentifier
 	if groupMasterKey == "" {
 		return nil, fmt.Errorf("No group master key found for group identifier %s", gid)
 	}
+	return cli.fetchGroupWithMasterKey(ctx, groupMasterKey)
+}
+func (cli *Client) fetchGroupWithMasterKey(ctx context.Context, groupMasterKey types.SerializedGroupMasterKey) (*Group, error) {
 	masterKeyBytes := masterKeyToBytes(groupMasterKey)
 	groupAuth, err := cli.GetAuthorizationForToday(ctx, masterKeyBytes)
 	if err != nil {
@@ -530,19 +697,19 @@ func (cli *Client) fetchGroupByID(ctx context.Context, gid types.GroupIdentifier
 
 	// Store the profile keys in case they're new
 	for _, member := range group.Members {
-		err = cli.Store.ProfileKeyStore.StoreProfileKey(ctx, member.UserID, member.ProfileKey)
+		err = cli.Store.RecipientStore.StoreProfileKey(ctx, member.UserID, member.ProfileKey)
 		if err != nil {
 			return nil, fmt.Errorf("failed to store profile key: %w", err)
 		}
 	}
 	for _, pendingMember := range group.PendingMembers {
-		err = cli.Store.ProfileKeyStore.StoreProfileKey(ctx, pendingMember.UserID, pendingMember.ProfileKey)
+		err = cli.Store.RecipientStore.StoreProfileKey(ctx, pendingMember.UserID, pendingMember.ProfileKey)
 		if err != nil {
 			return nil, fmt.Errorf("failed to store profile key: %w", err)
 		}
 	}
 	for _, requestingMember := range group.RequestingMembers {
-		err = cli.Store.ProfileKeyStore.StoreProfileKey(ctx, requestingMember.UserID, requestingMember.ProfileKey)
+		err = cli.Store.RecipientStore.StoreProfileKey(ctx, requestingMember.UserID, requestingMember.ProfileKey)
 		if err != nil {
 			return nil, fmt.Errorf("failed to store profile key: %w", err)
 		}
@@ -717,7 +884,7 @@ func (cli *Client) DecryptGroupChange(ctx context.Context, groupContext *signalp
 			GroupMember:        *decryptedMember,
 			JoinFromInviteLink: addMember.JoinFromInviteLink,
 		})
-		err = cli.Store.ProfileKeyStore.StoreProfileKey(ctx, decryptedMember.UserID, decryptedMember.ProfileKey)
+		err = cli.Store.RecipientStore.StoreProfileKey(ctx, decryptedMember.UserID, decryptedMember.ProfileKey)
 		if err != nil {
 			log.Err(err).Msg("failed to store profile key")
 			return nil, err
@@ -770,7 +937,7 @@ func (cli *Client) DecryptGroupChange(ctx context.Context, groupContext *signalp
 			UserID:     userID,
 			ProfileKey: *profileKey,
 		})
-		cli.Store.ProfileKeyStore.StoreProfileKey(ctx, userID, *profileKey)
+		err = cli.Store.RecipientStore.StoreProfileKey(ctx, userID, *profileKey)
 		if err != nil {
 			log.Err(err).Msg("failed to store profile key")
 			return nil, err
@@ -823,7 +990,7 @@ func (cli *Client) DecryptGroupChange(ctx context.Context, groupContext *signalp
 			UserID:     userID,
 			ProfileKey: *profileKey,
 		})
-		cli.Store.ProfileKeyStore.StoreProfileKey(ctx, userID, *profileKey)
+		err = cli.Store.RecipientStore.StoreProfileKey(ctx, userID, *profileKey)
 		if err != nil {
 			log.Err(err).Msg("failed to store profile key")
 			return nil, err
@@ -851,7 +1018,7 @@ func (cli *Client) DecryptGroupChange(ctx context.Context, groupContext *signalp
 			UserID:     userID,
 			ProfileKey: *profileKey,
 		})
-		cli.Store.ProfileKeyStore.StoreProfileKey(ctx, userID, *profileKey)
+		err = cli.Store.RecipientStore.StoreProfileKey(ctx, userID, *profileKey)
 		if err != nil {
 			log.Err(err).Msg("failed to store profile key")
 			return nil, err
@@ -867,7 +1034,7 @@ func (cli *Client) DecryptGroupChange(ctx context.Context, groupContext *signalp
 			return nil, err
 		}
 		decryptedGroupChange.AddRequestingMembers = append(decryptedGroupChange.AddRequestingMembers, decryptedRequestingMember)
-		cli.Store.ProfileKeyStore.StoreProfileKey(ctx, decryptedRequestingMember.UserID, decryptedRequestingMember.ProfileKey)
+		err = cli.Store.RecipientStore.StoreProfileKey(ctx, decryptedRequestingMember.UserID, decryptedRequestingMember.ProfileKey)
 		if err != nil {
 			log.Err(err).Msg("failed to store profile key")
 			return nil, err
@@ -956,6 +1123,10 @@ func (cli *Client) DecryptGroupChange(ctx context.Context, groupContext *signalp
 		newDisappaeringMessagesDuration := timerBlob.GetDisappearingMessagesDuration()
 		decryptedGroupChange.ModifyDisappearingMessagesDuration = &newDisappaeringMessagesDuration
 	}
+	if encryptedActions.ModifyInviteLinkPassword != nil {
+		inviteLinkPassword := InviteLinkPasswordFromBytes(encryptedActions.ModifyInviteLinkPassword.InviteLinkPassword)
+		decryptedGroupChange.ModifyInviteLinkPassword = &inviteLinkPassword
+	}
 
 	return decryptedGroupChange, nil
 }
@@ -979,7 +1150,7 @@ func decryptMember(ctx context.Context, member *signalpb.Member, groupSecretPara
 		ProfileKey:       *profileKey,
 		Role:             GroupMemberRole(member.Role),
 		JoinedAtRevision: member.JoinedAtRevision,
-	}, err
+	}, nil
 }
 
 func decryptPendingMember(ctx context.Context, pendingMember *signalpb.PendingMember, groupSecretParams libsignalgo.GroupSecretParams) (*PendingMember, error) {
@@ -1028,3 +1199,512 @@ func decryptRequestingMember(ctx context.Context, requestingMember *signalpb.Req
 		Timestamp:  requestingMember.Timestamp,
 	}, nil
 }
+
+func (cli *Client) EncryptAndSignGroupChange(ctx context.Context, decryptedGroupChange *GroupChange, gid types.GroupIdentifier) (*signalpb.GroupChange, error) {
+	log := zerolog.Ctx(ctx).With().Str("action", "EncryptGroupChange").Logger()
+	groupMasterKey := decryptedGroupChange.groupMasterKey
+	masterKeyBytes := masterKeyToBytes(groupMasterKey)
+	groupSecretParams, err := libsignalgo.DeriveGroupSecretParamsFromMasterKey(masterKeyBytes)
+	if err != nil {
+		log.Err(err).Msg("Could not get groupSecretParams from master key")
+		return nil, err
+	}
+	groupChangeActions := &signalpb.GroupChange_Actions{Revision: decryptedGroupChange.Revision}
+	if decryptedGroupChange.ModifyTitle != nil {
+		attributeBlob := signalpb.GroupAttributeBlob{Content: &signalpb.GroupAttributeBlob_Title{Title: *decryptedGroupChange.ModifyTitle}}
+		encryptedTitle, err := encryptBlobIntoGroupProperty(groupSecretParams, &attributeBlob)
+		if err != nil {
+			log.Err(err).Msg("Could not get encrypt Title")
+			return nil, err
+		}
+		groupChangeActions.ModifyTitle = &signalpb.GroupChange_Actions_ModifyTitleAction{Title: *encryptedTitle}
+	}
+	if decryptedGroupChange.ModifyDescription != nil {
+		attributeBlob := signalpb.GroupAttributeBlob{Content: &signalpb.GroupAttributeBlob_Description{Description: *decryptedGroupChange.ModifyDescription}}
+		encryptedDescription, err := encryptBlobIntoGroupProperty(groupSecretParams, &attributeBlob)
+		if err != nil {
+			log.Err(err).Msg("Could not get encrypt description")
+			return nil, err
+		}
+		groupChangeActions.ModifyDescription = &signalpb.GroupChange_Actions_ModifyDescriptionAction{Description: *encryptedDescription}
+	}
+	if decryptedGroupChange.ModifyAvatar != nil {
+		groupChangeActions.ModifyAvatar = &signalpb.GroupChange_Actions_ModifyAvatarAction{Avatar: *decryptedGroupChange.ModifyAvatar}
+	}
+	for _, addMember := range decryptedGroupChange.AddMembers {
+		encryptedMember, err := cli.encryptMember(ctx, &addMember.GroupMember, &groupSecretParams)
+		if err != nil {
+			log.Err(err).Msg("Failed to encrypt GroupMember")
+		}
+		groupChangeActions.AddMembers = append(groupChangeActions.AddMembers, &signalpb.GroupChange_Actions_AddMemberAction{
+			Added:              encryptedMember,
+			JoinFromInviteLink: addMember.JoinFromInviteLink,
+		})
+	}
+	for _, deleteMember := range decryptedGroupChange.DeleteMembers {
+		encryptedUserID, err := groupSecretParams.EncryptUUID(*deleteMember)
+		if err != nil {
+			log.Err(err).Msg("Encrypt UserId error for deleteMember")
+			return nil, err
+		}
+		groupChangeActions.DeleteMembers = append(groupChangeActions.DeleteMembers, &signalpb.GroupChange_Actions_DeleteMemberAction{
+			DeletedUserId: encryptedUserID[:],
+		})
+	}
+	for _, modifyMemberRoles := range decryptedGroupChange.ModifyMemberRoles {
+		encryptedUserID, err := groupSecretParams.EncryptUUID(modifyMemberRoles.UserID)
+		if err != nil {
+			log.Err(err).Msg("Encrypt UserId error for modifyMemberRoles")
+			return nil, err
+		}
+		groupChangeActions.ModifyMemberRoles = append(groupChangeActions.ModifyMemberRoles, &signalpb.GroupChange_Actions_ModifyMemberRoleAction{
+			UserId: encryptedUserID[:],
+			Role:   signalpb.Member_Role(modifyMemberRoles.Role),
+		})
+	}
+	// for _, addPendingMember := range decryptedGroupChange.AddPendingMembers {
+	// }
+	for _, deletePendingMember := range decryptedGroupChange.DeletePendingMembers {
+		encryptedUserID, err := groupSecretParams.EncryptUUID(*deletePendingMember)
+		if err != nil {
+			log.Err(err).Msg("Encrypt UserId error for deletePendingMember")
+			return nil, err
+		}
+		groupChangeActions.DeletePendingMembers = append(groupChangeActions.DeletePendingMembers, &signalpb.GroupChange_Actions_DeletePendingMemberAction{
+			DeletedUserId: encryptedUserID[:],
+		})
+	}
+	for _, promotePendingMember := range decryptedGroupChange.PromotePendingMembers {
+		expiringProfileKeyCredential, err := cli.FetchExpiringProfileKeyCredentialById(ctx, promotePendingMember.UserID)
+		if err != nil {
+			log.Err(err).Msg("failed getting expiring profile key credential for addMember")
+			return nil, err
+		}
+		presentation, err := groupSecretParams.CreateExpiringProfileKeyCredentialPresentation(
+			prodServerPublicParams,
+			*expiringProfileKeyCredential,
+		)
+		if err != nil {
+			log.Err(err).Msg("failed creating expiring profile key credential presentation for addMember")
+			return nil, err
+		}
+		groupChangeActions.PromotePendingMembers = append(groupChangeActions.PromotePendingMembers, &signalpb.GroupChange_Actions_PromotePendingMemberAction{
+			Presentation: *presentation,
+		})
+	}
+	for _, addRequestingMember := range decryptedGroupChange.AddRequestingMembers {
+		expiringProfileKeyCredential, err := cli.FetchExpiringProfileKeyCredentialById(ctx, addRequestingMember.UserID)
+		if err != nil {
+			log.Err(err).Msg("failed getting expiring profile key credential for addMember")
+			return nil, err
+		}
+		presentation, err := groupSecretParams.CreateExpiringProfileKeyCredentialPresentation(
+			prodServerPublicParams,
+			*expiringProfileKeyCredential,
+		)
+		if err != nil {
+			log.Err(err).Msg("failed creating expiring profile key credential presentation for addMember")
+			return nil, err
+		}
+		groupChangeActions.AddRequestingMembers = append(groupChangeActions.AddRequestingMembers, &signalpb.GroupChange_Actions_AddRequestingMemberAction{
+			Added: &signalpb.RequestingMember{
+				Presentation: *presentation,
+			},
+		})
+	}
+	for _, deleteRequestingMember := range decryptedGroupChange.DeleteRequestingMembers {
+		encryptedUserID, err := groupSecretParams.EncryptUUID(*deleteRequestingMember)
+		if err != nil {
+			log.Err(err).Msg("Encrypt UserId error for promotePendingMember")
+			return nil, err
+		}
+		groupChangeActions.DeleteRequestingMembers = append(groupChangeActions.DeleteRequestingMembers, &signalpb.GroupChange_Actions_DeleteRequestingMemberAction{
+			DeletedUserId: encryptedUserID[:],
+		})
+	}
+	for _, promoteRequestingMember := range decryptedGroupChange.PromoteRequestingMembers {
+		encryptedUserID, err := groupSecretParams.EncryptUUID(promoteRequestingMember.UserID)
+		if err != nil {
+			log.Err(err).Msg("Encrypt UserId error for promoteRequestingMember")
+			return nil, err
+		}
+
+		groupChangeActions.PromoteRequestingMembers = append(groupChangeActions.PromoteRequestingMembers, &signalpb.GroupChange_Actions_PromoteRequestingMemberAction{
+			UserId: encryptedUserID[:],
+			Role:   signalpb.Member_Role(promoteRequestingMember.Role),
+		})
+	}
+	for _, addBannedMember := range decryptedGroupChange.AddBannedMembers {
+		encryptedUserID, err := groupSecretParams.EncryptUUID(addBannedMember.UserID)
+		if err != nil {
+			log.Err(err).Msg("Encrypt UserId error for promoteRequestingMember")
+			return nil, err
+		}
+		groupChangeActions.AddBannedMembers = append(groupChangeActions.AddBannedMembers, &signalpb.GroupChange_Actions_AddBannedMemberAction{
+			Added: &signalpb.BannedMember{
+				UserId:    encryptedUserID[:],
+				Timestamp: addBannedMember.Timestamp,
+			},
+		})
+	}
+	for _, deleteBannedMember := range decryptedGroupChange.DeleteBannedMembers {
+		encryptedUserID, err := groupSecretParams.EncryptUUID(*deleteBannedMember)
+		if err != nil {
+			log.Err(err).Msg("Encrypt UserId error for promoteRequestingMember")
+			return nil, err
+		}
+		groupChangeActions.DeleteBannedMembers = append(groupChangeActions.DeleteBannedMembers, &signalpb.GroupChange_Actions_DeleteBannedMemberAction{
+			DeletedUserId: encryptedUserID[:],
+		})
+	}
+	if decryptedGroupChange.ModifyAnnouncementsOnly != nil {
+		groupChangeActions.ModifyAnnouncementsOnly = &signalpb.GroupChange_Actions_ModifyAnnouncementsOnlyAction{
+			AnnouncementsOnly: *decryptedGroupChange.ModifyAnnouncementsOnly,
+		}
+	}
+	if decryptedGroupChange.ModifyAttributesAccess != nil {
+		groupChangeActions.ModifyAttributesAccess = &signalpb.GroupChange_Actions_ModifyAttributesAccessControlAction{
+			AttributesAccess: signalpb.AccessControl_AccessRequired(*decryptedGroupChange.ModifyAttributesAccess),
+		}
+	}
+	if decryptedGroupChange.ModifyMemberAccess != nil {
+		groupChangeActions.ModifyMemberAccess = &signalpb.GroupChange_Actions_ModifyMembersAccessControlAction{
+			MembersAccess: signalpb.AccessControl_AccessRequired(*decryptedGroupChange.ModifyMemberAccess),
+		}
+	}
+	if decryptedGroupChange.ModifyAddFromInviteLinkAccess != nil {
+		groupChangeActions.ModifyAddFromInviteLinkAccess = &signalpb.GroupChange_Actions_ModifyAddFromInviteLinkAccessControlAction{
+			AddFromInviteLinkAccess: signalpb.AccessControl_AccessRequired(*decryptedGroupChange.ModifyAddFromInviteLinkAccess),
+		}
+	}
+	if decryptedGroupChange.ModifyDisappearingMessagesDuration != nil {
+		attributeBlob := signalpb.GroupAttributeBlob{Content: &signalpb.GroupAttributeBlob_DisappearingMessagesDuration{DisappearingMessagesDuration: *decryptedGroupChange.ModifyDisappearingMessagesDuration}}
+		encryptedTimer, err := encryptBlobIntoGroupProperty(groupSecretParams, &attributeBlob)
+		if err != nil {
+			log.Err(err).Msg("Could not get encrypt Title")
+			return nil, err
+		}
+		groupChangeActions.ModifyDisappearingMessagesTimer = &signalpb.GroupChange_Actions_ModifyDisappearingMessagesTimerAction{Timer: *encryptedTimer}
+	}
+	if decryptedGroupChange.ModifyInviteLinkPassword != nil {
+		inviteLinkPasswordBytes, err := inviteLinkPasswordToBytes(*decryptedGroupChange.ModifyInviteLinkPassword)
+		if err != nil {
+			log.Err(err).Msg("Failed to decode invite link password")
+		}
+		groupChangeActions.ModifyInviteLinkPassword = &signalpb.GroupChange_Actions_ModifyInviteLinkPasswordAction{
+			InviteLinkPassword: inviteLinkPasswordBytes,
+		}
+	}
+
+	return cli.patchGroup(ctx, groupChangeActions, groupMasterKey, nil)
+}
+
+func (cli *Client) encryptMember(ctx context.Context, member *GroupMember, groupSecretParams *libsignalgo.GroupSecretParams) (*signalpb.Member, error) {
+	log := zerolog.Ctx(ctx)
+	expiringProfileKeyCredential, err := cli.FetchExpiringProfileKeyCredentialById(ctx, member.UserID)
+	if err != nil {
+		log.Err(err).Msg("failed getting expiring profile key credential for addMember")
+		return nil, err
+	}
+	presentation, err := groupSecretParams.CreateExpiringProfileKeyCredentialPresentation(
+		prodServerPublicParams,
+		*expiringProfileKeyCredential,
+	)
+	if err != nil {
+		log.Err(err).Msg("failed creating expiring profile key credential presentation for addMember")
+		return nil, err
+	}
+	encryptedMember := signalpb.Member{
+		Presentation: *presentation,
+		Role:         signalpb.Member_Role(member.Role),
+	}
+	return &encryptedMember, nil
+}
+
+var (
+	NoContentError               = RespError{Err: "NoContentError"}
+	GroupPatchNotAcceptedError   = RespError{Err: "GroupPatchNotAcceptedError"}
+	ConflictError                = RespError{Err: "ConflictError"}
+	AuthorizationFailedError     = RespError{Err: "AuthorizationFailedError"}
+	NotFoundError                = RespError{Err: "NotFoundError"}
+	ContactManifestMismatchError = RespError{Err: "ContactManifestMismatchError"}
+	RateLimitError               = RespError{Err: "RateLimitError"}
+	DeprecatedVersionError       = RespError{Err: "DeprecatedVersionError"}
+	GroupExistsError             = RespError{Err: "GroupExistsError"}
+)
+
+type RespError struct {
+	Err string
+}
+
+func (e RespError) Error() string {
+	return e.Err
+}
+
+func (cli *Client) patchGroup(ctx context.Context, groupChange *signalpb.GroupChange_Actions, groupMasterKey types.SerializedGroupMasterKey, groupLinkPassword []byte) (*signalpb.GroupChange, error) {
+	log := zerolog.Ctx(ctx).With().Str("action", "patchGroup").Logger()
+	groupAuth, err := cli.GetAuthorizationForToday(ctx, masterKeyToBytes(groupMasterKey))
+	if err != nil {
+		log.Err(err).Msg("Failed to get Authorization for today")
+		return nil, err
+	}
+	var path string
+	if groupLinkPassword == nil {
+		path = "/v1/groups/"
+	} else {
+		path = fmt.Sprintf("/v1/groups/?inviteLinkPassword=%s", base64.StdEncoding.EncodeToString(groupLinkPassword))
+	}
+	requestBody, err := proto.Marshal(groupChange)
+	if err != nil {
+		log.Err(err).Msg("Failed to marshal request")
+		return nil, err
+	}
+	opts := &web.HTTPReqOpt{
+		Username:    &groupAuth.Username,
+		Password:    &groupAuth.Password,
+		ContentType: web.ContentTypeProtobuf,
+		Body:        requestBody,
+		Host:        web.StorageHostname,
+	}
+	resp, err := web.SendHTTPRequest(ctx, http.MethodPatch, path, opts)
+	if err != nil {
+		return nil, fmt.Errorf("SendRequest error: %w", err)
+	}
+	switch resp.StatusCode {
+	case http.StatusNoContent:
+		return nil, NoContentError
+	case http.StatusBadRequest:
+		return nil, GroupPatchNotAcceptedError
+	case http.StatusForbidden:
+		return nil, AuthorizationFailedError
+	case http.StatusNotFound:
+		return nil, NotFoundError
+	case http.StatusConflict:
+		if resp.Body != nil {
+			return nil, ContactManifestMismatchError
+		} else {
+			return nil, ConflictError
+		}
+	case http.StatusTooManyRequests:
+		return nil, RateLimitError
+	case 499:
+		return nil, DeprecatedVersionError
+	}
+	if resp.Body == nil {
+		return nil, errors.New("no response body")
+	}
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, fmt.Errorf("failed to read storage manifest response: %w", err)
+	}
+	signedGroupChange := signalpb.GroupChange{}
+	err = proto.Unmarshal(body, &signedGroupChange)
+	if err != nil {
+		return nil, fmt.Errorf("failed to unmarshal signed groupChange: %w", err)
+	}
+	return &signedGroupChange, nil
+}
+
+func (cli *Client) UpdateGroup(ctx context.Context, groupChange *GroupChange, gid types.GroupIdentifier) (uint32, error) {
+	log := zerolog.Ctx(ctx).With().Str("action", "UpdateGroup").Logger()
+	groupMasterKey, err := cli.Store.GroupStore.MasterKeyFromGroupIdentifier(ctx, gid)
+	if err != nil {
+		log.Err(err).Msg("Could not get master key from group id")
+		return 0, err
+	}
+	groupChange.groupMasterKey = groupMasterKey
+	masterKeyBytes := masterKeyToBytes(groupMasterKey)
+	var refetchedAddMemberCredentials bool
+	var signedGroupChange *signalpb.GroupChange
+	group, err := cli.RetrieveGroupByID(ctx, gid, 0)
+	if err != nil {
+		log.Err(err).Msg("Failed to retrieve Group")
+	}
+	if group.InviteLinkPassword == nil && groupChange.ModifyAddFromInviteLinkAccess != nil && groupChange.ModifyInviteLinkPassword != nil {
+		inviteLinkPasswordBytes := make([]byte, 16)
+		rand.Read(inviteLinkPasswordBytes)
+		inviteLinkPassword := InviteLinkPasswordFromBytes(inviteLinkPasswordBytes)
+		groupChange.ModifyInviteLinkPassword = &inviteLinkPassword
+	}
+	groupChange.Revision = group.Revision + 1
+	for attempt := 0; attempt < 5; attempt++ {
+		signedGroupChange, err = cli.EncryptAndSignGroupChange(ctx, groupChange, gid)
+		if errors.Is(err, GroupPatchNotAcceptedError) {
+			log.Warn().Str("Error applying GroupChange, retrying...", err.Error())
+			if len(groupChange.AddMembers) > 0 && !refetchedAddMemberCredentials {
+				refetchedAddMemberCredentials = true
+				// change = refetchAddMemberCredentials(change); TODO
+			} else {
+				return 0, fmt.Errorf("Group Change Failed: %w", err)
+			}
+		} else if errors.Is(err, ConflictError) {
+			delete(cli.GroupCache.groups, gid)
+			delete(cli.GroupCache.lastFetched, gid)
+			delete(cli.GroupCache.activeCalls, gid)
+			group, err = cli.RetrieveGroupByID(ctx, gid, 0)
+			groupChange.resolveConflict(group)
+			if groupChange.isEmptpy() {
+				log.Debug().Msg("Change is empty after conflict resolution")
+			}
+			groupChange.Revision = group.Revision + 1
+		} else {
+			break
+		}
+	}
+	delete(cli.GroupCache.groups, gid)
+	delete(cli.GroupCache.lastFetched, gid)
+	delete(cli.GroupCache.activeCalls, gid)
+	if err != nil {
+		log.Err(err).Msg("couldn't patch group on server")
+		return 0, err
+	}
+	groupChangeBytes, err := proto.Marshal(signedGroupChange)
+	if err != nil {
+		log.Err(err).Msg("Error marshalling signed GroupChange")
+		return 0, err
+	}
+	groupContext := &signalpb.GroupContextV2{Revision: &groupChange.Revision, GroupChange: groupChangeBytes, MasterKey: masterKeyBytes[:]}
+	_, err = cli.SendGroupChange(ctx, group, groupContext, groupChange)
+	if err != nil {
+		log.Err(err).Msg("Error sending GroupChange to group members")
+	}
+	return groupChange.Revision, nil
+}
+
+func (cli *Client) EncryptGroup(ctx context.Context, decryptedGroup *Group, groupSecretParams libsignalgo.GroupSecretParams) (*signalpb.Group, error) {
+	log := zerolog.Ctx(ctx)
+	attributeBlob := signalpb.GroupAttributeBlob{Content: &signalpb.GroupAttributeBlob_Title{Title: decryptedGroup.Title}}
+	encryptedTitle, err := encryptBlobIntoGroupProperty(groupSecretParams, &attributeBlob)
+	if err != nil {
+		log.Err(err).Msg("Could not get encrypt Title")
+		return nil, err
+	}
+	groupPublicParams, err := groupSecretParams.GetPublicParams()
+	if err != nil {
+		log.Err(err).Msg("Couldn't get public params from GroupSecretParams")
+		return nil, err
+	}
+	encryptedGroup := &signalpb.Group{
+		PublicKey:         groupPublicParams[:],
+		Title:             *encryptedTitle,
+		Avatar:            decryptedGroup.AvatarPath,
+		AnnouncementsOnly: decryptedGroup.AnnouncementsOnly,
+		Revision:          0,
+	}
+	if decryptedGroup.Description != "" {
+		attributeBlob := signalpb.GroupAttributeBlob{Content: &signalpb.GroupAttributeBlob_Description{Description: decryptedGroup.Description}}
+		encryptedDescription, err := encryptBlobIntoGroupProperty(groupSecretParams, &attributeBlob)
+		if err != nil {
+			log.Err(err).Msg("Could not get encrypt Description")
+			return nil, err
+		}
+		encryptedGroup.Description = *encryptedDescription
+	}
+	if decryptedGroup.AccessControl != nil {
+		encryptedGroup.AccessControl = &signalpb.AccessControl{
+			Members:           signalpb.AccessControl_AccessRequired(decryptedGroup.AccessControl.Members),
+			Attributes:        signalpb.AccessControl_AccessRequired(decryptedGroup.AccessControl.Attributes),
+			AddFromInviteLink: signalpb.AccessControl_AccessRequired(decryptedGroup.AccessControl.AddFromInviteLink),
+		}
+		if decryptedGroup.AccessControl.AddFromInviteLink != AccessControl_UNSATISFIABLE {
+			inviteLinkPasswordBytes := make([]byte, 16)
+			rand.Read(inviteLinkPasswordBytes)
+			encryptedGroup.InviteLinkPassword = inviteLinkPasswordBytes
+		}
+	}
+	for _, member := range decryptedGroup.Members {
+		encryptedMember, err := cli.encryptMember(ctx, member, &groupSecretParams)
+		if err != nil {
+			log.Err(err).Msg("Failed to encrypt GroupMember")
+		}
+		encryptedGroup.Members = append(encryptedGroup.Members, encryptedMember)
+	}
+	return encryptedGroup, nil
+}
+
+func (cli *Client) CreateGroupOnServer(ctx context.Context, decryptedGroup *Group, avatarBytes []byte) (*Group, error) {
+	log := zerolog.Ctx(ctx).With().Str("action", "CreateGroupOnServer").Logger()
+	masterKeyByteArray := make([]byte, 32)
+	rand.Read(masterKeyByteArray)
+	masterKeyBytes := libsignalgo.GroupMasterKey(masterKeyByteArray)
+	groupMasterKey := masterKeyFromBytes(masterKeyBytes)
+	groupId, err := groupIdentifierFromMasterKey(groupMasterKey)
+	if err != nil {
+		log.Err(err).Msg("Couldn't get gid from masterkey")
+		return nil, err
+	}
+	err = cli.Store.GroupStore.StoreMasterKey(ctx, groupId, groupMasterKey)
+	if err != nil {
+		return nil, fmt.Errorf("StoreMasterKey error: %w", err)
+	}
+	log.Debug().Msg(string(groupMasterKey))
+	groupSecretParams, err := libsignalgo.DeriveGroupSecretParamsFromMasterKey(masterKeyBytes)
+	if err != nil {
+		log.Err(err).Msg("DeriveGroupSecretParamsFromMasterKey error")
+		return nil, err
+	}
+	if len(avatarBytes) > 0 {
+		avatarPath, err := cli.UploadGroupAvatar(ctx, avatarBytes, groupId)
+		if err != nil {
+			log.Err(err).Msg("Failed to upload group avatar")
+			return nil, err
+		}
+		decryptedGroup.AvatarPath = *avatarPath
+	}
+	encryptedGroup, err := cli.EncryptGroup(ctx, decryptedGroup, groupSecretParams)
+	if err != nil {
+		log.Err(err).Msg("Failed to encrypt group")
+		return nil, err
+	}
+	log.Debug().Stringer("groupID", groupId)
+	groupAuth, err := cli.GetAuthorizationForToday(ctx, masterKeyBytes)
+	if err != nil {
+		log.Err(err).Msg("Failed to get Authorization for today")
+		return nil, err
+	}
+	path := "/v1/groups/"
+	requestBody, err := proto.Marshal(encryptedGroup)
+	if err != nil {
+		log.Err(err).Msg("Failed to marshal request")
+		return nil, err
+	}
+	opts := &web.HTTPReqOpt{
+		Username:    &groupAuth.Username,
+		Password:    &groupAuth.Password,
+		ContentType: web.ContentTypeProtobuf,
+		Body:        requestBody,
+		Host:        web.StorageHostname,
+	}
+	resp, err := web.SendHTTPRequest(ctx, http.MethodPut, path, opts)
+	if err != nil {
+		return nil, fmt.Errorf("SendRequest error: %w", err)
+	}
+	switch resp.StatusCode {
+	case http.StatusNoContent:
+		return nil, NoContentError
+	case http.StatusForbidden:
+		return nil, AuthorizationFailedError
+	case http.StatusNotFound:
+		return nil, NotFoundError
+	case http.StatusConflict:
+		return nil, GroupExistsError
+	case http.StatusTooManyRequests:
+		return nil, RateLimitError
+	case 499:
+		return nil, DeprecatedVersionError
+	case http.StatusBadRequest:
+		return nil, fmt.Errorf("failed to put new group: bad request")
+	}
+	group, err := cli.fetchGroupWithMasterKey(ctx, groupMasterKey)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get new group: %w", err)
+	}
+	log.Debug().Stringer("group id", group.GroupIdentifier).Msg("new group created")
+	return group, nil
+}
+
+func GenerateInviteLinkPassword() types.SerializedInviteLinkPassword {
+	inviteLinkPasswordBytes := make([]byte, 16)
+	rand.Read(inviteLinkPasswordBytes)
+	return InviteLinkPasswordFromBytes(inviteLinkPasswordBytes)
+}
diff --git a/pkg/signalmeow/keys.go b/pkg/signalmeow/keys.go
index 48a70a9e..ac4a3b20 100644
--- a/pkg/signalmeow/keys.go
+++ b/pkg/signalmeow/keys.go
@@ -26,11 +26,10 @@ import (
 	"strings"
 	"time"
 
-	"github.com/google/uuid"
 	"github.com/rs/zerolog"
 
 	"go.mau.fi/mautrix-signal/pkg/libsignalgo"
-	"go.mau.fi/mautrix-signal/pkg/signalmeow/types"
+	"go.mau.fi/mautrix-signal/pkg/signalmeow/store"
 	"go.mau.fi/mautrix-signal/pkg/signalmeow/web"
 )
 
@@ -42,18 +41,18 @@ type GeneratedPreKeys struct {
 	IdentityKey  []uint8
 }
 
-func (cli *Client) GenerateAndRegisterPreKeys(ctx context.Context, uuidKind types.UUIDKind) error {
-	_, err := cli.GenerateAndSaveNextPreKeyBatch(ctx, uuidKind)
+func (cli *Client) GenerateAndRegisterPreKeys(ctx context.Context, pks store.PreKeyStore) error {
+	_, err := cli.GenerateAndSaveNextPreKeyBatch(ctx, pks)
 	if err != nil {
 		return fmt.Errorf("failed to generate and save next prekey batch: %w", err)
 	}
-	_, err = cli.GenerateAndSaveNextKyberPreKeyBatch(ctx, uuidKind)
+	_, err = cli.GenerateAndSaveNextKyberPreKeyBatch(ctx, pks)
 	if err != nil {
 		return fmt.Errorf("failed to generate and save next kyber prekey batch: %w", err)
 	}
 
 	// We need to upload all currently valid prekeys, not just the ones we just generated
-	err = cli.RegisterAllPreKeys(ctx, uuidKind)
+	err = cli.RegisterAllPreKeys(ctx, pks)
 	if err != nil {
 		return fmt.Errorf("failed to register prekey batches: %w", err)
 	}
@@ -61,20 +60,22 @@ func (cli *Client) GenerateAndRegisterPreKeys(ctx context.Context, uuidKind type
 	return err
 }
 
-func (cli *Client) RegisterAllPreKeys(ctx context.Context, uuidKind types.UUIDKind) error {
+func (cli *Client) RegisterAllPreKeys(ctx context.Context, pks store.PreKeyStore) error {
 	var identityKeyPair *libsignalgo.IdentityKeyPair
-	if uuidKind == types.UUIDKindPNI {
+	var pni bool
+	if pks.GetServiceID().Type == libsignalgo.ServiceIDTypePNI {
+		pni = true
 		identityKeyPair = cli.Store.PNIIdentityKeyPair
 	} else {
 		identityKeyPair = cli.Store.ACIIdentityKeyPair
 	}
 
 	// Get all prekeys and kyber prekeys from the database
-	preKeys, err := cli.Store.PreKeyStoreExtras.AllPreKeys(ctx, uuidKind)
+	preKeys, err := pks.AllPreKeys(ctx)
 	if err != nil {
 		return fmt.Errorf("failed to get all prekeys: %w", err)
 	}
-	kyberPreKeys, err := cli.Store.PreKeyStoreExtras.AllNormalKyberPreKeys(ctx, uuidKind)
+	kyberPreKeys, err := pks.AllNormalKyberPreKeys(ctx)
 	if err != nil {
 		return fmt.Errorf("failed to get all kyber prekeys: %w", err)
 	}
@@ -94,42 +95,25 @@ func (cli *Client) RegisterAllPreKeys(ctx context.Context, uuidKind types.UUIDKi
 		KyberPreKeys: kyberPreKeys,
 		IdentityKey:  identityKey,
 	}
-	preKeyUsername := cli.Store.Number
-	if cli.Store.ACI != uuid.Nil {
-		preKeyUsername = cli.Store.ACI.String()
-	}
-	preKeyUsername = fmt.Sprintf("%s.%d", preKeyUsername, cli.Store.DeviceID)
+	preKeyUsername := fmt.Sprintf("%s.%d", cli.Store.ACI, cli.Store.DeviceID)
 	log := zerolog.Ctx(ctx).With().Str("action", "register prekeys").Logger()
 	log.Debug().Int("num_prekeys", len(preKeys)).Int("num_kyber_prekeys", len(kyberPreKeys)).Msg("Registering prekeys")
-	err = RegisterPreKeys(ctx, &generatedPreKeys, uuidKind, preKeyUsername, cli.Store.Password)
+	err = RegisterPreKeys(ctx, &generatedPreKeys, pni, preKeyUsername, cli.Store.Password)
 	if err != nil {
 		return fmt.Errorf("failed to register prekeys: %w", err)
 	}
 
-	// Mark prekeys as registered
-	// (kyber prekeys don't have "mark as uploaded" we just assume they always are)
-	// TODO: we don't need to mark prekeys as uploaded, since we just upload all unused prekeys each time.
-	// So we can drop this column and remove these methods
-	lastPreKeyID, err := preKeys[len(preKeys)-1].GetID()
-	if err != nil {
-		return fmt.Errorf("failed to get last prekey ID: %w", err)
-	}
-	err = cli.Store.PreKeyStoreExtras.MarkPreKeysAsUploaded(ctx, uuidKind, lastPreKeyID)
-	if err != nil {
-		zerolog.Ctx(ctx).Err(err).Msg("Failed to mark prekeys as uploaded")
-	}
-
 	return err
 }
 
-func (cli *Client) GenerateAndSaveNextPreKeyBatch(ctx context.Context, uuidKind types.UUIDKind) ([]*libsignalgo.PreKeyRecord, error) {
-	nextPreKeyID, err := cli.Store.PreKeyStoreExtras.GetNextPreKeyID(ctx, uuidKind)
+func (cli *Client) GenerateAndSaveNextPreKeyBatch(ctx context.Context, pks store.PreKeyStore) ([]*libsignalgo.PreKeyRecord, error) {
+	nextPreKeyID, err := pks.GetNextPreKeyID(ctx)
 	if err != nil {
 		return nil, fmt.Errorf("failed to get next prekey ID: %w", err)
 	}
-	preKeys := GeneratePreKeys(nextPreKeyID, PREKEY_BATCH_SIZE, uuidKind)
+	preKeys := GeneratePreKeys(nextPreKeyID, PREKEY_BATCH_SIZE)
 	for _, preKey := range preKeys {
-		err = cli.Store.PreKeyStoreExtras.SavePreKey(ctx, uuidKind, preKey, false)
+		err = pks.StorePreKey(ctx, 0, preKey)
 		if err != nil {
 			return nil, fmt.Errorf("failed to save prekey: %w", err)
 		}
@@ -137,20 +121,20 @@ func (cli *Client) GenerateAndSaveNextPreKeyBatch(ctx context.Context, uuidKind
 	return preKeys, nil
 }
 
-func (cli *Client) GenerateAndSaveNextKyberPreKeyBatch(ctx context.Context, uuidKind types.UUIDKind) ([]*libsignalgo.KyberPreKeyRecord, error) {
+func (cli *Client) GenerateAndSaveNextKyberPreKeyBatch(ctx context.Context, pks store.PreKeyStore) ([]*libsignalgo.KyberPreKeyRecord, error) {
 	var identityKeyPair *libsignalgo.IdentityKeyPair
-	if uuidKind == types.UUIDKindPNI {
+	if pks.GetServiceID().Type == libsignalgo.ServiceIDTypePNI {
 		identityKeyPair = cli.Store.PNIIdentityKeyPair
 	} else {
 		identityKeyPair = cli.Store.ACIIdentityKeyPair
 	}
-	nextKyberPreKeyID, err := cli.Store.PreKeyStoreExtras.GetNextKyberPreKeyID(ctx, uuidKind)
+	nextKyberPreKeyID, err := pks.GetNextKyberPreKeyID(ctx)
 	if err != nil {
 		return nil, fmt.Errorf("failed to get next kyber prekey ID: %w", err)
 	}
-	kyberPreKeys := GenerateKyberPreKeys(nextKyberPreKeyID, PREKEY_BATCH_SIZE, uuidKind, identityKeyPair)
+	kyberPreKeys := GenerateKyberPreKeys(nextKyberPreKeyID, PREKEY_BATCH_SIZE, identityKeyPair)
 	for _, kyberPreKey := range kyberPreKeys {
-		err = cli.Store.PreKeyStoreExtras.SaveKyberPreKey(ctx, uuidKind, kyberPreKey, false)
+		err = pks.StoreKyberPreKey(ctx, 0, kyberPreKey)
 		if err != nil {
 			return nil, fmt.Errorf("failed to save kyber prekey: %w", err)
 		}
@@ -158,14 +142,14 @@ func (cli *Client) GenerateAndSaveNextKyberPreKeyBatch(ctx context.Context, uuid
 	return kyberPreKeys, nil
 }
 
-func GeneratePreKeys(startKeyId uint, count uint, uuidKind types.UUIDKind) []*libsignalgo.PreKeyRecord {
-	generatedPreKeys := []*libsignalgo.PreKeyRecord{}
-	for i := startKeyId; i < startKeyId+count; i++ {
+func GeneratePreKeys(startKeyID uint32, count uint32) []*libsignalgo.PreKeyRecord {
+	generatedPreKeys := make([]*libsignalgo.PreKeyRecord, 0, count)
+	for keyID := startKeyID; keyID < startKeyID+count; keyID++ {
 		privateKey, err := libsignalgo.GeneratePrivateKey()
 		if err != nil {
 			panic(fmt.Errorf("error generating private key: %w", err))
 		}
-		preKey, err := libsignalgo.NewPreKeyRecordFromPrivateKey(uint32(i), privateKey)
+		preKey, err := libsignalgo.NewPreKeyRecordFromPrivateKey(keyID, privateKey)
 		if err != nil {
 			panic(fmt.Errorf("error creating prekey record: %w", err))
 		}
@@ -174,9 +158,9 @@ func GeneratePreKeys(startKeyId uint, count uint, uuidKind types.UUIDKind) []*li
 	return generatedPreKeys
 }
 
-func GenerateKyberPreKeys(startKeyId uint, count uint, uuidKind types.UUIDKind, identityKeyPair *libsignalgo.IdentityKeyPair) []*libsignalgo.KyberPreKeyRecord {
-	generatedKyberPreKeys := []*libsignalgo.KyberPreKeyRecord{}
-	for i := startKeyId; i < startKeyId+count; i++ {
+func GenerateKyberPreKeys(startKeyID uint32, count uint32, identityKeyPair *libsignalgo.IdentityKeyPair) []*libsignalgo.KyberPreKeyRecord {
+	generatedKyberPreKeys := make([]*libsignalgo.KyberPreKeyRecord, 0, count)
+	for keyID := startKeyID; keyID < startKeyID+count; keyID++ {
 		kyberPreKeyPair, err := libsignalgo.KyberKeyPairGenerate()
 		if err != nil {
 			panic(fmt.Errorf("error generating kyber key pair: %w", err))
@@ -193,7 +177,7 @@ func GenerateKyberPreKeys(startKeyId uint, count uint, uuidKind types.UUIDKind,
 		if err != nil {
 			panic(fmt.Errorf("error signing kyber public key: %w", err))
 		}
-		preKey, err := libsignalgo.NewKyberPreKeyRecord(uint32(i), time.Now(), kyberPreKeyPair, signature)
+		preKey, err := libsignalgo.NewKyberPreKeyRecord(keyID, time.Now(), kyberPreKeyPair, signature)
 		if err != nil {
 			panic(fmt.Errorf("error creating kyber prekey record: %w", err))
 
@@ -203,7 +187,7 @@ func GenerateKyberPreKeys(startKeyId uint, count uint, uuidKind types.UUIDKind,
 	return generatedKyberPreKeys
 }
 
-func GenerateSignedPreKey(startSignedKeyId uint32, uuidKind types.UUIDKind, identityKeyPair *libsignalgo.IdentityKeyPair) *libsignalgo.SignedPreKeyRecord {
+func GenerateSignedPreKey(startSignedKeyId uint32, identityKeyPair *libsignalgo.IdentityKeyPair) *libsignalgo.SignedPreKeyRecord {
 	// Generate a signed prekey
 	privateKey, err := libsignalgo.GeneratePrivateKey()
 	if err != nil {
@@ -267,7 +251,7 @@ func KyberPreKeyToJSON(kyberPreKey *libsignalgo.KyberPreKeyRecord) map[string]in
 	return kyberPreKeyJson
 }
 
-func RegisterPreKeys(ctx context.Context, generatedPreKeys *GeneratedPreKeys, uuidKind types.UUIDKind, username string, password string) error {
+func RegisterPreKeys(ctx context.Context, generatedPreKeys *GeneratedPreKeys, pni bool, username string, password string) error {
 	log := zerolog.Ctx(ctx).With().Str("action", "register prekeys").Logger()
 	// Convert generated prekeys to JSON
 	preKeysJson := []map[string]interface{}{}
@@ -289,14 +273,13 @@ func RegisterPreKeys(ctx context.Context, generatedPreKeys *GeneratedPreKeys, uu
 	}
 
 	// Send request
-	keysPath := "/v2/keys?identity=" + string(uuidKind)
 	jsonBytes, err := json.Marshal(register_json)
 	if err != nil {
 		log.Err(err).Msg("Error marshalling register JSON")
 		return err
 	}
 	opts := &web.HTTPReqOpt{Body: jsonBytes, Username: &username, Password: &password}
-	resp, err := web.SendHTTPRequest(ctx, http.MethodPut, keysPath, opts)
+	resp, err := web.SendHTTPRequest(ctx, http.MethodPut, keysPath(pni), opts)
 	if err != nil {
 		log.Err(err).Msg("Error sending request")
 		return err
@@ -341,13 +324,13 @@ func addBase64PaddingAndDecode(data string) ([]byte, error) {
 	return base64.StdEncoding.DecodeString(data)
 }
 
-func (cli *Client) FetchAndProcessPreKey(ctx context.Context, theirUUID uuid.UUID, specificDeviceID int) error {
+func (cli *Client) FetchAndProcessPreKey(ctx context.Context, theirServiceID libsignalgo.ServiceID, specificDeviceID int) error {
 	// Fetch prekey
 	deviceIDPath := "/*"
 	if specificDeviceID >= 0 {
 		deviceIDPath = "/" + fmt.Sprint(specificDeviceID)
 	}
-	path := "/v2/keys/" + theirUUID.String() + deviceIDPath + "?pq=true"
+	path := "/v2/keys/" + theirServiceID.String() + deviceIDPath + "?pq=true"
 	username, password := cli.Store.BasicAuthCreds()
 	resp, err := web.SendHTTPRequest(ctx, http.MethodGet, path, &web.HTTPReqOpt{Username: &username, Password: &password})
 	if err != nil {
@@ -436,7 +419,7 @@ func (cli *Client) FetchAndProcessPreKey(ctx context.Context, theirUUID uuid.UUI
 		if err != nil {
 			return fmt.Errorf("error creating prekey bundle: %w", err)
 		}
-		address, err := libsignalgo.NewUUIDAddress(theirUUID, uint(d.DeviceID))
+		address, err := theirServiceID.Address(uint(d.DeviceID))
 		if err != nil {
 			return fmt.Errorf("error creating address: %w", err)
 		}
@@ -444,8 +427,8 @@ func (cli *Client) FetchAndProcessPreKey(ctx context.Context, theirUUID uuid.UUI
 			ctx,
 			preKeyBundle,
 			address,
-			cli.Store.SessionStore,
-			cli.Store.IdentityStore,
+			cli.Store.ACISessionStore,
+			cli.Store.ACIIdentityStore,
 		)
 		if err != nil {
 			return fmt.Errorf("error processing prekey bundle: %w", err)
@@ -455,11 +438,22 @@ func (cli *Client) FetchAndProcessPreKey(ctx context.Context, theirUUID uuid.UUI
 	return err
 }
 
-func (cli *Client) GetMyKeyCounts(ctx context.Context, uuidKind types.UUIDKind) (int, int, error) {
+const (
+	aciKeysPath = "/v2/keys?identity=aci"
+	pniKeysPath = "/v2/keys?identity=pni"
+)
+
+func keysPath(pni bool) string {
+	if pni {
+		return pniKeysPath
+	}
+	return aciKeysPath
+}
+
+func (cli *Client) GetMyKeyCounts(ctx context.Context, pni bool) (int, int, error) {
 	log := zerolog.Ctx(ctx).With().Str("action", "get my key counts").Logger()
 	username, password := cli.Store.BasicAuthCreds()
-	path := "/v2/keys?identity=" + string(uuidKind)
-	resp, err := web.SendHTTPRequest(ctx, http.MethodGet, path, &web.HTTPReqOpt{Username: &username, Password: &password})
+	resp, err := web.SendHTTPRequest(ctx, http.MethodGet, keysPath(pni), &web.HTTPReqOpt{Username: &username, Password: &password})
 	if err != nil {
 		log.Err(err).Msg("Error sending request")
 		return 0, 0, err
@@ -473,10 +467,10 @@ func (cli *Client) GetMyKeyCounts(ctx context.Context, uuidKind types.UUIDKind)
 	return preKeyCountResponse.Count, preKeyCountResponse.PQCount, err
 }
 
-func (cli *Client) CheckAndUploadNewPreKeys(ctx context.Context, uuidKind types.UUIDKind) error {
+func (cli *Client) CheckAndUploadNewPreKeys(ctx context.Context, pks store.PreKeyStore) error {
 	log := zerolog.Ctx(ctx).With().Str("action", "check and upload new prekeys").Logger()
 	// Check if we need to upload prekeys
-	preKeyCount, kyberPreKeyCount, err := cli.GetMyKeyCounts(ctx, uuidKind)
+	preKeyCount, kyberPreKeyCount, err := cli.GetMyKeyCounts(ctx, pks.GetServiceID().Type == libsignalgo.ServiceIDTypePNI)
 	if err != nil {
 		log.Err(err).Msg("Error getting prekey counts")
 		return err
@@ -487,7 +481,7 @@ func (cli *Client) CheckAndUploadNewPreKeys(ctx context.Context, uuidKind types.
 	var kyberPreKeys []*libsignalgo.KyberPreKeyRecord
 	if preKeyCount < 10 {
 		log.Info().Int("preKeyCount", preKeyCount).Msg("Generating and saving new prekeys")
-		preKeys, err = cli.GenerateAndSaveNextPreKeyBatch(ctx, uuidKind)
+		preKeys, err = cli.GenerateAndSaveNextPreKeyBatch(ctx, pks)
 		if err != nil {
 			log.Err(err).Msg("Error generating and saving next prekey batch")
 			return err
@@ -495,7 +489,7 @@ func (cli *Client) CheckAndUploadNewPreKeys(ctx context.Context, uuidKind types.
 	}
 	if kyberPreKeyCount < 10 {
 		log.Info().Int("kyberPreKeyCount", kyberPreKeyCount).Msg("Generating and saving new kyber prekeys")
-		kyberPreKeys, err = cli.GenerateAndSaveNextKyberPreKeyBatch(ctx, uuidKind)
+		kyberPreKeys, err = cli.GenerateAndSaveNextKyberPreKeyBatch(ctx, pks)
 		if err != nil {
 			log.Err(err).Msg("Error generating and saving next kyber prekey batch")
 			return err
@@ -505,7 +499,7 @@ func (cli *Client) CheckAndUploadNewPreKeys(ctx context.Context, uuidKind types.
 		log.Debug().Msg("No new prekeys to upload")
 		return nil
 	}
-	err = cli.RegisterAllPreKeys(ctx, uuidKind)
+	err = cli.RegisterAllPreKeys(ctx, pks)
 	if err != nil {
 		log.Err(err).Msg("Error registering prekey batches")
 		return err
@@ -513,7 +507,7 @@ func (cli *Client) CheckAndUploadNewPreKeys(ctx context.Context, uuidKind types.
 	return nil
 }
 
-func (cli *Client) StartKeyCheckLoop(ctx context.Context, uuidKind types.UUIDKind) {
+func (cli *Client) StartKeyCheckLoop(ctx context.Context) {
 	log := zerolog.Ctx(ctx).With().Str("action", "start key check loop").Logger()
 	go func() {
 		// Do the initial check within an hour of starting the loop
@@ -528,9 +522,17 @@ func (cli *Client) StartKeyCheckLoop(ctx context.Context, uuidKind types.UUIDKin
 			case <-ctx.Done():
 				return
 			case <-time.After(check_time):
-				err := cli.CheckAndUploadNewPreKeys(ctx, uuidKind)
+				err := cli.CheckAndUploadNewPreKeys(ctx, cli.Store.ACIPreKeyStore)
+				if err != nil {
+					log.Err(err).Msg("Error checking and uploading new prekeys for ACI identity")
+					// Retry within half an hour
+					window_start = 5
+					window_size = 25
+					continue
+				}
+				err = cli.CheckAndUploadNewPreKeys(ctx, cli.Store.PNIPreKeyStore)
 				if err != nil {
-					log.Err(err).Msg("Error checking and uploading new prekeys")
+					log.Err(err).Msg("Error checking and uploading new prekeys for PNI identity")
 					// Retry within half an hour
 					window_start = 5
 					window_size = 25
diff --git a/pkg/signalmeow/profile.go b/pkg/signalmeow/profile.go
index f27db40e..81a6c860 100644
--- a/pkg/signalmeow/profile.go
+++ b/pkg/signalmeow/profile.go
@@ -102,7 +102,7 @@ func (cli *Client) ProfileKeyCredentialRequest(ctx context.Context, signalACI uu
 }
 
 func (cli *Client) ProfileKeyForSignalID(ctx context.Context, signalACI uuid.UUID) (*libsignalgo.ProfileKey, error) {
-	profileKey, err := cli.Store.ProfileKeyStore.LoadProfileKey(ctx, signalACI)
+	profileKey, err := cli.Store.RecipientStore.LoadProfileKey(ctx, signalACI)
 	if err != nil {
 		return nil, fmt.Errorf("error getting profile key: %w", err)
 	}
@@ -154,7 +154,6 @@ func (cli *Client) RetrieveProfileByID(ctx context.Context, signalID uuid.UUID)
 }
 
 func (cli *Client) fetchProfileByID(ctx context.Context, signalID uuid.UUID) (*types.Profile, error) {
-	log := zerolog.Ctx(ctx)
 	profileKey, err := cli.ProfileKeyForSignalID(ctx, signalID)
 	if err != nil {
 		return nil, fmt.Errorf("error getting profile key: %w", err)
@@ -162,6 +161,15 @@ func (cli *Client) fetchProfileByID(ctx context.Context, signalID uuid.UUID) (*t
 		return nil, errProfileKeyNotFound
 	}
 
+	credentialRequest, err := cli.ProfileKeyCredentialRequest(ctx, signalID)
+	if err != nil {
+		return nil, fmt.Errorf("error getting profile key credential request: %w", err)
+	}
+	return cli.fetchProfileWithRequestAndKey(ctx, signalID, credentialRequest, profileKey)
+}
+
+func (cli *Client) fetchProfileWithRequestAndKey(ctx context.Context, signalID uuid.UUID, credentialRequest []byte, profileKey *libsignalgo.ProfileKey) (*types.Profile, error) {
+	log := zerolog.Ctx(ctx)
 	profileKeyVersion, err := profileKey.GetProfileKeyVersion(signalID)
 	if err != nil {
 		return nil, fmt.Errorf("error getting profile key version: %w", err)
@@ -172,12 +180,6 @@ func (cli *Client) fetchProfileByID(ctx context.Context, signalID uuid.UUID) (*t
 		return nil, fmt.Errorf("error deriving access key: %w", err)
 	}
 	base64AccessKey := base64.StdEncoding.EncodeToString(accessKey[:])
-
-	credentialRequest, err := cli.ProfileKeyCredentialRequest(ctx, signalID)
-	if err != nil {
-		return nil, fmt.Errorf("error getting profile key credential request: %w", err)
-	}
-
 	path := "/v1/profile/" + signalID.String()
 	useUnidentified := profileKeyVersion != nil && accessKey != nil
 	if useUnidentified {
@@ -241,6 +243,7 @@ func (cli *Client) fetchProfileByID(ctx context.Context, signalID uuid.UUID) (*t
 	}
 	// TODO store other metadata fields?
 	profile.AvatarPath = profileResponse.Avatar
+	profile.Credential = profileResponse.Credential
 	profile.Key = *profileKey
 
 	return &profile, nil
@@ -344,3 +347,41 @@ func AesgcmEncrypt(key, nonce, plaintext []byte) ([]byte, error) {
 	}
 	return aesgcm.Seal(nil, nonce, plaintext, nil), nil
 }
+
+func (cli *Client) FetchExpiringProfileKeyCredentialById(ctx context.Context, signalACI uuid.UUID) (*libsignalgo.ExpiringProfileKeyCredential, error) {
+	profileKey, err := cli.ProfileKeyForSignalID(ctx, signalACI)
+	if err != nil {
+		return nil, fmt.Errorf("error getting profile key for ACI: %w", err)
+	}
+	requestContext, err := libsignalgo.CreateProfileKeyCredentialRequestContext(
+		prodServerPublicParams,
+		signalACI,
+		*profileKey,
+	)
+	if err != nil {
+		return nil, fmt.Errorf("error creating profile key credential request context: %w", err)
+	}
+
+	request, err := requestContext.ProfileKeyCredentialRequestContextGetRequest()
+	if err != nil {
+		return nil, fmt.Errorf("error getting profile key credential request: %w", err)
+	}
+
+	// convert request bytes to hexidecimal representation
+	hexRequest := hex.EncodeToString(request[:])
+	credentialRequest := []byte(hexRequest)
+
+	profile, err := cli.fetchProfileWithRequestAndKey(ctx, signalACI, credentialRequest, profileKey)
+	if err != nil {
+		return nil, fmt.Errorf("failed to fetch profile: %w", err)
+	}
+	response, err := libsignalgo.NewExpiringProfileKeyCredentialResponse(profile.Credential)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get expiring profile key credential response: %w", err)
+	}
+	epkc, err := libsignalgo.ReceiveExpiringProfileKeyCredential(prodServerPublicParams, requestContext, response, uint64(time.Now().Unix()))
+	if err != nil {
+		return nil, fmt.Errorf("failed to receive expiring profile key credential: %w", err)
+	}
+	return epkc, nil
+}
diff --git a/pkg/signalmeow/provisioning.go b/pkg/signalmeow/provisioning.go
index 5f959d64..95d0a773 100644
--- a/pkg/signalmeow/provisioning.go
+++ b/pkg/signalmeow/provisioning.go
@@ -123,21 +123,19 @@ func PerformProvisioning(ctx context.Context, deviceStore store.DeviceStore, dev
 		username := *provisioningMessage.Number
 		password := random.String(22)
 		code := provisioningMessage.ProvisioningCode
-		registrationId := mrand.Intn(16383) + 1
-		pniRegistrationId := mrand.Intn(16383) + 1
-		aciSignedPreKey := GenerateSignedPreKey(1, types.UUIDKindACI, aciIdentityKeyPair)
-		pniSignedPreKey := GenerateSignedPreKey(2, types.UUIDKindPNI, pniIdentityKeyPair)
-		aciPQLastResortPreKeys := GenerateKyberPreKeys(1, 1, types.UUIDKindACI, aciIdentityKeyPair)
-		pniPQLastResortPreKeys := GenerateKyberPreKeys(1, 1, types.UUIDKindPNI, pniIdentityKeyPair)
-		aciPQLastResortPreKey := aciPQLastResortPreKeys[0]
-		pniPQLastResortPreKey := pniPQLastResortPreKeys[0]
+		aciRegistrationID := mrand.Intn(16383) + 1
+		pniRegistrationID := mrand.Intn(16383) + 1
+		aciSignedPreKey := GenerateSignedPreKey(1, aciIdentityKeyPair)
+		pniSignedPreKey := GenerateSignedPreKey(1, pniIdentityKeyPair)
+		aciPQLastResortPreKey := GenerateKyberPreKeys(1, 1, aciIdentityKeyPair)[0]
+		pniPQLastResortPreKey := GenerateKyberPreKeys(1, 1, pniIdentityKeyPair)[0]
 		deviceResponse, err := confirmDevice(
 			ctx,
 			username,
 			password,
 			*code,
-			registrationId,
-			pniRegistrationId,
+			aciRegistrationID,
+			pniRegistrationID,
 			aciSignedPreKey,
 			pniSignedPreKey,
 			aciPQLastResortPreKey,
@@ -159,8 +157,8 @@ func PerformProvisioning(ctx context.Context, deviceStore store.DeviceStore, dev
 		data := &store.DeviceData{
 			ACIIdentityKeyPair: aciIdentityKeyPair,
 			PNIIdentityKeyPair: pniIdentityKeyPair,
-			RegistrationID:     registrationId,
-			PNIRegistrationID:  pniRegistrationId,
+			ACIRegistrationID:  aciRegistrationID,
+			PNIRegistrationID:  pniRegistrationID,
 			ACI:                deviceResponse.ACI,
 			PNI:                deviceResponse.PNI,
 			DeviceID:           deviceId,
@@ -187,15 +185,15 @@ func PerformProvisioning(ctx context.Context, deviceStore store.DeviceStore, dev
 		device.ClearDeviceKeys(ctx)
 
 		// Store identity keys?
-		address, err := libsignalgo.NewUUIDAddress(device.ACI, uint(device.DeviceID))
+		_, err = device.IdentityKeyStore.SaveIdentityKey(ctx, device.ACIServiceID(), device.ACIIdentityKeyPair.GetIdentityKey())
 		if err != nil {
 			c <- ProvisioningResponse{
 				State: StateProvisioningError,
-				Err:   fmt.Errorf("error creating new address: %w", err),
+				Err:   fmt.Errorf("error saving identity key: %w", err),
 			}
 			return
 		}
-		_, err = device.IdentityStore.SaveIdentityKey(ctx, address, device.ACIIdentityKeyPair.GetIdentityKey())
+		_, err = device.IdentityKeyStore.SaveIdentityKey(ctx, device.PNIServiceID(), device.PNIIdentityKeyPair.GetIdentityKey())
 		if err != nil {
 			c <- ProvisioningResponse{
 				State: StateProvisioningError,
@@ -205,13 +203,20 @@ func PerformProvisioning(ctx context.Context, deviceStore store.DeviceStore, dev
 		}
 
 		// Store signed prekeys (now that we have a device)
-		device.PreKeyStoreExtras.SaveSignedPreKey(ctx, types.UUIDKindACI, aciSignedPreKey, true)
-		device.PreKeyStoreExtras.SaveSignedPreKey(ctx, types.UUIDKindPNI, pniSignedPreKey, true)
-		device.PreKeyStoreExtras.SaveKyberPreKey(ctx, types.UUIDKindACI, aciPQLastResortPreKey, true)
-		device.PreKeyStoreExtras.SaveKyberPreKey(ctx, types.UUIDKindPNI, pniPQLastResortPreKey, true)
+		device.ACIPreKeyStore.StoreSignedPreKey(ctx, 1, aciSignedPreKey)
+		device.PNIPreKeyStore.StoreSignedPreKey(ctx, 1, pniSignedPreKey)
+		device.ACIPreKeyStore.StoreLastResortKyberPreKey(ctx, 1, aciPQLastResortPreKey)
+		device.PNIPreKeyStore.StoreLastResortKyberPreKey(ctx, 1, pniPQLastResortPreKey)
 
 		// Store our profile key
-		err = device.ProfileKeyStore.StoreProfileKey(ctx, data.ACI, profileKey)
+		err = device.RecipientStore.StoreRecipient(ctx, &types.Recipient{
+			ACI:  data.ACI,
+			PNI:  data.PNI,
+			E164: data.Number,
+			Profile: types.Profile{
+				Key: profileKey,
+			},
+		})
 		if err != nil {
 			c <- ProvisioningResponse{
 				State: StateProvisioningError,
@@ -226,7 +231,7 @@ func PerformProvisioning(ctx context.Context, deviceStore store.DeviceStore, dev
 		// Generate, store, and register prekeys
 		// TODO hacky client construction
 		cli := &Client{Store: device}
-		err = cli.GenerateAndRegisterPreKeys(ctx, types.UUIDKindACI)
+		err = cli.GenerateAndRegisterPreKeys(ctx, device.ACIPreKeyStore)
 		if err != nil {
 			c <- ProvisioningResponse{
 				State: StateProvisioningError,
@@ -234,7 +239,7 @@ func PerformProvisioning(ctx context.Context, deviceStore store.DeviceStore, dev
 			}
 			return
 		}
-		err = cli.GenerateAndRegisterPreKeys(ctx, types.UUIDKindPNI)
+		err = cli.GenerateAndRegisterPreKeys(ctx, device.PNIPreKeyStore)
 		if err != nil {
 			c <- ProvisioningResponse{
 				State: StateProvisioningError,
@@ -330,8 +335,8 @@ func confirmDevice(
 	username string,
 	password string,
 	code string,
-	registrationId int,
-	pniRegistrationId int,
+	aciRegistrationID int,
+	pniRegistrationID int,
 	aciSignedPreKey *libsignalgo.SignedPreKeyRecord,
 	pniSignedPreKey *libsignalgo.SignedPreKeyRecord,
 	aciPQLastResortPreKey *libsignalgo.KyberPreKeyRecord,
@@ -364,8 +369,8 @@ func confirmDevice(
 		"accountAttributes": map[string]any{
 			"fetchesMessages":   true,
 			"name":              encryptedDeviceName,
-			"registrationId":    registrationId,
-			"pniRegistrationId": pniRegistrationId,
+			"registrationId":    aciRegistrationID,
+			"pniRegistrationId": pniRegistrationID,
 			"capabilities": map[string]any{
 				"pni": true,
 			},
diff --git a/pkg/signalmeow/receiving.go b/pkg/signalmeow/receiving.go
index 0c9ca068..55a44e85 100644
--- a/pkg/signalmeow/receiving.go
+++ b/pkg/signalmeow/receiving.go
@@ -189,13 +189,16 @@ func (cli *Client) StartReceiveLoops(ctx context.Context) (chan SignalConnection
 				log.Info().Msg("Both websockets connected, sending contacts sync request")
 				// TODO hacky
 				cli.SendContactSyncRequest(ctx)
+				if cli.Store.MasterKey == nil {
+					cli.SendStorageMasterKeyRequest(ctx)
+				}
 				return
 			}
 		}
 	}()
 
 	// Start loop to check for and upload more prekeys
-	cli.StartKeyCheckLoop(ctx, types.UUIDKindACI)
+	cli.StartKeyCheckLoop(ctx)
 
 	return statusChan, nil
 }
@@ -241,24 +244,6 @@ func (cli *Client) ClearKeysAndDisconnect(ctx context.Context) error {
 	return stopLoopErr
 }
 
-// If a bridge can't decrypt prekeys, it's probably because the prekeys are broken so force re-registration
-func (cli *Client) checkDecryptionErrorAndDisconnect(ctx context.Context, err error) {
-	if err == nil {
-		return
-	}
-	log := zerolog.Ctx(ctx).With().Str("action", "check decryption error and disconnect").Logger()
-	if strings.Contains(err.Error(), "70: invalid signed prekey identifier") {
-		log.Warn().Msg("Failed decrypting a SignedPreKey message, invalid signed prekey identifier")
-	}
-	if strings.Contains(err.Error(), "30: invalid PreKey message: decryption failed") {
-		log.Warn().Msg("Failed decrypting a PreKey message, probably our prekeys are broken, force re-registration")
-		disconnectErr := cli.ClearKeysAndDisconnect(ctx)
-		if disconnectErr != nil {
-			log.Err(disconnectErr).Msg("ClearKeysAndDisconnect error")
-		}
-	}
-}
-
 func (cli *Client) incomingRequestHandler(ctx context.Context, req *signalpb.WebSocketRequestMessage) (*web.SimpleResponse, error) {
 	log := zerolog.Ctx(ctx).With().
 		Str("handler", "incoming request handler").
@@ -280,7 +265,7 @@ func (cli *Client) incomingRequestHandler(ctx context.Context, req *signalpb.Web
 
 // TODO: we should split this up into multiple functions
 func (cli *Client) incomingAPIMessageHandler(ctx context.Context, req *signalpb.WebSocketRequestMessage) (*web.SimpleResponse, error) {
-	log := zerolog.Ctx(ctx).With().Str("handler_type", "incoming API message handler").Logger()
+	log := *zerolog.Ctx(ctx)
 	responseCode := 200
 	envelope := &signalpb.Envelope{}
 	err := proto.Unmarshal(req.Body, envelope)
@@ -288,15 +273,33 @@ func (cli *Client) incomingAPIMessageHandler(ctx context.Context, req *signalpb.
 		log.Err(err).Msg("Unmarshal error")
 		return nil, err
 	}
+	destinationServiceID, err := libsignalgo.ServiceIDFromString(envelope.GetDestinationServiceId())
+	if err != nil {
+		log.Err(err).Str("destination_service_id", envelope.GetDestinationServiceId()).Msg("Failed to parse destination service ID")
+		return nil, err
+	}
 	var result *DecryptionResult
+	log.Trace().
+		Uint64("timestamp", envelope.GetTimestamp()).
+		Str("destination_service_id", envelope.GetDestinationServiceId()).
+		Str("source_service_id", envelope.GetSourceServiceId()).
+		Uint32("source_device_id", envelope.GetSourceDevice()).
+		Object("parsed_destination_service_id", destinationServiceID).
+		Int32("envelope_type_id", int32(envelope.GetType())).
+		Str("envelope_type", signalpb.Envelope_Type_name[int32(envelope.GetType())]).
+		Msg("Received envelope")
 
 	switch *envelope.Type {
 	case signalpb.Envelope_UNIDENTIFIED_SENDER:
-		log.Trace().Msg("Received envelope type UNIDENTIFIED_SENDER")
+		if destinationServiceID != cli.Store.ACIServiceID() {
+			log.Warn().Stringer("destination_service_id", destinationServiceID).
+				Msg("Received UNIDENTIFIED_SENDER envelope for non-ACI destination")
+			break
+		}
 		usmc, err := libsignalgo.SealedSenderDecryptToUSMC(
 			ctx,
 			envelope.GetContent(),
-			cli.Store.IdentityStore,
+			cli.Store.ACIIdentityStore,
 		)
 		if err != nil || usmc == nil {
 			if err == nil {
@@ -322,7 +325,7 @@ func (cli *Client) incomingAPIMessageHandler(ctx context.Context, req *signalpb.
 		if err != nil {
 			log.Err(err).Msg("GetDeviceID error")
 		}
-		senderAddress, err := libsignalgo.NewUUIDAddress(senderUUID, uint(senderDeviceID))
+		senderAddress, err := libsignalgo.NewACIServiceID(senderUUID).Address(uint(senderDeviceID))
 		if err != nil {
 			log.Err(err).Msg("NewAddress error")
 		}
@@ -342,10 +345,7 @@ func (cli *Client) incomingAPIMessageHandler(ctx context.Context, req *signalpb.
 		ctx = log.WithContext(ctx)
 		log.Trace().Msg("Received SealedSender message")
 
-		err = cli.UpdateContactE164(ctx, senderUUID, senderE164)
-		if err != nil {
-			log.Err(err).Msg("UpdateContactE164 error")
-		}
+		cli.Store.RecipientStore.UpdateRecipientE164(ctx, senderUUID, uuid.Nil, senderE164)
 
 		switch messageType {
 		case libsignalgo.CiphertextMessageTypeSenderKey:
@@ -381,26 +381,26 @@ func (cli *Client) incomingAPIMessageHandler(ctx context.Context, req *signalpb.
 
 		case libsignalgo.CiphertextMessageTypePreKey:
 			log.Trace().Msg("SealedSender messageType is CiphertextMessageTypePreKey")
-			result, err = cli.prekeyDecrypt(ctx, senderAddress, usmcContents)
+			result, err = cli.prekeyDecrypt(ctx, destinationServiceID, senderAddress, usmcContents)
 			if err != nil {
-				log.Err(err).Msg("prekeyDecrypt error")
+				log.Err(err).Msg("Sealed sender prekey ciphertext decryption error")
 			}
 
 		case libsignalgo.CiphertextMessageTypeWhisper:
 			log.Trace().Msg("SealedSender messageType is CiphertextMessageTypeWhisper")
 			message, err := libsignalgo.DeserializeMessage(usmcContents)
 			if err != nil {
-				log.Err(err).Msg("DeserializeMessage error")
+				log.Err(err).Msg("Sealed sender whisper deserialize error")
 			}
 			decryptedText, err := libsignalgo.Decrypt(
 				ctx,
 				message,
 				senderAddress,
-				cli.Store.SessionStore,
-				cli.Store.IdentityStore,
+				cli.Store.ACISessionStore,
+				cli.Store.ACIIdentityStore,
 			)
 			if err != nil {
-				log.Err(err).Msg("Sealed sender Whisper Decryption error")
+				log.Err(err).Msg("Sealed sender whisper decryption error")
 			} else {
 				err = stripPadding(&decryptedText)
 				if err != nil {
@@ -460,7 +460,6 @@ func (cli *Client) incomingAPIMessageHandler(ctx context.Context, req *signalpb.
 					log.Debug().Msg("Message sent by us, ignoring")
 				} else {
 					log.Err(err).Msg("sealedSenderDecrypt error")
-					cli.checkDecryptionErrorAndDisconnect(ctx, err)
 				}
 			} else {
 				log.Trace().
@@ -471,7 +470,6 @@ func (cli *Client) incomingAPIMessageHandler(ctx context.Context, req *signalpb.
 		}
 
 	case signalpb.Envelope_PREKEY_BUNDLE:
-		log.Debug().Msg("Received envelope type PREKEY_BUNDLE")
 		sender, err := libsignalgo.NewUUIDAddressFromString(
 			*envelope.SourceServiceId,
 			uint(*envelope.SourceDevice),
@@ -479,22 +477,19 @@ func (cli *Client) incomingAPIMessageHandler(ctx context.Context, req *signalpb.
 		if err != nil {
 			return nil, fmt.Errorf("NewAddress error: %v", err)
 		}
-		result, err = cli.prekeyDecrypt(ctx, sender, envelope.Content)
+		result, err = cli.prekeyDecrypt(ctx, destinationServiceID, sender, envelope.Content)
 		if err != nil {
-			log.Err(err).Msg("prekeyDecrypt error")
-			cli.checkDecryptionErrorAndDisconnect(ctx, err)
+			log.Err(err).Msg("Prekey bundle decryption error")
 		} else {
 			log.Trace().
 				Any("sender_address", result.SenderAddress).
 				Any("content", result.Content).
-				Msg("prekey decrypt result")
+				Msg("Prekey bundle decryption result")
 		}
 
 	case signalpb.Envelope_PLAINTEXT_CONTENT:
-		log.Debug().Msg("Received envelope type PLAINTEXT_CONTENT")
 
 	case signalpb.Envelope_CIPHERTEXT:
-		log.Debug().Msg("Received envelope type CIPHERTEXT")
 		message, err := libsignalgo.DeserializeMessage(envelope.Content)
 		if err != nil {
 			log.Err(err).Msg("DeserializeMessage error")
@@ -506,12 +501,20 @@ func (cli *Client) incomingAPIMessageHandler(ctx context.Context, req *signalpb.
 		if err != nil {
 			return nil, fmt.Errorf("NewAddress error: %w", err)
 		}
+		sessionStore := cli.Store.SessionStore(destinationServiceID)
+		if sessionStore == nil {
+			return nil, fmt.Errorf("no session store for destination service ID %s", destinationServiceID)
+		}
+		identityStore := cli.Store.IdentityStore(destinationServiceID)
+		if identityStore == nil {
+			return nil, fmt.Errorf("no identity store for destination service ID %s", destinationServiceID)
+		}
 		decryptedText, err := libsignalgo.Decrypt(
 			ctx,
 			message,
 			senderAddress,
-			cli.Store.SessionStore,
-			cli.Store.IdentityStore,
+			sessionStore,
+			identityStore,
 		)
 		if err != nil {
 			if strings.Contains(err.Error(), "message with old counter") {
@@ -536,15 +539,12 @@ func (cli *Client) incomingAPIMessageHandler(ctx context.Context, req *signalpb.
 		}
 
 	case signalpb.Envelope_RECEIPT:
-		log.Debug().Msg("Received envelope type RECEIPT")
 		// TODO: handle receipt
 
 	case signalpb.Envelope_KEY_EXCHANGE:
-		log.Debug().Msg("Received envelope type KEY_EXCHANGE")
 		responseCode = 400
 
 	case signalpb.Envelope_UNKNOWN:
-		log.Warn().Msg("Received envelope type UNKNOWN")
 		responseCode = 400
 
 	default:
@@ -562,6 +562,7 @@ func (cli *Client) incomingAPIMessageHandler(ctx context.Context, req *signalpb.
 		log = log.With().
 			Str("sender_name", name).
 			Uint("sender_device_id", deviceId).
+			Str("destination_service_id", destinationServiceID.String()).
 			Logger()
 		ctx = log.WithContext(ctx)
 		log.Debug().Msg("Decrypted message")
@@ -587,20 +588,63 @@ func (cli *Client) incomingAPIMessageHandler(ctx context.Context, req *signalpb.
 			}
 		}
 
-		theirUUID, err := result.SenderAddress.NameUUID()
+		theirServiceID, err := result.SenderAddress.NameServiceID()
 		if err != nil {
 			log.Err(err).Msg("Name error")
 			return nil, err
+		} else if theirServiceID.Type != libsignalgo.ServiceIDTypeACI {
+			log.Warn().Any("their_service_id", theirServiceID).Msg("Sender ServiceID is not an ACI")
+			return &web.SimpleResponse{
+				Status: responseCode,
+			}, nil
+		}
+
+		if destinationServiceID == cli.Store.PNIServiceID() {
+			_, err = cli.Store.RecipientStore.LoadAndUpdateRecipient(ctx, theirServiceID.UUID, uuid.Nil, func(recipient *types.Recipient) (changed bool, err error) {
+				if !recipient.NeedsPNISignature {
+					log.Debug().Msg("Marking recipient as needing PNI signature")
+					recipient.NeedsPNISignature = true
+					return true, nil
+				}
+				return false, nil
+			})
+			if err != nil {
+				log.Err(err).Msg("Failed to set needs_pni_signature flag after receiving message to PNI service ID")
+			}
+		}
+
+		if content.GetPniSignatureMessage() != nil {
+			log.Debug().Msg("Content includes PNI signature message")
+			err = cli.handlePNISignatureMessage(ctx, theirServiceID, content.GetPniSignatureMessage())
+			if err != nil {
+				log.Err(err).
+					Hex("pni_raw", content.GetPniSignatureMessage().GetPni()).
+					Stringer("aci", theirServiceID.UUID).
+					Msg("Failed to verify ACI-PNI mapping")
+			}
 		}
 
 		// TODO: handle more sync messages
 		if content.SyncMessage != nil {
+			if content.SyncMessage.Keys != nil {
+				cli.Store.MasterKey = content.SyncMessage.Keys.GetMaster()
+				err = cli.Store.DeviceStore.PutDevice(ctx, &cli.Store.DeviceData)
+				if err != nil {
+					log.Err(err).Msg("Failed to save device after receiving master key")
+				} else {
+					log.Info().Msg("Received master key")
+					go cli.SyncStorage(ctx)
+				}
+			} else if content.SyncMessage.GetFetchLatest().GetType() == signalpb.SyncMessage_FetchLatest_STORAGE_MANIFEST {
+				log.Debug().Msg("Received storage manifest fetch latest notice")
+				go cli.SyncStorage(ctx)
+			}
 			syncSent := content.SyncMessage.GetSent()
 			if syncSent.GetMessage() != nil || syncSent.GetEditMessage() != nil {
 				destination := syncSent.DestinationServiceId
-				var destinationUUID uuid.UUID
+				var syncDestinationServiceID libsignalgo.ServiceID
 				if destination != nil {
-					destinationUUID, err = uuid.Parse(*destination)
+					syncDestinationServiceID, err = libsignalgo.ServiceIDFromString(*destination)
 					if err != nil {
 						log.Err(err).Msg("Sync message destination parse error")
 						return nil, err
@@ -610,9 +654,9 @@ func (cli *Client) incomingAPIMessageHandler(ctx context.Context, req *signalpb.
 					log.Warn().Msg("sync message sent destination is nil")
 				} else if content.SyncMessage.Sent.Message != nil {
 					// TODO handle expiration start ts, and maybe the sync message ts?
-					cli.incomingDataMessage(ctx, content.SyncMessage.Sent.Message, cli.Store.ACI, destinationUUID)
+					cli.incomingDataMessage(ctx, content.SyncMessage.Sent.Message, cli.Store.ACI, syncDestinationServiceID)
 				} else if content.SyncMessage.Sent.EditMessage != nil {
-					cli.incomingEditMessage(ctx, content.SyncMessage.Sent.EditMessage, cli.Store.ACI, destinationUUID)
+					cli.incomingEditMessage(ctx, content.SyncMessage.Sent.EditMessage, cli.Store.ACI, syncDestinationServiceID)
 				}
 			}
 			if content.SyncMessage.Contacts != nil {
@@ -629,9 +673,10 @@ func (cli *Client) incomingAPIMessageHandler(ctx context.Context, req *signalpb.
 						log.Err(err).Msg("Contacts Sync unmarshalContactDetailsMessages error")
 					}
 					log.Debug().Int("contact_count", len(contacts)).Msg("Contacts Sync received contacts")
-					convertedContacts := make([]*types.Contact, 0, len(contacts))
+					convertedContacts := make([]*types.Recipient, 0, len(contacts))
 					for i, signalContact := range contacts {
 						if signalContact.Aci == nil || *signalContact.Aci == "" {
+							// TODO lookup PNI via CDSI and store that when ACI is missing?
 							log.Info().
 								Any("contact", signalContact).
 								Msg("Signal Contact UUID is nil, skipping")
@@ -659,13 +704,13 @@ func (cli *Client) incomingAPIMessageHandler(ctx context.Context, req *signalpb.
 
 		var sendDeliveryReceipt bool
 		if content.DataMessage != nil {
-			sendDeliveryReceipt = cli.incomingDataMessage(ctx, content.DataMessage, theirUUID, theirUUID)
+			sendDeliveryReceipt = cli.incomingDataMessage(ctx, content.DataMessage, theirServiceID.UUID, theirServiceID)
 		} else if content.EditMessage != nil {
-			sendDeliveryReceipt = cli.incomingEditMessage(ctx, content.EditMessage, theirUUID, theirUUID)
+			sendDeliveryReceipt = cli.incomingEditMessage(ctx, content.EditMessage, theirServiceID.UUID, theirServiceID)
 		}
 		if sendDeliveryReceipt {
 			// TODO send delivery receipts after actually bridging instead of here
-			err = cli.sendDeliveryReceipts(ctx, []uint64{content.DataMessage.GetTimestamp()}, theirUUID)
+			err = cli.sendDeliveryReceipts(ctx, []uint64{content.DataMessage.GetTimestamp()}, theirServiceID.UUID)
 			if err != nil {
 				log.Err(err).Msg("sendDeliveryReceipts error")
 			}
@@ -679,8 +724,8 @@ func (cli *Client) incomingAPIMessageHandler(ctx context.Context, req *signalpb.
 			}
 			cli.handleEvent(&events.ChatEvent{
 				Info: events.MessageInfo{
-					Sender: theirUUID,
-					ChatID: groupOrUserID(groupID, theirUUID),
+					Sender: theirServiceID.UUID,
+					ChatID: groupOrUserID(groupID, theirServiceID),
 				},
 				Event: content.TypingMessage,
 			})
@@ -690,8 +735,8 @@ func (cli *Client) incomingAPIMessageHandler(ctx context.Context, req *signalpb.
 		if content.CallMessage != nil && (content.CallMessage.Offer != nil || content.CallMessage.Hangup != nil) {
 			cli.handleEvent(&events.Call{
 				Info: events.MessageInfo{
-					Sender: theirUUID,
-					ChatID: theirUUID.String(),
+					Sender: theirServiceID.UUID,
+					ChatID: theirServiceID.String(),
 				},
 				IsRinging: content.CallMessage.Offer != nil,
 			})
@@ -699,14 +744,14 @@ func (cli *Client) incomingAPIMessageHandler(ctx context.Context, req *signalpb.
 
 		// Read and delivery receipts
 		if content.ReceiptMessage != nil {
-			if content.GetReceiptMessage().GetType() == signalpb.ReceiptMessage_DELIVERY && theirUUID == cli.Store.ACI {
+			if content.GetReceiptMessage().GetType() == signalpb.ReceiptMessage_DELIVERY && theirServiceID == cli.Store.ACIServiceID() {
 				// Ignore delivery receipts from other own devices
 				return &web.SimpleResponse{
 					Status: responseCode,
 				}, nil
 			}
 			cli.handleEvent(&events.Receipt{
-				Sender:  theirUUID,
+				Sender:  theirServiceID.UUID,
 				Content: content.ReceiptMessage,
 			})
 		}
@@ -767,14 +812,53 @@ func contentFieldsString(c *signalpb.Content) string {
 	return builder.String()
 }
 
-func groupOrUserID(groupID types.GroupIdentifier, userID uuid.UUID) string {
+func groupOrUserID(groupID types.GroupIdentifier, userID libsignalgo.ServiceID) string {
 	if groupID == "" {
 		return userID.String()
 	}
 	return string(groupID)
 }
 
-func (cli *Client) incomingEditMessage(ctx context.Context, editMessage *signalpb.EditMessage, messageSender, chatRecipient uuid.UUID) bool {
+func (cli *Client) handlePNISignatureMessage(ctx context.Context, sender libsignalgo.ServiceID, msg *signalpb.PniSignatureMessage) error {
+	if sender.Type != libsignalgo.ServiceIDTypeACI {
+		return fmt.Errorf("PNI signature message sender is not an ACI")
+	}
+	pniBytes := msg.GetPni()
+	if len(pniBytes) != 16 {
+		return fmt.Errorf("unexpected PNI length %d (expected 16)", len(pniBytes))
+	}
+	pni := uuid.UUID(pniBytes)
+	pniServiceID := libsignalgo.NewPNIServiceID(pni)
+	pniIdentity, err := cli.Store.IdentityKeyStore.GetIdentityKey(ctx, pniServiceID)
+	if err != nil {
+		return fmt.Errorf("failed to get identity for PNI %s: %w", pni, err)
+	} else if pniIdentity == nil {
+		return fmt.Errorf("identity not found for PNI %s", pni)
+	}
+	aciIdentity, err := cli.Store.IdentityKeyStore.GetIdentityKey(ctx, sender)
+	if err != nil {
+		return fmt.Errorf("failed to get identity for ACI %s: %w", sender, err)
+	} else if aciIdentity == nil {
+		return fmt.Errorf("identity not found for ACI %s", sender)
+	}
+	if ok, err := pniIdentity.VerifyAlternateIdentity(aciIdentity, msg.GetSignature()); err != nil {
+		return fmt.Errorf("signature validation failed: %w", err)
+	} else if !ok {
+		return fmt.Errorf("signature is invalid")
+	}
+	zerolog.Ctx(ctx).Debug().
+		Stringer("aci", sender.UUID).
+		Stringer("pni", pni).
+		Msg("Verified ACI-PNI mapping")
+	_, err = cli.Store.RecipientStore.LoadAndUpdateRecipient(ctx, sender.UUID, pni, nil)
+	if err != nil {
+		zerolog.Ctx(ctx).Err(err).Msg("Failed to update aci/pni mapping in store")
+	}
+	cli.handleEvent(&events.ACIFound{ACI: sender, PNI: pniServiceID})
+	return nil
+}
+
+func (cli *Client) incomingEditMessage(ctx context.Context, editMessage *signalpb.EditMessage, messageSenderACI uuid.UUID, chatRecipient libsignalgo.ServiceID) bool {
 	// If it's a group message, get the ID and invalidate cache if necessary
 	var groupID types.GroupIdentifier
 	var groupRevision uint32
@@ -792,7 +876,7 @@ func (cli *Client) incomingEditMessage(ctx context.Context, editMessage *signalp
 	}
 	cli.handleEvent(&events.ChatEvent{
 		Info: events.MessageInfo{
-			Sender:        messageSender,
+			Sender:        messageSenderACI,
 			ChatID:        groupOrUserID(groupID, chatRecipient),
 			GroupRevision: groupRevision,
 		},
@@ -801,11 +885,11 @@ func (cli *Client) incomingEditMessage(ctx context.Context, editMessage *signalp
 	return true
 }
 
-func (cli *Client) incomingDataMessage(ctx context.Context, dataMessage *signalpb.DataMessage, messageSender, chatRecipient uuid.UUID) bool {
+func (cli *Client) incomingDataMessage(ctx context.Context, dataMessage *signalpb.DataMessage, messageSenderACI uuid.UUID, chatRecipient libsignalgo.ServiceID) bool {
 	// If there's a profile key, save it
 	if dataMessage.ProfileKey != nil {
 		profileKey := libsignalgo.ProfileKey(dataMessage.ProfileKey)
-		err := cli.Store.ProfileKeyStore.StoreProfileKey(ctx, messageSender, profileKey)
+		err := cli.Store.RecipientStore.StoreProfileKey(ctx, messageSenderACI, profileKey)
 		if err != nil {
 			zerolog.Ctx(ctx).Err(err).Msg("StoreProfileKey error")
 			return false
@@ -829,7 +913,7 @@ func (cli *Client) incomingDataMessage(ctx context.Context, dataMessage *signalp
 	}
 
 	evtInfo := events.MessageInfo{
-		Sender:        messageSender,
+		Sender:        messageSenderACI,
 		ChatID:        groupOrUserID(groupID, chatRecipient),
 		GroupRevision: groupRevision,
 	}
@@ -855,7 +939,7 @@ func (cli *Client) sendDeliveryReceipts(ctx context.Context, deliveredTimestamps
 	// Send delivery receipts
 	if len(deliveredTimestamps) > 0 {
 		receipt := DeliveredReceiptMessageForTimestamps(deliveredTimestamps)
-		result := cli.SendMessage(ctx, senderUUID, receipt)
+		result := cli.SendMessage(ctx, libsignalgo.NewACIServiceID(senderUUID), receipt)
 		if !result.WasSuccessful {
 			return fmt.Errorf("failed to send delivery receipts: %v", result)
 		}
@@ -892,10 +976,10 @@ func (cli *Client) sealedSenderDecrypt(ctx context.Context, envelope *signalpb.E
 		localAddress,
 		prodServerTrustRootKey,
 		timestamp,
-		cli.Store.SessionStore,
-		cli.Store.IdentityStore,
-		cli.Store.PreKeyStore,
-		cli.Store.SignedPreKeyStore,
+		cli.Store.ACISessionStore,
+		cli.Store.ACIIdentityStore,
+		cli.Store.ACIPreKeyStore,
+		cli.Store.ACIPreKeyStore,
 	)
 	if err != nil {
 		return nil, fmt.Errorf("SealedSenderDecrypt error: %w", err)
@@ -906,10 +990,7 @@ func (cli *Client) sealedSenderDecrypt(ctx context.Context, envelope *signalpb.E
 	if err != nil {
 		return nil, fmt.Errorf("stripPadding error: %w", err)
 	}
-	address, err := libsignalgo.NewUUIDAddress(
-		result.Sender.UUID,
-		uint(result.Sender.DeviceID),
-	)
+	address, err := libsignalgo.NewACIServiceID(result.Sender.UUID).Address(uint(result.Sender.DeviceID))
 	if err != nil {
 		return nil, fmt.Errorf("NewAddress error: %w", err)
 	}
@@ -918,33 +999,41 @@ func (cli *Client) sealedSenderDecrypt(ctx context.Context, envelope *signalpb.E
 	if err != nil {
 		return nil, fmt.Errorf("Unmarshal error: %w", err)
 	}
-	DecryptionResult := &DecryptionResult{
+	return &DecryptionResult{
 		SenderAddress: address,
 		Content:       content,
-	}
-	return DecryptionResult, nil
+	}, nil
 }
 
-func (cli *Client) prekeyDecrypt(ctx context.Context, sender *libsignalgo.Address, encryptedContent []byte) (*DecryptionResult, error) {
+func (cli *Client) prekeyDecrypt(ctx context.Context, destination libsignalgo.ServiceID, sender *libsignalgo.Address, encryptedContent []byte) (*DecryptionResult, error) {
 	preKeyMessage, err := libsignalgo.DeserializePreKeyMessage(encryptedContent)
 	if err != nil {
-		err = fmt.Errorf("DeserializePreKeyMessage error: %v", err)
-		return nil, err
+		return nil, fmt.Errorf("failed to deserialize prekey message: %w", err)
+	} else if preKeyMessage == nil {
+		return nil, fmt.Errorf("deserializing prekey message returned nil")
 	}
-	if preKeyMessage == nil {
-		err = fmt.Errorf("preKeyMessage is nil")
-		return nil, err
+	pks := cli.Store.PreKeyStore(destination)
+	if pks == nil {
+		return nil, fmt.Errorf("no prekey store found for %s", destination)
+	}
+	ss := cli.Store.SessionStore(destination)
+	if ss == nil {
+		return nil, fmt.Errorf("no session store found for %s", destination)
+	}
+	is := cli.Store.IdentityStore(destination)
+	if is == nil {
+		return nil, fmt.Errorf("no identity store found for %s", destination)
 	}
 
 	data, err := libsignalgo.DecryptPreKey(
 		ctx,
 		preKeyMessage,
 		sender,
-		cli.Store.SessionStore,
-		cli.Store.IdentityStore,
-		cli.Store.PreKeyStore,
-		cli.Store.SignedPreKeyStore,
-		cli.Store.KyberPreKeyStore,
+		ss,
+		is,
+		pks,
+		pks,
+		pks,
 	)
 	if err != nil {
 		err = fmt.Errorf("DecryptPreKey error: %v", err)
diff --git a/pkg/signalmeow/sending.go b/pkg/signalmeow/sending.go
index 33cd93f3..ea1eeb4f 100644
--- a/pkg/signalmeow/sending.go
+++ b/pkg/signalmeow/sending.go
@@ -144,7 +144,7 @@ func checkForErrorWithSessions(err error, addresses []*libsignalgo.Address, sess
 }
 
 func (cli *Client) howManyOtherDevicesDoWeHave(ctx context.Context) int {
-	addresses, _, err := cli.Store.SessionStoreExtras.AllSessionsForUUID(ctx, cli.Store.ACI)
+	addresses, _, err := cli.Store.ACISessionStore.AllSessionsForServiceID(ctx, cli.Store.ACIServiceID())
 	if err != nil {
 		return 0
 	}
@@ -163,27 +163,26 @@ func (cli *Client) howManyOtherDevicesDoWeHave(ctx context.Context) int {
 	return otherDevices
 }
 
-func (cli *Client) buildMessagesToSend(ctx context.Context, recipientUUID uuid.UUID, content *signalpb.Content, unauthenticated bool) ([]MyMessage, error) {
+func (cli *Client) buildMessagesToSend(ctx context.Context, recipient libsignalgo.ServiceID, content *signalpb.Content, unauthenticated bool) ([]MyMessage, error) {
 	// We need to prevent multiple encryption operations from happening at once, or else ratchets can race
 	cli.encryptionLock.Lock()
 	defer cli.encryptionLock.Unlock()
 
-	messages := []MyMessage{}
-
-	addresses, sessionRecords, err := cli.Store.SessionStoreExtras.AllSessionsForUUID(ctx, recipientUUID)
+	addresses, sessionRecords, err := cli.Store.ACISessionStore.AllSessionsForServiceID(ctx, recipient)
 	if err == nil && (len(addresses) == 0 || len(sessionRecords) == 0) {
 		// No sessions, make one with prekey
-		err = cli.FetchAndProcessPreKey(ctx, recipientUUID, -1)
+		err = cli.FetchAndProcessPreKey(ctx, recipient, -1)
 		if err != nil {
 			return nil, err
 		}
-		addresses, sessionRecords, err = cli.Store.SessionStoreExtras.AllSessionsForUUID(ctx, recipientUUID)
+		addresses, sessionRecords, err = cli.Store.ACISessionStore.AllSessionsForServiceID(ctx, recipient)
 	}
 	err = checkForErrorWithSessions(err, addresses, sessionRecords)
 	if err != nil {
 		return nil, err
 	}
 
+	messages := make([]MyMessage, 0, len(addresses))
 	for i, recipientAddress := range addresses {
 		recipientDeviceID, err := recipientAddress.DeviceID()
 		if err != nil {
@@ -191,7 +190,7 @@ func (cli *Client) buildMessagesToSend(ctx context.Context, recipientUUID uuid.U
 		}
 
 		// Don't send to this device that we are sending from
-		if recipientUUID == cli.Store.ACI && recipientDeviceID == uint(cli.Store.DeviceID) {
+		if recipient == cli.Store.ACIServiceID() && recipientDeviceID == uint(cli.Store.DeviceID) {
 			zerolog.Ctx(ctx).Debug().
 				Uint("recipient_device_id", recipientDeviceID).
 				Msg("Not sending to the device I'm sending from")
@@ -203,7 +202,7 @@ func (cli *Client) buildMessagesToSend(ctx context.Context, recipientUUID uuid.U
 		if err != nil {
 			return nil, err
 		}
-		paddedMessage, err := addPadding(3, []byte(serializedMessage)) // TODO: figure out how to get actual version
+		paddedMessage, err := addPadding(3, serializedMessage) // TODO: figure out how to get actual version
 		if err != nil {
 			return nil, err
 		}
@@ -239,10 +238,10 @@ func (cli *Client) buildMessagesToSend(ctx context.Context, recipientUUID uuid.U
 func (cli *Client) buildAuthedMessageToSend(ctx context.Context, recipientAddress *libsignalgo.Address, paddedMessage []byte) (envelopeType int, encryptedPayload []byte, err error) {
 	cipherTextMessage, err := libsignalgo.Encrypt(
 		ctx,
-		[]byte(paddedMessage),
+		paddedMessage,
 		recipientAddress,
-		cli.Store.SessionStore,
-		cli.Store.IdentityStore,
+		cli.Store.ACISessionStore,
+		cli.Store.ACIIdentityStore,
 	)
 	if err != nil {
 		return 0, nil, err
@@ -271,11 +270,11 @@ func (cli *Client) buildSSMessageToSend(ctx context.Context, recipientAddress *l
 	}
 	encryptedPayload, err = libsignalgo.SealedSenderEncryptPlaintext(
 		ctx,
-		[]byte(paddedMessage),
+		paddedMessage,
 		recipientAddress,
 		cert,
-		cli.Store.SessionStore,
-		cli.Store.IdentityStore,
+		cli.Store.ACISessionStore,
+		cli.Store.ACIIdentityStore,
 	)
 	if err != nil {
 		return 0, nil, err
@@ -286,12 +285,14 @@ func (cli *Client) buildSSMessageToSend(ctx context.Context, recipientAddress *l
 }
 
 type SuccessfulSendResult struct {
-	RecipientUUID uuid.UUID
-	Unidentified  bool
+	Recipient              libsignalgo.ServiceID
+	RecipientE164          *string
+	Unidentified           bool
+	DestinationIdentityKey *libsignalgo.IdentityKey
 }
 type FailedSendResult struct {
-	RecipientUUID uuid.UUID
-	Error         error
+	Recipient libsignalgo.ServiceID
+	Error     error
 }
 type SendMessageResult struct {
 	WasSuccessful bool
@@ -312,7 +313,7 @@ func syncMessageFromGroupDataMessage(dataMessage *signalpb.DataMessage, results
 	unidentifiedStatuses := []*signalpb.SyncMessage_Sent_UnidentifiedDeliveryStatus{}
 	for _, result := range results {
 		unidentifiedStatuses = append(unidentifiedStatuses, &signalpb.SyncMessage_Sent_UnidentifiedDeliveryStatus{
-			DestinationServiceId: proto.String(result.RecipientUUID.String()),
+			DestinationServiceId: proto.String(result.Recipient.String()),
 			Unidentified:         &result.Unidentified,
 		})
 	}
@@ -330,7 +331,7 @@ func syncMessageFromGroupEditMessage(editMessage *signalpb.EditMessage, results
 	unidentifiedStatuses := []*signalpb.SyncMessage_Sent_UnidentifiedDeliveryStatus{}
 	for _, result := range results {
 		unidentifiedStatuses = append(unidentifiedStatuses, &signalpb.SyncMessage_Sent_UnidentifiedDeliveryStatus{
-			DestinationServiceId: proto.String(result.RecipientUUID.String()),
+			DestinationServiceId: proto.String(result.Recipient.String()),
 			Unidentified:         &result.Unidentified,
 		})
 	}
@@ -350,12 +351,14 @@ func syncMessageFromSoloDataMessage(dataMessage *signalpb.DataMessage, result Su
 		SyncMessage: &signalpb.SyncMessage{
 			Sent: &signalpb.SyncMessage_Sent{
 				Message:              dataMessage,
-				DestinationServiceId: proto.String(result.RecipientUUID.String()),
+				DestinationE164:      result.RecipientE164,
+				DestinationServiceId: proto.String(result.Recipient.String()),
 				Timestamp:            dataMessage.Timestamp,
 				UnidentifiedStatus: []*signalpb.SyncMessage_Sent_UnidentifiedDeliveryStatus{
 					{
-						DestinationServiceId: proto.String(result.RecipientUUID.String()),
-						Unidentified:         &result.Unidentified,
+						DestinationServiceId:   proto.String(result.Recipient.String()),
+						Unidentified:           &result.Unidentified,
+						DestinationIdentityKey: result.DestinationIdentityKey.TrySerialize(),
 					},
 				},
 			},
@@ -368,12 +371,14 @@ func syncMessageFromSoloEditMessage(editMessage *signalpb.EditMessage, result Su
 		SyncMessage: &signalpb.SyncMessage{
 			Sent: &signalpb.SyncMessage_Sent{
 				EditMessage:          editMessage,
-				DestinationServiceId: proto.String(result.RecipientUUID.String()),
+				DestinationE164:      result.RecipientE164,
+				DestinationServiceId: proto.String(result.Recipient.String()),
 				Timestamp:            editMessage.DataMessage.Timestamp,
 				UnidentifiedStatus: []*signalpb.SyncMessage_Sent_UnidentifiedDeliveryStatus{
 					{
-						DestinationServiceId: proto.String(result.RecipientUUID.String()),
-						Unidentified:         &result.Unidentified,
+						DestinationServiceId:   proto.String(result.Recipient.String()),
+						Unidentified:           &result.Unidentified,
+						DestinationIdentityKey: result.DestinationIdentityKey.TrySerialize(),
 					},
 				},
 			},
@@ -381,28 +386,23 @@ func syncMessageFromSoloEditMessage(editMessage *signalpb.EditMessage, result Su
 	}
 }
 
-func syncMessageForContactRequest() *signalpb.Content {
-	return &signalpb.Content{
-		SyncMessage: &signalpb.SyncMessage{
-			Request: &signalpb.SyncMessage_Request{
-				Type: signalpb.SyncMessage_Request_CONTACTS.Enum(),
-			},
-		},
-	}
-}
-
-func syncMessageFromReadReceiptMessage(ctx context.Context, receiptMessage *signalpb.ReceiptMessage, messageSender uuid.UUID) *signalpb.Content {
+func syncMessageFromReadReceiptMessage(ctx context.Context, receiptMessage *signalpb.ReceiptMessage, messageSender libsignalgo.ServiceID) *signalpb.Content {
 	if *receiptMessage.Type != signalpb.ReceiptMessage_READ {
 		zerolog.Ctx(ctx).Warn().
 			Any("receipt_message_type", receiptMessage.Type).
 			Msg("syncMessageFromReadReceiptMessage called with non-read receipt message")
 		return nil
+	} else if messageSender.Type != libsignalgo.ServiceIDTypeACI {
+		zerolog.Ctx(ctx).Warn().
+			Stringer("message_sender", messageSender).
+			Msg("syncMessageFromReadReceiptMessage called with non-ACI message sender")
+		return nil
 	}
 	read := []*signalpb.SyncMessage_Read{}
 	for _, timestamp := range receiptMessage.Timestamp {
 		read = append(read, &signalpb.SyncMessage_Read{
 			Timestamp: proto.Uint64(timestamp),
-			SenderAci: proto.String(messageSender.String()),
+			SenderAci: proto.String(messageSender.UUID.String()),
 		})
 	}
 	return &signalpb.Content{
@@ -413,30 +413,51 @@ func syncMessageFromReadReceiptMessage(ctx context.Context, receiptMessage *sign
 }
 
 func (cli *Client) SendContactSyncRequest(ctx context.Context) error {
-	if cli.LastContactRequestTime == nil {
-		cli.LastContactRequestTime = new(int64)
-	}
-	currentUnixTime := time.Now().Unix()
 	log := zerolog.Ctx(ctx).With().
 		Str("action", "send contact sync request").
-		Int64("current_unix_time", currentUnixTime).
-		Int64("last_request_time", *cli.LastContactRequestTime).
-		Int64("seconds_since_last_request", currentUnixTime-*cli.LastContactRequestTime).
+		Time("last_request_time", cli.LastContactRequestTime).
 		Logger()
 	ctx = log.WithContext(ctx)
 	// If we've requested in the last minute, don't request again
-	if cli.LastContactRequestTime != nil && currentUnixTime-*cli.LastContactRequestTime < 60 {
+	if time.Since(cli.LastContactRequestTime) < 60*time.Second {
 		log.Warn().Msg("Not sending contact sync request because we already requested it in the past minute")
 		return nil
 	}
 
-	groupRequest := syncMessageForContactRequest()
-	_, err := cli.sendContent(ctx, cli.Store.ACI, uint64(currentUnixTime), groupRequest, 0, true)
+	cli.LastContactRequestTime = time.Now()
+	_, err := cli.sendContent(ctx, cli.Store.ACIServiceID(), uint64(time.Now().UnixMilli()), &signalpb.Content{
+		SyncMessage: &signalpb.SyncMessage{
+			Request: &signalpb.SyncMessage_Request{
+				Type: signalpb.SyncMessage_Request_CONTACTS.Enum(),
+			},
+		},
+	}, 0, false)
 	if err != nil {
 		log.Err(err).Msg("Failed to send contact sync request message to myself")
 		return err
 	}
-	cli.LastContactRequestTime = &currentUnixTime
+	return nil
+}
+
+func (cli *Client) SendStorageMasterKeyRequest(ctx context.Context) error {
+	log := zerolog.Ctx(ctx).With().
+		Str("action", "send key sync request").
+		Logger()
+	ctx = log.WithContext(ctx)
+
+	_, err := cli.sendContent(ctx, cli.Store.ACIServiceID(), uint64(time.Now().UnixMilli()), &signalpb.Content{
+		SyncMessage: &signalpb.SyncMessage{
+			Request: &signalpb.SyncMessage_Request{
+				Type: signalpb.SyncMessage_Request_KEYS.Enum(),
+			},
+		},
+	}, 0, false)
+	if err != nil {
+		log.Err(err).Msg("Failed to send key sync request message to myself")
+		return err
+	} else {
+		log.Info().Msg("Sent key sync request to self")
+	}
 	return nil
 }
 
@@ -511,6 +532,31 @@ func wrapDataMessageInContent(dm *signalpb.DataMessage) *signalpb.Content {
 	}
 }
 
+func (cli *Client) SendGroupChange(ctx context.Context, group *Group, groupContext *signalpb.GroupContextV2, groupChange *GroupChange) (*GroupMessageSendResult, error) {
+	log := zerolog.Ctx(ctx).With().
+		Str("action", "send group change message").
+		Stringer("group_id", group.GroupIdentifier).
+		Logger()
+	ctx = log.WithContext(ctx)
+	timestamp := currentMessageTimestamp()
+	dm := &signalpb.DataMessage{
+		Timestamp: &timestamp,
+		GroupV2:   groupContext,
+	}
+	content := wrapDataMessageInContent(dm)
+	recipients := group.Members
+	for _, member := range group.PendingMembers {
+		recipients = append(recipients, &member.GroupMember)
+	}
+	for _, member := range groupChange.AddPendingMembers {
+		recipients = append(recipients, &member.GroupMember)
+	}
+	for _, member := range groupChange.AddMembers {
+		recipients = append(recipients, &member.GroupMember)
+	}
+	return cli.sendToGroup(ctx, recipients, content, timestamp)
+}
+
 func (cli *Client) SendGroupMessage(ctx context.Context, gid types.GroupIdentifier, content *signalpb.Content) (*GroupMessageSendResult, error) {
 	log := zerolog.Ctx(ctx).With().
 		Str("action", "send group message").
@@ -530,30 +576,33 @@ func (cli *Client) SendGroupMessage(ctx context.Context, gid types.GroupIdentifi
 		messageTimestamp = content.EditMessage.DataMessage.GetTimestamp()
 		content.EditMessage.DataMessage.GroupV2 = groupMetadataForDataMessage(*group)
 	}
+	return cli.sendToGroup(ctx, group.Members, content, messageTimestamp)
+}
 
+func (cli *Client) sendToGroup(ctx context.Context, recipients []*GroupMember, content *signalpb.Content, messageTimestamp uint64) (*GroupMessageSendResult, error) {
 	// Send to each member of the group
 	result := &GroupMessageSendResult{
 		SuccessfullySentTo: []SuccessfulSendResult{},
 		FailedToSendTo:     []FailedSendResult{},
 	}
-	for _, member := range group.Members {
+	for _, member := range recipients {
 		if member.UserID == cli.Store.ACI {
 			// Don't send normal DataMessages to ourselves
 			continue
 		}
-		log := log.With().Stringer("member", member.UserID).Logger()
+		log := zerolog.Ctx(ctx).With().Stringer("member", member.UserID).Logger()
 		ctx := log.WithContext(ctx)
-		sentUnidentified, err := cli.sendContent(ctx, member.UserID, messageTimestamp, content, 0, true)
+		sentUnidentified, err := cli.sendContent(ctx, member.UserServiceID(), messageTimestamp, content, 0, true)
 		if err != nil {
 			result.FailedToSendTo = append(result.FailedToSendTo, FailedSendResult{
-				RecipientUUID: member.UserID,
-				Error:         err,
+				Recipient: member.UserServiceID(),
+				Error:     err,
 			})
 			log.Err(err).Msg("Failed to send to user")
 		} else {
 			result.SuccessfullySentTo = append(result.SuccessfullySentTo, SuccessfulSendResult{
-				RecipientUUID: member.UserID,
-				Unidentified:  sentUnidentified,
+				Recipient:    member.UserServiceID(),
+				Unidentified: sentUnidentified,
 			})
 			log.Trace().Msg("Successfully sent to user")
 		}
@@ -567,9 +616,9 @@ func (cli *Client) SendGroupMessage(ctx context.Context, gid types.GroupIdentifi
 		} else if content.GetEditMessage() != nil {
 			syncContent = syncMessageFromGroupEditMessage(content.EditMessage, result.SuccessfullySentTo)
 		}
-		_, selfSendErr := cli.sendContent(ctx, cli.Store.ACI, messageTimestamp, syncContent, 0, true)
+		_, selfSendErr := cli.sendContent(ctx, cli.Store.ACIServiceID(), messageTimestamp, syncContent, 0, true)
 		if selfSendErr != nil {
-			log.Err(selfSendErr).Msg("Failed to send sync message to myself")
+			zerolog.Ctx(ctx).Err(selfSendErr).Msg("Failed to send sync message to myself")
 		}
 	}
 
@@ -593,10 +642,10 @@ func (cli *Client) sendSyncCopy(ctx context.Context, content *signalpb.Content,
 		} else if content.GetEditMessage() != nil {
 			syncContent = syncMessageFromSoloEditMessage(content.EditMessage, *result)
 		} else if content.GetReceiptMessage().GetType() == signalpb.ReceiptMessage_READ {
-			syncContent = syncMessageFromReadReceiptMessage(ctx, content.ReceiptMessage, result.RecipientUUID)
+			syncContent = syncMessageFromReadReceiptMessage(ctx, content.ReceiptMessage, result.Recipient)
 		}
 		if syncContent != nil {
-			_, selfSendErr := cli.sendContent(ctx, cli.Store.ACI, messageTS, syncContent, 0, true)
+			_, selfSendErr := cli.sendContent(ctx, cli.Store.ACIServiceID(), messageTS, syncContent, 0, true)
 			if selfSendErr != nil {
 				zerolog.Ctx(ctx).Err(selfSendErr).Msg("Failed to send sync message to myself")
 			} else {
@@ -607,7 +656,7 @@ func (cli *Client) sendSyncCopy(ctx context.Context, content *signalpb.Content,
 	return false
 }
 
-func (cli *Client) SendMessage(ctx context.Context, recipientID uuid.UUID, content *signalpb.Content) SendMessageResult {
+func (cli *Client) SendMessage(ctx context.Context, recipientID libsignalgo.ServiceID, content *signalpb.Content) SendMessageResult {
 	// Assemble the content to send
 	var messageTimestamp uint64
 	if content.GetDataMessage() != nil {
@@ -617,12 +666,51 @@ func (cli *Client) SendMessage(ctx context.Context, recipientID uuid.UUID, conte
 	} else {
 		messageTimestamp = currentMessageTimestamp()
 	}
+	needsPNISignature := false
+	var aci, pni uuid.UUID
+	if recipientID.Type == libsignalgo.ServiceIDTypeACI {
+		aci = recipientID.UUID
+	} else if recipientID.Type == libsignalgo.ServiceIDTypePNI {
+		pni = recipientID.UUID
+	}
+	recipientData, err := cli.Store.RecipientStore.LoadAndUpdateRecipient(ctx, aci, pni, func(recipientData *types.Recipient) (changed bool, err error) {
+		if recipientID.Type == libsignalgo.ServiceIDTypeACI && recipientData.NeedsPNISignature {
+			needsPNISignature = true
+			zerolog.Ctx(ctx).Debug().
+				Stringer("recipient", recipientID).
+				Msg("Including PNI identity in message")
+			sig, err := cli.Store.PNIIdentityKeyPair.SignAlternateIdentity(cli.Store.ACIIdentityKeyPair.GetIdentityKey())
+			if err != nil {
+				return false, err
+			}
+			recipientData.NeedsPNISignature = false
+			content.PniSignatureMessage = &signalpb.PniSignatureMessage{
+				Pni:       cli.Store.PNI[:],
+				Signature: sig,
+			}
+			return true, nil
+		}
+		return false, nil
+	})
+	if err != nil {
+		zerolog.Ctx(ctx).Err(err).Msg("Failed to get message recipient data")
+	}
+	// Treat needs PNI signature as "this is a message request" and don't send receipts/typing
+	if needsPNISignature && (content.TypingMessage != nil || content.ReceiptMessage != nil) {
+		zerolog.Ctx(ctx).Debug().Msg("Not sending typing/receipt message to recipient as needs PNI signature flag is set")
+		res := &SuccessfulSendResult{Recipient: recipientID}
+		if content.GetReceiptMessage().GetType() == signalpb.ReceiptMessage_READ {
+			// Still send sync messages for read receipts
+			cli.sendSyncCopy(ctx, content, messageTimestamp, res)
+		}
+		return SendMessageResult{WasSuccessful: true, SuccessfulSendResult: res}
+	}
 
 	isDeliveryReceipt := content.ReceiptMessage != nil && content.GetReceiptMessage().GetType() == signalpb.ReceiptMessage_DELIVERY
-	if recipientID == cli.Store.ACI && !isDeliveryReceipt {
+	if recipientID == cli.Store.ACIServiceID() && !isDeliveryReceipt {
 		res := &SuccessfulSendResult{
-			RecipientUUID: recipientID,
-			Unidentified:  false,
+			Recipient:    recipientID,
+			Unidentified: false,
 		}
 		ok := cli.sendSyncCopy(ctx, content, messageTimestamp, res)
 		return SendMessageResult{
@@ -637,18 +725,29 @@ func (cli *Client) SendMessage(ctx context.Context, recipientID uuid.UUID, conte
 		return SendMessageResult{
 			WasSuccessful: false,
 			FailedSendResult: &FailedSendResult{
-				RecipientUUID: recipientID,
-				Error:         err,
+				Recipient: recipientID,
+				Error:     err,
 			},
 		}
 	}
 	result := SendMessageResult{
 		WasSuccessful: true,
 		SuccessfulSendResult: &SuccessfulSendResult{
-			RecipientUUID: recipientID,
-			Unidentified:  sentUnidentified,
+			Recipient:    recipientID,
+			Unidentified: sentUnidentified,
 		},
 	}
+	if recipientID.Type == libsignalgo.ServiceIDTypePNI {
+		result.DestinationIdentityKey, err = cli.Store.IdentityKeyStore.GetIdentityKey(ctx, recipientID)
+		if err != nil {
+			zerolog.Ctx(ctx).Err(err).Msg("Failed to add PNI destination identity key to sync message")
+		}
+		if recipientData != nil && recipientData.E164 != "" {
+			result.RecipientE164 = &recipientData.E164
+		} else {
+			zerolog.Ctx(ctx).Warn().Msg("No E164 number found for PNI sync message")
+		}
+	}
 
 	cli.sendSyncCopy(ctx, content, messageTimestamp, result.SuccessfulSendResult)
 
@@ -661,7 +760,7 @@ func currentMessageTimestamp() uint64 {
 
 func (cli *Client) sendContent(
 	ctx context.Context,
-	recipientUUID uuid.UUID,
+	recipient libsignalgo.ServiceID,
 	messageTimestamp uint64,
 	content *signalpb.Content,
 	retryCount int,
@@ -669,7 +768,7 @@ func (cli *Client) sendContent(
 ) (sentUnidentified bool, err error) {
 	log := zerolog.Ctx(ctx).With().
 		Str("action", "send content").
-		Stringer("recipient", recipientUUID).
+		Stringer("recipient", recipient).
 		Uint64("timestamp", messageTimestamp).
 		Logger()
 	ctx = log.WithContext(ctx)
@@ -691,13 +790,18 @@ func (cli *Client) sendContent(
 		return false, fmt.Errorf("too many retries")
 	}
 
-	// Don't use unauthed websocket to send a payload to my own other devices
-	if recipientUUID == cli.Store.ACI {
+	if recipient.Type == libsignalgo.ServiceIDTypePNI && recipient.UUID == cli.Store.PNI {
+		return false, fmt.Errorf("can't send to own PNI")
+	} else if recipient.Type == libsignalgo.ServiceIDTypeACI && recipient.UUID == cli.Store.ACI {
+		// Don't use unauthed websocket to send a payload to my own other devices
+		useUnidentifiedSender = false
+	} else if recipient.Type == libsignalgo.ServiceIDTypePNI {
+		// Can't use unidentified sender for PNIs because only ACIs have profile keys
 		useUnidentifiedSender = false
 	}
 	var accessKey *libsignalgo.AccessKey
 	if useUnidentifiedSender {
-		profileKey, err := cli.ProfileKeyForSignalID(ctx, recipientUUID)
+		profileKey, err := cli.ProfileKeyForSignalID(ctx, recipient.UUID)
 		if err != nil {
 			return false, fmt.Errorf("failed to get profile key: %w", err)
 		} else if profileKey == nil {
@@ -710,7 +814,7 @@ func (cli *Client) sendContent(
 	}
 
 	var messages []MyMessage
-	messages, err = cli.buildMessagesToSend(ctx, recipientUUID, content, useUnidentifiedSender)
+	messages, err = cli.buildMessagesToSend(ctx, recipient, content, useUnidentifiedSender)
 	if err != nil {
 		log.Err(err).Msg("Error building messages to send")
 		return false, err
@@ -726,7 +830,7 @@ func (cli *Client) sendContent(
 	if err != nil {
 		return false, err
 	}
-	path := fmt.Sprintf("/v1/messages/%v", recipientUUID)
+	path := fmt.Sprintf("/v1/messages/%s", recipient)
 	request := web.CreateWSRequest(http.MethodPut, path, jsonBytes, nil, nil)
 
 	var response *signalpb.WebSocketResponseMessage
@@ -769,17 +873,17 @@ func (cli *Client) sendContent(
 	if needToRetry {
 		var err error
 		if *response.Status == 409 {
-			err = cli.handle409(ctx, recipientUUID, response)
+			err = cli.handle409(ctx, recipient, response)
 		} else if *response.Status == 410 {
-			err = cli.handle410(ctx, recipientUUID, response)
+			err = cli.handle410(ctx, recipient, response)
 		} else if *response.Status == 428 {
-			err = cli.handle428(ctx, recipientUUID, response)
+			err = cli.handle428(ctx, recipient, response)
 		}
 		if err != nil {
 			return false, err
 		}
 		// Try to send again (**RECURSIVELY**)
-		sentUnidentified, err = cli.sendContent(ctx, recipientUUID, messageTimestamp, content, retryCount+1, sentUnidentified)
+		sentUnidentified, err = cli.sendContent(ctx, recipient, messageTimestamp, content, retryCount+1, sentUnidentified)
 		if err != nil {
 			log.Err(err).Msg("2nd try sendMessage error")
 			return sentUnidentified, err
@@ -787,7 +891,7 @@ func (cli *Client) sendContent(
 	} else if *response.Status == 401 && useUnidentifiedSender {
 		log.Debug().Msg("Retrying send without sealed sender")
 		// Try to send again (**RECURSIVELY**)
-		sentUnidentified, err = cli.sendContent(ctx, recipientUUID, messageTimestamp, content, retryCount+1, false)
+		sentUnidentified, err = cli.sendContent(ctx, recipient, messageTimestamp, content, retryCount+1, false)
 		if err != nil {
 			log.Err(err).Msg("2nd try sendMessage error")
 			return sentUnidentified, err
@@ -800,7 +904,7 @@ func (cli *Client) sendContent(
 }
 
 // A 409 means our device list was out of date, so we will fix it up
-func (cli *Client) handle409(ctx context.Context, recipientUUID uuid.UUID, response *signalpb.WebSocketResponseMessage) error {
+func (cli *Client) handle409(ctx context.Context, recipient libsignalgo.ServiceID, response *signalpb.WebSocketResponseMessage) error {
 	log := zerolog.Ctx(ctx)
 	// Decode json body
 	// TODO use an actual struct for this
@@ -816,7 +920,7 @@ func (cli *Client) handle409(ctx context.Context, recipientUUID uuid.UUID, respo
 		log.Debug().Any("missing_devices", missingDevices).Msg("missing devices found in 409 response")
 		// TODO: establish session with missing devices
 		for _, missingDevice := range missingDevices {
-			err = cli.FetchAndProcessPreKey(ctx, recipientUUID, int(missingDevice.(float64)))
+			err = cli.FetchAndProcessPreKey(ctx, recipient, int(missingDevice.(float64)))
 			if err != nil {
 				return nil
 			}
@@ -826,16 +930,12 @@ func (cli *Client) handle409(ctx context.Context, recipientUUID uuid.UUID, respo
 		extraDevices := body["extraDevices"].([]any)
 		log.Debug().Any("extra_devices", extraDevices).Msg("extra devices found in 409 response")
 		for _, extraDevice := range extraDevices {
-			// Remove extra device from the sessionstore
-			recipient, err := libsignalgo.NewUUIDAddress(
-				recipientUUID,
-				uint(extraDevice.(float64)),
-			)
+			recipientAddr, err := recipient.Address(uint(extraDevice.(float64)))
 			if err != nil {
 				log.Err(err).Msg("NewAddress error")
 				return err
 			}
-			err = cli.Store.SessionStoreExtras.RemoveSession(ctx, recipient)
+			err = cli.Store.ACISessionStore.RemoveSession(ctx, recipientAddr)
 			if err != nil {
 				log.Err(err).Msg("RemoveSession error")
 				return err
@@ -846,7 +946,7 @@ func (cli *Client) handle409(ctx context.Context, recipientUUID uuid.UUID, respo
 }
 
 // A 410 means we have a stale device, so get rid of it
-func (cli *Client) handle410(ctx context.Context, recipientUUID uuid.UUID, response *signalpb.WebSocketResponseMessage) error {
+func (cli *Client) handle410(ctx context.Context, recipient libsignalgo.ServiceID, response *signalpb.WebSocketResponseMessage) error {
 	log := zerolog.Ctx(ctx)
 	// Decode json body
 	// TODO use an actual struct
@@ -861,20 +961,17 @@ func (cli *Client) handle410(ctx context.Context, recipientUUID uuid.UUID, respo
 		staleDevices := body["staleDevices"].([]any)
 		log.Debug().Any("stale_devices", staleDevices).Msg("stale devices found in 410 response")
 		for _, staleDevice := range staleDevices {
-			recipient, err := libsignalgo.NewUUIDAddress(
-				recipientUUID,
-				uint(staleDevice.(float64)),
-			)
+			recipientAddr, err := recipient.Address(uint(staleDevice.(float64)))
 			if err != nil {
 				log.Err(err).Msg("error creating new UUID Address")
 				return err
 			}
-			err = cli.Store.SessionStoreExtras.RemoveSession(ctx, recipient)
+			err = cli.Store.ACISessionStore.RemoveSession(ctx, recipientAddr)
 			if err != nil {
 				log.Err(err).Msg("RemoveSession error")
 				return err
 			}
-			err = cli.FetchAndProcessPreKey(ctx, recipientUUID, int(staleDevice.(float64)))
+			err = cli.FetchAndProcessPreKey(ctx, recipient, int(staleDevice.(float64)))
 			if err != nil {
 				return err
 			}
@@ -886,7 +983,7 @@ func (cli *Client) handle410(ctx context.Context, recipientUUID uuid.UUID, respo
 // We got rate limited.
 // We ~~will~~ could try sending a "pushChallenge" response, but if that doesn't work we just gotta wait.
 // TODO: explore captcha response
-func (cli *Client) handle428(ctx context.Context, recipientUUID uuid.UUID, response *signalpb.WebSocketResponseMessage) error {
+func (cli *Client) handle428(ctx context.Context, recipient libsignalgo.ServiceID, response *signalpb.WebSocketResponseMessage) error {
 	log := zerolog.Ctx(ctx)
 	// Decode json body
 	// TODO use an actual struct
diff --git a/pkg/signalmeow/storageservice.go b/pkg/signalmeow/storageservice.go
index 9e2e93c6..a78f964f 100644
--- a/pkg/signalmeow/storageservice.go
+++ b/pkg/signalmeow/storageservice.go
@@ -24,16 +24,93 @@ import (
 	"fmt"
 	"io"
 	"net/http"
+	"strings"
 
+	"github.com/google/uuid"
+	"github.com/rs/zerolog"
 	"go.mau.fi/util/exerrors"
 	"golang.org/x/exp/maps"
 	"golang.org/x/exp/slices"
 	"google.golang.org/protobuf/proto"
 
+	"go.mau.fi/mautrix-signal/pkg/libsignalgo"
 	signalpb "go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf"
+	"go.mau.fi/mautrix-signal/pkg/signalmeow/types"
 	"go.mau.fi/mautrix-signal/pkg/signalmeow/web"
 )
 
+func (cli *Client) SyncStorage(ctx context.Context) {
+	log := zerolog.Ctx(ctx).With().Str("action", "sync storage").Logger()
+	// TODO only fetch changed entries
+	update, err := cli.FetchStorage(ctx, cli.Store.MasterKey, 0, nil)
+	if err != nil {
+		log.Err(err).Msg("Failed to fetch storage")
+		return
+	}
+	for _, record := range update.NewRecords {
+		switch data := record.StorageRecord.GetRecord().(type) {
+		case *signalpb.StorageRecord_Contact:
+			log.Trace().Any("contact_record", data.Contact).Msg("Handling contact record")
+			aci, _ := uuid.Parse(data.Contact.Aci)
+			pni, _ := uuid.Parse(data.Contact.Pni)
+			if aci == uuid.Nil && pni == uuid.Nil {
+				log.Warn().
+					Str("raw_aci", data.Contact.Aci).
+					Str("raw_pni", data.Contact.Pni).
+					Str("raw_e164", data.Contact.E164).
+					Msg("Storage service has contact record with no ACI or PNI")
+				continue
+			}
+			contact := data.Contact
+			_, err = cli.Store.RecipientStore.LoadAndUpdateRecipient(ctx, aci, pni, func(recipient *types.Recipient) (changed bool, err error) {
+				if len(contact.ProfileKey) == libsignalgo.ProfileKeyLength {
+					newProfileKey := libsignalgo.ProfileKey(contact.ProfileKey)
+					changed = changed || recipient.Profile.Key != newProfileKey
+					recipient.Profile.Key = newProfileKey
+				}
+				if recipient.Profile.Name == "" && (contact.GivenName != "" || contact.FamilyName != "") {
+					changed = true
+					recipient.Profile.Name = strings.TrimSpace(fmt.Sprintf("%s %s", contact.GivenName, contact.FamilyName))
+				}
+				if contact.SystemGivenName != "" || contact.SystemFamilyName != "" {
+					changed = true
+					recipient.ContactName = strings.TrimSpace(fmt.Sprintf("%s %s", contact.SystemGivenName, contact.SystemFamilyName))
+				}
+				if contact.E164 != "" {
+					changed = changed || recipient.E164 != contact.E164
+					recipient.E164 = contact.E164
+				}
+				return
+			})
+			if err != nil {
+				log.Err(err).
+					Stringer("aci", aci).
+					Stringer("pni", pni).
+					Msg("Failed to update contact")
+			}
+		case *signalpb.StorageRecord_GroupV2:
+			if len(data.GroupV2.MasterKey) != libsignalgo.GroupMasterKeyLength {
+				log.Warn().Msg("Invalid group master key length")
+				continue
+			}
+			masterKey := libsignalgo.GroupMasterKey(data.GroupV2.MasterKey)
+			groupID, err := cli.StoreMasterKey(ctx, masterKeyFromBytes(masterKey))
+			if err != nil {
+				log.Err(err).Msg("Failed to store group master key from storage service")
+			} else {
+				log.Debug().Stringer("group_id", groupID).Msg("Stored group master key from storage service")
+			}
+		case *signalpb.StorageRecord_Account:
+			log.Trace().Any("account_record", data.Account).Msg("Found account record")
+			// There's probably some useful data here
+		case *signalpb.StorageRecord_GroupV1, *signalpb.StorageRecord_StoryDistributionList:
+			// irrelevant data
+		default:
+			log.Warn().Str("type", fmt.Sprintf("%T", data)).Msg("Unknown storage record type")
+		}
+	}
+}
+
 type StorageUpdate struct {
 	Version        uint64
 	NewRecords     []*DecryptedStorageRecord
diff --git a/pkg/signalmeow/store/contact_store.go b/pkg/signalmeow/store/contact_store.go
deleted file mode 100644
index f556124f..00000000
--- a/pkg/signalmeow/store/contact_store.go
+++ /dev/null
@@ -1,180 +0,0 @@
-// mautrix-signal - A Matrix-signal puppeting bridge.
-// Copyright (C) 2023 Scott Weber
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program.  If not, see <https://www.gnu.org/licenses/>.
-
-package store
-
-import (
-	"context"
-	"database/sql"
-	"errors"
-	"time"
-
-	"github.com/google/uuid"
-	"go.mau.fi/util/dbutil"
-
-	"go.mau.fi/mautrix-signal/pkg/libsignalgo"
-	"go.mau.fi/mautrix-signal/pkg/signalmeow/types"
-)
-
-type ContactStore interface {
-	LoadContact(ctx context.Context, theirUUID uuid.UUID) (*types.Contact, error)
-	LoadContactByE164(ctx context.Context, e164 string) (*types.Contact, error)
-	StoreContact(ctx context.Context, contact types.Contact) error
-	AllContacts(ctx context.Context) ([]*types.Contact, error)
-	UpdatePhone(ctx context.Context, theirUUID uuid.UUID, newE164 string) error
-}
-
-var _ ContactStore = (*SQLStore)(nil)
-
-const (
-	getAllContactsQuery = `
-		SELECT
-			aci_uuid,
-			e164_number,
-			contact_name,
-			contact_avatar_hash,
-			profile_key,
-			profile_name,
-			profile_about,
-			profile_about_emoji,
-			profile_avatar_path,
-			profile_fetched_at
-		FROM signalmeow_contacts
-	`
-	getAllContactsOfUserQuery = getAllContactsQuery + `WHERE our_aci_uuid = $1`
-	getContactByUUIDQuery     = getAllContactsQuery + `WHERE our_aci_uuid = $1 AND aci_uuid = $2`
-	getContactByPhoneQuery    = getAllContactsQuery + `WHERE our_aci_uuid = $1 AND e164_number = $2`
-	upsertContactQuery        = `
-		INSERT INTO signalmeow_contacts (
-			our_aci_uuid,
-			aci_uuid,
-			e164_number,
-			contact_name,
-			contact_avatar_hash,
-			profile_key,
-			profile_name,
-			profile_about,
-			profile_about_emoji,
-			profile_avatar_path,
-			profile_fetched_at
-		)
-		VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
-		ON CONFLICT (our_aci_uuid, aci_uuid) DO UPDATE SET
-			e164_number = excluded.e164_number,
-			contact_name = excluded.contact_name,
-			contact_avatar_hash = excluded.contact_avatar_hash,
-			profile_key = excluded.profile_key,
-			profile_name = excluded.profile_name,
-			profile_about = excluded.profile_about,
-			profile_about_emoji = excluded.profile_about_emoji,
-			profile_avatar_path = excluded.profile_avatar_path,
-			profile_fetched_at = excluded.profile_fetched_at
-	`
-	upsertContactPhoneQuery = `
-		INSERT INTO signalmeow_contacts (
-			our_aci_uuid,
-			aci_uuid,
-			e164_number,
-			contact_name,
-			contact_avatar_hash,
-			profile_key,
-			profile_name,
-			profile_about,
-			profile_about_emoji,
-			profile_avatar_path,
-			profile_fetched_at
-		)
-		VALUES ($1, $2, $3, '', '', NULL, '', '', '', '', NULL)
-		ON CONFLICT (our_aci_uuid, aci_uuid) DO UPDATE
-			SET e164_number = excluded.e164_number
-	`
-)
-
-func scanContact(row dbutil.Scannable) (*types.Contact, error) {
-	var contact types.Contact
-	var profileKey []byte
-	var profileFetchedAt sql.NullInt64
-	err := row.Scan(
-		&contact.UUID,
-		&contact.E164,
-		&contact.ContactName,
-		&contact.ContactAvatar.Hash,
-		&profileKey,
-		&contact.Profile.Name,
-		&contact.Profile.About,
-		&contact.Profile.AboutEmoji,
-		&contact.Profile.AvatarPath,
-		&profileFetchedAt,
-	)
-	if errors.Is(err, sql.ErrNoRows) {
-		return nil, nil
-	} else if err != nil {
-		return nil, err
-	}
-	if profileFetchedAt.Valid {
-		contact.Profile.FetchedAt = time.UnixMilli(profileFetchedAt.Int64)
-	}
-	if len(profileKey) != 0 {
-		contact.Profile.Key = libsignalgo.ProfileKey(profileKey)
-	}
-	return &contact, err
-}
-
-func (s *SQLStore) LoadContact(ctx context.Context, theirUUID uuid.UUID) (*types.Contact, error) {
-	return scanContact(s.db.QueryRow(ctx, getContactByUUIDQuery, s.ACI, theirUUID))
-}
-
-func (s *SQLStore) LoadContactByE164(ctx context.Context, e164 string) (*types.Contact, error) {
-	return scanContact(s.db.QueryRow(ctx, getContactByPhoneQuery, s.ACI, e164))
-}
-
-func (s *SQLStore) AllContacts(ctx context.Context) ([]*types.Contact, error) {
-	rows, err := s.db.Query(ctx, getAllContactsOfUserQuery, s.ACI)
-	if err != nil {
-		return nil, err
-	}
-	return dbutil.NewRowIter(rows, scanContact).AsList()
-}
-
-func (s *SQLStore) StoreContact(ctx context.Context, contact types.Contact) error {
-	var profileKey []byte
-	if contact.Profile.Key.IsEmpty() {
-		profileKey = contact.Profile.Key[:]
-	}
-	_, err := s.db.Exec(
-		ctx,
-		upsertContactQuery,
-		s.ACI,
-		contact.UUID,
-		contact.E164,
-		contact.ContactName,
-		contact.ContactAvatar.Hash,
-		profileKey,
-		contact.Profile.Name,
-		contact.Profile.About,
-		contact.Profile.AboutEmoji,
-		contact.Profile.AvatarPath,
-		dbutil.UnixMilliPtr(contact.Profile.FetchedAt),
-	)
-	return err
-}
-
-func (s *SQLStore) UpdatePhone(ctx context.Context, theirUUID uuid.UUID, newE164 string) error {
-	_, err := s.db.Exec(
-		ctx, upsertContactPhoneQuery, s.ACI, theirUUID, newE164,
-	)
-	return err
-}
diff --git a/pkg/signalmeow/store/container.go b/pkg/signalmeow/store/container.go
index a173597b..d649b92a 100644
--- a/pkg/signalmeow/store/container.go
+++ b/pkg/signalmeow/store/container.go
@@ -14,44 +14,44 @@ import (
 	"go.mau.fi/mautrix-signal/pkg/signalmeow/store/upgrades"
 )
 
-var _ DeviceStore = (*StoreContainer)(nil)
+var _ DeviceStore = (*Container)(nil)
 
 type DeviceStore interface {
 	PutDevice(ctx context.Context, dd *DeviceData) error
 	DeviceByACI(ctx context.Context, aci uuid.UUID) (*Device, error)
 }
 
-// StoreContainer is a wrapper for a SQL database that can contain multiple signalmeow sessions.
-type StoreContainer struct {
+// Container is a wrapper for a SQL database that can contain multiple signalmeow sessions.
+type Container struct {
 	db *dbutil.Database
 }
 
-func NewStore(db *dbutil.Database, log dbutil.DatabaseLogger) *StoreContainer {
-	return &StoreContainer{db: db.Child("signalmeow_version", upgrades.Table, log)}
+func NewStore(db *dbutil.Database, log dbutil.DatabaseLogger) *Container {
+	return &Container{db: db.Child("signalmeow_version", upgrades.Table, log)}
 }
 
 const getAllDevicesQuery = `
 SELECT
 	aci_uuid, aci_identity_key_pair, registration_id,
 	pni_uuid, pni_identity_key_pair, pni_registration_id,
-	device_id, number, password
+	device_id, number, password, master_key
 FROM signalmeow_device
 `
 
 const getDeviceQuery = getAllDevicesQuery + " WHERE aci_uuid=$1"
 
-func (c *StoreContainer) Upgrade(ctx context.Context) error {
+func (c *Container) Upgrade(ctx context.Context) error {
 	return c.db.Upgrade(ctx)
 }
 
-func (c *StoreContainer) scanDevice(row dbutil.Scannable) (*Device, error) {
+func (c *Container) scanDevice(row dbutil.Scannable) (*Device, error) {
 	var device Device
 	var aciIdentityKeyPair, pniIdentityKeyPair []byte
 
 	err := row.Scan(
-		&device.ACI, &aciIdentityKeyPair, &device.RegistrationID,
+		&device.ACI, &aciIdentityKeyPair, &device.ACIRegistrationID,
 		&device.PNI, &pniIdentityKeyPair, &device.PNIRegistrationID,
-		&device.DeviceID, &device.Number, &device.Password,
+		&device.DeviceID, &device.Number, &device.Password, &device.MasterKey,
 	)
 	if err != nil {
 		return nil, fmt.Errorf("failed to scan session: %w", err)
@@ -65,26 +65,34 @@ func (c *StoreContainer) scanDevice(row dbutil.Scannable) (*Device, error) {
 		return nil, fmt.Errorf("failed to deserialize PNI identity key pair: %w", err)
 	}
 
-	innerStore := newSQLStore(c, device.ACI)
-	// Assign innerStore to all the interfaces
-	device.PreKeyStore = innerStore
-	device.PreKeyStoreExtras = innerStore
-	device.SignedPreKeyStore = innerStore
-	device.KyberPreKeyStore = innerStore
-	device.IdentityStore = innerStore
-	device.SessionStore = innerStore
-	device.SessionStoreExtras = innerStore
-	device.ProfileKeyStore = innerStore
-	device.SenderKeyStore = innerStore
-	device.GroupStore = innerStore
-	device.ContactStore = innerStore
-	device.DeviceStore = innerStore
+	baseStore := &sqlStore{Container: c, AccountID: device.ACI}
+	aciStore := &scopedSQLStore{Container: c, AccountID: device.ACI, ServiceID: device.ACIServiceID()}
+	pniStore := &scopedSQLStore{Container: c, AccountID: device.ACI, ServiceID: device.PNIServiceID()}
+	device.ACIPreKeyStore = aciStore
+	device.PNIPreKeyStore = pniStore
+	device.ACISessionStore = aciStore
+	device.PNISessionStore = pniStore
+	device.ACIIdentityStore = &sqlIdentityStore{
+		sqlStore:            baseStore,
+		OwnKeyPair:          device.ACIIdentityKeyPair,
+		LocalRegistrationID: uint32(device.ACIRegistrationID),
+	}
+	device.PNIIdentityStore = &sqlIdentityStore{
+		sqlStore:            baseStore,
+		OwnKeyPair:          device.PNIIdentityKeyPair,
+		LocalRegistrationID: uint32(device.PNIRegistrationID),
+	}
+	device.IdentityKeyStore = baseStore
+	device.SenderKeyStore = baseStore
+	device.GroupStore = baseStore
+	device.RecipientStore = baseStore
+	device.DeviceStore = baseStore
 
 	return &device, nil
 }
 
 // GetAllDevices finds all the devices in the database.
-func (c *StoreContainer) GetAllDevices(ctx context.Context) ([]*Device, error) {
+func (c *Container) GetAllDevices(ctx context.Context) ([]*Device, error) {
 	rows, err := c.db.Query(ctx, getAllDevicesQuery)
 	if err != nil {
 		return nil, fmt.Errorf("failed to query sessions: %w", err)
@@ -103,7 +111,7 @@ func (c *StoreContainer) GetAllDevices(ctx context.Context) ([]*Device, error) {
 
 // GetDevice finds the device with the specified ACI UUID in the database.
 // If the device is not found, nil is returned instead.
-func (c *StoreContainer) DeviceByACI(ctx context.Context, aci uuid.UUID) (*Device, error) {
+func (c *Container) DeviceByACI(ctx context.Context, aci uuid.UUID) (*Device, error) {
 	sess, err := c.scanDevice(c.db.QueryRow(ctx, getDeviceQuery, aci))
 	if errors.Is(err, sql.ErrNoRows) {
 		return nil, nil
@@ -116,9 +124,9 @@ const (
 		INSERT INTO signalmeow_device (
 			aci_uuid, aci_identity_key_pair, registration_id,
 			pni_uuid, pni_identity_key_pair, pni_registration_id,
-			device_id, number, password
+			device_id, number, password, master_key
 		)
-		VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
+		VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
 		ON CONFLICT (aci_uuid) DO UPDATE SET
 			aci_identity_key_pair=excluded.aci_identity_key_pair,
 			registration_id=excluded.registration_id,
@@ -127,7 +135,8 @@ const (
 			pni_registration_id=excluded.pni_registration_id,
 			device_id=excluded.device_id,
 			number=excluded.number,
-			password=excluded.password
+			password=excluded.password,
+			master_key=excluded.master_key
 	`
 	deleteDeviceQuery = `DELETE FROM signalmeow_device WHERE aci_uuid=$1`
 )
@@ -136,7 +145,7 @@ const (
 var ErrDeviceIDMustBeSet = errors.New("device aci_uuid must be known before accessing database")
 
 // PutDevice stores the given device in this database.
-func (c *StoreContainer) PutDevice(ctx context.Context, device *DeviceData) error {
+func (c *Container) PutDevice(ctx context.Context, device *DeviceData) error {
 	if device.ACI == uuid.Nil {
 		return ErrDeviceIDMustBeSet
 	}
@@ -151,9 +160,9 @@ func (c *StoreContainer) PutDevice(ctx context.Context, device *DeviceData) erro
 		return err
 	}
 	_, err = c.db.Exec(ctx, insertDeviceQuery,
-		device.ACI, aciIdentityKeyPair, device.RegistrationID,
+		device.ACI, aciIdentityKeyPair, device.ACIRegistrationID,
 		device.PNI, pniIdentityKeyPair, device.PNIRegistrationID,
-		device.DeviceID, device.Number, device.Password,
+		device.DeviceID, device.Number, device.Password, device.MasterKey,
 	)
 	if err != nil {
 		zerolog.Ctx(ctx).Err(err).Msg("failed to insert device")
@@ -162,7 +171,7 @@ func (c *StoreContainer) PutDevice(ctx context.Context, device *DeviceData) erro
 }
 
 // DeleteDevice deletes the given device from this database
-func (c *StoreContainer) DeleteDevice(ctx context.Context, device *DeviceData) error {
+func (c *Container) DeleteDevice(ctx context.Context, device *DeviceData) error {
 	if device.ACI == uuid.Nil {
 		return ErrDeviceIDMustBeSet
 	}
diff --git a/pkg/signalmeow/store/device.go b/pkg/signalmeow/store/device.go
index 16d3c1e3..2c8d54a2 100644
--- a/pkg/signalmeow/store/device.go
+++ b/pkg/signalmeow/store/device.go
@@ -3,6 +3,7 @@ package store
 import (
 	"context"
 	"fmt"
+	"sync"
 
 	"github.com/google/uuid"
 	"github.com/rs/zerolog"
@@ -10,30 +11,38 @@ import (
 	"go.mau.fi/mautrix-signal/pkg/libsignalgo"
 )
 
-// SQLStore is basically a StoreContainer with an ACI UUID attached to it,
-// reperesenting a store for a single user
-type SQLStore struct {
-	*StoreContainer
-	ACI uuid.UUID
+type sqlStore struct {
+	*Container
+	AccountID uuid.UUID
+
+	contactLock sync.Mutex
 }
 
-func newSQLStore(container *StoreContainer, aci uuid.UUID) *SQLStore {
-	return &SQLStore{
-		StoreContainer: container,
-		ACI:            aci,
-	}
+type scopedSQLStore struct {
+	*Container
+	AccountID uuid.UUID
+	ServiceID libsignalgo.ServiceID
 }
 
 type DeviceData struct {
 	ACIIdentityKeyPair *libsignalgo.IdentityKeyPair
 	PNIIdentityKeyPair *libsignalgo.IdentityKeyPair
-	RegistrationID     int
+	ACIRegistrationID  int
 	PNIRegistrationID  int
 	ACI                uuid.UUID
 	PNI                uuid.UUID
 	DeviceID           int
 	Number             string
 	Password           string
+	MasterKey          []byte
+}
+
+func (d *DeviceData) ACIServiceID() libsignalgo.ServiceID {
+	return libsignalgo.NewACIServiceID(d.ACI)
+}
+
+func (d *DeviceData) PNIServiceID() libsignalgo.ServiceID {
+	return libsignalgo.NewPNIServiceID(d.PNI)
 }
 
 func (d *DeviceData) BasicAuthCreds() (string, string) {
@@ -51,20 +60,18 @@ type Device struct {
 	// (search for "innerStore" further down in this file)
 
 	// libsignalgo store interfaces
-	PreKeyStore       libsignalgo.PreKeyStore
-	SignedPreKeyStore libsignalgo.SignedPreKeyStore
-	KyberPreKeyStore  libsignalgo.KyberPreKeyStore
-	IdentityStore     libsignalgo.IdentityKeyStore
-	SessionStore      libsignalgo.SessionStore
-	SenderKeyStore    libsignalgo.SenderKeyStore
-
-	// internal store interfaces
-	PreKeyStoreExtras  PreKeyStoreExtras
-	SessionStoreExtras SessionStoreExtras
-	ProfileKeyStore    ProfileKeyStore
-	GroupStore         GroupStore
-	ContactStore       ContactStore
-	DeviceStore        DeviceStore
+	ACIPreKeyStore   PreKeyStore
+	PNIPreKeyStore   PreKeyStore
+	ACISessionStore  SessionStore
+	PNISessionStore  SessionStore
+	ACIIdentityStore libsignalgo.IdentityKeyStore
+	PNIIdentityStore libsignalgo.IdentityKeyStore
+	IdentityKeyStore IdentityKeyStore
+	SenderKeyStore   libsignalgo.SenderKeyStore
+
+	GroupStore     GroupStore
+	RecipientStore RecipientStore
+	DeviceStore    DeviceStore
 }
 
 func (d *Device) ClearDeviceKeys(ctx context.Context) error {
@@ -73,11 +80,11 @@ func (d *Device) ClearDeviceKeys(ctx context.Context) error {
 		zerolog.Ctx(ctx).Warn().Msg("ClearDeviceKeys called with nil device")
 		return nil
 	}
-	err := d.PreKeyStoreExtras.DeleteAllPreKeys(ctx)
+	err := d.ACIPreKeyStore.DeleteAllPreKeys(ctx)
 	if err != nil {
 		return err
 	}
-	err = d.SessionStoreExtras.RemoveAllSessions(ctx)
+	err = d.ACISessionStore.RemoveAllSessions(ctx)
 	return err
 }
 
@@ -92,3 +99,30 @@ func (d *Device) ClearPassword(ctx context.Context) error {
 	d.Password = ""
 	return d.DeviceStore.PutDevice(ctx, &d.DeviceData)
 }
+
+func (d *Device) PreKeyStore(serviceID libsignalgo.ServiceID) PreKeyStore {
+	if serviceID == d.ACIServiceID() {
+		return d.ACIPreKeyStore
+	} else if serviceID == d.PNIServiceID() {
+		return d.PNIPreKeyStore
+	}
+	return nil
+}
+
+func (d *Device) SessionStore(serviceID libsignalgo.ServiceID) SessionStore {
+	if serviceID == d.ACIServiceID() {
+		return d.ACISessionStore
+	} else if serviceID == d.PNIServiceID() {
+		return d.PNISessionStore
+	}
+	return nil
+}
+
+func (d *Device) IdentityStore(serviceID libsignalgo.ServiceID) libsignalgo.IdentityKeyStore {
+	if serviceID == d.ACIServiceID() {
+		return d.ACIIdentityStore
+	} else if serviceID == d.PNIServiceID() {
+		return d.PNIIdentityStore
+	}
+	return nil
+}
diff --git a/pkg/signalmeow/store/group_store.go b/pkg/signalmeow/store/group_store.go
index 5ee356ea..39b46846 100644
--- a/pkg/signalmeow/store/group_store.go
+++ b/pkg/signalmeow/store/group_store.go
@@ -26,7 +26,7 @@ import (
 	"go.mau.fi/mautrix-signal/pkg/signalmeow/types"
 )
 
-var _ GroupStore = (*SQLStore)(nil)
+var _ GroupStore = (*sqlStore)(nil)
 
 type dbGroup struct {
 	OurAciUuid      string
@@ -40,11 +40,11 @@ type GroupStore interface {
 }
 
 const (
-	getGroupByIDQuery         = `SELECT our_aci_uuid, group_identifier, master_key FROM signalmeow_groups WHERE our_aci_uuid=$1 AND group_identifier=$2`
+	getGroupByIDQuery         = `SELECT account_id, group_identifier, master_key FROM signalmeow_groups WHERE account_id=$1 AND group_identifier=$2`
 	upsertGroupMasterKeyQuery = `
-		INSERT INTO signalmeow_groups (our_aci_uuid, group_identifier, master_key)
+		INSERT INTO signalmeow_groups (account_id, group_identifier, master_key)
 		VALUES ($1, $2, $3)
-		ON CONFLICT (our_aci_uuid, group_identifier) DO UPDATE
+		ON CONFLICT (account_id, group_identifier) DO UPDATE
 			SET master_key = excluded.master_key;
 	`
 )
@@ -60,8 +60,8 @@ func scanGroup(row dbutil.Scannable) (*dbGroup, error) {
 	return &g, nil
 }
 
-func (s *SQLStore) MasterKeyFromGroupIdentifier(ctx context.Context, groupID types.GroupIdentifier) (types.SerializedGroupMasterKey, error) {
-	g, err := scanGroup(s.db.QueryRow(ctx, getGroupByIDQuery, s.ACI, groupID))
+func (s *sqlStore) MasterKeyFromGroupIdentifier(ctx context.Context, groupID types.GroupIdentifier) (types.SerializedGroupMasterKey, error) {
+	g, err := scanGroup(s.db.QueryRow(ctx, getGroupByIDQuery, s.AccountID, groupID))
 	if g == nil {
 		return "", err
 	} else {
@@ -69,7 +69,7 @@ func (s *SQLStore) MasterKeyFromGroupIdentifier(ctx context.Context, groupID typ
 	}
 }
 
-func (s *SQLStore) StoreMasterKey(ctx context.Context, groupID types.GroupIdentifier, key types.SerializedGroupMasterKey) error {
-	_, err := s.db.Exec(ctx, upsertGroupMasterKeyQuery, s.ACI, groupID, key)
+func (s *sqlStore) StoreMasterKey(ctx context.Context, groupID types.GroupIdentifier, key types.SerializedGroupMasterKey) error {
+	_, err := s.db.Exec(ctx, upsertGroupMasterKeyQuery, s.AccountID, groupID, key)
 	return err
 }
diff --git a/pkg/signalmeow/store/identity_store.go b/pkg/signalmeow/store/identity_store.go
index 14276a17..3b2435d4 100644
--- a/pkg/signalmeow/store/identity_store.go
+++ b/pkg/signalmeow/store/identity_store.go
@@ -27,77 +27,58 @@ import (
 	"go.mau.fi/mautrix-signal/pkg/libsignalgo"
 )
 
-var _ libsignalgo.IdentityKeyStore = (*SQLStore)(nil)
+type sqlIdentityStore struct {
+	*sqlStore
+
+	OwnKeyPair          *libsignalgo.IdentityKeyPair
+	LocalRegistrationID uint32
+}
+
+type IdentityKeyStore interface {
+	SaveIdentityKey(ctx context.Context, theirServiceID libsignalgo.ServiceID, identityKey *libsignalgo.IdentityKey) (bool, error)
+	GetIdentityKey(ctx context.Context, theirServiceID libsignalgo.ServiceID) (*libsignalgo.IdentityKey, error)
+	IsTrustedIdentity(ctx context.Context, theirServiceID libsignalgo.ServiceID, identityKey *libsignalgo.IdentityKey, direction libsignalgo.SignalDirection) (bool, error)
+}
+
+var _ libsignalgo.IdentityKeyStore = (*sqlIdentityStore)(nil)
+var _ IdentityKeyStore = (*sqlStore)(nil)
 
 const (
-	getIdentityKeyPairQuery     = `SELECT aci_identity_key_pair FROM signalmeow_device WHERE aci_uuid=$1`
-	getRegistrationLocalIDQuery = `SELECT registration_id FROM signalmeow_device WHERE aci_uuid=$1`
-	insertIdentityKeyQuery      = `
-		INSERT INTO signalmeow_identity_keys (our_aci_uuid, their_aci_uuid, their_device_id, key, trust_level)
-		VALUES ($1, $2, $3, $4, $5)
-		ON CONFLICT (our_aci_uuid, their_aci_uuid, their_device_id) DO UPDATE
+	insertIdentityKeyQuery = `
+		INSERT INTO signalmeow_identity_keys (account_id, their_service_id, key, trust_level)
+		VALUES ($1, $2, $3, $4)
+		ON CONFLICT (account_id, their_service_id) DO UPDATE
 			SET key=excluded.key, trust_level=excluded.trust_level
 	`
 	getIdentityKeyTrustLevelQuery = `
 		SELECT trust_level FROM signalmeow_identity_keys
-		WHERE our_aci_uuid=$1 AND their_aci_uuid=$2 AND their_device_id=$3
+		WHERE account_id=$1 AND their_service_id=$2
 	`
 	getIdentityKeyQuery = `
 		SELECT key FROM signalmeow_identity_keys
-		WHERE our_aci_uuid=$1 AND their_aci_uuid=$2 AND their_device_id=$3
+		WHERE account_id=$1 AND their_service_id=$2
 	`
 )
 
-func scanIdentityKeyPair(row dbutil.Scannable) (*libsignalgo.IdentityKeyPair, error) {
-	var keyPair []byte
-	err := row.Scan(&keyPair)
-	if errors.Is(err, sql.ErrNoRows) {
-		return nil, nil
-	} else if err != nil {
-		return nil, err
-	}
-	return libsignalgo.DeserializeIdentityKeyPair(keyPair)
+func (s *sqlIdentityStore) GetIdentityKeyPair(ctx context.Context) (*libsignalgo.IdentityKeyPair, error) {
+	return s.OwnKeyPair, nil
 }
 
-func scanIdentityKey(row dbutil.Scannable) (*libsignalgo.IdentityKey, error) {
-	var key []byte
-	err := row.Scan(&key)
-	if errors.Is(err, sql.ErrNoRows) {
-		return nil, nil
-	} else if err != nil {
-		return nil, err
-	}
-	return libsignalgo.DeserializeIdentityKey(key)
+func (s *sqlIdentityStore) GetLocalRegistrationID(ctx context.Context) (uint32, error) {
+	return s.LocalRegistrationID, nil
 }
 
-func (s *SQLStore) GetIdentityKeyPair(ctx context.Context) (*libsignalgo.IdentityKeyPair, error) {
-	return scanIdentityKeyPair(s.db.QueryRow(ctx, getIdentityKeyPairQuery, s.ACI))
-}
-
-func (s *SQLStore) GetLocalRegistrationID(ctx context.Context) (uint32, error) {
-	var regID sql.NullInt64
-	err := s.db.QueryRow(ctx, getRegistrationLocalIDQuery, s.ACI).Scan(&regID)
-	if err != nil {
-		return 0, fmt.Errorf("failed to get local registration ID: %w", err)
-	}
-	return uint32(regID.Int64), nil
+func scanIdentityKey(row dbutil.Scannable) (*libsignalgo.IdentityKey, error) {
+	return scanRecord(row, libsignalgo.DeserializeIdentityKey)
 }
 
-func (s *SQLStore) SaveIdentityKey(ctx context.Context, address *libsignalgo.Address, identityKey *libsignalgo.IdentityKey) (bool, error) {
+func (s *sqlStore) SaveIdentityKey(ctx context.Context, theirServiceID libsignalgo.ServiceID, identityKey *libsignalgo.IdentityKey) (bool, error) {
 	trustLevel := "TRUSTED_UNVERIFIED" // TODO: this should be hard coded here
 	serialized, err := identityKey.Serialize()
 	if err != nil {
 		return false, fmt.Errorf("failed to serialize identity key: %w", err)
 	}
-	theirUUID, err := address.Name()
-	if err != nil {
-		return false, fmt.Errorf("failed to get their uuid: %w", err)
-	}
-	deviceID, err := address.DeviceID()
-	if err != nil {
-		return false, fmt.Errorf("failed to get device ID: %w", err)
-	}
-	oldKey, err := scanIdentityKey(s.db.QueryRow(ctx, getIdentityKeyQuery, s.ACI, theirUUID, deviceID))
+	oldKey, err := scanIdentityKey(s.db.QueryRow(ctx, getIdentityKeyQuery, s.AccountID, theirServiceID))
 	if err != nil {
 		return false, fmt.Errorf("failed to get old identity key: %w", err)
 	}
@@ -110,25 +91,17 @@ func (s *SQLStore) SaveIdentityKey(ctx context.Context, address *libsignalgo.Add
 		// We are replacing the old key if the old key exists, and it is not equal to the new key
 		replacing = !equal
 	}
-	_, err = s.db.Exec(ctx, insertIdentityKeyQuery, s.ACI, theirUUID, deviceID, serialized, trustLevel)
+	_, err = s.db.Exec(ctx, insertIdentityKeyQuery, s.AccountID, theirServiceID, serialized, trustLevel)
 	if err != nil {
 		return replacing, fmt.Errorf("failed to insert new identity key: %w", err)
 	}
 	return replacing, err
 }
 
-func (s *SQLStore) IsTrustedIdentity(ctx context.Context, address *libsignalgo.Address, identityKey *libsignalgo.IdentityKey, direction libsignalgo.SignalDirection) (bool, error) {
+func (s *sqlStore) IsTrustedIdentity(ctx context.Context, theirServiceID libsignalgo.ServiceID, identityKey *libsignalgo.IdentityKey, direction libsignalgo.SignalDirection) (bool, error) {
 	// TODO: this should check direction, and probably some other stuff (though whisperfish is pretty basic)
-	theirUUID, err := address.Name()
-	if err != nil {
-		return false, fmt.Errorf("failed to get their uuid: %w", err)
-	}
-	deviceID, err := address.DeviceID()
-	if err != nil {
-		return false, fmt.Errorf("failed to get device ID: %w", err)
-	}
 	var trustLevel string
-	err = s.db.QueryRow(ctx, getIdentityKeyTrustLevelQuery, s.ACI, theirUUID, deviceID).Scan(&trustLevel)
+	err := s.db.QueryRow(ctx, getIdentityKeyTrustLevelQuery, s.AccountID, theirServiceID).Scan(&trustLevel)
 	if errors.Is(err, sql.ErrNoRows) {
 		// If no rows, they are a new identity, so trust by default
 		return true, nil
@@ -139,16 +112,8 @@ func (s *SQLStore) IsTrustedIdentity(ctx context.Context, address *libsignalgo.A
 	return trusted, nil
 }
 
-func (s *SQLStore) GetIdentityKey(ctx context.Context, address *libsignalgo.Address) (*libsignalgo.IdentityKey, error) {
-	theirUUID, err := address.Name()
-	if err != nil {
-		return nil, fmt.Errorf("failed to get their uuid: %w", err)
-	}
-	deviceID, err := address.DeviceID()
-	if err != nil {
-		return nil, fmt.Errorf("failed to get device ID: %w", err)
-	}
-	key, err := scanIdentityKey(s.db.QueryRow(ctx, getIdentityKeyQuery, s.ACI, theirUUID, deviceID))
+func (s *sqlStore) GetIdentityKey(ctx context.Context, theirServiceID libsignalgo.ServiceID) (*libsignalgo.IdentityKey, error) {
+	key, err := scanIdentityKey(s.db.QueryRow(ctx, getIdentityKeyQuery, s.AccountID, theirServiceID))
 	if err != nil {
 		return nil, fmt.Errorf("failed to get identity key from database: %w", err)
 	}
diff --git a/pkg/signalmeow/store/prekey_store.go b/pkg/signalmeow/store/prekey_store.go
index 49634916..9999aad2 100644
--- a/pkg/signalmeow/store/prekey_store.go
+++ b/pkg/signalmeow/store/prekey_store.go
@@ -25,281 +25,209 @@ import (
 	"go.mau.fi/util/dbutil"
 
 	"go.mau.fi/mautrix-signal/pkg/libsignalgo"
-	"go.mau.fi/mautrix-signal/pkg/signalmeow/types"
 )
 
-var _ libsignalgo.PreKeyStore = (*SQLStore)(nil)
-var _ libsignalgo.SignedPreKeyStore = (*SQLStore)(nil)
-var _ libsignalgo.KyberPreKeyStore = (*SQLStore)(nil)
-var _ PreKeyStoreExtras = (*SQLStore)(nil)
-
-// TODO: figure out how best to handle ACI vs PNI UUIDs
-
-type PreKeyStoreExtras interface {
-	PreKey(ctx context.Context, uuidKind types.UUIDKind, preKeyID int) (*libsignalgo.PreKeyRecord, error)
-	SignedPreKey(ctx context.Context, uuidKind types.UUIDKind, preKeyID int) (*libsignalgo.SignedPreKeyRecord, error)
-	KyberPreKey(ctx context.Context, uuidKind types.UUIDKind, preKeyID int) (*libsignalgo.KyberPreKeyRecord, error)
-	SavePreKey(ctx context.Context, uuidKind types.UUIDKind, preKey *libsignalgo.PreKeyRecord, markUploaded bool) error
-	SaveSignedPreKey(ctx context.Context, uuidKind types.UUIDKind, preKey *libsignalgo.SignedPreKeyRecord, markUploaded bool) error
-	SaveKyberPreKey(ctx context.Context, uuidKind types.UUIDKind, preKey *libsignalgo.KyberPreKeyRecord, lastResort bool) error
-	DeletePreKey(ctx context.Context, uuidKind types.UUIDKind, preKeyID int) error
-	DeleteSignedPreKey(ctx context.Context, uuidKind types.UUIDKind, preKeyID int) error
-	DeleteKyberPreKey(ctx context.Context, uuidKind types.UUIDKind, preKeyID int) error
-	GetNextPreKeyID(ctx context.Context, uuidKind types.UUIDKind) (uint, error)
-	GetSignedNextPreKeyID(ctx context.Context, uuidKind types.UUIDKind) (uint, error)
-	GetNextKyberPreKeyID(ctx context.Context, uuidKind types.UUIDKind) (uint, error)
-	MarkPreKeysAsUploaded(ctx context.Context, uuidKind types.UUIDKind, upToID uint) error
-	MarkSignedPreKeysAsUploaded(ctx context.Context, uuidKind types.UUIDKind, upToID uint) error
-	IsKyberPreKeyLastResort(ctx context.Context, uuidKind types.UUIDKind, preKeyID int) (bool, error)
-	AllPreKeys(ctx context.Context, uuidKind types.UUIDKind) ([]*libsignalgo.PreKeyRecord, error)
-	AllNormalKyberPreKeys(ctx context.Context, uuidKind types.UUIDKind) ([]*libsignalgo.KyberPreKeyRecord, error)
-	DeleteAllPreKeys(ctx context.Context) error
-}
-
-// libsignalgo.PreKeyStore implementation
-
-func (s *SQLStore) LoadPreKey(ctx context.Context, id uint32) (*libsignalgo.PreKeyRecord, error) {
-	return s.PreKey(ctx, types.UUIDKindACI, int(id))
-}
-func (s *SQLStore) StorePreKey(ctx context.Context, id uint32, preKeyRecord *libsignalgo.PreKeyRecord) error {
-	return s.SavePreKey(ctx, types.UUIDKindACI, preKeyRecord, false)
-}
-func (s *SQLStore) RemovePreKey(ctx context.Context, id uint32) error {
-	return s.DeletePreKey(ctx, types.UUIDKindACI, int(id))
-}
+var _ PreKeyStore = (*scopedSQLStore)(nil)
 
-// libsignalgo.SignedPreKeyStore implementation
-
-func (s *SQLStore) LoadSignedPreKey(ctx context.Context, id uint32) (*libsignalgo.SignedPreKeyRecord, error) {
-	return s.SignedPreKey(ctx, types.UUIDKindACI, int(id))
-}
-func (s *SQLStore) StoreSignedPreKey(ctx context.Context, id uint32, signedPreKeyRecord *libsignalgo.SignedPreKeyRecord) error {
-	return s.SaveSignedPreKey(ctx, types.UUIDKindACI, signedPreKeyRecord, false)
-}
-func (s *SQLStore) RemoveSignedPreKey(ctx context.Context, id uint32) error {
-	return s.DeleteSignedPreKey(ctx, types.UUIDKindACI, int(id))
+type ServiceScopedStore interface {
+	GetServiceID() libsignalgo.ServiceID
 }
 
-// libsignalgo.KyberPreKeyStore implementation
+type PreKeyStore interface {
+	libsignalgo.PreKeyStore
+	libsignalgo.SignedPreKeyStore
+	libsignalgo.KyberPreKeyStore
+	ServiceScopedStore
 
-func (s *SQLStore) LoadKyberPreKey(ctx context.Context, id uint32) (*libsignalgo.KyberPreKeyRecord, error) {
-	return s.KyberPreKey(ctx, types.UUIDKindACI, int(id))
-}
-func (s *SQLStore) StoreKyberPreKey(ctx context.Context, id uint32, kyberPreKeyRecord *libsignalgo.KyberPreKeyRecord) error {
-	return s.SaveKyberPreKey(ctx, types.UUIDKindACI, kyberPreKeyRecord, false)
-}
-func (s *SQLStore) MarkKyberPreKeyUsed(ctx context.Context, id uint32) error {
-	isLastResort, err := s.IsKyberPreKeyLastResort(ctx, types.UUIDKindACI, int(id))
-	if err != nil {
-		return err
-	}
-	if !isLastResort {
-		return s.DeleteKyberPreKey(ctx, types.UUIDKindACI, int(id))
-	}
-	return nil
+	StoreLastResortKyberPreKey(ctx context.Context, preKeyID uint32, record *libsignalgo.KyberPreKeyRecord) error
+	RemoveSignedPreKey(ctx context.Context, preKeyID uint32) error
+	RemoveKyberPreKey(ctx context.Context, preKeyID uint32) error
+	GetNextPreKeyID(ctx context.Context) (uint32, error)
+	GetNextKyberPreKeyID(ctx context.Context) (uint32, error)
+	IsKyberPreKeyLastResort(ctx context.Context, preKeyID uint32) (bool, error)
+	AllPreKeys(ctx context.Context) ([]*libsignalgo.PreKeyRecord, error)
+	AllNormalKyberPreKeys(ctx context.Context) ([]*libsignalgo.KyberPreKeyRecord, error)
+	DeleteAllPreKeys(ctx context.Context) error
 }
 
 const (
-	getKyberPreKeyQuery       = `SELECT key_pair, is_last_resort FROM signalmeow_kyber_pre_keys WHERE aci_uuid=$1 AND key_id=$2 AND uuid_kind=$3`
-	insertKyberPreKeyQuery    = `INSERT INTO signalmeow_kyber_pre_keys (aci_uuid, key_id, uuid_kind, key_pair, is_last_resort) VALUES ($1, $2, $3, $4, $5)`
-	deleteKyberPreKeyQuery    = `DELETE FROM signalmeow_kyber_pre_keys WHERE aci_uuid=$1 AND key_id=$2 AND uuid_kind=$3`
-	getLastKyberPreKeyIDQuery = `SELECT MAX(key_id) FROM signalmeow_kyber_pre_keys WHERE aci_uuid=$1 AND uuid_kind=$2`
-	isLastResortQuery         = `SELECT is_last_resort FROM signalmeow_kyber_pre_keys WHERE aci_uuid=$1 AND key_id=$2 AND uuid_kind=$3`
+	getAllPreKeysQuery   = `SELECT key_pair FROM signalmeow_pre_keys WHERE account_id=$1 AND service_id=$2 AND is_signed=$3`
+	getPreKeyQuery       = `SELECT key_pair FROM signalmeow_pre_keys WHERE account_id=$1 AND service_id=$2 AND key_id=$3 AND is_signed=$4`
+	insertPreKeyQuery    = `INSERT INTO signalmeow_pre_keys (account_id, service_id, key_id, is_signed, key_pair) VALUES ($1, $2, $3, $4, $5)`
+	deletePreKeyQuery    = `DELETE FROM signalmeow_pre_keys WHERE account_id=$1 AND service_id=$2 AND key_id=$3 AND is_signed=$4`
+	getLastPreKeyIDQuery = `SELECT MAX(key_id) FROM signalmeow_pre_keys WHERE account_id=$1 AND service_id=$2 AND is_signed=$3`
+
+	getAllKyberPreKeysQuery   = `SELECT key_pair FROM signalmeow_kyber_pre_keys WHERE account_id=$1 AND service_id=$2 AND is_last_resort=false`
+	getKyberPreKeyQuery       = `SELECT key_pair FROM signalmeow_kyber_pre_keys WHERE account_id=$1 AND service_id=$2 AND key_id=$3`
+	insertKyberPreKeyQuery    = `INSERT INTO signalmeow_kyber_pre_keys (account_id, service_id, key_id, key_pair, is_last_resort) VALUES ($1, $2, $3, $4, $5)`
+	deleteKyberPreKeyQuery    = `DELETE FROM signalmeow_kyber_pre_keys WHERE account_id=$1 AND service_id=$2 AND key_id=$3`
+	getLastKyberPreKeyIDQuery = `SELECT MAX(key_id) FROM signalmeow_kyber_pre_keys WHERE account_id=$1 AND service_id=$2`
+	isLastResortQuery         = `SELECT is_last_resort FROM signalmeow_kyber_pre_keys WHERE account_id=$1 AND service_id=$2 AND key_id=$3`
 )
 
-func (s *SQLStore) KyberPreKey(ctx context.Context, uuidKind types.UUIDKind, preKeyID int) (*libsignalgo.KyberPreKeyRecord, error) {
-	var record []byte
-	var isLastResort bool
-	err := s.db.QueryRow(ctx, getKyberPreKeyQuery, s.ACI, preKeyID, uuidKind).Scan(&record, &isLastResort)
+func scanRecord[T any](row dbutil.Scannable, deserializer func([]byte) (*T, error)) (*T, error) {
+	record, err := dbutil.ScanSingleColumn[[]byte](row)
 	if errors.Is(err, sql.ErrNoRows) {
 		return nil, nil
-	}
-	if err != nil {
+	} else if err != nil {
 		return nil, err
 	}
-	return libsignalgo.DeserializeKyberPreKeyRecord(record)
+	return deserializer(record)
 }
 
-func (s *SQLStore) SaveKyberPreKey(ctx context.Context, uuidKind types.UUIDKind, kyberPreKeyRecord *libsignalgo.KyberPreKeyRecord, lastResort bool) error {
-	id, err := kyberPreKeyRecord.GetID()
-	if err != nil {
-		return fmt.Errorf("failed to get kyber prekey record ID: %w", err)
-	}
-	serialized, err := kyberPreKeyRecord.Serialize()
-	if err != nil {
-		return fmt.Errorf("failed to serialize kyber prekey record: %w", err)
-	}
-	_, err = s.db.Exec(ctx, insertKyberPreKeyQuery, s.ACI, id, uuidKind, serialized, lastResort)
-	return err
+func scanPreKey(row dbutil.Scannable) (*libsignalgo.PreKeyRecord, error) {
+	return scanRecord(row, libsignalgo.DeserializePreKeyRecord)
 }
 
-func (s *SQLStore) DeleteKyberPreKey(ctx context.Context, uuidKind types.UUIDKind, preKeyID int) error {
-	_, err := s.db.Exec(ctx, deleteKyberPreKeyQuery, s.ACI, preKeyID, uuidKind)
-	return err
+func scanSignedPreKey(row dbutil.Scannable) (*libsignalgo.SignedPreKeyRecord, error) {
+	return scanRecord(row, libsignalgo.DeserializeSignedPreKeyRecord)
 }
 
-func (s *SQLStore) GetNextKyberPreKeyID(ctx context.Context, uuidKind types.UUIDKind) (uint, error) {
-	var lastKeyID sql.NullInt64
-	err := s.db.QueryRow(ctx, getLastKyberPreKeyIDQuery, s.ACI, uuidKind).Scan(&lastKeyID)
-	if err != nil {
-		return 0, fmt.Errorf("failed to query next kyber prekey ID: %w", err)
-	}
-	return uint(lastKeyID.Int64) + 1, nil
+func scanKyberPreKey(row dbutil.Scannable) (*libsignalgo.KyberPreKeyRecord, error) {
+	return scanRecord(row, libsignalgo.DeserializeKyberPreKeyRecord)
 }
 
-func (s *SQLStore) IsKyberPreKeyLastResort(ctx context.Context, uuidKind types.UUIDKind, preKeyID int) (bool, error) {
-	var isLastResort bool
-	err := s.db.QueryRow(ctx, isLastResortQuery, s.ACI, preKeyID, uuidKind).Scan(&isLastResort)
-	if err != nil {
-		return false, err
-	}
-	return isLastResort, nil
-}
-
-const (
-	getPreKeyQuery              = `SELECT key_id, key_pair FROM signalmeow_pre_keys WHERE aci_uuid=$1 AND key_id=$2 AND uuid_kind=$3 and is_signed=$4`
-	insertPreKeyQuery           = `INSERT INTO signalmeow_pre_keys (aci_uuid, key_id, uuid_kind, is_signed, key_pair, uploaded) VALUES ($1, $2, $3, $4, $5, $6)`
-	deletePreKeyQuery           = `DELETE FROM signalmeow_pre_keys WHERE aci_uuid=$1 AND key_id=$2 AND uuid_kind=$3 AND is_signed=$4`
-	getLastPreKeyIDQuery        = `SELECT MAX(key_id) FROM signalmeow_pre_keys WHERE aci_uuid=$1 AND uuid_kind=$2 AND is_signed=$3`
-	markPreKeysAsUploadedQuery  = `UPDATE signalmeow_pre_keys SET uploaded=true WHERE aci_uuid=$1 AND uuid_kind=$2 AND is_signed=$3 AND key_id<=$4`
-	getUnuploadedPreKeysQuery   = `SELECT key_id, key_pair FROM signalmeow_pre_keys WHERE aci_uuid=$1 AND uuid_kind=$2 AND is_signed=$3 AND uploaded=false ORDER BY key_id`
-	getUploadedPreKeyCountQuery = `SELECT COUNT(*) FROM signalmeow_pre_keys WHERE aci_uuid=$1 AND uuid_kind=$2 AND is_signed=$3 AND uploaded=true`
-)
-
-func scanPreKey(row dbutil.Scannable) (*libsignalgo.PreKeyRecord, error) {
-	var id uint
-	var record []byte
-	err := row.Scan(&id, &record)
-	if errors.Is(err, sql.ErrNoRows) {
-		return nil, nil
-	} else if err != nil {
-		return nil, err
-	}
-	return libsignalgo.DeserializePreKeyRecord(record)
+func (s *scopedSQLStore) GetServiceID() libsignalgo.ServiceID {
+	return s.ServiceID
 }
 
-func scanSignedPreKey(row dbutil.Scannable) (*libsignalgo.SignedPreKeyRecord, error) {
-	var id uint
-	var record []byte
-	err := row.Scan(&id, &record)
-	if errors.Is(err, sql.ErrNoRows) {
-		return nil, nil
-	} else if err != nil {
-		return nil, err
-	}
-	return libsignalgo.DeserializeSignedPreKeyRecord(record)
+func (s *scopedSQLStore) LoadPreKey(ctx context.Context, preKeyID uint32) (*libsignalgo.PreKeyRecord, error) {
+	return scanPreKey(s.db.QueryRow(ctx, getPreKeyQuery, s.AccountID, s.ServiceID, preKeyID, false))
 }
 
-func (s *SQLStore) PreKey(ctx context.Context, uuidKind types.UUIDKind, preKeyID int) (*libsignalgo.PreKeyRecord, error) {
-	return scanPreKey(s.db.QueryRow(ctx, getPreKeyQuery, s.ACI, preKeyID, uuidKind, false))
+func (s *scopedSQLStore) LoadSignedPreKey(ctx context.Context, preKeyID uint32) (*libsignalgo.SignedPreKeyRecord, error) {
+	return scanSignedPreKey(s.db.QueryRow(ctx, getPreKeyQuery, s.AccountID, s.ServiceID, preKeyID, true))
 }
 
-func (s *SQLStore) SignedPreKey(ctx context.Context, uuidKind types.UUIDKind, preKeyID int) (*libsignalgo.SignedPreKeyRecord, error) {
-	return scanSignedPreKey(s.db.QueryRow(ctx, getPreKeyQuery, s.ACI, preKeyID, uuidKind, true))
+func (s *scopedSQLStore) LoadKyberPreKey(ctx context.Context, preKeyID uint32) (*libsignalgo.KyberPreKeyRecord, error) {
+	return scanKyberPreKey(s.db.QueryRow(ctx, getKyberPreKeyQuery, s.AccountID, s.ServiceID, preKeyID))
 }
 
-func (s *SQLStore) SavePreKey(ctx context.Context, uuidKind types.UUIDKind, preKey *libsignalgo.PreKeyRecord, markUploaded bool) error {
+func (s *scopedSQLStore) StorePreKey(ctx context.Context, preKeyID uint32, preKey *libsignalgo.PreKeyRecord) error {
 	id, err := preKey.GetID()
 	if err != nil {
 		return fmt.Errorf("failed to get prekey ID: %w", err)
+	} else if preKeyID > 0 && id != preKeyID {
+		return fmt.Errorf("prekey ID mismatch: expected %d, got %d", preKeyID, id)
 	}
 	serialized, err := preKey.Serialize()
 	if err != nil {
 		return fmt.Errorf("failed to serialize prekey: %w", err)
 	}
-	_, err = s.db.Exec(ctx, insertPreKeyQuery, s.ACI, id, uuidKind, false, serialized, markUploaded)
+	_, err = s.db.Exec(ctx, insertPreKeyQuery, s.AccountID, s.ServiceID, id, false, serialized)
 	return err
 }
 
-func (s *SQLStore) SaveSignedPreKey(ctx context.Context, uuidKind types.UUIDKind, preKey *libsignalgo.SignedPreKeyRecord, markUploaded bool) error {
+func (s *scopedSQLStore) StoreSignedPreKey(ctx context.Context, preKeyID uint32, preKey *libsignalgo.SignedPreKeyRecord) error {
 	id, err := preKey.GetID()
 	if err != nil {
 		return fmt.Errorf("failed to get signed prekey ID: %w", err)
+	} else if preKeyID > 0 && id != preKeyID {
+		return fmt.Errorf("prekey ID mismatch: expected %d, got %d", preKeyID, id)
 	}
 	serialized, err := preKey.Serialize()
 	if err != nil {
 		return fmt.Errorf("failed to serialize signed prekey: %w", err)
 	}
-	_, err = s.db.Exec(ctx, insertPreKeyQuery, s.ACI, id, uuidKind, true, serialized, markUploaded)
+	_, err = s.db.Exec(ctx, insertPreKeyQuery, s.AccountID, s.ServiceID, id, true, serialized)
+	return err
+}
+
+func (s *scopedSQLStore) StoreKyberPreKey(ctx context.Context, preKeyID uint32, kyberPreKeyRecord *libsignalgo.KyberPreKeyRecord) error {
+	return s.storeKyberPreKey(ctx, preKeyID, kyberPreKeyRecord, false)
+}
+
+func (s *scopedSQLStore) StoreLastResortKyberPreKey(ctx context.Context, preKeyID uint32, kyberPreKeyRecord *libsignalgo.KyberPreKeyRecord) error {
+	return s.storeKyberPreKey(ctx, preKeyID, kyberPreKeyRecord, true)
+}
+
+func (s *scopedSQLStore) storeKyberPreKey(ctx context.Context, preKeyID uint32, kyberPreKeyRecord *libsignalgo.KyberPreKeyRecord, lastResort bool) error {
+	id, err := kyberPreKeyRecord.GetID()
+	if err != nil {
+		return fmt.Errorf("failed to get kyber prekey record ID: %w", err)
+	} else if preKeyID > 0 && id != preKeyID {
+		return fmt.Errorf("prekey ID mismatch: expected %d, got %d", preKeyID, id)
+	}
+	serialized, err := kyberPreKeyRecord.Serialize()
+	if err != nil {
+		return fmt.Errorf("failed to serialize kyber prekey record: %w", err)
+	}
+	_, err = s.db.Exec(ctx, insertKyberPreKeyQuery, s.AccountID, s.ServiceID, id, serialized, lastResort)
 	return err
 }
 
-func (s *SQLStore) DeletePreKey(ctx context.Context, uuidKind types.UUIDKind, preKeyID int) error {
-	_, err := s.db.Exec(ctx, deletePreKeyQuery, s.ACI, preKeyID, uuidKind, false)
+func (s *scopedSQLStore) RemovePreKey(ctx context.Context, preKeyID uint32) error {
+	_, err := s.db.Exec(ctx, deletePreKeyQuery, s.AccountID, s.ServiceID, preKeyID, false)
 	return err
 }
 
-func (s *SQLStore) DeleteSignedPreKey(ctx context.Context, uuidKind types.UUIDKind, preKeyID int) error {
-	_, err := s.db.Exec(ctx, deletePreKeyQuery, s.ACI, preKeyID, uuidKind, true)
+func (s *scopedSQLStore) RemoveSignedPreKey(ctx context.Context, preKeyID uint32) error {
+	_, err := s.db.Exec(ctx, deletePreKeyQuery, s.AccountID, s.ServiceID, preKeyID, true)
 	return err
 }
 
-func (s *SQLStore) GetNextPreKeyID(ctx context.Context, uuidKind types.UUIDKind) (uint, error) {
-	var lastKeyID sql.NullInt64
-	err := s.db.QueryRow(ctx, getLastPreKeyIDQuery, s.ACI, uuidKind, false).Scan(&lastKeyID)
+func (s *scopedSQLStore) RemoveKyberPreKey(ctx context.Context, preKeyID uint32) error {
+	_, err := s.db.Exec(ctx, deleteKyberPreKeyQuery, s.AccountID, s.ServiceID, preKeyID)
+	return err
+}
+
+func (s *scopedSQLStore) MarkKyberPreKeyUsed(ctx context.Context, id uint32) error {
+	isLastResort, err := s.IsKyberPreKeyLastResort(ctx, id)
 	if err != nil {
-		return 0, fmt.Errorf("failed to query next prekey ID: %w", err)
+		return err
 	}
-	return uint(lastKeyID.Int64) + 1, nil
+	if !isLastResort {
+		return s.RemoveKyberPreKey(ctx, id)
+	}
+	return nil
 }
 
-func (s *SQLStore) GetSignedNextPreKeyID(ctx context.Context, uuidKind types.UUIDKind) (uint, error) {
+func (s *scopedSQLStore) GetNextPreKeyID(ctx context.Context) (uint32, error) {
 	var lastKeyID sql.NullInt64
-	err := s.db.QueryRow(ctx, getLastPreKeyIDQuery, s.ACI, uuidKind, true).Scan(&lastKeyID)
+	err := s.db.QueryRow(ctx, getLastPreKeyIDQuery, s.AccountID, s.ServiceID, false).Scan(&lastKeyID)
 	if err != nil {
-		return 0, fmt.Errorf("failed to query next signed prekey ID: %w", err)
+		return 0, fmt.Errorf("failed to query next prekey ID: %w", err)
 	}
-	return uint(lastKeyID.Int64) + 1, nil
+	return uint32(lastKeyID.Int64) + 1, nil
 }
 
-func (s *SQLStore) MarkPreKeysAsUploaded(ctx context.Context, uuidKind types.UUIDKind, upToID uint) error {
-	_, err := s.db.Exec(ctx, markPreKeysAsUploadedQuery, s.ACI, uuidKind, false, upToID)
-	return err
+func (s *scopedSQLStore) GetNextKyberPreKeyID(ctx context.Context) (uint32, error) {
+	var lastKeyID sql.NullInt64
+	err := s.db.QueryRow(ctx, getLastKyberPreKeyIDQuery, s.AccountID, s.ServiceID).Scan(&lastKeyID)
+	if err != nil {
+		return 0, fmt.Errorf("failed to query next kyber prekey ID: %w", err)
+	}
+	return uint32(lastKeyID.Int64) + 1, nil
 }
 
-func (s *SQLStore) MarkSignedPreKeysAsUploaded(ctx context.Context, uuidKind types.UUIDKind, upToID uint) error {
-	_, err := s.db.Exec(ctx, markPreKeysAsUploadedQuery, s.ACI, uuidKind, true, upToID)
-	return err
+func (s *scopedSQLStore) IsKyberPreKeyLastResort(ctx context.Context, preKeyID uint32) (bool, error) {
+	var isLastResort bool
+	err := s.db.QueryRow(ctx, isLastResortQuery, s.AccountID, s.ServiceID, preKeyID).Scan(&isLastResort)
+	if err != nil {
+		return false, err
+	}
+	return isLastResort, nil
 }
 
-func (s *SQLStore) DeleteAllPreKeys(ctx context.Context) error {
+func (s *scopedSQLStore) DeleteAllPreKeys(ctx context.Context) error {
 	return s.db.DoTxn(ctx, nil, func(ctx context.Context) error {
-		_, err := s.db.Exec(ctx, "DELETE FROM signalmeow_pre_keys WHERE aci_uuid=$1", s.ACI)
+		_, err := s.db.Exec(ctx, "DELETE FROM signalmeow_pre_keys WHERE account_id=$1", s.AccountID)
 		if err != nil {
 			return err
 		}
-		_, err = s.db.Exec(ctx, "DELETE FROM signalmeow_kyber_pre_keys WHERE aci_uuid=$1", s.ACI)
+		_, err = s.db.Exec(ctx, "DELETE FROM signalmeow_kyber_pre_keys WHERE account_id=$1", s.AccountID)
 		return err
 	})
 }
 
-func (s *SQLStore) AllPreKeys(ctx context.Context, uuidKind types.UUIDKind) ([]*libsignalgo.PreKeyRecord, error) {
-	queryString := "SELECT key_id, key_pair FROM signalmeow_pre_keys WHERE aci_uuid=$1 AND uuid_kind=$2 AND is_signed=$3"
-	rows, err := s.db.Query(ctx, queryString, s.ACI, uuidKind, false)
-	if errors.Is(err, sql.ErrNoRows) {
-		return nil, nil
-	}
-	if err != nil {
-		return nil, err
-	}
-	return dbutil.NewRowIter(rows, scanPreKey).AsList()
+func (s *scopedSQLStore) AllPreKeys(ctx context.Context) ([]*libsignalgo.PreKeyRecord, error) {
+	return dbutil.ConvertRowFn[*libsignalgo.PreKeyRecord](scanPreKey).
+		NewRowIter(s.db.Query(ctx, getAllPreKeysQuery, s.AccountID, s.ServiceID, false)).
+		AsList()
 }
 
-func (s *SQLStore) AllNormalKyberPreKeys(ctx context.Context, uuidKind types.UUIDKind) ([]*libsignalgo.KyberPreKeyRecord, error) {
-	queryString := "SELECT key_id, key_pair FROM signalmeow_kyber_pre_keys WHERE aci_uuid=$1 AND uuid_kind=$2 AND is_last_resort=false"
-	rows, err := s.db.Query(ctx, queryString, s.ACI, uuidKind)
-	if errors.Is(err, sql.ErrNoRows) {
-		return nil, nil
-	} else if err != nil {
-		return nil, err
-	}
-	return dbutil.NewRowIter(rows, func(row dbutil.Scannable) (*libsignalgo.KyberPreKeyRecord, error) {
-		var id uint
-		var record []byte
-		err := row.Scan(&id, &record)
-		if err != nil {
-			return nil, err
-		}
-		return libsignalgo.DeserializeKyberPreKeyRecord(record)
-	}).AsList()
+func (s *scopedSQLStore) AllNormalKyberPreKeys(ctx context.Context) ([]*libsignalgo.KyberPreKeyRecord, error) {
+	return dbutil.ConvertRowFn[*libsignalgo.KyberPreKeyRecord](scanKyberPreKey).
+		NewRowIter(s.db.Query(ctx, getAllKyberPreKeysQuery, s.AccountID, s.ServiceID)).
+		AsList()
 }
diff --git a/pkg/signalmeow/store/profile_key_store.go b/pkg/signalmeow/store/profile_key_store.go
index c384f4f5..02fd3e65 100644
--- a/pkg/signalmeow/store/profile_key_store.go
+++ b/pkg/signalmeow/store/profile_key_store.go
@@ -1,5 +1,5 @@
 // mautrix-signal - A Matrix-signal puppeting bridge.
-// Copyright (C) 2023 Scott Weber
+// Copyright (C) 2024 Tulir Asokan
 //
 // This program is free software: you can redistribute it and/or modify
 // it under the terms of the GNU Affero General Public License as published by
@@ -18,8 +18,6 @@ package store
 
 import (
 	"context"
-	"database/sql"
-	"errors"
 
 	"github.com/google/uuid"
 	"go.mau.fi/util/dbutil"
@@ -27,42 +25,28 @@ import (
 	"go.mau.fi/mautrix-signal/pkg/libsignalgo"
 )
 
-var _ ProfileKeyStore = (*SQLStore)(nil)
-
-type ProfileKeyStore interface {
-	// LoadProfileKey loads the profile key for the given address.
-	// If the address is not found, nil is returned.
-	LoadProfileKey(ctx context.Context, theirACI uuid.UUID) (*libsignalgo.ProfileKey, error)
-	StoreProfileKey(ctx context.Context, theirACI uuid.UUID, key libsignalgo.ProfileKey) error
-	MyProfileKey(ctx context.Context) (*libsignalgo.ProfileKey, error)
-}
-
 const (
-	loadProfileKeyQuery  = `SELECT key FROM signalmeow_profile_keys WHERE our_aci_uuid=$1 AND their_aci_uuid=$2`
-	storeProfileKeyQuery = `INSERT INTO signalmeow_profile_keys (our_aci_uuid, their_aci_uuid, key) VALUES ($1, $2, $3) ON CONFLICT (our_aci_uuid, their_aci_uuid) DO UPDATE SET key=excluded.key`
+	loadProfileKeyQuery  = `SELECT profile_key FROM signalmeow_recipients WHERE account_id=$1 AND aci_uuid=$2`
+	storeProfileKeyQuery = `
+		INSERT INTO signalmeow_recipients (account_id, aci_uuid, profile_key)
+		VALUES ($1, $2, $3)
+		ON CONFLICT (account_id, aci_uuid) DO UPDATE SET profile_key=excluded.profile_key
+	`
 )
 
 func scanProfileKey(row dbutil.Scannable) (*libsignalgo.ProfileKey, error) {
-	var record []byte
-	err := row.Scan(&record)
-	if errors.Is(err, sql.ErrNoRows) {
-		return nil, nil
-	} else if err != nil {
-		return nil, err
-	}
-	profileKey := libsignalgo.ProfileKey(record)
-	return &profileKey, err
+	return scanRecord(row, libsignalgo.DeserializeProfileKey)
 }
 
-func (s *SQLStore) LoadProfileKey(ctx context.Context, theirACI uuid.UUID) (*libsignalgo.ProfileKey, error) {
-	return scanProfileKey(s.db.QueryRow(ctx, loadProfileKeyQuery, s.ACI, theirACI))
+func (s *sqlStore) LoadProfileKey(ctx context.Context, theirACI uuid.UUID) (*libsignalgo.ProfileKey, error) {
+	return scanProfileKey(s.db.QueryRow(ctx, loadProfileKeyQuery, s.AccountID, theirACI))
 }
 
-func (s *SQLStore) MyProfileKey(ctx context.Context) (*libsignalgo.ProfileKey, error) {
-	return scanProfileKey(s.db.QueryRow(ctx, loadProfileKeyQuery, s.ACI, s.ACI))
+func (s *sqlStore) MyProfileKey(ctx context.Context) (*libsignalgo.ProfileKey, error) {
+	return scanProfileKey(s.db.QueryRow(ctx, loadProfileKeyQuery, s.AccountID, s.AccountID))
 }
 
-func (s *SQLStore) StoreProfileKey(ctx context.Context, theirACI uuid.UUID, key libsignalgo.ProfileKey) error {
-	_, err := s.db.Exec(ctx, storeProfileKeyQuery, s.ACI, theirACI, key.Slice())
+func (s *sqlStore) StoreProfileKey(ctx context.Context, theirACI uuid.UUID, key libsignalgo.ProfileKey) error {
+	_, err := s.db.Exec(ctx, storeProfileKeyQuery, s.AccountID, theirACI, key.Slice())
 	return err
 }
diff --git a/pkg/signalmeow/store/recipient_store.go b/pkg/signalmeow/store/recipient_store.go
new file mode 100644
index 00000000..216dfc89
--- /dev/null
+++ b/pkg/signalmeow/store/recipient_store.go
@@ -0,0 +1,323 @@
+// mautrix-signal - A Matrix-signal puppeting bridge.
+// Copyright (C) 2023 Scott Weber
+// Copyright (C) 2024 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+package store
+
+import (
+	"context"
+	"database/sql"
+	"errors"
+	"fmt"
+	"time"
+
+	"github.com/google/uuid"
+	"go.mau.fi/util/dbutil"
+
+	"go.mau.fi/mautrix-signal/pkg/libsignalgo"
+	"go.mau.fi/mautrix-signal/pkg/signalmeow/types"
+)
+
+type RecipientStore interface {
+	LoadProfileKey(ctx context.Context, theirACI uuid.UUID) (*libsignalgo.ProfileKey, error)
+	StoreProfileKey(ctx context.Context, theirACI uuid.UUID, key libsignalgo.ProfileKey) error
+	MyProfileKey(ctx context.Context) (*libsignalgo.ProfileKey, error)
+
+	LoadAndUpdateRecipient(ctx context.Context, aci, pni uuid.UUID, updater RecipientUpdaterFunc) (*types.Recipient, error)
+	LoadRecipientByE164(ctx context.Context, e164 string) (*types.Recipient, error)
+	StoreRecipient(ctx context.Context, recipient *types.Recipient) error
+	UpdateRecipientE164(ctx context.Context, aci, pni uuid.UUID, e164 string) (*types.Recipient, error)
+}
+
+var _ RecipientStore = (*sqlStore)(nil)
+
+const (
+	getAllRecipientsQuery = `
+		SELECT
+			aci_uuid,
+			pni_uuid,
+			e164_number,
+			contact_name,
+			contact_avatar_hash,
+			profile_key,
+			profile_name,
+			profile_about,
+			profile_about_emoji,
+			profile_avatar_path,
+			profile_fetched_at,
+			needs_pni_signature
+		FROM signalmeow_recipients
+		WHERE account_id = $1
+	`
+	getRecipientByACIQuery      = getAllRecipientsQuery + `AND aci_uuid = $2`
+	getRecipientByPNIQuery      = getAllRecipientsQuery + `AND pni_uuid = $2`
+	getRecipientByACIOrPNIQuery = getAllRecipientsQuery + `AND (($2<>'00000000-0000-0000-0000-000000000000' AND aci_uuid = $2) OR ($3<>'00000000-0000-0000-0000-000000000000' AND pni_uuid = $3))`
+	getRecipientByPhoneQuery    = getAllRecipientsQuery + `AND e164_number = $2`
+	deleteRecipientByPNIQuery   = `DELETE FROM signalmeow_recipients WHERE account_id = $1 AND pni_uuid = $2`
+	upsertACIRecipientQuery     = `
+		INSERT INTO signalmeow_recipients (
+			account_id,
+			aci_uuid,
+			pni_uuid,
+			e164_number,
+			contact_name,
+			contact_avatar_hash,
+			profile_key,
+			profile_name,
+			profile_about,
+			profile_about_emoji,
+			profile_avatar_path,
+			profile_fetched_at,
+			needs_pni_signature
+		)
+		VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
+		ON CONFLICT (account_id, aci_uuid) DO UPDATE SET
+			pni_uuid = excluded.pni_uuid,
+			e164_number = excluded.e164_number,
+			contact_name = excluded.contact_name,
+			contact_avatar_hash = excluded.contact_avatar_hash,
+			profile_key = excluded.profile_key,
+			profile_name = excluded.profile_name,
+			profile_about = excluded.profile_about,
+			profile_about_emoji = excluded.profile_about_emoji,
+			profile_avatar_path = excluded.profile_avatar_path,
+			profile_fetched_at = excluded.profile_fetched_at,
+			needs_pni_signature = excluded.needs_pni_signature
+	`
+	upsertPNIRecipientQuery = `
+		INSERT INTO signalmeow_recipients (
+			account_id,
+			pni_uuid,
+			e164_number,
+			contact_name,
+			contact_avatar_hash
+		)
+		VALUES ($1, $2, $3, $4, $5)
+		ON CONFLICT (account_id, pni_uuid) DO UPDATE SET
+			e164_number = excluded.e164_number,
+			contact_name = excluded.contact_name,
+			contact_avatar_hash = excluded.contact_avatar_hash
+	`
+)
+
+func scanRecipient(row dbutil.Scannable) (*types.Recipient, error) {
+	var recipient types.Recipient
+	var aci, pni uuid.NullUUID
+	var profileKey []byte
+	var profileFetchedAt sql.NullInt64
+	err := row.Scan(
+		&aci,
+		&pni,
+		&recipient.E164,
+		&recipient.ContactName,
+		&recipient.ContactAvatar.Hash,
+		&profileKey,
+		&recipient.Profile.Name,
+		&recipient.Profile.About,
+		&recipient.Profile.AboutEmoji,
+		&recipient.Profile.AvatarPath,
+		&profileFetchedAt,
+		&recipient.NeedsPNISignature,
+	)
+	if errors.Is(err, sql.ErrNoRows) {
+		return nil, nil
+	} else if err != nil {
+		return nil, err
+	}
+	recipient.ACI = aci.UUID
+	recipient.PNI = pni.UUID
+	if profileFetchedAt.Valid {
+		recipient.Profile.FetchedAt = time.UnixMilli(profileFetchedAt.Int64)
+	}
+	if len(profileKey) == libsignalgo.ProfileKeyLength {
+		recipient.Profile.Key = libsignalgo.ProfileKey(profileKey)
+	}
+	return &recipient, err
+}
+
+func (s *sqlStore) LoadRecipientByACI(ctx context.Context, theirUUID uuid.UUID) (*types.Recipient, error) {
+	return scanRecipient(s.db.QueryRow(ctx, getRecipientByACIQuery, s.AccountID, theirUUID))
+}
+
+func (s *sqlStore) LoadRecipientByPNI(ctx context.Context, theirUUID uuid.UUID) (*types.Recipient, error) {
+	return scanRecipient(s.db.QueryRow(ctx, getRecipientByPNIQuery, s.AccountID, theirUUID))
+}
+
+type RecipientUpdaterFunc func(recipient *types.Recipient) (changed bool, err error)
+
+func (s *sqlStore) mergeRecipients(ctx context.Context, first, second *types.Recipient, updater RecipientUpdaterFunc) (*types.Recipient, error) {
+	if first.ACI == uuid.Nil {
+		first, second = second, first
+	}
+	first.PNI = second.PNI
+	if second.E164 != "" {
+		first.E164 = second.E164
+	}
+	if first.ContactName == "" {
+		first.ContactName = second.ContactName
+	}
+	if first.ContactAvatar.Hash == "" {
+		first.ContactAvatar = second.ContactAvatar
+	}
+	_, err := updater(first)
+	if err != nil {
+		return first, fmt.Errorf("failed to run updater function: %w", err)
+	}
+	err = s.DeleteRecipientByPNI(ctx, first.PNI)
+	if err != nil {
+		return first, fmt.Errorf("failed to delete duplicate PNI row: %w", err)
+	}
+	err = s.StoreRecipient(ctx, first)
+	if err != nil {
+		return first, fmt.Errorf("failed to store merged row: %w", err)
+	}
+	return first, nil
+}
+
+func (s *sqlStore) LoadAndUpdateRecipient(ctx context.Context, aci, pni uuid.UUID, updater RecipientUpdaterFunc) (outRecipient *types.Recipient, outErr error) {
+	if aci == uuid.Nil && pni == uuid.Nil {
+		return nil, fmt.Errorf("no ACI or PNI provided in LoadAndUpdateRecipient call")
+	}
+	if updater == nil {
+		updater = func(recipient *types.Recipient) (bool, error) {
+			return false, nil
+		}
+	}
+	s.contactLock.Lock()
+	defer s.contactLock.Unlock()
+	outErr = s.db.DoTxn(ctx, nil, func(ctx context.Context) error {
+		var entries []*types.Recipient
+		var err error
+		if aci != uuid.Nil && pni != uuid.Nil {
+			query := getRecipientByACIOrPNIQuery
+			if s.db.Dialect == dbutil.Postgres {
+				query += " FOR UPDATE"
+			}
+			entries, err = dbutil.ConvertRowFn[*types.Recipient](scanRecipient).
+				NewRowIter(s.db.Query(ctx, query, s.AccountID, aci, pni)).
+				AsList()
+		} else if aci != uuid.Nil {
+			var entry *types.Recipient
+			entry, err = s.LoadRecipientByACI(ctx, aci)
+			if entry != nil {
+				entries = []*types.Recipient{entry}
+			}
+		} else if pni != uuid.Nil {
+			var entry *types.Recipient
+			entry, err = s.LoadRecipientByPNI(ctx, pni)
+			if entry != nil {
+				entries = []*types.Recipient{entry}
+			}
+		} else {
+			panic("impossible case")
+		}
+		if err != nil {
+			return err
+		} else if len(entries) > 2 {
+			return fmt.Errorf("got more than two recipient rows for ACI %s and PNI %s", aci, pni)
+		} else if len(entries) < 2 {
+			if len(entries) == 0 {
+				outRecipient = &types.Recipient{
+					ACI: aci,
+					PNI: pni,
+				}
+			} else {
+				outRecipient = entries[0]
+			}
+			changed, err := updater(outRecipient)
+			if err != nil {
+				return fmt.Errorf("failed to run updater function: %w", err)
+			}
+			if outRecipient.PNI == uuid.Nil && pni != uuid.Nil {
+				outRecipient.PNI = pni
+				changed = true
+			}
+			if outRecipient.ACI == uuid.Nil && aci != uuid.Nil {
+				outRecipient.ACI = aci
+				changed = true
+			}
+			if changed || len(entries) == 0 {
+				err = s.StoreRecipient(ctx, outRecipient)
+				if err != nil {
+					return fmt.Errorf("failed to store updated recipient row: %w", err)
+				}
+			}
+			return nil
+		} else if outRecipient, err = s.mergeRecipients(ctx, entries[0], entries[1], updater); err != nil {
+			return fmt.Errorf("failed to merge recipient rows for ACI %s and PNI %s: %w", aci, pni, err)
+		} else {
+			return nil
+		}
+	})
+	return
+}
+
+func (s *sqlStore) UpdateRecipientE164(ctx context.Context, aci, pni uuid.UUID, e164 string) (*types.Recipient, error) {
+	return s.LoadAndUpdateRecipient(ctx, aci, pni, func(recipient *types.Recipient) (bool, error) {
+		if recipient.E164 != e164 {
+			recipient.E164 = e164
+			return true, nil
+		}
+		return false, nil
+	})
+}
+func (s *sqlStore) LoadRecipientByE164(ctx context.Context, e164 string) (*types.Recipient, error) {
+	return scanRecipient(s.db.QueryRow(ctx, getRecipientByPhoneQuery, s.AccountID, e164))
+}
+
+func (s *sqlStore) DeleteRecipientByPNI(ctx context.Context, pni uuid.UUID) error {
+	_, err := s.db.Exec(ctx, deleteRecipientByPNIQuery, s.AccountID, pni)
+	return err
+}
+
+func nullableUUID(u uuid.UUID) uuid.NullUUID {
+	return uuid.NullUUID{UUID: u, Valid: u != uuid.Nil}
+}
+
+func (s *sqlStore) StoreRecipient(ctx context.Context, recipient *types.Recipient) (err error) {
+	if recipient.ACI != uuid.Nil {
+		_, err = s.db.Exec(
+			ctx,
+			upsertACIRecipientQuery,
+			s.AccountID,
+			recipient.ACI,
+			nullableUUID(recipient.PNI),
+			recipient.E164,
+			recipient.ContactName,
+			recipient.ContactAvatar.Hash,
+			recipient.Profile.Key.Slice(),
+			recipient.Profile.Name,
+			recipient.Profile.About,
+			recipient.Profile.AboutEmoji,
+			recipient.Profile.AvatarPath,
+			dbutil.UnixMilliPtr(recipient.Profile.FetchedAt),
+			recipient.NeedsPNISignature,
+		)
+	} else if recipient.PNI != uuid.Nil {
+		_, err = s.db.Exec(
+			ctx,
+			upsertPNIRecipientQuery,
+			s.AccountID,
+			recipient.PNI,
+			recipient.E164,
+			recipient.ContactName,
+			recipient.ContactAvatar.Hash,
+		)
+	} else {
+		err = fmt.Errorf("no ACI or PNI provided in StoreRecipient call")
+	}
+	return
+}
diff --git a/pkg/signalmeow/store/sender_key_store.go b/pkg/signalmeow/store/sender_key_store.go
index e9ffea2b..092bcdde 100644
--- a/pkg/signalmeow/store/sender_key_store.go
+++ b/pkg/signalmeow/store/sender_key_store.go
@@ -28,11 +28,11 @@ import (
 	"go.mau.fi/mautrix-signal/pkg/libsignalgo"
 )
 
-var _ libsignalgo.SenderKeyStore = (*SQLStore)(nil)
+var _ libsignalgo.SenderKeyStore = (*sqlStore)(nil)
 
 const (
-	loadSenderKeyQuery  = `SELECT key_record FROM signalmeow_sender_keys WHERE our_aci_uuid=$1 AND sender_uuid=$2 AND sender_device_id=$3 AND distribution_id=$4`
-	storeSenderKeyQuery = `INSERT INTO signalmeow_sender_keys (our_aci_uuid, sender_uuid, sender_device_id, distribution_id, key_record) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (our_aci_uuid, sender_uuid, sender_device_id, distribution_id) DO UPDATE SET key_record=excluded.key_record`
+	loadSenderKeyQuery  = `SELECT key_record FROM signalmeow_sender_keys WHERE account_id=$1 AND sender_uuid=$2 AND sender_device_id=$3 AND distribution_id=$4`
+	storeSenderKeyQuery = `INSERT INTO signalmeow_sender_keys (account_id, sender_uuid, sender_device_id, distribution_id, key_record) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (account_id, sender_uuid, sender_device_id, distribution_id) DO UPDATE SET key_record=excluded.key_record`
 )
 
 func scanSenderKey(row dbutil.Scannable) (*libsignalgo.SenderKeyRecord, error) {
@@ -46,7 +46,7 @@ func scanSenderKey(row dbutil.Scannable) (*libsignalgo.SenderKeyRecord, error) {
 	return libsignalgo.DeserializeSenderKeyRecord(key)
 }
 
-func (s *SQLStore) LoadSenderKey(ctx context.Context, sender *libsignalgo.Address, distributionID uuid.UUID) (*libsignalgo.SenderKeyRecord, error) {
+func (s *sqlStore) LoadSenderKey(ctx context.Context, sender *libsignalgo.Address, distributionID uuid.UUID) (*libsignalgo.SenderKeyRecord, error) {
 	senderUUID, err := sender.Name()
 	if err != nil {
 		return nil, fmt.Errorf("failed to get sender UUID: %w", err)
@@ -55,10 +55,10 @@ func (s *SQLStore) LoadSenderKey(ctx context.Context, sender *libsignalgo.Addres
 	if err != nil {
 		return nil, fmt.Errorf("failed to get sender device ID: %w", err)
 	}
-	return scanSenderKey(s.db.QueryRow(ctx, loadSenderKeyQuery, s.ACI, senderUUID, deviceID, distributionID))
+	return scanSenderKey(s.db.QueryRow(ctx, loadSenderKeyQuery, s.AccountID, senderUUID, deviceID, distributionID))
 }
 
-func (s *SQLStore) StoreSenderKey(ctx context.Context, sender *libsignalgo.Address, distributionID uuid.UUID, record *libsignalgo.SenderKeyRecord) error {
+func (s *sqlStore) StoreSenderKey(ctx context.Context, sender *libsignalgo.Address, distributionID uuid.UUID, record *libsignalgo.SenderKeyRecord) error {
 	senderUUID, err := sender.Name()
 	if err != nil {
 		return fmt.Errorf("failed to get sender UUID: %w", err)
@@ -71,6 +71,6 @@ func (s *SQLStore) StoreSenderKey(ctx context.Context, sender *libsignalgo.Addre
 	if err != nil {
 		return fmt.Errorf("failed to serialize sender key: %w", err)
 	}
-	_, err = s.db.Exec(ctx, storeSenderKeyQuery, s.ACI, senderUUID, deviceID, distributionID, serialized)
+	_, err = s.db.Exec(ctx, storeSenderKeyQuery, s.AccountID, senderUUID, deviceID, distributionID, serialized)
 	return err
 }
diff --git a/pkg/signalmeow/store/session_store.go b/pkg/signalmeow/store/session_store.go
index 5c190da0..5fe6cba4 100644
--- a/pkg/signalmeow/store/session_store.go
+++ b/pkg/signalmeow/store/session_store.go
@@ -22,59 +22,65 @@ import (
 	"errors"
 	"fmt"
 
-	"github.com/google/uuid"
 	"go.mau.fi/util/dbutil"
 
 	"go.mau.fi/mautrix-signal/pkg/libsignalgo"
 )
 
-var _ libsignalgo.SessionStore = (*SQLStore)(nil)
-var _ SessionStoreExtras = (*SQLStore)(nil)
+var _ SessionStore = (*scopedSQLStore)(nil)
 
 const (
-	loadSessionQuery   = `SELECT their_device_id, record FROM signalmeow_sessions WHERE our_aci_uuid=$1 AND their_aci_uuid=$2 AND their_device_id=$3`
-	storeSessionQuery  = `INSERT INTO signalmeow_sessions (our_aci_uuid, their_aci_uuid, their_device_id, record) VALUES ($1, $2, $3, $4) ON CONFLICT (our_aci_uuid, their_aci_uuid, their_device_id) DO UPDATE SET record=excluded.record`
-	allSessionsQuery   = `SELECT their_device_id, record FROM signalmeow_sessions WHERE our_aci_uuid=$1 AND their_aci_uuid=$2`
-	removeSessionQuery = `DELETE FROM signalmeow_sessions WHERE our_aci_uuid=$1 AND their_aci_uuid=$2 AND their_device_id=$3`
+	loadSessionQuery  = `SELECT their_device_id, record FROM signalmeow_sessions WHERE account_id=$1 AND service_id=$2 AND their_service_id=$3 AND their_device_id=$4`
+	storeSessionQuery = `
+		INSERT INTO signalmeow_sessions (account_id, service_id, their_service_id, their_device_id, record)
+		VALUES ($1, $2, $3, $4, $5)
+		ON CONFLICT (account_id, service_id, their_service_id, their_device_id) DO UPDATE SET record=excluded.record
+	`
+	allSessionsQuery       = `SELECT their_device_id, record FROM signalmeow_sessions WHERE account_id=$1 AND service_id=$2 AND their_service_id=$3`
+	removeSessionQuery     = `DELETE FROM signalmeow_sessions WHERE account_id=$1 AND service_id=$2 AND their_service_id=$3 AND their_device_id=$4`
+	deleteAllSessionsQuery = "DELETE FROM signalmeow_sessions WHERE account_id=$1"
 )
 
-type SessionStoreExtras interface {
-	// AllSessionsForUUID returns all sessions for the given UUID.
-	AllSessionsForUUID(ctx context.Context, theirUUID uuid.UUID) ([]*libsignalgo.Address, []*libsignalgo.SessionRecord, error)
+type SessionStore interface {
+	libsignalgo.SessionStore
+	ServiceScopedStore
+
+	// AllSessionsForServiceID returns all sessions for the given service ID.
+	AllSessionsForServiceID(ctx context.Context, theirID libsignalgo.ServiceID) ([]*libsignalgo.Address, []*libsignalgo.SessionRecord, error)
 	// RemoveSession removes the session for the given address.
 	RemoveSession(ctx context.Context, address *libsignalgo.Address) error
 	// RemoveAllSessions removes all sessions for our ACI UUID
 	RemoveAllSessions(ctx context.Context) error
 }
 
-func scanRecord(row dbutil.Scannable) (int, *libsignalgo.SessionRecord, error) {
+func scanSessionRecord(row dbutil.Scannable) (int, *libsignalgo.SessionRecord, error) {
 	var record []byte
-	var deviceId int
-	err := row.Scan(&deviceId, &record)
+	var deviceID int
+	err := row.Scan(&deviceID, &record)
 	if errors.Is(err, sql.ErrNoRows) {
 		return 0, nil, nil
 	} else if err != nil {
 		return 0, nil, err
 	}
 	sessionRecord, err := libsignalgo.DeserializeSessionRecord(record)
-	return deviceId, sessionRecord, err
+	return deviceID, sessionRecord, err
 }
 
-func (s *SQLStore) RemoveSession(ctx context.Context, address *libsignalgo.Address) error {
-	theirUUID, err := address.Name()
+func (s *scopedSQLStore) RemoveSession(ctx context.Context, address *libsignalgo.Address) error {
+	theirServiceID, err := address.Name()
 	if err != nil {
-		return fmt.Errorf("failed to get their UUID: %w", err)
+		return fmt.Errorf("failed to get their service ID: %w", err)
 	}
 	deviceID, err := address.DeviceID()
 	if err != nil {
 		return fmt.Errorf("failed to get their device ID: %w", err)
 	}
-	_, err = s.db.Exec(ctx, removeSessionQuery, s.ACI, theirUUID, deviceID)
+	_, err = s.db.Exec(ctx, removeSessionQuery, s.AccountID, s.ServiceID, theirServiceID, deviceID)
 	return err
 }
 
-func (s *SQLStore) AllSessionsForUUID(ctx context.Context, theirUUID uuid.UUID) ([]*libsignalgo.Address, []*libsignalgo.SessionRecord, error) {
-	rows, err := s.db.Query(ctx, allSessionsQuery, s.ACI, theirUUID)
+func (s *scopedSQLStore) AllSessionsForServiceID(ctx context.Context, theirID libsignalgo.ServiceID) ([]*libsignalgo.Address, []*libsignalgo.SessionRecord, error) {
+	rows, err := s.db.Query(ctx, allSessionsQuery, s.AccountID, s.ServiceID, theirID)
 	if err != nil {
 		return nil, nil, err
 	}
@@ -82,12 +88,12 @@ func (s *SQLStore) AllSessionsForUUID(ctx context.Context, theirUUID uuid.UUID)
 	var records []*libsignalgo.SessionRecord
 	var addresses []*libsignalgo.Address
 	for rows.Next() {
-		deviceId, record, err := scanRecord(rows)
+		deviceID, record, err := scanSessionRecord(rows)
 		if err != nil {
 			return nil, nil, err
 		}
 		records = append(records, record)
-		address, err := libsignalgo.NewUUIDAddress(theirUUID, uint(deviceId))
+		address, err := theirID.Address(uint(deviceID))
 		if err != nil {
 			return nil, nil, err
 		}
@@ -96,23 +102,23 @@ func (s *SQLStore) AllSessionsForUUID(ctx context.Context, theirUUID uuid.UUID)
 	return addresses, records, rows.Err()
 }
 
-func (s *SQLStore) LoadSession(ctx context.Context, address *libsignalgo.Address) (*libsignalgo.SessionRecord, error) {
-	theirUUID, err := address.Name()
+func (s *scopedSQLStore) LoadSession(ctx context.Context, address *libsignalgo.Address) (*libsignalgo.SessionRecord, error) {
+	theirServiceID, err := address.Name()
 	if err != nil {
-		return nil, fmt.Errorf("failed to get their UUID: %w", err)
+		return nil, fmt.Errorf("failed to get their service ID: %w", err)
 	}
 	deviceID, err := address.DeviceID()
 	if err != nil {
 		return nil, fmt.Errorf("failed to get their device ID: %w", err)
 	}
-	_, record, err := scanRecord(s.db.QueryRow(ctx, loadSessionQuery, s.ACI, theirUUID, deviceID))
+	_, record, err := scanSessionRecord(s.db.QueryRow(ctx, loadSessionQuery, s.AccountID, s.ServiceID, theirServiceID, deviceID))
 	return record, err
 }
 
-func (s *SQLStore) StoreSession(ctx context.Context, address *libsignalgo.Address, record *libsignalgo.SessionRecord) error {
-	theirUUID, err := address.Name()
+func (s *scopedSQLStore) StoreSession(ctx context.Context, address *libsignalgo.Address, record *libsignalgo.SessionRecord) error {
+	theirServiceID, err := address.Name()
 	if err != nil {
-		return fmt.Errorf("failed to get their UUID: %w", err)
+		return fmt.Errorf("failed to get their service ID: %w", err)
 	}
 	deviceID, err := address.DeviceID()
 	if err != nil {
@@ -122,11 +128,11 @@ func (s *SQLStore) StoreSession(ctx context.Context, address *libsignalgo.Addres
 	if err != nil {
 		return fmt.Errorf("failed to serialize session record: %w", err)
 	}
-	_, err = s.db.Exec(ctx, storeSessionQuery, s.ACI, theirUUID, deviceID, serialized)
+	_, err = s.db.Exec(ctx, storeSessionQuery, s.AccountID, s.ServiceID, theirServiceID, deviceID, serialized)
 	return err
 }
 
-func (s *SQLStore) RemoveAllSessions(ctx context.Context) error {
-	_, err := s.db.Exec(ctx, "DELETE FROM signalmeow_sessions WHERE our_aci_uuid=$1", s.ACI)
+func (s *scopedSQLStore) RemoveAllSessions(ctx context.Context) error {
+	_, err := s.db.Exec(ctx, deleteAllSessionsQuery, s.AccountID)
 	return err
 }
diff --git a/pkg/signalmeow/store/upgrades/09-pni-sending.sql b/pkg/signalmeow/store/upgrades/09-pni-sending.sql
new file mode 100644
index 00000000..d3c1cb46
--- /dev/null
+++ b/pkg/signalmeow/store/upgrades/09-pni-sending.sql
@@ -0,0 +1,3 @@
+-- v9: Add support for sending to PNIs
+ALTER TABLE signalmeow_sessions RENAME COLUMN their_aci_uuid TO their_service_id;
+ALTER TABLE signalmeow_identity_keys RENAME COLUMN their_aci_uuid TO their_service_id;
diff --git a/pkg/signalmeow/store/upgrades/10-prekey-store-service-id.postgres.sql b/pkg/signalmeow/store/upgrades/10-prekey-store-service-id.postgres.sql
new file mode 100644
index 00000000..c4d913d9
--- /dev/null
+++ b/pkg/signalmeow/store/upgrades/10-prekey-store-service-id.postgres.sql
@@ -0,0 +1,31 @@
+-- v10: Change prekey store to use service IDs instead of UUID kind column
+ALTER TABLE signalmeow_pre_keys ADD COLUMN service_id TEXT;
+UPDATE signalmeow_pre_keys SET service_id=aci_uuid WHERE uuid_kind='aci';
+UPDATE signalmeow_pre_keys SET service_id='PNI:' || (
+    SELECT pni_uuid FROM signalmeow_device WHERE signalmeow_device.aci_uuid=signalmeow_pre_keys.aci_uuid
+) WHERE uuid_kind='pni';
+ALTER TABLE signalmeow_pre_keys ALTER COLUMN service_id SET NOT NULL;
+ALTER TABLE signalmeow_pre_keys DROP CONSTRAINT signalmeow_pre_keys_pkey;
+ALTER TABLE signalmeow_pre_keys DROP COLUMN uuid_kind;
+ALTER TABLE signalmeow_pre_keys RENAME COLUMN aci_uuid TO account_id;
+ALTER TABLE signalmeow_pre_keys ADD PRIMARY KEY (account_id, service_id, is_signed, key_id);
+
+ALTER TABLE signalmeow_pre_keys DROP COLUMN uploaded;
+
+ALTER TABLE signalmeow_kyber_pre_keys ADD COLUMN service_id TEXT;
+UPDATE signalmeow_kyber_pre_keys SET service_id=aci_uuid WHERE uuid_kind='aci';
+UPDATE signalmeow_kyber_pre_keys SET service_id='PNI:' || (
+    SELECT pni_uuid FROM signalmeow_device WHERE signalmeow_device.aci_uuid=signalmeow_kyber_pre_keys.aci_uuid
+) WHERE uuid_kind='pni';
+ALTER TABLE signalmeow_kyber_pre_keys ALTER COLUMN service_id SET NOT NULL;
+ALTER TABLE signalmeow_kyber_pre_keys DROP CONSTRAINT signalmeow_kyber_pre_keys_pkey;
+ALTER TABLE signalmeow_kyber_pre_keys DROP COLUMN uuid_kind;
+ALTER TABLE signalmeow_kyber_pre_keys RENAME COLUMN aci_uuid TO account_id;
+ALTER TABLE signalmeow_kyber_pre_keys ADD PRIMARY KEY (account_id, service_id, key_id);
+
+ALTER TABLE signalmeow_sessions ADD COLUMN service_id TEXT;
+UPDATE signalmeow_sessions SET service_id=our_aci_uuid; -- there are no PNI sessions yet
+ALTER TABLE signalmeow_sessions ALTER COLUMN service_id SET NOT NULL;
+ALTER TABLE signalmeow_sessions DROP CONSTRAINT signalmeow_sessions_pkey;
+ALTER TABLE signalmeow_sessions RENAME COLUMN our_aci_uuid TO account_id;
+ALTER TABLE signalmeow_sessions ADD PRIMARY KEY (account_id, service_id, their_service_id, their_device_id);
diff --git a/pkg/signalmeow/store/upgrades/10-prekey-store-service-id.sqlite.sql b/pkg/signalmeow/store/upgrades/10-prekey-store-service-id.sqlite.sql
new file mode 100644
index 00000000..25b2824f
--- /dev/null
+++ b/pkg/signalmeow/store/upgrades/10-prekey-store-service-id.sqlite.sql
@@ -0,0 +1,59 @@
+-- v10: Change prekey store to use service IDs instead of UUID kind column
+CREATE TABLE new_signalmeow_pre_keys (
+    account_id TEXT    NOT NULL,
+    service_id TEXT    NOT NULL,
+    key_id     INTEGER NOT NULL,
+    is_signed  BOOLEAN NOT NULL,
+    key_pair   bytea   NOT NULL,
+
+    PRIMARY KEY (account_id, service_id, is_signed, key_id),
+    FOREIGN KEY (account_id) REFERENCES signalmeow_device (aci_uuid) ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+INSERT INTO new_signalmeow_pre_keys (account_id, service_id, key_id, is_signed, key_pair)
+SELECT aci_uuid, CASE WHEN uuid_kind='pni' THEN 'PNI:'||(
+    SELECT pni_uuid FROM signalmeow_device WHERE signalmeow_device.aci_uuid=signalmeow_pre_keys.aci_uuid
+) ELSE aci_uuid END, key_id, is_signed, key_pair
+FROM signalmeow_pre_keys;
+
+DROP TABLE signalmeow_pre_keys;
+ALTER TABLE new_signalmeow_pre_keys RENAME TO signalmeow_pre_keys;
+
+
+CREATE TABLE new_signalmeow_kyber_pre_keys (
+    account_id     TEXT    NOT NULL,
+    service_id     TEXT    NOT NULL,
+    key_id         INTEGER NOT NULL,
+    key_pair       bytea   NOT NULL,
+    is_last_resort BOOLEAN NOT NULL,
+
+    PRIMARY KEY (account_id, service_id, key_id),
+    FOREIGN KEY (account_id) REFERENCES signalmeow_device (aci_uuid) ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+INSERT INTO new_signalmeow_kyber_pre_keys (account_id, service_id, key_id, key_pair, is_last_resort)
+SELECT aci_uuid, CASE WHEN uuid_kind='pni' THEN 'PNI:'||(
+    SELECT pni_uuid FROM signalmeow_device WHERE signalmeow_device.aci_uuid=signalmeow_kyber_pre_keys.aci_uuid
+) ELSE aci_uuid END, key_id, key_pair, is_last_resort
+FROM signalmeow_kyber_pre_keys;
+
+DROP TABLE signalmeow_kyber_pre_keys;
+ALTER TABLE new_signalmeow_kyber_pre_keys RENAME TO signalmeow_kyber_pre_keys;
+
+
+CREATE TABLE new_signalmeow_sessions (
+    account_id       TEXT    NOT NULL,
+    service_id       TEXT    NOT NULL,
+    their_service_id TEXT    NOT NULL,
+    their_device_id  INTEGER NOT NULL,
+    record           bytea   NOT NULL,
+
+    PRIMARY KEY (account_id, service_id, their_service_id, their_device_id),
+    FOREIGN KEY (account_id) REFERENCES signalmeow_device (aci_uuid) ON DELETE CASCADE ON UPDATE CASCADE
+);
+INSERT INTO new_signalmeow_sessions (account_id, service_id, their_service_id, their_device_id, record)
+SELECT our_aci_uuid, our_aci_uuid, their_service_id, their_device_id, record
+FROM signalmeow_sessions;
+
+DROP TABLE signalmeow_sessions;
+ALTER TABLE new_signalmeow_sessions RENAME TO signalmeow_sessions;
diff --git a/pkg/signalmeow/store/upgrades/11-aci-to-account-id.sql b/pkg/signalmeow/store/upgrades/11-aci-to-account-id.sql
new file mode 100644
index 00000000..88974a67
--- /dev/null
+++ b/pkg/signalmeow/store/upgrades/11-aci-to-account-id.sql
@@ -0,0 +1,6 @@
+-- v11: Rename our_aci_uuid columns to account_id
+ALTER TABLE signalmeow_identity_keys RENAME COLUMN our_aci_uuid TO account_id;
+ALTER TABLE signalmeow_profile_keys RENAME COLUMN our_aci_uuid TO account_id;
+ALTER TABLE signalmeow_sender_keys RENAME COLUMN our_aci_uuid TO account_id;
+ALTER TABLE signalmeow_groups RENAME COLUMN our_aci_uuid TO account_id;
+ALTER TABLE signalmeow_contacts RENAME COLUMN our_aci_uuid TO account_id;
diff --git a/pkg/signalmeow/store/upgrades/12-drop-identity-key-device-id.postgres.sql b/pkg/signalmeow/store/upgrades/12-drop-identity-key-device-id.postgres.sql
new file mode 100644
index 00000000..8625f122
--- /dev/null
+++ b/pkg/signalmeow/store/upgrades/12-drop-identity-key-device-id.postgres.sql
@@ -0,0 +1,5 @@
+-- v12: Drop their_device_id column in signalmeow_identity_keys table
+DELETE FROM signalmeow_identity_keys WHERE their_device_id<>1;
+ALTER TABLE signalmeow_identity_keys DROP CONSTRAINT signalmeow_identity_keys_pkey;
+ALTER TABLE signalmeow_identity_keys DROP COLUMN their_device_id;
+ALTER TABLE signalmeow_identity_keys ADD PRIMARY KEY (account_id, their_service_id);
diff --git a/pkg/signalmeow/store/upgrades/12-drop-identity-key-device-id.sqlite.sql b/pkg/signalmeow/store/upgrades/12-drop-identity-key-device-id.sqlite.sql
new file mode 100644
index 00000000..396862fd
--- /dev/null
+++ b/pkg/signalmeow/store/upgrades/12-drop-identity-key-device-id.sqlite.sql
@@ -0,0 +1,19 @@
+-- v12: Drop their_device_id column in signalmeow_identity_keys table
+CREATE TABLE new_signalmeow_identity_keys (
+    account_id       TEXT    NOT NULL,
+    their_service_id TEXT    NOT NULL,
+    key              bytea   NOT NULL,
+    trust_level      TEXT    NOT NULL,
+
+    PRIMARY KEY (account_id, their_service_id),
+    FOREIGN KEY (account_id) REFERENCES signalmeow_device (aci_uuid) ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+INSERT INTO new_signalmeow_identity_keys (account_id, their_service_id, key, trust_level)
+SELECT account_id, their_service_id, key, trust_level
+FROM signalmeow_identity_keys
+WHERE their_device_id=1;
+
+DROP TABLE signalmeow_identity_keys;
+
+ALTER TABLE new_signalmeow_identity_keys RENAME TO signalmeow_identity_keys;
diff --git a/pkg/signalmeow/store/upgrades/13-recipients-table.postgres.sql b/pkg/signalmeow/store/upgrades/13-recipients-table.postgres.sql
new file mode 100644
index 00000000..181807c2
--- /dev/null
+++ b/pkg/signalmeow/store/upgrades/13-recipients-table.postgres.sql
@@ -0,0 +1,22 @@
+-- v13: Add PNIs to recipient table and merge profile keys
+ALTER TABLE signalmeow_contacts DROP CONSTRAINT signalmeow_contacts_pkey;
+ALTER TABLE signalmeow_contacts RENAME TO signalmeow_recipients;
+ALTER TABLE signalmeow_recipients ADD COLUMN pni_uuid TEXT;
+ALTER TABLE signalmeow_recipients ALTER COLUMN aci_uuid DROP NOT NULL;
+ALTER TABLE signalmeow_recipients ADD CONSTRAINT signalmeow_contacts_aci_unique UNIQUE (account_id, aci_uuid);
+ALTER TABLE signalmeow_recipients ADD CONSTRAINT signalmeow_contacts_pni_unique UNIQUE (account_id, pni_uuid);
+
+ALTER TABLE signalmeow_recipients ALTER COLUMN e164_number SET DEFAULT '';
+ALTER TABLE signalmeow_recipients ALTER COLUMN contact_name SET DEFAULT '';
+ALTER TABLE signalmeow_recipients ALTER COLUMN contact_avatar_hash SET DEFAULT '';
+ALTER TABLE signalmeow_recipients ALTER COLUMN profile_name SET DEFAULT '';
+ALTER TABLE signalmeow_recipients ALTER COLUMN profile_about SET DEFAULT '';
+ALTER TABLE signalmeow_recipients ALTER COLUMN profile_about_emoji SET DEFAULT '';
+ALTER TABLE signalmeow_recipients ALTER COLUMN profile_avatar_path SET DEFAULT '';
+
+INSERT INTO signalmeow_recipients (account_id, aci_uuid, profile_key)
+SELECT account_id, their_aci_uuid, key
+FROM signalmeow_profile_keys
+ON CONFLICT (account_id, aci_uuid) DO UPDATE SET profile_key=excluded.profile_key;
+
+DROP TABLE signalmeow_profile_keys;
diff --git a/pkg/signalmeow/store/upgrades/13-recipients-table.sqlite.sql b/pkg/signalmeow/store/upgrades/13-recipients-table.sqlite.sql
new file mode 100644
index 00000000..4060685e
--- /dev/null
+++ b/pkg/signalmeow/store/upgrades/13-recipients-table.sqlite.sql
@@ -0,0 +1,37 @@
+-- v13: Add PNIs to recipient table and merge profile keys
+CREATE TABLE signalmeow_recipients (
+    account_id          TEXT NOT NULL,
+    aci_uuid            TEXT,
+    pni_uuid            TEXT,
+    e164_number         TEXT NOT NULL DEFAULT '',
+    contact_name        TEXT NOT NULL DEFAULT '',
+    contact_avatar_hash TEXT NOT NULL DEFAULT '',
+    profile_key         bytea,
+    profile_name        TEXT NOT NULL DEFAULT '',
+    profile_about       TEXT NOT NULL DEFAULT '',
+    profile_about_emoji TEXT NOT NULL DEFAULT '',
+    profile_avatar_path TEXT NOT NULL DEFAULT '',
+    profile_fetched_at  BIGINT,
+
+    CONSTRAINT signalmeow_contacts_account_id_fkey FOREIGN KEY (account_id) REFERENCES signalmeow_device (aci_uuid)
+        ON DELETE CASCADE ON UPDATE CASCADE,
+    CONSTRAINT signalmeow_contacts_aci_unique UNIQUE (account_id, aci_uuid),
+    CONSTRAINT signalmeow_contacts_pni_unique UNIQUE (account_id, pni_uuid)
+);
+
+INSERT INTO signalmeow_recipients (
+    account_id, aci_uuid, e164_number, contact_name, contact_avatar_hash, profile_key, profile_name,
+    profile_about, profile_about_emoji, profile_avatar_path, profile_fetched_at
+)
+SELECT account_id, aci_uuid, e164_number, contact_name, contact_avatar_hash, profile_key, profile_name,
+       profile_about, profile_about_emoji, profile_avatar_path, profile_fetched_at
+FROM signalmeow_contacts;
+
+INSERT INTO signalmeow_recipients (account_id, aci_uuid, profile_key)
+SELECT account_id, their_aci_uuid, key
+FROM signalmeow_profile_keys
+WHERE true -- https://sqlite.org/lang_upsert.html#parsing_ambiguity
+ON CONFLICT (account_id, aci_uuid) DO UPDATE SET profile_key=excluded.profile_key;
+
+DROP TABLE signalmeow_contacts;
+DROP TABLE signalmeow_profile_keys;
diff --git a/pkg/signalmeow/store/upgrades/14-save-storage-master-key.sql b/pkg/signalmeow/store/upgrades/14-save-storage-master-key.sql
new file mode 100644
index 00000000..6c75ed1e
--- /dev/null
+++ b/pkg/signalmeow/store/upgrades/14-save-storage-master-key.sql
@@ -0,0 +1,2 @@
+-- v14 (compatible with v13+): Save storage master key for devices
+ALTER TABLE signalmeow_device ADD COLUMN master_key bytea;
diff --git a/pkg/signalmeow/store/upgrades/15-needs-pni-signature.sql b/pkg/signalmeow/store/upgrades/15-needs-pni-signature.sql
new file mode 100644
index 00000000..26d854a3
--- /dev/null
+++ b/pkg/signalmeow/store/upgrades/15-needs-pni-signature.sql
@@ -0,0 +1,2 @@
+-- v15 (compatible with v13+): Store flag for recipients who need a PNI signature
+ALTER TABLE signalmeow_recipients ADD COLUMN needs_pni_signature boolean DEFAULT false;
diff --git a/pkg/signalmeow/types/contact.go b/pkg/signalmeow/types/contact.go
index f00c89b8..fea6fa54 100644
--- a/pkg/signalmeow/types/contact.go
+++ b/pkg/signalmeow/types/contact.go
@@ -31,6 +31,7 @@ type Profile struct {
 	AvatarPath string
 	Key        libsignalgo.ProfileKey
 	FetchedAt  time.Time
+	Credential []byte
 }
 
 func (p *Profile) Equals(other *Profile) bool {
@@ -41,17 +42,18 @@ func (p *Profile) Equals(other *Profile) bool {
 		p.Key == other.Key
 }
 
-// The Contact struct combines information from two sources:
+// The Recipient struct combines information from two sources:
 // - A Signal "contact": contact info harvested from our user's phone's contact list
 // - A Signal "profile": contact info entered by the target user when registering for Signal
-// Users of this Contact struct should prioritize "contact" information, but fall back
-// to "profile" information if the contact information is not available.
-type Contact struct {
-	UUID          uuid.UUID
+type Recipient struct {
+	ACI           uuid.UUID
+	PNI           uuid.UUID
 	E164          string
 	ContactName   string
 	ContactAvatar ContactAvatar
 	Profile       Profile
+
+	NeedsPNISignature bool
 }
 
 type ContactAvatar struct {
diff --git a/pkg/signalmeow/types/identifer.go b/pkg/signalmeow/types/identifer.go
index 334e9bba..c11238c7 100644
--- a/pkg/signalmeow/types/identifer.go
+++ b/pkg/signalmeow/types/identifer.go
@@ -44,3 +44,4 @@ func (gid GroupIdentifier) Bytes() (raw libsignalgo.GroupIdentifier, err error)
 
 // This is just base64 encoded group master key
 type SerializedGroupMasterKey string
+type SerializedInviteLinkPassword string
diff --git a/pkg/signalmeow/types/uuid.go b/pkg/signalmeow/types/uuid.go
deleted file mode 100644
index 86271dd4..00000000
--- a/pkg/signalmeow/types/uuid.go
+++ /dev/null
@@ -1,10 +0,0 @@
-package types
-
-const (
-	// UUIDKindACI is the UUID kind for account identifiers.
-	UUIDKindACI UUIDKind = "aci"
-	// UUIDKindPNI is the UUID kind for phone number identifiers.
-	UUIDKindPNI UUIDKind = "pni"
-)
-
-type UUIDKind string
diff --git a/portal.go b/portal.go
index 06ec3818..b5be7f37 100644
--- a/portal.go
+++ b/portal.go
@@ -46,6 +46,7 @@ import (
 	"go.mau.fi/mautrix-signal/msgconv"
 	"go.mau.fi/mautrix-signal/msgconv/matrixfmt"
 	"go.mau.fi/mautrix-signal/msgconv/signalfmt"
+	"go.mau.fi/mautrix-signal/pkg/libsignalgo"
 	"go.mau.fi/mautrix-signal/pkg/signalmeow"
 	"go.mau.fi/mautrix-signal/pkg/signalmeow/events"
 	signalpb "go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf"
@@ -72,8 +73,18 @@ func (br *SignalBridge) GetPortalByMXID(mxid id.RoomID) *Portal {
 func (br *SignalBridge) GetPortalByChatID(key database.PortalKey) *Portal {
 	br.portalsLock.Lock()
 	defer br.portalsLock.Unlock()
+	return br.unlockedGetPortalByChatID(key, true)
+}
+
+func (br *SignalBridge) GetPortalByChatIDIfExists(key database.PortalKey) *Portal {
+	br.portalsLock.Lock()
+	defer br.portalsLock.Unlock()
+	return br.unlockedGetPortalByChatID(key, false)
+}
+
+func (br *SignalBridge) unlockedGetPortalByChatID(key database.PortalKey, createIfNotExists bool) *Portal {
 	// If this PortalKey is for a group, Receiver should be empty
-	if key.UserID() == uuid.Nil {
+	if key.UserID().IsEmpty() {
 		key.Receiver = uuid.Nil
 	}
 	portal, ok := br.portalsByID[key]
@@ -83,7 +94,11 @@ func (br *SignalBridge) GetPortalByChatID(key database.PortalKey) *Portal {
 			br.ZLog.Err(err).Msg("Failed to get portal from database")
 			return nil
 		}
-		return br.loadPortal(context.TODO(), dbPortal, &key)
+		keyIfNotExists := &key
+		if !createIfNotExists {
+			keyIfNotExists = nil
+		}
+		return br.loadPortal(context.TODO(), dbPortal, keyIfNotExists)
 	}
 	return portal
 }
@@ -269,17 +284,18 @@ func (portal *Portal) GetRelayUser() *User {
 }
 
 func (portal *Portal) IsPrivateChat() bool {
-	return portal.UserID() != uuid.Nil
+	return !portal.UserID().IsEmpty()
 }
 
 func (portal *Portal) IsNoteToSelf() bool {
 	userID := portal.UserID()
-	return userID != uuid.Nil && userID == portal.Receiver
+	return !userID.IsEmpty() && userID.UUID == portal.Receiver
 }
 
 func (portal *Portal) MainIntent() *appservice.IntentAPI {
-	if portal.IsPrivateChat() {
-		return portal.bridge.GetPuppetBySignalID(portal.UserID()).DefaultIntent()
+	dmPuppet := portal.GetDMPuppet()
+	if dmPuppet != nil {
+		return dmPuppet.DefaultIntent()
 	}
 
 	return portal.bridge.Bot
@@ -880,7 +896,7 @@ func (portal *Portal) handleSignalDataMessage(source *User, sender *Puppet, msg
 	// If this message is a group change, don't handle it here, it's handled below.
 	if msg.GetGroupV2().GetGroupChange() == nil && portal.Revision < msg.GetGroupV2().GetRevision() {
 		portal.UpdateInfo(genericCtx, source, nil, msg.GetGroupV2().GetRevision())
-	} else if portal.IsPrivateChat() && portal.UserID() == portal.Receiver && portal.Name != NoteToSelfName {
+	} else if portal.IsPrivateChat() && portal.UserID().UUID == portal.Receiver && portal.Name != NoteToSelfName {
 		// Slightly hacky way to make note to self names backfill
 		portal.UpdateDMInfo(genericCtx, false)
 	}
@@ -1074,9 +1090,7 @@ func (portal *Portal) handleSignalGroupChange(source *User, sender *Puppet, grou
 				if *groupChange.ModifyAttributesAccess == signalmeow.AccessControl_ADMINISTRATOR {
 					level = 50
 				}
-				levels.EnsureEventLevel(event.StateRoomName, level)
-				levels.EnsureEventLevel(event.StateTopic, level)
-				levels.EnsureEventLevel(event.StateRoomAvatar, level)
+				levels.StateDefaultPtr = &level
 			}
 			if groupChange.ModifyMemberAccess != nil {
 				level := 0
@@ -1114,6 +1128,7 @@ func (portal *Portal) handleSignalGroupChange(source *User, sender *Puppet, grou
 		log.Err(err).Msg("Failed to save portal in database after processing group change")
 	}
 	portal.UpdateBridgeInfo(ctx)
+	portal.CleanupIfEmpty(ctx)
 }
 
 func (portal *Portal) sendMembershipForPuppetAndUser(ctx context.Context, sender *Puppet, target uuid.UUID, membership event.Membership, action string) (puppet *Puppet, err error) {
@@ -1497,7 +1512,8 @@ func (portal *Portal) setTyping(userIDs []id.UserID, isTyping bool) {
 		// Check to see if portal.ChatID is a standard UUID (with dashes)
 		// Note: not handling sending to a group right now, since that will
 		// require SenderKey sending to not be terrible
-		if portal.IsPrivateChat() {
+		dmUserID := portal.UserID()
+		if !dmUserID.IsEmpty() && dmUserID.Type == libsignalgo.ServiceIDTypeACI {
 			// this is a 1:1 chat
 			portal.log.Debug().Msg("Sending Typing event to Signal")
 			ctx := context.TODO()
@@ -1593,7 +1609,7 @@ func (portal *Portal) handleMatrixReadReceipt(sender *User, eventID id.EventID,
 		// Don't use portal.sendSignalMessage because we're sending this straight to
 		// who sent the original message, not the portal's ChatID
 		ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
-		result := sender.Client.SendMessage(ctx, destination, signalmeow.ReadReceptMessageForTimestamps(messages))
+		result := sender.Client.SendMessage(ctx, libsignalgo.NewACIServiceID(destination), signalmeow.ReadReceptMessageForTimestamps(messages))
 		cancel()
 		if !result.WasSuccessful {
 			log.Err(result.FailedSendResult.Error).
@@ -1718,7 +1734,7 @@ func (portal *Portal) CreateMatrixRoom(ctx context.Context, user *User, groupRev
 		})
 		portal.Encrypted = true
 
-		if portal.IsPrivateChat() {
+		if portal.IsPrivateChat() && portal.MainIntent() != portal.bridge.Bot {
 			invite = append(invite, portal.bridge.Bot.UserID)
 		}
 	}
@@ -1727,8 +1743,12 @@ func (portal *Portal) CreateMatrixRoom(ctx context.Context, user *User, groupRev
 	var groupInfo *signalmeow.Group
 	if portal.IsPrivateChat() {
 		dmPuppet = portal.GetDMPuppet()
-		dmPuppet.UpdateInfo(ctx, user)
-		portal.UpdateDMInfo(ctx, false)
+		if dmPuppet != nil {
+			dmPuppet.UpdateInfo(ctx, user)
+			portal.UpdateDMInfo(ctx, false)
+		} else {
+			portal.UpdatePNIDMInfo(ctx, user)
+		}
 	} else {
 		groupInfo = portal.UpdateGroupInfo(ctx, user, nil, groupRevision, true)
 		if groupInfo == nil {
@@ -1787,7 +1807,7 @@ func (portal *Portal) CreateMatrixRoom(ctx context.Context, user *User, groupRev
 	user.syncChatDoublePuppetDetails(portal, true)
 	go portal.addToPersonalSpace(portal.log.WithContext(context.TODO()), user)
 
-	if portal.IsPrivateChat() {
+	if dmPuppet != nil {
 		user.UpdateDirectChats(ctx, map[id.UserID][]id.RoomID{
 			dmPuppet.MXID: {portal.MXID},
 		})
@@ -1797,10 +1817,11 @@ func (portal *Portal) CreateMatrixRoom(ctx context.Context, user *User, groupRev
 }
 
 func (portal *Portal) GetDMPuppet() *Puppet {
-	if !portal.IsPrivateChat() {
+	userID := portal.UserID()
+	if userID.IsEmpty() || userID.Type != libsignalgo.ServiceIDTypeACI {
 		return nil
 	}
-	return portal.bridge.GetPuppetBySignalID(portal.UserID())
+	return portal.bridge.GetPuppetBySignalID(userID.UUID)
 }
 
 func (portal *Portal) UpdateInfo(ctx context.Context, source *User, groupInfo *signalmeow.Group, revision uint32) {
@@ -1818,6 +1839,18 @@ func (portal *Portal) UpdateInfo(ctx context.Context, source *User, groupInfo *s
 const PrivateChatTopic = "Signal private chat"
 const NoteToSelfName = "Signal Note to Self"
 
+func (portal *Portal) PostReIDUpdate(ctx context.Context, user *User) {
+	_, err := portal.bridge.Bot.SetPowerLevel(ctx, portal.MXID, portal.MainIntent().UserID, 100)
+	if err != nil {
+		zerolog.Ctx(ctx).Err(err).Msg("Failed to update ghost power level after portal re-ID")
+	}
+	portal.GetDMPuppet().UpdateInfo(ctx, user)
+	portal.UpdateDMInfo(ctx, true)
+	if !portal.Encrypted {
+		_, _ = portal.bridge.Bot.LeaveRoom(ctx, portal.MXID)
+	}
+}
+
 func (portal *Portal) UpdateDMInfo(ctx context.Context, forceSave bool) {
 	log := zerolog.Ctx(ctx).With().
 		Str("function", "UpdateDMInfo").
@@ -1827,7 +1860,7 @@ func (portal *Portal) UpdateDMInfo(ctx context.Context, forceSave bool) {
 	puppet := portal.GetDMPuppet()
 
 	update := forceSave
-	if portal.UserID() == portal.Receiver {
+	if portal.UserID().UUID == portal.Receiver {
 		noteToSelfAvatar := portal.bridge.Config.Bridge.NoteToSelfAvatar.ParseOrIgnore()
 		avatarHash := sha256.Sum256([]byte(noteToSelfAvatar.String()))
 
@@ -1836,6 +1869,19 @@ func (portal *Portal) UpdateDMInfo(ctx context.Context, forceSave bool) {
 	} else if portal.shouldSetDMRoomMetadata() {
 		update = portal.updateName(ctx, puppet.Name, nil) || update
 		update = portal.updateAvatarWithMXC(ctx, puppet.AvatarPath, puppet.AvatarHash, puppet.AvatarURL) || update
+	} else {
+		// Clear name/avatar if they're set in a DM that shouldn't have them set
+		if portal.Name != "" && portal.NameSet {
+			update = portal.updateName(ctx, "", nil) || update
+		}
+		// Avatar is currently never set in PNI portals
+		//if !portal.AvatarURL.IsEmpty() && portal.AvatarSet {
+		//	update = true
+		//	portal.AvatarURL = id.ContentURI{}
+		//	portal.AvatarHash = ""
+		//	portal.AvatarPath = ""
+		//	portal.updateAvatarInRoom(ctx, nil)
+		//}
 	}
 	topic := PrivateChatTopic
 	if portal.bridge.Config.Bridge.NumberInTopic && puppet.Number != "" {
@@ -1851,6 +1897,40 @@ func (portal *Portal) UpdateDMInfo(ctx context.Context, forceSave bool) {
 	}
 }
 
+func (portal *Portal) UpdatePNIDMInfo(ctx context.Context, user *User) {
+	portalUserID := portal.UserID()
+	if portalUserID.Type != libsignalgo.ServiceIDTypePNI {
+		return
+	}
+	log := zerolog.Ctx(ctx)
+	update := false
+	recipient, err := user.Client.Store.RecipientStore.LoadAndUpdateRecipient(ctx, uuid.Nil, portalUserID.UUID, nil)
+	if err != nil {
+		log.Err(err).Msg("Failed to get PNI DM recipient entry")
+	}
+	if recipient == nil {
+		recipient = &types.Recipient{PNI: portalUserID.UUID}
+	}
+	topic := PrivateChatTopic
+	name := portalUserID.UUID.String()
+	if recipient.E164 != "" {
+		topic = fmt.Sprintf("%s with %s", topic, recipient.E164)
+		name = recipient.E164
+	}
+	if recipient.ContactName != "" {
+		name = recipient.ContactName
+	}
+	update = portal.updateTopic(ctx, topic, nil) || update
+	update = portal.updateName(ctx, name, nil) || update
+	if update {
+		err = portal.Update(ctx)
+		if err != nil {
+			log.Err(err).Msg("Failed to save portal in database after updating group info")
+		}
+		portal.UpdateBridgeInfo(ctx)
+	}
+}
+
 func (portal *Portal) updatePowerLevelsAndJoinRule(ctx context.Context, info *signalmeow.Group, members map[id.UserID]int) {
 	log := zerolog.Ctx(ctx).With().
 		Str("function", "updatePowerLevelsAndJoinRule").
@@ -2311,6 +2391,7 @@ func (portal *Portal) SyncParticipants(ctx context.Context, source *User, info *
 			}
 		}
 	}
+	portal.CleanupIfEmpty(ctx)
 	return userIDs
 }
 
@@ -2379,6 +2460,19 @@ func (portal *Portal) Delete() {
 		portal.log.Err(err).Msg("Failed to delete portal from db")
 	}
 	portal.bridge.portalsLock.Lock()
+	portal.unlockedDeleteCache()
+	portal.bridge.portalsLock.Unlock()
+}
+
+func (portal *Portal) unlockedDelete() {
+	err := portal.Portal.Delete(context.TODO())
+	if err != nil {
+		portal.log.Err(err).Msg("Failed to delete portal from db")
+	}
+	portal.unlockedDeleteCache()
+}
+
+func (portal *Portal) unlockedDeleteCache() {
 	delete(portal.bridge.portalsByID, portal.PortalKey)
 	if len(portal.MXID) > 0 {
 		delete(portal.bridge.portalsByMXID, portal.MXID)
@@ -2395,7 +2489,6 @@ func (portal *Portal) Delete() {
 			user.RemoveInSpaceCache(portal.PortalKey)
 		}
 	}
-	portal.bridge.portalsLock.Unlock()
 }
 
 func (portal *Portal) Cleanup(ctx context.Context, puppetsOnly bool) {
@@ -2440,3 +2533,427 @@ func (br *SignalBridge) CleanupRoom(ctx context.Context, log *zerolog.Logger, in
 		log.Err(err).Msg("Failed to leave room while cleaning up portal")
 	}
 }
+
+func (portal *Portal) HandleMatrixLeave(brSender bridge.User, evt *event.Event) {
+	log := portal.log.With().
+		Str("action", "handle matrix leave").
+		Stringer("event_id", evt.ID).
+		Str("event_type", evt.Type.String()).
+		Logger()
+	ctx := log.WithContext(context.TODO())
+	sender := brSender.(*User)
+	if portal.IsPrivateChat() {
+		log.Info().Msg("User left private chat portal, cleaning up and deleting...")
+		portal.Delete()
+		portal.Cleanup(ctx, false)
+		return
+	} else if portal.bridge.Config.Bridge.BridgeMatrixLeave {
+		portal.deleteMember(sender, sender.SignalID, evt)
+	}
+	portal.CleanupIfEmpty(ctx)
+}
+func (portal *Portal) HandleMatrixKick(brSender bridge.User, ghost bridge.Ghost, evt *event.Event) {
+	portal.deleteMember(brSender.(*User), ghost.(*Puppet).SignalID, evt)
+}
+func (portal *Portal) deleteMember(sender *User, target uuid.UUID, evt *event.Event) {
+	log := portal.log.With().
+		Str("action", "handle matrix kick/leave").
+		Stringer("event_id", evt.ID).
+		Str("event_type", evt.Type.String()).
+		Logger()
+	ctx := log.WithContext(context.TODO())
+	groupChange := &signalmeow.GroupChange{DeleteMembers: []*uuid.UUID{&target}}
+	revision, err := sender.Client.UpdateGroup(ctx, groupChange, portal.GroupID())
+	if err != nil {
+		log.Err(err).Msg("Error deleting Member from Signal")
+		return
+	}
+	portal.Revision = revision
+	portal.Update(ctx)
+}
+func (portal *Portal) HandleMatrixInvite(brSender bridge.User, brGhost bridge.Ghost, evt *event.Event) {
+	log := portal.log.With().
+		Str("action", "handle matrix invite").
+		Stringer("event_id", evt.ID).
+		Str("event_type", evt.Type.String()).
+		Logger()
+	ctx := log.WithContext(context.TODO())
+	sender := brSender.(*User)
+	puppet := brGhost.(*Puppet)
+	role := signalmeow.GroupMember_DEFAULT
+	levels, err := portal.MainIntent().PowerLevels(ctx, portal.MXID)
+	if err != nil {
+		log.Err(err).Msg("Couldn't get power levels")
+		if levels.GetUserLevel(puppet.IntentFor(portal).UserID) >= 50 {
+			role = signalmeow.GroupMember_ADMINISTRATOR
+		}
+	}
+	groupChange := &signalmeow.GroupChange{AddMembers: []*signalmeow.AddMember{{
+		GroupMember: signalmeow.GroupMember{
+			UserID: puppet.SignalID,
+			Role:   role,
+		},
+	}}}
+	revision, err := sender.Client.UpdateGroup(ctx, groupChange, portal.GroupID())
+	if err != nil {
+		log.Err(err).Msg("Error inviting user on Signal")
+	}
+	puppet.IntentFor(portal).EnsureJoined(ctx, portal.MXID)
+	portal.Revision = revision
+	portal.Update(ctx)
+}
+
+func (portal *Portal) HandleMatrixAcceptKnock(brSender bridge.User, brGhost bridge.Ghost, evt *event.Event) {
+	log := portal.log.With().
+		Str("action", "handle matrix accept knock").
+		Stringer("event_id", evt.ID).
+		Str("event_type", evt.Type.String()).
+		Logger()
+	ctx := log.WithContext(context.TODO())
+	sender := brSender.(*User)
+	puppet := brGhost.(*Puppet)
+	role := signalmeow.GroupMember_DEFAULT
+	levels, err := portal.MainIntent().PowerLevels(ctx, portal.MXID)
+	if err != nil {
+		log.Err(err).Msg("Couldn't get power levels")
+		if levels.GetUserLevel(puppet.IntentFor(portal).UserID) >= 50 {
+			role = signalmeow.GroupMember_ADMINISTRATOR
+		}
+	}
+	groupChange := &signalmeow.GroupChange{PromoteRequestingMembers: []*signalmeow.RoleMember{{
+		UserID: puppet.SignalID,
+		Role:   role,
+	}}}
+	revision, err := sender.Client.UpdateGroup(ctx, groupChange, portal.GroupID())
+	if err != nil {
+		log.Err(err).Msg("Error accepting join request on Signal")
+	}
+	portal.Revision = revision
+	portal.Update(ctx)
+}
+
+func (portal *Portal) HandleMatrixRejectKnock(brSender bridge.User, brGhost bridge.Ghost, evt *event.Event) {
+	portal.removeRequestingMember(brSender.(*User), brGhost.(*Puppet).SignalID, evt)
+}
+
+func (portal *Portal) HandleMatrixRetractKnock(brSender bridge.User, evt *event.Event) {
+	portal.removeRequestingMember(brSender.(*User), brSender.(*User).SignalID, evt)
+}
+
+func (portal *Portal) removeRequestingMember(sender *User, target uuid.UUID, evt *event.Event) {
+	log := portal.log.With().
+		Str("action", "handle matrix knock -> leave").
+		Stringer("event_id", evt.ID).
+		Str("event_type", evt.Type.String()).
+		Logger()
+	ctx := log.WithContext(context.TODO())
+	groupChange := &signalmeow.GroupChange{DeleteRequestingMembers: []*uuid.UUID{&target}}
+	revision, err := sender.Client.UpdateGroup(ctx, groupChange, portal.GroupID())
+	if err != nil {
+		log.Err(err).Msg("Error removing requesting member")
+	}
+	portal.Revision = revision
+	portal.Update(ctx)
+}
+
+func (portal *Portal) HandleMatrixKnock(brSender bridge.User, evt *event.Event) {
+	log := portal.log.With().
+		Str("action", "handle matrix knock").
+		Stringer("event_id", evt.ID).
+		Str("event_type", evt.Type.String()).
+		Logger()
+	log.Debug().Msg("Knocks aren't implemented yet :(")
+}
+
+func (portal *Portal) HandleMatrixBan(brSender bridge.User, brGhost bridge.Ghost, evt *event.Event) {
+	log := portal.log.With().
+		Str("action", "handle matrix ban").
+		Stringer("event_id", evt.ID).
+		Str("event_type", evt.Type.String()).
+		Logger()
+	ctx := log.WithContext(context.TODO())
+	sender := brSender.(*User)
+	puppet := brGhost.(*Puppet)
+	groupChange := &signalmeow.GroupChange{AddBannedMembers: []*signalmeow.BannedMember{{
+		UserID:    puppet.SignalID,
+		Timestamp: uint64(time.Now().UnixMilli()),
+	}}}
+	switch prevMembership := evt.Unsigned.PrevContent.AsMember().Membership; prevMembership {
+	case event.MembershipJoin:
+		groupChange.DeleteMembers = []*uuid.UUID{&puppet.SignalID}
+	case event.MembershipKnock:
+		groupChange.DeleteRequestingMembers = []*uuid.UUID{&puppet.SignalID}
+	case event.MembershipInvite:
+		groupChange.DeletePendingMembers = []*uuid.UUID{&puppet.SignalID}
+	}
+	revision, err := sender.Client.UpdateGroup(ctx, groupChange, portal.GroupID())
+	if err != nil {
+		log.Err(err).Msg("Error banning on Signal")
+	}
+	portal.Revision = revision
+	portal.Update(ctx)
+}
+
+func (portal *Portal) HandleMatrixUnban(brSender bridge.User, brGhost bridge.Ghost, evt *event.Event) {
+	log := portal.log.With().
+		Str("action", "handle matrix unban").
+		Stringer("event_id", evt.ID).
+		Str("event_type", evt.Type.String()).
+		Logger()
+	ctx := log.WithContext(context.TODO())
+	sender := brSender.(*User)
+	puppet := brGhost.(*Puppet)
+	groupChange := &signalmeow.GroupChange{DeleteBannedMembers: []*uuid.UUID{&puppet.SignalID}}
+	revision, err := sender.Client.UpdateGroup(ctx, groupChange, portal.GroupID())
+	if err != nil {
+		log.Err(err).Msg("Error unbanning on Signal")
+	}
+	portal.Revision = revision
+	portal.Update(ctx)
+}
+
+func (portal *Portal) HandleMatrixPowerLevels(brSender bridge.User, evt *event.Event) {
+	log := portal.log.With().
+		Str("action", "handle matrix power levels").
+		Stringer("event_id", evt.ID).
+		Str("event_type", evt.Type.String()).
+		Logger()
+	ctx := log.WithContext(context.TODO())
+	sender := brSender.(*User)
+	if !sender.IsLoggedIn() {
+		log.Warn().Msg("Can't change power levels: user is not logged in")
+		return
+	}
+	evt.Content.ParseRaw(event.StatePowerLevels)
+	levels := evt.Content.AsPowerLevels()
+	var prevLevels *event.PowerLevelsEventContent
+	if evt.Unsigned.PrevContent != nil {
+		evt.Unsigned.PrevContent.ParseRaw(event.StatePowerLevels)
+		prevLevels = evt.Unsigned.PrevContent.AsPowerLevels()
+	} else {
+		prevLevels = &event.PowerLevelsEventContent{}
+	}
+	groupChange := &signalmeow.GroupChange{}
+	var role signalmeow.GroupMemberRole
+	for user, level := range levels.Users {
+		prevLevel := prevLevels.GetUserLevel(user)
+		if (level >= 50 && prevLevel < 50) || (level < 50 && prevLevel >= 50) {
+			puppet := portal.bridge.GetPuppetByMXID(user)
+			if puppet == nil {
+				log.Warn().Stringer("mxid", user).Msg("Couldn't get puppet for power level change")
+				continue
+			}
+			role = signalmeow.GroupMember_DEFAULT
+			if level >= 50 {
+				role = signalmeow.GroupMember_ADMINISTRATOR
+			}
+			groupChange.ModifyMemberRoles = append(groupChange.ModifyMemberRoles, &signalmeow.RoleMember{
+				UserID: puppet.SignalID,
+				Role:   role,
+			})
+		}
+	}
+	if levels.EventsDefault >= 50 && prevLevels.EventsDefault < 50 {
+		announcementsOnly := true
+		groupChange.ModifyAnnouncementsOnly = &announcementsOnly
+	} else if levels.EventsDefault < 50 && prevLevels.EventsDefault >= 50 {
+		announcementsOnly := false
+		groupChange.ModifyAnnouncementsOnly = &announcementsOnly
+	}
+	if levels.StateDefault() >= 50 && prevLevels.StateDefault() < 50 {
+		attributesAccess := signalmeow.AccessControl_ADMINISTRATOR
+		groupChange.ModifyAttributesAccess = &attributesAccess
+	} else if levels.StateDefault() < 50 && prevLevels.StateDefault() >= 50 {
+		attributesAccess := signalmeow.AccessControl_MEMBER
+		groupChange.ModifyAttributesAccess = &attributesAccess
+	}
+	if levels.Invite() >= 50 && prevLevels.Invite() < 50 {
+		memberAccess := signalmeow.AccessControl_ADMINISTRATOR
+		groupChange.ModifyMemberAccess = &memberAccess
+	} else if levels.Invite() < 50 && prevLevels.Invite() >= 50 {
+		memberAccess := signalmeow.AccessControl_MEMBER
+		groupChange.ModifyMemberAccess = &memberAccess
+	}
+	revision, err := sender.Client.UpdateGroup(ctx, groupChange, portal.GroupID())
+	if err != nil {
+		log.Err(err).Msg("Error changing group access control")
+		return
+	}
+	portal.Revision = revision
+	portal.Update(ctx)
+}
+
+func (portal *Portal) HandleMatrixJoinRule(brSender bridge.User, evt *event.Event) {
+	log := portal.log.With().
+		Str("action", "handle matrix join rule").
+		Stringer("event_id", evt.ID).
+		Str("event_type", evt.Type.String()).
+		Logger()
+	ctx := log.WithContext(context.TODO())
+	sender := brSender.(*User)
+	if !sender.IsLoggedIn() {
+		log.Warn().Msg("Can't change join rule: user is not logged in")
+		return
+	}
+	evt.Content.ParseRaw(event.StateJoinRules)
+	joinRule := evt.Content.AsJoinRules().JoinRule
+	groupChange := &signalmeow.GroupChange{}
+	addFromInviteLinkAccess := signalmeow.AccessControl_UNSATISFIABLE
+	if joinRule == event.JoinRuleKnock {
+		addFromInviteLinkAccess = signalmeow.AccessControl_ADMINISTRATOR
+	} else if joinRule == event.JoinRulePublic {
+		addFromInviteLinkAccess = signalmeow.AccessControl_ANY
+	}
+	groupChange.ModifyAddFromInviteLinkAccess = &addFromInviteLinkAccess
+	revision, err := sender.Client.UpdateGroup(ctx, groupChange, portal.GroupID())
+	if err != nil {
+		log.Err(err).Msg("Error updating group access control")
+		return
+	}
+	portal.Revision = revision
+	portal.Update(ctx)
+}
+
+func (portal *Portal) HandleMatrixMeta(brSender bridge.User, evt *event.Event) {
+	log := portal.log.With().
+		Str("action", "handle matrix meta").
+		Stringer("event_id", evt.ID).
+		Str("event_type", evt.Type.String()).
+		Logger()
+	ctx := log.WithContext(context.TODO())
+	sender := brSender.(*User)
+	if !sender.IsLoggedIn() {
+		log.Warn().Msg("Can't change room info: user is not logged in")
+		return
+	}
+
+	var err error
+	groupChange := &signalmeow.GroupChange{Revision: portal.Revision + 1}
+	var avatarPath *string
+	var avatarHash string
+	var avatarURL id.ContentURI
+	var avatarChanged bool
+	switch content := evt.Content.Parsed.(type) {
+	case *event.RoomNameEventContent:
+		if content.Name == portal.Name {
+			return
+		}
+		portal.Name = content.Name
+		groupChange.ModifyTitle = &content.Name
+	case *event.TopicEventContent:
+		if content.Topic == portal.Topic {
+			return
+		}
+		portal.Topic = content.Topic
+		groupChange.ModifyDescription = &content.Topic
+	case *event.RoomAvatarEventContent:
+		if content.URL == portal.AvatarURL {
+			return
+		}
+		var data []byte
+		if !content.URL.IsEmpty() {
+			data, err = portal.MainIntent().DownloadBytes(ctx, content.URL)
+			if err != nil {
+				log.Err(err).Stringer("Failed to download updated avatar %s", content.URL)
+				return
+			}
+			log.Debug().Stringers("%s set the group avatar to %s", []fmt.Stringer{sender.MXID, content.URL})
+		} else {
+			log.Debug().Stringer("%s removed the group avatar", sender.MXID)
+		}
+		avatarPath, err = sender.Client.UploadGroupAvatar(ctx, data, portal.GroupID())
+		if err != nil {
+			log.Err(err).Msg("Failed to upload group avatar")
+			return
+		}
+		groupChange.ModifyAvatar = avatarPath
+		hash := sha256.Sum256(data)
+		avatarHash = hex.EncodeToString(hash[:])
+		avatarChanged = true
+		avatarURL = content.URL
+	}
+	revision, err := sender.Client.UpdateGroup(ctx, groupChange, portal.GroupID())
+	if err != nil {
+		log.Err(err).Msg("Error updating group attributes")
+		return
+	}
+	if avatarChanged {
+		log.Debug().Msg("Successfully updated group avatar")
+		portal.AvatarSet = true
+		portal.AvatarPath = *avatarPath
+		portal.AvatarHash = avatarHash
+		portal.AvatarURL = avatarURL
+		portal.UpdateBridgeInfo(ctx)
+	}
+	portal.Revision = revision
+	portal.Update(ctx)
+	log.Info().Msg("finished updating group")
+}
+
+func (portal *Portal) CleanupIfEmpty(ctx context.Context) {
+	log := portal.log.With().
+		Str("action", "Clean up if empty").
+		Logger()
+	users, err := portal.GetMatrixUsers(ctx)
+	if err != nil {
+		log.Err(err).Msg("Failed to get Matrix user list to determine if portal needs to be cleaned up")
+		return
+	}
+
+	if len(users) == 0 {
+		log.Info().Msg("Room seems to be empty, cleaning up...")
+		portal.Delete()
+		portal.Cleanup(ctx, false)
+	}
+}
+
+func (portal *Portal) GetMatrixUsers(ctx context.Context) ([]id.UserID, error) {
+	members, err := portal.MainIntent().JoinedMembers(ctx, portal.MXID)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get member list: %w", err)
+	}
+	var users []id.UserID
+	for userID := range members.Joined {
+		_, isPuppet := portal.bridge.ParsePuppetMXID(userID)
+		if !isPuppet && userID != portal.bridge.Bot.UserID {
+			users = append(users, userID)
+		}
+	}
+	return users, nil
+}
+
+func (portal *Portal) GetInviteLink(ctx context.Context, source *User) (string, error) {
+	info, err := source.Client.RetrieveGroupByID(ctx, portal.GroupID(), portal.Revision)
+	if err != nil {
+		zerolog.Ctx(ctx).Err(err).
+			Stringer("source_user_id", source.MXID).
+			Msg("Failed to fetch group info")
+		return "", err
+	}
+	inviteLinkPassword, err := info.GetInviteLink()
+	if err != nil {
+		zerolog.Ctx(ctx).Err(err).Msg("Failed to get invite link")
+	}
+	return inviteLinkPassword, nil
+}
+
+func (portal *Portal) ResetInviteLink(ctx context.Context, source *User) error {
+	inviteLinkPassword := signalmeow.GenerateInviteLinkPassword()
+	groupChange := &signalmeow.GroupChange{ModifyInviteLinkPassword: &inviteLinkPassword}
+	revision, err := source.Client.UpdateGroup(ctx, groupChange, portal.GroupID())
+	if err != nil {
+		zerolog.Ctx(ctx).Err(err).Msg("Error setting invite link password")
+		return err
+	}
+	portal.Revision = revision
+	return portal.Update(ctx)
+}
+
+func (portal *Portal) GetEncryptionEventContent() (evt *event.EncryptionEventContent) {
+	evt = &event.EncryptionEventContent{Algorithm: id.AlgorithmMegolmV1}
+	if rot := portal.bridge.Config.Bridge.Encryption.Rotation; rot.EnableCustom {
+		evt.RotationPeriodMillis = rot.Milliseconds
+		evt.RotationPeriodMessages = rot.Messages
+	}
+	return
+}
diff --git a/provisioning.go b/provisioning.go
index 117fa1b8..211bd46b 100644
--- a/provisioning.go
+++ b/provisioning.go
@@ -36,7 +36,9 @@ import (
 	"maunium.net/go/mautrix"
 	"maunium.net/go/mautrix/id"
 
+	"go.mau.fi/mautrix-signal/pkg/libsignalgo"
 	"go.mau.fi/mautrix-signal/pkg/signalmeow"
+	"go.mau.fi/mautrix-signal/pkg/signalmeow/types"
 )
 
 type provisioningContextKey int
@@ -143,10 +145,10 @@ type WhoAmIResponseSignal struct {
 }
 
 type ResolveIdentifierResponse struct {
-	RoomID      id.RoomID                          `json:"room_id"`
-	ChatID      ResolveIdentifierResponseChatID    `json:"chat_id"`
-	JustCreated bool                               `json:"just_created"`
-	OtherUser   ResolveIdentifierResponseOtherUser `json:"other_user"`
+	RoomID      id.RoomID                           `json:"room_id"`
+	ChatID      ResolveIdentifierResponseChatID     `json:"chat_id"`
+	JustCreated bool                                `json:"just_created"`
+	OtherUser   *ResolveIdentifierResponseOtherUser `json:"other_user,omitempty"`
 }
 
 type ResolveIdentifierResponseChatID struct {
@@ -169,37 +171,56 @@ func (prov *ProvisioningAPI) resolveIdentifier(ctx context.Context, user *User,
 		return http.StatusBadRequest, nil, fmt.Errorf("error parsing phone number: %w", err)
 	}
 	e164String := fmt.Sprintf("+%d", e164Number)
-	var targetUUID uuid.UUID
-	if contact, err := user.Client.ContactByE164(ctx, e164String); err != nil {
+	var aci, pni uuid.UUID
+	var recipient *types.Recipient
+	if recipient, err = user.Client.ContactByE164(ctx, e164String); err != nil {
 		return http.StatusInternalServerError, nil, fmt.Errorf("error looking up number in local contact list: %w", err)
-	} else if contact != nil {
-		targetUUID = contact.UUID
+	} else if recipient != nil {
+		aci = recipient.ACI
+		pni = recipient.PNI
 	} else if resp, err := user.Client.LookupPhone(ctx, e164Number); err != nil {
 		return http.StatusInternalServerError, nil, fmt.Errorf("error looking up number on server: %w", err)
-	} else if resp[e164Number].ACI != uuid.Nil {
-		targetUUID = resp[e164Number].ACI
-		err = user.Client.Store.ContactStore.UpdatePhone(ctx, targetUUID, e164String)
+	} else {
+		aci = resp[e164Number].ACI
+		pni = resp[e164Number].PNI
+		if aci == uuid.Nil && pni == uuid.Nil {
+			return http.StatusNotFound, nil, errors.New("user not found on Signal")
+		}
+		recipient, err = user.Client.Store.RecipientStore.UpdateRecipientE164(ctx, aci, pni, e164String)
 		if err != nil {
-			zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to update phone number in user's contact store")
+			zerolog.Ctx(ctx).Err(err).Msg("Failed to save recipient entry after looking up phone")
+		}
+		aci, pni = recipient.ACI, recipient.PNI
+	}
+	zerolog.Ctx(ctx).Debug().
+		Uint64("e164", e164Number).
+		Stringer("aci", aci).
+		Stringer("pni", pni).
+		Msg("Found DM target user")
+
+	var targetServiceID libsignalgo.ServiceID
+	var otherUserInfo *ResolveIdentifierResponseOtherUser
+	if aci != uuid.Nil {
+		targetServiceID = libsignalgo.NewACIServiceID(aci)
+		puppet := prov.bridge.GetPuppetBySignalID(aci)
+		otherUserInfo = &ResolveIdentifierResponseOtherUser{
+			MXID:        puppet.MXID.String(),
+			DisplayName: puppet.Name,
+			AvatarURL:   puppet.AvatarURL.String(),
 		}
 	} else {
-		return http.StatusNotFound, nil, errors.New("user not found on Signal")
+		targetServiceID = libsignalgo.NewPNIServiceID(pni)
+		// TODO fill other user displayname/avatar if there's a contact entry?
 	}
-
-	portal := user.GetPortalByChatID(targetUUID.String())
-	puppet := prov.bridge.GetPuppetBySignalID(targetUUID)
+	portal := user.GetPortalByChatID(targetServiceID.String())
 
 	return http.StatusOK, &ResolveIdentifierResponse{
 		RoomID: portal.MXID,
 		ChatID: ResolveIdentifierResponseChatID{
-			UUID:   targetUUID.String(),
+			UUID:   targetServiceID.String(),
 			Number: e164String,
 		},
-		OtherUser: ResolveIdentifierResponseOtherUser{
-			MXID:        puppet.MXID.String(),
-			DisplayName: puppet.Name,
-			AvatarURL:   puppet.AvatarURL.String(),
-		},
+		OtherUser: otherUserInfo,
 	}, nil
 }
 
diff --git a/puppet.go b/puppet.go
index 1ecc2409..f58befa8 100644
--- a/puppet.go
+++ b/puppet.go
@@ -53,14 +53,14 @@ func (br *SignalBridge) GetPuppetBySignalIDString(id string) *Puppet {
 }
 
 func (br *SignalBridge) GetPuppetBySignalID(id uuid.UUID) *Puppet {
-	br.puppetsLock.Lock()
-	defer br.puppetsLock.Unlock()
-
 	if id == uuid.Nil {
 		br.ZLog.Warn().Msg("Trying to get puppet with empty signal_user_id")
 		return nil
 	}
 
+	br.puppetsLock.Lock()
+	defer br.puppetsLock.Unlock()
+
 	puppet, ok := br.puppets[id]
 	if !ok {
 		dbPuppet, err := br.DB.Puppet.GetBySignalID(context.TODO(), id)
@@ -216,7 +216,7 @@ func (puppet *Puppet) CustomIntent() *appservice.IntentAPI {
 
 func (puppet *Puppet) IntentFor(portal *Portal) *appservice.IntentAPI {
 	if puppet != nil {
-		if puppet.customIntent == nil || portal.UserID() == puppet.SignalID {
+		if puppet.customIntent == nil || portal.UserID().UUID == puppet.SignalID {
 			return puppet.DefaultIntent()
 		}
 		return puppet.customIntent
@@ -240,7 +240,7 @@ func (puppet *Puppet) UpdateInfo(ctx context.Context, source *User) {
 	ctx = log.WithContext(ctx)
 	var err error
 	log.Debug().Msg("Fetching contact info to update puppet")
-	info, err := source.Client.ContactByID(ctx, puppet.SignalID)
+	info, err := source.Client.ContactByACI(ctx, puppet.SignalID)
 	if err != nil {
 		log.Err(err).Msg("Failed to fetch contact info")
 		return
@@ -311,7 +311,7 @@ func (puppet *Puppet) updatePortalMeta(ctx context.Context) {
 	}
 }
 
-func (puppet *Puppet) updateAvatar(ctx context.Context, source *User, info *types.Contact) bool {
+func (puppet *Puppet) updateAvatar(ctx context.Context, source *User, info *types.Recipient) bool {
 	var avatarData []byte
 	var avatarContentType string
 	log := zerolog.Ctx(ctx)
@@ -390,7 +390,7 @@ func (puppet *Puppet) updateAvatar(ctx context.Context, source *User, info *type
 	return true
 }
 
-func (puppet *Puppet) updateName(ctx context.Context, contact *types.Contact) bool {
+func (puppet *Puppet) updateName(ctx context.Context, contact *types.Recipient) bool {
 	// TODO set name quality
 	newName := puppet.bridge.Config.Bridge.FormatDisplayname(contact)
 	if puppet.NameSet && puppet.Name == newName {
diff --git a/user.go b/user.go
index ab36113e..db5bd4cc 100644
--- a/user.go
+++ b/user.go
@@ -19,6 +19,7 @@ package main
 import (
 	"context"
 	"errors"
+	"fmt"
 	"net/http"
 	"strings"
 	"sync"
@@ -36,6 +37,7 @@ import (
 	"maunium.net/go/mautrix/id"
 
 	"go.mau.fi/mautrix-signal/database"
+	"go.mau.fi/mautrix-signal/pkg/libsignalgo"
 	"go.mau.fi/mautrix-signal/pkg/signalmeow"
 	"go.mau.fi/mautrix-signal/pkg/signalmeow/events"
 	signalpb "go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf"
@@ -746,14 +748,107 @@ func (user *User) handleReadSelf(evt *events.ReadSelf) {
 func (user *User) handleContactList(evt *events.ContactList) {
 	ctx := user.log.With().Str("action", "handle contact list").Logger().WithContext(context.TODO())
 	for _, contact := range evt.Contacts {
-		puppet := user.bridge.GetPuppetBySignalID(contact.UUID)
+		if contact.ACI == uuid.Nil {
+			continue
+		}
+		puppet := user.bridge.GetPuppetBySignalID(contact.ACI)
 		if puppet == nil {
-			return
+			continue
 		}
 		puppet.UpdateInfo(ctx, user)
 	}
 }
 
+func (user *User) handleACIFound(evt *events.ACIFound) {
+	log := user.log.With().
+		Str("action", "handle aci found").
+		Stringer("aci", evt.ACI.UUID).
+		Stringer("pni", evt.PNI.UUID).
+		Logger()
+	log.Debug().Msg("Handling ACI found event")
+	defer func() {
+		log.Debug().Msg("Finished handling ACI found event")
+	}()
+	ctx := log.WithContext(context.TODO())
+	user.bridge.portalsLock.Lock()
+	defer user.bridge.portalsLock.Unlock()
+	pniPortal := user.bridge.unlockedGetPortalByChatID(database.PortalKey{
+		ChatID:   evt.PNI.String(),
+		Receiver: user.SignalID,
+	}, false)
+	if pniPortal == nil {
+		log.Debug().Msg("PNI portal doesn't exist, ignoring event")
+		return
+	}
+	pniPortal.roomCreateLock.Lock()
+	defer pniPortal.roomCreateLock.Unlock()
+	if pniPortal.MXID == "" {
+		log.Info().Msg("PNI portal doesn't have Matrix room, deleting row")
+		pniPortal.unlockedDelete()
+		return
+	}
+	log.UpdateContext(func(c zerolog.Context) zerolog.Context {
+		return c.Stringer("pni_portal_mxid", pniPortal.MXID)
+	})
+	aciPortal := user.bridge.unlockedGetPortalByChatID(database.PortalKey{
+		ChatID:   evt.ACI.String(),
+		Receiver: user.SignalID,
+	}, false)
+	if aciPortal == nil {
+		log.Info().Msg("ACI portal doesn't exist, re-ID'ing PNI portal")
+		err := pniPortal.unlockedReID(ctx, evt.ACI.String())
+		if err != nil {
+			log.Err(err).Msg("Failed to re-ID PNI portal")
+		} else {
+			go pniPortal.PostReIDUpdate(ctx, user)
+		}
+		return
+	}
+	aciPortal.roomCreateLock.Lock()
+	defer aciPortal.roomCreateLock.Unlock()
+	if aciPortal.MXID == "" {
+		log.Info().Msg("ACI portal row exists, but doesn't have a Matrix room. Deleting ACI portal row and re-ID'ing PNI portal")
+		aciPortal.unlockedDelete()
+		err := pniPortal.unlockedReID(ctx, evt.ACI.String())
+		if err != nil {
+			log.Err(err).Msg("Failed to re-ID PNI portal")
+		} else {
+			go pniPortal.PostReIDUpdate(ctx, user)
+		}
+	} else {
+		log.UpdateContext(func(c zerolog.Context) zerolog.Context {
+			return c.Stringer("aci_portal_mxid", aciPortal.MXID)
+		})
+		log.Info().Msg("Both ACI and PNI portal have Matrix room, tombstoning PNI portal")
+		pniPortal.unlockedDelete()
+		go func() {
+			_, err := pniPortal.MainIntent().SendStateEvent(ctx, pniPortal.MXID, event.StateTombstone, "", &event.TombstoneEventContent{
+				Body:            fmt.Sprintf("This room has been merged"),
+				ReplacementRoom: aciPortal.MXID,
+			})
+			if err != nil {
+				log.Err(err).Msg("Failed to send tombstone to PNI portal")
+			}
+			pniPortal.Cleanup(ctx, err == nil)
+		}()
+	}
+}
+
+func (portal *Portal) unlockedReID(ctx context.Context, newID string) error {
+	err := portal.Portal.ReID(ctx, newID)
+	if err != nil {
+		return err
+	}
+	delete(portal.bridge.portalsByID, portal.PortalKey)
+	portal.PortalKey.ChatID = newID
+	portal.bridge.portalsByID[portal.PortalKey] = portal
+	err = portal.MainIntent().EnsureJoined(ctx, portal.MXID, appservice.EnsureJoinedParams{IgnoreCache: true})
+	if err != nil {
+		return fmt.Errorf("failed to ensure ghost is joined to portal: %w", err)
+	}
+	return nil
+}
+
 func (user *User) eventHandler(rawEvt events.SignalEvent) {
 	switch evt := rawEvt.(type) {
 	case *events.ChatEvent:
@@ -781,17 +876,25 @@ func (user *User) eventHandler(rawEvt events.SignalEvent) {
 		portal.sendMainIntentMessage(context.TODO(), content)
 	case *events.ContactList:
 		user.handleContactList(evt)
+	case *events.ACIFound:
+		user.handleACIFound(evt)
 	default:
 		user.log.Warn().Type("event_type", evt).Msg("Unrecognized event type from signalmeow")
 	}
 }
 
 func (user *User) GetPortalByChatID(signalID string) *Portal {
-	pk := database.PortalKey{
+	return user.bridge.GetPortalByChatID(database.PortalKey{
 		ChatID:   signalID,
 		Receiver: user.SignalID,
-	}
-	return user.bridge.GetPortalByChatID(pk)
+	})
+}
+
+func (user *User) GetPortalByChatIDIfExists(signalID string) *Portal {
+	return user.bridge.GetPortalByChatIDIfExists(database.PortalKey{
+		ChatID:   signalID,
+		Receiver: user.SignalID,
+	})
 }
 
 func (user *User) disconnectNoLock() (*signalmeow.Client, error) {
@@ -892,8 +995,9 @@ func (user *User) getDirectChats() map[id.UserID][]id.RoomID {
 		return chats
 	}
 	for _, portal := range privateChats {
-		if portal.MXID != "" {
-			puppetMXID := user.bridge.FormatPuppetMXID(portal.UserID())
+		portalUserID := portal.UserID()
+		if portal.MXID != "" && portalUserID.Type == libsignalgo.ServiceIDTypeACI {
+			puppetMXID := user.bridge.FormatPuppetMXID(portalUserID.UUID)
 
 			chats[puppetMXID] = []id.RoomID{portal.MXID}
 		}


Test patch:
diff --git a/pkg/libsignalgo/address_test.go b/pkg/libsignalgo/address_test.go
index 4026cb50..b72eb2fb 100644
--- a/pkg/libsignalgo/address_test.go
+++ b/pkg/libsignalgo/address_test.go
@@ -19,6 +19,7 @@ package libsignalgo_test
 import (
 	"testing"
 
+	"github.com/google/uuid"
 	"github.com/stretchr/testify/assert"
 
 	"go.mau.fi/mautrix-signal/pkg/libsignalgo"
@@ -28,12 +29,14 @@ import (
 func TestAddress(t *testing.T) {
 	setupLogging()
 
-	addr, err := libsignalgo.NewPhoneAddress("addr1", 5)
+	testUUID := uuid.New()
+
+	addr, err := libsignalgo.NewPNIServiceID(testUUID).Address(5)
 	assert.NoError(t, err)
 
 	name, err := addr.Name()
 	assert.NoError(t, err)
-	assert.Equal(t, "addr1", name)
+	assert.Equal(t, "PNI:"+testUUID.String(), name)
 
 	deviceID, err := addr.DeviceID()
 	assert.NoError(t, err)
diff --git a/pkg/libsignalgo/groupcipher_test.go b/pkg/libsignalgo/groupcipher_test.go
index f8627051..eda4fdf6 100644
--- a/pkg/libsignalgo/groupcipher_test.go
+++ b/pkg/libsignalgo/groupcipher_test.go
@@ -30,7 +30,7 @@ import (
 func TestGroupCipher(t *testing.T) {
 	ctx := context.TODO()
 
-	sender, err := libsignalgo.NewPhoneAddress("+14159999111", 4)
+	sender, err := libsignalgo.NewACIServiceID(uuid.New()).Address(4)
 	assert.NoError(t, err)
 
 	distributionID, err := uuid.Parse("d1d1d1d1-7000-11eb-b32a-33b8a8a487a6")
diff --git a/pkg/libsignalgo/inmemorystore_test.go b/pkg/libsignalgo/inmemorystore_test.go
index e73c201d..1c95ae98 100644
--- a/pkg/libsignalgo/inmemorystore_test.go
+++ b/pkg/libsignalgo/inmemorystore_test.go
@@ -44,7 +44,7 @@ type InMemorySignalProtocolStore struct {
 	identityKeyPair *libsignalgo.IdentityKeyPair
 	registrationID  uint32
 
-	identityKeyMap  map[AddressKey][]byte
+	identityKeyMap  map[libsignalgo.ServiceID][]byte
 	preKeyMap       map[uint32]*libsignalgo.PreKeyRecord
 	senderKeyMap    map[SenderKeyName]*libsignalgo.SenderKeyRecord
 	sessionMap      map[AddressKey]*libsignalgo.SessionRecord
@@ -67,7 +67,7 @@ func NewInMemorySignalProtocolStore() *InMemorySignalProtocolStore {
 		identityKeyPair: identityKeyPair,
 		registrationID:  uint32(registrationID.Uint64()),
 
-		identityKeyMap:  make(map[AddressKey][]byte),
+		identityKeyMap:  make(map[libsignalgo.ServiceID][]byte),
 		preKeyMap:       make(map[uint32]*libsignalgo.PreKeyRecord),
 		senderKeyMap:    make(map[SenderKeyName]*libsignalgo.SenderKeyRecord),
 		sessionMap:      make(map[AddressKey]*libsignalgo.SessionRecord),
@@ -147,18 +147,10 @@ func (ps *InMemorySignalProtocolStore) GetLocalRegistrationID(ctx context.Contex
 	return ps.registrationID, nil
 }
 
-func (ps *InMemorySignalProtocolStore) SaveIdentityKey(ctx context.Context, address *libsignalgo.Address, identityKey *libsignalgo.IdentityKey) (bool, error) {
+func (ps *InMemorySignalProtocolStore) SaveIdentityKey(ctx context.Context, theirServiceID libsignalgo.ServiceID, identityKey *libsignalgo.IdentityKey) (bool, error) {
 	log.Debug().Msg("SaveIdentityKey called")
-	name, err := address.Name()
-	if err != nil {
-		return false, err
-	}
-	deviceID, err := address.DeviceID()
-	if err != nil {
-		return false, err
-	}
 	replacing := false
-	oldKeySerialized, ok := ps.identityKeyMap[AddressKey{name, deviceID}]
+	oldKeySerialized, ok := ps.identityKeyMap[theirServiceID]
 	if ok {
 		oldKey, err := libsignalgo.DeserializeIdentityKey(oldKeySerialized)
 		if err != nil {
@@ -180,40 +172,22 @@ func (ps *InMemorySignalProtocolStore) SaveIdentityKey(ctx context.Context, addr
 	hexIdentityKey := hex.EncodeToString(serializedIdentityKey)
 	log.Debug().Str("hexIdentityKey", hexIdentityKey).Msg("SaveIdentityKey")
 
-	ps.identityKeyMap[AddressKey{name, deviceID}] = serializedIdentityKey
+	ps.identityKeyMap[theirServiceID] = serializedIdentityKey
 	return replacing, nil
 }
 
-func (ps *InMemorySignalProtocolStore) GetIdentityKey(ctx context.Context, address *libsignalgo.Address) (*libsignalgo.IdentityKey, error) {
+func (ps *InMemorySignalProtocolStore) GetIdentityKey(ctx context.Context, theirServiceID libsignalgo.ServiceID) (*libsignalgo.IdentityKey, error) {
 	log.Debug().Msg("GetIdentityKey called")
-	name, err := address.Name()
-	if err != nil {
-		return nil, err
-	}
-	deviceID, err := address.DeviceID()
-	if err != nil {
-		return nil, err
-	}
-	serializedIdentityKey, ok := ps.identityKeyMap[AddressKey{name, deviceID}]
+	serializedIdentityKey, ok := ps.identityKeyMap[theirServiceID]
 	if !ok {
 		return nil, nil
 	}
 	return libsignalgo.DeserializeIdentityKey(serializedIdentityKey)
 }
 
-func (ps *InMemorySignalProtocolStore) IsTrustedIdentity(ctx context.Context, address *libsignalgo.Address, identityKey *libsignalgo.IdentityKey, direction libsignalgo.SignalDirection) (bool, error) {
+func (ps *InMemorySignalProtocolStore) IsTrustedIdentity(ctx context.Context, theirServiceID libsignalgo.ServiceID, identityKey *libsignalgo.IdentityKey, direction libsignalgo.SignalDirection) (bool, error) {
 	log.Debug().Msg("IsTrustedIdentity called")
-	name, err := address.Name()
-	if err != nil {
-		log.Error().Err(err).Msg("Error getting name")
-		return false, err
-	}
-	deviceID, err := address.DeviceID()
-	if err != nil {
-		log.Error().Err(err).Msg("Error getting deviceID")
-		return false, err
-	}
-	if existingSerialized, ok := ps.identityKeyMap[AddressKey{name, deviceID}]; ok {
+	if existingSerialized, ok := ps.identityKeyMap[theirServiceID]; ok {
 		existingKey, err := libsignalgo.DeserializeIdentityKey(existingSerialized)
 		if err != nil {
 			log.Error().Err(err).Interface("existingKey", existingKey).Msg("Error deserializing existing identity key")
diff --git a/pkg/libsignalgo/session_test.go b/pkg/libsignalgo/session_test.go
index b557dfa6..17ea8c97 100644
--- a/pkg/libsignalgo/session_test.go
+++ b/pkg/libsignalgo/session_test.go
@@ -121,10 +121,12 @@ func initializeSessions(t *testing.T, aliceStore, bobStore *InMemorySignalProtoc
 // From SessionTests.swift:testSessionCipher
 func TestSessionCipher(t *testing.T) {
 	ctx := context.TODO()
+	aliceACI := uuid.New()
+	bobACI := uuid.New()
 
-	aliceAddress, err := libsignalgo.NewPhoneAddress("+14151111111", 1)
+	aliceAddress, err := libsignalgo.NewACIServiceID(aliceACI).Address(1)
 	assert.NoError(t, err)
-	bobAddress, err := libsignalgo.NewPhoneAddress("+14151111112", 1)
+	bobAddress, err := libsignalgo.NewACIServiceID(bobACI).Address(1)
 	assert.NoError(t, err)
 
 	aliceStore := NewInMemorySignalProtocolStore()
@@ -170,9 +172,12 @@ func TestSessionCipher(t *testing.T) {
 func TestSessionCipherWithBadStore(t *testing.T) {
 	ctx := context.TODO()
 
-	aliceAddress, err := libsignalgo.NewPhoneAddress("+14151111111", 1)
+	aliceACI := uuid.New()
+	bobACI := uuid.New()
+
+	aliceAddress, err := libsignalgo.NewACIServiceID(aliceACI).Address(1)
 	assert.NoError(t, err)
-	bobAddress, err := libsignalgo.NewPhoneAddress("+14151111112", 1)
+	bobAddress, err := libsignalgo.NewACIServiceID(bobACI).Address(1)
 	assert.NoError(t, err)
 
 	aliceStore := NewInMemorySignalProtocolStore()
@@ -337,7 +342,9 @@ func TestArchiveSession(t *testing.T) {
 	ctx := context.TODO()
 	setupLogging()
 
-	bobAddress, err := libsignalgo.NewPhoneAddress("+14151111112", 1)
+	bobACI := uuid.New()
+
+	bobAddress, err := libsignalgo.NewACIServiceID(bobACI).Address(1)
 	assert.NoError(t, err)
 
 	aliceStore := NewInMemorySignalProtocolStore()
diff --git a/pkg/signalmeow/store/upgrades/00-latest.sql b/pkg/signalmeow/store/upgrades/00-latest.sql
index 6c7dc4bb..bd376bbf 100644
--- a/pkg/signalmeow/store/upgrades/00-latest.sql
+++ b/pkg/signalmeow/store/upgrades/00-latest.sql
@@ -1,4 +1,4 @@
--- v0 -> v8: Latest revision
+-- v0 -> v15 (compatible with v13+): Latest revision
 CREATE TABLE signalmeow_device (
     aci_uuid              TEXT PRIMARY KEY,
 
@@ -11,94 +11,99 @@ CREATE TABLE signalmeow_device (
 
     device_id             INTEGER NOT NULL,
     number                TEXT    NOT NULL DEFAULT '',
-    password              TEXT    NOT NULL DEFAULT ''
+    password              TEXT    NOT NULL DEFAULT '',
+
+    master_key bytea
 );
 
 CREATE TABLE signalmeow_pre_keys (
-    aci_uuid  TEXT    NOT NULL,
-    key_id    INTEGER NOT NULL,
-    uuid_kind TEXT    NOT NULL,
-    is_signed BOOLEAN NOT NULL,
-    key_pair  bytea   NOT NULL,
-    uploaded  BOOLEAN NOT NULL,
-
-    PRIMARY KEY (aci_uuid, uuid_kind, is_signed, key_id),
-    FOREIGN KEY (aci_uuid) REFERENCES signalmeow_device (aci_uuid) ON DELETE CASCADE ON UPDATE CASCADE
+    account_id TEXT    NOT NULL,
+    service_id TEXT    NOT NULL,
+    key_id     INTEGER NOT NULL,
+    is_signed  BOOLEAN NOT NULL,
+    key_pair   bytea   NOT NULL,
+
+    PRIMARY KEY (account_id, service_id, key_id, is_signed),
+    FOREIGN KEY (account_id) REFERENCES signalmeow_device (aci_uuid) ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+CREATE TABLE signalmeow_kyber_pre_keys (
+    account_id     TEXT    NOT NULL,
+    service_id     TEXT    NOT NULL,
+    key_id         INTEGER NOT NULL,
+    key_pair       bytea   NOT NULL,
+    is_last_resort BOOLEAN NOT NULL,
+
+    PRIMARY KEY (account_id, service_id, key_id),
+    FOREIGN KEY (account_id) REFERENCES signalmeow_device (aci_uuid) ON DELETE CASCADE ON UPDATE CASCADE
 );
 
 CREATE TABLE signalmeow_identity_keys (
-    our_aci_uuid    TEXT    NOT NULL,
-    their_aci_uuid  TEXT    NOT NULL,
-    their_device_id INTEGER NOT NULL,
-    key             bytea   NOT NULL,
-    trust_level     TEXT    NOT NULL,
-
-    PRIMARY KEY (our_aci_uuid, their_aci_uuid, their_device_id),
-    FOREIGN KEY (our_aci_uuid) REFERENCES signalmeow_device (aci_uuid) ON DELETE CASCADE ON UPDATE CASCADE
+    account_id       TEXT    NOT NULL,
+    their_service_id TEXT    NOT NULL,
+    key              bytea   NOT NULL,
+    trust_level      TEXT    NOT NULL,
+
+    PRIMARY KEY (account_id, their_service_id),
+    FOREIGN KEY (account_id) REFERENCES signalmeow_device (aci_uuid) ON DELETE CASCADE ON UPDATE CASCADE
 );
 
 CREATE TABLE signalmeow_sessions (
-    our_aci_uuid    TEXT    NOT NULL,
-    their_aci_uuid  TEXT    NOT NULL,
-    their_device_id INTEGER NOT NULL,
-    record          bytea   NOT NULL,
-
-    PRIMARY KEY (our_aci_uuid, their_aci_uuid, their_device_id),
-    FOREIGN KEY (our_aci_uuid) REFERENCES signalmeow_device (aci_uuid) ON DELETE CASCADE ON UPDATE CASCADE
+    account_id       TEXT    NOT NULL,
+    service_id       TEXT    NOT NULL,
+    their_service_id TEXT    NOT NULL,
+    their_device_id  INTEGER NOT NULL,
+    record           bytea   NOT NULL,
+
+    PRIMARY KEY (account_id, service_id, their_service_id, their_device_id),
+    FOREIGN KEY (account_id) REFERENCES signalmeow_device (aci_uuid) ON DELETE CASCADE ON UPDATE CASCADE
 );
 
 CREATE TABLE signalmeow_profile_keys (
-    our_aci_uuid   TEXT  NOT NULL,
+    account_id     TEXT  NOT NULL,
     their_aci_uuid TEXT  NOT NULL,
     key            bytea NOT NULL,
 
-    PRIMARY KEY (our_aci_uuid, their_aci_uuid),
-    FOREIGN KEY (our_aci_uuid) REFERENCES signalmeow_device (aci_uuid) ON DELETE CASCADE ON UPDATE CASCADE
+    PRIMARY KEY (account_id, their_aci_uuid),
+    FOREIGN KEY (account_id) REFERENCES signalmeow_device (aci_uuid) ON DELETE CASCADE ON UPDATE CASCADE
 );
 
 CREATE TABLE signalmeow_sender_keys (
-    our_aci_uuid     TEXT    NOT NULL,
-    sender_uuid      TEXT    NOT NULL,
+    account_id       TEXT    NOT NULL,
+    sender_uuid      TEXT    NOT NULL, -- note: this may actually be a service id
     sender_device_id INTEGER NOT NULL,
     distribution_id  TEXT    NOT NULL,
     key_record       bytea   NOT NULL,
 
-    PRIMARY KEY (our_aci_uuid, sender_uuid, sender_device_id, distribution_id),
-    FOREIGN KEY (our_aci_uuid) REFERENCES signalmeow_device (aci_uuid) ON DELETE CASCADE ON UPDATE CASCADE
+    PRIMARY KEY (account_id, sender_uuid, sender_device_id, distribution_id),
+    FOREIGN KEY (account_id) REFERENCES signalmeow_device (aci_uuid) ON DELETE CASCADE ON UPDATE CASCADE
 );
 
 CREATE TABLE signalmeow_groups (
-    our_aci_uuid     TEXT NOT NULL,
+    account_id     TEXT NOT NULL,
     group_identifier TEXT NOT NULL,
     master_key       TEXT NOT NULL,
 
-    PRIMARY KEY (our_aci_uuid, group_identifier)
+    PRIMARY KEY (account_id, group_identifier)
 );
 
-CREATE TABLE signalmeow_contacts (
-    our_aci_uuid        TEXT   NOT NULL,
-    aci_uuid            TEXT   NOT NULL,
-    e164_number         TEXT   NOT NULL,
-    contact_name        TEXT   NOT NULL,
-    contact_avatar_hash TEXT   NOT NULL,
+CREATE TABLE signalmeow_recipients (
+    account_id          TEXT NOT NULL,
+    aci_uuid            TEXT,
+    pni_uuid            TEXT,
+    e164_number         TEXT NOT NULL DEFAULT '',
+    contact_name        TEXT NOT NULL DEFAULT '',
+    contact_avatar_hash TEXT NOT NULL DEFAULT '',
     profile_key         bytea,
-    profile_name        TEXT   NOT NULL,
-    profile_about       TEXT   NOT NULL,
-    profile_about_emoji TEXT   NOT NULL,
-    profile_avatar_path TEXT   NOT NULL,
+    profile_name        TEXT NOT NULL DEFAULT '',
+    profile_about       TEXT NOT NULL DEFAULT '',
+    profile_about_emoji TEXT NOT NULL DEFAULT '',
+    profile_avatar_path TEXT NOT NULL DEFAULT '',
     profile_fetched_at  BIGINT,
+    needs_pni_signature BOOLEAN NOT NULL DEFAULT false,
 
-    PRIMARY KEY (our_aci_uuid, aci_uuid),
-    FOREIGN KEY (our_aci_uuid) REFERENCES signalmeow_device (aci_uuid) ON DELETE CASCADE ON UPDATE CASCADE
-);
-
-CREATE TABLE signalmeow_kyber_pre_keys (
-    aci_uuid       TEXT    NOT NULL,
-    key_id         INTEGER NOT NULL,
-    uuid_kind      TEXT    NOT NULL,
-    key_pair       bytea   NOT NULL,
-    is_last_resort BOOLEAN NOT NULL,
-
-    PRIMARY KEY (aci_uuid, uuid_kind, key_id),
-    FOREIGN KEY (aci_uuid) REFERENCES signalmeow_device (aci_uuid) ON DELETE CASCADE ON UPDATE CASCADE
+    CONSTRAINT signalmeow_contacts_account_id_fkey FOREIGN KEY (account_id) REFERENCES signalmeow_device (aci_uuid)
+        ON DELETE CASCADE ON UPDATE CASCADE,
+    CONSTRAINT signalmeow_contacts_aci_unique UNIQUE (account_id, aci_uuid),
+    CONSTRAINT signalmeow_contacts_pni_unique UNIQUE (account_id, pni_uuid)
 );