From 3896ee60733ea767687669ddcb2a0a380351aac0 Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Mon, 5 Oct 2026 14:57:42 +0000 Subject: [PATCH 1/7] Own LINE crypto by session and login attempt --- pkg/connector/auth_recovery.go | 35 +++ pkg/connector/client.go | 163 +++++++++-- pkg/connector/connector.go | 276 +++++++++++++++--- pkg/connector/creategroup.go | 57 +++- pkg/connector/send_message.go | 16 +- pkg/e2ee/manager.go | 179 ++++++++++-- pkg/line/client.go | 93 +++++- pkg/line/structs.go | 1 + pkg/ltsm/runtime.go | 4 + pkg/runner.go | 517 +++++++++++++++++++++++++++------ 10 files changed, 1122 insertions(+), 219 deletions(-) diff --git a/pkg/connector/auth_recovery.go b/pkg/connector/auth_recovery.go index e024790..5c2bc0b 100644 --- a/pkg/connector/auth_recovery.go +++ b/pkg/connector/auth_recovery.go @@ -11,6 +11,7 @@ type lineCallDeps[T any] struct { newClient func() *line.Client recover func(context.Context, *line.Client, error) (*line.Client, error) isAuthError func(error) bool + beforeCall func() error call func(*line.Client) (T, error) } @@ -19,6 +20,19 @@ var recoverLineToken = func(lc *LineClient, ctx context.Context) error { } func callLineWithRecovery[T any](ctx context.Context, client *line.Client, deps lineCallDeps[T]) (*line.Client, T, error) { + check := func() error { + if err := ctx.Err(); err != nil { + return err + } + if deps.beforeCall != nil { + return deps.beforeCall() + } + return nil + } + if err := check(); err != nil { + var zero T + return client, zero, err + } if client == nil { client = deps.newClient() } @@ -37,6 +51,10 @@ func callLineWithRecovery[T any](ctx context.Context, client *line.Client, deps } client = recoveredClient + if err := check(); err != nil { + var zero T + return client, zero, err + } res, err = deps.call(client) if line.IsLoggedOut(err) { // The retry is the final attempt, but a current-token logout still needs @@ -139,11 +157,27 @@ func (lc *LineClient) callLine(ctx context.Context, call func(*line.Client) erro return lc.callLineUsing(ctx, nil, call) } +func (lc *LineClient) checkLineCall(ctx context.Context) error { + lc.runMu.Lock() + defer lc.runMu.Unlock() + if err := ctx.Err(); err != nil { + return err + } + if lc.stopped || lc.superseded.Load() { + return errLineClientSuperseded + } + if lc.isSessionInvalidated() { + return errLineSessionInvalidated + } + return nil +} + func (lc *LineClient) callLineUsing(ctx context.Context, client *line.Client, call func(*line.Client) error) (*line.Client, error) { client, _, err := callLineWithRecovery(ctx, client, lineCallDeps[struct{}]{ newClient: func() *line.Client { return lc.newClient() }, recover: lc.recoverClientAfterAuthError, isAuthError: lc.isTokenError, + beforeCall: func() error { return lc.checkLineCall(ctx) }, call: func(client *line.Client) (struct{}, error) { return struct{}{}, call(client) }, @@ -160,6 +194,7 @@ func callLineResultUsing[T any](lc *LineClient, ctx context.Context, client *lin newClient: func() *line.Client { return lc.newClient() }, recover: lc.recoverClientAfterAuthError, isAuthError: lc.isTokenError, + beforeCall: func() error { return lc.checkLineCall(ctx) }, call: call, }) return client, res, err diff --git a/pkg/connector/client.go b/pkg/connector/client.go index f3d5b14..dea2548 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "reflect" "strconv" "strings" "sync" @@ -29,10 +30,23 @@ const lineMissingE2EEKeyMessage = "LINE encryption keys are unavailable. Reconne var newLineAPIClient = line.NewClient var newE2EEManager = e2ee.NewManager -var loginWithCredentials = func(email, password, certificate string) (*line.LoginResult, error) { +func defaultLoginWithCredentials(email, password, certificate string) (*line.LoginResult, error) { return newLineAPIClient("").Login(email, password, certificate) } +var loginWithCredentials = defaultLoginWithCredentials + +func validLoginProducer(res *line.LoginResult) bool { + return res != nil && (res.Attempt != nil || reflect.ValueOf(loginWithCredentials).Pointer() != reflect.ValueOf(defaultLoginWithCredentials).Pointer()) +} + +func loginWithCredentialsContext(ctx context.Context, email, password, certificate string) (*line.LoginResult, error) { + if reflect.ValueOf(loginWithCredentials).Pointer() != reflect.ValueOf(defaultLoginWithCredentials).Pointer() { + return loginWithCredentials(email, password, certificate) + } + return newLineAPIClient("").LoginContext(ctx, email, password, certificate) +} + var getProfileWithToken = func(ctx context.Context, token string) (*line.Profile, error) { return newLineAPIClient(token).GetProfileContext(ctx) } @@ -163,7 +177,7 @@ func (lc *LineClient) beginRun(parent context.Context) (context.Context, *lineCl ctx, cancel := context.WithCancel(parent) run := &lineClientRun{cancel: cancel} lc.runMu.Lock() - if lc.stopped { + if lc.stopped || lc.activeRun != nil { lc.runMu.Unlock() cancel() return ctx, run, false @@ -471,7 +485,7 @@ func (lc *LineClient) recoverTokenWith( return nil } else if ctx.Err() != nil { return ctx.Err() - } else if lc.isLoggedOut(err) || errors.Is(err, errLineSessionInvalidated) { + } else if lc.isLoggedOut(err) || errors.Is(err, errLineSessionInvalidated) || errors.Is(err, errLineClientSuperseded) || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return err } lc.UserLogin.Bridge.Log.Info().Msg("Refresh failed, attempting re-login with stored credentials...") @@ -526,6 +540,9 @@ func (lc *LineClient) Connect(ctx context.Context) { workersStarted := false defer func() { if !workersStarted { + if lc.E2EE != nil { + lc.E2EE.Close() + } run.cancel() } }() @@ -689,10 +706,16 @@ func (lc *LineClient) tryLogin(ctx context.Context) error { Str("email", email). Bool("has_certificate", certificate != ""). Msg("Attempting to login with email/password...") - res, err := loginWithCredentials(email, password, certificate) + res, err := loginWithCredentialsContext(ctx, email, password, certificate) + if res != nil { + defer res.Attempt.Close() + } if err != nil { return fmt.Errorf("login failed: %w", err) } + if !validLoginProducer(res) { + return errors.New("missing LINE login producer") + } if res.AuthToken == "" { // No usable verifier means LINE didn't engage the PIN-based flow. // Bail out before emitting a misleading "PIN required" bridge state — @@ -718,6 +741,7 @@ func (lc *LineClient) tryLogin(ctx context.Context) error { lc.UserLogin.Bridge.Log.Info().Msg("Waiting for PIN verification on mobile device...") waitClient := newLineAPIClient("") + waitClient.LoginAttempt = res.Attempt waitRes, err := waitClient.WaitForLogin(res.Verifier, res.NoE2EE) if err != nil { return fmt.Errorf("PIN verification failed: %w", err) @@ -738,32 +762,81 @@ func (lc *LineClient) tryLogin(ctx context.Context) error { refreshToken = res.TokenV3IssueResult.RefreshToken } } - accessToken, refreshToken = lc.setTokens(accessToken, refreshToken) - - // Re-login replaces the main access token, which invalidates any cached - // OBS token derived from the previous one. - line.InvalidateOBSTokenCache() - + if ctx.Err() != nil { + return ctx.Err() + } + if lc.superseded.Load() { + return errLineClientSuperseded + } + if res.Attempt != nil { + profile, err := newLineAPIClient(accessToken).GetProfileContext(ctx) + if err != nil { + return fmt.Errorf("verify re-login account: %w", err) + } + if profile.Mid == "" || profile.Mid != string(lc.UserLogin.ID) || + (lc.Mid != "" && profile.Mid != lc.Mid) || (res.Mid != "" && profile.Mid != res.Mid) { + return errors.New("re-login account does not match active LINE account") + } + res.Mid = profile.Mid + } + meta, ok := lc.UserLogin.Metadata.(*UserLoginMetadata) + if !ok { + return errors.New("missing LINE login metadata") + } + if res.Attempt != nil && meta.Mid != "" && res.Mid != meta.Mid { + return errors.New("re-login account does not match stored LINE account") + } + staged := *meta + staged.AccessToken = accessToken + if refreshToken != "" { + staged.RefreshToken = refreshToken + } + staged.SessionInvalidated = false if res.Mid != "" { - lc.Mid = res.Mid - if meta, ok := lc.UserLogin.Metadata.(*UserLoginMetadata); ok { - meta.Mid = res.Mid + staged.Mid = res.Mid + } + if res.Certificate != "" { + staged.Certificate = res.Certificate + } + mgr, exported, err := exportLoginE2EEKeys(res, newLineAPIClient(accessToken)) + if err != nil { + return fmt.Errorf("refresh re-login E2EE keys: %w", err) + } + if mgr != nil { + defer mgr.Close() + if res.Mid != "" && res.Mid == string(lc.UserLogin.ID) { + exported = mergeLoginKeyMaps(meta.ExportedKeyMap, exported) } + if err := mgr.LoadMyKeyFromExportedMap(exported); err != nil { + return fmt.Errorf("validate recovered E2EE keys: %w", err) + } + applyExportedLoginE2EEKeys(&staged, res, exported) } - - // Save the new tokens and updated certificate to metadata - if meta, ok := lc.UserLogin.Metadata.(*UserLoginMetadata); ok { - meta.AccessToken = accessToken - meta.RefreshToken = refreshToken - meta.SessionInvalidated = false - if res.Certificate != "" { - meta.Certificate = res.Certificate + lc.runMu.Lock() + defer lc.runMu.Unlock() + if err := lc.admitRecoveredLogin(ctx, res); err != nil { + return err + } + if mgr != nil && lc.E2EE != nil { + if err := lc.E2EE.LoadMyKeyFromExportedMap(exported); err != nil { + return fmt.Errorf("load recovered E2EE keys: %w", err) } - if err := lc.refreshLoginE2EEKeys(res, meta, newLineAPIClient(accessToken)); err != nil { - lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Failed to refresh E2EE keys after re-login") + if err := lc.admitRecoveredLogin(ctx, res); err != nil { + return err } - if err := lc.UserLogin.Save(ctx); err != nil { - lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Failed to save new tokens to DB") + } + previous := *meta + *meta = staged + if err := lc.UserLogin.Save(ctx); err != nil { + *meta = previous + return fmt.Errorf("save recovered LINE login: %w", err) + } + lc.setTokens(accessToken, refreshToken) + lc.Mid = staged.Mid + line.InvalidateOBSTokenCache() + if mgr != nil { + if err := mgr.SaveSecureDataToFile(loginSecureDataID(&staged, string(lc.UserLogin.ID)), map[string]any{"exportedKeyMap": exported}); err != nil { + return fmt.Errorf("save recovered E2EE secure data: %w", err) } } @@ -771,6 +844,24 @@ func (lc *LineClient) tryLogin(ctx context.Context) error { return nil } +func (lc *LineClient) admitRecoveredLogin(ctx context.Context, res *line.LoginResult) error { + if err := ctx.Err(); err != nil { + return err + } + if res.Attempt != nil { + if err := res.Attempt.Context.Err(); err != nil { + return err + } + } + if lc.superseded.Load() || lc.stopped || lc.recoveryStopped { + return errLineClientSuperseded + } + if lc.isSessionInvalidated() { + return errLineSessionInvalidated + } + return nil +} + func (lc *LineClient) refreshLoginE2EEKeys(res *line.LoginResult, meta *UserLoginMetadata, client *line.Client) error { if res.EncryptedKeyChain == "" || res.E2EEPublicKey == "" { return nil @@ -779,14 +870,31 @@ func (lc *LineClient) refreshLoginE2EEKeys(res *line.LoginResult, meta *UserLogi if err != nil { return err } + defer mgr.Close() + if res.Attempt != nil && res.Attempt.Context.Err() != nil { + return res.Attempt.Context.Err() + } + if lc.superseded.Load() { + return errLineClientSuperseded + } + if res.Mid != "" && res.Mid == meta.Mid && lc.UserLogin != nil && res.Mid == string(lc.UserLogin.ID) { + exported = mergeLoginKeyMaps(meta.ExportedKeyMap, exported) + } + if lc.E2EE != nil { if err := lc.E2EE.LoadMyKeyFromExportedMap(exported); err != nil { return fmt.Errorf("load exported keys into active E2EE manager: %w", err) } } + if res.Attempt != nil && res.Attempt.Context.Err() != nil { + return res.Attempt.Context.Err() + } + if lc.superseded.Load() { + return errLineClientSuperseded + } lc.applyRefreshedLoginE2EEKeys(meta, res, exported) if err := mgr.SaveSecureDataToFile(loginSecureDataID(meta, string(lc.UserLogin.ID)), map[string]any{"exportedKeyMap": exported}); err != nil { - lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Failed to save E2EE secure data after re-login") + return fmt.Errorf("save E2EE secure data after re-login: %w", err) } lc.UserLogin.Bridge.Log.Info().Int("keys", len(exported)).Msg("Refreshed E2EE keys after re-login") return nil @@ -827,6 +935,9 @@ func (lc *LineClient) Disconnect() { // superseded flag was visible before a replacement installs new metadata. lc.recoverMu.Lock() lc.recoveryStopped = true + if lc.E2EE != nil { + lc.E2EE.Close() + } lc.recoverMu.Unlock() } diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index af9b340..35502e3 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -178,11 +178,15 @@ type LineEmailLogin struct { ExistingMetadata *UserLoginMetadata ExistingLogin *bridgev2.UserLogin - pollResult chan *line.LoginResult - pollErr chan error - polling bool - mu sync.Mutex - finalizeMu *sync.Mutex + pollResult chan *line.LoginResult + pollErr chan error + polling bool + attempt *line.LoginAttempt + attemptCtx context.Context + attemptCancel context.CancelFunc + canceled bool + mu sync.Mutex + finalizeMu *sync.Mutex } var _ bridgev2.LoginProcessUserInput = (*LineEmailLogin)(nil) @@ -237,7 +241,7 @@ func (ll *LineEmailLogin) StartWithOverride(ctx context.Context, override *bridg override.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnecting}) - res, err := loginWithCredentials(ll.Email, ll.Password, ll.Certificate) + res, err := ll.loginCredentials(ctx, ll.Certificate) if err != nil { ll.logLoginFailure(err, "reconnect") reason := loginErrorReason(err) @@ -264,7 +268,7 @@ func (ll *LineEmailLogin) SubmitUserInput(ctx context.Context, input map[string] return ll.loginErrorStep("Email and password are required"), nil } - res, err := loginWithCredentials(ll.Email, ll.Password, "") + res, err := ll.loginCredentials(ctx, "") if err != nil { ll.logLoginFailure(err, "credentials") reason := loginErrorReason(err) @@ -458,24 +462,76 @@ func isBlockedUserLoginError(message string) bool { return strings.EqualFold(strings.TrimSpace(message), "blocked user") } +func (ll *LineEmailLogin) loginCredentials(ctx context.Context, certificate string) (*line.LoginResult, error) { + ll.mu.Lock() + if ll.canceled { + ll.mu.Unlock() + return nil, context.Canceled + } + if ll.attemptCancel != nil { + ll.attemptCancel() + } + if ll.attempt != nil { + ll.attempt.Close() + } + ll.attemptCtx, ll.attemptCancel = context.WithCancel(context.WithoutCancel(ctx)) + attemptCtx := ll.attemptCtx + attemptCancel := ll.attemptCancel + ll.mu.Unlock() + stop := context.AfterFunc(ctx, attemptCancel) + defer stop() + res, err := loginWithCredentialsContext(attemptCtx, ll.Email, ll.Password, certificate) + ll.mu.Lock() + defer ll.mu.Unlock() + if ll.canceled || ctx.Err() != nil || attemptCtx.Err() != nil || ll.attemptCtx != attemptCtx { + if res != nil { + res.Attempt.Close() + } + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, context.Canceled + } + if res != nil { + ll.attempt = res.Attempt + } + return res, err +} + func (ll *LineEmailLogin) Wait(ctx context.Context) (*bridgev2.LoginStep, error) { - if ll.Verifier != "" { + ll.mu.Lock() + verifier, awaitingPIN := ll.Verifier, ll.AwaitingPIN + resultCh, errCh := ll.pollResult, ll.pollErr + var done <-chan struct{} + if ll.attemptCtx != nil { + done = ll.attemptCtx.Done() + } + canceled := ll.canceled + ll.mu.Unlock() + if canceled { + return nil, context.Canceled + } + if verifier != "" { select { - case res := <-ll.pollResult: + case res := <-resultCh: if res.AuthToken != "" { return ll.finishLogin(ctx, res) } + res.Attempt.Close() return nil, ErrLoginVerificationFailed - case err := <-ll.pollErr: + case err := <-errCh: ll.logLoginFailure(err, "verification_poll") return nil, wrapLineLoginError(err) + case <-done: + return nil, context.Canceled case <-ctx.Done(): + ll.Cancel() return nil, ctx.Err() } } - if ll.AwaitingPIN { - res, err := loginWithCredentials(ll.Email, ll.Password, ll.Certificate) + if awaitingPIN { + res, err := ll.loginCredentials(ctx, ll.Certificate) if err != nil { ll.logLoginFailure(err, "pin_continuation") return nil, wrapLineLoginError(err) @@ -487,14 +543,19 @@ func (ll *LineEmailLogin) Wait(ctx context.Context) (*bridgev2.LoginStep, error) } func (ll *LineEmailLogin) handleLoginResponse(ctx context.Context, res *line.LoginResult) (*bridgev2.LoginStep, error) { + ll.mu.Lock() + canceled := ll.canceled || res.Attempt != ll.attempt || !validLoginProducer(res) + ll.mu.Unlock() + if canceled || ctx.Err() != nil || (res.Attempt != nil && res.Attempt.Context.Err() != nil) { + res.Attempt.Close() + return nil, context.Canceled + } + if res.AuthToken != "" { return ll.finishLogin(ctx, res) } if (res.Type == 3 || res.Type == 0) && res.Verifier != "" { - ll.Verifier = res.Verifier - ll.NoE2EE = res.NoE2EE - ll.AwaitingPIN = false instructions := "Please open the LINE app on your mobile device to complete the login." pin := res.Pin if res.PinCode != "" { @@ -506,16 +567,48 @@ func (ll *LineEmailLogin) handleLoginResponse(ctx context.Context, res *line.Log // Start polling in background immediately so it's running while the user enters the PIN ll.mu.Lock() + if ll.canceled { + ll.mu.Unlock() + res.Attempt.Close() + return nil, context.Canceled + } + ll.Verifier, ll.NoE2EE, ll.AwaitingPIN = res.Verifier, res.NoE2EE, false ll.polling = true ll.pollResult = make(chan *line.LoginResult, 1) ll.pollErr = make(chan error, 1) + attempt := res.Attempt + verifier, noE2EE := ll.Verifier, ll.NoE2EE + resultCh, errCh := ll.pollResult, ll.pollErr + pollCtx := ll.attemptCtx + if pollCtx == nil { + pollCtx = ctx + } go func() { client := newLineAPIClient("") - res, err := client.WaitForLogin(ll.Verifier, ll.NoE2EE) + client.LoginAttempt = attempt + result, err := client.WaitForLogin(verifier, noE2EE) + if pollCtx.Err() != nil { + if attempt != nil { + attempt.Close() + } + return + } if err != nil { - ll.pollErr <- err + if attempt != nil { + attempt.Close() + } + select { + case errCh <- err: + case <-pollCtx.Done(): + } } else { - ll.pollResult <- res + select { + case resultCh <- result: + case <-pollCtx.Done(): + if attempt != nil { + attempt.Close() + } + } } }() ll.mu.Unlock() @@ -531,7 +624,10 @@ func (ll *LineEmailLogin) handleLoginResponse(ctx context.Context, res *line.Log } if res.Certificate != "" { + ll.mu.Lock() ll.AwaitingPIN = true + ll.mu.Unlock() + res.Attempt.Close() return &bridgev2.LoginStep{ Type: bridgev2.LoginStepTypeDisplayAndWait, StepID: "dev.highest.matrix.line.enter_pin", @@ -542,14 +638,37 @@ func (ll *LineEmailLogin) handleLoginResponse(ctx context.Context, res *line.Log }, nil } + res.Attempt.Close() return nil, fmt.Errorf("login incomplete but no PIN found in response (Type: %d, Msg: %s)", res.Type, res.Message) } func (ll *LineEmailLogin) finishLogin(ctx context.Context, res *line.LoginResult) (*bridgev2.LoginStep, error) { + if res != nil { + defer res.Attempt.Close() + } + if res == nil { return nil, fmt.Errorf("login result missing") } + ll.mu.Lock() + processCtx := ll.attemptCtx + canceled := ll.canceled || res.Attempt != ll.attempt || !validLoginProducer(res) + if res.Attempt != nil { + processCtx = res.Attempt.Context + } + ll.mu.Unlock() + if canceled || ctx.Err() != nil || (processCtx != nil && processCtx.Err() != nil) { + return nil, context.Canceled + } + finishCtx, cancel := context.WithCancel(ctx) + defer cancel() + if processCtx != nil { + stop := context.AfterFunc(processCtx, cancel) + defer stop() + } + ctx = finishCtx + token := res.AuthToken refreshToken := "" if res.TokenV3IssueResult != nil { @@ -563,7 +682,8 @@ func (ll *LineEmailLogin) finishLogin(ctx context.Context, res *line.LoginResult } client := newLineAPIClient(token) - profile, err := client.GetProfile() + client.LoginAttempt = res.Attempt + profile, err := client.GetProfileContext(ctx) if err != nil { return nil, fmt.Errorf("failed to verify token: %w", err) } @@ -577,18 +697,28 @@ func (ll *LineEmailLogin) finishLogin(ctx context.Context, res *line.LoginResult if certificate == "" { certificate = ll.Certificate } - mid := res.Mid - if mid == "" { - mid = profile.Mid + 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} - exportedKeys := ll.fetchLoginKeys(res, meta, client) - if shouldPreserveExistingE2EEKeys(exportedKeys, ll.ExistingMetadata) { + loginManager, err := ll.fetchLoginKeys(res, meta, client) + if err != nil { + return nil, err + } + exportedKeys := loginManager != nil + if loginManager != nil { + defer loginManager.Close() + } + if ll.ExistingMetadata != nil && ll.ExistingLogin != nil && mid == string(ll.ExistingLogin.ID) && mid == ll.ExistingMetadata.Mid && shouldPreserveExistingE2EEKeys(exportedKeys, ll.ExistingMetadata) { copyLoginE2EEKeyMetadata(meta, ll.ExistingMetadata) ll.User.Bridge.Log.Info().Int("keys", len(meta.ExportedKeyMap)).Msg("Preserved existing E2EE keys after re-login") } + if exportedKeys && ll.ExistingMetadata != nil && ll.ExistingLogin != nil && mid == string(ll.ExistingLogin.ID) && mid == ll.ExistingMetadata.Mid { + meta.ExportedKeyMap = mergeLoginKeyMaps(ll.ExistingMetadata.ExportedKeyMap, meta.ExportedKeyMap) + } if !res.NoE2EE && len(meta.ExportedKeyMap) == 0 { return nil, ErrLoginNoKeychain } @@ -601,7 +731,13 @@ func (ll *LineEmailLogin) finishLogin(ctx context.Context, res *line.LoginResult ll.finalizeMu.Lock() defer ll.finalizeMu.Unlock() } + if err := ll.admitLogin(ctx, res); err != nil { + return nil, err + } targetLogin := findReusedLineLogin(ll.ExistingLogin, ll.User.GetUserLogins(), detectedLineID) + if err := ll.admitLogin(ctx, res); err != nil { + return nil, err + } retireLineLogin(targetLogin) var newClient *LineClient @@ -610,7 +746,10 @@ func (ll *LineEmailLogin) finishLogin(ctx context.Context, res *line.LoginResult RemoteName: displayName, Metadata: meta, }, &bridgev2.NewLoginParams{ - LoadUserLogin: func(ctx context.Context, login *bridgev2.UserLogin) error { + LoadUserLogin: func(_ context.Context, login *bridgev2.UserLogin) error { + if err := ll.admitLogin(ctx, res); err != nil { + return err + } newClient = &LineClient{ UserLogin: login, AccessToken: token, @@ -631,12 +770,22 @@ func (ll *LineEmailLogin) finishLogin(ctx context.Context, res *line.LoginResult if newClient == nil { return nil, fmt.Errorf("failed to create LINE client") } + if err := ll.admitLogin(ctx, res); err != nil { + newClient.Disconnect() + return nil, err + } if ll.ExistingLogin != nil && ll.ExistingLogin.UserLogin != nil && ll.ExistingLogin.ID != ul.ID { // A different-ID override remains valid until the new login is safely // installed. Retire it only after NewLogin succeeds. retireLineLogin(ll.ExistingLogin) } + if loginManager != nil { + if err := loginManager.SaveSecureDataToFile(loginSecureDataID(meta, string(ll.User.MXID)), map[string]any{"exportedKeyMap": meta.ExportedKeyMap}); err != nil { + ll.User.Bridge.Log.Warn().Err(err).Msg("Login: failed to save E2EE secure data") + } + } + go newClient.Connect(context.Background()) return &bridgev2.LoginStep{ @@ -676,10 +825,23 @@ func exportLoginE2EEKeys(res *line.LoginResult, client *line.Client) (*e2ee.Mana if res.EncryptedKeyChain == "" || res.E2EEPublicKey == "" { return nil, nil, nil } - mgr, err := newE2EEManager() - if err != nil { - return nil, nil, fmt.Errorf("create E2EE manager: %w", err) - } + var mgr *e2ee.Manager + if res.Attempt == nil { + unused, err := newE2EEManager() + if err != nil { + return nil, nil, fmt.Errorf("create E2EE manager: %w", err) + } + unused.Close() + return nil, nil, errors.New("missing E2EE login attempt") + } + mgr = e2ee.NewManagerWithRunner(res.Attempt.Runner) + success := false + defer func() { + if !success { + mgr.Close() + } + }() + client.LoginAttempt = res.Attempt ei3, err := client.GetEncryptedIdentityV3() if err != nil { return nil, nil, fmt.Errorf("get EncryptedIdentityV3: %w", err) @@ -691,6 +853,7 @@ func exportLoginE2EEKeys(res *line.LoginResult, client *line.Client) (*e2ee.Mana if err != nil { return nil, nil, fmt.Errorf("init from login keychain: %w", err) } + success = true return mgr, exported, nil } @@ -707,6 +870,17 @@ func applyExportedLoginE2EEKeys(meta *UserLoginMetadata, res *line.LoginResult, meta.ForceFullE2EELogin = false } +func mergeLoginKeyMaps(previous, current map[string]string) map[string]string { + merged := make(map[string]string, len(previous)+len(current)) + for id, key := range previous { + merged[id] = key + } + for id, key := range current { + merged[id] = key + } + return merged +} + func copyLoginE2EEKeyMetadata(dst, src *UserLoginMetadata) { dst.EncryptedKeyChain = src.EncryptedKeyChain dst.E2EEPublicKey = src.E2EEPublicKey @@ -731,21 +905,47 @@ func loginSecureDataID(meta *UserLoginMetadata, fallback string) string { return fallback } -func (ll *LineEmailLogin) fetchLoginKeys(res *line.LoginResult, meta *UserLoginMetadata, client *line.Client) bool { +func (ll *LineEmailLogin) fetchLoginKeys(res *line.LoginResult, meta *UserLoginMetadata, client *line.Client) (*e2ee.Manager, error) { if res.EncryptedKeyChain == "" || res.E2EEPublicKey == "" { - return false + return nil, nil } mgr, exported, err := exportLoginE2EEKeys(res, client) if err != nil { - ll.User.Bridge.Log.Warn().Err(err).Msg("Login: failed to export E2EE keys") - return false + return nil, fmt.Errorf("export login E2EE keys: %w", err) } applyExportedLoginE2EEKeys(meta, res, exported) - if err := mgr.SaveSecureDataToFile(loginSecureDataID(meta, string(ll.User.MXID)), map[string]any{"exportedKeyMap": exported}); err != nil { - ll.User.Bridge.Log.Warn().Err(err).Msg("Login: failed to save E2EE secure data") - } + ll.User.Bridge.Log.Info().Int("keys", len(exported)).Msg("Login: E2EE keys exported successfully") - return true + return mgr, nil } -func (ll *LineEmailLogin) Cancel() {} +func (ll *LineEmailLogin) admitLogin(ctx context.Context, res *line.LoginResult) error { + ll.mu.Lock() + defer ll.mu.Unlock() + if err := ctx.Err(); err != nil { + return err + } + if ll.canceled || res.Attempt != ll.attempt || !validLoginProducer(res) { + return context.Canceled + } + if res.Attempt != nil { + return res.Attempt.Context.Err() + } + if ll.attemptCtx != nil { + return ll.attemptCtx.Err() + } + return nil +} + +func (ll *LineEmailLogin) Cancel() { + ll.mu.Lock() + ll.canceled = true + if ll.attemptCancel != nil { + ll.attemptCancel() + } + attempt := ll.attempt + ll.mu.Unlock() + if attempt != nil { + attempt.Close() + } +} diff --git a/pkg/connector/creategroup.go b/pkg/connector/creategroup.go index 9119908..01c544c 100644 --- a/pkg/connector/creategroup.go +++ b/pkg/connector/creategroup.go @@ -2,6 +2,7 @@ package connector import ( "context" + "errors" "fmt" "strings" @@ -135,6 +136,46 @@ type groupKeyCrypto interface { WrapGroupKeyForMember(string, int) (string, error) } +type groupKeyReleaser interface { + ReleaseGroupKey(int) error +} + +func wrapRegistrationGroupKey(ctx context.Context, crypto groupKeyCrypto, pubKeys map[string]line.E2EEPeerPublicKey) (apiMembers []string, keyIDs []int, encryptedKeys []string, err error) { + groupKeyID, err := crypto.GenerateGroupKey() + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to generate group key: %w", err) + } + if releaser, ok := crypto.(groupKeyReleaser); ok { + defer func() { + if recovered := recover(); recovered != nil { + panic(recovered) + } + if releaseErr := releaser.ReleaseGroupKey(groupKeyID); releaseErr != nil { + err = errors.Join(err, fmt.Errorf("release generated group key: %w", releaseErr)) + } + }() + } + apiMembers = make([]string, 0, len(pubKeys)) + keyIDs = make([]int, 0, len(pubKeys)) + encryptedKeys = make([]string, 0, len(pubKeys)) + for mid, pk := range pubKeys { + if err := ctx.Err(); err != nil { + return nil, nil, nil, err + } + encryptedKey, err := crypto.WrapGroupKeyForMember(pk.KeyData, groupKeyID) + if err != nil { + return nil, nil, nil, 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 nil, nil, nil, err + } + return apiMembers, keyIDs, encryptedKeys, nil +} + func (lc *LineClient) registerGroupKey(ctx context.Context, chatMid string) error { if lc.E2EE == nil { return fmt.Errorf("%w: E2EE manager not initialized", line.ErrNoUsableE2EEGroupKey) @@ -173,21 +214,9 @@ func (lc *LineClient) registerGroupKeyWithCrypto(ctx context.Context, chatMid st } } - groupKeyID, err := crypto.GenerateGroupKey() + apiMembers, keyIDs, encryptedKeys, err := wrapRegistrationGroupKey(ctx, crypto, pubKeys) 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) + return err } if err := ctx.Err(); err != nil { return err diff --git a/pkg/connector/send_message.go b/pkg/connector/send_message.go index ef2dc55..8611d4f 100644 --- a/pkg/connector/send_message.go +++ b/pkg/connector/send_message.go @@ -19,6 +19,7 @@ import ( "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/event" + gen "github.com/highesttt/matrix-line-messenger/pkg" "github.com/highesttt/matrix-line-messenger/pkg/e2ee" "github.com/highesttt/matrix-line-messenger/pkg/line" "github.com/highesttt/matrix-line-messenger/pkg/ltsm" @@ -35,6 +36,10 @@ var mentionLinkRegex = regexp.MustCompile(`]*href="https://matrix\.to/#/ const lineGroupE2EEReconnectNotice = "LINE encryption keys for this group are unavailable. Reconnect LINE in Beeper, then try sending again." +func isTerminalCryptoError(err error) bool { + return errors.Is(err, ltsm.ErrAbort) || errors.Is(err, gen.ErrRunnerClosed) +} + func lineGroupE2EEReconnectRequiredError(err error) error { if !errors.Is(err, e2ee.ErrMissingOwnPrivateKey) { return nil @@ -58,6 +63,9 @@ func lineGroupE2EEFetchFailureError(err error) error { } func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.MatrixMessage) (*bridgev2.MatrixMessageResponse, error) { + if err := lc.checkLineCall(ctx); err != nil { + return nil, err + } client := lc.newClient() callLineErr := func(call func(*line.Client) error) error { var err error @@ -590,7 +598,7 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat if isGroup { 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") - if errors.Is(errFetch, ltsm.ErrAbort) { + if isTerminalCryptoError(errFetch) { return nil, errFetch } if errFetch = lineGroupE2EEFetchFailureError(errFetch); errFetch != nil { @@ -603,7 +611,7 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat chunks, err = lc.E2EE.EncryptGroupMessage(portalMid, fromMid, msg.Content.Body) } if err != nil { - if errors.Is(err, ltsm.ErrAbort) { + if isTerminalCryptoError(err) { return nil, err } if errFetch := lc.fetchAndUnwrapGroupKey(ctx, portalMid, 0); errFetch == nil { @@ -612,12 +620,12 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat } else { chunks, err = lc.E2EE.EncryptGroupMessage(portalMid, fromMid, msg.Content.Body) } - } else if errors.Is(errFetch, ltsm.ErrAbort) { + } else if isTerminalCryptoError(errFetch) { return nil, errFetch } else if errFetch = lineGroupE2EEFetchFailureError(errFetch); errFetch != nil { return nil, errFetch } - if errors.Is(err, ltsm.ErrAbort) { + if isTerminalCryptoError(err) { return nil, err } if err != nil { diff --git a/pkg/e2ee/manager.go b/pkg/e2ee/manager.go index b852743..0d1c251 100644 --- a/pkg/e2ee/manager.go +++ b/pkg/e2ee/manager.go @@ -24,7 +24,7 @@ var ( ) func isFatalLTSMError(err error) bool { - return errors.Is(err, ltsm.ErrAbort) + return errors.Is(err, ltsm.ErrAbort) || errors.Is(err, gen.ErrRunnerClosed) } /* @@ -33,15 +33,17 @@ This wraps the JS runner to perform storage init, key/channel handling, and e2ee type Manager struct { runner *gen.Runner - mu sync.Mutex - myKeyID int - myRawKeyID int - myKeyB64 string - myPublicB64 string - peerPublic map[int]string // raw key id -> public key b64 - 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 + mu sync.Mutex + closed bool + myKeyID int + myRawKeyID int + myKeyB64 string + myPublicB64 string + ownKeyByExport map[string]int + peerPublic map[int]string // raw key id -> public key b64 + 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 @@ -63,12 +65,17 @@ type groupKeyRef struct { const maxGroupNativeObjects = 4096 func NewManager() (*Manager, error) { - r, err := gen.GetRunner() + r, err := gen.NewRunner() if err != nil { return nil, err } + return NewManagerWithRunner(r), nil +} + +func NewManagerWithRunner(r *gen.Runner) *Manager { return &Manager{ runner: r, + ownKeyByExport: make(map[string]int), peerPublic: make(map[int]string), keyByRawID: make(map[int]int), channelByPair: make(map[string]int), @@ -76,12 +83,50 @@ func NewManager() (*Manager, error) { groupKeys: make(map[string]map[int]int), groupSessionChannels: make(map[string]map[int]map[int]int), latestGroupKey: make(map[string]int), - }, nil + } +} + +func (m *Manager) Close() { + m.mu.Lock() + defer m.mu.Unlock() + if m.closed { + return + } + m.closed = true + m.runner.Close() + m.myKeyID, m.myRawKeyID = 0, 0 + m.myKeyB64, m.myPublicB64 = "", "" + m.ownKeyByExport = nil + m.peerPublic = nil + m.keyByRawID = nil + m.channelByPair = nil + m.sequence = nil + m.groupKeys = nil + m.groupSessionChannels = nil + m.latestGroupKey = nil + m.groupKeyLRU = nil + m.groupNativeObjects = 0 +} + +func (m *Manager) errLocked() error { + if m.runner != nil { + if err := m.runner.Err(); err != nil { + return err + } + } + if m.closed { + return gen.ErrRunnerClosed + } + return nil } // returns both the raw key id and the runner key ids func (m *Manager) MyKeyIDs() (rawID int, keyID int, err error) { m.mu.Lock() + if err := m.errLocked(); err != nil { + m.mu.Unlock() + return 0, 0, err + } defer m.mu.Unlock() if m.myKeyID == 0 || m.myRawKeyID == 0 { return 0, 0, fmt.Errorf("my key not loaded") @@ -93,6 +138,10 @@ func (m *Manager) MyKeyIDs() (rawID int, keyID int, err error) { // Used to include the caller's own entry when registering a group key. func (m *Manager) MyPublicKey() (rawID int, pubB64 string, err error) { m.mu.Lock() + if err := m.errLocked(); err != nil { + m.mu.Unlock() + return 0, "", err + } defer m.mu.Unlock() if m.myRawKeyID == 0 || m.myPublicB64 == "" { return 0, "", fmt.Errorf("my key not loaded") @@ -106,10 +155,23 @@ func (m *Manager) InitStorage(wrappedNonce, kdf1, kdf2 string) error { func (m *Manager) LoadMyKey(b64Key string) error { m.mu.Lock() + if err := m.errLocked(); err != nil { + m.mu.Unlock() + return err + } defer m.mu.Unlock() - keyID, err := m.runner.KeyLoad(b64Key) + keyBytes, err := base64.StdEncoding.DecodeString(b64Key) if err != nil { - return err + return fmt.Errorf("invalid key: %w", err) + } + b64Key = base64.StdEncoding.EncodeToString(keyBytes) + keyID := m.ownKeyByExport[b64Key] + if keyID == 0 { + keyID, err = m.runner.KeyLoad(b64Key) + if err != nil { + return err + } + m.ownKeyByExport[b64Key] = keyID } rawID, err := m.runner.KeyGetID(keyID) if err != nil { @@ -156,6 +218,9 @@ func (m *Manager) LoadMyKeyFromSecureData(data map[string]any) error { if err := m.LoadMyKey(b64); err == nil { loadedAny = true } else { + if isFatalLTSMError(err) { + return err + } lastErr = err } } @@ -165,7 +230,9 @@ func (m *Manager) LoadMyKeyFromSecureData(data map[string]any) error { } return fmt.Errorf("failed to load any exported key") } - return nil + m.mu.Lock() + defer m.mu.Unlock() + return m.errLocked() } func (m *Manager) LoadMyKeyFromExportedMap(exported map[string]string) error { @@ -324,6 +391,10 @@ func (m *Manager) encryptV1Chunks(chanID, senderKeyID, receiverKeyID int, payloa func (m *Manager) ensureChannelForEncrypt(chatID string, myKeyID, senderKeyID, receiverKeyID int, peerPubKeyB64 string) (int, int, error) { m.mu.Lock() + defer m.mu.Unlock() + if err := m.errLocked(); err != nil { + return 0, 0, err + } myRaw := m.myRawKeyID myRunnerKey := myKeyID if myRunnerKey == 0 { @@ -332,7 +403,6 @@ func (m *Manager) ensureChannelForEncrypt(chatID string, myKeyID, senderKeyID, r peerPub := m.peerPublic keyByRaw := m.keyByRawID seqMap := m.sequence - m.mu.Unlock() var privRaw, pubRaw, privKeyID int if senderKeyID == myRaw { @@ -349,35 +419,31 @@ func (m *Manager) ensureChannelForEncrypt(chatID string, myKeyID, senderKeyID, r pubKey := peerPubKeyB64 if pubKey == "" { - m.mu.Lock() pubKey = peerPub[pubRaw] - m.mu.Unlock() } if pubKey == "" { return 0, 0, fmt.Errorf("missing peer public key for raw id %d", pubRaw) } - m.RegisterPeerPublicKey(pubRaw, pubKey) + normalized, err := gen.NormalizePeerPublicKeyB64(pubKey) + if err != nil { + return 0, 0, err + } + m.peerPublic[pubRaw] = normalized pairKey := fmt.Sprintf("%d|%d", privRaw, pubRaw) - m.mu.Lock() chanID, ok := m.channelByPair[pairKey] - m.mu.Unlock() if !ok { created, err := m.runner.ChannelCreate(privKeyID, pubKey) if err != nil { return 0, 0, err } chanID = created - m.mu.Lock() m.channelByPair[pairKey] = chanID - m.mu.Unlock() } - m.mu.Lock() seq := seqMap[chatID] + 1 // start at 1 like extension? seqMap[chatID] = seq - m.mu.Unlock() return chanID, seq, nil } @@ -387,6 +453,10 @@ func (m *Manager) DecryptMessageV2(msg *line.Message) (string, error) { return "", fmt.Errorf("not enough chunks") } m.mu.Lock() + if err := m.errLocked(); err != nil { + m.mu.Unlock() + return "", err + } myRaw := m.myRawKeyID m.mu.Unlock() if myRaw == 0 { @@ -483,6 +553,10 @@ func cipherVersion(chunks []string) (int, error) { func (m *Manager) UnwrapGroupSharedKey(chatMid string, sharedKey *line.E2EEGroupSharedKey) (int, error) { m.mu.Lock() + if err := m.errLocked(); err != nil { + m.mu.Unlock() + return 0, err + } defer m.mu.Unlock() ref := groupKeyRef{chatMid, sharedKey.GroupKeyID} if id := m.groupKeys[chatMid][sharedKey.GroupKeyID]; id != 0 { @@ -568,7 +642,21 @@ func (m *Manager) evictGroupKeysLocked() error { // GenerateGroupKey generates a new Curve25519 key in the WASM module for use as a group key. // Returns the key ID for use with WrapGroupKeyForMember. func (m *Manager) GenerateGroupKey() (int, error) { - return m.runner.KeyGenerate() + m.mu.Lock() + defer m.mu.Unlock() + if err := m.errLocked(); err != nil { + return 0, err + } + return m.runner.RegistrationKeyGenerate() +} + +func (m *Manager) ReleaseGroupKey(keyID int) error { + m.mu.Lock() + defer m.mu.Unlock() + if err := m.errLocked(); err != nil { + return err + } + return m.runner.RegistrationKeyRelease(keyID) } // WrapGroupKeyForMember wraps a WASM-generated group key for a specific group member. @@ -576,6 +664,10 @@ func (m *Manager) GenerateGroupKey() (int, error) { // Returns the base64-encoded wrapped key, ready for registerE2EEGroupKey. func (m *Manager) WrapGroupKeyForMember(memberPubKeyB64 string, keyID int) (string, error) { m.mu.Lock() + if err := m.errLocked(); err != nil { + m.mu.Unlock() + return "", err + } myKeyID := m.myKeyID m.mu.Unlock() @@ -621,6 +713,9 @@ func (m *Manager) DecryptGroupMessage(msg *line.Message, chatMid string) (string m.mu.Lock() defer m.mu.Unlock() + if err := m.errLocked(); err != nil { + return "", 0, err + } unwrappedKeyID := m.groupKeys[chatMid][groupKeyID] if unwrappedKeyID == 0 { return "", groupKeyID, fmt.Errorf("group key %d not found", groupKeyID) @@ -675,6 +770,9 @@ func (m *Manager) DecryptGroupMessage(msg *line.Message, chatMid string) (string // Callers keep m.mu held while using the channel so an eviction cannot destroy it. func (m *Manager) ensureGroupChannelLocked(chatMid string, groupKeyID, unwrappedKeyID, senderKeyID int) (int, error) { + if err := m.errLocked(); err != nil { + return 0, err + } if m.groupSessionChannels[chatMid] == nil { m.groupSessionChannels[chatMid] = make(map[int]map[int]int) } @@ -713,6 +811,9 @@ func (m *Manager) EncryptGroupMessage(chatMid, fromMid string, plaintext string) func (m *Manager) EncryptGroupMessageRaw(chatMid, fromMid string, contentType int, payload []byte) ([]string, error) { m.mu.Lock() defer m.mu.Unlock() + if err := m.errLocked(); err != nil { + return nil, err + } groupKeyID, ok := m.latestGroupKey[chatMid] var unwrappedKeyID int if ok { @@ -766,6 +867,10 @@ func (m *Manager) EncryptGroupMessageRaw(chatMid, fromMid string, contentType in func (m *Manager) HasPeerPublicKey(rawKeyID int) bool { m.mu.Lock() + if err := m.errLocked(); err != nil { + m.mu.Unlock() + return false + } defer m.mu.Unlock() _, ok := m.peerPublic[rawKeyID] return ok @@ -777,6 +882,10 @@ func (m *Manager) HasPeerPublicKey(rawKeyID int) bool { // since myRawKeyID only points to the latest one. func (m *Manager) IsMyKey(rawKeyID int) bool { m.mu.Lock() + if err := m.errLocked(); err != nil { + m.mu.Unlock() + return false + } defer m.mu.Unlock() _, ok := m.keyByRawID[rawKeyID] return ok @@ -788,23 +897,34 @@ func (m *Manager) RegisterPeerPublicKey(rawKeyID int, pubB64 string) { return } m.mu.Lock() + if err := m.errLocked(); err != nil { + m.mu.Unlock() + return + } defer m.mu.Unlock() m.peerPublic[rawKeyID] = normalized } func (m *Manager) RegisterUnwrappedKey(rawKeyID, runnerKeyID int) { m.mu.Lock() + if err := m.errLocked(); err != nil { + m.mu.Unlock() + return + } defer m.mu.Unlock() m.keyByRawID[rawKeyID] = runnerKeyID } func (m *Manager) channelFromKeyIDs(senderKeyID, receiverKeyID int) (int, error) { m.mu.Lock() + defer m.mu.Unlock() + if err := m.errLocked(); err != nil { + return 0, err + } myRaw := m.myRawKeyID myKeyID := m.myKeyID peerPub := m.peerPublic keyByRaw := m.keyByRawID - m.mu.Unlock() // Determine which side is the users's var privRaw, pubRaw, privKeyID int @@ -836,20 +956,15 @@ func (m *Manager) channelFromKeyIDs(senderKeyID, receiverKeyID int) (int, error) } pairKey := fmt.Sprintf("%d|%d", privRaw, pubRaw) - m.mu.Lock() if id, ok := m.channelByPair[pairKey]; ok { - m.mu.Unlock() return id, nil } - m.mu.Unlock() chanID, err := m.runner.ChannelCreate(privKeyID, pubB64) if err != nil { return 0, err } - m.mu.Lock() m.channelByPair[pairKey] = chanID - m.mu.Unlock() return chanID, nil } diff --git a/pkg/line/client.go b/pkg/line/client.go index 90e2cb7..0a7aaf3 100644 --- a/pkg/line/client.go +++ b/pkg/line/client.go @@ -19,7 +19,6 @@ import ( gen "github.com/highesttt/matrix-line-messenger/pkg" "github.com/highesttt/matrix-line-messenger/pkg/line/password" - "github.com/highesttt/matrix-line-messenger/pkg/line/secret" ) const ( @@ -43,14 +42,36 @@ var ( ) type Client struct { - HTTPClient *http.Client - OBSClient *http.Client - AccessToken string + LoginAttempt *LoginAttempt + HTTPClient *http.Client + OBSClient *http.Client + AccessToken string channelTokenMu sync.Mutex channelTokenCache map[string]cachedChannelAccessToken } +type LoginAttempt struct { + Runner *gen.Runner + Context context.Context + cancel context.CancelFunc +} + +func (a *LoginAttempt) Close() { + if a == nil { + return + } + a.cancel() + a.Runner.Close() +} + +func (c *Client) requestContext() context.Context { + if c.LoginAttempt != nil { + return c.LoginAttempt.Context + } + return context.Background() +} + type OBSDownloadOptions struct { TID string OBSPop string @@ -80,6 +101,32 @@ func (c *Client) obsHTTPClient() *http.Client { } func (c *Client) Login(email, pass, certificate string) (*LoginResult, error) { + return c.LoginContext(context.Background(), email, pass, certificate) +} + +func (c *Client) LoginContext(ctx context.Context, email, pass, certificate string) (result *LoginResult, err error) { + if c.LoginAttempt != nil { + c.LoginAttempt.Close() + } + r, err := gen.NewRunner() + if err != nil { + return nil, err + } + attemptCtx, cancel := context.WithCancel(ctx) + attempt := &LoginAttempt{Runner: r, Context: attemptCtx, cancel: cancel} + c.LoginAttempt = attempt + defer func() { + if ctx.Err() != nil { + result = nil + err = ctx.Err() + } + if result == nil { + attempt.Close() + } else { + result.Attempt = attempt + } + }() + // 1. Get RSA Key Info rsaKey, err := c.GetRSAKeyInfo() if err != nil { @@ -93,7 +140,7 @@ func (c *Client) Login(email, pass, certificate string) (*LoginResult, error) { } // 3. Generate E2EE Secret - secretRes, err := secret.GenerateSecret() + secretRes, err := attempt.Runner.GenerateE2EESecret() if err != nil { return nil, fmt.Errorf("failed to generate e2ee secret: %w", err) } @@ -139,6 +186,9 @@ func (c *Client) Login(email, pass, certificate string) (*LoginResult, error) { } res.NoE2EE = noE2EE + if noE2EE { + attempt.Runner.Close() + } // Prefer the V3 token if present, otherwise fall back to legacy authToken. // LINE returns only the V3 token when re-authenticating with a stored @@ -161,7 +211,22 @@ func isLoginNotSupported(err error) bool { return strings.Contains(msg, "\"code\":89") || strings.Contains(msg, "not supported") } -func (c *Client) WaitForLogin(verifier string, noE2EE bool) (*LoginResult, error) { +func (c *Client) WaitForLogin(verifier string, noE2EE bool) (res *LoginResult, err error) { + defer func() { + if c.requestContext().Err() != nil { + res = nil + err = c.requestContext().Err() + } + if err != nil && c.LoginAttempt != nil { + c.LoginAttempt.Close() + } + + if res != nil { + res.Attempt = c.LoginAttempt + res.NoE2EE = noE2EE + } + }() + if noE2EE { return c.waitForLoginJQ(verifier) } @@ -172,7 +237,7 @@ func (c *Client) WaitForLogin(verifier string, noE2EE bool) (*LoginResult, error func (c *Client) waitForLoginJQ(verifier string) (*LoginResult, error) { url := "https://line-chrome-gw.line-apps.com/api/talk/long-polling/JQ" - req, err := http.NewRequest("GET", url, nil) + req, err := http.NewRequestWithContext(c.requestContext(), "GET", url, nil) if err != nil { return nil, err } @@ -236,7 +301,7 @@ func (c *Client) waitForLoginJQ(verifier string) (*LoginResult, error) { func (c *Client) waitForLoginLF1(verifier string) (*LoginResult, error) { url := "https://line-chrome-gw.line-apps.com/api/talk/long-polling/LF1" - req, err := http.NewRequest("GET", url, nil) + req, err := http.NewRequestWithContext(c.requestContext(), "GET", url, nil) if err != nil { return nil, err } @@ -331,7 +396,7 @@ func (c *Client) GetRSAKeyInfo() (*RSAKeyInfo, error) { } func (c *Client) callRPC(service, method string, args ...interface{}) ([]byte, error) { - return c.callRPCContext(context.Background(), service, method, args...) + return c.callRPCContext(c.requestContext(), service, method, args...) } func (c *Client) callRPCContext(ctx context.Context, service, method string, args ...interface{}) ([]byte, error) { @@ -343,7 +408,7 @@ func (c *Client) callShopRPC(service, method string, args ...interface{}) ([]byt } func (c *Client) callRPCWithBaseURL(baseURL, service, method string, args ...interface{}) ([]byte, error) { - return c.callRPCWithBaseURLContext(context.Background(), baseURL, service, method, args...) + return c.callRPCWithBaseURLContext(c.requestContext(), baseURL, service, method, args...) } func (c *Client) callRPCWithBaseURLContext(ctx context.Context, baseURL, service, method string, args ...interface{}) ([]byte, error) { @@ -404,10 +469,10 @@ func (c *Client) callRPCWithBaseURLContext(ctx context.Context, baseURL, service // ConfirmE2EELogin completes the E2EE handshake after LF1 by hashing the encrypted key // chain and posting it alongside the verifier. func (c *Client) ConfirmE2EELogin(verifier, serverPublicKeyB64, encryptedKeyChainB64 string) (string, error) { - runner, err := gen.GetRunner() - if err != nil { - return "", fmt.Errorf("failed to init runner: %w", err) + if c.LoginAttempt == nil { + return "", errors.New("missing E2EE login attempt") } + runner := c.LoginAttempt.Runner hash, err := runner.GenerateConfirmHash(serverPublicKeyB64, encryptedKeyChainB64) if err != nil { @@ -445,7 +510,7 @@ func (c *Client) ConfirmE2EELogin(verifier, serverPublicKeyB64, encryptedKeyChai // postWithHMAC is a small helper for non-standard RPC endpoints that still expect // the same headers and HMAC signature as the Talk endpoints. func (c *Client) postWithHMAC(fullURL string, body []byte) ([]byte, error) { - req, err := http.NewRequest("POST", fullURL, bytes.NewBuffer(body)) + req, err := http.NewRequestWithContext(c.requestContext(), "POST", fullURL, bytes.NewBuffer(body)) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } diff --git a/pkg/line/structs.go b/pkg/line/structs.go index 36edb16..1816f6d 100644 --- a/pkg/line/structs.go +++ b/pkg/line/structs.go @@ -131,6 +131,7 @@ type RSAKeyInfo struct { } type LoginResult struct { + Attempt *LoginAttempt `json:"-"` AuthToken string `json:"authToken"` Certificate string `json:"certificate"` Verifier string `json:"verifier"` diff --git a/pkg/ltsm/runtime.go b/pkg/ltsm/runtime.go index 6894db0..a0be4f6 100644 --- a/pkg/ltsm/runtime.go +++ b/pkg/ltsm/runtime.go @@ -293,6 +293,10 @@ func (rt *Runtime) Curve25519KeyGenerate() (uint32, error) { return ptr, nil } +func (rt *Runtime) Curve25519KeyDestroy(ptr uint32) error { + return rt.imp.Destroy("Curve25519Key", ptr) +} + // --- E2EEKey --- func (rt *Runtime) E2EEKeyLoadKey(keyBytes []byte) (uint32, error) { diff --git a/pkg/runner.go b/pkg/runner.go index d50ad7e..7907480 100644 --- a/pkg/runner.go +++ b/pkg/runner.go @@ -17,17 +17,19 @@ import ( ) type Runner struct { - rt *ltsm.Runtime - token string - clientVersion string - skPtr uint32 // SecureKey from loadToken - storageKey uint32 // AesKey ptr (after StorageInit) - loginCurveKey uint32 // Curve25519Key ptr (after GenerateE2EESecret) - keyStore map[int]uint32 // internal ID -> E2EEKey ptr - channelStore map[int]uint32 // internal ID -> E2EEChannel ptr - channelsByKey map[channelKey]int - nextID int - mu sync.Mutex + failure error + rt *ltsm.Runtime + token string + clientVersion string + skPtr uint32 // SecureKey from loadToken + storageKey uint32 // AesKey ptr (after StorageInit) + loginCurveKey uint32 // Curve25519Key ptr (after GenerateE2EESecret) + registrationKeys map[int]struct{} + keyStore map[int]uint32 // internal ID -> E2EEKey ptr + channelStore map[int]uint32 // internal ID -> E2EEChannel ptr + channelsByKey map[channelKey]int + nextID int + mu sync.Mutex // Signing state can be recreated from configuration and the access token // without invalidating the E2EE objects in rt. All access is under mu. @@ -84,43 +86,99 @@ var ( runnerErr error ) +var ErrRunnerClosed = errors.New("crypto runner closed") + func GetRunner() (*Runner, error) { - runnerOnce.Do(func() { - token := os.Getenv("SECURE_KEY") - if token == "" { - token = "wODdrvWqmdP4Zliay-iF3cz3KZcK0ekrial868apg06TXeCo7A1hIQO0ESElHg6D" - } - clientVersion := os.Getenv("CLIENT_VERSION") - if clientVersion == "" { - clientVersion = "3.7.2" + runnerOnce.Do(func() { globalRunner, runnerErr = NewRunner() }) + return globalRunner, runnerErr +} +func NewRunner() (r *Runner, err error) { + defer func() { + if recovered := recover(); recovered != nil { + abort, ok := recovered.(error) + if !ok || !errors.Is(abort, ltsm.ErrAbort) { + panic(recovered) + } + r = nil + err = ltsmPanicError("ltsm initialization", recovered) } + }() + token := os.Getenv("SECURE_KEY") + if token == "" { + token = "wODdrvWqmdP4Zliay-iF3cz3KZcK0ekrial868apg06TXeCo7A1hIQO0ESElHg6D" + } + clientVersion := os.Getenv("CLIENT_VERSION") + if clientVersion == "" { + clientVersion = "3.7.2" + } - rt, err := ltsm.NewRuntime() - if err != nil { - runnerErr = fmt.Errorf("failed to initialize LTSM runtime: %w", err) - return - } + rt, err := ltsm.NewRuntime() + if err != nil { + err = fmt.Errorf("failed to initialize LTSM runtime: %w", err) + return nil, err + } - skPtr, err := rt.SecureKeyLoadToken(token) - if err != nil { - rt.Close() - runnerErr = fmt.Errorf("failed to load secure key: %w", err) - return - } + skPtr, err := rt.SecureKeyLoadToken(token) + if err != nil { + rt.Close() + err = fmt.Errorf("failed to load secure key: %w", err) + return nil, err + } - globalRunner = &Runner{ - rt: rt, - token: token, - clientVersion: clientVersion, - skPtr: skPtr, - keyStore: make(map[int]uint32), - channelStore: make(map[int]uint32), - nextID: 1, - goKeys: make(map[int]*goKeyEntry), - goChannels: make(map[int]*ltsm.Channel), + r = &Runner{ + rt: rt, + token: token, + clientVersion: clientVersion, + skPtr: skPtr, + keyStore: make(map[int]uint32), + channelStore: make(map[int]uint32), + nextID: 1, + goKeys: make(map[int]*goKeyEntry), + goChannels: make(map[int]*ltsm.Channel), + } + return r, nil +} +func (r *Runner) discard(err error) { + r.failure = err + r.rt = nil + r.skPtr = 0 + r.storageKey = 0 + r.loginCurveKey = 0 + r.registrationKeys = nil + r.keyStore = nil + r.channelStore = nil + r.channelsByKey = nil + r.goKeys = nil + r.goChannels = nil + r.resetSigningRuntime() +} +func (r *Runner) Close() { + r.mu.Lock() + defer r.mu.Unlock() + if r.failure == nil { + r.discard(ErrRunnerClosed) + } +} +func (r *Runner) Err() error { + r.mu.Lock() + defer r.mu.Unlock() + return r.failure +} + +func (r *Runner) finishOperation(operation string, err *error, authoritative bool, zero func()) { + if recovered := recover(); recovered != nil { + abort, ok := recovered.(error) + if !ok || !errors.Is(abort, ltsm.ErrAbort) { + panic(recovered) } - }) - return globalRunner, runnerErr + *err = ltsmPanicError("ltsm "+operation, recovered) + } + if authoritative && errors.Is(*err, ltsm.ErrAbort) { + r.discard(*err) + } + if *err != nil { + zero() + } } func (r *Runner) putKey(ptr uint32) int { @@ -138,9 +196,31 @@ func (r *Runner) getKey(id int) (uint32, error) { return ptr, nil } -func (r *Runner) KeyGenerate() (int, error) { +func (r *Runner) KeyGenerate() (out0 int, err error) { r.mu.Lock() defer r.mu.Unlock() + if r.failure != nil { + return 0, r.failure + } + defer func() { + if recovered := recover(); recovered != nil { + abort, ok := recovered.(error) + if !ok || !errors.Is(abort, ltsm.ErrAbort) { + panic(recovered) + } + err = ltsmPanicError("ltsm KeyGenerate", recovered) + } + if errors.Is(err, ltsm.ErrAbort) { + r.discard(err) + } + if err != nil { + out0 = 0 + } + }() + return r.keyGenerate() +} + +func (r *Runner) keyGenerate() (int, error) { ptr, err := r.rt.Curve25519KeyGenerate() if err != nil { return 0, err @@ -148,6 +228,53 @@ func (r *Runner) KeyGenerate() (int, error) { return r.putKey(ptr), nil } +func (r *Runner) RegistrationKeyGenerate() (out0 int, err error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.failure != nil { + return 0, r.failure + } + defer r.finishOperation("RegistrationKeyGenerate", &err, true, func() { out0 = 0 }) + id, err := r.keyGenerate() + if err != nil { + return 0, err + } + if r.registrationKeys == nil { + r.registrationKeys = make(map[int]struct{}) + } + r.registrationKeys[id] = struct{}{} + return id, nil +} + +func (r *Runner) RegistrationKeyRelease(keyID int) (err error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.failure != nil { + return r.failure + } + defer r.finishOperation("RegistrationKeyRelease", &err, true, func() {}) + if _, ok := r.registrationKeys[keyID]; !ok { + return fmt.Errorf("key %d is not an ephemeral registration key", keyID) + } + for dependency := range r.channelsByKey { + if dependency.keyID == keyID { + return fmt.Errorf("registration key %d has a channel dependency", keyID) + } + } + ptr, err := r.getKey(keyID) + if err != nil { + return err + } + if err = r.rt.Curve25519KeyDestroy(ptr); err != nil { + err = errors.Join(ErrRunnerClosed, fmt.Errorf("destroy registration key: %w", err)) + r.discard(err) + return err + } + delete(r.registrationKeys, keyID) + delete(r.keyStore, keyID) + return nil +} + func (r *Runner) putChannel(ptr uint32) int { id := r.nextID r.nextID++ @@ -173,6 +300,10 @@ func ltsmPanicError(operation string, recovered any) error { func (r *Runner) exportE2EEKeyPanicSafe(keyPtr uint32) (exported []byte, err error) { defer func() { if recovered := recover(); recovered != nil { + abort, ok := recovered.(error) + if !ok || !errors.Is(abort, ltsm.ErrAbort) { + panic(recovered) + } exported = nil err = ltsmPanicError("ltsm E2EEKey.exportKey", recovered) } @@ -183,6 +314,10 @@ func (r *Runner) exportE2EEKeyPanicSafe(keyPtr uint32) (exported []byte, err err func (r *Runner) unwrapGroupSharedKeyPanicSafe(chanPtr uint32, encKey []byte) (keyPtr uint32, err error) { defer func() { if recovered := recover(); recovered != nil { + abort, ok := recovered.(error) + if !ok || !errors.Is(abort, ltsm.ErrAbort) { + panic(recovered) + } keyPtr = 0 err = ltsmPanicError("ltsm E2EEChannel.unwrapGroupSharedKey", recovered) } @@ -190,18 +325,13 @@ func (r *Runner) unwrapGroupSharedKeyPanicSafe(chanPtr uint32, encKey []byte) (k return r.rt.E2EEChannelUnwrapGroupSharedKey(chanPtr, encKey) } -func (r *Runner) destroyPanicSafe(class string, destroy func(uint32) error, ptr uint32) (err error) { - defer func() { - if recovered := recover(); recovered != nil { - err = ltsmPanicError("ltsm "+class+" destroy", recovered) - } - }() - return destroy(ptr) -} - func (r *Runner) encryptV1PanicSafe(chanPtr uint32, plaintext []byte) (ciphertext []byte, err error) { defer func() { if recovered := recover(); recovered != nil { + abort, ok := recovered.(error) + if !ok || !errors.Is(abort, ltsm.ErrAbort) { + panic(recovered) + } ciphertext = nil err = ltsmPanicError("ltsm E2EEChannel.encryptV1", recovered) } @@ -212,6 +342,10 @@ func (r *Runner) encryptV1PanicSafe(chanPtr uint32, plaintext []byte) (ciphertex func (r *Runner) encryptV2PanicSafe(chanPtr uint32, to, from string, senderKeyID, receiverKeyID, contentType int, seq int64, plaintext []byte) (ciphertext []byte, err error) { defer func() { if recovered := recover(); recovered != nil { + abort, ok := recovered.(error) + if !ok || !errors.Is(abort, ltsm.ErrAbort) { + panic(recovered) + } ciphertext = nil err = ltsmPanicError("ltsm E2EEChannel.encryptV2", recovered) } @@ -222,6 +356,10 @@ func (r *Runner) encryptV2PanicSafe(chanPtr uint32, to, from string, senderKeyID func (r *Runner) decryptV1PanicSafe(chanPtr uint32, ciphertext []byte) (plaintext []byte, err error) { defer func() { if recovered := recover(); recovered != nil { + abort, ok := recovered.(error) + if !ok || !errors.Is(abort, ltsm.ErrAbort) { + panic(recovered) + } plaintext = nil err = ltsmPanicError("ltsm E2EEChannel.decryptV1", recovered) } @@ -232,6 +370,10 @@ func (r *Runner) decryptV1PanicSafe(chanPtr uint32, ciphertext []byte) (plaintex func (r *Runner) decryptV2PanicSafe(chanPtr uint32, to, from string, senderKeyID, receiverKeyID, contentType int, ciphertext []byte) (plaintext []byte, err error) { defer func() { if recovered := recover(); recovered != nil { + abort, ok := recovered.(error) + if !ok || !errors.Is(abort, ltsm.ErrAbort) { + panic(recovered) + } plaintext = nil err = ltsmPanicError("ltsm E2EEChannel.decryptV2", recovered) } @@ -299,16 +441,34 @@ func (r *Runner) signingHMAC(accessToken string) (uint32, error) { return hmacPtr, nil } -func (r *Runner) GetSignature(reqPath, body, accessToken string) (string, error) { +func (r *Runner) GetSignature(reqPath, body, accessToken string) (out0 string, err error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.failure != nil { + return "", r.failure + } + defer func() { + if recovered := recover(); recovered != nil { + abort, ok := recovered.(error) + if !ok || !errors.Is(abort, ltsm.ErrAbort) { + panic(recovered) + } + err = ltsmPanicError("ltsm GetSignature", recovered) + } + if err != nil { + out0 = "" + } + }() + return r.signatureLocked(reqPath, body, accessToken) +} + +func (r *Runner) signatureLocked(reqPath, body, accessToken string) (string, error) { if reqPath == "" { reqPath = "/" } else if reqPath[0] != '/' { reqPath = "/" + reqPath } - r.mu.Lock() - defer r.mu.Unlock() - signature, err := r.getSignature(reqPath, body, accessToken) if errors.Is(err, ltsm.ErrAbort) { // getSignature discarded the aborted runtime. Retry once from a clean @@ -375,14 +535,35 @@ func (r *Runner) getSignature(reqPath, body, accessToken string) (signature stri // DebugExportDerivedSigningKey returns the derived SecureKey export blob used for // signing. Disabled unless LTSM_ENABLE_DEBUG_EXPORT=1 is set. -func (r *Runner) DebugExportDerivedSigningKey(accessToken string) (string, error) { +func (r *Runner) DebugExportDerivedSigningKey(accessToken string) (out0 string, err error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.failure != nil { + return "", r.failure + } + defer func() { + if recovered := recover(); recovered != nil { + abort, ok := recovered.(error) + if !ok || !errors.Is(abort, ltsm.ErrAbort) { + panic(recovered) + } + err = ltsmPanicError("ltsm DebugExportDerivedSigningKey", recovered) + } + if errors.Is(err, ltsm.ErrAbort) { + r.discard(err) + } + if err != nil { + out0 = "" + } + }() + return r.debugExportDerivedSigningKey(accessToken) +} + +func (r *Runner) debugExportDerivedSigningKey(accessToken string) (string, error) { if os.Getenv("LTSM_ENABLE_DEBUG_EXPORT") != "1" { return "", fmt.Errorf("runner error: debug export disabled") } - r.mu.Lock() - defer r.mu.Unlock() - cvHash := sha256.Sum256([]byte(r.clientVersion)) atHash := sha256.Sum256([]byte(accessToken)) @@ -390,7 +571,6 @@ func (r *Runner) DebugExportDerivedSigningKey(accessToken string) (string, error if err != nil { return "", err } - defer func() { _ = r.rt.SecureKeyDestroy(dkPtr) }() // Mark the derived key as exportable by setting the C++ flag at ptr+16 r.rt.MarkSecureKeyExportable(dkPtr) @@ -399,13 +579,24 @@ func (r *Runner) DebugExportDerivedSigningKey(accessToken string) (string, error if err != nil { return "", err } + if err := r.rt.SecureKeyDestroy(dkPtr); err != nil { + return "", err + } return base64.StdEncoding.EncodeToString(exported), nil } // StorageInit initializes the storage key using getEncryptedIdentityV3 response fields. -func (r *Runner) StorageInit(wrappedNonce, kdf1, kdf2 string) error { +func (r *Runner) StorageInit(wrappedNonce, kdf1, kdf2 string) (err error) { r.mu.Lock() defer r.mu.Unlock() + if r.failure != nil { + return r.failure + } + defer r.finishOperation("StorageInit", &err, true, func() {}) + return r.storageInit(wrappedNonce, kdf1, kdf2) +} + +func (r *Runner) storageInit(wrappedNonce, kdf1, kdf2 string) error { nonceBytes, err := base64.StdEncoding.DecodeString(wrappedNonce) if err != nil { @@ -435,9 +626,17 @@ func (r *Runner) StorageInit(wrappedNonce, kdf1, kdf2 string) error { } // StorageDecrypt decrypts lcs_secure blobs with the initialized storage key. -func (r *Runner) StorageDecrypt(ciphertext string) (string, error) { +func (r *Runner) StorageDecrypt(ciphertext string) (out0 string, err error) { r.mu.Lock() defer r.mu.Unlock() + if r.failure != nil { + return "", r.failure + } + defer r.finishOperation("StorageDecrypt", &err, true, func() { out0 = "" }) + return r.storageDecrypt(ciphertext) +} + +func (r *Runner) storageDecrypt(ciphertext string) (string, error) { if r.storageKey == 0 { return "", fmt.Errorf("storage key not initialized") @@ -457,9 +656,17 @@ func (r *Runner) StorageDecrypt(ciphertext string) (string, error) { } // StorageEncrypt encrypts plaintext with the initialized storage key. -func (r *Runner) StorageEncrypt(plaintext string) (string, error) { +func (r *Runner) StorageEncrypt(plaintext string) (out0 string, err error) { r.mu.Lock() defer r.mu.Unlock() + if r.failure != nil { + return "", r.failure + } + defer r.finishOperation("StorageEncrypt", &err, true, func() { out0 = "" }) + return r.storageEncrypt(plaintext) +} + +func (r *Runner) storageEncrypt(plaintext string) (string, error) { if r.storageKey == 0 { return "", fmt.Errorf("storage key not initialized") @@ -474,15 +681,22 @@ func (r *Runner) StorageEncrypt(plaintext string) (string, error) { } // LoginUnwrapKeyChain unwraps the encrypted key chain from LF1 using the login curve key. -func (r *Runner) LoginUnwrapKeyChain(serverPubB64, encryptedKeyChainB64 string) ([]UnwrappedKey, error) { +func (r *Runner) LoginUnwrapKeyChain(serverPubB64, encryptedKeyChainB64 string) (out0 []UnwrappedKey, err error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.failure != nil { + return nil, r.failure + } + defer r.finishOperation("LoginUnwrapKeyChain", &err, true, func() { out0 = nil }) + return r.loginUnwrapKeyChain(serverPubB64, encryptedKeyChainB64) +} + +func (r *Runner) loginUnwrapKeyChain(serverPubB64, encryptedKeyChainB64 string) ([]UnwrappedKey, error) { normalizedServerPub, err := normalizeServerPublicKeyB64(serverPubB64) if err != nil { return nil, err } - r.mu.Lock() - defer r.mu.Unlock() - if r.loginCurveKey == 0 { return nil, fmt.Errorf("login key not initialized") } @@ -550,9 +764,17 @@ func (r *Runner) LoginUnwrapKeyChain(serverPubB64, encryptedKeyChainB64 string) // KeyLoad loads a base64 E2EE key and returns an internal key ID. // Also stores the decoded key bytes as a Go key entry so ChannelCreate can // create a pure Go Channel (required for group shared key wrapping). -func (r *Runner) KeyLoad(b64Key string) (int, error) { +func (r *Runner) KeyLoad(b64Key string) (out0 int, err error) { r.mu.Lock() defer r.mu.Unlock() + if r.failure != nil { + return 0, r.failure + } + defer r.finishOperation("KeyLoad", &err, true, func() { out0 = 0 }) + return r.keyLoad(b64Key) +} + +func (r *Runner) keyLoad(b64Key string) (int, error) { keyBytes, err := base64.StdEncoding.DecodeString(b64Key) if err != nil { @@ -575,9 +797,17 @@ func (r *Runner) KeyLoad(b64Key string) (int, error) { } // KeyGetID returns the raw key ID for a loaded key. -func (r *Runner) KeyGetID(keyID int) (int, error) { +func (r *Runner) KeyGetID(keyID int) (out0 int, err error) { r.mu.Lock() defer r.mu.Unlock() + if r.failure != nil { + return 0, r.failure + } + defer r.finishOperation("KeyGetID", &err, true, func() { out0 = 0 }) + return r.keyGetID(keyID) +} + +func (r *Runner) keyGetID(keyID int) (int, error) { keyPtr, err := r.getKey(keyID) if err != nil { @@ -588,9 +818,17 @@ func (r *Runner) KeyGetID(keyID int) (int, error) { } // KeyGetPublic returns the base64 public key for a loaded key. -func (r *Runner) KeyGetPublic(keyID int) (string, error) { +func (r *Runner) KeyGetPublic(keyID int) (out0 string, err error) { r.mu.Lock() defer r.mu.Unlock() + if r.failure != nil { + return "", r.failure + } + defer r.finishOperation("KeyGetPublic", &err, true, func() { out0 = "" }) + return r.keyGetPublic(keyID) +} + +func (r *Runner) keyGetPublic(keyID int) (string, error) { keyPtr, err := r.getKey(keyID) if err != nil { @@ -608,9 +846,20 @@ func (r *Runner) KeyGetPublic(keyID int) (string, error) { // ChannelCreate creates a channel with our key and peer public key. // If a raw Go private key is available for the key, also creates a pure Go // channel for V1/V2 encrypt/decrypt without WASM overhead. -func (r *Runner) ChannelCreate(keyID int, peerPublicB64 string) (int, error) { +func (r *Runner) ChannelCreate(keyID int, peerPublicB64 string) (out0 int, err error) { r.mu.Lock() defer r.mu.Unlock() + if r.failure != nil { + return 0, r.failure + } + defer r.finishOperation("ChannelCreate", &err, true, func() { out0 = 0 }) + return r.channelCreate(keyID, peerPublicB64) +} + +func (r *Runner) channelCreate(keyID int, peerPublicB64 string) (int, error) { + if _, ok := r.registrationKeys[keyID]; ok { + return 0, fmt.Errorf("registration key %d cannot own a channel", keyID) + } keyPtr, err := r.getKey(keyID) if err != nil { @@ -651,9 +900,17 @@ func (r *Runner) ChannelCreate(keyID int, peerPublicB64 string) (int, error) { } // ChannelUnwrapGroupSharedKey unwraps the group shared key using the channel. -func (r *Runner) ChannelUnwrapGroupSharedKey(channelID int, encryptedSharedKeyB64 string) (int, error) { +func (r *Runner) ChannelUnwrapGroupSharedKey(channelID int, encryptedSharedKeyB64 string) (out0 int, err error) { r.mu.Lock() defer r.mu.Unlock() + if r.failure != nil { + return 0, r.failure + } + defer r.finishOperation("ChannelUnwrapGroupSharedKey", &err, true, func() { out0 = 0 }) + return r.channelUnwrapGroupSharedKey(channelID, encryptedSharedKeyB64) +} + +func (r *Runner) channelUnwrapGroupSharedKey(channelID int, encryptedSharedKeyB64 string) (int, error) { chanPtr, err := r.getChannel(channelID) if err != nil { @@ -674,15 +931,18 @@ func (r *Runner) ChannelUnwrapGroupSharedKey(channelID int, encryptedSharedKeyB6 } // KeyDestroy releases an unwrapped group key and every channel created from it. -func (r *Runner) KeyDestroy(keyID int) error { +func (r *Runner) KeyDestroy(keyID int) (err error) { r.mu.Lock() defer r.mu.Unlock() + if r.failure != nil { + return r.failure + } + defer r.finishOperation("KeyDestroy", &err, true, func() {}) keyPtr, err := r.getKey(keyID) if err != nil { return err } - var errs []error for cacheKey, channelID := range r.channelsByKey { if cacheKey.keyID != keyID { continue @@ -691,19 +951,35 @@ func (r *Runner) KeyDestroy(keyID int) error { delete(r.channelsByKey, cacheKey) delete(r.channelStore, channelID) delete(r.goChannels, channelID) - errs = append(errs, r.destroyPanicSafe("E2EEChannel", r.rt.E2EEChannelDestroy, chanPtr)) + if err = r.rt.E2EEChannelDestroy(chanPtr); err != nil { + err = errors.Join(ErrRunnerClosed, fmt.Errorf("destroy group channel: %w", err)) + r.discard(err) + return err + } } delete(r.keyStore, keyID) delete(r.goKeys, keyID) - errs = append(errs, r.destroyPanicSafe("E2EEKey", r.rt.E2EEKeyDestroy, keyPtr)) - return errors.Join(errs...) + if err = r.rt.E2EEKeyDestroy(keyPtr); err != nil { + err = errors.Join(ErrRunnerClosed, fmt.Errorf("destroy group key: %w", err)) + r.discard(err) + return err + } + return nil } // ChannelEncryptV1 encrypts plaintext with channel V1 (AES-256-CBC + MAC). // Uses pure Go crypto when a Go channel is available. -func (r *Runner) ChannelEncryptV1(channelID int, plaintext string) (string, error) { +func (r *Runner) ChannelEncryptV1(channelID int, plaintext string) (out0 string, err error) { r.mu.Lock() defer r.mu.Unlock() + if r.failure != nil { + return "", r.failure + } + defer r.finishOperation("ChannelEncryptV1", &err, false, func() { out0 = "" }) + return r.channelEncryptV1(channelID, plaintext) +} + +func (r *Runner) channelEncryptV1(channelID int, plaintext string) (string, error) { // Prefer pure Go channel if goChan, ok := r.goChannels[channelID]; ok { @@ -731,9 +1007,17 @@ func (r *Runner) ChannelEncryptV1(channelID int, plaintext string) (string, erro // ChannelEncryptV2 encrypts plaintext with channel v2. // Uses pure Go crypto when a Go channel is available (faster, and V2 works // correctly in Go whereas it fails in the WASM bridge due to SKB issues). -func (r *Runner) ChannelEncryptV2(channelID int, to, from string, senderKeyID, receiverKeyID, contentType, seq int, plaintext string) (string, error) { +func (r *Runner) ChannelEncryptV2(channelID int, to, from string, senderKeyID, receiverKeyID, contentType, seq int, plaintext string) (out0 string, err error) { r.mu.Lock() defer r.mu.Unlock() + if r.failure != nil { + return "", r.failure + } + defer r.finishOperation("ChannelEncryptV2", &err, false, func() { out0 = "" }) + return r.channelEncryptV2(channelID, to, from, senderKeyID, receiverKeyID, contentType, seq, plaintext) +} + +func (r *Runner) channelEncryptV2(channelID int, to, from string, senderKeyID, receiverKeyID, contentType, seq int, plaintext string) (string, error) { // Prefer pure Go channel (V2 is broken in WASM due to SKB AAD failure) if goChan, ok := r.goChannels[channelID]; ok { @@ -761,9 +1045,20 @@ func (r *Runner) ChannelEncryptV2(channelID int, to, from string, senderKeyID, r // ChannelDecryptV1 decrypts ciphertext with channel v1 (ios). // Uses pure Go crypto when a Go channel is available. -func (r *Runner) ChannelDecryptV1(channelID, senderKeyID, receiverKeyID int, ciphertext string) (string, string, error) { +func (r *Runner) ChannelDecryptV1(channelID, senderKeyID, receiverKeyID int, ciphertext string) (out0 string, out1 string, err error) { r.mu.Lock() defer r.mu.Unlock() + if r.failure != nil { + return "", "", r.failure + } + defer r.finishOperation("ChannelDecryptV1", &err, false, func() { + out0 = "" + out1 = "" + }) + return r.channelDecryptV1(channelID, senderKeyID, receiverKeyID, ciphertext) +} + +func (r *Runner) channelDecryptV1(channelID, senderKeyID, receiverKeyID int, ciphertext string) (string, string, error) { ctBytes, err := base64.StdEncoding.DecodeString(ciphertext) if err != nil { @@ -795,9 +1090,20 @@ func (r *Runner) ChannelDecryptV1(channelID, senderKeyID, receiverKeyID int, cip // ChannelDecryptV2 decrypts ciphertext with channel v2. // Uses pure Go crypto when a Go channel is available (V2 is broken in WASM). -func (r *Runner) ChannelDecryptV2(channelID int, to, from string, senderKeyID, receiverKeyID, contentType int, ciphertext string) (string, string, error) { +func (r *Runner) ChannelDecryptV2(channelID int, to, from string, senderKeyID, receiverKeyID, contentType int, ciphertext string) (out0 string, out1 string, err error) { r.mu.Lock() defer r.mu.Unlock() + if r.failure != nil { + return "", "", r.failure + } + defer r.finishOperation("ChannelDecryptV2", &err, false, func() { + out0 = "" + out1 = "" + }) + return r.channelDecryptV2(channelID, to, from, senderKeyID, receiverKeyID, contentType, ciphertext) +} + +func (r *Runner) channelDecryptV2(channelID int, to, from string, senderKeyID, receiverKeyID, contentType int, ciphertext string) (string, string, error) { ctBytes, err := base64.StdEncoding.DecodeString(ciphertext) if err != nil { @@ -831,9 +1137,17 @@ func (r *Runner) ChannelDecryptV2(channelID int, to, from string, senderKeyID, r // 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. -func (r *Runner) GenerateE2EESecret() (*SecretResult, error) { +func (r *Runner) GenerateE2EESecret() (out0 *SecretResult, err error) { r.mu.Lock() defer r.mu.Unlock() + if r.failure != nil { + return nil, r.failure + } + defer r.finishOperation("GenerateE2EESecret", &err, true, func() { out0 = nil }) + return r.generateE2EESecret() +} + +func (r *Runner) generateE2EESecret() (*SecretResult, error) { ckPtr, err := r.rt.Curve25519KeyNew(r.skPtr) if err != nil { @@ -865,15 +1179,22 @@ func (r *Runner) GenerateE2EESecret() (*SecretResult, error) { // GenerateConfirmHash derives the hash key chain for confirmE2EELogin. // Must be called after GenerateE2EESecret. -func (r *Runner) GenerateConfirmHash(serverPublicKeyB64, encryptedKeyChainB64 string) (string, error) { +func (r *Runner) GenerateConfirmHash(serverPublicKeyB64, encryptedKeyChainB64 string) (out0 string, err error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.failure != nil { + return "", r.failure + } + defer r.finishOperation("GenerateConfirmHash", &err, true, func() { out0 = "" }) + return r.generateConfirmHash(serverPublicKeyB64, encryptedKeyChainB64) +} + +func (r *Runner) generateConfirmHash(serverPublicKeyB64, encryptedKeyChainB64 string) (string, error) { normalizedServerPub, err := normalizeServerPublicKeyB64(serverPublicKeyB64) if err != nil { return "", err } - r.mu.Lock() - defer r.mu.Unlock() - if r.loginCurveKey == 0 { return "", fmt.Errorf("login key not initialized") } @@ -903,9 +1224,17 @@ func (r *Runner) GenerateConfirmHash(serverPublicKeyB64, encryptedKeyChainB64 st // ReadChannelRaw returns 128 bytes of the channel C++ object's WASM linear memory. // Used for debugging the shared secret offset in the E2EEChannel/Curve25519KeyChannel object. -func (r *Runner) ReadChannelRaw(channelID int) ([]byte, error) { +func (r *Runner) ReadChannelRaw(channelID int) (out0 []byte, err error) { r.mu.Lock() defer r.mu.Unlock() + if r.failure != nil { + return nil, r.failure + } + defer r.finishOperation("ReadChannelRaw", &err, true, func() { out0 = nil }) + return r.readChannelRaw(channelID) +} + +func (r *Runner) readChannelRaw(channelID int) ([]byte, error) { chanPtr, err := r.getChannel(channelID) if err != nil { @@ -927,9 +1256,17 @@ func (r *Runner) ReadChannelRaw(channelID int) ([]byte, error) { // (from KeyGenerate), not raw bytes. // // Returns base64-encoded ciphertext ready for registerE2EEGroupKey. -func (r *Runner) ChannelWrapGroupSharedKey(channelID int, keyID int) (string, error) { +func (r *Runner) ChannelWrapGroupSharedKey(channelID int, keyID int) (out0 string, err error) { r.mu.Lock() defer r.mu.Unlock() + if r.failure != nil { + return "", r.failure + } + defer r.finishOperation("ChannelWrapGroupSharedKey", &err, true, func() { out0 = "" }) + return r.channelWrapGroupSharedKey(channelID, keyID) +} + +func (r *Runner) channelWrapGroupSharedKey(channelID int, keyID int) (string, error) { chanPtr, err := r.getChannel(channelID) if err != nil { @@ -942,8 +1279,6 @@ func (r *Runner) ChannelWrapGroupSharedKey(channelID int, keyID int) (string, er } // Use the WASM module's wrapGroupSharedKey directly (same as LINE Extension). - // keyPtr is an emval handle wrapping the Curve25519Key C++ object. - // The C++ method expects an emscripten::val, which the emval handle represents. wrapped, err := r.rt.E2EEChannelWrapGroupSharedKey(chanPtr, keyPtr) if err != nil { return "", err From 9a9abc6e7bba8eff0864c017bcf5705b15618e9b Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Mon, 5 Oct 2026 17:51:17 +0000 Subject: [PATCH 2/7] Require upstream cancellation-aware login admission --- go.mod | 22 +++++++++--------- go.sum | 72 ++++++++++++++++++++-------------------------------------- 2 files changed, 35 insertions(+), 59 deletions(-) diff --git a/go.mod b/go.mod index a2dbd3b..eac36a7 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,13 @@ module github.com/highesttt/matrix-line-messenger go 1.26.0 require ( + github.com/mattn/go-sqlite3 v1.14.52 github.com/rs/zerolog v1.35.1 github.com/u2takey/ffmpeg-go v0.5.0 - go.mau.fi/util v0.9.12-0.20260719092501-f9c03d846391 - golang.org/x/crypto v0.54.0 + go.mau.fi/util v0.10.2-0.20260918225449-a4c0d5b86aa8 + golang.org/x/crypto v0.57.0 golang.org/x/image v0.23.0 - maunium.net/go/mautrix v0.29.1-0.20260814120312-a6614a03769b + maunium.net/go/mautrix v0.31.1-0.20261005174338-57e1ea5dd74d ) require ( @@ -25,8 +26,7 @@ require ( github.com/lib/pq v1.12.3 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-sqlite3 v1.14.48 // indirect - github.com/petermattis/goid v0.0.0-20260713124913-97594f28f5ca // indirect + github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261 // indirect github.com/rs/xid v1.6.0 // indirect github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect github.com/tidwall/gjson v1.19.0 // indirect @@ -34,13 +34,13 @@ require ( github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect github.com/u2takey/go-utils v0.3.1 // indirect - github.com/yuin/goldmark v1.8.4 // indirect + github.com/yuin/goldmark v1.8.6 // indirect go.mau.fi/zeroconfig v0.2.0 // indirect - golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597 // indirect - golang.org/x/net v0.57.0 // indirect - golang.org/x/sync v0.22.0 // indirect - golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.40.0 // indirect + golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba // indirect + golang.org/x/net v0.59.0 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect maunium.net/go/mauflag v1.0.0 // indirect diff --git a/go.sum b/go.sum index 9113c9d..b04f48f 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,6 @@ github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7Oputl github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/aws/aws-sdk-go v1.38.20 h1:QbzNx/tdfATbdKfubBpkt84OM6oBkxQZRw6+bW2GyeA= github.com/aws/aws-sdk-go v1.38.20/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= -github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= -github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA= @@ -36,10 +34,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= -github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= -github.com/mattn/go-sqlite3 v1.14.48 h1:7XHIgl0a8HwOaiK4E47ozLkST78rR9+OtNGx27D/TFs= -github.com/mattn/go-sqlite3 v1.14.48/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= +github.com/mattn/go-sqlite3 v1.14.52 h1:wVbm2Qnf4OXkqhBTSPuCRZDRnxfbVrrmiCEroVdog8U= +github.com/mattn/go-sqlite3 v1.14.52/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= @@ -47,10 +43,8 @@ github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3Rllmb github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/panjf2000/ants/v2 v2.4.2/go.mod h1:f6F0NZVFsGCp5A7QW/Zj/m92atWwOkY0OIhFxRNFr4A= -github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM= -github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= -github.com/petermattis/goid v0.0.0-20260713124913-97594f28f5ca h1:GHSUVE4yOgX4E7kTRzpxCPbCOYkd3Kj8Dgdod30OI1E= -github.com/petermattis/goid v0.0.0-20260713124913-97594f28f5ca/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261 h1:lcWAnrqr2nNfDiArwFNHCE4787Mw2tCdVSOXCru0/0E= +github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -66,8 +60,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI= +github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= @@ -82,56 +76,40 @@ github.com/u2takey/ffmpeg-go v0.5.0 h1:r7d86XuL7uLWJ5mzSeQ03uvjfIhiJYvsRAJFCW4uk github.com/u2takey/ffmpeg-go v0.5.0/go.mod h1:ruZWkvC1FEiUNjmROowOAps3ZcWxEiOpFoHCvk97kGc= github.com/u2takey/go-utils v0.3.1 h1:TaQTgmEZZeDHQFYfd+AdUT1cT4QJgJn/XVPELhHw4ys= github.com/u2takey/go-utils v0.3.1/go.mod h1:6e+v5vEZ/6gu12w/DC2ixZdZtCrNokVxD0JUklcqdCs= -github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= -github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -github.com/yuin/goldmark v1.8.4 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA= -github.com/yuin/goldmark v1.8.4/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -go.mau.fi/util v0.9.9 h1:ujDeXCo07HBor5oQLyO1tHklupmqVmPgasc53d7q/NE= -go.mau.fi/util v0.9.9/go.mod h1:pqt4Vcrt+5gcH/CgrHZg11qSx+b34o6mknGzOEA6waY= -go.mau.fi/util v0.9.12-0.20260719092501-f9c03d846391 h1:lsvBEY8MJfYdV61YbwikiQvb0Al/onbmLW5wfl/0tag= -go.mau.fi/util v0.9.12-0.20260719092501-f9c03d846391/go.mod h1:xunp/oIQfFD68HHcNHfG0pOiHkvEtDhTweeIwKJ//+Q= +github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4= +github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +go.mau.fi/util v0.10.2-0.20260918225449-a4c0d5b86aa8 h1:PxaO4u8ti7Eef1WKPAINXrcUkqKshE+n239UtbbJOn4= +go.mau.fi/util v0.10.2-0.20260918225449-a4c0d5b86aa8/go.mod h1:40TDo7/ekSeOjgr8KAmX31Yf4zrOF94j83WQB+u5ZPc= go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU= go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w= gocv.io/x/gocv v0.25.0/go.mod h1:Rar2PS6DV+T4FL+PM535EImD/h13hGVaHhnCu1xarBs= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= -golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw= -golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= -golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597 h1:qLvzZeaANDgyVOA8pyHCOStGlXn0rseXma+GQjeuv2g= -golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+aiyBQyDSMae6e/xmpU= +golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba/go.mod h1:50RgIsmK7OwqzTTeqcSXQW8SswW0o8fRcDxmqGluJ8E= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= -golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= -golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= +golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -148,8 +126,6 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M= maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA= -maunium.net/go/mautrix v0.28.0 h1:vBakLzf8MAdfED3NzAKiMeKQbc3AQ4EAS03NC+TVMXQ= -maunium.net/go/mautrix v0.28.0/go.mod h1:/a9A7LGaqb9B3nho4tLd28n0EPcCdwpm2dxkxkLLgh0= -maunium.net/go/mautrix v0.29.1-0.20260814120312-a6614a03769b h1:DKy6Vnhoyxq0TY30k9J8mGuaV3/qBgpEnb17V0Ouv9w= -maunium.net/go/mautrix v0.29.1-0.20260814120312-a6614a03769b/go.mod h1:PeLuIih5jnbwb2xKNNi0Te7AtINEQE2Uv3p6iG3RV80= +maunium.net/go/mautrix v0.31.1-0.20261005174338-57e1ea5dd74d h1:sOdF5AF9MiU2mudfMBgQC79FJrhBBDbUkxQCi0T2uHI= +maunium.net/go/mautrix v0.31.1-0.20261005174338-57e1ea5dd74d/go.mod h1:t97J+6KmbmeoepWOunBgpu1xIvuBpI36ebBqIbYT8Jk= sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= From c21bb560e3771d2ff8a7221fbac95063536bdfeb Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Mon, 5 Oct 2026 18:15:42 +0000 Subject: [PATCH 3/7] Do not treat retired crypto owners as missing login keys --- pkg/connector/client.go | 2 +- pkg/connector/handle_message.go | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/pkg/connector/client.go b/pkg/connector/client.go index dea2548..8514111 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -423,7 +423,7 @@ func (lc *LineClient) saveSessionInvalidated(ctx context.Context) { } func (lc *LineClient) markMissingE2EEKey(ctx context.Context, err error) { - if !errors.Is(err, e2ee.ErrMissingOwnPrivateKey) || lc.UserLogin == nil { + if isTerminalCryptoError(err) || !errors.Is(err, e2ee.ErrMissingOwnPrivateKey) || lc.UserLogin == nil { return } lc.missingE2EEKeyMu.Lock() diff --git a/pkg/connector/handle_message.go b/pkg/connector/handle_message.go index cb190c7..3c66a58 100644 --- a/pkg/connector/handle_message.go +++ b/pkg/connector/handle_message.go @@ -3,7 +3,6 @@ package connector import ( "context" "encoding/json" - "errors" "fmt" "html" "sort" @@ -21,7 +20,6 @@ import ( "github.com/highesttt/matrix-line-messenger/pkg/connector/handlers" "github.com/highesttt/matrix-line-messenger/pkg/e2ee" "github.com/highesttt/matrix-line-messenger/pkg/line" - "github.com/highesttt/matrix-line-messenger/pkg/ltsm" ) const ( @@ -283,7 +281,7 @@ func (lc *LineClient) decryptMessageBody(msg *line.Message, portalIDStr string, } else { groupDecryptLogContext(lc.UserLogin.Bridge.Log.Debug().Err(err), msg, portalIDStr, opType). Msg("DecryptGroupMessage failed") - if !errors.Is(err, ltsm.ErrAbort) && keyID != 0 { + if !isTerminalCryptoError(err) && keyID != 0 { if errFetch := lc.fetchAndUnwrapGroupKey(context.Background(), portalIDStr, keyID); errFetch != nil { groupDecryptLogContext(lc.UserLogin.Bridge.Log.Warn().Err(errFetch), msg, portalIDStr, opType). Msg("Failed to fetch/unwrap group key") @@ -305,13 +303,16 @@ func (lc *LineClient) decryptMessageBody(msg *line.Message, portalIDStr string, } else { directDecryptLogContext(lc.UserLogin.Bridge.Log.Debug().Err(err), msg, portalIDStr, opType). Msg("DecryptMessageV2 failed on first attempt") - if errors.Is(err, ltsm.ErrAbort) { + if isTerminalCryptoError(err) { directDecryptLogContext(lc.UserLogin.Bridge.Log.Warn().Err(err), msg, portalIDStr, opType). - Msg("LTSM runtime aborted; skipping key refresh") + Msg("Crypto owner unavailable; skipping key refresh") } else if _, _, errKey := lc.E2EE.MyKeyIDs(); errKey != nil { + if isTerminalCryptoError(errKey) { + return "", "", true + } directDecryptLogContext(lc.UserLogin.Bridge.Log.Error().Err(errKey), msg, portalIDStr, opType). Msg("E2EE own key not loaded; cannot decrypt any messages. Re-login required") - lc.markMissingE2EEKey(context.Background(), fmt.Errorf("%w: %v", e2ee.ErrMissingOwnPrivateKey, errKey)) + lc.markMissingE2EEKey(context.Background(), fmt.Errorf("%w: %w", e2ee.ErrMissingOwnPrivateKey, errKey)) } else { peerMid := msg.From peerKeyID := 0 From 0a69552e2f68221b67e58a31b398db31a19d96e4 Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Mon, 5 Oct 2026 18:23:41 +0000 Subject: [PATCH 4/7] Preserve reusable login keys after ordinary reconnect export failures Keep the established manual reconnect fallback for a verified same account with stored keys. Cancellation, deadlines, terminal crypto errors, forced full verification and account mismatches still reject completion. --- pkg/connector/connector.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 35502e3..a3535de 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -703,20 +703,27 @@ func (ll *LineEmailLogin) finishLogin(ctx context.Context, res *line.LoginResult } 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 loginManager, err := ll.fetchLoginKeys(res, meta, client) if err != nil { - return nil, err + if isTerminalCryptoError(err) || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || !sameAccount || !shouldPreserveExistingE2EEKeys(false, ll.ExistingMetadata) { + return nil, err + } + if err := ll.admitLogin(ctx, res); err != nil { + return nil, err + } + ll.User.Bridge.Log.Warn().Err(err).Msg("Login: failed to export E2EE keys; using existing keys") } exportedKeys := loginManager != nil if loginManager != nil { defer loginManager.Close() } - if ll.ExistingMetadata != nil && ll.ExistingLogin != nil && mid == string(ll.ExistingLogin.ID) && mid == ll.ExistingMetadata.Mid && shouldPreserveExistingE2EEKeys(exportedKeys, ll.ExistingMetadata) { + if sameAccount && shouldPreserveExistingE2EEKeys(exportedKeys, ll.ExistingMetadata) { copyLoginE2EEKeyMetadata(meta, ll.ExistingMetadata) ll.User.Bridge.Log.Info().Int("keys", len(meta.ExportedKeyMap)).Msg("Preserved existing E2EE keys after re-login") } - if exportedKeys && ll.ExistingMetadata != nil && ll.ExistingLogin != nil && mid == string(ll.ExistingLogin.ID) && mid == ll.ExistingMetadata.Mid { + if exportedKeys && sameAccount { meta.ExportedKeyMap = mergeLoginKeyMaps(ll.ExistingMetadata.ExportedKeyMap, meta.ExportedKeyMap) } if !res.NoE2EE && len(meta.ExportedKeyMap) == 0 { From 608a5b6536899482f31def9861e8a0716435fd6c Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Mon, 5 Oct 2026 18:31:35 +0000 Subject: [PATCH 5/7] Keep recovered tokens when reusable E2EE keys survive ordinary refresh errors Preserve staged token publication for the verified same account when existing key metadata is reusable and the active owner remains usable. Failed fresh-key validation does not publish partial key metadata. Cancellation, retirement, forced verification and terminal crypto failures still stop recovery. --- pkg/connector/client.go | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 8514111..3fc090c 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -798,25 +798,35 @@ func (lc *LineClient) tryLogin(ctx context.Context) error { if res.Certificate != "" { staged.Certificate = res.Certificate } - mgr, exported, err := exportLoginE2EEKeys(res, newLineAPIClient(accessToken)) - if err != nil { - return fmt.Errorf("refresh re-login E2EE keys: %w", err) - } + mgr, exported, keyErr := exportLoginE2EEKeys(res, newLineAPIClient(accessToken)) if mgr != nil { defer mgr.Close() if res.Mid != "" && res.Mid == string(lc.UserLogin.ID) { exported = mergeLoginKeyMaps(meta.ExportedKeyMap, exported) } if err := mgr.LoadMyKeyFromExportedMap(exported); err != nil { - return fmt.Errorf("validate recovered E2EE keys: %w", err) + keyErr = fmt.Errorf("validate recovered E2EE keys: %w", err) + } else { + applyExportedLoginE2EEKeys(&staged, res, exported) } - applyExportedLoginE2EEKeys(&staged, res, exported) + } + if keyErr != nil { + if isTerminalCryptoError(keyErr) || errors.Is(keyErr, context.Canceled) || errors.Is(keyErr, context.DeadlineExceeded) || res.Mid != string(lc.UserLogin.ID) || res.Mid != meta.Mid || !shouldPreserveExistingE2EEKeys(false, meta) || lc.E2EE == nil { + return fmt.Errorf("refresh re-login E2EE keys: %w", keyErr) + } + mgr = nil + lc.UserLogin.Bridge.Log.Warn().Err(keyErr).Msg("Re-login: failed to refresh E2EE keys; using existing keys") } lc.runMu.Lock() defer lc.runMu.Unlock() if err := lc.admitRecoveredLogin(ctx, res); err != nil { return err } + if keyErr != nil { + if _, _, err := lc.E2EE.MyKeyIDs(); err != nil { + return fmt.Errorf("preserve active E2EE keys: %w", err) + } + } if mgr != nil && lc.E2EE != nil { if err := lc.E2EE.LoadMyKeyFromExportedMap(exported); err != nil { return fmt.Errorf("load recovered E2EE keys: %w", err) From 8a13ff0b8823cdd247789d72cdef7f0fdaf3c1a2 Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Mon, 5 Oct 2026 20:40:27 +0000 Subject: [PATCH 6/7] Keep registration generation and wrapping on a disposable crypto heap (#254) --- pkg/connector/creategroup.go | 31 +++++++++++++++++++++++++ pkg/e2ee/manager.go | 27 ++++++++++++++++++++++ pkg/ltsm/runtime.go | 44 ++++++++++++++++++++++++++++-------- pkg/runner.go | 27 +++++++++++++++++++++- 4 files changed, 119 insertions(+), 10 deletions(-) diff --git a/pkg/connector/creategroup.go b/pkg/connector/creategroup.go index 01c544c..e8a3bd8 100644 --- a/pkg/connector/creategroup.go +++ b/pkg/connector/creategroup.go @@ -140,7 +140,38 @@ type groupKeyReleaser interface { ReleaseGroupKey(int) error } +type groupKeyBatchCrypto interface { + WrapRegistrationGroupKey([]string) ([]string, error) +} + func wrapRegistrationGroupKey(ctx context.Context, crypto groupKeyCrypto, pubKeys map[string]line.E2EEPeerPublicKey) (apiMembers []string, keyIDs []int, encryptedKeys []string, err error) { + if err := ctx.Err(); err != nil { + return nil, nil, nil, err + } + if batch, ok := crypto.(groupKeyBatchCrypto); ok { + apiMembers = make([]string, 0, len(pubKeys)) + keyIDs = make([]int, 0, len(pubKeys)) + memberPubKeys := make([]string, 0, len(pubKeys)) + for mid, pk := range pubKeys { + apiMembers = append(apiMembers, mid) + keyIDs = append(keyIDs, pk.KeyID) + memberPubKeys = append(memberPubKeys, pk.KeyData) + } + if err := ctx.Err(); err != nil { + return nil, nil, nil, err + } + encryptedKeys, err = batch.WrapRegistrationGroupKey(memberPubKeys) + if err != nil { + return nil, nil, nil, fmt.Errorf("wrap group key for members: %w", err) + } + if err := ctx.Err(); err != nil { + return nil, nil, nil, err + } + if len(encryptedKeys) != len(apiMembers) { + return nil, nil, nil, fmt.Errorf("wrapped group key count does not match members") + } + return apiMembers, keyIDs, encryptedKeys, nil + } groupKeyID, err := crypto.GenerateGroupKey() if err != nil { return nil, nil, nil, fmt.Errorf("failed to generate group key: %w", err) diff --git a/pkg/e2ee/manager.go b/pkg/e2ee/manager.go index 0d1c251..b17eda6 100644 --- a/pkg/e2ee/manager.go +++ b/pkg/e2ee/manager.go @@ -650,6 +650,33 @@ func (m *Manager) GenerateGroupKey() (int, error) { return m.runner.RegistrationKeyGenerate() } +func (m *Manager) WrapRegistrationGroupKey(memberPubKeys []string) ([]string, error) { + m.mu.Lock() + defer m.mu.Unlock() + if err := m.errLocked(); err != nil { + return nil, err + } + if m.myKeyID == 0 { + return nil, fmt.Errorf("my E2EE key not loaded") + } + channelIDs := make([]int, len(memberPubKeys)) + for i, memberPubKey := range memberPubKeys { + normalized, err := gen.NormalizePeerPublicKeyB64(memberPubKey) + if err != nil { + return nil, fmt.Errorf("invalid member public key: %w", err) + } + channelIDs[i], err = m.runner.ChannelCreate(m.myKeyID, normalized) + if err != nil { + return nil, fmt.Errorf("failed to create ECDH channel: %w", err) + } + } + wrapped, err := m.runner.WrapRegistrationGroupKey(channelIDs) + if err != nil { + return nil, fmt.Errorf("failed to wrap group key: %w", err) + } + return wrapped, nil +} + func (m *Manager) ReleaseGroupKey(keyID int) error { m.mu.Lock() defer m.mu.Unlock() diff --git a/pkg/ltsm/runtime.go b/pkg/ltsm/runtime.go index a0be4f6..114d602 100644 --- a/pkg/ltsm/runtime.go +++ b/pkg/ltsm/runtime.go @@ -379,17 +379,43 @@ func (rt *Runtime) E2EEChannelUnwrapGroupSharedKey(chanPtr uint32, encKey []byte return ptr, nil } -func (rt *Runtime) E2EEChannelWrapGroupSharedKey(chanPtr uint32, keyHandle uint32) ([]byte, error) { - handle, err := rt.imp.CallMethod("E2EEChannel", "wrapGroupSharedKey", chanPtr, keyHandle) - if err != nil { - return nil, fmt.Errorf("ltsm: E2EEChannel.wrapGroupSharedKey failed: %w", err) - } - data, err := rt.imp.ReadEmvalBytes(handle) +func (rt *Runtime) WrapRegistrationGroupKey(chanPtrs []uint32) ([][]byte, error) { + var wrapped [][]byte + defer func() { rt.cryptoDirty = true }() + _, err := rt.channelCrypto(func(crypto *Runtime) ([]byte, error) { + keyPtr, err := crypto.Curve25519KeyGenerate() + if err != nil { + return nil, err + } + wrapped = make([][]byte, len(chanPtrs)) + for i, chanPtr := range chanPtrs { + data, err := crypto.E2EEChannelWrapGroupSharedKey(chanPtr, keyPtr) + if err != nil { + return nil, err + } + wrapped[i] = append([]byte(nil), data...) + } + return nil, nil + }) if err != nil { - return nil, fmt.Errorf("ltsm: failed to read wrapGroupSharedKey result: %w", err) + return nil, err } - rt.imp.emval.DecRef(handle) - return data, nil + return wrapped, nil +} + +func (rt *Runtime) E2EEChannelWrapGroupSharedKey(chanPtr uint32, keyPtr uint32) ([]byte, error) { + return rt.channelCrypto(func(crypto *Runtime) ([]byte, error) { + handle, err := crypto.imp.CallMethod("E2EEChannel", "wrapGroupSharedKey", chanPtr, keyPtr) + if err != nil { + return nil, fmt.Errorf("ltsm: E2EEChannel.wrapGroupSharedKey failed: %w", err) + } + data, err := crypto.imp.ReadEmvalBytes(handle) + if err != nil { + return nil, fmt.Errorf("ltsm: failed to read wrapGroupSharedKey result: %w", err) + } + crypto.imp.emval.DecRef(handle) + return data, nil + }) } func (rt *Runtime) E2EEChannelGenerateConfirmHash(chanPtr uint32, encKeyChain []byte) ([]byte, error) { diff --git a/pkg/runner.go b/pkg/runner.go index 7907480..e633d37 100644 --- a/pkg/runner.go +++ b/pkg/runner.go @@ -1262,10 +1262,35 @@ func (r *Runner) ChannelWrapGroupSharedKey(channelID int, keyID int) (out0 strin if r.failure != nil { return "", r.failure } - defer r.finishOperation("ChannelWrapGroupSharedKey", &err, true, func() { out0 = "" }) + defer r.finishOperation("ChannelWrapGroupSharedKey", &err, false, func() { out0 = "" }) return r.channelWrapGroupSharedKey(channelID, keyID) } +func (r *Runner) WrapRegistrationGroupKey(channelIDs []int) (out0 []string, err error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.failure != nil { + return nil, r.failure + } + defer r.finishOperation("WrapRegistrationGroupKey", &err, false, func() { out0 = nil }) + chanPtrs := make([]uint32, len(channelIDs)) + for i, channelID := range channelIDs { + chanPtrs[i], err = r.getChannel(channelID) + if err != nil { + return nil, err + } + } + wrapped, err := r.rt.WrapRegistrationGroupKey(chanPtrs) + if err != nil { + return nil, err + } + out0 = make([]string, len(wrapped)) + for i, data := range wrapped { + out0[i] = base64.StdEncoding.EncodeToString(data) + } + return out0, nil +} + func (r *Runner) channelWrapGroupSharedKey(channelID int, keyID int) (string, error) { chanPtr, err := r.getChannel(channelID) From 9377b1af6a7a7bbdd95b7c3e64fcf6c83853d704 Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Mon, 5 Oct 2026 21:47:42 +0000 Subject: [PATCH 7/7] Preserve LINE recovery across request timeouts and cache failures Distinguish individual HTTP timeouts from expired caller or login-attempt contexts. Allow stored-key fallback during startup before an E2EE manager exists. Reset missing-key notification state only after fresh keys, database metadata and live tokens are published successfully. Keep post-publication secure-file failures warning-only. --- pkg/connector/client.go | 13 +++++++++---- pkg/connector/connector.go | 2 +- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 3fc090c..92669f0 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -485,7 +485,7 @@ func (lc *LineClient) recoverTokenWith( return nil } else if ctx.Err() != nil { return ctx.Err() - } else if lc.isLoggedOut(err) || errors.Is(err, errLineSessionInvalidated) || errors.Is(err, errLineClientSuperseded) || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + } else if lc.isLoggedOut(err) || errors.Is(err, errLineSessionInvalidated) || errors.Is(err, errLineClientSuperseded) || errors.Is(err, context.Canceled) { return err } lc.UserLogin.Bridge.Log.Info().Msg("Refresh failed, attempting re-login with stored credentials...") @@ -811,7 +811,7 @@ func (lc *LineClient) tryLogin(ctx context.Context) error { } } if keyErr != nil { - if isTerminalCryptoError(keyErr) || errors.Is(keyErr, context.Canceled) || errors.Is(keyErr, context.DeadlineExceeded) || res.Mid != string(lc.UserLogin.ID) || res.Mid != meta.Mid || !shouldPreserveExistingE2EEKeys(false, meta) || lc.E2EE == nil { + if isTerminalCryptoError(keyErr) || errors.Is(keyErr, context.Canceled) || res.Mid != string(lc.UserLogin.ID) || res.Mid != meta.Mid || !shouldPreserveExistingE2EEKeys(false, meta) { return fmt.Errorf("refresh re-login E2EE keys: %w", keyErr) } mgr = nil @@ -822,7 +822,7 @@ func (lc *LineClient) tryLogin(ctx context.Context) error { if err := lc.admitRecoveredLogin(ctx, res); err != nil { return err } - if keyErr != nil { + if keyErr != nil && lc.E2EE != nil { if _, _, err := lc.E2EE.MyKeyIDs(); err != nil { return fmt.Errorf("preserve active E2EE keys: %w", err) } @@ -835,6 +835,8 @@ func (lc *LineClient) tryLogin(ctx context.Context) error { return err } } + lc.missingE2EEKeyMu.Lock() + defer lc.missingE2EEKeyMu.Unlock() previous := *meta *meta = staged if err := lc.UserLogin.Save(ctx); err != nil { @@ -844,9 +846,12 @@ func (lc *LineClient) tryLogin(ctx context.Context) error { lc.setTokens(accessToken, refreshToken) lc.Mid = staged.Mid line.InvalidateOBSTokenCache() + if mgr != nil { + lc.missingE2EEKeyMarked = false + } if mgr != nil { if err := mgr.SaveSecureDataToFile(loginSecureDataID(&staged, string(lc.UserLogin.ID)), map[string]any{"exportedKeyMap": exported}); err != nil { - return fmt.Errorf("save recovered E2EE secure data: %w", err) + lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Failed to save E2EE secure data after re-login") } } diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index a3535de..8c7d009 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -707,7 +707,7 @@ func (ll *LineEmailLogin) finishLogin(ctx context.Context, res *line.LoginResult loginManager, err := ll.fetchLoginKeys(res, meta, client) if err != nil { - if isTerminalCryptoError(err) || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || !sameAccount || !shouldPreserveExistingE2EEKeys(false, ll.ExistingMetadata) { + if isTerminalCryptoError(err) || errors.Is(err, context.Canceled) || !sameAccount || !shouldPreserveExistingE2EEKeys(false, ll.ExistingMetadata) { return nil, err } if err := ll.admitLogin(ctx, res); err != nil {