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
38 changes: 34 additions & 4 deletions pkg/connector/connector.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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})

Expand Down Expand Up @@ -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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Latent: this copy has no !res.NoE2EE guard, so if the new LoginResult is LSOFF (res.NoE2EE == true) with an empty keychain but the previous metadata was LSON, meta ends up with a full E2EE block on a NoE2EE login. In practice accounts rarely downgrade LSON→LSOFF, but the guard on line 452 (!res.NoE2EE && len(meta.ExportedKeyMap) == 0) implies you already treat those two states as mutually exclusive — consider gating the copy on !res.NoE2EE too so downstream code doesn't get contradictory metadata.

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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: every other user-facing failure in this file returns via ll.loginErrorStep(...) (see lines 205, 222, 248) so the user gets a re-prompt with the error message inline. This branch instead returns a raw fmt.Errorf, which bubbles up to the bridge framework as a plain error string — less actionable UX than the surrounding paths.

}

detectedLineID := networkid.UserLoginID(mid)

Expand Down Expand Up @@ -499,28 +515,42 @@ 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
}
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
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")
return true
}

func (ll *LineEmailLogin) Cancel() {}
66 changes: 44 additions & 22 deletions pkg/connector/e2ee_keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package connector

import (
"context"
"errors"
"fmt"
"time"

Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions pkg/connector/forced_logout_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
},
},
}
Expand Down
94 changes: 94 additions & 0 deletions pkg/connector/login_keys_test.go
Original file line number Diff line number Diff line change
@@ -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"
)
Expand All @@ -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" {
Expand All @@ -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")
Expand Down
18 changes: 14 additions & 4 deletions pkg/connector/send_message.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Behavior change worth confirming: Previously the two group-encrypt branches only aborted on ErrMissingOwnPrivateKey and logged/ignored every other fetchAndUnwrapGroupKey error, so a transient LINE API failure (e.g. getChats failed for X: connection refused, autoRegisterGroupKey failure, token-recovery failure) would fall through to EncryptGroupMessage with cached state or eventually the plaintext fallback at line 605.

With lineGroupE2EEFetchFailureError, any non-NoUsableE2EEGroupKey error is now returned as-is to Matrix (unwrapped — no MessageStatus, no reconnect notice), which:

  1. Turns transient errors into hard user-visible send failures instead of best-effort delivery.
  2. Skips the plaintext-fallback path in the retry branch (line ~605) entirely for these errors.
  3. Surfaces raw fmt.Errorf strings like "getChats failed for X: %w" to end users instead of the actionable reconnect notice.

If the intent is only to bubble reconnect-required errors, consider returning nil (instead of err) from the default branch of lineGroupE2EEFetchFailureError to preserve the previous "log and continue" semantics.

}

func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.MatrixMessage) (*bridgev2.MatrixMessageResponse, error) {
client := lc.newClient()
callLineErr := func(call func(*line.Client) error) error {
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand Down
25 changes: 25 additions & 0 deletions pkg/connector/send_message_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading