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
194 changes: 61 additions & 133 deletions pkg/connector/creategroup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
)

Expand Down Expand Up @@ -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...)
Expand All @@ -66,7 +65,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")
Expand Down Expand Up @@ -131,153 +130,82 @@ 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("%w: no registration members returned for group key", line.ErrNoUsableE2EEGroupKey)
}
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 {
Comment thread
indent[bot] marked this conversation as resolved.
return fmt.Errorf("%w: group key registration members do not include caller", line.ErrNoUsableE2EEGroupKey)
}
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) {
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)
}
}
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
}
Loading
Loading