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
2 changes: 1 addition & 1 deletion pkg/connector/auth_recovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func callLineWithRecovery[T any](ctx context.Context, client *line.Client, deps

if errRecover := deps.recover(ctx); errRecover != nil {
var zero T
return client, zero, fmt.Errorf("failed to recover token after LINE auth error: %w", errRecover)
return client, zero, fmt.Errorf("failed to recover token after LINE auth error (%w): %w", err, errRecover)
}

client = deps.newClient()
Expand Down
5 changes: 5 additions & 0 deletions pkg/connector/auth_recovery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ func TestCallLineWithRecovery(t *testing.T) {
wantRecover int
wantErr error
wantErrPrefix string
wantAuthError bool
}{
{
name: "success without recovery",
Expand Down Expand Up @@ -62,6 +63,7 @@ func TestCallLineWithRecovery(t *testing.T) {
wantCalls: 1,
wantRecover: 1,
wantErrPrefix: "failed to recover token after LINE auth error",
wantAuthError: true,
},
{
name: "retry auth error is not retried again",
Expand Down Expand Up @@ -114,6 +116,9 @@ func TestCallLineWithRecovery(t *testing.T) {
t.Fatalf("err = %v, want containing %q", err, tt.wantErrPrefix)
}
}
if tt.wantAuthError && (!errors.Is(err, errAuthRequired) || !line.IsAuthError(err)) {
t.Fatalf("err = %v, want original auth error to remain detectable", err)
}
if tt.wantErr == nil && tt.wantErrPrefix == "" && err != nil {
t.Fatalf("unexpected err: %v", err)
}
Expand Down
151 changes: 85 additions & 66 deletions pkg/connector/creategroup.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,13 @@ import (
"github.com/highesttt/matrix-line-messenger/pkg/line"
)

var _ bridgev2.GroupCreatingNetworkAPI = (*LineClient)(nil)
var (
_ bridgev2.GroupCreatingNetworkAPI = (*LineClient)(nil)

getLastE2EEPublicKeysWithClient = func(client *line.Client, req line.GetLastE2EEPublicKeysRequest) (map[string]line.E2EEPeerPublicKey, error) {
return client.GetLastE2EEPublicKeys(req)
}
)

func (lc *LineClient) CreateGroup(ctx context.Context, params *bridgev2.GroupCreateParams) (*bridgev2.CreateChatResponse, error) {
participantMids := make([]string, len(params.Participants))
Expand Down Expand Up @@ -133,73 +139,92 @@ func (lc *LineClient) CreateGroup(ctx context.Context, params *bridgev2.GroupCre
}, nil
}

// registerGroupKey generates a random 32-byte group key, wraps it for each member
// using ECDH + AES-256-CBC, and registers it with the LINE server so all members
// can decrypt group messages. The bridge user's own MID is excluded since the
// creator already has the group key.
func (lc *LineClient) registerGroupKey(ctx context.Context, chatMid string, members []string) error {
// Exclude the bridge user's own MID — the creator already has the group key.
func (lc *LineClient) groupKeyMemberMIDs(chatMid string, members []string) []string {
otherMembers := make([]string, 0, len(members))
seen := make(map[string]struct{}, len(members))
for _, mid := range members {
if mid != lc.Mid {
otherMembers = append(otherMembers, mid)
if !isUserMID(mid) || lc.isOwnMID(mid) || mid == chatMid {
continue
}
if _, ok := seen[mid]; ok {
continue
}
seen[mid] = struct{}{}
otherMembers = append(otherMembers, mid)
}
if len(otherMembers) == 0 {
return fmt.Errorf("no other members to register group key for")
}
members = otherMembers

client := lc.newClient()
return otherMembers
}

// Fetch current E2EE public keys for all other members as a batch. If the batch
// call fails (e.g. server 500 for a specific member), fall back to fetching
// each member's key individually via NegotiateE2EEPublicKey.
pubKeysReq := line.GetLastE2EEPublicKeysRequest{
ChatMid: chatMid,
Members: members,
}
pubKeys, err := client.GetLastE2EEPublicKeys(pubKeysReq)
if err != nil {
if lc.shouldAttemptTokenRecovery(ctx, err) {
if errRecover := lc.recoverToken(ctx); errRecover == nil {
client = lc.newClient()
pubKeys, err = client.GetLastE2EEPublicKeys(pubKeysReq)
}
func (lc *LineClient) resolveGroupMemberPublicKeys(ctx context.Context, client *line.Client, chatMid string, members []string) (*line.Client, map[string]line.E2EEPeerPublicKey, error) {
req := line.GetLastE2EEPublicKeysRequest{ChatMid: chatMid, Members: members}
client, pubKeys, batchErr := callLineResultUsing(lc, ctx, client, func(client *line.Client) (map[string]line.E2EEPeerPublicKey, error) {
return getLastE2EEPublicKeysWithClient(client, req)
})
if batchErr != nil {
if ctx.Err() != nil || lc.isSessionInvalidated() || line.IsAuthError(batchErr) {
return client, nil, batchErr
}
}
if err != nil {
// Batch call failed — try individual key negotiation per member
lc.UserLogin.Bridge.Log.Warn().Err(err).
lc.UserLogin.Bridge.Log.Warn().Err(batchErr).
Str("chat_mid", chatMid).
Int("members", len(members)).
Msg("Batch GetLastE2EEPublicKeys failed, falling back to per-member NegotiateE2EEPublicKey")
pubKeys = make(map[string]line.E2EEPeerPublicKey)
for _, mid := range members {
res, nErr := client.NegotiateE2EEPublicKey(mid)
if nErr != nil {
if line.IsNoUsableE2EEPublicKey(nErr) {
lc.UserLogin.Bridge.Log.Debug().Str("member", mid).Msg("Member has Letter Sealing disabled, skipping")
continue
}
if lc.shouldAttemptTokenRecovery(ctx, nErr) {
if errRecover := lc.recoverToken(ctx); errRecover == nil {
client = lc.newClient()
res, nErr = client.NegotiateE2EEPublicKey(mid)
}
}
if nErr != nil {
lc.UserLogin.Bridge.Log.Warn().Err(nErr).Str("member", mid).Msg("Failed to negotiate key for member, skipping")
continue
}
Msg("Batch GetLastE2EEPublicKeys failed, resolving member keys individually")
}
if pubKeys == nil {
pubKeys = make(map[string]line.E2EEPeerPublicKey, len(members))
}

for _, mid := range members {
if pk, ok := pubKeys[mid]; ok && pk.KeyID > 0 && pk.KeyData != "" {
continue
}

var res *line.E2EEPublicKey
var err error
client, res, err = callLineResultUsing(lc, ctx, client, func(client *line.Client) (*line.E2EEPublicKey, error) {
return negotiateE2EEPublicKeyWithClient(client, mid)
})
if err != nil {
if ctx.Err() != nil || lc.isSessionInvalidated() || line.IsAuthError(err) {
return client, nil, fmt.Errorf("negotiate E2EE key for member %s: %w", mid, err)
}
keyID, nErr := res.KeyID.Int64()
if nErr != nil {
lc.UserLogin.Bridge.Log.Warn().Err(nErr).Str("member", mid).Msg("Failed to parse key ID, skipping")
continue
if line.IsNoUsableE2EEPublicKey(err) {
return client, nil, fmt.Errorf("%w: member %s has Letter Sealing disabled", line.ErrNoUsableE2EEGroupKey, mid)
}
pubKeys[mid] = line.E2EEPeerPublicKey{KeyID: int(keyID), KeyData: res.PublicKey}
return client, nil, fmt.Errorf("%w: negotiate E2EE key for member %s: %w", line.ErrNoUsableE2EEGroupKey, mid, err)
}
if res == nil || res.PublicKey == "" {
return client, nil, fmt.Errorf("%w: member %s returned no public key", line.ErrNoUsableE2EEGroupKey, mid)
}
keyID, err := res.KeyID.Int64()
if err != nil {
return client, nil, fmt.Errorf("%w: parse E2EE key ID for member %s: %w", line.ErrNoUsableE2EEGroupKey, mid, err)
}
if keyID <= 0 {
return client, nil, fmt.Errorf("%w: member %s returned invalid key ID %d", line.ErrNoUsableE2EEGroupKey, mid, keyID)
}
pubKeys[mid] = line.E2EEPeerPublicKey{KeyID: int(keyID), KeyData: res.PublicKey}
}

return client, pubKeys, nil
}

// registerGroupKey generates a random 32-byte group key, wraps it for each member
// using ECDH + AES-256-CBC, and registers it with the LINE server so all members
// can decrypt group messages.
func (lc *LineClient) registerGroupKey(ctx context.Context, chatMid string, members []string) error {
members = lc.groupKeyMemberMIDs(chatMid, members)
if len(members) == 0 {
return fmt.Errorf("no other members to register group key for")
}

client := lc.newClient()

// Batch responses can be partial without returning an error. Resolve every
// missing member individually so registration arrays retain the server's
// expected member count.
client, pubKeys, err := lc.resolveGroupMemberPublicKeys(ctx, client, chatMid, members)
if err != nil {
return err
}

// Generate group key in WASM (same approach as LINE Chrome Extension).
Expand All @@ -216,19 +241,13 @@ func (lc *LineClient) registerGroupKey(ctx context.Context, chatMid string, memb

for _, mid := range members {
pk, ok := pubKeys[mid]
if !ok {
lc.UserLogin.Bridge.Log.Debug().Str("member", mid).Msg("No E2EE public key for member, skipping")
continue
}
if pk.KeyData == "" {
lc.UserLogin.Bridge.Log.Debug().Str("member", mid).Int("key_id", pk.KeyID).Msg("Empty public key data for member, skipping")
continue
if !ok || pk.KeyID <= 0 || pk.KeyData == "" {
return fmt.Errorf("%w: incomplete E2EE public key for member %s", line.ErrNoUsableE2EEGroupKey, mid)
}

encryptedKey, err := lc.E2EE.WrapGroupKeyForMember(pk.KeyData, groupKeyID)
if err != nil {
lc.UserLogin.Bridge.Log.Warn().Err(err).Str("member", mid).Msg("Failed to wrap group key for member, skipping")
continue
return fmt.Errorf("wrap group key for member %s: %w", mid, err)
}

apiMembers = append(apiMembers, mid)
Expand Down
54 changes: 38 additions & 16 deletions pkg/connector/e2ee_keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ func (lc *LineClient) fetchAndUnwrapGroupKey(ctx context.Context, chatMid string
Msg("No group key found, auto-registering")
if registerErr := lc.autoRegisterGroupKey(ctx, chatMid); registerErr != nil {
lc.UserLogin.Bridge.Log.Warn().Err(registerErr).Str("chat_mid", chatMid).
Msg("Auto-register group key failed, returning original error")
return err
Msg("Auto-register group key failed")
return fmt.Errorf("auto-register group key: %w", registerErr)
}
sharedKey, err = fetch()
}
Expand Down Expand Up @@ -193,6 +193,20 @@ func (lc *LineClient) clearGroupNoE2EE(chatMid string) {
delete(lc.noE2EEGroups, chatMid)
}

func (lc *LineClient) cacheGroupMemberMIDs(chatMid string, mids []string) {
// A self-only result is ambiguous: LINE sometimes returns an empty member
// map for active groups. Keep a previously complete list for fallback.
if len(mids) <= 1 {
return

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.

Skipping writes when len(mids) <= 1 protects against LINE's ambiguous self-only responses (good), but it also means a group that legitimately shrinks to only self will never update the cache: autoRegisterGroupKey at lines 275-285 will keep falling back to the stale richer list and attempt to wrap keys for departed members, which RegisterE2EEGroupKey will reject (or worse, silently accept with stale members).

Consider adding an authoritative-source flag or a TTL so genuine shrinkage can evict the cache while still shielding against ambiguous server responses.

}
lc.cacheMu.Lock()
defer lc.cacheMu.Unlock()
if lc.groupMemberCache == nil {
lc.groupMemberCache = make(map[string][]string)
}
lc.groupMemberCache[chatMid] = append([]string(nil), mids...)
}

// getChatMemberMIDs fetches the member and invitee MIDs for a group chat via GetChats.
// Invitees are included because group key registration must happen before they accept,
// otherwise the key won't be available when they start sending messages.
Expand All @@ -219,13 +233,17 @@ func (lc *LineClient) getChatMemberMIDs(ctx context.Context, chatMid string) ([]
}
seen := make(map[string]struct{})
for mid := range chat.Extra.GroupExtra.MemberMids {
seen[mid] = struct{}{}
if isUserMID(mid) {
seen[mid] = struct{}{}
}
}
for mid := range chat.Extra.GroupExtra.InviteeMids {
seen[mid] = struct{}{}
if isUserMID(mid) {
seen[mid] = struct{}{}
}
}
// Always include the bridge user's own MID since we're definitely a member
if _, ok := seen[lc.Mid]; !ok {
// Include the bridge user's MID when it has a valid user prefix.
if isUserMID(lc.Mid) {
seen[lc.Mid] = struct{}{}
}
mids := make([]string, 0, len(seen))
Expand All @@ -236,13 +254,9 @@ func (lc *LineClient) getChatMemberMIDs(ctx context.Context, chatMid string) ([]
return nil, fmt.Errorf("chat %s has no members or invitees", chatMid)
}

// Cache the successful result for fallback use.
lc.cacheMu.Lock()
if lc.groupMemberCache == nil {
lc.groupMemberCache = make(map[string][]string)
}
lc.groupMemberCache[chatMid] = mids
lc.cacheMu.Unlock()
// Cache complete results for fallback use. Do not replace a richer cached
// list when LINE returns only the caller.
lc.cacheGroupMemberMIDs(chatMid, mids)

return mids, nil
}
Expand All @@ -261,6 +275,7 @@ func (lc *LineClient) autoRegisterGroupKey(ctx context.Context, chatMid string)
if len(members) == 1 && members[0] == lc.Mid {
lc.cacheMu.Lock()
cached, ok := lc.groupMemberCache[chatMid]
cached = append([]string(nil), cached...)
lc.cacheMu.Unlock()
if ok && len(cached) > 1 {
lc.UserLogin.Bridge.Log.Warn().Str("chat_mid", chatMid).
Expand Down Expand Up @@ -308,6 +323,7 @@ func (lc *LineClient) getGroupMemberMIDsViaMatrix(ctx context.Context, chatMid s
}

mids := make([]string, 0, len(matrixMembers))
seen := make(map[string]struct{}, len(matrixMembers)+1)
for mxid := range matrixMembers {
// Skip the bridge user's own Matrix account if present.
if mxid == lc.UserLogin.UserMXID {
Expand All @@ -316,7 +332,11 @@ func (lc *LineClient) getGroupMemberMIDsViaMatrix(ctx context.Context, chatMid s
// Parse the ghost MXID back to a network ID (LINE MID).
if netID, ok := lc.UserLogin.Bridge.Matrix.ParseGhostMXID(mxid); ok {
mid := string(netID)
if mid != lc.Mid && mid != string(lc.UserLogin.ID) {
if isUserMID(mid) && !lc.isOwnMID(mid) {
if _, exists := seen[mid]; exists {
continue
}
seen[mid] = struct{}{}
mids = append(mids, mid)
}
}
Expand All @@ -326,8 +346,10 @@ func (lc *LineClient) getGroupMemberMIDsViaMatrix(ctx context.Context, chatMid s
return nil, fmt.Errorf("no LINE members found via Matrix")
}

// Always include the bridge user's own MID.
mids = append(mids, lc.Mid)
// Include the bridge user's MID when it has a valid user prefix.
if isUserMID(lc.Mid) {
mids = append(mids, lc.Mid)
}

lc.UserLogin.Bridge.Log.Debug().Str("chat_mid", chatMid).
Int("matrix_members", len(matrixMembers)).
Expand Down
Loading
Loading