diff --git a/README.md b/README.md index d720ed3..ae93682 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,6 @@ Based on the [mautrix-twilio](https://github.com/mautrix/twilio) bridge - [ ] Reaction support - [ ] Reply support - [ ] Prefetch chats -- [ ] Group chats +- [x] Group chats - [ ] Media messages (images, videos, voice notes, files) - [ ] Sticker support - diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 6e501c4..c024d5a 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -27,12 +27,13 @@ import ( ) type LineClient struct { - UserLogin *bridgev2.UserLogin - AccessToken string - Mid string - HTTPClient *http.Client - E2EE *e2ee.Manager - peerKeys map[string]peerKeyInfo + UserLogin *bridgev2.UserLogin + AccessToken string + RefreshToken string + Mid string + HTTPClient *http.Client + E2EE *e2ee.Manager + peerKeys map[string]peerKeyInfo reqSeqMu sync.Mutex sentReqSeqs map[int]time.Time @@ -43,6 +44,39 @@ type LineClient struct { var _ bridgev2.NetworkAPI = (*LineClient)(nil) var _ bridgev2.ReadReceiptHandlingNetworkAPI = (*LineClient)(nil) +func (lc *LineClient) refreshAndSave(ctx context.Context) error { + if lc.RefreshToken == "" { + return fmt.Errorf("no refresh token available") + } + + client := line.NewClient(lc.AccessToken) + res, err := client.RefreshAccessToken(lc.RefreshToken) + if err != nil { + return fmt.Errorf("failed to refresh token: %w", err) + } + + lc.AccessToken = res.AccessToken + if res.RefreshToken != "" { + lc.RefreshToken = res.RefreshToken + } + + meta := lc.UserLogin.Metadata.(*UserLoginMetadata) + meta.AccessToken = lc.AccessToken + meta.RefreshToken = lc.RefreshToken + err = lc.UserLogin.Save(ctx) + if err != nil { + lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Failed to save refreshed tokens to DB") + } else { + lc.UserLogin.Bridge.Log.Info().Msg("Tokens refreshed and saved") + } + + return nil +} + +func (lc *LineClient) isRefreshRequired(err error) bool { + return strings.Contains(err.Error(), "\"code\":119") || strings.Contains(err.Error(), "Access token refresh required") +} + func (lc *LineClient) Connect(ctx context.Context) { if lc.peerKeys == nil { lc.peerKeys = make(map[string]peerKeyInfo) @@ -127,7 +161,18 @@ func (lc *LineClient) Connect(ctx context.Context) { // Storage key is optional for runtime decrypt/encrypt; try it for file support client := line.NewClient(lc.AccessToken) - if ei3, err := client.GetEncryptedIdentityV3(); err == nil { + ei3, err := client.GetEncryptedIdentityV3() + if err != nil && lc.isRefreshRequired(err) { + lc.UserLogin.Bridge.Log.Info().Msg("Access token expired, refreshing...") + if errRefresh := lc.refreshAndSave(ctx); errRefresh == nil { + client = line.NewClient(lc.AccessToken) + ei3, err = client.GetEncryptedIdentityV3() + } else { + lc.UserLogin.Bridge.Log.Error().Err(errRefresh).Msg("Failed to refresh token") + } + } + + if err == nil { if err := mgr.InitStorage(ei3.WrappedNonce, ei3.KDFParameter1, ei3.KDFParameter2); err != nil { lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Failed to init storage key") } else if data, err := mgr.LoadSecureDataFromFile(string(lc.UserLogin.ID)); err == nil { @@ -146,45 +191,157 @@ func (lc *LineClient) Connect(ctx context.Context) { func (lc *LineClient) syncChats(ctx context.Context) { client := line.NewClient(lc.AccessToken) - resp, err := client.GetMessageBoxes() + midsResp, err := client.GetAllChatMids(true, true) + if err != nil && lc.isRefreshRequired(err) { + if errRefresh := lc.refreshAndSave(ctx); errRefresh == nil { + client = line.NewClient(lc.AccessToken) + midsResp, err = client.GetAllChatMids(true, true) + } + } if err != nil { - lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Failed to sync chats") + lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Failed to fetch all chat mids") return } - var data struct { - Data struct { - MessageBoxes []struct { - ID string `json:"id"` - } `json:"messageBoxes"` - } `json:"data"` - } - if err := json.Unmarshal(resp, &data); err != nil { - lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Failed to parse message boxes") + allMids := append(midsResp.MemberChatMids, midsResp.InvitedChatMids...) + if len(allMids) == 0 { return } - for _, mb := range data.Data.MessageBoxes { - portalKey := networkid.PortalKey{ID: makePortalID(mb.ID), Receiver: lc.UserLogin.ID} - portal, err := lc.UserLogin.Bridge.GetPortalByKey(ctx, portalKey) - if err != nil || portal == nil { - continue + chunkSize := 20 + for i := 0; i < len(allMids); i += chunkSize { + end := i + chunkSize + if end > len(allMids) { + end = len(allMids) + } + batch := allMids[i:end] + chatsResp, err := client.GetChats(batch, true, true) + if err != nil && lc.isRefreshRequired(err) { + if errRefresh := lc.refreshAndSave(ctx); errRefresh == nil { + client = line.NewClient(lc.AccessToken) + chatsResp, err = client.GetChats(batch, true, true) + } } - - info, err := lc.GetChatInfo(ctx, portal) if err != nil { + lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Failed to fetch batch of chats") continue } - lc.UserLogin.Bridge.QueueRemoteEvent(lc.UserLogin, &simplevent.ChatResync{ - EventMeta: simplevent.EventMeta{ - Type: bridgev2.RemoteEventChatResync, - PortalKey: portalKey, - Timestamp: time.Now(), + for _, chat := range chatsResp.Chats { + portalKey := networkid.PortalKey{ID: makePortalID(chat.ChatMid), Receiver: lc.UserLogin.ID} + + info := lc.chatToChatInfo(&chat) + lc.UserLogin.Bridge.QueueRemoteEvent(lc.UserLogin, &simplevent.ChatResync{ + EventMeta: simplevent.EventMeta{ + Type: bridgev2.RemoteEventChatResync, + PortalKey: portalKey, + Timestamp: time.Now(), + }, + ChatInfo: info, + }) + } + } +} + +func (lc *LineClient) chatToChatInfo(chat *line.Chat) *bridgev2.ChatInfo { + var avatar *bridgev2.Avatar + if chat.PicturePath != "" { + avatar = &bridgev2.Avatar{ + ID: networkid.AvatarID(chat.PicturePath), + Get: func(ctx context.Context) ([]byte, error) { + return lc.GetAvatar(ctx, networkid.AvatarID(chat.PicturePath)) }, - ChatInfo: info, - }) + } } + + members := []bridgev2.ChatMember{ + { + EventSender: bridgev2.EventSender{ + IsFromMe: true, + Sender: networkid.UserID(lc.UserLogin.ID), + }, + Membership: event.MembershipJoin, + PowerLevel: ptr.Ptr(0), + }, + } + + if chat.Extra.GroupExtra != nil { + if chat.Extra.GroupExtra.CreatorMid == lc.Mid { + members[0].PowerLevel = ptr.Ptr(100) + } + for m := range chat.Extra.GroupExtra.MemberMids { + if m == lc.Mid || m == string(lc.UserLogin.ID) { + continue + } + members = append(members, bridgev2.ChatMember{ + EventSender: bridgev2.EventSender{ + Sender: makeUserID(m), + }, + Membership: event.MembershipJoin, + }) + } + for m := range chat.Extra.GroupExtra.InviteeMids { + if m == lc.Mid || m == string(lc.UserLogin.ID) { + continue + } + members = append(members, bridgev2.ChatMember{ + EventSender: bridgev2.EventSender{ + Sender: makeUserID(m), + }, + Membership: event.MembershipInvite, + }) + } + } + + name := chat.ChatName + if name == "" && chat.Extra.GroupExtra != nil { + name = lc.generateNameFromMembers(chat.Extra.GroupExtra.MemberMids) + } + + return &bridgev2.ChatInfo{ + Name: &name, + Avatar: avatar, + Members: &bridgev2.ChatMemberList{IsFull: true, Members: members}, + } +} + +func (lc *LineClient) generateNameFromMembers(members map[string]bool) string { + var names []string + count := 0 + for mid := range members { + if mid == string(lc.UserLogin.ID) || mid == lc.Mid || strings.HasPrefix(mid, "c") || strings.HasPrefix(mid, "r") { + continue + } + if contact, ok := lc.contactCache[mid]; ok && contact.DisplayName != "" { + names = append(names, contact.DisplayName) + } + count++ + if count >= 20 { + break + } + } + + finalNames := names + if len(names) > 3 { + finalNames = names[:3] + } + + if len(finalNames) == 0 { + return "" + } + + result := strings.Join(finalNames, ", ") + actualMemberCount := 0 + for m := range members { + if m != string(lc.UserLogin.ID) && m != lc.Mid && !strings.HasPrefix(m, "c") && !strings.HasPrefix(m, "r") { + actualMemberCount++ + } + } + remaining := actualMemberCount - len(finalNames) + if remaining > 0 { + result += fmt.Sprintf(" and %d others", remaining) + } + return result } func (lc *LineClient) pollLoop(ctx context.Context) { @@ -243,6 +400,11 @@ func (lc *LineClient) pollLoop(ctx context.Context) { if err != nil { if err.Error() != "EOF" { lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("SSE Disconnected") + if strings.Contains(err.Error(), "SSE error: 401") || strings.Contains(err.Error(), "SSE error: 403") { + if errRefresh := lc.refreshAndSave(ctx); errRefresh == nil { + client = line.NewClient(lc.AccessToken) + } + } } time.Sleep(3 * time.Second) } @@ -254,10 +416,16 @@ func (lc *LineClient) handleOperation(_ context.Context, op line.Operation) { // Type 25 = SEND_MESSAGE (Message sent by you from another device) // Type 26 = RECEIVE_MESSAGE (Message received from another user) + if op.Type == 122 || op.Type == 121 { + lc.UserLogin.Bridge.Log.Info().Str("chat_mid", op.Param1).Int("op_type", op.Type).Msg("Received chat update operation") + go lc.syncSingleChat(context.Background(), op.Param1) + } + if op.Type == 55 { - senderID := makeUserID(op.Param1) - portalID := makePortalID(op.Param2) - // Param 2 is the group id or sender id in 1:1 chats + portalID := makePortalID(op.Param1) + senderID := makeUserID(op.Param2) + // Param 1 is the group id or sender id in 1:1 chats + // Param 2 is the user who read the message ts, _ := op.CreatedTime.Int64() lc.UserLogin.Bridge.QueueRemoteEvent(lc.UserLogin, &simplevent.Receipt{ @@ -290,6 +458,53 @@ func (lc *LineClient) handleOperation(_ context.Context, op line.Operation) { } } +func (lc *LineClient) syncSingleChat(ctx context.Context, chatMid string) { + client := line.NewClient(lc.AccessToken) + chatsResp, err := client.GetChats([]string{chatMid}, true, true) + if err != nil && lc.isRefreshRequired(err) { + if errRefresh := lc.refreshAndSave(ctx); errRefresh == nil { + client = line.NewClient(lc.AccessToken) + chatsResp, err = client.GetChats([]string{chatMid}, true, true) + } + } + if err != nil { + lc.UserLogin.Bridge.Log.Warn().Err(err).Str("chat_mid", chatMid).Msg("Failed to fetch chat info") + return + } + if len(chatsResp.Chats) == 0 { + return + } + chat := chatsResp.Chats[0] + portalKey := networkid.PortalKey{ID: makePortalID(chat.ChatMid), Receiver: lc.UserLogin.ID} + + if chat.ChatName == "" && chat.Extra.GroupExtra != nil { + var missingMids []string + for mid := range chat.Extra.GroupExtra.MemberMids { + if _, ok := lc.contactCache[mid]; !ok && mid != string(lc.UserLogin.ID) { + missingMids = append(missingMids, mid) + } + } + if len(missingMids) > 0 { + res, err := client.GetContactsV2(missingMids) + if err == nil && res != nil && res.Contacts != nil { + for mid, wrapper := range res.Contacts { + lc.contactCache[mid] = wrapper.Contact + } + } + } + } + + info := lc.chatToChatInfo(&chat) + lc.UserLogin.Bridge.QueueRemoteEvent(lc.UserLogin, &simplevent.ChatResync{ + EventMeta: simplevent.EventMeta{ + Type: bridgev2.RemoteEventChatResync, + PortalKey: portalKey, + Timestamp: time.Now(), + }, + ChatInfo: info, + }) +} + func (lc *LineClient) queueIncomingMessage(msg *line.Message, opType int) { senderID := makeUserID(msg.From) @@ -310,12 +525,40 @@ func (lc *LineClient) queueIncomingMessage(msg *line.Message, opType int) { if bodyText == "" && len(msg.Chunks) > 0 { bodyText = "[Unable to decrypt message. Open an issue on GitHub.]" if lc.E2EE != nil { - if pt, err := lc.E2EE.DecryptMessageV2(msg); err == nil && pt != "" { - bodyText = pt + if msg.ToType == 1 || msg.ToType == 2 { + if len(msg.Chunks) >= 5 { + if gkID, err := e2ee.DecodeKeyID(msg.Chunks[len(msg.Chunks)-1]); err == nil && gkID != 0 { + if errFetch := lc.fetchAndUnwrapGroupKey(context.Background(), portalIDStr, gkID); errFetch != nil { + lc.UserLogin.Bridge.Log.Debug().Err(errFetch).Int("key_id", gkID).Str("chat_mid", portalIDStr).Msg("Prefetch group key before decrypt failed") + } + } + } + + pt, keyID, err := lc.E2EE.DecryptGroupMessage(msg, portalIDStr) + if err == nil && pt != "" { + bodyText = pt + } else { + lc.UserLogin.Bridge.Log.Debug().Err(err).Int("key_id", keyID).Str("chat_mid", portalIDStr).Msg("DecryptGroupMessage failed, trying to fetch key") + if keyID != 0 { + lc.ensurePeerKeyForMessage(context.Background(), msg) + if errFetch := lc.fetchAndUnwrapGroupKey(context.Background(), portalIDStr, keyID); errFetch != nil { + lc.UserLogin.Bridge.Log.Warn().Err(errFetch).Int("key_id", keyID).Str("chat_mid", portalIDStr).Msg("Failed to fetch/unwrap group key") + } else if ptRetry, _, errRetry := lc.E2EE.DecryptGroupMessage(msg, portalIDStr); errRetry == nil && ptRetry != "" { + bodyText = ptRetry + } else if errRetry != nil { + lc.UserLogin.Bridge.Log.Warn().Err(errRetry).Msg("DecryptGroupMessage retry failed") + } + } + } } else { - lc.ensurePeerKeyForMessage(context.Background(), msg) - if ptRetry, errRetry := lc.E2EE.DecryptMessageV2(msg); errRetry == nil && ptRetry != "" { - bodyText = ptRetry + // 1-1 Message + if pt, err := lc.E2EE.DecryptMessageV2(msg); err == nil && pt != "" { + bodyText = pt + } else { + lc.ensurePeerKeyForMessage(context.Background(), msg) + if ptRetry, errRetry := lc.E2EE.DecryptMessageV2(msg); errRetry == nil && ptRetry != "" { + bodyText = ptRetry + } } } } @@ -398,6 +641,26 @@ func (lc *LineClient) IsThisUser(ctx context.Context, userID networkid.UserID) b } func (lc *LineClient) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) { + mid := string(portal.ID) + lowerMid := strings.ToLower(mid) + if strings.HasPrefix(lowerMid, "c") || strings.HasPrefix(lowerMid, "r") { + client := line.NewClient(lc.AccessToken) + res, err := client.GetChats([]string{mid}, true, true) + if err != nil && lc.isRefreshRequired(err) { + if errRefresh := lc.refreshAndSave(ctx); errRefresh == nil { + client = line.NewClient(lc.AccessToken) + res, err = client.GetChats([]string{mid}, true, true) + } + } + if err != nil { + return nil, err + } + if len(res.Chats) == 0 { + return nil, fmt.Errorf("chat not found") + } + return lc.chatToChatInfo(&res.Chats[0]), nil + } + contact := lc.getContact(string(portal.ID)) var avatar *bridgev2.Avatar if contact.PicturePath != "" { @@ -458,6 +721,12 @@ func (lc *LineClient) getContact(mid string) line.Contact { } client := line.NewClient(lc.AccessToken) res, err := client.GetContactsV2([]string{mid}) + if err != nil && lc.isRefreshRequired(err) { + if errRefresh := lc.refreshAndSave(context.TODO()); errRefresh == nil { + client = line.NewClient(lc.AccessToken) + res, err = client.GetContactsV2([]string{mid}) + } + } if err == nil && res != nil && res.Contacts != nil { if wrapper, ok := res.Contacts[mid]; ok { lc.contactCache[mid] = wrapper.Contact @@ -489,6 +758,66 @@ func guessToType(mid string) int { return 0 // USER } +// fetchAndUnwrapGroupKey retrieves a specific group key (or the latest when groupKeyID == 0) +// and unwraps it so the E2EE manager can encrypt/decrypt group messages. +func (lc *LineClient) fetchAndUnwrapGroupKey(ctx context.Context, chatMid string, groupKeyID int) error { + if lc.E2EE == nil { + return fmt.Errorf("E2EE manager not initialized") + } + + client := line.NewClient(lc.AccessToken) + fetch := func() (*line.E2EEGroupSharedKey, error) { + if groupKeyID > 0 { + return client.GetE2EEGroupSharedKey(chatMid, groupKeyID) + } + return client.GetLastE2EEGroupSharedKey(chatMid) + } + + sharedKey, err := fetch() + if err != nil && lc.isRefreshRequired(err) { + if errRefresh := lc.refreshAndSave(ctx); errRefresh == nil { + client = line.NewClient(lc.AccessToken) + sharedKey, err = fetch() + } else { + return fmt.Errorf("failed to refresh token before fetching group key: %w", errRefresh) + } + } + if err != nil { + return err + } + if sharedKey == nil { + 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") + + 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) + } + + 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 +} + func (lc *LineClient) ensurePeerKey(_ context.Context, mid string) (int, string, error) { if lc.peerKeys == nil { lc.peerKeys = make(map[string]peerKeyInfo) @@ -516,6 +845,42 @@ func (lc *LineClient) ensurePeerKey(_ context.Context, mid string) (int, string, return pk.raw, pk.pub, nil } +func (lc *LineClient) ensurePeerKeyByID(_ context.Context, mid string, keyID int) (int, string, error) { + if lc.peerKeys == nil { + lc.peerKeys = make(map[string]peerKeyInfo) + } + if cached, ok := lc.peerKeys[mid]; ok && cached.raw == keyID && cached.pub != "" { + if lc.E2EE != nil { + lc.E2EE.RegisterPeerPublicKey(cached.raw, cached.pub) + } + return cached.raw, cached.pub, nil + } + + client := line.NewClient(lc.AccessToken) + // keyVersion 1 + res, err := client.GetE2EEPublicKey(mid, 1, keyID) + if err != nil { + return 0, "", err + } + + resKeyID, err := res.KeyID.Int64() + if err != nil { + return 0, "", err + } + + if int(resKeyID) != keyID { + return 0, "", fmt.Errorf("fetched key ID %d does not match requested %d", resKeyID, keyID) + } + + pk := peerKeyInfo{raw: int(resKeyID), pub: res.PublicKey} + // Cache the fetched key so subsequent lookups reuse it. + lc.peerKeys[mid] = pk + if lc.E2EE != nil { + lc.E2EE.RegisterPeerPublicKey(pk.raw, pk.pub) + } + return pk.raw, pk.pub, nil +} + func (lc *LineClient) ensurePeerKeyForMessage(ctx context.Context, msg *line.Message) { if lc.E2EE == nil || len(msg.Chunks) < 5 { return @@ -533,11 +898,13 @@ func (lc *LineClient) ensurePeerKeyForMessage(ctx context.Context, msg *line.Mes if peerRaw == 0 || peerRaw == myRaw { return } - if _, ok := lc.peerKeys[msg.From]; ok { + if lc.E2EE.HasPeerPublicKey(peerRaw) { return } - if _, _, err := lc.ensurePeerKey(ctx, msg.From); err != nil { - lc.UserLogin.Bridge.Log.Warn().Err(err).Str("peer", msg.From).Msg("Failed to fetch peer key for decrypt") + if _, _, err := lc.ensurePeerKeyByID(ctx, msg.From, peerRaw); err != nil { + if _, _, err2 := lc.ensurePeerKey(ctx, msg.From); err2 != nil { + lc.UserLogin.Bridge.Log.Warn().Err(err).Err(err2).Str("peer", msg.From).Int("key_id", peerRaw).Msg("Failed to fetch peer key for decrypt") + } } } @@ -552,20 +919,47 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat portalMid := string(msg.Portal.ID) client := line.NewClient(lc.AccessToken) - myRaw, myKeyID, err := lc.E2EE.MyKeyIDs() - if err != nil { - return nil, fmt.Errorf("missing own E2EE key: %w", err) - } + fromMid := lc.midOrFallback() - peerRaw, peerPub, err := lc.ensurePeerKey(ctx, portalMid) - if err != nil { - return nil, fmt.Errorf("failed to get peer key: %w", err) - } + var chunks []string + var err error + lowerPortalID := strings.ToLower(portalMid) + // groups *should* start with 'c' and rooms with 'r' + if strings.HasPrefix(lowerPortalID, "c") || strings.HasPrefix(lowerPortalID, "r") { + 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") + } + chunks, err = lc.E2EE.EncryptGroupMessage(portalMid, fromMid, msg.Content.Body) + if err != nil { + lc.UserLogin.Bridge.Log.Debug().Err(err).Str("chat_mid", portalMid).Msg("EncryptGroupMessage failed, fetching latest group key") + if errFetch := lc.fetchAndUnwrapGroupKey(ctx, portalMid, 0); errFetch != nil { + err = fmt.Errorf("%w (failed to fetch latest group key: %v)", err, errFetch) + } else { + chunks, err = lc.E2EE.EncryptGroupMessage(portalMid, fromMid, msg.Content.Body) + if err != nil { + lc.UserLogin.Bridge.Log.Debug().Err(err).Str("chat_mid", portalMid).Msg("EncryptGroupMessage still missing key after fetch") + } + } + } + if err != nil { + return nil, fmt.Errorf("group encrypt failed: %w", err) + } + } else { + // 1-1 Encryption + myRaw, myKeyID, err := lc.E2EE.MyKeyIDs() + if err != nil { + return nil, fmt.Errorf("missing own E2EE key: %w", err) + } - fromMid := lc.midOrFallback() - chunks, err := lc.E2EE.EncryptMessageV2(portalMid, fromMid, myKeyID, peerPub, myRaw, peerRaw, 0, msg.Content.Body) - if err != nil { - return nil, fmt.Errorf("encrypt failed: %w", err) + peerRaw, peerPub, err := lc.ensurePeerKey(ctx, portalMid) + if err != nil { + return nil, fmt.Errorf("failed to get peer key: %w", err) + } + + chunks, err = lc.E2EE.EncryptMessageV2(portalMid, fromMid, myKeyID, peerPub, myRaw, peerRaw, 0, msg.Content.Body) + if err != nil { + return nil, fmt.Errorf("encrypt failed: %w", err) + } } now := time.Now().UnixMilli() diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index d401c44..80bc815 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -86,6 +86,7 @@ func (lc *LineConnector) GetDBMetaTypes() database.MetaTypes { type UserLoginMetadata struct { AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token,omitempty"` Email string `json:"email,omitempty"` Password string `json:"password,omitempty"` Mid string `json:"mid,omitempty"` @@ -99,10 +100,11 @@ type UserLoginMetadata struct { func (lc *LineConnector) LoadUserLogin(ctx context.Context, login *bridgev2.UserLogin) error { meta := login.Metadata.(*UserLoginMetadata) login.Client = &LineClient{ - UserLogin: login, - AccessToken: meta.AccessToken, - Mid: meta.Mid, - HTTPClient: &http.Client{Timeout: 10 * time.Second}, + UserLogin: login, + AccessToken: meta.AccessToken, + RefreshToken: meta.RefreshToken, + Mid: meta.Mid, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, } return nil } @@ -261,8 +263,10 @@ func (ll *LineEmailLogin) finishLogin(ctx context.Context, res *line.LoginResult } token := res.AuthToken + refreshToken := "" if token == "" && res.TokenV3IssueResult != nil { token = res.TokenV3IssueResult.AccessToken + refreshToken = res.TokenV3IssueResult.RefreshToken } if token == "" { return nil, fmt.Errorf("missing access token in login result") @@ -279,7 +283,7 @@ func (ll *LineEmailLogin) finishLogin(ctx context.Context, res *line.LoginResult displayName = "LINE User" } - meta := &UserLoginMetadata{AccessToken: token, Email: ll.Email, Password: ll.Password, Mid: res.Mid} + meta := &UserLoginMetadata{AccessToken: token, RefreshToken: refreshToken, Email: ll.Email, Password: ll.Password, Mid: res.Mid} if res.EncryptedKeyChain != "" && res.E2EEPublicKey != "" { meta.EncryptedKeyChain = res.EncryptedKeyChain meta.E2EEPublicKey = res.E2EEPublicKey @@ -306,9 +310,10 @@ func (ll *LineEmailLogin) finishLogin(ctx context.Context, res *line.LoginResult }, &bridgev2.NewLoginParams{ LoadUserLogin: func(ctx context.Context, login *bridgev2.UserLogin) error { login.Client = &LineClient{ - UserLogin: login, - AccessToken: token, - HTTPClient: &http.Client{Timeout: 10 * time.Second}, + UserLogin: login, + AccessToken: token, + RefreshToken: refreshToken, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, } return nil }, diff --git a/pkg/e2ee/manager.go b/pkg/e2ee/manager.go index 2cf3fb0..045044e 100644 --- a/pkg/e2ee/manager.go +++ b/pkg/e2ee/manager.go @@ -31,6 +31,13 @@ type Manager struct { keyByRawID map[int]int // raw key id -> runner key id channelByPair map[string]int // privRaw/peerRaw -> channelId sequence map[string]int // portal/chat id -> seq + + // groupKeys maps chatMid -> groupKeyId -> e2eeKeyId (unwrapped group private key, runner ID) + groupKeys map[string]map[int]int + // groupSessionChannels maps chatMid -> groupKeyId -> senderKeyId -> channelId (runner Channel ID) + groupSessionChannels map[string]map[int]map[int]int + + latestGroupKey map[string]int // chatMid -> latest groupKeyId } func NewManager() (*Manager, error) { @@ -39,11 +46,14 @@ func NewManager() (*Manager, error) { return nil, err } return &Manager{ - runner: r, - peerPublic: make(map[int]string), - keyByRawID: make(map[int]int), - channelByPair: make(map[string]int), - sequence: make(map[string]int), + runner: r, + peerPublic: make(map[int]string), + keyByRawID: make(map[int]int), + channelByPair: make(map[string]int), + sequence: make(map[string]int), + groupKeys: make(map[string]map[int]int), + groupSessionChannels: make(map[string]map[int]map[int]int), + latestGroupKey: make(map[string]int), }, nil } @@ -321,6 +331,185 @@ func (m *Manager) DecryptMessageV2(msg *line.Message) (string, error) { return pt, nil } +func (m *Manager) UnwrapGroupSharedKey(chatMid string, sharedKey *line.E2EEGroupSharedKey) (int, error) { + m.mu.Lock() + defer m.mu.Unlock() + + creatorRawKeyID := sharedKey.CreatorKeyID + creatorPubKeyB64, ok := m.peerPublic[creatorRawKeyID] + if !ok { + return 0, fmt.Errorf("missing creator public key for %d", creatorRawKeyID) + } + + receiverRawKeyID := sharedKey.ReceiverKeyID + myRunnerKey := 0 + if receiverRawKeyID == m.myRawKeyID { + myRunnerKey = m.myKeyID + } else if id, ok := m.keyByRawID[receiverRawKeyID]; ok { + myRunnerKey = id + } + + if myRunnerKey == 0 { + return 0, fmt.Errorf("missing my private key for %d", receiverRawKeyID) + } + + chanID, err := m.runner.ChannelCreate(myRunnerKey, creatorPubKeyB64) + if err != nil { + return 0, fmt.Errorf("failed to create channel with creator: %w", err) + } + + unwrappedKeyID, err := m.runner.ChannelUnwrapGroupSharedKey(chanID, sharedKey.EncryptedSharedKey) + if err != nil { + return 0, fmt.Errorf("failed to unwrap group key: %w", err) + } + + if _, ok := m.groupKeys[chatMid]; !ok { + m.groupKeys[chatMid] = make(map[int]int) + } + m.groupKeys[chatMid][sharedKey.GroupKeyID] = unwrappedKeyID + + m.latestGroupKey[chatMid] = sharedKey.GroupKeyID + + return unwrappedKeyID, nil +} + +func (m *Manager) DecryptGroupMessage(msg *line.Message, chatMid string) (string, int, error) { + // Group chunks: [..., groupKeyID(4), ???] + // JS: vF365(t, n, r); n=myKeyID, r=groupKeyID. + // chunks = [first, body, tag, senderKeyID, groupKeyID] + if len(msg.Chunks) < 5 { + return "", 0, fmt.Errorf("not enough chunks") + } + + senderKeyID, err := DecodeKeyID(msg.Chunks[len(msg.Chunks)-2]) + if err != nil { + return "", 0, fmt.Errorf("failed to decode sender key id: %w", err) + } + + groupKeyID, err := DecodeKeyID(msg.Chunks[len(msg.Chunks)-1]) + if err != nil { + return "", 0, fmt.Errorf("failed to decode group key id: %w", err) + } + + m.mu.Lock() + keyMap, ok := m.groupKeys[chatMid] + var unwrappedKeyID int + if ok { + unwrappedKeyID = keyMap[groupKeyID] + } + m.mu.Unlock() + + if unwrappedKeyID == 0 { + return "", groupKeyID, fmt.Errorf("group key %d not found", groupKeyID) + } + + chanID, err := m.ensureGroupChannel(chatMid, groupKeyID, unwrappedKeyID, senderKeyID) + if err != nil { + return "", groupKeyID, err + } + + cipher, err := assembleCipher(msg.Chunks) + if err != nil { + return "", groupKeyID, err + } + + pt, _, err := m.runner.ChannelDecryptV2(chanID, msg.To, msg.From, senderKeyID, groupKeyID, msg.ContentType, base64.StdEncoding.EncodeToString(cipher)) + if err != nil { + return "", groupKeyID, err + } + + return pt, groupKeyID, nil +} + +func (m *Manager) ensureGroupChannel(chatMid string, groupKeyID, unwrappedKeyID, senderKeyID int) (int, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if m.groupSessionChannels[chatMid] == nil { + m.groupSessionChannels[chatMid] = make(map[int]map[int]int) + } + if m.groupSessionChannels[chatMid][groupKeyID] == nil { + m.groupSessionChannels[chatMid][groupKeyID] = make(map[int]int) + } + if id, ok := m.groupSessionChannels[chatMid][groupKeyID][senderKeyID]; ok { + return id, nil + } + + senderPubB64, ok := m.peerPublic[senderKeyID] + if !ok { + return 0, fmt.Errorf("missing public key for sender %d", senderKeyID) + } + + chanID, err := m.runner.ChannelCreate(unwrappedKeyID, senderPubB64) + if err != nil { + return 0, fmt.Errorf("failed to create group channel: %w", err) + } + + m.groupSessionChannels[chatMid][groupKeyID][senderKeyID] = chanID + return chanID, nil +} + +func (m *Manager) EncryptGroupMessage(chatMid, fromMid string, plaintext string) ([]string, error) { + m.mu.Lock() + groupKeyID, ok := m.latestGroupKey[chatMid] + var unwrappedKeyID int + if ok { + unwrappedKeyID = m.groupKeys[chatMid][groupKeyID] + } + myKeyID := m.myRawKeyID + seq := m.sequence[chatMid] + 1 + m.sequence[chatMid] = seq + m.mu.Unlock() + + if !ok || unwrappedKeyID == 0 { + return nil, fmt.Errorf("no group key found for %s", chatMid) + } + + chanID, err := m.ensureGroupChannel(chatMid, groupKeyID, unwrappedKeyID, myKeyID) + if err != nil { + return nil, err + } + + payload, err := json.Marshal(map[string]string{"text": plaintext}) + if err != nil { + return nil, fmt.Errorf("failed to marshal payload: %w", err) + } + + ctB64, err := m.runner.ChannelEncryptV2(chanID, chatMid, fromMid, myKeyID, groupKeyID, 0, seq, string(payload)) + if err != nil { + return nil, err + } + ctBytes, err := base64.StdEncoding.DecodeString(ctB64) + if err != nil { + return nil, err + } + + if len(ctBytes) < 28 { + return nil, fmt.Errorf("ciphertext too short") + } + + first := ctBytes[:16] + tag := ctBytes[16:28] + body := ctBytes[28:] + + senderBuf := make([]byte, 4) + receiverBuf := make([]byte, 4) + binary.BigEndian.PutUint32(senderBuf, uint32(myKeyID)) // Sender is my Key ID + binary.BigEndian.PutUint32(receiverBuf, uint32(groupKeyID)) // Receiver is Group Key ID + + toB64 := func(b []byte) string { return base64.StdEncoding.EncodeToString(b) } + // [first, body, tag, senderKeyID, groupKeyID] + chunks := []string{toB64(first), toB64(body), toB64(tag), toB64(senderBuf), toB64(receiverBuf)} + return chunks, nil +} + +func (m *Manager) HasPeerPublicKey(rawKeyID int) bool { + m.mu.Lock() + defer m.mu.Unlock() + _, ok := m.peerPublic[rawKeyID] + return ok +} + func (m *Manager) RegisterPeerPublicKey(rawKeyID int, pubB64 string) { m.mu.Lock() defer m.mu.Unlock() diff --git a/pkg/internal/runner.js b/pkg/internal/runner.js index 3d6a971..195b6bc 100644 --- a/pkg/internal/runner.js +++ b/pkg/internal/runner.js @@ -364,6 +364,12 @@ async function run() { const chan = k.createChannel(b64ToU8(peerPublicKey)); const chanId = putChannel(chan); process.stdout.write(JSON.stringify({ channelId: chanId }) + "\n"); + } else if (type === "channel_unwrap_group_shared_key") { + const { channelId, encryptedSharedKey } = query; + const chan = getChannel(toIntStrict("channelId", channelId)); + const unwrappedKey = chan.unwrapGroupSharedKey(b64ToU8(encryptedSharedKey)); + const keyId = putKey(unwrappedKey); + process.stdout.write(JSON.stringify({ keyId }) + "\n"); } else if (type === "channel_encrypt_v2") { const { channelId, diff --git a/pkg/line/client.go b/pkg/line/client.go index dad2f80..8bac29f 100644 --- a/pkg/line/client.go +++ b/pkg/line/client.go @@ -305,3 +305,33 @@ func (c *Client) postWithHMAC(fullURL string, body []byte) ([]byte, error) { return io.ReadAll(resp.Body) } + +func (c *Client) RefreshAccessToken(refreshToken string) (*TokenV3IssueResult, error) { + url := "https://line-chrome-gw.line-apps.com/api/auth/tokenRefresh" + + reqBody := RefreshAccessTokenRequest{ + RefreshToken: refreshToken, + RetryCount: 0, + } + + bodyBytes, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal refresh request: %w", err) + } + + respBytes, err := c.postWithHMAC(url, bodyBytes) + if err != nil { + return nil, err + } + + var res TokenV3IssueResult + if err := json.Unmarshal(respBytes, &res); err != nil { + return nil, fmt.Errorf("failed to parse refresh response: %w", err) + } + + if res.AccessToken != "" { + c.AccessToken = res.AccessToken + } + + return &res, nil +} diff --git a/pkg/line/methods.go b/pkg/line/methods.go index a0fa5b2..0618f02 100644 --- a/pkg/line/methods.go +++ b/pkg/line/methods.go @@ -104,8 +104,44 @@ func (c *Client) GetEncryptedIdentityV3() (*EncryptedIdentityV3, error) { if err := json.Unmarshal(resp, &wrapper); err != nil { return nil, err } + return &wrapper.Data, nil +} + +func (c *Client) GetE2EEGroupSharedKey(chatMid string, groupKeyID int) (*E2EEGroupSharedKey, error) { + // args: [1, chatMid, groupKeyID] + resp, err := c.callRPC("TalkService", "getE2EEGroupSharedKey", 1, chatMid, groupKeyID) + if err != nil { + return nil, err + } + var wrapper struct { + Code int `json:"code"` + Message string `json:"message"` + Data E2EEGroupSharedKey `json:"data"` + } + if err := json.Unmarshal(resp, &wrapper); err != nil { + return nil, err + } if wrapper.Code != 0 { - return nil, fmt.Errorf("getEncryptedIdentityV3 failed: %s", wrapper.Message) + return nil, fmt.Errorf("getE2EEGroupSharedKey failed: %s", wrapper.Message) + } + return &wrapper.Data, nil +} + +func (c *Client) GetLastE2EEGroupSharedKey(chatMid string) (*E2EEGroupSharedKey, error) { + resp, err := c.callRPC("TalkService", "getLastE2EEGroupSharedKey", 1, chatMid) + if err != nil { + return nil, err + } + var wrapper struct { + Code int `json:"code"` + Message string `json:"message"` + Data E2EEGroupSharedKey `json:"data"` + } + if err := json.Unmarshal(resp, &wrapper); err != nil { + return nil, err + } + if wrapper.Code != 0 { + return nil, fmt.Errorf("getLastE2EEGroupSharedKey failed: %s", wrapper.Message) } return &wrapper.Data, nil } @@ -127,8 +163,12 @@ func (c *Client) NegotiateE2EEPublicKey(mid string) (*E2EEPublicKey, error) { if wrapper.Code != 0 { return nil, fmt.Errorf("negotiateE2EEPublicKey failed: %s", wrapper.Message) } + return parseE2EEPublicKey(wrapper.Data) +} + +func parseE2EEPublicKey(rawData []byte) (*E2EEPublicKey, error) { var data map[string]any - if err := json.Unmarshal(wrapper.Data, &data); err != nil { + if err := json.Unmarshal(rawData, &data); err != nil { return nil, err } @@ -234,7 +274,7 @@ func (c *Client) NegotiateE2EEPublicKey(mid string) (*E2EEPublicKey, error) { keyID = findInt64(data) } if pub == "" || keyID == 0 { - return nil, fmt.Errorf("negotiateE2EEPublicKey: missing fields (pub=%t keyID=%d raw=%s)", pub != "", keyID, string(wrapper.Data)) + return nil, fmt.Errorf("missing fields (pub=%t keyID=%d raw=%s)", pub != "", keyID, string(rawData)) } return &E2EEPublicKey{ @@ -247,6 +287,26 @@ func (c *Client) NegotiateE2EEPublicKey(mid string) (*E2EEPublicKey, error) { }, nil } +func (c *Client) GetE2EEPublicKey(mid string, keyVersion, keyID int) (*E2EEPublicKey, error) { + resp, err := c.callRPC("TalkService", "getE2EEPublicKey", mid, keyVersion, keyID) + if err != nil { + return nil, err + } + var wrapper struct { + Code int `json:"code"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(resp, &wrapper); err != nil { + return nil, err + } + if wrapper.Code != 0 { + return nil, fmt.Errorf("getE2EEPublicKey failed: %s", wrapper.Message) + } + + return parseE2EEPublicKey(wrapper.Data) +} + func (c *Client) SendMessage(reqSeq int64, msg *Message) error { resp, err := c.callRPC("TalkService", "sendMessage", reqSeq, msg) if err != nil { @@ -292,14 +352,49 @@ func (c *Client) GetContactsV2(mids []string) (*ContactsResponse, error) { return &wrapper.Data, nil } -func (c *Client) GetMessageBoxes() ([]byte, error) { - reqStruct := map[string]interface{}{ - "activeOnly": true, - "unreadOnly": false, - "messageBoxCountLimit": 100, - "withUnreadCount": true, - "lastMessagesPerMessageBoxCount": 1, - } - // "2" is the syncReason - return c.callRPC("TalkService", "getMessageBoxes", reqStruct, 2) +func (c *Client) GetAllChatMids(withMemberChats, withInvitedChats bool) (*GetAllChatMidsResponse, error) { + req := GetAllChatMidsRequest{ + WithMemberChats: withMemberChats, + WithInvitedChats: withInvitedChats, + } + resp, err := c.callRPC("TalkService", "getAllChatMids", req, 2) + if err != nil { + return nil, err + } + var wrapper struct { + Code int `json:"code"` + Message string `json:"message"` + Data GetAllChatMidsResponse `json:"data"` + } + if err := json.Unmarshal(resp, &wrapper); err != nil { + return nil, err + } + if wrapper.Code != 0 { + return nil, fmt.Errorf("getAllChatMids failed: %s", wrapper.Message) + } + return &wrapper.Data, nil +} + +func (c *Client) GetChats(mids []string, withMembers, withInvitees bool) (*GetChatsResponse, error) { + req := GetChatsRequest{ + ChatMids: mids, + WithMembers: withMembers, + WithInvitees: withInvitees, + } + resp, err := c.callRPC("TalkService", "getChats", req, 2) + if err != nil { + return nil, err + } + var wrapper struct { + Code int `json:"code"` + Message string `json:"message"` + Data GetChatsResponse `json:"data"` + } + if err := json.Unmarshal(resp, &wrapper); err != nil { + return nil, err + } + if wrapper.Code != 0 { + return nil, fmt.Errorf("getChats failed: %s", wrapper.Message) + } + return &wrapper.Data, nil } diff --git a/pkg/line/structs.go b/pkg/line/structs.go index 3750a4a..552f6fe 100644 --- a/pkg/line/structs.go +++ b/pkg/line/structs.go @@ -152,3 +152,67 @@ type Contact struct { StatusMessage string `json:"statusMessage"` PicturePath string `json:"picturePath"` } + +type E2EEGroupSharedKey struct { + KeyVersion int `json:"keyVersion"` + GroupKeyID int `json:"groupKeyId"` + Creator string `json:"creator"` + CreatorKeyID int `json:"creatorKeyId"` + Receiver string `json:"receiver"` + ReceiverKeyID int `json:"receiverKeyId"` + EncryptedSharedKey string `json:"encryptedSharedKey"` + AllowedTypes []int `json:"allowedTypes"` + SpecVersion int `json:"specVersion"` +} + +type RefreshAccessTokenRequest struct { + RefreshToken string `json:"refreshToken"` + RetryCount int `json:"retryCount"` +} + +type GetAllChatMidsRequest struct { + WithMemberChats bool `json:"withMemberChats"` + WithInvitedChats bool `json:"withInvitedChats"` +} + +type GetAllChatMidsResponse struct { + MemberChatMids []string `json:"memberChatMids"` + InvitedChatMids []string `json:"invitedChatMids"` +} + +type GetChatsRequest struct { + ChatMids []string `json:"chatMids"` + WithMembers bool `json:"withMembers"` + WithInvitees bool `json:"withInvitees"` +} + +type GetChatsResponse struct { + Chats []Chat `json:"chats"` +} + +type Chat struct { + ChatMid string `json:"chatMid"` + CreatedTime int64 `json:"createdTime"` + NotificationDisabled bool `json:"notificationDisabled"` + FavoriteTimestamp int64 `json:"favoriteTimestamp"` + ChatName string `json:"chatName"` + PicturePath string `json:"picturePath"` + Extra ChatExtra `json:"extra"` + Type int `json:"type"` // 0=GROUP, 1=ROOM +} + +type ChatExtra struct { + GroupExtra *GroupExtra `json:"groupExtra,omitempty"` + PeerExtra *PeerExtra `json:"peerExtra,omitempty"` +} + +type GroupExtra struct { + CreatorMid string `json:"creatorMid"` + PreventedMids map[string]bool `json:"preventedMids"` + InvitationTicket string `json:"invitationTicket"` + MemberMids map[string]bool `json:"memberMids"` + InviteeMids map[string]bool `json:"inviteeMids"` +} + +type PeerExtra struct { +} diff --git a/pkg/runner.go b/pkg/runner.go index 3c9e644..0e4a6bc 100644 --- a/pkg/runner.go +++ b/pkg/runner.go @@ -357,6 +357,23 @@ func (r *Runner) ChannelCreate(keyID int, peerPublicB64 string) (int, error) { return chanID, nil } +// unwraps the group shared key using the channel +func (r *Runner) ChannelUnwrapGroupSharedKey(channelID int, encryptedSharedKeyB64 string) (int, error) { + resp, err := r.call(map[string]any{ + "type": "channel_unwrap_group_shared_key", + "channelId": channelID, + "encryptedSharedKey": encryptedSharedKeyB64, + }) + if err != nil { + return 0, err + } + var keyID int + if v, ok := resp["keyId"]; ok { + _ = json.Unmarshal(v, &keyID) + } + return keyID, nil +} + type UnwrappedKey struct { KeyID int `json:"keyId"` Exported string `json:"exported"`