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
26 changes: 26 additions & 0 deletions pkg/connector/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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")
}
Expand All @@ -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
}
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")
}
if lc.E2EE != nil {
if err := lc.E2EE.LoadMyKeyFromExportedMap(exported); err != nil {
return fmt.Errorf("load exported keys into active E2EE manager: %w", err)
}
}
Comment on lines +574 to +578

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

Install the freshly initialized manager when lc.E2EE is nil.

exportLoginE2EEKeys returns an initialized manager, but this branch drops it when no active manager exists. That leaves the re-logged-in client without in-memory E2EE keys even after a successful refresh.

Proposed fix
-	if lc.E2EE != nil {
-		if err := lc.E2EE.LoadMyKeyFromExportedMap(exported); err != nil {
-			return fmt.Errorf("load exported keys into active E2EE manager: %w", err)
-		}
+	if lc.E2EE == nil {
+		lc.E2EE = mgr
+	} else if err := lc.E2EE.LoadMyKeyFromExportedMap(exported); err != nil {
+		return fmt.Errorf("load exported keys into active E2EE manager: %w", err)
 	}
📝 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 lc.E2EE != nil {
if err := lc.E2EE.LoadMyKeyFromExportedMap(exported); err != nil {
return fmt.Errorf("load exported keys into active E2EE manager: %w", err)
}
}
if lc.E2EE == nil {
lc.E2EE = mgr
} else if err := lc.E2EE.LoadMyKeyFromExportedMap(exported); err != nil {
return fmt.Errorf("load exported keys into active E2EE manager: %w", err)
}
🤖 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 574 - 578, The exportLoginE2EEKeys flow
is dropping the freshly initialized E2EE manager when lc.E2EE is nil, so the
client keeps no in-memory keys after login refresh. Update the lc.E2EE handling
in exportLoginE2EEKeys to install the returned manager into lc.E2EE when no
active manager exists, and only call LoadMyKeyFromExportedMap on an existing
manager. Use the lc.E2EE and exportLoginE2EEKeys symbols to locate the branch.

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 {
Expand Down
51 changes: 36 additions & 15 deletions pkg/connector/connector.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
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
_ = 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")
}

Expand Down
77 changes: 77 additions & 0 deletions pkg/connector/login_keys_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package connector

import (
"errors"
"testing"

"github.com/highesttt/matrix-line-messenger/pkg/e2ee"
"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)
}
}

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)
}
}
21 changes: 21 additions & 0 deletions pkg/connector/send_message.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"image"
"image/jpeg"
Expand All @@ -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"
)

Expand All @@ -30,6 +32,20 @@ type mentionEntry struct {

var mentionLinkRegex = regexp.MustCompile(`<a\s+[^>]*href="https://matrix\.to/#/([^"]+)"[^>]*>([^<]+)</a>`)

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)

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 — reconnect notice can loop. lineGroupE2EEReconnectRequiredError triggers on any ErrMissingOwnPrivateKey, but the sentinel bubbles up from Manager.UnwrapGroupSharedKey whenever sharedKey.ReceiverKeyID isn't in keyByRawID/myRawKeyID. If LINE hands us a group key wrapped for a receiver key that the freshly-refreshed keychain also doesn't contain (e.g. an old shared key still targeting a rotated device key), the user follows the instructions, refreshLoginE2EEKeys succeeds, and the next send fails with the same notice — an infinite reconnect prompt from the user's perspective. Consider only surfacing this status when the loaded MyKeyIDs() differs from ReceiverKeyID, or falling back to the existing markGroupNoE2EE + plaintext path once a reconnect has already been suggested.

}

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 @@ -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)
Expand All @@ -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
Expand Down
44 changes: 44 additions & 0 deletions pkg/connector/send_message_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
10 changes: 8 additions & 2 deletions pkg/e2ee/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
Expand All @@ -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.
*/
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
36 changes: 36 additions & 0 deletions pkg/e2ee/manager_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading