diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 0e1a343..30c6d39 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -20,6 +20,8 @@ import ( var errLineSessionInvalidated = errors.New("LINE session invalidated by another client") +const lineMissingE2EEKeyMessage = "LINE encryption keys are unavailable. Reconnect LINE in Beeper to restore message decryption." + var newLineAPIClient = line.NewClient var newE2EEManager = e2ee.NewManager @@ -44,9 +46,11 @@ type LineClient struct { sentReqSeqs map[int]time.Time lastReqSeq int - tokenMu sync.RWMutex - recoverMu sync.Mutex - recoverTime time.Time + tokenMu sync.RWMutex + recoverMu sync.Mutex + recoverTime time.Time + missingE2EEKeyMu sync.Mutex + missingE2EEKeyMarked bool // sessionInvalidated is set when LINE forcefully logs out this Chrome-style // session. It prevents background calls from re-logging in before the user // clicks Reconnect. @@ -303,6 +307,49 @@ func (lc *LineClient) saveSessionInvalidated(ctx context.Context) { } } +func (lc *LineClient) markMissingE2EEKey(ctx context.Context, err error) { + if !errors.Is(err, e2ee.ErrMissingOwnPrivateKey) || lc.UserLogin == nil { + return + } + lc.missingE2EEKeyMu.Lock() + if lc.missingE2EEKeyMarked { + lc.missingE2EEKeyMu.Unlock() + return + } + lc.missingE2EEKeyMarked = true + if meta, ok := lc.UserLogin.Metadata.(*UserLoginMetadata); ok { + needsSave := !meta.ForceFullE2EELogin || meta.Certificate != "" + meta.ForceFullE2EELogin = true + meta.Certificate = "" + if !needsSave { + lc.missingE2EEKeyMu.Unlock() + return + } + lc.missingE2EEKeyMu.Unlock() + if errSave := lc.UserLogin.Save(ctx); errSave != nil && lc.UserLogin.Bridge != nil { + lc.UserLogin.Bridge.Log.Warn().Err(errSave).Msg("Failed to save LINE E2EE reconnect requirement") + } + } else { + lc.missingE2EEKeyMu.Unlock() + } + if lc.UserLogin.Bridge != nil { + lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("LINE E2EE private key missing; marking login for full reconnect") + } + lc.UserLogin.BridgeState.Send(status.BridgeState{ + StateEvent: status.StateBadCredentials, + Error: "line-e2ee-key-missing", + Message: lineMissingE2EEKeyMessage, + UserAction: status.UserActionRelogin, + }) +} + +func (lc *LineClient) applyRefreshedLoginE2EEKeys(meta *UserLoginMetadata, res *line.LoginResult, exported map[string]string) { + lc.missingE2EEKeyMu.Lock() + defer lc.missingE2EEKeyMu.Unlock() + applyExportedLoginE2EEKeys(meta, res, exported) + lc.missingE2EEKeyMarked = false +} + // recoverToken attempts to restore a valid session by refreshing, then re-logging in. // Returns nil on success. On failure the caller should send StateBadCredentials. func (lc *LineClient) recoverToken(ctx context.Context) error { @@ -566,16 +613,15 @@ func (lc *LineClient) refreshLoginE2EEKeys(res *line.LoginResult, meta *UserLogi 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") - } 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.applyRefreshedLoginE2EEKeys(meta, res, 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") + } lc.UserLogin.Bridge.Log.Info().Int("keys", len(exported)).Msg("Refreshed E2EE keys after re-login") return nil } diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 70ee62b..54d05c7 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -116,6 +116,7 @@ type UserLoginMetadata struct { E2EEVersion string `json:"e2ee_version,omitempty"` E2EEKeyID string `json:"e2ee_key_id,omitempty"` ExportedKeyMap map[string]string `json:"exported_key_map,omitempty"` + ForceFullE2EELogin bool `json:"force_full_e2ee_login,omitempty"` BlockedMIDs []string `json:"blocked_mids,omitempty"` } @@ -204,10 +205,13 @@ func (ll *LineEmailLogin) StartWithOverride(ctx context.Context, override *bridg if ll.Email == "" || ll.Password == "" { return ll.loginErrorStep("No stored LINE credentials are available. Please enter your LINE email and password to reconnect."), nil } - if len(meta.ExportedKeyMap) == 0 { + if meta.ForceFullE2EELogin || len(meta.ExportedKeyMap) == 0 { ll.Certificate = "" if override.Bridge != nil { - override.Bridge.Log.Info().Msg("No stored LINE E2EE keys, forcing full reconnect") + override.Bridge.Log.Info(). + Bool("missing_e2ee_key", meta.ForceFullE2EELogin). + Bool("has_exported_keys", len(meta.ExportedKeyMap) > 0). + Msg("Forcing full LINE reconnect to refresh E2EE keys") } } @@ -445,7 +449,7 @@ func (ll *LineEmailLogin) finishLogin(ctx context.Context, res *line.LoginResult meta := &UserLoginMetadata{AccessToken: token, RefreshToken: refreshToken, Email: ll.Email, Password: ll.Password, Certificate: certificate, Mid: mid} exportedKeys := ll.fetchLoginKeys(res, meta, client) - if !exportedKeys && ll.ExistingMetadata != nil && len(ll.ExistingMetadata.ExportedKeyMap) > 0 { + if shouldPreserveExistingE2EEKeys(exportedKeys, ll.ExistingMetadata) { copyLoginE2EEKeyMetadata(meta, ll.ExistingMetadata) ll.User.Bridge.Log.Info().Int("keys", len(meta.ExportedKeyMap)).Msg("Preserved existing E2EE keys after re-login") } @@ -515,6 +519,12 @@ func saveLoginE2EEKeyMetadata(meta *UserLoginMetadata, res *line.LoginResult) { meta.E2EEKeyID = res.E2EEKeyID } +func applyExportedLoginE2EEKeys(meta *UserLoginMetadata, res *line.LoginResult, exported map[string]string) { + saveLoginE2EEKeyMetadata(meta, res) + meta.ExportedKeyMap = exported + meta.ForceFullE2EELogin = false +} + func copyLoginE2EEKeyMetadata(dst, src *UserLoginMetadata) { dst.EncryptedKeyChain = src.EncryptedKeyChain dst.E2EEPublicKey = src.E2EEPublicKey @@ -528,6 +538,10 @@ func copyLoginE2EEKeyMetadata(dst, src *UserLoginMetadata) { } } +func shouldPreserveExistingE2EEKeys(exportedKeys bool, existing *UserLoginMetadata) bool { + return !exportedKeys && existing != nil && len(existing.ExportedKeyMap) > 0 && !existing.ForceFullE2EELogin +} + func loginSecureDataID(meta *UserLoginMetadata, fallback string) string { if meta.Mid != "" { return meta.Mid @@ -544,8 +558,7 @@ func (ll *LineEmailLogin) fetchLoginKeys(res *line.LoginResult, meta *UserLoginM ll.User.Bridge.Log.Warn().Err(err).Msg("Login: failed to export E2EE keys") return false } - saveLoginE2EEKeyMetadata(meta, res) - meta.ExportedKeyMap = exported + applyExportedLoginE2EEKeys(meta, res, 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/handle_message.go b/pkg/connector/handle_message.go index 13abba9..51ee5c6 100644 --- a/pkg/connector/handle_message.go +++ b/pkg/connector/handle_message.go @@ -50,7 +50,7 @@ func e2eeChunkLengths(chunks []string) []int { return lengths } -func groupDecryptLogContext(evt *zerolog.Event, msg *line.Message, chatMID string, opType int) *zerolog.Event { +func messageDecryptLogContext(evt *zerolog.Event, msg *line.Message, chatMID string, opType int, finalKeyIDField string) *zerolog.Event { evt = evt. Str("msg_id", msg.ID). Str("chat_mid", chatMID). @@ -71,16 +71,24 @@ func groupDecryptLogContext(evt *zerolog.Event, msg *line.Message, chatMID strin } else { evt = evt.Str("sender_key_id_error", err.Error()) } - if groupKeyID, err := e2ee.DecodeKeyID(msg.Chunks[len(msg.Chunks)-1]); err == nil { - evt = evt.Int("group_key_id", groupKeyID) + if finalKeyID, err := e2ee.DecodeKeyID(msg.Chunks[len(msg.Chunks)-1]); err == nil { + evt = evt.Int(finalKeyIDField, finalKeyID) } else { - evt = evt.Str("group_key_id_error", err.Error()) + evt = evt.Str(finalKeyIDField+"_error", err.Error()) } } return evt } +func groupDecryptLogContext(evt *zerolog.Event, msg *line.Message, chatMID string, opType int) *zerolog.Event { + return messageDecryptLogContext(evt, msg, chatMID, opType, "group_key_id") +} + +func directDecryptLogContext(evt *zerolog.Event, msg *line.Message, chatMID string, opType int) *zerolog.Event { + return messageDecryptLogContext(evt, msg, chatMID, opType, "receiver_key_id") +} + type messageWithChatInfo struct { *simplevent.Message[line.Message] GetChatInfoFunc func(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) @@ -264,36 +272,56 @@ func (lc *LineClient) decryptMessageBody(msg *line.Message, portalIDStr string, bodyText = pt decryptionFailed = false } else { - lc.UserLogin.Bridge.Log.Debug().Err(err).Msg("DecryptMessageV2 failed on first attempt") + directDecryptLogContext(lc.UserLogin.Bridge.Log.Debug().Err(err), msg, portalIDStr, opType). + Msg("DecryptMessageV2 failed on first attempt") if _, _, errKey := lc.E2EE.MyKeyIDs(); errKey != nil { - lc.UserLogin.Bridge.Log.Error().Msg("E2EE own key not loaded — cannot decrypt any messages. Re-login required.") + directDecryptLogContext(lc.UserLogin.Bridge.Log.Error().Err(errKey), msg, portalIDStr, opType). + Msg("E2EE own key not loaded; cannot decrypt any messages. Re-login required") + lc.markMissingE2EEKey(context.Background(), fmt.Errorf("%w: %v", e2ee.ErrMissingOwnPrivateKey, errKey)) } else { peerMid := msg.From - if peerMid == lc.Mid || peerMid == string(lc.UserLogin.ID) { + peerKeyID := 0 + messageFromMe := peerMid == lc.Mid || peerMid == string(lc.UserLogin.ID) + if messageFromMe { peerMid = msg.To } // Fetch the EXACT keyID the message used (handles peer key rotation) // before falling back to negotiating the peer's current key. fetched := false if len(msg.Chunks) >= 5 { - if receiverKeyID, errKID := e2ee.DecodeKeyID(msg.Chunks[len(msg.Chunks)-1]); errKID == nil && receiverKeyID != 0 { - if _, _, errPeer := lc.ensurePeerKeyByID(context.Background(), peerMid, receiverKeyID); errPeer == nil { + senderKeyID, errSender := e2ee.DecodeKeyID(msg.Chunks[len(msg.Chunks)-2]) + receiverKeyID, errReceiver := e2ee.DecodeKeyID(msg.Chunks[len(msg.Chunks)-1]) + if errSender == nil { + peerKeyID = senderKeyID + } + if messageFromMe && errReceiver == nil { + peerKeyID = receiverKeyID + } + if peerKeyID != 0 { + if _, _, errPeer := lc.ensurePeerKeyByID(context.Background(), peerMid, peerKeyID); errPeer == nil { fetched = true } else { - lc.UserLogin.Bridge.Log.Debug().Err(errPeer).Str("peer", peerMid).Int("key_id", receiverKeyID).Msg("ensurePeerKeyByID failed on retry, falling back to NegotiateE2EEPublicKey") + directDecryptLogContext(lc.UserLogin.Bridge.Log.Debug().Err(errPeer), msg, portalIDStr, opType). + Str("peer", peerMid). + Int("key_id", peerKeyID). + Msg("ensurePeerKeyByID failed on retry, falling back to NegotiateE2EEPublicKey") } } } if !fetched { if _, _, errPeer := lc.ensurePeerKey(context.Background(), peerMid); errPeer != nil { - lc.UserLogin.Bridge.Log.Warn().Err(errPeer).Str("peer", peerMid).Msg("Failed to force-fetch peer key for retry") + directDecryptLogContext(lc.UserLogin.Bridge.Log.Warn().Err(errPeer), msg, portalIDStr, opType). + Str("peer", peerMid). + Msg("Failed to force-fetch peer key for retry") } } if ptRetry, errRetry := lc.E2EE.DecryptMessageV2(msg); errRetry == nil { bodyText = ptRetry decryptionFailed = false } else { - lc.UserLogin.Bridge.Log.Warn().Err(errRetry).Msg("DecryptMessageV2 failed on retry") + directDecryptLogContext(lc.UserLogin.Bridge.Log.Warn().Err(errRetry), msg, portalIDStr, opType). + Msg("DecryptMessageV2 failed on retry") + lc.markMissingE2EEKey(context.Background(), errRetry) } } } diff --git a/pkg/connector/login_keys_test.go b/pkg/connector/login_keys_test.go index bbc73bf..43bb1e0 100644 --- a/pkg/connector/login_keys_test.go +++ b/pkg/connector/login_keys_test.go @@ -33,6 +33,37 @@ func TestSaveLoginE2EEKeyMetadata(t *testing.T) { } } +func TestApplyExportedLoginE2EEKeysClearsForcedReconnect(t *testing.T) { + meta := &UserLoginMetadata{ + EncryptedKeyChain: "old-keychain", + E2EEPublicKey: "old-public-key", + E2EEVersion: "1", + E2EEKeyID: "old-key-id", + ExportedKeyMap: map[string]string{"old-key-id": "old-export"}, + ForceFullE2EELogin: true, + } + res := &line.LoginResult{ + EncryptedKeyChain: "new-keychain", + E2EEPublicKey: "new-public-key", + E2EEVersion: "2", + E2EEKeyID: "new-key-id", + } + exported := map[string]string{"new-key-id": "new-export"} + + applyExportedLoginE2EEKeys(meta, res, exported) + + if meta.EncryptedKeyChain != res.EncryptedKeyChain || + meta.E2EEPublicKey != res.E2EEPublicKey || + meta.E2EEVersion != res.E2EEVersion || + meta.E2EEKeyID != res.E2EEKeyID || + meta.ExportedKeyMap["new-key-id"] != "new-export" { + t.Fatalf("metadata = %#v, want refreshed E2EE fields", meta) + } + if meta.ForceFullE2EELogin { + t.Fatal("successful key refresh must clear ForceFullE2EELogin") + } +} + func TestCopyLoginE2EEKeyMetadataClonesExportedKeyMap(t *testing.T) { src := &UserLoginMetadata{ EncryptedKeyChain: "encrypted-keychain", @@ -133,6 +164,53 @@ func TestStartWithOverrideKeepsCertificateWithStoredE2EEKeys(t *testing.T) { } } +func TestStartWithOverrideForcesFullReconnectWhenE2EEKeyMissing(t *testing.T) { + oldLogin := loginWithCredentials + var gotCertificate string + loginWithCredentials = func(_, _, certificate string) (*line.LoginResult, error) { + gotCertificate = certificate + return nil, errors.New("login failed") + } + t.Cleanup(func() { + loginWithCredentials = oldLogin + }) + + ll := &LineEmailLogin{} + override := &bridgev2.UserLogin{ + Bridge: &bridgev2.Bridge{Log: zerolog.New(io.Discard)}, + UserLogin: &database.UserLogin{ + Metadata: &UserLoginMetadata{ + Email: "user@example.com", + Password: "password", + Certificate: "stored-certificate", + ExportedKeyMap: map[string]string{"old-key-id": "old-export"}, + ForceFullE2EELogin: true, + }, + }, + } + + if _, err := ll.StartWithOverride(context.Background(), override); err != nil { + t.Fatalf("StartWithOverride returned error: %v", err) + } + if gotCertificate != "" { + t.Fatalf("certificate = %q, want empty to force full E2EE reconnect", gotCertificate) + } +} + +func TestShouldPreserveExistingE2EEKeys(t *testing.T) { + existing := &UserLoginMetadata{ExportedKeyMap: map[string]string{"old-key-id": "old-export"}} + if !shouldPreserveExistingE2EEKeys(false, existing) { + t.Fatal("expected existing keys to be preserved when no new keys were exported") + } + if shouldPreserveExistingE2EEKeys(true, existing) { + t.Fatal("must not preserve existing keys when new keys were exported") + } + existing.ForceFullE2EELogin = true + if shouldPreserveExistingE2EEKeys(false, existing) { + t.Fatal("must not preserve stale keys after missing-key forced full reconnect") + } +} + func TestRefreshLoginE2EEKeysKeepsMetadataOnExportFailure(t *testing.T) { oldManager := newE2EEManager exportErr := errors.New("manager unavailable") diff --git a/pkg/e2ee/manager.go b/pkg/e2ee/manager.go index 2859c91..8c6f7cd 100644 --- a/pkg/e2ee/manager.go +++ b/pkg/e2ee/manager.go @@ -724,6 +724,12 @@ func (m *Manager) channelFromKeyIDs(senderKeyID, receiverKeyID int) (int, error) privRaw, pubRaw = receiverKeyID, senderKeyID privKeyID = id } else { + if _, ok := peerPub[senderKeyID]; ok { + return 0, fmt.Errorf("%w for %d", ErrMissingOwnPrivateKey, receiverKeyID) + } + if _, ok := peerPub[receiverKeyID]; ok { + return 0, fmt.Errorf("%w for %d", ErrMissingOwnPrivateKey, senderKeyID) + } return 0, fmt.Errorf("no matching private key for senderKeyID=%d receiverKeyID=%d", senderKeyID, receiverKeyID) } diff --git a/pkg/e2ee/manager_test.go b/pkg/e2ee/manager_test.go index 5034b52..fddb818 100644 --- a/pkg/e2ee/manager_test.go +++ b/pkg/e2ee/manager_test.go @@ -2,6 +2,7 @@ package e2ee import ( "errors" + "strings" "testing" "github.com/highesttt/matrix-line-messenger/pkg/line" @@ -22,6 +23,21 @@ func TestUnwrapGroupSharedKeyReturnsMissingOwnPrivateKey(t *testing.T) { } } +func TestChannelFromKeyIDsReturnsMissingOwnPrivateKeyWhenPeerKeyKnown(t *testing.T) { + manager := &Manager{ + peerPublic: map[int]string{1513671: "peer-public-key"}, + keyByRawID: map[int]int{}, + } + + _, err := manager.channelFromKeyIDs(1513671, 5920082) + if !errors.Is(err, ErrMissingOwnPrivateKey) { + t.Fatalf("err = %v, want ErrMissingOwnPrivateKey", err) + } + if !strings.Contains(err.Error(), "5920082") { + t.Fatalf("err = %v, want missing key ID in error", err) + } +} + func TestEncryptGroupMessageRawReturnsGroupKeyNotLoaded(t *testing.T) { manager := &Manager{ sequence: map[string]int{},