Explorar el Código

Merge remote-tracking branch 'beeper/main'

Tulir Asokan hace 3 años
padre
commit
cf5384d908

+ 19 - 5
bridge/attachments.go

@@ -9,6 +9,7 @@ import (
 
 	"github.com/bwmarrin/discordgo"
 
+	"maunium.net/go/mautrix"
 	"maunium.net/go/mautrix/appservice"
 	"maunium.net/go/mautrix/event"
 	"maunium.net/go/mautrix/id"
@@ -76,13 +77,26 @@ func (p *Portal) downloadMatrixAttachment(eventID id.EventID, content *event.Mes
 }
 
 func (p *Portal) uploadMatrixAttachment(intent *appservice.IntentAPI, data []byte, content *event.MessageEventContent) error {
-	uploaded, err := intent.UploadBytes(data, content.Info.MimeType)
-	if err != nil {
-		return err
+	req := mautrix.ReqUploadMedia{
+		ContentBytes: data,
+		ContentType:  content.Info.MimeType,
+	}
+	var mxc id.ContentURI
+	if p.bridge.Config.Homeserver.AsyncMedia {
+		uploaded, err := intent.UnstableUploadAsync(req)
+		if err != nil {
+			return err
+		}
+		mxc = uploaded.ContentURI
+	} else {
+		uploaded, err := intent.UploadMedia(req)
+		if err != nil {
+			return err
+		}
+		mxc = uploaded.ContentURI
 	}
 
-	content.URL = uploaded.ContentURI.CUString()
-
+	content.URL = mxc.CUString()
 	content.Info.Size = len(data)
 
 	if content.Info.Width == 0 && content.Info.Height == 0 && strings.HasPrefix(content.Info.MimeType, "image/") {

+ 31 - 0
bridge/bridge.go

@@ -48,6 +48,8 @@ type Bridge struct {
 	puppetsLock         sync.Mutex
 
 	StateStore *database.SQLStateStore
+
+	crypto Crypto
 }
 
 func New(cfg *config.Config) (*Bridge, error) {
@@ -104,6 +106,8 @@ func New(cfg *config.Config) (*Bridge, error) {
 		StateStore: stateStore,
 	}
 
+	bridge.crypto = NewCryptoHelper(bridge)
+
 	if cfg.Appservice.Provisioning.Enabled() {
 		bridge.provisioning = newProvisioningAPI(bridge)
 	}
@@ -151,6 +155,13 @@ func (b *Bridge) Start() error {
 		return err
 	}
 
+	if b.crypto != nil {
+		if err := b.crypto.Init(); err != nil {
+			b.log.Fatalln("Error initializing end-to-bridge encryption:", err)
+			return err
+		}
+	}
+
 	b.log.Debugln("Starting application service HTTP server")
 	go b.as.Start()
 
@@ -159,6 +170,10 @@ func (b *Bridge) Start() error {
 
 	go b.updateBotProfile()
 
+	if b.crypto != nil {
+		go b.crypto.Start()
+	}
+
 	go b.startUsers()
 
 	// Finally tell the appservice we're ready
@@ -168,5 +183,21 @@ func (b *Bridge) Start() error {
 }
 
 func (b *Bridge) Stop() {
+	if b.crypto != nil {
+		b.crypto.Stop()
+	}
+
+	b.as.Stop()
+	b.eventProcessor.Stop()
+
+	for _, user := range b.usersByMXID {
+		if user.Session == nil {
+			continue
+		}
+
+		b.log.Debugln("Disconnecting", user.MXID)
+		user.Session.Close()
+	}
+
 	b.log.Infoln("Bridge stopped")
 }

+ 1 - 1
bridge/commands.go

@@ -306,7 +306,7 @@ func (m *pingMatrixCmd) Run(g *globals) error {
 type guildsCmd struct {
 	Status   guildStatusCmd   `kong:"cmd,help='Show the bridge status for the guilds you are in'"`
 	Bridge   guildBridgeCmd   `kong:"cmd,help='Bridge a guild'"`
-	Unbridge guildUnbridgeCmd `kong:"cmd,help="Unbridge a guild'"`
+	Unbridge guildUnbridgeCmd `kong:"cmd,help='Unbridge a guild'"`
 }
 
 type guildStatusCmd struct{}

+ 339 - 0
bridge/crypto.go

@@ -0,0 +1,339 @@
+package bridge
+
+import (
+	"fmt"
+	"runtime/debug"
+	"time"
+
+	"maunium.net/go/maulogger/v2"
+
+	"maunium.net/go/mautrix"
+	"maunium.net/go/mautrix/crypto"
+	"maunium.net/go/mautrix/event"
+	"maunium.net/go/mautrix/id"
+
+	"go.mau.fi/mautrix-discord/database"
+)
+
+var NoSessionFound = crypto.NoSessionFound
+
+var levelTrace = maulogger.Level{
+	Name:     "TRACE",
+	Severity: -10,
+	Color:    -1,
+}
+
+type Crypto interface {
+	HandleMemberEvent(*event.Event)
+	Decrypt(*event.Event) (*event.Event, error)
+	Encrypt(id.RoomID, event.Type, event.Content) (*event.EncryptedEventContent, error)
+	WaitForSession(id.RoomID, id.SenderKey, id.SessionID, time.Duration) bool
+	RequestSession(id.RoomID, id.SenderKey, id.SessionID, id.UserID, id.DeviceID)
+	ResetSession(id.RoomID)
+	Init() error
+	Start()
+	Stop()
+}
+
+type CryptoHelper struct {
+	bridge  *Bridge
+	client  *mautrix.Client
+	mach    *crypto.OlmMachine
+	store   *database.SQLCryptoStore
+	log     maulogger.Logger
+	baseLog maulogger.Logger
+}
+
+func NewCryptoHelper(bridge *Bridge) Crypto {
+	if !bridge.Config.Bridge.Encryption.Allow {
+		bridge.log.Debugln("Bridge built with end-to-bridge encryption, but disabled in config")
+		return nil
+	}
+
+	baseLog := bridge.log.Sub("Crypto")
+	return &CryptoHelper{
+		bridge:  bridge,
+		log:     baseLog.Sub("Helper"),
+		baseLog: baseLog,
+	}
+}
+
+func (helper *CryptoHelper) Init() error {
+	helper.log.Debugln("Initializing end-to-bridge encryption...")
+
+	helper.store = database.NewSQLCryptoStore(helper.bridge.db, helper.bridge.as.BotMXID(),
+		fmt.Sprintf("@%s:%s", helper.bridge.Config.Bridge.FormatUsername("%"), helper.bridge.as.HomeserverDomain))
+
+	var err error
+	helper.client, err = helper.loginBot()
+	if err != nil {
+		return err
+	}
+
+	helper.log.Debugln("Logged in as bridge bot with device ID", helper.client.DeviceID)
+
+	logger := &cryptoLogger{helper.baseLog}
+	stateStore := &cryptoStateStore{helper.bridge}
+	helper.mach = crypto.NewOlmMachine(helper.client, logger, helper.store, stateStore)
+	helper.mach.AllowKeyShare = helper.allowKeyShare
+
+	helper.client.Syncer = &cryptoSyncer{helper.mach}
+	helper.client.Store = &cryptoClientStore{helper.store}
+
+	return helper.mach.Load()
+}
+
+func (helper *CryptoHelper) allowKeyShare(device *crypto.DeviceIdentity, info event.RequestedKeyInfo) *crypto.KeyShareRejection {
+	cfg := helper.bridge.Config.Bridge.Encryption.KeySharing
+	if !cfg.Allow {
+		return &crypto.KeyShareRejectNoResponse
+	} else if device.Trust == crypto.TrustStateBlacklisted {
+		return &crypto.KeyShareRejectBlacklisted
+	} else if device.Trust == crypto.TrustStateVerified || !cfg.RequireVerification {
+		portal := helper.bridge.GetPortalByMXID(info.RoomID)
+		if portal == nil {
+			helper.log.Debugfln("Rejecting key request for %s from %s/%s: room is not a portal", info.SessionID, device.UserID, device.DeviceID)
+
+			return &crypto.KeyShareRejection{Code: event.RoomKeyWithheldUnavailable, Reason: "Requested room is not a portal room"}
+		}
+		user := helper.bridge.GetUserByMXID(device.UserID)
+		// FIXME reimplement IsInPortal
+		if !user.Admin /*&& !user.IsInPortal(portal.Key)*/ {
+			helper.log.Debugfln("Rejecting key request for %s from %s/%s: user is not in portal", info.SessionID, device.UserID, device.DeviceID)
+
+			return &crypto.KeyShareRejection{Code: event.RoomKeyWithheldUnauthorized, Reason: "You're not in that portal"}
+		}
+		helper.log.Debugfln("Accepting key request for %s from %s/%s", info.SessionID, device.UserID, device.DeviceID)
+
+		return nil
+	}
+
+	return &crypto.KeyShareRejectUnverified
+}
+
+func (helper *CryptoHelper) loginBot() (*mautrix.Client, error) {
+	deviceID := helper.store.FindDeviceID()
+	if len(deviceID) > 0 {
+		helper.log.Debugln("Found existing device ID for bot in database:", deviceID)
+	}
+
+	client, err := mautrix.NewClient(helper.bridge.as.HomeserverURL, "", "")
+	if err != nil {
+		return nil, fmt.Errorf("failed to initialize client: %w", err)
+	}
+
+	client.Logger = helper.baseLog.Sub("Bot")
+	client.Client = helper.bridge.as.HTTPClient
+	client.DefaultHTTPRetries = helper.bridge.as.DefaultHTTPRetries
+	flows, err := client.GetLoginFlows()
+	if err != nil {
+		return nil, fmt.Errorf("failed to get supported login flows: %w", err)
+	}
+
+	flow := flows.FirstFlowOfType(mautrix.AuthTypeAppservice, mautrix.AuthTypeHalfyAppservice)
+	if flow == nil {
+		return nil, fmt.Errorf("homeserver does not support appservice login")
+	}
+
+	// We set the API token to the AS token here to authenticate the appservice login
+	// It'll get overridden after the login
+	client.AccessToken = helper.bridge.as.Registration.AppToken
+	resp, err := client.Login(&mautrix.ReqLogin{
+		Type:                     flow.Type,
+		Identifier:               mautrix.UserIdentifier{Type: mautrix.IdentifierTypeUser, User: string(helper.bridge.as.BotMXID())},
+		DeviceID:                 deviceID,
+		InitialDeviceDisplayName: "Discord Bridge",
+		StoreCredentials:         true,
+	})
+	if err != nil {
+		return nil, fmt.Errorf("failed to log in as bridge bot: %w", err)
+	}
+
+	helper.store.DeviceID = resp.DeviceID
+
+	return client, nil
+}
+
+func (helper *CryptoHelper) Start() {
+	helper.log.Debugln("Starting syncer for receiving to-device messages")
+
+	err := helper.client.Sync()
+	if err != nil {
+		helper.log.Errorln("Fatal error syncing:", err)
+	} else {
+		helper.log.Infoln("Bridge bot to-device syncer stopped without error")
+	}
+}
+
+func (helper *CryptoHelper) Stop() {
+	helper.log.Debugln("CryptoHelper.Stop() called, stopping bridge bot sync")
+	helper.client.StopSync()
+}
+
+func (helper *CryptoHelper) Decrypt(evt *event.Event) (*event.Event, error) {
+	return helper.mach.DecryptMegolmEvent(evt)
+}
+
+func (helper *CryptoHelper) Encrypt(roomID id.RoomID, evtType event.Type, content event.Content) (*event.EncryptedEventContent, error) {
+	encrypted, err := helper.mach.EncryptMegolmEvent(roomID, evtType, &content)
+
+	if err != nil {
+		if err != crypto.SessionExpired && err != crypto.SessionNotShared && err != crypto.NoGroupSession {
+			return nil, err
+		}
+
+		helper.log.Debugfln("Got %v while encrypting event for %s, sharing group session and trying again...", err, roomID)
+		users, err := helper.store.GetRoomMembers(roomID)
+		if err != nil {
+			return nil, fmt.Errorf("failed to get room member list: %w", err)
+		}
+
+		err = helper.mach.ShareGroupSession(roomID, users)
+		if err != nil {
+			return nil, fmt.Errorf("failed to share group session: %w", err)
+		}
+
+		encrypted, err = helper.mach.EncryptMegolmEvent(roomID, evtType, &content)
+		if err != nil {
+			return nil, fmt.Errorf("failed to encrypt event after re-sharing group session: %w", err)
+		}
+	}
+
+	return encrypted, nil
+}
+
+func (helper *CryptoHelper) WaitForSession(roomID id.RoomID, senderKey id.SenderKey, sessionID id.SessionID, timeout time.Duration) bool {
+	return helper.mach.WaitForSession(roomID, senderKey, sessionID, timeout)
+}
+
+func (helper *CryptoHelper) RequestSession(roomID id.RoomID, senderKey id.SenderKey, sessionID id.SessionID, userID id.UserID, deviceID id.DeviceID) {
+	err := helper.mach.SendRoomKeyRequest(roomID, senderKey, sessionID, "", map[id.UserID][]id.DeviceID{userID: {deviceID}})
+	if err != nil {
+		helper.log.Warnfln("Failed to send key request to %s/%s for %s in %s: %v", userID, deviceID, sessionID, roomID, err)
+	} else {
+		helper.log.Debugfln("Sent key request to %s/%s for %s in %s", userID, deviceID, sessionID, roomID)
+	}
+}
+
+func (helper *CryptoHelper) ResetSession(roomID id.RoomID) {
+	err := helper.mach.CryptoStore.RemoveOutboundGroupSession(roomID)
+	if err != nil {
+		helper.log.Debugfln("Error manually removing outbound group session in %s: %v", roomID, err)
+	}
+}
+
+func (helper *CryptoHelper) HandleMemberEvent(evt *event.Event) {
+	helper.mach.HandleMemberEvent(evt)
+}
+
+type cryptoSyncer struct {
+	*crypto.OlmMachine
+}
+
+func (syncer *cryptoSyncer) ProcessResponse(resp *mautrix.RespSync, since string) error {
+	done := make(chan struct{})
+	go func() {
+		defer func() {
+			if err := recover(); err != nil {
+				syncer.Log.Error("Processing sync response (%s) panicked: %v\n%s", since, err, debug.Stack())
+			}
+			done <- struct{}{}
+		}()
+		syncer.Log.Trace("Starting sync response handling (%s)", since)
+		syncer.ProcessSyncResponse(resp, since)
+		syncer.Log.Trace("Successfully handled sync response (%s)", since)
+	}()
+
+	select {
+	case <-done:
+	case <-time.After(30 * time.Second):
+		syncer.Log.Warn("Handling sync response (%s) is taking unusually long", since)
+	}
+
+	return nil
+}
+
+func (syncer *cryptoSyncer) OnFailedSync(_ *mautrix.RespSync, err error) (time.Duration, error) {
+	syncer.Log.Error("Error /syncing, waiting 10 seconds: %v", err)
+
+	return 10 * time.Second, nil
+}
+
+func (syncer *cryptoSyncer) GetFilterJSON(_ id.UserID) *mautrix.Filter {
+	everything := []event.Type{{Type: "*"}}
+
+	return &mautrix.Filter{
+		Presence:    mautrix.FilterPart{NotTypes: everything},
+		AccountData: mautrix.FilterPart{NotTypes: everything},
+		Room: mautrix.RoomFilter{
+			IncludeLeave: false,
+			Ephemeral:    mautrix.FilterPart{NotTypes: everything},
+			AccountData:  mautrix.FilterPart{NotTypes: everything},
+			State:        mautrix.FilterPart{NotTypes: everything},
+			Timeline:     mautrix.FilterPart{NotTypes: everything},
+		},
+	}
+}
+
+type cryptoLogger struct {
+	int maulogger.Logger
+}
+
+func (c *cryptoLogger) Error(message string, args ...interface{}) {
+	c.int.Errorfln(message, args...)
+}
+
+func (c *cryptoLogger) Warn(message string, args ...interface{}) {
+	c.int.Warnfln(message, args...)
+}
+
+func (c *cryptoLogger) Debug(message string, args ...interface{}) {
+	c.int.Debugfln(message, args...)
+}
+
+func (c *cryptoLogger) Trace(message string, args ...interface{}) {
+	c.int.Logfln(levelTrace, message, args...)
+}
+
+type cryptoClientStore struct {
+	int *database.SQLCryptoStore
+}
+
+func (c cryptoClientStore) SaveFilterID(_ id.UserID, _ string) {}
+func (c cryptoClientStore) LoadFilterID(_ id.UserID) string    { return "" }
+func (c cryptoClientStore) SaveRoom(_ *mautrix.Room)           {}
+func (c cryptoClientStore) LoadRoom(_ id.RoomID) *mautrix.Room { return nil }
+
+func (c cryptoClientStore) SaveNextBatch(_ id.UserID, nextBatchToken string) {
+	c.int.PutNextBatch(nextBatchToken)
+}
+
+func (c cryptoClientStore) LoadNextBatch(_ id.UserID) string {
+	return c.int.GetNextBatch()
+}
+
+var _ mautrix.Storer = (*cryptoClientStore)(nil)
+
+type cryptoStateStore struct {
+	bridge *Bridge
+}
+
+var _ crypto.StateStore = (*cryptoStateStore)(nil)
+
+func (c *cryptoStateStore) IsEncrypted(id id.RoomID) bool {
+	portal := c.bridge.GetPortalByMXID(id)
+	if portal != nil {
+		return portal.Encrypted
+	}
+
+	return false
+}
+
+func (c *cryptoStateStore) FindSharedRooms(id id.UserID) []id.RoomID {
+	return c.bridge.StateStore.FindSharedRooms(id)
+}
+
+func (c *cryptoStateStore) GetEncryptionEvent(id.RoomID) *event.EncryptionEventContent {
+	// TODO implement
+	return nil
+}

+ 132 - 13
bridge/matrix.go

@@ -1,7 +1,10 @@
 package bridge
 
 import (
+	"errors"
+	"fmt"
 	"strings"
+	"time"
 
 	"maunium.net/go/maulogger/v2"
 	"maunium.net/go/mautrix"
@@ -29,9 +32,11 @@ func (b *Bridge) setupEvents() {
 	}
 
 	b.eventProcessor.On(event.EventMessage, b.matrixHandler.handleMessage)
+	b.eventProcessor.On(event.EventEncrypted, b.matrixHandler.handleEncrypted)
 	b.eventProcessor.On(event.EventReaction, b.matrixHandler.handleReaction)
 	b.eventProcessor.On(event.EventRedaction, b.matrixHandler.handleRedaction)
 	b.eventProcessor.On(event.StateMember, b.matrixHandler.handleMembership)
+	b.eventProcessor.On(event.StateEncryption, b.matrixHandler.handleEncryption)
 }
 
 func (mh *matrixHandler) join(evt *event.Event, intent *appservice.IntentAPI) *mautrix.RespJoinedMembers {
@@ -101,30 +106,30 @@ func (mh *matrixHandler) handleMessage(evt *event.Event) {
 
 }
 
-func (mh *matrixHandler) joinAndCheckMembers(evt *event.Event, intent *appservice.IntentAPI) *mautrix.RespJoinedMembers {
+func (mh *matrixHandler) joinAndCheckMembers(evt *event.Event, intent *appservice.IntentAPI) int {
 	resp, err := intent.JoinRoomByID(evt.RoomID)
 	if err != nil {
 		mh.log.Debugfln("Failed to join room %q as %q with invite from %q: %v", evt.RoomID, intent.UserID, evt.Sender, err)
 
-		return nil
+		return 0
 	}
 
-	members, err := intent.JoinedMembers(resp.RoomID)
+	members, err := intent.Members(resp.RoomID)
 	if err != nil {
 		mh.log.Debugfln("Failed to get members in room %q with invite from %q as %q: %v", resp.RoomID, evt.Sender, intent.UserID, err)
 
-		return nil
+		return 0
 	}
 
-	if len(members.Joined) < 2 {
+	if len(members.Chunk) < 2 {
 		mh.log.Debugfln("Leaving empty room %q with invite from %q as %q", resp.RoomID, evt.Sender, intent.UserID)
 
 		intent.LeaveRoom(resp.RoomID)
 
-		return nil
+		return 0
 	}
 
-	return members
+	return len(members.Chunk)
 }
 
 func (mh *matrixHandler) sendNoticeWithmarkdown(roomID id.RoomID, message string) (*mautrix.RespSendEvent, error) {
@@ -144,24 +149,24 @@ func (mh *matrixHandler) handleBotInvite(evt *event.Event) {
 	}
 
 	members := mh.joinAndCheckMembers(evt, intent)
-	if members == nil {
+	if members == 0 {
 		return
 	}
 
 	// If this is a DM and the user doesn't have a management room, make this
 	// the management room.
-	if len(members.Joined) == 2 && (user.ManagementRoom == "" || evt.Content.AsMember().IsDirect) {
+	if members == 2 && (user.ManagementRoom == "" || evt.Content.AsMember().IsDirect) {
 		user.SetManagementRoom(evt.RoomID)
 
 		intent.SendNotice(user.ManagementRoom, "This room has been registered as your bridge management/status room")
 		mh.log.Debugfln("%q registered as management room with %q", evt.RoomID, evt.Sender)
 	}
 
-	// Wait to send the welcome message until we're sure we're not in an empty
-	// room.
-	mh.sendNoticeWithmarkdown(evt.RoomID, mh.bridge.Config.Bridge.ManagementRoomText.Welcome)
-
 	if evt.RoomID == user.ManagementRoom {
+		// Wait to send the welcome message until we're sure we're not in an empty
+		// room.
+		mh.sendNoticeWithmarkdown(evt.RoomID, mh.bridge.Config.Bridge.ManagementRoomText.Welcome)
+
 		if user.Connected() {
 			mh.sendNoticeWithmarkdown(evt.RoomID, mh.bridge.Config.Bridge.ManagementRoomText.Connected)
 		} else {
@@ -185,6 +190,10 @@ func (mh *matrixHandler) handleMembership(evt *event.Event) {
 		return
 	}
 
+	if mh.bridge.crypto != nil {
+		mh.bridge.crypto.HandleMemberEvent(evt)
+	}
+
 	// Grab the content of the event.
 	content := evt.Content.AsMember()
 
@@ -255,3 +264,113 @@ func (mh *matrixHandler) handleRedaction(evt *event.Event) {
 		portal.handleMatrixRedaction(evt)
 	}
 }
+
+func (mh *matrixHandler) handleEncryption(evt *event.Event) {
+	if evt.Content.AsEncryption().Algorithm != id.AlgorithmMegolmV1 {
+		return
+	}
+
+	portal := mh.bridge.GetPortalByMXID(evt.RoomID)
+	if portal != nil && !portal.Encrypted {
+		mh.log.Debugfln("%s enabled encryption in %s", evt.Sender, evt.RoomID)
+		portal.Encrypted = true
+		portal.Update()
+	}
+}
+
+const sessionWaitTimeout = 5 * time.Second
+
+func (mh *matrixHandler) handleEncrypted(evt *event.Event) {
+	if mh.ignoreEvent(evt) || mh.bridge.crypto == nil {
+		return
+	}
+
+	decrypted, err := mh.bridge.crypto.Decrypt(evt)
+	decryptionRetryCount := 0
+	if errors.Is(err, NoSessionFound) {
+		content := evt.Content.AsEncrypted()
+		mh.log.Debugfln("Couldn't find session %s trying to decrypt %s, waiting %d seconds...", content.SessionID, evt.ID, int(sessionWaitTimeout.Seconds()))
+		mh.as.SendErrorMessageSendCheckpoint(evt, appservice.StepDecrypted, err, false, decryptionRetryCount)
+		decryptionRetryCount++
+
+		if mh.bridge.crypto.WaitForSession(evt.RoomID, content.SenderKey, content.SessionID, sessionWaitTimeout) {
+			mh.log.Debugfln("Got session %s after waiting, trying to decrypt %s again", content.SessionID, evt.ID)
+			decrypted, err = mh.bridge.crypto.Decrypt(evt)
+		} else {
+			mh.as.SendErrorMessageSendCheckpoint(evt, appservice.StepDecrypted, fmt.Errorf("didn't receive encryption keys"), false, decryptionRetryCount)
+
+			go mh.waitLongerForSession(evt)
+
+			return
+		}
+	}
+
+	if err != nil {
+		mh.as.SendErrorMessageSendCheckpoint(evt, appservice.StepDecrypted, err, true, decryptionRetryCount)
+
+		mh.log.Warnfln("Failed to decrypt %s: %v", evt.ID, err)
+		_, _ = mh.bridge.bot.SendNotice(evt.RoomID, fmt.Sprintf(
+			"\u26a0 Your message was not bridged: %v", err))
+
+		return
+	}
+
+	mh.as.SendMessageSendCheckpoint(decrypted, appservice.StepDecrypted, decryptionRetryCount)
+	mh.bridge.eventProcessor.Dispatch(decrypted)
+}
+
+func (mh *matrixHandler) waitLongerForSession(evt *event.Event) {
+	const extendedTimeout = sessionWaitTimeout * 3
+
+	content := evt.Content.AsEncrypted()
+	mh.log.Debugfln("Couldn't find session %s trying to decrypt %s, waiting %d more seconds...",
+		content.SessionID, evt.ID, int(extendedTimeout.Seconds()))
+
+	go mh.bridge.crypto.RequestSession(evt.RoomID, content.SenderKey, content.SessionID, evt.Sender, content.DeviceID)
+
+	resp, err := mh.bridge.bot.SendNotice(evt.RoomID, fmt.Sprintf(
+		"\u26a0 Your message was not bridged: the bridge hasn't received the decryption keys. "+
+			"The bridge will retry for %d seconds. If this error keeps happening, try restarting your client.",
+		int(extendedTimeout.Seconds())))
+	if err != nil {
+		mh.log.Errorfln("Failed to send decryption error to %s: %v", evt.RoomID, err)
+	}
+
+	update := event.MessageEventContent{MsgType: event.MsgNotice}
+
+	if mh.bridge.crypto.WaitForSession(evt.RoomID, content.SenderKey, content.SessionID, extendedTimeout) {
+		mh.log.Debugfln("Got session %s after waiting more, trying to decrypt %s again", content.SessionID, evt.ID)
+
+		decrypted, err := mh.bridge.crypto.Decrypt(evt)
+		if err == nil {
+			mh.as.SendMessageSendCheckpoint(decrypted, appservice.StepDecrypted, 2)
+			mh.bridge.eventProcessor.Dispatch(decrypted)
+			_, _ = mh.bridge.bot.RedactEvent(evt.RoomID, resp.EventID)
+
+			return
+		}
+
+		mh.log.Warnfln("Failed to decrypt %s: %v", evt.ID, err)
+		mh.as.SendErrorMessageSendCheckpoint(evt, appservice.StepDecrypted, err, true, 2)
+		update.Body = fmt.Sprintf("\u26a0 Your message was not bridged: %v", err)
+	} else {
+		mh.log.Debugfln("Didn't get %s, giving up on %s", content.SessionID, evt.ID)
+		mh.as.SendErrorMessageSendCheckpoint(evt, appservice.StepDecrypted, fmt.Errorf("didn't receive encryption keys"), true, 2)
+		update.Body = "\u26a0 Your message was not bridged: the bridge hasn't received the decryption keys. " +
+			"If this error keeps happening, try restarting your client."
+	}
+
+	newContent := update
+	update.NewContent = &newContent
+	if resp != nil {
+		update.RelatesTo = &event.RelatesTo{
+			Type:    event.RelReplace,
+			EventID: resp.EventID,
+		}
+	}
+
+	_, err = mh.bridge.bot.SendMessageEvent(evt.RoomID, event.EventMessage, &update)
+	if err != nil {
+		mh.log.Debugfln("Failed to update decryption error notice %s: %v", resp.EventID, err)
+	}
+}

+ 117 - 12
bridge/portal.go

@@ -35,6 +35,7 @@ type Portal struct {
 	log    log.Logger
 
 	roomCreateLock sync.Mutex
+	encryptLock    sync.Mutex
 
 	discordMessages chan portalDiscordMessage
 	matrixMessages  chan portalMatrixMessage
@@ -144,7 +145,7 @@ func (p *Portal) handleMatrixInvite(sender *User, evt *event.Event) {
 		p.log.Infoln("no puppet for %v", sender)
 		// Open a conversation on the discord side?
 	}
-	p.log.Infoln("puppet:", puppet)
+	p.log.Infoln("matrixInvite: puppet:", puppet)
 }
 
 func (p *Portal) messageLoop() {
@@ -171,14 +172,14 @@ func (p *Portal) MainIntent() *appservice.IntentAPI {
 }
 
 func (p *Portal) createMatrixRoom(user *User, channel *discordgo.Channel) error {
+	p.roomCreateLock.Lock()
+	defer p.roomCreateLock.Unlock()
+
 	// If we have a matrix id the room should exist so we have nothing to do.
 	if p.MXID != "" {
 		return nil
 	}
 
-	p.roomCreateLock.Lock()
-	defer p.roomCreateLock.Unlock()
-
 	p.Type = channel.Type
 	if p.Type == discordgo.ChannelTypeDM {
 		p.DMUser = channel.Recipients[0].ID
@@ -212,14 +213,25 @@ func (p *Portal) createMatrixRoom(user *User, channel *discordgo.Channel) error
 
 	var invite []id.UserID
 
-	if p.IsPrivateChat() {
-		invite = append(invite, p.bridge.bot.UserID)
+	if p.bridge.Config.Bridge.Encryption.Default {
+		initialState = append(initialState, &event.Event{
+			Type: event.StateEncryption,
+			Content: event.Content{
+				Parsed: event.EncryptionEventContent{Algorithm: id.AlgorithmMegolmV1},
+			},
+		})
+		p.Encrypted = true
+
+		if p.IsPrivateChat() {
+			invite = append(invite, p.bridge.bot.UserID)
+		}
 	}
 
 	resp, err := intent.CreateRoom(&mautrix.ReqCreateRoom{
 		Visibility:      "private",
 		Name:            p.Name,
 		Topic:           p.Topic,
+		Invite:          invite,
 		Preset:          "private_chat",
 		IsDirect:        p.IsPrivateChat(),
 		InitialState:    initialState,
@@ -325,7 +337,7 @@ func (p *Portal) sendMediaFailedMessage(intent *appservice.IntentAPI, bridgeErr
 		MsgType: event.MsgNotice,
 	}
 
-	_, err := intent.SendMessageEvent(p.MXID, event.EventMessage, content)
+	_, err := p.sendMatrixMessage(intent, event.EventMessage, content, nil, time.Now().UTC().UnixMilli())
 	if err != nil {
 		p.log.Warnfln("failed to send error message to matrix: %v", err)
 	}
@@ -379,7 +391,7 @@ func (p *Portal) handleDiscordAttachment(intent *appservice.IntentAPI, msgID str
 		return
 	}
 
-	resp, err := intent.SendMessageEvent(p.MXID, event.EventMessage, content)
+	resp, err := p.sendMatrixMessage(intent, event.EventMessage, content, nil, time.Now().UTC().UnixMilli())
 	if err != nil {
 		p.log.Warnfln("failed to send media message to matrix: %v", err)
 	}
@@ -399,6 +411,29 @@ func (p *Portal) handleDiscordMessageCreate(user *User, msg *discordgo.Message)
 		return
 	}
 
+	// Handle room name changes
+	if msg.Type == discordgo.MessageTypeChannelNameChange {
+		channel, err := user.Session.Channel(msg.ChannelID)
+		if err != nil {
+			p.log.Errorf("Failed to find the channel for portal %s", p.Key)
+			return
+		}
+
+		name, err := p.bridge.Config.Bridge.FormatChannelname(channel, user.Session)
+		if err != nil {
+			p.log.Errorf("Failed to format name for portal %s", p.Key)
+			return
+		}
+
+		p.Name = name
+		p.Update()
+
+		p.MainIntent().SetRoomName(p.MXID, name)
+
+		return
+	}
+
+	// Handle normal message
 	existing := p.bridge.db.Message.GetByDiscordID(p.Key, msg.ID)
 	if existing != nil {
 		p.log.Debugln("not handling duplicate message", msg.ID)
@@ -406,7 +441,9 @@ func (p *Portal) handleDiscordMessageCreate(user *User, msg *discordgo.Message)
 		return
 	}
 
-	intent := p.bridge.GetPuppetByID(msg.Author.ID).IntentFor(p)
+	puppet := p.bridge.GetPuppetByID(msg.Author.ID)
+	puppet.SyncContact(user)
+	intent := puppet.IntentFor(p)
 
 	if msg.Content != "" {
 		content := &event.MessageEventContent{
@@ -418,7 +455,7 @@ func (p *Portal) handleDiscordMessageCreate(user *User, msg *discordgo.Message)
 			key := database.PortalKey{msg.MessageReference.ChannelID, user.ID}
 			existing := p.bridge.db.Message.GetByDiscordID(key, msg.MessageReference.MessageID)
 
-			if existing.MatrixID != "" {
+			if existing != nil && existing.MatrixID != "" {
 				content.RelatesTo = &event.RelatesTo{
 					Type:    event.RelReply,
 					EventID: existing.MatrixID,
@@ -426,7 +463,7 @@ func (p *Portal) handleDiscordMessageCreate(user *User, msg *discordgo.Message)
 			}
 		}
 
-		resp, err := intent.SendMessageEvent(p.MXID, event.EventMessage, content)
+		resp, err := p.sendMatrixMessage(intent, event.EventMessage, content, nil, time.Now().UTC().UnixMilli())
 		if err != nil {
 			p.log.Warnfln("failed to send message %q to matrix: %v", msg.ID, err)
 
@@ -450,6 +487,23 @@ func (p *Portal) handleDiscordMessagesUpdate(user *User, msg *discordgo.Message)
 		return
 	}
 
+	// There's a few scenarios where the author is nil but I haven't figured
+	// them all out yet.
+	if msg.Author == nil {
+		// If the server has to lookup opengraph previews it'll send the
+		// message through without the preview and then add the preview later
+		// via a message update. However, when it does this there is no author
+		// as it's just the server, so for the moment we'll ignore this to
+		// avoid a crash.
+		if len(msg.Embeds) > 0 {
+			p.log.Debugln("ignoring update for opengraph attachment")
+
+			return
+		}
+
+		p.log.Errorfln("author is nil: %#v", msg)
+	}
+
 	intent := p.bridge.GetPuppetByID(msg.Author.ID).IntentFor(p)
 
 	existing := p.bridge.db.Message.GetByDiscordID(p.Key, msg.ID)
@@ -498,7 +552,7 @@ func (p *Portal) handleDiscordMessagesUpdate(user *User, msg *discordgo.Message)
 
 	content.SetEdit(existing.MatrixID)
 
-	resp, err := intent.SendMessageEvent(p.MXID, event.EventMessage, content)
+	resp, err := p.sendMatrixMessage(intent, event.EventMessage, content, nil, time.Now().UTC().UnixMilli())
 	if err != nil {
 		p.log.Warnfln("failed to send message %q to matrix: %v", msg.ID, err)
 
@@ -567,6 +621,57 @@ func (p *Portal) syncParticipants(source *User, participants []*discordgo.User)
 	}
 }
 
+func (portal *Portal) encrypt(content *event.Content, eventType event.Type) (event.Type, error) {
+	if portal.Encrypted && portal.bridge.crypto != nil {
+		// TODO maybe the locking should be inside mautrix-go?
+		portal.encryptLock.Lock()
+		encrypted, err := portal.bridge.crypto.Encrypt(portal.MXID, eventType, *content)
+		portal.encryptLock.Unlock()
+		if err != nil {
+			return eventType, fmt.Errorf("failed to encrypt event: %w", err)
+		}
+		eventType = event.EventEncrypted
+		content.Parsed = encrypted
+	}
+	return eventType, nil
+}
+
+const doublePuppetKey = "fi.mau.double_puppet_source"
+const doublePuppetValue = "mautrix-discord"
+
+func (portal *Portal) sendMatrixMessage(intent *appservice.IntentAPI, eventType event.Type, content *event.MessageEventContent, extraContent map[string]interface{}, timestamp int64) (*mautrix.RespSendEvent, error) {
+	wrappedContent := event.Content{Parsed: content, Raw: extraContent}
+	if timestamp != 0 && intent.IsCustomPuppet {
+		if wrappedContent.Raw == nil {
+			wrappedContent.Raw = map[string]interface{}{}
+		}
+		if intent.IsCustomPuppet {
+			wrappedContent.Raw[doublePuppetKey] = doublePuppetValue
+		}
+	}
+	var err error
+	eventType, err = portal.encrypt(&wrappedContent, eventType)
+	if err != nil {
+		return nil, err
+	}
+
+	if eventType == event.EventEncrypted {
+		// Clear other custom keys if the event was encrypted, but keep the double puppet identifier
+		if intent.IsCustomPuppet {
+			wrappedContent.Raw = map[string]interface{}{doublePuppetKey: doublePuppetValue}
+		} else {
+			wrappedContent.Raw = nil
+		}
+	}
+
+	_, _ = intent.UserTyping(portal.MXID, false, 0)
+	if timestamp == 0 {
+		return intent.SendMessageEvent(portal.MXID, eventType, &wrappedContent)
+	} else {
+		return intent.SendMassagedMessageEvent(portal.MXID, eventType, &wrappedContent, timestamp)
+	}
+}
+
 func (p *Portal) handleMatrixMessages(msg portalMatrixMessage) {
 	switch msg.evt.Type {
 	case event.EventMessage:

+ 3 - 0
bridge/puppet.go

@@ -164,7 +164,10 @@ func (p *Puppet) CustomIntent() *appservice.IntentAPI {
 
 func (p *Puppet) updatePortalMeta(meta func(portal *Portal)) {
 	for _, portal := range p.bridge.GetAllPortalsByID(p.ID) {
+		// Get room create lock to prevent races between receiving contact info and room creation.
+		portal.roomCreateLock.Lock()
 		meta(portal)
+		portal.roomCreateLock.Unlock()
 	}
 }
 

+ 4 - 1
bridge/user.go

@@ -32,6 +32,9 @@ type User struct {
 	bridge *Bridge
 	log    log.Logger
 
+	// TODO finish implementing
+	Admin bool
+
 	guilds     map[string]*database.Guild
 	guildsLock sync.Mutex
 
@@ -717,7 +720,7 @@ func (u *User) updateDirectChats(chats map[id.UserID][]id.RoomID) {
 
 	var err error
 	if u.bridge.Config.Homeserver.Asmux {
-		urlPath := intent.BuildBaseURL("_matrix", "client", "unstable", "com.beeper.asmux", "dms")
+		urlPath := intent.BuildURL(mautrix.ClientURLPath{"unstable", "com.beeper.asmux", "dms"})
 		_, err = intent.MakeFullRequest(mautrix.FullRequest{
 			Method:      method,
 			URL:         urlPath,

+ 2 - 0
config/bridge.go

@@ -30,6 +30,8 @@ type bridge struct {
 	DoublePuppetAllowDiscovery bool              `yaml:"double_puppet_allow_discovery"`
 	LoginSharedSecretMap       map[string]string `yaml:"login_shared_secret_map"`
 
+	Encryption encryption `yaml:"encryption"`
+
 	usernameTemplate    *template.Template `yaml:"-"`
 	displaynameTemplate *template.Template `yaml:"-"`
 	channelnameTemplate *template.Template `yaml:"-"`

+ 29 - 0
config/encryption.go

@@ -0,0 +1,29 @@
+package config
+
+type encryption struct {
+	Allow   bool `yaml:"allow"`
+	Default bool `yaml:"default"`
+
+	KeySharing struct {
+		Allow               bool `yaml:"allow"`
+		RequireCrossSigning bool `yaml:"require_cross_signing"`
+		RequireVerification bool `yaml:"require_verification"`
+	} `yaml:"key_sharing"`
+}
+
+func (e *encryption) validate() error {
+	return nil
+}
+
+func (e *encryption) UnmarshalYAML(unmarshal func(interface{}) error) error {
+	type rawEncryption encryption
+
+	raw := rawEncryption{}
+	if err := unmarshal(&raw); err != nil {
+		return err
+	}
+
+	*e = encryption(raw)
+
+	return e.validate()
+}

+ 1 - 0
config/homeserver.go

@@ -14,6 +14,7 @@ type homeserver struct {
 	Domain         string `yaml:"domain"`
 	Asmux          bool   `yaml:"asmux"`
 	StatusEndpoint string `yaml:"status_endpoint"`
+	AsyncMedia     bool   `yaml:"async_media"`
 }
 
 func (h *homeserver) validate() error {

+ 97 - 0
database/cryptostore.go

@@ -0,0 +1,97 @@
+package database
+
+import (
+	"database/sql"
+
+	log "maunium.net/go/maulogger/v2"
+
+	"maunium.net/go/mautrix/crypto"
+	"maunium.net/go/mautrix/id"
+)
+
+type SQLCryptoStore struct {
+	*crypto.SQLCryptoStore
+	UserID        id.UserID
+	GhostIDFormat string
+}
+
+var _ crypto.Store = (*SQLCryptoStore)(nil)
+
+func NewSQLCryptoStore(db *Database, userID id.UserID, ghostIDFormat string) *SQLCryptoStore {
+	return &SQLCryptoStore{
+		SQLCryptoStore: crypto.NewSQLCryptoStore(db.DB, db.dialect, "", "",
+			[]byte("maunium.net/go/mautrix-whatsapp"),
+			&cryptoLogger{db.log.Sub("CryptoStore")}),
+		UserID:        userID,
+		GhostIDFormat: ghostIDFormat,
+	}
+}
+
+func (store *SQLCryptoStore) FindDeviceID() id.DeviceID {
+	var deviceID id.DeviceID
+
+	query := `SELECT device_id FROM crypto_account WHERE account_id=$1`
+	err := store.DB.QueryRow(query, store.AccountID).Scan(&deviceID)
+	if err != nil && err != sql.ErrNoRows {
+		store.Log.Warn("Failed to scan device ID: %v", err)
+	}
+
+	return deviceID
+}
+
+func (store *SQLCryptoStore) GetRoomMembers(roomID id.RoomID) ([]id.UserID, error) {
+	query := `
+		SELECT user_id FROM mx_user_profile
+		WHERE room_id=$1
+			AND (membership='join' OR membership='invite')
+			AND user_id<>$2
+			AND user_id NOT LIKE $3
+	`
+
+	members := []id.UserID{}
+
+	rows, err := store.DB.Query(query, roomID, store.UserID, store.GhostIDFormat)
+	if err != nil {
+		return members, err
+	}
+
+	for rows.Next() {
+		var userID id.UserID
+		err := rows.Scan(&userID)
+		if err != nil {
+			store.Log.Warn("Failed to scan member in %s: %v", roomID, err)
+			return members, err
+		}
+
+		members = append(members, userID)
+	}
+
+	return members, nil
+}
+
+// TODO merge this with the one in the parent package
+type cryptoLogger struct {
+	int log.Logger
+}
+
+var levelTrace = log.Level{
+	Name:     "TRACE",
+	Severity: -10,
+	Color:    -1,
+}
+
+func (c *cryptoLogger) Error(message string, args ...interface{}) {
+	c.int.Errorfln(message, args...)
+}
+
+func (c *cryptoLogger) Warn(message string, args ...interface{}) {
+	c.int.Warnfln(message, args...)
+}
+
+func (c *cryptoLogger) Debug(message string, args ...interface{}) {
+	c.int.Debugfln(message, args...)
+}
+
+func (c *cryptoLogger) Trace(message string, args ...interface{}) {
+	c.int.Logfln(levelTrace, message, args...)
+}

+ 3 - 0
database/migrations/08-add-crypto-store-to-database.sql

@@ -0,0 +1,3 @@
+-- This migration is implemented in migrations.go as it comes from
+-- maunium.net/go/mautrix/crypto/sql_store_upgrade. It runs upgrade at index 0
+-- which is described as "Add crypto store to database".

+ 3 - 0
database/migrations/09-add-account_id-to-crypto-store.sql

@@ -0,0 +1,3 @@
+-- This migration is implemented in migrations.go as it comes from
+-- maunium.net/go/mautrix/crypto/sql_store_upgrade. It runs upgrade at index 1
+-- which is described as "Add account_id to crypto store".

+ 3 - 0
database/migrations/10-add-megolm-withheld-data-to-crypto-store.sql

@@ -0,0 +1,3 @@
+-- This migration is implemented in migrations.go as it comes from
+-- maunium.net/go/mautrix/crypto/sql_store_upgrade. It runs upgrade at index 2
+-- which is described as "Add megolm withheld data to crypto store".

+ 3 - 0
database/migrations/11-add-cross-signing-keys-to-crypto-store.sql

@@ -0,0 +1,3 @@
+-- This migration is implemented in migrations.go as it comes from
+-- maunium.net/go/mautrix/crypto/sql_store_upgrade. It runs upgrade at index 3
+-- which is described as "Add cross-signing keys to crypto store".

+ 4 - 0
database/migrations/12-replace-varchar-with-text-in-the-crypto-database.sql

@@ -0,0 +1,4 @@
+-- This migration is implemented in migrations.go as it comes from
+-- maunium.net/go/mautrix/crypto/sql_store_upgrade. It runs upgrade at index 4
+-- which is described as "Replace VARCHAR(255) with TEXT in the crypto
+-- database".

+ 4 - 0
database/migrations/13-split-last_used-into-last_encrypted-and-last_decrypted-in-crypto-store.sql

@@ -0,0 +1,4 @@
+-- This migration is implemented in migrations.go as it comes from
+-- maunium.net/go/mautrix/crypto/sql_store_upgrade. It runs upgrade at index 5
+-- which is described as "Split last_used into last_encrypted and
+-- last_decrypted in crypto store".

+ 1 - 0
database/migrations/14-add-encrypted-column-to-portal-table.sql

@@ -0,0 +1 @@
+ALTER TABLE portal ADD COLUMN encrypted BOOLEAN NOT NULL DEFAULT false;

+ 71 - 38
database/migrations/migrations.go

@@ -3,37 +3,18 @@ package migrations
 import (
 	"database/sql"
 	"embed"
-	"sort"
 
 	"github.com/lopezator/migrator"
 	log "maunium.net/go/maulogger/v2"
+	"maunium.net/go/mautrix/crypto/sql_store_upgrade"
 )
 
 //go:embed *.sql
 var embeddedMigrations embed.FS
 
-var (
-	commonMigrations = []string{
-		"01-initial.sql",
-		"02-attachments.sql",
-		"03-emoji.sql",
-		"04-custom-puppet.sql",
-		"05-additional-puppet-fields.sql",
-		"07-guilds.sql",
-	}
-
-	sqliteMigrations = []string{
-		"06-remove-unique-user-constraint.sqlite.sql",
-	}
-
-	postgresMigrations = []string{
-		"06-remove-unique-user-constraint.postgres.sql",
-	}
-)
-
-func migrationFromFile(filename string) *migrator.Migration {
+func migrationFromFile(description, filename string) *migrator.Migration {
 	return &migrator.Migration{
-		Name: filename,
+		Name: description,
 		Func: func(tx *sql.Tx) error {
 			data, err := embeddedMigrations.ReadFile(filename)
 			if err != nil {
@@ -49,31 +30,83 @@ func migrationFromFile(filename string) *migrator.Migration {
 	}
 }
 
-func Run(db *sql.DB, baseLog log.Logger, dialect string) error {
-	subLogger := baseLog.Sub("Migrations")
-	logger := migrator.LoggerFunc(func(msg string, args ...interface{}) {
-		subLogger.Infof(msg, args...)
-	})
-
-	migrationNames := commonMigrations
+func migrationFromFileWithDialect(dialect, description, sqliteFile, postgresFile string) *migrator.Migration {
 	switch dialect {
 	case "sqlite3":
-		migrationNames = append(migrationNames, sqliteMigrations...)
+		return migrationFromFile(description, sqliteFile)
 	case "postgres":
-		migrationNames = append(migrationNames, postgresMigrations...)
+		return migrationFromFile(description, postgresFile)
+	default:
+		return nil
 	}
+}
 
-	sort.Strings(migrationNames)
-
-	migrations := make([]interface{}, len(migrationNames))
-	for idx, name := range migrationNames {
-		migrations[idx] = migrationFromFile(name)
-	}
+func Run(db *sql.DB, baseLog log.Logger, dialect string) error {
+	subLogger := baseLog.Sub("Migrations")
+	logger := migrator.LoggerFunc(func(msg string, args ...interface{}) {
+		subLogger.Infof(msg, args...)
+	})
 
 	m, err := migrator.New(
 		migrator.TableName("version"),
 		migrator.WithLogger(logger),
-		migrator.Migrations(migrations...),
+		migrator.Migrations(
+			migrationFromFile("Initial Schema", "01-initial.sql"),
+			migrationFromFile("Attachments", "02-attachments.sql"),
+			migrationFromFile("Emoji", "03-emoji.sql"),
+			migrationFromFile("Custom Puppets", "04-custom-puppet.sql"),
+			migrationFromFile(
+				"Additional puppet fields",
+				"05-additional-puppet-fields.sql",
+			),
+			migrationFromFileWithDialect(
+				dialect,
+				"Remove unique user constraint",
+				"06-remove-unique-user-constraint.sqlite.sql",
+				"06-remove-unique-user-constraint.postgres.sql",
+			),
+			migrationFromFile("Guild Bridging", "07-guilds.sql"),
+			&migrator.Migration{
+				Name: "Add crypto store to database",
+				Func: func(tx *sql.Tx) error {
+					return sql_store_upgrade.Upgrades[0](tx, dialect)
+				},
+			},
+			&migrator.Migration{
+				Name: "Add account_id to crypto store",
+				Func: func(tx *sql.Tx) error {
+					return sql_store_upgrade.Upgrades[1](tx, dialect)
+				},
+			},
+			&migrator.Migration{
+				Name: "Add megolm withheld data to crypto store",
+				Func: func(tx *sql.Tx) error {
+					return sql_store_upgrade.Upgrades[2](tx, dialect)
+				},
+			},
+			&migrator.Migration{
+				Name: "Add cross-signing keys to crypto store",
+				Func: func(tx *sql.Tx) error {
+					return sql_store_upgrade.Upgrades[3](tx, dialect)
+				},
+			},
+			&migrator.Migration{
+				Name: "Replace VARCHAR(255) with TEXT in the crypto database",
+				Func: func(tx *sql.Tx) error {
+					return sql_store_upgrade.Upgrades[4](tx, dialect)
+				},
+			},
+			&migrator.Migration{
+				Name: "Split last_used into last_encrypted and last_decrypted in crypto store",
+				Func: func(tx *sql.Tx) error {
+					return sql_store_upgrade.Upgrades[5](tx, dialect)
+				},
+			},
+			migrationFromFile(
+				"Add encryption column to portal table",
+				"14-add-encrypted-column-to-portal-table.sql",
+			),
+		),
 	)
 	if err != nil {
 		return err

+ 10 - 6
database/portal.go

@@ -19,6 +19,8 @@ type Portal struct {
 	Name  string
 	Topic string
 
+	Encrypted bool
+
 	Avatar    string
 	AvatarURL id.ContentURI
 
@@ -33,7 +35,8 @@ func (p *Portal) Scan(row Scannable) *Portal {
 	var typ sql.NullInt32
 
 	err := row.Scan(&p.Key.ChannelID, &p.Key.Receiver, &mxid, &p.Name,
-		&p.Topic, &p.Avatar, &avatarURL, &typ, &p.DMUser, &firstEventID)
+		&p.Topic, &p.Avatar, &avatarURL, &typ, &p.DMUser, &firstEventID,
+		&p.Encrypted)
 
 	if err != nil {
 		if err != sql.ErrNoRows {
@@ -62,12 +65,12 @@ func (p *Portal) mxidPtr() *id.RoomID {
 func (p *Portal) Insert() {
 	query := "INSERT INTO portal" +
 		" (channel_id, receiver, mxid, name, topic, avatar, avatar_url," +
-		" type, dmuser, first_event_id)" +
-		" VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)"
+		" type, dmuser, first_event_id, encrypted)" +
+		" VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)"
 
 	_, err := p.db.Exec(query, p.Key.ChannelID, p.Key.Receiver, p.mxidPtr(),
 		p.Name, p.Topic, p.Avatar, p.AvatarURL.String(), p.Type, p.DMUser,
-		p.FirstEventID.String())
+		p.FirstEventID.String(), p.Encrypted)
 
 	if err != nil {
 		p.log.Warnfln("Failed to insert %s: %v", p.Key, err)
@@ -77,11 +80,12 @@ func (p *Portal) Insert() {
 func (p *Portal) Update() {
 	query := "UPDATE portal SET" +
 		" mxid=$1, name=$2, topic=$3, avatar=$4, avatar_url=$5, type=$6," +
-		" dmuser=$7, first_event_id=$8" +
-		" WHERE channel_id=$9 AND receiver=$10"
+		" dmuser=$7, first_event_id=$8, encrypted=$9" +
+		" WHERE channel_id=$10 AND receiver=$11"
 
 	_, err := p.db.Exec(query, p.mxidPtr(), p.Name, p.Topic, p.Avatar,
 		p.AvatarURL.String(), p.Type, p.DMUser, p.FirstEventID.String(),
+		p.Encrypted,
 		p.Key.ChannelID, p.Key.Receiver)
 
 	if err != nil {

+ 11 - 5
database/portalquery.go

@@ -6,6 +6,12 @@ import (
 	"maunium.net/go/mautrix/id"
 )
 
+const (
+	portalSelect = "SELECT channel_id, receiver, mxid, name, topic, avatar," +
+		" avatar_url, type, dmuser, first_event_id, encrypted" +
+		" FROM portal"
+)
+
 type PortalQuery struct {
 	db  *Database
 	log log.Logger
@@ -19,23 +25,23 @@ func (pq *PortalQuery) New() *Portal {
 }
 
 func (pq *PortalQuery) GetAll() []*Portal {
-	return pq.getAll("SELECT * FROM portal")
+	return pq.getAll(portalSelect)
 }
 
 func (pq *PortalQuery) GetByID(key PortalKey) *Portal {
-	return pq.get("SELECT * FROM portal WHERE channel_id=$1 AND receiver=$2", key.ChannelID, key.Receiver)
+	return pq.get(portalSelect+" WHERE channel_id=$1 AND receiver=$2", key.ChannelID, key.Receiver)
 }
 
 func (pq *PortalQuery) GetByMXID(mxid id.RoomID) *Portal {
-	return pq.get("SELECT * FROM portal WHERE mxid=$1", mxid)
+	return pq.get(portalSelect+" WHERE mxid=$1", mxid)
 }
 
 func (pq *PortalQuery) GetAllByID(id string) []*Portal {
-	return pq.getAll("SELECT * FROM portal WHERE receiver=$1", id)
+	return pq.getAll(portalSelect+" WHERE receiver=$1", id)
 }
 
 func (pq *PortalQuery) FindPrivateChats(receiver string) []*Portal {
-	query := "SELECT * FROM portal WHERE receiver=$1 AND type=$2;"
+	query := portalSelect + " portal WHERE receiver=$1 AND type=$2;"
 
 	return pq.getAll(query, receiver, discordgo.ChannelTypeDM)
 }

+ 30 - 0
database/sqlstatestore.go

@@ -272,3 +272,33 @@ func (s *SQLStateStore) HasPowerLevel(roomID id.RoomID, userID id.UserID, eventT
 
 	return s.GetPowerLevel(roomID, userID) >= s.GetPowerLevelRequirement(roomID, eventType)
 }
+
+func (store *SQLStateStore) FindSharedRooms(userID id.UserID) []id.RoomID {
+	query := `
+		SELECT room_id FROM mx_user_profile
+		LEFT JOIN portal ON portal.mxid=mx_user_profile.room_id
+		WHERE user_id=$1 AND portal.encrypted=true
+	`
+
+	rooms := []id.RoomID{}
+
+	rows, err := store.db.Query(query, userID)
+	if err != nil {
+		store.log.Warnfln("Failed to query shared rooms with %s: %v", userID, err)
+
+		return rooms
+	}
+
+	for rows.Next() {
+		var roomID id.RoomID
+
+		err = rows.Scan(&roomID)
+		if err != nil {
+			store.log.Warnfln("Failed to scan room ID: %v", err)
+		} else {
+			rooms = append(rooms, roomID)
+		}
+	}
+
+	return rooms
+}

+ 25 - 0
example-config.yaml

@@ -11,6 +11,8 @@ homeserver:
     # If set, the bridge will make POST requests to this URL whenever a user's discord connection state changes.
     # The bridge will use the appservice as_token to authorize requests.
     status_endpoint: null
+    # Does the homeserver support https://github.com/matrix-org/matrix-spec-proposals/pull/2246?
+    async_media: false
 
 # Application service host/registration related details.
 # Changing these values requires regeneration of the registration.
@@ -110,6 +112,29 @@ bridge:
         # Optional extra text sent when joining a management room.
         additional_help: ""
 
+    # End-to-bridge encryption support options.
+    #
+    # See https://docs.mau.fi/bridges/general/end-to-bridge-encryption.html for more info.
+    encryption:
+        # Allow encryption, work in group chat rooms with e2ee enabled
+        allow: false
+        # Default to encryption, force-enable encryption in all portals the bridge creates
+        # This will cause the bridge bot to be in private chats for the encryption to work properly.
+        # It is recommended to also set private_chat_portal_meta to true when using this.
+        default: false
+        # Options for automatic key sharing.
+        key_sharing:
+            # Enable key sharing? If enabled, key requests for rooms where users are in will be fulfilled.
+            # You must use a client that supports requesting keys from other users to use this feature.
+            allow: false
+            # Require the requesting device to have a valid cross-signing signature?
+            # This doesn't require that the bridge has verified the device, only that the user has verified it.
+            # Not yet implemented.
+            require_cross_signing: false
+            # Require devices to be verified by the bridge?
+            # Verification by the bridge is not yet implemented.
+            require_verification: true
+
 logging:
   directory: ./logs
   file_name_format: '{{.Date}}-{{.Index}}.log'

+ 11 - 7
go.mod

@@ -6,23 +6,27 @@ require (
 	github.com/alecthomas/kong v0.5.0
 	github.com/bwmarrin/discordgo v0.23.2
 	github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
+	github.com/gorilla/mux v1.8.0
 	github.com/gorilla/websocket v1.5.0
-	github.com/lib/pq v1.10.4
+	github.com/lib/pq v1.10.5
 	github.com/lopezator/migrator v0.3.0
 	github.com/mattn/go-sqlite3 v1.14.12
 	github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
 	gopkg.in/yaml.v2 v2.4.0
 	maunium.net/go/maulogger/v2 v2.3.2
-	maunium.net/go/mautrix v0.10.12
+	maunium.net/go/mautrix v0.10.13-0.20220417095934-0eee489b6417
 )
 
 require (
-	github.com/gorilla/mux v1.8.0 // indirect
 	github.com/pkg/errors v0.9.1 // indirect
-	github.com/russross/blackfriday/v2 v2.1.0 // indirect
-	golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29 // indirect
-	golang.org/x/net v0.0.0-20220403103023-749bd193bc2b // indirect
-	golang.org/x/sys v0.0.0-20220406163625-3f8b81556e12 // indirect
+	github.com/tidwall/gjson v1.14.1 // indirect
+	github.com/tidwall/match v1.1.1 // indirect
+	github.com/tidwall/pretty v1.2.0 // indirect
+	github.com/tidwall/sjson v1.2.4 // indirect
+	github.com/yuin/goldmark v1.4.12 // indirect
+	golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f // indirect
+	golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4 // indirect
+	golang.org/x/sys v0.0.0-20220422013727-9388b58f7150 // indirect
 )
 
 replace github.com/bwmarrin/discordgo v0.23.2 => gitlab.com/beeper/discordgo v0.23.3-0.20220219094025-13ff4cc63da7

+ 24 - 14
go.sum

@@ -18,6 +18,8 @@ github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad
 github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
 github.com/lib/pq v1.10.4 h1:SO9z7FRPzA03QhHKJrH5BXA6HU1rS4V2nIVrrNC1iYk=
 github.com/lib/pq v1.10.4/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
+github.com/lib/pq v1.10.5 h1:J+gdV2cUmX7ZqL2B0lFcW0m+egaHC2V3lpO8nWxyYiQ=
+github.com/lib/pq v1.10.5/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
 github.com/lopezator/migrator v0.3.0 h1:VW/rR+J8NYwPdkBxjrFdjwejpgvP59LbmANJxXuNbuk=
 github.com/lopezator/migrator v0.3.0/go.mod h1:bpVAVPkWSvTw8ya2Pk7E/KiNAyDWNImgivQY79o8/8I=
 github.com/mattn/go-sqlite3 v1.14.12 h1:TJ1bhYJPV44phC+IMu1u2K/i5RriLTPe+yc68XDJ1Z0=
@@ -26,8 +28,6 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
-github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -35,31 +35,41 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
 github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY=
 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/tidwall/gjson v1.12.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/gjson v1.14.0 h1:6aeJ0bzojgWLa82gDQHcx3S0Lr/O51I9bJ5nv6JFx5w=
 github.com/tidwall/gjson v1.14.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/gjson v1.14.1 h1:iymTbGkQBhveq21bEvAQ81I0LEBork8BFe1CUZXdyuo=
+github.com/tidwall/gjson v1.14.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
 github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
+github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
 github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/tidwall/sjson v1.2.4 h1:cuiLzLnaMeBhRmEv00Lpk3tkYrcxpmbU81tAY4Dw0tc=
 github.com/tidwall/sjson v1.2.4/go.mod h1:098SZ494YoMWPmMO6ct4dcFnqxwj9r/gF0Etp19pSNM=
+github.com/yuin/goldmark v1.4.11 h1:i45YIzqLnUc2tGaTlJCyUxSG8TvgyGqhqOZOUKIjJ6w=
+github.com/yuin/goldmark v1.4.11/go.mod h1:rmuwmfZ0+bvzB24eSC//bk1R1Zp3hM0OXYv/G2LIilg=
+github.com/yuin/goldmark v1.4.12 h1:6hffw6vALvEDqJ19dOJvJKOoAOKe4NDaTqvd2sktGN0=
+github.com/yuin/goldmark v1.4.12/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
 gitlab.com/beeper/discordgo v0.23.3-0.20220219094025-13ff4cc63da7 h1:8ieR27GadHnShqhsvPrDzL1/ZOntavGGt4TXqafncYE=
 gitlab.com/beeper/discordgo v0.23.3-0.20220219094025-13ff4cc63da7/go.mod h1:Hwfv4M8yP/MDh47BN+4Z1WItJ1umLKUyplCH5KcQPgE=
 golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
-golang.org/x/crypto v0.0.0-20220315160706-3147a52a75dd h1:XcWmESyNjXJMLahc3mqVQJcgSTDxFxhETVlfk9uGc38=
-golang.org/x/crypto v0.0.0-20220315160706-3147a52a75dd/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
-golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29 h1:tkVvjkPTB7pnW3jnid7kNyAMPVWllTNOf/qKDze4p9o=
-golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.0.0-20220408190544-5352b0902921 h1:iU7T1X1J6yxDr0rda54sWGkHgOp5XJrqm79gcNlC2VM=
+golang.org/x/crypto v0.0.0-20220408190544-5352b0902921/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f h1:OeJjE6G4dgCY4PIXvIRQbE8+RX+uXZyGhUy/ksMGJoc=
+golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.0.0-20220225172249-27dd8689420f h1:oA4XRj0qtSt8Yo1Zms0CUlsT3KG69V2UGQWPBxujDmc=
-golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
-golang.org/x/net v0.0.0-20220403103023-749bd193bc2b h1:vI32FkLJNAWtGD4BwkThwEy6XS7ZLLMHkSkYfF8M0W0=
-golang.org/x/net v0.0.0-20220403103023-749bd193bc2b/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
+golang.org/x/net v0.0.0-20220407224826-aac1ed45d8e3 h1:EN5+DfgmRMvRUrMGERW2gQl3Vc+Z7ZMnI/xdEpPSf0c=
+golang.org/x/net v0.0.0-20220407224826-aac1ed45d8e3/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
+golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4 h1:HVyaeDAYux4pnY+D/SiwmLOR36ewZ4iGQIIrtnuCjFA=
+golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220315194320-039c03cc5b86 h1:A9i04dxx7Cribqbs8jf3FQLogkL/CV2YN7hj9KWJCkc=
-golang.org/x/sys v0.0.0-20220315194320-039c03cc5b86/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220406163625-3f8b81556e12 h1:QyVthZKMsyaQwBTJE04jdNN0Pp5Fn9Qga0mrgxyERQM=
 golang.org/x/sys v0.0.0-20220406163625-3f8b81556e12/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220422013727-9388b58f7150 h1:xHms4gcpe1YE7A3yIllJXP16CMAGuqwO2lX1mTyyRRc=
+golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -77,5 +87,5 @@ gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v
 gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 maunium.net/go/maulogger/v2 v2.3.2 h1:1XmIYmMd3PoQfp9J+PaHhpt80zpfmMqaShzUTC7FwY0=
 maunium.net/go/maulogger/v2 v2.3.2/go.mod h1:TYWy7wKwz/tIXTpsx8G3mZseIRiC5DoMxSZazOHy68A=
-maunium.net/go/mautrix v0.10.12 h1:GqmsksKyKrTqmLb2B6yGOawoFLPTJ3A3NtXrygAvKM8=
-maunium.net/go/mautrix v0.10.12/go.mod h1:xTq6+uMCAXtQwfqjUrYd8O10oIyymbzZm02CYOMt4ek=
+maunium.net/go/mautrix v0.10.13-0.20220417095934-0eee489b6417 h1:dEJ9MKQvd4v2Rk2W6EUiO1T6PrSWPsB/JQOHQn4H6X0=
+maunium.net/go/mautrix v0.10.13-0.20220417095934-0eee489b6417/go.mod h1:zOor2zO/F10T/GbU67vWr0vnhLso88rlRr1HIrb1XWU=