From add12d030d1bff9070ac585868edef7d23a508b4 Mon Sep 17 00:00:00 2001 From: highesttt Date: Tue, 8 Sep 2026 11:04:22 -0400 Subject: [PATCH 1/2] fix: handle error 99 (member count mismatch) --- pkg/connector/creategroup.go | 188 +++++++++------------------- pkg/connector/e2ee_keys.go | 150 +--------------------- pkg/connector/e2ee_keys_test.go | 215 +------------------------------- pkg/line/errors.go | 24 ++++ pkg/line/methods.go | 20 +-- pkg/line/structs.go | 6 - 6 files changed, 97 insertions(+), 506 deletions(-) diff --git a/pkg/connector/creategroup.go b/pkg/connector/creategroup.go index f9ef1cb..4f0430d 100644 --- a/pkg/connector/creategroup.go +++ b/pkg/connector/creategroup.go @@ -16,8 +16,8 @@ import ( var ( _ bridgev2.GroupCreatingNetworkAPI = (*LineClient)(nil) - getLastE2EEPublicKeysWithClient = func(client *line.Client, req line.GetLastE2EEPublicKeysRequest) (map[string]line.E2EEPeerPublicKey, error) { - return client.GetLastE2EEPublicKeys(req) + getLastE2EEPublicKeysWithClient = func(client *line.Client, chatMid string) (map[string]line.E2EEPeerPublicKey, error) { + return client.GetLastE2EEPublicKeys(chatMid) } ) @@ -66,7 +66,7 @@ func (lc *LineClient) CreateGroup(ctx context.Context, params *bridgev2.GroupCre // Registration is best-effort: an incomplete E2EE member list leaves the // chat on its plaintext fallback without aborting group creation. if lc.E2EE != nil && len(participantMids) > 0 { - if err := lc.registerGroupKey(ctx, chat.ChatMid, participantMids); err != nil { + if err := lc.registerGroupKey(ctx, chat.ChatMid); err != nil { lc.UserLogin.Bridge.Log.Warn().Err(err). Str("chat_mid", chat.ChatMid). Msg("Failed to register E2EE group key, continuing without E2EE") @@ -131,153 +131,79 @@ func (lc *LineClient) CreateGroup(ctx context.Context, params *bridgev2.GroupCre }, nil } -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 !isUserMID(mid) || lc.isOwnMID(mid) || mid == chatMid { - continue - } - if _, ok := seen[mid]; ok { - continue - } - seen[mid] = struct{}{} - otherMembers = append(otherMembers, mid) - } - return otherMembers +type groupKeyCrypto interface { + GenerateGroupKey() (int, error) + WrapGroupKeyForMember(string, int) (string, error) } -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 - } - lc.UserLogin.Bridge.Log.Warn().Err(batchErr). - Str("chat_mid", chatMid). - Int("members", len(members)). - Msg("Batch GetLastE2EEPublicKeys failed, resolving member keys individually") - } - if pubKeys == nil { - pubKeys = make(map[string]line.E2EEPeerPublicKey, len(members)) +func (lc *LineClient) registerGroupKey(ctx context.Context, chatMid string) error { + if lc.E2EE == nil { + return fmt.Errorf("%w: E2EE manager not initialized", line.ErrNoUsableE2EEGroupKey) } + return lc.registerGroupKeyWithCrypto(ctx, chatMid, lc.E2EE) +} - for _, mid := range members { - if pk, ok := pubKeys[mid]; ok && pk.KeyID > 0 && pk.KeyData != "" { - continue +func (lc *LineClient) registerGroupKeyWithCrypto(ctx context.Context, chatMid string, crypto groupKeyCrypto) error { + client := lc.newClient() + for attempt := 0; ; attempt++ { + if err := ctx.Err(); err != nil { + return err } - - var res *line.E2EEPublicKey + // LINE returns the current registration recipients, including the caller. + // GetChats and Matrix membership can be incomplete or stale. + var pubKeys map[string]line.E2EEPeerPublicKey var err error - client, res, err = callLineResultUsing(lc, ctx, client, func(client *line.Client) (*line.E2EEPublicKey, error) { - return negotiateE2EEPublicKeyWithClient(client, mid) + client, pubKeys, err = callLineResultUsing(lc, ctx, client, func(client *line.Client) (map[string]line.E2EEPeerPublicKey, error) { + return getLastE2EEPublicKeysWithClient(client, chatMid) }) 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) - } - if line.IsNoUsableE2EEPublicKey(err) { - return client, nil, fmt.Errorf("%w: member %s has Letter Sealing disabled", line.ErrNoUsableE2EEGroupKey, mid) - } - return client, nil, fmt.Errorf("%w: negotiate E2EE key for member %s: %w", line.ErrNoUsableE2EEGroupKey, mid, err) + return fmt.Errorf("getLastE2EEPublicKeys failed: %w", err) } - if res == nil || res.PublicKey == "" { - return client, nil, fmt.Errorf("%w: member %s returned no public key", line.ErrNoUsableE2EEGroupKey, mid) + if len(pubKeys) == 0 { + return fmt.Errorf("no registration members returned for group key") } - 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 _, ok := pubKeys[lc.Mid]; !ok { + return fmt.Errorf("group key registration members do not include caller") } - if keyID <= 0 { - return client, nil, fmt.Errorf("%w: member %s returned invalid key ID %d", line.ErrNoUsableE2EEGroupKey, mid, keyID) + for mid, pk := range pubKeys { + if !isUserMID(mid) || pk.KeyID <= 0 || pk.KeyData == "" { + return fmt.Errorf("incomplete E2EE group member public key") + } } - 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 lc.E2EE == nil { - return fmt.Errorf("%w: E2EE manager not initialized", line.ErrNoUsableE2EEGroupKey) - } - - 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. - pubKeys := make(map[string]line.E2EEPeerPublicKey, len(members)) - if len(members) > 0 { - var err error - client, pubKeys, err = lc.resolveGroupMemberPublicKeys(ctx, client, chatMid, members) + groupKeyID, err := crypto.GenerateGroupKey() if err != nil { + return fmt.Errorf("failed to generate group key: %w", err) + } + apiMembers := make([]string, 0, len(pubKeys)) + keyIDs := make([]int, 0, len(pubKeys)) + encryptedKeys := make([]string, 0, len(pubKeys)) + for mid, pk := range pubKeys { + encryptedKey, err := crypto.WrapGroupKeyForMember(pk.KeyData, groupKeyID) + if err != nil { + return fmt.Errorf("wrap group key for member: %w", err) + } + apiMembers = append(apiMembers, mid) + keyIDs = append(keyIDs, pk.KeyID) + encryptedKeys = append(encryptedKeys, encryptedKey) + } + if err := ctx.Err(); err != nil { return err } - } - // Generate group key in WASM (same approach as LINE Chrome Extension). - // The generated key is a Curve25519Key object stored in the WASM module. - groupKeyID, err := lc.E2EE.GenerateGroupKey() - if err != nil { - return fmt.Errorf("failed to generate group key: %w", err) - } - - // Wrap the group key for each member that has a public key - apiMembers := make([]string, 0, len(members)) - keyIds := make([]int, 0, len(members)) - encryptedKeys := make([]string, 0, len(members)) - - for _, mid := range members { - pk, ok := pubKeys[mid] - if !ok || pk.KeyID <= 0 || pk.KeyData == "" { - return fmt.Errorf("%w: incomplete E2EE public key for member %s", line.ErrNoUsableE2EEGroupKey, mid) + client, err = lc.callLineUsing(ctx, client, func(client *line.Client) error { + return client.RegisterE2EEGroupKey(1, chatMid, apiMembers, keyIDs, encryptedKeys) + }) + if attempt == 0 && line.IsE2EEGroupMemberMismatch(err) { + lc.UserLogin.Bridge.Log.Debug().Int("members", len(pubKeys)). + Msg("Group membership changed during key registration, refreshing member keys") + continue } - - encryptedKey, err := lc.E2EE.WrapGroupKeyForMember(pk.KeyData, groupKeyID) if err != nil { - return fmt.Errorf("wrap group key for member %s: %w", mid, err) + return fmt.Errorf("registerE2EEGroupKey failed: %w", err) } - - apiMembers = append(apiMembers, mid) - keyIds = append(keyIds, pk.KeyID) - encryptedKeys = append(encryptedKeys, encryptedKey) - } - - // LINE's registerE2EEGroupKey requires the caller's own key entry as well — without it the - // server rejects the request with "empty caller key". The Chrome extension wraps the group - // key for every member returned by getLastE2EEPublicKeys, which includes the caller. Mirror - // that by wrapping the group key for our own public key and appending ourselves. - selfRawID, selfPub, err := lc.E2EE.MyPublicKey() - if err != nil { - return fmt.Errorf("get own E2EE key: %w", err) + lc.UserLogin.Bridge.Log.Info().Str("chat_mid", chatMid). + Int("members", len(apiMembers)).Msg("Registered E2EE group key") + return nil } - selfEncryptedKey, err := lc.E2EE.WrapGroupKeyForMember(selfPub, groupKeyID) - if err != nil { - return fmt.Errorf("wrap group key for self: %w", err) - } - apiMembers = append(apiMembers, lc.Mid) - keyIds = append(keyIds, selfRawID) - encryptedKeys = append(encryptedKeys, selfEncryptedKey) - - _, err = lc.callLineUsing(ctx, client, func(client *line.Client) error { - return client.RegisterE2EEGroupKey(1, chatMid, apiMembers, keyIds, encryptedKeys) - }) - if err != nil { - return fmt.Errorf("registerE2EEGroupKey failed: %w", err) - } - - lc.UserLogin.Bridge.Log.Info(). - Str("chat_mid", chatMid). - Int("members", len(apiMembers)). - Msg("Registered E2EE group key") - - return nil } diff --git a/pkg/connector/e2ee_keys.go b/pkg/connector/e2ee_keys.go index 2fc2821..be125df 100644 --- a/pkg/connector/e2ee_keys.go +++ b/pkg/connector/e2ee_keys.go @@ -6,8 +6,6 @@ import ( "fmt" "time" - "maunium.net/go/mautrix/bridgev2/networkid" - "github.com/highesttt/matrix-line-messenger/pkg/e2ee" "github.com/highesttt/matrix-line-messenger/pkg/line" ) @@ -209,154 +207,10 @@ func (lc *LineClient) cacheGroupMemberMIDs(chatMid string, mids []string) { lc.groupMemberCache[chatMid] = append([]string(nil), mids...) } -func joinedGroupMemberMIDs(group *line.GroupExtra, ownMID string) ([]string, bool) { - seen := make(map[string]struct{}) - if group != nil { - for mid := range group.MemberMids { - if isUserMID(mid) { - seen[mid] = struct{}{} - } - } - } - if isUserMID(ownMID) { - seen[ownMID] = struct{}{} - } - - mids := make([]string, 0, len(seen)) - for mid := range seen { - mids = append(mids, mid) - } - return mids, group != nil && len(group.InviteeMids) > 0 -} - -// getChatMemberMIDs fetches joined member MIDs for a group chat via GetChats. -// Pending invitees are deliberately excluded: LINE validates group keys against the -// current joined-member set and rejects keys that include invitees. -func (lc *LineClient) getChatMemberMIDs(ctx context.Context, chatMid string) ([]string, bool, error) { - _, chats, err := callLineResult(lc, ctx, func(client *line.Client) (*line.GetChatsResponse, error) { - return client.GetChats([]string{chatMid}, true, true) - }) - if err != nil { - return nil, false, fmt.Errorf("getChats failed for %s: %w", chatMid, err) - } - if len(chats.Chats) == 0 { - return nil, false, fmt.Errorf("chat %s not found", chatMid) - } - chat := chats.Chats[0] - if chat.Extra.GroupExtra == nil { - return nil, false, fmt.Errorf("chat %s has no group extra", chatMid) - } - group := chat.Extra.GroupExtra - mids, hasPendingInvitees := joinedGroupMemberMIDs(group, lc.Mid) - if len(mids) == 0 { - return nil, hasPendingInvitees, fmt.Errorf("chat %s has no joined members", chatMid) - } - - // 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, hasPendingInvitees, nil -} - -// autoRegisterGroupKey fetches group members, then registers a new E2EE group key -// for the chat. This is called when fetchAndUnwrapGroupKey finds no key exists. +// autoRegisterGroupKey uses LINE's current member/key snapshot for registration. func (lc *LineClient) autoRegisterGroupKey(ctx context.Context, chatMid string) error { - members, hasPendingInvitees, err := lc.getChatMemberMIDs(ctx, chatMid) - if err != nil { - return fmt.Errorf("getChatMemberMIDs: %w", err) - } - - // If getChatMemberMIDs returned only ourself, the server likely returned - // an empty MemberMids map (known LINE API issue). Fall back to cached - // member list from CreateGroup or a prior successful fetch. - if len(members) == 1 && members[0] == lc.Mid && !hasPendingInvitees { - 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). - Msg("GetChats returned only self MID, falling back to cached member list") - members = cached - } - } - - // Last resort: query Matrix room members via the bridge API. - if len(members) == 1 && members[0] == lc.Mid && !hasPendingInvitees { - matrixMembers, err := lc.getGroupMemberMIDsViaMatrix(ctx, chatMid) - if err != nil { - lc.UserLogin.Bridge.Log.Warn().Err(err).Str("chat_mid", chatMid). - Msg("Matrix member fallback also failed") - } else if len(matrixMembers) > 1 { - lc.UserLogin.Bridge.Log.Warn().Str("chat_mid", chatMid). - Int("members", len(matrixMembers)). - Msg("GetChats returned only self MID, falling back to Matrix room members") - members = matrixMembers - } - } - - return lc.registerGroupKey(ctx, chatMid, members) + return lc.registerGroupKey(ctx, chatMid) } - -// getGroupMemberMIDsViaMatrix queries the Matrix room's member list via the bridge -// API and converts ghost user IDs back to LINE MIDs. This is a fallback when the -// LINE API's GetChats withMembers returns an empty MemberMids map. -func (lc *LineClient) getGroupMemberMIDsViaMatrix(ctx context.Context, chatMid string) (_ []string, err error) { - portalKey := networkid.PortalKey{ - ID: makePortalID(chatMid), - Receiver: lc.UserLogin.ID, - } - portal, err := lc.UserLogin.Bridge.GetPortalByKey(ctx, portalKey) - if err != nil { - return nil, fmt.Errorf("get portal: %w", err) - } - if portal == nil || portal.MXID == "" { - return nil, fmt.Errorf("portal has no Matrix room") - } - - matrixMembers, err := lc.UserLogin.Bridge.Matrix.GetMembers(ctx, portal.MXID) - if err != nil { - return nil, fmt.Errorf("get matrix members: %w", err) - } - - 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 { - continue - } - // 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 isUserMID(mid) && !lc.isOwnMID(mid) { - if _, exists := seen[mid]; exists { - continue - } - seen[mid] = struct{}{} - mids = append(mids, mid) - } - } - } - - if len(mids) == 0 { - return nil, fmt.Errorf("no LINE members found via Matrix") - } - - // 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)). - Int("resolved_mids", len(mids)). - Msg("Resolved group members via Matrix room") - - return mids, nil -} - func (lc *LineClient) ensurePeerKeyByID(ctx context.Context, mid string, keyID int) (int, string, error) { lc.cacheMu.Lock() if lc.peerKeys == nil { diff --git a/pkg/connector/e2ee_keys_test.go b/pkg/connector/e2ee_keys_test.go index ae39c93..19dc3b9 100644 --- a/pkg/connector/e2ee_keys_test.go +++ b/pkg/connector/e2ee_keys_test.go @@ -161,26 +161,6 @@ func TestEnsurePeerKeyCachesNoUsablePublicKeyWithoutRecovery(t *testing.T) { } } -func TestGroupKeyMemberMIDsKeepsOnlyUniqueUsers(t *testing.T) { - lc := newPeerKeyTestClient() - lc.Mid = "U-self" - - got := lc.groupKeyMemberMIDs("C-group", []string{ - "U-self", - "C-group", - "R-room", - "U-peer", - "U-peer", - "u-lowercase-peer", - "", - "not-a-mid", - }) - - if len(got) != 2 || got[0] != "U-peer" || got[1] != "u-lowercase-peer" { - t.Fatalf("groupKeyMemberMIDs = %v, want [U-peer u-lowercase-peer]", got) - } -} - func TestIsUserMIDAcceptsWirePrefixCasing(t *testing.T) { tests := map[string]bool{ "U-peer": true, @@ -233,203 +213,12 @@ func TestGroupKeyFetchErrorPreservesSpecificKeyMembershipError(t *testing.T) { } } -func TestRegisterGroupKeyAllowsPlaintextFallbackWithoutKnownMembers(t *testing.T) { +func TestRegisterGroupKeyAllowsPlaintextFallbackWithoutE2EE(t *testing.T) { lc := newPeerKeyTestClient() lc.Mid = "U-self" - err := lc.registerGroupKey(context.Background(), "C-group", []string{"U-self"}) - if !errors.Is(err, line.ErrNoUsableE2EEGroupKey) { - t.Fatalf("error = %v, want ErrNoUsableE2EEGroupKey", err) - } -} - -func TestJoinedGroupMemberMIDsExcludeInvitees(t *testing.T) { - group := &line.GroupExtra{ - MemberMids: line.FlexibleMidMap{ - "U-self": true, - "U-member": true, - }, - InviteeMids: line.FlexibleMidMap{ - "U-invitee": true, - }, - } - - mids, hasPendingInvitees := joinedGroupMemberMIDs(group, "U-self") - if !hasPendingInvitees { - t.Fatal("hasPendingInvitees = false, want true") - } - if len(mids) != 2 { - t.Fatalf("joined mids = %v, want self and joined member only", mids) - } - for _, mid := range mids { - if mid == "U-invitee" { - t.Fatalf("joined mids unexpectedly contain pending invitee: %v", mids) - } - } -} - -func TestResolveGroupMemberPublicKeysFillsPartialBatchResponse(t *testing.T) { - oldGetLast := getLastE2EEPublicKeysWithClient - oldNegotiate := negotiateE2EEPublicKeyWithClient - t.Cleanup(func() { - getLastE2EEPublicKeysWithClient = oldGetLast - negotiateE2EEPublicKeyWithClient = oldNegotiate - }) - - getLastE2EEPublicKeysWithClient = func(*line.Client, line.GetLastE2EEPublicKeysRequest) (map[string]line.E2EEPeerPublicKey, error) { - return map[string]line.E2EEPeerPublicKey{ - "U-batch": {KeyID: 10, KeyData: "batch-public-key"}, - }, nil - } - var negotiated []string - negotiateE2EEPublicKeyWithClient = func(_ *line.Client, mid string) (*line.E2EEPublicKey, error) { - negotiated = append(negotiated, mid) - return &line.E2EEPublicKey{KeyID: json.Number("20"), PublicKey: "negotiated-public-key"}, nil - } - - lc := newPeerKeyTestClient() - _, got, err := lc.resolveGroupMemberPublicKeys( - context.Background(), - line.NewClient("access"), - "C-group", - []string{"U-batch", "U-missing"}, - ) - if err != nil { - t.Fatalf("resolveGroupMemberPublicKeys returned error: %v", err) - } - if len(negotiated) != 1 || negotiated[0] != "U-missing" { - t.Fatalf("negotiated members = %v, want [U-missing]", negotiated) - } - if got["U-batch"].KeyID != 10 || got["U-missing"].KeyID != 20 { - t.Fatalf("resolved keys = %#v, want batch and negotiated entries", got) - } -} - -func TestResolveGroupMemberPublicKeysFallsBackAfterBatchError(t *testing.T) { - oldGetLast := getLastE2EEPublicKeysWithClient - oldNegotiate := negotiateE2EEPublicKeyWithClient - t.Cleanup(func() { - getLastE2EEPublicKeysWithClient = oldGetLast - negotiateE2EEPublicKeyWithClient = oldNegotiate - }) - - getLastE2EEPublicKeysWithClient = func(*line.Client, line.GetLastE2EEPublicKeysRequest) (map[string]line.E2EEPeerPublicKey, error) { - return nil, errors.New("batch unavailable") - } - var negotiated []string - negotiateE2EEPublicKeyWithClient = func(_ *line.Client, mid string) (*line.E2EEPublicKey, error) { - negotiated = append(negotiated, mid) - return &line.E2EEPublicKey{KeyID: json.Number("30"), PublicKey: "fallback-public-key"}, nil - } - - lc := newPeerKeyTestClient() - _, got, err := lc.resolveGroupMemberPublicKeys( - context.Background(), - line.NewClient("access"), - "C-group", - []string{"U-peer"}, - ) - if err != nil { - t.Fatalf("resolveGroupMemberPublicKeys returned error: %v", err) - } - if len(negotiated) != 1 || negotiated[0] != "U-peer" { - t.Fatalf("negotiated members = %v, want [U-peer]", negotiated) - } - if got["U-peer"].KeyID != 30 { - t.Fatalf("resolved key = %#v, want fallback key", got["U-peer"]) - } -} - -func TestResolveGroupMemberPublicKeysReturnsNoUsableGroupKey(t *testing.T) { - oldGetLast := getLastE2EEPublicKeysWithClient - oldNegotiate := negotiateE2EEPublicKeyWithClient - t.Cleanup(func() { - getLastE2EEPublicKeysWithClient = oldGetLast - negotiateE2EEPublicKeyWithClient = oldNegotiate - }) - - getLastE2EEPublicKeysWithClient = func(*line.Client, line.GetLastE2EEPublicKeysRequest) (map[string]line.E2EEPeerPublicKey, error) { - return nil, nil - } - negotiateE2EEPublicKeyWithClient = func(*line.Client, string) (*line.E2EEPublicKey, error) { - return nil, line.ErrNoUsableE2EEPublicKey - } - - lc := newPeerKeyTestClient() - _, _, err := lc.resolveGroupMemberPublicKeys( - context.Background(), - line.NewClient("access"), - "C-group", - []string{"U-peer"}, - ) - if !errors.Is(err, line.ErrNoUsableE2EEGroupKey) { - t.Fatalf("error = %v, want ErrNoUsableE2EEGroupKey", err) - } -} - -func TestResolveGroupMemberPublicKeysAllowsFallbackAfterMemberError(t *testing.T) { - oldGetLast := getLastE2EEPublicKeysWithClient - oldNegotiate := negotiateE2EEPublicKeyWithClient - t.Cleanup(func() { - getLastE2EEPublicKeysWithClient = oldGetLast - negotiateE2EEPublicKeyWithClient = oldNegotiate - }) - - getLastE2EEPublicKeysWithClient = func(*line.Client, line.GetLastE2EEPublicKeysRequest) (map[string]line.E2EEPeerPublicKey, error) { - return nil, nil - } - memberErr := errors.New("temporary member key failure") - negotiateE2EEPublicKeyWithClient = func(*line.Client, string) (*line.E2EEPublicKey, error) { - return nil, memberErr - } - - lc := newPeerKeyTestClient() - _, _, err := lc.resolveGroupMemberPublicKeys( - context.Background(), - line.NewClient("access"), - "C-group", - []string{"U-peer"}, - ) + err := lc.registerGroupKey(context.Background(), "C-group") if !errors.Is(err, line.ErrNoUsableE2EEGroupKey) { t.Fatalf("error = %v, want ErrNoUsableE2EEGroupKey", err) } - if !errors.Is(err, memberErr) { - t.Fatalf("error = %v, want member error to remain wrapped", err) - } -} - -func TestResolveGroupMemberPublicKeysPreservesAuthRecoveryFailure(t *testing.T) { - oldGetLast := getLastE2EEPublicKeysWithClient - oldNegotiate := negotiateE2EEPublicKeyWithClient - oldRecover := recoverLineToken - t.Cleanup(func() { - getLastE2EEPublicKeysWithClient = oldGetLast - negotiateE2EEPublicKeyWithClient = oldNegotiate - recoverLineToken = oldRecover - }) - - getLastE2EEPublicKeysWithClient = func(*line.Client, line.GetLastE2EEPublicKeysRequest) (map[string]line.E2EEPeerPublicKey, error) { - return nil, nil - } - negotiateE2EEPublicKeyWithClient = func(*line.Client, string) (*line.E2EEPublicKey, error) { - return nil, errAuthRequired - } - recoveryErr := errors.New("token recovery failed") - recoverLineToken = func(*LineClient, context.Context) error { - return recoveryErr - } - - lc := newPeerKeyTestClient() - _, _, err := lc.resolveGroupMemberPublicKeys( - context.Background(), - line.NewClient("access"), - "C-group", - []string{"U-peer"}, - ) - if !errors.Is(err, errAuthRequired) || !errors.Is(err, recoveryErr) { - t.Fatalf("error = %v, want auth and recovery failures", err) - } - if errors.Is(err, line.ErrNoUsableE2EEGroupKey) { - t.Fatalf("error = %v, auth failure must not allow plaintext fallback", err) - } } diff --git a/pkg/line/errors.go b/pkg/line/errors.go index 17c50b3..93ca05e 100644 --- a/pkg/line/errors.go +++ b/pkg/line/errors.go @@ -150,6 +150,30 @@ type talkExceptionData struct { Reason string `json:"reason"` } +// IsE2EEGroupMemberMismatch identifies a rejected registration that needs a +// fresh server member/key snapshot, not a plaintext fallback. +func IsE2EEGroupMemberMismatch(err error) bool { + if err == nil { + return false + } + msg := err.Error() + start := strings.IndexByte(msg, '{') + if start < 0 { + return false + } + var response struct { + Code int `json:"code"` + Message string `json:"message"` + Data talkExceptionData `json:"data"` + } + if json.NewDecoder(strings.NewReader(msg[start:])).Decode(&response) != nil { + return false + } + return response.Code == 10051 && strings.EqualFold(response.Message, "RESPONSE_ERROR") && + strings.EqualFold(response.Data.Name, "TalkException") && response.Data.Code == 99 && + strings.EqualFold(strings.TrimSpace(response.Data.Reason), "member count mismatch") +} + // IsGroupKeyNotRegisteredError returns true when SendMessage returns code 99 // "group key is not registered". This means a group key must be registered before // sending any message (even plain text) to this group. diff --git a/pkg/line/methods.go b/pkg/line/methods.go index 913d8a3..e5c2024 100644 --- a/pkg/line/methods.go +++ b/pkg/line/methods.go @@ -881,23 +881,27 @@ func (c *Client) CreateChat(mids []string, name string, chatType int) (*Chat, er // GetLastE2EEPublicKeys fetches the latest E2EE public keys for all members of a chat. // Returns a map of member MID → {keyId, keyData}. -func (c *Client) GetLastE2EEPublicKeys(req GetLastE2EEPublicKeysRequest) (map[string]E2EEPeerPublicKey, error) { - resp, err := c.callRPC("TalkService", "getLastE2EEPublicKeys", req) +func (c *Client) GetLastE2EEPublicKeys(chatMid string) (map[string]E2EEPeerPublicKey, error) { + resp, err := c.callRPC("TalkService", "getLastE2EEPublicKeys", chatMid) if err != nil { return nil, err } var wrapper struct { - Code int `json:"code"` - Message string `json:"message"` - Data map[string]E2EEPeerPublicKey `json:"data"` + Code int `json:"code"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` } if err := json.Unmarshal(resp, &wrapper); err != nil { return nil, fmt.Errorf("failed to parse getLastE2EEPublicKeys response: %w", err) } if wrapper.Code != 0 { - return nil, fmt.Errorf("getLastE2EEPublicKeys failed: %s", wrapper.Message) + return nil, fmt.Errorf("getLastE2EEPublicKeys failed: %s", resp) } - return wrapper.Data, nil + var keys map[string]E2EEPeerPublicKey + if err := json.Unmarshal(wrapper.Data, &keys); err != nil { + return nil, fmt.Errorf("failed to parse group member public keys: %w", err) + } + return keys, nil } // RegisterE2EEGroupKey registers a shared E2EE group key with the server. @@ -916,7 +920,7 @@ func (c *Client) RegisterE2EEGroupKey(keyVersion int, chatMid string, members [] return fmt.Errorf("failed to parse registerE2EEGroupKey response: %w", err) } if wrapper.Code != 0 { - return fmt.Errorf("registerE2EEGroupKey failed: %s", wrapper.Message) + return fmt.Errorf("registerE2EEGroupKey failed: %s", resp) } return nil } diff --git a/pkg/line/structs.go b/pkg/line/structs.go index 29e3595..36edb16 100644 --- a/pkg/line/structs.go +++ b/pkg/line/structs.go @@ -351,12 +351,6 @@ type E2EEPeerPublicKey struct { KeyData string `json:"keyData"` } -// GetLastE2EEPublicKeysRequest is the request body for getLastE2EEPublicKeys. -type GetLastE2EEPublicKeysRequest struct { - ChatMid string `json:"chatMid"` - Members []string `json:"members"` -} - type CreateChatRequest struct { ReqSeq int `json:"reqSeq"` Type int `json:"type"` From c28fd7dd7e83b716b0c0244a1e29fc3551cf1518 Mon Sep 17 00:00:00 2001 From: highesttt Date: Tue, 8 Sep 2026 11:22:14 -0400 Subject: [PATCH 2/2] fix: restore plaintext fallback for unavailable group keys --- pkg/connector/creategroup.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/pkg/connector/creategroup.go b/pkg/connector/creategroup.go index 4f0430d..9119908 100644 --- a/pkg/connector/creategroup.go +++ b/pkg/connector/creategroup.go @@ -47,8 +47,7 @@ func (lc *LineClient) CreateGroup(ctx context.Context, params *bridgev2.GroupCre Int("participants", len(participantMids)). Msg("LINE group chat created") - // Cache the member list so auto-registration can fall back to it - // when GetChats withMembers returns empty data. + // Cache the member list for group name generation. groupMembers := make([]string, 0, len(participantMids)+1) groupMembers = append(groupMembers, lc.Mid) groupMembers = append(groupMembers, participantMids...) @@ -160,14 +159,17 @@ func (lc *LineClient) registerGroupKeyWithCrypto(ctx context.Context, chatMid st return fmt.Errorf("getLastE2EEPublicKeys failed: %w", err) } if len(pubKeys) == 0 { - return fmt.Errorf("no registration members returned for group key") + return fmt.Errorf("%w: no registration members returned for group key", line.ErrNoUsableE2EEGroupKey) } if _, ok := pubKeys[lc.Mid]; !ok { - return fmt.Errorf("group key registration members do not include caller") + return fmt.Errorf("%w: group key registration members do not include caller", line.ErrNoUsableE2EEGroupKey) } for mid, pk := range pubKeys { - if !isUserMID(mid) || pk.KeyID <= 0 || pk.KeyData == "" { - return fmt.Errorf("incomplete E2EE group member public key") + if !isUserMID(mid) { + return fmt.Errorf("invalid E2EE group member MID") + } + if pk.KeyID <= 0 || pk.KeyData == "" { + return fmt.Errorf("%w: incomplete E2EE group member public key", line.ErrNoUsableE2EEGroupKey) } }