From 868fa9c13b6a8e50ca9b161d095a200ef7f4c260 Mon Sep 17 00:00:00 2001 From: highesttt Date: Wed, 1 Jul 2026 10:56:29 -0400 Subject: [PATCH 1/2] fix: chat keys not updating when refreshing --- pkg/connector/client.go | 26 +++++++++++++++ pkg/connector/connector.go | 51 +++++++++++++++++++++--------- pkg/connector/login_keys_test.go | 38 ++++++++++++++++++++++ pkg/connector/send_message.go | 21 ++++++++++++ pkg/connector/send_message_test.go | 44 ++++++++++++++++++++++++++ pkg/e2ee/manager.go | 10 ++++-- pkg/e2ee/manager_test.go | 36 +++++++++++++++++++++ 7 files changed, 209 insertions(+), 17 deletions(-) create mode 100644 pkg/connector/login_keys_test.go create mode 100644 pkg/connector/send_message_test.go create mode 100644 pkg/e2ee/manager_test.go diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 4ee7293..e9d680a 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -21,6 +21,7 @@ import ( var errLineSessionInvalidated = errors.New("LINE session invalidated by another client") var newLineAPIClient = line.NewClient +var newE2EEManager = e2ee.NewManager var loginWithCredentials = func(email, password, certificate string) (*line.LoginResult, error) { return newLineAPIClient("").Login(email, password, certificate) @@ -545,6 +546,9 @@ func (lc *LineClient) tryLogin(ctx context.Context) error { if res.Certificate != "" { meta.Certificate = res.Certificate } + if err := lc.refreshLoginE2EEKeys(res, meta, newLineAPIClient(accessToken)); err != nil { + lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Failed to refresh E2EE keys after re-login") + } if err := lc.UserLogin.Save(ctx); err != nil { lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Failed to save new tokens to DB") } @@ -554,6 +558,28 @@ func (lc *LineClient) tryLogin(ctx context.Context) error { return nil } +func (lc *LineClient) refreshLoginE2EEKeys(res *line.LoginResult, meta *UserLoginMetadata, client *line.Client) error { + if res.EncryptedKeyChain == "" || res.E2EEPublicKey == "" { + return nil + } + saveLoginE2EEKeyMetadata(meta, res) + mgr, exported, err := exportLoginE2EEKeys(res, client) + if err != nil { + return err + } + meta.ExportedKeyMap = exported + if err := mgr.SaveSecureDataToFile(loginSecureDataID(meta, string(lc.UserLogin.ID)), map[string]any{"exportedKeyMap": exported}); err != nil { + lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Failed to save E2EE secure data after re-login") + } + if lc.E2EE != nil { + if err := lc.E2EE.LoadMyKeyFromExportedMap(exported); err != nil { + return fmt.Errorf("load exported keys into active E2EE manager: %w", err) + } + } + lc.UserLogin.Bridge.Log.Info().Int("keys", len(exported)).Msg("Refreshed E2EE keys after re-login") + return nil +} + func (lc *LineClient) ensureValidToken(ctx context.Context) error { _, err := getProfileWithToken(lc.getAccessToken()) if err == nil { diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index fffb4f6..119ae87 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -470,35 +470,56 @@ func (ll *LineEmailLogin) finishLogin(ctx context.Context, res *line.LoginResult }, nil } -func (ll *LineEmailLogin) fetchLoginKeys(res *line.LoginResult, meta *UserLoginMetadata, client *line.Client) { +func exportLoginE2EEKeys(res *line.LoginResult, client *line.Client) (*e2ee.Manager, map[string]string, error) { if res.EncryptedKeyChain == "" || res.E2EEPublicKey == "" { - return + return nil, nil, nil } - meta.EncryptedKeyChain = res.EncryptedKeyChain - meta.E2EEPublicKey = res.E2EEPublicKey - meta.E2EEVersion = res.E2EEVersion - meta.E2EEKeyID = res.E2EEKeyID - mgr, err := e2ee.NewManager() + mgr, err := newE2EEManager() if err != nil { - ll.User.Bridge.Log.Warn().Err(err).Msg("Login: failed to create E2EE manager") - return + return nil, nil, fmt.Errorf("create E2EE manager: %w", err) } ei3, err := client.GetEncryptedIdentityV3() if err != nil { - ll.User.Bridge.Log.Warn().Err(err).Msg("Login: failed to get EncryptedIdentityV3") - return + return nil, nil, fmt.Errorf("get EncryptedIdentityV3: %w", err) } if err := mgr.InitStorage(ei3.WrappedNonce, ei3.KDFParameter1, ei3.KDFParameter2); err != nil { - ll.User.Bridge.Log.Warn().Err(err).Msg("Login: InitStorage failed") - return + return nil, nil, fmt.Errorf("init storage: %w", err) } exported, err := mgr.InitFromLoginKeyChain(res.E2EEPublicKey, res.EncryptedKeyChain) if err != nil { - ll.User.Bridge.Log.Warn().Err(err).Msg("Login: InitFromLoginKeyChain failed") + return nil, nil, fmt.Errorf("init from login keychain: %w", err) + } + return mgr, exported, nil +} + +func saveLoginE2EEKeyMetadata(meta *UserLoginMetadata, res *line.LoginResult) { + meta.EncryptedKeyChain = res.EncryptedKeyChain + meta.E2EEPublicKey = res.E2EEPublicKey + meta.E2EEVersion = res.E2EEVersion + meta.E2EEKeyID = res.E2EEKeyID +} + +func loginSecureDataID(meta *UserLoginMetadata, fallback string) string { + if meta.Mid != "" { + return meta.Mid + } + return fallback +} + +func (ll *LineEmailLogin) fetchLoginKeys(res *line.LoginResult, meta *UserLoginMetadata, client *line.Client) { + if res.EncryptedKeyChain == "" || res.E2EEPublicKey == "" { + return + } + saveLoginE2EEKeyMetadata(meta, res) + mgr, exported, err := exportLoginE2EEKeys(res, client) + if err != nil { + ll.User.Bridge.Log.Warn().Err(err).Msg("Login: failed to export E2EE keys") return } meta.ExportedKeyMap = exported - _ = mgr.SaveSecureDataToFile(string(ll.User.MXID), map[string]any{"exportedKeyMap": exported}) + if err := mgr.SaveSecureDataToFile(loginSecureDataID(meta, string(ll.User.MXID)), map[string]any{"exportedKeyMap": exported}); err != nil { + ll.User.Bridge.Log.Warn().Err(err).Msg("Login: failed to save E2EE secure data") + } ll.User.Bridge.Log.Info().Int("keys", len(exported)).Msg("Login: E2EE keys exported successfully") } diff --git a/pkg/connector/login_keys_test.go b/pkg/connector/login_keys_test.go new file mode 100644 index 0000000..b4dd3d0 --- /dev/null +++ b/pkg/connector/login_keys_test.go @@ -0,0 +1,38 @@ +package connector + +import ( + "testing" + + "github.com/highesttt/matrix-line-messenger/pkg/line" +) + +func TestSaveLoginE2EEKeyMetadata(t *testing.T) { + meta := &UserLoginMetadata{} + res := &line.LoginResult{ + EncryptedKeyChain: "encrypted-keychain", + E2EEPublicKey: "public-key", + E2EEVersion: "2", + E2EEKeyID: "5625926", + } + + saveLoginE2EEKeyMetadata(meta, res) + + if meta.EncryptedKeyChain != res.EncryptedKeyChain || + meta.E2EEPublicKey != res.E2EEPublicKey || + meta.E2EEVersion != res.E2EEVersion || + meta.E2EEKeyID != res.E2EEKeyID { + t.Fatalf("metadata = %#v, want login E2EE fields copied", meta) + } +} + +func TestLoginSecureDataIDPrefersLineMID(t *testing.T) { + meta := &UserLoginMetadata{Mid: "u-line-mid"} + if got := loginSecureDataID(meta, "@user:example.com"); got != "u-line-mid" { + t.Fatalf("loginSecureDataID = %q, want LINE MID", got) + } + + meta.Mid = "" + if got := loginSecureDataID(meta, "@user:example.com"); got != "@user:example.com" { + t.Fatalf("loginSecureDataID fallback = %q, want Matrix fallback", got) + } +} diff --git a/pkg/connector/send_message.go b/pkg/connector/send_message.go index b586790..6162379 100644 --- a/pkg/connector/send_message.go +++ b/pkg/connector/send_message.go @@ -5,6 +5,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "image" "image/jpeg" @@ -18,6 +19,7 @@ import ( "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/event" + "github.com/highesttt/matrix-line-messenger/pkg/e2ee" "github.com/highesttt/matrix-line-messenger/pkg/line" ) @@ -30,6 +32,20 @@ type mentionEntry struct { var mentionLinkRegex = regexp.MustCompile(`]*href="https://matrix\.to/#/([^"]+)"[^>]*>([^<]+)`) +const lineGroupE2EEReconnectNotice = "LINE encryption keys for this group are unavailable. Reconnect LINE in Beeper, then try sending again." + +func lineGroupE2EEReconnectRequiredError(err error) error { + if !errors.Is(err, e2ee.ErrMissingOwnPrivateKey) { + return nil + } + return bridgev2.WrapErrorInStatus(fmt.Errorf("%s: %w", lineGroupE2EEReconnectNotice, err)). + WithStatus(event.MessageStatusFail). + WithIsCertain(true). + WithMessage(lineGroupE2EEReconnectNotice). + WithSendNotice(true). + WithErrorReason(event.MessageStatusGenericError) +} + func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.MatrixMessage) (*bridgev2.MatrixMessageResponse, error) { client := lc.newClient() callLineErr := func(call func(*line.Client) error) error { @@ -556,6 +572,9 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat if isGroup { if errFetch := lc.fetchAndUnwrapGroupKey(ctx, portalMid, 0); errFetch != nil { lc.UserLogin.Bridge.Log.Debug().Err(errFetch).Str("chat_mid", portalMid).Msg("fetchAndUnwrapGroupKey before encrypt failed") + if errStatus := lineGroupE2EEReconnectRequiredError(errFetch); errStatus != nil { + return nil, errStatus + } } if contentType != int(ContentText) { chunks, err = lc.E2EE.EncryptGroupMessageRaw(portalMid, fromMid, contentType, payload) @@ -569,6 +588,8 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat } else { chunks, err = lc.E2EE.EncryptGroupMessage(portalMid, fromMid, msg.Content.Body) } + } else if errStatus := lineGroupE2EEReconnectRequiredError(errFetch); errStatus != nil { + return nil, errStatus } if err != nil { // E2EE setup failed — fall back to plain text diff --git a/pkg/connector/send_message_test.go b/pkg/connector/send_message_test.go new file mode 100644 index 0000000..7264595 --- /dev/null +++ b/pkg/connector/send_message_test.go @@ -0,0 +1,44 @@ +package connector + +import ( + "errors" + "fmt" + "testing" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/event" + + "github.com/highesttt/matrix-line-messenger/pkg/e2ee" + "github.com/highesttt/matrix-line-messenger/pkg/line" +) + +func TestLineGroupE2EEReconnectRequiredError(t *testing.T) { + err := lineGroupE2EEReconnectRequiredError(fmt.Errorf("failed to unwrap group key: %w for 5625926", e2ee.ErrMissingOwnPrivateKey)) + if err == nil { + t.Fatal("expected missing private key to produce a message status error") + } + + var status bridgev2.MessageStatus + if !errors.As(err, &status) { + t.Fatalf("error %T does not wrap bridgev2.MessageStatus", err) + } + if status.Status != event.MessageStatusFail { + t.Fatalf("Status = %q, want %q", status.Status, event.MessageStatusFail) + } + if !status.IsCertain || !status.SendNotice { + t.Fatalf("status certainty/notice flags = %v/%v, want true/true", status.IsCertain, status.SendNotice) + } + if status.Message != lineGroupE2EEReconnectNotice { + t.Fatalf("Message = %q, want %q", status.Message, lineGroupE2EEReconnectNotice) + } + if !errors.Is(err, e2ee.ErrMissingOwnPrivateKey) { + t.Fatalf("err = %v, want to wrap ErrMissingOwnPrivateKey", err) + } +} + +func TestLineGroupE2EEReconnectRequiredErrorIgnoresNoUsableGroupKey(t *testing.T) { + err := lineGroupE2EEReconnectRequiredError(line.ErrNoUsableE2EEGroupKey) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } +} diff --git a/pkg/e2ee/manager.go b/pkg/e2ee/manager.go index de520b3..2859c91 100644 --- a/pkg/e2ee/manager.go +++ b/pkg/e2ee/manager.go @@ -4,6 +4,7 @@ import ( "encoding/base64" "encoding/binary" "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -16,6 +17,11 @@ import ( "github.com/highesttt/matrix-line-messenger/pkg/line" ) +var ( + ErrMissingOwnPrivateKey = errors.New("missing my private key") + ErrGroupKeyNotLoaded = errors.New("e2ee key not loaded") +) + /* This wraps the JS runner to perform storage init, key/channel handling, and e2ee encrypt/decrypt. */ @@ -457,7 +463,7 @@ func (m *Manager) UnwrapGroupSharedKey(chatMid string, sharedKey *line.E2EEGroup } if myRunnerKey == 0 { - return 0, fmt.Errorf("missing my private key for %d", receiverRawKeyID) + return 0, fmt.Errorf("%w for %d", ErrMissingOwnPrivateKey, receiverRawKeyID) } chanID, err := m.runner.ChannelCreate(myRunnerKey, creatorPubKeyB64) @@ -624,7 +630,7 @@ func (m *Manager) EncryptGroupMessageRaw(chatMid, fromMid string, contentType in m.mu.Unlock() if !ok || unwrappedKeyID == 0 { - return nil, fmt.Errorf("e2ee key not loaded for group %s", chatMid) + return nil, fmt.Errorf("%w for group %s", ErrGroupKeyNotLoaded, chatMid) } chanID, err := m.ensureGroupChannel(chatMid, groupKeyID, unwrappedKeyID, myKeyID) diff --git a/pkg/e2ee/manager_test.go b/pkg/e2ee/manager_test.go new file mode 100644 index 0000000..5034b52 --- /dev/null +++ b/pkg/e2ee/manager_test.go @@ -0,0 +1,36 @@ +package e2ee + +import ( + "errors" + "testing" + + "github.com/highesttt/matrix-line-messenger/pkg/line" +) + +func TestUnwrapGroupSharedKeyReturnsMissingOwnPrivateKey(t *testing.T) { + manager := &Manager{ + peerPublic: map[int]string{1234: "creator-public-key"}, + keyByRawID: map[int]int{}, + } + + _, err := manager.UnwrapGroupSharedKey("c-group", &line.E2EEGroupSharedKey{ + CreatorKeyID: 1234, + ReceiverKeyID: 5625926, + }) + if !errors.Is(err, ErrMissingOwnPrivateKey) { + t.Fatalf("err = %v, want ErrMissingOwnPrivateKey", err) + } +} + +func TestEncryptGroupMessageRawReturnsGroupKeyNotLoaded(t *testing.T) { + manager := &Manager{ + sequence: map[string]int{}, + groupKeys: map[string]map[int]int{}, + latestGroupKey: map[string]int{}, + } + + _, err := manager.EncryptGroupMessageRaw("c-group", "u-sender", 0, []byte(`{"text":"hello"}`)) + if !errors.Is(err, ErrGroupKeyNotLoaded) { + t.Fatalf("err = %v, want ErrGroupKeyNotLoaded", err) + } +} From 5a167978c8cb27cf16f5957a9bbecbd5feeb0864 Mon Sep 17 00:00:00 2001 From: highesttt Date: Wed, 1 Jul 2026 11:11:32 -0400 Subject: [PATCH 2/2] fix: e2ee keychain metadata is now only written after exportLoginE2EEKeys succeeds --- pkg/connector/client.go | 2 +- pkg/connector/connector.go | 2 +- pkg/connector/login_keys_test.go | 39 ++++++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/pkg/connector/client.go b/pkg/connector/client.go index e9d680a..0e1a343 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -562,11 +562,11 @@ func (lc *LineClient) refreshLoginE2EEKeys(res *line.LoginResult, meta *UserLogi if res.EncryptedKeyChain == "" || res.E2EEPublicKey == "" { return nil } - saveLoginE2EEKeyMetadata(meta, res) mgr, exported, err := exportLoginE2EEKeys(res, client) if err != nil { return err } + saveLoginE2EEKeyMetadata(meta, res) meta.ExportedKeyMap = exported if err := mgr.SaveSecureDataToFile(loginSecureDataID(meta, string(lc.UserLogin.ID)), map[string]any{"exportedKeyMap": exported}); err != nil { lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Failed to save E2EE secure data after re-login") diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 119ae87..886b3cf 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -510,12 +510,12 @@ func (ll *LineEmailLogin) fetchLoginKeys(res *line.LoginResult, meta *UserLoginM if res.EncryptedKeyChain == "" || res.E2EEPublicKey == "" { return } - saveLoginE2EEKeyMetadata(meta, res) mgr, exported, err := exportLoginE2EEKeys(res, client) if err != nil { ll.User.Bridge.Log.Warn().Err(err).Msg("Login: failed to export E2EE keys") return } + saveLoginE2EEKeyMetadata(meta, res) meta.ExportedKeyMap = exported if err := mgr.SaveSecureDataToFile(loginSecureDataID(meta, string(ll.User.MXID)), map[string]any{"exportedKeyMap": exported}); err != nil { ll.User.Bridge.Log.Warn().Err(err).Msg("Login: failed to save E2EE secure data") diff --git a/pkg/connector/login_keys_test.go b/pkg/connector/login_keys_test.go index b4dd3d0..c31e387 100644 --- a/pkg/connector/login_keys_test.go +++ b/pkg/connector/login_keys_test.go @@ -1,8 +1,10 @@ package connector import ( + "errors" "testing" + "github.com/highesttt/matrix-line-messenger/pkg/e2ee" "github.com/highesttt/matrix-line-messenger/pkg/line" ) @@ -36,3 +38,40 @@ func TestLoginSecureDataIDPrefersLineMID(t *testing.T) { t.Fatalf("loginSecureDataID fallback = %q, want Matrix fallback", got) } } + +func TestRefreshLoginE2EEKeysKeepsMetadataOnExportFailure(t *testing.T) { + oldManager := newE2EEManager + exportErr := errors.New("manager unavailable") + newE2EEManager = func() (*e2ee.Manager, error) { + return nil, exportErr + } + t.Cleanup(func() { + newE2EEManager = oldManager + }) + + meta := &UserLoginMetadata{ + EncryptedKeyChain: "old-keychain", + E2EEPublicKey: "old-public-key", + E2EEVersion: "1", + E2EEKeyID: "old-key-id", + ExportedKeyMap: map[string]string{"old-key-id": "old-export"}, + } + res := &line.LoginResult{ + EncryptedKeyChain: "new-keychain", + E2EEPublicKey: "new-public-key", + E2EEVersion: "2", + E2EEKeyID: "new-key-id", + } + + err := (&LineClient{}).refreshLoginE2EEKeys(res, meta, nil) + if !errors.Is(err, exportErr) { + t.Fatalf("err = %v, want %v", err, exportErr) + } + if meta.EncryptedKeyChain != "old-keychain" || + meta.E2EEPublicKey != "old-public-key" || + meta.E2EEVersion != "1" || + meta.E2EEKeyID != "old-key-id" || + meta.ExportedKeyMap["old-key-id"] != "old-export" { + t.Fatalf("metadata was clobbered on export failure: %#v", meta) + } +}