From 0c143b16318ca222b6fc07c70eac9570d035e7d1 Mon Sep 17 00:00:00 2001 From: highesttt Date: Wed, 1 Jul 2026 11:53:53 -0400 Subject: [PATCH] fix: e2ee keys not exported correctly --- pkg/connector/connector.go | 38 ++++++++++-- pkg/connector/e2ee_keys.go | 66 +++++++++++++------- pkg/connector/forced_logout_test.go | 3 + pkg/connector/login_keys_test.go | 94 +++++++++++++++++++++++++++++ pkg/connector/send_message.go | 18 ++++-- pkg/connector/send_message_test.go | 25 ++++++++ 6 files changed, 214 insertions(+), 30 deletions(-) diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 886b3cf..70ee62b 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -157,6 +157,8 @@ type LineEmailLogin struct { AwaitingPIN bool NoE2EE bool // True when login fell back to non-E2EE (LSOFF account) + ExistingMetadata *UserLoginMetadata + pollResult chan *line.LoginResult pollErr chan error polling bool @@ -197,10 +199,17 @@ func (ll *LineEmailLogin) StartWithOverride(ctx context.Context, override *bridg ll.Email = meta.Email ll.Password = meta.Password ll.Certificate = meta.Certificate + ll.ExistingMetadata = meta 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 { + ll.Certificate = "" + if override.Bridge != nil { + override.Bridge.Log.Info().Msg("No stored LINE E2EE keys, forcing full reconnect") + } + } override.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnecting}) @@ -435,7 +444,14 @@ 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} - ll.fetchLoginKeys(res, meta, client) + exportedKeys := ll.fetchLoginKeys(res, meta, client) + if !exportedKeys && ll.ExistingMetadata != nil && len(ll.ExistingMetadata.ExportedKeyMap) > 0 { + copyLoginE2EEKeyMetadata(meta, ll.ExistingMetadata) + ll.User.Bridge.Log.Info().Int("keys", len(meta.ExportedKeyMap)).Msg("Preserved existing E2EE keys after re-login") + } + if !res.NoE2EE && len(meta.ExportedKeyMap) == 0 { + return nil, fmt.Errorf("LINE login completed without E2EE keychain; please reconnect again and complete the LINE verification prompt") + } detectedLineID := networkid.UserLoginID(mid) @@ -499,6 +515,19 @@ func saveLoginE2EEKeyMetadata(meta *UserLoginMetadata, res *line.LoginResult) { meta.E2EEKeyID = res.E2EEKeyID } +func copyLoginE2EEKeyMetadata(dst, src *UserLoginMetadata) { + dst.EncryptedKeyChain = src.EncryptedKeyChain + dst.E2EEPublicKey = src.E2EEPublicKey + dst.E2EEVersion = src.E2EEVersion + dst.E2EEKeyID = src.E2EEKeyID + if len(src.ExportedKeyMap) > 0 { + dst.ExportedKeyMap = make(map[string]string, len(src.ExportedKeyMap)) + for keyID, exported := range src.ExportedKeyMap { + dst.ExportedKeyMap[keyID] = exported + } + } +} + func loginSecureDataID(meta *UserLoginMetadata, fallback string) string { if meta.Mid != "" { return meta.Mid @@ -506,14 +535,14 @@ func loginSecureDataID(meta *UserLoginMetadata, fallback string) string { return fallback } -func (ll *LineEmailLogin) fetchLoginKeys(res *line.LoginResult, meta *UserLoginMetadata, client *line.Client) { +func (ll *LineEmailLogin) fetchLoginKeys(res *line.LoginResult, meta *UserLoginMetadata, client *line.Client) bool { if res.EncryptedKeyChain == "" || res.E2EEPublicKey == "" { - return + return false } mgr, exported, err := exportLoginE2EEKeys(res, client) if err != nil { ll.User.Bridge.Log.Warn().Err(err).Msg("Login: failed to export E2EE keys") - return + return false } saveLoginE2EEKeyMetadata(meta, res) meta.ExportedKeyMap = exported @@ -521,6 +550,7 @@ func (ll *LineEmailLogin) fetchLoginKeys(res *line.LoginResult, meta *UserLoginM 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") + return true } func (ll *LineEmailLogin) Cancel() {} diff --git a/pkg/connector/e2ee_keys.go b/pkg/connector/e2ee_keys.go index 6326f6b..2d178f9 100644 --- a/pkg/connector/e2ee_keys.go +++ b/pkg/connector/e2ee_keys.go @@ -2,6 +2,7 @@ package connector import ( "context" + "errors" "fmt" "time" @@ -66,33 +67,54 @@ func (lc *LineClient) fetchAndUnwrapGroupKey(ctx context.Context, chatMid string return fmt.Errorf("no group shared key returned for %s", chatMid) } - lc.UserLogin.Bridge.Log.Debug(). - Str("chat_mid", chatMid). - Int("group_key_id", sharedKey.GroupKeyID). - Int("creator_key_id", sharedKey.CreatorKeyID). - Int("receiver_key_id", sharedKey.ReceiverKeyID). - Msg("Fetched group shared key") + unwrap := func(sharedKey *line.E2EEGroupSharedKey) error { + lc.UserLogin.Bridge.Log.Debug(). + Str("chat_mid", chatMid). + Int("group_key_id", sharedKey.GroupKeyID). + Int("creator_key_id", sharedKey.CreatorKeyID). + Int("receiver_key_id", sharedKey.ReceiverKeyID). + Msg("Fetched group shared key") - if _, _, err := lc.ensurePeerKey(ctx, sharedKey.Creator); err != nil { - return fmt.Errorf("failed to ensure creator key: %w", err) - } - if _, _, err := lc.ensurePeerKeyByID(ctx, sharedKey.Creator, sharedKey.CreatorKeyID); err != nil { - return fmt.Errorf("failed to ensure creator key id %d: %w", sharedKey.CreatorKeyID, err) - } + if _, _, err := lc.ensurePeerKey(ctx, sharedKey.Creator); err != nil { + return fmt.Errorf("failed to ensure creator key: %w", err) + } + if _, _, err := lc.ensurePeerKeyByID(ctx, sharedKey.Creator, sharedKey.CreatorKeyID); err != nil { + return fmt.Errorf("failed to ensure creator key id %d: %w", sharedKey.CreatorKeyID, err) + } - unwrappedID, err := lc.E2EE.UnwrapGroupSharedKey(chatMid, sharedKey) - if err != nil { - return fmt.Errorf("failed to unwrap group key: %w", err) + unwrappedID, err := lc.E2EE.UnwrapGroupSharedKey(chatMid, sharedKey) + if err != nil { + return fmt.Errorf("failed to unwrap group key: %w", err) + } + + lc.UserLogin.Bridge.Log.Debug(). + Str("chat_mid", chatMid). + Int("group_key_id", sharedKey.GroupKeyID). + Int("receiver_key_id", sharedKey.ReceiverKeyID). + Int("unwrapped_id", unwrappedID). + Msg("Unwrapped group shared key") + + return nil } - lc.UserLogin.Bridge.Log.Debug(). - Str("chat_mid", chatMid). - Int("group_key_id", sharedKey.GroupKeyID). - Int("receiver_key_id", sharedKey.ReceiverKeyID). - Int("unwrapped_id", unwrappedID). - Msg("Unwrapped group shared key") + err = unwrap(sharedKey) + if groupKeyID == 0 && errors.Is(err, e2ee.ErrMissingOwnPrivateKey) { + lc.UserLogin.Bridge.Log.Warn().Err(err).Str("chat_mid", chatMid). + Msg("Latest group key targets a missing private key, registering a fresh group key") + if registerErr := lc.autoRegisterGroupKey(ctx, chatMid); registerErr != nil { + return fmt.Errorf("%w (fresh group key registration failed: %v)", err, registerErr) + } + sharedKey, err = fetch() + if err != nil { + return fmt.Errorf("failed to fetch fresh group key after registration: %w", err) + } + if sharedKey == nil { + return fmt.Errorf("no fresh group shared key returned for %s", chatMid) + } + err = unwrap(sharedKey) + } - return nil + return err } func (lc *LineClient) ensurePeerKey(ctx context.Context, mid string) (int, string, error) { diff --git a/pkg/connector/forced_logout_test.go b/pkg/connector/forced_logout_test.go index d4eec88..9268ed4 100644 --- a/pkg/connector/forced_logout_test.go +++ b/pkg/connector/forced_logout_test.go @@ -65,6 +65,9 @@ func TestStartWithOverrideUsesStoredCredentials(t *testing.T) { Email: "stored@example.com", Password: "stored-password", Certificate: "stored-cert", + ExportedKeyMap: map[string]string{ + "5625926": "exported-key", + }, }, }, } diff --git a/pkg/connector/login_keys_test.go b/pkg/connector/login_keys_test.go index c31e387..bbc73bf 100644 --- a/pkg/connector/login_keys_test.go +++ b/pkg/connector/login_keys_test.go @@ -1,9 +1,15 @@ package connector import ( + "context" "errors" + "io" "testing" + "github.com/rs/zerolog" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "github.com/highesttt/matrix-line-messenger/pkg/e2ee" "github.com/highesttt/matrix-line-messenger/pkg/line" ) @@ -27,6 +33,31 @@ func TestSaveLoginE2EEKeyMetadata(t *testing.T) { } } +func TestCopyLoginE2EEKeyMetadataClonesExportedKeyMap(t *testing.T) { + src := &UserLoginMetadata{ + EncryptedKeyChain: "encrypted-keychain", + E2EEPublicKey: "public-key", + E2EEVersion: "2", + E2EEKeyID: "5625926", + ExportedKeyMap: map[string]string{"5625926": "exported-key"}, + } + dst := &UserLoginMetadata{} + + copyLoginE2EEKeyMetadata(dst, src) + + if dst.EncryptedKeyChain != src.EncryptedKeyChain || + dst.E2EEPublicKey != src.E2EEPublicKey || + dst.E2EEVersion != src.E2EEVersion || + dst.E2EEKeyID != src.E2EEKeyID || + dst.ExportedKeyMap["5625926"] != "exported-key" { + t.Fatalf("metadata = %#v, want copied E2EE fields", dst) + } + src.ExportedKeyMap["5625926"] = "mutated" + if dst.ExportedKeyMap["5625926"] != "exported-key" { + t.Fatal("ExportedKeyMap must be cloned, not shared") + } +} + func TestLoginSecureDataIDPrefersLineMID(t *testing.T) { meta := &UserLoginMetadata{Mid: "u-line-mid"} if got := loginSecureDataID(meta, "@user:example.com"); got != "u-line-mid" { @@ -39,6 +70,69 @@ func TestLoginSecureDataIDPrefersLineMID(t *testing.T) { } } +func TestStartWithOverrideForcesFullReconnectWithoutStoredE2EEKeys(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", + }, + }, + } + + 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 TestStartWithOverrideKeepsCertificateWithStoredE2EEKeys(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{"5625926": "exported-key"}, + }, + }, + } + + if _, err := ll.StartWithOverride(context.Background(), override); err != nil { + t.Fatalf("StartWithOverride returned error: %v", err) + } + if gotCertificate != "stored-certificate" { + t.Fatalf("certificate = %q, want stored certificate", gotCertificate) + } +} + func TestRefreshLoginE2EEKeysKeepsMetadataOnExportFailure(t *testing.T) { oldManager := newE2EEManager exportErr := errors.New("manager unavailable") diff --git a/pkg/connector/send_message.go b/pkg/connector/send_message.go index 6162379..3b98017 100644 --- a/pkg/connector/send_message.go +++ b/pkg/connector/send_message.go @@ -46,6 +46,16 @@ func lineGroupE2EEReconnectRequiredError(err error) error { WithErrorReason(event.MessageStatusGenericError) } +func lineGroupE2EEFetchFailureError(err error) error { + if err == nil || line.IsNoUsableE2EEGroupKey(err) { + return nil + } + if errStatus := lineGroupE2EEReconnectRequiredError(err); errStatus != nil { + return errStatus + } + return err +} + func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.MatrixMessage) (*bridgev2.MatrixMessageResponse, error) { client := lc.newClient() callLineErr := func(call func(*line.Client) error) error { @@ -572,8 +582,8 @@ 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 errFetch = lineGroupE2EEFetchFailureError(errFetch); errFetch != nil { + return nil, errFetch } } if contentType != int(ContentText) { @@ -588,8 +598,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 + } else if errFetch = lineGroupE2EEFetchFailureError(errFetch); errFetch != nil { + return nil, errFetch } 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 index 7264595..7b4a502 100644 --- a/pkg/connector/send_message_test.go +++ b/pkg/connector/send_message_test.go @@ -42,3 +42,28 @@ func TestLineGroupE2EEReconnectRequiredErrorIgnoresNoUsableGroupKey(t *testing.T t.Fatalf("err = %v, want nil", err) } } + +func TestLineGroupE2EEFetchFailureErrorReturnsAuthErrors(t *testing.T) { + err := lineGroupE2EEFetchFailureError(errLoggedOut) + if !errors.Is(err, errLoggedOut) { + t.Fatalf("err = %v, want logged-out error", err) + } +} + +func TestLineGroupE2EEFetchFailureErrorAllowsNoUsableGroupKeyFallback(t *testing.T) { + err := lineGroupE2EEFetchFailureError(line.ErrNoUsableE2EEGroupKey) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } +} + +func TestLineGroupE2EEFetchFailureErrorWrapsMissingPrivateKeyStatus(t *testing.T) { + err := lineGroupE2EEFetchFailureError(fmt.Errorf("failed to unwrap group key: %w", e2ee.ErrMissingOwnPrivateKey)) + var status bridgev2.MessageStatus + if !errors.As(err, &status) { + t.Fatalf("error %T does not wrap bridgev2.MessageStatus", err) + } + if status.Message != lineGroupE2EEReconnectNotice || status.Status != event.MessageStatusFail { + t.Fatalf("status = %#v, want reconnect failure notice", status) + } +}