From df576dffa6544f91d00e0fef235925e465f1d270 Mon Sep 17 00:00:00 2001 From: highesttt Date: Tue, 30 Jun 2026 09:13:24 -0500 Subject: [PATCH 1/2] fix: bridge state is now set to badcredentials when a user gets logged out from another instance --- pkg/connector/auth_recovery.go | 15 +- pkg/connector/auth_recovery_test.go | 15 +- pkg/connector/client.go | 96 +++++- pkg/connector/connector.go | 55 +++- pkg/connector/creategroup.go | 8 +- pkg/connector/e2ee_keys.go | 4 +- pkg/connector/forced_logout_test.go | 85 ++++++ pkg/connector/handle_message.go | 1 + pkg/connector/handlers/handler.go | 9 +- pkg/connector/reaction_test.go | 410 ++++++++++++++++++++++++++ pkg/connector/sync.go | 38 ++- pkg/connector/userinfo.go | 12 +- pkg/line/errors.go | 13 +- pkg/line/errors_test.go | 3 + pkg/line/reaction_test.go | 140 +++++++++ pkg/ltsm/crypto_test.go | 196 +++++++++++++ pkg/ltsm/embind_debug_test.go | 54 ++++ pkg/ltsm/embind_test.go | 438 ++++++++++++++++++++++++++++ pkg/ltsm/mix_test.go | 20 ++ pkg/ltsm/vector_test.go | 378 ++++++++++++++++++++++++ pkg/ltsm/wbc_test.go | 90 ++++++ pkg/runner_secret_test.go | 171 +++++++++++ pkg/runner_vectors_replay_test.go | 14 + 23 files changed, 2213 insertions(+), 52 deletions(-) create mode 100644 pkg/connector/forced_logout_test.go create mode 100644 pkg/connector/reaction_test.go create mode 100644 pkg/line/reaction_test.go create mode 100644 pkg/ltsm/crypto_test.go create mode 100644 pkg/ltsm/embind_debug_test.go create mode 100644 pkg/ltsm/embind_test.go create mode 100644 pkg/ltsm/mix_test.go create mode 100644 pkg/ltsm/vector_test.go create mode 100644 pkg/ltsm/wbc_test.go create mode 100644 pkg/runner_secret_test.go create mode 100644 pkg/runner_vectors_replay_test.go diff --git a/pkg/connector/auth_recovery.go b/pkg/connector/auth_recovery.go index 143cf8f..9277ea5 100644 --- a/pkg/connector/auth_recovery.go +++ b/pkg/connector/auth_recovery.go @@ -37,6 +37,12 @@ func (lc *LineClient) isTokenError(err error) bool { if line.IsNoUsableE2EEGroupKey(err) || line.IsNoUsableE2EEPublicKey(err) { return false } + if lc.isSessionInvalidated() { + return false + } + if line.IsLoggedOut(err) { + return false + } return line.IsAuthError(err) } @@ -53,6 +59,9 @@ func (lc *LineClient) callLineUsing(ctx context.Context, client *line.Client, ca return struct{}{}, call(client) }, }) + if lc.isLoggedOut(err) { + lc.markLoggedOutByOtherClient(ctx, err) + } return client, err } @@ -61,10 +70,14 @@ func callLineResult[T any](lc *LineClient, ctx context.Context, call func(*line. } func callLineResultUsing[T any](lc *LineClient, ctx context.Context, client *line.Client, call func(*line.Client) (T, error)) (*line.Client, T, error) { - return callLineWithRecovery(ctx, client, lineCallDeps[T]{ + client, res, err := callLineWithRecovery(ctx, client, lineCallDeps[T]{ newClient: func() *line.Client { return lc.newClient() }, recover: lc.recoverToken, isAuthError: lc.isTokenError, call: call, }) + if lc.isLoggedOut(err) { + lc.markLoggedOutByOtherClient(ctx, err) + } + return client, res, err } diff --git a/pkg/connector/auth_recovery_test.go b/pkg/connector/auth_recovery_test.go index 029b4e3..80f8751 100644 --- a/pkg/connector/auth_recovery_test.go +++ b/pkg/connector/auth_recovery_test.go @@ -14,6 +14,8 @@ import ( var ( errAuthRequired = errors.New(`API error 400: {"code":10051,"message":"RESPONSE_ERROR","data":{"name":"TalkException","code":119,"reason":"Access token refresh required"}}`) + errLoggedOut = errors.New(`API error 400: {"code":10051,"message":"RESPONSE_ERROR","data":{"name":"TalkException","code":8,"reason":"V3_TOKEN_CLIENT_LOGGED_OUT"}}`) + errSenderKey = errors.New(`API error 400: {"code":10051,"message":"RESPONSE_ERROR","data":{"name":"TalkException","code":83,"reason":"invalid sender key"}}`) errNotMember = errors.New(`API error 400: {"code":10051,"data":{"name":"TalkException","code":10,"reason":"not a member"}}`) errNetwork = errors.New("request failed: dial tcp: i/o timeout") ) @@ -187,11 +189,22 @@ func TestCallLineWithRecoveryUsesProvidedClientWithoutRecreating(t *testing.T) { } } -func TestLineClientIsTokenErrorExcludesE2EEErrors(t *testing.T) { +func TestLineClientIsTokenErrorExcludesNonRecoverableErrors(t *testing.T) { lc := &LineClient{} if !lc.isTokenError(errAuthRequired) { t.Fatal("expected auth-required error to be classified as token error") } + if lc.isTokenError(errLoggedOut) { + t.Fatal("logged-out sessions must not trigger token recovery") + } + if lc.isTokenError(errSenderKey) { + t.Fatal("invalid sender key sessions must not trigger token recovery") + } + lc.sessionInvalidated = true + if lc.isTokenError(errAuthRequired) { + t.Fatal("invalidated sessions must not trigger token recovery") + } + lc.sessionInvalidated = false if lc.isTokenError(line.ErrNoUsableE2EEGroupKey) { t.Fatal("E2EE group key errors must not trigger token recovery") } diff --git a/pkg/connector/client.go b/pkg/connector/client.go index eca68d1..16c8abc 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -2,6 +2,7 @@ package connector import ( "context" + "errors" "fmt" "net/http" "strconv" @@ -17,6 +18,18 @@ import ( "github.com/highesttt/matrix-line-messenger/pkg/line" ) +var errLineSessionInvalidated = errors.New("LINE session invalidated by another client") + +var newLineAPIClient = line.NewClient + +var loginWithCredentials = func(email, password, certificate string) (*line.LoginResult, error) { + return newLineAPIClient("").Login(email, password, certificate) +} + +var getProfileWithToken = func(token string) (*line.Profile, error) { + return newLineAPIClient(token).GetProfile() +} + type LineClient struct { UserLogin *bridgev2.UserLogin AccessToken string @@ -33,6 +46,10 @@ type LineClient struct { tokenMu sync.RWMutex recoverMu sync.Mutex recoverTime time.Time + // sessionInvalidated is set when LINE forcefully logs out this Chrome-style + // session. It prevents background calls from re-logging in before the user + // clicks Reconnect. + sessionInvalidated bool // cacheMu protects peerKeys, blockedUsers, contactCache, mediaFlowCache, // noE2EEGroups, groupMemberCache, generatedGroupNameCache, and knownMemberChatMIDs. @@ -90,18 +107,34 @@ func (lc *LineClient) setTokens(accessToken, refreshToken string) (string, strin lc.tokenMu.Lock() defer lc.tokenMu.Unlock() lc.AccessToken = accessToken + if accessToken != "" { + lc.sessionInvalidated = false + } if refreshToken != "" { lc.RefreshToken = refreshToken } return lc.AccessToken, lc.RefreshToken } +func (lc *LineClient) invalidateAccessToken() { + lc.tokenMu.Lock() + lc.AccessToken = "" + lc.sessionInvalidated = true + lc.tokenMu.Unlock() +} + func (lc *LineClient) hasAccessToken() bool { return lc.getAccessToken() != "" } +func (lc *LineClient) isSessionInvalidated() bool { + lc.tokenMu.RLock() + defer lc.tokenMu.RUnlock() + return lc.sessionInvalidated +} + func (lc *LineClient) newClient() *line.Client { - return line.NewClient(lc.getAccessToken()) + return newLineAPIClient(lc.getAccessToken()) } func (lc *LineClient) avatarFromPicturePath(picturePath string) *bridgev2.Avatar { @@ -183,7 +216,7 @@ func (lc *LineClient) refreshAndSave(ctx context.Context) error { return fmt.Errorf("no refresh token available") } - client := line.NewClient(accessToken) + client := newLineAPIClient(accessToken) res, err := client.RefreshAccessToken(refreshToken) if err != nil { return fmt.Errorf("failed to refresh token: %w", err) @@ -216,6 +249,43 @@ func (lc *LineClient) isLoggedOut(err error) bool { return line.IsLoggedOut(err) } +func (lc *LineClient) shouldAttemptTokenRecovery(ctx context.Context, err error) bool { + if err == nil { + return false + } + if lc.isSessionInvalidated() { + return false + } + if lc.isLoggedOut(err) { + lc.markLoggedOutByOtherClient(ctx, err) + return false + } + return lc.isRefreshRequired(err) || line.IsUnauthorizedStatus(err) +} + +func (lc *LineClient) markLoggedOutByOtherClient(_ context.Context, err error) { + lc.invalidateAccessToken() + line.InvalidateOBSTokenCache() + if lc.UserLogin == nil { + return + } + if lc.UserLogin.Client != nil && lc.UserLogin.Client != lc { + if lc.UserLogin.Bridge != nil { + lc.UserLogin.Bridge.Log.Debug().Err(err).Msg("Ignoring forced logout from stale LINE client") + } + return + } + if lc.UserLogin.Bridge != nil { + lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("LINE session invalidated by another client; marking login bad credentials") + } + lc.UserLogin.BridgeState.Send(status.BridgeState{ + StateEvent: status.StateBadCredentials, + Error: "line-logged-out", + Message: "LINE logged this Chrome Extension session out because another LINE client connected. Click Reconnect in Beeper to reconnect LINE.", + UserAction: status.UserActionRelogin, + }) +} + // recoverToken attempts to restore a valid session by refreshing, then re-logging in. // Returns nil on success. On failure the caller should send StateBadCredentials. func (lc *LineClient) recoverToken(ctx context.Context) error { @@ -274,6 +344,10 @@ func (lc *LineClient) Connect(ctx context.Context) { } } if !lc.hasAccessToken() { + if lc.isSessionInvalidated() { + lc.markLoggedOutByOtherClient(ctx, errLineSessionInvalidated) + return + } if err := lc.tryLogin(ctx); err != nil { lc.UserLogin.BridgeState.Send(status.BridgeState{ StateEvent: status.StateBadCredentials, @@ -287,6 +361,10 @@ func (lc *LineClient) Connect(ctx context.Context) { // Verify the token is still valid before proceeding if err := lc.ensureValidToken(ctx); err != nil { + if lc.isLoggedOut(err) { + lc.markLoggedOutByOtherClient(ctx, err) + return + } lc.UserLogin.BridgeState.Send(status.BridgeState{ StateEvent: status.StateBadCredentials, Error: "line-token-expired", @@ -377,8 +455,7 @@ func (lc *LineClient) tryLogin(ctx context.Context) error { Str("email", email). Bool("has_certificate", certificate != ""). Msg("Attempting to login with email/password...") - client := line.NewClient("") - res, err := client.Login(email, password, certificate) + res, err := loginWithCredentials(email, password, certificate) if err != nil { return fmt.Errorf("login failed: %w", err) } @@ -406,7 +483,7 @@ func (lc *LineClient) tryLogin(ctx context.Context) error { } lc.UserLogin.Bridge.Log.Info().Msg("Waiting for PIN verification on mobile device...") - waitClient := line.NewClient("") + waitClient := newLineAPIClient("") waitRes, err := waitClient.WaitForLogin(res.Verifier, res.NoE2EE) if err != nil { return fmt.Errorf("PIN verification failed: %w", err) @@ -416,9 +493,8 @@ func (lc *LineClient) tryLogin(ctx context.Context) error { } // Replace res with the verified result res = waitRes - client = waitClient } - accessToken := client.AccessToken + accessToken := res.AuthToken refreshToken := "" if res.TokenV3IssueResult != nil { if res.TokenV3IssueResult.AccessToken != "" { @@ -458,15 +534,13 @@ func (lc *LineClient) tryLogin(ctx context.Context) error { } func (lc *LineClient) ensureValidToken(ctx context.Context) error { - client := lc.newClient() - _, err := client.GetProfile() + _, err := getProfileWithToken(lc.getAccessToken()) if err == nil { return nil } if lc.isLoggedOut(err) { - lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Session invalidated (logged out by another client), attempting recovery...") - return lc.recoverToken(ctx) + return err } if !lc.isRefreshRequired(err) { diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 1d953a8..ea9b28e 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -146,6 +146,7 @@ type LineEmailLogin struct { User *bridgev2.User Email string Password string + Certificate string Verifier string AwaitingPIN bool NoE2EE bool // True when login fell back to non-E2EE (LSOFF account) @@ -158,6 +159,7 @@ type LineEmailLogin struct { var _ bridgev2.LoginProcessUserInput = (*LineEmailLogin)(nil) var _ bridgev2.LoginProcessDisplayAndWait = (*LineEmailLogin)(nil) +var _ bridgev2.LoginProcessWithOverride = (*LineEmailLogin)(nil) func (ll *LineEmailLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) { return &bridgev2.LoginStep{ @@ -181,6 +183,32 @@ func (ll *LineEmailLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error }, nil } +func (ll *LineEmailLogin) StartWithOverride(ctx context.Context, override *bridgev2.UserLogin) (*bridgev2.LoginStep, error) { + meta, ok := override.Metadata.(*UserLoginMetadata) + if !ok { + return nil, fmt.Errorf("existing LINE login metadata has unexpected type %T", override.Metadata) + } + ll.Email = meta.Email + ll.Password = meta.Password + ll.Certificate = meta.Certificate + + if ll.Email == "" || ll.Password == "" { + return ll.loginErrorStep("No stored LINE credentials are available. Please enter your LINE email and password to reconnect."), nil + } + + override.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnecting}) + + res, err := loginWithCredentials(ll.Email, ll.Password, ll.Certificate) + if err != nil { + reason := loginErrorReason(err) + if reason == "" { + reason = fmt.Sprintf("Login failed: %v", err) + } + return ll.loginErrorStep(reason), nil + } + return ll.handleLoginResponse(ctx, res) +} + func (ll *LineEmailLogin) SubmitUserInput(ctx context.Context, input map[string]string) (*bridgev2.LoginStep, error) { if ll.Verifier != "" { return ll.Wait(ctx) @@ -189,14 +217,14 @@ func (ll *LineEmailLogin) SubmitUserInput(ctx context.Context, input map[string] if input["email"] != "" { ll.Email = input["email"] ll.Password = input["password"] + ll.Certificate = "" } if ll.Email == "" || ll.Password == "" { return ll.loginErrorStep("Email and password are required"), nil } - client := line.NewClient("") - res, err := client.Login(ll.Email, ll.Password, "") + res, err := loginWithCredentials(ll.Email, ll.Password, "") if err != nil { reason := loginErrorReason(err) if reason == "" { @@ -293,8 +321,7 @@ func (ll *LineEmailLogin) Wait(ctx context.Context) (*bridgev2.LoginStep, error) } if ll.AwaitingPIN { - client := line.NewClient("") - res, err := client.Login(ll.Email, ll.Password, "") + res, err := loginWithCredentials(ll.Email, ll.Password, ll.Certificate) if err != nil { return nil, fmt.Errorf("login failed: %w", err) } @@ -328,7 +355,7 @@ func (ll *LineEmailLogin) handleLoginResponse(ctx context.Context, res *line.Log ll.pollResult = make(chan *line.LoginResult, 1) ll.pollErr = make(chan error, 1) go func() { - client := line.NewClient("") + client := newLineAPIClient("") res, err := client.WaitForLogin(ll.Verifier, ll.NoE2EE) if err != nil { ll.pollErr <- err @@ -380,8 +407,8 @@ func (ll *LineEmailLogin) finishLogin(ctx context.Context, res *line.LoginResult return nil, fmt.Errorf("missing access token in login result") } - client := line.NewClient(token) - profile, err := client.GetProfile() + client := newLineAPIClient(token) + profile, err := getProfileWithToken(token) if err != nil { return nil, fmt.Errorf("failed to verify token: %w", err) } @@ -391,11 +418,20 @@ func (ll *LineEmailLogin) finishLogin(ctx context.Context, res *line.LoginResult displayName = "LINE User" } - meta := &UserLoginMetadata{AccessToken: token, RefreshToken: refreshToken, Email: ll.Email, Password: ll.Password, Certificate: res.Certificate, Mid: res.Mid} + certificate := res.Certificate + if certificate == "" { + certificate = ll.Certificate + } + mid := res.Mid + if mid == "" { + mid = profile.Mid + } + + meta := &UserLoginMetadata{AccessToken: token, RefreshToken: refreshToken, Email: ll.Email, Password: ll.Password, Certificate: certificate, Mid: mid} ll.fetchLoginKeys(res, meta, client) - detectedLineID := networkid.UserLoginID(profile.Mid) + detectedLineID := networkid.UserLoginID(mid) ul, err := ll.User.NewLogin(ctx, &database.UserLogin{ ID: detectedLineID, @@ -407,6 +443,7 @@ func (ll *LineEmailLogin) finishLogin(ctx context.Context, res *line.LoginResult UserLogin: login, AccessToken: token, RefreshToken: refreshToken, + Mid: mid, HTTPClient: &http.Client{Timeout: 10 * time.Second}, } return nil diff --git a/pkg/connector/creategroup.go b/pkg/connector/creategroup.go index 4e44ccb..d839335 100644 --- a/pkg/connector/creategroup.go +++ b/pkg/connector/creategroup.go @@ -32,7 +32,7 @@ func (lc *LineClient) CreateGroup(ctx context.Context, params *bridgev2.GroupCre chatType := 1 // ROOM: members join automatically. lineName := name chat, err = client.CreateChat(participantMids, lineName, chatType) - if err != nil && (lc.isRefreshRequired(err) || lc.isLoggedOut(err)) { + if err != nil && lc.shouldAttemptTokenRecovery(ctx, err) { if errRecover := lc.recoverToken(ctx); errRecover == nil { client = lc.newClient() chat, err = client.CreateChat(participantMids, lineName, chatType) @@ -161,7 +161,7 @@ func (lc *LineClient) registerGroupKey(ctx context.Context, chatMid string, memb } pubKeys, err := client.GetLastE2EEPublicKeys(pubKeysReq) if err != nil { - if lc.isRefreshRequired(err) || lc.isLoggedOut(err) { + if lc.shouldAttemptTokenRecovery(ctx, err) { if errRecover := lc.recoverToken(ctx); errRecover == nil { client = lc.newClient() pubKeys, err = client.GetLastE2EEPublicKeys(pubKeysReq) @@ -182,7 +182,7 @@ func (lc *LineClient) registerGroupKey(ctx context.Context, chatMid string, memb lc.UserLogin.Bridge.Log.Debug().Str("member", mid).Msg("Member has Letter Sealing disabled, skipping") continue } - if lc.isRefreshRequired(nErr) || lc.isLoggedOut(nErr) { + if lc.shouldAttemptTokenRecovery(ctx, nErr) { if errRecover := lc.recoverToken(ctx); errRecover == nil { client = lc.newClient() res, nErr = client.NegotiateE2EEPublicKey(mid) @@ -257,7 +257,7 @@ func (lc *LineClient) registerGroupKey(ctx context.Context, chatMid string, memb encryptedKeys = append(encryptedKeys, selfEncryptedKey) if err := client.RegisterE2EEGroupKey(1, chatMid, apiMembers, keyIds, encryptedKeys); err != nil { - if lc.isRefreshRequired(err) || lc.isLoggedOut(err) { + if lc.shouldAttemptTokenRecovery(ctx, err) { if errRecover := lc.recoverToken(ctx); errRecover == nil { client = lc.newClient() err = client.RegisterE2EEGroupKey(1, chatMid, apiMembers, keyIds, encryptedKeys) diff --git a/pkg/connector/e2ee_keys.go b/pkg/connector/e2ee_keys.go index 77fc43a..65c41c0 100644 --- a/pkg/connector/e2ee_keys.go +++ b/pkg/connector/e2ee_keys.go @@ -42,7 +42,7 @@ func (lc *LineClient) fetchAndUnwrapGroupKey(ctx context.Context, chatMid string sharedKey, err = fetch() } // Token recovery for other error types - if err != nil && !line.IsNoUsableE2EEGroupKey(err) && (lc.isRefreshRequired(err) || lc.isLoggedOut(err)) { + if err != nil && !line.IsNoUsableE2EEGroupKey(err) && lc.shouldAttemptTokenRecovery(ctx, err) { if errRecover := lc.recoverToken(ctx); errRecover == nil { client = lc.newClient() sharedKey, err = fetch() @@ -168,7 +168,7 @@ func (lc *LineClient) getChatMemberMIDs(ctx context.Context, chatMid string) ([] client := lc.newClient() chats, err := client.GetChats([]string{chatMid}, true, true) if err != nil { - if lc.isRefreshRequired(err) || lc.isLoggedOut(err) { + if lc.shouldAttemptTokenRecovery(ctx, err) { if errRecover := lc.recoverToken(ctx); errRecover == nil { client = lc.newClient() chats, err = client.GetChats([]string{chatMid}, true, true) diff --git a/pkg/connector/forced_logout_test.go b/pkg/connector/forced_logout_test.go new file mode 100644 index 0000000..c4720be --- /dev/null +++ b/pkg/connector/forced_logout_test.go @@ -0,0 +1,85 @@ +package connector + +import ( + "context" + "testing" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + + "github.com/highesttt/matrix-line-messenger/pkg/line" +) + +func TestEnsureValidTokenReturnsLoggedOutWithoutRelogin(t *testing.T) { + oldGetProfile := getProfileWithToken + oldLogin := loginWithCredentials + t.Cleanup(func() { + getProfileWithToken = oldGetProfile + loginWithCredentials = oldLogin + }) + + var profileCalls int + var loginCalls int + getProfileWithToken = func(token string) (*line.Profile, error) { + profileCalls++ + if token != "expired" { + t.Fatalf("profile token = %q, want expired", token) + } + return nil, errLoggedOut + } + loginWithCredentials = func(email, password, certificate string) (*line.LoginResult, error) { + loginCalls++ + return &line.LoginResult{AuthToken: "new-token"}, nil + } + + lc := &LineClient{AccessToken: "expired"} + err := lc.ensureValidToken(context.Background()) + if !line.IsLoggedOut(err) { + t.Fatalf("ensureValidToken error = %v, want logged-out error", err) + } + if profileCalls != 1 { + t.Fatalf("profile calls = %d, want 1", profileCalls) + } + if loginCalls != 0 { + t.Fatalf("login calls = %d, want 0", loginCalls) + } +} + +func TestStartWithOverrideUsesStoredCredentials(t *testing.T) { + oldLogin := loginWithCredentials + t.Cleanup(func() { + loginWithCredentials = oldLogin + }) + + var gotEmail, gotPassword, gotCertificate string + loginWithCredentials = func(email, password, certificate string) (*line.LoginResult, error) { + gotEmail = email + gotPassword = password + gotCertificate = certificate + return &line.LoginResult{Type: 3, Verifier: "verifier", Pin: "123456"}, nil + } + + override := &bridgev2.UserLogin{ + UserLogin: &database.UserLogin{ + Metadata: &UserLoginMetadata{ + Email: "stored@example.com", + Password: "stored-password", + Certificate: "stored-cert", + }, + }, + } + + step, err := (&LineEmailLogin{}).StartWithOverride(context.Background(), override) + if err != nil { + t.Fatalf("StartWithOverride returned error: %v", err) + } + if gotEmail != "stored@example.com" || gotPassword != "stored-password" || gotCertificate != "stored-cert" { + t.Fatalf("login called with email=%q password=%q certificate=%q", gotEmail, gotPassword, gotCertificate) + } + if step == nil || step.Type != bridgev2.LoginStepTypeDisplayAndWait { + t.Fatalf("step = %#v, want display-and-wait verification step", step) + } + if step.StepID != "dev.highest.matrix.line.wait_verification" { + t.Fatalf("step ID = %q, want wait verification", step.StepID) + } +} diff --git a/pkg/connector/handle_message.go b/pkg/connector/handle_message.go index c234f81..eefcb85 100644 --- a/pkg/connector/handle_message.go +++ b/pkg/connector/handle_message.go @@ -30,6 +30,7 @@ func (lc *LineClient) newMessageHandler() *handlers.Handler { RecoverToken: lc.recoverToken, IsRefreshRequired: lc.isRefreshRequired, IsLoggedOut: lc.isLoggedOut, + HandleLoggedOut: lc.markLoggedOutByOtherClient, NewClient: func() *line.Client { return lc.newClient() }, DecryptMedia: lc.decryptImageData, } diff --git a/pkg/connector/handlers/handler.go b/pkg/connector/handlers/handler.go index 88e78a0..79255e7 100644 --- a/pkg/connector/handlers/handler.go +++ b/pkg/connector/handlers/handler.go @@ -18,6 +18,7 @@ type Handler struct { RecoverToken func(ctx context.Context) error IsRefreshRequired func(err error) bool IsLoggedOut func(err error) bool + HandleLoggedOut func(ctx context.Context, err error) // NewClient creates a new LINE API client with the current access token. NewClient func() *line.Client @@ -32,7 +33,13 @@ func (h *Handler) tryRecoverClient(ctx context.Context, err error) (*line.Client if err == nil { return nil, false } - if !line.IsUnauthorizedStatus(err) && !h.IsRefreshRequired(err) && !h.IsLoggedOut(err) { + if h.IsLoggedOut(err) { + if h.HandleLoggedOut != nil { + h.HandleLoggedOut(ctx, err) + } + return nil, false + } + if !line.IsUnauthorizedStatus(err) && !h.IsRefreshRequired(err) { return nil, false } if errRecover := h.RecoverToken(ctx); errRecover != nil { diff --git a/pkg/connector/reaction_test.go b/pkg/connector/reaction_test.go new file mode 100644 index 0000000..bbeb0aa --- /dev/null +++ b/pkg/connector/reaction_test.go @@ -0,0 +1,410 @@ +package connector + +import ( + "errors" + "testing" + "time" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/event" +) + +func TestNormalizeMatrixReactionKey(t *testing.T) { + tests := map[string]string{ + "9\uFE0F\u20E3": "9", + "9\u20E3": "9", + "\u2764\uFE0F": "\u2764", + "\u2600\uFE0F": "\u2600", + "\u2615": "\u2615", + } + + for input, expected := range tests { + if actual := normalizeMatrixReactionKey(input); actual != expected { + t.Fatalf("normalizeMatrixReactionKey(%q) = %q, want %q", input, actual, expected) + } + } +} + +func TestNextReqSeqIsUniqueAndNonZeroAcrossWrap(t *testing.T) { + lc := &LineClient{ + lastReqSeq: maxLineReqSeq - 1, + } + + first := lc.nextReqSeq() + second := lc.nextReqSeq() + + if first <= 0 || second <= 0 { + t.Fatalf("reqSeqs = %d/%d, want non-zero positive values", first, second) + } + if first >= maxLineReqSeq || second >= maxLineReqSeq { + t.Fatalf("reqSeqs = %d/%d, want values below %d", first, second, maxLineReqSeq) + } + if first == second { + t.Fatalf("reqSeqs both = %d, want unique values", first) + } + if _, ok := lc.sentReqSeqs[first]; !ok { + t.Fatalf("first reqSeq %d was not tracked", first) + } + if _, ok := lc.sentReqSeqs[second]; !ok { + t.Fatalf("second reqSeq %d was not tracked", second) + } +} + +func TestTrackReqSeqCleansExpiredEntries(t *testing.T) { + now := time.Now() + lc := &LineClient{ + sentReqSeqs: map[int]time.Time{ + 1: now.Add(-sentReqSeqTTL - time.Second), + 2: now, + }, + } + + lc.trackReqSeq(3) + + if _, ok := lc.sentReqSeqs[1]; ok { + t.Fatal("expired reqSeq was not cleaned") + } + if _, ok := lc.sentReqSeqs[2]; !ok { + t.Fatal("fresh reqSeq was unexpectedly removed") + } + if _, ok := lc.sentReqSeqs[3]; !ok { + t.Fatal("new reqSeq was not tracked") + } +} + +func TestParseLineSticonURL(t *testing.T) { + ref, err := parseLineSticonURL("https://stickershop.line-scdn.net/sticonshop/v1/sticon/670e0cce840a8236ddd4ee4c/android/211.png?385d6a34-b435-4fd8-8428-6f73eef5f110") + if err != nil { + t.Fatal(err) + } + if ref.ProductID != lineOriginalEmojiProductID { + t.Fatalf("ProductID = %q, want %q", ref.ProductID, lineOriginalEmojiProductID) + } + if ref.EmojiID != "211" { + t.Fatalf("EmojiID = %q, want 211", ref.EmojiID) + } + if ref.ResourceType != 1 || ref.Version != 1 { + t.Fatalf("ResourceType/Version = %d/%d, want 1/1", ref.ResourceType, ref.Version) + } + + ref, err = parseLineSticonURL("https://stickershop.line-scdn.net/sticonshop/v1/sticon/670e0cce840a8236ddd4ee4c/android/211.png?v=2") + if err != nil { + t.Fatal(err) + } + if ref.Version != 2 { + t.Fatalf("Version = %d, want 2", ref.Version) + } +} + +func TestLinePaidReactionForMatrixEmoji(t *testing.T) { + ref, ok := linePaidReactionForMatrixEmoji("9\uFE0F\u20E3") + if !ok { + t.Fatal("expected keycap nine to be supported") + } + if ref.ProductID != lineOriginalEmojiProductID || ref.EmojiID != "211" { + t.Fatalf("reaction ref = %#v, want product %q emoji 211", ref, lineOriginalEmojiProductID) + } + + ref, ok = linePaidReactionForMatrixEmoji("\u2764\uFE0F") + if !ok { + t.Fatal("expected variation-selector heart to be supported") + } + if ref.EmojiID != "165" { + t.Fatalf("heart EmojiID = %q, want 165", ref.EmojiID) + } + + ref, ok = linePaidReactionForMatrixEmoji("\U0001F495") + if !ok { + t.Fatal("expected two hearts to be supported") + } + if ref.ProductID != lineTrialEmojiProductID || ref.EmojiID != "224" { + t.Fatalf("two hearts ref = %#v, want product %q emoji 224", ref, lineTrialEmojiProductID) + } + + ref, ok = linePaidReactionForMatrixEmoji("\U0001F496") + if !ok { + t.Fatal("expected sparkling heart to be supported") + } + if ref.ProductID != lineTrialEmojiProductID || ref.EmojiID != "225" { + t.Fatalf("sparkling heart ref = %#v, want product %q emoji 225", ref, lineTrialEmojiProductID) + } + + ref, ok = linePaidReactionForMatrixEmoji("\U0001F497") + if !ok { + t.Fatal("expected growing heart to be supported") + } + if ref.ProductID != lineTrialEmojiProductID || ref.EmojiID != "226" { + t.Fatalf("growing heart ref = %#v, want product %q emoji 226", ref, lineTrialEmojiProductID) + } + + ref, ok = linePaidReactionForMatrixEmoji("\U0001F498") + if !ok { + t.Fatal("expected heart with arrow to be supported") + } + if ref.ProductID != lineTrialEmojiProductID || ref.EmojiID != "227" { + t.Fatalf("heart with arrow ref = %#v, want product %q emoji 227", ref, lineTrialEmojiProductID) + } + + ref, ok = linePaidReactionForMatrixEmoji("\U0001F642") + if !ok { + t.Fatal("expected slightly smiling face to be supported") + } + if ref.EmojiID != "077" { + t.Fatalf("smile EmojiID = %q, want 077", ref.EmojiID) + } + + ref, ok = linePaidReactionForMatrixEmoji("\U0001F979") + if !ok { + t.Fatal("expected face holding back tears to be supported") + } + if ref.EmojiID != "081" { + t.Fatalf("face holding back tears EmojiID = %q, want 081", ref.EmojiID) + } + + ref, ok = linePaidReactionForMatrixEmoji("\U0001F60D") + if !ok { + t.Fatal("expected smiling face with heart-eyes to be supported") + } + if ref.ProductID != lineTrialEmojiProductID || ref.EmojiID != "001" { + t.Fatalf("smiling face with heart-eyes ref = %#v, want product %q emoji 001", ref, lineTrialEmojiProductID) + } + + ref, ok = linePaidReactionForMatrixEmoji("\U0001F606") + if !ok { + t.Fatal("expected grinning squinting face to be supported") + } + if ref.ProductID != lineTrialEmojiProductID || ref.EmojiID != "002" { + t.Fatalf("grinning squinting face ref = %#v, want product %q emoji 002", ref, lineTrialEmojiProductID) + } + + ref, ok = linePaidReactionForMatrixEmoji("\U0001F609") + if !ok { + t.Fatal("expected winking face to be supported") + } + if ref.ProductID != lineTrialEmojiProductID || ref.EmojiID != "011" { + t.Fatalf("winking face ref = %#v, want product %q emoji 011", ref, lineTrialEmojiProductID) + } + + ref, ok = linePaidReactionForMatrixEmoji("\U0001F60C") + if !ok { + t.Fatal("expected relieved face to be supported") + } + if ref.ProductID != lineTrialEmojiProductID || ref.EmojiID != "012" { + t.Fatalf("relieved face ref = %#v, want product %q emoji 012", ref, lineTrialEmojiProductID) + } + + ref, ok = linePaidReactionForMatrixEmoji("\U0001F632") + if !ok { + t.Fatal("expected astonished face to be supported") + } + if ref.ProductID != lineTrialEmojiProductID || ref.EmojiID != "029" { + t.Fatalf("astonished face ref = %#v, want product %q emoji 029", ref, lineTrialEmojiProductID) + } + + ref, ok = linePaidReactionForMatrixEmoji("\U0001F611") + if !ok { + t.Fatal("expected expressionless face to be supported") + } + if ref.ProductID != lineTrialEmojiProductID || ref.EmojiID != "036" { + t.Fatalf("expressionless face ref = %#v, want product %q emoji 036", ref, lineTrialEmojiProductID) + } + + ref, ok = linePaidReactionForMatrixEmoji("\U0001F616") + if !ok { + t.Fatal("expected confounded face to be supported") + } + if ref.ProductID != lineTrialEmojiProductID || ref.EmojiID != "129" { + t.Fatalf("confounded face ref = %#v, want product %q emoji 129", ref, lineTrialEmojiProductID) + } + + ref, ok = linePaidReactionForMatrixEmoji("\U0001F624") + if !ok { + t.Fatal("expected face with steam from nose to be supported") + } + if ref.ProductID != lineTrialEmojiProductID || ref.EmojiID != "135" { + t.Fatalf("face with steam from nose ref = %#v, want product %q emoji 135", ref, lineTrialEmojiProductID) + } + + ref, ok = linePaidReactionForMatrixEmoji("\U0001F612") + if !ok { + t.Fatal("expected unamused face to be supported") + } + if ref.ProductID != lineTrialEmojiProductID || ref.EmojiID != "141" { + t.Fatalf("unamused face ref = %#v, want product %q emoji 141", ref, lineTrialEmojiProductID) + } + + ref, ok = linePaidReactionForMatrixEmoji("\U0001FAE8") + if !ok { + t.Fatal("expected shaking face to be supported") + } + if ref.ProductID != lineTrialEmojiProductID || ref.EmojiID != "142" { + t.Fatalf("shaking face ref = %#v, want product %q emoji 142", ref, lineTrialEmojiProductID) + } + + ref, ok = linePaidReactionForMatrixEmoji("\U0001F978") + if !ok { + t.Fatal("expected disguised face to be supported") + } + if ref.ProductID != lineTrialEmojiProductID || ref.EmojiID != "146" { + t.Fatalf("disguised face ref = %#v, want product %q emoji 146", ref, lineTrialEmojiProductID) + } + + ref, ok = linePaidReactionForMatrixEmoji("\U0001F442") + if !ok { + t.Fatal("expected ear to be supported") + } + if ref.ProductID != lineTrialEmojiProductID || ref.EmojiID != "246" { + t.Fatalf("ear ref = %#v, want product %q emoji 246", ref, lineTrialEmojiProductID) + } + + ref, ok = linePaidReactionForMatrixEmoji("\U0001F443") + if !ok { + t.Fatal("expected nose to be supported") + } + if ref.ProductID != lineTrialEmojiProductID || ref.EmojiID != "245" { + t.Fatalf("nose ref = %#v, want product %q emoji 245", ref, lineTrialEmojiProductID) + } + + ref, ok = linePaidReactionForMatrixEmoji("\U0001F444") + if !ok { + t.Fatal("expected mouth to be supported") + } + if ref.ProductID != lineTrialEmojiProductID || ref.EmojiID != "247" { + t.Fatalf("mouth ref = %#v, want product %q emoji 247", ref, lineTrialEmojiProductID) + } + + ref, ok = linePaidReactionForMatrixEmoji("\U0001F97A") + if !ok { + t.Fatal("expected pleading face to be supported") + } + if ref.EmojiID != "087" { + t.Fatalf("pleading face EmojiID = %q, want 087", ref.EmojiID) + } + + ref, ok = linePaidReactionForMatrixEmoji("\U0001F641") + if !ok { + t.Fatal("expected slightly frowning face to be supported") + } + if ref.EmojiID != "088" { + t.Fatalf("slightly frowning face EmojiID = %q, want 088", ref.EmojiID) + } + + ref, ok = linePaidReactionForMatrixEmoji("\U0001F62E") + if !ok { + t.Fatal("expected face with open mouth to be supported") + } + if ref.EmojiID != "089" { + t.Fatalf("face with open mouth EmojiID = %q, want 089", ref.EmojiID) + } + + ref, ok = linePaidReactionForMatrixEmoji("\U0001F627") + if !ok { + t.Fatal("expected anguished face to be supported") + } + if ref.EmojiID != "090" { + t.Fatalf("anguished face EmojiID = %q, want 090", ref.EmojiID) + } + + ref, ok = linePaidReactionForMatrixEmoji("\U0001F622") + if !ok { + t.Fatal("expected crying face to be supported") + } + if ref.EmojiID != "092" { + t.Fatalf("crying face EmojiID = %q, want 092", ref.EmojiID) + } + + ref, ok = linePaidReactionForMatrixEmoji("\U0001F620") + if !ok { + t.Fatal("expected angry face to be supported") + } + if ref.EmojiID != "094" { + t.Fatalf("angry face EmojiID = %q, want 094", ref.EmojiID) + } + + ref, ok = linePaidReactionForMatrixEmoji("\U0001FAE0") + if !ok { + t.Fatal("expected melting face to be supported") + } + if ref.EmojiID != "125" { + t.Fatalf("melting face EmojiID = %q, want 125", ref.EmojiID) + } + + ref, ok = linePaidReactionForMatrixEmoji("\U0001F972") + if !ok { + t.Fatal("expected smiling face with tear to be supported") + } + if ref.EmojiID != "102" { + t.Fatalf("smiling face with tear EmojiID = %q, want 102", ref.EmojiID) + } + + ref, ok = linePaidReactionForMatrixEmoji("\U0001F44D") + if !ok { + t.Fatal("expected thumbs up to be supported") + } + if ref.EmojiID != "143" { + t.Fatalf("thumbs up EmojiID = %q, want 143", ref.EmojiID) + } + + ref, ok = linePaidReactionForMatrixEmoji("\U0001F34E") + if !ok { + t.Fatal("expected apple to be supported") + } + if ref.EmojiID != "173" { + t.Fatalf("apple EmojiID = %q, want 173", ref.EmojiID) + } + + if _, ok = linePaidReactionForMatrixEmoji("\U0001F984"); ok { + t.Fatal("unicorn should not be supported in pack-only mapping") + } +} + +func TestParseReactionTargetMessageID(t *testing.T) { + messageID, err := parseReactionTargetMessageID(networkid.MessageID("616934195205767730")) + if err != nil { + t.Fatal(err) + } + if messageID != "616934195205767730" { + t.Fatalf("messageID = %q, want 616934195205767730", messageID) + } + + for _, bad := range []networkid.MessageID{"", "local-123", "$event:example.org", "not-a-number"} { + if _, err = parseReactionTargetMessageID(bad); err == nil { + t.Fatalf("expected %q to be rejected", bad) + } + } +} + +func TestUnsupportedMatrixReactionError(t *testing.T) { + err := unsupportedMatrixReactionError("\U0001F44D") + var status bridgev2.MessageStatus + if !errors.As(err, &status) { + t.Fatalf("error %T does not wrap bridgev2.MessageStatus", err) + } + if status.ErrorReason != event.MessageStatusUnsupported { + t.Fatalf("ErrorReason = %q, want %q", status.ErrorReason, event.MessageStatusUnsupported) + } + if status.Status != event.MessageStatusFail { + t.Fatalf("Status = %q, want %q", status.Status, event.MessageStatusFail) + } + if !status.IsCertain || !status.ErrorAsMessage { + t.Fatalf("status certainty/message flags = %v/%v, want true/true", status.IsCertain, status.ErrorAsMessage) + } +} + +func TestReactionNotAMemberError(t *testing.T) { + err := reactionNotAMemberError() + var status bridgev2.MessageStatus + if !errors.As(err, &status) { + t.Fatalf("error %T does not wrap bridgev2.MessageStatus", err) + } + if status.Status != event.MessageStatusFail { + t.Fatalf("Status = %q, want %q", status.Status, event.MessageStatusFail) + } + if status.ErrorReason != event.MessageStatusNoPermission { + t.Fatalf("ErrorReason = %q, want %q", status.ErrorReason, event.MessageStatusNoPermission) + } + if !status.IsCertain || !status.ErrorAsMessage { + t.Fatalf("status certainty/message flags = %v/%v, want true/true", status.IsCertain, status.ErrorAsMessage) + } +} diff --git a/pkg/connector/sync.go b/pkg/connector/sync.go index 4857877..da65514 100644 --- a/pkg/connector/sync.go +++ b/pkg/connector/sync.go @@ -37,7 +37,7 @@ const ( func (lc *LineClient) getMessageBoxesWithRecovery(ctx context.Context, opts line.MessageBoxesOptions) (*line.MessageBoxesResponse, error) { client := lc.newClient() res, err := client.GetMessageBoxes(opts) - if err != nil && (lc.isRefreshRequired(err) || lc.isLoggedOut(err)) { + if err != nil && lc.shouldAttemptTokenRecovery(ctx, err) { if errRecover := lc.recoverToken(ctx); errRecover == nil { client = lc.newClient() res, err = client.GetMessageBoxes(opts) @@ -77,7 +77,7 @@ func (lc *LineClient) fetchAllMessageBoxes(ctx context.Context, opts line.Messag func (lc *LineClient) refreshBlockedContacts(ctx context.Context) ([]string, error) { client := lc.newClient() blockedMIDs, err := client.GetBlockedContactIds() - if err != nil && (lc.isRefreshRequired(err) || lc.isLoggedOut(err)) { + if err != nil && lc.shouldAttemptTokenRecovery(ctx, err) { if errRecover := lc.recoverToken(ctx); errRecover == nil { client = lc.newClient() blockedMIDs, err = client.GetBlockedContactIds() @@ -304,7 +304,7 @@ func (lc *LineClient) FetchMessages(ctx context.Context, params bridgev2.FetchMe client := lc.newClient() msgs, err := client.GetRecentMessagesV2(chatMID, limit) - if err != nil && (lc.isRefreshRequired(err) || lc.isLoggedOut(err)) { + if err != nil && lc.shouldAttemptTokenRecovery(ctx, err) { if errRecover := lc.recoverToken(ctx); errRecover == nil { client = lc.newClient() msgs, err = client.GetRecentMessagesV2(chatMID, limit) @@ -458,7 +458,7 @@ func (lc *LineClient) backfillRecentMessages(ctx context.Context, chatMID string start := time.Now() client := lc.newClient() msgs, err := client.GetRecentMessagesV2(chatMID, limit) - if err != nil && (lc.isRefreshRequired(err) || lc.isLoggedOut(err)) { + if err != nil && lc.shouldAttemptTokenRecovery(ctx, err) { if errRecover := lc.recoverToken(ctx); errRecover == nil { client = lc.newClient() msgs, err = client.GetRecentMessagesV2(chatMID, limit) @@ -502,7 +502,7 @@ func (lc *LineClient) syncChats(ctx context.Context) { client := lc.newClient() midsResp, err := client.GetAllChatMids(true, true) - if err != nil && (lc.isRefreshRequired(err) || lc.isLoggedOut(err)) { + if err != nil && lc.shouldAttemptTokenRecovery(ctx, err) { if errRecover := lc.recoverToken(ctx); errRecover == nil { client = lc.newClient() midsResp, err = client.GetAllChatMids(true, true) @@ -533,7 +533,7 @@ func (lc *LineClient) syncChats(ctx context.Context) { } batch := allMids[i:end] chatsResp, err := client.GetChats(batch, true, true) - if err != nil && (lc.isRefreshRequired(err) || lc.isLoggedOut(err)) { + if err != nil && lc.shouldAttemptTokenRecovery(ctx, err) { if errRecover := lc.recoverToken(ctx); errRecover == nil { client = lc.newClient() chatsResp, err = client.GetChats(batch, true, true) @@ -870,7 +870,7 @@ func (lc *LineClient) cacheGroupMembersFromRecentMessages(ctx context.Context, c } client := lc.newClient() msgs, err := client.GetRecentMessagesV2(chatMid, 50) - if err != nil && (lc.isRefreshRequired(err) || lc.isLoggedOut(err)) { + if err != nil && lc.shouldAttemptTokenRecovery(ctx, err) { if errRecover := lc.recoverToken(ctx); errRecover == nil { client = lc.newClient() msgs, err = client.GetRecentMessagesV2(chatMid, 50) @@ -1151,7 +1151,11 @@ func (lc *LineClient) pollLoop(ctx context.Context) { lc.UserLogin.Bridge.Log.Info().Msg("Starting LINE SSE loop...") rev, err := client.GetLastOpRevision() - if err != nil && (lc.isRefreshRequired(err) || lc.isLoggedOut(err)) { + if err != nil && lc.isLoggedOut(err) { + lc.markLoggedOutByOtherClient(ctx, err) + return + } + if err != nil && lc.shouldAttemptTokenRecovery(ctx, err) { if errRecover := lc.recoverToken(ctx); errRecover == nil { client = lc.newClient() rev, err = client.GetLastOpRevision() @@ -1227,11 +1231,13 @@ func (lc *LineClient) pollLoop(ctx context.Context) { if err.Error() != "EOF" { lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("SSE Disconnected") - isAuthErr := strings.Contains(err.Error(), "SSE error: 401") || - strings.Contains(err.Error(), "SSE error: 403") || - lc.isLoggedOut(err) + if lc.isLoggedOut(err) { + lc.markLoggedOutByOtherClient(ctx, err) + return + } - if isAuthErr { + if strings.Contains(err.Error(), "SSE error: 401") || + strings.Contains(err.Error(), "SSE error: 403") { if errRecover := lc.recoverToken(ctx); errRecover != nil { lc.UserLogin.Bridge.Log.Error().Err(errRecover).Msg("Failed to recover session, stopping poll loop") lc.UserLogin.BridgeState.Send(status.BridgeState{ @@ -1722,7 +1728,7 @@ func (lc *LineClient) syncSingleChat(ctx context.Context, op line.Operation) { chatMid := op.Param1 client := lc.newClient() chatsResp, err := client.GetChats([]string{chatMid}, true, true) - if err != nil && (lc.isRefreshRequired(err) || lc.isLoggedOut(err)) { + if err != nil && lc.shouldAttemptTokenRecovery(ctx, err) { if errRecover := lc.recoverToken(ctx); errRecover == nil { client = lc.newClient() chatsResp, err = client.GetChats([]string{chatMid}, true, true) @@ -1783,7 +1789,7 @@ func (lc *LineClient) syncSingleChat(ctx context.Context, op line.Operation) { func (lc *LineClient) checkChatMembership(ctx context.Context, chatMid string) (isMember, isInvitee bool) { client := lc.newClient() midsResp, err := client.GetAllChatMids(true, true) - if err != nil && (lc.isRefreshRequired(err) || lc.isLoggedOut(err)) { + if err != nil && lc.shouldAttemptTokenRecovery(ctx, err) { if errRecover := lc.recoverToken(ctx); errRecover == nil { client = lc.newClient() midsResp, err = client.GetAllChatMids(true, true) @@ -1866,7 +1872,7 @@ func (lc *LineClient) handleMemberJoin(chatMid, joinerMid string) { func (lc *LineClient) handleInvite(ctx context.Context, chatMid string, opType OperationType) { client := lc.newClient() chatsResp, err := client.GetChats([]string{chatMid}, true, true) - if err != nil && (lc.isRefreshRequired(err) || lc.isLoggedOut(err)) { + if err != nil && lc.shouldAttemptTokenRecovery(ctx, err) { if errRecover := lc.recoverToken(ctx); errRecover == nil { client = lc.newClient() chatsResp, err = client.GetChats([]string{chatMid}, true, true) @@ -1914,7 +1920,7 @@ func (lc *LineClient) handleInvite(ctx context.Context, chatMid string, opType O func (lc *LineClient) handleInviteForSelf(ctx context.Context, chatMid string) { client := lc.newClient() chatsResp, err := client.GetChats([]string{chatMid}, true, true) - if err != nil && (lc.isRefreshRequired(err) || lc.isLoggedOut(err)) { + if err != nil && lc.shouldAttemptTokenRecovery(ctx, err) { if errRecover := lc.recoverToken(ctx); errRecover == nil { client = lc.newClient() chatsResp, err = client.GetChats([]string{chatMid}, true, true) diff --git a/pkg/connector/userinfo.go b/pkg/connector/userinfo.go index c5d0dba..fd7b301 100644 --- a/pkg/connector/userinfo.go +++ b/pkg/connector/userinfo.go @@ -144,7 +144,7 @@ func (lc *LineClient) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) if strings.HasPrefix(lowerMid, "c") || strings.HasPrefix(lowerMid, "r") { client := lc.newClient() res, err := client.GetChats([]string{mid}, true, true) - if err != nil && (lc.isRefreshRequired(err) || lc.isLoggedOut(err)) { + if err != nil && lc.shouldAttemptTokenRecovery(ctx, err) { if errRecover := lc.recoverToken(ctx); errRecover == nil { client = lc.newClient() res, err = client.GetChats([]string{mid}, true, true) @@ -211,7 +211,7 @@ func (lc *LineClient) getContact(ctx context.Context, mid string) line.Contact { if mid == lc.Mid || mid == string(lc.UserLogin.ID) { client := lc.newClient() profile, err := client.GetProfile() - if err != nil && (lc.isRefreshRequired(err) || lc.isLoggedOut(err)) { + if err != nil && lc.shouldAttemptTokenRecovery(ctx, err) { if errRecover := lc.recoverToken(ctx); errRecover == nil { client = lc.newClient() profile, err = client.GetProfile() @@ -227,7 +227,7 @@ func (lc *LineClient) getContact(ctx context.Context, mid string) line.Contact { client := lc.newClient() res, err := client.GetContactsV2([]string{mid}) - if err != nil && (lc.isRefreshRequired(err) || lc.isLoggedOut(err)) { + if err != nil && lc.shouldAttemptTokenRecovery(ctx, err) { if errRecover := lc.recoverToken(ctx); errRecover == nil { client = lc.newClient() res, err = client.GetContactsV2([]string{mid}) @@ -243,7 +243,7 @@ func (lc *LineClient) getContact(ctx context.Context, mid string) line.Contact { // Fall back to BuddyService for official/business accounts lc.UserLogin.Bridge.Log.Debug().Str("mid", mid).Msg("Contact not found via GetContactsV2, trying BuddyService") buddy, err := client.GetBuddyProfile(mid) - if err != nil && (lc.isRefreshRequired(err) || lc.isLoggedOut(err)) { + if err != nil && lc.shouldAttemptTokenRecovery(ctx, err) { if errRecover := lc.recoverToken(ctx); errRecover == nil { client = lc.newClient() buddy, err = client.GetBuddyProfile(mid) @@ -333,7 +333,7 @@ func (lc *LineClient) SearchUsers(ctx context.Context, query string) ([]*bridgev client := lc.newClient() allMids, err := client.GetAllContactIds() if err != nil { - if lc.isRefreshRequired(err) || lc.isLoggedOut(err) { + if lc.shouldAttemptTokenRecovery(ctx, err) { if errRecover := lc.recoverToken(ctx); errRecover == nil { client = lc.newClient() allMids, err = client.GetAllContactIds() @@ -379,7 +379,7 @@ func (lc *LineClient) GetContactList(ctx context.Context) ([]*bridgev2.ResolveId client := lc.newClient() allMids, err := client.GetAllContactIds() if err != nil { - if lc.isRefreshRequired(err) || lc.isLoggedOut(err) { + if lc.shouldAttemptTokenRecovery(ctx, err) { if errRecover := lc.recoverToken(ctx); errRecover == nil { client = lc.newClient() allMids, err = client.GetAllContactIds() diff --git a/pkg/line/errors.go b/pkg/line/errors.go index 438bef2..74fb9f3 100644 --- a/pkg/line/errors.go +++ b/pkg/line/errors.go @@ -28,7 +28,18 @@ func IsLoggedOut(err error) bool { if err == nil { return false } - return strings.Contains(err.Error(), "V3_TOKEN_CLIENT_LOGGED_OUT") + return strings.Contains(err.Error(), "V3_TOKEN_CLIENT_LOGGED_OUT") || IsInvalidSenderKey(err) +} + +func IsInvalidSenderKey(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return hasResponseErrorCode(msg) && + strings.Contains(msg, "talkexception") && + strings.Contains(msg, "\"code\":83") && + strings.Contains(msg, "invalid sender key") } func IsUnauthorizedStatus(err error) bool { diff --git a/pkg/line/errors_test.go b/pkg/line/errors_test.go index e907186..652f128 100644 --- a/pkg/line/errors_test.go +++ b/pkg/line/errors_test.go @@ -51,6 +51,9 @@ func TestIsLoggedOut(t *testing.T) { if !IsLoggedOut(errors.New("V3_TOKEN_CLIENT_LOGGED_OUT")) { t.Fatal("expected logged-out error to be detected") } + if !IsLoggedOut(errors.New(`API error 400: {"code":10051,"message":"RESPONSE_ERROR","data":{"name":"TalkException","message":"TalkException","code":83,"reason":"invalid sender key","parameterMap":null}}`)) { + t.Fatal("expected invalid sender key to be detected as logged out") + } if IsLoggedOut(errors.New("Access token refresh required")) { t.Fatal("refresh-required error should not be classified as logged out") } diff --git a/pkg/line/reaction_test.go b/pkg/line/reaction_test.go new file mode 100644 index 0000000..fa3afec --- /dev/null +++ b/pkg/line/reaction_test.go @@ -0,0 +1,140 @@ +package line + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "testing" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return fn(req) +} + +func newReactionTestClient(t *testing.T, wantPath string, body *string) *Client { + return newReactionTestClientWithResponse(t, wantPath, `{"code":0,"message":"ok","data":null}`, body) +} + +func newReactionTestClientWithResponse(t *testing.T, wantPath, responseBody string, body *string) *Client { + t.Helper() + client := NewClient("test-token") + client.HTTPClient = &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.Path != wantPath { + t.Fatalf("path = %q, want %q", req.URL.Path, wantPath) + } + data, err := io.ReadAll(req.Body) + if err != nil { + t.Fatal(err) + } + if body != nil { + *body = string(data) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(responseBody)), + }, nil + }), + } + return client +} + +func TestReactRequestBody(t *testing.T) { + var body string + client := newReactionTestClient(t, "/api/talk/thrift/Talk/TalkService/react", &body) + err := client.React(123, "616934195205767730", ReactionType{ + PaidReactionType: &PaidReactionType{ + ProductID: "670e0cce840a8236ddd4ee4c", + EmojiID: "211", + ResourceType: 1, + Version: 1, + }, + }) + if err != nil { + t.Fatal(err) + } + + var args []ReactRequest + if err = json.Unmarshal([]byte(body), &args); err != nil { + t.Fatal(err) + } + if len(args) != 1 { + t.Fatalf("arg count = %d, want 1", len(args)) + } + req := args[0] + if req.ReqSeq != 123 || req.MessageID != "616934195205767730" { + t.Fatalf("request seq/message = %d/%s, want 123/616934195205767730", req.ReqSeq, req.MessageID) + } + if req.ReactionType.PaidReactionType == nil { + t.Fatal("paidReactionType missing") + } + if req.ReactionType.PaidReactionType.ProductID != "670e0cce840a8236ddd4ee4c" || + req.ReactionType.PaidReactionType.EmojiID != "211" || + req.ReactionType.PaidReactionType.ResourceType != 1 || + req.ReactionType.PaidReactionType.Version != 1 { + t.Fatalf("paidReactionType = %#v", req.ReactionType.PaidReactionType) + } +} + +func TestCancelReactionRequestBody(t *testing.T) { + var body string + client := newReactionTestClient(t, "/api/talk/thrift/Talk/TalkService/cancelReaction", &body) + err := client.CancelReaction(123, "616934195205767730") + if err != nil { + t.Fatal(err) + } + + var args []CancelReactionRequest + if err = json.Unmarshal([]byte(body), &args); err != nil { + t.Fatal(err) + } + if len(args) != 1 { + t.Fatalf("arg count = %d, want 1", len(args)) + } + if args[0].ReqSeq != 123 || args[0].MessageID != "616934195205767730" { + t.Fatalf("request seq/message = %d/%s, want 123/616934195205767730", args[0].ReqSeq, args[0].MessageID) + } +} + +func TestReactNonZeroWrapperKeepsInvalidPaidReactionDetails(t *testing.T) { + client := newReactionTestClientWithResponse(t, "/api/talk/thrift/Talk/TalkService/react", `{"code":10051,"message":"RESPONSE_ERROR","data":{"name":"TalkException","message":"TalkException","code":0,"reason":"Invalid paidReactionType in reactionType","parameterMap":null}}`, nil) + err := client.React(123, "616934195205767730", ReactionType{ + PaidReactionType: &PaidReactionType{ + ProductID: "670e0cce840a8236ddd4ee4c", + EmojiID: "211", + ResourceType: 1, + Version: 1, + }, + }) + if err == nil { + t.Fatal("expected non-zero wrapper error") + } + if !IsInvalidPaidReactionType(err) { + t.Fatalf("expected invalid paid reaction error to be detected from %q", err.Error()) + } +} + +func TestIsInvalidPaidReactionType(t *testing.T) { + err := errors.New(`API error 400: {"code":10051,"message":"RESPONSE_ERROR","data":{"name":"TalkException","message":"TalkException","code":0,"reason":"Invalid paidReactionType in reactionType","parameterMap":null}}`) + if !IsInvalidPaidReactionType(err) { + t.Fatal("expected invalid paid reaction error to be detected") + } + if IsInvalidPaidReactionType(errors.New("other error")) { + t.Fatal("unexpected invalid paid reaction match") + } +} + +func TestIsNotAMemberError(t *testing.T) { + err := errors.New(`API error 400: {"code":10051,"message":"RESPONSE_ERROR","data":{"name":"TalkException","message":"TalkException","code":10,"reason":"You are not a member of this chat","parameterMap":null}}`) + if !IsNotAMemberError(err) { + t.Fatal("expected not-a-member error to be detected") + } + if IsNotAMemberError(errors.New("other error")) { + t.Fatal("unexpected not-a-member match") + } +} diff --git a/pkg/ltsm/crypto_test.go b/pkg/ltsm/crypto_test.go new file mode 100644 index 0000000..421844a --- /dev/null +++ b/pkg/ltsm/crypto_test.go @@ -0,0 +1,196 @@ +package ltsm + +import ( + "crypto/rand" + "testing" + + "golang.org/x/crypto/curve25519" +) + +func TestEncryptDecryptV1Roundtrip(t *testing.T) { + // Generate two keypairs + privA := make([]byte, 32) + rand.Read(privA) + pubA, _ := curve25519.X25519(privA, curve25519.Basepoint) + + privB := make([]byte, 32) + rand.Read(privB) + pubB, _ := curve25519.X25519(privB, curve25519.Basepoint) + + // Create channels (A->B and B->A should have same shared secret) + chanAB, err := NewChannel(privA, pubB) + if err != nil { + t.Fatalf("NewChannel A->B: %v", err) + } + chanBA, err := NewChannel(privB, pubA) + if err != nil { + t.Fatalf("NewChannel B->A: %v", err) + } + + // Verify shared secrets match + if chanAB.SharedSecret != chanBA.SharedSecret { + t.Fatal("shared secrets don't match") + } + + // Encrypt with A, decrypt with B + plaintext := []byte(`{"text":"hello from pure Go!"}`) + ciphertext, err := chanAB.EncryptV1(plaintext) + if err != nil { + t.Fatalf("EncryptV1: %v", err) + } + + decrypted, err := chanBA.DecryptV1(ciphertext) + if err != nil { + t.Fatalf("DecryptV1: %v", err) + } + + if string(decrypted) != string(plaintext) { + t.Errorf("roundtrip mismatch:\n got: %q\n want: %q", string(decrypted), string(plaintext)) + } +} + +func TestEncryptDecryptV2Roundtrip(t *testing.T) { + privA := make([]byte, 32) + rand.Read(privA) + pubA, _ := curve25519.X25519(privA, curve25519.Basepoint) + + privB := make([]byte, 32) + rand.Read(privB) + pubB, _ := curve25519.X25519(privB, curve25519.Basepoint) + + chanAB, err := NewChannel(privA, pubB) + if err != nil { + t.Fatalf("NewChannel A->B: %v", err) + } + chanBA, err := NewChannel(privB, pubA) + if err != nil { + t.Fatalf("NewChannel B->A: %v", err) + } + + to := "u8ae764e8e69e6bd4ecdd9b6ea0c40fce" + from := "uf1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6" + senderKeyID := 42 + receiverKeyID := 99 + contentType := 0 + seq := int64(1) + plaintext := []byte(`{"text":"hello V2 from pure Go!"}`) + + ciphertext, err := chanAB.EncryptV2(to, from, senderKeyID, receiverKeyID, contentType, seq, plaintext) + if err != nil { + t.Fatalf("EncryptV2: %v", err) + } + + t.Logf("V2 ciphertext: %d bytes", len(ciphertext)) + + // Decrypt with the SAME parameters + decrypted, err := chanBA.DecryptV2(to, from, senderKeyID, receiverKeyID, contentType, ciphertext) + if err != nil { + t.Fatalf("DecryptV2: %v", err) + } + + if string(decrypted) != string(plaintext) { + t.Errorf("roundtrip mismatch:\n got: %q\n want: %q", string(decrypted), string(plaintext)) + } +} + +func TestV1CiphertextFormat(t *testing.T) { + priv := make([]byte, 32) + rand.Read(priv) + pub, _ := curve25519.X25519(priv, curve25519.Basepoint) + + ch, err := NewChannel(priv, pub) + if err != nil { + t.Fatalf("NewChannel: %v", err) + } + + plaintext := []byte(`{"text":"hello crypto test"}`) // 28 bytes + ct, err := ch.EncryptV1(plaintext) + if err != nil { + t.Fatalf("EncryptV1: %v", err) + } + + // Verify format: salt(8) || encrypted(32) || MAC(16) = 56 bytes + // 28 bytes + 4 bytes PKCS#7 padding = 32 bytes encrypted + expectedLen := 8 + 32 + 16 + if len(ct) != expectedLen { + t.Errorf("unexpected ciphertext length: got %d, want %d", len(ct), expectedLen) + } + + t.Logf("V1 ciphertext: %d bytes (salt=8, body=%d, mac=16)", len(ct), len(ct)-24) +} + +func TestV2CiphertextFormat(t *testing.T) { + priv := make([]byte, 32) + rand.Read(priv) + pub, _ := curve25519.X25519(priv, curve25519.Basepoint) + + ch, err := NewChannel(priv, pub) + if err != nil { + t.Fatalf("NewChannel: %v", err) + } + + plaintext := []byte(`{"text":"test"}`) // 15 bytes + ct, err := ch.EncryptV2("uto", "ufrom", 1, 2, 0, 1, plaintext) + if err != nil { + t.Fatalf("EncryptV2: %v", err) + } + + // Format: salt(16) || nonce(12) || ciphertext(15) || tag(16) = 59 bytes + expectedLen := 16 + 12 + 15 + 16 + if len(ct) != expectedLen { + t.Errorf("unexpected ciphertext length: got %d, want %d", len(ct), expectedLen) + } + + t.Logf("V2 ciphertext: %d bytes (salt=16, nonce=12, body=%d, tag=16)", len(ct), len(ct)-44) +} + +func TestProcessMIDforAAD(t *testing.T) { + // Test MID processing - should be identity (raw UTF-8 bytes, lowercased) + mid := "u8ae764e8e69e6bd4ecdd9b6ea0c40fce" + result := processMIDforAAD(mid) + + if len(result) != 33 { + t.Fatalf("expected 33 bytes, got %d", len(result)) + } + + // Output should be the same as the input MID (already lowercase) + if string(result) != mid { + t.Errorf("expected identity:\n got: %q\n want: %q", string(result), mid) + } + + // Test uppercase prefix gets lowercased + midUpper := "U8ae764e8e69e6bd4ecdd9b6ea0c40fce" + resultUpper := processMIDforAAD(midUpper) + if resultUpper[0] != 'u' { + t.Errorf("uppercase prefix not lowercased: got %c", resultUpper[0]) + } +} + +func TestPKCS7Padding(t *testing.T) { + tests := []struct { + input string + padded int // expected padded length + blockSz int + }{ + {"", 16, 16}, + {"a", 16, 16}, + {"0123456789abcdef", 32, 16}, // exactly one block -> pad to two blocks + {"0123456789abcde", 16, 16}, // 15 bytes -> pad to 16 + } + + for _, tt := range tests { + padded := pkcs7Pad([]byte(tt.input), tt.blockSz) + if len(padded) != tt.padded { + t.Errorf("pkcs7Pad(%q, %d): got len %d, want %d", tt.input, tt.blockSz, len(padded), tt.padded) + } + + unpadded, err := pkcs7Unpad(padded, tt.blockSz) + if err != nil { + t.Errorf("pkcs7Unpad: %v", err) + continue + } + if string(unpadded) != tt.input { + t.Errorf("pkcs7 roundtrip: got %q, want %q", string(unpadded), tt.input) + } + } +} diff --git a/pkg/ltsm/embind_debug_test.go b/pkg/ltsm/embind_debug_test.go new file mode 100644 index 0000000..452c04b --- /dev/null +++ b/pkg/ltsm/embind_debug_test.go @@ -0,0 +1,54 @@ +package ltsm + +import ( + "testing" +) + +func TestDebugMemoryStrings(t *testing.T) { + // Check strings at addresses used in embind registrations + addrs := []uint32{ + // From Import_i (class registration) first call + 4724, 4752, 4796, 7096, 8180, 4812, 1403, + // From Import_g (static method registration) calls + 2480, 1374, 4824, 4828, 4836, + // More method names + 1416, 1427, 1437, 1448, 1461, 1480, 1501, + 2541, 2558, 2684, 2685, 2697, + // Module property name at 2304 + 2304, + // Error string at 1212 + 1212, + } + for _, addr := range addrs { + if int(addr) < len(memInit) { + var s []byte + for i := addr; i < uint32(len(memInit)) && i < addr+100; i++ { + if memInit[i] == 0 { + break + } + s = append(s, memInit[i]) + } + t.Logf("addr %5d: %q", addr, string(s)) + } + } +} + +func TestDebugStaticMethodRegistrations(t *testing.T) { + _, imp := initModule(t) + + // Log all static method invoker details + for name, ci := range imp.classByName { + for mname, mi := range ci.StaticMethods { + t.Logf("%s.%s: invokerIdx=%d, fnIdx=%d, argCount=%d, argTypes=%v", + name, mname, mi.InvokerIdx, mi.Context, mi.ArgCount, mi.ArgTypes) + } + for mname, mi := range ci.Methods { + t.Logf("%s.%s (instance): invokerIdx=%d, context=%d, argCount=%d, argTypes=%v", + name, mname, mi.InvokerIdx, mi.Context, mi.ArgCount, mi.ArgTypes) + } + for i, ctor := range ci.Constructors { + t.Logf("%s.ctor[%d]: invokerIdx=%d, rawCtorIdx=%d, argCount=%d, argTypes=%v", + name, i, ctor.InvokerIdx, ctor.RawCtorIdx, ctor.ArgCount, ctor.ArgTypes) + } + } +} diff --git a/pkg/ltsm/embind_test.go b/pkg/ltsm/embind_test.go new file mode 100644 index 0000000..a68f200 --- /dev/null +++ b/pkg/ltsm/embind_test.go @@ -0,0 +1,438 @@ +package ltsm + +import ( + "crypto/sha256" + "encoding/base64" + "testing" +) + +// initModule creates and initializes a Module with the embind Imports. +func initModule(t *testing.T) (*Module, *Imports) { + t.Helper() + imp := NewImports() + mod := NewModule(imp) + imp.SetModule(mod) + + // Phase 1: __wasm_call_ctors + mod.fP() + // Phase 2: embind type/class registrations + mod.fT() + + return mod, imp +} + +func TestEmbindInit(t *testing.T) { + _, imp := initModule(t) + + // Check that fundamental types are registered + foundVoid := false + foundBool := false + foundInt := false + foundString := false + foundEmval := false + for _, ti := range imp.types { + switch ti.Kind { + case "void": + foundVoid = true + case "bool": + foundBool = true + case "int": + foundInt = true + case "string": + foundString = true + case "emval": + foundEmval = true + } + } + + if !foundVoid { + t.Error("missing void type registration") + } + if !foundBool { + t.Error("missing bool type registration") + } + if !foundInt { + t.Error("missing int type registration") + } + if !foundString { + t.Error("missing string type registration") + } + if !foundEmval { + t.Error("missing emval type registration") + } + + t.Logf("registered %d types", len(imp.types)) + + // Check expected classes + expectedClasses := []string{ + "SecureKey", + "Hmac", + "Curve25519Key", + "E2EEKey", + "E2EEChannel", + "E2EEKeychain", + "AesKey", + } + + for _, name := range expectedClasses { + ci := imp.classByName[name] + if ci == nil { + t.Errorf("missing class: %s", name) + continue + } + t.Logf("class %s: %d ctors, %d methods, %d static methods", + name, len(ci.Constructors), len(ci.Methods), len(ci.StaticMethods)) + + for mName := range ci.Methods { + t.Logf(" method: %s (args=%d, invoker=%d)", mName, ci.Methods[mName].ArgCount, ci.Methods[mName].InvokerIdx) + } + for mName := range ci.StaticMethods { + t.Logf(" static: %s (args=%d, invoker=%d)", mName, ci.StaticMethods[mName].ArgCount, ci.StaticMethods[mName].InvokerIdx) + } + for i, ctor := range ci.Constructors { + t.Logf(" ctor[%d]: args=%d, invoker=%d", i, ctor.ArgCount, ctor.InvokerIdx) + } + } +} + +func TestSecureKeyLoadToken(t *testing.T) { + _, imp := initModule(t) + + token := "wODdrvWqmdP4Zliay-iF3cz3KZcK0ekrial868apg06TXeCo7A1hIQO0ESElHg6D" + + // Write token as std::string in module memory + strPtr := imp.writeStdString(token) + + // Call SecureKey.loadToken via embind + skPtr, err := imp.CallStatic("SecureKey", "loadToken", strPtr) + if err != nil { + t.Fatalf("SecureKey.loadToken failed: %v", err) + } + if skPtr == 0 { + t.Fatal("SecureKey.loadToken returned null pointer") + } + t.Logf("SecureKey.loadToken returned ptr=%d", skPtr) +} + +func TestSecureKeyDeriveKey(t *testing.T) { + _, imp := initModule(t) + + token := "wODdrvWqmdP4Zliay-iF3cz3KZcK0ekrial868apg06TXeCo7A1hIQO0ESElHg6D" + strPtr := imp.writeStdString(token) + + skPtr, err := imp.CallStatic("SecureKey", "loadToken", strPtr) + if err != nil { + t.Fatalf("SecureKey.loadToken failed: %v", err) + } + + // Create hash inputs + cvHash := sha256.Sum256([]byte("testClientVersion")) + atHash := sha256.Sum256([]byte("testAccessToken")) + + cvHandle := imp.WriteEmvalBytes(cvHash[:]) + atHandle := imp.WriteEmvalBytes(atHash[:]) + + dkPtr, err := imp.CallMethod("SecureKey", "deriveKey", skPtr, cvHandle, atHandle) + if err != nil { + t.Fatalf("SecureKey.deriveKey failed: %v", err) + } + if dkPtr == 0 { + t.Fatal("SecureKey.deriveKey returned null pointer") + } + t.Logf("SecureKey.deriveKey returned ptr=%d", dkPtr) +} + +func TestHmacDigest(t *testing.T) { + _, imp := initModule(t) + + // loadToken → deriveKey → Hmac.new → digest + token := "wODdrvWqmdP4Zliay-iF3cz3KZcK0ekrial868apg06TXeCo7A1hIQO0ESElHg6D" + strPtr := imp.writeStdString(token) + + skPtr, err := imp.CallStatic("SecureKey", "loadToken", strPtr) + if err != nil { + t.Fatalf("SecureKey.loadToken failed: %v", err) + } + + cvHash := sha256.Sum256([]byte("testClientVersion")) + atHash := sha256.Sum256([]byte("testAccessToken")) + cvHandle := imp.WriteEmvalBytes(cvHash[:]) + atHandle := imp.WriteEmvalBytes(atHash[:]) + + dkPtr, err := imp.CallMethod("SecureKey", "deriveKey", skPtr, cvHandle, atHandle) + if err != nil { + t.Fatalf("SecureKey.deriveKey failed: %v", err) + } + + // Create Hmac + hmacPtr, err := imp.Construct("Hmac", dkPtr) + if err != nil { + t.Fatalf("Hmac.new failed: %v", err) + } + if hmacPtr == 0 { + t.Fatal("Hmac constructor returned null") + } + t.Logf("Hmac constructed at ptr=%d", hmacPtr) + + // Compute digest + dataToSign := []byte("/api/v4/test" + `{"key":"value"}`) + dataHandle := imp.WriteEmvalBytes(dataToSign) + + resultHandle, err := imp.CallMethod("Hmac", "digest", hmacPtr, dataHandle) + if err != nil { + t.Fatalf("Hmac.digest failed: %v", err) + } + + sigBytes, err := imp.ReadEmvalBytes(resultHandle) + if err != nil { + t.Fatalf("failed to read digest result: %v", err) + } + imp.emval.DecRef(resultHandle) + + sig := base64.StdEncoding.EncodeToString(sigBytes) + t.Logf("HMAC signature: %s (len=%d)", sig, len(sigBytes)) + + if len(sigBytes) == 0 { + t.Fatal("digest returned empty bytes") + } +} + +func TestSignFullFlow(t *testing.T) { + _, imp := initModule(t) + + // Full sign flow matching Runtime.Sign + token := "wODdrvWqmdP4Zliay-iF3cz3KZcK0ekrial868apg06TXeCo7A1hIQO0ESElHg6D" + clientVersion := "13.4.2" + accessToken := "test_access_token_12345" + reqPath := "/api/v4/TalkService.do" + body := "" + + // 1. SecureKey.loadToken + strPtr := imp.writeStdString(token) + skPtr, err := imp.CallStatic("SecureKey", "loadToken", strPtr) + if err != nil { + t.Fatalf("loadToken failed: %v", err) + } + + // 2. deriveKey + cvHash := sha256.Sum256([]byte(clientVersion)) + atHash := sha256.Sum256([]byte(accessToken)) + dkPtr, err := imp.CallMethod("SecureKey", "deriveKey", skPtr, imp.WriteEmvalBytes(cvHash[:]), imp.WriteEmvalBytes(atHash[:])) + if err != nil { + t.Fatalf("deriveKey failed: %v", err) + } + + // 3. Hmac.new + hmacPtr, err := imp.Construct("Hmac", dkPtr) + if err != nil { + t.Fatalf("Hmac.new failed: %v", err) + } + + // 4. digest + dataToSign := []byte(reqPath + body) + resultHandle, err := imp.CallMethod("Hmac", "digest", hmacPtr, imp.WriteEmvalBytes(dataToSign)) + if err != nil { + t.Fatalf("digest failed: %v", err) + } + + sigBytes, err := imp.ReadEmvalBytes(resultHandle) + if err != nil { + t.Fatalf("read digest result failed: %v", err) + } + imp.emval.DecRef(resultHandle) + + sig := base64.StdEncoding.EncodeToString(sigBytes) + t.Logf("Signature: %s", sig) + + if len(sigBytes) != 32 { + t.Errorf("expected 32-byte signature, got %d bytes", len(sigBytes)) + } + + // Verify deterministic: same inputs should produce same output + strPtr2 := imp.writeStdString(token) + skPtr2, _ := imp.CallStatic("SecureKey", "loadToken", strPtr2) + dkPtr2, _ := imp.CallMethod("SecureKey", "deriveKey", skPtr2, imp.WriteEmvalBytes(cvHash[:]), imp.WriteEmvalBytes(atHash[:])) + hmacPtr2, _ := imp.Construct("Hmac", dkPtr2) + resultHandle2, _ := imp.CallMethod("Hmac", "digest", hmacPtr2, imp.WriteEmvalBytes(dataToSign)) + sigBytes2, _ := imp.ReadEmvalBytes(resultHandle2) + + sig2 := base64.StdEncoding.EncodeToString(sigBytes2) + if sig != sig2 { + t.Errorf("non-deterministic: sig1=%s, sig2=%s", sig, sig2) + } +} + +func TestSecureKeyExportKey(t *testing.T) { + _, imp := initModule(t) + + token := "wODdrvWqmdP4Zliay-iF3cz3KZcK0ekrial868apg06TXeCo7A1hIQO0ESElHg6D" + strPtr := imp.writeStdString(token) + skPtr, err := imp.CallStatic("SecureKey", "loadToken", strPtr) + if err != nil { + t.Fatalf("loadToken failed: %v", err) + } + + // Mark as exportable + imp.MarkSecureKeyExportable(skPtr) + + resultHandle, err := imp.CallMethod("SecureKey", "exportKey", skPtr) + if err != nil { + t.Fatalf("exportKey failed: %v", err) + } + + keyBytes, err := imp.ReadEmvalBytes(resultHandle) + if err != nil { + t.Fatalf("read exportKey result failed: %v", err) + } + imp.emval.DecRef(resultHandle) + + t.Logf("exported key: %d bytes", len(keyBytes)) + if len(keyBytes) == 0 { + t.Fatal("exportKey returned empty bytes") + } +} + +func TestSecureKeyLoadKeyRoundtrip(t *testing.T) { + _, imp := initModule(t) + + // loadToken → exportKey → loadKey → verify functional equivalence via HMAC + token := "wODdrvWqmdP4Zliay-iF3cz3KZcK0ekrial868apg06TXeCo7A1hIQO0ESElHg6D" + strPtr := imp.writeStdString(token) + skPtr, err := imp.CallStatic("SecureKey", "loadToken", strPtr) + if err != nil { + t.Fatalf("loadToken failed: %v", err) + } + + imp.MarkSecureKeyExportable(skPtr) + h1, err := imp.CallMethod("SecureKey", "exportKey", skPtr) + if err != nil { + t.Fatalf("exportKey failed: %v", err) + } + key1, _ := imp.ReadEmvalBytes(h1) + imp.emval.DecRef(h1) + t.Logf("exported key: %d bytes", len(key1)) + + // loadKey from exported bytes + skPtr2, err := imp.CallStatic("SecureKey", "loadKey", imp.WriteEmvalBytes(key1)) + if err != nil { + t.Fatalf("loadKey failed: %v", err) + } + + // Verify functional equivalence: both keys produce same HMAC + cvHash := sha256.Sum256([]byte("testCV")) + atHash := sha256.Sum256([]byte("testAT")) + data := []byte("/test/path") + + sign := func(sk uint32) string { + dk, err := imp.CallMethod("SecureKey", "deriveKey", sk, imp.WriteEmvalBytes(cvHash[:]), imp.WriteEmvalBytes(atHash[:])) + if err != nil { + t.Fatalf("deriveKey failed: %v", err) + } + hm, err := imp.Construct("Hmac", dk) + if err != nil { + t.Fatalf("Hmac.new failed: %v", err) + } + rh, err := imp.CallMethod("Hmac", "digest", hm, imp.WriteEmvalBytes(data)) + if err != nil { + t.Fatalf("digest failed: %v", err) + } + sig, _ := imp.ReadEmvalBytes(rh) + imp.emval.DecRef(rh) + return base64.StdEncoding.EncodeToString(sig) + } + + sig1 := sign(skPtr) + sig2 := sign(skPtr2) + + if sig1 != sig2 { + t.Errorf("signatures differ: original=%s, reimported=%s", sig1, sig2) + } else { + t.Logf("SecureKey roundtrip OK (functionally equivalent): sig=%s", sig1) + } +} + +func TestAesKeyEncryptDecrypt(t *testing.T) { + _, imp := initModule(t) + + // Generate an AesKey + genHandle := imp.WriteEmvalBytes([]byte{}) // empty seed for generate + aesPtr, err := imp.CallStatic("AesKey", "generate", 32, genHandle) + if err != nil { + t.Fatalf("AesKey.generate failed: %v", err) + } + if aesPtr == 0 { + t.Fatal("AesKey.generate returned null") + } + + // Encrypt + plaintext := []byte("Hello, Letter Sealing!") + ptHandle := imp.WriteEmvalBytes(plaintext) + ctHandle, err := imp.CallMethod("AesKey", "encrypt", aesPtr, ptHandle) + if err != nil { + t.Fatalf("AesKey.encrypt failed: %v", err) + } + ciphertext, err := imp.ReadEmvalBytes(ctHandle) + if err != nil { + t.Fatalf("read encrypt result failed: %v", err) + } + imp.emval.DecRef(ctHandle) + t.Logf("encrypted: %d bytes → %d bytes", len(plaintext), len(ciphertext)) + + if len(ciphertext) <= len(plaintext) { + t.Error("ciphertext should be longer than plaintext (includes IV/tag)") + } + + // Decrypt + ctHandle2 := imp.WriteEmvalBytes(ciphertext) + ptHandle2, err := imp.CallMethod("AesKey", "decrypt", aesPtr, ctHandle2) + if err != nil { + t.Fatalf("AesKey.decrypt failed: %v", err) + } + decrypted, err := imp.ReadEmvalBytes(ptHandle2) + if err != nil { + t.Fatalf("read decrypt result failed: %v", err) + } + imp.emval.DecRef(ptHandle2) + + if string(decrypted) != string(plaintext) { + t.Errorf("decrypted mismatch: got %q, want %q", decrypted, plaintext) + } + t.Logf("AesKey encrypt/decrypt roundtrip OK") +} + +func TestCurve25519KeyGenerate(t *testing.T) { + _, imp := initModule(t) + + // Load a SecureKey first + token := "wODdrvWqmdP4Zliay-iF3cz3KZcK0ekrial868apg06TXeCo7A1hIQO0ESElHg6D" + strPtr := imp.writeStdString(token) + skPtr, err := imp.CallStatic("SecureKey", "loadToken", strPtr) + if err != nil { + t.Fatalf("loadToken failed: %v", err) + } + + // Generate Curve25519 key + c25Ptr, err := imp.CallStatic("Curve25519Key", "generate", skPtr) + if err != nil { + t.Fatalf("Curve25519Key.generate failed: %v", err) + } + if c25Ptr == 0 { + t.Fatal("Curve25519Key.generate returned null") + } + + // Get public key + pubHandle, err := imp.CallMethod("Curve25519Key", "getPublicKey", c25Ptr) + if err != nil { + t.Fatalf("getPublicKey failed: %v", err) + } + pubKey, err := imp.ReadEmvalBytes(pubHandle) + if err != nil { + t.Fatalf("read public key failed: %v", err) + } + imp.emval.DecRef(pubHandle) + + t.Logf("Curve25519 public key: %d bytes", len(pubKey)) + if len(pubKey) != 32 { + t.Errorf("expected 32-byte public key, got %d bytes", len(pubKey)) + } +} diff --git a/pkg/ltsm/mix_test.go b/pkg/ltsm/mix_test.go new file mode 100644 index 0000000..5b8f245 --- /dev/null +++ b/pkg/ltsm/mix_test.go @@ -0,0 +1,20 @@ +package ltsm + +import "testing" + +func TestMix66OutputInRange(t *testing.T) { + // Input digits must be in 0..7 (base-8 digits). + var x, y [66]byte + for i := 0; i < 66; i++ { + x[i] = byte((i * 3) % 8) + y[i] = byte((i * 5) % 8) + } + + var out [66]byte + Mix66(out[:], x[:], y[:], 0) + for i, b := range out { + if b > 7 { + t.Fatalf("out[%d]=%d out of range", i, b) + } + } +} diff --git a/pkg/ltsm/vector_test.go b/pkg/ltsm/vector_test.go new file mode 100644 index 0000000..bbd8a0d --- /dev/null +++ b/pkg/ltsm/vector_test.go @@ -0,0 +1,378 @@ +package ltsm + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "testing" + + "golang.org/x/crypto/curve25519" +) + +// TestGenerateV2TestVector generates a complete V2 encryption test vector +// with all intermediate values exposed for cross-validation. +func TestGenerateV2TestVector(t *testing.T) { + // Use deterministic keys so the test vector is reproducible + senderPriv, _ := hex.DecodeString("a546e36bf0527c9d3b16154b82465edd62144c0ac1fc5a18506a2244ba449ac4") + receiverPriv, _ := hex.DecodeString("4b66e9d4d1b4673c5ad22691957d6af5c11b6421e0ea01d42ca4169e7918ba0d") + + senderPub, _ := curve25519.X25519(senderPriv, curve25519.Basepoint) + receiverPub, _ := curve25519.X25519(receiverPriv, curve25519.Basepoint) + + t.Logf("Sender private key: %s", hex.EncodeToString(senderPriv)) + t.Logf("Sender public key: %s", hex.EncodeToString(senderPub)) + t.Logf("Receiver private key: %s", hex.EncodeToString(receiverPriv)) + t.Logf("Receiver public key: %s", hex.EncodeToString(receiverPub)) + + // Create channels + senderChan, err := NewChannel(senderPriv, receiverPub) + if err != nil { + t.Fatalf("NewChannel sender->receiver: %v", err) + } + receiverChan, err := NewChannel(receiverPriv, senderPub) + if err != nil { + t.Fatalf("NewChannel receiver->sender: %v", err) + } + + t.Logf("Shared secret: %s", hex.EncodeToString(senderChan.SharedSecret[:])) + + if senderChan.SharedSecret != receiverChan.SharedSecret { + t.Fatal("shared secrets don't match") + } + + to := "u8ae764e8e69e6bd4ecdd9b6ea0c40fce" + from := "uf1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6" + senderKeyID := 42 + receiverKeyID := 99 + contentType := 0 + seq := int64(1) + plaintext := []byte(`{"text":"hello from pure Go V2 test vector"}`) + + // Encrypt V2 + ciphertext, err := senderChan.EncryptV2(to, from, senderKeyID, receiverKeyID, contentType, seq, plaintext) + if err != nil { + t.Fatalf("EncryptV2: %v", err) + } + + // Decrypt with receiver to verify + decrypted, err := receiverChan.DecryptV2(to, from, senderKeyID, receiverKeyID, contentType, ciphertext) + if err != nil { + t.Fatalf("DecryptV2: %v", err) + } + if string(decrypted) != string(plaintext) { + t.Fatalf("roundtrip mismatch: got %q, want %q", string(decrypted), string(plaintext)) + } + t.Logf("V2 roundtrip OK: %s", string(decrypted)) + + // Parse ciphertext components + salt := ciphertext[:16] + nonce := ciphertext[16:28] + sealed := ciphertext[28:] + encBody := sealed[:len(sealed)-16] + gcmTag := sealed[len(sealed)-16:] + + // Derive key for verification + kh := sha256.New() + kh.Write(senderChan.SharedSecret[:]) + kh.Write(salt) + kh.Write([]byte("Key")) + derivedKey := kh.Sum(nil) + + // Build AAD for verification + aad := buildAADv2(to, from, senderKeyID, receiverKeyID, contentType) + + t.Logf("\n--- V2 Test Vector ---") + t.Logf("Sender private key: %s", hex.EncodeToString(senderPriv)) + t.Logf("Sender public key: %s", hex.EncodeToString(senderPub)) + t.Logf("Receiver private key: %s", hex.EncodeToString(receiverPriv)) + t.Logf("Receiver public key: %s", hex.EncodeToString(receiverPub)) + t.Logf("Shared secret: %s", hex.EncodeToString(senderChan.SharedSecret[:])) + t.Logf("To MID: %s", to) + t.Logf("From MID: %s", from) + t.Logf("Sender Key ID: %d", senderKeyID) + t.Logf("Receiver Key ID: %d", receiverKeyID) + t.Logf("Content Type: %d", contentType) + t.Logf("Sequence: %d", seq) + t.Logf("Plaintext: %s", string(plaintext)) + t.Logf("Plaintext hex: %s", hex.EncodeToString(plaintext)) + t.Logf("Ciphertext hex: %s", hex.EncodeToString(ciphertext)) + t.Logf(" Salt (16): %s", hex.EncodeToString(salt)) + t.Logf(" Nonce (12): %s", hex.EncodeToString(nonce)) + t.Logf(" Encrypted body: %s", hex.EncodeToString(encBody)) + t.Logf(" GCM Tag (16): %s", hex.EncodeToString(gcmTag)) + t.Logf("Derived AES key: %s", hex.EncodeToString(derivedKey)) + t.Logf("AAD (%d bytes): %s", len(aad), hex.EncodeToString(aad)) + + // Also do a V1 test vector for cross-validation + v1ct, err := senderChan.EncryptV1(plaintext) + if err != nil { + t.Fatalf("EncryptV1: %v", err) + } + v1dec, err := receiverChan.DecryptV1(v1ct) + if err != nil { + t.Fatalf("DecryptV1: %v", err) + } + if string(v1dec) != string(plaintext) { + t.Fatalf("V1 roundtrip mismatch") + } + + v1salt := v1ct[:8] + v1body := v1ct[8 : len(v1ct)-16] + v1mac := v1ct[len(v1ct)-16:] + + t.Logf("\n--- V1 Test Vector ---") + t.Logf("V1 ciphertext hex: %s", hex.EncodeToString(v1ct)) + t.Logf(" Salt (8): %s", hex.EncodeToString(v1salt)) + t.Logf(" Encrypted body: %s", hex.EncodeToString(v1body)) + t.Logf(" MAC (16): %s", hex.EncodeToString(v1mac)) + + // Output JSON test vector + type TestVectorJSON struct { + SenderPrivateKey string `json:"senderPrivateKey"` + SenderPublicKey string `json:"senderPublicKey"` + ReceiverPrivateKey string `json:"receiverPrivateKey"` + ReceiverPublicKey string `json:"receiverPublicKey"` + SharedSecret string `json:"sharedSecret"` + ToMID string `json:"toMID"` + FromMID string `json:"fromMID"` + SenderKeyID int `json:"senderKeyID"` + ReceiverKeyID int `json:"receiverKeyID"` + ContentType int `json:"contentType"` + Sequence int64 `json:"sequence"` + Plaintext string `json:"plaintext"` + PlaintextHex string `json:"plaintextHex"` + V2 struct { + Ciphertext string `json:"ciphertext"` + Salt string `json:"salt"` + Nonce string `json:"nonce"` + EncryptedBody string `json:"encryptedBody"` + GCMTag string `json:"gcmTag"` + DerivedAESKey string `json:"derivedAESKey"` + AAD string `json:"aad"` + AADLength int `json:"aadLength"` + } `json:"v2"` + V1 struct { + Ciphertext string `json:"ciphertext"` + Salt string `json:"salt"` + EncryptedBody string `json:"encryptedBody"` + MAC string `json:"mac"` + } `json:"v1"` + } + + vec := TestVectorJSON{ + SenderPrivateKey: hex.EncodeToString(senderPriv), + SenderPublicKey: hex.EncodeToString(senderPub), + ReceiverPrivateKey: hex.EncodeToString(receiverPriv), + ReceiverPublicKey: hex.EncodeToString(receiverPub), + SharedSecret: hex.EncodeToString(senderChan.SharedSecret[:]), + ToMID: to, + FromMID: from, + SenderKeyID: senderKeyID, + ReceiverKeyID: receiverKeyID, + ContentType: contentType, + Sequence: seq, + Plaintext: string(plaintext), + PlaintextHex: hex.EncodeToString(plaintext), + } + vec.V2.Ciphertext = hex.EncodeToString(ciphertext) + vec.V2.Salt = hex.EncodeToString(salt) + vec.V2.Nonce = hex.EncodeToString(nonce) + vec.V2.EncryptedBody = hex.EncodeToString(encBody) + vec.V2.GCMTag = hex.EncodeToString(gcmTag) + vec.V2.DerivedAESKey = hex.EncodeToString(derivedKey) + vec.V2.AAD = hex.EncodeToString(aad) + vec.V2.AADLength = len(aad) + + vec.V1.Ciphertext = hex.EncodeToString(v1ct) + vec.V1.Salt = hex.EncodeToString(v1salt) + vec.V1.EncryptedBody = hex.EncodeToString(v1body) + vec.V1.MAC = hex.EncodeToString(v1mac) + + jsonBytes, _ := json.MarshalIndent(vec, "", " ") + t.Logf("\n--- JSON Test Vector ---\n%s", string(jsonBytes)) + + // Write to file + if err := os.WriteFile("/tmp/ltsm_v2_test_vector.json", jsonBytes, 0644); err != nil { + t.Logf("Warning: could not write test vector file: %v", err) + } else { + t.Logf("Test vector written to /tmp/ltsm_v2_test_vector.json") + } +} + +// TestGenerateV2DeterministicVector generates a V2 vector with a deterministic +// salt and nonce (by encrypting manually) for truly reproducible test vectors. +func TestGenerateV2DeterministicVector(t *testing.T) { + // Deterministic keys + senderPriv, _ := hex.DecodeString("a546e36bf0527c9d3b16154b82465edd62144c0ac1fc5a18506a2244ba449ac4") + receiverPriv, _ := hex.DecodeString("4b66e9d4d1b4673c5ad22691957d6af5c11b6421e0ea01d42ca4169e7918ba0d") + + senderPub, _ := curve25519.X25519(senderPriv, curve25519.Basepoint) + receiverPub, _ := curve25519.X25519(receiverPriv, curve25519.Basepoint) + + senderChan, err := NewChannel(senderPriv, receiverPub) + if err != nil { + t.Fatalf("NewChannel: %v", err) + } + receiverChan, err := NewChannel(receiverPriv, senderPub) + if err != nil { + t.Fatalf("NewChannel: %v", err) + } + + if senderChan.SharedSecret != receiverChan.SharedSecret { + t.Fatal("shared secrets mismatch") + } + + to := "u8ae764e8e69e6bd4ecdd9b6ea0c40fce" + from := "uf1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6" + senderKeyID := 42 + receiverKeyID := 99 + contentType := 0 + seq := int64(1) + plaintext := []byte(`{"text":"hello from pure Go V2 test vector"}`) + + // Deterministic salt and nonce + salt, _ := hex.DecodeString("0102030405060708090a0b0c0d0e0f10") + nonce := make([]byte, 12) + binary.BigEndian.PutUint64(nonce[:8], uint64(seq)) + // nonce[8:12] = deterministic random part + nonce[8] = 0xaa + nonce[9] = 0xbb + nonce[10] = 0xcc + nonce[11] = 0xdd + + // Derive key + kh := sha256.New() + kh.Write(senderChan.SharedSecret[:]) + kh.Write(salt) + kh.Write([]byte("Key")) + aesKey := kh.Sum(nil) + + // Build AAD + aad := buildAADv2(to, from, senderKeyID, receiverKeyID, contentType) + + // AES-256-GCM encrypt + block, err := aes.NewCipher(aesKey) + if err != nil { + t.Fatalf("aes.NewCipher: %v", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + t.Fatalf("cipher.NewGCM: %v", err) + } + sealed := gcm.Seal(nil, nonce, plaintext, aad) + + // Assemble ciphertext: salt(16) || nonce(12) || sealed + ciphertext := make([]byte, 0, 16+12+len(sealed)) + ciphertext = append(ciphertext, salt...) + ciphertext = append(ciphertext, nonce...) + ciphertext = append(ciphertext, sealed...) + + encBody := sealed[:len(sealed)-16] + gcmTag := sealed[len(sealed)-16:] + + // Verify decrypt + decrypted, err := receiverChan.DecryptV2(to, from, senderKeyID, receiverKeyID, contentType, ciphertext) + if err != nil { + t.Fatalf("DecryptV2 failed: %v", err) + } + if string(decrypted) != string(plaintext) { + t.Fatalf("roundtrip mismatch: got %q, want %q", string(decrypted), string(plaintext)) + } + + t.Logf("V2 Deterministic Test Vector") + t.Logf("============================") + t.Logf("Sender private key: %s", hex.EncodeToString(senderPriv)) + t.Logf("Sender public key: %s", hex.EncodeToString(senderPub)) + t.Logf("Receiver private key: %s", hex.EncodeToString(receiverPriv)) + t.Logf("Receiver public key: %s", hex.EncodeToString(receiverPub)) + t.Logf("Shared secret: %s", hex.EncodeToString(senderChan.SharedSecret[:])) + t.Logf("To MID: %s", to) + t.Logf("From MID: %s", from) + t.Logf("Sender Key ID: %d", senderKeyID) + t.Logf("Receiver Key ID: %d", receiverKeyID) + t.Logf("Content Type: %d", contentType) + t.Logf("Sequence: %d", seq) + t.Logf("Plaintext: %s", string(plaintext)) + t.Logf("Plaintext hex: %s", hex.EncodeToString(plaintext)) + t.Logf("") + t.Logf("Ciphertext hex: %s", hex.EncodeToString(ciphertext)) + t.Logf(" Salt (16): %s", hex.EncodeToString(salt)) + t.Logf(" Nonce (12): %s", hex.EncodeToString(nonce)) + t.Logf(" Encrypted body: %s", hex.EncodeToString(encBody)) + t.Logf(" GCM Tag (16): %s", hex.EncodeToString(gcmTag)) + t.Logf("Derived AES key: %s", hex.EncodeToString(aesKey)) + t.Logf("AAD (%d bytes): %s", len(aad), hex.EncodeToString(aad)) + t.Logf("AAD breakdown:") + t.Logf(" processed_to (33): %s", hex.EncodeToString(aad[:33])) + t.Logf(" processed_from (33): %s", hex.EncodeToString(aad[33:66])) + t.Logf(" senderKeyId (4): %s", hex.EncodeToString(aad[66:70])) + t.Logf(" receiverKeyId (4): %s", hex.EncodeToString(aad[70:74])) + t.Logf(" version (4): %s", hex.EncodeToString(aad[74:78])) + t.Logf(" contentType (4): %s", hex.EncodeToString(aad[78:82])) + + // Output JSON + type DeterministicVector struct { + SenderPrivateKey string `json:"senderPrivateKey"` + SenderPublicKey string `json:"senderPublicKey"` + ReceiverPrivateKey string `json:"receiverPrivateKey"` + ReceiverPublicKey string `json:"receiverPublicKey"` + SharedSecret string `json:"sharedSecret"` + ToMID string `json:"toMID"` + FromMID string `json:"fromMID"` + SenderKeyID int `json:"senderKeyID"` + ReceiverKeyID int `json:"receiverKeyID"` + ContentType int `json:"contentType"` + Sequence int64 `json:"sequence"` + Plaintext string `json:"plaintext"` + PlaintextHex string `json:"plaintextHex"` + Salt string `json:"salt"` + Nonce string `json:"nonce"` + DerivedAESKey string `json:"derivedAESKey"` + AAD string `json:"aad"` + AADLength int `json:"aadLength"` + EncryptedBody string `json:"encryptedBody"` + GCMTag string `json:"gcmTag"` + Ciphertext string `json:"ciphertext"` + CiphertextLength int `json:"ciphertextLength"` + } + + vec := DeterministicVector{ + SenderPrivateKey: hex.EncodeToString(senderPriv), + SenderPublicKey: hex.EncodeToString(senderPub), + ReceiverPrivateKey: hex.EncodeToString(receiverPriv), + ReceiverPublicKey: hex.EncodeToString(receiverPub), + SharedSecret: hex.EncodeToString(senderChan.SharedSecret[:]), + ToMID: to, + FromMID: from, + SenderKeyID: senderKeyID, + ReceiverKeyID: receiverKeyID, + ContentType: contentType, + Sequence: seq, + Plaintext: string(plaintext), + PlaintextHex: hex.EncodeToString(plaintext), + Salt: hex.EncodeToString(salt), + Nonce: hex.EncodeToString(nonce), + DerivedAESKey: hex.EncodeToString(aesKey), + AAD: hex.EncodeToString(aad), + AADLength: len(aad), + EncryptedBody: hex.EncodeToString(encBody), + GCMTag: hex.EncodeToString(gcmTag), + Ciphertext: hex.EncodeToString(ciphertext), + CiphertextLength: len(ciphertext), + } + + jsonBytes, _ := json.MarshalIndent(vec, "", " ") + fmt.Println(string(jsonBytes)) + + // Write to file + if err := os.WriteFile("/tmp/ltsm_v2_deterministic_vector.json", jsonBytes, 0644); err != nil { + t.Logf("Warning: could not write test vector file: %v", err) + } else { + t.Logf("Test vector written to /tmp/ltsm_v2_deterministic_vector.json") + } +} diff --git a/pkg/ltsm/wbc_test.go b/pkg/ltsm/wbc_test.go new file mode 100644 index 0000000..9c87fea --- /dev/null +++ b/pkg/ltsm/wbc_test.go @@ -0,0 +1,90 @@ +package ltsm + +import ( + "testing" +) + +// stubImports is a minimal ModuleImports implementation for testing. +type stubImports struct{} + +func (s *stubImports) Import_a(p0 uint32) {} +func (s *stubImports) Import_b(p0, p1, p2, p3, p4, p5, p6, p7 uint32) {} +func (s *stubImports) Import_c(p0, p1 uint32, p2 float64) {} +func (s *stubImports) Import_d(p0, p1, p2 uint32) {} +func (s *stubImports) Import_e(p0, p1, p2, p3 uint32) uint32 { return 0 } +func (s *stubImports) Import_f(p0 uint32) uint32 { return 0 } +func (s *stubImports) Import_g(p0, p1, p2, p3, p4, p5, p6 uint32) {} +func (s *stubImports) Import_h(p0, p1, p2, p3, p4 uint32) {} +func (s *stubImports) Import_i(p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12 uint32) {} +func (s *stubImports) Import_j(p0 uint32) {} +func (s *stubImports) Import_k(p0, p1, p2, p3, p4, p5 uint32) {} +func (s *stubImports) Import_l(p0, p1 uint32) uint32 { return 0 } +func (s *stubImports) Import_m() {} +func (s *stubImports) Import_n(p0, p1, p2 uint32) {} +func (s *stubImports) Import_o(p0, p1, p2 uint32) uint32 { return 0 } +func (s *stubImports) Import_p(p0 uint32) uint32 { return 0 } +func (s *stubImports) Import_q(p0 uint32) uint32 { return 0 } +func (s *stubImports) Import_r(p0, p1, p2, p3 uint32) uint32 { return 0 } +func (s *stubImports) Import_s(p0, p1, p2 uint32) uint32 { return 0 } +func (s *stubImports) Import_t(p0, p1 uint32) {} +func (s *stubImports) Import_u(p0, p1, p2 uint32) {} +func (s *stubImports) Import_v(p0, p1, p2 uint32, p3, p4 uint64) {} +func (s *stubImports) Import_w() uint32 { return 0 } +func (s *stubImports) Import_x() uint32 { return 0 } +func (s *stubImports) Import_y(p0 uint32) {} +func (s *stubImports) Import_z(p0, p1, p2 uint32) float64 { return 0 } +func (s *stubImports) Import_A(p0, p1 uint32) uint32 { return 0 } +func (s *stubImports) Import_B(p0, p1, p2 uint32) {} +func (s *stubImports) Import_C(p0 uint32) uint32 { return 0 } +func (s *stubImports) Import_D(p0 uint32, p1 uint64, p2, p3 uint32) uint32 { return 0 } +func (s *stubImports) Import_E(p0, p1, p2, p3 uint32) uint32 { return 0 } +func (s *stubImports) Import_F(p0, p1, p2 uint32) uint32 { return 0 } +func (s *stubImports) Import_G(p0, p1, p2, p3 uint32) uint32 { return 0 } +func (s *stubImports) Import_H(p0, p1, p2 uint32) {} +func (s *stubImports) Import_I(p0, p1 uint32) {} +func (s *stubImports) Import_J(p0, p1, p2, p3, p4 uint32) {} +func (s *stubImports) Import_K(p0, p1 uint32) {} +func (s *stubImports) Import_L(p0, p1, p2 uint32) {} +func (s *stubImports) Import_M(p0 uint32) uint32 { return 0 } +func (s *stubImports) Import_N(p0, p1, p2, p3, p4, p5 uint32) {} + +// TestTranspiledMix66 validates the transpiled mix function matches the hand-written Mix66. +func TestTranspiledMix66(t *testing.T) { + mod := NewModule(&stubImports{}) + + // Set up test data in module memory + xAddr := uint32(8000000) + yAddr := uint32(8001000) + dstAddr := uint32(8002000) + + // Create test inputs (base-8 digits, 0..7) + var x, y [66]byte + for i := 0; i < 66; i++ { + x[i] = byte((i * 3) % 8) + y[i] = byte((i * 5) % 8) + } + + // Copy inputs to module memory + copy(mod.mem[xAddr:], x[:]) + copy(mod.mem[yAddr:], y[:]) + + // Run the hand-written Mix66 for reference output + var refOut [66]byte + Mix66(refOut[:], x[:], y[:], 0) + + // Run the transpiled mix function + mod.f156(0, xAddr, yAddr, dstAddr) + + // Read transpiled output from module memory + var transOut [66]byte + copy(transOut[:], mod.mem[dstAddr:dstAddr+66]) + + // Compare + for i := 0; i < 66; i++ { + if transOut[i] != refOut[i] { + t.Errorf("mismatch at index %d: transpiled=%d, reference=%d", i, transOut[i], refOut[i]) + } + } + + t.Logf("transpiled mix output matches hand-written Mix66: %v", transOut[:10]) +} diff --git a/pkg/runner_secret_test.go b/pkg/runner_secret_test.go new file mode 100644 index 0000000..4634f78 --- /dev/null +++ b/pkg/runner_secret_test.go @@ -0,0 +1,171 @@ +package line + +import ( + "encoding/base64" + "os" + "regexp" + "strings" + "testing" +) + +func TestBuildLoginSecretMatchesNodeAlgorithm(t *testing.T) { + publicKeyB64 := "kOf6ZAf5lQcROq7mwBwIj/NfncQ/d8LDuk6DwpiVQX4=" + publicKey, err := base64.StdEncoding.DecodeString(publicKeyB64) + if err != nil { + t.Fatalf("failed to decode public key: %v", err) + } + + secret, err := buildLoginSecret("123456", publicKey) + if err != nil { + t.Fatalf("buildLoginSecret failed: %v", err) + } + + const want = "tC9UuIuLVhZvv+tKCVg5II41yBYhtOjmhrta1uTecds=" + if secret != want { + t.Fatalf("unexpected secret: got %q want %q", secret, want) + } +} + +func TestGenerateLoginPINFormat(t *testing.T) { + pin, err := generateLoginPIN() + if err != nil { + t.Fatalf("generateLoginPIN failed: %v", err) + } + + re := regexp.MustCompile(`^\d{6}$`) + if !re.MatchString(pin) { + t.Fatalf("invalid pin format: %q", pin) + } +} + +func TestNormalizeServerPublicKeyB64(t *testing.T) { + already32 := make([]byte, 32) + for i := range already32 { + already32[i] = byte(i) + } + already32B64 := base64.StdEncoding.EncodeToString(already32) + normalized, err := normalizeServerPublicKeyB64(already32B64) + if err != nil { + t.Fatalf("normalizeServerPublicKeyB64 failed: %v", err) + } + if normalized != already32B64 { + t.Fatalf("unexpected normalized key for 32-byte input: got %q want %q", normalized, already32B64) + } + + longer := make([]byte, 40) + for i := range longer { + longer[i] = byte(i) + } + longerB64 := base64.StdEncoding.EncodeToString(longer) + normalized, err = normalizeServerPublicKeyB64(longerB64) + if err != nil { + t.Fatalf("normalizeServerPublicKeyB64 failed: %v", err) + } + wantTrimmed := base64.StdEncoding.EncodeToString(longer[8:]) + if normalized != wantTrimmed { + t.Fatalf("unexpected normalized key for long input: got %q want %q", normalized, wantTrimmed) + } +} + +func TestNormalizeServerPublicKeyB64RejectsInvalidInput(t *testing.T) { + if _, err := normalizeServerPublicKeyB64("not-base64"); err == nil { + t.Fatalf("expected base64 decoding error") + } + + tooShort := base64.StdEncoding.EncodeToString([]byte{1, 2, 3}) + if _, err := normalizeServerPublicKeyB64(tooShort); err == nil { + t.Fatalf("expected short-key error") + } +} + +func TestGenerateE2EESecretFromRunner(t *testing.T) { + if testing.Short() { + t.Skip("skipping runner integration test in short mode") + } + + r, err := GetRunner() + if err != nil { + t.Fatalf("GetRunner failed: %v", err) + } + secretRes, err := r.GenerateE2EESecret() + if err != nil { + t.Fatalf("GenerateE2EESecret failed: %v", err) + } + + if secretRes.Secret == "" { + t.Fatalf("secret should not be empty") + } + if ok := regexp.MustCompile(`^\d{6}$`).MatchString(secretRes.Pin); !ok { + t.Fatalf("invalid pin format: %q", secretRes.Pin) + } + if ok := regexp.MustCompile(`^[a-f0-9]{64}$`).MatchString(secretRes.PublicKeyHex); !ok { + t.Fatalf("invalid public key hex: %q", secretRes.PublicKeyHex) + } +} + +func TestGetSignatureFromRunner(t *testing.T) { + if testing.Short() { + t.Skip("skipping runner integration test in short mode") + } + + r, err := GetRunner() + if err != nil { + t.Fatalf("GetRunner failed: %v", err) + } + signature, err := r.GetSignature("/api/test", "{}", "token") + if err != nil { + t.Fatalf("GetSignature failed: %v", err) + } + + const want = "HivdBfCU/jXr3qqNDr68RScS2jp4FR2SYGZcL9wbw0k=" + if signature != want { + t.Fatalf("unexpected signature: got %q want %q", signature, want) + } +} + +func TestDebugExportDerivedSigningKeyDisabledByDefault(t *testing.T) { + if testing.Short() { + t.Skip("skipping runner integration test in short mode") + } + if os.Getenv("LTSM_ENABLE_DEBUG_EXPORT") == "1" { + t.Skip("debug export is explicitly enabled in environment") + } + + r, err := GetRunner() + if err != nil { + t.Fatalf("GetRunner failed: %v", err) + } + _, err = r.DebugExportDerivedSigningKey("token") + if err == nil { + t.Fatalf("expected debug_sign_key_export to be disabled") + } + if !strings.Contains(err.Error(), "debug export disabled") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestDebugExportDerivedSigningKeyEnabled(t *testing.T) { + if testing.Short() { + t.Skip("skipping runner integration test in short mode") + } + + t.Setenv("LTSM_ENABLE_DEBUG_EXPORT", "1") + + r, err := GetRunner() + if err != nil { + t.Fatalf("GetRunner failed: %v", err) + } + + exported, err := r.DebugExportDerivedSigningKey("token") + if err != nil { + t.Fatalf("DebugExportDerivedSigningKey failed: %v", err) + } + + raw, err := base64.StdEncoding.DecodeString(exported) + if err != nil { + t.Fatalf("invalid base64 returned by debug export: %v", err) + } + if len(raw) == 0 { + t.Fatalf("expected non-empty exported signing key") + } +} diff --git a/pkg/runner_vectors_replay_test.go b/pkg/runner_vectors_replay_test.go new file mode 100644 index 0000000..06ae32a --- /dev/null +++ b/pkg/runner_vectors_replay_test.go @@ -0,0 +1,14 @@ +package line + +import ( + "testing" +) + +// TestReplayLTSMVectors previously replayed captured JSON IPC vectors through +// the Node.js runner's call() method. Since the runner now uses wazero directly +// (no JSON IPC), this test needs a rewrite to use the typed Runner methods. +// The WASM-level correctness is covered by pkg/wasm/ltsm_test.go (sign vectors, +// AES roundtrip, Curve25519 roundtrip). +func TestReplayLTSMVectors(t *testing.T) { + t.Skip("vector replay test needs rewrite for wazero-based runner (no JSON IPC)") +} From 8718a01f8ee645c4c3527b4ff8d040459fcf42f1 Mon Sep 17 00:00:00 2001 From: highesttt Date: Tue, 30 Jun 2026 10:34:23 -0500 Subject: [PATCH 2/2] fix: prevent silent relogin after forced logout --- pkg/connector/auth_recovery_test.go | 16 ++++++++++ pkg/connector/client.go | 27 +++++++++++++++-- pkg/connector/connector.go | 42 +++++++++++++++----------- pkg/connector/forced_logout_test.go | 6 ++-- pkg/connector/handle_message.go | 1 + pkg/connector/handlers/handler.go | 7 ++++- pkg/connector/handlers/handler_test.go | 36 ++++++++++++++++++++++ pkg/connector/sync.go | 3 ++ 8 files changed, 113 insertions(+), 25 deletions(-) create mode 100644 pkg/connector/handlers/handler_test.go diff --git a/pkg/connector/auth_recovery_test.go b/pkg/connector/auth_recovery_test.go index 80f8751..f72c59d 100644 --- a/pkg/connector/auth_recovery_test.go +++ b/pkg/connector/auth_recovery_test.go @@ -229,6 +229,22 @@ func TestRunTokenRecoverySkipsRecentRecovery(t *testing.T) { } } +func TestRunTokenRecoveryRejectsInvalidatedSessionBeforeRecentRecovery(t *testing.T) { + lc := &LineClient{recoverTime: time.Now(), sessionInvalidated: true} + var calls int + + err := lc.runTokenRecovery(context.Background(), func(context.Context) error { + calls++ + return nil + }) + if !errors.Is(err, errLineSessionInvalidated) { + t.Fatalf("err = %v, want errLineSessionInvalidated", err) + } + if calls != 0 { + t.Fatalf("recovery calls = %d, want 0", calls) + } +} + func TestRunTokenRecoverySerializesConcurrentRecovery(t *testing.T) { var lc LineClient var calls int32 diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 16c8abc..4ee7293 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -231,6 +231,7 @@ func (lc *LineClient) refreshAndSave(ctx context.Context) error { meta := lc.UserLogin.Metadata.(*UserLoginMetadata) meta.AccessToken = accessToken meta.RefreshToken = refreshToken + meta.SessionInvalidated = false err = lc.UserLogin.Save(ctx) if err != nil { lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Failed to save refreshed tokens to DB") @@ -263,10 +264,10 @@ func (lc *LineClient) shouldAttemptTokenRecovery(ctx context.Context, err error) return lc.isRefreshRequired(err) || line.IsUnauthorizedStatus(err) } -func (lc *LineClient) markLoggedOutByOtherClient(_ context.Context, err error) { - lc.invalidateAccessToken() - line.InvalidateOBSTokenCache() +func (lc *LineClient) markLoggedOutByOtherClient(ctx context.Context, err error) { if lc.UserLogin == nil { + lc.invalidateAccessToken() + line.InvalidateOBSTokenCache() return } if lc.UserLogin.Client != nil && lc.UserLogin.Client != lc { @@ -275,6 +276,9 @@ func (lc *LineClient) markLoggedOutByOtherClient(_ context.Context, err error) { } return } + lc.invalidateAccessToken() + line.InvalidateOBSTokenCache() + lc.saveSessionInvalidated(ctx) if lc.UserLogin.Bridge != nil { lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("LINE session invalidated by another client; marking login bad credentials") } @@ -286,6 +290,18 @@ func (lc *LineClient) markLoggedOutByOtherClient(_ context.Context, err error) { }) } +func (lc *LineClient) saveSessionInvalidated(ctx context.Context) { + meta, ok := lc.UserLogin.Metadata.(*UserLoginMetadata) + if !ok { + return + } + meta.AccessToken = "" + meta.SessionInvalidated = true + if err := lc.UserLogin.Save(ctx); err != nil && lc.UserLogin.Bridge != nil { + lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Failed to save LINE session invalidation") + } +} + // recoverToken attempts to restore a valid session by refreshing, then re-logging in. // Returns nil on success. On failure the caller should send StateBadCredentials. func (lc *LineClient) recoverToken(ctx context.Context) error { @@ -303,6 +319,10 @@ func (lc *LineClient) runTokenRecovery(ctx context.Context, recover func(context lc.recoverMu.Lock() defer lc.recoverMu.Unlock() + if lc.isSessionInvalidated() { + return errLineSessionInvalidated + } + if !lc.recoverTime.IsZero() && time.Since(lc.recoverTime) < recentTokenRecoveryWindow { return nil } @@ -521,6 +541,7 @@ func (lc *LineClient) tryLogin(ctx context.Context) error { if meta, ok := lc.UserLogin.Metadata.(*UserLoginMetadata); ok { meta.AccessToken = accessToken meta.RefreshToken = refreshToken + meta.SessionInvalidated = false if res.Certificate != "" { meta.Certificate = res.Certificate } diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index ea9b28e..fffb4f6 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -104,28 +104,34 @@ func (lc *LineConnector) GetDBMetaTypes() database.MetaTypes { } type UserLoginMetadata struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token,omitempty"` - Email string `json:"email,omitempty"` - Password string `json:"password,omitempty"` - Certificate string `json:"certificate,omitempty"` - Mid string `json:"mid,omitempty"` - EncryptedKeyChain string `json:"encrypted_key_chain,omitempty"` - E2EEPublicKey string `json:"e2ee_public_key,omitempty"` - E2EEVersion string `json:"e2ee_version,omitempty"` - E2EEKeyID string `json:"e2ee_key_id,omitempty"` - ExportedKeyMap map[string]string `json:"exported_key_map,omitempty"` - BlockedMIDs []string `json:"blocked_mids,omitempty"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token,omitempty"` + SessionInvalidated bool `json:"session_invalidated,omitempty"` + Email string `json:"email,omitempty"` + Password string `json:"password,omitempty"` + Certificate string `json:"certificate,omitempty"` + Mid string `json:"mid,omitempty"` + EncryptedKeyChain string `json:"encrypted_key_chain,omitempty"` + E2EEPublicKey string `json:"e2ee_public_key,omitempty"` + E2EEVersion string `json:"e2ee_version,omitempty"` + E2EEKeyID string `json:"e2ee_key_id,omitempty"` + ExportedKeyMap map[string]string `json:"exported_key_map,omitempty"` + BlockedMIDs []string `json:"blocked_mids,omitempty"` } func (lc *LineConnector) LoadUserLogin(ctx context.Context, login *bridgev2.UserLogin) error { meta := login.Metadata.(*UserLoginMetadata) + accessToken := meta.AccessToken + if meta.SessionInvalidated { + accessToken = "" + } login.Client = &LineClient{ - UserLogin: login, - AccessToken: meta.AccessToken, - RefreshToken: meta.RefreshToken, - Mid: meta.Mid, - HTTPClient: &http.Client{Timeout: 10 * time.Second}, + UserLogin: login, + AccessToken: accessToken, + RefreshToken: meta.RefreshToken, + Mid: meta.Mid, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + sessionInvalidated: meta.SessionInvalidated, } return nil } @@ -408,7 +414,7 @@ func (ll *LineEmailLogin) finishLogin(ctx context.Context, res *line.LoginResult } client := newLineAPIClient(token) - profile, err := getProfileWithToken(token) + profile, err := client.GetProfile() if err != nil { return nil, fmt.Errorf("failed to verify token: %w", err) } diff --git a/pkg/connector/forced_logout_test.go b/pkg/connector/forced_logout_test.go index c4720be..d4eec88 100644 --- a/pkg/connector/forced_logout_test.go +++ b/pkg/connector/forced_logout_test.go @@ -56,7 +56,7 @@ func TestStartWithOverrideUsesStoredCredentials(t *testing.T) { gotEmail = email gotPassword = password gotCertificate = certificate - return &line.LoginResult{Type: 3, Verifier: "verifier", Pin: "123456"}, nil + return &line.LoginResult{Certificate: "123456"}, nil } override := &bridgev2.UserLogin{ @@ -79,7 +79,7 @@ func TestStartWithOverrideUsesStoredCredentials(t *testing.T) { if step == nil || step.Type != bridgev2.LoginStepTypeDisplayAndWait { t.Fatalf("step = %#v, want display-and-wait verification step", step) } - if step.StepID != "dev.highest.matrix.line.wait_verification" { - t.Fatalf("step ID = %q, want wait verification", step.StepID) + if step.StepID != "dev.highest.matrix.line.enter_pin" { + t.Fatalf("step ID = %q, want enter PIN", step.StepID) } } diff --git a/pkg/connector/handle_message.go b/pkg/connector/handle_message.go index eefcb85..902acd0 100644 --- a/pkg/connector/handle_message.go +++ b/pkg/connector/handle_message.go @@ -28,6 +28,7 @@ func (lc *LineClient) newMessageHandler() *handlers.Handler { Log: lc.UserLogin.Bridge.Log, HTTPClient: lc.HTTPClient, RecoverToken: lc.recoverToken, + ShouldRecover: lc.shouldAttemptTokenRecovery, IsRefreshRequired: lc.isRefreshRequired, IsLoggedOut: lc.isLoggedOut, HandleLoggedOut: lc.markLoggedOutByOtherClient, diff --git a/pkg/connector/handlers/handler.go b/pkg/connector/handlers/handler.go index 79255e7..c5e3c55 100644 --- a/pkg/connector/handlers/handler.go +++ b/pkg/connector/handlers/handler.go @@ -16,6 +16,7 @@ type Handler struct { // RecoverToken attempts to restore a valid session by refreshing or re-logging in. RecoverToken func(ctx context.Context) error + ShouldRecover func(ctx context.Context, err error) bool IsRefreshRequired func(err error) bool IsLoggedOut func(err error) bool HandleLoggedOut func(ctx context.Context, err error) @@ -39,7 +40,11 @@ func (h *Handler) tryRecoverClient(ctx context.Context, err error) (*line.Client } return nil, false } - if !line.IsUnauthorizedStatus(err) && !h.IsRefreshRequired(err) { + if h.ShouldRecover != nil { + if !h.ShouldRecover(ctx, err) { + return nil, false + } + } else if !line.IsUnauthorizedStatus(err) && !h.IsRefreshRequired(err) { return nil, false } if errRecover := h.RecoverToken(ctx); errRecover != nil { diff --git a/pkg/connector/handlers/handler_test.go b/pkg/connector/handlers/handler_test.go new file mode 100644 index 0000000..9bcface --- /dev/null +++ b/pkg/connector/handlers/handler_test.go @@ -0,0 +1,36 @@ +package handlers + +import ( + "context" + "errors" + "testing" +) + +func TestTryRecoverClientUsesShouldRecover(t *testing.T) { + errAuth := errors.New("SSE error: 401") + var recoverCalled bool + + h := &Handler{ + ShouldRecover: func(context.Context, error) bool { + return false + }, + IsLoggedOut: func(error) bool { + return false + }, + IsRefreshRequired: func(error) bool { + return true + }, + RecoverToken: func(context.Context) error { + recoverCalled = true + return nil + }, + } + + client, ok := h.tryRecoverClient(context.Background(), errAuth) + if ok || client != nil { + t.Fatalf("tryRecoverClient returned client=%v ok=%v, want no recovery", client, ok) + } + if recoverCalled { + t.Fatal("RecoverToken was called despite ShouldRecover returning false") + } +} diff --git a/pkg/connector/sync.go b/pkg/connector/sync.go index da65514..34bca86 100644 --- a/pkg/connector/sync.go +++ b/pkg/connector/sync.go @@ -1238,6 +1238,9 @@ func (lc *LineClient) pollLoop(ctx context.Context) { if strings.Contains(err.Error(), "SSE error: 401") || strings.Contains(err.Error(), "SSE error: 403") { + if !lc.shouldAttemptTokenRecovery(ctx, err) { + return + } if errRecover := lc.recoverToken(ctx); errRecover != nil { lc.UserLogin.Bridge.Log.Error().Err(errRecover).Msg("Failed to recover session, stopping poll loop") lc.UserLogin.BridgeState.Send(status.BridgeState{