Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 54 additions & 8 deletions pkg/connector/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down Expand Up @@ -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()
}
Comment on lines +320 to +334

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Early return on !needsSave skips the bad-credentials bridge state.

When metadata already carries ForceFullE2EELogin=true and an empty Certificate (e.g., after a restart), needsSave is false and the function returns at Line 324–326. But missingE2EEKeyMarked was just set to true, so the StateBadCredentials emission at Line 338 never runs for this client lifetime and the user is never prompted to reconnect. The needsSave check should gate only the Save call, not the notification.

🐛 Proposed fix: skip only the Save, keep the notification
 	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")
+		if needsSave {
+			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()
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 meta, ok := lc.UserLogin.Metadata.(*UserLoginMetadata); ok {
needsSave := !meta.ForceFullE2EELogin || meta.Certificate != ""
meta.ForceFullE2EELogin = true
meta.Certificate = ""
lc.missingE2EEKeyMu.Unlock()
if needsSave {
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()
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/connector/client.go` around lines 320 - 334, The early return in the
`UserLoginMetadata` handling block of `client.go` is preventing the
bad-credentials bridge state from being emitted when `needsSave` is false.
Update the logic around `needsSave`, `lc.missingE2EEKeyMu`, and
`lc.UserLogin.Save(ctx)` so the flag only controls whether the save is
attempted, while the later `StateBadCredentials` notification still runs for the
current client lifetime. Keep the existing metadata update to
`ForceFullE2EELogin` and `Certificate`, but avoid returning before the reconnect
prompt path.

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,
})
}
Comment thread
indent-zero[bot] marked this conversation as resolved.

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 {
Expand Down Expand Up @@ -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
}
Expand Down
23 changes: 18 additions & 5 deletions pkg/connector/connector.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}

Expand Down Expand Up @@ -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")
}
}

Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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")
}
Expand Down
52 changes: 40 additions & 12 deletions pkg/connector/handle_message.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
}
}
Expand Down
78 changes: 78 additions & 0 deletions pkg/connector/login_keys_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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")
Expand Down
6 changes: 6 additions & 0 deletions pkg/e2ee/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
16 changes: 16 additions & 0 deletions pkg/e2ee/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package e2ee

import (
"errors"
"strings"
"testing"

"github.com/highesttt/matrix-line-messenger/pkg/line"
Expand All @@ -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{},
Expand Down
Loading