From cca2b95af7caf91f91719addf9a1f2471bf992cc Mon Sep 17 00:00:00 2001 From: highesttt Date: Tue, 6 Oct 2026 14:02:29 -0400 Subject: [PATCH 1/3] feat: LINE QR Login as Primary login --- pkg/connector/connector.go | 23 +++- pkg/connector/login_qr.go | 152 ++++++++++++++++++++++++ pkg/line/qr.go | 230 +++++++++++++++++++++++++++++++++++++ pkg/runner.go | 2 +- 4 files changed, 404 insertions(+), 3 deletions(-) create mode 100644 pkg/connector/login_qr.go create mode 100644 pkg/line/qr.go diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index a3db57c..9ee892d 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -28,6 +28,7 @@ const ( ) type LineConnector struct { + Config Config br *bridgev2.Bridge loginFinalizeMu sync.Mutex directMedia atomic.Bool @@ -96,7 +97,17 @@ func (lc *LineConnector) GetName() bridgev2.BridgeName { } func (lc *LineConnector) GetConfig() (example string, data any, upgrader configupgrade.Upgrader) { - return "", nil, nil + const base = "qr_login: false\n" + return base, &lc.Config, &configupgrade.StructUpgrader{ + Base: base, + SimpleUpgrader: func(helper configupgrade.Helper) { + helper.Copy(configupgrade.Bool, "qr_login") + }, + } +} + +type Config struct { + QRLogin bool `yaml:"qr_login"` } func (lc *LineConnector) GetDBMetaTypes() database.MetaTypes { @@ -154,16 +165,24 @@ func (lc *LineConnector) LoadUserLogin(ctx context.Context, login *bridgev2.User } const LoginFlowIDEmail = "dev.highest.matrix.line.email_login" +const LoginFlowIDQR = "dev.highest.matrix.line.qr_login" func (lc *LineConnector) GetLoginFlows() []bridgev2.LoginFlow { - return []bridgev2.LoginFlow{{ + flows := []bridgev2.LoginFlow{{ Name: "Login", Description: "Login with your LINE Email and Password", ID: LoginFlowIDEmail, }} + if lc.Config.QRLogin { + flows = append([]bridgev2.LoginFlow{{Name: "QR Code", Description: "Scan a QR code with the LINE mobile app", ID: LoginFlowIDQR}}, flows...) + } + return flows } func (lc *LineConnector) CreateLogin(ctx context.Context, user *bridgev2.User, flowID string) (bridgev2.LoginProcess, error) { + if flowID == LoginFlowIDQR && lc.Config.QRLogin { + return &LineQRLogin{login: &LineEmailLogin{User: user, finalizeMu: &lc.loginFinalizeMu}}, nil + } if flowID != LoginFlowIDEmail { return nil, bridgev2.ErrInvalidLoginFlowID } diff --git a/pkg/connector/login_qr.go b/pkg/connector/login_qr.go new file mode 100644 index 0000000..411a5eb --- /dev/null +++ b/pkg/connector/login_qr.go @@ -0,0 +1,152 @@ +package connector + +import ( + "context" + "fmt" + "time" + + "maunium.net/go/mautrix/bridgev2" + + "github.com/highesttt/matrix-line-messenger/pkg/line" +) + +type LineQRLogin struct { + login *LineEmailLogin + client *line.Client + session string + qr *line.QRCodeResponse + poll chan error + pin bool +} + +var _ bridgev2.LoginProcessDisplayAndWait = (*LineQRLogin)(nil) +var _ bridgev2.LoginProcessWithOverride = (*LineQRLogin)(nil) + +func (lq *LineQRLogin) StartWithOverride(ctx context.Context, override *bridgev2.UserLogin) (*bridgev2.LoginStep, error) { + meta, ok := override.Metadata.(*UserLoginMetadata) + if !ok { + return nil, fmt.Errorf("existing LINE login metadata has unexpected type %T", override.Metadata) + } + lq.login.ExistingLogin, lq.login.ExistingMetadata = override, meta + lq.login.Certificate = meta.Certificate + return lq.Start(ctx) +} + +func (lq *LineQRLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) { + ll := lq.login + ll.mu.Lock() + if ll.canceled || ll.attemptCtx != nil { + ll.mu.Unlock() + return nil, fmt.Errorf("QR login is already started or canceled") + } + ll.attemptCtx, ll.attemptCancel = context.WithTimeout(context.WithoutCancel(ctx), 10*time.Minute) + processCtx := ll.attemptCtx + ll.mu.Unlock() + stop := context.AfterFunc(ctx, ll.attemptCancel) + defer stop() + client := newLineAPIClient("") + session, qr, err := client.StartQRLogin(processCtx) + ll.mu.Lock() + ll.attempt = client.LoginAttempt + canceled := ll.canceled || ctx.Err() != nil || processCtx.Err() != nil + ll.mu.Unlock() + if err != nil || canceled { + lq.Cancel() + if canceled { + return nil, context.Canceled + } + return nil, err + } + lq.client, lq.session, lq.qr = client, session, qr + timeout := time.Duration(qr.LongPollingIntervalSeconds) * time.Second + if timeout <= 0 { + timeout = 150 * time.Second + } + lq.startPoll(func(ctx context.Context, session string) error { + return client.CheckQRCodeVerifiedContext(ctx, session, timeout) + }, true) + return &bridgev2.LoginStep{ + Type: bridgev2.LoginStepTypeDisplayAndWait, StepID: "dev.highest.matrix.line.qr", + Instructions: "Scan this QR code with the LINE mobile app.", + DisplayAndWaitParams: &bridgev2.LoginDisplayAndWaitParams{Type: bridgev2.LoginDisplayTypeQR, Data: qr.CallbackURL}, + }, nil +} + +func (lq *LineQRLogin) startPoll(check func(context.Context, string) error, retryExpired bool) { + result := make(chan error, 1) + lq.poll = result + ctx, session, qr := lq.login.attemptCtx, lq.session, lq.qr + go func() { + count := max(1, min(qr.LongPollingMaxCount, 10)) + for attempt := 0; ; attempt++ { + err := check(ctx, session) + if err == nil || ctx.Err() != nil || attempt+1 >= count || !retryExpired || !line.IsQRLoginPollExpired(err) { + result <- err + return + } + timer := time.NewTimer(min(time.Second< 0 { + service = "SecondaryQrCodeLoginPermitNoticeService" + } + path := "/api/talk/thrift/LoginQrCode/" + service + "/" + method + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://line-chrome-gw.line-apps.com"+path, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", UserAgent) + req.Header.Set("x-line-chrome-version", ExtensionVersion) + req.Header.Set("x-lal", "en_US") + if session != "" { + req.Header.Set("X-Line-Session-ID", session) + } + if pollTimeout > 0 { + req.Header.Set("X-LST", strconv.FormatInt(pollTimeout.Milliseconds(), 10)) + } + signature, err := c.LoginAttempt.Runner.GetSignature(path, string(body), "") + if err != nil { + return err + } + req.Header.Set("x-hmac", signature) + httpClient := *c.HTTPClient + if pollTimeout > 0 { + httpClient.Timeout = pollTimeout + 10*time.Second + } + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + var wrapper struct { + Code *int `json:"code"` + Data json.RawMessage `json:"data"` + } + decodeErr := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&wrapper) + if resp.StatusCode != http.StatusOK || (wrapper.Code != nil && *wrapper.Code != 0) { + response := &qrRPCError{method: method, httpStatus: resp.StatusCode} + if wrapper.Code != nil { + response.code = *wrapper.Code + } + var details struct { + StatusCode int `json:"statusCode"` + } + if json.Unmarshal(wrapper.Data, &details) == nil { + response.statusCode = details.StatusCode + } + return response + } + if decodeErr != nil || wrapper.Code == nil { + return fmt.Errorf("QR %s returned an invalid response", method) + } + if out != nil { + if err = json.Unmarshal(wrapper.Data, out); err != nil { + return fmt.Errorf("QR %s returned invalid data", method) + } + } + return ctx.Err() +} diff --git a/pkg/runner.go b/pkg/runner.go index e633d37..cde2854 100644 --- a/pkg/runner.go +++ b/pkg/runner.go @@ -1149,7 +1149,7 @@ func (r *Runner) GenerateE2EESecret() (out0 *SecretResult, err error) { func (r *Runner) generateE2EESecret() (*SecretResult, error) { - ckPtr, err := r.rt.Curve25519KeyNew(r.skPtr) + ckPtr, err := r.rt.Curve25519KeyGenerate() if err != nil { return nil, err } From aa35008cd4f586d8454261227d4d60012283f553 Mon Sep 17 00:00:00 2001 From: highesttt Date: Tue, 6 Oct 2026 14:48:17 -0400 Subject: [PATCH 2/3] fix: preserve LINE credentials on QR reauthentication --- pkg/connector/connector.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 9ee892d..5aefd9d 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -716,17 +716,19 @@ func (ll *LineEmailLogin) finishLogin(ctx context.Context, res *line.LoginResult displayName = "LINE User" } - certificate := res.Certificate - if certificate == "" { - certificate = ll.Certificate - } mid := profile.Mid if mid == "" || (res.Mid != "" && res.Mid != mid) { return nil, errors.New("login result does not match verified LINE account") } - - meta := &UserLoginMetadata{AccessToken: token, RefreshToken: refreshToken, Email: ll.Email, Password: ll.Password, Certificate: certificate, Mid: mid} sameAccount := ll.ExistingMetadata != nil && ll.ExistingLogin != nil && ll.ExistingLogin.UserLogin != nil && mid == string(ll.ExistingLogin.ID) && mid == ll.ExistingMetadata.Mid + certificate := res.Certificate + if certificate == "" && (ll.ExistingMetadata == nil || sameAccount) { + certificate = ll.Certificate + } + meta := &UserLoginMetadata{AccessToken: token, RefreshToken: refreshToken, Email: ll.Email, Password: ll.Password, Certificate: certificate, Mid: mid} + if sameAccount && meta.Email == "" && meta.Password == "" { + meta.Email, meta.Password = ll.ExistingMetadata.Email, ll.ExistingMetadata.Password + } loginManager, err := ll.fetchLoginKeys(res, meta, client) if err != nil { From 22dca1a9ccbe7b1434dc7c75687faa0b51d679e2 Mon Sep 17 00:00:00 2001 From: highesttt Date: Tue, 6 Oct 2026 16:30:12 -0400 Subject: [PATCH 3/3] fix: relog line qr --- README.md | 14 ++++++++++++++ pkg/connector/client.go | 6 ++++++ pkg/connector/connector.go | 16 +++++++++++++++- pkg/connector/login_qr.go | 3 +++ pkg/line/qr.go | 2 +- pkg/runner.go | 4 +--- 6 files changed, 40 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 53d430f..37484f6 100644 --- a/README.md +++ b/README.md @@ -283,6 +283,20 @@ cd data ## Login +Email/password login is always available. Set `network.qr_login: true` in +`config.yaml` and restart to make QR Code the first login option. Scan with +the LINE mobile app, then enter the displayed PIN on your phone. Set the setting +back to `false` to disable new QR login attempts. + +Beeper Services controls client rollout with +`bridge:line:login:dev.highest.matrix.line.qr_login`, defaulting to `false`. +Distribute that flag before enabling QR login in the cloud bridge config. + +Use `logging.min_level: warn` when enabling QR login: provisioning info/debug +logs include QR URLs and verification codes. Passwordless accounts need another +QR scan if token refresh fails. QR login currently requires a Letter Sealing +keychain; accounts without one fail before a login is stored. + ### Via Beeper Desktop Settings 1. Open Beeper Desktop Settings diff --git a/pkg/connector/client.go b/pkg/connector/client.go index ef28939..f213bc9 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -23,6 +23,7 @@ import ( var ( errLineSessionInvalidated = errors.New("LINE session invalidated by another client") errLineClientSuperseded = errors.New("LINE client was superseded") + errLineQRLoginRequired = errors.New("LINE requires a new QR scan. Reconnect in Beeper to continue") ) const lineMissingE2EEKeyMessage = "LINE encryption keys are unavailable. Reconnect LINE in Beeper to restore message decryption." @@ -702,6 +703,11 @@ func (lc *LineClient) tryLogin(ctx context.Context) error { } if email == "" || password == "" { + if lc.UserLogin.Bridge != nil { + if network, ok := lc.UserLogin.Bridge.Network.(*LineConnector); ok && network.Config.QRLogin { + return errLineQRLoginRequired + } + } return fmt.Errorf("no stored credentials available for re-login") } diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 5aefd9d..1fee2b0 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -186,7 +186,7 @@ func (lc *LineConnector) CreateLogin(ctx context.Context, user *bridgev2.User, f if flowID != LoginFlowIDEmail { return nil, bridgev2.ErrInvalidLoginFlowID } - return &LineEmailLogin{User: user, finalizeMu: &lc.loginFinalizeMu}, nil + return &LineEmailLogin{User: user, finalizeMu: &lc.loginFinalizeMu, qrEnabled: lc.Config.QRLogin}, nil } type LineEmailLogin struct { @@ -200,6 +200,8 @@ type LineEmailLogin struct { ExistingMetadata *UserLoginMetadata ExistingLogin *bridgev2.UserLogin + qrEnabled bool + qrLogin *LineQRLogin pollResult chan *line.LoginResult pollErr chan error @@ -250,6 +252,14 @@ func (ll *LineEmailLogin) StartWithOverride(ctx context.Context, override *bridg ll.ExistingLogin = override if ll.Email == "" || ll.Password == "" { + if ll.qrEnabled { + ll.Email, ll.Password = "", "" + ll.mu.Lock() + ll.qrLogin = &LineQRLogin{login: ll} + qrLogin := ll.qrLogin + ll.mu.Unlock() + return qrLogin.StartWithOverride(ctx, override) + } return ll.loginErrorStep("No stored LINE credentials are available. Please enter your LINE email and password to reconnect."), nil } if meta.ForceFullE2EELogin || len(meta.ExportedKeyMap) == 0 { @@ -523,6 +533,10 @@ func (ll *LineEmailLogin) loginCredentials(ctx context.Context, certificate stri func (ll *LineEmailLogin) Wait(ctx context.Context) (*bridgev2.LoginStep, error) { ll.mu.Lock() + if qrLogin := ll.qrLogin; qrLogin != nil { + ll.mu.Unlock() + return qrLogin.Wait(ctx) + } verifier, awaitingPIN := ll.Verifier, ll.AwaitingPIN resultCh, errCh := ll.pollResult, ll.pollErr var done <-chan struct{} diff --git a/pkg/connector/login_qr.go b/pkg/connector/login_qr.go index 411a5eb..22760e6 100644 --- a/pkg/connector/login_qr.go +++ b/pkg/connector/login_qr.go @@ -29,6 +29,9 @@ func (lq *LineQRLogin) StartWithOverride(ctx context.Context, override *bridgev2 } lq.login.ExistingLogin, lq.login.ExistingMetadata = override, meta lq.login.Certificate = meta.Certificate + if meta.ForceFullE2EELogin || len(meta.ExportedKeyMap) == 0 { + lq.login.Certificate = "" + } return lq.Start(ctx) } diff --git a/pkg/line/qr.go b/pkg/line/qr.go index 9d92a93..e12d67a 100644 --- a/pkg/line/qr.go +++ b/pkg/line/qr.go @@ -88,7 +88,7 @@ func (c *Client) CheckQRCodeVerifiedContext(ctx context.Context, session string, func (c *Client) VerifyQRCertificate(ctx context.Context, session, certificate string) (bool, error) { err := c.callQRRPC(ctx, "verifyCertificate", "", 0, map[string]string{"authSessionId": session, "certificate": certificate}, nil) var response *qrRPCError - if errors.As(err, &response) && (response.httpStatus == 400 || (response.httpStatus == 200 && response.code != 0)) { + if errors.As(err, &response) && response.httpStatus == http.StatusBadRequest { return false, nil } return err == nil, err diff --git a/pkg/runner.go b/pkg/runner.go index cde2854..30a2dc6 100644 --- a/pkg/runner.go +++ b/pkg/runner.go @@ -1134,9 +1134,7 @@ func (r *Runner) channelDecryptV2(channelID int, to, from string, senderKeyID, r return string(ptBytes), base64.StdEncoding.EncodeToString(ptBytes), nil } -// GenerateE2EESecret generates a login secret with PIN and public key. -// The Curve25519Key is used because GenerateConfirmHash and -// LoginUnwrapKeyChain require the SKB-wrapped key for ECDH. +// GenerateE2EESecret generates a fresh login key, PIN and public key. func (r *Runner) GenerateE2EESecret() (out0 *SecretResult, err error) { r.mu.Lock() defer r.mu.Unlock()